HTTP/2マルチプレクシングの裏側:クライアント側ストリーム数制限(SETTINGS_MAX_CONCURRENT_STREAMS)が引き起こす実務の罠
こんにちは。ネットワークとプロトコルの深淵を覗き続けて幾星霜、数々の不可解なパケットロスやトランスポート層の泥沼を潜り抜けてきたシニアアーキテクトの私だ。
今日のテーマは、HTTP/2における「クライアント側の同時ストリーム数制限」だ。
「HTTP/2って、1つのTCPコネクション上で複数のリクエストを同時に流せる(マルチプレクシング)から、HTTP/1.1のドメインシャーディングやHead-of-Line Blocking地獄とはおさらばだよね?」
そう思ってモダンなWeb API設計やインフラ構築に挑み、本番環境で痛い目を見たエンジニアは数知れない。
実は、HTTP/2のパラメーター設計を誤ると、ブラウザやAPIクライアント、そして逆方向プロキシ(NginxやEnvoyなど)の間で奇妙なレイテンシーの悪化や、最悪の場合はリクエストのデッドロック、`REFUSED_STREAM` エラーによるサイレントなリトライの嵐に巻き込まれることになる。
今回は、RFC 7540が規定する `SETTINGS_MAX_CONCURRENT_STREAMS` の実務的な意味と、それが破られたときにネットワーク上で何が起きているのかを、パケットの挙動と現場の知見を交えて徹底的に解説しよう。
—
1. そもそも `SETTINGS_MAX_CONCURRENT_STREAMS` とは何か?
HTTP/2の真髄は、1本のTCPコネクションを仮想的な「ストリーム」の束に分割し、そこに複数のリクエストとレスポンスをインターリーブ(多重化)して流し込むマルチプレクシングにある。
しかし、無限にストリームを生成できるわけではない。サーバー側(あるいはクライアント側)のリソース――メモリ、CPU、ファイルディスクリプタ、そして状態管理コスト――には物理的な限界がある。そこで登場するのが、HTTP/2のコネクション確立直後に交わされる SETTINGSフレーム だ。
この設定フレームに含まれる `SETTINGS_MAX_CONCURRENT_STREAMS` は、「相手に対して、同時にオープンできるストリームの最大数」 を通知するためのものである。
ここで多くのエンジニアが混乱するポイントを整理しておこう。
- サーバーからクライアントへの通知:
「うちのサーバーはリソースがカツカツだから、同時ストリーム数は最大100個までにしてくれよ」とサーバーがクライアントに伝える。クライアントはこの上限を守る義務がある。
- クライアントからサーバーへの通知(今回の主役):
「私のほう(クライアント)は、同時にこれだけのストリームを受け付ける準備がある」と通知する。実はRFC 7540では、クライアント側もこの制限をサーバーに対して設定できる(デフォルトでは無制限、あるいは十分大きな値にすることが多い)。
実務上、特にWeb APIクライアントやマイクロサービス間の通信(gRPC含む)において問題になるのは、「クライアントがサーバーに対して設定する制限」ではなく、「サーバーがクライアント(またはリバースプロキシがアップストリーム)に対して課す制限」の超え方、そしてその逆のケースだ。
—
2. 上限を超過したとき、ネットワーク上で何が起きるのか?
もし、クライアント側(あるいは負荷をかける側)が、サーバーから通知された `SETTINGS_MAX_CONCURRENT_STREAMS` の上限を超えて新しいストリーム(HEADERSフレーム)を開こうとした場合、プロトコルはどう裁くのか。
答えは非情で、かつ非常に厳格だ。
サーバーは、上限を超えて送られてきた新しいストリームに対して、`RST_STREAM` フレーム(エラーコード:`REFUSED_STREAM` (0x7)) を即座に送り返す。
通信シーケンスのイメージ
[Client / API Caller] [Server / Reverse Proxy]
| |
| —- SETTINGS (MAX_CONCURRENT_STREAMS: 3) —>|
|<---- SETTINGS_ACK ----------------------------|
| |
| ---- HEADERS (Stream #1: GET /api/v1) ------->|
| —- HEADERS (Stream #3: GET /api/v2) ——->|
| —- HEADERS (Stream #5: GET /api/v3) ——->|
| | (上限3に到達)
| —- HEADERS (Stream #7: GET /api/v4) ——->|
| |
|<--- RST_STREAM (Stream #7, REFUSED_STREAM) ---| (拒絶!)
| |
ここで重要なインフラエンジニア的知見がある。
`REFUSED_STREAM` エラーを受け取った場合、HTTP/2の仕様上、クライアントはそのリクエストを「安全に自動リトライ(Safe to retry)」してよいことになっている(RFC 7540 Section 8.1.4)。TCPのコネクション自体が切断されるわけではないため、クライアントライブラリが裏側で勝手に再送を試みる。
しかし、これが災いする。
アプリケーション層の意図とは裏腹に、背後で無駄なパケットが往復し、レイテンシーが劇的に悪化する「隠れレイテンシー増大の温床」となるのだ。
—
3. 実践:環境ごとの設定と挙動の確認
百聞は一見に如かず。実際にNginxなどのインフラ設定と、クライアント側からの挙動をコードベースで確認してみよう。
① サーバー側(Nginx)での設定例
Nginxでは、`http`ブロックや`server`ブロックでHTTP/2の同時ストリーム数を制御できる。デフォルトでは128(バージョンやビルドによる)だが、負荷テストやリソース保護のために調整することがある。
/etc/nginx/nginx.conf またはバーチャルホスト設定
server {
listen 443 ssl http2;
server_name api.example.com;
# クライアントが同時にオープンできる最大ストリーム数を「10」に厳しく制限
# (※実際のプロダクションでは100〜256程度が一般的です)
http2_max_concurrent_streams 10;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
location / {
proxy_pass http://backend_cluster;
# アップストリームへの接続でもHTTP/2を使う場合の考慮も必要
}
}
この環境に対して、1つのクライアントから一気に20個の並行リクエストを投げ込むと、11個目以降のリクエストは前述の `REFUSED_STREAM` の洗礼を受けることになる。
—
② クライアント側(Node.js / Fetch API または Python)での実装と挙動
次に、開発現場でAPIを叩く側のコードを見てみよう。ここでは Pythonの `httpx` ライブラリ(HTTP/2をネイティブサポート)を例にとる。
import asyncio
import httpx
async def fetch_resource(client, url, index):
try:
# 同時に多数のリクエストを投げる
response = await client.get(url)
print(f”Request {index}: Status {response.status_code}”)
except httpx.HTTPError as exc:
print(f”Request {index} failed: {exc}”)
async def main():
url = “https://api.example.com/heavy-endpoint”
# HTTP/2を有効にしたクライアントセッション
async with httpx.AsyncClient(http2=True) as client:
# 同時に50個のリクエストを非同期で発射!
# サーバー側の max_concurrent_streams (例: 10) を大幅に超える
tasks = [fetch_resource(client, url, i) for i in range(50)]
await asyncio.gather(tasks)
if __name__ == “__main__”:
asyncio.run(main())
【現場のTips】
このコードを先ほどのNginx(上限10)に対して実行すると、運良く最初の10個は処理されるが、残りの40個はクライアントライブラリの内部キューで待たされるか、サーバーから送られてきた `REFUSED_STREAM` を受けてクライアント側で自動再送(リトライ)の嵐が発生する。
結果として、サーバーのCPU使用率が跳ね上がり、全体のスループットがガタ落ちする現象に直面する。これが「HTTP/2のマルチプレクシングなら何でも高速になる」という幻想が打ち砕かれる瞬間だ。
—
4. トラブルシューティング:パケットキャプチャ(Wireshark / nghttp2)で何を見るか?
インフラエンジニアとして障害対応を行う際、ブラウザのDevToolsだけでは限界がある。正確な真実を知るためには、TLSを復号しながらパケット(あるいはフレーム)を覗き見る必要がある。
コマンドラインで手軽にHTTP/2の挙動をデバッグしたいなら、`nghttp` コマンド(nghttp2パッケージに含まれる)が最強の相棒だ。
冗長出力(-v)を有効にして、ストリームの生成とフレームのやり取りを監視する
nghttp -v -n 50 https://api.example.com/heavy-endpoint
出力結果の中に、以下のようなやり取りが見つかったら、それはまさに `SETTINGS_MAX_CONCURRENT_STREAMS` の制限に引っかかっている証拠だ。
[1.234] send SETTINGS frame
[1.300] recv (stream_id=0) SETTINGS frame:
MAX_CONCURRENT_STREAMS: 10
[1.301] send HEADERS frame (stream_id=3)
[1.302] send HEADERS frame (stream_id=5)
… (中略) …
[1.350] recv RST_STREAM frame (stream_id=23, error_code=REFUSED_STREAM (0x7))
このログが見えたら、以下の2つのアプローチのどちらか(あるいは両方)を検討すべきだ。
1. サーバー側のリソース増強とチューニング:
バックエンドの処理能力に余裕があるなら、`http2_max_concurrent_streams` の値を引き上げる(ただし、DoS攻撃に対する耐性とのトレードオフになるため無制限は厳禁)。
2. クライアント側の並行度制御(コネクションプーリングとバッチ処理):
クライアント側で無謀な数の同時リクエストを乱れ撃つのではなく、セマフォやキューイング機構(例: p-limit等のライブラリ、またはコネクションプールの分割)を挟んで、適切な並行数にコントロールする。
—
5. まとめ
HTTP/2のマルチプレクシングは魔法の杖ではない。1本のTCPパイプラインを共有するということは、「1つのボトルネック(上限値)に全体が縛られるリスク」を常に孕んでいるということだ。
- `SETTINGS_MAX_CONCURRENT_STREAMS` は、サーバーとクライアントの健康状態を守るための「安全弁」。
- 上限を超えたリクエストは容赦なく `REFUSED_STREAM` で弾かれ、予期せぬ自動リトライやレイテンシー悪化を引き起こす。
- インフラを設計する際は、サーバー側の制限値だけでなく、クライアントが叩くリクエストの同時実行数を必ず計測・制御すること。
プロトコルの仕様書(RFC)の行間に隠されたこうした挙動を理解しているか否かで、障害発生時の復旧スピードは文字通り天と地ほどの差となって現れる。
さあ、今すぐあなたのシステムのNginx設定と、APIクライアントの同時実行数を再確認してみよう。パケットは嘘をつかない。
コメント