HPACKの深層:パケットを極限まで削ぎ落とすHTTP/2ヘッダー圧縮とHuffman符号化の哲学
ウェブの高速化において、私たちは长らく「ペイロード(ボディ)の圧縮」にいそしんできた。BrotliやGzipでHTMLやJSを極限まで小さくし、CDNの端っこでキャッシュを爆発的にヒットさせる。しかし、ふと立ち止まってネットワークアナライザのパケットを覗いてみてほしい。現代のWebアプリケーションが発信するリクエストの群れは、その本体がどれほど小さくとも、頭頂部にそびえ立つ「HTTPヘッダー」の肥大化によって深刻な足枷を嵌められている。
Cookie、User-Agent、Authorization、Accept-Language——。
これらが毎リクエスト、何百バイト、時には何キロバイトという規模でTCPストリームに載せられ、RTT(往復遅延時間)の荒波を往復している。TLSの暗号化ハンドシェイクを終え、いざデータが流れるというその瞬間に、冗長な文字列の重複送信で帯域がむしばまれているのだ。
HTTP/2はこの構造的矛盾にメスを入れた。そしてそのコアキテクチャであるHPACK(RFC 7541)は、単なる圧縮アルゴリズムの域を超え、クライアントとサーバーが「文脈(コンテキスト)を共有する」という、極めてステートフルなメカニズムをトランスポート層の上に構築した。
今回は、このHPACKが隠し持つ「静的・動的テーブルの連携」と「Huffman符号化」の深淵を、パケットレベルの挙動とLinuxカーネルのチューニングの視点から丸裸にしていこう。
—
1. なぜHTTP/1.xのヘッダーは「悪」だったのか
HTTP/1.xのテキストベースのプロトコルは、人間にとっては極めて優しかった。Telnetで接続し、`GET / HTTP/1.1` と叩けばレスポンスが返ってくる。しかし、Webが複雑化し、1ページあたりのリソースリクエスト数が数百に達する現代において、この仕様は致命的なスケーラビリティの欠如を露呈した。
各リクエストはプレーンテキストのヘッダーを抱えている。
GET /style.css HTTP/1.1
Host: example.com
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36…
Accept: text/css,/;q=0.1
Accept-Encoding: gzip, deflate, br
Accept-Language: ja,en-US;q=0.9,en;q=0.8
Cookie: session_id=9a8b7c6d5e4f3a2b1c; user_pref=dark_mode=true; tracking_uuid=f8e7d6c5-b4a3-9281-7060-50403020100f
これを数十の並行接続、あるいはHTTP/1.1のパイプライン(事実上HEAD-OF-LINEブロッキングで死語と化したが)で送り続けると何が起きるか。同じ `User-Agent` や `Cookie` のキーと値が、毎リクエスト、数バイトの例外を除いて全く同じバイト列としてワイヤー(回線)を流れる。
ここに目を付けたのがHTTP/2の設計者たちだ。「毎回同じ文字列を送る必要はどこにもない。一度送ったものはインデックス番号で置き換え、さらに差分だけをインクリメンタルに更新すればいい」——これがHPACKの思想の根幹である。
—
2. HPACKの核心:静的テーブルと動的テーブルの二重構造
HPACKは、通信の両端(クライアントとサーバー)に「ヘッダーテーブル」という名の同一の辞書を持たせることで圧縮率を劇的に高める。このテーブルは、あらかじめ定義された固定の「静的テーブル(Static Table)」と、通信の進行に伴動的に構築される「動的テーブル(Dynamic Table)」の2つで構成される。
静的テーブル(Static Table)の全貌
RFC 7541の付録Aで定義されている静的テーブルには、Webで頻出する61個の標準的なヘッダーフィールド(名前、または名前と値のペア)がハードコードされている。
例えば:
- インデックス `2` は `:method` / `GET`
- インデックス `8` は `:path` / `/`
- インデックス `31` は `content-length`
- インデックス `56` は `user-agent`
これらはプログラムのコード内に埋め込まれているため、クライアントもサーバーも一切のやり取りなしに「インデックス番号だけでそのヘッダーを指し示す」ことができる。
動的テーブル(Dynamic Table)とインクリメンタル更新
静的テーブルに載っていないカスタムヘッダー(例: `X-Request-ID: uuid-xxx` や独自の `Cookie` の値)はどう扱うのか。ここで登場するのが動的テーブルだ。
動的テーブルは、接続(Connection)が確立された時点では空である。しかし、リクエストやレスポンスのヘッダーが処理されるたびに、新しく登場したヘッダーペアがテーブルの先頭(インデックスの若い方)にインクリメンタルに追加されていく。
[初期状態]
Dynamic Table: [ 空 ]
[リクエスト1: “x-custom-header: foobar” が到着]
-> 動的テーブルに追加され、インデックス [62] が割り当てられる。
[リクエスト2: 再び “x-custom-header: foobar” を送信する場合]
-> 文字列をそのまま送る必要はなく、インデックス [62] を指すだけで済む。
この動的テーブルは有限のメモリサイズ(デフォルトでは通常4096オクテット)で制限されており、容量を超過した場合は古いエントリがFIFO(先入れ先出し)方式で自動的にパージされる。このサイズ上限は、HTTP/2の `SETTINGS` フレーム(`SETTINGS_HEADER_TABLE_SIZE`)によって、通信の途中に動的に再ネゴシエーションすることが可能だ。
—
3. Huffman符号化:ビット単位の究極のパッキング
インデックス化によって「文字列の反復」を排除したHPACKだが、それでもなお、動的テーブルに初めて載せる文字列や、どうしても一意に送らなければならない値が存在する。ここで発動するのがHuffman符号化だ。
HPACKが採用しているHuffman符号は、一般的なテキスト圧縮(DEFLATEなど)で使われる動的なツリー構造ではなく、RFC 7541で静的に定義された固定のハフマンコードテーブルを使用する。
なぜ静的なのか?
動的なハフマン木を構築・送信するには、その木構造自体のデータをパケットに含めるか、あるいは送受信者間で完全に同期させるコストがかかる。HTTP/2のヘッダーはサイズが比較的小さいため、動的ツリーの構築オーバーヘッドがかえってマイナスに働く。そのため、HTTPの歴史的統計データから割り出された「出現頻度の高い文字には短いビット列を、低い文字には長いビット列を割り当てる」静的ハフマンテーブルがあらかじめ定められている。
例えば、アルゴリズムの内部では、ASCII文字が次のような可変長ビットに変換される。
- 頻出する小文字の `e` や `space` ⇒ 数ビットの極めて短いコード
- 滅多に出現しない特殊文字 ⇒ 長いコード
これにより、テキストデータを平均して約30%〜50%サイズダウンさせ、TCPセグメントのペイロードサイズを物理的に最小限へと押し下げる。パケットアナライザ(Wireshark等)でHTTP/2のヘッダーブロックを覗くと、人間には解読不能なバイナリの塊(`H`フラグが立ったHPACKブロック)が見えるのはこのためだ。
—
4. パケットレベルの挙動とHTTP/2フレームの解剖学
では、実際のワイヤー上でヘッダーはどのように流れているのか。TCPストリーム上のバイナリ構造を紐解こう。
HTTP/2の通信は、バイナリ形式のフレーム(Frame)単位で行われる。ヘッダーを運ぶ主役は `HEADERS` フレームと、必要に応じて溢れた分を繋ぐ `CONTINUATION` フレームだ。
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Length (24) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Type (8) | Flags (8) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|R| Stream Identifier (31) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|opt: Padding Length (8)| +
+-+-+-+-+-+-+-+-+-+-+-+ Header Block Fragment |
|use: Priority (32) (HPACK圧縮されたバイト列)… |
| … | |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
この `Header Block Fragment` の内部に、HPACKの表現形式(Representation)が詰まっている。HPACKのバイト表現にはいくつかのモードがある。
1. Indexed Header Field: 上位ビットが `1` で始まる。静的・動的テーブルのインデックス番号をそのまま指す。
2. Literal Header Field with Incremental Indexing: 上位2ビットが `01`。新しいヘッダー名と値を送りつつ、それを動的テーブルに「新規追加」する。
3. Literal Header Field without Indexing: 上位4ビットが `0000`。動的テーブルを汚染したくない機密性の高いヘッダー(例: `Authorization` トークンなど)や一度きりの値に使う。
4. Dynamic Table Size Update: 動的テーブルのサイズ変更を通知する。
この精緻なビット演算の組み合わせにより、CPUサイクルを最小限に抑えつつ、ネットワーク帯域を極限まで節約しているのだ。
—
5. 攻防の裏側:HPACKを狙った脆弱性とセキュリティ対策
ここまでの話を聞くと、HPACKは完璧な仕組みに見えるだろう。しかし、インフラアーキテクトやセキュリティ専門家が忘れてはならないのが、「ステートを持つ複雑なプロトコルは、常に悪意ある攻撃のターゲットになる」という現実だ。
HPACK Bomb(CVE-2019-9512など)の脅威
2019年に発覚したHTTP/2の脆弱性群(GoAwayやRST_STREAMの悪用と並び)の中に、HPACKの動的テーブルの仕様を悪用した「HPACK Bomb」が存在した。
攻撃者は、極限まで圧縮された悪意あるヘッダーブロックをサーバーに送りつける。
- ハフマン符号化と動的テーブルの参照(リテラル表現での動的テーブルへのエントリ追加)を組み合わせる。
- ワイヤー上ではわずか数百バイトのパケットであるにもかかわらず、サーバー側でそれを展開(Decompress)し、動的テーブルや内部のヘッダーバッファに展開すると、数メガバイト、あるいはギガバイト単位のメモリを消費する。
サーバーのメモリが瞬く間に枯渇し、OOM Killer(Out of Memory Killer)が発動してプロセスがクラッシュする——これが、状態を持つ圧縮アルゴリズムが抱える「CBR(Compression Bomb)」のネットワーク版である。
実務での防衛策
現代の堅牢なHTTP/2実装(Nginx, Envoy, Apache, あるいはGo/Node.jsの標準ライブラリなど)は、この攻撃を防ぐための厳格なリミッターを備えている。
- 最大ヘッダーリストサイズの制限(`SETTINGS_MAX_HEADER_LIST_SIZE`): 展開後のヘッダー総サイズにハードリミットを設け、それを超えた瞬間にストリームを強制切断(RST_STREAM)する。
- 動的テーブルサイズの適正化: メモリの無駄な肥大化を防ぐため、サーバー側で動的テーブルの上限(例: 4096オクテット)を厳しく維持し、攻撃者が意図的にテーブルを巨大化させるのを防ぐ。
プロキシやAPI Gatewayを構築する際は、これらのパラメータがデフォルトのまま安全にコンフィグされているか、必ず検証しなければならない。
—
6. パフォーマンスの極限へ:RTT、TLS、TCPバッファチューニング
HPACKとHTTP/2の真価を発揮させるためには、トランスポート層とセキュリティ層のチューニングが不可欠である。どれほど洗練されたヘッダー圧縮機構があっても、下位レイヤーが詰まっていれば意味がない。
1. TLS 1.3ハンドシェイクと0-RTTの罠
HTTP/2は実質的に暗号化(TLS)が必須(H2Cはほとんど普及していない)であるため、TLSのハンドシェイクコストがつきまとう。
- TLS 1.3を採用し、ハンドシェイクを1-RTTに短縮する。
- 0-RTT(Resumption)の利用は、再送攻撃(Replay Attack)のリスクを伴うため、安全性が検証されていない書き込みリクエスト(POST等)には適用せず、GETリクエストや静的アセットの取得に限定するなどの慎重な設計が求められる。
2. Linuxカーネルパラメータ(TCPチューニング)
HTTP/2は単一のTCPコネクション上で複数のストリームを多重化(Multiplexing)するため、TCPのHEAD-OF-LINEブロッキング(パケットロスによる全ストリームの停止)の影響を強く受ける。これを緩和・最適化するためのLinuxカーネル設定例を挙げる。
`/etc/sysctl.conf` の推奨設定:
TCPバッファの動的サイジングを有効化し、BDP(Bandwidth-Delay Product)を最大化する
net.ipv4.tcp_rmem = 4096 87380 16777216
net.ipv4.tcp_wmem = 4096 65536 16777216
輻輳制御アルゴリズムに BBR を採用する(高遅延・変動回線でのスループット最大化)
net.core.default_qdisc = fq
net.ipv4.tcp_congestion_control = bbr
ウィンドウのスケーリングを有効化
net.ipv4.tcp_window_scaling = 1
TIME_WAITソケットの再利用を許可
net.ipv4.tcp_tw_reuse = 1
特に、Googleが開発したBBR(Bottleneck Bandwidth and RTT)輻輳制御アルゴリズムの適用は、HTTP/2のマルチプレクシング環境において、パケットロス発生時のスループット急低下を防ぐ上で極めて効果的である。
—
結びにかえて
HPACKにおける静的・動的テーブルの協調とHuffman符号化は、単に「データを小さくする技術」ではない。それは、無機質なバイトの奔流であるネットワーク通信に「文脈(コンテキスト)」という記憶を宿し、プロトコル全体を洗練されたエコシステムへと昇華させた偉大な発明である。
私たちが何気なくブラウザにURLを入力し、一瞬でリッチなWebアプリケーションが描画される裏側では、このようなミリ秒単位のビットパッキングと、カーネルレベルのシビアなチューニングが幾重にも組み合わされている。
インフラを愛するエンジニアよ、たまにはtcpdumpやWiresharkを開き、そのトランスポート層の深淵に流れるバイナリの息吹に耳を澄ませてみてほしい。そこには、美しく最適化されたエンジニアリングのロマンが確実に息づいているのだから。
コメント