HTTP/2 HPACK動的テーブルの深層:LRU制御とパケットロスがもたらす「不可視のレイテンシ」の正体
ウェブの高速化において、HTTP/1.1の呪縛であった「ヘッダーの冗長性」をいかに排除するかは、長年アーキテクトたちの頭を悩ませてきた問題だ。CookieやUser-Agent、Authorizationヘッダーなど、毎リクエスト数キロバイトに及ぶテキストがTCPストリームの初期ウィンドウを圧迫し、TCPスロースタートの恩恵を台無しにしていた。
HTTP/2はこの課題に対し、HPACKという専用の圧縮メカニズムを導入して解答を出した。静的テーブル(Static Table)と動的テーブル(Dynamic Table)を組み合わせたこの仕組みは、一見するとエレガントなバイト列の削減技術に見える。しかし、パケットレベルの挙動、そしてトランスポート層(TCP/TLS)の制約と交差させるとき、HPACKの動적テーブル管理は、ひとたび設計を誤れば致命的なパフォーマンス劣化やセキュリティリスクを引き起こす「諸刃の剣」に変貌する。
今回は、このHPACK動的テーブルの心臓部である「LRU(Least Recently Used)アルゴリズムによるエントリ入れ替え制御」と、それがネットワーク全体の挙動に与える影響について、パケットアナライザの視点とカーネルチューニングの現場から徹底的に解剖していこう。
—
1. 静的テーブルの限界と動的テーブルのメカニズム
HTTP/2の仕様(RFC 7541)で定義されるHPACKは、ヘッダーフィールドをインデックスに置き換えて圧縮する。
- 静的テーブル (Static Table): あらかじめ仕様書で定義された61個の標準的なヘッダー(例: `:method: GET` や `content-type: application/json` など)のペア。これは不変である。
- 動的テーブル (Dynamic Table): 通信のライフサイクル(コネクション単位)の中で、動的に生成・追加されるテーブル。例えば、カスタムヘッダーや、変化するCookieの値を記憶するために使われる。
動的テーブルの本質は、「送信側と受信側が、完全に同期したステートマシン(状態機械)を維持し続けること」にある。送信側(例えばクライアント)が「この新しいヘッダーを動的テーブルのインデックス62番として追加せよ」とエンコードして送信した瞬間、受信側(サーバー)も同じバイト列をデコードし、全く同一のインデックスに同一のヘッダーを配置しなければならない。
もし、この同期がわずかでも崩れれば、後続のすべてのヘッダーデコードは致命的な破損(Compression Error)を引き起こし、HTTP/2コネクションそのものが `RST_STREAM` または `GOAWAY` によって強制切断される。
—
2. LRUアルゴリズムによるテーブルサイズ管理とエントリの入れ替え
動的テーブルは無限に肥大化させるわけにはいかない。メモリ消費量を制御し、かつ各エンドポイントでのリソース枯渇を防ぐため、仕様ではテーブルの最大許容サイズ(`SETTINGS_HEADER_TABLE_SIZE`)が厳格に定義されている。デフォルトでは4,096バイトだが、サーバーとクライアントのネゴシエーションによって動的に変更される。
ここで問題になるのが、「新しいヘッダーを追加する余地がない場合に、古いエントリをどう排除するか」という点だ。HPACKでは、動的テーブルの管理にLRU(Least Recently Used)アルゴリズムが採用されている。
パケットおよびメモリ上の挙動
1. エントリの追加: 新しいヘッダーフィールドがエンコードされ、動的テーブルに追加される際、そのサイズ(名前の長さ+値の長さ+32バイトのオーバーヘッド)が計算される。
2. 容量超過の検知: 現在のテーブル総サイズに新しいエントリを加えた値が、許可された最大サイズを超える場合、テーブルの「最も古く参照された(あるいは最も長期間使われていない)」エントリから順に削除(Eviction)される。
3. インデックスの再割り当て: エントリが削除されると、残ったエントリのインデックス番号は自動的にシフトアップ(若い番号へ移動)する。
この挙動をC言語風の疑似コードとコメントで追いかけてみよう。
include
include
include
// HPACK動的テーブルのエントリ構造体
typedef struct HeaderEntry {
char name;
char value;
size_t size; // 名前の長さ + 値の長さ + 32バイトのオーバーヘッド
struct HeaderEntry next;
struct HeaderEntry prev;
} HeaderEntry;
typedef struct {
HeaderEntry head; // 最も新しいエントリ
HeaderEntry tail; // 最も古いエントリ(LRUにより次に削除される)
size_t max_size; // 許可された最大テーブルサイズ (SETTINGS_HEADER_TABLE_SIZE)
size_t current_size; // 現在使用中のサイズ
} DynamicTable;
// エントリの削除とサイズ調整(LRUの核心部)
void evict_entries_if_needed(DynamicTable table, size_t incoming_size) {
while (table->current_size + incoming_size > table->max_size && table->tail != NULL) {
HeaderEntry victim = table->tail;
// サイズを減算
table->current_size -= victim->size;
// 連結リストから切り離し
table->tail = victim->prev;
if (table->tail != NULL) {
table->tail->next = NULL;
} else {
table->head = NULL; // テーブルが空になった
}
// メモリ解放
free(victim->name);
free(victim->value);
free(victim->size); // ※実際には構造体自体のfree
free(victim);
}
}
このコードが示す通り、動的テーブルは常にメモリ上の厳密な上限管理と隣り合わせで動作している。インフラエンジニアとして注目すべきは、「アプリケーションの挙動によって、このLRUエビクションが激しく発生し、CPUキャッシュヒット率やメモリ割り当てのオーバーヘッドが増加する」という点だ。多様なユーザーがバラバラのカスタムヘッダーを送信する環境では、動的テーブルのヒット率が低下し、単なるメモリの無駄遣いになりかねない。
—
3. トランスポート層(TCP/TLS)との密接な関係:ヘッド・オブ・ライン・ブロッキングの罠
HPACKの動的テーブル管理は、レイヤー4のTCPおよびレイヤー5-6のTLS/QUICの挙動と深く結びついている。ここにネットワークアーキテクトが最も頭を悩ませるポイントがある。
TCPパケットロスとHPACKの死活問題
HTTP/2の最大のメリットはマルチプレクシング(単一のTCPコネクション上で複数のストリームを並列処理すること)だが、これは同時に「TCPレイヤーでのパケットロスが、全ストリームをまとめてフリーズさせる(Head-of-Line Blocking)」という弱点も意味する。
ここでHPACKの動的テーブルが絡むと、事態はさらに複雑化する。
1. パケットAの送信: サーバーが「新しい動的テーブルエントリを追加する」というエンコードを含んだHEADERSフレームを送信する。
2. パケットロス: ネットワーク上のルータのバッファ溢れにより、パケットAがドロップする。
3. パケットBの到着: パケットAより後に送信された(あるいは別ルートを通った)パケットBがクライアントに到着する。パケットBは、「先ほどパケットAで動的テーブルに追加されたはずのインデックス」を参照している。
4. デコード失敗: クライアント側の動的テーブルには該当するインデックスのエントリが存在しないため、デコードエラー(COMPRESSION_ERROR)が発生し、コネクションが即座にアボートされる。
この脆弱性を避けるため、HPACKの実装では「動的テーブルの更新を含むパケットが確実に届くまで、それを参照する後続のパケットの送信を遅らせる」、あるいは「パケットロス発生時の再送制御(TCPのSACKなど)に依存せざるを得ない」という物理的制約が生じる。
TLS 1.3 / TCPバッファチューニングの極意
このパケットロスのリスクを最小限に抑え、HPACKの効率を極限まで引き出すためには、以下のLinuxカーネルパラメータおよびTLS層のチューニングが不可欠となる。
/etc/sysctl.conf におけるネットワークスタックの最適化例
1. TCP初期ウィンドウサイズ(IW10)の確保
コネクション確立直後のスロースタートを回避し、HPACKテーブル初期設定を含む初期リクエストを1RTTで叩き込む
(近年のLinuxカーネルではデフォルトで10ですが、明示的に確認・維持)
net.ipv4.tcp_slow_start_after_idle = 0
2. BBR混雑制御アルゴリズムの採用
損失ベースのCUBICではなく、帯域とRTTをベースにするBBRにより、バッファ溢れ(Bufferbloat)を防ぎパケットロスを劇的に削減
net.core.default_qdisc = fq
net.ipv4.tcp_congestion_control = bbr
3. TCPウィンドウサイズの動的チューニング範囲の拡大
HTTP/2の大量のパラレルストリームと動的テーブル更新の往復を支えるため、メモリバッファを拡張
net.ipv4.tcp_rmem = 4096 87380 16777216
net.ipv4.tcp_wmem = 4096 65536 16777216
特に `BBR` の導入は、HPACK動的テーブルの同期不全リスクを低減する上で極めて効果的だ。パケットロスそのものを発生させにくいネットワークパスを維持することが、HTTP/2のステートフルな圧縮メカニズムを守る最良の防御策となる。
—
4. セキュリティ上の脅威:HPACKテーブル容量攻撃(Memory Exhaustion)
アーキテクトやセキュリティ専門家が最も警戒しなければならないのが、HPACKの仕様を悪用したサービス拒否(DoS)攻撃、いわゆる「HPACKテーブル容量攻撃(CVE-2019-9516 など)」だ。
攻撃者は、悪意あるHTTP/2クライアントまたはサーバーとなり、次のような攻撃を仕掛ける。
1. 極端に大きな `SETTINGS_HEADER_TABLE_SIZE` を要求、あるいは動的テーブルサイズを毎フレーム激しく変動させる。
2. 巨大なカスタムヘッダー(数メガバイト規模の文字列)を次々と送信し、動的テーブルのLRUエビクションとメモリ再割り当て(malloc/free)を無限にループさせる。
3. これにより、ターゲットのメモリ(RAM)を枯渇させ、プロセスをOOM Killerの餌食にする。
対策とアーキテクチャ設計
堅牢なリバースプロキシ(Nginx, Envoy, Cloudflareのインフラなど)や自社製APIゲートウェイでは、この攻撃を防ぐために厳格なガードレールが設けられている。
- 最大テーブルサイズのハードリミット: クライアントからの `SETTINGS` フレームによる動的テーブルサイズの変更要求に対し、サーバー側で上限(例: 4096バイトや8192バイトなど)を厳しく制限し、それを超える要求はコネクション切断とする。
- メモリプールの活用: LRUエビクションのたびにヒープ領域の `malloc/free` を繰り返すのではなく、事前に静的に確保したメモリプール(Slab allocatorなど)内でエントリのポインタを付け替える実装にすることで、CPU枯渇攻撃(CPU exhaustion)を無効化する。
—
5. 次世代(HTTP/3)への架け橋と、HPACKの未来
ここまでHTTP/2のHPACK動的テーブルの深層を見てきたが、現代のインターネットはさらにその先、HTTP/3(QUICベース)へと移行しつつある。
HTTP/3で採用されているのは、HPACKの進化系であるQPACKだ。
HPACKが抱えていた最大の弱点である「TCPのヘッド・オブ・ライン・ブロッキングによる動的テーブルの同期ズレ」を解決するため、QPACKでは動的テーブルを「送信側が送るプレフィックス(Encoder Stream)」と「受信側が確認応答を返す(Decoder Stream)」に分離し、パケットロスが発生しても他のストリームがブロックされない構造にリファクタリングされている。
しかし、どれほどプロトコルが進化しようとも、「限られたメモリリソースの中で、ステートフルなテーブルをLRUなどのアルゴリズムでいかに効率よく管理するか」という根本的なコンピュータサイエンスの課題は変わらない。
現場のテックリードやインフラエンジニアにとって、目の前のブラウザやクライアントが発する1本のパケットが、どのようにカーネルのバッファを通り、どのようにHPACKの動的テーブル上でLRUの波に揉まれているか――そのミクロな挙動を脳内に描き切る能力こそが、真に堅牢で爆速なWebインフラを構築する唯一の武器となるのだ。
コメント