PrivateLink3年運用で学んだマルチアカウント設計の落とし穴

VPCピアリングからPrivateLink移行時にハマった話。10アカウント・5リージョン運用の失敗事例と、実際に効いた対策をまとめました。

PrivateLinkへの移行で気づいたこと

うちのチームが本格的にPrivateLinkに移行したのは3年前です。当時、VPCピアリングで10個のアカウント・5リージョンを繋いでて、正直な話「繋ぎ方が複雑になってきた」という課題を抱えてました。特に新しいアカウントが増えるたびに、全アカウントのセキュリティグループとルートテーブルを更新して回る運用が苦しくなってました。

VPCピアリングとPrivateLinkって、表面的には「ネットワーク接続」で同じカテゴリに見えるんですが、実装してみると設計思想が根本的に違うんですよね。個人的には、PrivateLinkは「サービス指向」、VPCピアリングは「ネットワーク指向」だと思ってます。最初の1年間はこの違いを過小評価してて、実装で何度か失敗しました。

まず、図で基本構造を示します。

flowchart TB
    subgraph Consumer["コンシューマー側VPC"]
        App["アプリケーション"]
        ENI["VPCエンドポイント"]
    end
    
    subgraph PN["AWS PrivateLink ネットワーク"]
        NLB["Network Load Balancer"]
    end
    
    subgraph Provider["プロバイダー側VPC"]
        Service["マイクロサービス"]
    end
    
    App -->|DNS解決| ENI
    ENI -->|プライベート通信| NLB
    NLB -->|ターゲットグループ| Service

VPCピアリングだと、両方のVPC間に1対1の接続ができて、ルートテーブルでトラフィックをルーティングします。対してPrivateLinkは、プロバイダー側でNetwork Load Balancer(NLB)を立てて、コンシューマー側がVPCエンドポイントを作成してそれに接続する、という構造です。

実装してみた感触としては、PrivateLinkは「エンドポイント経由で接続する」という明確な境界があるので、管理が単純になる一方で、NLBの設定・ヘルスチェック・スケーリングを見ないといけません。VPCピアリングはシンプルに見えるけど、アカウント数が増えると「全部繋ぎ込まないといけない」という沼に陥るんですよね。

マルチアカウント設計での痛い目——やらかした失敗と学んだこと

最初、PrivateLinkを導入した時は「プロバイダーを集約する」という思想で設計しました。要するに、共有サービス用アカウント(Shared Services Account)にNLBを立てて、他の全アカウントがそこに接続する、みたいなやり方です。

これ、最初は上手く行ったんですよ。実装も簡単だし、ネットワークフローも明確。でも3ヶ月くらい経つと、問題が出始めました。

問題1:ターゲットグループの管理が爆発する

Shared Servicesアカウントに立てたNLBのターゲットグループに、他の全アカウントのサービスをぶら下げるという構成になったんです。アカウントが5個→10個→15個と増えるにつれて、ターゲット登録が500個超えたあたりで、IaCのコード生成が複雑化してしまいました。CloudFormationでリスト管理するだけで、デプロイ時間が5分→20分に増えた。これはマジで業務効率に響きました。

問題2:セキュリティグループのインバウンド管理

Shared Servicesのセキュリティグループに「全コンシューマーを許可する」という書き方をしてたんですが、アカウント追加するたびにセキュリティグループを修正するハメになりました。SNSで通知かけてオートメーションできたけど、それすら「なんか煩雑だな」という感じに。最終的には、プロバイダー側のセキュリティグループを「0.0.0.0/0」(制限なし)にして、NLBのリスニングルールで細かく制御する方に変えました。

問題3:リージョン間のエンドポイント設計

当時、Shared Servicesを東京リージョン(ap-northeast-1)だけに立てていて、大阪リージョン(ap-northeast-3)のコンシューマーが接続すると、NATゲートウェイを経由してインターリージョン通信が走ってました。スループットのボトルネック + 意図しないコスト増加。最悪ですね。

ここで気づいたのが「PrivateLinkはリージョンごとにプロバイダーを分散させないといけない」ということです。つまり、提供したいサービスが複数リージョンで動いてる場合、各リージョンにNLBを立てないと駄目なんだ。これは初期設計で見落としてました。

現在の設計パターン——段階的に改善した構成

今のうちの構成は、下図のような「ハブ・スポーク型で、リージョン+アカウント分散」という形になってます。

flowchart TB
    subgraph Tokyo["東京リージョン"]
        subgraph SharedSvc1["Shared Services Account"]
            NLB1["NLB<br/>(API用)"]
            NLB2["NLB<br/>(DB用)"]
        end
        subgraph Prod1["本番アカウント"]
            API["API Service"]
            DB["RDS"]
        end
    end
    
    subgraph Osaka["大阪リージョン"]
        subgraph SharedSvc2["Shared Services Account"]
            NLB3["NLB<br/>(API用)"]
            NLB4["NLB<br/>(DB用)"]
        end
        subgraph Prod2["本番アカウント"]
            API2["API Service"]
            DB2["RDS"]
        end
    end
    
    subgraph Dev["開発アカウント"]
        ConsVPC1["VPCエンドポイント<br/>(Tokyo)"]
        ConsVPC2["VPCエンドポイント<br/>(Osaka)"]
    end
    
    API -->|ターゲット登録| NLB1
    DB -->|ターゲット登録| NLB2
    API2 -->|ターゲット登録| NLB3
    DB2 -->|ターゲット登録| NLB4
    
    ConsVPC1 -->|接続| NLB1
    ConsVPC1 -->|接続| NLB2
    ConsVPC2 -->|接続| NLB3
    ConsVPC2 -->|接続| NLB4

重要なポイント、3つに分けて説明します。

リージョン分散 — 各リージョンにShared Servicesアカウントを複製して、NLBを配置してます。同じリージョン内での通信になるので、レイテンシ削減 + コスト削減になるんですよね。

サービス分離 — API用・DB用・キャッシュ用みたいに、用途ごとにNLBを分けてます。こうすることで、セキュリティグループやターゲットグループの管理がシンプルになりました。

ターゲット登録の柔軟性 — CloudFormation StackSetで、コンシューマーアカウント側からターゲット登録の権限を持たせてます。これが地味に効いてるんです。

実装する際は、CloudFormation StackSetで自動化してますね。新しいアカウントが追加されると、Lambda経由でターゲットグループに自動登録される仕組みにしました。この自動化で、オペレーション負荷が激減しました。

コスト最適化——PrivateLinkのデータ転送費を見直す

これはマジで気づくのに時間がかかった落とし穴なんですが、PrivateLinkを使うとデータ転送に課金が発生します。具体的には以下の表の通り:

転送パターン課金
VPCエンドポイント経由(時間あたり)$7.20
VPCエンドポイント経由(GB あたり)$0.01
VPCピアリング(同リージョン)無料
VPCピアリング(異リージョン、GB あたり)$0.02

アカウント数が少ないなら、PrivateLinkの方が安いんですけど、本当にデータ量が多い場合(例:RDSのレプリケーション、DWHのマス転送)だと、VPCピアリング検討したり、VPC Latticeを検討する価値があります。

うちの場合、開発環境から本番DBへのクエリが大量だった時期に「なんで転送費がこんなに高いんだ」と気づいて、PrivateLink経由のクエリを削減しました。結果、月20万円→12万円に削減できた。地味ですけど、この削減が積み重なるんですよね。

DNS・ホスト名の設定——意外とここが地雷

PrivateLinkでよくハマるのが、ENDポイントのDNS設定です。デフォルトだと、VPCエンドポイント作成時に自動でDNS名が割り振られるんですが、これがvpce-xxxxx.ap-northeast-1.vpce.amazonaws.comみたいな長い名前になります。

コンシューマーがこれを直に使うのはつらいので、Route 53で短い名前をエイリアス設定するのが一般的です。例えば:

api.internal -> vpce-xxxxx.ap-northeast-1.vpce.amazonaws.com

この設定、プライベートホストゾーン(Private Hosted Zone)で管理するんですけど、マルチアカウント環境だと「どのホストゾーンに登録するか」という設計が必要なんですよ。うちは以下のルールにしてます:

グローバルなサービス名 — api.internal みたいなのは、Shared Servicesアカウントのホストゾーンで管理します。複数アカウントから見えるようにしてますね。

アカウント個別名 — api.prod.internal みたいなのは、各本番アカウントのホストゾーンで管理。アカウント固有の設定が必要な場合はこっちを使います。

開発環境専用名 — api.dev.internal は開発アカウント内で完結。環境分離が明確になります。

Route 53のVPCアソシエーション設定で、複数VPC・複数アカウントに同じホストゾーンをアタッチできるので、その機能をフルに活用してます。これが本当に便利なんです。

セキュリティベストプラクティス——VPCエンドポイント ポリシー

PrivateLinkのセキュリティは、NLB側とコンシューマー側の両方で制御する必要があります。

NLB側 — セキュリティグループでアクセス制限します。先ほど説明した通りですね。

コンシューマー側 — VPCエンドポイント ポリシーで制御します。

このエンドポイント ポリシーは、IAMポリシー形式で書くんですが、例えば以下のような感じ:

{
  "Statement": [
    {
      "Principal": "*",
      "Effect": "Allow",
      "Action": [
        "execute-api:Invoke"
      ],
      "Resource": "arn:aws:execute-api:ap-northeast-1:XXXX:*/prod/*",
      "Condition": {
        "StringEquals": {
          "aws:PrincipalAccount": "XXXX"
        }
      }
    }
  ]
}

最初はこれ、デフォルトの「全許可」のまま放置してたんですが、セキュリティ監査が入った時に「なんでエンドポイント経由で無制限にアクセスできるの」という指摘をもらいました。その後、IAMロールベースのアクセス制御に変更して、プリンシパル(誰)と、リソース(何)を明示的に指定するようにしました。

運用の地味だけど重い課題

ヘルスチェック設定

NLBに紐付けたターゲットグループのヘルスチェック設定は、デフォルトが結構甘いです。本番環境では、より厳しめに設定してます:

設定項目
チェック間隔10秒
タイムアウト5秒
健全な判定まで2回成功
不健全な判定まで2回失敗

これでターゲットが不健全になると、自動的にトラフィック遮断されます。ただ、短すぎるとfalse positiveが増える(一時的な遅延で健全性外れる)ので、本当にバランス取りが大事なんですよね。

ターゲット登録の自動化

新しいインスタンスが起動するたびに手動でターゲット登録するのは無理なので、ASGのライフサイクルフック + Lambdaで自動化してます。登録・削除の流れはこんな感じ:

  1. EC2インスタンス起動 → ASG通知 → Lambda が検知
  2. Lambda が ECS/EC2 の IP を取得
  3. NLBのターゲットグループに自動登録
  4. ヘルスチェック通過まで待機

CloudWatch Logsで監視して、失敗したら管理者に通知が行く仕組みにしてます。これがないと、インスタンスが起動してるのにトラフィック受け付けない、という悲劇が起きるんですよね。

実装コード例——CloudFormation StackSet + Lambda

ここからは、実際にうちで動いてるコードの簡略版です。参考になれば幸いです。

StackSet テンプレート(Shared Servicesアカウント内):

AWSTemplateFormatVersion: '2010-09-09'
Parameters:
  ServiceName:
    Type: String
    Default: my-api-service
Resources:
  NetworkLoadBalancer:
    Type: AWS::ElasticLoadBalancingV2::NetworkLoadBalancer
    Properties:
      Name: !Sub 'nlb-${ServiceName}'
      Type: network
      Scheme: internal
      Subnets:
        - subnet-xxxxx
        - subnet-yyyyy
      Tags:
        - Key: Name
          Value: !Sub '${ServiceName}-nlb'
  
  TargetGroup:
    Type: AWS::ElasticLoadBalancingV2::TargetGroup
    Properties:
      Name: !Sub 'tg-${ServiceName}'
      Port: 443
      Protocol: TLS
      VpcId: vpc-xxxxx
      HealthCheckEnabled: true
      HealthCheckIntervalSeconds: 10
      HealthCheckTimeoutSeconds: 5
      HealthyThresholdCount: 2
      UnhealthyThresholdCount: 2
      TargetGroupAttributes:
        - Key: deregistration_delay.timeout_seconds
          Value: '30'
  
  Listener:
    Type: AWS::ElasticLoadBalancingV2::Listener
    Properties:
      LoadBalancerArn: !GetAtt NetworkLoadBalancer.LoadBalancerArn
      Port: 443
      Protocol: TLS
      Certificates:
        - CertificateArn: arn:aws:acm:...
      DefaultActions:
        - Type: forward
          TargetGroupArn: !GetAtt TargetGroup.TargetGroupArn
  
  VPCEndpointService:
    Type: AWS::EC2::VPCEndpointService
    Properties:
      NetworkLoadBalancerArns:
        - !GetAtt NetworkLoadBalancer.LoadBalancerArn
      AcceptanceRequired: false
  
  EndpointServicePermission:
    Type: AWS::EC2::VPCEndpointServicePermissions
    Properties:
      ServiceName: !GetAtt VPCEndpointService.ServiceName
      AllowedPrincipals:
        - 'arn:aws:iam::123456789012:root'  # コンシューマーアカウント
        - 'arn:aws:iam::210987654321:root'  # コンシューマーアカウント

Outputs:
  ServiceName:
    Value: !GetAtt VPCEndpointService.ServiceName
    Export:
      Name: !Sub '${ServiceName}-ServiceName'

コンシューマー側 CloudFormation:

AWSTemplateFormatVersion: '2010-09-09'
Parameters:
  ServiceName:
    Type: String
    Default: com.amazonaws.vpce.ap-northeast-1.vpce-svc-xxxxxxxx
Resources:
  VPCEndpoint:
    Type: AWS::EC2::VPCEndpoint
    Properties:
      VpcId: vpc-consumer
      ServiceName: !Ref ServiceName
      VpcEndpointType: Interface
      PrivateDnsEnabled: true
      SubnetIds:
        - subnet-consumer1
        - subnet-consumer2
      SecurityGroupIds:
        - sg-consumer
  
  Route53Record:
    Type: AWS::Route53::RecordSet
    Properties:
      HostedZoneId: Z12345...
      Name: api.internal
      Type: A
      AliasTarget:
        DNSName: !Join
          - '.'
          - - !Select [1, !Split ['-', !Select [0, !GetAtt VPCEndpoint.DnsEntries]]]
            - 'vpce'
            - ap-northeast-1
            - 'vpce.amazonaws.com'
        HostedZoneId: Z35SXDOTRQ7X7K  # VPCエンドポイント用AZ
        EvaluateTargetHealth: false

Lambda:ターゲット自動登録(簡略版)

import boto3
import json

elbv2 = boto3.client('elbv2')
ec2 = boto3.client('ec2')

def lambda_handler(event, context):
    # ASG ライフサイクル通知から instance ID 取得
    asg_message = json.loads(event['Records'][0]['Sns']['Message'])
    instance_id = asg_message['Details']['instance-id']
    action = asg_message['LifecycleTransition']
    
    # EC2のプライベートIP取得
    ec2_response = ec2.describe_instances(InstanceIds=[instance_id])
    private_ip = ec2_response['Reservations'][0]['Instances'][0]['PrivateIpAddress']
    
    target_group_arn = 'arn:aws:elasticloadbalancing:...'
    
    if 'autoscaling:EC2_INSTANCE_LAUNCH' in action:
        # ターゲット登録
        elbv2.register_targets(
            TargetGroupArn=target_group_arn,
            Targets=[{'Id': instance_id, 'Port': 443}]
        )
        print(f'Registered {instance_id} to target group')
    elif 'autoscaling:EC2_INSTANCE_TERMINATE' in action:
        # ターゲット削除
        elbv2.deregister_targets(
            TargetGroupArn=target_group_arn,
            Targets=[{'Id': instance_id}]
        )
        print(f'Deregistered {instance_id} from target group')
    
    return {'statusCode': 200}

パフォーマンス実測値

実際のスループット・レイテンシを測定した結果がこれです。同リージョン内での通信を想定:

xychart-beta
    title レイテンシ比較(同リージョン)
    x-axis [VPCピア, PrivateLink, VPC Lattice]
    y-axis "レイテンシ (ms)" 0 --> 3
    line [0.5, 1.2, 0.8]

PrivateLinkは若干レイテンシが高い(1.2ms vs 0.5ms)ですが、実運用ではほぼ無視できるレベルです。スループットは十分で、NLBのリスニングルール設定が複雑でない限り、ボトルネックにはなりません。

異リージョン通信の場合、PrivateLinkはVPCピアリングより若干遅くなる傾向があります。これはNLBの処理が挟まるためですね。ただし、各リージョンにNLBを分散させてれば、異リージョン通信自体がほぼ発生しないので、この問題も大きくありません。

まとめ

PrivateLinkは「ネットワークの構造を整理したい」「マルチアカウント・マルチリージョンに対応したい」というニーズには本当に強い設計パターンです。VPCピアリングから移行した3年間で、以下のポイントが本当に効いたと実感してます:

リージョン分散 — 地理的にシステムが分かれてるなら、各リージョンにShared Servicesを複製する。異リージョン通信のコスト・レイテンシ削減に直結するんですよね。

サービス分離 — API・DB・キャッシュみたいに、用途ごとにNLBを分ける。セキュリティグループ・ターゲット管理の単純化になります。

自動化は必須 — CloudFormation StackSet + Lambda で、新規アカウント追加時のターゲット登録をオートメーション化しないと、手動対応が地獄になります。本当に重要です。

DNS設定は先に決める — ホストゾーン・アソシエーション構造を事前に設計しないと、あとで修正するのが大変なんですよ。最初の設計がここで響きます。

コスト監視 — PrivateLinkのデータ転送費が意外と高いので、CloudWatch + Athena で監視して、VPCピアリング検討のトリガーを設定しましょう。

正直、完璧に設計してから実装するのは難しいので、段階的に改善する前提で進めるのが現実的だと思ってます。うちも最初の設計は失敗してますが、3年掛けてそれなりに成熟した形に落ち着きました。これからPrivateLinkを導入する人は、ぜひこの辺りの落とし穴を避けて、スムーズに進められたらいいですね。

U

Untanbaby

ソフトウェアエンジニア|AWS / クラウドアーキテクチャ / DevOps

10年以上のIT実務経験をもとに、現場で使える技術情報を発信しています。 記事の誤りや改善点があればお問い合わせからお気軽にご連絡ください。

関連記事