HTTP/2 ストリーム状態遷移の深層:パケットの奔流を支配する有限ステートマシーンの解剖学
ネットワークの底流を流れるパケットの挙動に思いを馳せる時、私たちは常に「秩序と混沌の境界線」に立っている。HTTP/1.xがもたらした「1つのTCPコネクションにつき1リクエスト」という呪縛は、ヘッド・オブ・ライン(HoL)ブロッキングという名の遅延の巨人を産み落とし、我々は長年にわたりドメインシャーディングやスプライト画像といった泥臭いハックでその場凌ぎを続けてきた。
そして登場したHTTP/2は、単一のTCPコネクション上で無数の双方向ストリームを多重化(マルチプレクシング)し、このボトルネックを鮮やかに粉砕した。しかし、アーキテクトやテックリードである我々が理解しなければならないのは、「多重化の裏側で、いかに精緻なステートマシーンが稼働しているか」という厳然たる事実だ。
パケットが1つドロップした瞬間、あるいは悪意あるクライアントが不正なフレームを送りつけた瞬間、この優美なステートの舞踏会は、一瞬にしてリソース枯渇のディストピアへと変貌する。今回は、HTTP/2の心臓部である「ストリーム状態遷移」の全貌を、パケットレベルの内部挙動、TLS最適化、そして実装の急所たるカーネルチューニングの視点から丸裸にしていく。
—
1. HTTP/2ストリームのライフサイクルと5つのステート
HTTP/2の通信単位である「ストリーム」は、独立した一連のフレームのやり取りであり、1つのTCPコネクション上で同時に何百、何千と並行して存在し得る。各ストリームは、厳格なRFC 7540の仕様に基づき、生成から消滅まで以下の5つの状態(State)を遷移する。
+——–+
| |
| idle |
| |
+——–+
/ \
/ \
v v
+———-+ +———-+
| | | |
| reserved | | reserved |
| (local) | | (remote) |
| | | |
+———-+ +———-+
| |
| |
v v
+———-+ +———-+
| | | |
| half | | open |
| (closed) | | |
| | | |
+———-+ +———-+
\ /
\ /
v v
+——–+
| |
| half |
|closed |
|(remote)|
| |
+——–+
| |
| v
| +——–+
| | |
| | closed |
| | |
| +——–+
v /
+——–+
| |
| closed |
| |
+——–+
idle(アイドル)
すべてのストリームの出発点。メモリ上にはまだ実体がほとんど割り当てられておらず、コネクションが確立された直後のすべての新規ストリームIDはこの状態にある。ここから `HEADERS` フレームの送受信によって、次の運命へと押し出される。
reserved(予約済み)
サーバープッシュ(Server Push)の文脈でのみ現れる特殊なステート。
- reserved (local): 自サーバーが `PUSH_PROMISE` フレームを送信し、これからプッシュするリソースのストリームを確保した状態。
- reserved (remote): クライアント側からプッシュの予約を受け入れた状態。
この状態から送受信できるのは、限定的なフレーム(`HEADERS`, `RST_STREAM`, `PRIORITY` など)のみであり、通常のデータやり取りはまだ行えない。
open(オープン)
双方向のデータ送受信が完全に許可された、実働部隊のステート。クライアントもサーバーも、任意の数の `DATA`, `HEADERS`, `CONTINUATION` フレームを互いに送り合うことができる。パフォーマンスとスループットの主戦場はここだ。
half-closed (local / remote)
片方向だけが閉じた状態。
- half-closed (remote): 自らが `END_STREAM` フラグ付きのパケットを送信し終えた(あるいは受信した)状態。こちらからのデータ送信は継続できるが、相手からのデータ送信はもう期待できない。
- half-closed (local): 相手が `END_STREAM` を送ってきたため、相手からのデータ受信は完了したが、こちらからはまだデータを送り続けられる状態。
closed(クローズ)
ストリームの終着駅。すべてのリソースが解放され、このストリームIDを用いた新たなフレームの送受信は原則として拒絶される(例外として、状態遷移直後のタイ競合を防ぐための少数の `RST_STREAM` や `PRIORITY` のみが許容される)。
—
2. フレーム送受信による遷移トリガーの深層
ステート間の遷移は、すべて「どのフレームが流れたか」「どちらがイニシエータか」によって機械的に決定される。
クライアント側のリクエスト送信とサーバーの応答における実際の遷移
1. Idle → Open (Client)
クライアントが新しい奇数のストリームIDで `HEADERS` フレーム(`END_STREAM` フラグなし)を送信した瞬間、クライアント側のストリームは `open` に遷移する。
2. Idle → Open (Server)
サーバー側でその `HEADERS` フレームを受信した瞬間、サーバー側のストリームも `open` に遷移する。
3. Open → Half-closed (local) (Client)
クライアントがリクエストボディ(POSTデータなど)の送信を終え、最後の `DATA` フレームに `END_STREAM` フラグを付与して送出した瞬間、クライアント側は `half-closed (local)` に移行する。
4. Open → Half-closed (remote) (Server)
サーバー側がその `END_STREAM` 付きフレームを受信すると、サーバー側は `half-closed (remote)` となる。サーバーはこの時点でリクエストの全容を把握し、レスポンスの生成フェーズに入る。
5. Half-closed (remote) → Closed (Server)
サーバーがレスポンスのヘッダーとボディを送信し、最後に `END_STREAM` を付与した `DATA` または `HEADERS` を送出した瞬間、サーバー側のストリームは `closed` に落ちる。
6. Half-closed (local) → Closed (Client)
クライアント側がサーバーからの `END_STREAM` を受信した瞬間、クライアント側のストリームも `closed` となり、一連のライフサイクルが完結する。
この一連のプロセスにおいて、もしネットワークの途中で異常が発生したり、タイムアウトが起きた場合、双方からいつでも `RST_STREAM` フレームをぶつけることで、強制的に任意のステートから `closed` へと引きずり下ろすことができる。
—
3. トランスポート層の最適化:TLS 1.3とTCPバッファの現実
HTTP/2のマルチプレクシングは強力だが、その下を支えるTCPとTLSが貧弱であれば、どれだけ洗練されたステートマシーンも宝の持ち腐れとなる。特に「1つのTCPコネクションを共有する」という特性は、単一のパケットロスが全ストリームを巻き込んでストップする(TCPのHoLブロッキング)という最大の弱点を孕んでいる。
TLS 1.3によるハンドシェイクの極限圧縮
HTTP/2の仕様(RFC 7540)では、平文のh2cは事実上普及しておらず、TLS上のh2がデファクトである。TLS 1.3は、従来のTLS 1.2と比較してハンドシェイクの往復(RTT)を劇的に削減した。
- TLS 1.2: TCP 3-way handshake (1 RTT) + TLS handshake (2 RTT) = 合計 3 RTT
- TLS 1.3: TCP 3-way handshake (1 RTT) + TLS 1.3 handshake (1 RTT、0-RTTも可能) = 合計 2 RTT以下
アーキテクトとして特筆すべきは、TLS 1.3の ALPN(Application-Layer Protocol Negotiation) の統合だ。ハンドシェイクの暗号パラメータ交換と同時にHTTP/2のプロトコルネゴシエーションが完了するため、追加のRTTが一切発生しない。
LinuxカーネルにおけるTCPバッファチューニング
多数の並行ストリームが活発にデータを流すHTTP/2環境では、デフォルトのTCPウィンドウサイズでは瞬く間に帯域幅遅延積(BDP: Bandwidth-Delay Product)の限界に達する。Linuxサーバー側で以下のカーネルパラメータをチューニングし、パケットの奔流を受け止めるだけの土管を確保せよ。
/etc/sysctl.d/99-http2-tuning.conf
TCPの送受信バッファの最大値を32MBに拡張(高BDP回線対策)
net.core.rmem_max = 33554432
net.core.wmem_max = 33554432
自動チューニングのバッファ範囲を設定 (最小, デフォルト, 最大)
net.ipv4.tcp_rmem = 4096 87380 33554432
net.ipv4.tcp_wmem = 4096 65536 33554432
マルチプレクシング時の輻輳制御アルゴリズムにはBBRを推奨
パケットロスを「混雑」と誤認せず、帯域の限界を正確に計測する
net.core.default_qdisc = fq
net.ipv4.tcp_congestion_control = bbr
—
4. ヘッダー圧縮 HPACK の仕組みとセキュリティの罠
HTTP/1.xの冗長なテキストヘッダー(User-Agent, Cookieなど)を排除するため、HTTP/2では HPACK が導入された。HPACKは、静的テーブル(あらかじめ定義された65個の共通ヘッダーフィールド)と、動的テーブル(通信中に動的に追加されるセッション固有のテーブル)を組み合わせ、ハフマン符号化を駆使してヘッダーサイズを数分の一に圧縮する。
しかし、この「状態を持つヘッダー圧縮」が、セキュリティ専門家を悩ませる致命的な脆弱性の温床となった。それが HTTP/2 Rapid Reset Attack (CVE-2023-44487) である。
Rapid Reset脆弱性のメカニズム
HTTP/2のストリームキャンセルは、`RST_STREAM` フレームを送るだけで非同期に実行できる。本来これは非常に優れた機能だが、悪意ある攻撃者は以下のような手法をとった:
1. 大量の新しいストリームを `HEADERS` で一斉に `open` 状態にする。
2. サーバーが応答する間もなく、直ちに `RST_STREAM` フレームを送信して強制的に `closed` に遷移させる。
3. これを数千、数万のストリームIDで毎秒ループさせる。
サーバー側は、CPUサイクルとメモリを割いてストリームの作成とHPACKの動的テーブル更新、そして即座の破棄処理に追いつめられ、実際のアプリケーションロジックに到達する前にリソースが枯渇(Denial of Service)してしまう。
対策:Nginx / Envoy / Go言語における防御パラメータのチューニング
現代のプロダクション環境では、単にHTTP/2を有効にするだけでなく、こうしたストリームの暴走を防ぐためのガードレールを明示的に設定しなければならない。
例えば、Nginx環境では以下のディレクティブで並行ストリーム数やリセットの閾値を厳しく制限する。
http {
# 1つのHTTP/2コネクションあたりに許可する最大同時ストリーム数
# 無制限(デフォルトは実質無制限か高め)にせず、128〜256程度に絞る
http2_max_concurrent_streams 128;
# 1つのコネクション内で同時に処理する最大リクエスト数
http2_max_requests 10000;
# HPACKの動的テーブルサイズを制限し、メモリ枯渇を防ぐ
http2_recv_buffer_size 256k;
}
Go言語で独自にHTTP/2サーバーを実装・運用する場合も、標準ライブラリの `http.Server` に用意された構造体で明示的にリソース制限をかけるべきだ。
package main
import (
“crypto/tls”
“net/http”
“golang.org/x/net/http2”
)
func main() {
srv := &http.Server{
Addr: “:443”,
}
// HTTP/2の設定を明示的にカスタマイズ
h2s := &http2.Server{
// 1つのコネクションで許可する最大同時ストリーム数
MaxConcurrentStreams: 128,
// 読み取りバッファのサイズ制限
MaxReadFrameSize: 16384, // 16KB
}
// サーバーにHTTP/2機能を紐付け
http2.ConfigureServer(srv, h2s)
// TLS設定(TLS 1.3を強制)
srv.TLSConfig = &tls.Config{
MinVersion: tls.VersionTLS13,
}
// サーバー起動
// srv.ListenAndServeTLS(“cert.pem”, “key.pem”)
}
—
5. 結びにかえて:パケットの規律を守るアーキテクトの眼差し
HTTP/2のストリーム状態遷移図は、一見すると美しい数理モデルのようにおりなされている。しかし、その背後では、ミリ秒単位のレイテンシーを削り取るためのTCP輻輳制御アルゴリズムが唸りをあげ、HPACKの動的テーブルがメモリ上で絶えず書き換えられ、そして世界中のどこかから容赦ないDDoS攻撃のパケットが送り込まれている。
テックリードやインフラアーキテクトに求められるのは、単に「HTTP/2が有効になっているか」をブラウザのデベロッパーツールで確認することではない。パケットキャプチャの波形からステートの異常を読み解き、カーネルのバッファとアプリケーションのレイヤーを寸分違わず調律し、制御不能なトラフィックの奔流をねじ伏せることだ。
プロトコルを支配する者だけが、真のパフォーマンスと堅牢性を手に入れる。さあ、あなたのアーキテクチャのステートマシーンを、今一度見直そう。
コメント