この連載の記事(全4回)
- 【第1回】Next.jsのキャッシュを複数インスタンスで共有する ── GCSカスタムキャッシュハンドラ実装記
- 【第2回】「ローカルでは動いたんですけどね」── 本番でしか出ない404と、一度戻したリリースの記録(この記事)
- 【第3回】たった1行の欠落が、3分の瞬断を77分の障害に変えた
- 【第4回】「本番でしか出ないバグ」は、本当に本番でしか出ないのか
前回、私たちはNext.jsのISRキャッシュをGCSへ逃がすカスタムキャッシュハンドラを作りました。ローカルでも検証環境でも、ひととおり動いていました。ところが本番に出すと、ISRのページが404を返しはじめます。
しかしリリースは一度で終わりませんでした。最初のリリースは6日後に全ページ404を引き起こして丸ごと切り戻しになり、作り直して本番に出したあとも、本番でだけ404が多発しました。
この記事は、その2回分の失敗の記録です。共通しているのは「ローカルでも検証環境でも、絶対に再現しなかった」こと。なぜ再現しなかったのか、どうすれば気づけたのかを、症状から順に書いていきます。
同じコードなのに、本番でだけ「ページが見つかりません」
ローカルと検証環境(STG)ではひととおり動いていました。ところが本番にリリースしてみると、本番でだけ「ページが見つかりません」が多発します。しかも症状がいやらしいのです。
- URLを直接打つと正常に表示されるのに、TOPのリンクから講座一覧へ遷移すると404になる
- 一部の動的ページが、本番でだけ404を返す
- 同じコードなのに、ローカルやSTGでは一切再現しない
なぜISRの404は検証環境で再現しなかったのか
犯人探しの前に、まず「なぜ気づけなかったのか」を書いておきます。ここがいちばんの学びでした。
原因は、検証環境と本番環境で、Next.jsの動き方そのものが違っていたことです。検証環境は開発(dev)モードで動いており、devモードではISRが無効で、ページごとのデータ取得(/_next/data/...)の挙動も本番と異なります。
一方、本番は「standaloneビルド + ISR + Custom Cache Handler」という構成。さらに、Dockerのビルド段階ではGCSの認証情報が無いため、キャッシュ関連のコードはビルド時には動きません。
つまり今回のバグ群は、本番のビルド・実行構成でしか姿を現さないものばかりでした。devモードで動く検証環境では、そもそも再現しようがなかったのです。
こんな方に読んでほしい
- ISRやSSGを本番運用していて、「本番でだけ404」を経験したことがある
- 検証環境をdevモードで動かしている ── そのままでは検出できないバグの種類を知っておきたい
- CI/CDでバックエンドとフロントエンドを並列デプロイしている
getStaticPathsのfallbackを、なんとなくfalseにしている
原因① ビルド時にGCS認証がなく、ページがキャッシュに載らない
本番でだけ404になる動的ページ。犯人は fallback: false でした。Custom Cache Handlerを使うstandalone構成では、fallback: false のページはキャッシュミス時に getStaticProps を呼ばず、いきなり404を返します。
そしてビルド段階ではGCS認証が無いためハンドラーが動かず、事前ビルドしたページがGCSに書き込まれません。結果、実行時にキャッシュが空 → 404、という流れです。
対処は、対象ページの fallback: false を fallback: "blocking" に変えること。これならキャッシュミス時に getStaticProps が呼ばれ、ISRが正しく動きます。
あわせて、notFound: true を返すケースにも revalidate: 60 を付け、ビルド時に一度404になったページが永続的に404のまま固定されるのを防ぎました。
── この最後の1行が、9か月後に大きな意味を持つことになります。このとき直したのは、あくまで「新しく触ったページだけ」だったからです(その顛末は第3回で)。
原因② リダイレクトがデータ取得リクエストを横取りしていた
次が、リンク遷移でだけ404になる現象です。原因は、next.config.mjs に書いた何気ないリダイレクトでした。
Next.jsはクライアント遷移のとき /_next/data/{buildId}/....json というデータ取得リクエストを飛ばしますが、リダイレクト判定の前にこのプレフィックスを取り除きます。
そのため、ページ用に書いたリダイレクトがデータ取得リクエストにも適用され、JSONの代わりにHTMLが返ってきてパース失敗 →「ページが見つかりません」になっていたのです。URL直打ちが正常だったのは、そちらがページ本体のリクエストで、データ取得リクエストを経由しないためでした。そしてこれも、ISRが無効なdevモードでは発生しません。

対処は、リダイレクトに1行足すだけでした。
missing: [{ type: "header", key: "x-nextjs-data" }]
こうするとデータ取得リクエストにはリダイレクトが適用されなくなります。ISRページへのカスタムリダイレクトには、この missing 条件を必ず付ける──これが大きな教訓になりました。
初回リリースの6日後、全件404で一括切り戻しになった
実は本番へ出る前に、一度リリースして戻しています。2026年4月21日にmainへマージし、その6日後に切り戻しました。
きっかけは、今回の改修とは無関係な小さなPR(フロントエンドのコメント修正)のマージでした。これがデプロイパイプラインを起動し、その最中に /request/各講座/ が全件404になったのです。
原因はデプロイの並列実行でした。deploy.yml はbackendとfrontendを同時にデプロイします。frontend側の next build が走っているまさにその時間帯に、backend(Strapi)はデプロイとDBマイグレーションの真っ最中で、/courses APIが空を返していました。
getStaticPaths はそれを受け取って paths = [] のままビルドを完了し、fallback: false の設定によって全ページが404になる成果物が焼き込まれた──という流れです。

同じ getStaticPaths でも fallback: "blocking" だった /[course]/index.js は無傷でした。これが「犯人は fallback の設定だ」と確定させた決め手です。
「キャッシュ化のせいではない」と分かっても、戻すしかなかった
調査の結果、この404は 今回のGCSキャッシュ化が直接の原因ではないことが分かりました。fallback: false という元々の設定と、並列デプロイの組み合わせが原因です。
それでも私たちは 今回の変更を全部revertしました。理由は「障害が起きている状態では、今回の改修の影響範囲を切り分けるのが難しい」から。同じタイミングでCI周りのビルドエラー(依存パッケージのバージョン競合)も複数出ており、安全側に倒して一括で戻す判断をしました。
感情としては「濡れ衣なのに」という気持ちもありましたが、いま振り返ると正しい判断だったと思います。障害対応中に「これは自分たちのせいではない」を証明するコストは、戻すコストより高くつきます。そして次に書くとおり、戻したからこそ見つかったものがありました。
巻き添えで見つかった、もっと深刻なバグ
切り戻し後の調査で、cache-handler.mjs の set() にこんな条件が残っているのを見つけました。
// キャッシュ保存をスキップする条件(開発中の名残)
if (
props &&
!props.pageData &&
!props.courseData &&
!props.courseAttributes
) return
特定のpropsを持たないページのキャッシュ保存を丸ごとスキップする、という条件です。実際に何%のページが該当するか数えてみたところ、全体の91%でした。つまりGCSキャッシュ化を入れたのに、9割のページは何も保存されず、実質ほぼ毎リクエストでStrapiを叩き続けていたことになります。
このバグは404を引き起こしてはいません(ビルド時のHTMLは .next/server/pages/ に存在するので、ルーティング判定には影響しない)。害はパフォーマンスの劣化だけです。だからこそ、誰も気づかないまま本番で動き続ける類のバグでした。障害がなければ、おそらく見つかっていません。
なぜテストで拾えなかったのか
この一件について「なぜ事前に拾えなかったか」を整理したところ、4つ出てきました。
- ローカル・STGの単独テスト:
next build時にStrapiが正常稼働しているためgetStaticPathsはパスを正しく生成する。fallback: falseでも404にならない - cache-handlerのユニットテストが無い:
set()が91%のページをスキップしている事実は、テストなしでは目視で気づきにくい - 統合テスト環境の制約:STGはmin 1インスタンス・devモードのためISRが動かず、GCSキャッシュのread/writeパスを通るテストがそもそもできない
- デプロイパイプラインの結合テストが無い:並列実行というリスク要因はあったが、パイプライン自体をテストする発想がなかった
そしてもうひとつ、いちばん痛かったのがこれです。障害の13日前(4月14日)に専用のCloud Run環境でテストを実施しており、その結果表には /request/ と /request/takken/ が「404」と記録されていました。症状は観測できていたのです。
しかし記録には「STG CMSにコンテンツなし」と注記され、そのまま流されていました。「コンテンツが無いから404」なのか「getStaticPaths が空リストを返したから404」なのかを区別していなかったのです。しかもその表のGCS書き込み確認欄は全件が空のままで、テスト本来の目的すら完了していませんでした。
観測できていたのに、解釈を間違えて見送った。これは第4回で詳しく書く「発見可能性」の話の、最初の実例でした。
再実装して、いよいよ本番へ
切り戻し後の2週間で、fallback: "blocking" 化、91%スキップ条件の削除、revalidate の単位変換の誤り、GCSタイムアウトの短縮(10秒→3秒、exists()+download() の2回呼び出しを1本に統合して最悪TTFBを20秒→3秒に)、タグ保存漏れの修正までまとめて片付け、feature ブランチで再実装しました。
そして本番へ。ところが今度は、STGでは起きなかった別の404が待っていました。
結果:本番の404は解消した
fallback の変更とリダイレクトの missing 条件、この2つで本番の404は解消しました。動的ページはキャッシュミス時に getStaticProps が呼ばれるようになり、リンク遷移のデータ取得もリダイレクトに横取りされなくなりました。
ただし、このとき学んだ「notFound には revalidate を付ける」という対策を適用したのは、新しく触ったページだけでした。旧来のページには横展開していません。それが9か月後に効いてきます。
getStaticPathsのfallback: falseは、ビルド時にデータ取得が失敗すると「404が焼き込まれた成果物」を作る。CI/CDの並列デプロイと組み合わさると全ページ404になりうる- ISRページへのカスタムリダイレクトには
missing: [{ type: "header", key: "x-nextjs-data" }]を付ける。付けないとクライアント遷移のデータ取得を横取りする notFound: trueを返すときはrevalidateも付ける。一度404になったページが永続的に固定されるのを防げる- 検証環境がdevモードだと、ISR関連のバグは構造的に検出できない。「devで動いた」を完了条件にしない
- テスト結果に出た404を「データが無いから」で片付けない。症状が同じでも原因は複数ありうる