はじめに
HTTP/3も出てきて今更感があるが、改めてHTTP/2についてまとめてみました。
HTTP1.1とその問題点
HTTP/2誕生前から使用されているHTTP/1.1では基本的には1つのリクエストが完了しレスポンスが返ってくるするまで、次のリクエストを送ることができません。
HTTPパイプラインという仕組みを使えばHTTP/1.1でも完了を待たずに、複数のリクエストを送信することが可能ですが
サーバーはリクエストの順番通りにレスポンスを返さなければならないという制約があります。
3つのリクエストを送信して、
1つめのリクエストのレスポンスが最も重い場合は
2つめ以降のレスポンスが待たされる結果となります。
これをHTTP HOL Blockingといいます。
HTTP/1.1で速度を上げ場合には
TCP接続を多重化するしかないです。では多重化する場合どこに問題があるのでしょうか?
クライアントがWebブラウザであったりする場合は1ドメインにおける最大同時接続数に制限があります。
あとWebブラウザに関係なく、多重化して使用する場合TCPコネクションを行うための負荷がかかります。
多重化して使用する場合の問題点
- Webブラウザ上からの接続の場合は1ドメインにおける同時接続数に規制があったりする
- 現在使用されている主要なWebブラウザの場合でも最大6まで
- TCPコネクションのバーヘッド
TCPコネクション
3Wayハンドシェイク
TCPコネクションが確立されるまでに何が実施されているかというと、接続エンドポイント間で3Wayハンドシェイクが実施されています。
TCPコネクションフロー
アプリケーションから見たとき、TCPコネクション確立時に何を実施されているかというと
簡単には下記のフローの通りsocketの設定、実施を実施しております。各処理においてシステムコールが発生してます。
-
サーバー
-
socket作成
-
IPアドレス、Portの設定
-
IPアドレス、Portとsocketをbindする
-
接続待ち
-
クライアント
-
socket作成
-
接続相手を設定する
-
接続
以上のことによりコネクション数はなるべく少なくて済むものなら少なく済ませたいです。
HTTP/2
HTTP2では一つにTCPコネクションに内において複数のストリームというデータ(フレーム)の送受信シーケンスをつくることができるようになりました。
また、HTTP/1.1まではヘッダー部、ボティー部は改行で分割されているだけで1つの送信・受信のデータ単位でした。(ヘッダー、ボティを一括して送受信)
HTTP/2ではフレームというものをデータ最小単位として、ヘッダーフレーム、データフレームと独立して送受信をできるようになりました。
これらの機能を駆使してより柔軟な送受信に対応可能になりました。
- ストリームの多重化
- ストリームの優先度
- フレーム
- ヘッダー圧縮
- フロー制御
仕様
ストリーム
- フレームのやり取りをするためのもの
- 1TCPコネクション内に複数のストリームを持つことができる(多重化)
- 複数ストリーム間で優先度や依存関係を持てる(依存関係)
ストリームの多重化
HTTP/1.1ではリクエストとレスポンスの組を1つずつしか同時に送受信できないことが制限となり、プロトコルレベルでボトルネックになっていました。HTTP/2では1つの接続上にストリームと呼ばれる仮想的な双方向シーケンスを作ることで問題を克服しています。
HTTP/2では1つのコネクション上で複数並列に扱うことができます。そのためHTTP/1.1時代で問題となっていたHOLブロッキング問題を解決します。
ストリームの優先度 (改訂版にて廃止)
ストリームの多重化によりブロックされることは無くなりました。しかし例えばレンダリングに関係ないリソースは後回しにするとういうようなケースはストリームの優先度というものが必要があります。HTTP/2ではPRIORITYフレームを用いてストリームに優先度を付けることが可能となりました。
優先順位は、「重み付け」と「依存関係」の2つがあり、ストリームAを他のストリームより優先させることや、BとCのストリームをそれぞれ2:5の重み付けを付ける事などが可能になります。
動画サイトの表示なので動画再生中にPauseされた瞬間に動画ダウンロードの優先度を下げて、別の通信の優先度が上がるような使用法が考えられます。
しかし PRIORITY関連のパラメーターは全てサーバーへの優先度提案であり、必ずしもパラーメーター通りに配信されるとは限りません。
2022年現在RFC7540 HTTP/2の改定作業が進められており、優先度制度は複雑すぎるという理由で排除されました。
ストリームの構成
多重化、優先度を扱うための一意なIDと送信、受信エンドポイントから様々なストリームの操作を行うため、もしくはネットワークやエンドポイントの状況よりストリーム制御を行うために状態管理をしています。
- ID
- 状態
ストリームのID
ストリームには一意のIDが存在し、ストリーム生成時に採番します。
- クライアントが生成したストリームIDは奇数
- サーバーが生成したストリームIDは偶数
ストリームの状態
idle状態にあるストリームはHEADERSフレームを受け取ることによりopen状態となります。
一般的な使われ方としてはopen状態にあるストリームがhalf-closedもしくはclosedに遷移するまでの間には、DATAフレームが送受信されます。
状態 | 内容 |
---|---|
idle | 初期状態 |
reserved(local) | PUSH_PROMISEを送信して予約した状態 |
reserved(remote) | PUSH_PROMISEを受信して予約された状態 |
open | Data送受信可能な状態。MAX_CONCURRENT_STREAMSに含まれる |
half-closed(local) | Data送受信可能な状態。MAX_CONCURRENT_STREAMSに含まれる |
half-closed(remote) | Data送受信可能な状態。MAX_CONCURRENT_STREAMSに含まれる |
close | クローズ |
フレーム
フレームタイプに従って構成される可変長のオクテット列からなるHTTP/2接続内部での最小の通信単位です。
- 可変長のオクテット列からなる
- フレームタイプに従ってタイプが分かれる
- バイナリ形式
HTTP/1.1以前までは1リクエストにつきヘッダー部とボディ(Data)部を1つのデータ形式でかつ、テキストベースでした。それ故オーバヘッドが発生することがありました。HTTP/2以降はHeaderフレーム, Dataフレームと分割されて、様々なシチュエーションに対して柔軟な送受信が可能になってます。
フレームの種類
Type | フレームの種類 | 役割 | gRPCでの使用 |
---|---|---|---|
0 | DATA | HTTP/1.1におけるリクエスト/レスポンスのボディ部分に相当 | ○ |
1 | HEADERS | HTTP/1.1におけるリクエスト/レスポンスのヘッダー部分に相当 | ○ |
2 | ストリームの優先順位を指定(クライアントのみ送信可能)(改訂版により廃止) | ☓ | |
3 | RST_STREAM | エラーなどの理由でストリームを終了するために用いる | ○ |
4 | SETTINGS | 接続設定を変更する | ○ |
5 | PUSH_PROMISE | サーバプッシュを予告します(サーバのみ送信可能) | ☓ |
6 | PING | 接続の生存状態を調べる | ○ |
7 | GOAWAY | エラーなどの理由で接続を終了するために用いる | ○ |
8 | WINDOW_UPDATE | ウィンドウサイズを変更する。フロー制御で使用する | ○ |
9 | CONTINUATION | サイズの大きなHEADERS/PUSH_PROMISEフレームの断片 | ○ |
※実はgRPCを介してはPRIORITYとPUSH_PROMISEは使用していません
各フレームの内部構成
各フレームの構成は下記の通り。フレームヘッダーとはPayload以外を指す
項目 | 容量(bit) | 内容 |
---|---|---|
Length | 24 | Payloadの容量を示す |
Type | 8 | フレームの種類を指す |
Flags | 8 | フレームタイプ固有の真偽値フラグ |
R | 1 | 予約済みの1ビットのフィールド。送信時は0固定、受信時は無視 |
Stream Identifier | 31 | 所属するStreamIDを示す |
Frame Payload | 0〜 | データ部 |
ヘッダー圧縮
送受信したヘッダーサイズを削減するために下記の方法が取られています。
- ハフマン符号化データ圧縮
- ヘッダーのKey-ValueのIndex化により、以前送信した値は送信データ量を縮小可能
ハフマン符号化を使用すると、転送時に個々の値を圧縮できます。また、以前に転送した値をインデックス化したリストを使用すると、インデックス値を転送することで、重複する値をエンコードできます。これは、効率的な検索とヘッダー全体のキーと値の再構築に使用できる場合があります。
フロー制御
フロー制御は、センダーからレシーバーに望ましくない量のデータや処理できないデータを送信して負荷をかけることを防ぐしくみです。
-
レシーバーの負荷が高くビジー状態だったり、または特定のストリームに所定のリソースを割り当てたいだけの場合
-
優先度の高い動画ストリーミング中にユーザーによって一時停止がなされてストリーミングよりも他のタスクの優先度を上げたい場合
-
高速のダウンストリーム接続と低速のアップストリーム接続がある場合、リソースの使用状況を制御するため、プロキシサーバーにてアップストリームの速度に合わせてダウンストリームのデータ配信速度を制限したい場合
-
各レシーバーは、各ストリームおよび接続全体に必要な任意のウィンドウサイズを設定可能
-
ウィンドウサイズとはACKを待たずに一度に送信できるデータ量
-
デフォルト値は65,535バイト
-
最大ウィンドウサイズ(2^31-1 バイト)
-
各レシーバーは最初の接続とストリームフロー制御ウィンドウ(バイト単位)をWINDOW_UPDATEフレームによって予約。これはセンダーがDATAフレームを発行すると削減。
-
フロー制御を無効にすることはできない
-
フロー制御はエンドツーエンドではなく、中間でフロー制御を使用してリソースの使用を制御したり、リソース割り当てのしくみを実装したりできる
gRPC
gRPC側でどのように処理をしているかを簡単に説明します。
Goのライブラリーを使用していますが、おそらく他の言語でも参考になるかと思います。
送受信の処理
クライアントでのTCP接続時のオプション
No | コネクション接続時に設定するオプション |
---|---|
1 | 書き込みバッファサイズ。システムコールの容量の2倍(デフォルト32KB) |
2 | 読み込みバッファサイズ。システムコールの容量(デフォルト32KB) |
3 | 初期ウィンドウサイズ (最小値64KB) |
4 | コネクション上での初期ウィンドウサイズ (最小値64KB) |
5 | デフォルトストリームコールオプション設定(下記参照) |
6 | 外部API接続パラメーター |
7 | 接続失敗時のリトライ回数設定 |
8 | サーバー接続をバックグラウンドで行う設定 |
9 | トランスポート層のセキュリティーを有効にする |
10 | TLSなど認証・認可・セキュリティー設定(トランスポート-セッション層) |
11 | TLSなど認証・認可・セキュリティー設定(RPC単位) |
12 | 外部APIを用いたTLSなど認証・認可・セキュリティー設定 |
13 | 接続失敗時に接続する接続先を設定 |
14 | KeepAlive用設定 |
15 | Unary接続のInterceptorを設定 |
16 | Stream接続のInterceptorを設定 |
クライアントからリクエスト(HEADERSフレーム、DATAフレーム)送信時のストリームオプション
No | ストリーム単位で設定するオプション |
---|---|
1 | (画像、動画などの)圧縮形式 |
2 | 接続・リクエスト失敗時に再接続するか |
3 | クライアントストリーム |
4 | 受信時のヘッダーフレームとデータフレームの合計の最大サイズ |
5 | 送信時のヘッダーフレームとデータフレームの合計の最大サイズ |
6 | RPC単位の認証・認可設定 |
7 | RPC用のコーデック設定(デフォルトはprotocol buffers) |
8 | リトライ時の最大バッファサイズ |
サーバーサイドでのTCP接続時の設定
No | コネクション接続時に設定するオプション |
---|---|
1 | 書き込みバッファサイズ。(デフォルト32KB) |
2 | 読み込みバッファサイズ。(デフォルト32KB) |
3 | 初期ウィンドウサイズ (最小値64KB) |
4 | コネクション上での初期ウィンドウサイズ (最小値64KB) |
5 | KeepAlive設定 |
6 | KeepAlive強制執行設定 |
7 | コーデック設定 |
8 | 受信時のヘッダフレームとデータフレームの合計の最大サイズ(デフォルト4MB) |
9 | 送信時のヘッダフレームとデータフレームの合計の最大サイズ(デフォルトmath.MaxInt32) |
10 | MAX_CONCURRENT_STREAMS(同時接続可能なストリーム数) |
11 | 認証・認可設定 |
12 | Unary接続のInterceptorを設定 |
13 | Stream接続のInterceptorを設定 |
14 | 接続タイムアウト制限時間 |
15 | HTTP/2のHEDAERSリストの無圧縮のサイズ |
16 | HTTP/2のHEDAERSのサイズ |
KeepAliveについて
- サーバー立ち上げ後、常にKeepAliveチェックを行っている
- クライアントTCP接続直後にKeepApive設定済ならPINGフレームを送信
- サーバーサイドも該当クライアントとTCP接続開始直後より、KeepAlive設定情報の間隔でPINGフレームを送付する
- クライアント、サーバーそれぞれにKeepAlive設定が存在する
- ただし、サーバーサイドにはKeepAlive強制執行用の設定もある
- GOAWAYフレームの理由がPingによるものの場合、自動的にKeepAlive制限の設定数か変わる
KeepAlive設定
パラメータ | 内容 |
---|---|
MaxConnectionIdle | GoAwayを送信してアイドル接続が閉じられるまでの時間の長さ。アイドル期間は未処理のRPCの数が最後にゼロになったとき、または接続が確立されたときから定義される(デフォルト:無限) |
MaxConnectionAge | Go Awayを送信して接続が閉じられるまでの、接続が存在する最大時間の期間。この設定値の0.9〜1.1倍にランダム補正を加えて実施する(デフォルト:無限) |
MaxConnectionAgeGrace | MaxConnectionAgeの後の追加期間であり、その後は接続が強制的に閉じる(デフォルト:無限) |
Time | Ping送信間隔。 1秒未満には設定不可 |
Timeout | タイムアウト待機時間 |
KeepAlive強制執行ポリシー
サーバー側でキープアライブ施行ポリシーを設定するために使用されます。サーバーは、このポリシーに違反するクライアントとの接続を閉じます
パラメータ | 内容 |
---|---|
MinTime | クライアントPING待機制限(デフォルト5分) |
PermitWithoutStream | trueの場合、サーバーはアクティブなストリーム(RPC)がない場合でもキープアライブpingを許可します。 falseの場合、アクティブなストリームがないときにクライアントがpingを送信すると、サーバーはGOAWAYを送信して接続を閉じます。(デフォルト:false) |
MAX_CONCURRENT_STREAMS(同時接続可能なストリーム数)
1TCPコネクション内で多重化させられるストリーム数。
この値を超えるストリーム作成を要するHEADERSフレームが送信されたタイミングで、HTTP/2はPROTOCOL_ERROR またはREFUSED_STREAMのストリームエラーを返します。
同時接続可能なストリーム数は、下記の状態のストリームの数で計算されます。
- "open"
- "half-closed"
ストリームの状態に関してはこちらを参照
gRPCでは使用されていないHTTP/2の機能
改めて調べてみてちょっと驚いたのはgRPCでは下記の機能を使用していないことです。
- サーバープッシュ
- ストリームの優先順位を指定
サーバープッシュ
gRPCは実はサーバートリガーのプッシュ機能はありません。
1つのTCPコネクションが使い回せることで、何度もリクエストを送信したり、間隔を空けて何度もレスポンス返したりすることで見た目上サーバープッシュに近いことはできるかもしれませんが、あくまでもクライアントからのリクエストとそれに対するサーバーレスポンスの返却を実施するものです。
ストリームの優先順位を指定
複数のストリームに依存関係を持たせたり、ストリームの優先度を設けて制御する機能はgRPCでは利用できません。
ソース上で確認
サーバープッシュ
GoのgRPCパッケージ上におけるクライアント側でのサーバーからのフレーム受信時のソースの一部を抜粋すると下記の通りです。(readerメソッド(*2))
gRPCで使用するすべてのフレームタイプをswitchで判別してますが、サーバープッシュ開始時にサーバーからクライアントに送られるPUSH_PROMISEが存在しません。
switch frame := frame.(type) {
case *http2.MetaHeadersFrame:
t.operateHeaders(frame)
case *http2.DataFrame:
t.handleData(frame)
case *http2.RSTStreamFrame:
t.handleRSTStream(frame)
case *http2.SettingsFrame:
t.handleSettings(frame, false)
case *http2.PingFrame:
t.handlePing(frame)
case *http2.GoAwayFrame:
t.handleGoAway(frame)
case *http2.WindowUpdateFrame:
t.handleWindowUpdate(frame)
default:
errorf("transport: http2Client.reader got unhandled frame type %v.", frame)
}
ストリームの優先度
サーバー側でクライアントからのフレーム受信時のソースの一部を抜粋すると下記の通りです。
(handleStreamメソッド(*1))
こちらもクライアントからフレームタイプをswitchで判別してますが、優先順位指定で使用するPRIORITYが存在しません。
switch frame := frame.(type) {
case *http2.MetaHeadersFrame:
if t.operateHeaders(frame, handle, traceCtx) {
t.Close()
break
}
case *http2.DataFrame:
t.handleData(frame)
case *http2.RSTStreamFrame:
t.handleRSTStream(frame)
case *http2.SettingsFrame:
t.handleSettings(frame)
case *http2.PingFrame:
t.handlePing(frame)
case *http2.WindowUpdateFrame:
t.handleWindowUpdate(frame)
case *http2.GoAwayFrame:
// TODO: Handle GoAway from the client appropriately.
default:
errorf("transport: http2Server.HandleStreams found unhandled frame type %v.", frame)
}
HTTP/2パッケージでは
Goのgrpcパッケージはhttp2パッケージを内包してます。
http2パッケージにはhttp2.PushPromiseFrame, http2.PriorityFrameは存在します。しかし、改訂版では廃止されました。互換性を残すためにPRIORITYフレームは削除されずのこしてますが、同フレームを受信しても機能せず破棄されるようになっています。
所感
個人的な必要性からgRPCという切り口からまとめてみました。多少に理解のお役に立てれば恐縮です。
HTTP/2の改定、HTTP/3と現在も改良が続けられています。
複雑であることが理由でブラウザやgRPCなどクライアントサイドであまり利用されなかったことを受け、ストリームの優先度機能は廃止されました。今後もHTTP通信の動向により目が離せません。