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

HTTP/3 は、順序を守る単位をストリームに移した

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

前回は、HTTP/2 でも下の TCP の順序は1本のままだと見ました。今回は、並べ直しの順序を両端がどの単位で共有しているかを見ます。

まず結論

HTTP/2 は要求に番号を付けて1本の接続に混ぜましたが、並べ直しの順序は下の TCP 接続1本で両端が共有したままでした。1つのパケットが欠けると、関係の無い要求まで待たされます。

HTTP/3 が載る QUIC は、両端が順序を共有する単位を接続からストリームへ狭めました。欠けたストリームだけが待ちます。ただし輻輳制御は接続全体で共有したままで、「すべてが独立した」わけではありません。

? 読む前に予測

QUIC は UDP の上で動きます。では HTTP/3 で、1つの応答のバイト列はアプリへ順序どおりに渡されるでしょうか。

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

抽象原理

並べ直しを待つ範囲を、どこで区切るか

TCP は1本の接続を1列のバイト列として届けます。RFC 9001 §2.1 は TLS の下にある TCP を a reliable transport (typically TCP), which provides sequencing and guaranteed delivery と書きます。1列なので、途中が1つ欠けると、その後ろは届いていても渡せず待ちます。

HTTP/2 はこの1列の上に、番号付きのストリームを混ぜます。しかし番号は HTTP/2 の側にあり、TCP からは見えません。RFC 9114 §1.1 はこう書きます。

However, because the parallel nature of HTTP/2's multiplexing is not visible to TCP's loss recovery mechanisms, a lost or reordered packet causes all active transactions to experience a stall regardless of whether that transaction was directly impacted by the lost packet.

両端の TCP が共有している順序は、接続全体で1つだけです。だから欠けたパケットと無関係な要求まで止まります。

QUIC は順序を共有する単位を変えました。RFC 9000 §2 です。

Streams in QUIC provide a lightweight, ordered byte-stream abstraction to an application.

QUIC does not provide any means of ensuring ordering between bytes on different streams.

各ストリームの中は順序どおり。ストリームの間には順序が無い。並べ直しの待ちはストリームの中に閉じます。HTTP/3 はこれを使い、RFC 9114 §2 は Each request-response pair consumes a single QUIC stream. と定め、こう続けます。

Streams are independent of each other, so one stream that is blocked or suffers packet loss does not prevent progress on other streams.

両端が順序を共有する単位
HTTP/2 のストリーム番号は HTTP/2 の側にある
HTTP/3 の要求QUIC のストリームを1本ずつ使う
並べ直しを待つ単位
TCP 接続 1本1つ欠けると全ストリームが待つ
QUIC のストリームごと欠けたストリームだけが待つ
混雑の推測(輻輳制御)
TCP 接続で1つ
QUIC 接続全体で1つここは分けていない
  • ・左が HTTP/2 over TCP、右が HTTP/3 over QUIC
  • ・QUIC が分けたのは順序の共有であって、接続まるごとではない
具体実物

ストリーム番号 0 と 4 を見る

nginx:alpine(nginx/1.31.6)を HTTP/3 で待ち受けさせ、HTTP/3 を話せる curl から2つの要求を並行に送りました。検証環境の macOS 標準 curl 8.7.1 は HTTP/3 を持たないため、Docker の debian:trixie-slim に apt で入る curl 8.14.1 を使っています。証明書は自作の CA で発行したものです。

$ curl -v -sS --parallel --http3-only ... a.txt b.txt(grep で抽出)コマンド出力
* Connection #0 is not open enough, cannot reuse
* Waiting on connection to negotiate possible multiplexing.
* Connected to localhost (172.19.0.2) port 8443
* using HTTP/3
* [HTTP/3] [0] OPENED stream for https://localhost:8443/a.txt
* Re-using existing https: connection with host localhost
* [HTTP/3] [4] OPENED stream for https://localhost:8443/b.txt
* Connection #0 to host localhost left intact

同じ接続を使い回して(Re-using existing https: connection)、ストリーム 0 と 4 が開いています。2回実行して同じでした。

番号が偶数なのは、HTTP/3 が自前の番号を持たず QUIC のストリーム ID を使うからです。RFC 9000 §2.1 は Client-initiated streams have even-numbered stream IDs と書き、下位2ビットで開いた側と方向を表します。クライアントが開く双方向ストリームは 0, 4, 8 と進みます。HTTP/2 は逆で、RFC 9113 §5.1.1 は Streams initiated by a client MUST use odd-numbered stream identifiers です。並べて読むときは偶奇の向きに注意してください。

この実演では遅延も損失も入れていません。損失が起きたときに他のストリームが止まらないことは実演しておらず、上の RFC 9114 §2 の記述に依っています。

✓ ここまでの確認

「QUIC は UDP の上で動くので、HTTP/3 を使うアプリは受け取ったデータを自分で並べ直す必要がある」という説明は妥当ですか。

抽象原理

分けたものと、共有したまま残したもの

QUIC はすべてを分けたわけではありません。RFC 9114 §1.2 です。

By providing reliability at the stream level and congestion control across the entire connection, QUIC has the capability to improve the performance of HTTP compared to a TCP mapping.

分けたのは信頼性、つまり欠けを埋めて並べ直す待ちです。輻輳制御は接続全体で1つのままです。「ネットワークの仕組み」コースの『送りすぎない仕組み — フロー制御と輻輳制御の違い』で見たとおり、輻輳制御は途中の道の都合を推測して送る量を抑える仕組みです。

逆に、共有するものを増やして往復を減らした箇所もあります。TCP と TLS は別々に合意します。TCP の3ウェイハンドシェイクで1往復、TLS 1.3 でもう1往復です(RFC 9001 §2.1 は TLS 1.3 を A full 1-RTT handshake, in which the client is able to send application data after one round trip と説明します)。QUIC は2つを1つにしました。

QUIC relies on a combined cryptographic and transport handshake to minimize connection establishment latency.

同コースの『なぜ手を3回握るのか』の区別(送り始められるまで/応答が返るまで)に揃えて数えると、0-RTT と TCP Fast Open を使わない場合、要求を送り始められるまでは TCP + TLS 1.3 が 2 RTT、QUIC が 1 RTT。応答が返るまでは 3 RTT と 2 RTT です。

具体実物

遅延 50ms を入れて往復を数える

先の nginx コンテナの eth0 に tc qdisc add dev eth0 root netem delay 50ms を入れ、サーバからクライアントへの向きだけを 50ms 遅らせました。RTT はおよそ 50ms です。curl の -w で段階ごとの経過秒を出しています(最初の1回は準備運転として除いた)。

$ curl -sS -o /dev/null -w '...' --http3-only / --http2 https://localhost:8443/a.txt(3回ずつ)コマンド出力
=== run1 --http3-only 02:00:46
proto=3 namelookup=0.000018 connect=0.057160 appconnect=0.057156 pretransfer=0.059611 starttransfer=0.121289 total=0.121409
=== run1 --http2 02:00:46
proto=2 namelookup=0.000019 connect=0.052631 appconnect=0.120827 pretransfer=0.120998 starttransfer=0.174001 total=0.174319
=== run2 --http3-only 02:00:46
proto=3 namelookup=0.000012 connect=0.056757 appconnect=0.056756 pretransfer=0.058824 starttransfer=0.113046 total=0.113080
=== run2 --http2 02:00:46
proto=2 namelookup=0.000008 connect=0.052582 appconnect=0.110965 pretransfer=0.111012 starttransfer=0.163608 total=0.163664
=== run3 --http3-only 02:00:46
proto=3 namelookup=0.000010 connect=0.056028 appconnect=0.056026 pretransfer=0.057156 starttransfer=0.113417 total=0.113491
=== run3 --http2 02:00:46
proto=2 namelookup=0.000015 connect=0.052027 appconnect=0.110631 pretransfer=0.110786 starttransfer=0.165207 total=0.165475

HTTP/2 は TCP の接続(connect)が約 52ms で1往復、TLS が終わる appconnect が 111〜121ms でもう1往復。要求を送り始める pretransfer は約 2 RTT、応答を受け終える total は 164〜174ms で約 3 RTT です。

HTTP/3 は connect と appconnect がほぼ同じ値(56〜57ms)です。輸送と暗号が別の段階として分かれていません。pretransfer は 57〜60ms で約 1 RTT、total は 113〜121ms で約 2 RTT。どの回でも差はおよそ 1 RTT でした。

条件は、同じホスト上の Docker、損失なし、片方向 50ms の遅延です。この差は往復の数から来るもので、UDP だから速いわけではありません。

✓ ここまでの確認

「QUIC ではストリームがすべて独立しているので、同じ接続で大きな転送が走っていても、他のストリームが送れる量には一切影響しない」という説明は妥当ですか。

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

HTTP/3(QUIC のストリーム)

要求1つにストリーム1本。並べ直しの待ちはストリームに閉じ、輻輳制御は接続全体で1つ。RFC 9114 §1.2 の書き方です。

何をどの単位で共有するか表
項目 HTTP/3 (QUIC)
並行する要求1つに ストリーム1本
並べ直しの待ち ストリームごと
輻輳制御 接続全体で1つ
具体例 B

HTTP/1.1 で TCP 接続を複数張る

並行したい要求の数だけ TCP 接続を張る。並べ直しの待ちは接続ごとに分かれるが、輻輳制御も接続ごとに別々になる。

何をどの単位で共有するか表
項目 HTTP/1.1 の複数接続
並行する要求1つに TCP 接続1本
並べ直しの待ち 接続ごと
輻輳制御 接続ごとに別々

どちらも「1つが欠けても他を待たせない」を実現しています。では何が違うのでしょうか。

RFC 9114 §1.1 が HTTP/1.1 の複数接続の何を問題にしているかを、QUIC の側と照らしてください。

この章のまとめ

  • HTTP/2 の番号は TCP から見えない。両端の TCP が共有する順序は 接続全体で1つで、1つの欠けが全ストリームを止める(RFC 9114 §1.1)。
  • QUIC のストリームは 中は順序どおり、間は順序なし(RFC 9000 §2)。並べ直すのは QUIC で、「UDP だから順序が無い」は誤り。
  • HTTP/3 は要求1つに QUIC のストリーム1本。クライアントが開く番号は 0, 4, 8(偶数)。HTTP/2 は奇数。
  • 分けたのは信頼性、輻輳制御は接続全体で共有(RFC 9114 §1.2)。「すべて独立」ではない。
  • 輸送と暗号の合意を1つにまとめ、要求を送り始められるまで 2 RTT → 1 RTT。実測でも差は約 1 RTT。

自分の現場で確かめる

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

環境:

理解度チェック

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

問題 1 / 3正解: 0

curl で HTTP/3 の要求を2つ並行に送ると、ストリーム番号は 0 と 4 でした。HTTP/2 では奇数になります。この違いの説明として正しいのはどれですか。

用語集(3語)
ストリーム(QUIC)
1つの QUIC 接続の中に作られる、順序つきのバイト列。RFC 9000 §2 は `ordered byte-stream abstraction` と定め、異なるストリームの間の順序は保証しない。HTTP/3 は要求と応答の1組に1本を使う。
ストリーム ID
接続の中でストリームを識別する番号。QUIC では下位2ビットが開いた側と方向を表し、クライアントが開く双方向ストリームは 0, 4, 8 と進む。HTTP/2 ではクライアントが開くストリームは奇数。
輻輳制御
経路の混み具合を推測して送る量を抑える仕組み。QUIC は接続全体で1つ持つ(RFC 9114 §1.2)。