【テクニカル・上級編】HTTP/2におけるクライアント側の同時ストリーム数制限の挙動 – HTTPプロトコル・通信規格実践ガイド

HTTP/2マルチプレクシングの暗黒面:クライアント側ストリーム数制限が生むパケットの静寂と悲劇

Webの高速化を旗印にHTTP/1.xの頭痛の種であったHOLブロック(Head-of-Line Blocking)を粉砕し、単一のTCPコネクション上に仮想的なマルチプレクシング空間を切り拓いたHTTP/2。このプロトコルは一見すると魔法の杖のように、1つのTLSセッション上で無限とも思える並行リクエストをさばくかのように振る舞います。

しかし、パケットアナライザの深淵を覗いたことがあるネットワークアーキテクトなら知っているはずです。この「多重化」という甘美な仕組みの裏側には、送信側と受信側の間で交わされる厳格なリソース協調のルール、そしてその境界を踏み越えたときに訪れる、静かで確実な破滅が存在することを。

今回は、HTTP/2における「クライアント側の同時ストリーム数制限(SETTINGS_MAX_CONCURRENT_STREAMS)」という、普段はあまりスポットライトが当たらないプロトコルの急所に焦点を当てます。パケットレベルの挙動、HTTP/2の生みの親が仕組んだ制御フローの裏側、そして現場のインフラエンジニアが踏み地雷になり得る脆弱性とチューニングの極意を紐解いていきましょう。

—

1. SETTINGSフレームの裏側:誰が主導権を握っているのか

HTTP/2のコネクションが確立され、クライアントとサーバーの間で暗号化されたTLSハンドシェイクの泥臭いやり取りが完了すると、次に待っているのはバイナリフレームによる挨拶、すなわち`SETTINGS`フレームの交換です。

ここで多くのエンジニアが犯す最大の誤解があります。それは、「ストリーム数の制限は、常にサーバー側がクライアントを縛るためのものだ」という思い込みです。

もちろん、リクエストを受け止めるバックエンドの負荷やメモリ枯渇を防ぐため、サーバーがクライアントに対して「同時にはせいぜい100ストリームまでにしてくれ」と要求する(`SETTINGS_MAX_CONCURRENT_STREAMS`を通知する)のが一般的なユースケースです。しかし、プロトコル仕様(RFC 7543 / RFC 9113)の観点から見れば、クライアント側もまた、自分が同時に処理・保持できるストリーム数の上限をサーバーに宣言する権利と義務を持っています。

クライアント側制限のパラドックス

リバースプロキシやAPIゲートウェイ、あるいはマイクロサービス群の間でHTTP/2バックエンド通信を設計しているとき、この「クライアント側の制限」が牙を剥きます。

サーバー側(例えばアップストリームのAPIサーバー)が、突発的なトラフィックやバックプレッシャーの伝播によって、何千ものプッシュ約束(Push Promise)や、あるいはサーバー主導のデータ送信を行おうとしたとしましょう。もしクライアント側が自身のメモリやディスクリプタの限界を見越し、`SETTINGS_MAX_CONCURRENT_STREAMS`を厳格に(例えば `10` などと)低く設定していた場合、何が起きるでしょうか。

サーバーは、クライアントの定めた境界線を越えて新しいストリームを開こうとした瞬間、プロトコルの鉄槌を下されます。

—

2. 境界突破の代償:RST_STREAMとPROTOCOL_ERRORのパケット挙動

もしサーバーが、クライアント側が設定した最大ストリーム数を超えて、新たなストリーム識別子(Stream ID)を持つ`HEADERS`フレームを送出した場合、クライアントのネットワークスタック(nghttp2やEnvoy、Goの `net/http` など)は冷徹に反応します。

ワイヤー越しに流れるパケットをWiresharkや`tcpdump`で覗いてみましょう。そこには以下のようなやり取りが記録されます。

[Client] — SETTINGS (MAX_CONCURRENT_STREAMS = 32) —> [Server]
[Server] — HEADERS (Stream ID: 1, 3, 5, …, 65) —-> [Client] (計33個のストリームを同時オープンしようと試みる)
[Client] — RST_STREAM (Stream ID: 65, Error: PROTOCOL_ERROR) -> [Server]

ここで発行されるのが、HTTP/2エラーコードの基本にして最も強力な駒である `RST_STREAM` です。その中でも、許可されたストリーム数を超過した暴挙に対しては、エラーコード `0x1 (PROTOCOL_ERROR)` または実装によっては `0x7 (REFUSED_STREAM)` が付与されます。

ストリームの強制切断とゴーストコネクション

ここでインフラエンジニアが留意すべきは、`RST_STREAM` はTCPコネクションそのものを切断するわけではないという点です。

TCPの観点からは、TLSセッションやトランスポート層のウィンドウサイズは健全に維持されています。しかし、HTTP/2レイヤーの上では、その特定のストリームだけが即座にアボートされ、関連するメモリバッファが解放されます。
もしアプリケーション層が、この `RST_STREAM` の検知と適切なバックオフ(再試行制御)を実装していない場合、次のような悪夢のようなループが発生します。

1. サーバーがリクエストを送り続ける
2. クライアントが `RST_STREAM` で拒絶し続ける
3. 双方の間で無駄なCPUサイクルと帯域が消費され、見かけ上のスループットが極限まで低下する(いわゆるプロトコルレベルの活火山状態)

—

3. 実践:Nginx / Envoy / Goにおけるストリーム数制限のチューニング

現場の最前線で戦うアーキテクトにとって重要なのは、この制限値をどのように設計し、コードや設定ファイルに落とし込むかです。過剰な緩和はDDoS耐性の低下を招き、過剰な絞りはレイテンシの悪化というブーメランとなって返ってきます。

ここでは、代表的なインフラコンポーネントにおける設定例を見てみましょう。

Nginx におけるサーバー側の制限チューニング

Nginxでは、クライアントから受け入れる最大ストリーム数を `http` または `server` コンテキストで制御します。

http {
# HTTP/2の有効化と、単一コネクションあたりの最大同時ストリーム数の定義
# デフォルトは128ですが、高負荷APIサーバーではトラフィック特性に応じて調整します。
http2_max_concurrent_streams 256;

# クライアントからのリクエストボディやヘッダーのバッファリング制御
http2_chunk_size 8k;

server {
listen 443 ssl http2;
server_name api.example.internal;

# TLS証明書や暗号スイートの設定は省略…

location / {
proxy_pass http://backend_cluster;
# バックエンドへのプロキシ時にもHTTP/2の特性を意識したタイムアウト設定
proxy_read_timeout 60s;
}
}
}

Go言語(net/http)におけるクライアント/サーバー設定

Goの `net/http` パッケージを使ってHTTP/2サーバーを構築、あるいは微調整する場合、`http.Server` の `TLSConfig` や内部の `http2.Server` 構造体を直接叩くことになります。

package main

import (
“log”
“net/http”
“golang.org/x/net/http2”
)

func main() {
mux := http.NewServeMux()
mux.HandleFunc(“/”, func(w http.ResponseWriter, r http.Request) {
w.Write([]byte(“Hello from HTTP/2 tuned server!”))
})

srv := &http.Server{
Addr: “:8443”,
Handler: mux,
}

// HTTP/2の細やかなパラメーターチューニングを行うための設定インスタンス
http2Server := &http2.Server{
// 同時ストリーム数の上限を厳格に制限し、メモリ枯渇(OOM)を防ぐ
MaxConcurrentStreams: 128,

// 1つのストリームで許容される最大フレームサイズ(デフォルトは16KBだが、巨大ペイロード向けに拡張可能)
MaxReadFrameSize: 16384,

// アイドル状態のコネクションを維持するタイムアウト等
}

// サーバーにHTTP/2設定をバインド
err := http2.ConfigureServer(srv, http2Server)
if err != nil {
log.Fatalf(“Failed to configure HTTP/2: %v”, err)
}

log.Println(“Starting tuned HTTP/2 server on :8443…”)
if err := srv.ListenAndServeTLS(“server.crt”, “server.key”); err != nil {
log.Fatalf(“Server failed: %v”, err)
}
}

—

4. セキュリティとパフォーマンスの十字路:リソース枯渇攻撃の防衛

ストリーム数の制限は、単なるパフォーマンスチューニングのパラメータではありません。セキュリティの最前線を守る防壁そのものです。

悪意ある攻撃者が、1本のTCP/TLSコネクションを確立し、そこから数千、数万の非同期ストリームを同時にオープンするリクエストを送りつけたらどうなるでしょうか。サーバー側は各ストリームの状態管理(HPACKのコンテキスト、リクエストヘッダーのデコードバッファ、内部キューなど)のためにメモリを割り当て続けなければなりません。

これが、有名な HTTP/2 の脆弱性である 「Rapid Reset Attack (CVE-2023-44487)」 や各種リソース枯渇(DoS)攻撃のメカニズムです。攻撃者はリクエストを送信した直後に `RST_STREAM` を送り、サーバーに処理を強要しつつ自身は負荷を逃れる、あるいは極限までストリームを占有し続けることで、正当なユーザーのトラフィックを完全に締め出します。

アーキテクトが講じるべき防衛策

1. 適切な `SETTINGS_MAX_CONCURRENT_STREAMS` の設定

  • 闇雲に数千へと増やすのではなく、バックエンドのリソース(メモリ、スレッド/ゴルーチン数)から逆算した現実的な数(一般的には 100 〜 250 程度)に固定する。

2. アクティブなストリームのタイムアウト監視

  • ヘッダーの受信完了からレスポンスの送出完了までの間に厳格なタイムアウトを設け、ゾンビ化したストリームを容赦なく切り捨てる。

3. WAF(Web Application Firewall)およびAPIゲートウェイでのレートリミット

  • 単一のIPアドレスやTLSフィンガープリント単位でのストリーム生成頻度をモニタリングし、異常な挙動を示すコネクションはTCPレベル(あるいはTLSセッションごと)で強制遮断する。

—

5. 結びにかえて:パケットの呼吸を感じろ

HTTP/2のマルチプレクシングは、ネットワークエンジニアリングのロマンが詰まった美しい仕組みです。しかし、その美しさは、送信側と受信側が互いのリソースの境界線を尊重し、緻密に調律されているという前提の上に成り立っています。

クライアント側の同時ストリーム数制限という、一見地味なプロトコルの仕様。この挙動の裏にあるパケットのやり取りや、エラーコードの意味を深く理解しているかどうかが、障害発生時に「なぜこのリクエストだけがサイレントに消えるのか」を見抜くプロフェッショナルと、ただログの海に溺れるアマチュアを分ける境界線となります。

ネットワークを流れるバイナリの息吹に耳を澄まし、限界値をコントロール下におくこと。それこそが、真にレジリエントなインフラストラクチャを構築するための唯一の道なのです。

コメント

タイトルとURLをコピーしました