接続はちゃんと閉じたのに、なぜ TIME_WAIT が溜まって新しい接続が張れなくなるのか?
外部 API を叩くバッチを並列化したら、途中から connect: cannot assign requested address が出始めた。慌てて ss を見ると、TIME_WAIT という状態の接続が何万本も並んでいる。コードでは毎回きちんと接続を閉じているのに、閉じたはずの接続はどこに残っていて、なぜ新しい接続を邪魔するのだろうか。
- TCP 接続は「送信元 IP・送信元ポート・宛先 IP・宛先ポート」の 4 つ組で識別され、クライアント側の送信元ポートは有限のエフェメラルポート範囲から割り当てられる。
- 先に接続を閉じた側は、遅れて届くパケットと最後の ACK の再送に備えて TIME_WAIT 状態で 4 つ組を保持し続ける(Linux では 60 秒固定)。
- 同じ宛先へ毎回新規接続を張ると、この 60 秒の保持でポートが埋まる。対策の本命はカーネル設定ではなく keep-alive とコネクションプールによる接続の再利用である。
接続を識別するのは「4 つ組」である
TCP の接続は、送信元 IP・送信元ポート・宛先 IP・宛先ポート の 4 つの値の組(4-tuple、4 つ組)で一意に識別される。カーネルは届いたパケットのこの 4 つ組を見て、どのソケットに渡すかを決めている。
アプリから https://api.example.com に接続するとき、宛先 IP と宛先ポート 443 は決まっている。送信元 IP もマシンの IP で固定だ。毎回変わるのは送信元ポートだけで、これはカーネルがエフェメラルポート(ephemeral port、接続ごとに一時的に割り当てる送信元ポート)の範囲から自動で選ぶ。
Linux ではこの範囲を net.ipv4.ip_local_port_range で決めており、デフォルトは 32768〜60999、つまり 28,232 個である。同じ宛先(IP とポートの組)に対して同時に存在できる接続は、この個数が上限になる。宛先が違えば同じ送信元ポートを使い回せるため、問題になるのは「同じ相手に大量に繋ぐ」場合だ。
$ sysctl net.ipv4.ip_local_port_range net.ipv4.ip_local_port_range = 32768 60999
閉じた側だけが TIME_WAIT に残る
close() を呼ぶと接続はすぐ消えるように思える。しかし実際は、TCP は両側が FIN(もう送るデータは無いという合図)と ACK(受け取ったという確認)を交換して終了する手順を踏み、先に FIN を送った側(アクティブクローズ側)は最後の ACK を送ったあとも TIME_WAIT という状態でしばらく残る。
先に閉じる側 後から閉じる側
ESTABLISHED ESTABLISHED
│ ── FIN ───────────────▶ │
FIN_WAIT_1 CLOSE_WAIT
│ ◀─────────────── ACK ── │
FIN_WAIT_2 │
│ ◀─────────────── FIN ── │
│ LAST_ACK
│ ── ACK ───────────────▶ │
TIME_WAIT CLOSED
(2MSL 待機。Linux は 60 秒)
▼
CLOSED
待つ時間は仕様上 2MSL と定められている。MSL(Maximum Segment Lifetime)はパケットがネットワーク上を漂い続けうる最大時間の想定値で、TCP の仕様 RFC 9293 は MSL を 2 分としている。Linux はこれより短い 60 秒をカーネルのソースに定数 TCP_TIMEWAIT_LEN として埋め込んでおり、sysctl では変えられない。
TIME_WAIT 中のソケットはアプリからはもう見えないが、カーネルの中では 4 つ組を占有したままだ。そのため同じ 4 つ組で新しい接続を始めることは、原則としてできない。
TIME_WAIT は、退去した部屋をすぐには次の入居者に貸さない「空室期間」に近い。前の住人宛ての郵便物がまだ配達途中かもしれないので、しばらく部屋番号(4 つ組)を寝かせておけば、新しい住人が前の住人宛ての手紙を受け取る事故が起きない。部屋数(エフェメラルポート)が限られたアパートで入退去を高速に繰り返すと、空室期間中の部屋ばかりになり新しい入居者が入れなくなる。
なぜ 60 秒も待つ必要があるのか
TIME_WAIT には 2 つの役目がある。1 つ目は、最後の ACK が途中で失われた場合への備えだ。相手は ACK が来ないので FIN を再送してくるが、こちらが状態を完全に捨てていると、知らない接続宛てのパケットとして RST(強制切断)を返してしまう。TIME_WAIT で状態を残しておけば、再送された FIN に正しく ACK を返し直せる。
2 つ目は、古い接続の遅延パケットの混入防止である。ネットワーク経路の都合で遅れたパケットが、同じ 4 つ組で直後に張られた新しい接続に届くと、新しい接続のデータとして誤って受け取られかねない。パケットが消えるのに十分な時間 4 つ組を寝かせることで、この事故を防いでいる。
つまり TIME_WAIT は異常でもリークでもなく、TCP が正しく動いている証拠だ。TIME_WAIT 1 つあたりのカーネル内のメモリ消費も小さいため、数が多いこと自体はほとんど問題にならない。問題になるのは、ポートという有限の番号を食いつぶすときだけである。
ポートが尽きる計算と、本当の対策
計算は単純だ。同じ宛先への接続に使える送信元ポートが 28,232 個で、1 つの接続が閉じたあと 60 秒保持されるなら、毎回新規接続を張るクライアントが維持できるのはおおよそ 28232 ÷ 60 ≒ 470 接続/秒になる。これを超えるペースで「繋いで、1 リクエストして、閉じる」を繰り返すと、空きポートが無くなって connect() が EADDRNOTAVAIL(cannot assign requested address)で失敗する。
対策の本命は、接続を閉じないことだ。HTTP の keep-alive やコネクションプールで同じ接続を使い回せば、TIME_WAIT はそもそも発生しない。Go の http.Transport は宛先ホストごとに保持するアイドル接続数のデフォルト DefaultMaxIdleConnsPerHost が 2 しかない。同じ API に 100 並列で投げると、使い終わった接続のうち 2 本を超える分は閉じられ、その都度 TIME_WAIT が生まれる。並列度に合わせて上げておく。
tr := &http.Transport{
MaxIdleConns: 200,
MaxIdleConnsPerHost: 100, // デフォルトは 2
}
client := &http.Client{Transport: tr}
リバースプロキシでも同じことが起きる。nginx は 1.29.7 より前のバージョンでは upstream への接続を keep-alive せず、リクエストごとに閉じるのがデフォルトだった。古いバージョンでは upstream に keepalive を書き、HTTP/1.1 で接続するよう明示しないと、nginx とアプリサーバーの間に TIME_WAIT が積み上がる。
upstream app {
server 127.0.0.1:3000;
keepalive 16; # ワーカーごとに保持するアイドル接続数
}
location / {
proxy_pass http://app;
proxy_http_version 1.1; # 1.29.7 より前で必要
proxy_set_header Connection ""; # 同上
}
カーネル側にも調整はあるが、補助と考える。ip_local_port_range を広げれば使えるポート数がそのまま増える。net.ipv4.tcp_tw_reuse は、TCP タイムスタンプで安全と判断できる場合に TIME_WAIT 中の 4 つ組を自分から張る新しい接続に再利用させる設定で、0 が無効、1 が全体で有効、デフォルトの 2 はループバック通信のみ有効だ。カーネルのドキュメントは専門家の助言なしに変えるべきでないと注記している。古いブログで見かける tcp_tw_recycle は NAT 環境で接続を壊す問題があり、Linux 4.12 で削除済みなので使えない。
典型は「ワーカーやバッチの並列度を上げた途端、外部 API・Redis・DB への接続が cannot assign requested address で失敗し始める」パターンだ。HTTP クライアントを毎回 new している、ライブラリのプール上限が小さすぎる、あるいはプロキシの upstream keep-alive が無効、のどれかであることが多い。調べるときは ss -tan state time-wait | wc -l で数を、ss -tan state time-wait の宛先列でどの相手に偏っているかを見る。1 つの宛先に偏っていれば、その宛先へのクライアント設定を見直す。
「先に閉じた側に TIME_WAIT が残る」ことを Mac 上で確かめる。Python の簡易サーバーは HTTP/1.0 で応答し、レスポンスを返すとサーバー側から接続を閉じる。ターミナルを 2 つ開いて実行する。
# ターミナル 1: 簡易 HTTP サーバーを起動 python3 -m http.server 8000 # ターミナル 2: 5 回リクエストして TIME_WAIT を数える for i in 1 2 3 4 5; do curl -s -o /dev/null http://127.0.0.1:8000/; done netstat -an -p tcp | grep TIME_WAIT | grep '127.0.0.1.8000' # macOS の MSL 設定値(ミリ秒)も見ておく sysctl net.inet.tcp.msl
ローカルアドレス側が 127.0.0.1.8000、つまりサーバー側に TIME_WAIT が 5 本並ぶはずだ。先に閉じたのがサーバーだからである。しばらく待ってから再度 netstat を叩くと、TIME_WAIT が消えていることも確認できる。
- TIME_WAIT が大量にあるのは接続リークだ — TIME_WAIT はアプリが正しく
close()したあとの正常な状態である。アプリが閉じ忘れたときに溜まるのは、相手から FIN を受け取ったのに自分が閉じていないCLOSE_WAITの方で、こちらは fd を握ったままなのでリークを疑う。 tcp_fin_timeoutを下げれば TIME_WAIT が短くなる — この設定が決めるのは、アプリから切り離された接続がFIN_WAIT_2に留まる時間である。Linux の TIME_WAIT の長さは 60 秒の定数で、sysctl からは変えられない。- サーバー側の TIME_WAIT で待ち受けポートが足りなくなる — サーバー側の接続は宛先ポート(たとえば 443)を全クライアントで共有し、相手の IP とポートで区別されるので、エフェメラルポートを消費しない。ポート枯渇が起きるのは、同じ宛先へ大量に新規接続するクライアント側である。
- 4 つ組(4-tuple)
- 送信元 IP・送信元ポート・宛先 IP・宛先ポートの組。TCP 接続を一意に識別する。
- エフェメラルポート
- クライアントが接続ごとにカーネルから一時的に割り当てられる送信元ポート。Linux のデフォルト範囲は 32768〜60999。
- TIME_WAIT
- 先に接続を閉じた側が、最後の ACK を送ったあと 4 つ組を保持して待機する状態。
- MSL
- Maximum Segment Lifetime。パケットがネットワーク上に残りうる最大時間の想定値で、TIME_WAIT はその 2 倍待つ。
- keep-alive
- 1 本の TCP 接続を閉じずに複数のリクエストで使い回すこと。
- CLOSE_WAIT
- 相手から FIN を受け取ったが、自分のアプリがまだ
close()していない状態。
- Coping with the TCP TIME-WAIT state on busy Linux servers — TIME_WAIT の 2 つの目的、
tcp_tw_reuseと削除されたtcp_tw_recycleの違いを Linux の実装に即して解説した定番記事。 - How to stop running out of ephemeral ports and start to love long-lived connections — Cloudflare による、エフェメラルポートの割り当て規則と宛先ごとの再利用の詳細。
- Linux kernel: IP Sysctl —
ip_local_port_range・tcp_tw_reuse・tcp_fin_timeoutの正式な定義とデフォルト値。 - RFC 9293: Transmission Control Protocol (TCP) — 接続終了の状態遷移と、TIME-WAIT で 2MSL 待つ理由の一次資料。