なぜ君のサーバーは「ポート枯渇」で死ぬのか?―TCP切断の深淵とTIME_WAITの正体
エンジニアたるもの、APIのレスポンスが返ってこないというアラートに飛び起きた経験が一度はあるはずだ。そのとき、君はまず何を疑う?DBのロックか、それともアプリケーションのメモリリークか。
しかし、現場で長年戦っていると、往々にして犯人はもっと「足元」にいる。そう、TCPコネクションの管理だ。特に、高負荷なWeb APIサーバーで突如として発生する「接続不可」のトラブルは、その多くが TIME_WAIT 状態の積み上げによるポート枯渇に起因している。
今回は、教科書を読み飛ばして現場に飛び込んだ君たちへ、TCPの「お別れ」の作法と、それがインフラに与えるリアルな影響について深掘りしていく。
—
TCPの「4ウェイハンドシェイク」というお別れの儀式
TCPはコネクション型だ。接続時に SYN を送り合うのは知っての通りだが、切断時はもっと慎重だ。片方が「もう送るものはないよ」と言ったからといって、即座にブチッと切るわけにはいかない。相手がまだ何か言いたいかもしれないからだ。
ここで登場するのが「4ウェイハンドシェイク」だ。
1. FIN: 「もう送るデータはないよ」(アクティブクローズ側)
2. ACK: 「了解、受け取ったよ」(パッシブクローズ側)
3. FIN: 「じゃあ私も閉じるね」(パッシブクローズ側)
4. ACK: 「了解、完全に閉じるよ」(アクティブクローズ側)
このプロセスを経て、コネクションは静かに幕を下ろす。だが、問題はここからだ。
—
なぜ TIME_WAIT は存在するのか?
最後の ACK を送った側は、すぐには CLOSED 状態にならず、TIME_WAIT という状態に留まる。これはOSの仕様であり、RFC 793で定められた「誠実さ」だ。
理由は主に2つある。
- 遅延パケットの消滅待ち: ネットワークのどこかで迷子になっていた古いパケットが、新しいコネクションに混入するのを防ぐため。
- 最後のACKの再送: 自分が送った最後の
ACKが相手に届かなかった場合、相手は「FIN」を再送してくる。その際、自分側が既にCLOSEDになっていると「RST(強制リセット)」を返してしまい、相手にエラーを報告してしまう。それを避けるための猶予期間だ。
この期間は通常 2MSL(Maximum Segment Lifetime:セグメント生存時間)と呼ばれ、Linuxでは大抵1分(60秒)に設定されている。
—
現場で直面する「ポート枯渇」の罠
秒間数千のリクエストを処理するAPIサーバーにおいて、1分間もコネクションが TIME_WAIT で居座られたらどうなるか?
サーバーが使えるエフェメラルポート(一時的なポート)は、せいぜい6万弱だ。TIME_WAIT が溜まりきると、新しいコネクションを作ろうとしても「そんなポートは残っていないよ」とOSが拒絶する。これが、君たちが夜中に叩き起こされる「接続不可」の正体だ。
解決策:カーネルパラメータをいじる
まずは現場で即効性のあるパラメータチューニングだ。/etc/sysctl.conf に以下の設定を投入する。
# TIME_WAIT状態のソケットを再利用可能にする(クライアント側で特に有効)
net.ipv4.tcp_tw_reuse = 1
# エフェメラルポートの範囲を広げる(デフォルトは狭すぎる場合が多い)
net.ipv4.ip_local_port_range = 1024 65535
ただし、tcp_tw_reuse はタイムスタンプオプション(PAWS)が有効な場合にのみ安全に動作する。現代のネットワーク環境ではほぼ問題ないが、設計時には考慮が必要だ。
—
コードで見る「接続の使い回し」
そもそも、なぜこれほどコネクションが頻繁に切れるのか?原因の多くは、クライアント側のコードにある。
アンチパターン:都度コネクションを張る(Pythonの例)
import requests
# ループの中で毎回セッションを作っていると、そのたびにTCPハンドシェイクが発生し、切断時にTIME_WAITが生成される
for i in range(1000):
response = requests.get("https://api.example.com/data")
ベストプラクティス:コネクションプールを使う
requests.Session() を使うことで、TCPコネクションを Keep-Alive で維持し、ハンドシェイクのオーバーヘッドと TIME_WAIT の発生を劇的に減らせる。
import requests
# Sessionオブジェクトを使い回すことで、コネクションが維持される
session = requests.Session()
for i in range(1000):
response = session.get("https://api.example.com/data")
—
まとめ:ネットワークは「行儀よく」扱う
TCPの TIME_WAIT は、決して「悪者」ではない。ネットワークという不完全な世界で、通信の整合性を守るための「殿(しんがり)」だ。
君たちがインフラを設計する際は、以下の3点を意識してほしい。
1. コネクションプーリング: アプリケーション側で接続を使い回す。これが最大の防御策だ。
2. Keep-Aliveの活用: HTTPレベルでもTCPレベルでも、接続を無駄に切らない設定を入れる。
3. OSチューニングは最後の手段: カーネルパラメータを弄るのは、コードやアーキテクチャの改善をやり尽くしてからにしよう。
ネットワークは正直だ。パケットの挙動を想像し、一つひとつのコネクションを大切に扱う。それができるエンジニアこそが、真に「枯れた」安定したシステムを構築できると私は信じている。
さて、そろそろ次の障害調査の時間だ。君たちも、たまには netstat や ss コマンドで、サーバーの中で何が起きているか覗いてみるといい。意外な「お別れ」のドラマが見えてくるはずだぞ。
コメント