【実務・中級編】 GCP Cloud NATのポートパブリッシュ、SNAT、およびポートオーバーサブスクリプションの回避策 – クラウドインフラと仮想化ネットワーク実践ガイド

こんにちは。インフラの裏側でうごめくパケットの息遣いにいつも心を躍らせているシニアSREの私です。

Webアプリケーションのモダナイゼーションが進み、多くのマイクロサービスがGCP(Google Cloud)上のプライベートGKEクラスターや内部VPCに閉じ込められるようになりました。セキュリティの観点から「外部から直接アクセスさせない(プライベートIPのみ)」という設計はもはや鉄則です。

しかし、ここで一つ大きな壁が立ちふさがります。「プライベートな子たちに、どうやって安全に外の世界(外部APIやSaaS)を歩かせるか」という問題です。

今日は、GCPの黒衣(くろご)として黙々とパケットの翻訳をこなす Cloud NAT に焦点を当て、その心臓部であるSNAT(Source NAT)、ダイナミックポート割り当てのメカニズム、そして本番環境で突如牙をむく「ポート枯渇(ポートオーバーサブスクリプション)」の回避策について、現場の泥臭い知見を交えて徹底解説します。

—

1. Cloud NATが裏で行っていること:SNATとパケットの書き換え劇

プライベートIPアドレス(例: 10.128.0.5)を持つCompute EngineやGKEのポッドが、インターネット上の外部API(例: StripeやSendGrid)へリクエストを投げるとします。

RFC 1918で定められたプライベートIPアドレスは、グローバルインターネットの世界ではルーティングできません。そのまま外に出そうものなら、ISPのルーターで即座にパケットは捨てられます。そこで登場するのが、VPCネットワークのエッジに位置するCloud NATです。

通信の裏側で何が起きているのか?

パケットがプライベートVMから外部へ飛び出すとき、Cloud NATは以下の魔術的な書き換えを行っています。

1. アウトバウンド(往路):

  • 送信元IP/ポート: 10.128.0.5:45123
  • 宛先IP/ポート: 203.0.113.50:443(外部API)
  • Cloud NATの介入: 送信元IPを、Cloud NATに割り当てられたグローバルIPアドレス(例: 34.120.10.20)に、送信元ポートをCloud NATが管理するエフェメラルポート(例: 32768)に書き換えます。
  • 書き換え後: 送信元IP/ポートは 34.120.10.20:32768 になり、インターネットへ旅立ちます。

2. インバウンド(復路):

  • 外部APIからのレスポンスは 34.120.10.20:32768 宛てに戻ってきます。
  • Cloud NATは自身のステートフルなコネクションテーブルを参照し、「あ、これはあの時の 10.128.0.5:45123 の通信だな」と即座に逆変換を行い、元のプライベートIPとポートに戻してVMへ届けます。

この仕組みを SNAT(Source Network Address Translation) と呼びます。Cloud NATは、この変換を完全に分散化されたソフトウェア定義ネットワーク(SDN)のハードウェアアクセラレーション層で処理しています。そのため、従来のトラディショナルなNATゲートウェイのような「単一障害点(SPOF)」や「明確なスループットのボトルネック」が存在しないのがGoogle Cloudの恐ろしいところです。

—

2. 動的ポート割り当てアルゴリズムとポートオーバーサブスクリプション

Cloud NATの運用で最も頭を悩ませるのが、「利用可能なNATポートの枯渇」です。これを理解するには、GCPがどのようにポートをVMに割り当てているかを知る必要があります。

動的ポート割り当て(Dynamic Port Allocation)

デフォルト(または設定による)では、Cloud NATは必要に応じて各VMインスタンスに動的にポートを割り当てます。
GCPは、1つのプライベートIP(VM)に対して、初期値として一定数の最小ポート(例: 64 ポートなど)を割り当て、トラフィックが増加すると自動的に追加のポートブロック(最大数百〜数千ポート)を貸し出します。

ここで計算式を考えてみましょう。
TCP/IPの仕様上、1つの送信元IP(Cloud NATのグローバルIP)から、同一の「宛先IP + 宛先ポート」へ接続できる組み合わせには限界があります。しかし実務では、1つのVMから外部の特定Web APIに対して、膨大な数の並行リクエスト(HTTP Keep-Aliveを使ったコネクションプールなど)を飛ばすことがあります。

恐るべき「ポートオーバーサブスクリプション」とは

もし、1つのVMや、同一NATゲートウェイ配下の複数VMが、短時間にあまりにも多くの外向きコネクションを確立し、Cloud NATが保持できるポートプールを使い果たしてしまうとどうなるでしょうか?

これが ポートオーバーサブスクリプション(ポート枯渇) です。

現場でこれが発生すると、アプリケーションログには以下のような無慈悲なエラーが踊ります。

  • Python (requests / urllib3): Max retries exceeded with url: ... (Caused by NewConnectionError('<urllib3.connection.HTTPSConnection object>: Failed to establish a new connection: [Errno 99] Cannot assign requested address'))
  • Node.js (Fetch API / Axios): EADDRNOTAVAIL: address not available
  • Linuxシステム (dmesg / journalctl): nf_conntrack: table full, dropping packet または TCP: outgoing connections are throttled

パケットレベルで見ると、VMは外へ行きたがっているのに、Cloud NATが「おいおい、もう割り当てられるポートの空きがないよ!」と、SYNパケットの転送を拒絶(あるいはドロップ)している状態です。

—

3. 現場で使える!ポート枯渇を防ぐための実務的対策とパラメーター設計

この悪夢を防ぐために、SREやインフラエンジニアが打つべき具体的な手を解説します。

対策A: Cloud NATの「最小ポート数」と「最大ポート数」のチューニング

デフォルトのままで大規模なトラフィックを捌こうとすると、ポートの動的割り当て・解放の追従が遅れ、バーストトラフィック時に枯渇します。Terraform等でCloud NATを構築する際は、アプリケーションの性格に合わせて明示的にポート数をチューニングします。

以下は、Terraformでの堅牢なCloud NAT設定のサンプルです。

# TerraformによるCloud NATの設定例(ポート枯渇対策を意識したチューニング)
resource "google_compute_router_nat" "advanced_nat" {
  name                               = "production-nat-config"
  router                             = google_compute_router.region_router.name
  region                             = google_compute_router.region_router.region
  nat_ip_allocate_option             = "MANUAL_ONLY"
  nat_ips                            = [google_compute_address.nat_ip.self_link]

  source_subnetwork_ip_ranges_to_nat = "ALL_SUBNETWORKS_ALL_IP_RANGES"

  # 動的ポート割り当ての有効化
  enable_dynamic_port_allocation     = true
  
  # 1インスタンスあたりの最小ポート数を多めに確保(デフォルトより大きめに設定)
  min_ports_per_vm                   = 256
  
  # トラフィック急増時に自動拡張する最大ポート数
  max_ports_per_vm                   = 2048

  # コネクションタイムアウトの短縮(アイドル状態のコネクションを早めに掃除する)
  tcp_established_idle_timeout_sec   = 120 # デフォルトは1200秒だが、短くしてポートを回収する
  tcp_time_wait_timeout_sec          = 30
}

> SREの知見: tcp_established_idle_timeout_sec をデフォルトの1200秒(20分)から大幅に短縮(例: 120秒)することで、アプリケーションが使い終わったコネクションのポートをCloud NATが迅速に回収し、プールへ戻してくれます。ただし、アイドル状態の長寿なWebソケットやDBコネクションを使っている場合は注意が必要です。

対策B: アプリケーション層でのコネクションプールの適正化(コード例)

インフラ側でどれだけポートを増やしても、アプリケーション側が「使い捨ての雑な通信」を繰り返していれば、いずれポートは枯渇します。特にサーバーレス(Cloud Runなど)やコンテナ環境では顕著です。

Pythonの requests や urllib3 を使う際、セッションを使い回さずに毎回新しいインスタンスを作ると、TCPの TIME_WAIT 状態と相まって瞬く間にポートが蒸発します。

以下は、コネクションプールとKeep-Aliveを正しく活用したPythonのコード例です。

import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry

def create_robust_session():
    """
    ポート枯渇を防ぎ、堅牢に外部APIへリクエストを送るための
    コネクションプール設定済みセッションを生成する
    """
    session = requests.Session()

    # リトライ戦略の定義(一時的なエラーに対して指数バックオフを実施)
    retries = Retry(
        total=3,
        backoff_factor=1,
        status_forcelist=[500, 502, 503, 504],
        raise_on_status=False
    )

    # HTTPAdapterでプールサイズを明示的に制御
    # pool_maxsizeを適切に設定し、同一接続先へのソケットを再利用(Keep-Alive)する
    adapter = HTTPAdapter(
        pool_connections=50, # プール自体の保持数
        pool_maxsize=50,     # 同時接続の最大プールサイズ(ポートの乱立を防ぐ)
        max_retries=retries
    )

    session.mount("https://", adapter)
    session.mount("http://", adapter)
    
    return session

# グローバルにセッションを保持して使い回す
api_session = create_robust_session()

def call_external_api(payload: dict):
    url = "https://api.example.com/v1/data"
    try:
        # 新規TCPハンドシェイクを毎度行わず、確立済みのコネクションを再利用する
        response = api_session.post(url, json=payload, timeout=5.0)
        response.raise_for_status()
        return response.json()
    except requests.exceptions.RequestException as e:
        # ログにエラーを詳細に吐き出す
        print(f"APIリクエストに失敗しました: {e}")
        raise

この実装により、TCPの3ウェイハンドシェイクのオーバーヘッドが削減されるだけでなく、Cloud NATが消費するエフェメラルポートの数も劇的に節約されます。

対策C: NAT用グローバルIPアドレスの追加(スケールアウト)

どれだけチューニングしても、1台のNATゲートウェイ(Cloud Router)が持つグローバルIPの数には物理的な上限があります(最大64個のIPアドレスを1つのNATにアタッチ可能)。

もし、システム全体のトラフィックが単一のNAT IPの処理能力(およびポート数)を超えた場合は、Cloud NATに割り当てるIPアドレスを追加します。

# GCP CLI (gcloud) を使って既存のCloud NATに追加のIPアドレスを割り当てる手順
# 1. 新規の外部静的IPアドレスを予約
gcloud compute addresses create nat-ip-secondary-01 \
    --region=asia-northeast1 \
    --network-tier=PREMIUM

# 2. 既存のCloud NATルーターの設定を更新し、IPアドレスを追加する
gcloud compute routers nats update production-nat-config \
    --router=region-router \
    --region=asia-northeast1 \
    --nat-ips=nat-ip-primary,nat-ip-secondary-01

GCPのCloud NATは、複数のNAT IPが割り当てられている場合、それらをラウンドロビンやハッシュに基づいて自動的に負荷分散し、利用可能なポートプールを拡張してくれます。

—

4. トラブルシューティング:今まさにポート枯渇が起きているときのデバッグ手順

深夜のインシデント対応で、「外部APIへの接続エラーが頻発している!」というアラートを受け取った際、SREがどのような手順で原因を切り分けるべきか、実務の現場のフローを共有します。

1. Cloud Loggingの確認:

  • Cloud NATのログ(ログの有効化が必要)で、OUT_OF_RESOURCES やパケット破棄のログが出ていないか確認します。
  • Google Cloudのメトリクスエクスプローラで nat/allocated_ports や nat/used_ports のグラフが上限に張り付いていないかチェックします。

2. 対象VM内でのソケット状態の確認:

  • 問題の起きていそうなVMにSSH(またはIAPデスクトップ)でログインし、以下のコマンドで TIME_WAIT や SYN_SENT の状態が異常に溜まっていないかを調べます。
# 現在のTCPコネクションの状態をカウントして一覧化する
ss -s

# 外部への接続でTIME_WAITが大量発生していないか確認
ss -tan 'sport = :http or sport = :https' | grep TIME-WAIT | wc -l

3. 一時的な応急処置:

  • 緊急避難として、Cloud NATの設定で min_ports_per_vm の値を引き上げるか、追加の nat_ips をアタッチすることで、即座にポートプールを拡充してトラフィックを受け受け付けられる状態に戻します。

—

まとめ

Cloud NATは、「動いて当たり前」のインフラコンポーネントとして見過ごされがちですが、その裏側では精緻なポート管理とSNATの変換劇が繰り広げられています。

「プライベートIPだから安全」という設計の裏には、必ず「出口」のキャパシティ設計という責任が伴います。ポートオーバーサブスクリプションのメカニズムを正しく理解し、適切なTerraformでのパラメーター設計と、アプリケーション層でのコネクションプールの最適化を両輪で回すこと。これこそが、予測不可能なバーストトラフィックを涼しい顔して乗り切る、一流のSREの仕事術です。

皆さんのVPCネットワークが、今日もスムーズで淀みのないパケットの流れに満たされていますように。

コメント

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