AWS PrivateLink トンネルエンドポイントで閉域 VPC からインターネットへ出られるか試してみた

AWS PrivateLink トンネルエンドポイントで閉域 VPC からインターネットへ出られるか試してみた

AWS PrivateLink のトンネルエンドポイントで、IGW も NAT Gateway もない VPC からインターネットへ出られるか試しました。HTTP/1.1・HTTP/2 の Web サイトと STS には到達でき、QUIC(HTTP/3)は通りませんでした。送信元 IP も確認しています。
2026.10.04

はじめに

2026 年 9 月、PrivateLink の新しい VPC エンドポイントとしてトンネルエンドポイントが利用可能になりました。
トンネルエンドポイントの基礎と GENEVE リレーの仕組みや、CIDR が重複する VPC 間の検証は、次の記事で紹介しています。

https://dev.classmethod.jp/articles/aws-privatelink-tunnel-endpoint-cross-account/

https://dev.classmethod.jp/articles/privatelink-tunnel-endpoint-cidr-overlap/

この記事では、IGW と NAT Gateway のない VPC の EC2 から、別 VPC の NAT Gateway を経由して、AWS API や一般のインターネットへ通信できるか検証してみました。

検証構成

閉域 VPC と出口 VPC

東京リージョン(ap-northeast-1)に VPC を 2 つ用意しました。この記事でいう閉域 VPC は、IGW・NAT Gateway・STS 用の VPC エンドポイントがない VPC です。EC2 の操作には SSM を利用するため、SSM 用のエンドポイントがあります。

閉域 VPC 出口 VPC
CIDR 10.1.0.0/16 10.2.0.0/16
ルート 10.1.0.0/16 local のみ。IGW・NAT Gateway なし パブリックサブネットに IGW と NAT Gateway。プライベートサブネット(Resource Gateway)は 0.0.0.0/0 → NAT Gateway
VPC エンドポイント ssm / ssmmessages / ec2messages(Interface)とトンネルエンドポイント。STS 用はなし —
配置 EC2 1 台(1a、t3.micro、Amazon Linux 2023、curl 8.21.0 は HTTP/2 対応・HTTP/3 非対応)。トンネルエンドポイントは 1a / 1c の 2 AZ Resource Gateway は 1a / 1c

Resource Configuration は CIDR タイプで、共有 CIDR は 0.0.0.0/0、プロトコルは TCP_UDP、ポートは 1-65535 です。

ドキュメントでは、ネットワーク全体へのアクセスを許可するには、IPv4 なら共有 CIDR に 0.0.0.0/0 を指定するとされています。宛先が共有 CIDR の範囲外のパケットは、トンネルエンドポイントで転送されません。

https://docs.aws.amazon.com/vpc/latest/privatelink/resource-configuration.html

https://docs.aws.amazon.com/vpc/latest/privatelink/privatelink-access-cidr-ranges.html

トンネルエンドポイント用と EC2 用のセキュリティグループには、10.1.0.0/16 からの UDP 6081 を許可するルールを入れました。

EC2 側の設定

閉域 VPC の EC2 に、次のリレーを /opt/geneve_relay.py として置きました。先行記事のスクリプトを、トンネルへ向ける宛先を引数の CIDR で指定できるようにしたものです。

geneve_relay.py
#!/usr/bin/env python3
"""
TUN device + GENEVE relay for AWS PrivateLink Tunnel Endpoint (CIDR type).
Runs on the source EC2 itself. Routes the given CIDRs into tun0.

Based on geneve_nat_relay.py from the previous verification; the only change is
that the routed destinations are arbitrary CIDRs instead of a single /32.

Usage (run as root):
  python3 geneve_relay.py <tunnel_ep_ip> <src_ip> <cidr> [<cidr> ...]

Example (send all IPv4 except VPC-local and link-local through the tunnel):
  ip route add 169.254.0.0/16 dev ens5        # keep IMDS / Amazon DNS / NTP local
  python3 geneve_relay.py 10.1.2.211 10.1.2.10 0.0.0.0/1 128.0.0.0/1
"""

import sys, os, struct, socket, fcntl, select, signal, time

TUNNEL_PORT = 6081
GENEVE_HDR  = struct.pack('!BBH', 0, 0, 0x0800) + b'\x00\x00\x00\x00'  # 8 bytes, VNI=0

TUNSETIFF = 0x400454ca
IFF_TUN   = 0x0001
IFF_NO_PI = 0x1000

def log(msg):
    print(f'{time.strftime("%H:%M:%S")} {msg}', flush=True)

def open_tun(name='tun0'):
    tun = open('/dev/net/tun', 'r+b', buffering=0)
    fcntl.ioctl(tun, TUNSETIFF, struct.pack('16sH', name.encode(), IFF_TUN | IFF_NO_PI))
    return tun

def main():
    if len(sys.argv) < 4:
        print(f'Usage: {sys.argv[0]} <tunnel_ep_ip> <src_ip> <cidr> [<cidr> ...]')
        sys.exit(1)
    tunnel_ep, src_ip, cidrs = sys.argv[1], sys.argv[2], sys.argv[3:]
    tun_name = 'tun0'

    tun = open_tun(tun_name)
    os.system(f'ip link set {tun_name} up')
    for c in cidrs:
        rc = os.system(f'ip route replace {c} dev {tun_name} src {src_ip}')
        log(f'route {c} -> {tun_name} (src {src_ip}) rc={rc}')

    udp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    udp.bind(('0.0.0.0', TUNNEL_PORT))
    log(f'relay up: tunnel_ep={tunnel_ep}:{TUNNEL_PORT}')

    signal.signal(signal.SIGTERM, lambda s, f: (_ for _ in ()).throw(KeyboardInterrupt))
    sent = recv = 0
    last = time.time()
    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))
                        sent += 1
                else:
                    data, _ = udp.recvfrom(65536)
                    if len(data) >= 8:
                        hdr_len = 8 + (data[0] & 0x3f) * 4  # base header + options
                        tun.write(data[hdr_len:])
                        recv += 1
            if time.time() - last >= 30:
                log(f'stats sent={sent} recv={recv}')
                last = time.time()
    except KeyboardInterrupt:
        log('stopping')
    finally:
        for c in cidrs:
            os.system(f'ip route del {c} dev {tun_name} 2>/dev/null')
        os.system(f'ip link set {tun_name} down 2>/dev/null')
        log(f'routes removed. total sent={sent} recv={recv}')

if __name__ == '__main__':
    main()

このリレーを、root として次のように実行しました。<...> は環境に合わせて置き換えます。

ip route replace 169.254.0.0/16 dev ens5
python3 /opt/geneve_relay.py <トンネルエンドポイント ENI の IP> <EC2 のプライベート IP> 0.0.0.0/1 128.0.0.0/1

実際には、この python3 の行を systemd-run 経由で起動して常駐させています。0.0.0.0/1 と 128.0.0.0/1 の 2 本で IPv4 全体を tun0 に向けます。169.254.0.0/16 は、リレーの起動前に ens5 へ向けるルートを入れて、IMDS や NTP が tun0 へ流れないように調整しました。

起動後のルートの抜粋です(default とゲートウェイ宛ての行は省略しています)。

0.0.0.0/1 dev tun0 scope link src <EC2 のプライベート IP>
10.1.0.2 via <サブネットのゲートウェイ> dev ens5 proto dhcp src <EC2 のプライベート IP> metric 512
10.1.2.0/24 dev ens5 proto kernel scope link src <EC2 のプライベート IP> metric 512
128.0.0.0/1 dev tun0 scope link src <EC2 のプライベート IP>
169.254.0.0/16 dev ens5 scope link

0.0.0.0/1 は 10.1.0.0/16 全体を含むので、VPC 内の他サブネット宛ても tun0 に向かいます。VPC 内宛てのうち ens5 に残るのは、EC2 と同じサブネットの 10.1.2.0/24 の直接経路と、DHCP が入れた 10.1.0.2(Amazon DNS)のホストルートです。リレーの宛先に指定したトンネルエンドポイント ENI は EC2 と同じサブネットにあったので、この直接経路で届きます。

閉域 VPC からインターネットへ出られるか

トンネルなしではどこにも届かない

リレーを起動する前に、同じ EC2 から STS を 1 回呼び出しました。dev.classmethod.jp と example.com には、HTTP/1.1 と HTTP/2 で 1 回ずつ、計 4 回 curl を実行しました。すべて接続タイムアウトでした。リレーを停止した後に測り直しても、同じ結果でした。以下は出力の抜粋です。

$ aws sts get-caller-identity --cli-connect-timeout 5 --cli-read-timeout 5 --region ap-northeast-1 --query Arn --output text
exit=255 elapsed=16.612 at=2026-10-03T16:18:59.602Z
$ curl -sS --http1.1 --connect-timeout 5 --max-time 8 -o /dev/null -w http_code=%{http_code} http_version=%{http_version} remote_ip=%{remote_ip} total=%{time_total}\n https://dev.classmethod.jp/
http_code=000 http_version=0 remote_ip= total=5.002210
exit=28 elapsed=5.009 at=2026-10-03T16:19:04.637Z
$ curl -sS --http2 --connect-timeout 5 --max-time 8 -o /dev/null -w http_code=%{http_code} http_version=%{http_version} remote_ip=%{remote_ip} total=%{time_total}\n https://example.com/
http_code=000 http_version=0 remote_ip= total=5.000986
exit=28 elapsed=5.008 at=2026-10-03T16:19:19.741Z
aws: [ERROR]: Connect timeout on endpoint URL: "https://sts.ap-northeast-1.amazonaws.com/"

トンネル経由で STS と外部サイトに届く

リレーを起動した状態で、同じ 2 サイトを HTTP/1.1 と HTTP/2 で各 3 回取得しました。dev.classmethod.jp の例は次のコマンドです。

curl -sS --http2 --connect-timeout 10 --max-time 20 -o /dev/null -w 'http_code=%{http_code} http_version=%{http_version} remote_ip=%{remote_ip}:%{remote_port} ssl_verify=%{ssl_verify_result} size=%{size_download} t_connect=%{time_connect} t_tls=%{time_appconnect} total=%{time_total}\n' https://dev.classmethod.jp/
宛先 指定 http_code http_version size_download time_total
dev.classmethod.jp --http1.1 200 ×3 1.1 283309 0.029〜0.074 秒
dev.classmethod.jp --http2 200 ×3 2 283309 0.027〜0.036 秒
example.com --http1.1 200 ×3 1.1 577 0.026〜0.057 秒
example.com --http2 200 ×3 2 577 0.025〜0.026 秒

どちらのサイトも、HTTP/1.1・HTTP/2 の各 3 回(計 6 回)で証明書の検証に成功しました(ssl_verify_result=0)。dev.classmethod.jp への 6 回の接続先は、18.65.214.x の 4 種類の IP に分かれました。

aws sts get-caller-identity も 3 回とも成功し、所要時間は約 0.50〜0.53 秒でした。応答の ARN は、この EC2 に付けたロールのセッション(arn:aws:sts::<ACCT>:assumed-role/<ロール名>/<インスタンス ID>)でした。

tcpdump で GENEVE パケットの中身を確認する

ens5 の tcpdump で、トンネルエンドポイント ENI 宛ての GENEVE パケットを見ると、内側に dev.classmethod.jp の IP:443 宛ての SYN がありました。

1791044829.666713 IP <EC2 のプライベート IP>.6081 > <トンネルエンドポイント ENI の IP>.6081: Geneve, Flags [none], vni 0x0: IP <EC2 のプライベート IP>.44212 > 18.65.214.101.443: Flags [S], seq 4166829652, win 64240, options [mss 1460,sackOK,TS val 3555845180 ecr 0,nop,wscale 7], length 0
ens5 packets to 18.65.214.101:443 inside GENEVE: 176
ens5 packets from 18.65.214.101:443 inside GENEVE: 428

18.65.214.101 は、--http2 の curl が接続した remote_ip と一致しました。

パッケージ導入と Docker

閉域 EC2 で dnf -y install docker を実行すると、Amazon Linux 2023 の公式リポジトリからトンネル経由でパッケージを取得でき、インストールに成功しました。リポジトリのメタデータ取得を含む初回の実行には約 60 秒かかりました。

tun0 に 0.0.0.0/1 と 128.0.0.0/1 のルートがある状態では、初回の systemctl start docker が失敗しました(could not find an available, non-overlapping IPv4 address)。/etc/docker/daemon.json でデフォルトブリッジのアドレスを指定し、reset-failed の後に起動し直すと成功しました(Docker version 25.0.14)。コマンドは次のとおりです。

mkdir -p /etc/docker
echo '{"bip":"172.17.0.1/16"}' > /etc/docker/daemon.json
systemctl reset-failed docker
systemctl start docker

その後、docker pull curlimages/curl:latest は約 3.2 秒で成功しました。ymuski/curl-http3、badouralix/curl-http3(いずれも Docker Hub)と quay.io/curl/curl:latest(quay.io)も取得できました。

送信元 IP と CloudTrail

checkip.amazonaws.com の応答は、出口 VPC の NAT Gateway の EIP(<NAT-EIP>)と一致しました。CloudTrail では、このインスタンスの 24 件のイベント(16:20〜16:40 UTC)を確認しました。

イベント sourceIPAddress vpcEndpointId 件数
sts GetCallerIdentity <NAT-EIP> なし 3
ec2 DescribeRegions <NAT-EIP> なし 15
ssm UpdateInstanceInformation / ListInstanceAssociations EC2 のプライベート IP vpce-… 6

DescribeRegions は、動作確認のために EC2 API を呼び出したものです。同じ EC2 からの SSM 通信は Interface エンドポイント経由なので、EC2 のプライベート IP と VPC エンドポイントの ID が記録されました。GetCallerIdentity のイベント本文では vpcEndpointId が null で、clientProvidedHostHeader は sts.ap-northeast-1.amazonaws.com でした。

QUIC(HTTP/3)は届かない

dev.classmethod.jp の応答には alt-svc: h3=":443"; ma=86400 が付いていて、HTTP/3 への対応が示されています。

閉域 EC2 の Docker(--network host)で、HTTP/3 対応の curl イメージ 2 種を latest タグで使いました。dev.classmethod.jp と www.google.com に --http3-only を 2 回ずつ、--http3(フォールバックあり)を 1 回ずつ実行しました。

イメージ curl --http3-only --http3
ymuski/curl-http3:latest 8.2.1-DEV(quiche 0.18.0) 2 サイト × 2 回とも 8 秒で接続タイムアウト(exit=28) 2 サイトとも 200、http_version=2
badouralix/curl-http3:latest 8.2.0-DEV(ngtcp2 0.17.0-DEV) 2 サイト × 2 回とも 8 秒で接続タイムアウト(exit=28) 2 サイトとも 200、http_version=1.1

HTTP/3 を試している間の tun0 の tcpdump では、閉域 EC2 から UDP 443 宛てに送信したパケットは 66 件、443 番ポートからの UDP 応答は 0 件でした。送信パケットの例は <EC2 のプライベート IP>.37648 > 18.65.214.101.443: UDP, length 1200 です。

今回の構成では、QUIC(HTTP/3)はトンネル経由では使えないことが確認できました。

まとめ

PrivateLink のトンネルエンドポイントを使うと、IGW も NAT Gateway もない VPC の EC2 から、別 VPC の NAT Gateway を出口にして、AWS の API や HTTP/1.1・HTTP/2 の Web サイトにアクセスできました。

トンネルを利用する EC2 側の設定管理などの点で、常用は難しいと考えられます。ただ、閉域 VPC の EC2 のメンテナンスなどで、外部 API やパッケージの参照のように TCP でインターネットアクセスが必要な場合は、トンネルエンドポイントが一時的な経路として利用できることが確認できました。

また、VPC の閉域性を担保する必要がある場合は、トンネルエンドポイントの扱いに留意することをおすすめします。

この記事をシェアする

AWSのお困り事はクラスメソッドへ

関連記事