この連載の記事(全4回)
- 【第1回】Next.jsのキャッシュを複数インスタンスで共有する ── GCSカスタムキャッシュハンドラ実装記
- 【第2回】「ローカルでは動いたんですけどね」── 本番でしか出ない404と、一度戻したリリースの記録
- 【第3回】たった1行の欠落が、3分の瞬断を77分の障害に変えた(この記事)
- 【第4回】「本番でしか出ないバグ」は、本当に本番でしか出ないのか
前回、私たちはNext.jsのISRキャッシュをGCSへ逃がし、本番リリース初回の404を鎮めました。そのとき学んだ対策のひとつが「notFound: true を返すときは revalidate も付ける」でした。
それから9か月後の朝、本番のTOPページが404を返し続けるという障害を起こしました。原因は、あのとき学んだはずの1行が、古いページに横展開されていなかったことです。しかもその1行の欠落は、たった3分のバックエンド瞬断を、77分の障害へと増幅させました。
この記事は、その全記録です。「404がCDNに焼き付く」という現象が実際にどう起きるのか、そしてなぜ「CDN側で404をキャッシュしない設定」では防げなかったのかを、実測値とともに書きます。
「私は見えてます」「私は404です」が同時に起きる
2026年7月20日、朝8時10分ごろ。監視が本番サイトのダウンを検知しました。
- 現象:本番TOPページで404。ただし人によっては正常に見えている
- 条件:大量アクセス時に発生
- 期間:8:10頃 〜 9:27頃(約77分)
- 復旧契機:CDNでTOPのキャッシュをクリアしたら、一斉に回復
いちばん不気味だったのは「人によっては見えている」でした。社内で確認しても、見える人と見えない人がいる。リロードすると直る人もいれば、何度やっても404の人もいる。障害対応でこの状況に陥ると、そもそも「本当に落ちているのか」の合意形成から始めることになり、初動が確実に遅れます。
キャッシュ層が6段あると、6通りの答えが返る
原因は、キャッシュが1か所に集約されておらず、経路ごとに独立して持たれていたことでした。
- CDNエッジ単位(主要因):エッジごとにキャッシュを持つ。障害前の正常な200が残っているエッジなら200、404を焼き付けたエッジなら404。エッジは回線や地域でほぼ固定されるので「人ごとに安定して違う」という現象になる
- Cloud Runインスタンス単位(メモリTTL 60秒):台ごとに独自のインメモリキャッシュ。良品を持つ台なら200、メモリが切れた台ならGCSも空で404。振り分けはランダムなので「同じ人でもリロードで揺れる」差になる
- ブラウザ/bfcache:障害前に開いていた人はそもそもoriginに行かない
TOPページ1リクエストの経路は、実際にはこうなっていました。
ブラウザ → CDN(エッジ毎) → 顧客側プロキシ → LB → Cloud Run N台(各台メモリL1 + 共有GCS)
各層でTTLも無効化方法も挙動もバラバラ。この「層が多い × 非協調 × 本番でしか出ない」という構図が、私たちのトラブルの共通項でした。
こんな方に読んでほしい
- ISRやSSGを本番で運用していて、CDNを前段に置いている
- 「一部のユーザーだけ古い/壊れたページが見える」障害を経験したことがある
getStaticPropsでnotFound: trueを返しているコードが手元にある- 429を見たら反射的に「レート制限だ」と考えてしまう
犯人はnotFoundにrevalidateが無いことだった
ログとコードを突き合わせた結果、総合TOPの getStaticProps がこうなっていました。
// frontend/src/pages/index.js
const pageData = pageSogoTopAttributes?.data?.[0] || null
if (!pageData) {
return { notFound: true } // 75行目: revalidate なし
}
...
} catch (error) {
return { notFound: true } // 91行目: revalidate なし
}
第1回で「notFound には revalidate を付けよう」と学んだはずの、まさにその対策が総合TOPには入っていませんでした。今回の改修で触った新しいページ群にだけ適用され、旧来ページには横展開されていなかったのです。
しかも悪いことに、当時のコミットは index.js の getStaticProps を実際に編集していました。つまり notFound ブロックのすぐ隣を編集しながら、revalidate を足さなかったわけです。
さらに fetchAPI にはタイムアウトもリトライもなく、バックエンドが非2xxを返すと即 throw → catch → notFound。バックエンドが少しでもぐらつけば、TOPは即座に404になる構造でした。
実測:同じ404でも Cache-Control はまったく違う
では、なぜその1行が77分を生んだのか。公開URLに curl を打って、実際のレスポンスヘッダを並べてみました。
curl -sI https://example.com/存在しないパス/ | grep -i cache-control

これが決定打でした。revalidate の無い notFound は、404でありながら s-maxage=31536000(=1年)という「積極的にキャッシュしろ」という指示付きで返るのです。revalidate の値がそのまま s-maxage になり、欠落すると1年になる。
つまり「CDNが200と誤認した」のではありません。CDNもoriginも404だと正しく認識したうえで、origin自身が「この404を1年キャッシュしてくれ」と明示的にお願いしていたのです。CDNは言われたとおりにしただけでした。
なぜ「CDNで404をキャッシュしない」設定では直せなかったのか
社内には「以前『CDNで404をキャッシュしない』設定を入れたが効かず、外した」という記憶がありました。これも同じ理屈で説明がつきます。
あの設定はCDNのネガティブキャッシュ(CDNが裁量でエラー応答をキャッシュする挙動)を制御するもので、originが明示的な Cache-Control: s-maxage で指示した応答は上書きできません。一般的な404(no-cache, no-store)は除外できていたけれど、ISRのnotFoundはs-maxage付きなので素通りしていたわけです。
ここが重要なポイントです。修正の本質はCDN側の設定ではなく、originが返すCache-Controlを正すことでした。つまり revalidate: 60 を付けるという、あの1行です。
修正そのものは1行 × 19か所
- return { notFound: true }
+ return { notFound: true, revalidate: 60 }
同じリスクを持つ箇所を全ファイルから洗い出したところ、19か所・10ファイルありました。共通パターンは明確で、「成功時は revalidate(100〜3600)を持つのに、空データ時や catch 時の notFound にだけ revalidate が無い」というものです。書いている本人は正常系のことしか考えていない、という人間の癖がそのまま出ています。
なお、getServerSideProps を使うページは対象外です。SSRに revalidate は無効なプロパティで、付けても効きません。そもそもSSRの404は private, no-cache, no-store で返るため、焼き付きリスクは最初からありません。
3つの欠陥が直列につながって「自己回復できない404」が完成した
実は、欠陥は1つではありませんでした。もうひとつ、GCSキャッシュハンドラ側にも問題があったのです。
// cache-handler.mjs get() 85-93行目
if (cached.expireAt && Date.now() > cached.expireAt) {
file.delete(...) // 期限切れGCSエントリを削除
return null // stale を返さず null(=ハードミス)
}
Next.js標準のISRは「期限切れでも古いHTMLを返しつつ、裏で再生成する」(stale-while-revalidate)という粘り強い動きをします。ところが私たちの実装は、期限切れの瞬間に良品を削除して null を返す。完全なキャッシュミス扱いです。本来なら「古いTOPを配り続けて嵐をやり過ごす」はずが、その退避経路を自分で塞いでいました。

originは3分で回復していた。誰も気づけなかっただけで
ログを時系列で並べたとき、いちばん背筋が寒くなったのがこれです。

originの実不調は約3分だけ。にもかかわらずユーザーには約77分404が見え続けました。差の約76分は、焼き付いた404がCDNに残り、自己回復の手段が無かった時間です。revalidate: 60 さえあれば、バックエンド回復の1分後にCDNも自然に治り、手動クリアを待つ必要はありませんでした。
そして、真の起点は「レート制限」ではなかった
ログには大量の 429 Too Many Requests が出ていました。429といえばレート制限です。当初の調査では「バックエンドのStrapiがレート制限を発動している」と結論づけていました。
しかし追加調査で、Cloud Runのログにこう出ていることがわかります。
logName: .../logs/run.googleapis.com%2Frequests
status: 429
textPayload: "The request was aborted because there was no available instance."
429を返していたのはアプリではなく、Cloud Run自身でした。リクエストはStrapiに到達すらしていなかったのです。実際、設定ファイルを確認してもレート制限の設定は一切ありませんでした。

フロントは4台まで増え、その各々が getStaticProps でAPIを複数回叩く。対してバックエンドは最大1台、同時80リクエストで頭打ち。この非対称が構造的なボトルネックでした。障害時間帯の429は4,977件にのぼります。
なぜ1台だったかというと、毎分動く予約公開cronに排他制御が無く、複数台にすると全台が同時に同じ記事をpublishしてしまうからでした。つまりスケールできない理由がアプリ側にあったわけです。PostgreSQLのadvisory lockで排他を入れ、次のリリースで4台に上げます。
レイヤーを1つ取り違えていた
ここで痛烈に効いたのが、第1回・第2回で書いたGCS化のPRのテスト項目です。そこには「404ページがGCSに保存されないこと」という確認項目がちゃんと入っていました。つまり「404をキャッシュしない」という観点はあったのです。
でも実害が出たのは、その先の「CDNが見るHTTPレスポンスヘッダ」でした。「GCSに404を書かない」対策と「CDNに404を焼き付けない」対策はまったく別のレイヤーで、後者だけがすっぽり抜けていた。観点は正しかったのに、適用するレイヤーを1つ間違えていたのです。多段キャッシュ構成では、この「どの層の話をしているのか」を毎回言語化する必要があると痛感しました。
あえて直さなかったもの(やらない判断も対策である)
技術的に最も効くのは、キャッシュハンドラをstale-while-revalidate対応にすることです。しかし私たちはこれを見送りました。
理由は3つ。急性リスク(焼き付き)は revalidate: 60 で解消済みで、残るのは慢性リスクであること。この改修は本番同等構成での負荷試験が必須で、lastModified を誤ると「コンテンツが永久に更新されない」という逆向きの障害を作り込むこと。そして中期的にアーキテクチャ移行を検討しており、その場合この自作ハンドラは存在ごと不要になることです。
ただし「やらない」で終わらせず、再着手トリガーを書き残しました。「revalidate: 60 導入後もなお短時間404が再発したら」「移行の意思決定が否、または1年以上先に確定したら」「TTFBの劣化がSEO上の実害として観測されたら」。理由と再着手条件をセットで記録すれば、やらない判断も立派な対策になります。
結果:最悪でも60秒で自己回復するようになった
19か所への revalidate: 60 適用により、同じスパイクが再来しても被害は「77分の障害」から「最大60秒で自己回復するブリップ」に変わりました。手動オペレーションが必要な障害から、監視上の一時的な揺れへの格下げです。
根本的なスループット不足(バックエンドが1インスタンス)は、cronの排他制御を入れたうえで次のリリースに合わせて解消する予定です。DB接続数は実測でピーク25・上限500だったため、台数を増やしても余裕があることは確認済みです。
getStaticPropsでnotFound: trueを返すときは必ずrevalidateも付ける。無いと404がs-maxage=31536000(1年)でCDNに焼き付き、手動クリアするまで消えない- originが
Cache-Control: s-maxageで明示指示した応答は、CDN側の「404をキャッシュしない」設定では上書きできない。直すべきはorigin側 - エラー時の分岐にも正常系と同じ品質基準を適用する。今回の19か所はすべて「成功時にはrevalidateがあるのに、catch時だけ無い」というパターンだった
- 429を見たら「誰が返したのか」を確認する。アプリのレート制限とは限らず、今回はCloud Runのインスタンス上限だった