QUICをゆっくり解説(15):HTTP/3

2021年11月10日 水曜日


【この記事を書いた人】
山本 和彦

Haskellコミュニティでは、ネットワーク関連を担当。 4児の父であり、家庭では子供たちと、ジョギング、サッカー、スキー、釣り、クワガタ採集をして過ごす。

「QUICをゆっくり解説(15):HTTP/3」のイメージ

QUICは汎用的なトランスポート層プロトコルですので、様々なアプリケーションプロトコルのやりとりに使えます。みなさんがよく知っているHTTP/1.1を乗せることも可能です。実際、開発者の間では、HTTP/1.1の前身の前身であるHTTP/0.9がテスト用に使われていました。

しかし、QUIC上のHTTPの本命は、第一回目の記事で説明したようにHTTP/3です。QUICは、「HTTP/2のストリームによる多重化の機能」を取り込んでいます。QUICに取り込まれなかったHTTP/2の残りの部分をQUIC用に再定義したのがHTTP/3です。

今回は、HTTPの標準化状況とHTTP/3について説明します。

HTTPの標準化状況

現在よく使われているHTTPのバージョンは、以下のとおりです。

  • HTTP/1.1: テキストベースのプロトコルで、TCPやTLS上で使う。
  • HTTP/2: バイナリベースのプロトコルで、主にTLS上で使う。ヘッダなどの意味は、HTTP/1.1と同じ。
  • HTTP/3: バイナリベースのプロトコルで、QUIC上で使う。ヘッダなどの意味は、HTTP/1.1と同じ。

HTTP/3はQUICに合わせて標準化されてきました。またHTTP/2は、実装が難しく脆弱性も入り易いプライオリティ制御の機能を削るために、改訂版が作成され標準化の最終段階にあります。

HTTP/2とHTTP/3は、HTTP/1.1の「セマンティクス」(ヘッダなどの意味)を引き継いでいるので、本来ならHTTP/1.1を参照することになります。ここで、HTTPのセマンティクスをHTTP/1.1の「運搬時の書式」から切り離そうという機運が高まりました。

セマンティクスの仕様があるならば、それぞれのバージョンはセマンティクスを参照すると共に、それぞれの運搬時の書式と運搬方法を定めればよくなります。このため、HTTP/1.1に関する一連のRFCに対しても改訂作業が進んでいます。

たとえば、セマンティクスではRefererを以下のように定義しています。

Referer = absolute-URI / partial-URI

つまりRefererの値は、URIだと定義されているのです。そして、新しいHTTP/1.1の仕様では、ヘッダの各行の書式を以下のように定めています。

field-line   = field-name ":" OWS field-value OWS

新しいHTTP関連の仕様では、いわゆるヘッダの行のことをフィールドと呼ぶようになりました。この記事の以降では、単に「ヘッダ」と言えばヘッダ全体を指し、HTTP/1.1で言う各行はフィールドと呼ぶことにします。

上記の定義は「フィールドは、フィールド名の後に “:” が続き、さらにフィールドの値が続く」という意味です。OWSは、オプショナルの空白文字です。以下に、HTTP/1.1のRefererフィールドの例を示します。

Referer: https://example.net/

これに対しHTTP/2やHTTP/3では、フィールドがバイナリ形式で表現されます。

HTTP関連の標準化状況をまとめると、HTTP/1.1とHTTP/2の仕様は改定中で、HTTP/3の仕様は新たに策定中で、これらすべての作業が最終段階にあり、もうすぐRFCとして公開される予定です。

HTTPの進化

大成功を収めたHTTP/1.1には、以下のような問題がありました。

  • 応答の順序依存:HTTP/1.1では複数の要求がサーバで逐次的に処理され、応答も順番を守って送信されなければなりません。サーバがある要求の処理に時間がかかると、それ以降の要求の処理は待たされることになります。これを「HTTP/1.1のHoL(head-of-line)ブロッキング」と呼びます。
  • 低い並行性:コネクション上では一度に1つの要求しか送信できません。そのためブラウザは、複数のコネクションを張って並行性を上げようとします。複数のコネクション間では輻輳制御の情報が共有できないので、互いに悪影響を及ぼします。また、それでも並行性が足らない場合は、複数のサーバにコンテンツを分けること(domain sharding)も実施されます。この場合、コンテンツの管理が複雑になります。
  • 冗長なヘッダ:HTTP/1.1は状態を持たないプロトコルなので、状態を実現するには、それぞれの要求に同じようなフィールド(特にCookie)を格納する必要があります。2015年当時の要求ヘッダの大きさは、平均800バイトだと言われていました。これはネットワークの帯域を浪費していると言えます。

これらの問題を解決するために、HTTP/2が策定されました。HTTP/2の特長は以下のとおりです。

  • 応答の順序独立:ストリームIDのおかげで要求と応答を対応付けられます。このため、応答の順番が要求の順番どおりではなくてもよくなりました。これで、HTTP/1.1のHoLブロッキングを解決します。
  • 高い並行性:一度に複数の要求と応答を送受信できます。利用するTCPのコネクションの数は1つであり、輻輳制御の干渉の問題がなくなります。デフォルトの並行度は100であるので、コンテンツを複数のサーバに分ける必要がなくなります。
  • 圧縮されたヘッダ:HPACKと呼ばれる方法でフィールドを圧縮できます。

ところが、HTTP/2にも問題点がありました。

  • TCPの順序依存:あるTCPセグメントが落ちたが、それ以降のセグメントが届いた状況であっても、欠落したセグメントが再送されて届かなければ処理が進みません。これを「TCのHoLブロキング」と言います。HTTPの視点から見て、欠落したセグメントが届いたセグメント内のストリームに「たまたま」なんら影響を与えない場合でも、処理は進みません。なお、HTTP/2のフレームはTCPセグメントの境界を守って格納される訳ではありません。
  • HPACKの順序依存:要求ヘッダや送信ヘッダは、「送信された順番」を守って受信できることが仮定されて設計されています。たとえ、TCPではなく(たとえばQUICのように)欠損に強いトランスポート層を利用したとしても、あるヘッダが欠落すれば、すでに届いてているそれ以降のヘッダを伸長できません。これを「HPACKのHoLブロッキング」と呼びます。

これらの問題を解決するのがHTTP/3です。

  • ストリーム間の順序独立:QUICでは、フレームがパケットの境界を守って格納されます。それぞれのストリームのデータをそれぞれ独立したパケットに格納して配送すれば、あるストリームに関するパケットの欠落が他のストリームに影響を及ぼさなくなります。
  • 柔軟なヘッダ圧縮:新しく定義されたQPACKは、「HoLブロッキングの危険性」と「圧縮率」とのバランスを選択できるように設計されています。

HTTP/2のフレーム

HTTP/2では、ある要求とそれに対する応答が1つのストリーム内で送受信されます。要求と応答は、ヘッダ用の「HEADERSフレーム」に本文用の「DATAフレーム」が続く構造をしています。各フレームは、以下のように9バイトのヘッダが付きます。

 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)                      |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                   Frame Payload (0...)                      ...
+---------------------------------------------------------------+

HTTP/2のフレームは、3バイトの長さ、型、フラグ、4バイトのストリームIDから構成されています。前述のようにストリームIDのおかげで、順番どおりに戻ってくるとは限らない応答を要求に対応付けられます。圧縮したヘッダが16,777,215(2^24-1)バイトよりも大きい場合は、圧縮したヘッダを分割し、HEADERSフレームと1つ以上のCONTINUATIONフレームに格納します。

要求の先頭はHEADERSフレームですが、メソッドやパスはどこへ行ってしまったのでしょうか?実は:methodと:pathのようにコロンから始まる擬似フィールドが用意されています。たとえば、:methodフィールドの値がGETであれば、GETメソッドが指定されたことになります。応答には:statusフィールドが定義されています。このようにHEADERSフレームだけで必要な情報を格納できるように設計されています。

RFC7540に掲載されている例が秀逸なので、ここに再掲載します。HTTP/1.1では以下のように表現される要求があるとしましょう。

GET /resource HTTP/1.1
Host: example.org
Accept: image/jpeg

これは、HTTP/2では以下のように符号化されます。

HEADERS                                           
  + END_STREAM
  + END_HEADERS
    :method = GET
    :scheme = https
    :path = /resource
    host = example.org
    accept = image/jpeg

END_STREAMとEND_HEADERSはフラグで、その名のとおりストリームとヘッダの終了です。“+”はフラグが立っていることを表します。この要求は本文がなくヘッダのみなので、HEADERSフレームにEND_STREAMフラグが立っています。HEADERSフレームのペイロードには、HPACKで圧縮した結果が入ります。

次は応答の例です。HTTP/1.1の以下のような応答を考えます。

HTTP/1.1 200 OK
Content-Type: image/jpeg
Content-Length: 123
                                     
{binary data}                        

HTTP/2では、以下のように表現されます。

HEADERS
  - END_STREAM
  + END_HEADERS
  :status = 200
  content-type = image/jpeg
  content-length = 123

DATA
  + END_STREAM
  {binary data}

“-”はフラグが立ってないことを表します。この応答では、HEADERSフレームの次にDATAフレームがあるので、HEADERSフレームのEND_STREAMは立っていません。

HTTP/3のフレーム

HTTP/3では、QUICのSTREAMフレームの中に、さらにHTTP/3のフレームを格納します。HTTP/3のフレームは、以下のような構造を持っています。

 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
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                           Type (i)                          ...
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                          Length (i)                         ...
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                       Frame Payload (*)                     ...
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

いわゆる、TLV(Type Length Value)ですね。HTTP/2と比較すると、ストリームIDはQUICのSTREAMフレームにあるので不要です。長さは、4,611,686,018,427,387,903(2^62-1)まで表現できるので、圧縮したヘッダを分割することはなく、CONTINUATIONフレームは仕様から削除されました。フラグも不要なのでなくなりました。

おわりに

HTTP/2のストリームIDの内、0は特別扱いされており、制御情報の交換に使われたりします。HTTP/3では、制御情報の伝達に一方向のストリームが使われます。今回は、これら制御系の詳細は割愛させていただきました。

次回は、この連載の締めくくりとして、ヘッダの圧縮について説明する予定です。

山本 和彦

2021年11月10日 水曜日

Haskellコミュニティでは、ネットワーク関連を担当。 4児の父であり、家庭では子供たちと、ジョギング、サッカー、スキー、釣り、クワガタ採集をして過ごす。

Related
関連記事