HTTP/2マルチプレクシングの罠:`SETTINGS_MAX_CONCURRENT_STREAMS`でサーバーを守り、APIの限界を引き出す設計術
こんにちは。インフラとプロトコルの底を這い回るネットワークエンジニアの私です。
Web APIの設計やインフラのサイジングをしているとき、「HTTP/2になったから、もうコネクションの枯渇なんて気にしなくていいよね」なんて楽観的な言葉を、若手エンジニアから耳にすることがあります。気持ちは痛いほど分かります。HTTP/1.1のあの呪いのような「ドメインあたり6本のTCPコネクション制限」から解放され、単一のTCPコネクション上で何百ものリクエストを同時に流し込める(マルチプレクシング)を見たときの感動は、今でも忘れられません。
しかし、現実はそう甘くありません。TCPの呪縛から逃れた代わりに、私たちは「サーバーリソースの限界」という、よりシビアな物理的制約と向き合うことになりました。
今回は、HTTP/2の真骨頂であるストリーム制御、その中でもサーバーとクライアントの命運を握る `SETTINGS_MAX_CONCURRENT_STREAMS`(最大同時ストリーム数)の設計指針について、現場の泥臭い実体験を交えながら徹底的に解説していきましょう。
—
1. なぜ「無限の並行処理」は凶器になるのか?
HTTP/2のマルチプレクシングは、1本のTCPセッション上で、独立した仮想チャネルである「ストリーム」を同時に多重化します。ブラウザやAPIクライアントから見れば、リクエストごとにTCPのハンドシェイクコストを払う必要がなくなり、まさに高速道路の車線が何車線にも拡張されたような快適さです。
だがしかし。車線が増えたからといって、料金所の処理能力を超えてトラックを突っ込ませたらどうなるでしょうか?
料金所(Webサーバー)のCPUやメモリ、バックエンドのデータベースコネクションは有限です。クライアントが悪意なく(あるいは大量の非同期処理によって悪意を持って)1つのTCPコネクション上で数千個のストリームを同時にオープンしたら、サーバーのメモリは瞬く間に食いつぶされ、OSのファイルディスクリプタは枯渇し、最終的にはOOM Killerによってサーバーが沈黙します。
この「野放図なマルチプレクシング」からサーバーの身を守るためにHTTP/2規格(RFC 7540)が用意した防波堤、それが `SETTINGS_MAX_CONCURRENT_STREAMS` です。
—
2. `SETTINGS_MAX_CONCURRENT_STREAMS` の正体と通信フロー
このパラメータは、HTTP/2の接続確立直後に、サーバー側からクライアントへ送られる `SETTINGS` フレームの中に含まれます。
> 「我が輩(サーバー)は、この単一のTCPコネクション上で、同時に処理できるストリームの数は最大でも N個 までとする。これを超える新規ストリームの開設要求は受け付けない!」
クライアントはこの値を遵守する義務があります。では、このパラメータが通信においてどのように機能するのか、シーケンスを見てみましょう。
[Client] [Server/API Gateway]
| |
|— 1. TCP 3-Way Handshake & TLS Negotiation ————->|
| |
|— 2. HTTP/2 Connection Preface (Client Magic + SETTINGS)->|
|<-- 3. SETTINGS Frame (includes MAX_CONCURRENT_STREAMS=100)-|
| |
|--- 4. STREAM #1 (GET /api/v1/users) --------------------->|
|— 5. STREAM #3 (GET /api/v1/items) ———————>|
| … (中略: 最大100個まで同時にリクエスト送信) |
| |
|— 6. STREAM #201 (GET /api/v1/logs) [超過!] ————>|
|<-- 7. RST_STREAM (Error: REFUSED_STREAM) -----------------|
| |
もしクライアントが制限値(上記の例では100)を超えてストリームを生成しようとした場合、サーバーは `RST_STREAM` フレームにエラーコード `REFUSED_STREAM`(エラー番号 0x7)を乗せて即座に拒絶します。
ここで重要なのは、サーバーは拒絶されたストリームについて、バックエンドの処理を一切実行していないという点です。これにより、サーバーリソースが無駄な負荷で溺れるのを防ぎます。
—
3. 現場で悩む「適切な設定値」の算定ロジック
では、この `MAX_CONCURRENT_STREAMS` は、具体的にいくつに設定するのが正解なのでしょうか?
残念ながら「魔法の数字」はありません。インフラのアーキテクチャ、バックエンドのデータベースの性能、そして扱うAPIの性格によってチューニングが必要です。
実務で私が設計する際に用いている算定アプローチを共有しましょう。
① サーバーのメモリとスレッドモデルからの逆算
Node.jsのようなシングルスレッド・非同期I/Oモデルと、Java (Spring Boot) や Ruby (Puma) のようなスレッドプール・プロセスモデルでは、耐えうる同時リクエスト数が全く異なります。
- スレッドプール型の場合:
バックエンドの最大DBコネクション数が `50` で、1つのリクエストが平均して1つのDBコネクションを専有するなら、サーバー全体(あるいはインスタンス単位)での同時処理限界は自ずと見えてきます。
ロードバランサーやリバースプロキシ(NginxやEnvoy)の段階で `MAX_CONCURRENT_STREAMS` を絞ることで、バックエンドへの過剰な負荷流入を物理的にブロックできます。
② 標準的な推奨値の目安
RFC 7540では、初期値として 100 以上の同時ストリームをサポートすることが強く推奨されています(あまりに小さすぎるとHTTP/2の恩恵が消え去るため)。
- 一般的なWeb/APIサーバー: `100` 〜 `250`
- リソースヘビーな重い処理を扱うAPI: `50` 前後(クライアント側にバックプレッシャーをかけるため)
- 軽量な静的アセット配信やマイクロサービス間通信: `500` 〜 `1000`
—
4. 実設定とコードによる挙動確認
百聞は一見に如かず。実際にNginxの設定と、クライアント側(Python)での振る舞いを見てみましょう。
設定例:NginxにおけるHTTP/2パラメータのチューニング
NginxをリバースプロキシやAPIゲートウェイとして使う場合、`http` ブロックや `server` ブロックでHTTP/2の挙動を制御できます。
http {
# HTTP/2の設定(Nginx 1.25.1以降の新しいディレクティブ形式)
# 同時ストリーム数の上限を「128」に厳格に制限し、暴走するクライアントから身を守る
http2_max_concurrent_streams 128;
# 1つのコネクション内で流せる最大データ量(例: 1GBを超えたらコネクションを張替える)
http2_max_requests 10000;
server {
listen 443 ssl http2;
server_name api.example.com;
# SSL/TLS証明書の設定は省略…
location / {
proxy_pass http://backend_cluster;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $http_host;
# バックエンドへのタイムアウト設定
proxy_read_timeout 60s;
}
}
}
クライアント側の実装とデバッグ:Python (httpx)
現代のモダンなHTTPクライアント(Pythonの `httpx` や Goの `net/http` など)は、HTTP/2のマルチプレクシングと `MAX_CONCURRENT_STREAMS` を自動的にハンドリングします。
もし制限値を超えるリクエストを同時に投げた場合、クライアントライブラリはサーバーからの `REFUSED_STREAM` を検知し、自動でリトライするか、適切にキューイングを行います。
以下のPythonコードで、サーバー側の制限にぶつかった際の挙動をシミュレート(または確認)できます。
import asyncio
import httpx
テスト対象のAPIエンドポイント
URL = “https://api.example.com/v1/health”
async def fetch_resource(client: httpx.AsyncClient, index: int):
try:
# HTTP/2が有効なクライアントを使用
response = await client.get(URL, timeout=10.0)
print(f”[{index}] Status: {response.status_code}”)
except httpx.HTTPStatusError as e:
print(f”[{index}] HTTP Error: {e}”)
except httpx.StreamError as e:
# サーバー側で MAX_CONCURRENT_STREAMS 超過により拒絶された場合の捕捉
print(f”[{index}] Stream Error (Likely REFUSED_STREAM): {e}”)
except Exception as e:
print(f”[{index}] Other Error: {e}”)
async def main():
# HTTP/2を明示的に有効化した非同期クライアントを作成
async with httpx.AsyncClient(http2=True) as client:
# サーバー側の設定値(例: 128)を遥かに超える 300個 のリクエストを同時に発射
print(“300個の並行リクエストを発射します…”)
tasks = [fetch_resource(client, i) for i in range(300)]
await asyncio.gather(tasks)
if __name__ == “__main__”:
asyncio.run(main())
—
5. シニアが教えるトラブルシューティングと現場の知見
最後に、現場で実際に遭遇しがちな「HTTP/2ストリーム周りのハマりどころ」と、その処方箋をいくつか授けましょう。
トラブル1:突然のコネクション切断と `GOAWAY`
ある日突然、特定の巨大クライアントからのリクエストがパタッと止まり、サーバーのログに `GOAWAY` フレームの送受信エラーが記録されることがあります。
これは、HTTP/2のコネクション寿命(Max RequestsやMax Connection Duration)に達したか、あるいはあまりに多くのストリームエラーが発生したため、サーバー側が「おい、このコネクションはもう一度イチから張り直そうぜ」と強制切断しているサインです。
- 処方箋: クライアント側のコネクションプールの持ち方を見直す。長寿命すぎるTCPコネクションは、ロードバランサーの負荷分散(ラウンドロビン等)を阻害する原因にもなります。適度なタイミングでコネクションを自発的に閉じ、再確立させる設計(Connection Keep-Aliveの適切なライフサイクル管理)が不可欠です。
トラブル2:Head-of-Line Blocking(ヘッド・オブ・ライン・ブロッキング)の勘違い
「HTTP/2はマルチプレクシングだから、前のリクエストが詰まっても後ろは平気!」……これはTCPレイヤーの話としては正解ですが、アプリケーションレイヤーでは半分間違いです。
1つのTCPコネクション上で極端に重いレスポンス(例えば数メガバイトのJSON生成など)が帯域を占有し、かつTCPのウィンドウサイズが枯渇すると、同じコネクション上の他の軽いAPIリクエスト(ヘルスチェック等)まで遅延する現象が起きます。
- 処方箋: 重い処理と軽い処理、あるいはデータ取得系とファイルアップロード系などの性質が大きく異なる通信は、あえて異なるドメイン(またはオリジン)に分離し、別々のTCPコネクションを使わせることも、高度なアーキテクチャ設計としては立派な選択肢です。
—
まとめ
HTTP/2の `SETTINGS_MAX_CONCURRENT_STREAMS` は、単なるプロトコル上の仕様の数値ではありません。「クライアントの自由な要求」と「サーバーの物理的な限界」の境界線に引かれた、極めて重要な防衛線です。
APIを設計する際、そしてインフラを構築する際には、「何台のクライアントが、同時に何本のリクエストを流し込んできたときに、どこでリソースが飽和するのか」を常に逆算してください。このパラメータを制する者が、高負荷耐性を持つ真にロバストなWebインフラを制します。
現場からは以上です。あなたのアーキテクチャが、今日も美しいパケットの軌跡を描くことを祈っています。
コメント