こんにちは、シニアSREの私だ。夜な夜なPagerDutyのアラートに飛び起き、tcpdumpの出力結果をにらみつけながらパケットの息遣いを聞く……そんな泥臭い日々を送っている君なら、きっと一度は経験があるはずだ。
「あれ、特定のマイクロサービスから外部APIへのリクエストだけが、なぜか時々タイムアウトするぞ?」
GCP(Google Cloud)上でKubernetes(Google Kubernetes Engine: GKE)を運用したり、高負荷なWeb APIサーバー群を構築したりしていると、ある日突然、この怪奇現象に直面する。ログを漁ってもアプリケーションコードには異常なし。相手方のAPIサーバーが落ちているわけでもない。
犯人は、大抵の場合 Cloud NATのポート枯渇(Port Exhaustion) だ。
今回は、この厄介なポート枯渇のメカニズムを解き明かし、Cloud NATの「最小ポート数」と「タイマー設定」をどうチューニングすれば、パケットドロップの恐怖から解放されるのか、実務に直結するノウハウを余すところなく伝授しよう。
—
1. なぜポートが枯渇するのか? Cloud NATの裏側とRFCの現実
まず、パケットがネットワークを駆け巡るリアルな挙動を想像してほしい。
VPC内のプライベートIPアドレスを持つVMインスタンス(あるいはGKEのPod)が、インターネット上の外部APIへリクエストを飛ばすとき、グローバルIPを持たないそれらのインスタンスは、Cloud NAT(SNAT: Source Network Address Translation)によって、Cloud NATゲートウェイの持つ外部IPアドレスへと変換される。
ここでTCP/IPの基本原理を思い出してほしい。TCPコネクションが一意に識別されるのは、以下の「5タプル(5-tuple)」の組み合わせによってだ。
1. 送信元IPアドレス(VMのプライベートIP)
2. 送信元ポート番号
3. 宛先IPアドレス(外部APIのIP)
4. 宛先ポート番号(通常は 443 など)
5. プロトコル(TCP)
Cloud NATは、VMからのアウトバウンド通信を受け取ると、送信元IPとポートを「Cloud NATの外部IP」と「動的に割り当てたNATポート」に書き換える。ここで問題になるのが、「1つの外部IPアドレスと宛先IP/ポートのペアに対して、利用できる送信元ポートの数には物理的な限界(最大65,535)がある」という事実だ。
デフォルト設定のままでいると、GCPはVMインスタンスごとの最小ポート数(Min ports per VM instance)を「64」といった非常に控えめな数値に初期設定している。
もし、ひとつのVM上で動くアプリケーションが、外部の決済APIやSaaSに対して一斉に大量のHTTPリクエストを非同期で投げたらどうなるか? わずか数秒で64個のポートは使い果たされ、新規のTCPハンドシェイク(SYNパケット)はCloud NATで容赦なくドロップ(パケットドロップ)される。これがポート枯渇の正体だ。
—
2. 最小ポート数(Min ports per VM instance)の設計と罠
この状況を打破するため、最初に手を入れるべきパラメータが 「VMインスタンスごとの最小ポート数」 である。
GCPのCloud NATを作成・編集する際、高度な設定(Advanced configurations)の中にこの項目が存在する。デフォルトの 64 から、例えば 1024 や 2048、あるいは大規模なワークロードであればそれ以上に引き上げることが可能だ。
どのくらいのポート数を割り当てるべきか?
計算式はこうだ。
$$\text{必要なポート数} = \text{秒間リクエスト数 (QPS)} \times \text{平均コネクション持続時間 (秒)}$$
例えば、あるVMが外部APIへ向けて毎秒100リクエストを処理し、それぞれのTCPコネクションが平均して2秒間生き続ける(あるいはTCPのTIME_WAIT状態に留まる)場合、
$$100 \times 2 = 200 \text{ポート}$$
が常に消費される計算になる。ここにバースト耐性を考慮し、最低でもデフォルトの数倍、実務では 1024 ポート以上を初期値として検討するのがシニアとしての定石だ。
しかし、ここで一つ重要な注意点がある。ポート数をやみくもに増やせばいいというわけではない。
Cloud NATが1つの外部IPあたりに保持できるNATポートの総数には上限(通常は64,000ポート程度)がある。1つのVMに過剰な最小ポート数を割り当てると、そのNATゲートウェイ配下で稼働できるVMの総数が制限されてしまうというトレードオフが発生するのだ。
—
3. タイムアウト設定の最適化:FIN/RSTとTCPタイマーの魔術
最小ポート数の引き上げと並行して必ず実施すべきなのが、タイマー設定のチューニングである。
TCPコネクションが閉じられた後、OSやルーターはそのポートをすぐには再利用せず、迷子になった古いパケット(ロストパケット)が後から届いて誤動作を起こすのを防ぐために、一定期間ポートを「クールダウン(TIME_WAIT)」状態に置く。
GCPのCloud NATでは、このコネクション状態ごとのタイムアウト時間を明示的に変更できる。
- TCP established idle timeout(確立されたTCPコネクションのアイドルタイムアウト): デフォルトは1200秒(20分)だが、Web API通信などでは長すぎるケースが多い。
- TCP TIME_WAIT timeout: デフォルトは120秒。
- TCP transitory idle timeout(FIN/RST受信後のアイドルタイムアウト): デフォルトは30秒。
特に、Web APIクライアントが短いスパンで頻繁にリクエストを送り直すアーキテクチャの場合、これらのタイマーが長すぎると、すでに役目を終えたTCPコネクションの残骸がポートを占有し続け、ポート枯渇を加速させる。
推奨されるカスタムタイマー設定の例
一般的なREST APIやマイクロサービス間通信を想定した場合、以下のようにタイマーを短縮することで、ポートの回転率(Turnover rate)を劇的に向上させることができる。
TCP established idle timeout: 120秒 (アイドル状態のコネクションを早めに切断)TCP transitory idle timeout (FIN/RST): 30秒 (終了シーケンス後のパージを迅速化)
—
4. 実践:GCP CLI (gcloud) によるCloud NATの最適化設定
口で言うだけではなく、実際にインフラをコード(あるいはコマンド)でどう構築するかを示そう。
以下の gcloud コマンドは、既存のCloud NATルーターに対して、最小ポート数を 2048 に引き上げ、各種タイムアウト時間を最適化した実戦仕様の設定スクリプトだ。
# 環境変数の定義
REGION="asia-northeast1"
ROUTER_NAME="my-production-router"
NAT_CONFIG_NAME="my-nat-config"
# Cloud NATの最小ポート数とタイムアウト設定をアップデートする
gcloud compute routers update-nat ${ROUTER_NAME} \
--region=${REGION} \
--nat-name=${NAT_CONFIG_NAME} \
--min-ports-per-vm=2048 \
--enable-dynamic-port-allocation \
--tcp-established-idle-timeout=120 \
--tcp-transitory-idle-timeout=30 \
--udp-idle-timeout=30
> 💡 シニアからのワンポイントアドバイス:
> 上記のコマンドで使用している --enable-dynamic-port-allocation は、GCPの非常に強力な機能だ。これを有効にすると、静的に固定された最小ポート数を割り当てるのではなく、VMのトラフィック量に応じて動的にポート数がスケール(最大数まで自動拡張)するようになる。ポート枯渇を防ぎつつ、無駄なポート占有を防ぐため、今のモダンなインフラ設計では必須のオプションと言える。
—
5. アプリケーション側からのアプローチ:Keep-Aliveの重要性
インフラ側のチューニングと同時に、アプリケーション層(Python、Node.js、Go、あるいはcurl等)の設計も見直す必要がある。
最悪なのは、「リクエストを送るたびに毎回新しいTCPコネクションを張って(3-way handshake)、リクエストが終わったら即座にFINを送って切断する」という実装だ。これではいくらCloud NATをチューニングしても、瞬時にポートが枯渇する。
対策:HTTP Keep-Alive(コネクションプーリング)の活用
TCPのコネクションを維持し、同じコネクション上で複数のHTTPリクエストをマルチプレックス(あるいは順次送信)させる「HTTP Keep-Alive」を必ず有効にすること。
例えば、Pythonの requests ライブラリを使用する場合、Session オブジェクトを使い回すことで自動的にコネクションプールが維持される。
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
def create_robust_session():
"""
コネクションプーリングとリトライロジックを組み込んだ堅牢なセッションを生成する。
毎回新しいTCPコネクションを張るのを防ぎ、Cloud NATのポート枯渇を抑制する。
"""
session = requests.Session()
# リトライ戦略の設定(500番台やネットワークエラーに対する備え)
retries = Retry(
total=3,
backoff_factor=0.5,
status_forcelist=[500, 502, 503, 504],
raise_on_status=False
)
# アダプターを設定(プールサイズを大きめに確保)
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(endpoint_url: str, payload: dict):
try:
# Keep-Aliveにより既存のTCPコネクションが再利用される
response = api_client.post(endpoint_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ハンドシェイクの発生頻度が劇的に下がり、Cloud NATのポート消費を最小限に抑えることができる。
—
まとめ
Cloud NATのポート枯渇は、クラウドインフラの裏側で起きているパケットの物理的な制約を理解していないと、原因究明に何時間も費やしてしまう厄介なトラブルだ。
今日の学びを振り返ろう:
1. メカニズムを知る: 5タプルとCloud NATの仕組みを理解し、ポートがどこで消費されているかを意識する。
2. 最小ポート数の見直し: デフォルトのまま放置せず、QPSとコネクション寿命から逆算して適切な数値を設定する(可能なら動的ポート割り当てる)。
3. タイマーの短縮: TIME_WAIT やアイドルタイムアウトを適切に短くし、ポートの回転率を上げる。
4. アプリ側のマナー: HTTP Keep-Alive(コネクションプーリング)を徹底し、無駄なコネクション張替えをなくす。
インフラとアプリケーションが両輪となって初めて、堅牢なクラウドシステムは成り立つ。この記事が、君のシステムのネットワークをより強靭にするための羅針盤となれば幸いだ。さあ、今すぐコンソールを開き、設定を確認してみよう!
コメント