HTTP/2の闇を暴く:RST_STREAMと異常系のパケット生態学
ウェブの高速化を至上命題として降臨したHTTP/2。HTTP/1.xが抱えていたヘッド・オブ・ライン・ブロック(HoLB)の呪縛を解き放ち、1本のTCPコネクション上で無数のストリームを多重化(マルチプレクシング)するそのアーキテクチャは、初めて見たときには魔法のように美しかった。
しかし、現場のインフラエンジニアやテックリードなら知っているはずだ。「1つのコネクションがすべてを運ぶ」ということは、ひとたびその内部で異常系が発生した際の影響範囲が、かつてないほど巨大化することを意味する。HTTP/1.xであれば壊れたTCPコネクションを捨てて張り直せば済んだ話が、HTTP/2の世界ではコネクションを維持したまま、特定の論理的通信路だけを外科手術的に、かつ瞬時に殺す必要がある。
そのための切り札が、今回取り上げる `RST_STREAM`フレーム だ。
パケットアナライザーの向こう側で、この小さな4バイトのエラーコードがどのように網の目を引き裂き、時にアプリケーションを救い、時に致命的なセキュリティホール(HTTP/2 Rapid Resetなど)の温床となるのか。プロトコルの深淵を覗いてみよう。
—
1. マルチプレクシングの代償:なぜ `RST_STREAM` が必要なのか
HTTP/2の真骨頂は、単一のTCPセッション上に「ストリーム(Stream)」という概念を持ち込み、双方向のメッセージ交換を並行して行うことにある。各ストリームには奇数の整数ID(クライアント発信の場合)が振られ、バイナリフレームとしてインターリーブされて流れていく。
ここで、ひとつのシチュエーションを想像してほしい。
ブラウザ(クライアント)が、重いクエリを伴うAPIリクエスト(Stream ID: 3)と、巨大な画像ファイルのダウンロードリクエスト(Stream ID: 5)を同時に送ったとする。しかし、ユーザーが途中でページを離脱した。あるいは、APIサーバー側でタイムアウトが検知された。
HTTP/1.xであれば、ブラウザはTCPのFINまたはRSTを送りつけてコネクションをブツ切りにするしかなかった。しかしHTTP/2では、Stream ID: 3とStream ID: 5は同じTCPコネクションを相乗りしている。
[TCP Connection]
├── [Stream ID: 3] (API Request) ── ユーザー離脱によりキャンセルしたい!
└── [Stream ID: 5] (Image DL) ── こいつは継続させたい
ここでTCPコネクションを切断してしまうと、継続したいStream ID: 5のダウンロードまで中断されてしまい、効率が台無しになる。
「コネクション全体を生かしたまま、特定のストリームだけを即座にabort(強制終了)したい」
この切実な要求を満たすためにRFC 7540で定義されたのが、HTTP/2レイヤーの強制切断シグナル、`RST_STREAM`フレーム(Type: 0x3) である。
—
2. `RST_STREAM` フレームの解剖学:構造とエラーコードの生態系
では、ワイヤー上のパケットを覗いてみよう。`RST_STREAM`フレームの構造は極めてシンプルだが、そのペイロードに刻まれる「32ビットのエラーコード」こそが、トラブルシューティングの羅針盤となる。
フレーム構造
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Length (24) | Type (8) |
+———————————————–+—————+
|大法(Flags) (8) |R| Stream Identifier (31) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|R| Error Code (32) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
ペイロードのサイズは常に 4オクテット(32ビット)固定 であり、ここにエラーコードが格納される。フラグ(Flags)は定義されていない(常に `0x0`)。
主要なエラーコードの全貌
RFC 7540および関連するRFCで定義されている主なエラーコードを、現場のリアルな文脈とともに整理する。
| エラーコード (Hex) | 名前 | 現場での発生文脈と意味 |
| :— | :— | :— |
| `0x0` | `NO_ERROR` | 異常終了ではない。グレースフルなキャンセルや、リクエストの意図的な破棄。 |
| `0x1` | `PROTOCOL_ERROR` | 不正なフレームシーケンスや、仕様違反のパケットを受信した場合。パーサーの怒り。 |
| `0x2` | `INTERNAL_ERROR` | サーバー内部の予期せぬ障害(DB接続断、パニックなど)。 |
| `0x3` | `FLOW_CONTROL_ERROR` | フロー制御ウインドウのサイズを超過したデータ送信など。 |
| `0x7` | `REFUSED_STREAM` | サーバーがリクエストを処理する前に拒否。冪等性のあるリクエストなら再送安全。 |
| `0x8` | `CANCEL` | クライアントが自発的にリクエストを放棄した(タブを閉じた等)。 |
| `0xd` | `HTTP_1_1_REQUIRED` | HTTP/2ではなくHTTP/1.1で来てくれという要求。 |
特にインフラエンジニアがログやパケットキャプチャで頻繁に目撃するのが、`CANCEL (0x8)` と `REFUSED_STREAM (0x7)` だ。
- `CANCEL`: クライアント起点。フロントエンドのJavaScript(`AbortController`など)からリクエストがキャンセルされたときに飛んでくる。
- `REFUSED_STREAM`: サーバー起点。「今ちょっと忙しすぎてこのストリーム処理できないから、別のコネクションか後でやり直して(でもコネクションは切らないよ)」という優しさ、あるいはバックプレッシャーの現れ。
—
3. 現場のデバッグ:Wiresharkとnghttp2でパケットを暴く
言葉だけでは味気ないので、実際に `RST_STREAM` が流れる瞬間を捕捉する方法を見ておこう。
開発環境やステージング環境で、クライアントがリクエストをキャンセルした際の挙動を `nghttp2`(`nghttp`コマンド)と `Wireshark` で追跡する。
nghttpコマンドによる検証
デバッグモード(-v)でHTTP/2リクエストを投げつつ、途中でCtrl+Cなどで中断してみる
nghttp -v https://http2.golang.org/reqinfo
通信が確立され、HEADERSフレームが飛び交った後にプロセスを中断(またはクライアント側で破棄)すると、以下のようなログが吐き出される。
[ 0.038] send HEADERS stream_id=1
[ 0.150] recv (stream_id=1) HEADERS flags=END_HEADERS
[ 0.150] recv (stream_id=1) DATA flags=NONE, payloadlen=1024
ユーザーが中断したため、RST_STREAMが送出される
[ 2.100] send RST_STREAM stream_id=1, err_code=8 (CANCEL)
この瞬間、ワイヤー上では以下のようなバイナリ(概念的なHEX表現)が流れている。
4バイトのレングス(0)、タイプ(0x3 = RST_STREAM)、フラグ(0)、ストリームID(1)、エラーコード(0x00000008 = CANCEL)
00 00 04 03 00 00 00 00 01 00 00 00 08
Wiresharkでキャプチャしている場合は、フィルターに `http2.type == 3` を設定すれば、容赦なく発生している `RST_STREAM` の嵐を観測できるだろう。もしここで `PROTOCOL_ERROR (0x1)` が頻発しているなら、自社製プロキシやリバースプロキシ(Nginx, Envoy等)のアップストリーム実装にバグがあるか、ヘッダー圧縮(HPACK)の動的テーブルの同期が崩壊している可能性が高い。
—
4. セキュリティの暗黒面:HTTP/2 Rapid Reset 脆弱性(CVE-2023-44487)のメカニズム
`RST_STREAM` を語る上で、2023年秋に世界中のインフラエンジニアを震撼させた「HTTP/2 Rapid Reset 攻撃(CVE-2023-44487)」に触れないわけにはいかない。これは、プロトコルの仕様の隙を突いた、歴史上最大級のDDoS攻撃ベクトルとなった。
攻撃のロジック
通常、ブラウザは「リクエストを送る = レスポンスを待つ」ため、無限にリクエストを乱発することはない。しかし、HTTP/2のマルチプレクシングと `RST_STREAM` の組み合わせを利用すると、以下の非対称なループが可能になる。
1. クライアントが `HEADERS` フレームでリクエストを送信(Stream ID: 1)。
2. 直ちに `RST_STREAM` (`CANCEL` または `NO_ERROR`) を送り、そのストリームを自分で即座に殺す。
3. サーバー側は、リクエストを受け付けて処理リソース(メモリやCPU)を割り当てかけた矢先にストリームのキャンセル処理に追われる。
4. クライアントはこれを数千・数万のストリームIDで並行して無限ループさせる。
[Attacker] ── (HEADERS) ──> [Server (Resource Allocated)]
[Attacker] ── (RST_STREAM) ──> [Server (Resource Freed / Overwhelmed)]
↑ ─── このサイクルを数万ストリームで高速回転 ─── ↓
結果どうなるか?
サーバー側は「ストリームの生成と破棄」のステート管理だけでCPUコアを100%使い果たし、正当なユーザーへのサービス提供が完全に不可能(DoS状態)に陥る。TCPコネクション自体は1本、あるいは数本しか張られていないため、従来のIPベースのレートリミットやWAFのシグネチャを容易くすり抜けてしまった。
対策とパッチの適用
この脆弱性への対策として、主要なHTTP/2実装(Nginx, Envoy, Goのnet/httpなど)やパブリッククラウドのロードバランサーは、「1つのコネクション内で許可される未処理のRST_STREAMの回数/レートに厳格な制限(上限値)」を設けるパッチを急遽適用した。
例えば、Nginxでは以下のようなディレクティブで、異常なスピードで `RST_STREAM` を連打する不正なクライアントを即座にコネクション切断(`GOAWAY`)する防衛策が標準化されている。
http {
# HTTP/2の接続管理におけるレートリミットやストリーム制限の強化
# (※使用しているNginxのバージョンやモジュールに依存します)
http2_max_concurrent_streams 128;
# 悪意あるRapid Resetを防ぐためのストリーム処理制限パラメータ
# 過剰なRST_STREAMを受信した場合にコネクションを強制切断する閾値を設定
}
インフラエンジニアたるもの、ミドルウェアのバージョンアップをサボることがいかに命取りになるか、この脆弱性は痛烈に教えてくれた。
—
5. 極限のパフォーマンスチューニング:カーネルとTLS、そしてHTTP/2の調律
`RST_STREAM` やマルチプレクシングがスムーズに機能するためには、その土台である TCP(トランスポート層) と TLS(トランスポートセキュリティ層) が完璧に調律されている必要がある。
HTTP/2の最大の敵は「TCPのヘッド・オブ・ライン・ブロック」だ。単一のTCPコネクション上でどれだけ綺麗にストリームを多重化しても、下層のTCPパケットが1つロス(パケットロス)すると、その先のすべてのストリームがカーネルのバッファで足止めを食らう。
ここでは、高負荷環境においてHTTP/2のパフォーマンスを極限まで引き出すためのLinuxカーネルチューニングとTLS最適化のレシピを公開しよう。
Linuxカーネルチューニング (`/etc/sysctl.conf`)
パケットロスに強く、大容量のストリーム並行処理をさばくための推奨パラメータだ。
— BBR混雑制御アルゴリズムの有効化 —
従来のCUBICに比べ、パケットロス耐性が劇的に高く、高RTT環境やモバイル回線で威力を発揮
net.core.default_qdisc = fq
net.ipv4.tcp_congestion_control = bbr
— TCP受信/送信バッファの動的チューニング —
多数のHTTP/2ストリームが同時にデータを要求するため、バッファ枯渇を防ぐ
net.ipv4.tcp_rmem = 4096 87380 16777216
net.ipv4.tcp_wmem = 4096 65536 16777216
— TIME_WAITソケットの再利用と高速化 —
net.ipv4.tcp_tw_reuse = 1
— セキュリティとメモリ効率 —
SYNプールの拡張(高負荷時のDDoS緩和)
net.ipv4.tcp_max_syn_backlog = 8192
net.ipv4.tcp_fin_timeout = 15
TLSとハンドシェイクの最適化
HTTP/2は実質的にTLS 1.2以上(推奨はTLS 1.3)が必須要件となっている。特にTLS 1.3では、ハンドシェイクが「1-RTT」に短縮され、早期データ送信(0-RTT Resumption)も可能になった。
しかし、0-RTTには「リプレイアタックの危険性」というダークサイドがある。特にHTTP/2のPOSTリクエストなどが0-RTTで送出された場合、それを悪意ある第三者がキャプチャして再送(リプレイ)されるリスクがあるため、慎重な設計が求められる。
安全かつ高速なTLS 1.3のセッティング(Nginxの例):
server {
listen 443 ssl http2;
server_name api.example.com;
ssl_certificate /path/to/fullchain.pem;
ssl_certificate_key /path/to/privkey.pem;
# TLS 1.3を最優先し、レガシーな暗号スイートを排除
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256;
ssl_prefer_server_ciphers on;
# セッションキャッシュの最適化
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1h;
}
—
結び:プロトコルの美しさと、現場の泥臭さの交差点
HTTP/2の `RST_STREAM` は、一見すると地味なエラー制御のパーツに過ぎない。しかし、その4バイトのコードの背後には、「単一コネクションの効率性」と「異常系における独立性」という、相反するエンジニアリングのジレンマを解決するための先人たちの知恵が詰まっている。
パケットを愛する者にとって、ネットワークは生き物だ。Wiresharkの画面に突如現れる `RST_STREAM (0x8: CANCEL)` や `REFUSED_STREAM (0x7)` は、クライアントの焦燥や、サーバーの悲鳴をダイレクトに伝えてくれる。
教科書通りの設定をなぞるだけでは、大規模トラフィックの荒波を乗り越えることはできない。プロトコルの内部挙動を解剖し、カーネルのバッファからTLSのハンドシェイク、そしてセキュリティの脆弱性までを一本の線で捉えたとき、あなたのインフラストラクチャは真の意味で「強靭」になる。
さあ、次はあなたのターミナルとパケットアナライザーの番だ。プロトコルの鼓動に耳を澄ませてほしい。
コメント