こんにちは。今日もパケットの海を泳いでいますか?技術メディア主筆のSysEng-Mです。
「本番環境のAPIサーバーが、高負荷時に突然 Cannot assign requested address を吐いて沈んだ」
「WAFを導入した途端、特定のクライアントからの接続が不自然に切断されるようになった」
インフラ運用やWeb APIの設計に携わっていると、こうした「TCPの足回り」に起因するトラブルに必ずと言っていいほど直面します。アプリケーションレイヤー(HTTP/HTTPS)のコードがどれほど完璧に書かれていても、その下でパケットを運ぶレイヤー4、すなわちTCP(Transmission Control Protocol)の挙動を無視することはできません。
今回は、TCPのライフサイクルにおける最もドラマチックな局面――「切断プロセス」にスポットライトを当てます。
美しく調和の取れた正常終了である「FIN/ACK(4ウェイ・ハンドシェイク)」と、問答無用でコネクションを叩き切る「RST(リセット)」。この2つの終了プロセスの本質的な違いと、インフラエンジニアを夜も眠らせない「TIME_WAIT 状態」によるリソース枯渇のメカニズム、そしてその実践的な回避策について、パケットレベルの深い知見をお届けします。
さあ、Wiresharkの画面を脳内に展開しながら、深淵なるTCPの世界へ潜りましょう。
—
1. 正常な別れ:FIN/ACK(4ウェイ・ハンドシェイク)の全貌
TCPは「信頼性のある、双方向のバイトストリーム通信」を提供するプロトコルです。コネクションを確立する際の「3ウェイ・ハンドシェイク」は有名ですが、切断する際は基本的に「4ウェイ・ハンドシェイク(4-way Handshake)」という、もう1ステップ多いプロセスを踏みます。
なぜ切断には4つのステップが必要なのでしょうか?それは、TCPが「半二重クローズ(Half-Close)」をサポートしているからです。つまり、「自分からはもうデータを送らない(送信ストリームのクローズ)が、相手から送られてくるデータはまだ受け取る(受信ストリームはオープン)」という状態を許容しているのです。
まずは、その美しいシーケンスを見てみましょう。
4ウェイ・ハンドシェイクのパケット遷移
Active Close (クライアント側) Passive Close (サーバー側)
| |
|--- [1] FIN (SEQ=100, ACK=500) ------------------->| (CLOSE_WAITに遷移)
| (FIN_WAIT_1に遷移) |
| |
|<-- [2] ACK (SEQ=500, ACK=101) --------------------|
| (FIN_WAIT_2に遷移) |
| |
| [サーバー側の残処理・データ送信が完了] |
| |
|<-- [3] FIN (SEQ=500, ACK=101) --------------------| (LAST_ACKに遷移)
| |
|--- [4] ACK (SEQ=101, ACK=501) ------------------->|
| (TIME_WAITに遷移) | (CLOSEDに遷移)
| |
[2 * MSL 待機] |
| |
(CLOSEDに遷移) |
各ステップの解説
1. FIN(Active Closeの開始)
クライアント(アクティブクローズ側)がアプリケーションから close() を呼び出すと、OSのプロトコルスタックは FIN(Finish)フラグを立てたセグメントを送信します。クライアントのステータスは FIN_WAIT_1 に遷移します。
2. ACK(ハーフクローズの承認)
パケットを受け取ったサーバー(パッシブクローズ側)は、クライアントからの切断要求を確認したことを示す ACK(Acknowledgment)を返します。これにより、サーバー側は CLOSE_WAIT 状態になり、クライアント側は FIN_WAIT_2 状態になります。
*この時点で、クライアントからサーバーへのデータ送信は完全にストップしますが、サーバーからクライアントへの送信はまだ可能です。*
3. FIN(サーバー側からの切断要求)
サーバー側もアプリケーション処理を終え、送るべきデータをすべて送り出すと、自身の close() を呼び出します。これにより、サーバーからクライアントへ FIN が送信されます。サーバーのステータスは LAST_ACK に遷移します。
4. ACK(最終合意と儀式の終わり)
クライアントはサーバーからの FIN を受け取ると、最後の確認応答である ACK を送信します。サーバーはこの ACK を受信した瞬間に、ソケットリソースを完全に解放して CLOSED 状態になります。
一方、クライアントは即座に CLOSED にはならず、TIME_WAIT という非常に重要なステータスに遷移します。
—
2. 魔の「TIME_WAIT」状態:なぜすぐ死ねないのか?
多くのインフラエンジニアを悩ませるのが、コマンド ss -an や netstat -an を叩いたときに画面を埋め尽くす大量の TIME_WAIT という文字です。なぜクライアント(アクティブクローズ側)は、最後の ACK を送った後、すぐにソケットを解放して消え去ることができないのでしょうか?
理由は主に2つあります。
理由①:最後の ACK がロストした場合のセーフティネット
もしクライアントが送った最後の ACK(ステップ [4])が、ネットワークの途中でドロップしてサーバーに届かなかったらどうなるでしょうか?
サーバーは LAST_ACK 状態のままタイムアウトし、クライアントが FIN を受け取れなかったと判断して、再度 FIN を再送してきます。
もしクライアントがすでに CLOSED 状態になってソケットを解放していたら、この再送された FIN に対して「そんなコネクションは知らない」と RST を返してしまい、サーバー側は異常終了としてエラーを記録することになります。
TIME_WAIT 状態で待機していれば、再送されてきた FIN に対して再度 ACK を送り直すことができ、穏便に接続を終了させられます。
理由②:遅延したパケット(迷子パケット)の誤認防止
IPネットワークでは、パケットがルーティングのループなどで遅延し、コネクションが終了した後に遅れて到着することがあります。
もしクライアントが即座に同じIPアドレス・同じポート番号(ソケットの4つの要素:送信元IP、送信元ポート、送信先IP、送信先ポートが同一)で新しいコネクションを張り直した場合、前回のコネクションの「生き残りパケット」が遅れて届くと、新しいコネクションのデータとして誤って受信されてしまい、データ破損(データインジェクション)を引き起こすリスクがあります。
このため、TCP仕様(RFC 793 / RFC 9293)では、パケットがネットワーク上に存在できる最大時間である MSL(Maximum Segment Lifetime)の2倍の時間(2 * MSL) だけ、同一ポートの再利用を禁止して待機することを義務付けています。Linuxカーネルでは、この値は通常 60秒(30秒 × 2)に固定されています。
—
3. 実務で直面する「TIME_WAIT」によるリソース枯渇とチューニング
Web APIのクライアントとして動作するプロキシサーバーや、バックエンドのマイクロサービス間で、高頻度かつ短寿命のHTTP接続(Keep-Aliveが無効な接続)を繰り返すと、クライアント側で TIME_WAIT のソケットが数万規模で滞留します。
TCP接続で利用できるクライアント側の「送信元ポート(エフェメラルポート)」の数には上限があります。デフォルトではおよそ3万〜6万個程度です。
TIME_WAIT が60秒間ポートを占有し続けるため、秒間1,000回以上のAPIリクエストを新規接続で投げ続けると、1分間で60,000ポートを使い果たし、新規のTCPソケットを作成できなくなります。これがポート枯渇(Port Exhaustion)です。
解決策①:Linuxカーネルパラメータの最適化
この問題に対処するため、インフラエンジニアは Linux のカーネルパラメータ(/etc/sysctl.conf)をチューニングします。
# /etc/sysctl.conf の記述例
# エフェメラルポートの範囲を限界まで広げる(標準は 32768〜60999)
net.ipv4.ip_local_port_range = 10240 65535
# TIME_WAIT 状態のソケットを安全に再利用する(TCPタイムスタンプオプションが有効である必要があります)
net.ipv4.tcp_tw_reuse = 1
net.ipv4.tcp_timestamps = 1
# TIME_WAIT ソケットの最大数を制限する(これを超えると古いものから破棄される)
net.ipv4.tcp_max_tw_buckets = 262144
設定を反映するには、以下のコマンドを実行します。
sudo sysctl -p
> 【シニアエンジニアの警告】
> 昔の技術ブログでよく見かけた net.ipv4.tcp_tw_recycle は、Linuxカーネル 4.12 で非推奨となり、4.15 で完全に削除されました。
> このオプションはNAT(Network Address Translation)配下のクライアントからの接続で、タイムスタンプの不整合によるパケットドロップ(接続不能フェイル)を引き起こす致命的な副作用があったためです。現在は tcp_tw_reuse のみを使用するのが鉄則です。
解決策②:アプリケーション側での接続プール(Keep-Alive)の徹底
カーネルパラメータの調整は最後の手段(絆創膏)に過ぎません。根本的な解決策は、「コネクションを使い回す(接続プーリング)」ことです。
HTTP/1.1の Keep-Alive や、HTTP/2、gRPCなどを活用し、1つのTCPコネクションを維持したまま、複数のリクエスト/レスポンスを多重化して処理します。
Python の requests ライブラリを例にとり、悪いアプローチと良いアプローチを比較してみましょう。
悪い例(毎回コネクションを切断し、TIME_WAIT を量産する)
import requests
# 毎回個別のリクエストを投げる(内部的に毎回 socket.close() が走り、TIME_WAIT が発生する)
for i in range(100):
response = requests.get('https://api.example.com/data')
print(response.status_code)
良い例(Session オブジェクトを使い、コネクションをプールする)
import requests
from requests.adapters import HTTPAdapter
# セッションを作成(内部で urllib3 の ConnectionPool が動作する)
session = requests.Session()
# プールする接続数と最大数を設定
adapter = HTTPAdapter(pool_connections=10, pool_maxsize=10)
session.mount('https://', adapter)
# コネクションを再利用してリクエストを送信(TCPの3ウェイ・ハンドシェイクも切断も1回だけで済む)
for i in range(100):
response = session.get('https://api.example.com/data')
print(response.status_code)
# 明示的に最後にクローズする
session.close()
—
4. 突発的な暴力:RST(リセット)による強制終了
4ウェイ・ハンドシェイクが「お互いの合意に基づく円満離婚」であるならば、RST(Reset)による終了は、事前通告なしの「一方的な絶縁状」です。
RST パケットは、TCPヘッダーの RST フラグが 1 に設定されたセグメントで、これを受け取ったホストは、送信中のデータや確認応答(ACK)の有無にかかわらず、その瞬間に該当コネクションのソケットを強制クローズ(CLOSEDに遷移)します。 TIME_WAIT 状態への移行すらスキップされます。
Host A (Client) Host B (Server)
| |
|--- [データ送信] --------------------------------->| (ソケットが既に閉じている、
| | または予期せぬパケット)
| |
|<-- [RST] (SEQ=500, ACK=0) ------------------------| (問答無用の強制終了)
| |
(即座にCLOSED) (即座にCLOSED)
RST が送出される代表的なトリガー
実務において RST がネットワーク上を飛び交う場合、以下のシナリオが考えられます。
1. ポートが閉じている(Connection Refused)
クライアントがサーバーの 8080 ポートに接続しようとしたが、サーバー上で対象のプロセスが起動していなかった場合、OSのカーネルは SYN に対して RST/ACK を返します。
2. ハーフオープン状態でのデータ受信
サーバー側がアプリケーションのクラッシュなどでソケットを閉じたにもかかわらず、クライアントがそれを検知せず、古いコネクションに対してデータを送信し続けた場合、サーバーは RST を送り返します。
3. ファイアウォール(FWA)やWAF、L7ロードバランサーによる割り込み
セキュリティデバイスが攻撃トラフィックやポリシー違反(不正なペイロードなど)を検知した際、通信を「遮断」するために、クライアントとサーバーの両方に向けて偽装した RST パケットを送りつけ、コネクションを強制終了させます(TCP Reset攻撃の防御応用)。
4. アプリケーションによる明示的なアボート(SO_LINGER設定)
アプリケーションが「溜まっている未送信データを破棄して、即座にポートを解放したい」場合、ソケットオプションの SO_LINGER を 0 に設定してクローズすると、FIN ではなく RST が送出されます。
—
5. 実戦デバッグ:パケットから異常を見抜く
トラブルシューティングの現場において、切断が正常に行われているか、あるいは異常終了しているかを見分けるには、tcpdump を使ったリアルタイムなパケットキャプチャが最も信頼できます。
tcpdump による切断パケットの監視
特定のホスト(例: 192.168.1.50)との間で交わされる FIN および RST パケットだけをフィルタリングしてキャプチャするコマンドです。
# FIN または RST フラグがセットされている TCP パケットをキャプチャする
sudo tcpdump -i eth0 -nn 'tcp[tcpflags] & (tcp-fin|tcp-rst) != 0 and host 192.168.1.50'
キャプチャ出力の読み方(正常終了:FIN)
14:32:01.102345 IP 192.168.1.10.51234 > 192.168.1.50.443: Flags [F.], seq 1001, ack 5001, win 2048
14:32:01.104567 IP 192.168.1.50.443 > 192.168.1.10.51234: Flags [.], ack 1002, win 2048
14:32:01.105123 IP 192.168.1.50.443 > 192.168.1.10.51234: Flags [F.], seq 5001, ack 1002, win 2048
14:32:01.106789 IP 192.168.1.10.51234 > 192.168.1.50.443: Flags [.], ack 5002, win 2048
[F.]はFIN-ACKを意味し、[.]は純粋なACKを意味します。この4つのやり取りがあれば、正常に終了し、クライアント側がTIME_WAITに入ったことがわかります。
キャプチャ出力の読み方(異常終了:RST)
14:35:12.987654 IP 192.168.1.50.443 > 192.168.1.10.51234: Flags [R], seq 5001, win 0
[R]はRSTを意味します。確認応答(ACK)番号すら含まず、一方的にコネクションが絶たれているのが見て取れます。これが出た場合、サーバーアプリケーションのクラッシュ、またはネットワーク経路上のセキュリティ機器(IPS/WAF)の検知ログを掘り返す必要があります。
—
6. まとめ:プロトコルの美学に裏打ちされた堅牢な設計を
最後に、今回解説した2つの切断プロセスの特徴をマトリクスで整理しておきましょう。
| 項目 | 正常切断(FIN/ACK) | 異常/強制終了(RST) |
| :— | :— | :— |
| シーケンス | 4ウェイ・ハンドシェイク(合意形成) | 単一パケットの送出(一方的) |
| 主な発生契機 | アプリの close() 呼び出し、通信正常完了 | 未起動ポートへの接続、アプリ異常終了、WAF遮断 |
| TIME_WAIT 遷移 | アクティブクローズ側が遷移(リソースを一時占有) | 遷移しない(即座にリソース解放) |
| データの安全性 | バッファ内の未送信データはすべて送信・保証される | バッファ内のデータは即座に破棄される |
| 実務上の懸念 | 大量接続時のポート枯渇(TIME_WAIT 滞留) | コネクション遮断による不意の通信エラー |
TCPの設計は、1981年の RFC 793 から現在に至るまで、インターネットの過酷なトラフィックを支え続けてきました。TIME_WAIT の存在も、一見リソースを浪費する厄介者のように見えますが、データの整合性とネットワークの平穏を保つための、エレガントな「知恵」なのです。
インフラを設計する際、また高パフォーマンスなAPIを開発する際は、このプロトコルの美学をリスペクトし、適切なキープアライブや、カーネルパラメータの最適化を施してください。
パケットの挙動を制する者は、システムの安定性を制します。あなたのデバッグライフが、より豊かになりますように。
コメント