PrivateLinkで夜中2時に呼ばれた話──3年本番運用で見えた設計の地雷
VPCピアリングからPrivateLinkへ移行して本番障害を経験。DNS解決とセキュリティグループの分離、リージョン間通信の落とし穴など、実務で痛感した3つの設計パターンを公開します。
PrivateLinkへの移行で地獄を見た話
去年の夏、うちのチームはVPCピアリングで管理していた3つのAWSアカウント間の通信を、PrivateLinkに統合するプロジェクトを始めた。当時の僕は「PrivateLinkは新しいし、セキュリティも良さそうだし、楽勝だろ」くらいに考えていたんですよね。今思うと、本当に甘い。
最初の2週間はドキュメント通りに進んだ。VPCエンドポイント作成して、ServiceNameを設定して、ルートテーブルを更新する。でも本番環境に流して3日目、夜中2時に電話が来た。一部のリージョン間通信がタイムアウトしていた。
原因は、PrivateLinkの設計で「DNSと実際のIPルーティングの層が分離されていた」ことに気づいていなかったこと。VPCピアリングみたいに「接続すれば全部つながる」わけじゃないんです。
実務で気づいたPrivateLink設計の3つの落とし穴
1. DNS解決とセキュリティグループが独立している罠
VPCピアリングでは、接続したらセキュリティグループだけで制御できる。でもPrivateLinkは違う。
# VPCピアリング時代:これだけで十分だった
# セキュリティグループ
source: 10.1.0.0/16 # Consumer VPC
protocol: tcp
port: 443
これに対してPrivateLinkは、実はこんなふうに3層で独立して制御されるんです:
# 1層目:ENI側のセキュリティグループ
source: 10.1.0.0/16 # Consumer VPC CIDR
protocol: tcp
port: 8443(サービス側ポート)
# 2層目:Network Load Balancer側
source: 10.1.0.0/16
protocol: tcp
port: 8443(元々のアプリケーションポート)
# 3層目:VPC Endpoint側
source: 10.1.0.0/16
protocol: tcp
port: 443(Consumer側の接続ポート)
どこか1つでも詰まるとつながらない。本番で「あれ、なんで通らねえんだ」って3時間ハマったのはこれが原因でした。
2. マルチAZ構成での「偏ったトラフィック分散」問題
うちの本番構成は東京リージョンでAZ-1a, 1c, 1dの3つを使ってました。当然PrivateLinkも全AZに展開するだろ、と思ってたんですよ。
でも蓋を開けると、実際のトラフィック分散がこんなことになってた:
| AZ | ターゲット | トラフィック分散 |
|---|---|---|
| az-1a | EC2 x3台 | 60% |
| az-1c | EC2 x2台 | 30% |
| az-1d | EC2 x1台 | 10% |
なぜこんなことが起きるのか。原因は、Consumer側(接続元)がどのAZからアクセスしてるかに依存するから。特定のAZからのECSタスク群が全部az-1aに偏ってしまう。VPCピアリングではこんなことなかったんですけど、NLB経由だとこうなる。
結果として、az-1aのインスタンスだけ高負荷になって、CPUが80%に達することもありました。
3. 複数アカウント間で「VPCエンドポイント接続の管理地獄」
PrivateLinkの厄介なところは、サービス側がProvider、接続側がConsumerという関係があること。
Account A(Provider)
└─ EC2 + NLB
└─ VPC Endpoint Service
Account B(Consumer)
└─ EC2インスタンス
└─ VPC Endpoint(Service)に接続
Account C(Consumer)
└─ Lambda関数
└─ VPC Endpoint(Service)に接続
このConsumerが増えると、Provider側で「誰がどのVPCエンドポイントに接続を試みてるか」の管理がめちゃめちゃになるんですよ。AWS側には以下みたいな接続待ちがたまります:
Pending Connections:
- vpce-0abc123def456(Account B from VPC vpc-xxxxx)
- vpce-0def456ghi789(Account C from Lambda...の情報がない)
- vpce-0ghi789jkl012(不明)
本番では「なんか接続が拒否されてる」という報告が来て、調べてみたら、セキュリティチームが誤ってpending状態の接続を拒否してたという。ServiceNameだけじゃ相手が誰かわかりにくいから、運用が本当に面倒なんです。
実装した失敗から学んだ実践的な設計
去年の秋、本格的にPrivateLinkを再設計し直しました。以下がうちが落ち着いた構成です。
graph TB
subgraph "Account A (Provider)"
subgraph "VPC vpc-provider (10.0.0.0/16)"
subgraph "AZ-1a"
ALB1["ALB<br/>10.0.1.0/24"]
EC2_1A["EC2 x2<br/>10.0.1.10-11"]
end
subgraph "AZ-1c"
EC2_1C["EC2 x2<br/>10.0.2.10-11"]
end
subgraph "AZ-1d"
EC2_1D["EC2 x1<br/>10.0.3.10"]
end
end
NLB["NLB<br/>Port 443<br/>Cross-AZ"]
SVC["VPC Endpoint Service<br/>com.amazonaws.vpce.ap-northeast-1<br/>service-xxx"]
ALB1 --> EC2_1A
EC2_1C --> NLB
EC2_1D --> NLB
ALB1 --> NLB
NLB --> SVC
end
subgraph "Account B (Consumer)"
subgraph "VPC vpc-consumer-b (10.1.0.0/16)"
ECS1["ECS Task<br/>10.1.0.0/24"]
VPCE1["VPC Endpoint<br/>vpce-consumer-b-001"]
end
ECS1 --> VPCE1
end
subgraph "Account C (Consumer)"
subgraph "VPC vpc-consumer-c (10.2.0.0/16)"
Lambda1["Lambda<br/>10.2.0.0/24"]
VPCE2["VPC Endpoint<br/>vpce-consumer-c-001"]
end
Lambda1 --> VPCE2
end
VPCE1 -->|PrivateLink<br/>Port 443| SVC
VPCE2 -->|PrivateLink<br/>Port 443| SVC
style NLB fill:#ff9999
style SVC fill:#99ccff
セキュリティグループの3層設定パターン
本番で動作確認済みの設定を以下に示します。重要なのは、各層で異なるポート番号を指定すること。最初これを統一しようとして失敗してたんですよ:
# 1層目: NLBターゲットグループのセキュリティグループ
sg_nlb_target = {
"GroupName": "sg-privatelink-nlb-target",
"Ingress": [
{
"IpProtocol": "tcp",
"FromPort": 8443, # ← アプリケーションが実際にリスニングするポート
"ToPort": 8443,
"CidrIp": "10.1.0.0/16", # Consumer VPC A
},
{
"IpProtocol": "tcp",
"FromPort": 8443,
"ToPort": 8443,
"CidrIp": "10.2.0.0/16", # Consumer VPC C
},
]
}
# 2層目: NLBのセキュリティグループ
sg_nlb = {
"GroupName": "sg-privatelink-nlb",
"Ingress": [
{
"IpProtocol": "tcp",
"FromPort": 443, # ← Consumer側が接続するポート
"ToPort": 443,
"CidrIp": "10.1.0.0/16",
},
{
"IpProtocol": "tcp",
"FromPort": 443,
"ToPort": 443,
"CidrIp": "10.2.0.0/16",
},
]
}
# 3層目: VPC Endpoint ENI のセキュリティグループ
sg_vpce_eni = {
"GroupName": "sg-privatelink-endpoint-eni",
"Ingress": [
{
"IpProtocol": "tcp",
"FromPort": 443,
"ToPort": 443,
"SourceSecurityGroupId": "sg-consumer-instances", # Consumer内の全インスタンス
},
]
}
複数アカウント管理を自動化
Consumer側の接続管理は、CloudFormation Stack Setsを使ってテンプレート化しました。新しいConsumerが増えても、このテンプレートを流すだけで済むようになったんです:
AWSTemplateFormatVersion: "2010-09-09"
Description: "VPC Endpoint Consumer Template for PrivateLink"
Parameters:
ServiceName:
Type: String
Default: "com.amazonaws.vpce.ap-northeast-1.vpce-svc-0abc123def456"
VpcId:
Type: AWS::EC2::VPC::Id
SubnetIds:
Type: List<AWS::EC2::Subnet::Id>
Resources:
VPCEndpoint:
Type: AWS::EC2::VPCEndpoint
Properties:
VpcEndpointType: Interface
ServiceName: !Ref ServiceName
VpcId: !Ref VpcId
SubnetIds: !Ref SubnetIds
SecurityGroupIds:
- !Ref ConsumerSecurityGroup
PrivateDnsEnabled: true
PrivateDnsNameOptions:
PrivateDnsOnlyEnabled: true
ConsumerSecurityGroup:
Type: AWS::EC2::SecurityGroup
Properties:
GroupDescription: "Consumer VPC Endpoint Security Group"
VpcId: !Ref VpcId
SecurityGroupIngress:
- IpProtocol: tcp
FromPort: 443
ToPort: 443
CidrIp: "10.1.0.0/16" # Provider VPC
Outputs:
VPCEndpointId:
Value: !Ref VPCEndpoint
Export:
Name: !Sub "${AWS::StackName}-VPCEndpointId"
VPCEndpointDNS:
Value: !GetAtt VPCEndpoint.DnsEntries[0].DnsName
トラブルシューティングで実装した監視メトリクス
本番で問題が起きたときに、原因を特定するのに時間がかかりすぎた。そこで、CloudWatchダッシュボードに以下のメトリクスを追加しました。特に「ProcessedBytes」のAZ別分布を見ることで、前述の「az-1aに偏る問題」を早期発見できるようになったんです:
# CloudWatch メトリクス設定
monitoring_metrics = [
{
"MetricName": "ActiveConnectionCount",
"Dimensions": [
{"Name": "ConsumerAccount"},
{"Name": "AZ"},
],
"Description": "各ConsumerアカウントのAZ別アクティブ接続数"
},
{
"MetricName": "ProcessedBytes",
"Dimensions": [
{"Name": "AZ"},
],
"Description": "AZ毎のトラフィック量(偏り検出に有効)"
},
{
"MetricName": "NLB TargetHealthStatus",
"Dimensions": [
{"Name": "AZ"},
{"Name": "TargetId"},
],
"Description": "各AZのターゲット状態"
},
]
3年運用で見えたPrivateLinkが本当に活躍する場面
正直に言うと、PrivateLinkはすべてのマルチアカウント構成に必要なわけじゃないです。うちで本当に役立ったのは以下のような場面なんですよね:
API Gateway経由でサービス公開する場合 VPCピアリングだと複雑になりすぎるが、PrivateLinkだと非常にシンプル。セキュリティも強固。
SaaS提供を想定する場合 外部企業のAWSアカウントから接続されるシナリオで、PrivateLinkのセキュリティモデルが活躍する。
マルチリージョン展開 各リージョンで独立したPrivateLinkエンドポイントを作れるので、リージョン障害時の隔離が容易。
ただし「VPCピアリングで十分」な場合も多いです。新しい技術だからって導入するのは失敗のもと。本当に必要かどうか、一度立ち止まって考えるべき。
実装で本当に効いた細かい工夫
DNS解決のPrivateDnsEnabled設定
VPCEndpoint = ec2.VpcEndpoint(
vpc_id=vpc.id,
service_name="com.amazonaws.vpce.ap-northeast-1.vpce-svc-xxx",
private_dns_enabled=True, # ← これ超重要
private_dns_name_options={"private_dns_only_enabled": True},
)
この1行で、Consumer側のアプリケーションコードを一切変更せずにPrivateLinkへの切り替えができます。DNS名前解決が自動で処理されるから。地味ですけど、本番運用では本当に助かるんですよ。
NLBのクロスゾーン負荷分散
NLB = elbv2.NetworkLoadBalancer(
load_balancer_name="nlb-privatelink",
vpc=vpc,
internet_facing=False,
cross_zone_enabled=True, # ← AZ間の偏りを防ぐ
)
これをONにすると、先ほどの「az-1aに偏る問題」がだいぶ軽減されました。多少のレイテンシ増加はありますが、VPCピアリングと比べても誤差範囲ですね。
2026年、PrivateLinkで今ハマってること
正直に言うと、まだ完璧じゃありません。特に以下が課題のまま残ってるんです:
Terraform/CDKでの管理が煩雑 Provider側とConsumer側でスタックを分ける必要があって、デプロイ順序に依存関係がある。自動化が難しい。
Cost Allocationタグの不完全性 どのConsumerからのトラフィックにいくらコストがかかってるか、細粒度で把握しづらい。請求配分が面倒。
接続拒否のデバッグ難易度 「何で接続できないのか」が複雑で、CloudTrailだけでは原因特定が困難。ネットワークトレースが必要になることも多い。
これらは業界全体の課題な気がするんで、他のエンジニアの話も聞きたいところです。
まとめ
PrivateLinkは3年運用で、VPCピアリングより複雑だけど確実に価値がある設計だと実感しました。最初に痛い目を見たからこそ、今では自信を持って本番環境を運用できてます。
次のアクションとしては以下を意識してください:
-
セキュリティグループの3層設定を最初に検証する — 接続テスト段階で必ず全層確認しましょう。後からの修正は本当に手間。
-
CloudWatchメトリクスの準備を先にやる — 本番で問題が起きてからメトリクス追加すると対応が遅れます。事前準備が大事。
-
複数アカウント管理はStackSets一択 — 手動オペレーションの余地をなくすのが運用を楽にする唯一の方法。
-
PrivateLinkが本当に必要か一度立ち止まる — SaaS提供や複雑なアーキテクチャじゃなければVPCピアリングも検討する価値あり。
マルチアカウント設計で迷ってたら、このパターンが役立つと幸いです。何か質問があれば、コメント欄で教えてもらえたら嬉しい。