接続を断つな、ストリームを切れ:HTTP/2の命綱「RST_STREAM」とエラーハンドリングの実際
こんにちは。インフラとプロトコルの世界に浸かって早幾年、数々の夜間障害や「なぜかこのリクエストだけ死ぬ」という不可解なパケットキャプチャと格闘してきたシニアエンジニアです。
Web APIの設計や大規模インフラの運用を共にするエンジニアの皆さん、日々のトラフィック分析お疲れ様です。
HTTP/1.1の時代、ブラウザからの同時接続数を増するためにドメインシャーディングを行い、1つのTCPコネクションが詰まればすべてがブロックされる——そんな「頭部阻塞(Head-of-Line Blocking)」に頭を悩ませていたのは、今となっては懐かしい思い出かもしれません。
そして私たちはHTTP/2を手に入れました。単一のTCPコネクション上で複数のリクエストとレスポンスを並行して流す「マルチプレクシング」は、Webの速度を劇的に引き上げました。しかし、ここで一つの疑問が湧き上がります。
「もし、多重化された無数のストリームのうち、たった1つのリクエストで致命的なエラーが起きたら、コネクション全体はどうなるのか?」
HTTP/1.1であれば、その重いレスポンスを切り捨てるためにTCPコネクションごと破棄(RSTパケットによる強制切断)するしかありませんでした。しかし、HTTP/2は違います。コネクションという「大動脈」を生かしたまま、病んだ「毛細血管」だけを鮮やかに外科手術のように切り捨てる仕組みを持っています。
それが今回解説する `RST_STREAM`フレーム です。
—
1. HTTP/2のエエラーハンドリング思想:なぜ「コネクション切断」は悪なのか?
HTTP/2の根底にある思想は 「TCPコネクションの極小化と再利用」 です。TLSのハンドシェイクコストやTCPのスロースタート育成コストを考えれば、一度確立したコネクションは可能な限り維持し、血流のようにデータを流し続けたい。
しかし、アプリケーション層の世界は無慈悲です。
- クライアントが突然「やっぱこの画像要らんわ」とリクエストをキャンセルした。
- サーバ側でデータベースのクエリがタイムアウトし、これ以上処理を継続しても無駄だと判明した。
- 不正なヘッダーが送りつけられ、特定のストリームだけがパース不能に陥った。
こんな時、もしHTTP/1.1のノリでTCPコネクションを切断してしまったらどうなるでしょう? その裏で並行して流れていた、ユーザーにとって極めて重要な別のAPIレスポンス(例えば、決済完了画面のデータなど)まで強制的に巻き添え事故で吹っ飛んでしまいます。
ここに、HTTP/2のエラーハンドリングの美しい二段構えがあります。
1. ストリームエラー (`RST_STREAM`): 特定のストリームで発生した問題。他のストリームには一切影響を与えない。
2. コネクションエラー (`GOAWAY`): コネクション全体が危ぶまれる深刻な問題。新たなストリームの作成を禁じ、既存の処理を安全に終わらせてから切断する。
今回は、この前者の主役である `RST_STREAM` にスポットライトを当て、プロトコルの深淵を覗いてみましょう。
—
2. `RST_STREAM` の正体とエラーコード体系
`RST_STREAM`(フレームタイプ:`0x3`)は、HTTP/2のバイナリフレームの一つです。非常にシンプルで、ペイロードには32ビット(4バイト)のエラーコードしか持ちません。
実際に流れるエラーコードの主要な面々
RFC 7540で定義されている標準的なエラーコードのうち、実務で頻繁に遭遇する、あるいは設計時に意識すべきものをいくつかピックアップします。
| エラーコード (Hex) | 名前 | 意味・実務での発生シチュエーション |
| :— | :— | :— |
| `0x0` | `NO_ERROR` | エラーではない通常の完了(ストリームの早期終了など)。 |
| `0x1` | `PROTOCOL_ERROR` | プロトコル違反。不正なフレームシーケンスなど。 |
| `0x2` | `INTERNAL_ERROR` | サーバ側の予期せぬ内部エラー(DB落ちなど)。 |
| `0x3` | `FLOW_CONTROL_ERROR` | フロー制御ウインドウのサイズを超過した。 |
| `0x5` | `STREAM_CLOSED` | すでに閉じられたストリームへのフレーム受信。 |
| `0x8` | `CANCEL` | ユーザーやクライアントによる意図的なキャンセル(ブラウザの閉鎖やFetchのAbortなど)。 |
| `0xd` | `ENHANCE_YOUR_CALM` | レートリミット超過など、クライアントに負荷軽減を求める場合。 |
—
3. 通信シーケンス:何が起きているのか?
クライアントが重いデータを要求したが途中で不要になり、`RST_STREAM`(エラーコード:`CANCEL`)を送信する典型的なシーケンスを見てみましょう。
Client Server
| |
|— HEADERS (Stream ID: 3) —————————>| (リクエスト開始)
| |
|<-- DATA (Stream ID: 3, チャンク1) --------------------| (レスポンス返送中)
| |
| (ユーザーが画面を離脱、リクエストをキャンセル) |
| |
|--- RST_STREAM (Stream ID: 3, Error: CANCEL) --------->| (★該当ストリームのみ即座に破棄)
| |
| ※Stream ID: 5 など他のストリームは影響なく継続 |
| |
注目すべきは、クライアントが `RST_STREAM` を送った後、サーバ側は即座にそのストリームのメモリ領域やバッファを解放し、CPUサイクルの無駄遣いを止める点です。TCPのバッファレベルではつながったままなので、Stream ID: 5で流れていた別のAPIレスポンスは1ミリ秒たりとも遅延することなく届き続けます。
—
4. 実務での検証:コードとデバッグTips
では、この `RST_STREAM` を実際の開発やインフラ検証でどのように扱い、どう観測するのか。具体的なコードと手順を見ていきましょう。
実装例1: JavaScript (Fetch API + AbortController)
フロントエンドやNode.js環境で、リクエストを途中でキャンセルすると、内部的には `RST_STREAM`(`CANCEL`)がサーバへ飛びます。
// AbortControllerのインスタンスを生成
const controller = new AbortController();
const { signal } = controller.signal;
async function fetchUserData() {
try {
// signalをfetchに渡す
const response = await fetch(‘https://api.example.com/v2/heavy-data’, {
signal: controller.signal,
headers: { ‘Accept’: ‘application/json’ }
});
const data = await response.json();
console.log(data);
} catch (error) {
if (error.name === ‘AbortError’) {
console.log(‘リクエストは正常にキャンセルされました(HTTP/2層ではRST_STREAMが送信されます)。’);
} else {
console.error(‘その他の通信エラー:’, error);
}
}
}
// リクエスト実行
fetchUserData();
// 100ミリ秒後に強制キャンセル(ここでRST_STREAMが発射される)
setTimeout(() => {
controller.abort();
}, 100);
実装例2: Python (httpx)
モダンなPythonのHTTPクライアントである `httpx` でも、タイムアウトやストリームの途中切断でHTTP/2の挙動をコントロールできます。
import httpx
import time
HTTP/2を有効にしてクライアントを初期化
with httpx.Client(http2=True) as client:
try:
# あえて極端に短いタイムアウトを設定し、サーバーの応答遅延による中断をテストする
response = client.get(“https://httpbin.org/delay/5″, timeout=1.0)
response.raise_for_status()
except httpx.TimeoutException as e:
print(f”タイムアウト発生: サーバー処理が間に合わず、クライアント側から中断しました。”)
# 内部的にはHTTP/2ストリームに対してRST_STREAMが送信されています。
except Exception as e:
print(f”予期せぬエラー: {e}”)
インフラ運用者のためのデバッグTips:Wiresharkとnghttp2
ネットワークエンジニアとして現場で障害解析を行う際、「本当にブラウザから `RST_STREAM` が飛んでいるのか?」を確信したい場面があります。
1. Wiresharkでのキャプチャ:
TLS復号鍵(`SSLKEYLOGFILE`など)を設定した上でパケットをキャプチャし、フィルターに `http2.type == 3` を入力します。
これにより、どのStream IDに対して、どのエラーコード(例:`Cancel (0x8)`)でリクエストが断ち切られたかが一目瞭然になります。
2. CLIツール(`nghttp`コマンド)の活用:
C言語ベースのHTTP/2クライアント実装である `nghttp2` パッケージに含まれる `nghttp` を使うと、詳細なフレームのやり取りを標準出力にダンプできます。
# -v オプションでフレームの送受信(RST_STREAMを含む)をすべて可視化する
nghttp -v https://api.example.com/v2/heavy-data
出力ログの中に `send RST_STREAM stream_id=3, error_code=8` のようなログを見つけた時、プロトコルスペシャリストとしてのパズルがカチリと噛み合う瞬間が訪れます。
—
5. まとめ:トラブルシューティングの勘所
HTTP/2の `RST_STREAM` は、単なるエラー通知の仕組みではありません。「コネクションの健全性を保ちながら、不要になった負荷をリアルタイムに刈り取る」ための洗練されたメカニズムです。
もしあなたが今後、以下のようなインフラ・アプリケーションの挙動に直面したら、この `RST_STREAM` を思い出してください。
- 「APIサーバのログに、クライアントが切断した形跡はないのに、なぜか途中でレスポンスが途切れている」
→ ロードバランサー(ALBやEnvoyなど)のタイムアウト設定が短すぎず、クライアント側(または途中のプロキシ)が `RST_STREAM` を送出している可能性があります。
- 「特定の重いリクエストを発火させると、なぜか他の軽いAPIのレイテンシーまで一時的に跳ね上がる」
→ マルチプレクシングの恩恵を受けつつも、サーバ側のリソース(スレッドプールやDBコネクション)が枯渇し、最終的に `RST_STREAM (INTERNAL_ERROR)` が乱れ飛んでいないか、メトリクスを確認してみましょう。
プロトコルの挙動を解剖し、パケットの呼吸に耳を澄ませることで、障害の原因は必ず見えてきます。
それでは、次の現場でお会いしましょう。快適なネットワークライフを!
コメント