この連載の記事(全4回)
- 【第1回】Next.jsのキャッシュを複数インスタンスで共有する ── GCSカスタムキャッシュハンドラ実装記(この記事)
- 【第2回】「ローカルでは動いたんですけどね」── 本番でしか出ない404と、一度戻したリリースの記録
- 【第3回】たった1行の欠落が、3分の瞬断を77分の障害に変えた
- 【第4回】「本番でしか出ないバグ」は、本当に本番でしか出ないのか
Next.jsのISR(Incremental Static Regeneration)はとても便利です。一度生成したページをキャッシュしておき、期限が切れたら裏側で作り直してくれる。誰でもお手軽に、高速なページ配信ができるようになりました。
ところがこの「お手軽さ」は、サーバーが1台のうちだけ成立するものでした。アクセスが増えてインスタンスを複数台に増やした途端、キャッシュの効きが目に見えて悪くなり、「消したはずのキャッシュが消えない」という不可解な現象に出くわすようになります。
もっとも、私たちがこの問題に取り組み始めたきっかけは「キャッシュを共有したい」ではありませんでした。本番のサーバーが4時間おきにメモリ上限に達して再起動を繰り返す、という別の障害が発端です。その真因を掘っていった結果、行き着いた先がキャッシュの共有でした。
この記事は、私たちがNext.jsのISRキャッシュをGCS(Google Cloud Storage)へ逃がすまでの実装記です。同時に、「ローカルでは動くのに本番だけ404」という、本番のビルド・実行構成でしか姿を現さないバグ群との最初の遭遇記でもあります。まずは全体像から。

4時間おきに再起動を繰り返す本番サーバー
すべての発端は、キャッシュとは一見無関係な症状でした。Cloud Run 上の Next.js が約4時間でメモリ上限に到達し、強制再起動を繰り返していたのです。メモリ使用率のグラフは階段状に上昇して100%に達し、再起動で10%に戻り、また上り始める。これを延々と繰り返していました。
厄介だったのは、STG(devモード・min 1インスタンス)ではまったく起きず、本番だけで起きていたことです。当初は「どこかにメモリリークがある」と疑い、setInterval の消し忘れ、モジュールスコープで育つ変数、keep-aliveソケットの蓄積……と候補を潰していきました。しかしどれも該当しません。
決め手になったのは、サイトを一時的に非公開にして観測した比較でした。トラフィックが無い状態ではメモリが安定していたのです。つまりリークではなく、リクエストを処理すること自体がメモリを押し上げていた──構造的な問題でした。
真因は _app.js の App.getInitialProps でした。これが存在するとNext.jsは全ページをSSR扱いにし、ISRも静的キャッシュも一切効かなくなります。結果、ユーザーのリクエストが来るたびにReactツリーの構築・APIレスポンスのパース・HTML文字列化をフルで行うことになる。
V8のGCはレイジーなので、アロケーションのペースが回収を上回るとヒープは階段状に膨らみ続け、やがて上限に達します(再起動で10%に戻るのは、プロセスが死んでOSがメモリを回収するためです)。
そこで getInitialProps を削除してISRを有効化することにしました。これでリクエストごとのフルレンダリングが激減し、メモリは安定します。ところが、ISRが効くようになるとキャッシュが各インスタンスのファイルシステムに保存されるようになる。そしてCloud Runはインスタンス間でファイルシステムを共有しません。
つまり「キャッシュを共有したい」は目的ではなく、メモリ問題を解いた結果として生じた副作用でした。ここからが、この記事の本題です。

A号機が作ったキャッシュを、B号機は知らない
Next.jsのキャッシュは、標準ではそのサーバーのファイルシステムに保存されます。1台だけで運用しているうちは、これで何の問題もありません。
ところが、アクセスが増えてサーバーを2台、3台と増やすと話が変わります。A号機が作ったキャッシュはA号機のディスクにしかなく、B号機はそれを知りません。結果として、同じページなのにインスタンスごとにバラバラのキャッシュを持つことになります。
ユーザーから見ると「リロードするたびに内容が変わる」「同じURLなのに人によって表示が違う」という形で現れます。原因の見当がつかないまま、キャッシュのTTLを短くしてごまかす……という対症療法に流れがちです。
revalidateTag() が、いつも「0件削除」を返す
もっとわかりやすく困るのが、キャッシュの明示的な削除です。CMSで記事を更新したタイミングでWebhookを飛ばし、revalidateTag() で該当ページのキャッシュを消す──よくある構成だと思います。
これが複数台環境では「1台だけ消えて、残りは古いまま」になります。運が悪いと、記事を公開したのに何度リロードしても古い内容が出続ける。編集者から「反映されてないんですけど」と言われて、開発者がリロードすると直っている(=自分は消えた側の号機を引いた)。この構図に心当たりのある方は多いのではないでしょうか。
こんな方に読んでほしい
- Next.jsをCloud Runなどでスケールさせたい ── インスタンスを増やしたらキャッシュがバラついた
- Custom Cache Handlerを自作しようとしている ── GCSやS3、Redisを保存先にしたいが、ハマりどころを先に知っておきたい
- 「ローカルでは動くのに本番だけ404」に悩んでいる ── 検証環境では再現しない不具合に頭を抱えている
- そもそもISRがちゃんと効いているか不安 ── なんとなくISR化したが、本当にキャッシュが効いているか自信が持てない
Next.js ISRのキャッシュ保存先を、ディスクから共有ストレージへ
問題の本質は「複数台であること」ではなく、「台ごとにキャッシュが割れること」です。であれば、キャッシュの置き場所を各サーバーのディスクから、みんなで共有できる場所に移せばよい。私たちはそれをGCSにしました。
ありがたいことに、Next.jsはキャッシュの読み書きを自前のコードに差し替える「Custom Cache Handler」という仕組みを用意してくれています。next.config.mjs で自作のハンドラーを指定するだけで、get / set / revalidateTag の3つを自分で書けるようになります。
// next.config.mjs
export default {
cacheHandler: require.resolve('./src/cache-handler.mjs'),
cacheMaxMemorySize: 0, // 標準のインメモリキャッシュは無効化する
}
L1インメモリ + L2 GCS の2層構成にする
とはいえ、毎回GCSを読みに行くとレイテンシが乗ります。そこで2層構造にしました。
- L1: インメモリ(TTL 60秒)── まず手元の速いキャッシュを見る
- L2: GCS ── なければGCSからダウンロードし、メモリにも載せて返す
読むときはメモリ→GCSの順に探し、書くときはGCSとメモリの両方に保存します。これでどのインスタンスからでも同じキャッシュを参照でき、revalidateTag() も全台に効くようになりました。あわせて、404やリダイレクトなど「HTMLの中身がないエントリはキャッシュしない」フィルタも入れています。

掘ってみたら、そもそもISRが効いていなかった
GCS化を進める過程で、もっと根っこの問題に気づきました。一部のページは、そもそもISRが効いていなかったのです。
講座コラムの一覧・カリキュラム一覧・講師一覧、合格者の声といったページは、getStaticProps を一切持たない、純粋なクライアントサイドレンダリング(CSR)構成でした。サーバーは中身のない「同じ静的シェルHTML」を返すだけで、実際のコンテンツはブラウザ側の useEffect がAPIを叩いて後から描画していたのです。
これには2つの問題があります。ひとつは、キャッシュに載せる意味のあるHTMLがそもそも生成されないこと。せっかくISRでキャッシュを効かせようとしても、中身が空っぽでは意味がありません。もうひとつは、初期HTMLにコンテンツもSEOタグも含まれないため、検索エンジンやOGPのクローラーには「空っぽのページ」に見えてしまうことです。
対処として、これらのページに getStaticProps(と getStaticPaths の fallback: "blocking")を追加し、ISR/SSRで初期HTMLを生成するようにしました。ページの骨格とメタ情報がサーバー側で確定し、SEOタグがHTMLに含まれ、GCSキャッシュにも意味のあるページが載るようになりました。

globalデータが undefined になる(_app.js の落とし穴)
ISRが効いていたページにも、似た落とし穴がありました。ヘッダーのロゴやサイト共通情報(global)を各ページが初回レンダリング時に持っておらず、undefined になっていたのです。
私たちのサイトでは、この global を _app.js の中で useSWR("/global") を呼んで取得していました。クライアント側ではSWRがあとから取得してくれるものの、サーバーが返す初期HTMLにはグローバル情報が入っていません。結果、ファーストビューでヘッダーが欠けたり、不完全なHTMLがキャッシュされてしまいます。
そこで、各ページの getStaticProps で取得した global を、_app.js の SWRConfig の fallback に事前に詰めておくことにしました。
// _app.js
const MyApp = ({ Component, pageProps = {} }) => (
<SWRConfig value={{ fallback: pageProps.fallback ?? {} }}>
<AppContent Component={Component} pageProps={pageProps} />
</SWRConfig>
)
こうするとSWRは初回レンダリング時点で fallback を「取得済みの初期データ」として扱ってくれるので、サーバーが返すHTMLにもグローバル情報が含まれ、キャッシュされるHTMLが「完成品」になります。
ここでもうひとつハマったのが、useSWR("/global") を呼ぶ場所です。SWRConfig と同じコンポーネント内で useSWR を呼ぶと、その呼び出しは SWRConfig の外側に位置することになり、渡した fallback が効きません。
global を参照する部分を AppContent という内部コンポーネントに切り出し、SWRConfig の内側で useSWR が呼ばれるようにしました。上のコードで1枚挟んでいるのは、このためです。
revalidateTag() を仕上げる
最後にキャッシュ削除まわりを詰めました。キャッシュを消すはずの revalidateTag() を呼んでも、なぜか常に「0件削除」。原因は単純で、set() のときにタグを保存し忘れていたことでした。タグを頼りに削除対象を探すのに、タグが記録されていなければ見つかるはずもありません。
set() の際にGCSのメタデータ・JSONペイロード・メモリエントリの3か所へタグを保存し、get() でも復元するようにして解決しました。もうひとつ、複数ファイルをまとめて消す処理を Promise.all で書いていたため、1件の削除失敗が残り全部を巻き添えにするリスクがありました。各削除に個別の catch とタイムアウトを付け、1件こけても他は消え切るようにしています。
技術選定の振り返り:フルスクラッチで書く必要はなかったかもしれない
最後に、正直な反省をひとつ。cacheHandlerの差し替え自体は必要でしたし、GCSを使うのは発注要件でもあったので選定に落ち度はありません。ただし、フルスクラッチで全部自作する必要があったかというと、回避余地はありました。
@neshca/cache-handler(現 @fortedigital/nextjs-cache-handler)のようなライブラリを使えば、GCSへの get/set/delete だけを自作し、stale-while-revalidate・タグ管理・TTL・notFoundの扱いといった「難所」はライブラリに委譲できます。
私たちが踏んだバグ(タグ保存漏れ、revalidateTagが0件)は、まさにその難所を自前で再実装したことに起因していました。
当時の記録を掘り返すと、「自作 vs 既製ライブラリ」を比較検討した形跡はありませんでした。発端がメモリ逼迫による緊急対応だったこと、GCSアダプタを持つライブラリが無かったこと、Next.js公式の拡張方法が「自分でクラスを書く」ものであることが重なり、自然に全自作へ流れたのだと思います。緊急時ほど「この難所は誰かが解いていないか」と一度立ち止まる価値がある、というのが学びです。
結果:メモリは平均8.4%で安定した
対応後の本番環境(直近7日間)のメモリ使用率を Cloud Monitoring で確認すると、1時間平均で 8.4%、最大でも 15.8% でした。かつて4時間ごとに 100% へ到達して強制再起動を繰り返していたことを思えば、症状は完全に消えています。
副次的な効果もありました。get() を見直す過程でGCSのタイムアウトを10秒から3秒に短縮し、exists() と download() の2回呼び出しを1本に統合したことで、最悪ケースのTTFBが20秒から3秒になっています。
ただし、この時点で確認できたのは「ISRが効いてメモリが安定した」ことだけです。キャッシュの中身が期待どおりか、期限切れの扱いが正しいかは、このあと本番で痛い目を見ながら知ることになります。
_app.jsのApp.getInitialPropsは全ページをSSRにし、ISRを丸ごと無効化する。メモリ逼迫・SEO・キャッシュ設計のすべての根になりうる- 複数インスタンスでISRを使うなら、cacheHandlerを共有ストレージに差し替える。L1インメモリ+L2オブジェクトストレージの2層構成が実用的
- ISR化したつもりのページが、実はCSRのままということがある。初期HTMLに中身とSEOタグが入っているかを必ず確認する(メモリにもSEOにも効く)
_app.jsの共通データはSWRのfallbackに詰める。ただしuseSWRは必ずSWRConfigの内側で呼ぶこと- stale-while-revalidate・タグ管理・TTLといった難所は既製ライブラリに委譲できないか、実装前に一度調べる