【実務・中級編】 TCP接続のTIME_WAIT状態の枯渇原因とソケットオプションチューニング – トラブルシューティング&ネットワーク運用監視実践ガイド

サーバーが突然沈黙する夜:TIME_WAITという名の「見えない亡霊」

深夜3時、静まり返ったNOCのフロアに、監視ツールのけたたましいアラート音が鳴り響く。
「Web APIサーバー群からのレスポンスが消失、ロードバランサーからのヘルスチェックが全滅――」

冷や汗を流しながら踏み台サーバー経由で該当ノードにSSHログインし、真っ先に叩いたのはお馴染みの ss コマンドだった。出力された画面を見て、私は思わず「やっぱりか」と呟いた。画面の隅から隅まで、数万行もの TIME_WAIT 状態のソケットが隙間なく埋め尽くしていたのだ。

新しいリクエストを受け付けようにも、OSのローカルポートの枯渇によって、接続の受け皿(ファイルディスクリプタ)が物理的に足りなくなっている。サービスは完全に麻痺し、ユーザーには無慈悲な502 Bad Gatewayが返され続けている――。

インフラエンジニアやWeb APIの設計者であれば、一度はこうした修羅場をくぐり抜けたことがあるはずだ。
今回は、この TIME_WAIT 状態の正体に深く切り込み、パケットの挙動というミクロな視点から、現場で使える骨太なトラブルシューティングとカーネルチューニングの手法までを徹底的に解説しよう。

—

なぜTIME_WAITは生まれるのか? TCP4wayハンドシェイクの宿命

まずは、パケットの動きを脳裏に思い浮かべてほしい。
TCPは信頼性の高い通信を実現するため、接続開始時(3wayハンドシェイク)だけでなく、終了時にも厳密な手続きを踏む。これが有名な4wayハンドシェイク(切断シーケンス)だ。

[Client]                                    [Server]
   |                                           |
   |-------------- FIN -------------->         | (1. クライアントから切断要求)
   |                                           |
   |<------------- ACK -------------           | (2. サーバーが受領応答)
   |                                           |
   |                                  (アプリが close())
   |<------------- FIN -------------           | (3. サーバーからも切断要求)
   |                                           |
   |-------------- ACK ------------->         | (4. クライアントが受領応答)
   |                                           |
   +-- (TIME_WAIT 状態: 2MSL待機) -------------+

ここで重要なのは、「先に切断を仕掛けた側(Active Close)」が TIME_WAIT 状態を背負うというRFCの仕様だ。
HTTP/1.0の Connection: close や、HTTP/1.1の短命なAPIリクエストにおいて、サーバー側が自らコネクションを切断する設計になっている場合、サーバー側に大量の TIME_WAIT が蓄積することになる。

なぜすぐにポートを解放してくれないのか?

「終わった通信なら、すぐにポートを再利用させてくれればいいのに」と思いたくなる。だが、これには明確な理由がある。

1. ロストしたパケットの迷子対策(遅延パケットの処理)
ネットワークのどこかで迷子になっていた古いパケットが、切断直後のポートに遅れて到着した場合、それを新しい別の通信のパケットとして誤認してしまうと、データの破損やセキュリティ上の致命的なインシデントに繋がる。
2. 確実な切断の完了確認
最後の ACK が相手に届かなかった場合、相手は再度 FIN を送ってくる。自側が TIME_WAIT で待機していれば、再送されてきた FIN に対して再度 ACK を返し、穏便にコネクションを消滅させることができる。

RFC 793では、この待機時間(2MSL: Maximum Segment Lifetimeの2倍)を通常 60秒(Linuxの場合は TCP_TIMEWAIT_LEN としてハードコード、またはカーネル定数で定義)と定めている。つまり、1秒間に1,000件のリクエストを処理するシステムであれば、常に $1,000 \times 60 = 60,000$ 個のポートが60秒間占有され続ける計算になる。これが「TIME_WAIT枯渇のメカニズム」だ。

—

現場の目を欺く「見せかけの健康」:ssコマンドによる実態把握

トラブルシューティングの基本は、憶測を排し、ファクト(事実)を直視することだ。
古くは netstat が使われていたが、現代のLinux環境(特にコンテナやクラウドネイティブな環境)において、数万〜数十万のソケットを高速かつ詳細に調べるには ss コマンド一択である。

まずは、現在のサーバー上でどの状態のソケットがどれだけ存在するのか、ざっくりとした全体像を把握してみよう。

# すべてのTCPソケットの統計情報をサマリー表示する
$ ss -s
Total: 45212 (kernel 45350)
TCP:   42150 (estab 120, closed 41900, orphaned 0, synrecv 0, timewait 41900/0), ports 0
Transport Total     45350     (kernel 45356)
...

この出力例を見てほしい。timewait 41900 とある。総TCPソケット42,150個のうち、なんと約99%が TIME_WAIT 状態で埋め尽くされている。これでは新しい接続を受け付けられるはずがない。

次に、具体的にどの宛先やポートに対して TIME_WAIT が滞留しているのかを特定する。

# TIME_WAIT状態のソケットを詳細にリストアップする(数値表現で高速化)
$ ss -t -n state time-wait
State       Recv-Q Send-Q     Local Address:Port          Peer Address:Port      
TIME-WAIT   0      0          192.168.10.50:443           10.0.1.15:52344
TIME-WAIT   0      0          192.168.10.50:443           10.0.1.15:52345
TIME-WAIT   0      0          192.168.10.50:443           10.0.1.15:52346
...(中略)...

ここで、Local Address:Port が自サーバーのサービスポート(例: 443)、Peer Address:Port が接続元(ロードバランサーや上流のAPIクライアント)のポートを指している。
もし、特定のクライアントIPからのリクエストが異常に多い場合は、クライアント側のコネクションプールの設定不備や、Keep-Aliveが無効になっていることが疑われる。

—

根本治療と対症療法:ソケットオプションチューニングの極意

根本的な解決策は、Webアプリケーション側やAPIクライアント側の設計を見直し、HTTP Keep-Aliveを有効化して「コネクションの張りっぱなし(再利用)」を実現することだ。しかし、アーキテクチャの改修には時間がかかる。
緊急を要する現場において、Linuxカーネルパラメータのチューニングは、まさに救世主となる。

1. net.ipv4.tcp_tw_reuse (安全な再利用の特効薬)

現代のLinux(Kernel 4.1以降など)において最も推奨される安全なアプローチがこれだ。
これは、「タイムスタンプ(Timestamps, RFC 1323)」の仕組みを利用し、TIME_WAIT 状態にある既存のソケットを、安全に「新しい発信元からの接続(Outbound)」として再利用する機能である。

  • 注意点: あくまで「アウトバウンド(自ら外向きに接続する際)」のポート再利用に効くパラメータであり、インバウンド(サーバーとして待ち受ける側)の TIME_WAIT には直接効かない点に注意が必要だ。

2. net.ipv4.tcp_fin_timeout (待ち時間の短縮)

FIN-WAIT-2状態から完全に切断されるまでの時間を短縮する。デフォルトの60秒から、ネットワーク環境の信頼性を考慮して30秒程度に短縮することで、リソース解放のサイクルを早める。

—

実践:sysctl.confによるカーネルチューニングと適用手順

それでは、実際にインフラエンジニアが本番サーバーで実施する設定手順を見ていこう。

設定ファイルの作成と編集

/etc/sysctl.d/99-tcp-tuning.conf などの名前で専用のコンフィグファイルを作成するのが、現代のコンフィグ管理のベストプラクティスだ。

# /etc/sysctl.d/99-tcp-tuning.conf
# 高負荷Web APIサーバー向け TCP TIME_WAIT 最適化設定

# 1. TIME_WAIT ソケットの安全な再利用を有効化(タイムスタンプ必須)
net.ipv4.tcp_tw_reuse = 1

# 2. タイムスタンプオプションを有効化(tw_reuseの前提条件)
net.ipv4.tcp_timestamps = 1

# 3. FIN-WAIT-2 状態のタイムアウト時間を短縮(デフォルト60秒 -> 30秒)
net.ipv4.tcp_fin_timeout = 30

# 4. ローカルポートの動的割り当て範囲を拡大(一時的な接続枯渇対策)
# 標準の 32768-60999 から、より広いレンジへ拡張
net.ipv4.ip_local_port_range = 1024 65535

# 5. SYNバックログキューのサイズを拡大(DDoSや急激なトラフィック増への備え)
net.ipv4.tcp_max_syn_backlog = 8192

設定の即時反映コマンド

ファイルを作成したら、以下のコマンドでカーネルに即時反映させる。再起動は不要だ。

# 設定ファイルを読み込ませて即時反映する
$ sudo sysctl --system

もし、一時的かつ即座にコマンドラインから値を変更したい場合は、以下のように直接 /proc を叩くこともできる(※ただし再起動すると消えるため、必ず sysctl.conf にも記述すること)。

# 一時的に tcp_tw_reuse を有効化する場合
$ sudo sysctl -w net.ipv4.tcp_tw_reuse=1

—

アプリケーション設計からのアプローチ:コード例と検証

インフラ側のチューニングで延命を図る一方、アプリケーションやクライアントのコード側で正しくコネクション管理を行うことが、真の安定稼働への近道だ。
ここでは、代表的な言語やAPIクライアントにおける「Keep-Aliveの維持」と「適切なコネクション管理」の実装例を示す。

1. Python (requests) の場合:Sessionによるコネクションプーリング

PythonでスクリプトやAPIクライアントを書く際、毎回 requests.get() を呼び出すと、その都度新規にTCPコネクションが張られ、終了時にローカル側に TIME_WAIT が大量発生する。
requests.Session を使うことで、コネクションを維持(Keep-Alive)し、ポートの無駄な消耗を防ぐことができる。

import requests

# セッションオブジェクトを作成(コネクションプールが内部で維持される)
session = requests.Session()

# 接続先APIのエンドポイント
api_url = "https://api.example.com/v1/data"

print("APIリクエストを連続送信します...")
for i in range(100):
    try:
        # 同一セッションを使うことで、TCP接続が再利用される(TIME_WAITを抑制)
        response = session.get(api_url, timeout=5)
        
        if response.status_code == 200:
            print(f"[{i+1}] 成功: Status {response.status_code}")
        else:
            print(f"[{i+1}] 警告: Status {response.status_code}")
            
    except requests.exceptions.RequestException as e:
        print(f"[{i+1}] エラー発生: {e}")

# セッションを明示的にクローズ
session.close()
print("処理が完了しました。")

2. Node.js (Fetch API / Agent) の場合

Node.js環境(fetchやhttpモジュール)においても、デフォルトでKeep-Aliveが効いているか、あるいはカスタムエージェントでソケットの最大数を適切に制御しているかが運用の分かれ道となる。

const http = require('http');

// KEEP-ALIVEを有効にしたカスタムHTTPエージェントの定義
const agent = new http.Agent({
  keepAlive: true,
  maxSockets: 100,         // 同時接続する最大ソケット数
  maxFreeSockets: 10,      // プール内で待機させるアイドル状態の最大ソケット数
  timeout: 60000           // アイドルタイムアウト (60秒)
});

// 使用例(Node.js 標準 httpモジュールの場合)
const options = {
  hostname: 'api.example.com',
  port: 80,
  path: '/v1/health',
  method: 'GET',
  agent: agent // エージェントをアサインしてコネクションをプールする
};

const req = http.request(options, (res) => {
  console.log(`ステータスコード: ${res.statusCode}`);
  res.on('data', (chunk) => {
    // レスポンスデータの処理
  });
});

req.on('error', (e) => {
  console.error(`リクエストエラー: ${e.message}`);
});

req.end();

—

最後に:アラートに怯えないために

深夜の障害対応で得た教訓はいつも同じだ。
「ネットワークの挙動は、決して気まぐれではなく、コードとカーネルの対話の結果として厳密に動いている」ということ。

TIME_WAIT は敵ではない。パケットロスや誤認を防ぐための、TCPプロトコルが持つ不可欠な防衛機能だ。しかし、設計思想を無視した力技の短命接続の繰り返しは、確実にシステムを窒息死させる。

ss コマンドで今の状態を正確に観測し、カーネルパラメータで適切にハンドリングし、そして何よりアプリケーション層で美しいコネクション設計を行う。この三位一体の対策を施してこそ、私たちは深夜のアラートから解放され、心安らかな眠りを取り戻すことができるのだ。さあ、明日からのインフラ設計に、この知見をフル活用してほしい。

コメント

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