PrivateLink3年運用で学んだマルチアカウント設計の落とし穴
VPCピアリングからPrivateLink移行時にハマった話。10アカウント・5リージョン運用の失敗事例と、実際に効いた対策をまとめました。
PrivateLinkへの移行で気づいたこと
うちのチームが本格的にPrivateLinkに移行したのは3年前です。当時、VPCピアリングで10個のアカウント・5リージョンを繋いでて、正直な話「繋ぎ方が複雑になってきた」という課題を抱えてました。特に新しいアカウントが増えるたびに、全アカウントのセキュリティグループとルートテーブルを更新して回る運用が苦しくなってました。
VPCピアリングとPrivateLinkって、表面的には「ネットワーク接続」で同じカテゴリに見えるんですが、実装してみると設計思想が根本的に違うんですよね。個人的には、PrivateLinkは「サービス指向」、VPCピアリングは「ネットワーク指向」だと思ってます。最初の1年間はこの違いを過小評価してて、実装で何度か失敗しました。
基本構成の違い——VPCピアリング vs PrivateLink
まず、図で基本構造を示します。
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で自動化してます。登録・削除の流れはこんな感じ:
- EC2インスタンス起動 → ASG通知 → Lambda が検知
- Lambda が ECS/EC2 の IP を取得
- NLBのターゲットグループに自動登録
- ヘルスチェック通過まで待機
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を導入する人は、ぜひこの辺りの落とし穴を避けて、スムーズに進められたらいいですね。