【実務・中級編】 NATゲートウェイを経由するTCPセッションの最大コネクション数とSNATポート枯渇問題 – クラウドインフラと仮想化ネットワーク実践ガイド

こんにちは。クラウドのインフラやKubernetesのネットワーク周りを中心に、数々の修羅場をくぐり抜けてきたシニアSREの私です。

プロダクトが成長し、マイクロサービスが外部のSaaSや決済APIを叩きまくるようになると、ある日突然、見慣れない Connection Timed Out のエラーログがSentryやDatadogに押し寄せるようになります。アプリケーションコードは一文字も変えていない、相手方のAPIサーバーも生きている。なのに、なぜか通信がパタリと途絶える――。

この怪奇現象の犯人のほとんどは、AWSのNATゲートウェイ(NAT Gateway)における「SNATポート枯渇」です。

今回は、パケットがAWSの仮想ネットワーク空間をどう駆け巡り、なぜポートが枯渇するのか、そしてそれをどう回避すべきかについて、実務の現場で培った知見を総動員して徹底的に解説します。

—

1. なぜSNATポートは枯渇するのか?――パケットの旅路と基本原則

プライベートサブネットに配置されたEC2インスタンスやECSタスクから、インターネット上の外部APIへリクエストを飛ばすとき、パケットは必ずNATゲートウェイを経由します。

このとき、NATゲートウェイは何をしているでしょうか? そう、SNAT(Source Network Address Translation:送信元ネットワークアドレス変換)です。プライベートIPアドレス(例: 10.0.1.100)を、NATゲートウェイ自身が持つパブリックIPアドレス(例: 54.238.x.x)に書き換えてインターネットへ送り出しています。

55,000ポートの壁とRFCの厳格な縛り

ここで問題になるのが、TCP/IPの仕様です。TCP通信を識別するために、OSやルーターは「4タプル(送信元IP、送信元ポート、宛先IP、宛先ポート)」という組み合わせを使います。

AWSのNATゲートウェイは、1つの宛先IPアドレス(Destination IP)に対して、最大55,000個の一時ポート(Ephemeral Ports)を割り当ててSNATを行います。つまり、「同一の宛先IPアドレス」に対する同時アクティブTCPコネクション数は、理論上最大55,000が限界となります。

> SREの現場メモ:
> 「えっ、55,000もあるなら十分じゃないか」と思いましたか? しかし、特定の巨大なクラウドサービスやCDN(例えば、同一のIP群を共有するAWSの他サービスや外部SaaS)に対して数千個のコンテナが一斉にリクエストを撃ち込むと、この55,000ポートは驚くほど一瞬で食い潰されます。

—

2. 枯渇時の通信フローと「TIME_WAIT」の悪夢

SNATポートが枯渇したとき、NATゲートウェイやクライアント側で何が起きているのか。その通信のシーケンスと挙動を紐解きます。

通常、TCPコネクションが切断されると、パケットの取りこぼしを防ぐために TIME_WAIT ステートという冷却期間が一定時間(Linuxのデフォルトでは通常60秒)存在します。この間、そのポートは再利用できません。

枯渇エッジケースのシーケンス

[プライベートEC2 / ECS]               [AWS NAT Gateway]              [外部APIサーバー]
        |                                     |                               |
        |---- (TCP SYN: 55,001個目) --------->|                               |
        |     ※すでにポート枯渇               |                               |
        |                                     |-- (破棄 / Drop)               |
        |                                     |                               |
        |<- (RST または 応答なし) ------------|                               |
        |                                                                     |
   [Connection Timed Out が発生!]

ポートが枯渇した状態で新しいTCP接続を要求すると、NATゲートウェイは新しいポートを割り当てられず、パケットをドロップするか、接続確立に失敗します。アプリケーション側は、コネクションプールのタイムアウト値に達するまでフリーズし、最終的に Connection Timed Out を吐き出すのです。

—

3. 実務で使える回避策とコード実装例

この問題に対するアプローチは主に3つあります。
1. HTTP/1.1の持続的接続(Keep-Alive)の徹底によるコネクションの再利用
2. 複数のNATゲートウェイによるトラフィックの分散(マルチAZ配置)
3. アプリケーション層での適切なタイムアウト・リトライ制御

ここでは、開発現場で真っ直ぐに効果が出る、アプリケーションコード(Python / Node.js)とHTTPクライアントの設定例を紹介します。

実装例1: Python (requests / urllib3) でのコネクションプールとKeep-Aliveの維持

毎回 requests.get() を呼び出すと、デフォルトでは新しいTCPコネクションが毎回張られ(あるいはすぐに閉じられ)、ポートを無駄に消費します。requests.Session を用いてコネクションを維持(Keep-Alive)するのが鉄則です。

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

def create_robust_session():
    session = requests.Session()
    
    # リトライ戦略の設定(ネットワーク一時断への備え)
    retries = Retry(
        total=3,
        backoff_factor=0.5,
        status_forcelist=[500, 502, 503, 504],
        raise_on_status=False
    )
    
    # 接続プールのサイズを明示的に定義し、TCPコネクションを張ったまま再利用する
    # pool_maxsizeを適切に設定することで、無駄なSYN/FINパケットの往復を防ぐ
    adapter = HTTPAdapter(
        pool_connections=50, 
        pool_maxsize=100, 
        max_retries=retries
    )
    
    session.mount("https://", adapter)
    session.mount("http://", adapter)
    
    return session

# グローバルまたはシングルトンとしてセッションを保持して使い回す
api_client = create_robust_session()

def call_external_api(payload):
    url = "https://api.example.com/v1/data"
    try:
        # Keep-Aliveにより既存のTCPコネクションが再利用されるため、SNATポートを消費しにくい
        response = api_client.post(url, json=payload, timeout=3.0)
        response.raise_for_status()
        return response.json()
    except requests.exceptions.RequestException as e:
        # ログ出力とサーキットブレーカーへの通知など
        print(f"API呼び出しに失敗しました: {e}")
        raise

実装例2: Node.js (Fetch API / Node 18+) でのエージェント設定

Node.js環境でも同様に、HTTPエージェントが持つKeep-Aliveの寿命や最大ソケット数をチューニングする必要があります。

import http from 'http';
import https from 'https';

// グローバルなエージェントでKeep-Aliveを有効化し、ソケットをプールする
const keepAliveAgent = new https.Agent({
  keepAlive: true,
  maxSockets: 100,          // ホストごとの最大同時ソケット数
  maxFreeSockets: 10,       // アイドル状態で維持する最大ソケット数
  timeout: 60000            // ソケットのアイドルタイムアウト (ms)
});

async function callExternalApiNode(payload) {
  const url = 'https://api.example.com/v1/data';
  
  try {
    const response = await fetch(url, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(payload),
      // Node.jsのfetchでカスタムエージェントを渡す場合 (undiciベースの場合はdispatcherを使用)
      agent: keepAliveAgent 
    });

    if (!response.ok) {
      throw new Error(`HTTP Error: ${response.status}`);
    }

    return await response.json();
  } catch (error) {
    console.error('SNATポート枯渇またはネットワークエラーの可能性:', error);
    throw error;
  }
}

—

4. インフラ・SRE視点でのデバッグと根本対策

コード側でコネクションを最適化してもなお、バースト的なトラフィック(例えば、毎朝9時のバッチ処理など)によってSNATポートが枯渇する場合は、インフラストラクチャ側のアーキテクチャで殴り倒す必要があります。

1. CloudWatchメトリクスでの監視

AWSの AWS/NATGateway 名前空間にある ErrorPortAllocation メトリクスを必ずCloudWatchアラームに組み込んでください。

  • ErrorPortAllocation が 0 より大きい値を示した場合:それはまさに今、SNATポートが枯渇してパケットがドロップしている決定的な証拠です。

2. パブリックIP(NATゲートウェイ)のスケールアウト

AWSのNATゲートウェイ自体は、単一のIPで55,000ポートの制限を受けますが、異なるAZに複数のNATゲートウェイを配置し、ルートテーブルを適切に分割することで、利用可能なポート数を単純に倍増(2つなら11万ポート、3つなら16.5万ポート)させることができます。

さらに、AWSが提供する機能として、単一のNATゲートウェイに複数のElastic IP(EIP)を割り当てることはできませんが、複数のNATゲートウェイを並列稼働させることで、プライベートサブネットからのトラフィックを負荷分散させることが王道のアーキテクチャとなります。

—

まとめ

NATゲートウェイのSNATポート枯渇は、クラウドインフラストラクチャとアプリケーションの挙動が複雑に絡み合う、非常に見つけにくい厄介なトラブルです。「コードが動いているから大丈夫」ではなく、「そのTCPコネクションは本当に再利用されているか?」「宛先IPあたりの同時接続数が55,000を超えていないか?」という視点を常に持ち、コネクションプールの適切なチューニングと監視を怠らないようにしましょう。

現場からは以上です。皆さんのシステムから Connection Timed Out のエラーログが綺麗に消え去ることを祈っています!

コメント

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