mcpField
D
debugging-network-issues

debugging-network-issues

github.com/daymadeクラウド・DevOps1.5k680 回インストール今日更新
リポジトリ

debugging-network-issues について

ネットワークやストリーミング障害を多層にわたって調査するための、証拠重視のスキルです。個別のログや症状から原因を決めつけず、仮説を実験で検証する調査担当者向けです。

主な機能

  1. 01証拠を優先する診断
  2. 02反証可能な仮説の検証
  3. 03レイヤーごとの切り分け実験
  4. 04修正前の反対レビュー
  5. 05プローブの経路と規模の確認
  6. 06ユーザーへの質問前の計測確認

活用シーン

  1. 01断続的なネットワーク障害の調査
  2. 02アップロードと処理のタイムアウトの切り分け
  3. 03転送速度低下の測定
  4. 04多段システムの障害追跡

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/daymade/claude-code-skills --skill debugging-network-issues

インストール後、Claude Code で/debugging-network-issuesを入力して読み込まれたか確認します。

同梱ファイル

SKILL.md と一緒にインストールされる参考資料やスクリプト。

evals/
evals.json
references/
case-cloudflare-524-upload.md
case-iot-router-migration-wpa3.md
case-proxy-tun-cname-override.md
case-sse-rst-130s.md
case-throughput-collapse-no-errors.md
cognitive-traps.md
counter-review-pattern.md
device-throughput-fieldwork.md
http-response-completeness.md
instrumentation-patterns.md
layered-isolation-experiment.md
packet-capture-recipes.md
scripts/
layered-isolation-probe.sh
mock-idle-upstream.py

詳細

ネットワーク問題のデバッグ

明白な原因が間違っている可能性が高いインシデントを、証拠に基づいて調査するための方法論です。実際に発生した5時間の本番環境での事例(references/case-sse-rst-130s.mdを参照)をもとにしています。この事例では、仮説を積み重ねた結果、階層的な実験を10分行えば解決できた問題に何時間も費やしました。

ユーザーがネットワーク、ストリーミング、またはプロトコルに関する症状を報告し、調査担当者が1行のログや単独の状況証拠から診断したくなったときに、このスキルを適用してください。このスキルの役割は、その反射的な判断を抑えることです。

まずはトリアージ — 既知のドメインですか?

以下の一般的な方法論を適用する前に、症状がこのリポジトリに専用のスキルがあるスタックを示していないか確認してください。専用スキルには、このスキルが意図的に一般論にとどめている、ドメイン固有の「症状→原因→修正」表があります。まずはそちらを確認し、根本原因が別のところにあるとわかったら、方法論を確認するためにここへ戻ってきてください。

症状が次のいずれかの場合最初に確認するスキル
macOS での Tailscale とプロキシ/VPN の競合(Shadowrocket / Clash / Surge):tailscale ping は成功するが、SSH/curl/git は失敗する、Connection closed by 198.18.x.x、TUN による DNS の乗っ取り、getaddrinfo の名前解決が約 60 秒停止するtunnel-doctor
Cloudflare の設定:ERR_TOO_MANY_REDIRECTS、SSL モードの不一致、オレンジ色の雲の背後での DNS/プロキシステータスの問題cloudflare-troubleshooting
Windows App / AVD / W365 の RDP 接続品質:UDP Shortpath ではなく WebSocket を使用する、高い RTT、STUN/TURN の干渉windows-remote-desktop-connection-doctor
クライアント側のプロキシ/VPN/TUN による誤ったルーティング:特定のサイトだけ ERR_CONNECTION_CLOSED または SSL_ERROR_SYSCALL で失敗し、ほかのサイトは動作する、DNS が偽の IP/TUN の IP を返す、PROXY ルールを追加しても解決しないこのスキル — まず references/case-proxy-tun-cname-override.md を読んでください
TLS 証明書の検証エラー(UNKNOWN_CERTIFICATE_VERIFICATION_ERROR、別のサイト用の証明書)またはハンドシェイク途中の EOF が、すべての DIRECT ルーティング/国内ドメインで一斉に発生する一方、プロキシ経由のドメインは動作し、プロキシのヘルスウォッチドッグも正常を報告しているtunnel-doctor(TUN DIRECT スプリットブレインの手順)
プロキシに接続でき、OS/Tailscale のルートも正常だが、大容量転送が遅く、アクティブな出口/ノードを変更すると測定速度が変わるtunnel-doctor — プロキシノード/出口/チェーンの容量に関する分岐
エラーは出ず、ただ遅い。 すべてのリクエストが 200 を返し、レイテンシとヘルスチェックも正常に見えるが、転送が極端に遅い、一括処理が見積もり時間を超える、またはファイルが途中で切れて届くこのスキル — ステップ 0.7 に進んでください。エラーを起点とする手順では対処できません
スマートホーム/IoT デバイスの一群が一斉にオフラインになる。ルーター/AP の交換や Wi-Fi 設定の変更から数週間後に発生することが多く、ハブはすぐそこにあるのに配下のデバイスがすべて動作せず、一部の Wi-Fi 直結デバイスもオフラインになるこのスキル — まず references/case-iot-router-migration-wpa3.md を読んでください

いずれにも当てはまらない場合、またはドメイン別スキルを試しても証拠が別の原因を示す場合は、以下に進んでください。この方法論は、あらゆる多層システムに応用できます。

このスキルに関する注記:症状が Cloudflare の 524/522 で、大きな POST 本文(例:/<openrouter-path> で Content-Length が 1 MB を超える)の場合、原因はバックエンドの処理の遅さではなく、オリジンへのアップロード時間が Cloudflare のオリジン読み取りタイムアウトを超えていることがよくあります。バックエンドの停止と決めつける前に、以下のアップロードと処理を切り分けるチェックリストを使ってください。

基本原則

1. 仮定より証拠を重視する

具体的な証拠(ログ行、pcap フレーム、プローブ出力、メトリクスのサンプルなど)を示せないなら、それは診断ではなく推測です。「X が原因です」と述べる前に、直接的な証拠を挙げられるか確認してください。まだ証拠がない場合は、先に計測を追加する(references/instrumentation-patterns.md を参照)か、証拠を取得してください(references/packet-capture-recipes.md を参照)。

2. 確証より反証を重視する

仮説を「裏付ける」N 個の独立した情報源があっても、その仮説が正しいとは限りません。反証となる観察結果が一つでもあれば、その仮説は否定されます。仮説に基づいて行動する前に、次の問いに答えてください。

「どのような観察結果があれば、この仮説を捨てますか?」

答えが「何もありません」または「思いつきません」なら、その仮説は反証不可能であり、調査の根拠にしてはいけません。具体的な答えがある場合は、行動に移す前にその観察結果を探してください。

3. レイヤーごとの切り分け

複数のホップを経由するシステム(クライアント → CDN → LB → リバースプロキシ → アプリ → 上流)では、レイヤー間の境界でバグが発生しやすくなります。症状の原因が複数のレイヤーにあり得る場合は、どのレイヤーか推測せず、テストしてください。検証済みの経路間で同じ論理リクエストを比較し、変更されたホップをすべて特定して、切り分けられた差異だけを原因として特定してください。断続的な障害の場合は、変更後に成功したことを因果関係の証拠とみなす前に、元の条件に戻す期間を設けてください。経路の設計と A/B/A の解釈については、references/layered-isolation-experiment.md をお読みください。

この手法は、正しさだけでなく処理能力の切り分けにも使えます。 症状がエラーではなくレートとして現れる場合は、ホップではなくスタックを変更します。ネットワーク経路は共有していてもアプリケーションコードは共有していない 2 つのチャネルで、同じ方向の通信を測定してください。ボトルネックではなかったアプリケーションの調整をやめるための、最も手軽な方法です。結果の解釈を「一致すれば経路が原因」と単純化しないでください。これは両方のチャネルが低速な場合にしか当てはまりません。両方のチャネルが高速なら、プローブで症状を再現できなかったことを意味し、何も証明しません。4 通りの結果それぞれの読み取り方については、ステップ 0.7 を参照してください。

4. 決定前に反対意見をレビューする

根本原因を決定したり修正をリリースしたりする前に、独立したレビュアーに結論を確認させるのではなく、異議を唱えてもらってください。エージェントは、単独の調査担当者が思いつかなかったリスクを見つけるのは得意ですが、それらの重要度を評価するのは得意ではありません。各調査結果が対応方針に影響する前に、4 つの質問によるフィルターを適用してください(references/counter-review-pattern.md を参照)。

5. 緑色のヘルスチェックが証明するのは、チェック対象の経路だけです

モニターが監視するのは、作成者がチェック対象として考えた経路です。複数のプレーンを持つシステム――DIRECT プレーンとプロキシ経由のプレーンを持つ TUN プロキシ、データプレーンとコントロールプレーンを持つサービス、バックエンド API とウェブログインページを持つスタック――では、各プレーンが個別に停止します。もう一方のプレーンしかチェックしないウォッチドッグは、障害が続いている間ずっと正常を示します。この原則の背景にあるインシデントでは、プロキシのヘルスデーモンが 5 分ごとにプロキシ経由で海外のエンドポイントを 1 つチェックし、直接経路が完全に停止していた 2 時間以上の間、「healthy」と記録し続けていました。

「モニターでは正常と表示されている」を根拠として受け入れる前に、**そのチェックが実際に通る経路はどれか?**と確認してください。緑色の表示が証拠となるのは、その経路だけです。システムが実際に転送または提供するプレーンをすべて列挙し、障害が起きているプレーンを直接チェックしてください。障害を検知できたはずのチェックは、たいてい curl を 1 回実行するだけです。

**チェックが証明できるのは、測定に十分な大きさの 量 についてだけです。**経路のカバレッジが一つの軸だとすれば、もう一つは規模であり、スループットの低下を見落とす原因になります。Liveness プローブが返すのは数百バイトなので、所要時間はハンドシェイクと往復時間に左右されます。ケーススタディでは、リンクの実際の容量が 100 倍変動しても、応答は一日中変わらず 40 ms でした。しかし、それは嘘ではありません。測定対象をきちんと測っていたのです。小さなパケットの往復時間を測定する ping/RTT も同じで、容量については何もわかりません。

実践的なルールは、転送時間が所要時間の大半を占める場合に限り、プローブはリンクを測定できるということです。そのため、利便性ではなく必要な答えに合わせてサイズを決めてください。プローブが 1 秒を大きく下回る時間で応答するなら、その時間のほぼすべてはセットアップであり、測定できたのはセットアップです。サイズの問題は、バイト数ではなく 時間 を基準にすることで回避できます(N 秒間ストリーミングし、受信したデータ量を N で割ります)。Step 0.7 のコマンドがこの方法を使っているのは、すでに速度が著しく低下しているリンクでも負荷が小さいためです。問いを次のように広げてください。どの経路を、どの規模でチェックしているのか?

6. ユーザーに尋ねる前に計測手段を使い尽くす — ユーザーにしか提供できない情報だけを尋ねる

ユーザーの口頭での観察(「Wi-Fi が数秒間途切れます」「一部のアプリで接続エラーが報告されます」)は、返答を求める質問ではなく、手がかりです。そこに含まれる未解決の小さな疑問は、観測可能なものと考え、尋ねる前に計測手段を探してください。監視/プローブの記録(ヘルスデーモンがある場合、そのログには、プローブ対象の経路で発生した障害の時系列が、双方向について記録されています。記録が正常でもユーザーの観察が誤りだとは証明できません(原則 5)。また、記録が異常でも、プローブされていない経路については何も示しません)、システムログ(カーネルのリンク/切断イベントで「Wi-Fi が物理的に切断されたか」が確定します。プロセスごとのエラーの集中で「どのアプリが失敗したか」が確定するのは、そのアプリがそこにログを記録している場合だけです)、同じネットワーク上の制御チャネル(異なる接続手段を使う 2 台目のホスト — プロキシを使わない有線接続の端末、ホットスポットとの比較、あるいはルーター自体のステータスページなどです。制御チャネルがない場合は、質問に回答があったかのように扱わず、その旨を明確に伝えてください)。ある小さな疑問について計測手段を探しても何も見つからない場合は、ステップ 4 — 計測手段を導入して再発を待つ — に進むべきであり、「ユーザーに思い出してもらう」ことではありません。

調査中にユーザーに尋ねることが正当に認められるのは、次の 3 つのカテゴリだけです。

  1. 意思決定権 — 本番環境への変更、再起動、支出など、権限の範囲を超える事項です。
  2. 計測手段では捉えられない物理的な操作や本人の観察 — 機器のプラグを抜くこと、ラベルに記載されたパスワードを読むこと、テレメトリのないデバイスで Wi-Fi アイコン自体が消えたかどうかなどです。
  3. 保有していない認証情報やアクセス権 — デバイスのラベルにしか記載されていない管理用パスワードなどです。

2026-09-10 に観察された事例:家庭内ネットワークの調査で、最終報告にユーザーへの質問が 4 つ含まれていました。「どのアプリでエラーが報告されたか」 — 診断上の情報、つまり各プレーンでの障害の時系列は、すでにヘルスデーモンの記録にありました(アプリ名の一覧自体はユーザーの画面にしか表示されておらず、その時系列にないレイヤー情報は追加されません)。「Wi-Fi が実際に切断されたか」は、12 時間分のカーネルログに切断イベントが 1 件もなかったことで確定していました。それにもかかわらず、どちらもユーザーに尋ねられました。4 つ目の質問 — 「使われなくなったルーターはまだブロードキャストしているか」 — は、正当な反例です。ARP が見つからなかったことは何の証明にもなりません(落とし穴 14:プローブで確認できるのは、プローブを実行した L2 ドメインだけです)。プラットフォームによって Wi-Fi スキャン結果が秘匿されていたため、この質問は 5 秒でできる物理的な確認としてユーザーに尋ねるのが適切でした。

ユーザーに尋ねる質問ごとに、なぜどの計測手段でも回答できないのか、また 3 つのカテゴリのどれに該当するのかを、報告書に 1 文で記載してください。その文を書けないなら、その質問はまだ調査し尽くされていません。計測手段に戻ってください。尋ねてはいけません。

ワークフロー

このチェックリストを調査メモにコピーし、項目にチェックを入れてください。

Investigation Progress:
- [ ] Step 0:   Scope the symptom (exact error, exact times, who, who-not, what changed)
- [ ] Step 0.5: Verify the premise — does direct evidence show the symptom is actually happening?
- [ ] Step 0.6: **For large POST bodies: distinguish upload-timeout from processing-timeout** (see recipe below)
- [ ] Step 0.7: **If nothing errors and it is just slow: measure a rate, then two-channel it** (see recipe below)
- [ ] Step 1:   Gather direct evidence at every hop before hypothesizing
- [ ] Step 2:   Frame ≥3 hypotheses; for each, name (a) what falsifies it, (b) which layer boundary the intervention would target
- [ ] Step 3:   Design a decisive experiment (for network: layered isolation)
- [ ] Step 4:   Add instrumentation if evidence gaps block direct observation
- [ ] Step 5:   Execute, record actual vs predicted
- [ ] Step 6:   Counter-review before acting
- [ ] Step 7:   Fix + re-run the same experiment to verify
- [ ] Step 8:   Document wrong turns as teaching material
Step 0: 範囲

範囲を絞ることが、20分の調査と5時間の調査の分かれ目です。何かを調べる前に、次の情報を抜き出します。

  • 正確なエラー文字列(言い換えずにコピー&ペーストします)。socket closed と ECONNRESET は異なり、HTTP/2 RST_STREAM INTERNAL_ERROR (err 2) もまた別のエラーです。
  • 正確なタイムスタンプ(「昨日の夕方」ではなく、タイムゾーン付きの ISO-8601 形式)
  • 再現性(毎回/断続的/特定のユーザーのみ)
  • 影響を受ける人と、受けない人(違いを観察すると、調査範囲を絞れます)
  • 最近変更されたもの(デプロイ、設定、上流の依存関係、クライアントのバージョン)

症状と診断を区別します。「遅い」は症状ではありません。「リクエストに 130.898 秒かかり、その後 HTTP/2 INTERNAL_ERROR が返された」なら、症状です。

ただし、この一文を理由に実際のインシデントを対象外にしてはいけません。 求めているのは定量化であり、エラーコードではありません。エラーコードがまったくなくても、失敗の一連の現象を 発生率 として定量化できます。すべてのリクエストが 200 を返し、何もリセットされないのに、システムが使いものにならないケースです。「遅い」は、明示したペイロードサイズに対する 1 秒あたりのバイト数として記述した時点で症状になります(「95 秒で 8.4 MB = 0.09 MB/s、終始 HTTP 200」)。この数値があれば、Step 0.7 に決定的な実験方法があります。対象外となるのは、測定されていない訴え――「ユーザーが遅く感じると言っています」――です。これはこのステップではなく、Step 0.5 で扱う問題です。

Step 0.5: 前提を検証する

本格的な調査に取りかかる前に、報告された症状が実際に起きていることを確認します。下流の影響やユーザーの不満から推測しただけではいけません。安価な直接観察を 1 回行うだけで、問題が存在しないのに何時間も調査する事態を防げます。

次のように尋ねます。「この症状が実際に起きていることを示す、直接的な証拠は何ですか?」

  • ユーザーが「130 秒でタイムアウトする」と報告した場合、それはタイムスタンプ付きのログ、ブラウザーのネットワークパネル、あるいは記憶に基づくものですか?
  • ユーザーが「接続がリセットされる」と報告した場合、そのパケットを確認したのですか、それとも再試行の急増から推測したのですか?
  • ユーザーが「一部の人では失敗するが、ほかの人では失敗しない」と報告した場合、制御されたテストで再現しましたか、それとも伝聞にすぎませんか?

前提として受け入れられるもの:

  • タイムスタンプとエラー文字列を含むログ行
  • 失敗を示すブラウザーの DevTools Network のスクリーンショット
  • 症状を任意のタイミングで発生させる再現コマンド
  • 特定のエラー件数の増加を示すメトリクスのグラフ

前提として不十分なもの:

  • 「ユーザーが遅く感じると言っています」
  • 「アラートが発報したものの、実際に何が失敗したかは確認していません」
  • 「先週、誰かが言っていました……」

前提を検証できなかった場合、必要なのは調査ではなく、観察です。不足しているテレメトリを追加し、計測できる状態で次に発生するまで待ち、実際のデータがそろってから戻ってきてください。「もうここまで来たのだから」と、サンクコストに引きずられて調査を続けないようにしてください。

Step 0.6: 大きな POST ボディのアップロードタイムアウトと処理タイムアウト

大きなボディを扱う CDN 経由の POST/PUT エンドポイントでよくある誤診は、実際の問題が ボディのアップロードにかかる時間が CDN/プロキシのオリジンタイムアウトを超えていること なのに、バックエンドの遅延のせいにすることです。

Content-Length > ~500 KB のリクエストで 524/522/504 が発生する場合は、次のサブチェックリストを適用してください。

  1. エッジ/リバースプロキシのアクセスログを確認します(Caddy、nginx、Envoy、Cloudflare Logpush)。
  2. bytes_read(または同等の値)と Content-Length を比較します:
    • bytes_read == Content-Length かつ status がエラー → バックエンド/処理の問題である可能性が高いです。
    • bytes_read < Content-Length かつタイムアウト時点付近で接続が閉じている → アップロードの問題 です。
  3. duration/request_time の意味を確認します:
    • Caddy の duration = 最初のバイトを読み込んでからレスポンスが完了するまでの実時間です。
    • nginx の $request_time = 同じです。
    • /アプリの request_time = ボディの受信完了後にバックエンドが処理に費やした時間です。
    • プロキシの duration ≈ タイムアウトで、アップストリームの request_time が短い、または記録されていない場合、ボディのアップロードがボトルネックです。
  4. status=0(Caddy)または -(nginx)を確認します:
    • status=0 は、プロキシが HTTP レスポンスを書き込まなかったことを意味し、通常はダウンストリーム/クライアント側が先に接続を閉じたためです。
  5. アップストリームのログと照合します:
    • リクエスト ID/ray ID/trace ID がアップストリーム(、、アプリ)のログに 現れない 場合、リクエストのアップロードは完了していません。

アップロードタイムアウトによる 524 の典型的な例:

{
  "status": 0,
  "duration": 125.0,
  "bytes_read": 4111422,
  "request": {
    "headers": { "Content-Length": ["6042141"] }
  }
}

解釈:プロキシは接続を 125 秒間維持し、6 MB のボディのうち 4.1 MB を読み込んだ後、Cloudflare が接続を閉じて 524 を返しました。

処理タイムアウトの典型的な例:

{
  "status": 504,
  "duration": 120.1,
  "bytes_read": 6042141,
  "request": { "headers": { "Content-Length": ["6042141"] } }
}

解釈:ボディはすべてアップロードされましたが、プロキシのタイムアウト前にバックエンドが応答しませんでした → バックエンド/処理の問題です。

Step 0.7: スループットの急落 — エラーは出ないのに、すべてが遅い場合

上記の手順(0.6)は、grep で見つけられるエラーコードが出る タイムアウト に対処するものです。こちらは、エラーがまったく出ない ケースに対処します。すべてのリクエストが成功し、ヘルスチェックもすべて正常なのに、想定レートのごく一部の速度でしかバイトが届かず、システムが使いものにならない状態です。エラーに基づく診断手段が何も役立たないため、何時間も誤診される可能性が最も高い障害モードです。

通常のシグナルが正常を示す理由 — 100 倍に劣化したリンクと同時に、次のすべてが観測されました。

シグナル読み取られた値それでも正常と判断される理由
HTTP ステータスすべて 200遅いことはエラーではありません。レスポンスは完了しますが、遅れているだけです
往復レイテンシ12 msRTT が測るのは小さなパケットの往復時間であり、容量の測定ではありません
稼働/ヘルスエンドポイント0.04 sレスポンスのサイズがハンドシェイク程度なので、計測される時間はすべて RTT で、転送時間は含まれません。リンクが制限要因になる前に完了するプローブでは、リンクを測定できません — 原則 5 を参照してください
トランスポートステータスフィールドactive; direct <endpoint>経路の種類を報告するもので、経路の容量ではありません — 落とし穴 16 を参照してください

したがって、最初に行うべきことは、ステータスを見るのをやめて レートを測定する ことです。

デバイスまたは有線/Wi-Fi の比較を行う場合は、接続先を調べたり変更したりする前に、デバイススループットの現地調査を読み込みます。制御/データ経路のバインド、比較、復旧時間枠については、そこで扱います。

決定的な実験:独立した 2 つのチャネルを、同じ方向に、同じ時間枠で測定します。

これは、正しさではなく容量に対して、原則 3 のレイヤー別切り分けを適用するものです。同じネットワーク経路で同じホストに到達し、アプリケーションコードを共有しない 2 つのチャネルを選んでください。両者が一致する結果は、どちらか一方の内部要因では起こり得ない、というのがその狙いです。 以下の 2 つのコマンドは、サイズではなく時間を基準に予算を設定しています。一定の秒数だけストリーミングし、その間に届いたデータを報告します。これにより、リンクの状態がどれほど悪くてもプローブのコストは一定に保たれます。ケーススタディでは、固定の 8 MB のペイロードが正常な経路では 1 秒未満で完了しましたが、劣化した経路では約 90 秒かかりました。さらに劣化が進むと、待ち時間は上限なく延び、まさに時間に余裕がないときに問題になります。また、2 つの数値を比較できるのもこのためです。どちらも同じ秒数で、同じ方向に転送し、受信側で測定しています。

BUDGET=20

# Channel A — the service under suspicion. Point it at the largest object you can name.
# --max-time aborts mid-transfer; -w still prints, and curl exits 28. That is a
# successful measurement, not a failure — read the rate, ignore the exit code here.
curl -s -o /dev/null --max-time "$BUDGET" \
  -w 'A: %{size_download} B in %{time_total}s = %{speed_download} B/s\n' \
  "http://<host>:<port>/<a-large-object>"

# Channel B — a completely different stack to the same host. `cat /dev/zero` streams
# until cut off, so the byte count is decided by the link, not by the payload size.
B=$(timeout "$BUDGET" ssh <host> 'cat /dev/zero' 2>/dev/null | wc -c)
echo "B: $B B in ${BUDGET}s = $((B / BUDGET)) B/s"

チャンネル B の数値として、dd 自身のサマリー行を代用しないでください。 dd if=/dev/zero … が報告するのは、標準出力への書き込み速度です。パイプ経由の SSH 転送では、パイプと SSH のチャンネルウィンドウがその出力を受け取るため、測定されるのはローカルでの書き込みであり、実際の回線速度ではありません。また、接続の確立にかかった時間も含まれません。ケーススタディの正常な経路で測定したところ、raw SSH はエンドツーエンドで 33.6 MB を 44 MB/s で転送しましたが、同じ転送で dd が自己申告した速度は 54.9 MB/s でした。送信側で測定した速度は、回線速度の上限を示すにすぎず、回線速度そのものの測定値ではありません。上記のように、受信側で呼び出し全体の所要時間を測定してください。

2 つのチャンネルで絶対値が異なるのは想定どおりであり、問題ではありません。 同じ正常な経路でも、raw SSH の測定値は約 44 MB/s でしたが、メディアサービス経由のフェッチは 11~16 MB/s でした。これは、チャンネル A では回線に加えて、サービス独自の読み取りとエンコードの処理が必要だからです。そのため、以下の判定ルールで見るのは値の一致ではなく、桁の違いです。確認するのは、両方のチャンネルが同じ範囲に収まっているかどうかであり、同じ数値が出るかどうかではありません。正常なリンクで 2 つのチャンネルの値に 3 倍の差があり、劣化したリンクでは両方とも 0.1 MB/s に落ちるなら、必要な情報はすべて得られています。

表を見る前に、両方のプローブが実際に実行されたことを確認してください。 これは、トラップ 17 を自分の手順に当てはめたもので、ここで自信満々に誤った答えを導く最も簡単な原因です。データを一度も転送しなかったプローブは、低速またはゼロの速度を返します。以下の表ではこれを「低速」と判定し、「経路」の問題だと結論づけます。しかし、それは測定自体が行われていないのに導かれた結論です。どちらも確認は簡単です。

  • チャンネル A:size_download はオブジェクトの実際のサイズに近いですか? DNS の失敗(curl の終了コード 6)、接続拒否(終了コード 7)、または 404 エラーページはいずれもすぐに返り、サイズもごく小さくなります。そのため、高速に見えてしまい、反対方向から誤った行に当てはまることさえあります。「測定は有効だが、オブジェクトが予算内に収まらないほど大きかった」を意味するのは、転送途中で --max-time が発動した場合の終了コード 28 だけです。
  • チャンネル B:$B は妥当なゼロ以外の値ですか? このコマンドの 2>/dev/null は SSH 自身のエラーを隠すため、認証できないホストでは B=0 が何も表示されずに返ります。数値がおかしい場合は、2>/dev/null を外してもう一度実行してください。

結果は 4 通りのテストとして読み取ってください。4 通りすべてが起こり得ますが、そのうち 3 通りでは、今から行おうとしていた作業を中止するよう示されます。

チャンネル Aチャンネル B結論
低速低速経路の問題です。 無関係な 2 つのスタックが、無関係な理由で同じ速度まで遅くなることはありません。アプリケーションの調整は中止してください。アプリケーション内部でできることは何もありません。以下の「経路に何が起きたか」に進んでください。
低速高速アプリケーション/サービスの問題です。 経路では明らかにバイトを転送できていますが、A ではできていません。これで比較検証に使える対照ができました。
高速低速まだどちらとも結論づけられません。対照側に異常があります。 弱い CPU での暗号方式の負荷、SSH を整形するホップなど、SSH 固有のコストがあるか、B が別の経路を通った可能性があります。「サービスに問題はない」と結論づけないでください。B を 3 つ目のスタックに置き換えて再実行してください。
高速高速プローブでは症状を再現できませんでした。 インシデントが解決したと判断しないでください。実際のワークロードには、まだ再現できていない違いがあります。方向(アップロードかダウンロードか)、並行処理数、オブジェクトのサイズ、時間帯などです。そのうち 1 つを変更して再測定し、結果が良好だからといってすぐに信頼しないでください。
「同じ速度」とはどの程度ですか? この用途では、約20%以内の差であれば同じ速度として扱います。2つの経路ではフレーミング、暗号化、オブジェクトごとのオーバーヘッドが異なるため、完全に一致する必要はなく、そもそも期待できません。判断するのは桁の一致です。たとえば、2つのスタックの測定値が0.09 MB/sと0.11 MB/sなら一致していますが、0.1 MB/sと12 MB/sなら一致していません。差が約20%から約2倍の範囲なら、どちらかを選ぶのではなく、判定保留として予算かオブジェクトサイズを増やしてください。

SSHがない場合のチャネルBの組み合わせ方。 要件は「同じホスト、同じ方向、アプリケーションコードを共有しないこと」だけです。SSHは便利ですが、特別なものではありません。次のいずれでも機能します。ホスト上の無関係な別サービス(メトリクスエンドポイント、オブジェクトストア、静的ファイルサーバー)、コンテナランタイムを使った転送(そのホストからのdocker cp)、両端で実行できる場合は生のスループット測定ツール(iperf3 -c)、またはホスト独自のパッケージ/アーティファクトミラーです。該当しないのは、同じサービスの2つ目のエンドポイントです。 これでは原因として否定しようとしているコードを共有するため、一致しても何の証明にもなりません。

次に、経路で何が変わったかを調べます。 経路が原因だと分かったら、変数は通常ハードウェアではなくトポロジーです。つまり、今日と昨日で、トラフィックが実際にどのルートを通っているかです。メッシュVPN(Tailscale、Nebula、ZeroTier)やスプリットトンネルプロキシでは、同じ論理アドレスに対して、LAN直結経路、WAN直結経路、またはリレーを経由する経路が使われることがあり、容量は桁違いに異なる一方で、ステータスフィールドにも接続先のアドレスにも変化がありません。トランスポートに、実際に使用している経路を報告させてください(tailscale ping <host>を実行すると、エンドポイントと、返信がリレー経由だったかどうかが表示されます)。

比較できる過去の正常な測定値がない場合(初回のインシデントではよくあります)でも、なくては進展できないわけではありません。「これが変わったのか?」という問いには、より手軽な代替策があります。別の場所から同じ経路を測定してください。 別のネットワークにある2台目のクライアント、またはホスト自身によるループバック経由の測定で、履歴がなくても問題の範囲を絞れます。次に、今日の測定値を、これまでなかったベースラインとして記録してください。このインシデントが2度目に発生したときは、誰かが測定値を書き留めておけば、初回よりもずっと簡単に対処できます。

最後に、誤った前提によってすでに何が損なわれたかを監査します。 リンクの劣化は、単に処理を遅くするだけではありません。高速な経路に合わせて調整したすべてのタイムアウトをひそかに無効にし、そのタイムアウトによって、成功したように見える破損アーティファクトが生成されます。インシデントが解決したと判断する前に、劣化していた期間中に転送したものをすべて再検証してください。トラップ18とステップ7を参照してください。

意味を変える前に、元のワークロードを再実行します。 経路またはノードの変更によって代表的な転送速度が回復したら、最初に失敗した操作を、元の完了条件のまま正確に再実行してください。クローンのインシデントならGitの完全なクローン、アップロードのインシデントなら本文全体、ストリームのインシデントなら元のストリーミングプロトコルを使います。小さなヘルスリクエスト、ls-remote、または部分/シャロークローンで証明できるのは、それ自体の小さな経路が機能することだけです。元のワークロードが既存の期限内に完了すれば、部分クローンやタイムアウトの緩和といったアプリケーションレベルの回避策が必要だという主張は、証拠によって否定されます。それでも失敗する場合は、その結果を保持し、その後で初めてワークロードの意味を調査してください。これはRFC 6349が持続的なTCPスループットとRTT/到達性を区別していることに沿ったものです。証明では、セットアップコストよりも転送容量の影響が大きくなる量のデータを扱う必要があります。

ステップ 1:すべてのホップで直接的な証拠を集める

不慣れなデバイスやローカルクライアントの HTTP/RPC エンドポイントを呼び出して証拠を集める前に、デバイスのスループットに関する現場調査を読み込みます。オフラインでの調査を完了し、影響を分類してから、別途実行の呼び出しを行います。

仮説を立てる前に、次の情報を収集します。

  • リクエスト経路上の各ホップのサーバー側ログ
  • クライアント側ログ(ブラウザー開発者ツールの HAR、CLI デバッグログ、SDK トレース)
  • インシデント発生時間帯のメトリクス(RPS、レイテンシー、エラー率、接続数、CPU/メモリー)
  • 利用可能な場合は分散トレース
  • 症状がワイヤーレベルにある場合はパケットキャプチャ(references/packet-capture-recipes.mdを参照)

これらのうち関連する情報が欠けている場合は、推測する前に不足を補ってください。TRACE_* 環境変数フラグを追加してコンテナーを再起動するほうが、1 時間仮説を積み重ねるより効果的です。references/instrumentation-patterns.mdの計測パターンはリスクが低く、環境変数で有効化でき、本番環境に恒久的に導入しても安全です。

アップロードと処理を切り分けるリバースプロキシのアクセスログの読み方

Caddy と nginx のログは、「バックエンドが遅い」という仮説を手早く検証するのに最適です。次の 3 つのフィールドに注目してください。

フィールドCaddy の JSON キーnginx 変数意味
合計実時間duration$request_timeクライアントからの最初のバイト受信からクライアントへの最後のバイト送信まで(または接続終了まで)
受信したボディのバイト数bytes_read$request_length (概算)プロキシがクライアントから実際に読み取ったバイト数
宣言されたボディサイズrequest.headers.Content-Length$content_lengthクライアントが送信すると伝えたサイズ
レスポンスステータスstatus$status0/- は、プロキシがレスポンスを書き込まなかったことを示します

主なパターン:

  • bytes_read < Content-Length かつ duration ≈ timeout → アップロードタイムアウトです。
  • bytes_read == Content-Length かつ status が 5xx → 処理タイムアウトです。
  • status == 0 かつ bytes_read < Content-Length → アップロード完了前にクライアント/CDN が接続を閉じています。
スタック全体で単一のリクエストを追跡する

スタック(Cloudflare → Caddy → → → )における標準的なトレース手順は次のとおりです。

  1. Cloudflare:クライアントエラーまたは Cloudflare Logpush から Cf-Ray とタイムスタンプを取得します。
  2. Caddy:docker logs <gateway-container> | grep <Cf-Ray> を実行し、X-Request-Id(Caddy の uuid)を抽出して、bytes_read、duration、status を確認します。
  3. :docker logs <provider-gateway-service> で Client request error: aborted またはリクエスト/レスポンスログを確認します。
  4. :grep <X-Request-Id or timestamp> /data/<upstream-capture-service>/log/access.log を実行し、リクエストが に到達したか、上流の処理にどれだけ時間がかかったかを確認します。
  5. :docker logs <new-api-container> で課金/チャネルのエラーを確認します。

リクエスト ID が手順 3~5 に一度も現れない場合、障害はエッジ、またはボディのアップロード中に発生しています。

クライアント IP ごとに集計してパターンを見つける

単発の 524 は偶然かもしれませんが、特定の IP とパスに集中する 524 のパターンは決定的な兆候です。次のような集計を実行します。

# Caddy JSON example: count failures by IP and body size for an endpoint
python3 -c "
import sys, json
from collections import Counter, defaultdict
stats = defaultdict(lambda: {'total': 0, 'fail': 0, 'slow': 0, 'max_cl': 0})
for line in sys.stdin:
    d = json.loads(line)
    req = d.get('request', {})
    if req.get('uri', '').startswith('/<openrouter-path>'):
        ip = req.get('headers', {}).get('Cf-Connecting-Ip', [''])[0]
        cl = int(req.get('headers', {}).get('Content-Length', ['0'])[0] or 0)
        dur = d.get('duration', 0)
        status = d.get('status', 0)
        s = stats[ip]
        s['total'] += 1
        s['max_cl'] = max(s['max_cl'], cl)
        if status == 0:
            s['fail'] += 1
        elif status == 200 and dur > 60:
            s['slow'] += 1
for ip, s in sorted(stats.items(), key=lambda x: -x[1]['fail']):
    print(f\"{ip}: total={s['total']} fail={s['fail']} slow={s['slow']} max_cl={s['max_cl']}\")
" < caddy-access-log.jsonl

1 つの IP が失敗の大半を占め、その max_cl が大きい場合は、バックエンドより先にアップロード帯域幅や経路を調査してください。

ステップ 2:反証可能な仮説と脅威モデルの境界

考えられる原因を 3 つ以上挙げます。それぞれについて、次の 3 文を書きます。

  • 何が確認材料になりますか?(確認しやすいものの、誤解を招きやすいもの)
  • 何が反証になりますか?(反証条件 — 重要なのはこちらです)
  • 介入はどのレイヤー境界を対象にしますか?(脅威モデルに関する問い — 修正を適用する場所を正確にするためのものです)

3 つ目の問いによって、よくあるアンチパターン、つまり誤ったホップに作用する修正案を提案することを防げます。たとえば、バイト列をクライアントに向けて下流へ送る「キープアライブ」修正は、_上流_のアイドルタイムアウトには役に立ちません。介入対象が問題の境界と異なるためです。境界を事前に明示すれば、コーディングを始める前にこの不一致が明らかになります。

具体的な反証条件を示せない場合、その仮説は反証不可能です。明示はしますが、対応は行いません。修正案がどの境界を対象にするかを示せない場合、その修正が実際に何をするのか、まだ理解できていません。

ステップ 3:決定的な実験

ネットワーク層の問題には、経路の違いを検証したうえで段階的に切り分ける方法を使います。例:Client → CDN → LB → Origin の場合:

経路ルート完全な経路との違い
ACDN と LB を経由する完全な経路ベースライン
B検証済みのオリジン IP への --resolveCDN、LB、およびそれらを接続するルートを取り除く
Cサーバーのループバック外部クライアントからオリジンまでのルートも取り除く

A だけで失敗する場合は、取り除いた CDN/LB 経路の違いに原因を絞り込みます。LB を維持する経路を追加して、どのレイヤーが原因かを判別してください。断続的な症状に対する解釈の全容、範囲を限定した A/B/A チェック、制御されたアイドル時間で動作するモックのアイドル状態の上流については、references/layered-isolation-experiment.md を参照してください。

ネットワーク以外の領域では:

  • パフォーマンス:変数を 1 つだけ変更した制御ベンチマーク
  • 正確性のバグ:再現する失敗テストケース
  • 断続的な問題:サンプリングしたトレースと、元の条件に戻す回数を限定した確認。差が再現しない場合は、不確実性を残します
ステップ 4:必要に応じて計測を追加する

決定的な実験に、現在は得られない観測が必要な場合は、それを追加してください。省略してはいけません。標準的なパターンは、環境変数で制御する計測で、次の条件を満たします。

  • デフォルトでは無効(通常時のランタイムコストはゼロ)
  • コードを変更せず、環境変数 1 つで有効化
  • grep 可能なログタグを書き出す([SSE-CHUNK] ts=... req=... bytes=...)
  • 本番環境に恒久的に組み込む — 今後のインシデントでも再利用できます

このインシデントで の上流が 125 秒間無応答だった問題の診断に使った、正確なテンプレートについては、references/instrumentation-patterns.md を参照してください。

ステップ 5:実行して記録する

実験を 1 回実行し、コマンド、環境、入力、観測した出力、実時間のタイムスタンプをすべて記録します。ステップ 2 で立てた予測と比較します。実際の結果が予測と一致すれば、仮説は適切に調整されています。一致しなければ仮説が間違っています — 場当たり的な補助仮説でつじつまを合わせてはいけません(「ああ、でも X も干渉している可能性がある……」など)。ステップ 2 に戻り、仮説を一から立て直してください。

ステップ 6:反対の立場からのレビュー

根本原因を確定したり修正をリリースしたりする前に、独立したレビュアーを参加させて結論に異議を唱えてもらいます。同じ証拠を渡し、確認ではなく反証を求めてください。レビュアーが挙げた各指摘に、次の 4 つの質問を適用します。

  1. 可能性 — 実際に起こり得ますか?
  2. コスト — 修正する場合と無視する場合のコストはどのくらいですか?
  3. 現実的なシナリオ — ユーザーの実際のビジネスケースに当てはまりますか?
  4. 検証 — 低コストで確認または反証できますか?

すべての指摘を分類します:実際の問題/一部は妥当/可能性は低い/実害がある。生のエージェント出力をユーザーに貼り付けてはいけません。必ず先に精査してください。references/counter-review-pattern.md を参照してください。

ステップ 7:修正して検証する

修正を適用します。ステップ 3 と同じ決定的な実験を再実行します。同じセットアップで確実に発生していた症状が再現しなくなったことを確認します。修正後に修正前の状態を再現できなくなった場合、その修正を証明することはできません。成功を宣言する前に、再現できなくなった理由を突き止めてください。

ステップ 8:誤った判断も記録する

調査における誤った判断は、正しい答えよりも価値があります。以下を記録したインシデントレポートを作成します。

  • 症状と直接的な証拠
  • 試した各仮説と、それをどう反証したか
  • 決定的な実験の設計と結果
  • 修正と検証
  • 新たに追加した監視や計測

未来の調査担当者(未来の自分も含みます)が、同じ認知の落とし穴を避けるために読みます。

よくある認知の落とし穴

  1. 状況証拠の収束。 間接的な手がかりが5つすべて同じ方向を示すと、証明のように感じられます。しかし、そうではありません。直接プローブを安価に実行できるなら、実行してください。

  2. フィールドの意味の混同。 duration=5.95s は、合計ウォール時間(あるツール)、ハンドラーの実行フェーズ(別のツール)、TTFB(さらに別のツール)を意味する場合があります。数値フィールドを引用する前に、ドキュメントまたはコードでその意味を必ず確認してください。

  3. 単一原因バイアス。 多層システムの障害は、複数層にまたがる不具合の組み合わせによって発生します。直接の原因を修正するだけでなく、増幅要因も記録し、次の防御層も強化できるようにしてください。

  4. 名前に基づく思い込み。 spot-instance というラベルのリソースが、実際にスポットインスタンスとは限りません。メタデータ上の名前ではなく、API で属性を確認してください。

  5. プローブの自己検証。 壊れた接続を通じて実行する診断で、その壊れた接続をテストしても、結果は解釈できません。必ず独立したプローブで相互検証してください。

  6. 思い込みを救済するサイクル。 証拠が仮説に反するとき、「そうですが、ケース X の場合に限ります」のように条件を追加したくなります。その誘惑に抵抗してください。最初の反証が見つかったら、その仮説は破棄してください。

  7. 未検証の前提。 ユーザーの苛立ち、アラートのタイトル、下流への影響から推測しただけで、直接観測していない症状を調査することです。まず確認してください(ステップ 0.5)。伝聞を調査しないでください。

  8. 脅威モデルの不一致。 誤ったレイヤーを対象とする修正を提案することです。上流の問題を解決するために下流へバイトを書き込んだり、タイムアウトが発生しないホップのタイムアウトを調整したりするケースです。各仮説が対象とする境界を明記すると(ステップ 2)、不一致が明らかになります。

  9. 戻り経路/方向の非対称性。 A→B が正常でも、B→A が正常とは限りません。ノードへの外部プローブで確認できるのは、そのノードの戻り/受信方向だけです。ネットワーク経路と輻輳には方向性があります。ホップが正常だと判断する前に、ユーザーのトラフィックと同じ方向を、ユーザー側から測定してください(影響を受けている送信元から mtr/nexttrace を TCP モードで実行)。

  10. 上流クライアントの中断に見せかけるエッジタイムアウト。 Cloudflare からの 524 により、オリジンプロキシ(Caddy/nginx)が上流接続を「クライアントによる中断」としてログに記録することがあります(status=0、Client request error: aborted)。オリジンでは実際に中断が発生していますが、その 原因 は CDN エッジが先にタイムアウトしたことです。中断をクライアントのせいにする前に、必ずエッジのエラーコード、エッジのタイムスタンプ、オリジンのログを照合してください。ステップ 0.6 のアップロードと処理に関するレシピを参照してください。

  11. リスト最上位のプロキシルールが CNAME の照合より優先されるという思い込み。 CNAME を解決するプロキシクライアントは、元のホスト名だけでなく、解決後の CNAME チェーンにもルールを適用する場合があります。DOMAIN-SUFFIX,<cname-suffix>,DIRECT ルールが、明示的な DOMAIN,<target>,PROXY ルールを上書きすることがあります。設定を調べ、プロキシ経由でホスト名と IP の経路をテストして確認してください。

  12. プロキシノードの DNS = クライアントの DNS。 プロキシノードは、クライアントとは異なる方法でホスト名を解決する場合があります。クライアント側の DoH クエリでは通信可能な IP が返っても、プロキシノードではブロックされた IP やルーティング不能な IP が返ることがあります。curl -x proxy -H 'Host: host' -I https://<working-ip> を使って、DNS と到達可能性を切り分けてください。

  13. フィンガープリント ≠ 識別情報。 サービスバナー、ポートのシグネチャ、MAC OUI は、偽装されやすい手がかりであり、デバイスの正体を証明するものではありません。ポート 5000 で応答し、Server: AirTunes/… ヘッダーを返し、_raop mDNS ブロードキャストを行わない機器は、自作の Linux AirPlay レシーバー(shairport-sync)の「ように見えました」。しかし、実際は macOS の AirPlay レシーバーでした(ControlCenter は 5000/7000 でリッスンします)。現代的な OpenSSH_10.x バナーが決め手でした。同じインシデントで、Realtek OUI の MAC は「NAS と同じベンダーファミリー」を示唆しましたが、実際には Mac に接続された USB Ethernet ドングルのものでした。デバイスの正体を結論づける前に、自己識別の証拠を確認してください。SSH ホストキーと ~/.ssh/known_hosts の照合(決定的な証拠 — 1つのホストに1つのキー)、mDNS ホスト名の解決、AirPlay /info plist(自己申告の名前/モデル/osBuildVersion)を確認します。バナーや OUI は反証すべき仮説として扱い、結論として扱わないでください。

  14. あるセグメントで到達不能 = 停止ではない。 プローブで確認できるのは、それを実行した L2 ドメインだけです。ルーターを交換した後、古いルーターは Ethernet では ARP に応答せず、「停止」しているように見えました。しかし、Wi-Fi AP はブロードキャストを続け、DHCP も提供していたため、保存済みの認証情報を持つデバイスが WAN に接続できないネットワークへ気づかずに参加していました。対象自身の視点(たとえばデバイス上で ipconfig getifaddr en1 を実行し、別のインターフェースを確認する)から不在を確かめてから、消失したと判断してください。また、トポロジーを変更した後は、退役した機器の電源を物理的に切ってください。DHCP を提供し続ける「停止した」ルーターは、デバイスを集め続ける罠になります。

  15. トポロジーの変更で手動 IP デバイスが取り残される。 DHCP クライアントは新しいネットワークに自動で追従しますが、手動/静的 IP のデバイスは古いゲートウェイと DNS を保持し、外部から到達できず、外部へも到達できない孤立状態になります。macOS では「DHCP ルーター構成を手動で使用」(手動 IP + 以前の DHCP から取得したルーター)によって、さらに見つけにくくなります。アドレスは意図的に設定されたように見えても、ゲートウェイは古いままです。ルーターまたはサブネットを変更した後は、移行完了と判断する前に、古いサブネット上の ARP エントリ、古いサブネットのアドレスに解決される mDNS 名、および既知のすべての静的 IP 機器(サーバー、NAS、プリンター)について、ゲートウェイと DNS を再確認してください。

  16. パスの「種類」フィールドをパスの「容量」フィールドとして読み取る。 Mesh-VPN とプロキシのステータス行が示すのは、ピアへの到達方法、つまり direct か relay か、およびエンドポイントアドレスです。これは「ホールパンチできているか」には答えますが、「どれくらい速いか」には決して答えません。この2つは大きく食い違うことがあります。この落とし穴につながったインシデントでは、ステータス行は 0.09~0.11 MB/s の状態でも 11~16 MB/s の状態でも、どちらも active; direct <endpoint> と表示されていました。同じフィールド、同じ単語なのに、速度は2桁違っていました。遅いほうは WAN 経由のダイレクトパスで、速いほうは LAN 経由のダイレクトパスだったためです。RTT も予測には役立ちませんでした。遅い状態のほうが 12 ms と低く見え、速いほうは 4 ms でした。どちらも何かを実際に測定した値ですが、どちらも容量を測定していません。速度を測定し(ステップ 0.7)、ステータスフィールドはトポロジーに関する参考情報として扱ってください。

  17. 自分のプローブが引き起こしたことを、接続先ホストのせいにする。 「サーバーが遅い」と結論づける前に、送信したリクエストが意図したとおりのものか確認してください。不正な識別子は、有効な識別子よりも遅い経路をたどることがよくあります。検索に失敗すると全件スキャンが実行され、その後エラーが返されるためです。その結果、壊れたプローブが「相手側で問題が起きている」という、まさに探している兆候を作り出します。実例では、Windows 形式のオブジェクトキー(…\archive\…)を、\a を BEL 文字として処理するシェル経由で埋め込んでいました。破損したキーはすべてのインデックスで見つからず、サービスは約 21 秒間スキャンした後、17 バイトの 404 を返しました。「何も返さないのに 21 秒もかかった。ホストに負荷がかかっている」と解釈され、調査全体の方向が変わりかけました。見分ける手がかりはペイロードのサイズです。17 バイトを返すのに 21 秒かかるのは、帯域幅の問題ではありません。サーバー側のスキャンであり、存在しないものを探すスキャンなら、たいてい問題は相手のディスクではなく自分のキーにあります。シェルを経由しない方法(引数ベクトルを使うスクリプトなど)でプローブを作り直し、何かに原因を帰する前に再測定してください。

  18. 容量の想定を誤ると、成果物が気づかないうちに破損し、成功チェックも通過する。 スループットの低下は、単に「遅い」状態にとどまりません。正常な経路を基準に設定したすべてのタイムアウトが無効になり、自分で設定したタイムアウトによって中断された転送は、エラーではなく途中で切れたファイルを残します。それに気づけるかどうかは、成功チェックが何を測定するかに完全に左右されます。もっともらしいサイズの下限を設定しても防げません。この落とし穴につながったインシデントでは、取得ループは HTTP 200 && bytes > 1000 を確認していました。実際の整合性チェックに思える条件ですが、ダウンロード 35 件すべてが成功と判定され、そのうち 14 件は途中で切れていました。正常な速度なら十分だった 180 秒の上限も、低下した速度では達成不可能でした。中断された転送にも 200 が付いていました。ステータス行は本文の転送が止まるよりずっと早く届くからです。また、1000 バイトも超えていました。数メガバイトのファイルが途中で切れていても、設定しようと思いつくどんなサイズのしきい値と比べても巨大だからです。バイト数の下限だけでは「完全」と「大部分」を見分けられません。それはサイズではなく、ファイル形式の性質です。確実な対策は2つあります。レスポンスのステータスだけでなく、転送の終了ステータスを確認すること(HTTP 200 が示すのはレスポンスの開始であり、終了を示すのは終了コードだけです)、そして成果物をそれ自身の形式に照らして検証することです。終端記号(%%EOF、IEND、終了フレームなど)を持つコンテナーなら、完全性を自ら証明できます。このチェックは、後から影響範囲を監査する方法でもあります。終端記号を持つファイルとパーサーが受け入れたファイルは、どちらも正確に 21 件で一致しており、「一部のダウンロードが不完全かもしれない」という状態を、正確な一覧に変えられました。

    JSON の解析も、転送が完全かどうかを確認する方法にはなりません。 Python の上限付き HTTP 読み取りでは、宣言された本文がまだ不完全でも有効な JSON が返されることがあります。解析に成功したことを受信の成功として扱う前に、上限付きレスポンスのチェックを読んでください。サイズ制限を維持しながら、完全な長さ指定型、チャンク型、接続終了区切り型のレスポンスを扱えます。

  19. ワイルドカードリスナーが、すべてのインターフェースで到達可能であるとは限りません。 lsof -nP -iTCP -sTCP:LISTEN は、ワイルドカードアドレスにバインドされたソケットに対して *:59807 と表示します。これは BSD ソケット層に関する真実の記述です。しかし「このポートはインターネットから到達可能か」という問いへの答えではありません。macOS では、Network.framework 上に構築されたサービスはワイルドカードにバインドしたうえで、インターフェースごとに 1 つの受信ボックスを開きます。そのため、同じ * が、物理 NIC 上に存在するサービスと、Apple のピアツーピア無線でのみ応答するサービスを覆い隠してしまいます。この罠の背景にあるインシデントでは、パブリック IPv6 アドレスを取得したばかりのマシンに対する露出監査が、*:port だけを根拠に 4 つのポートをインターネット公開と断定しました。統合ログはそのうち 3 つについて異を唱えました。rapportd のリスナーレコードにあるすべての interface: フィールドは awdl0 を指しており、en0 を指すものはありませんでした。AWDL は Apple のピアツーピア無線であり、fe80:: リンクローカルアドレスのみを運び、相手デバイスが電波範囲内にある必要があります。したがって、それらのポートはリンク外のどこからも到達可能ではありませんでした。同じマシン、同じログの中で、sharingd は en0 と 5 つのトンネルにまたがって展開しており、これこそが真にワイルドカードリスナーである姿です。同じ監査は、実際にすべてのインターフェース上にあった 2 つのリスナーも見逃していました。見慣れたプロセス名を目にしたところで読むのをやめてしまったからです。対策: フレームワークが自身について書き出すログからインターフェースを読み取り(レシピと log show の 2 つの落とし穴は references/instrumentation-patterns.md にあります)、次にアドレスクラスでクロスチェックします。これはログの内容とは独立しています。グローバルにルーティング可能なアドレスは実ネットワークインターフェースにのみ現れるので、そこで応答しているのを観測されたサービスは真に露出しており、fe80:: でのみ見られるサービスはリンクを出たことがありません。lsof はソケットの目録であり、到達可能性の判定ではありません。これは原則 6 の前提条件です。ユーザーではなく計測器に手を伸ばすことが改善となるのは、その計測器がどの層に答えるのかを把握している間だけです。

  20. ネットワーク変更を最新のクライアントと最も近い物理的証拠で検証すること。 どちらも間違った母集団です。「SSID + パスワードのクローン」による AP 交換は、数時間以内に成功したように見えます。新しいクライアント — スマートフォン、新しいスピーカー — がシームレスにローミングするからであり、それらはまさにリスクにさらされていなかったデバイスです。失敗する母集団は、新しい AP のデフォルト(WPA2/WPA3 トランジション、ax 専用の 2.4 GHz)を解析できないレガシー IoT シリコンであり、それは遅延を伴って失敗します — 1 回の DHCP 更新や deauth が数日から数週間にわたって積み重なる形で — そのため、犠牲者が表面化するずっと前に交換は完了と宣言されてしまいます。同じ視点の誤りは物理的にも現れます。「そこにあって、電源が入っている」ハブは、電気についてしか証言していません。ハブ自体が Wi-Fi クライアントであり、そのメンバーシップはネットワーク側からのみ判定可能です(ARP テーブル内の OUI、mDNS レコード)。対策: AP 交換時には 4 つの事項を揃えます — SSID、パスワード、セキュリティモード、バンド/PHY 互換性 — そして検証は最新のクライアントではなく最も古いクライアントで行います。デバイスの一群がまとめて死んだときは、どのデバイスに触れる前にもハブの関連付けを確認してください。ハードウェアはハブの形をしたクラスターで故障したりはしないのです。完全なケースと調査レシピは references/case-iot-router-migration-wpa3.md にあります。

references/cognitive-traps.md には、罠 1〜12 の詳細な解説(レスキューサイクルの警告、フィールドセマンティクスの例)が掲載されています。罠 13〜20 はそこではなく、それぞれの出所で文書化されています。13〜15 は LAN/トポロジーの資料に、16〜18 は references/case-throughput-collapse-no-errors.md に、19 は上記インラインに(コマンドレシピは references/instrumentation-patterns.md に)、20 も上記インラインに(ケースは references/case-iot-router-migration-wpa3.md に)あります。

クライアント側のプロキシ/VPN/TUN による誤ルーティング

症状がクライアント固有の場合(ある端末のブラウザーでは失敗するが、ほかの端末やネットワークでは動作する、またはプロキシ/VPN をオフにすると失敗しなくなる場合)、プロキシクライアント自体がネットワーク上の中継点です。そのように扱ってください。

原因を切り分けるための簡易チェックリスト:

  1. DNS:OS はどの IP アドレスに名前解決していますか?偽の TUN IP アドレス(例:198.18.x.x)であれば、プロキシクライアントが DNS を傍受しています。
  2. ルート:route -n get <ip> で、パケットがどのインターフェースから送信されるかを確認できます。TUN モードで偽の IP アドレスが utun5 経由になるのは正常です。物理インターフェースから到達できないのに、実 IP アドレスが TUN 経由でしかルーティングされない場合は、ローカルから直接接続できていません。
  3. プロキシポート:ローカルプロキシは待ち受けていますか?lsof -P -i TCP:<port> で確認できます。プロキシありとなしの両方でテストしてください。
  4. プロキシ経由のホスト名と IP アドレス:
    • curl -x http://127.0.0.1:<port> -I https://<host>
    • 自分でホスト名を解決(DoH)してから、curl -x http://127.0.0.1:<port> -k -H 'Host: <host>' -I https://<ip> を実行します。 2つ目は成功し、1つ目は失敗する場合、プロキシノードの DNS が、クライアントの DoH クエリとは異なる不正な IP アドレスを返しています。
  5. 物理インターフェースからの到達性:実 IP アドレスを一時的に en0(または現在アクティブな物理インターフェース)から送信するよう強制します。TUN 経由では動作するのに失敗する場合、ローカルネットワークから接続先に到達できず、プロキシ/TUN が必要です。
  6. ルールと CNAME の相互作用:接続先の CNAME のサフィックスに一致するルールがプロキシ設定にないか確認します。クライアントが解決済みの CNAME に対してルールを評価する場合、DOMAIN-SUFFIX,<cname-suffix>,DIRECT ルールが明示的な DOMAIN,<host>,PROXY ルールを上書きすることがあります。

以上のすべてから、プロキシクライアントが不正な CNAME を名前解決している、または不正なプロキシノード DNS に依存していると考えられる場合は、references/case-proxy-tun-cname-override.md の修正パターンを参照してください。

アンチパターン — 明確に避けるべきこと

  • 反証を見つける前に修正に飛びつく。「おそらく X です。再起動/調整/アップグレードしてみましょう」。このような対応は、学びの機会を再発防止につながらない原因不明の修正に変えてしまいます。
  • **エージェントによる反対レビューの指摘をすべて受け入れる。**エージェントはリスクに関する指摘を過剰に挙げる傾向があります。対応する前に取捨選択してください(上記の4つの質問によるフィルターを参照)。
  • **IaC を迂回した場当たり的な本番環境の変更。**調査のために本番環境の変更が必要な場合は、まず信頼できる唯一の情報源を変更してから適用してください。そうしないと、「修正」は次のデプロイで消え、ドリフトによって実際の状態が見えなくなります。
  • **単一の観測結果から根本原因を断定する。**まず反証を試みてください。
  • **失敗した実験を再実行せずに「これで動くはずです」と書く。**再検証してください。

ケーススタディ

以下の代表的なケースは、さまざまな障害モードにおける手法を示しています。

  1. references/case-sse-rst-130s.md — アシスタントが何度も誤った結論に飛びついた、5 時間にわたる調査です。正しい答えは、126 秒で発生する Cloudflare エッジの HTTP/2 ストリームのアイドルタイムアウトが、 が の tool_use 生成中に SSE ping を送信しないことで増幅されていた、というものでした。サブエージェントが、アイドル状態のアップストリームを模擬する 3 経路の段階的な切り分け実験を設計すると、わずか 10 分で明らかになりました。

  2. references/case-cloudflare-524-upload.md — <api-domain>/<openrouter-path> で発生した Cloudflare 524 のケースです。約 6 MB の POST ボディを、米国のクライアントから のオリジンへアップロードするのにかかった時間が、Cloudflare のデフォルトのオリジン読み取りタイムアウトを超えていました。重要な手がかりは、bytes_read(4.1 MB)と Content-Length(6.0 MB)を比較し、リクエストが <upstream-capture-service> にも <new-api-container> にも到達していないことを確認した点です。このケースは、アップロードと処理を区別する手順、および上記の「エッジタイムアウトがクライアントの中断に見せかける」落とし穴の元になっています。

  3. references/case-proxy-tun-cname-override.md — クライアント側の <proxy-client> TUN に関するケースです。設定の先頭に明示的な PROXY ルールがあったにもかかわらず、<auth-domain> で ERR_CONNECTION_CLOSED が発生しました。根本原因は、DOMAIN-SUFFIX,<cname-suffix>,DIRECT ルールがターゲットの CNAME チェーンに一致していたことに加え、プロキシノード自身の DNS が、クライアントの DoH クエリとは異なる IP を返していたことでした。解決パターンでは、[Host] マッピングと use-local-host-item-for-proxy を使用します。

  4. references/case-throughput-collapse-no-errors.md — ここで唯一、エラー文字列がまったくないケースです。HTTP 200 が続き、RTT は 12 ms、ヘルスエンドポイントの応答は 0.04 s で、回線速度は 0.09〜0.11 MB/s でした。「検証パスが遅い」→「ホストのアップリンクは約 1 Mbps に違いない」→「原因はリレーだ」と、3 時間にわたって推測しました。どれももっともらしいものの、検証されていませんでした。同じ経路を共有する 2 つの通信(HTTP サービスと通常の SSH)にはアプリケーションコードがなく、約 20% 以内の差で一致し、1 つのコマンドで決着しました。原因はサービスではなく経路でした。これはステップ 0.7 と落とし穴 16〜18 の元になっています。誤った速度から導き出した 180 s のタイムアウトによって、200 && bytes > 1000 のチェックにはすべて合格していた 35 件中 14 件のダウンロードが途中で打ち切られた経緯も含まれます。

  5. references/case-iot-router-migration-wpa3.md — ここで唯一、クライアント側の接続確立を扱うケースです。「シームレスな」SSID 複製によるルーター交換から 3 週間後、ハブを中心とする IoT デバイスのグループがオフラインになりました。実はハブ自体がネットワークに接続されていませんでした。反証の連鎖は、1 台のノート PC だけで完結します(mDNS の調査で AP の分離を反証し、ARP と OUI の調査でハブの OUI が存在しないことを示し、ビーコンパラメーターから、以前の AP が WPA2 のみだったのに対し、現在は WPA2/WPA3 移行モードになっていることを確認します)。決定的な測定は介入によるもので、セキュリティを WPA2 のみに戻すと、数分以内に 12 台のデバイスが復旧しました。これは、ルーター交換時の調整チェックリスト、受動的な IoT 調査プローブ、および落とし穴 20 の元になっています。

このスキルを未知の問題領域に適用する前に、これらを読んでください。誤った方向へ進む過程こそが、学ぶべき点です。

参考ファイル

スクリプト

  • scripts/mock-idle-upstream.py — 1つのフレームを送信してから N 秒間アイドル状態になる SSE サーバーです。アイドル間隔を正確に制御するため、レイヤー別の切り分け実験でアップストリームとして使用します。
  • scripts/layered-isolation-probe.sh — 3経路の A/B/C 比較を実行し、診断マトリクスを表示します。

コメント