HTTP/2

20 分で読了
このページの目次

HTTP/2は、HTTPのメソッド、ステータス、キャッシュなどのセマンティクスを維持し、フレームとストリームを用いて1つの接続内で複数の交換を表現します。異なるリクエストのレスポンスをインターリーブ(交差)して送信できるため、HTTP/1.1の「レスポンスはリクエストの順序に従わなければならない」という制限を解決します。基盤となるTCPは依然として単一の順序付きバイトストリームを提供します。

現行の基礎仕様はRFC 9113であり、RFC 7540に取って代わります。これをTCPを書き換えるトランスポートプロトコルと記述してはいけません。「1つの接続で複数のストリーム」という概念は、サイト全体で永久に1つの接続しか開かないことを意味するものではありません。RFC 9113

問題の特定最初に確認すること
h2がネゴシエートされたのに、GETのテキストが見当たらないリクエストヘッダーはフィールドブロックとHPACKを使用している
特定のレスポンスが遅い場合、他のストリームはそれを待たなければならないかストリームのスケジューリング、接続ウィンドウ、TCPのギャップはそれぞれ別に見る
END_HEADERSはリクエストの終了を意味するかフィールドブロックの終了と送信方向の終了は異なる
ウィンドウが大きいのに、送信が停止しているのはなぜかストリームウィンドウと接続ウィンドウの両方を同時に確認する
圧縮テーブルが時々デコードできないのはなぜか双方向にそれぞれ状態があり、パケットキャプチャでは履歴が欠けている可能性がある
GOAWAY後にどのリクエストを再試行できるかLast-Stream-IDとビジネスロジックの実行不確実性

接続の開始とフィールドの表現

HTTPSでは、TLS ALPNを用いてh2をネゴシエーションするのが一般的です。その後、クライアントは規定の接続プレフィックスとSETTINGSを送信し、サーバーもSETTINGSで接続プレフィックスを開始します。クライアントの24バイトの固定プレフィックスにはHTTPのテキストが含まれているように見えますが、アプリケーションに対して通常のHTTP/1.1リクエストを1回送信するものではありません。

TLSを使用しないHTTP/2は、事前の知識に基づいて起動できます。歴史的なh2c Upgradeは、すべてのHTTP/2の必須エントリポイントとして扱うべきではありません。最新の基礎仕様では、このアップグレードメカニズムは廃止されています。

通常のリクエストは、:method、:scheme、:authority、:pathなどの擬似フィールドでターゲットを表現します。レスポンスでは:statusを使用します。擬似フィールドは通常のフィールドの前に配置する必要があり、フィールド名は小文字を使用します。CONNECTなどの特定のメソッドには対応するルールがあります。HTTP/1.1のステータスライン全体をHEADERSに詰め込むことはできません。

Connection、Keep-Alive、Transfer-Encodingなどの接続専用フィールドはそのまま持ち越せません。TEにはtrailersのみを許可するという例外があります。Content-Lengthは期待されるコンテンツの長さを表現するために使用できますが、ストリームはHTTP/2のフレームとEND_STREAMで区切られるため、CL(Content-Length)を使ってストリームの状態を判断することはできません。

フレームヘッダー9バイト:長さは完全なフレーム長ではない

フィールド長さ意味
Length24 bitペイロードのバイト数のみ
Type8 bitDATA、HEADERS、SETTINGSなど
Flags8 bitフレームタイプに応じて解釈
Reserved + Stream ID1 + 31 bit予約ビットと所属ストリーム。0は接続制御に使用

受信側のデフォルトの最大フレームペイロードは16,384バイトです。SETTINGS_MAX_FRAME_SIZEによって規定の範囲内で調整可能で、最大16,777,215バイトまで拡張できます。この上限は、1つのレスポンスまたは1つのフィールドリストの総長ではありません。

以下に、ストリーム1上で5バイトの内容を含むDATAフレームを構築し、この送信方向を終了させます。完全なフレームは14バイトです。コードはフレームヘッダーと長さのみを担当し、接続状態マシンは実装しません。

def frame(kind, flags, stream, payload):
    assert 0 <= stream < 2**31 and len(payload) < 2**24
    return (len(payload).to_bytes(3, 'big') + bytes([kind, flags])
            + stream.to_bytes(4, 'big') + payload)

wire = frame(0, 1, 1, b'hello')
assert wire.hex() == '00000500010000000168656c6c6f'
assert len(wire) == 14
assert int.from_bytes(wire[:3], 'big') == len(wire) - 9
assert int.from_bytes(wire[5:9], 'big') & 0x7fffffff == 1
print('DATAペイロード / 完全なフレーム:', len(wire) - 9, len(wire))

ストリーム状態と2つのENDフラグ

クライアントが作成したリクエストストリームは奇数IDを使用します。サーバーが開始するプッシュストリームは偶数IDを使用します。任意の双方向RPCで偶数のリクエストストリームを適当に開くことはできません。IDは接続内で単調に使用され、完了後に回収されて再利用されません。ストリーム0はビジネスリクエストストリームではありません。

END_HEADERSは、現在の圧縮フィールドブロックが完全であることを示し、END_STREAMはこの送信方向の終了を示します。GETリクエストヘッダーのみがHEADERSでこれら両方を同時に設定できますが、サーバーは同じストリームでレスポンスを送信し続けることができます。コンテンツを含むPOSTでは、まずHEADERSを送信し、次に複数のDATAを送信し、最後にDATAまたはtrailer HEADERSで終了する可能性があります。

フィールドブロックがCONTINUATIONに分割された場合、END_HEADERSに至るまで、そのストリームに対応するCONTINUATIONを連続して送信しなければなりません。他のストリームのDATAを挿入することはできません。マルチプレクシングにもプロトコルレベルでの連続領域があるため、すべてのフレームが任意に割り込めるわけではありません。

マルチプレクシング:アプリケーションのキューとTCPのヘッドオブブロックは別々に考える

フレームはインターリーブ可能だが、TCPのギャップ後のバイトは依然として待機が必要 S1 HEADERS Stream IDでディスパッチ S3 HEADERS Stream IDでディスパッチ S3 DATA Stream IDでディスパッチ S1 DATA Stream IDでディスパッチ 欠落バイト 他のストリームに属するフレームでも、TCPのギャップをスキップすることはできない 図中のストリーム順序は例示です。HEADERSの未完了フィールドブロックには、連続するCONTINUATIONの要件があります。

ストリーム1のクエリがレスポンスの準備ができていない場合、ストリーム3は自分のレスポンスを先に送信できます。しかし、送信されたすべてのフレームは最終的に同じTCPシーケンス番号空間に収まります。TCPのバイトが失われると、受信側はギャップ後のバイトを、それらが別のHTTPストリームに属していても、連続ストリームとしてHTTP/2に渡すことはできません。

これは、パケットが1つ失われると、すでに配信されたすべてのストリームのデータが取り消されることを意味するものではありません。また、フレームが天然に対応するTCPパケットがあることを意味するものではありません。接続レベルの帯域幅、輻輳制御、サーバーのスケジューリング、アプリケーションのロック競合、HPACKの状態も、ストリームをまたぐ影響を引き起こす可能性があります。

二層のフロー制御:DATAのみを対象とし、輻輳制御ではない

DATAは接続ウィンドウと自身のストリームウィンドウの両方を消費する 接続送信余力:10,000 B すべてのストリームがこの方向の割り当てを共有 ストリーム1:余力 8,000 B まず6,000 BのDATAを送信 ストリーム3:余力 8,000 B その後、最大4,000 BのDATAを送信可能 例ではパディングなし、フレーム長上限は十分。ストリームウィンドウにまだ余裕があっても、接続ウィンドウによってブロックされる可能性がある。

送信方向に対して、DATAは接続とストリームの両方の割り当てによって制限されます。デフォルトの初期割り当ては65,535バイトです。SETTINGS_INITIAL_WINDOW_SIZEはストリームウィンドウを変更しますが、これを使って接続ウィンドウを大きくすることはできません。接続の割り当ては、ストリーム0のWINDOW_UPDATEによって調整されます。逆方向の送信には独自の割り当てがあります。

DATAのペイロード全体はフロー制御にカウントされます。これにはパディングとその長さフィールドが含まれますが、9バイトのフレームヘッダーは含まれません。HEADERS、PING、SETTINGSなどはDATAウィンドウを消費しません。したがって、DATAのみを制限しても、相手のフィールドデコードや制御フレームのリソース消費を制限するには不十分です。

connection = 10_000
stream = {1: 8_000, 3: 8_000}

def send(sid, size):
    global connection
    assert 0 <= size <= min(connection, stream[sid])
    connection -= size
    stream[sid] -= size

send(1, 6_000)
assert connection == 4_000 and stream[1] == 2_000
send(3, 4_000)
assert connection == 0 and stream[3] == 4_000
# ストリームウィンドウを増やしても、接続の余力を超えることはできない。
stream[3] += 10_000
assert min(connection, stream[3]) == 0
connection += 2_000
assert min(connection, stream[3]) == 2_000
print('接続ウィンドウ更新後、ストリーム3で送信可能:', min(connection, stream[3]))

WINDOW_UPDATEの増分は0であってはならず、ウィンドウにも上限があります。SETTINGS_INITIAL_WINDOW_SIZEを下げると、既存のストリームウィンドウが一時的に負の値になる可能性があります。この場合、割り当てが回復するのを待つ必要があり、負の数を強制的に符号なしの大きな数に変換してはいけません。アプリケーションがタイムリーに消費して受信バッファを解放することで、フロー制御の停止を防ぐことができます。

1つのストリームに割り当てを追加したのに、なぜまだ送信できないのか?

接続の割り当てを使い果たした後、ストリーム3のWINDOW_UPDATEのみをクリックします。金色のバーが伸びますが、送信ボタンはまだ使用できません。接続の割り当てをさらに追加して初めて、送信が再開されます。この状態は、2つの「残量の最小値」の制約が過去の操作によってどのように形成されるかを示しています。

図を準備しています
1つのストリームに割り当てを追加したのに、なぜまだ送信できないのか?

各DATAは2つの勘定を同時に引く。ストリーム3には残高があるが、接続が0になると停止する。HEADERSはこの2つのDATA勘定には含まれないが、他のリソース制約の影響を受ける。

HPACK:双方向でそれぞれ圧縮状態を維持する

HPACKは、61項目の静的テーブル、動的テーブル、およびオプションの固定Huffman符号化を使用します。リクエスト方向のエンコーダーとサーバー側のデコーダーは、対応する状態を共有しています。レスポンス方向は別の状態です。サーバーがレスポンスフィールドを格納したからといって、クライアントが次のリクエストでそれを参照できるわけではありません。

動的テーブルはエンコーディングコマンドに基づいてエントリを挿入し、後から挿入された項目が前に位置し、古い項目は容量に基づいて淘汰されます。「各HEADERSの終了後にすべてのフィールドが自動的に保存される」わけではなく、古い項目へのアクセスがLRUとして昇格されることもありません。エントリの課金は、名前バイト数 + 値バイト数 + 32で、圧縮後の線長とは異なります。

例えば、:authority: www.example.comは10+15+32=57バイトのテーブル容量を占めます。最初の動的インデックスは62です。次に新しい項目を挿入すると、古い項目のインデックスは63になります。静的インデックス2は:method: GETに対応し、1バイトのindexed表現は0x82です。インデックス62は0xbeですが、方向と履歴の状態が一致している場合にのみ、それが何を意味するかがわかります。

Never Indexedは、機密フィールドのエンコーディング制約に使用されます。フィールドを暗号化するものではなく、総長を隠すものでもなく、すべての圧縮側チャネルを排除するものでもありません。フィールドテーブルの容量制限と、デコード後のフィールドリストのサイズ制限は、異なるリソース制御です。RFC 7541:HPACK

インデックス62がなぜ次は別のフィールドを指すのか?

2つの項目を連続して挿入した後、62は後から挿入された方を指し、以前のフィールドは63になります。62を参照してもテーブルの順序に変化はありません。容量を縮小してスライダーを戻しても、すでに淘汰された項目は復活しません。容量制限と実際の履歴状態は、2つの独立した変数だからです。

図を準備しています
インデックス62がなぜ次は別のフィールドを指すのか?

インデックスは、現在の方向の圧縮状態における位置であり、フィールドの永続的な番号ではありません。挿入により古いインデックスが後方に移動し、容量はname+value+32で課金され、読み取りはLRU昇格を実行しません。

SETTINGS、優先度、リソースの境界

SETTINGSは、送信側が受け入れたい制約を記述するもので、相手側はこれに基づいて自分の送信を調整します。例えば、クライアントが最大フレーム長を宣言すると、サーバーがクライアントに送信するフレームが制限されます。これを逆に解釈して、クライアント自身の送信許可と見なしてはいけません。ACK付きのSETTINGSは設定項目を含まない。

MAX_CONCURRENT_STREAMSは、対応する方向で並列化できるストリームの数を制限するものであり、接続の履歴における累積リクエスト数ではありません。瞬時の並列数だけを制御しても、急速なストリームの作成/取消しによる作業負荷に耐えられない可能性があるため、リクエストのコスト、キュー、フィールドサイズ、取消しの伝播にも注目する必要があります。

古い優先度の依存ツリーはRFC 9113で廃止されました。RFC 9218は拡張可能な優先度を定義しており、urgencyは0から7の値を持ち、数値が小さいほど緊急度が高く、incrementalフラグは漸進的な配信に適しているかどうかを示します。これらはスケジューリングのヒントであり、固定されたスループットシェアを保証するものではなく、フロー制御ウィンドウを突破することもできません。RFC 9218

取消しと優雅な終了:RSTをトランザクションのロールバックと見なさない

RST_STREAMはストリームを終了しますが、アプリケーションがリクエストを実行していないことを証明するものではなく、データベーストランザクションを自動的にロールバックするものでもありません。実装は、接続に関連する圧縮とフロー制御の状態を維持し続ける必要があり、特定のストリームが取消されたからといって、関連するすべてのフレームを無視することはできません。

GOAWAYはLast-Stream-IDを用いて、相手側にどのストリームがすでに処理された可能性があるかを通知します。サーバーが最後に受け入れたクライアントのストリームIDが5であると仮定します。ストリーム7は新しい接続で再試行できます。ストリーム1、3、5のうち、完全なレスポンスを受け取れていないものは、実行の不確実性が残るため、メソッドとビジネスロジックに基づいて重複排除して処理する必要があります。優雅な終了では、まず新しいストリームの受信を停止し、既存のストリームの完了を待つ必要があります。GOAWAYを受信しただけで、すべてのリクエストを単純に再送信してはいけません。RFC 9113:接続の終了

Server Push:プロトコルの機能とブラウザのサポートは分けて考える

PUSH_PROMISEは、サーバーがクライアントが必要とする可能性のあるリソースを事前に約束し、送信することを可能にしました。しかし、キャッシュのヒットが不明確であることや、転送とスケジューリングの複雑さによる浪費が、その恩恵を弱める可能性があります。Chromeは106以降、HTTP/2 Server Pushのサポートを削除しましたが、これをもって「HTTP/2仕様からプッシュフレームが削除された」と記述してはいけません。Chromeの削除に関する説明

103 Early Hintsやプリロードヒントは、クライアントがリクエストを開始するかどうかを自分で決定できるようにするもので、サーバーがコンテンツを直接プッシュするものとは異なるメカニズムです。デプロイの選択は、実際のクライアントのサポートと測定結果に基づいて行うべきであり、「HTTP/2を有効にする」と「pushを有効にするべきだ」を結びつけてはいけません。

継続的な参照

  • HTTP/1.1:メッセージの境界、キャッシュ、メソッドのセマンティクス。
  • HTTP/3:QUICストリームとQPACKのトレードオフ。
  • TCP:バイトシーケンス番号、ウィンドウ、輻輳制御。