HTTP/2の裏側で起きていること:なぜ高速なはずのAPIが突如として沈黙するのか?
ウェブエンジニアやインフラの現場にいる私たちにとって、HTTP/2はもはや「当たり前のインフラ」だ。HTTP/1.1の呪縛であったHOL(Head-of-Line)ブロックを打ち破り、1本のTCPコネクション上で複数のリクエストとレスポンスを同時に流す「マルチプレクシング」は、Webの表示速度を劇的に引き上げた。
だが、現場の第一線で大規模なAPI基盤やマイクロサービス間の通信を設計・運用していると、ある不可解な壁にぶぶつかることがある。
「なぜか特定の大容量データ転送時に、突然ストリームの進みが止まるのか?」
「サーバー側のCPUやメモリには余裕があるのに、一部のクライアントだけスループットが出ないのはなぜか?」
その答えの多くは、HTTP/2の心臓部である「フロー制御(Flow Control)」、特にコネクションレベルとストリームレベルのウィンドウサイズの相互作用に隠されている。
教科書には「HTTP/2はフロー制御をサポートしています」と一行で片付けられがちだが、この仕組みを正しく理解していないと、実務の現場で思わぬデッドロックやパフォーマンス劣化の罠にハマることになる。今日は、パケットがワイヤー上を駆け巡るリアルな挙動を紐解きながら、この複雑な制御機構の正体を明らかにしていこう。
—
1. HTTP/2フロー制御の基本構造:2階建てのウィンドウ
HTTP/2のフロー制御は、TCPがOSのカーネルレベルで行う混雑制御とは異なり、アプリケーション層(HTTP/2レイヤー)で完結するきめ細やかな制御メカニズムだ。
ここで重要なのは、制御レイヤーが「2階建て」構造になっている点だ。
1. ストリームレベル(Stream-Level)フロー制御
- 個々のリクエスト/レスポンス(ストリーム)単位で独立したウィンドウサイズを持つ。
2. コネクションレベル(Connection-Level)フロー制御
- 1本のTCPコネクション上で動くすべてのストリームが共有する大元のウィンドウサイズ。
この2つは同時に、かつ独立して機能する。つまり、あるストリームが「俺のストリームウィンドウはまだ余裕があるぜ」とデータを送り出そうとしても、共有のコネクションウィンドウが枯渇していれば、1バイトたりともデータを送信することはできない。 逆に、コネクションウィンドウに空きがあっても、特定のストリームのウィンドウが0であれば、そのストリームの通信はそこでストップする。
この「二重の関所」を突破して初めて、データは安全にピア(対向ノード)に届くのだ。
—
2. パラメーターと通信の仕組み:WINDOW_UPDATEフレームの舞踏会
HTTP/2のコネクションが確立されると、双方は`SETTINGS`フレームを通じて初期ウィンドウサイズ(デフォルトでは65,535バイト=64KB)を合意する。
パケットのやり取りの裏側では、以下のようなダンスが繰り広げられている。
Client Server
| |
|— HEADERS / DATA (消費: 10KB) ———————–>|
| [Stream Window: 54KB / Conn Window: 54KB] |
| | (データ受信・処理)
|— WINDOW_UPDATE (Stream: +10KB) ———————>|
|— WINDOW_UPDATE (Connection: +10KB) —————–>|
| |
|<– DATA (Response: 70KB) ——————————|
| ここでコネクションウィンドウ(64KB)を超える! |
| |
|<– (残りのデータ送信がストップ:フロー制御ブロック) —-|
| |
|— WINDOW_UPDATE (Connection: +64KB) —————–>|
| |
|<– DATA (残りの6KBを送信完了) ————————–|
押さえておくべき主要パラメーター
- `SETTINGS_INITIAL_WINDOW_SIZE` (Setting ID: 0x4)
- 新規作成されるストリームの初期ウィンドウサイズを規定する。
- `WINDOW_UPDATE` フレーム (Frame Type: 0x8)
- 受信側が「データを処理してバッファに空きができたから、もっと送っていいよ」と送信側に伝えるためのフレーム。4バイトの「ウィンドウサイズインクリメント」を通知する。
ここでプロトコルスペシャリストとして特筆すべきポイントは、「コネクションレベルの `WINDOW_UPDATE` を送り忘れると、他のすべての健全なストリームまで巻き添え食って止まる」という事実だ。実装によっては、ストリームごとの管理に気を取られ、コネクション全体のクレジット(Window)回復処理が疎かになり、パフォーマンスが急降下するケースが後を絶たない。
—
3. 実務で直面する罠とデバッグアプローチ
大規模なJSONペイロードを返すAPIや、ファイルストリーミングを行うマイクロサービスで、「特定の環境からだと途中でレスポンスがフリーズする」というバグに遭遇したことはないだろうか。
大抵の場合、原因は以下のいずれかだ。
1. 受信側のアプリケーションが遅い、またはバッファを読まない
- 受信側がデータを読み進めないため、`WINDOW_UPDATE` が発行されず、送信側がウィンドウ制限に引っかかる。
2. リバースプロキシ(NginxやEnvoy)とバックエンド間のウィンドウミスマッチ
- 途中のプロキシがウィンドウ管理を誤ったり、バッファリングの設定が不適切で、コネクションプールのフロー制御がデッドロック気味になる。
トラブルシューティングの現場:nghttp2やcurlを使ったパケット解析
現場で「今、どこでつまっているのか」を突き止めるには、HTTP/2のフレームを直接覗くのが一番の近道だ。
1. `curl` の詳細ログでフレームを観測する
curlの`-v`オプションや `–trace` オプションを使うと、HTTP/2のネゴシエーションからフレームの往来まで丸裸にできる。
バイナリおよびテキストでフレームのやり取りを詳細にファイルへ出力する
curl –trace-ascii h2_trace.log –http2 https://api.example.com/v1/large-data
生成されたログファイルの中から、`WINDOW_UPDATE` や `SETTINGS`、そしてデータがパタッと止まる瞬間を探す。特に `hlth` や `sent` の前後のウィンドウサイズの変化に注目してほしい。
2. Python (httpx) を使ったカスタムクライアントでの挙動検証
実務では、あえてウィンドウサイズを小さく制限したクライアントやサーバーを立てて挙動をテストすることがある。現代のPython製HTTPクライアント `httpx` はHTTP/2をネイティブサポートしている。
import httpx
HTTP/2を有効にしたクライアントの作成
注:標準のhttpxは内部でh2ライブラリを使用しており、
高度なウィンドウ制御パラメータは低レイヤーのh2コンテキストを叩く必要があるが、
タイムアウトやストリームの挙動を検証する基本形として以下のように記述する。
client = httpx.Client(http2=True, timeout=30.0)
try:
# 大容量データを取得するリクエスト
response = client.get(“https://httpbin.org/bytes/10485760″) # 10MBのバイナリ
print(f”ステータスコード: {response.status_code}”)
print(f”受信サイズ: {len(response.content)} バイト”)
print(f”使用されたプロトコル: {response.http_version}”)
except httpx.ReadTimeout:
# フロー制御やバッファ詰まりによって読み込みが完了しない場合に発生する代表的なエラー
print(“エラー: データの読み込みがタイムアウトしました。フロー制御によるブロックの可能性があります。”)
finally:
client.close()
3. Nginxの設定でウィンドウサイズをチューニングする
もしあなたがインフラエンジニアで、Nginxをリバースプロキシとして運用している場合、クライアントとバックエンド間のバッファサイズやHTTP/2の設定を見直す必要がある。
http {
# HTTP/2の接続・ストリーム制御に関するディレクティブ例
# クライアントからの初期ウィンドウサイズを大きめに設定し、スループットを向上させる
http2_max_field_size 16k;
http2_max_header_size 32k;
# バッファサイズを最適化し、フロー制御による不要なウェイトを軽減
client_body_buffer_size 128k;
client_max_body_size 50m;
server {
listen 443 ssl http2;
server_name api.example.com;
location / {
proxy_pass http://backend_upstream;
proxy_http_version 1.1; # バックエンドへはHTTP/1.1で接続する場合のバッファリング設定
proxy_buffering on;
proxy_buffers 16 32k;
proxy_buffer_size 64k;
}
}
}
※Nginxのバージョンやモジュール構成によって、HTTP/2設定の構文(`http2` ディレクティブの扱いなど)が異なるため、利用しているディストリビューションのドキュメントを必ず確認してほしい。
—
4. シニアアーキテクトからの実務的アドバイス
ここまで、HTTP/2のコネクションレベルおよびストリームレベルのフロー制御について掘り下げてきた。最後に、現場で設計・運用にあたるエンジニアへ私からのアドバイスを贈ろう。
1. 「速いはず」という思い込みを捨てる
HTTP/2は万能の魔法ではない。特にパケットロスが多いモバイル回線や悪環境のネットワークでは、TCP層の再送とHTTP/2のフロー制御(およびヘッドオブラインブロッキング)が複雑に絡み合い、かえってHTTP/1.1より遅くなるケース(いわゆる「HTTP/2の罠」)が存在する。大容量のバイナリ転送を行うAPIでは、あえてコネクションを分ける(ドメインをシャードする、またはHTTP/1.1を併用する)設計判断も時には必要だ。
2. オブザーバビリティ(可観測性)を確保する
API Gatewayやリバースプロキシのメトリクスで、`connection window` や `stream blocked` といった指標がモニタリングできるようにしておこう。いざ障害が起きたとき、勘に頼らず「今、どこでウィンドウが枯渇しているか」をデータで即座に指し示せるエンジニアこそが、現場で最も信頼されるアーキテクトなのだから。
プロトコルの仕様書をめくるのはこれで終わりだ。次は、君自身の環境でパケットをキャプチャし、この見えない「ウィンドウのダンス」をご自身の目で確かめてみてほしい。
コメント