2つのVPC間でAWS PrivateLinkの双方向通信を検証してみた
はじめに
お犬様が好きなクラウド事業本部、あきやまです。
人生生きていると、CIDRが重複している2つのVPCだが、どうしてもそれぞれのVPCに配置しているサービス同士で相互に接続させたいという思いに駆られることはないですか?私はあります。
VPC ピアリングや Transit Gateway は CIDR が重複しているとルーティングが成立せず使えません。そこで AWS PrivateLink を使うわけですが、PrivateLink は Consumer から Provider への「片方向」の接続です。
そうなると「逆方向(B から A)はどうするのか」という疑問が湧いてきます。今回はこの疑問を起点に、重複 CIDR の 2 VPC 間で PrivateLink を 2 本張って双方向通信できるのかを Terraform で検証してみました。
検証の背景と疑問
PrivateLink の基本をおさらいします。
- Provider 側: Network Load Balancer(NLB)の前段に VPC Endpoint Service を作成し、サービスを公開する
- Consumer 側: Interface VPC Endpoint を作成し、Provider のサービスへ接続する
- 接続を「開始」できるのは Consumer → Provider の片方向のみ。応答トラフィックは同一コネクション上で自動的に戻る

つまり 1 本の PrivateLink を「逆流」させることはできません。ではサービス A(VPC-A)とサービス B(VPC-B)が互いに独立して相手を呼び出したい場合はどうするか。答えは PrivateLink を 2 本張るです。各 VPC が Provider と Consumer の両方の役割を兼ねます。
そして今回の本題は「CIDR が重複していても、この双方向通信が成立するのか」という点です。
結論
先に結論です。
| 観点 | 結果 |
|---|---|
| 重複 CIDR の 2 VPC 間で双方向通信できるか | できる(PrivateLink を 2 本張る) |
| ピアリング / TGW で代替できるか | できない(CIDR 重複でルーティング不可) |
| サービス A・B のプライベート IP が完全に同一でも通るか | 通る(今回は両方 10.0.1.10 で検証) |
| Provider のターゲットが見る送信元 IP | NLB のプライベート IP(Consumer の実 IP は届かない) |
ポイントは、重複が両端でマスクされることです。
- Consumer は接続先を「自 VPC 内のローカル IP(Interface Endpoint の ENI)」として見る。実 Provider の IP は見えない
- Provider のターゲットが見る送信元は「NLB のプライベート IP」。Consumer の実 IP は届かない
この 2 段のマスクのおかげで、CIDR が重複していても IP の衝突が起きません。詳しい仕組みは後述の「なぜ重複 CIDR でも通るのか」で解説します。
構成
同一 AWS アカウント内に、CIDR が完全に重複した 2 つの VPC を作成します。

環境
| 項目 | 値 |
|---|---|
| OS | macOS |
| Terraform | v1.15.4 |
| AWS Provider | ~> 5.0 |
| リージョン | ap-northeast-1 |
| AMI | Amazon Linux 2023(最新) |
| インスタンスタイプ | t3.micro |
やってみた
Step 1: 重複 CIDR の 2 VPC とサービス EC2 を作る
2 つの VPC を同じ 10.0.0.0/16、プライベートサブネットも同じ 10.0.1.0/24 で作成します。サービス用 EC2 はどちらも private_ip = "10.0.1.10" を明示的に指定し、わざと同一 IP にします。
VPC ごとの構成は再利用のためモジュール化しました。
module "vpc_a" {
source = "./modules/service-vpc"
name = "a"
vpc_cidr = var.vpc_cidr # 10.0.0.0/16
subnet_cidr = var.subnet_cidr # 10.0.1.0/24
service_ip = var.service_ip # 10.0.1.10
az = local.az
region = var.region
instance_type = var.instance_type
ami_id = data.aws_ssm_parameter.al2023.value
account_id = data.aws_caller_identity.current.account_id
}
module "vpc_b" {
source = "./modules/service-vpc"
name = "b"
vpc_cidr = var.vpc_cidr # 10.0.0.0/16(A と同一)
subnet_cidr = var.subnet_cidr # 10.0.1.0/24(A と同一)
service_ip = var.service_ip # 10.0.1.10(A と同一)
# 以降は vpc_a と同じ
}
EC2 には HTTP サーバを立てますが、閉域なので dnf install ができません(パッケージリポジトリに到達できない)。そこで Amazon Linux 2023 に標準で入っている python3 だけで HTTP サーバを起動します。応答には「どのサービスが・どのホスト名で・どの IP で」答えたかを返すようにして、どちら側が応答したか一目で分かるようにしました。
cat >/opt/server.py <<'PYEOF'
from http.server import BaseHTTPRequestHandler, HTTPServer
import socket
SVC = "${svc_name}" # a または b
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
host = socket.gethostname()
ip = socket.gethostbyname(host)
body = "service=%s hostname=%s ip=%s\n" % (SVC, host, ip)
self.send_response(200)
self.send_header("Content-Type", "text/plain")
self.end_headers()
self.wfile.write(body.encode())
def log_message(self, *args):
pass
HTTPServer(("0.0.0.0", 80), Handler).serve_forever()
PYEOF
Step 2: 各 VPC に NLB と Endpoint Service を作る(Provider 側)
PrivateLink で公開するには NLB が必要です。NLB(internal、TCP:80)を作り、サービス EC2 を instance ターゲットとして登録、その NLB をバックエンドに VPC Endpoint Service を作成します。
resource "aws_lb" "this" {
name = "${local.prefix}-nlb"
internal = true
load_balancer_type = "network"
subnets = [aws_subnet.this.id]
# NLB には SG を付けない(= 全許可)。後述の注意点を参照
}
resource "aws_lb_target_group" "this" {
name = "${local.prefix}-tg"
port = 80
protocol = "TCP"
target_type = "instance"
vpc_id = aws_vpc.this.id
health_check {
protocol = "TCP"
port = "80"
}
}
resource "aws_vpc_endpoint_service" "this" {
acceptance_required = false
network_load_balancer_arns = [aws_lb.this.arn]
allowed_principals = ["arn:aws:iam::${var.account_id}:root"]
}
同一アカウント内なので acceptance_required = false(自動承認)にし、allowed_principals には自アカウントを指定しています。
Step 3: 相手の Endpoint Service へ Interface Endpoint を張る(Consumer 側)
ここが双方向化の肝です。各 VPC に「相手の Endpoint Service」宛の Interface Endpoint を作ります。
- VPC-A 内 → B の Endpoint Service 宛(A→B 用)
- VPC-B 内 → A の Endpoint Service 宛(B→A 用)
Endpoint Service は Interface Endpoint に依存しないため、相互参照しても循環は発生しません。
# A→B: VPC-A 内に「B の Endpoint Service」宛の Interface Endpoint
resource "aws_vpc_endpoint" "a_to_b" {
vpc_id = module.vpc_a.vpc_id
service_name = module.vpc_b.endpoint_service_name
vpc_endpoint_type = "Interface"
subnet_ids = [module.vpc_a.subnet_id]
security_group_ids = [aws_security_group.a_consumer.id]
private_dns_enabled = false # 独自ドメイン検証を避け、自動生成のVPCE DNS名を使う
}
# B→A: VPC-B 内に「A の Endpoint Service」宛の Interface Endpoint
resource "aws_vpc_endpoint" "b_to_a" {
vpc_id = module.vpc_b.vpc_id
service_name = module.vpc_a.endpoint_service_name
vpc_endpoint_type = "Interface"
subnet_ids = [module.vpc_b.subnet_id]
security_group_ids = [aws_security_group.b_consumer.id]
private_dns_enabled = false
}
Step 4: SSM で閉域ログインできるようにする
IGW も NAT もないため、EC2 への接続は SSM Session Manager を使います。閉域で SSM を成立させるには ssm / ssmmessages / ec2messages の Interface Endpoint が必要です。
resource "aws_vpc_endpoint" "ssm" {
for_each = toset(["ssm", "ssmmessages", "ec2messages"])
vpc_id = aws_vpc.this.id
service_name = "com.amazonaws.${var.region}.${each.key}"
vpc_endpoint_type = "Interface"
subnet_ids = [aws_subnet.this.id]
security_group_ids = [aws_security_group.endpoints.id]
private_dns_enabled = true
}
EC2 には AmazonSSMManagedInstanceCore をアタッチしたインスタンスプロファイルを付与しています。
Step 5: デプロイ
terraform init
terraform apply
apply 完了後、接続先の Interface Endpoint DNS 名が output されます。
a_to_b_endpoint_dns = "vpce-0f383cc60123953bd-8xfvdr3j.vpce-svc-0c9c6ab279c9c98bc.ap-northeast-1.vpce.amazonaws.com"
b_to_a_endpoint_dns = "vpce-03728f9fe07a0554e-9lzcsvyl.vpce-svc-04fcbaf27978a7bd1.ap-northeast-1.vpce.amazonaws.com"
Step 6: A→B / B→A の疎通確認
SSM で EC2-A にログインし、B のサービスへ curl します。
aws ssm start-session --target <EC2-A の instance id>
curl http://vpce-0f383cc60123953bd-8xfvdr3j.vpce-svc-0c9c6ab279c9c98bc.ap-northeast-1.vpce.amazonaws.com/
service=b hostname=ip-10-0-1-10.ap-northeast-1.compute.internal ip=10.0.1.10
service=b が返ってきました。A 側(10.0.1.10)から B 側(同じく 10.0.1.10)のサービスへ到達できています。
同様に EC2-B から A のサービスへ curl します。
aws ssm start-session --target <EC2-B の instance id>
curl http://vpce-03728f9fe07a0554e-9lzcsvyl.vpce-svc-04fcbaf27978a7bd1.ap-northeast-1.vpce.amazonaws.com/
service=a hostname=ip-10-0-1-10.ap-northeast-1.compute.internal ip=10.0.1.10
service=a が返ってきました。10.0.1.10 という完全に同一の IP を持つ 2 つのサービスでも、B 側から A 側のサービスへ問題なく到達できています。
Step 7: 受信側で送信元 IP を観測する
「重複がマスクされている」ことを確認するため、受信側 EC2 でパケットをキャプチャします。
sudo tcpdump -ni any port 80
# --- 実際のHTTPリクエスト(curl)---
08:00:58.889558 ens5 In IP 10.0.1.241.xinuexpansion4 > 10.0.1.10.http: Flags [S], ...
08:00:58.890504 ens5 In IP 10.0.1.241.xinuexpansion4 > 10.0.1.10.http: ... length 156: HTTP: GET / HTTP/1.1
08:00:58.892478 ens5 Out IP 10.0.1.10.http > 10.0.1.241.xinuexpansion4: ... length 118: HTTP: HTTP/1.0 200 OK
# --- 10秒間隔でSYN/FINだけの接続はNLBのヘルスチェック ---
08:00:43.622946 ens5 In IP 10.0.1.241.gbjd816 > 10.0.1.10.http: Flags [S], ...
08:00:53.622956 ens5 In IP 10.0.1.241.52721 > 10.0.1.10.http: Flags [S], ...
08:01:03.622931 ens5 In IP 10.0.1.241.27275 > 10.0.1.10.http: Flags [S], ...
注目すべきは、受信側(自身は 10.0.1.10)に届くすべての接続の送信元が 10.0.1.241 になっている点です。これは NLB ノードのプライベート IP(サブネット 10.0.1.0/24 内)で、Consumer 側の実 IP(10.0.1.10)は一切現れていません。
仮に client IP が保持されていたら、送信元 10.0.1.10 → 宛先 10.0.1.10(=自分自身)という状態になり破綻します。NLB の IP にマスクされることで、IP が完全に重複していても衝突せずに通信が成立しているわけです。
逆方向(A→B)でも同じことを確認しました。今度は受信側が service=b(自身は同じく 10.0.1.10)です。
08:11:05.658834 ens5 In IP 10.0.1.182.44825 > 10.0.1.10.http: ... length 156: HTTP: GET / HTTP/1.1
08:11:05.660741 ens5 Out IP 10.0.1.10.http > 10.0.1.182.44825: ... length 118: HTTP: HTTP/1.0 200 OK
送信元が 10.0.1.182 になっています。これは B 側の NLB ノードの IP です。B→A 方向で見えた 10.0.1.241(A 側の NLB)とは別の IP であり、方向ごとに別々の NLB を経由してそれぞれ送信元がマスクされていることが分かります。どちらの方向でも Consumer の実 IP(10.0.1.10)は受信側に現れません。
なぜ重複 CIDR でも通るのか
PrivateLink 経由のトラフィックでは、Provider 側のアプリケーションが見る送信元 IP は NLB ノードのプライベート IP になります。Consumer の実 IP は届きません。AWS の公式ドキュメントにも明記されています。
When service consumers send traffic to a service through an interface endpoint, the source IP addresses provided to the application are the private IP addresses of the Network Load Balancer nodes, and not the IP addresses of the service consumers.
整理すると、IP の重複は次の 2 か所でマスクされます。
- Consumer 側: 接続先は自 VPC 内の Interface Endpoint の ENI(ローカル IP)。実 Provider IP は見えない
- Provider 側: ターゲットが見る送信元は NLB の IP。実 Consumer IP は届かない
そのため、サービス A と B が同じ 10.0.1.10 を持っていても、お互いに「自分自身の IP からパケットが来た」という状況にはならず、衝突しません。
補足: 送信元 IP の見え方は 2 か所で違う
少しややこしいのですが、「NLB に SG を付けた場合」の SG 評価とターゲットが見る IP は異なります。
You can control whether PrivateLink traffic is subject to inbound rules. If you enable inbound rules on PrivateLink traffic, the source of the traffic is the private IP address of the client, not the endpoint interface.
(Update the security groups for your Network Load Balancer より)
- NLB の SG(
enforce_security_group_inbound_rules_on_private_link_trafficが ON のとき): クライアントの実 IP で評価される - ターゲット EC2: NLB の IP を送信元として見る(client IP は PrivateLink 経由では保持されない)
重複 CIDR では NLB SG enforcement を ON にすると送信元マッチが厄介になる(Consumer の実 IP が自 VPC にも存在する IP になる)ため、今回は NLB に SG を付けない構成にしました。
注意点・制約
- PrivateLink は片方向。双方向にしたい場合は方向ごとに 1 本ずつ、計 2 本必要です
- client IP は保持されない。Provider 側で実クライアント IP が必要な場合は Proxy Protocol v2 を使います(ただし重複 CIDR ではその IP 自体が衝突するため扱いに注意)
- NLB の SG は作成時にしか付けられない。後付けできないので、SG を使う場合は作成時に付与します
- AZ マッピング。別アカウント間では AZ 名(例: ap-northeast-1a)が指す物理 AZ が一致しない場合があります。今回は同一アカウントのため問題になりません
- コスト。NLB × 2、Interface Endpoint × 8(サービス 2 + SSM 6)は時間課金です。検証後は
terraform destroyを忘れずに
まとめ
検証用 Terraform 構成として、以下を実現しました(実出力は apply 後に追記します)。
- 重複 CIDR の 2 VPC 間で PrivateLink を 2 本張る双方向構成
- サービスの IP を完全に同一(
10.0.1.10)にしても通る想定 - IGW / NAT なしの閉域構成(SSM も PrivateLink 経由)
「PrivateLink は片方向」という性質と、「重複 CIDR でも IP が両端でマスクされて衝突しない」という仕組みが確認できました!もっと良い構成やハマりどころをご存知の方がいらっしゃいましたら、ぜひ教えてください。




