【第2回】「ローカルでは動いたんですけどね」── 本番でしか出ない404と、一度戻したリリースの記録

前回、私たちは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でバックエンドとフロントエンドを並列デプロイしている
  • getStaticPathsfallback を、なんとなく false にしている

原因① ビルド時にGCS認証がなく、ページがキャッシュに載らない

本番でだけ404になる動的ページ。犯人は fallback: false でした。Custom Cache Handlerを使うstandalone構成では、fallback: false のページはキャッシュミス時に getStaticProps を呼ばず、いきなり404を返します。

そしてビルド段階ではGCS認証が無いためハンドラーが動かず、事前ビルドしたページがGCSに書き込まれません。結果、実行時にキャッシュが空 → 404、という流れです。

対処は、対象ページの fallback: falsefallback: "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モードでは発生しません。

ISRのリダイレクトがデータ取得を横取りして404になる仕組み
図1 リダイレクトがデータ取得リクエストを横取りする

対処は、リダイレクトに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になる成果物が焼き込まれた──という流れです。

図2 並列デプロイが「空のパス一覧」を焼き込んだ
図2 並列デプロイが「空のパス一覧」を焼き込んだ

同じ getStaticPaths でも fallback: "blocking" だった /[course]/index.js は無傷でした。これが「犯人は fallback の設定だ」と確定させた決め手です。

「キャッシュ化のせいではない」と分かっても、戻すしかなかった

調査の結果、この404は 今回のGCSキャッシュ化が直接の原因ではないことが分かりました。fallback: false という元々の設定と、並列デプロイの組み合わせが原因です。

それでも私たちは 今回の変更を全部revertしました。理由は「障害が起きている状態では、今回の改修の影響範囲を切り分けるのが難しい」から。同じタイミングでCI周りのビルドエラー(依存パッケージのバージョン競合)も複数出ており、安全側に倒して一括で戻す判断をしました。

感情としては「濡れ衣なのに」という気持ちもありましたが、いま振り返ると正しい判断だったと思います。障害対応中に「これは自分たちのせいではない」を証明するコストは、戻すコストより高くつきます。そして次に書くとおり、戻したからこそ見つかったものがありました。

巻き添えで見つかった、もっと深刻なバグ

切り戻し後の調査で、cache-handler.mjsset() にこんな条件が残っているのを見つけました。

// キャッシュ保存をスキップする条件(開発中の名残)
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か月後に効いてきます。

  • getStaticPathsfallback: false は、ビルド時にデータ取得が失敗すると「404が焼き込まれた成果物」を作る。CI/CDの並列デプロイと組み合わさると全ページ404になりうる
  • ISRページへのカスタムリダイレクトには missing: [{ type: "header", key: "x-nextjs-data" }] を付ける。付けないとクライアント遷移のデータ取得を横取りする
  • notFound: true を返すときは revalidate も付ける。一度404になったページが永続的に固定されるのを防げる
  • 検証環境がdevモードだと、ISR関連のバグは構造的に検出できない。「devで動いた」を完了条件にしない
  • テスト結果に出た404を「データが無いから」で片付けない。症状が同じでも原因は複数ありうる

参考リンク