Dev Study
← HTTPとTLSの仕組み
レッスン 11 / 12·約 9 分·第18期 HTTPとTLSの仕組み

共有する表は、届く順序を要求する

リクエストがアプリケーションに届くまで
  1. ブラウザURL入力今ここ
  2. DNS名前→IP
  3. ルータ経路選択
  4. ロードバランサ振り分け
  5. Nginx受け口今ここ
  6. アプリ処理
  7. DB保存

前回は、順序を守る単位をストリームに移したことを見ました。今回は、ヘッダの表を共有するには何がさらに共有されていなければならないかを見ます。

まず結論

HTTP/2 のヘッダ圧縮 HPACK は、両端が同じ表を持っていることを前提に、2回目以降のヘッダを短い番号で送ります。共有しているから省略できる、というこのコースの芯そのものです。

ただし表は届いたブロックで順に書き換わるので、表を共有するには届く順序も共有していなければなりません。HTTP/2 はその順序を TCP から借りていました。ストリームをまたぐ順序を保証しない QUIC の上では、HTTP/3 は表の使い方を変えた QPACK に置き換えています。

? 読む前に予測

同じ HPACK エンコーダで同じ5つのヘッダを2回符号化しました。2回目のブロックだけを、1回目を一度も受け取っていない新しいデコーダに渡すとどうなるでしょうか。

当てることが目的ではありません。外した予測ほど記憶に残るので、直感で選んでください。

抽象原理

番号で送れるのは、同じ表を持っているから

このコースの芯は「両端が何かを共有していれば省略でき、共有していなければ往復が要る」でした。ヘッダ圧縮はその見本です。

HPACK(RFC 7541)は、ヘッダを表の番号で送ります。表は2種類あります。仕様に固定で書かれた静的表(1〜61番。:method: GET が2番など)と、接続の中で送ったヘッダを追加していく動的表です。

Encoding is informed by header field tables that map header fields to indexed values. These header field tables can be incrementally updated as new header fields are encoded or decoded.

送り手は「このヘッダを表に追加せよ」と書いたブロックを送り、受け手はそれを読みながら自分の表に同じ項目を追加します。次からは番号1つで済みます。RFC 9113 はこの状態を接続単位のものと定めています。

Field compression is stateful. Each endpoint has an HPACK encoder context and an HPACK decoder context that are used for encoding and decoding all field blocks on a connection.

ここに条件が1つ隠れています。表はブロックを処理した順に書き換わります。受け手が送り手と同じ表を持てるのは、送られた順に処理したときだけです。表を共有するには、届く順序も共有していなければなりません。

動的表は、ブロックを処理した順に両端で同じように育つ
1回目のブロック2項目を「表に追加せよ」付きのリテラルで送る
受け手が処理すると
動的表 62: user-agent後から入った項目ほど小さい番号
動的表 63: :authority先に入った項目
この表が両端にある前提で
2回目のブロック5ヘッダすべてを番号1つずつで送る
  • ・静的表は仕様に固定されているので、順序に関係なく引ける
  • ・動的表の番号は、それまでに処理したブロックの並びで決まる
具体実物

1回目を見ていない受け手に、2回目だけを渡す

Python の hpack 4.2.0(Docker の python:3.12-slim、Python 3.12.14)で、同じ5つのヘッダを同じエンコーダで2回符号化し、2回目だけを新しいデコーダに渡しました。

import sys, importlib.metadata as md
import hpack
from hpack import Encoder, Decoder
print("python", sys.version.split()[0], "hpack", md.version("hpack"))
headers = [(":method","GET"),(":scheme","https"),(":authority","example.com"),(":path","/index.html"),("user-agent","l11-demo/1.0")]
enc = Encoder()
b1 = enc.encode(headers)
b2 = enc.encode(headers)
print("1st block:", len(b1), "bytes", b1.hex())
print("2nd block:", len(b2), "bytes", b2.hex())
d = Decoder()
print("in-order decode 1st:", d.decode(b1))
print("in-order decode 2nd:", d.decode(b2))
fresh = Decoder()
try:
    print("fresh decoder, 2nd only:", fresh.decode(b2))
except Exception as e:
    print("fresh decoder, 2nd only -> exception:", type(e).__module__ + "." + type(e).__name__, "|", e)
2回実行して同じ出力(1回目を掲載)コマンド出力
python 3.12.14 hpack 4.2.0
1st block: 24 bytes 828741882f91d35d055c87a7857a89a0215a42d27602b83f
2nd block: 5 bytes 8287bf85be
in-order decode 1st: [(':method', 'GET'), (':scheme', 'https'), (':authority', 'example.com'), (':path', '/index.html'), ('user-agent', 'l11-demo/1.0')]
in-order decode 2nd: [(':method', 'GET'), (':scheme', 'https'), (':authority', 'example.com'), (':path', '/index.html'), ('user-agent', 'l11-demo/1.0')]
fresh decoder, 2nd only -> exception: hpack.exceptions.InvalidTableIndex | Invalid table index 63

名前と値を合わせて81バイトのヘッダが、1回目は24バイト、2回目は5バイトになりました。ただしこの数字はヘッダの組と Huffman 符号(文字列を短く詰める固定の符号表。RFC 7541 §5.2)の有無で変わります。同じ組を huffman=False で符号化すると1回目は30バイト、2回目は同じ5バイトでした。2回目が5バイトなのは、5つとも表の参照1バイトで済んだからです。

2回目の5バイトを読むと、先頭ビットが1(表の参照)で、下位7ビットが番号です。

2nd block 8287bf85be の読み(RFC 7541 §2.3.3・Appendix A)表
バイト 番号 指す表 中身
82 2 静的表 :method: GET
87 7 静的表 :scheme: https
bf 63 動的表 :authority: example.com
85 5 静的表 :path: /index.html
be 62 動的表 user-agent: l11-demo/1.0

順に処理したデコーダは、1回目で動的表に2項目を足しているので62と63を引けます。新しいデコーダの動的表は空なので、最初の動的参照63で止まりました。

この「新しいデコーダ」は、1回目を受け取り損ねた受け手の模型です。実際の HTTP/2 では TCP が順序を保つので、この形の失敗は起きません。復号に失敗すれば、RFC 9113 §4.3 は接続全体のエラー(COMPRESSION_ERROR)として扱うよう定めています。表が接続全体で1つだからです。

✓ ここまでの確認

実演の2回目のブロック `82 87 bf 85 be` のうち、`bf`(索引63)は何を指していますか。

抽象原理

順序を借りられない下の層では、表の使い方を変える

HTTP/2 の下の TCP は、接続の全バイトを1本の順序で渡します。HPACK はその順序を前提に作られていました。RFC 9204 はこう書きます。

If HPACK were used for HTTP/3 ([HTTP/3]), it would induce head-of-line blocking for field sections due to built-in assumptions of a total ordering across frames on all streams.

HTTP/3 の下の QUIC は、ストリームをまたぐ順序を保証しません。そこで HTTP/3 は HPACK を QPACK に置き換えました(RFC 9114 §2)。

変わったのは表を書き換える役と参照する役を分けたことです(同 §2)。更新は専用のストリームで流し、各リクエストのヘッダは表を書き換えず参照だけします。すると残る問題は「参照した項目が、受け手にもう届いているか」の1つになります。

References to the static table and literal representations do not require any dynamic state and never risk head-of-line blocking. References to the dynamic table risk head-of-line blocking if the encoder has not received an acknowledgment indicating the entry is available at the decoder.

届いたと確認できた項目だけを参照すれば、待ちは起きません。確認前の項目を参照すれば圧縮は効きますが、損失や入れ替わりがあればそのストリームが待たされることがあります。受け手は待たせてよいストリーム数の上限を宣言し、その範囲でどちらを取るかは送り手が決めます(§2.1.2 An encoder can decide whether to risk having a stream become blocked.)。

具体実物

QPACK の既定値は、待たせる参照を禁じている

RFC 9204 §5 が定める設定は2つで、どちらも既定値は0です。

RFC 9204 §5 Configuration表
設定 意味 既定値
SETTINGS_QPACK_MAX_TABLE_CAPACITY 動的表の容量 0
SETTINGS_QPACK_BLOCKED_STREAMS 待たせてよいストリームの数 0

容量が0なので、何も宣言しなければ動的表そのものを使いません。待たせてよい数は受け手が上限を宣言し、送り手はそれを守らなければなりません(§2.1.2 An encoder MUST limit the number of streams that could become blocked to the value of SETTINGS_QPACK_BLOCKED_STREAMS at all times.)。

受け手が1以上を宣言した場合に限り、確認前の項目を参照でき、損失や順序の入れ替わりがあればそのストリームが待つことがあります(§2.1.2 the stream can become blocked at the decoder)。待つのは、その参照を含むストリームです。

RFC 9204 §1 は設計目標を substantially less head-of-line blocking under the same loss conditions と書いています。ゼロとは書いていません。待ちが起きうる上限は受け手が決め、その内側で危険を取るかは送り手が決めます。

この QPACK の挙動は仕様の記述から読んだもので、手元の実演はしていません。

✓ ここまでの確認

何も設定を交わしていない既定の QPACK で、エンコーダは受け手に届いたと確認できていない動的表の項目を参照してよいですか。

⇄ 2つを見比べる
具体例 A

HPACK(HTTP/2、TCP の上)

TCP が接続の全バイトを1本の順序で渡すので、ヘッダブロック自体が表を書き換えても両端の表はずれません。その代わり、表は接続全体で1つの状態で、復号の食い違いは接続エラーになります。

順序と表の関係表
ストリームをまたぐ順序 TCP が1本の順序で渡す
表を書き換えるもの 各ヘッダブロック
食い違ったとき COMPRESSION_ERROR(接続エラー)
具体例 B

QPACK(HTTP/3、QUIC の上)

QUIC はストリームをまたぐ順序を保証しないので、表の更新を専用ストリームに分け、ヘッダは参照だけにしました。待たせてよいストリーム数の上限は受け手が宣言し(既定は0)、その範囲で確認前の項目を参照するかは送り手が選びます。

順序と表の関係表
ストリームをまたぐ順序 保証されない
表を書き換えるもの 専用の単方向ストリーム
確認前の項目の参照 受け手が許した数まで(既定0)

同じ「表を共有して番号で送る」仕組みが、下の層によって作りを変えています。2つに共通する前提は何でしょうか。

表の中身だけでなく、表が「いつの時点の表か」を両端がどうやって揃えているかを比べてみてください。

この章のまとめ

  • HPACK は静的表と動的表の番号でヘッダを送る。番号で済むのは、両端が同じ表を持っているから。
  • 動的表は処理した順に書き換わる。表を共有するには、届く順序も共有していなければならない。
  • hpack 4.2.0 の実演では、この5ヘッダが24バイトから5バイトになった。数字はヘッダの組と Huffman の有無で変わる。1回目を見ていない受け手は InvalidTableIndex(索引63)で止まる。
  • HTTP/2 はこの順序を TCP から借りていた。復号の食い違いは接続エラーになる。
  • QUIC はストリームをまたぐ順序を保証しないので、HTTP/3 は QPACK に置き換えた。表の更新は専用ストリームで送り、ヘッダは参照だけする。
  • QPACK は動的表の容量も、待たせてよいストリーム数も既定0。確認前の項目の参照は、受け手が宣言した数までしか許されない。
  • RFC 7541 §1 は、SPDY の DEFLATE 圧縮が CRIME 攻撃で示された危険を招いたことを HPACK の背景に挙げている。これは順序とは別の話。

自分の現場で確かめる

読んだだけでは身につきません。手元の環境で実際に見てみてください。

環境:

理解度チェック

読んだ直後は「分かった」と感じやすいものです。実際に解いて確かめてください。

問題 1 / 3正解: 0

HTTP/2 で、あるヘッダブロックの復号に失敗した場合の扱いとして RFC 9113 が定めているのはどれですか。

用語集(4語)
HPACK
HTTP/2 のヘッダ圧縮(RFC 7541)。静的表・動的表・リテラルで表す。表は符号化・復号のたびに更新される。
動的表
接続の中で送ったヘッダを追加していく表。HTTP/2 での初期サイズは4,096バイト(RFC 9113 §4.3.1)。
QPACK
HTTP/3 のヘッダ圧縮(RFC 9204)。表の更新は別の単方向ストリームで送り、ヘッダ側は表を書き換えずに参照だけする。
ブロックされたストリーム
参照している動的表の項目がまだ届いておらず、ヘッダを処理できずに待っているストリーム(RFC 9204 §2.1.2)。何本まで許すかは受け手が宣言し、既定は0。