サーバーは正常に動いているのに、なぜ一部のリクエストだけ timeout になるのか?
本番でアクセスが急増した瞬間、一部のリクエストだけ画面が真っ白なまま固まり、やがて timeout で失敗した経験はないだろうか。CPU にもメモリにも余裕があるように見えるのに、だ。サーバープロセス自体は他のリクエストを普通に捌けているのだから、原因はアプリの外、OS の中にある小さな待ち行列であることが多い。
- TCP 接続は SYN → SYN-ACK → ACK の 3 ウェイハンドシェイクで確立し、その途中は「SYN キュー」に、確立済みでアプリが
accept()するのを待つ間は「Accept キュー」に積まれる - 両方のキューには
listen()の backlog 引数と OS 全体の上限net.core.somaxconnで決まる有限のサイズがあり、溢れると新規 SYN は多くの場合 RST も返さず黙って破棄される - サーバーの CPU・メモリに余裕があっても、
accept()を呼ぶ速度がリクエストの到着速度に追いつかなければキューは埋まる。「リソースは空いているのに一部だけ詰まる」現象の犯人はここにあることが多い
SYN・SYN-ACK・ACK ― 3 ウェイハンドシェイクの中身
TCP の接続は connect() を呼んだ瞬間に完成するわけではない。クライアントとサーバーの間で 3 回パケットをやり取りしてから、ようやく「確立」の状態になる。まずクライアントが自分の初期シーケンス番号(ISN、以後のバイト列の順序を数えるための起点)とともに SYN を送る。サーバーはそれに応えて自分の ISN を含む SYN-ACK(相手の ISN + 1 を確認応答として添える)を返し、最後にクライアントがサーバーの ISN + 1 を確認する ACK を送り返した時点で、クライアント側は接続が確立したとみなす。この一連の手順を RFC 9293 は 3 ウェイハンドシェイクと呼ぶ。
ここで見落とされがちなのが、サーバー側は最後の ACK が届くまで「接続完了」とはみなさない、という点だ。SYN を受け取っただけの半分開いた接続は SYN キュー(SYN 受信済み・ACK 未到達の接続を並べる待ち行列)に置かれ、最後の ACK が届いてはじめて Accept キュー(確立済みだがアプリがまだ accept() で取り出していない接続を並べる待ち行列)へ移される。アプリコードが呼ぶ listen() と accept() は、この 2 段のキューの入口と出口にそれぞれ対応している。
クライアント サーバー │ │ │──①SYN seq=x ────────▶│SYNキューへ │ │ │◀─②SYN-ACK seq=y──────│ │ ack=x+1 │ │ │ │──③ACK ack=y+1 ──────▶│Acceptキューへ │ │ ESTABLISHED accept()で取得
backlog と somaxconn ― キューのサイズは誰が決めるのか
アプリがリスニングソケットを作るとき渡す backlog 引数が、この SYN キューと Accept キューそれぞれの上限に効く。たとえば C の listen(sfd, 1024) であれば、それぞれのキューは最大 1024 件まで積める設計になる。Node.js の server.listen(port, backlog) や nginx の listen 80 backlog=511; のように、普段はデフォルト値のまま意識せず動かしていることがほとんどだろう。
ただし backlog を大きくしても無限には増えない。Linux では OS 全体の上限であるカーネルパラメータ net.core.somaxconn が効き、実際のキューサイズはアプリが指定した backlog と somaxconn の小さい方でクランプされる。somaxconn のデフォルト値は Linux 5.4 以降で 4096、それより前のバージョンでは 128 だ。つまり未調整の古い環境ではアプリ側で backlog を 1024 に設定しても、実質 128 までしか積めていないということが起こりうる。
# 現在の上限を確認する(Linux) sysctl net.core.somaxconn # 一時的に引き上げる(再起動で消える) sudo sysctl -w net.core.somaxconn=4096
行列のできるラーメン店を思い浮かべてほしい。店の前で整理券を配る係が SYN キューにあたる。整理券をもらった客がまだ店内の待ち椅子に座れていない状態だ。整理券配布の上限に達すると、それ以上並んでも整理券すら渡してもらえず追い返される。無事に席が空いて座れた客の列が Accept キューで、店員が一人ずつ呼んで注文を取る(accept() する)までそこで待つ。行列の長さが同じでも「整理券待ち」と「着席待ち」は別の列であり、それぞれに定員がある。
キューが溢れるとどうなるか ― 静かに消える接続
直感的には「混雑していれば Connection refused のように明示的に拒否されるはず」と思うかもしれない。しかし実際にはそう単純ではない。Linux のカーネルパラメータ net.ipv4.tcp_abort_on_overflow はデフォルトで無効(FALSE)になっており、Accept キューが溢れた場合、カーネルは新しく届いた SYN や ACK パケットを RST(明示的な拒否応答)も返さずに黙って破棄する。これは意図的な設計で、輻輳を起こしているクライアントに再送のタイミングで自然に速度を落とさせる「push-back」として機能する。
クライアント側から見ると、SYN を送っても応答が来ないので TCP の再送タイマーに従って複数回 SYN を送り直し、最終的に接続タイムアウトで諦める。これが冒頭の疑問への答えだ。サーバーは他のリクエストを正常に処理していても、一部の接続だけが応答なしのまま固まり、明確なエラーメッセージすら出ないまま timeout として観測される。tcp_abort_on_overflow を有効にすれば即座に RST を返すよう挙動を変えられるが、カーネルのドキュメントは「リスニングデーモン側の遅さを改善できないと確認してから有効にすべき」と釘を刺している。一時的な負荷スパイクでも問答無用で切断するようになるためだ。
接続を閉じたあとの後始末 ― TIME-WAIT
ここまでは接続を開く側の話だ。閉じる側にも「すぐには消えない」仕組みがある。接続を能動的に close した側は TIME-WAIT という状態にしばらく留まる。RFC 9293 はこの状態を MSL(Maximum Segment Lifetime、同仕様では 2 分と規定)の 2 倍の時間維持するよう求めている。目的は 2 つある。ひとつは自分が最後に送った ACK が相手に届かず、相手が FIN を再送してきた場合にまだ応答できるようにするため。もうひとつは、同じ IP・ポートの組み合わせを使う新しい接続に、古い接続の遅延パケットが紛れ込むのを防ぐためだ。
実務でよく踏むのがこれの副作用で、サーバープロセスを再起動した直後に bind: Address already in use で失敗するのは、直前の接続が TIME-WAIT として残っているせいであることが多い。実際の待機時間は OS やカーネルの実装ごとに異なり、RFC の 2×MSL はあくまで仕様上の目安値であって、断定できる秒数ではない。Linux にはこの状態のソケットを条件付きで新しい接続に再利用できる net.ipv4.tcp_tw_reuse という設定もあり、プロトコル上安全と判断できる場合に限って再利用を許す仕組みになっている。
Kubernetes でスケールアウトした直後や、Node.js のイベントループが重い同期処理でブロックされている間は、新しい接続が Accept キューに積まれたまま accept() が呼ばれず詰まりやすい。readinessProbe が通ってトラフィックが流れ始めた瞬間だけ一部のリクエストが timeout になる、という不可解な現象の裏にはこの仕組みがあることが多い。nginx や Node.js のようなアプリ側で backlog を大きくしても、システム側の somaxconn が低いままでは効果が出ない、という点もあわせて確認する価値がある。
accept() を呼ぶのを止めただけで新しい接続がどう振る舞うかを手元で観察する。ターミナルを 2 つ用意する。
# ターミナル1: backlog=1のリスナーを立て、10秒 accept() を呼ばない
python3 - <<'EOF'
import socket, time
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("127.0.0.1", 5555))
s.listen(1)
print("listening (backlog=1) -- accept()を10秒呼ばない")
time.sleep(10)
EOF
ターミナル 1 を起動したら、すぐにターミナル 2 で 3 本同時に接続してみる。
# ターミナル2: 3本同時に接続してみる for i in 1 2 3; do (time nc -zv -w 3 127.0.0.1 5555) 2>&1 | grep -E 'succeeded|real' & done wait
1 本目か 2 本目はほぼ一瞬で succeeded と表示されるはずだ(backlog=1 のキューに乗れた分)。残りはサーバーが accept() を呼ぶまで応答が来ず、real の時間が数秒に伸びるか、タイムアウトで失敗するのが見えるはずだ。OS やカーネルのバージョンによって具体的な閾値は変わるが、「accept() を呼ぶのが遅いだけで新規接続に影響が出る」という核心は変わらない。
- 接続が混雑して弾かれる時は必ず即座に Connection refused になる — Linux のデフォルト設定(
tcp_abort_on_overflowが無効)では、多くの場合は明示的な拒否ではなく無応答による timeout として現れる。即座に RST が返る refused は、そもそもそのポートで誰も listen していない場合などに起きる。 - アプリコードで backlog を大きくすれば、その値がそのままキューサイズになる — Linux では
net.core.somaxconnによって実効値がクランプされるため、システム側の設定も合わせて確認しないと意図したキューサイズにならない。 - サーバーの CPU・メモリに余裕があれば接続は詰まらない — 詰まる原因はリソース枯渇ではなく
accept()を呼ぶ速度が追いつかないことであるケースが多く、イベントループのブロックやワーカー不足でも同じ症状が起きる。
- 3 ウェイハンドシェイク
- SYN・SYN-ACK・ACK の 3 回のパケット交換で TCP 接続を確立する手順。
- SYN キュー
- SYN を受信したが最後の ACK がまだ届いていない、半分だけ確立した接続を並べる待ち行列。
- Accept キュー
- 3 ウェイハンドシェイクが完了したが、アプリが
accept()でまだ取り出していない接続を並べる待ち行列。 - backlog
listen()に渡す引数で、SYN キュー・Accept キューそれぞれの上限サイズを指定する値。- net.core.somaxconn
- backlog の実効値をクランプする、Linux カーネルの持つシステム全体の上限値。
- TIME-WAIT
- 接続を能動的に閉じた側が、遅延パケットの誤配送を防ぐために一定時間留まる TCP の状態。
- RFC 9293 - Transmission Control Protocol (TCP) — 3 ウェイハンドシェイク・状態遷移・TIME-WAIT の 2×MSL を一次情報で確認できる。
- Linux Kernel Documentation - IP Sysctl —
somaxconn・tcp_max_syn_backlog・tcp_abort_on_overflowのデフォルト値と挙動を確認できる。 - SYN packet handling in the wild - Cloudflare Blog — SYN キューと Accept キューの違い、backlog 引数が両方に効く実装の詳細を解説している。