HTTP/2の「ストリーム」を制する者は、モダンWebのボトルネックを制する
HTTP/1.1の時代、私たちは「ブラウザが一度に開けるコネクションは6つまで」という制約に縛られ、ドメインシャーディングやアセットの結合といった涙ぐましい努力で回線をやりくりしていました。しかし、HTTP/2の登場によってその景色は一変しました。
HTTP/2の核心は「ストリーム(Stream)」にあります。今回は、単なる概念論にとどまらず、現場のエンジニアがトラブルシューティングで必ず立ち返ることになるストリームのライフサイクルと、その挙動をパケットレベルで理解するための深掘り解説をお届けします。
—
1. ストリームとは何か?―「論理的」という魔法
HTTP/2において、TCP接続はただの「土管」です。その中を流れる個々のリクエスト/レスポンスのやり取りを識別するために導入されたのが「ストリーム」です。
ストリームは、「単一のTCP接続内で確立される、双方向の論理的なバイトストリーム」です。
重要なのは、これらがインターリーブ(多重化)される点です。パケットが届く順番を待つことなく、複数のリクエストが混ざり合って一つのTCPセッションを流れる。このおかげで、大きな画像データのロード中に小さなJSONデータが割り込んで先着する、といった芸当が可能になりました。
—
2. ストリームのライフサイクル:誕生から消滅まで
ストリームは、RFC 7540で厳密に定義された状態遷移を辿ります。現場で「なぜかレスポンスが返ってこない」「リセットされる」といった現象に出くわしたとき、この状態遷移を頭に描けるかどうかが勝負の分かれ目です。
主要な状態遷移
1. Idle(アイドル): まだ何も始まっていない状態。
2. Open(オープン): 両端でデータの送受信が可能な状態。
3. Half-Closed(半クローズ): 片側からの送信が終了した状態。例えば、クライアントがリクエストを送り終えたが、サーバーからのレスポンスを待っている状態です。
4. Closed(クローズ): 全ての処理が完了した状態。
特にHalf-Closedは重要です。クライアントがリクエストのヘッダーと共に`END_STREAM`フラグを送った後、サーバーが処理を完了して`END_STREAM`を返すまでの間、TCP接続は維持されたままこの「半クローズ状態」を駆け抜けます。ここで接続が切れると、HTTP/2のレイヤーで`RST_STREAM`(強制終了フレーム)が発行され、デバッグログに「Stream Reset」の文字が踊ることになります。
—
3. 実践:ストリームを確認する(デバッグTips)
現場で「HTTP/2の多重化がうまく機能しているか?」を確かめるには、`curl`のオプションが非常に優秀です。以下のコマンドで、ストリームIDの割り当てを確認してみてください。
–http2 オプションで強制的にHTTP/2を試行
-v でフレームの詳細を表示し、ストリームIDを確認する
curl -v –http2 https://example.com/api/v1/data
出力結果の中に `Using HTTP2, server supports multi-use` や、`[stream-id=1]` といった記述が見えるはずです。もしブラウザで確認したい場合は、Chromeの `chrome://net-export/` を使い、[netlog-viewer](https://netlog-viewer.appspot.com/) に流し込むのが、シニアエンジニアの定石です。
—
4. Web API設計における注意点:ストリームの限界
ストリームは無制限に作れるわけではありません。HTTP/2には `SETTINGS_MAX_CONCURRENT_STREAMS` という設定パラメータが存在します。
Nginxでの設定例
もしあなたがインフラエンジニアなら、バックエンドの負荷に合わせてこの値を調整する必要があります。
http {
# 同時に処理するストリーム数の上限(デフォルトは通常100〜128)
# 大規模なマイクロサービス環境では、ここを絞ることで過負荷を防ぐ
http2_max_concurrent_streams 128;
}
この値を超えたリクエストは、クライアント側でキューイングされます。APIのレイテンシが妙に高いと感じたときは、サーバー側でこの上限に達していないか、あるいはクライアントが不用意にストリームを大量展開(スパム)していないかを確認しましょう。
—
5. Pythonでの実装イメージ:非同期ストリーム
Pythonの `httpx` ライブラリなどを使うと、この多重化の恩恵を直感的に感じることができます。
import asyncio
import httpx
async def fetch_data(client, url):
# このリクエスト一つ一つが独立したストリームIDを持つ
response = await client.get(url)
return response.status_code
async def main():
# 接続を再利用(コネクションプール)し、HTTP/2ストリームを活用
async with httpx.AsyncClient(http2=True) as client:
tasks = [fetch_data(client, “https://api.example.com/data”) for _ in range(10)]
results = await asyncio.gather(tasks)
print(f”全リクエスト完了: {results}”)
asyncio.run(main())
このコードを実行すると、背後では一つのTCPコネクションの中で、ストリームIDが1, 3, 5, 7…とインクリメントされながら、並行してデータが流れている様子が観察できます。
—
最後に:ネットワークを「視る」力を養う
HTTP/2のストリームは、ネットワーク上の「見えない壁」を突破するための強力な武器です。しかし、その強力さゆえに、一度問題が起きるとプロトコルスタックの深い階層まで潜る必要があります。
「なぜリクエストが止まるのか?」「なぜヘッダーが肥大化するのか?」
そんな疑問にぶつかったときは、RFCの仕様を追いかける前に、まずは `Wireshark` で `http2.streamid` をフィルタリングして、パケットの往来を眺めてみてください。パケットは嘘をつきません。
現場でトラブルに立ち向かう皆さんの健闘を祈ります。技術は、常に現場の最前線で磨かれるものです。
コメント