CIDR が完全に重複する VPC 間で PrivateLink トンネルエンドポイントを試してみた
はじめに
2026年9月18日、AWS PrivateLink にトンネルエンドポイントが追加されました。
先行記事では、トンネルエンドポイントを介して別アカウントの VPC 内にある EC2 と HTTP 通信できることを確認しました。
VPC ピアリングでは、CIDR が重複する VPC 同士を接続できません。Transit Gateway では VPC をアタッチできても、重複する CIDR の宛先を VPC ごとに区別してルーティングできません。
この制約がある構成でも、PrivateLink トンネルエンドポイントを使って VPC 間の通信を実現できるか確認するため、両側に同じ 10.0.0.0/16 の CIDR を持つ VPC を用意し、HTTP 疎通を検証しました。
共通設定 — GENEVE リレー
Resource Gateway、Resource Configuration(CIDR タイプ、10.0.0.0/16)、トンネルエンドポイントの作成手順は先行記事と同じです。本記事は同一アカウント内の 2 VPC 構成のため、RAM 共有は行っていません。
GENEVE リレーは、先行記事のスクリプトを、接続先 VPC の CIDR 全体ではなく接続先ホストの /32 だけを tun0 に向けるよう変更したものです。接続元 VPC も 10.0.0.0/16 のため、先行記事のように /16 を tun0 に向けると接続元 VPC 内の通信が届かなくなります。本記事では DNS 解決は扱わないため、リゾルバー向けのルートも外しています。このリレーをアクセス元 EC2(方式1)または中継用 EC2(方式2、方式3)で実行します。
このスクリプトは検証用です。root で実行し、ホストのルーティングテーブルを書き換えます。また、疎通確認は curl で行います。ICMP はトンネルを通らないため、ping では確認できません。
geneve_nat_relay.py(方式1〜3で共通の GENEVE リレー)
#!/usr/bin/env python3
"""
TUN device + GENEVE relay for AWS PrivateLink Tunnel Endpoint (CIDR type).
Runs on either a source EC2 or an intermediary EC2.
- Creates tun0
- Routes the specific /32 host via tun0
- Reads L3 IP packets from tun0, wraps in GENEVE (VNI=0, proto=0x0800), sends UDP to tunnel EP ENI
- Receives UDP from tunnel EP ENI, strips GENEVE header, writes inner IP to tun0
Usage (run as root):
python3 geneve_nat_relay.py <tunnel_ep_ip> <src_ip> <target_host>
tunnel_ep_ip : Tunnel Endpoint ENI IP (e.g. 10.0.2.174)
src_ip : This EC2's primary private IP (e.g. 10.0.0.7)
target_host : /32 route target (e.g. 10.0.2.37)
Example:
python3 geneve_nat_relay.py 10.0.2.174 10.0.0.7 10.0.2.37
"""
import sys, os, struct, socket, fcntl, select, signal
TUNNEL_PORT = 6081
GENEVE_HDR = struct.pack('!BBH', 0, 0, 0x0800) + b'\x00\x00\x00\x00' # 8 bytes, VNI=0
# Linux TUN/TAP constants
TUNSETIFF = 0x400454ca
IFF_TUN = 0x0001
IFF_NO_PI = 0x1000
def open_tun(name='tun0'):
tun = open('/dev/net/tun', 'r+b', buffering=0)
ifr = struct.pack('16sH', name.encode(), IFF_TUN | IFF_NO_PI)
fcntl.ioctl(tun, TUNSETIFF, ifr)
return tun
def setup_route(tun_name, src_ip, target_host):
os.system(f'ip link set {tun_name} up')
# /32 route only — avoids conflicting with local VPC routing (10.0.0.0/16)
os.system(f'ip route add {target_host}/32 dev {tun_name} src {src_ip}')
print(f'Route added: {target_host}/32 -> {tun_name} (src {src_ip})')
def teardown_route(tun_name, target_host):
os.system(f'ip route del {target_host}/32 dev {tun_name} 2>/dev/null')
os.system(f'ip link set {tun_name} down 2>/dev/null')
print('Routes removed')
def main():
if len(sys.argv) < 4:
print(f'Usage: {sys.argv[0]} <tunnel_ep_ip> <src_ip> <target_host>')
sys.exit(1)
tunnel_ep = sys.argv[1]
src_ip = sys.argv[2]
target_host = sys.argv[3]
tun_name = 'tun0'
print(f'Tunnel EP: {tunnel_ep}:{TUNNEL_PORT}')
print(f'Source IP: {src_ip}')
print(f'Target host: {target_host}/32 via tun0')
tun = open_tun(tun_name)
print(f'Opened {tun_name}')
setup_route(tun_name, src_ip, target_host)
udp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
udp.bind(('0.0.0.0', TUNNEL_PORT))
print(f'Listening on UDP :{TUNNEL_PORT}')
print('Relay running. Ctrl+C to stop.')
def shutdown(sig, frame):
raise KeyboardInterrupt
signal.signal(signal.SIGTERM, shutdown)
try:
while True:
rlist, _, _ = select.select([tun, udp], [], [], 1.0)
for fd in rlist:
if fd is tun:
pkt = tun.read(65536)
if pkt:
udp.sendto(GENEVE_HDR + pkt, (tunnel_ep, TUNNEL_PORT))
elif fd is udp:
data, _ = udp.recvfrom(65536)
if len(data) >= 8:
inner = data[8:] # strip 8-byte GENEVE header
tun.write(inner)
except KeyboardInterrupt:
print('\nStopping relay...')
finally:
teardown_route(tun_name, target_host)
tun.close()
udp.close()
if __name__ == '__main__':
main()
方式1 — アクセス元で GENEVE リレーを直接起動
アクセス元 EC2 自身で GENEVE リレーを動かし、中継用 EC2 を置かない構成です。アクセス元 EC2 と接続先 HTTP サーバーは、どちらも 10.0.2.0/24 に属します。サブネット CIDR まで重複する、最も制約の厳しいケースです。
接続元 VPC (10.0.0.0/16)
アクセス元 EC2 (10.0.2.30, prv-1a)
│
▼
Tunnel Endpoint ENI (10.0.2.174, 1a)
│
▼
接続先 VPC (10.0.0.0/16)
Resource Gateway
│
▼
HTTP サーバー (10.0.2.37, prv-1a)
アクセス元 EC2 で GENEVE リレーを起動します。
sudo python3 geneve_nat_relay.py 10.0.2.174 10.0.2.30 10.0.2.37
EC2 の OS ルートテーブルにある 10.0.0.0/16 のルートはそのままにし、接続先 HTTP サーバーの 10.0.2.37/32 だけを tun0 に向けます。ロンゲストマッチにより /32 のホストルートが /16 より優先されるため、アプリケーションは 10.0.2.37 を指定したまま接続先 VPC 側の HTTP サーバーへ到達できます。一方で、この /32 ルートを持つ EC2 からは、接続元 VPC 内に同じ IP 10.0.2.37 のホストがあっても到達できなくなります。
アクセス元 EC2 から、接続先 VPC の HTTP サーバー(10.0.2.37、ポート 80)へ HTTP リクエストを送信します。
curl -v http://10.0.2.37/
* Established connection to 10.0.2.37 (10.0.2.37 port 80) from 10.0.2.30 port 33330
< HTTP/1.0 200 OK
< Server: SimpleHTTP/0.6 Python/3.9.25
hello from http-server
curl exit status: 0
CIDR が完全に重複する VPC 間でも、HTTP 疎通を確認できました。
方式2 — HAProxy L4 プロキシ(宛先変更あり)
中継用 EC2 上で HAProxy と GENEVE リレーを動かし、アプリケーションは HAProxy に接続する構成です。従来の PrivateLink では接続先側の NLB が接続先リソースへ振り分けます。本方式では、その振り分けを接続元 VPC 内の HAProxy が担い、HAProxy からの通信を同じ EC2 上の GENEVE リレーで PrivateLink トンネルエンドポイントへ送信します。本検証では、方式3 の NAT インスタンスを中継用 EC2 として兼用しました。
接続元 VPC (10.0.0.0/16)
アクセス元 EC2 (10.0.2.30, prv-1a)
│
▼
NAT インスタンス (10.0.0.7, pub-1a)
│
▼
Tunnel Endpoint ENI (10.0.2.174, 1a)
│
▼
接続先 VPC (10.0.0.0/16)
Resource Gateway
│
▼
HTTP サーバー (10.0.2.37, prv-1a)
NAT インスタンスで GENEVE リレーと HAProxy を起動します。アクセス元から HAProxy へ接続できるよう、NAT インスタンスの SG で TCP 8080 を許可します。
sudo python3 geneve_nat_relay.py 10.0.2.174 10.0.0.7 10.0.2.37
/etc/haproxy/haproxy.cfg(中継に関係する部分)
defaults
mode tcp
timeout connect 5s
timeout client 30s
timeout server 30s
frontend proxy_in
bind 0.0.0.0:8080
default_backend http_server
backend http_server
server http_server 10.0.2.37:80 check
アクセス元 EC2 から、NAT インスタンス上で稼働する HAProxy のプライベート IP アドレス(10.0.0.7)と待受ポート(8080)を指定して HTTP リクエストを送信します。
curl -v http://10.0.0.7:8080/
* Established connection to 10.0.0.7 (10.0.0.7 port 8080) from 10.0.2.30 port 38194
< HTTP/1.0 200 OK
< Server: SimpleHTTP/0.6 Python/3.9.25
hello from http-server
curl exit status: 0
HAProxy を経由して、接続先 VPC の HTTP サーバーから 200 OK を取得できました。アクセス元 EC2 には GENEVE リレーは不要です。
今回未検証ですが、Squid などのフォワードプロキシも利用できる可能性はあります。その場合、クライアント側にプロキシ設定が必要です。
方式3 — NAT 透過中継
NAT インスタンスを中継点として、アクセス元サブネットからのパケットをトンネルエンドポイントへ透過転送できるかを検証しました。アクセス元 EC2 には GENEVE リレーやプロキシを導入せず、VPC ルートテーブルで NAT インスタンスへ転送します。
この方式は、アクセス元サブネットと宛先サブネットの CIDR が異なる場合にのみ使えます。本検証では、アクセス元 EC2 を 10.0.3.0/24、接続先 HTTP サーバーを 10.0.2.0/24 のサブネットに配置しました。両サブネットの CIDR が異なるため、アクセス元サブネットのルートテーブルで 10.0.2.0/24 を NAT インスタンスの ENI に向けられます。
一方、アクセス元と宛先が同じサブネット CIDR に見える場合、その通信は VPC ルートテーブルを経由しないため NAT インスタンスへ向けられません。宛先 IP だけを /32 で NAT ENI に向けるルートも、CreateRoute API が拒否します。VPC ルートテーブルで ENI に向けられる宛先は、VPC 内のサブネット CIDR と一致するものに限られます。
接続元 VPC (10.0.0.0/16)
アクセス元 EC2 (10.0.3.243, prv-1c)
│ VPC ルート: 10.0.2.0/24 → NAT ENI
▼
NAT インスタンス (10.0.0.7, pub-1a)
│
▼
Tunnel Endpoint ENI (10.0.2.174, 1a)
│
▼
接続先 VPC (10.0.0.0/16)
Resource Gateway
│
▼
HTTP サーバー (10.0.2.37, prv-1a)
アクセス元サブネットのルートテーブルに、宛先サブネットの CIDR である 10.0.2.0/24 を NAT インスタンスの ENI に向けるルートを設定します。NAT インスタンスでは送信元/送信先チェックを無効化し、IP 転送を有効にします。NAT インスタンスの SG は、送信元を SG ID 参照ではなく CIDR で指定する必要があります。検証では SG ID 参照のままだと転送トラフィックが一致せず、疎通しませんでした。
NAT インスタンスで GENEVE リレーを起動すると、10.0.2.37/32 宛てのパケットを tun0 で受け取り、トンネルエンドポイントへ GENEVE カプセル化して送信します。
sudo sysctl -w net.ipv4.ip_forward=1
sudo python3 geneve_nat_relay.py 10.0.2.174 10.0.0.7 10.0.2.37
アクセス元 EC2(10.0.3.243、prv-1c サブネット)から、接続先 VPC の HTTP サーバーのプライベート IP アドレス(10.0.2.37)と待受ポート(80)を指定して HTTP リクエストを送信します。
# 実行元: 10.0.3.243 (prv-1c)
curl -v http://10.0.2.37/
< HTTP/1.0 200 OK
< Server: SimpleHTTP/0.6 Python/3.9.25
hello from http-server
curl exit status: 0
NAT インスタンスで取得したパケットキャプチャでは、ens5 で受信したカプセル化前の SYN が、GENEVE でカプセル化されてトンネルエンドポイントへ送出され、戻りもデカプセル後に SYN-ACK としてアクセス元へ転送されることを確認しました。
18:46:14.828757 IP 10.0.3.243.52276 > 10.0.2.37.http: Flags [S]
18:46:14.828879 IP 10.0.0.7.geneve > 10.0.2.174.geneve: Geneve, vni 0x0:
IP 10.0.3.243.52276 > 10.0.2.37.http: Flags [S]
18:46:14.831782 IP 10.0.2.174.geneve > 10.0.0.7.geneve: Geneve, vni 0x0:
IP 10.0.2.37.http > 10.0.3.243.52276: Flags [S.]
18:46:14.831880 IP 10.0.2.37.http > 10.0.3.243.52276: Flags [S.]
...(GET / HTTP/1.1 → HTTP/1.0 200 OK 完了)
NAT インスタンスと呼んでいますが、この経路ではアドレス変換(SNAT/DNAT)は行っていません。内側パケットの送信元が 10.0.3.243 のまま GENEVE でカプセル化されています。
以上から、アクセス元 EC2 は接続先を 10.0.2.37 のまま変更せず、NAT インスタンス経由でトンネルエンドポイントへ転送できることを確認できました。
なお、このルートにより、アクセス元サブネットから 10.0.2.0/24 宛ての通信はすべて NAT インスタンスを経由します。10.0.2.37 以外の宛先は通常どおり接続元 VPC 内に届きますが、往路だけ NAT インスタンスを通る非対称経路になります。
方式の使い分け
各方式を選択できる条件は次のとおりです。
| 方式 | アクセス元・宛先のサブネット CIDR が重複していても使えるか | 選択する条件 |
|---|---|---|
| GENEVE リレー直接起動 | 使える | アクセス元 EC2 で GENEVE リレーを実行できる。アプリケーションの接続先は変更したくない |
| HAProxy L4 | 使える | アプリケーションの接続先を中継用 EC2 上の HAProxy に変更できる |
| NAT 透過中継 | 使えない | アクセス元・宛先サブネットの CIDR が異なり、宛先サブネットへの VPC ルートを NAT ENI に向けられる |
まとめ
PrivateLink トンネルエンドポイントを使い、VPC ピアリングや Transit Gateway では接続できない、CIDR が完全に重複する VPC 間でも HTTP 接続できることを確認しました。
アクセス元と宛先のサブネット CIDR まで重複する場合に使えるのは、アクセス元 EC2 で GENEVE リレーを直接起動する方式と、HAProxy を経由する方式です。NAT 透過中継は、アクセス元と宛先のサブネット CIDR が異なる場合に限られます。
CIDR が重複する環境では、先行記事のように接続先 VPC の CIDR 全体を tun0 に向けることはできません。本記事では接続先ホストごとに /32 ルートを指定しており、そのルートを持つ EC2 からは接続元 VPC 内の同じ IP に到達できなくなります。接続先の数に応じたルート管理が必要になる点は、採用時に考慮してください。
一方で、トンネルエンドポイントには接続元側からのみ接続を開始できるなどの制約があります(詳細は先行記事を参照)。アクセス元 EC2 でリレーを直接起動する方式では EC2 ごとにリレーの配布と root 実行が必要になり、中継用 EC2 を設ける方式では、その EC2 が単一障害点や性能上のボトルネックになり得ます。要件、可用性、性能を十分に評価したうえで利用してください。







