ゾンビ化したソケットの葬り方:CLOSE_WAITの深層とアプリケーションの責任
深夜2時、オンコール端末が鳴り響く。監視ダッシュボードに浮かび上がるのは、特定のマイクロサービスにおける「接続数急増によるメモリ枯渇」のアラートだ。現場のエンジニアがまず疑うのはコネクションリークだが、ss コマンドを叩いた瞬間、その光景に既視感を覚えるはずだ。
そこには、無数に鎮座する CLOSE_WAIT の残骸がある。これは単なる設定ミスではない。TCPのステートマシンが、アプリケーション層の怠慢によって行き場を失い、死に損ねた「ゾンビ」の姿なのだ。
1. なぜ「CLOSE_WAIT」は消えないのか
TCPの受動切断(Passive Close)において、相手側から FIN パケットを受け取ると、OSのカーネルは自動的に ACK を返し、自身のステータスを CLOSE_WAIT へ遷移させる。この瞬間、カーネルはアプリケーションに対して「おい、相手が接続を切りたがっているぞ。お前も close() を呼んでくれ」とシグナルを送る。
しかし、アプリケーションがそのシグナルを無視、あるいは別の処理に忙殺されて close() を発行しなければ、そのソケットは永遠に CLOSE_WAIT の牢獄に囚われる。これが、「カーネルは仕事をしているが、ユーザーランドが寝ている」という最悪の状況だ。
2. 泥臭い調査:どこで詰まっているのか
まずは、どのプロセスがこのゾンビを抱えているのかを特定しなければならない。ss コマンドでプロセスID(PID)をあぶり出すのが鉄則だ。
# -t: TCPを表示, -a: 全ソケット, -p: プロセスを表示
# CLOSE_WAITのソケットを抽出し、所有プロセスを特定する
ss -tapn state close-wait
このコマンドで特定したPIDに対して、gdb や strace を仕掛けるのが次のステップだ。
# プロセスがシステムコールでどこでブロックされているか追跡する
# もしread()やrecv()で止まっているなら、アプリケーションがデータの読み込みを完了できずにいる可能性が高い
strace -p <PID> -f -e trace=network,read,recvmsg
3. パケットレベルの病理:TLSとバッファの相関
最近の現場でよく見るのは、TLSハンドシェイクの不完全さや、巨大なペイロード受信時のバッファ溢れによるデッドロックだ。
CLOSE_WAIT が発生する文脈で、しばしば見落とされるのが「TCP receive buffer」と「アプリケーションの読み取り速度」の乖離だ。TLS通信の場合、カーネルのTCPスタックより上の層で復号処理が走る。もしアプリケーションが read() を呼び出すタイミングを逸していると、カーネル側の受信バッファは埋まり、フロー制御(Window Update)が働いて通信は止まる。その状態で相手から切断要求が来れば、見事に CLOSE_WAIT が完成する。
これを防ぐためのチューニングとして、カーネルのバッファ設定を最適化するのは基本中の基本だ。
# /etc/sysctl.conf でTCPの受信バッファを適切にチューニングする
# ネットワーク帯域とRTTに応じて動的に調整させるのが現代の最適解
net.ipv4.tcp_rmem = 4096 87380 16777216
net.ipv4.tcp_wmem = 4096 65536 16777216
# サーバー側でTIME_WAITを再利用し、リソース枯渇を防ぐ
net.ipv4.tcp_tw_reuse = 1
4. 解決策:コードレベルでの実装不備を正す
根本治療は、アプリケーションの「行儀の良さ」を正すことにある。特に非同期I/Oやスレッドプールを使用している場合、接続が切断されたイベント(EOF)を適切にキャッチしていないケースが多い。
Pythonの socket ライブラリを例にとると、以下のような実装が求められる。
import socket
# 接続処理...
try:
data = sock.recv(4096)
if not data:
# EOF(FINパケット受信)を検知!
# ここで必ずclose()を呼ぶことが、CLOSE_WAITを生まない絶対条件
sock.close()
print("正常にクローズしました")
except Exception as e:
# 異常終了時も確実にリソースを解放する
sock.close()
5. アーキテクトへの提言:設計の再考
もし、どれだけコードを修正しても CLOSE_WAIT が消えないなら、それはアプリケーション単体の問題ではなく、ネットワークアーキテクチャの敗北かもしれない。
- Keep-Aliveの最適化: ロードバランサー(L7)とバックエンド間の
Keep-Aliveタイムアウトが、アプリケーションの処理能力より短い場合、不要な切断が頻発する。 - ヘッダー圧縮の弊害: HTTP/2のHPACKのようなヘッダー圧縮を採用している場合、ストリームの同期が崩れると、接続のクローズシーケンスが正しく伝搬しないことがある。
- タイムアウトの多重化: アプリケーション、ライブラリ、ロードバランサー、カーネルの各層でタイムアウト値を設定し、スタックが発生した際に自動的に接続を強制切断(
RST)する仕組みを設けるべきだ。
最後に
CLOSE_WAIT は、システムが発する「限界です、助けてください」という悲鳴である。これを単に sysctl のパラメータで隠蔽するのは、熱を出している患者に解熱剤だけを飲ませるようなものだ。
パケットが流れる先には、必ず人間が書いたコードがある。そのコードが、カーネルからの「切断してくれ」という声を聞き取れるようにすること。それこそが、インフラエンジニアとアプリケーションエンジニアが対話すべき唯一の真実だ。
今夜も、ゾンビを退治する準備はできているか。ネットワークの深淵は、いつでもあなたを待っている。
コメント