【実務・中級編】 AWS PrivateLink(VPCエンドポイントサービス)のNLBとENI連携動作 – クラウドインフラと仮想化ネットワーク実践ガイド

こんにちは。現場の泥臭いトラブルシューティングと、パケットキャプチャの生データが大好物のシニアネットワークエンジニアだ。

今日も元気にVPCのルートテーブルやセキュリティグループと格闘しているだろうか?

実務でシステムがスケールしてくると、必ず直面するのが「他社システムや自社の別VPCにあるマイクロサービスと、どうやって安全かつ低遅延で通信するか」という課題だ。

「VPCピアリングで繋げばいいじゃないですか」と事もなげに言う若手もいる。だが、現実はそう甘くない。お互いのVPCで10.0.0.0/16がバッティングしていたらその時点でピアリングは破綻するし、セキュリティ部門からは「相手のVPCからこちらのVPC内部へ全方位でルーティングが通るなんて言語道断」と却下されるのがオチだ。

そこで登場するのが、今回の主役であるAWS PrivateLink(VPCエンドポイントサービス)だ。

今回は、PrivateLinkの心臓部である「コンシューマー側ENI」と「プロバイダー側NLB(Network Load Balancer)」の連携動作にスポットを当て、パケットがどのように変換され、どのように流れていくのか、その裏側の挙動をパケットレベルで解き明かしていく。教科書的な設定手順だけでは絶対にハマる「ソースIPの消失問題」や「Proxy Protocol v2」の実装方法まで、実戦で使える知識を叩き込むので、ぜひ最後まで付き合ってほしい。

—

1. AWS PrivateLinkの全体像とアーキテクチャ

まずは、PrivateLinkがどのような構造で2つのVPC(あるいはAWSアカウント)を跨いでいるのか、頭の中のパケット中継図を整理しよう。

[コンシューマーVPC (10.0.0.0/16)]                [プロバイダーVPC (192.168.0.0/16)]
+----------------------------------+            +----------------------------------+
| [App EC2] (10.0.1.50)            |            | [Target EC2] (192.168.1.100)     |
|       |                          |            |       ^                          |
|       v (アクセス先: VPC EP)       |            |       | (Proxy Protocol v2)      |
|  [VPC Endpoint (ENI)]            |======AWS======> [NLB] (192.168.1.10)          |
|  (10.0.1.80)                     |   Hyperplane   +----------------------------------+
+----------------------------------+

この構成において、パケットの送信元と宛先は以下のように変化していく。

1. コンシューマーのアプリ (10.0.1.50) は、自サブネット内に生えてきた VPCエンドポイントのENI (10.0.1.80) を目がけてTCP接続を開始する。
2. VPCエンドポイント(ENI) に吸い込まれたパケットは、AWSの裏側の超巨大で堅牢なSDN(Software Defined Network)基盤である「AWS Hyperplane」によってカプセル化され、VPCの境界を物理的に飛び越える。
3. プロバイダー側のNLB (192.168.1.10) に到着したパケットは、デカプセル化されて最終的なターゲット(ECSやEC2など、192.168.1.100)へとルーティングされる。

ここで最も重要なポイントは、「IPルーティングではなく、カプセル化によるマッピング(NAT)が行われている」という点だ。コンシューマーとプロバイダーのCIDRがどれだけ重複していようが、パケットは衝突することなく、整然と宛先に届けられる。

—

2. ディープダイブ:NLBとENIの連携とパケットの挙動

さて、ここからが本題だ。ネットワークエンジニアとして最も脳汁が出る「パケットのソースIPヘッダー」の変遷を見ていこう。

「クライアントIPの保存」が効かないという残酷な現実

NLBには、ターゲットにクライアントのソースIPをそのまま透過して伝える「クライアントIPの保存(Client IP Preservation)」という機能がある。しかし、PrivateLinkを経由した通信においては、この機能は一切動作しない。

なぜか?

PrivateLinkの接続は、AWS Hyperplaneによってトランスポート層(L4)でProxy(中継)されるからだ。コンシューマー側ENIに届いたSYNパケットは、プロバイダー側のNLBに届く時点で、ソースIPが「NLB自体のプライベートIP」または「Hyperplaneの内部IP」に書き換わってしまう。

ターゲットであるWeb APIサーバー(192.168.1.100)から見ると、アクセス元はすべてNLB(192.168.1.10など)に見える。

これでは、以下のような実務要件が満たせなくなる。

  • 接続元のコンシューマー(クライアント)ごとにIPベースのレートリミットをかけたい
  • 監査ログに接続元IPを記録したい

救世主「Proxy Protocol v2」の仕組み

この問題をエレガントに解決するのが、Proxy Protocol v2 (PPv2) だ。これはHAProxyの作成者が提唱した仕様で、RFCではないが、業界の事実上の標準(de facto standard)として広く使われている。

NLBで「Proxy Protocol v2」を有効にすると、NLBはターゲットへのTCPコネクションを確立する際、TCP 3ウェイハンドシェイクの直後、実際のアプリケーションデータ(HTTPリクエストなど)を送信する前に、接続元のリアルなIPアドレス情報を含んだ特別なバイナリヘッダーをパケットの先頭に挿入する。

[TCP SYN] -> [TCP SYN-ACK] -> [TCP ACK]  (3ウェイハンドシェイク完了)
      |
      v
[Proxy Protocol v2 ヘッダー]  <-- NLBがこれを先頭にねじ込む!
      |
      v
[HTTP GET /api/v1/resource ...] (実際のデータ)

このPPv2ヘッダーには、コンシューマー側ENIのIPアドレス(あるいはコンシューマーのオリジナルIP)が厳密に記録されている。ターゲットサーバー側でこのヘッダーをデコードして読み解けば、真のクライアントIPが手に入るというわけだ。

—

3. 実践:Terraformで構築するPrivateLink

能書きはこれくらいにして、実際のインフラコードに落とし込んでみよう。今回は、プロバイダー側で「Proxy Protocol v2」を有効にしたNLBとEndpoint Serviceを構築し、コンシューマー側でInterface Endpointを作成するTerraformのコード例を示す。

プロバイダー側:NLB & エンドポイントサービスの設定

プロバイダー側で最も重要なのは、ターゲットグループ(aws_lb_target_group)の proxy_protocol_v2 を true に設定することだ。

# ------------------------------------------------------------------------------
# プロバイダー側: Network Load Balancer の作成
# ------------------------------------------------------------------------------
resource "aws_lb" "provider_nlb" {
  name               = "provider-privatelink-nlb"
  internal           = true # VPC内部(PrivateLink経由)で公開するため internal にする
  load_balancer_type = "network"
  subnets            = ["subnet-0123456789abcdef0"] # NLBを配置するサブネット

  enable_cross_zone_load_balancing = true
}

# ------------------------------------------------------------------------------
# プロバイダー側: ターゲットグループの設定(Proxy Protocol v2 を有効化)
# ------------------------------------------------------------------------------
resource "aws_lb_target_group" "provider_tg" {
  name        = "provider-tg"
  port        = 80
  protocol    = "TCP"
  vpc_id      = "vpc-0987654321fedcba0"
  target_type = "instance"

  # 【超重要】Proxy Protocol v2 を有効にする
  proxy_protocol_v2 = true

  health_check {
    protocol            = "TCP"
    port                = "80"
    healthy_threshold   = 3
    unhealthy_threshold = 3
    interval            = 10
  }
}

# ------------------------------------------------------------------------------
# プロバイダー側: VPCエンドポイントサービスの作成
# ------------------------------------------------------------------------------
resource "aws_vpc_endpoint_service" "provider_service" {
  acceptance_required        = true # コンシューマーからの接続要求を手動承認する
  network_load_balancer_arns = [aws_lb.provider_nlb.arn]

  tags = {
    Name = "my-apiservice"
  }
}

コンシューマー側:VPCエンドポイント(Interface型)の設定

コンシューマー側は、プロバイダーが発行したエンドポイントサービス名(com.amazonaws.vpce.ap-northeast-1.vpce-svc-xxxxxxxx)を指定して、自VPC内にENIを割り当てる。

# ------------------------------------------------------------------------------
# コンシューマー側: インターフェース型 VPCエンドポイント (ENI) の作成
# ------------------------------------------------------------------------------
resource "aws_vpc_endpoint" "consumer_endpoint" {
  vpc_id            = "vpc-0aaaaabbbbbccccc0"
  service_name      = "com.amazonaws.vpce.ap-northeast-1.vpce-svc-0123456789abcdef0" # プロバイダーから共有されたサービス名
  vpc_endpoint_type = "Interface"

  subnet_ids         = ["subnet-0dddddeeeeefffff0"] # ENIをデプロイするサブネット
  security_group_ids = [aws_security_group.endpoint_sg.id]

  private_dns_enabled = false # 独自DNSを引く場合は要件に応じて true にする
}

# ------------------------------------------------------------------------------
# コンシューマー側: エンドポイント用セキュリティグループ
# ------------------------------------------------------------------------------
resource "aws_security_group" "endpoint_sg" {
  name        = "endpoint-sg"
  description = "Allow traffic to VPC Endpoint"
  vpc_id      = "vpc-0aaaaabbbbbccccc0"

  # アプリケーションサーバーからのTCPアクセス(例: 80ポート)を許可
  ingress {
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["10.0.0.0/16"] # コンシューマーVPC内の通信元セグメント
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

—

4. 実務でのデバッグと検証:Proxyプロトコルを解釈する

よし、インフラは構築できた。しかし、プロバイダー側のターゲットサーバーが普通のWebサーバー(Nginxなど)のデフォルト設定のままだと、「通信が502エラーになる」「Nginxのログがバイナリ交じりでバグる」という悲惨な事態になる。

なぜなら、サーバーはただのHTTPリクエストを待っているのに、NLBから「Proxy Protocol v2のバイナリデータ」が送りつけられるため、パースエラーを起こすからだ。

ターゲット側でこのProxy Protocolを正しく捌くための、2つのアプローチを紹介しよう。

アプローチA:Nginxでの設定例

もしリバースプロキシにNginxを使っているなら、設定は至極簡単だ。listen ディレクティブに proxy_protocol を追加するだけで、Nginxが自動的にヘッダーを剥ぎ取り、$proxy_protocol_addr 変数に真のクライアントIPを格納してくれる。

# /etc/nginx/nginx.conf の設定例

http {
    # ログフォーマットに Proxy Protocol 経由のIPを組み込む
    log_format  main_with_real_ip  '$proxy_protocol_addr - $remote_user [$time_local] '
                                   '"$request" $status $body_bytes_sent '
                                   '"$http_referer" "$http_user_agent"';

    server {
        # 【重要】listen の最後に proxy_protocol を付与する
        listen 80 proxy_protocol;
        server_name api.internal.local;

        # 信頼するNLB(またはHyperplane)のIP帯を指定。ここではVPC全体のローカルIPを設定
        set_real_ip_from 192.168.0.0/16;
        real_ip_header proxy_protocol;

        access_log /var/log/nginx/access.log main_with_real_ip;

        location / {
            proxy_pass http://localhost:8080; # 後続のアプリケーションに転送
            proxy_set_header X-Real-IP $proxy_protocol_addr;
            proxy_set_header X-Forwarded-For $proxy_protocol_addr;
        }
    }
}

アプローチB:Python(Go/Node.js等)でパケットを直接パースする

「Nginxを挟まず、PythonのAPIサーバーで直接パケットを処理したい」という硬派なシステム向けに、Proxy Protocol v2のヘッダーをパースする簡単なPythonのTCPサーバーコードを用意した。

PPv2のシグネチャは、特定の12バイトのマジックワード(\x0D\x0A\x0D\x0A\x00\x0D\x0A\x51\x55\x49\x54\x0A)で始まる。これを目印に、パケット構造をデコードする。

import socket
import struct

def parse_proxy_protocol_v2(data):
    # Proxy Protocol v2 のシグネチャ (12バイト)
    PPV2_SIGNATURE = b'\x0d\x0a\x0d\x0a\x00\x0d\x0a\x51\x55\x49\x54\x0a'
    
    if len(data) < 16:
        return None, data
    
    if not data.startswith(PPV2_SIGNATURE):
        # PPv2 ヘッダーがない場合は、そのままデータを返す
        return None, data

    # 13バイト目: バージョンとコマンド
    # 14バイト目: ファミリーとプロトコル (AF_INET = 0x11, AF_INET6 = 0x21)
    family_proto = data[13]
    
    # 15-16バイト目: アドレス長 (Big Endian)
    addr_len = struct.unpack('!H', data[14:16])[0]
    header_total_len = 16 + addr_len
    
    if len(data) < header_total_len:
        return None, data # データ不足
    
    addr_data = data[16:header_total_len]
    
    # IPv4 / TCP の場合 (family_proto == 0x11)
    if family_proto == 0x11:
        # 送信元IP (4B), 宛先IP (4B), 送信元ポート (2B), 宛先ポート (2B)
        src_ip_bin, dst_ip_bin, src_port, dst_port = struct.unpack('!4s4sHH', addr_data[:12])
        src_ip = socket.inet_ntoa(src_ip_bin)
        dst_ip = socket.inet_ntoa(dst_ip_bin)
        
        # ヘッダーを除去した残りのアプリケーションデータを返す
        remaining_data = data[header_total_len:]
        return {
            "src_ip": src_ip,
            "dst_ip": dst_ip,
            "src_port": src_port,
            "dst_port": dst_port
        }, remaining_data

    return None, data[header_total_len:]

# 簡易TCPサーバーの起動
def start_debug_server():
    server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    server.bind(('0.0.0.0', 80))
    server.listen(5)
    print("[*] Debug Server listening on port 80 (Expecting Proxy Protocol v2)...")

    while True:
        client_sock, addr = server.accept()
        print(f"\n[*] New Connection from NLB IP: {addr[0]}:{addr[1]}")
        
        raw_data = client_sock.recv(4096)
        pp_info, app_data = parse_proxy_protocol_v2(raw_data)
        
        if pp_info:
            print(f"[+] Proxy Protocol v2 Decoded successfully!")
            print(f"    ├─ Real Client IP: {pp_info['src_ip']}:{pp_info['src_port']}")
            print(f"    └─ Destination IP: {pp_info['dst_ip']}:{pp_info['dst_port']}")
        else:
            print("[-] No Proxy Protocol v2 header detected, or unsupported protocol.")
            
        print(f"    Raw Application Data: {app_data.decode('utf-8', errors='ignore').strip()}")
        
        # HTTPレスポンスを返す
        response = "HTTP/1.1 200 OK\r\nContent-Length: 18\r\n\r\nConnection Success"
        client_sock.sendall(response.encode('utf-8'))
        client_sock.close()

if __name__ == "__main__":
    start_debug_server()

—

5. 現場で役立つトラブルシューティング・チェックリスト

実務でPrivateLinkを構築し、「繋がらない!」と叫んでいるメンバーがいたら、まず以下の3点を確認させてほしい。私が現場で何度も遭遇した「リアルな罠」だ。

① セキュリティグループの「インバウンド」と「アウトバウンド」の非対称性

コンシューマー側のVPCエンドポイント(ENI)に設定するセキュリティグループ。これは「コンシューマーのアプリサーバーからのインバウンド(TCP 80等)」を許可する必要がある。
よくある間違いは、「エンドポイントから外に出ていくからアウトバウンド(Egress)を開ければいい」という勘違いだ。エンドポイントのENIは、コンシューマー側から見れば「外へパケットを吸い込むインターフェース(宛先)」なので、インバウンドの許可が必要になる。

② NLBのターゲットグループにおける「ヘルスチェック」の宛先

NLBのターゲットグループでProxy Protocol v2を有効化すると、NLBからターゲットへのヘルスチェックパケットにもProxy Protocol v2ヘッダーが付与される。
もし、ターゲットサーバー側で「ポート80はPPv2をパースするが、ヘルスチェック用のポート8080はPPv2非対応の普通のHTTPサーバー」といった中途半端な実装をしていると、NLBからのヘルスチェックがすべて失敗し、ターゲットが Unhealthy になって通信が一切疎通しなくなる。ヘルスチェック用のエンドポイントも、PPv2を正しく処理できる(またはPPv2を要求しない別ポートでヘルスチェックを行う)ように設計すること。

③ DNS解決(Private DNS)の罠

PrivateLinkを構築すると、コンシューマー側でエンドポイントにアクセスするための長いDNS名(例: vpce-xxxxxx.ap-northeast-1.vpce.amazonaws.com)が発行される。
プロバイダー側が公開している「本来のドメイン(例: api.mycompany.com)」のままコンシューマーにアクセスさせたい場合は、コンシューマー側のVPCエンドポイントの設定で “Private DNS” を有効化 する。これを有効にすると、AWSのRoute 53 Resolverが、そのVPC内でのみ api.mycompany.com の問い合わせに対してエンドポイントENIのプライベートIPを返すようになる。これが有効になっていないと、パケットはパブリックなインターネット経由でルーティングされようとし、PrivateLinkを一切通らない。

—

まとめ:ネットワークの境界をエレガントに超えよう

AWS PrivateLinkは、セキュアで独立したネットワークトポロジーを維持したまま、特定のサービスだけを疎結合かつ高速に連携させることができる、極めて強力なアーキテクチャだ。

しかし、その裏側では、今回解説したような「Hyperplaneによるカプセル化」「L4 ProxyによるソースIPの変換」「Proxy Protocolによる接続元の伝播」といった、ネットワークの基礎技術が緻密に組み合わさって動いている。

これらの仕組みを「ブラックボックス」のままにしておくと、いざ障害が起きたときや、セキュリティ要件の厳しい商用環境にデプロイしたときに、一歩も前に進めなくなってしまう。

パケットの挙動を正しく理解し、Terraformで再現可能なインフラを組み、コードレベルでヘッダーを制御する。この一連のフローをマスターすれば、君のインフラエンジニアとしての戦闘力は確実にワンランク上がるはずだ。

もし構築中に謎の挙動に出会ったら、迷わずEC2を1台立てて tcpdump を回し、Rawなパケットを眺めてみてほしい。答えは常に、パケットの中に書いてある。

それでは、また次の現場(か、障害対応のチャットルーム)で会おう!

コメント

タイトルとURLをコピーしました