AWS PrivateLink の新機能「トンネルエンドポイント」を試してみた

AWS PrivateLink の新機能「トンネルエンドポイント」を試してみた

AWS PrivateLink に追加されたトンネルエンドポイントを検証しました。クロスアカウントで CIDR レンジ単位のネットワークへプライベートにアクセスでき、接続元では GENEVE リレーが必要ですが、アプリは変更不要です。HTTP 疎通・DNS 解決・CIDR 境界を確認しました。
2026.09.19

はじめに

2026年9月18日、AWS PrivateLink にトンネルエンドポイントが追加されました。

https://aws.amazon.com/jp/about-aws/whats-new/2026/9/privatelink-tunnel-endpoint/

今回は、監視やファイル配信の基盤から別アカウントの多数のインスタンスへアクセスする用途を想定し、異なる Organization の 2 アカウント間で CIDR タイプの Resource Configuration を AWS RAM で共有、トンネルエンドポイント経由でアクセスを試みました。

トンネルエンドポイントとは

公式ドキュメントの定義では、別の VPC やアカウントから共有されたネットワークセグメント(CIDR レンジのリスト)へプライベートにトンネルするエンドポイントです。接続元側では、TUN デバイスと GENEVE リレーを動かす仕組みを用意し、共有された CIDR レンジ内の IP アドレスへアクセスします。リレーは接続元 EC2 上で動かすことも、中継用 EC2 として配置することもできます。

https://docs.aws.amazon.com/vpc/latest/privatelink/concepts.html

接続先側に Resource Gateway と CIDR タイプの Resource Configuration を作り、接続元側にトンネルエンドポイントを作ります。接続元からのパケットは次の経路で接続先側へ届きます。

接続元 EC2 → TUN デバイス → GENEVE リレー → Tunnel Endpoint ENI → Resource Gateway → 接続先 EC2

GENEVE パケットの宛先ポートは UDP 6081、VNI は 0 固定、インナーパケットは L3 の IP パケットです。

なお、接続を開始できるのは接続元側からだけです。接続先側から接続元側へ新規の接続を開始することはできず、接続元からの通信に対する応答だけが戻ります。

検証構成

アカウント リソース
接続先(Account A) VPC 10.0.0.0/16
接続先(Account A) Resource Gateway のサブネット 10.0.2.0/24(apne1-az4)、10.0.3.0/24(apne1-az1)
接続先(Account A) EC2(EC2-A) 10.0.0.152、python3 -m http.server 8080、Amazon Linux 2023
接続元(Account B) VPC 10.1.0.0/16
接続元(Account B) トンネルエンドポイントのサブネット 10.1.2.0/24(apne1-az4)、10.1.3.0/24(apne1-az1)
接続元(Account B) EC2 10.1.0.200、Amazon Linux 2023

接続先側のセットアップ(Account A)

Resource Gateway は接続先の VPC のプライベートサブネット 2 つに作成しました。Resource Gateway を配置した AZ が、接続元側でトンネルエンドポイントを配置できる AZ になります。

Resource Gateway 作成コマンド
aws vpc-lattice create-resource-gateway \
  --region ap-northeast-1 \
  --name <name> \
  --vpc-identifier <vpc-id> \
  --subnet-ids <dst-prv-az4-id> <dst-prv-az1-id> \
  --security-group-ids <rgw-sg-id>

共有する範囲は CIDR タイプの Resource Configuration で指定します。

aws vpc-lattice create-resource-configuration \
  --region ap-northeast-1 \
  --name <name> \
  --type CIDR \
  --resource-gateway-identifier <rgw-id> \
  --protocol TCP_UDP \
  --port-ranges "1-65535" \
  --resource-configuration-definition \
    "cidrResource={cidrRanges=[\"<CIDR>\"]}"

今回は --protocolTCP_UDP で作成しました。アプリケーション通信は TCP のみですが、DNS 問い合わせがインナーの UDP で流れるため、これを通すために TCP_UDP を指定しています。

疎通確認のため、Resource Configuration の --port-ranges1-65535 にし、Resource Gateway の SG でもすべてのトラフィックを許可しました。Resource Configuration の --port-ranges と Resource Gateway の SG は別レイヤーの制御であり、実運用ではどちらも必要なポートだけに限定することを推奨します。

Resource Configuration を別アカウントで使うには、AWS RAM で共有します。今回のように、接続元アカウントと接続先アカウントが異なる AWS Organizations に属する場合は、共有の作成時に外部プリンシパルを許可し、接続元アカウントで招待を承諾する必要があります。

# 接続先側: RAM 共有の作成
aws ram create-resource-share \
  --region ap-northeast-1 \
  --name <share-name> \
  --resource-arns <rcfg-arn> \
  --principals <src-account-id> \
  --allow-external-principals

# 接続元側: 招待の承諾
aws ram accept-resource-share-invitation \
  --region ap-northeast-1 \
  --resource-share-invitation-arn <invitation-arn>

セキュリティグループの設計

この構成では、外側の GENEVE 通信と内側のアプリケーション通信を、それぞれ異なる SG で制御します。SG が効くのは VPC 上の ENI 間の通信で、接続元 EC2 内の TUN デバイスと GENEVE リレーの処理はホスト内で完結するため SG の対象外です。

[接続元 EC2 ホスト内]  ← SG 対象外
  アプリ → tun0 → GENEVE リレー
        ↓(UDP 6081 で送出)
────────────────────────────────
[VPC 上の通信]  ← ここから SG が効く

外側: GENEVE(UDP 6081)
  接続元 EC2 の ENI [SG: outbound UDP 6081]
    → Tunnel Endpoint ENI [SG: inbound UDP 6081]

内側: アプリケーション通信(例: TCP 8080)
  Resource Gateway の ENI [SG: outbound TCP 8080]
    → 接続先 EC2 の ENI [SG: inbound TCP 8080]

Resource Gateway の SG はステートフルで両方向のルールを持ちますが、今回の経路で効くのはアウトバウンド側(Resource Gateway → 接続先リソース)のルールです。前段で「すべてのトラフィックを許可」としたのは、このアウトバウンドを含めて絞らなかったという意味です。

接続先 EC2 から見たトラフィックの送信元は、接続元 VPC の CIDR ではなく Resource Gateway の ENI の IP です。Resource Gateway は配置したサブネット(AZ)ごとに ENI を持つため、特定の IP だけを送信元として許可すると、一部の接続では疎通しても、別の IP が使われた接続では通信できない可能性があります。

接続先 EC2 の SG の送信元には、Resource Gateway に関連付けた SG を指定できます。SG 参照を使わない場合は、Resource Gateway を置いたサブネットの CIDR(今回は 10.0.2.0/24 と 10.0.3.0/24)を指定できます。

接続元側のセットアップ(Account B)

RAM の招待を承諾すると、共有された Resource Configuration を接続元アカウントで確認できるようになります。トンネルエンドポイントは Tunnel タイプの VPC エンドポイントとして作成し、--resource-configuration-arn に接続先アカウントで作成した Resource Configuration の ARN を指定します。

aws ec2 create-vpc-endpoint \
  --vpc-endpoint-type Tunnel \
  --vpc-id <vpc-id> \
  --subnet-ids <src-prv-az4-id> <src-prv-az1-id> \
  --security-group-ids <tep-sg-id> \
  --resource-configuration-arn arn:aws:vpc-lattice:<region>:<dst-account-id>:resourceconfiguration/<rcfg-id>

指定するサブネットは、Resource Gateway を配置したサブネットと同じ AZ に配置する必要があります。AZ 名と物理 AZ の対応はアカウントごとに異なるため、クロスアカウントでは AZ ID(今回は apne1-az4 / apne1-az1)で突き合わせます。

トンネルエンドポイントの SG には、接続元 EC2 の CIDR からの UDP 6081 受信を許可するルールが必要です。今回は 10.1.0.0/16 からの UDP 6081 を許可しました。

GENEVE リレー

接続元 EC2 では TUN デバイスとユーザースペースのリレーを動かしました。リレーには送信元 IP として接続元 EC2 のプライマリ IP(今回は 10.1.0.200)を渡します。リレーの役割は次のとおりです。

  • tun0 を作る
  • 接続先の VPC の 10.0.0.0/16 と DNS リゾルバーの 169.254.168.253/32 への経路を tun0 に向ける
  • tun0 から読んだ L3 パケットに 8 バイトの GENEVE ヘッダー(VNI=0、Protocol Type 0x0800)を付け、UDP 6081 でトンネルエンドポイントの ENI へ送る
  • UDP 6081 で受け取ったパケットから GENEVE ヘッダーを剥がし、tun0 へ書き戻す

戻りパケットもリレーが tun0 へ書き戻すので、カーネルの TCP スタックがそのまま使えます。アプリケーション側は変更不要で、curldig をそのまま実行できます。

Linux の ip link add type geneve で作成する L2 オーバーレイデバイスは、Protocol Type が 0x6558(Transparent Ethernet Bridging)になり、ペイロードに Ethernet フレームを運びます。トンネルエンドポイントが期待するのは IP パケットを直接ペイロードに持つ 0x0800(IPv4)のため、そのままでは利用できません。実測でも ip link add type geneve では疎通せず、0x0800 を指定した自前のリレーで初めて HTTP 200 を確認しました。

geneve_tun.py(GENEVE リレースクリプト全文)
#!/usr/bin/env python3
"""
TUN device + GENEVE relay for AWS PrivateLink Tunnel Endpoint (CIDR type).
- Reads L3 IP packets from tun0, wraps in GENEVE (VNI=0, proto=0x0800), sends UDP to tunnel EP
- Receives UDP from tunnel EP, strips GENEVE header, writes inner IP to tun0

Usage (run as root):
  python3 geneve_tun.py <tunnel_ep_ip> <local_src_ip>

  local_src_ip: source IP of this EC2 (its primary private IP, e.g. 10.1.0.200)

Example:
  python3 geneve_tun.py 10.1.2.24 10.1.0.200
"""

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

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):
    os.system(f'ip link set {tun_name} up')
    os.system(f'ip route add 10.0.0.0/16 dev {tun_name} src {src_ip}')
    os.system(f'ip route add 169.254.168.253/32 dev {tun_name} src {src_ip}')
    print(f'Routes added: 10.0.0.0/16 and 169.254.168.253/32 -> {tun_name}')

def teardown_route(tun_name):
    os.system(f'ip route del 10.0.0.0/16 dev {tun_name} 2>/dev/null')
    os.system(f'ip route del 169.254.168.253/32 dev {tun_name} 2>/dev/null')
    os.system(f'ip link set {tun_name} down 2>/dev/null')

def main():
    if len(sys.argv) < 3:
        print(f'Usage: {sys.argv[0]} <tunnel_ep_ip> <local_src_ip>')
        sys.exit(1)

    tunnel_ep = sys.argv[1]
    src_ip    = sys.argv[2]
    tun_name  = 'tun0'

    print(f'Tunnel EP: {tunnel_ep}:{TUNNEL_PORT}')
    print(f'Source IP: {src_ip}')

    tun = open_tun(tun_name)
    print(f'Opened {tun_name}')

    setup_route(tun_name, src_ip)

    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.')

    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, addr = 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)
        tun.close()
        udp.close()

if __name__ == '__main__':
    main()

疎通確認

接続元 EC2 から接続先 EC2 へ curl -v を実行しました。

curl -v http://10.0.0.152:8080/
< HTTP/1.0 200 OK
< Server: SimpleHTTP/0.6 Python/3.9.25

同時に接続先 EC2 側で取得した tcpdump です。

04:19:40 ens5 In  IP 10.0.2.85.12907 > 10.0.0.152.8080: Flags [S]  (SYN)
04:19:40 ens5 Out IP 10.0.0.152.8080 > 10.0.2.85.12907: Flags [P.] length 157: HTTP/1.0 200 OK

送信元は Resource Gateway の ENI の IP(10.0.2.85)です。リクエスト元の接続元 EC2(10.1.0.200)ではありません。

接続先の VPC のリゾルバーでの名前解決

接続先の VPC に Route 53 プライベートホストゾーン internal.pl-test.local を作成して接続先の VPC に関連付け、provider-ec2.internal.pl-test.local の A レコードに 10.0.0.152 を登録しました。接続元 EC2 から接続先の VPC のリゾルバーへ問い合わせた結果です。

$ dig +short @169.254.168.253 provider-ec2.internal.pl-test.local A
10.0.0.152

トンネルエンドポイント経由で接続先 VPC の DNS リゾルバーへ問い合わせる場合は、ネームサーバーに 169.254.168.253 を指定します。これは公式ドキュメントで定められた専用アドレスで、通常の VPC DNS リゾルバーの 169.254.169.253 とは異なります。

DNS クエリも GENEVE でカプセル化して送信します。このインナー UDP を通すために、前述のとおり Resource Configuration を TCP_UDP で作成しています。

CIDR 指定範囲の検証

10.0.0.0/1610.0.0.0/24 をそれぞれ Resource Configuration に指定したトンネルエンドポイントを用意し、10.0.0.15210.0.1.218 に対して curl を実行しました。2 組の違いは、Resource Configuration に指定した CIDR だけです。

curl -s -o /dev/null -w '%{http_code}' --max-time 5 http://<host>:8080/
/16 EP → 10.0.0.152:8080  (CIDR 内)          200  ✅ 期待どおり疎通
/16 EP → 10.0.1.218:8080  (CIDR 内・対照)     200  ✅ 期待どおり疎通
/24 EP → 10.0.0.152:8080  (CIDR 内)          200  ✅ 期待どおり疎通
/24 EP → 10.0.1.218:8080  (CIDR 外)          000  ✅ 期待どおり接続不可(タイムアウト)

✅ は結果が期待どおりであることを示します(HTTP コード 000curl がタイムアウトした場合の値で、ここでは CIDR 外への接続不可を意味します)。10.0.1.218 は、/16 では CIDR 内、/24 では CIDR 外になる対照用のホストです。同じ宛先が、指定した CIDR の違いだけで疎通したりしなかったりすることを確認しています。

Resource Configuration に指定した CIDR の範囲内でのみ、HTTP ステータスコード 200 を確認できました。

制約と注意事項

CIDR タイプの Resource Configuration はトンネルエンドポイント専用です。リソースエンドポイントからはアクセスできず、サービスネットワークへの関連付けもできません。

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

トンネルエンドポイント自身のプライベート DNS 名は非サポートです。前述のとおり接続先 VPC のリゾルバーへ問い合わせて名前解決すること自体はできますが、これはエンドポイント自身に対する Private DNS 機能(インターフェイスエンドポイントのように独自の DNS 名を割り当てる機能)とは別で、後者は提供されません。

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

料金

トンネルエンドポイントの時間料金とデータ処理料金は接続元アカウントに発生します。接続先アカウントには、Resource Gateway のデータ処理料金が発生します。また、接続元側で GENEVE リレーを EC2 などで動かす場合は、その EC2 のコンピューティング料金や、CloudWatch などで監視する場合の監視コストも接続元の負担です。単価と最新の料金体系は、AWS PrivateLink の料金ページを参照してください。

https://aws.amazon.com/jp/privatelink/pricing/

まとめ

AWS PrivateLink にトンネルエンドポイントが追加されました。クロスアカウントで監視やファイル配信を行う場合、接続元は、アクセス対象のリソースがある VPC に Resource Gateway を配置し、接続元 EC2 に TUN デバイスと GENEVE リレーを構成することで、CIDR 単位で許可した接続先にアクセスできます。監視対象が動的に増減しても、CIDR の範囲内であれば個別登録なしでアクセスできます。

従来の PrivateLink では、接続先側に NLB を配置してサービスを公開します。一方、トンネルエンドポイントでは、接続元側に GENEVE カプセル化・デカプセル化の仕組みが必要です。接続元ごとにリレーを配布するか、可用性と経路制御を考慮した共有リレーを運用する必要があるため、採用時は接続元側のネットワーク実装・運用負担も評価することをおすすめします。

この記事をシェアする

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

関連記事