TL;DR
- 2026-09-10、筆者の公開先でフレームワークのバージョンを上げた結果、トップページ
/だけが 500 になり、記事ページ/articles/<slug>と一覧/articlesは 200 のままでした(社内データ)。動的に組み立てられる経路だけが落ち、事前生成されて配信される経路は生き残る形の故障です。 - リリースした側と、公開を検証した側の2人が、独立に同じ盲点を踏みました。 どちらも「記事ページが 200 だから大丈夫」と判断し、
/を開いていません。観測は 2 件なので原因は断定しませんが、個人の注意力に帰着させる前に、手順の設計を疑う価値はあります。 - ただし、対象リストは既にありました。
/を含む自動の外形検査が 5 か月前から置かれていて、その対象一覧はこの記事が説くルートクラス表とほぼ同型でした。足りなかったのは対象ではなく発火経路で、デプロイイベントが作られず不発、定期実行は止めてあり、残るは手動起動だけという配線でした(当時の状態。社内データ)。対象を正しく選んでも、自動で回らなければ同じ事故になります。 - 復旧後、筆者が Playwright で公開先を測り直したところ、
/と/articles/<slug>は応答ヘッダの時点で別グループでした。/はx-nextjs-cache: HIT/x-nextjs-prerender: 1,1を返し、記事ページはx-nextjs-prerender: 1だけを返します(2026-09-10 実測、社内データ)。経路が分かれていることは、外から観測できます。 - 執筆中、筆者自身が同じ間違いをしました。
/sitemap.xmlが 200・<loc>7 件で記事 URL を含まないのを見て「中身が空だ」と書きかけましたが、この公開先は sitemap を分割しており、記事 URL は/sitemaps/articles/sitemap.xmlに 223 件ありました(同日実測、社内データ)。代表 1 枚の選び方を、筆者も間違えています。 - 404 の本文は、存在しない slug でも、あるはずなのに未公開の slug でも完全に一致しました(SHA-256 の先頭 16 桁と文字数がともに一致、57,783 文字)。「URL を間違えた」と「公開が届いていない」を応答から区別できません。
- 持ち帰れる手順は 1 つです。先に故障モードを列挙し、そこからルートクラスを割り、クラスごとに1枚ずつ、ステータスと本文の不変量の両方を確かめる。 第 7 節にそのまま動くスクリプトを置き、使い捨てディレクトリで異常系 30 系統を実行した結果を表にしました。そのうち 9 系統は、独立レビューに「試していない形がある」と指摘されて初めて足したものです。
- 同じ構成で、これは 2 回目でした。 約 5 か月前にも別のバージョン(16.2.3)で「トップだけが 500」が起きて記録されています。同一構成・2 事象・3 バージョン(16.2.3 / 16.3.0 / 16.3.3)で同じ形が出ました。ただし支えているのはこの 1 構成の中だけで、別の構成へは一般化できません。持ち帰ってほしいのは数字ではなく、逆算という順序のほうです。
はじめに:確認漏れを「気をつける」で終わらせない
本番が落ちたあとの振り返りは、たいてい「確認が漏れていた」で終わります。そして次の再発防止策は「確認を丁寧にする」になります。これは、次に落ちる場所が前回と同じでない限り効きません。
この記事は一般論ではなく、2026-09-10 に筆者の公開先で実際に起きたことの記録です。落ちたのはトップページだけで、記事ページは無事でした。そして重要なのは、その形が「確認する側が何を見るか」をあらかじめ誘導していたことです。
扱うのは、フレームワークのバグの解説ではありません。確認手順という設計物を、故障モードの形からどう逆算するかです。
そして先に書いておきます。この事故で足りなかったのは、対象リストだけではありませんでした。 対象を正しく選んでも、その手順が自動で回らなければ結果は同じです。ガードが「置かれているのに効いていない」問題は 効いていないガードを見つける7つの型 で扱いました。本記事はその別の半分で、2 つは同じ事故の両側です。第 3 節と第 6 節のステップ 5 で接続します。

1. 測定条件と基準時点
Web の応答は時間で変わります。いつ・何を・どう測ったかを先に固定します。
| 項目 | 値 |
|---|---|
| 対象 | 筆者の公開先サイト https://the3396.com(Next.js + Cloudflare。リポジトリは非公開) |
| 測定日 | 2026-09-10 |
| 測定手段 | Playwright(Chromium)でトップページを開き、同一オリジンのページ内から fetch() を発行して status / 応答ヘッダ / 本文を取得 |
なぜ curl を使わなかったか | この公開先は Cloudflare の Managed Challenge が有効で、curl からの素のリクエストは 403 を返します。ブラウザで一度チャレンジを通した文脈から測る必要がありました |
| 障害中の値の出どころ | 筆者と公開先を検証したセッションが障害中に実測した値。復旧済みのため再現はできません |
| 公開範囲 | 挙動の実測値(バージョンと HTTP ステータスの対応、故障の形)のみ。リリース手順・内部識別子は書きません |
社内データ と付した値は、すべてこの条件で得たものです。公開先は記事が増えれば応答も変わるので、同じ URL を後日測っても同じ文字数にはなりません。 数え方のほうを持ち帰ってください。
なお curl が 403 になること自体が、この記事の主題と地続きです。「500 でなければ合格」という確認基準は、403 のチャレンジ画面も合格にします。 確認できなかったことと、確認して問題がなかったことは別の結果です。
2. 何が落ちて、何が落ちなかったか
障害中に測れた値は次のとおりです(すべて 社内データ。フレームワークのバージョンを変えて、トップページの応答だけを見ています)。
| バージョン | / の応答 |
|---|---|
| next 16.3.0 | 500 |
| next 16.3.3 | 500 |
| next 16.1.7 | 200 |
そして / が 500 だったその瞬間、同じ本番で次が成立していました(社内データ)。
| パス | 応答 |
|---|---|
/ | 500 |
/articles(一覧) | 200(223 記事が並んでいた) |
/articles/<slug>(記事ページ) | 200 |
サイトの本体である記事は、1 本も落ちていません。サイトの入口だけが落ちていました。
この形には理由があります。記事ページはビルド時に事前生成されて配信されるのに対し、トップページはリクエストのたびに組み立てられる経路にありました。Next.js は同じアプリの中でルートセグメントごとにレンダリング戦略を切り替えられる設計で(Route Segment Config — Next.js Docs、2026-09-10 確認)、どのルートがどちらに落ちるかは、ソースを読まないと分かりません。
したがって「代表的なページを1枚開いて確認する」という手順は、この構成ではどちらのグループを引くかで結果が変わる賭けになります。手順が悪いのではなく、手順の粒度が構成の粒度と合っていないのです。
3. 2人が独立に同じ盲点を踏んだ
ここが、この記録でいちばん書きたかった部分です。
このリリースには、変更を出した側と、公開先の状態を検証した側の 2 者がいました。役割は分かれており、互いの確認手順を相談してもいません。それでも2 人とも、記事ページを開いて 200 を確認し、/ を開きませんでした。
独立した 2 人が同じ選択をしたなら、その選択は偶然ではありません。次の条件が揃うと、記事ページを見るのが自然な確認になります。
- このサイトの成果物は記事である。「ちゃんと出ているか」を確かめたければ、確かめたい対象は記事ページになる
- 直前の作業が記事の追加・更新であれば、確認したいのは追加した記事のほうになる
- 記事ページは URL が具体的で、内容を目で見て正しさを判定できる。トップページは「一覧が並んでいる」以上の判定基準を持ちにくい
- そして記事ページは事前生成されていて、めったに落ちない。落ちない場所は、確認しても情報量が少ないぶん、確認した気持ちだけが残る
つまり、故障モードの形(動的経路だけが落ちる)と、確認の動機の形(成果物である記事を見たい)が、正確にすれ違っていました。 この 2 つの形が固定されているなら同じ穴が再び踏まれても不思議はない、というのが筆者の見立てです(観測は 2 件で、再現性を確かめたわけではありません)。
ただし、対象リストは既にあった
ここまでを「見る対象の選び方の問題」だけで終わらせると、事実と食い違います。この公開先には、/ を含む自動の外形検査が、5 か月前の同型障害の再発防止として既に置かれていました。 検査対象は、この記事が第 6 節で「逆算して作れ」と説くものとほぼ同じ構成でした——トップ、一覧、タグ、機械可読フィード、記事詳細、そして「存在しない slug」「未公開の slug」「削除済みの slug」を分けた 404 の確認まで。対象の選定はできていたのです。
足りなかったのは発火経路でした。その検査は、当時こういう状態にありました(社内データ)。
| トリガ | 当時の状態 |
|---|---|
| デプロイ完了イベント | 配信基盤がそのイベントを作らないため発火しない(導入直後に未稼働と判明していた) |
| 定期実行 | 経路上の防御が素の HTTP クライアントを 403 にするため止めてあった |
| 手動実行 | これだけが生きていた |
この表は障害当時の状態です。現在どうなっているかは本記事の範囲外で、書きません。持ち帰ってほしいのは「対象は揃っていたのに起動条件で止まっていた」という形のほうです。
つまり / を見る検査は 2026-04 から存在していて、人が手で起動しない限り一度も回らない配線でした。 第 3 節の冒頭で書いた「2 人とも / を見なかった」は、正確には人間の目視 2 件の話です。自動検査のほうは / を見る設計になっていました——起動していれば。
ここからは筆者の意見です。「確認漏れ」を人の属性として記録すると、この構造は次も残ります。記録すべきは「誰が見落としたか」ではなく、その手順で見える範囲がどこまでだったかと、その手順が自分で動くのかだと考えています。
この記事の手順(故障モード列挙 → クラス分け → 1 枚ずつ)を丁寧にやっても、発火しない配線に載せた時点で同じ事故が起きます。 だから第 6 節には、対象選びの 4 ステップのあとにステップ 5 を足してあります。
4. 復旧後に自分で測り直した
障害中の値は再現できません。そこで復旧後の状態を筆者が測り直し、「落ちた経路と落ちなかった経路が、外から区別できるか」を確かめました。2026-09-10、Playwright 経由の実測です(社内データ)。
| パス | status | x-nextjs-cache | x-nextjs-prerender | 本文の文字数 |
|---|---|---|---|---|
/ | 200 | HIT | 1,1 | 146,242 |
/articles | 200 | (無し) | 1 | 1,314,293 |
/articles/guard-not-firing-patterns | 200 | (無し) | 1 | 409,818 |
/articles/<存在しない slug> | 404 | HIT | 1,1 | 57,783 |
/tags | 200 | (無し) | 1 | 187,080 |
/sitemap.xml | 200 | (無し) | (無し) | 1,186 |
/rss.xml | 200 | (無し) | (無し) | 20,370 |
/feed.xml | 404 | HIT | 1,1 | 57,783 |
最終列はバイト数ではなく文字数(JavaScript の String.length = UTF-16 コードユニット数)です。日本語のページではバイト数のほうが 1〜2 割大きくなります。この列は「同じか違うか」を見るためのもので、転送量の指標には使えません。(第 7 節のスクリプトのほうは Buffer.byteLength() でバイト数を出しています。同じ記事に 2 つの数え方があるので、混ぜないでください。)
cf-cache-status は全パスで DYNAMIC でした。注目したいのは 2 列目・3 列目です。
/ と 404 だけが x-nextjs-cache: HIT と x-nextjs-prerender: 1,1 を返し、記事ページ・一覧・タグは x-nextjs-prerender: 1 だけを返します。 応答ヘッダのこの差は、ルートが同じ扱いを受けていないことを外から示しています。ヘッダの意味を仕様として断定はしません(x-nextjs-* は公開仕様ではありません)。しかし**「値が違うルートは同じ確認で代表できない」と判断するには十分**です。
つまり、確認対象を選ぶときに使える材料は、ソースを読まなくても手元にあります。全ルートを一度舐めて、応答ヘッダの組み合わせでグループ分けする。
ただし、これで得られるのは分割の下限であって、クラスの一覧そのものではありません。上の表をヘッダだけでグループ化すると 3 つにしかならず、しかも / と 404 が同じグループに落ちます。そのまま「グループごとに 1 枚」を選ぶと、今回落ちた / を引かない可能性が残ります。 クラスの一覧は次節の故障モード表から作り、ヘッダはその分割が足りているかを外から検算する材料として使ってください。ヘッダが違うのに同じクラスに入れているものがあれば、そこは割り直しです。
5. 200 が保証しないもの
同じ測定で、ステータスだけを見る確認では通ってしまう事象が出ました。いずれも 2026-09-10 の実測です(社内データ)。最初の 1 つは、筆者自身が代表 1 枚を選び間違えた記録です。
5-1. 筆者も「代表 1 枚」を選び間違えた
/sitemap.xml は 200・1,186 バイトで返りました。中身の <loc> は 7 件で、内訳は /・/articles・/about・/contact・/privacy・/editorial-policy・/ai-policy。記事ページの URL は 1 件も含まれていません。同じ日、一覧ページには 223 本の記事リンクがあります。
ここで筆者は「sitemap が記事を落としている」と書きかけました。間違いでした。 /robots.txt を読むと、この公開先は sitemap を 6 本に分割して宣言しています。実際に測ると次のとおりでした。
| パス | status | <loc> 件数 |
|---|---|---|
/sitemaps.xml(インデックス) | 200 | 6(子 sitemap への参照) |
/sitemap.xml | 200 | 7(静的ページのみ) |
/sitemaps/articles/sitemap.xml | 200 | 223 |
/sitemaps/tags/sitemap.xml | 200 | 84 |
/sitemaps/article-images/sitemap.xml | 200 | 223 |
/sitemaps/article-videos/sitemap.xml | 200 | 223 |
記事 URL は正しく出ていました。/sitemap.xml の 7 件は、静的ページだけを収録するという分割の設計から必ずそうなる値であって、欠落ではありません。sitemap をインデックス形式で分割するのは仕様上の正規の使い方です(Google 検索セントラル: サイトマップ、2026-09-10 確認)。
この記事の主題そのものが、ここで自分に跳ね返ってきました。machine-feed クラスの代表を /sitemap.xml 1 枚に決めたのは、「sitemap といえばこの URL」という思い込みで、実際の入口宣言(/robots.txt)を読んでいなかったからです。第 6 節のステップ 2 で「クラスの一覧は故障モード表から作り、ヘッダは検算に使う」と書き、machine-feed の代表を記事 URL を実際に載せている sitemap に変えたのは、この失敗を踏まえた結果です。
なお /rss.xml の <item> は 50 件でしたが、これはフィード側の件数上限による値で、記事総数の指標にはなりません。上限で決まる数字を「測定結果」として並べてはいけません。
5-2. 404 の本文が全パターンで同一
次の 2 つを比べました。
/articles/zzz-nonexistent-9999(存在しない slug)/articles/framework-patch-pin-compensating-controls(記事リポジトリには存在するが、公開先にまだ届いていない slug)
両者の本文は一致しました(SHA-256 の先頭 16 桁が c91e4f4fbceaffda で一致、文字数もともに 57,783)。応答からは「URL のタイプミス」と「公開が届いていない」を区別できません。404 を返すこと自体は仕様どおりです(MDN: 404 Not Found、2026-09-10 確認)。問題は、確認手順の側がその 2 つを別の事象として扱えないことです。
前節の表がこの事象を数で裏づけています。記事 sitemap の <loc> は 223 件で、記事リポジトリ側の記事数(224 本)と 1 本ずれていました。ずれの中身は、まさにこの未到達の 1 本です。
5-3. /feed.xml は無く /rss.xml はある
/feed.xml は 404、/rss.xml は 200 でした。外部サービスに登録した URL が前者であれば、サイトはずっと健全なまま、配信だけが止まります。 これも 200/500 の監視には映りません。
3 つに共通するのは、壊れているのがステータスではなく中身だということです。だから確認項目には、ステータスと並べて本文の不変量が要ります。そして 5-1 が示すとおり、その不変量を置く先(どの URL を代表にするか)を間違えると、今度は健全なものを異常と読みます。
6. 逆算の手順
ここまでの観測を、そのまま持ち帰れる手順に落とします。順序が本体です。「監視項目を増やす」ではなく、「故障モードを先に書く」から始めます。
ステップ 1:落ち方を先に列挙する
確認したいページを挙げるのではなく、壊れ方を挙げます。筆者の構成で書き出したものは次のとおりです(この列挙自体が構成依存で、あなたの構成では別になります)。
| # | 故障モード | この形なら何が落ちるか |
|---|---|---|
| F1 | 依存の更新で、リクエスト時レンダリングだけが例外を投げる | 動的ルートのみ 500。事前生成ルートは 200 |
| F2 | ビルドは通るが、生成対象の一覧を作る処理が空を返す | ページは 200 のまま、一覧・sitemap の件数だけ減る |
| F3 | 公開が本番へ届かない | 新しい slug が 404。既存は全部 200 |
| F4 | 経路上の防御が反応する | 全パスが 403 / 429。アプリは無傷 |
| F5 | 配信 URL の変更・改名 | 特定の 1 URL だけ 404。他は全部 200 |
ステップ 2:故障モードをルートクラスへ写す
各故障モードが同じ結果を出すルートの集合を 1 クラスにします。クラスの一覧はこの表から作り、第 4 節の応答ヘッダは「分割が足りているか」の検算に使います(ヘッダが違うのに同じクラスなら割り直し)。ヘッダだけからクラスを導こうとすると、今回の構成では / と 404 が同じグループに落ちて足りません。
筆者の構成では、dynamic-root(/)/ index-page(/articles)/ detail-page(/articles/<slug>)/ not-found(存在しない slug)/ machine-feed(記事 sitemap・/rss.xml)の 5 クラスになりました。machine-feed の代表を /sitemap.xml ではなく記事 URL を実際に載せている sitemap にしたのは、第 5-1 節の失敗を反映した結果です。代表は /robots.txt の Sitemap: 宣言から選びます。
ステップ 3:クラスごとに1枚だけ選ぶ
全ページを見る必要はありません。 クラス分けが妥当なら、同じクラスの 2 枚目が新しく教えてくれることはほとんどありません(逆に 2 枚目で結果が割れたら、誤っているのはクラス分けのほうです)。一方、クラスを 1 つ落とすと、その故障モードは永久に見えません。 今回落とされていたクラスは dynamic-root でした。
ステップ 4:ステータスと本文の不変量を両方書く
各クラスに「期待するステータス」と「本文に必ず含まれる文字列」を書きます。第 5 節の 3 例は、後者が無いと全部素通りします。加えて、測定できなかったことを合格にしない出口を用意します。
- 期待どおり → 合格
- 期待と違う → 不合格
- そもそも観測できなかった(403 / タイムアウト / 接続不能)→ 合格でも不合格でもない第三の結果にする
3 つ目を独立させるのは、Google の SRE 本が挙げる古典的な失敗(Site Reliability Engineering, Ch.6 Monitoring Distributed Systems、2026-09-10 確認)と同じ理由です。測定系の沈黙を「異常なし」と読むと、監視は静かに機能を失います。
ステップ 5:その手順が自動で発火することを、壊した入力で1回確かめる
ここまでの 4 ステップは、対象の選び方しか決めていません。 第 3 節で見たとおり、正しいクラス表を持った検査が、起動条件のせいで一度も回らないことがあります。存在することと、回ることは別です。
確かめ方は 1 つだけです。わざと壊した状態を 1 回通して、その手順が自分で起動し、非 0 で落ちることを見る。
- 起動条件は、実際に起きるイベントに紐づいているか(配信基盤がそのイベントを出さないなら、その配線は永久に沈黙します)
- 定期実行を止めたなら、止めたことが誰かのタスクとして追跡されているか
- 経路上の防御を通れるか。通れないなら、それは「合格」ではなく「実行できていない」です(ステップ 4 の第三の結果と同じ話)
- 手動起動しか残っていないなら、それは手順書であって自動検査ではありません。そう呼ぶこと
この観点そのものは 効いていないガードを見つける7つの型 で 7 通りに分類しました。対象選びとセットで初めて閉じます。
7. 手順を1本のスクリプトにする
ステップ 1〜4(対象選び)をそのまま実装したものを置きます。ステップ 5(自動で発火するか)は、このスクリプトを何に載せるかの話なので、スクリプト自身は答えを持ちません。 そこは自分の配線で確かめてください。
Node 20 以上で、拡張子 .mjs で保存してください(top-level await と ESM を使います)。
設定は「ルートクラスの一覧」です。各クラスに expectStatus と、本文か Location の不変量を最低 1 つ必須にしてあります。書かなければ設定エラーで落ちるので、ステータスだけを見る設定は作れません。
{
"classes": [
{ "name": "dynamic-root", "sample": "/", "expectStatus": 200, "expectBodyIncludes": ["/articles/alpha"] },
{ "name": "index-page", "sample": "/articles", "expectStatus": 200, "expectBodyIncludes": ["/articles/alpha"] },
{ "name": "detail-page", "sample": "/articles/alpha", "expectStatus": 200, "expectBodyIncludes": ["<h1>alpha</h1>"] },
{ "name": "not-found", "sample": "/articles/zzz-none", "expectStatus": 404, "expectBodyIncludes": ["Not Found"] },
{ "name": "machine-feed", "sample": "/sitemap.xml", "expectStatus": 200, "expectBodyIncludes": ["<loc>/articles/alpha</loc>"] },
{ "name": "renamed-url", "sample": "/old-url", "expectStatus": 301, "expectLocationIncludes": ["/articles/alpha"] }
]
}
dynamic-root の不変量に "/articles/" のような汎用の断片を書かないでください。 ナビゲーションのリンク 1 本で満たされてしまい、故障モード F2(一覧が空になる)を素通しします。具体的な記事の slug やタイトルを書きます。
#!/usr/bin/env node
// route-class-smoke.mjs — 故障モードから逆算したルートクラスごとに1枚ずつ確認する。
// Node 20 以上。拡張子 .mjs で保存すること(top-level await と ESM を使う)。
// exit 0 = 全クラス合格 / 1 = 期待と違う応答 / 2 = 設定・引数の不備 / 3 = 測定できなかった
const EXIT_OK = 0, EXIT_FAIL = 1, EXIT_CONFIG = 2, EXIT_UNMEASURED = 3;
// 経路上の防御が返すステータス。オリジンへ届いていないので「測れていない」に倒す。
// 5xx(502/503/504 を含む)はここに入れない。オリジン側の障害でも同じコードが出るため、
// 「測れていない」に倒すとサイト全断が exit 3 になり、記事本文の運用と組み合わせて素通りする。
const BLOCKED_BY_INTERMEDIARY = new Set([403, 429]);
function die(code, msg) { console.error(msg); process.exit(code); }
function parseArgs(argv) {
const a = { base: null, config: null, timeoutMs: 10000, bust: false };
for (let i = 0; i < argv.length; i++) {
const k = argv[i];
if (k === '--base') a.base = argv[++i] ?? null;
else if (k === '--config') a.config = argv[++i] ?? null;
else if (k === '--timeout-ms') a.timeoutMs = Number(argv[++i]);
else if (k === '--cache-bust') a.bust = true;
else die(EXIT_CONFIG, `unknown argument: ${k}`);
}
if (!a.base) die(EXIT_CONFIG, 'missing --base <origin>');
if (!a.config) die(EXIT_CONFIG, 'missing --config <file.json>');
let origin;
try { origin = new URL(a.base); } catch { die(EXIT_CONFIG, `--base is not a URL: ${a.base}`); }
if (!/^https?:$/.test(origin.protocol)) die(EXIT_CONFIG, `--base must be http(s): ${a.base}`);
// sample は必ず "/" 始まりなので、base のパスは URL 解決で捨てられる。
// 黙って別の場所を測らないよう、ここで落とす。
if (origin.pathname !== '/' || origin.search || origin.hash)
die(EXIT_CONFIG, `--base must be a bare origin (no path/query/hash): ${a.base}`);
if (!Number.isFinite(a.timeoutMs) || a.timeoutMs <= 0) die(EXIT_CONFIG, '--timeout-ms must be a positive number');
a.origin = origin;
return a;
}
async function loadConfig(path, origin) {
const { readFile } = await import('node:fs/promises');
let raw;
try { raw = await readFile(path, 'utf8'); }
catch (e) { die(EXIT_CONFIG, `cannot read config: ${path} (${e.code ?? e.message})`); }
let cfg;
try { cfg = JSON.parse(raw); }
catch (e) { die(EXIT_CONFIG, `config is not valid JSON: ${e.message}`); }
if (!cfg || !Array.isArray(cfg.classes) || cfg.classes.length === 0)
die(EXIT_CONFIG, 'config.classes must be a non-empty array');
const names = new Set(), samples = new Set();
for (const c of cfg.classes) {
if (!c || typeof c.name !== 'string' || c.name === '') die(EXIT_CONFIG, 'every class needs a non-empty "name"');
if (names.has(c.name)) die(EXIT_CONFIG, `duplicate class name: ${c.name}`);
names.add(c.name);
if (typeof c.sample !== 'string' || !c.sample.startsWith('/'))
die(EXIT_CONFIG, `class "${c.name}": "sample" must be a path starting with "/"`);
// "//host/" は protocol-relative URL で、別ホストへ逃げる。
if (c.sample.startsWith('//'))
die(EXIT_CONFIG, `class "${c.name}": "sample" must not start with "//" (protocol-relative)`);
// 別クラスが同じ1枚を指していたら、クラス分けが仕事をしていない。
if (samples.has(c.sample)) die(EXIT_CONFIG, `duplicate sample across classes: ${c.sample}`);
samples.add(c.sample);
try { c._url = new URL(c.sample, origin).toString(); }
catch (e) { die(EXIT_CONFIG, `class "${c.name}": "sample" does not resolve to a URL (${e.message})`); }
if (!Number.isInteger(c.expectStatus) || c.expectStatus < 100 || c.expectStatus > 599)
die(EXIT_CONFIG, `class "${c.name}": "expectStatus" must be an integer between 100 and 599`);
const body = c.expectBodyIncludes ?? [];
const loc = c.expectLocationIncludes ?? [];
for (const [field, list] of [['expectBodyIncludes', body], ['expectLocationIncludes', loc]]) {
if (!Array.isArray(list)) die(EXIT_CONFIG, `class "${c.name}": "${field}" must be an array`);
for (const s of list)
if (typeof s !== 'string' || s === '') die(EXIT_CONFIG, `class "${c.name}": ${field} entries must be non-empty strings`);
}
// ステータスだけを見る設定を作れないようにする。3xx は本文が空なので Location で代替できる。
if (body.length === 0 && loc.length === 0)
die(EXIT_CONFIG, `class "${c.name}": needs at least one invariant ("expectBodyIncludes", or "expectLocationIncludes" for a 3xx class)`);
c._body = body; c._loc = loc;
}
return cfg;
}
async function probe(c, timeoutMs, bust) {
const url = bust ? `${c._url}${c._url.includes('?') ? '&' : '?'}__smoke=${Date.now()}` : c._url;
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), timeoutMs);
let res, body;
try {
res = await fetch(url, { redirect: 'manual', signal: ac.signal, headers: { 'cache-control': 'no-cache' } });
body = await res.text();
} catch (e) {
const why = e.name === 'AbortError' ? `timeout after ${timeoutMs}ms` : `${e.message}${e.cause ? ` (${e.cause.code ?? e.cause.message})` : ''}`;
return { name: c.name, url, verdict: 'UNMEASURED', detail: `request failed: ${why}` };
} finally { clearTimeout(timer); }
const location = res.headers.get('location');
const where = location ? ` -> Location: ${location}` : '';
if (res.status !== c.expectStatus) {
// 経路上の防御に阻まれた場合だけ「壊れている」と読まない。
if (BLOCKED_BY_INTERMEDIARY.has(res.status))
return { name: c.name, url, verdict: 'UNMEASURED', detail: `blocked before reaching the origin (status ${res.status})${where}` };
return { name: c.name, url, verdict: 'FAIL', detail: `status ${res.status} (expected ${c.expectStatus})${where}` };
}
const problems = [];
for (const s of c._body)
if (!body.includes(s)) problems.push(`body invariant missing: ${JSON.stringify(s)}`);
for (const s of c._loc)
if (!location || !location.includes(s)) problems.push(`Location invariant missing: ${JSON.stringify(s)} (got ${location ?? 'no Location header'})`);
const size = `${Buffer.byteLength(body)} bytes`;
return problems.length
? { name: c.name, url, verdict: 'FAIL', detail: problems.join('; ') }
: { name: c.name, url, verdict: 'PASS', detail: `status ${res.status}, ${size}${where}` };
}
const args = parseArgs(process.argv.slice(2));
const cfg = await loadConfig(args.config, args.origin);
const results = [];
for (const c of cfg.classes) results.push(await probe(c, args.timeoutMs, args.bust));
for (const r of results) console.log(`${r.verdict.padEnd(10)} ${r.name.padEnd(22)} ${r.url} ${r.detail}`);
const unmeasured = results.filter((r) => r.verdict === 'UNMEASURED');
const failed = results.filter((r) => r.verdict === 'FAIL');
if (unmeasured.length) console.error(`\nUNMEASURED — ${unmeasured.length}/${results.length} class(es) could not be observed.`);
if (failed.length) {
// 壊れている方を優先する。UNMEASURED に隠して緑にしない。
console.error(`FAIL — ${failed.length}/${results.length} class(es) did not match.`);
process.exit(EXIT_FAIL);
}
if (unmeasured.length) process.exit(EXIT_UNMEASURED);
console.log(`\nOK — ${results.length}/${results.length} route classes matched.`);
process.exit(EXIT_OK);
設計上、意図して決めたことが 4 つあります。いずれも、独立レビューで「その形だと空振りする」と指摘されて入れたものです。
| 決めたこと | 入れた理由 |
|---|---|
--base にパスが付いていたら設定エラー | sample が / 始まりなので、--base https://host/preview/x/ のパス部分は URL 解決で黙って捨てられます。壊れている検証先ではなく健全な本番を測って緑になります |
別クラスが同じ sample を指していたら設定エラー | 全クラスが同じ 1 枚を指す設定が書けてしまうと、この記事が批判している「代表 1 枚」を設定ファイルの形で再現します |
sample の // 始まりを拒否 | //example.com/ は protocol-relative URL として別ホストへ逃げます |
| 「測れていない」に倒すのは 403 / 429 だけ | 経路上の防御に阻まれてオリジンへ届いていないためです。5xx はここに入れません。 502 / 503 / 504 は経路側の事情でもオリジン側の障害でも同じコードが出るので、「測れていない」に倒すとサイト全断が exit 3 になり、後述の運用と組み合わせて素通りします。ただし 403 でも期待どおりのステータスなら合格にします(expectStatus: 403 のクラスを作れる) |
そして終了コードの優先順位を決めます。FAIL と UNMEASURED が混在したら FAIL を採ります。 逆にすると、「1 クラスが 403 で遮られた」ことを理由に、同時に起きている本物の 500 が exit 3 に隠れます。exit 3 をリリースのブロッカーにしない運用と組み合わさると、本番の 500 がそのまま通ります。
障害の形を再現したときの出力
上の設定で、トップページだけが 500 を返すサーバーへ向けると次のようになります(ローカルの使い捨てサーバーでの実行結果、社内データ)。
FAIL dynamic-root http://127.0.0.1:8811/ status 500 (expected 200)
PASS index-page http://127.0.0.1:8811/articles status 200, 112 bytes
PASS detail-page http://127.0.0.1:8811/articles/alpha status 200, 88 bytes
PASS not-found http://127.0.0.1:8811/articles/zzz-none status 404, 81 bytes
PASS machine-feed http://127.0.0.1:8811/sitemap.xml status 200, 98 bytes
PASS renamed-url http://127.0.0.1:8811/old-url status 301, 0 bytes -> Location: /articles/alpha
FAIL — 1/6 class(es) did not match.
落ちたクラスだけが FAIL になり、他は PASS のまま残ります。これは「/ だけを壊した」設定から必然的にそうなる出力で、発見ではありません。 ここで確かめているのは 1 点だけ、「故障が 1 クラスに閉じたとき、スクリプトがそのクラスを名指しできるか」です。現実の確認では detail-page の 1 クラスしか触れていませんでした。
故障モード F2(ビルドは通るが生成対象の一覧が空になる)を再現すると、ステータスは全部 200 のまま dynamic-root だけが落ちます。
FAIL dynamic-root http://127.0.0.1:8812/ body invariant missing: "/articles/alpha"
PASS index-page http://127.0.0.1:8812/articles status 200, 112 bytes
PASS detail-page http://127.0.0.1:8812/articles/alpha status 200, 88 bytes
PASS not-found http://127.0.0.1:8812/articles/zzz-none status 404, 81 bytes
PASS machine-feed http://127.0.0.1:8812/sitemap.xml status 200, 98 bytes
PASS renamed-url http://127.0.0.1:8812/old-url status 301, 0 bytes -> Location: /articles/alpha
FAIL — 1/6 class(es) did not match.
本文の不変量を書いていなければ、この設定は 6/6 PASS で終わります。 そして不変量が "/articles/" のような汎用の断片だと、やはり PASS で終わります。
FAIL と UNMEASURED が混ざったときはこうなります。
FAIL dynamic-root http://127.0.0.1:8813/ status 500 (expected 200)
UNMEASURED blocked http://127.0.0.1:8813/blocked blocked before reaching the origin (status 403)
UNMEASURED — 1/2 class(es) could not be observed.
FAIL — 1/2 class(es) did not match.
両方を報告したうえで、終了コードは 1(壊れている側) になります。
異常系まで含めた実測(30 系統)
掲載したコードは、使い捨てディレクトリで全系統を実際に走らせています(以下すべて 社内データ)。「合格しか試していないチェッカー」を載せないためです(この点は 効いていないガードを見つける7つの型 と同じ立場です)。
| 与えた状況 | 実測 |
|---|---|
| 引数なし | exit 2 missing --base <origin> |
--base に値が無い | exit 2 |
--base が URL でない | exit 2 |
--base が ftp:// | exit 2 --base must be http(s) |
--base にパスが付いている | exit 2 must be a bare origin (no path/query/hash) |
| 未知のフラグ | exit 2 unknown argument: --deep |
--config が存在しないファイル | exit 2 (ENOENT) |
--config が壊れた JSON | exit 2 |
classes が空配列 | exit 2 |
expectStatus の欠落 | exit 2 |
expectStatus が値域外(999) | exit 2 |
| 不変量が 1 つも無い | exit 2 needs at least one invariant |
| クラス名の重複 | exit 2 duplicate class name |
sample の重複(別クラスが同じ 1 枚) | exit 2 duplicate sample across classes |
sample が / 始まりでない | exit 2 |
sample が // 始まり(別ホストへ逃げる) | exit 2 must not start with "//" |
--timeout-ms 0 | exit 2 |
| 全クラス一致(健全) | exit 0 OK — 6/6 |
| 301 を期待するクラスが一致 | exit 0(expectLocationIncludes で判定) |
| 403 を期待するクラスが一致 | exit 0 |
/ だけ 500(今回の障害の形) | exit 1 FAIL — 1/6 |
| 200 だが sitemap に記事が無い | exit 1 FAIL — 1/6 |
200 だが / に記事が 1 件も無い(F2) | exit 1 body invariant missing |
/ が 302 で別ホストへ飛ぶ | exit 1(-> Location: を出力に併記) |
存在するはずの記事が 404(detail-page 単独構成で実行) | exit 1 FAIL — 1/1 |
| FAIL と UNMEASURED の混在 | exit 1(両方を報告し、壊れている側を採る) |
| 403 で遮断される | exit 3 UNMEASURED |
| 503 が返る(オリジン側の障害でも出る) | exit 1 status 503 (expected 200) |
| 接続できない(ポート閉塞) | exit 3 UNMEASURED — 6/6 |
タイムアウト(--timeout-ms 500) | exit 3 UNMEASURED |
太字の 9 行は、独立レビューの指摘を受けて初めて追加した系統です。指摘前の版はこのうち 7 つで誤った結果を返していました(--base のパスを黙って捨てて緑、同一 sample の設定を許して緑、expectStatus: 403 が永久に到達不能、FAIL が UNMEASURED に隠れて exit 3、302 の Location を出さない、sample の // で別ホストへ、F2 が汎用の不変量で素通り)。「異常系を 20 系統試した」という当初の主張自体が、試していない形の存在を隠していました。
最後の 3 行が独立した終了コード(3)になっているのが要点です。不合格(1)と、測れなかった(3)を同じコードにすると、「監視が壊れている」を「本番が壊れている」と読み違えます。
そしてその逆も同じくらい危険です。503 の行を exit 3 ではなく exit 1 に置いているのは、502 / 503 / 504 が経路側の事情でもオリジン側の障害でも同じコードで出るからです。「測れていない」に倒すと、サイト全断が exit 3 になり、この記事自身が勧める「exit 3 はブロッカーにしない」運用で素通りします。 どちらに倒すかは自分の構成で決めてください。少なくとも両方に倒せる曖昧なコードを、片方に決め打ちで倒したまま忘れないことです。
なお、このスクリプトを筆者の公開先へそのまま向けても動きません。Cloudflare の Managed Challenge が 403 を返すため、全クラスが UNMEASURED になります(Cloudflare Docs: Managed Challenge、2026-09-10 確認)。この設計では意図どおりの結果です(測れなかったものを PASS にしないため)。本記事の実測をブラウザ経由で取ったのはこのためで、防御を通す経路(許可済みの送信元、あるいはブラウザ文脈)を用意するのは、確認手順の設計に含まれる仕事です。
最後にもう 1 つ。--cache-bust を付けない限り、cache-control: no-cache リクエストヘッダを送るだけです。CDN がこれを尊重する義務はありません。 第 4 節の実測で / が x-nextjs-cache: HIT を返していたとおり、緑になっても「いまデプロイしたコードを測った」ことにはなりません。
8. この観測の限界
書ける範囲を明示します。
- n=1 ではありませんが、1 構成の中の話です。 同じ構成(Next.js + Cloudflare)で 2 事象・3 バージョン(16.2.3 / 16.3.0 / 16.3.3)にわたり「トップだけが 500」が出ています。支えているのはこの構成の中での再現性だけで、「フレームワークを上げると一般に動的経路が落ちる」とは言っていませんし、別構成への一般化を支える材料も持っていません
- 2026-04 の記録には「記事詳細等の静的ルートの一部は静的配信されていた可能性」と書かれており、記事ページの生存が確認されたのは今回の事象だけです。「動的だけが落ちて静的は生き残る」という組み合わせまで揃った実測は 1 件です
- 障害中の値は再現できません。復旧済みのため、第 2 節の表は追試不能です。追試できるのは第 4 節以降(復旧後の実測)と第 7 節(ローカル再現)だけです
x-nextjs-*ヘッダは公開仕様ではありません。ルートの区別に使えるという以上の意味を読み取っていません- 「2 人が同じ盲点を踏んだ」は 2 件の観測です。構造だと断定するには少なすぎます。 本記事が主張しているのは「個人の注意力に帰着させる前に手順の設計を疑う価値がある」までで、それ以上ではありません
- 第 5-1 節は筆者の誤読の記録です。公開先の sitemap は分割設計として正しく動いており、欠陥ではありません。あの節が示すのは「代表 1 枚の選び方は、確認する側の思い込みで簡単にずれる」という 1 件の実例だけです
- 第 5-2 節の「223 と 224 のずれ」は、記事が公開先へ届くまでの時間差を見ているだけで、公開の仕組みに欠陥があるという主張ではありません
- 第 7 節のスクリプトは 30 系統で検証しましたが、網羅の証明ではありません。初版は「20 系統試した」と書いていて、実際には 7 つの形で誤った結果を返していました。試した系統数は、試していない形が無いことを意味しません
一般化できるのは数字ではなく、「故障モードを先に書き、そこからルートクラスを割り、クラスごとに1枚、ステータスと不変量の両方を見る」という順序だけです。この順序自体はあなたの構成の故障モード表から始めれば成立します。
FAQ
Q. 全ページを確認すればいいのでは?
規模によります。記事 223 本の一覧を毎リリース目視するのは現実的ではなく、自動化しても同じクラスの 2 枚目以降は情報量がほぼゼロです。削るべきはクラス内の枚数で、クラスの数ではありません。
Q. ルートクラスはどうやって決めますか?
まず故障モードを列挙し、次に「その故障モードで同じ結果を出すルート」をまとめます。分け方の当たりを付けるには、本記事の第 4 節のように全ルートを一度舐めて応答ヘッダをグループ化するのが安い方法です。ソースの構造と一致しないこともありますが、確認対象の選定にはそれで十分です。
Q. ステータスだけの監視では何が漏れますか?
本記事の実測では 2 つ出ました。404 の本文が「存在しない slug」と「未公開の slug」で同一、/feed.xml が 404 で /rss.xml が 200。いずれも 200/500 の監視には映りません。 逆に、中身を見るなら見る先を間違えない必要もあります(第 5-1 節で筆者が実際に間違えました)。
Q. 「測れなかった」を別扱いにする意味は?
403 やタイムアウトを不合格にすると、監視系の一時的な不調でリリースが止まります。逆に合格にすると、確認が完全に止まっていても緑のままになります。第三の終了コードに分けるのは、その二択を避けるためです。
Q. この記事の数字はそのまま使えますか?
使えません。本記事の数値は 2026-09-10 時点・特定の 1 構成の実測です。同じ構成の中では 2 事象・3 バージョンで再現していますが、別の構成へは一般化できません。持ち帰れるのは第 6 節の手順(故障モード → ルートクラス → 1 枚ずつ → ステータス+不変量)と、第 7 節の終了コード設計です。
References
- Next.js Docs — Route Segment Config(2026-09-10 確認)
- Next.js Docs — Caching(2026-09-10 確認)
- MDN — HTTP 404 Not Found(2026-09-10 確認)
- MDN — HTTP 500 Internal Server Error(2026-09-10 確認)
- Google 検索セントラル — サイトマップについて(2026-09-10 確認)
- Google SRE Book — Monitoring Distributed Systems(2026-09-10 確認)
- Cloudflare Docs — Cloudflare challenges(2026-09-10 確認)
まとめ
- トップページだけが 500 になり、記事ページは 200 のままでした。動的経路と事前生成経路で落ち方が分かれる形です。同じ構成で 2 事象・3 バージョンにわたって出ています(別構成へは一般化しません)
- リリース側と検証側の 2 人が独立に
/を見ませんでした。故障モードの形と、確認したい動機の形がすれ違っていたためだと筆者は見ています(観測 2 件、断定はしません) - 復旧後の実測では、
/と記事ページは応答ヘッダの時点で別グループでした。クラス分けはソースを読まなくても外から取れます - 200 は中身を保証しません。 404 が公開遅延と区別できない・
/feed.xmlだけ無い、が同じ日に出ました - 代表の選び方も同じくらい間違えます。 筆者は
/sitemap.xml1 枚を見て「記事が落ちている」と書きかけましたが、記事 URL は分割された別 sitemap に 223 件ありました。クラスの代表は思い込みではなく/robots.txtのような入口宣言から取る - 対象を正しく選んでも、その手順が自動で回らなければ結果は同じでした。
/を見る自動検査は既に存在していて、発火しない配線に載っていました。第 6 節のステップ 5 はそのために足しています - 今日できることは 1 つです。あなたの構成の故障モードを 5 行でいいので書き出し、それぞれが同じ結果を出すルートをまとめ、クラスごとに 1 枚、ステータスと本文の不変量を書く。 そして「測れなかった」を合格にしない出口を足す
