【テクニカル・上級編】HTTP/2におけるエラーコード(RST_STREAM)の分類と原因 – HTTPプロトコル・通信規格実践ガイド

止まらない「RST_STREAM」の謎を追う:HTTP/2エラーコードの深層と現場のトラブルシューティング

Webフロントエンドの高速化、そしてマイクロサービス間通信の標準プロトコルとしてHTTP/2(そしてその先にあるHTTP/3)が普及して久しい。1本のTCPコネクション上で複数のリクエストとレスポンスを同時に多重化(Multiplexing)するその仕組みは、かつてのHTTP/1.1におけるHead-of-Line(HoL)ブロック問題を鮮やかに解決し、私たちインフラエンジニアに圧倒的なスループットをもたらした。

しかし、TCPという信頼性はあるが融通の利かない「単一の巨大なパイプ」の中に、独立した論理的ストリームを何十、何百と詰め込む代償として、新たなレイヤーの複雑性が私たちの前に立ちはだかるようになった。その最たるものが、「RST_STREAM(リセットストリーム)」フレームである。

HTTP/1.1であれば、接続の切断はコネクション自体のクローズ(`Connection: close`)かTCPのFIN/RSTに直結していた。だが、HTTP/2の世界では、コネクションは健全に維持されているにもかかわらず、特定のストリームだけが突如として死に至る。ブラウザのコンソールに突如現れる `ERR_HTTP2_PROTOCOL_ERROR` や、リバースプロキシのアクセスログに刻まれる謎のステータス。

今回は、パケットのワイヤーフォーマット、HPACKのコンテキスト、そしてLinuxカーネルの挙動までを視野に入れながら、RST_STREAMが抱えるエラーコードの深層と、現場で実践すべきトラブルシューティングの極意を紐解いていこう。

—

1. RST_STREAMの正体:なぜ「コネクション」ではなく「ストリーム」を殺すのか

HTTP/2の美しさは、その「ストリーム(Stream)」という抽象化レイヤーにある。各ストリームには一意の奇数(クライアント発信の場合)のIDが振られ、ひとつのTCPコネクション上で完全に独立した双方向のバイトストリームとして振る舞う。

しかし、この独立性が災いすることがある。あるストリームで不正なヘッダーが送信されたり、サーバー側で予期せぬ例外が発生したり、あるいはクライアントが突然「もうこの画像はいらない」と判断したとき、HTTP/1.1であればコネクションを破棄するしかなかった。

ここで登場するのが `RST_STREAM`(フレームタイプ `0x3`)だ。

+—————————————————————+
| Length (24) |
+—————+—————+—————+
| Type (8) | Flags (8) |
+-+————-+—————+——————————-+
|R| Stream Identifier (31) |
+-+—————————————————————+
| Error Code (32) |
+—————————————————————+

RST_STREAMは、他のストリームやTCPコネクション全体に影響を与えることなく、ピンポイントで特定のストリームを強制終了(Abort)させるための強力なプリミティブである。このフレームが流れた瞬間、送信側と受信双方はそのストリームのコンテキストを即座に破棄し、リソースを解放しなければならない。

だが、この「手軽な切断」が、時として原因不明の障害解析を困難にするパンドラの箱となる。

—

2. 主要なエラーコードとパケットレベルの発生条件

RFC 7540(および関連するRFC)では、RST_STREAMやGOAWAYフレームで使用されるエラーコードが厳密に定義されている。現場のデバッグで頻繁に遭遇する代表的なコード群を、その「生々しい発生条件」とともに見ていこう。

`PROTOCOL_ERROR` (0x1)

もっとも汎用的でありながら、もっとも原因特定が厄介なエラーだ。HTTP/2のフレーム構造や状態遷移のルールに違反したときに送出される。

  • 発生条件の例:
  • HEADERSフレームが続くべきところでDATAフレームが飛んできた(状態機械の崩壊)。
  • 予約されているビット(Reserved Bit)が `1` になっている。
  • 偽陽性(Pseudo-header fields)の順序違反、あるいは `:method` などの必須擬似ヘッダーの欠落。
  • アーキテクトの視点: 大抵の場合、クライアント側の実装バグか、途中のWAFやAPIゲートウェイがHTTP/2のペイロードを誤って書き換えた(ヘッダーインジェクションの検知など)場合に発生する。

`INTERNAL_ERROR` (0x2)

サーバーサイドの不都合、文字通り「内部エラー」である。

  • 発生条件の例: アプリケーションサーバーへのルーティング中にバックエンドがクラッシュした、あるいはデータベースとのコネクションプールが枯渇し、ハンドラーが例外をキャッチしてストリーム処理を継続できなくなった場合、サーバーは自発的にRST_STREAM(INTERNAL_ERROR)を送出する。
  • アーキテクトの視点: これが多発している場合、HTTP/2レイヤーの問題ではなく、バックエンドのアプリケーションレイヤーの健全性が崩壊しているシグナルである。

`CANCEL` (0x3)

クライアント、あるいはサーバーが「もうこのリクエストの結果は不要だ」と判断したときに使われる。

  • 発生条件の例: ブラウザでページを読み込んでいる最中に、ユーザーが「停止」ボタンを押した場合や、JavaScriptの `AbortController` を使って `fetch()` リクエストを途中でキャンセルした場合。
  • アーキテクトの視点: 一見正常な動作に見えるが、CDNやリバースプロキシのキャッシュ戦略やバックエンドの非同期処理において、キャンセルされたリクエストのバックエンド処理が継続して走り続ける(スレッドやDBコネクションを無駄に食う)「ゾンビ処理」の温床になりやすい。適切なキャンセル伝播(Propagation)の実装が不可欠だ。

`REFUSED_STREAM` (0x7)

サーバーが「処理を開始する前に、そのリクエストを拒絶した」ことを示す。

  • 発生条件の例: サーバーが、まだ処理を一切始めていない安全な状態で、同時ストリーム数の制限を超えたリクエストや、べき等性(Idempotency)の観点から再試行可能と判断したリクエストを受け取った場合。
  • アーキテクトの視点: このエラーコードの最大の特徴は、「クライアントが安全にリクエストを別のTCPコネクションや新しいストリームで再試行(Retry)してよい」という明確なシグナルである点だ。耐障害性の高いクライアント実装では、このエラーを受け取ると自動的にリトライを行う。

—

3. ヘッダー圧縮(HPACK)の破綻が引き起こす致命的なRST_STREAM

HTTP/2のパフォーマンスを支える功労者であり、同時に最大のトラブルメーカーがHPACKによるヘッダー圧縮である。

HPACKは、静的テーブルと「動的テーブル(Dynamic Table)」を双方のメモリ上に保持し、過去に送信したヘッダーのペアをインデックス番号で置き換えることで帯域を極限まで削減する。

ここで想像してほしい。
クライアントとサーバーの間で動的テーブルの状態が完全な同期(Sync)を保っていなければならない。もしパケットロスやバッファ溢れによって、クライアントが「動的テーブルのインデックス 62 は `user-agent: Mozilla…` である」と記憶しているのに、サーバー側が何らかの理由でそのエントリを追い出していた(あるいは同期ズレを起こした)としたらどうなるか?

サーバーがデコードに失敗した瞬間、それは修復不可能なHPACKのコンテキスト崩壊となる。この場合、単一のストリームのRST_STREAMでは済まず、コネクション全体が `COMPRESSION_ERROR` (0x9) を伴う `GOAWAY` によって強制切断される。

[クライアント] — (動的テーブル: 62 = A) —> [サーバー (動的テーブル: 62 = B)]
↓
【コンテキスト崩壊】
↓
[クライアント] <--- GOAWAY (COMPRESSION_ERROR) --- [サーバー] このリスクを回避するため、プロダクション環境のインフラアーキテクトは、HTTP/2終端(Nginx、Envoy、HAProxyなど)の設定において、動的テーブルの最大サイズ(SETTINGS_HEADER_TABLE_SIZE)を適切に制限するか、あるいはパケットロスに強いトランスポート層のチューニングを施す必要がある。 ---

4. 現場で役立つトラブルシューティングとパケット解析の実践

理論を頭に入れたところで、実際に現場でどのようにRST_STREAMの波を乗りこなすべきか、実践的な手法を解説しよう。

ステップ1: `nghttp2` や `tcpdump` による生パケットの捕獲

ブラウザの開発者ツールでは、RST_STREAMの細かいエラーコード(`PROTOCOL_ERROR` なのか `REFUSED_STREAM` なのか)が隠蔽されて単なる「ネットワークエラー」として表示されることが多い。真実を知るには、ワイヤー上のパケットを見るしかない。

以下のコマンドで、特定のエンドポイントとの通信を `nghttp` コマンドラインツールでデバッグしつつ、フレームのやり取りを詳細に確認する。

nghttp2クライアントを用いて、VERBOSEモードでHTTP/2通信の全フレームをダンプする
nghttp -v https://api.example.com/v1/resource

出力結果の中に、以下のようなフレームの応酬が見つかるはずだ。

[ 0.038] send HEADERS frame
[ 0.072] recv HEADERS frame
[ 0.072] recv DATA frame
[ 0.080] recv RST_STREAM frame , error_code=INTERNAL_ERROR (0x2)

このログから、「リクエスト送信後、サーバーは1KBのデータを返した直後に `INTERNAL_ERROR` でストリームを強制切断した」という事実がミリ秒単位で判明する。

ステップ2: Envoy / Nginx でのエラーログの精緻化

リバースプロキシ層で何が起きているかを把握するためには、アクセスログのフォーマットを拡張し、HTTP/2特有の変数を記録できるようにする。

例えば、Envoyの場合、アクセスメソッドやレスポンスフラグにHTTP/2の内部状態を出力させることが定石だ。

Envoyのアクセスログ設定例(JSONフォーマット)
access_log:

  • name: envoy.access_loggers.file

typed_config:
“@type”: type.googleapis.com/envoy.extensions.access_loggers.file.v3.FileAccessLog
path: “/dev/stdout”
log_format:
json_format:
start_time: “%START_TIME%”
method: “%REQ(:METHOD)%”
path: “%REQ(X-ENVOY-ORIGINAL-PATH?:PATH)%”
response_code: “%RESPONSE_CODE%”
response_flags: “%RESPONSE_FLAGS%” # ここに UH (Upstream Healthy error) や DC (Downstream Connection Termination) が記録される
stream_id: “%DYNAMIC_METADATA(envoy.http:%STREAM_ID%)%”
upstream_transport_failure_reason: “%UPSTREAM_TRANSPORT_FAILURE_REASON%”

`RESPONSE_FLAGS` に現れるフラグ(例: `UEX` や `NC` など)を追うことで、Envoyがバックエンドからの応答異常を検知してクライアントにRST_STREAMを投げたのか、あるいはクライアント側からキャンセルされたのかが一目瞭然になる。

—

5. 極限のパフォーマンスとセキュリティのためのチューニングパラメーター

最後に、RST_STREAMの発生確率を最小限に抑え、HTTP/2のパフォーマンスを極限まで引き出すためのインフラ・カーネルチューニングの処方箋を提示する。

Linuxカーネル(TCP/IPスタック)の最適化

HTTP/2は1本のTCPコネクション上で多重化するため、TCPのバッファサイズ不足やパケットロスが、すべてのストリーム全体のレイテンシ悪化(Head-of-Line Blocking at TCP level)を引き起こす。これがタイムアウトを誘発し、結果的にRST_STREAM(CANCEL)の嵐を呼び起こす。

`/etc/sysctl.conf` に以下の設定を施し、TCPのウィンドウ制御とバッファを最適化する。

TCPの送受信バッファのデフォルト値と最大値を拡張(高スループット・高レイテンシ網対応)
net.core.rmem_max = 16777216
net.core.wmem_max = 16777216
net.ipv4.tcp_rmem = 4096 87380 16777216
net.ipv4.tcp_wmem = 4096 65536 16777216

BBR混雑制御アルゴリズムの有効化(パケットロスが多い環境でのスループット低下を防ぐ)
net.core.default_qdisc = fq
net.ipv4.tcp_congestion_control = bbr

TCP Keepaliveの調整(ゾンビ化したHTTP/2コネクションの迅速な検出)
net.ipv4.tcp_keepalive_time = 300
net.ipv4.tcp_keepalive_intvl = 15
net.ipv4.tcp_keepalive_probes = 5

リバースプロキシ(Nginx)側でのHTTP/2パラメーター調整

Nginxを用いる場合、デフォルトのままだと大規模なトラフィックや攻撃的なクライアントに対して脆弱、あるいは非効率である。`nginx.conf` の `http` ブロックを以下のようにチューニングする。

http {
# HTTP/2接続あたりの最大同時ストリーム数を制限(リソース枯渇を防ぐ)
http2_max_concurrent_streams 128;

# ヘッダーの合計サイズの制限(HTTP/2 Request Header Smuggling等の脆弱性対策)
http2_max_field_size 4k;
http2_max_header_size 32k;

# HPACK動的テーブルのサイズ制限(メモリ消費とコンテキスト同期ズレのリスク軽減)
http2_chunk_size 8k;

# クライアントからの過剰なリクエスト(RST_STREAMを頻発させるような挙動)に対するレートリミット
limit_req_zone $binary_remote_addr zone=h2_limit:10m rate=50r/s;

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

# レートリミットの適用
location / {
limit_req zone=h2_limit burst=20 nodelay;
# … その他のproxy_pass設定 …
}
}
}

—

おわりに

HTTP/2の `RST_STREAM` は、決して「バグの象徴」ではない。それは、複雑化したWebのトラフィックを安全かつ効率的に制御するための、プロトコルが備えた洗練された「安全弁(セーフティバルブ)」である。

エラーコードの背後にあるパケットの挙動を読み解き、トランスポート層の物理的制約からアプリケーション層の論理的状態までを一気通貫で見通すこと。それこそが、現代のインフラアーキテクトやテックリードに求められる本質的なスキルなのである。

ログに刻まれた小さな `RST_STREAM (0x2)` を見過ごさず、その裏にあるネットワークの呼吸に耳を傾けてほしい。そこには、システム全体をより堅牢にするための貴重なヒントが必ず隠されている。

コメント

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