HTTP/2ストリーム状態遷移の完全理解:パケットの交差点を制する者が Web インフラを制す
おい、最近のWebアプリ、やけに表示が速いと思わないか?
HTTP/1.1の時代、ブラウザは画像やCSSを読み込むために何本もTCPコネクションを張り、それでも足りずにヘッド・オブ・ライン・ブロッキング(Head-of-line blocking)に泣かされていた。あの頃の苦労を知っている身からすると、1本のTCPコネクション上で複数のリクエストとレスポンスを同時に、しかも行儀よく流し込むHTTP/2の「マルチプレクシング」は、まさに魔法の技術に見えたものだ。
しかし、その魔法の裏側を支えているのは、極めて緻密に設計された「ストリーム状態遷移(Stream States)」のルールブックである。
「なぜか突然 `RST_STREAM` が飛んできてコネクションが切れる」
「リバースプロキシのログに見たことのないエラーコードが並んでいる」
現場でこんなトラブルに直面したとき、RFCの仕様書(RFC 7540)の海に溺れかけたエンジニアは少なくないはずだ。今日は、パケットがネットワークの荒波をどう駆け抜け、ブラウザとサーバーの間でどのように命を燃やしているのか、その「ストリームの一生」をシニアの視点から徹底的に解き明かしていこう。
—
1. HTTP/2ストリームの基本概念:なぜ「状態(State)」が必要なのか?
HTTP/1.1では、1つのコネクションにつき同時に1つのリクエストしか処理できなかった(パイプライン化もあったが、実運用ではほぼ封印されていた)。そのため、「今、何を喋っているか」のコンテキストはTCPのバイトストリームの中に暗黙的に存在していただけで十分だった。
だが、HTTP/2は違う。1本のTCPコネクションという「高速道路」の中に、仮想的な車線(ストリーム)を何本も並行して走らせる。
車線AではAPIのJSONを返し、車線ビ(B)では重い画像データを送り、車線Cではサーバープッシュを行っている――このカオスな状態を交通整理するため、HTTP/2の各ストリームには厳格な「ライフサイクル」が定義されている。
すべてのストリームは、必ず以下の5つの状態のいずれかに身を置き、フレーム(HEADERSやDATAなど)の送受信をトリガーにして次々と状態を変えていく。
—
2. 5つのステートとライフサイクルの全貌
まずは、パケットの往来によってストリームがどのように形を変えていくのか、その5つの状態を確認しよう。
+——–+
+—-| idle |—-+
| +——–+ |
| | |
| | (HEADERS)
| v |
| +——–+ |
| | open | |
| +——–+ |
| / \ |
(RST_STREAM) (RST_STREAM)
| / \ |
| v v |
| +——–+ +——–+
+-|half-closed(remote)|
| +——–+ +——–+
| | |
| (END_STREAM) |
| v v
| +——–+ +——–+
+-| half-closed(local)|
| +——–+ +——–+
| | |
| (END_STREAM) |
| v v
+-> +——–+ <-+
| closed |
+--------+
1. `idle`(アイドル)
すべてのストリームの出発点だ。まだパケットは1つも流れておらず、メモリ上にもリソースはほぼ割り当てられていない。ここから奇数ID(クライアント起因)または偶数ID(サーバー起因、主にPush用)のストリームが産声を上げる。
2. `reserved`(予約済み)
サーバープッシュ(Server Push)の文脈で登場するレアケースだ。クライアントが送ったリクエストに対して、サーバーが「このリソースもついでに送るからね」と先回りしてストリームを確保した状態。この状態では、データを送ることはできず、送れるのは `HEADERS` フレームのみとなる。
3. `open`(オープン)
両者双方向で自由にデータ(`DATA`フレーム)のやり取りができる、いわば「稼働中」の状態だ。クライアントはリクエストボディを送り、サーバーはレスポンスを返す。HTTP/2の実力を最も発揮している熱いフェーズ。
4. `half-closed`(半閉じ)
片側の送信が終了した状態。ここがHTTP/1.1脳のエンジニアが最もハマりやすいポイントだ。
- `half-closed (remote)`: クライアントからの送信(リクエスト)は終わったが、サーバーからの送信(レスポンス)はまだ続いている状態。
- `half-closed (local)`: サーバーからの送信は終わったが、こちら(クライアント)からはまだデータを送り続けられる状態。
5. `closed`(クローズ)
ストリームの終着駅。すべてのトラフィックが流れ終え、エラーによる強制終了(`RST_STREAM`)が発生した場合もここに落ちる。この状態に達すると、ストリームIDの再利用はできず、コネクション管理上の最小限のメタデータを除いてリソースは解放される。
—
3. 状態遷移を駆動するフレームたち
ストリームを次の状態へと押し進めるのは、HTTP/2レイヤーで飛び交うフレームだ。主要なものを押さえておこう。
- `HEADERS` フレーム: ヘッダー圧縮(HPACK)されたHTTPヘッダーを運ぶ。`idle` 状態のストリームを `open`(または `reserved`)へ遷移させるトリガーとなる。
- `DATA` フレーム: ペイロード(実データ)を運ぶ。`open` または `half-closed (remote)` の状態で飛び交う。
- `END_STREAM` フラグ: フレームのヘッダーに含まれるフラグ。「これ以上のデータ送信はない」ことを示し、ストリームを `half-closed` や `closed` へ進める決定打になる。
- `RST_STREAM` フレーム: 緊急停止ボタン。どの状態からでも一気に `closed` へ叩き落とす。
—
4. 実務でのデバッグ:Pythonとcurlでパケットの挙動を追う
言葉だけではピンとこない読者のために、実際にHTTP/2の通信を行い、裏で何が起きているかを確認する方法を紹介しよう。
① `curl` による詳細なパケットトレース
まずは、現代のインフラエンジニアの必須ツール `curl` でHTTP/2通信を強制し、フレームのやり取りを覗き見してみる。
–http2 オプションを指定し、-v(verbose)でフレームのやり取りを標準エラー出力に吐き出す
curl -v –http2 https://httpbin.org/get
実行結果のログには、以下のようなHTTP/2特有のハンドシェイクやフレームの断片が現れる。
- Using HTTP/2, server supports multiplexing
- Connection state (HTTP/2 confirmed)
- Copying HTTP/2 data in stream ID: 1 (open)
> GET /get HTTP/2
> Host: httpbin.org
> user-agent: curl/7.88.1
> accept: /
>
- Connection state (MAX_CONCURRENT_STREAMS == 128)
< HTTP/2 200 < content-type: application/json < { "args": {}, "headers": { ... }, ... }
- Connection #0 to host httpbin.org left intact
ログの `stream ID: 1 (open)` という記述に注目してほしい。ここでストリームID「1」が `open` 状態になり、リクエストとレスポンスの往来が完了すると速やかにクローズされる。これが一連のライフサイクルだ。
② Python (httpx) による非同期マルチプレクシングの実装
次に、PythonのモダンなHTTPクライアント `httpx` を使って、1つのコネクション上で複数のストリームを同時に立ち上げるコードを見てみよう。
import asyncio
import httpx
async def fetch_resource(client: httpx.AsyncClient, url: string, stream_id_label: str):
print(f”[{stream_id_label}] リクエスト送信開始”)
# HTTP/2が有効な場合、同一クライアントからのリクエストは自動的にマルチプレクシングされる
response = await client.get(url)
print(f”[{stream_id_label}] レスポンス受信完了: ステータス {response.status_code}”)
async def main():
# HTTP/2を明示的に有効にしたクライアントを作成
async with httpx.AsyncClient(http2=True) as client:
url = “https://httpbin.org/delay/1”
# 3つのリクエストを同時に発火(1本のTCPコネクション上で3つのストリームが並行稼働)
await asyncio.gather(
fetch_resource(client, url, “Stream-A”),
fetch_resource(client, url, “Stream-B”),
fetch_resource(client, url, “Stream-C”),
)
if __name__ == “__main__”:
asyncio.run(main())
このスクリプトを実行すると、サーバー側が1秒の遅延(`/delay/1`)を返すにもかかわらず、3つのリクエストがほぼ同時に完了するはずだ。これは、TCPコネクションを分けることなく、3つの異なるストリームIDが `open` 状態を同時に維持し、パケットをインターリーブ(混合)させているからに他ならない。
—
5. 現場で役立つトラブルシューティングTips
最後に、インフラ運用やWeb API設計の現場で、このストリーム状態の知識がどのように活きるか、実践的な知見をいくつか共有しよう。
1. `RST_STREAM (Error: CANCEL / REFUSED_STREAM)` の嵐
NginxやEnvoyなどのリバースプロキシのログで、突然 `RST_STREAM` が頻発する場合、以下の原因が考えられる。
- クライアント側のタイムアウト: ブラウザやモバイルアプリがレスポンスを待ちきれずにコネクションを切断したとき、クライアントはサーバーに対して `RST_STREAM (CANCEL)` を送り、ストリームを強制的に `closed` にする。サーバー側では無駄なバックエンド処理が走り続けている可能性(ゾンビリクエスト)があるので、プロキシとバックエンド間のタイムアウト設定を見直そう。
- サーバー側の過負荷: 同時ストリーム数の上限(`SETTINGS_MAX_CONCURRENT_STREAMS`)を超えたリクエストや、サーバーリソース不足による `REFUSED_STREAM` が発生していないか、メトリクスを確認すること。
2. サーバープッシュ(Server Push)の罠
`reserved` 状態を生み出すサーバープッシュだが、近年のモダンブラウザ(Chrome等)ではその複雑さやキャッシュ効率の悪さから、サポート縮小や廃止の方向へ進んでいる。実務のAPI設計においては、下手にサーバープッシュを実装するよりも、`103 Early Hints` ステータスコードを活用してブラウザに事前ヒントを与えるモダンなアプローチを選ぶ方が、状態遷移の複雑性を持ち込まずに済むため堅実だ。
—
まとめ
HTTP/2のストリーム状態遷移図は、単なる仕様書の飾りではない。ブラウザから発せられたひとつのリクエストが、どのようなライフサイクルを辿り、どうやって消えていくのかを指し示す「航海図」だ。
この状態遷移のロジックが頭に入っていれば、パケットキャプチャ(Wiresharkなど)を開いたときに、どのストリームがハングアップしているのか、どこでコネクションが不自然に切断されたのかが手に取るようにわかるようになる。
ネットワークの挙動に迷ったときは、原点に立ち返り、今そのパケットがどの「ステート」にいるのかを思い出してほしい。トラブルシューティングの精度が一段階引き上げられるはずだ。
コメント