【実務・中級編】HTTP/1.1のTCP接続クローズシーケンス(FIN/ACKとConnection: close) – HTTPプロトコル・通信規格実践ガイド

こんにちは。ネットワークの深淵を覗き続けて幾星霜、今日もどこかの本番環境で暴走するパケットを追いかけているシニアネットワークエンジニアだ。

Webエンジニアやインフラ担当者であれば、「APIのレスポンスが急に遅くなった」「高負荷時にサーバーのポートが枯渇して接続エラー(`EADDRNOTAVAIL`)が多発する」といったトラブルに一度は直面したことがあるはずだ。

アプリケーションコードやデータベースのクエリをいくらチューニングしても改善しない。そんなとき、パケットキャプチャ(Wireshark)を覗いてみると、犯人はコードではなく「TCPの終了シーケンス」と「HTTPの持続的接続(Keep-Alive)」のミスマッチであることが少なくない。

今回は、HTTP/1.1の根幹を支えつつ、現場のインフラを静かに蝕むこともある `Connection: close` とTCPクローズシーケンスのリアル について、パケットの挙動からコード実装、そして実務的な対策まで徹底的に紐解いていこう。

—

1. そもそもHTTP/1.1のデフォルトは何だったか?

HTTP/1.0の時代を覚えているだろうか? 当時は、1つのリクエストを投げてレスポンスを受け取ると、その瞬間にTCPのコネクションが容赦なく切断されていた。画像が何十枚もあるWebページを読み込むたびに、TCPの3ウェイハンドシェイク(SYN, SYN-ACK, ACK)を繰り返すものだから、ネットワークは常にギクシャクし、遅延の温床となっていた。

そこでHTTP/1.1では、「Persistent Connection(持続的接続)」がデフォルトになった。
特別な指示をしない限り、クライアントとサーバーは1本のTCPコネクションを維持し続け、次々とHTTPリクエストを流し込む。

しかし、この「繋ぎっぱなし」の裏で、いつ、どのようにコネクションを切断するのかという「引き際」のルールが、現場のエンジニアを悩ませる要因になる。

—

2. `Connection: close` がトリガーするTCPクローズの全貌

基本は持続的接続だが、クライアントあるいはサーバーのどちらかが 「これでこのコネクションはおしまいだ」 と宣言したい場合がある。それが HTTPヘッダーにおける `Connection: close` だ。

このヘッダーが流れた瞬間、パケットの世界では何が起きるのか。具体的なシーケンスを追ってみよう。

シーケンスの全体像

サーバー(またはクライアント)が `Connection: close` を含んだ最後のレスポンスを返した後の、標準的なTCP四国崩し(4ウェイハンドシェイク)のフローだ。

[Client] [Server]
| |
| <--- [HTTP Response] + "Connection: close" ----- | (お仕事終了の合図) | | | --- [TCP FIN/ACK] -----------------------------> | (サーバー側から切断要求)
| <--- [TCP ACK] --------------------------------- | (受領確認) | | | <--- [TCP FIN/ACK] ----------------------------- | (クライアント側からも切断要求) | --- [TCP ACK] ---------------------------------> | (受領確認)
| |
| [TIME_WAIT状態] (約2分間ポート保持) |

ここで重要なのは、HTTPのレイヤーで `Connection: close` が宣言されると、アプリケーションがデータを送り終えた側(通常はサーバー側からが多い)が、自ら積極的にTCPの `FIN` パケットを送信してコネクションの切断(Active Close)を開始するという点だ。

—

3. 現場を泣かせる「TIME_WAIT」の正体と悪影響

先ほどのシーケンス図の最後に登場した `TIME_WAIT`。
これが、インフラエンジニアにとって最も頭の痛いキーワードの一つだ。

なぜ TIME_WAIT が必要なのか?

TCPの仕様(RFC 793)において、Active Closeを行った側(先に `FIN` を送った側)は、すぐに完全消滅せず、`2MSL`(Maximum Segment Lifetimeの2倍、通常は60秒〜120秒)の間、そのソケット(IPアドレスとポート番号のペア)を `TIME_WAIT` 状態で保持し続けなければならない。

理由は主に2つある。
1. ロストした最後のACKの再送対応: 自分が送った最後の `ACK` が途中で消滅した場合、相手が再送してくる `FIN` を受け取るため。
2. 迷子パケットの消滅: 過去のコネクションで使用されていた古いパケットが、新しい別のコネクションに混入してデータを破壊するのを防ぐため。

`Connection: close` 多用が引き起こす悲劇

もし、APIサーバーが毎回のリクエストに対して律儀に `Connection: close` を返し、自らActive Closeを連発したとしたらどうなるか?

サーバー側のエフェメラルポート(クライアントとして外向きに通信する場合や、OSが内部で割り当てるポート)や、あるいはリバースプロキシとバックエンド間のコネクションにおいて、膨大な数のソケットが `TIME_WAIT` 状態に釘付けになる。

Linuxサーバーでは、利用可能なポート数が枯渇すると、新規のTCP接続を受け付けられなくなる。
`1秒間に1000リクエスト` をさばくAPIサーバーで、すべての通信が `Connection: close` によって `TIME_WAIT`(保持期間60秒)に入ったとしよう。

$$\text{必要な同時ソケット数} = 1000 \text{ req/sec} \times 60 \text{ sec} = 60,000 \text{ ポート}$$

Linuxのデフォルトのエフェメラルポート範囲を遥かに超え、システムは悲鳴を上げ、`Address already in use` や `Cannot assign requested address` のエラーで崩壊する。

—

4. 実務で遭遇するコードと設定の具体例

では、実際の開発やインフラ運用において、この挙動をどうコントロールすべきか。
いくつかの具体例を見ていこう。

パターンA: Python (Requests) での挙動と制御

Pythonの `requests` ライブラリを使う際、デフォルトでは内部でコネクションプール(Keep-Alive)が効いている。しかし、明示的にセッションを切断したい場合や、古いサーバーと通信する際の挙動だ。

import requests

セッションオブジェクトの作成(コネクションプールの維持)
session = requests.Session()

通常のリクエスト(Keep-Aliveが有効、コネクションは維持される)
response1 = session.get(‘https://api.example.com/v1/data’)
print(response1.headers.get(‘Connection’)) # 大抵は “keep-alive”

あえてコネクションを即座に閉じさせたい場合(Connection: closeを指定)
headers = {‘Connection’: ‘close’}
response2 = session.get(‘https://api.example.com/v1/data’, headers=headers)

このレスポンスを受け取った後、クライアント側からもFINが送信され、
このソケットは速やかにTIME_WAITへと移行する。
session.close() # セッション全体の破棄時にも同様にクローズが発生

パターンB: Nginx からバックエンド(App Server)への設定

WebフロントにNginxを置き、背後にGunicornやPumaなどのアプリケーションサーバーを配置している構成を想像してほしい。
Nginxとバックエンド間のコネクション維持(Keep-Alive)を適切に行わないと、毎回のバックエンド通信で `TIME_WAIT` が爆発する。

以下は、Nginxからバックエンドへの接続で持続的接続を維持し、無駄な `Connection: close` を発生させないための模範的な設定だ。

server {
listen 80;
server_name api.example.com;

location / {
proxy_pass http://backend_cluster;

# HTTP/1.1をプロキシで使用することを明示(デフォルトはHTTP/1.0)
proxy_http_version 1.1;

# バックエンドへ流す際に “Connection: close” ヘルパーをクリアする
# (クライアントからの close 指示をそのままバックエンドに伝えない)
proxy_set_header Connection “”;

# バックエンドとのアイドル接続を維持するバッファやタイムアウト設定
proxy_read_timeout 60s;
proxy_send_timeout 60s;
}
}

upstream backend_cluster {
server 127.0.0.1:8080;

# Nginxのアップストリーム側でコネクションプールを維持(KEEPALIVE)
# これにより、バックエンドとのTCPハンドシェイクとTIME_WAITの発生を最小限に抑える
keepalive 32;
}

この設定により、Nginxとバックエンド間のTCPコネクションは再利用され、無慈悲な `TIME_WAIT` の嵐からサーバーを救い出すことができる。

—

5. シニアエンジニアからの実務的アドバイスとデバッグTips

最後に、現場でトラブルシューティングを行う際に必ず役に立つ、実践的な知見をいくつか共有しよう。

1. `tcpdump` や Wireshark での確認方法

「本当にコネクションが綺麗に切れているか?」を確かめるには、パケットキャプチャが一番確実だ。
例えば、特定のポート(例: 8080)のやり取りを `tcpdump` でキャプチャしつつ、FINパケットの流れを確認する。

8080ポートの通信をキャプチャしてファイルに保存
sudo tcpdump -i eth0 tcp port 8080 -w http_close_check.pcap

Wiresharkで開いた際、フラグフィールドに `[FIN, ACK]` が双方向から飛び交い、その後ステータスが消える一連の流れが見えれば、正常にクローズ処理が行われている証拠だ。

2. 커ネルパラメータ(sysctl)での防御的チューニング

どうしても `TIME_WAIT` が避けられない高負荷環境(負荷分散装置やプロキシサーバーなど)では、Linuxカーネルのパラメータチューニングでリスクを軽減する。

`/etc/sysctl.conf` に以下の設定を施すのが定石だ。

TIME_WAIT ソケットの迅速な再利用を許可 (セキュリティ上のリスクが極めて低いケースで有効)
net.ipv4.tcp_tw_reuse = 1

TIME_WAITの保持時間をデフォルト(60秒)より短くすることはRFC違反になるため推奨されないが、
エフェメラルポートの範囲を広げることで枯渇を防ぐことは可能
net.ipv4.ip_local_port_range = 1024 65535

ただし、`net.ipv4.tcp_tw_recycle` は、NAT環境下において異なるクライアントからのパケットを同一セッションと誤認して破棄してしまう重大なトラブルを引き起こすため、現代のLinux(Kernel 4.11以降で完全削除)では絶対に使ってはいけない。時代遅れの古いネット記事に惑わされないよう注意してほしい。

—

まとめ

HTTP/1.1の `Connection: close` とTCPクローズシーケンスは、一見すると地味なプロトコルの仕様に思えるかもしれない。しかし、その挙動を誤解していると、スケールするはずのシステムが高負荷時に突如として倒れるという手痛い洗礼を受けることになる。

  • Keep-Aliveを基本とし、無駄な `Connection: close` を乱発しない。
  • プロキシやリバースプロキシを挟む場合は、バックエンドとのコネクションプール(Keep-Alive)を必ず設計に組み込む。
  • `TIME_WAIT` の意味を正しく理解し、ポート枯渇の兆候があればインフラストラクチャ全体でフローを見直す。

通信の背後にあるパケットの呼吸を感じ取れるようになれば、あなたのインフラストラクチャはより堅牢で、信頼性の高いものへと進化するはずだ。さあ、次のデバッグへ向かおう。

コメント

タイトルとURLをコピーしました