パケットの鼓動を聞け:HTTP/2ストリーム状態遷移の深層と現場のトラブルシューティング
こんにちは、シニアネットワークアーキテクトの私です。
これまで幾多の本番障害、原因不明のタイムアウト、そして「なぜか突然切断されるコネクション」の山を越えてきました。その度に私が何をしていたかといえば、パケットキャプチャを広げ、TCPの向こう側で息づくHTTP/2のマルチプレクシングの鼓動に耳を澄ませていました。
HTTP/1.1の呪縛である「Head-of-Line Blocking(行頭ブロック)」を打破するためにHTTP/2が導入されて久しいですが、実務の現場――とりわけ大規模なWeb API設計や、シビアなレイテンシが要求されるマイクロサービス間通信において、「HTTP/2のストリーム状態遷移(Stream States)」を正確に理解しているエンジニアは驚くほど少ない。
「あ、またRST_STREAM(エラーコード: CANCEL)で流れている……なんでだ?」
そんな現場の嘆きを今日で終わりにしましょう。RFC 7540の冷徹な仕様書を紐解きつつ、現場で本当に役立つ実務的知見をあなたにお届けします。
—
1. なぜストリームの状態遷移を知る必要があるのか?
HTTP/1.1では、1つのTCPコネクションにつき同時に1つのリクエストしか処理できませんでした(パイプライン化は実質的に破綻していました)。しかし、HTTP/2は単一のTCPコネクション上に「ストリーム(Stream)」という仮想的な双方向チャネルを何本も多重化(マルチプレクシング)します。
ここで重要になるのが、「今、そのストリームが何合目にいて、どのフレームを送信していいのか」という状態管理です。
もしクライアントやサーバーが、この状態遷移のルールを無視して勝手なタイミングでフレーム(HEADERSやDATAなど)を送りつけるとどうなるか。プロトコル違反として容赦なく `RST_STREAM` フレームが飛んできたり、最悪の場合はコネクション全体が `GOAWAY` で強制切断されます。「APIクライアントから突然 `ERR_HTTP2_PROTOCOL_ERROR` が返ってくる」という現象の多くは、この状態遷移の理解不足に起因しています。
—
2. 状態遷移の全貌:IdleからClosedへのロードマップ
HTTP/2のストリームは、そのライフサイクルにおいて以下の主要な状態を渡り歩きます。まずは全体の全体像を把握してください。
+——–+
+——-| idle |——-+
| +——–+ |
| (送受信) (受信) |
v v
+———-+ +———-+
| | | |
| open | | reserved |
| | | (local) |
+———-+ +———-+
| | | |
| (送信) | | (受信) |
v v v v
+—————–+ +—————–+
| | | |
| half-closed | | half-closed |
| (remote) | | (local) |
+—————–+ +—————–+
| | | |
| (受信) | | (送信) |
v v v v
+——–+ +——–+
| closed | | closed |
+——–+ +——–+
主要な4つの状態
1. `idle`(アイドル)
- すべてのストリームが生まれる最初の状態です。まだどのフレームも送受信されていません。
- できること: クライアントなら `HEADERS` フレームを送信してストリームを `open` にできます。
2. `open`(オープン)
- 双方向で自由にフレーム(`DATA`, `HEADERS`, `RST_STREAM` など)のやり取りができる状態です。
- サーバーもクライアントも、相手からのデータを受け取りつつ、自分のデータを送り続けることができます。
3. `half-closed (remote)` / `half-closed (local)`(ハーフクローズ)
- 片方向からの送信は終わった(`END_STREAM` フラグを受信、または送信した)けれど、反対方向からはまだデータを送受信できる状態です。
- 例えば、クライアントがリクエストの送信を終えた(`END_STREAM`付きのHEADERSを送った)が、サーバーからのレスポンスをまだ待っている状態は、クライアント側から見ると `half-closed (remote)` になります。
4. `closed`(クローズド)
- ストリームの終着駅です。これ以上のフレーム交換は行えません(例外として、極稀に直後の `RST_STREAM` に対するパケット競合の処理を除き、基本はリソースの解放対象となります)。
—
3. 現場でハマる!「送信して良いフレーム・いけないフレーム」の鉄則
インフラエンジニアやバックエンド開発者が最も頭を悩ませるのが、「この状態でこのフレームを送るとどうなるか」という点です。いくつか実務で致命傷になりやすいポイントを挙げます。
- `idle` 状態での非 `HEADERS` フレームの送信
- いきなり `DATA` フレームを特定のストリームID(例: ストリームID `3`)で送ることは許されません。必ず最初に `HEADERS`(またはサーバー側なら `PUSH_PROMISE`)でストリームを顕現させる必要があります。違反した場合は `CONNECTION_ERROR`(`PROTOCOL_ERROR`)となり、TCPコネクションが切断されます。
- `half-closed (remote)` でのデータ送信
- 相手からの終了通知を受けているにもかかわらず、こちらがさらに `DATA` を送ろうとすると、プロトコル違反となり、相手から `RST_STREAM`(エラーコード: `STREAM_CLOSED`)が返されます。
—
4. 実務での検証:Python & curl で見るストリームの挙動
机上の空論はここまでにして、実際に手を動かしてHTTP/2のストリームがどのように動いているかを確認してみましょう。ここでは、Pythonの `httpcore` / `httpx` ライブラリを使用して、リクエストとレスポンスのストリーム制御を紐解くコードスニペットを紹介します。
Python (Httpx) を使ったリクエストの非同期マルチプレクシング例
実務のAPIクライアント実装において、複数のHTTP/2ストリームを同一コネクション上で同時に走らせるコードです。
import asyncio
import httpx
async def fetch_api_stream(client: httpx.AsyncClient, url: str, stream_id_hint: int):
“””
HTTP/2コネクション上で非同期にリクエストを投げる関数。
同一のClientインスタンスを使用することで、HTTP/2のコネクションプーリングと
マルチプレクシング(単一TCP上の複数ストリーム)が有効になります。
“””
print(f”[Client] ストリーム作成準備: {url}”)
try:
# GETリクエスト送信。ここで裏側では新しいストリームIDが割り当てられ、
# idle -> open へ遷移します。
response = await client.get(url)
print(f”[Client] レスポンス受信成功: Status={response.status_code}, URL={url}”)
# レスポンスボディの読み込み完了により、ストリームは half-closed -> closed へ遷移
except httpx.HTTPError as e:
print(f”[Client 異常終了] 通信エラー発生: {e}”)
async def main():
# HTTP/2を明示的に有効化したHTTPXクライアントの構築
async with httpx.AsyncClient(http2=True) as client:
# 同一ドメインへの複数のリクエスト
# これらは原則として「1つのTCPコネクション」上の「異なるストリームID(1, 3, 5…)」として
# 多重化されて流れます。
urls = [
“https://httpbin.org/delay/1”,
“https://httpbin.org/bytes/1024”,
“https://httpbin.org/json”
]
# 同時並行(マルチプレクシング)でリクエストを発射
tasks = [fetch_api_stream(client, url, i) for i, url in enumerate(urls, start=1)]
await asyncio.gather(tasks)
if __name__ == “__main__”:
# イベントループの実行
asyncio.run(main())
トラブルシューティングTips: `curl` でのパケットレベルのデバッグ
「バックエンドのNginxやEnvoyプロキシが、特定のクライアントからのリクエストを突如 `RST_STREAM` で弾いている」そんなときのデバッグには、`curl` の詳細ログ(`-v` や `–http2`)がファーストチョイスになります。
HTTP/2での通信を強制し、フレームのやり取りを詳細にstderrに出力する
curl -v –http2 “https://api.example.com/v1/users”
出力結果の中に、以下のような記述を見つけたら要注意です。
- Using HTTP2, stream ID 3
- Connection state (MAX_CONCURRENT_STREAMS = 100)
…
- http2 error: Stream closed with error code: PROTOCOL_ERROR (err 1)
- RST stream 3
これは、「ストリームID `3` においてプロトコル違反が検出されたため、サーバー側(またはクライアント側)が強制的にストリームを破棄した」ことを意味します。このログを見たら、ヘッダーの順序、偽装された疑似ヘッダー(`:method`, `:path` など)、あるいはすでに `closed` になったストリームへのデータ送信が行われていないかを疑ってください。
—
5. シニアアーキテクトからの提言:プロトコルエラーを未然に防ぐために
HTTP/2のストリーム状態遷移は、美しい理論であると同時に、実装のバグやネットワークの不確実性に対して非常にタイトに作られています。HTTP/1.1の感覚で「適当にデータを投げてもよしなにやってくれるだろう」という甘えは、大規模トラフィックの前では必ずシステム障害という形でしっぺ返しを食らいます。
- プロキシやロードバランサー(Envoy, Nginx, ALBなど)のタイムアウト設定を合わせる:
Idle状態やHalf-closed状態でコネクションが中途半端に残留すると、資源のリークや予期せぬ `RST_STREAM` の嵐を招きます。keepaliveやタイムアウト値(`http2_stream_idle_timeout` など)は適切にチューニングしましょう。
- クライアントライブラリのバージョンを常に最新に:
古すぎるHTTP/2実装(gRPCや古いOkHttpなど)は、エッジサーバーとの間でステートマシンの解釈に微小なズレが生じ、断続的なコネクション切断を引き起こすことがあります。
パケットの流れる音に耳を澄まし、ステートマシンの今どこにいるのかをイメージできるようになれば、あなたも立派なネットワーク・スペシャリストです。
次回のトラブルシューティングでは、ぜひ `nghttp2` や `Wireshark` を開いて、ストリームのライフサイクルをこの目で追ってみてください。それでは、また次の現場でお会いしましょう。
コメント