DNS と HTTP 基礎
curl https://api.example.com/user/42 を実行する。主張は 3 つ、どれもドラッグできる図で確かめられる。コールドな DNS 解決は 4 往復で、変更後も丸 1 TTL は古い答えが返る。 HTTP/1.1 は 1 接続に 1 リクエストしか運べず、6 本の接続はそこから出る。 HTTP/2 は TCP の囚人であり続け、それが QUIC の理由だ。
名前をアドレスに変える
最初のバイトの前に api.example.com は 203.0.113.42 にならなければならない。「たまに落ちる」障害の多くはこの道中に住んでいる。
getaddrinfo は /etc/resolv.conf のアドレスへ UDP データグラムを 1 つ送る。転送先が再帰リゾルバで、キャッシュが冷たいときは代わりに歩く。飛んでいくクエリは 4 回出ていき、すでに答えたホップは残る。再生を押すか、1 ホップずつドラッグしてほしい:
どのホップが高いかに注目してほしい。ルートと .com のサーバは数百拠点へ anycast されているので 12 ms と 18 ms で返る。権威サーバはドメインの持ち主が置いた場所にあり、34 ms かかる。4 ホップで 72 ms —— しかもどれも答えではなく委任だ。ルートは api.example.com の居場所を知らず、.com を誰が運用しているかだけを知っている。
実際にこの代金を払う問い合わせはほとんどない。プログラムと権威サーバの間のどの層も写しを持っているので、同じ探索はまだ答えを握っている層で打ち切られる。スライダーをキャッシュ階層の下へドラッグし、起きなかった道のりが縮むのを見る:
差は 3 桁ある。ブラウザ自身の表なら 0.05 ms、すでに持っているリゾルバなら 8.0 ms、誰も持っていなければ 。新しいホストへの最初のリクエストが遅く、2 回目が一瞬なのはこれが理由で、自分のサービスと無関係な長い尾がレイテンシのグラフに出るのもこれが理由だ。
アドレスは名前が運べるものの 1 つにすぎない。問い合わせのレコード種別がANSWER SECTION に何が返るかを決める。知る価値のある 8 種をスライダーで辿ってほしい:
SOA で止めてほしい。その最後のフィールドが次の 2 つの図の主題だ。否定の TTL —— 名前が存在しないことを世界が覚えていてよい時間である。なお CAA は認証局が発行前に確認するので、古いままだとトラフィックではなく更新が壊れる —— 障害は 60 日後に届く。
キャッシュの代償は、世界がいつ古い答えを信じなくなるかを自分で決められないことだ。写しはそれぞれ TTL を持ち、各リゾルバはたまたま問い合わせた瞬間から自分の時計を回している。変更後の秒数をスライダーで進め、まだ古いアドレスを返すリゾルバを数えてほしい:
最後に残る古い写しがいつ切り替わるかを見てほしい。収束には変更のあと丸 1 TTL かかる。たまたま観測した最後の更新からではない —— ゾーンを編集する 1 秒前に問い合わせたリゾルバは、窓いっぱい写しを抱えるからだ。不変条件はこれだ:どのリゾルバも、変更の瞬間から最大 1 TTL のあいだ古いレコードを返し得るし、それを呼び戻す手段はない。だから TTL は変更と同時ではなく、変更の前に下げる。
失敗は自分の時計で動き、たいていそちらのほうが長い。タイプミスを直したあとの秒数をドラッグして、まだ NXDOMAIN を返すリゾルバをSOA の minimum と突き合わせて数えてほしい:
SOA の minimum はレコードではなくゾーンの性質なので、一度も存在しなかった名前は、誰もそれ用に設定していない数字でキャッシュされる —— しばしば 1 h だ。タイプミスを直すのに 10 秒、すべてのリゾルバが忘れるまでに 1 時間。
別名は自分の往復を足す。CNAME は「この別の名前でもう一度聞け」なので、 1 段ごとにアドレスが返る前の権威問い合わせが 1 回増える。スライダーでチェーンを伸ばし、最下行でコールドのコストを読む:
別名 3 段で 42 ms が になり、各ホップは自分の TTL を持つ権威問い合わせだ。破線の行が現場を捕まえる。ゾーン頂点には既に SOA と NS が住み、RFC 1034 は同じ名前への CNAME 同居を禁じる。つまり example.com 自身は CNAME にできず、 DNS 事業者の ALIAS / ANAME / flattening を使い、サーバ側で A レコードが返る。
大きさは、プロトコルが手放さなかったもう 1 つの制約だ。古典的な DNS 応答は 1 つの UDP データグラムで、元の上限は 512 B。スライダーで A レコードを足し、応答を予算の外へ押し出してほしい:
512 B —— —— を超えるとサーバは TC ビットを立て、クライアントは TCP でやり直す。往復が 1 つ増える。 EDNS(0) が大きなバッファを宣言させるので今日ほとんど TCP には落ちない。ただし 1.2 KB —— IPv6 の最小 MTU からヘッダを引いた値 —— を超えるとデータグラムは断片化し、断片を落とす中間装置が多いため、 DNS Flag Day 2020 は業界にこの数値を求めた。本当の天井は だ。
「リゾルバは速い」の裏にもう 1 つある。1.1.1.1 はサーバではなく、数百拠点から同時に BGP で広告される 1 つのアドレスで、パケットは最も近い拠点で止まる。経路上でクライアントをドラッグし、最寄り拠点と1 台のサーバを比べてほしい:
光ファイバ中の光はおよそ c の 3 分の 2 で進むので、往復はおおむね 100 km あたり 1 ミリ秒であり、どのプロトコルもこれには勝てない。anycast はパケットを速くするのではなく、宛先を近くへ動かす。名前つきサーバが 13 台しかないルートゾーンが世界中でミリ秒で答えるのも同じ手だ。
そしてここからが実際に人を叩き起こす障害だ。レコードが移り、TTL を尊重するものはすべて追随し、アドレスを永久にキャッシュした 1 つのプロセスだけが、誰も応じない機械に話し続ける。移動後の 10 分をスライダーで進めてほしい:
再解決するクライアントは 1 TTL で復帰する。固定されたほうは 10 分後もまだ失敗し、24,000 リクエストに達し、しかも静かに失敗する。DNS のエラーはなく、既に移ったマシンへの接続があるだけだ。JVM は常習犯で、 security manager 下では networkaddress.cache.ttl の既定が −1、永久キャッシュになる。レコードの TTL に設定すること。
一度に 1 リクエスト、しかもテキスト
HTTP/1.1 は TCP 上の行指向テキストプロトコルで、 1 本のコネクションにつき未完了リクエストはちょうど 1 つ。 web が 20 年かけて育てた回避策は、すべてこの一文の帰結だ。
アドレスを手にすると curl は TCP コネクションを開き、 TLS 握手を終え、話し始める。内容は ASCII で空行で終わる —— 長さ前置もフレーミングヘッダもない。
再生を押してリクエストを 1 行ずつ送り、下のバーですでにワイヤに乗ったバイトが積み上がるのを見てほしい:
最後の行に注目してほしい。空の \r\nだけが「ヘッダは終わり」をサーバに伝える —— だから HTTP/1.1 の解析は長さを読むのではなく区切りを走査することになる。サイズの分かるボディには Content-Length: N が付く。まだ誰も知らないサイズのボディは、チャンク単位でフレーミングされる。 1 つずつ辿ってほしい:
各チャンクは前に自分の 16 進の長さを持ち、長さ 0 のチャンクがボディの終わりだ。 server-sent-events のストリームも、ロングポールも、まだ数え終わっていない検索結果も、こうしてサイズが分かる前に出ていく —— そして、レスポンス全体をバッファするプロキシが、誰も 1 行も変えないままストリーミングのエンドポイントをバッチのそれに変えてしまう理由でもある。
その 149 B は安い。安くないのは、それを送れる状態まで行くことだ。往復時間のスライダーを動かし、握手の種類を切り替えて、最初のレスポンスバイトがいつ着くかを見てほしい:
50 ms なら、新規の TLS 1.3 接続で最初のバイトは 150 ms、 TLS 1.2 なら 200 ms、既に開いている接続なら 50 ms。これが keep-alive の理由のすべてだ。3 往復のうち 2 つは準備で、準備は使い回せる。
HTTP/1.0 はレスポンスごとに接続を閉じた。HTTP/1.1 は開けたままを既定にした。リクエスト数のスライダーを動かして、毎回新しい接続と1 本を開いたままを比べてほしい:
6 リクエストは旧来なら 1.02 s、新しいやり方なら 520 ms。サーバは再利用に上限を置く。アイドルタイムアウト(たいてい 60〜75 秒)と、 1 接続あたりの最大リクエスト数だ。curl https://a/x https://a/y が curl を 2 回走らせるより速いのも同じ理由 —— 1 プロセス、1 コネクション。
keep-alive は握手を薄めるが往復は薄めない。pipelining はそれを直すはずだった。レスポンス 1 が返る前にリクエスト 2 を送る。問題は、レスポンスが順番どおりに返らなければならないことだ。最初のレスポンスの右端をドラッグして、その後ろの 2 つを見てほしい:
レスポンス 3 を見てほしい。ほぼ即座に準備できているのに、前の遅いものが出るまで出られない。これがヘッドオブラインブロッキングで、このページの残りが扱う言葉だ。pipelining は 2 つ目の壁にも当たった。パイプライン化されたレスポンスをバッファし、並べ替え、落とす透過プロキシだ。ブラウザは 2000 年代半ばに有効化し、どれも元に戻した。
残る並列性は接続を増やすことだけになる。並列接続数をドラッグして、どのブラウザも止まる数を見つけてほしい:
曲線は接続数について双曲線なので、最初の 6 本が を買い、次の 6 本は しか買わない。6 が膝で、しかも妥協だ。意味があるだけの並列性と、資源が 100 個あるページが攻撃に見えない程度の少なさ。上限を破る古い技がドメインシャーディング ——img1、img2 …… と名前を分けて 6 本ずつ開かせる。 HTTP/2 ではその技は積極的に有害で、助言のほうが、それが書かれたプロトコルより長生きした。
最後のコストは誰にも見えない。その 30 リクエストはどれも同じヘッダを運び、その中でいちばん大きいのはたいていcookie だ。気前のよいセッションライブラリが平気で渡してくる 4 KB まで上げてほしい:
1 ページあたり のリクエストヘッダ。しかも上り方向で、コンテンツのバイトが 1 つも来る前に。HTTP/1.1 には手がない。「前回と同じヘッダ」と言う方法がないからだ。それを直すことが、HTTP/2 が最初にやることになる。
1 本のコネクション、多数のストリーム
HTTP/2 は HTTP の意味論をすべて残し、ワイヤ形式だけを置き換える。 stream id を持つバイナリフレーム、共有ヘッダテーブル、そして 6 本ではなく 1 本のコネクション。それでも TCP の囚人だ。
1 コネクション 1 リクエストを強いていたのはテキストフレーミングだ。メッセージの前に長さがなければ、2 つを交互に流す方法がない。そこで HTTP/2 はすべての前に長さを置く。あらゆるメッセージはフレーム列になり、どのフレームも同じ 9 バイトで始まる。
ヘッダのフィールドを 1 つずつ辿り、その上の行から幅を読んでほしい:
72 ビット、そして最後の 31 ビットが要点のすべてだ。すべてのフレームに stream id が付く。 length はフレームの終わり、type は HEADERS / DATA / SETTINGS / WINDOW_UPDATE / RST_STREAM / PING / GOAWAY のどれか、 id はどの会話に属するかを言う。クライアント起点のストリームは奇数、サーバ起点は偶数、id 0 はコネクション自身だ。
このタグがあれば、送信側は異なるリクエストのフレームを好きな順でワイヤに置ける。コネクションを 1 フレームずつ再生し、いま出ていくフレームがそれを所有するストリームに落ちるのを見てほしい:
どのストリームも他の完了を待たないことに注目してほしい。これが 6 接続という回避策を引退させる。1 本のコネクションがすべてを運ぶので、競合する 6 つではなく 1 つの輻輳ウィンドウを探ることになり、 HTTP/1.1 では美徳だったドメインシャーディングは、利点を捨てる方法に変わる。
フレーミング層は、のちに誤りと分かる機能も可能にした。ページに app.css が要ると知っているサーバは、ブラウザが求める前に別のストリームで押し込める。ブラウザが既に持っていた割合をドラッグしてほしい:
サーバはブラウザのキャッシュを見られないので、再訪では120 KB のほとんどが無駄に送られる —— しかもブラウザが実際に待っている HTML の前に送られる。 Chrome は 2022 年に server push を削除した。レスポンスヘッダの Link: rel=preload が有用な半分をやる。何を取るべきかを伝え、要るかどうかはブラウザに決めさせる。
フレーミングはヘッダ問題も解けるようにする。HPACK は両端に同期したテーブルを与える。ヘッダは一度送り、以後はそのインデックスを送る。リクエスト数のスライダーを動かして、HTTP/1.1 なら送っていた量といまワイヤに乗る量を比べてほしい:
最初のリクエストは安くならない。全ヘッダを Huffman リテラルで運びテーブルに登録するからだ。2 回目には 55% 小さく、8 回目は 対 1.2 KB で82% の削減。刃もある。圧縮は暗号文の長さを平文に依存させるので、秘密のヘッダの隣に注入できる攻撃者は長さから秘密を学べる —— CRIME 攻撃だ。答えは決してインデックスしないリテラルである。
多重化には独自の背圧が要る。1 本の貪欲なストリームが、ソケットを共有する他の 5 本を飢えさせかねないからだ。各ストリームは受信ウィンドウを持つ。バイト軸に沿ってウィンドウをドラッグし、往復の合間に送信側が止まるのを見てほしい:
既定値は 64 KB —— RFC 9113 §6.9.2 だ —— で、1 往復に飛べるのは 1 ウィンドウなので、50 ms では 1 本のストリームは太い回線でも 1.25 MB/s で頭打ちになる。 まで上げれば同じストリームが 320 MB/s に届く。「HTTP/2 は大きなダウンロードで遅い」の背後にある数字はこれで、ほとんどの場合は SETTINGS_INITIAL_WINDOW_SIZE を上げていないだけだ。
並行度にも天井があり、それは多くの人が想定するものではない。同時に投げるリクエスト数のスライダーをサーバが宣言した上限の先まで動かしてほしい:
128 —— nginx の http2_max_concurrent_streams の既定 —— を超えると、あふれたリクエストは拒否されるのではなく、stream id が空くまでクライアント側で待たされる。 投げれば 172 本がネットワークに届く前から待っている。「HTTP/2 は並行度をタダにする」は誤りで、サーバが選んだ数までは安くする、が正しい。
そして HTTP/3 を必要にした欠陥だ。TCP は 1 本の順序付きバイト列を配るので、セグメントが 1 つ失われると、その後ろのすべてが押さえられる —— すでに届いているストリームのバイトも含めて。損失のスライダーを上げ、すべてのレーンに同時に隙間が現れるのを見てほしい:
ストリームが 1 つの順序を共有しているので、そのすべてが同じ再送を待つ。きれいな回線なら 170 ms、損失 1% なら 。 HTTP/2 はアプリケーション層の詰まりを直し、トランスポート層のそれを相続し、 6 本を 1 本にまとめたぶん 1 回の損失が高くついた。
ストリームを HTTP の下へ動かす
HTTP/2 に残った問題はどれも TCP の問題で、TCP はカーネルにある。 QUIC は UDP の上でトランスポートを作り直す。
新しいプロトコル番号は中間装置の半分に落とされる。だから QUIC は UDP に乗る。セグメント切替を動かして、5 つの仕事がカーネルからプロセスへ移るのを見てほしい:
5 つすべてをユーザ空間に置くのが取引だ。配備性を買える —— QUIC の修正はカーネル更新ではなくアプリと一緒に出る —— 代わりに CPU を払う。すべてのパケットを、カーネルのセグメンテーションオフロードを使えないコードが暗号化し確認するからだ。さらにコネクションはソケットではなくライブラリのものになり、次の 3 つの図はそれゆえに可能になる。
まず買えるのは握手だ。QUIC は TLS 1.3 の暗号交換と自身のトランスポートパラメータを同じ飛行に載せる。往復時間のスライダーを動かしトランスポートを切り替えて、最初のバイトがいつ届くか見てほしい:
50 ms なら TCP + TLS 1.3 は 150 ms、QUIC は 100 ms —— 先に済ませるべき別の TCP 握手がないので、1 往復ぶん浮く。話したことのあるサーバには 0-RTT が最初のパケットにリクエストを入れる。50 ms、光速が定める床だ。
その前に、サーバはクライアントのアドレスが本物だと知る必要がある。でなければ QUIC は、送信元アドレスが指した誰かに向けた反射増幅器になる。そこでアドレスが検証されるまで、サーバは受け取った量の 3 倍までしか返せない。クライアントの最初のデータグラムを、サーバが送れる量が破線に届かなくなるまで下げてほしい:
予算が本当に足りなくなる位置に注目してほしい。入りが 1,000 バイトであって、1,200 ではない。1,200 バイトの最小データグラムは別の規則で、 QUIC の経路がそれより狭くならないように RFC 9000 が定めたものだ。そこに 3 倍の天井がかかると 3,600 バイトの予算になり、3,000 バイトの証明書の飛行より 600 バイト余る。1,000 を下回るまでドラッグすると、下の帯が破線に届かなくなる。サーバは送れるぶんだけ送って途中で止まり、次のデータグラムを待ってからでないと続けられない —— 往復が 1 つ増える。パディングは余裕であって、しきい値ではない。
0-RTT の床にも穴がある。その飛行は前回のセッションから導いた鍵で暗号化され、新鮮性の証明を持たない —— だから捕まえた者は誰でも送り直せる。再送の回数をドラッグして台帳を読んでほしい:
コピーごとに課金が 1 回実行されるのを見てほしい。 0-RTT が冪等なリクエストにしか安全でないのはこのためだ。GET の再送はレスポンスの無駄だが、POST /charge の再送は 2 回目の課金になる。サーバは early data を安全なメソッドに限るか、自前の再送防御 —— 使い捨てチケット、あるいはアプリが検査する冪等キー —— を持たなければならない。
QUIC が買う 2 つ目は、HTTP/2 が直せなかったものだ。ストリームは自分のシーケンス番号を持つので、失われたパケットは自分のストリームだけを押さえる。損失のスライダーを上げ、このページが終わる場所とHTTP/2 が終わった場所を比べてほしい:
損失 1% でページは 124 ms、HTTP/2 は 290 ms。 3% なら 対 530 ms。違いのすべては機構にある。6 レーンのうち 5 本は再送に気づきさえしない。自分のバイトが、欠けたパケットの先着に依存していないからだ。きれいな有線回線では利得はほぼ 0 に丸まる —— 勝つのは損失であって帯域ではない。
ただし独立したストリームは HPACK を壊す。HPACK は両端がテーブル更新を送信順に処理することを前提にし、QUIC はそれを意図的に保証しない。 QPACK の答えは予算だ。まだ届いていないテーブル項目のために何本のストリームがブロックしてよいかをドラッグしてほしい:
0 のとき —— SETTINGS_QPACK_BLOCKED_STREAMS の既定値 —— エンコーダは到着を証明できない項目を参照できないので、リテラルに落ちる。 6 リクエストで 84 B ではなく 726 B だ。予算を上げれば圧縮が買えるが、代金はヘッドオブラインブロッキングを 1 ストリームずつ払うことになる。ほとんど誰も調整しないので、現実の QPACK の削減は HPACK より小さい。
QUIC が変える最後のものは、コネクションとは何かだ。 TCP は 4 タプルで 1 本を識別するので、回線が変われば死ぬ。 QUIC は全パケットに connection ID を置く。クライアントを Wi-Fi から LTE へドラッグし、4 タプルが一致しなくなる一方でconnection ID は一致し続けるのを見てほしい:
サーバはアドレスではなく ID でセッションを引くので、ハンドオーバの間も動画は止まらず、新しいコネクションと新しい TLS セッションを組み直すこともない。 ID は設計上ローテーションされ相互に紐づけられないので、マイグレーションが受動的な観測者に端末を回線間で追う手段を与えることはない。
残るのは、3 つのプロトコルを話すクライアントがどうやってこの 1 つに落ち着くかだ。 TLS の ALPN 拡張は握手の中で HTTP/1.1 と HTTP/2 の勝負をつける。 HTTP/3 はその方法では選べない。選ぶとは TCP コネクションをそもそも張らないという意味だからだ。訪問を 1 回ずつ辿ってほしい:
1 回目は普通の TCP 上の HTTP/2 で、そのレスポンスが Alt-Svc: h3=":443"; ma=86400 を運ぶ —— 「ここに QUIC の入口がある、1 日覚えておけ」。 2 回目は QUIC と TCP を競走させ、先に終わったほうを採る。 Cloudflare が 2024 年に処理した HTTPS リクエストの約 30% が HTTP/3 で、大半は CDN のエッジだ。データセンタ内部では HTTP/2 が今も既定である。
リクエスト全体の値段
そらで答える価値のある 4 つの問いを、上の図と同じモデルで値付けし、レビューで捕まえるべき 5 点を添える。
1 つ目は面接で実際に訊かれる問いだ。URL を打ってから最初の JSON バイトまで、時間はどこへ行くのか。ほとんどは往復で、どれを払うかはトランスポート次第だ。切り替えて、往復のスライダーを動かしてほしい:
50 ms、温かいリゾルバで TCP は 158 ms、HTTP/3 は 108 ms、 0-RTT は 58 ms。キャッシュが冷たければ 3 つとも 72 ms 増える。帯域は 1 つもない。すべてレイテンシで、レバーは往復を減らすことと短くすることだけだ。
2 つ目はヘッドオブラインブロッキングで、層ごとに別物を指す。 HTTP/1.1 の pipelining はアプリケーション層、 HTTP/2 は TCP が 1 本の順序付きバイト列なのでトランスポート層、 HTTP/3 はトランスポート層では無い —— ただしブロック許容数を上げると QPACK がヘッダ層で呼び戻す。ブラウザは前者に 6 本、後者に 1 本開き、その 1 本が既定で 128 本のストリームを運ぶ。
3 つ目は、HTTP/3 が導入に見合うのはいつか。正直な答えは判決ではなく交点だ —— 損失率のスライダーを動かし、HTTP/2 がまずHTTP/3 に、次に6 本の HTTP/1.1 接続に負けるのを見てほしい:
モデルは一文だ。失われたパケット 1 つは、順序を共有するものに往復 1 回の停止を課す。1% では 124 ms / 290 ms / 470 ms、 を超えると 1 本は置き換えた 6 本より悪い。
networkaddress.cache.ttl=-1—— JVM の生涯 1 つのアドレスに固定される。- HTTP/2 オリジンでのドメインシャーディング —— 1 つで足りる輻輳ウィンドウが 6 つになる。
- HTTP/2 server push への依存 —— Chrome は 2022 年に削除。
Link: rel=preloadを使う。 - 0-RTT から届く非冪等ハンドラ —— early data は設計上再送可能だ。