HTTP/2コネクション管理の真実:なぜ1本のTCP接続がWebの未来を変えたのか
「おい、ちょっと画面を見てくれ。APIのレスポンスが妙に遅延する瞬間があるんだ。パケットキャプチャを取ったら、なんだかTCPの3ウェイハンドシェイクが頻発しているように見えるんだけど……」
先日、若手のエースエンジニアからそんな焦った声で相談を受けた。
ブラウザを開けば当たり前のように高速にコンテンツが描画される現代において、私たちはつい「HTTP通信が速いのは当たり前」だと思い込みがちだ。しかし、その裏側でネットワークとプロトコルがどのように協調し、泥臭くパケットをさばいているのかを意識しているエンジニアは、実はそう多くない。
HTTP/1.1の時代、私たちは「ドメインシャーディング(複数ドメインへの分割)」という涙ぐましい努力で、ブラウザの同時接続数制限(通常は1ドメインあたり6本程度)を突破しようと悪戦苦闘していた。リクエストの数だけTCP接続が乱立し、スロースタートのオーバーヘッドに泣かされ、HOL(Head-of-Line)ブロックに頭を抱えたものだ。
しかし、HTTP/2はそのゲームのルールを根本から書き換えた。
単一のTCP接続上で複数のストリームを多重化(マルチプレクシング)し、ヘッダーをHPACKで極限まで圧縮する。これにより、私たちは「1本の永続化されたTCPパイプライン」を手に入れた。
今回は、このHTTP/2の命綱とも言える「接続の再利用とコネクション管理」について、実務の現場で即座に役立つ知識とデバッグのノウハウを交えながら、徹底的に紐解いていこう。
—
1. HTTP/2コネクションの基本ライフサイクル:なぜ「1本」にこだわるのか
HTTP/2の最大の恩恵は、「1つのTCP接続上で、数千・数万の論理的ストリームを同時に流せる(Multiplexing)」という点にある。
HTTP/1.1の永続接続(Keep-Alive)では、1つの接続につき同時に処理できるリクエストは1つだけだった。そのため、重い画像やAPIレスポンスの到着を、後ろの小さなCSSやJSONが待たされる「ヘッド・オブ・ライン・ブロッキング(HOLブロック)」が頻発した。
HTTP/2では、データを「フレーム(Frame)」という細かい単位に分割し、それぞれに `Stream ID` を付与して混在させて送受信する。これにより、TCPセグメントの順序制御による足かせをアプリケーション層で華麗に回避できるようになったのだ。
接続確立から終了までのフロー
[Client] [Server]
|— 1. TCP 3-Way Handshake (SYN) ———->|
|<-- (SYN-ACK) ------------------------------|
|--- (ACK) --------------------------------->|
| |
|— 2. TLS Handshake & ALPN (“h2”) ——->|
|<-- (Cert, Key Exchange, Finished) ---------|
| |
|--- 3. HTTP/2 Connection Preface (Client) ->|
|<-- 4. SETTINGS Frame & Connection Preface -|
| |
|=== [Multiplexed Streams over 1 TCP] =======|
|--- Stream 1 (GET /api/v1/users) ---------->|
|— Stream 3 (GET /api/v1/items) ———->|
|<-- Stream 1 Response (Headers + Data) -----|
|<-- Stream 3 Response (Headers + Data) -----|
| |
|--- 5. GOAWAY Frame (Graceful Shutdown) --->|
|<-- FIN / Close ----------------------------|
ここで重要なのが、TLSハンドシェイク時の拡張機能であるALPN(Application-Layer Protocol Negotiation)だ。クライアントとサーバーは、TLSのネゴシエーションの最中に「次に来る通信はHTTP/2(識別子: `h2`)で行こう」と合意を形成する。これが失敗すると、強制的にHTTP/1.1へとフォールバックすることになる。
—
2. 接続の維持とパラメーター設計(Keep-Aliveとタイムアウト)
「1本のTCP接続をずっと維持し続けられるなら、永遠に張りっぱなしでいいじゃないか」と思うかもしれない。しかし、実務のインフラ設計ではそう甘くない。
L4/L7ロードバランサー(AWS ALB、Nginx、Envoyなど)、ファイアウォール、NATゲートウェイには、それぞれ「アイドルタイムアウト(Idle Timeout)」が存在する。一定時間パケットが流れないと、ステートフルな機器は勝手にセッション情報を破棄してしまう。
サーバー・プロキシ側での適切なパラメーター設定
Nginxをリバースプロキシとして運用する場合、HTTP/2のコネクション管理を最適化するには以下のディレクティブが極めて重要になる。
http {
# HTTP/2接続のアイドルタイムアウト
# デフォルトは長めだが、LBのタイムアウトに合わせて明示的に短く設定するのが実務の定石
keepalive_timeout 65s;
# 1つのTCP接続あたりに処理させる最大リクエスト数
# これを超えると、サーバー側からGOAWAYを送信して优雅に再接続を促す(メモリリーク対策)
keepalive_requests 10000;
server {
listen 443 ssl http2; # Nginx 1.25.1以降は listen 443 ssl; に加え http2 on;
# HTTP/2のウィンドウサイズや同時ストリーム数の制御
# クライアントからの過剰なストリーム同時オープンを防ぐ
http2_max_concurrent_streams 128;
# クライアントへ定期的にPINGフレームを送り、接続が生きているか確認する設定(EnvoyやNginx Plus等で重要)
}
}
GOAWAYフレームによる優雅な切断(Graceful Shutdown)
コネクションを破棄する際、HTTP/2には非常に洗練された仕組みがある。それが`GOAWAY`フレームだ。
サーバー側でメンテナンスやオートスケーリングによるスケールインが発生した場合、突然TCPのRST(強制切断)を送るのではなく、`GOAWAY`をクライアントに送信する。
これにより、「これ以降の新しいStream IDの作成は拒否するが、すでに受け付けたストリームの処理は最後までやり切る」という、クライアントに優しい協調的な切断が可能になる。インフラエンジニアとしては、この`GOAWAY`がログに頻発していないかを監視することが、接続安定性のバロメーターとなる。
—
3. 実践:コードとツールで見るHTTP/2接続の挙動
それでは、実際にコードやコマンドを使って、HTTP/2の接続再利用がどのように行われているかを確認してみよう。
① `curl` を使ったデバッグとHTTP/2の確認
モダンな `curl` コマンドであれば、`-v`(verbose)オプションをつけるだけで、HTTP/2のネゴシエーションや接続の再利用状況が一目でわかる。
HTTP/2でリクエストを送り、プロトコルバージョンを確認する
curl -v –http2 https://api.example.com/health
出力の注目ポイント:
- Connected to api.example.com (192.0.2.1) port 443 (#0)
- ALPN, offering h2
- ALPN, offering http/1.1
- Cipher selected is TLS_AES_256_GCM_SHA384
- Using HTTP/2, server supports multiplexing
- Connection state changed (HTTP/2 confirmed)
- Copying HTTP/2 data in stream ID: 1 (for GET /health)
ちゃんと `#0` という単一の接続(Connection ID)上で、`stream ID: 1` としてパケットが流れていることが確認できる。
② Python (httpx) による持続的接続(Connection Pooling)の利用
HTTP/2の恩恵を最大限に受けるには、アプリケーション側でもコネクションプール(Connection Pooling)を適切に維持する必要がある。Pythonの標準 `requests` はHTTP/2をネイティブサポートしていないため、次世代クライアントである `httpx` を使用するのがデバッグやAPI連携の現場ではデファクトだ。
import httpx
httpx.Clientはデフォルトでコネクションプールを維持し、HTTP/2をサポートします
複数のリクエストを同じインスタンス経由で投げると、同一のTCP接続が再利用されます。
with httpx.Client(http2=True) as client:
urls = [
“https://httpbin.org/get?id=1”,
“https://httpbin.org/get?id=2”,
“https://httpbin.org/get?id=3″
]
for url in urls:
response = client.get(url)
print(f”URL: {url}”)
print(f”HTTP Version: {response.http_version}”) # “HTTP/2″ と出力されるはず
print(f”Status: {response.status_code}”)
print(“-” 40)
実務での教訓:
ループの都度 `httpx.Client()` を新しくインスタンス化(`with`ブロックを毎回作成)してしまうと、毎回TCPハンドシェイクとTLSネゴシエーションが走り、HTTP/2のマルチプレクシングのメリットが完全にスポイルされる。クライアントインスタンスはアプリケーションのライフサイクル全体でシングルトン(またはプール)として使い回すことが鉄則だ。
—
4. 現場でハマるトラブルシューティングとTips
最後に、シニアエンジニアとして幾度となく現場の修羅場をくぐり抜けてきた中で得た、HTTP/2コネクション管理に関する「生きた知見」をいくつか授けよう。
トラブル1:プロキシやWAFを挟んだ途端にHTTP/2が落ちる(フォールバック地獄)
- 症状: ローカル環境(直叩き)では `h2` で通信できるのに、本番環境のCDNやWAF(CloudflareやAWS CloudFrontなど)を挟んだ瞬間に、なぜかHTTP/1.1に落ちる、あるいは妙なコネクションエラーが多発する。
- 原因: クライアントとサーバーの間にいる中継プロキシがHTTP/2に対応していなかったり、古すぎるSSL/TLSバージョン(TLS 1.0/1.1)を強制しているケース。また、ALPNのネゴシエーション時に中間機器が独自の不正なパケットを挿入している場合もある。
- 対策: `openssl` や `curl` の詳細ログで、どのレイヤーでプロトコルがダウングレードしたのかを追跡する。
OpenSSLを使ってサーバーのALPN対応状況を直接確認する
openssl s_client -connect api.example.com:443 -alpn h2,http/1.1
トラブル2:ロングポーリングやServer-Sent Events (SSE) でのストリーム枯渇
- 症状: HTTP/2の1本の接続上で、SSE(Server-Sent Events)や長時間かかるストリーミングAPIを流し始めた途端、他の通常のAPIリクエストが一切通らなくなる(あるいは非常に遅くなる)。
- 原因: HTTP/2は1つの接続における「最大同時ストリーム数(`SETTINGS_MAX_CONCURRENT_STREAMS`)」がサーバー側で制限されている(通常は100〜128程度)。ロングポーリングなどでストリームを占有し続けると、枠が埋まってしまい、新規のリクエストがブロックされてしまう。
- 対策:
1. 重いストリーミング通信やリアルタイム通信を行うエンドポイントは、通常のAPI用コネクションとは別のオリジン(別ドメイン、あるいは別ポート)に分離する。
2. サーバー側の `SETTINGS_MAX_CONCURRENT_STREAMS` の値を適切にチューニングする。
—
おわりに:プロトコルの裏側にある「意図」を読め
HTTP/2の接続管理は、単なる「設定値の調整」ではない。
パケットが1本の太いパイプラインをどのように流れ、ロードバランサーやアプリケーションがどう呼吸しているのか——その一連のダイナミクスを頭の中でイメージできるようになると、ネットワークのトラブルシューティングは「勘と経験」から「論理的な推理」へと劇的に変わる。
「なぜ今、ここでTCPの再接続が起きているのか?」
ログやパケットキャプチャの向こう側にあるプロトコルの声に耳を澄ませば、障害の原因自ずと見えてくるはずだ。さあ、今日のデバッグ作業も鮮やかにクリアしてやろう。
コメント