【実務・中級編】 ポートフォワーディングとポートアドレス変換(NAPT/PAT)のポート枯渇問題 – クラウド&コンテナネットワーク実践ガイド

お疲れ様です。今日も本番環境のメトリクス監視や、マイクロサービスの分散トレースを追いかける仕事に追われているでしょうか。

今回は、クラウドインフラを運用する上で、ある日突然、前触れもなく牙をむく「SNAT(Source Network Address Translation)ポート枯渇問題」について、じっくりと腰を据えて語り合おうと思います。

「コンテナを増やしてリクエストの処理能力を上げたはずなのに、なぜか外部APIへのリクエストが Connection Timeout で全滅する」
「特定の時間帯だけ、バックエンドから外部決済ゲートウェイへの通信がパケ死(パケットドロップ)している」

こうしたトラブルの裏には、ほぼ間違いなくNAPT(Network Address Port Translation)のポート上限という「物理的な壁」が存在します。教科書的なネットワーク解説書には「NATゲートウェイを置けばプライベートサブネットからインターネットに出られます」としか書かれていませんが、実務の最前線ではその裏で動くプロトコルの挙動、特にL4(TCP/UDP)のポート管理テーブルの挙動をミリ秒単位で理解しておく必要があります。

今回は、RFCの仕様、パケットの遷移図、そしてアプリケーションコードにおける接続プーリング(Keep-Alive)の実装例まで、網羅的にこの問題を解剖していきます。準備はいいですか? それでは始めましょう。

—

1. NAPT (PAT) の基本メカニズム:なぜ「ポート」が足りなくなるのか?

まず、私たちが日常的に利用しているAWSの「NAT Gateway」やGCPの「Cloud NAT」が、裏側で何をやっているかをおさらいします。これらは技術的にはNAPT(Network Address Port Translation)、あるいはCisco用語で言うところのPAT(Port Address Translation)と呼ばれる技術です。

プライベートサブネット内にある何百台もの仮想マシンやコンテナ(送信元IP:10.0.0.0/16 など)が、たった1つのパブリックIPアドレス(例:203.0.113.50)を経由してインターネット上の外部API(例:198.51.100.20:443)と通信できるのは、このNAPTが送信元ポート番号を動的に書き換えてマッピングしているからに他なりません。

5タプル(5-Tuple)による通信の一意性

IPネットワークにおいて、1つのコネクション(接続)は以下の5つの要素(5タプル)によって一意に識別されます。

1. 送信元IPアドレス (Source IP)
2. 送信元ポート番号 (Source Port)
3. 宛先IPアドレス (Destination IP)
4. 宛先ポート番号 (Destination Port)
5. プロトコル (Protocol: TCP/UDP)

NAPTデバイスは、プライベートIPを持つクライアントから届いたパケットの「送信元IP」を自身の「パブリックIP」に書き換え、同時に「送信元ポート」も自身が管理する空きポートに書き換えます。そして、そのマッピング関係を「NATセッションテーブル」に記録します。

[プライベートIPのコンテナ]
  IP: 10.0.1.5
  Port: 54321 (Ephemeral Port)
       │
       │ (1) 送信: Src=10.0.1.5:54321 -> Dst=198.51.100.20:443
       ▼
[NAT ゲートウェイ (パブリックIP: 203.0.113.50)]
       │
       │ (2) 変換: Src=203.0.113.50:1024 -> Dst=198.51.100.20:443
       │     ※ セッションテーブルに「10.0.1.5:54321 ⇔ 203.0.113.50:1024」を記録
       ▼
[外部APIサーバー (198.51.100.20:443)]

1つのパブリックIPが持つ「65,535」の限界

TCP/UDPのヘッダーにおいて、ポート番号は16ビットのフィールドで定義されています。つまり、指定できるポート番号は 0 から 65,535 までの最大65,536個しかありません(RFC 793 / RFC 768)。

さらに、ウェルノウンポート(0〜1023)などはシステムや特殊な用途のために予約されているため、NATデバイスが動的変換に利用できるポート(Ephemeral Port / 一時ポート)は、実質的に約55,000〜64,000個程度に制限されます(AWS NAT Gatewayの場合は1つのIPアドレスにつき55,000個が上限です)。

「55,000個もあれば十分じゃないか」と思うかもしれません。しかし、ここに大きな罠があります。

もし、あなたのシステムが特定の外部システム(例:同一の決済API 198.51.100.20:443)に対してのみ大量のリクエストを送る場合、5タプルのうち以下の4つが固定されてしまいます。

  • 送信元IP:203.0.113.50(NATのパブリックIP)
  • 宛先IP:198.51.100.20(外部API)
  • 宛先ポート:443
  • プロトコル:TCP

この状況下で、一意のコネクションを識別するために動的に変更できるパラメータは、NAT側の「送信元ポート(最大55,000個)」の1つだけになってしまうのです。

—

2. ポート枯渇(SNAT Port Exhaustion)を招く真の犯人:TIME_WAIT

「秒間数千リクエストも捌いていないから、55,000ポートも消費しないはずだ」

そう考えたエンジニアが、本番環境で「謎の通信タイムアウト」に遭遇して頭を抱えます。ポート枯渇の真の主犯は、リクエストの絶対数ではなく、TCPコネクション切断後に発生する TIME_WAIT 状態の存在です。

TCPクローズシーケンスとTIME_WAITの役割

RFC 793において、TCPコネクションを正常に終了する際、最後にアクティブクローズ(自発的に切断)した側は、接続を完全に破棄する前に一定時間 TIME_WAIT というステート(状態)に留まることが義務付けられています。

Active Close側 (Client/NAT)                  Passive Close側 (Server)
      │                                              │
      ├─────────────────── FIN ─────────────────────►│
      │◄────────────────── ACK ──────────────────────┤
      │                                              │
      │◄────────────────── FIN ──────────────────────┤
      ├─────────────────── ACK ─────────────────────►│
      │                                              │
  [TIME_WAIT]                                    [CLOSED]
  (2 * MSLの間、ポートをロック)
      │
  [CLOSED] (ようやくポート解放)

この TIME_WAIT 状態を維持する期間は 2MSL(Maximum Segment Size) と呼ばれ、OSのデフォルト設定では60秒から120秒(2分)に設定されています。

この期間が必要な理由は主に2つあります。
1. 最後に送信した ACK パケットがネットワーク上で消失した場合に、サーバーからの FIN 再送を受け取って正しく応答するため。
2. ネットワーク上で遅延していた「古いコネクションのパケット(迷子パケット)」が、新しく同じポートで開始された別のコネクションに混入してデータを汚染するのを防ぐため。

恐るべき掛け算

ここに、「毎回接続を使い捨てる(Connection: close)アプリケーション」があるとします。

  • 秒間リクエスト数:500 req/sec
  • TIME_WAIT の保持期間:120秒(Linuxデフォルト設定に多い値)

このとき、NATデバイス上で占有され続けるポート数は、以下の単純な掛け算で求められます。

$$\text{占有ポート数} = 500 \text{ (req/sec)} \times 120 \text{ (sec)} = 60,000 \text{ ポート}$$

NATゲートウェイが持つ上限(55,000)をあっさりと突破しました。この瞬間から、新規のTCP接続はすべてNATデバイスによってサイレントにドロップされ、アプリケーション側には Connection Timeout や Gateway Timeout が多発することになります。これがSNATポート枯渇の正体です。

—

3. クラウド各社における挙動とパラメーター設計

AWSとGCPでは、このNATポートの管理方法や設計思想が大きく異なります。実務で慌てないために、それぞれの挙動を整理しておきましょう。

AWS: NAT Gateway

AWSのManaged NAT Gatewayは非常にシンプルですが、それゆえにエンジニア側での適切な設計が必要です。

  • ポート上限:1つのパブリックIPアドレスにつき、同一宛先(宛先IP + 宛先ポート + プロトコル)に対して55,000個の同時接続。
  • 緩和策:NAT Gatewayに複数のElastic IP (EIP) を関連付ける(最大8個まで拡張可能)。
  • 2つのEIPを割り当てれば、利用可能ポートは $55,000 \times 2 = 110,000$ に倍増します。
  • ただし、接続先(宛先)が1つのIPアドレスの場合、AWSのNAT Gatewayは送信元ポートの選択をラウンドロビン等で行うため、完全に均等分散されるわけではない点に注意が必要です。

GCP: Cloud NAT

GCPのCloud NATは非常に柔軟かつインテリジェントですが、設定ミスによるポート枯渇が起きやすいという側面もあります。

  • 動的ポート割り当て (Dynamic Port Allocation):GCPでは、各VM(Compute EngineやGKEノード)に対して、あらかじめ「最小割り当てポート数(Min ports per VM)」を静的に、または動的に確保します。
  • デフォルト値の罠:デフォルトでは、1つのVMに対して「最低64ポート」が割り当てられます。もし、あるVMが瞬間的に100個の並列外部リクエストを投げようとすると、たとえNAT全体でポートが余っていても、そのVMに割り当てられた枠(64)を超えた時点で即座にポート枯渇(GCP側でドロップ)が発生します。
  • パラメーターの調整:
  • Min ports per VM(最小ポート数):VMの最大同時接続予測数に合わせて、あらかじめ大きめの値(例:1024 や 2048)に設定しておく必要があります。
  • Max ports per VM(最大ポート数)を設定し、動的割り当て(Dynamic Port Allocation)を有効にすることで、負荷に応じてポートを自動で追加割り当てさせることが可能です。

—

4. コードで防ぐ:ポート枯渇を回避する「良いコード」と「悪いコード」

インフラ側のEIPを増やすのは、いわば「絆創膏を貼る」ような対症療法に過ぎません。根本的な解決策は、アプリケーションがTCPコネクションを適切に再利用(Connection Pooling / Keep-Alive)することです。

ここでは、Pythonを用いて、ポートを急激に消費してしまう「悪い例」と、接続を再利用してポートを節約する「良い例」を対比して解説します。

❌ 悪い例:リクエストごとにコネクションを使い捨てる(非推奨)

以下のコードは、ループのたびに新しいTCP接続を確立し、レスポンスを受け取ると即座にクローズします。

import requests
import time

# 外部のモックAPI(例としてJSONPlaceholderを使用)
DEST_URL = "https://jsonplaceholder.typicode.com/posts/1"

def bad_request_loop():
    print("--- 悪い例: コネクション使い捨てパターンの開始 ---")
    for i in range(100):
        # 内部的に毎回 socket() -> connect() -> close() が走る
        # これにより、ループの回数分だけ TIME_WAIT 状態のソケットが量産される
        response = requests.get(DEST_URL, headers={"Connection": "close"})
        if response.status_code == 200:
            print(f"[{i+1:03d}] リクエスト成功")
        time.sleep(0.01)  # 短いウェイトで高頻度にリクエストを送信

if __name__ == "__main__":
    bad_request_loop()

このコードを実行すると、クライアントマシンのローカルポート(および経由するNATのポート)は、リクエストの数だけ TIME_WAIT になり、最大2分間解放されません。

⭕ 良い例:接続プール(Keep-Alive)を有効にしてコネクションを再利用する

HTTP/1.1のデフォルト仕様である Keep-Alive を活用し、同一のTCPコネクションを維持したまま、複数のHTTPリクエストをその上で使い回します。

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

DEST_URL = "https://jsonplaceholder.typicode.com/posts/1"

def good_request_loop():
    print("--- 良い例: 接続プール(Keep-Alive)パターンの開始 ---")
    
    # 1. requests.Session オブジェクトを作成(これが接続プールを管理する)
    session = requests.Session()
    
    # 2. プールサイズとリトライ戦略を定義
    # pool_connections: ホストごとのキャッシュする接続数
    # pool_maxsize: 接続プール内に保持する最大接続数(並行処理を行う場合に重要)
    adapter = HTTPAdapter(
        pool_connections=10, 
        pool_maxsize=20,
        max_retries=Retry(total=3, backoff_factor=0.5)
    )
    
    # HTTPおよびHTTPSの通信に対して、このアダプターを適用
    session.mount("http://", adapter)
    session.mount("https://", adapter)
    
    # 3. セッションを使い回してリクエストを送信
    for i in range(100):
        # 同一ホストへのリクエストであるため、裏側のTCP接続は1つ(または少数)に固定される
        # HTTPヘッダーにはデフォルトで Connection: keep-alive が付与される
        response = session.get(DEST_URL)
        if response.status_code == 200:
            print(f"[{i+1:03d}] コネクション再利用でリクエスト成功")
    
    # 4. 明示的にセッションをクローズ(アプリケーション終了時やコンテキストを抜ける際)
    session.close()

if __name__ == "__main__":
    good_request_loop()

この「良い例」では、100回リクエストを投げても、NAT上で消費されるポートは極論1つ(プール内のアクティブなTCP接続分のみ)です。システム全体の安定性とリソース効率は天と地ほどの差になります。

—

5. インフラとOSレイヤーでのデバッグ・調査手順

もし現在進行形で「ポート枯渇が疑われる現象」に直面しているなら、以下の手順でデバッグを進めてください。

手順 1: クラウドのメトリクスを確認する

まずは推測するのをやめ、クラウドプロバイダが提供する生のメトリクスを確認しましょう。

  • AWS (CloudWatch):
  • 対象リソース:AWS/NATGateway
  • メトリクス名:ErrorPortAllocation
  • 意味:NATゲートウェイが送信元ポートを割り当てられずにプロトコル接続を拒否した回数。これが 0 より大きければ、一発でポート枯渇と断定できます。
  • メトリクス名:ConnectionEstablishmentResolutionTimeout
  • GCP (Cloud Monitoring):
  • 対象リソース:nat_gateway
  • メトリクス名:router.googleapis.com/nat/dropped_packets_count
  • フィルター:reason = "OUT_OF_RESOURCES"(リソース不足によるドロップ)

手順 2: OS(コンテナ/VM)のソケット状態を監視する

サーバー内部にログインできる場合、ss コマンド(または古いシステムなら netstat)を使って、現在どのステートのソケットがどれだけ存在するかを確認します。

# 現在のソケット状態のサマリーを表示
$ ss -s

# 出力例:
# Total: 12542 (kernel 12600)
# TCP:   12040 (estab 40, closed 12000, orphaned 0, synrecv 0, timewait 11980/0), ports 0
#
# ↑ timewait が 11,980 個もあることが一目でわかります。

特定の宛先(例:外部APIサーバーのIP 198.51.100.20)に対する TIME_WAIT の数を確認するには、以下のコマンドを実行します。

$ ss -o state time-wait dst 198.51.100.20 | wc -l

手順 3: OSカーネルパラメータの即効薬(チューニング)

アプリケーションコードの修正にはデプロイやテストが伴うため、障害発生時の「緊急避難」として、OSカーネルパラメータ(sysctl)の調整が有効な場合があります。

Linuxサーバーであれば、以下のパラメータを検討してください。

# TIME_WAIT状態のソケットを、安全基準に適合する範囲で迅速に再利用する設定(Linux 4.1以降推奨)
$ sudo sysctl -w net.ipv4.tcp_tw_reuse=1

# ローカルポート範囲(Ephemeral Port)の拡張
# デフォルトで狭い場合があるため、最大の範囲(1024〜65535)に広げる
$ sudo sysctl -w net.ipv4.ip_local_port_range="1024 65535"

> ⚠️ 注意: 昔のドキュメントによく登場した net.ipv4.tcp_tw_recycle は、NAT環境下(複数の異なるクライアントが1つのIPの裏にいる環境)において、TCPのタイムスタンプが狂うことで通信が完全に遮断される重大なバグを引き起こすため、現代のLinuxカーネルでは非推奨(廃止)になっています。絶対に有効にしないでください。

—

6. まとめ:アーキテクトに求められる「防弾」設計

SNATポート枯渇は、ネットワークエンジニアとアプリケーション開発者の「知識の隙間」に落ちやすい、きわめて厄介な問題です。

この問題を未然に防ぎ、堅牢な(防弾仕様の)インフラを構築するためのチェックリストを以下に示します。

1. HTTP/1.1 Keep-Alive の徹底:
すべての外部APIクライアント、SDK、HTTPライブラリで、接続プーリングがデフォルトで有効になっているかコードレビューで確認する。
2. DNSベースの解決と複数IPの活用:
AWSであれば、大量のトラフィックを処理するNAT Gatewayには最初から2〜3個のElastic IPを紐づけておき、バッファを確保する。
3. プライベート接続の活用 (VPC Endpoint / Private Service Connect):
通信相手が AWS S3、DynamoDB、あるいは GCP Cloud Storage などのマネージドサービスであるなら、NATを経由させる必要は一切ありません。VPCエンドポイントやPrivate Service Connectを構築し、NATを介さないプライベートルートでトラフィックを逃がしましょう。これだけでNATの負荷は激減します。

パケットの挙動を理解し、メトリクスから裏付けを取り、コードで解決する。これができるエンジニアこそが、本番環境の「見えない障害」を解決できる真のプロフェッショナルです。

あなたのシステムのNATメトリクス、最後に確認したのはいつですか? 手遅れになる前に、一度ダッシュボードを覗いてみてください。

コメント

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