HTTP/2の裏側で何が起きているのか?CONTINUATIONフレームとヘッダー爆発の現実
ネットワークエンジニアとして現場を渡り歩いていると、教科書通りにいかないカオスなトラフィックに何度も遭遇します。「HTTP/2はマルチプレクシングで高速化する」——それは紛れもない事実です。TCPのコネクションを一本にまとめ、その中で複数のストリームを並行して流す。これによって、HTTP/1.xの足枷だったHead-of-Line阻塞(HoLブロック)は見事に解消されました。
しかし、現場でWeb APIの設計や大規模インフラの運用に携わっていると、この「美しきHTTP/2」の裏側で、時に頭を抱えたくなるようなドラマが繰り広げられていることに気づきます。その主役の一つが、今回スポットを当てる `CONTINUATION` フレーム です。
「ヘッダーが大きすぎて1つのフレームに入りきらないから、分割して送る」
言葉だけを聞くと地味な仕様ですが、ここを理解していないと、いざ本番環境で奇妙なコネクション切断(RST_STREAM)や、セキュリティ上の脆弱性(いわゆる「HTTP/2 Continuation Flood」)に直面したとき、完全に迷子になります。
今日は、パケットがワイヤー上をどのように流れているのか、そのリアルな挙動を紐解きながら、実務で役立つ知識を余すところなくお伝えしましょう。
—
1. なぜヘッダーは分割されなければならないのか?
HTTP/2の基本単位は「フレーム」です。すべてのフレームは、9オクテットの固定ヘッダーから始まります。この構造を思い出してください。
+—————————————————————+
| Length (24) |
+—————+—————+—————+
| Type (8) | Flags (8) |
+-+————-+—————+——————————-+
|R| Stream Identifier (31) |
+-+—————————————————————+
|… Frame Payload (variable) …
+—————————————————————+
ここで注目してほしいのが、フレームの先頭にある Length(ペイロード長) フィールドです。24ビット、つまり最大で約16MB($2^{24}-1$ バイト)までのデータを表現できます。
「じゃあ、16MBまで一気にヘッダーを送れるなら余裕じゃないか」と思いましたか?
ここにHTTP/2の実装上の現実があります。
HPACKと「SETTINGS_MAX_FRAME_SIZE」の制約
HTTP/2では、冗長なHTTPヘッダーを効率的に圧縮するために HPACK という仕組みを使います。しかし、どれほどHPACKが優秀であっても、巨大なCookie、数々のカスタム認証ヘッダー、あるいは複雑なAPIスキーマが絡み合うと、圧縮後のヘッダーブロック(Header Block Fragment)すら巨大化することがあります。
さらに、受信側のサーバーやプロキシ(Nginx, Envoy, API Gatewayなど)は、メモリ保護やDDoS対策の観点から、受け入れ可能なフレームの最大サイズ(`SETTINGS_MAX_FRAME_SIZE`)を厳しく制限しています。デフォルトでは、多くの実装が `16384バイト(16KB)` を上限としています。
もし、送信したいヘッダーブロックがこの上限(例: 16KB)を超えてしまったら?
ここで登場するのが、今回の主役 `CONTINUATION` フレーム(Type: 0x9) です。
—
2. CONTINUATIONフレームの構造と通信フロー
ヘッダーが大きすぎて1つの `HEADERS` フレームに収まりきらない場合、通信は次のようなルールに従って行われます。
1. 先頭は必ず `HEADERS` フレーム(または `PUSH_PROMISE`)で送信する。この時点では、ヘッダーブロックの「一部」しか送られていない。
2. ヘッダーブロックがまだ続くことを示すため、最初のフレームでは `END_HEADERS` フラグを オフ(0) にしておく。
3. 残りのヘッダーフラグメントを送信するために、連続して `CONTINUATION` フレーム を送出する。
4. 最後の `CONTINUATION` フレームには、`END_HEADERS` フラグを オン(1) に設定し、一連のヘッダーブロックの終了を告げる。
シーケンスのイメージ
Client Server
| |
|— HEADERS (Stream ID: 1, End Headers: 0) ————>|
| [ヘッダーブロックの前半部分] |
| |
|— CONTINUATION (Stream ID: 1, End Headers: 0) ——->|
| [ヘッダーブロックの中間部分] |
| |
|— CONTINUATION (Stream ID: 1, End Headers: 1) ——->|
| [ヘッダーブロックの後半部分 + 終了フラグ] |
| |
|— (この後にデータフレームやレスポンスが続く) ———–>|
ここでネットワークエンジニアとして絶対に覚えておかなければならない重要な制約があります。
> 【厳格なルール:原子性の維持】
> `HEADERS` フレームの送信を開始したら、その `END_HEADERS` フラグが立つまでの間、同じストリーム上で他のフレーム(DATAなど)を割り込ませてはならない。さらに言えば、別のストリームのフレームであっても、このヘッダー断片のシーケンスの間に挟み込むことは、実装によってはパーサーを混乱させる原因となります。HTTP/2のマルチプレクシングは強力ですが、ヘッダーの分割伝送に関しては「一気通貫」で処理されなければならないというルールがあります。
—
3. 実務でのデバッグとトラブルシューティング
インフラの現場でこの `CONTINUATION` が問題になるのは、主に 「ヘッダー肥大化によるリソース枯渇」 と 「セキュリティ脆弱性(CVE-2023-44487など)」 の文脈です。
現象:突然の `RST_STREAM` (PROTOCOL_ERROR)
クライアント(あるいはプロキシ)から巨大なCookieやAuthorizationトークンを投げた際、サーバー側が突如としてリクエストを拒絶し、ログに `RST_STREAM` が記録されることがあります。
原因の多くは以下のいずれかです。
- `SETTINGS_MAX_HEADER_LIST_SIZE` の超過: フレームのサイズ(`SETTINGS_MAX_FRAME_SIZE`)とは別に、デコード後のヘッダー全体のサイズ制限にひっかかっている。
- 不当なシーケンス: `HEADERS` の直後に `CONTINUATION` 以外のフレームが割り込んだ場合、受信側はパケットパーサーのエラーとして `PROTOCOL_ERROR` を返します。
パケットキャプチャ(Wireshark)での確認方法
トラブルシューティングの現場では、tcpdumpで取得したpcapファイルをWiresharkで開くのが一番確実です。
フィルタリング式に `http2` と入力し、詳細ツリーを確認してみましょう。
- `HEADERS` フレームの `Flags` フィールダーにおいて、`End Headers` が `0 (Not Set)` になっているか。
- その直後に、連続して `CONTINUATION` フレーム(Type 9)が並んでいるか。
- 最後の `CONTINUATION` フレームで `End Headers` が `1 (Set)` に変わり、そこで初めてストリームが完結しているか。
この一連の流れを目で追うことで、「どこでヘッダーが途切れたか」「プロキシが途中でリクエストをドロップしていないか」が手に取るように分かります。
—
4. 開発現場で役立つコードと設定のプラクティス
では、実際にWeb APIを設計・運用する立場として、このHTTP/2の制約とどう向き合うべきでしょうか。コードと設定の具体例を見ていきましょう。
1. ヘッダー肥大化を防ぐAPIクライアントの実装例(Python / `httpx`)
Pythonの最新標準とも言える `httpx` ライブラリはHTTP/2をネイティブサポートしています。巨大なカスタムヘッダーや不要なCookieを送りつけて `CONTINUATION` 地獄を引き起こさないための、クリーンなリクエスト送信のサンプルです。
import httpx
def call_microservice_api():
url = “https://api.internal.example.com/v1/data”
# 【Tips】
# 必要最小限のヘッダーのみを定義する。
# 巨大なJSON文字列などをカスタムヘッダーに詰めるのはHTTP/2のアンチパターン。
headers = {
“User-Agent”: “InventoryService/2.1.0”,
“Accept”: “application/json”,
“Authorization”: “Bearer eyJhbGciOi…”, # 必要十分なトークン
}
# HTTP/2を有効にしてクライアントを初期化
with httpx.Client(http2=True) as client:
try:
response = client.get(url, headers=headers, timeout=5.0)
# ステータスコードのチェック
response.raise_for_status()
print(f”成功: {response.status_code}”)
return response.json()
except httpx.HTTPStatusError as e:
# サーバー側でヘッダー制限に引っかかった場合などのハンドリング
print(f”HTTPエラー発生: {e.response.status_code} – サーバーがリクエストを拒絶した可能性があります”)
except httpx.RequestError as e:
print(f”通信エラー発生: {e}”)
if __name__ == “__main__”:
call_microservice_api()
2. リバースプロキシ(Nginx)でのヘッダーサイズ制限のチューニング
インフラエンジニアとしてNginxをフロントに置く場合、HTTP/2のヘッダー関連のディレクティブを適切にチューニングしておく必要があります。デフォルトのままだと、クライアントからの巨大なヘッダーを受け止めきれずに `431 Request Header Fields Too Large` やコネクション切断を引き起こします。
以下は、本番環境で推奨されるNginxの設定スニペットです。
server {
listen 443 ssl http2;
server_name api.example.com;
# SSL証明書の設定などは省略…
# 【重要】HTTP/2ヘッダーバッファのチューニング
# クライアントから送られてくる巨大なCookieやトークンを処理するためのバッファサイズ
http2_max_field_size 16k; # 個々のヘッダーフィールドの最大長
http2_max_header_size 64k; # 1つのリクエストに含まれるヘッダー全体の最大長(これを超えると431エラーや切断へ)
location / {
proxy_pass http://backend_cluster;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
# バックエンドへの引き渡し時にもバッファ溢れに注意
proxy_buffer_size 16k;
proxy_buffers 4 32k;
proxy_busy_buffers_size 64k;
}
}
—
5. シニアからのメッセージ:プロトコルを愛せよ
`CONTINUATION` フレームは、普段のアプリケーション開発では意識することの少ない、HTTP/2の「裏方」です。しかし、基盤を支えるネットワークスペシャリストやインフラエンジニアにとって、こうしたフレーム単位の挙動を知っているかどうかが、障害発生時の復旧スピードを劇的に左右します。
「なぜこのリクエストだけが弾かれるのか?」
「なぜプロキシのログに不可解なエラーコードが残るのか?」
その答えの多くは、ワイヤー上のパケット、そして今回解説したフレームの制約の中に隠されています。仕様書の海に飛び込むのは時に億劫ですが、パケットの呼吸が聞こえるようになると、ネットワークのトラブルシューティングは最高にスリリングで知的なエンターテインメントに変わります。
皆さんの設計するシステムが、今日も滑らかに、そして確実にパケットを運び続けられますように。
コメント