NuxtのSSGビルドが3倍遅くなった原因調査 — 差分ビルドではなくプリレンダー共有キャッシュで立て直す

開発mdx-playground

NuxtのSSGビルドが3倍遅くなった原因調査 — 差分ビルドではなくプリレンダー共有キャッシュで立て直す

バックグラウンドで回していたデプロイの完了通知に「26分35秒」と出ていて、手が止まった。昨日までは13分台で回っていたはずだ。フェーズ別の内訳を見るとアプリレンダリングが膨らんでいるように見える。過去のデプロイ計測記録と突き合わせて、何がどれだけ伸びたのか分析してもらった。

過去計測との突き合わせ

nitroが報告するプリレンダー時間を並べると、こうなった。

デプロイルート数プリレンダー
07-175,693256秒
07-185,676335秒
07-19 1回目8,440749秒
07-19 2回目8,6571,003秒

悪化は今朝入れた内部リンク施策のコミットと同時に起きている。関連記事セクションの追加と、タグページ553枚の再導入だ。

原因コードは2箇所で、構造は同じだった。記事ページ([...slug].vue)の関連記事選定と、タグページ(tags/[tag].vue)の記事一覧が、1ページをレンダリングするたびに queryCollection().all() で全記事約1,700本をDBから引き直している。記事ページ約1,570枚+タグページ553枚がそれぞれ全件クエリを走らせるので、実質O(N²)。ルート別のログ集計では、記事ページの平均レンダリング時間が3,018msから12,958msへ4.3倍に跳ねていた。

音声生成のせいだと思っていた

正直、最初に疑ったのはCPU競合だ。別セッションで音声生成を回しっぱなしにしていたから、デプロイとCPUを取り合ったに違いない、と。

切り分けてもらうと、この仮説は半分だけ当たっていた。同じコードで同日に回した2回のデプロイは、裏が静かだった朝の1回目が749秒、音声生成と並走した2回目が1,003秒。競合の上乗せは確かに1.3倍ぶんある。ただし、昨日の335秒から749秒への伸びはコードだけで起きている。決め手は全件クエリのないカスタムVueページで、487ms→495msとほぼ動いていなかった。CPUが取り合いになっていたなら、こちらも遅くなっていたはずだ。音声生成は悪化を1.3倍にした増幅要素であって、3倍化の主因ではなかった。

差分ビルドはできないのか

そこで聞いてみたのが差分検知だ。更新のない記事はスキャンしなくていいのだから、変わった記事だけビルドし直す形にできないか。

返ってきた答えは「今の構成では安全にできない」。このサイトはcloudflare-pages-staticプリセットで動いていて、プリレンダーしなかったルートは404になる。差分ビルドは2026-05-03に一度検討して、プリセット切替とセットでないと成立しないと結論を出していた。しかも今回の施策で関連記事とタグページが記事間の依存を作ったので、1記事の変更がどこまで波及するかの計算自体が危うくなっている。

ただ、このやり取りで問題が2つに割れた。全件スキャンは「どの記事をビルドし直すか」の話ではなく、「1ページ描画するたびに全記事をDBから引き直す」というレンダリング中のクエリの話だった。後者だけなら、差分検知がなくても、ビルド冒頭の1回の取得を全ページで共有すれば丸ごと消える。

音声生成を使っていなくても12分かかっていたデプロイが、これで6分〜8分に戻るなら十分な改善だ。そう判断して、計画書を作ってCodexのレビューまで回してもらうことにした。

計画書と「案B改」

計画書は、過去メモ・Webの定石・ChatGPT Proの3経路の調査を反映して memo/2026-07-19/prerender-fullscan-cache-plan.md にまとめてもらった。ChatGPT Proへの相談はブラウザ側のブリッジが未初期化で注入からやり直しになり、生成完了を待つ間にCodexの初回レビューが先に戻ってくる並走になった。

採用したのは「案B改」。全記事リストのクエリをビルドプロセス内で1回だけ実行し、タグ逆引き索引まで作って全ページで共有する。設計の要点は3つ。

  • 値ではなくPromiseをキャッシュする。同時に来た複数ページが同じ取得を待つので、クエリは1回しか走らない
  • 失敗したPromiseはキャッシュに残さない。残すと以後の全ページが同じ失敗を受け続ける
  • キャッシュするのはプリレンダー時(import.meta.prerender)だけ。devでは毎回取得して、編集の反映を保つ
// pages-lite.ts の核心: single-flight キャッシュ
let cache: Promise<PagesIndex> | null = null
return () => {
  if (!shouldCache()) return build() // dev では毎回取得
  if (!cache) {
    cache = build().catch((err) => {
      cache = null // 失敗は破棄して後続ページに再試行させる
      throw err
    })
  }
  return cache
}

見送った案は2つ。差分ビルド(案C)は前述の404リスクと依存計算の危うさで不採用。Nuxt公式の experimental.sharedPrerenderData は、効かせるには固定キーのuseAsyncDataに組み替える必要があり、そうすると全記事リストが全ページの _payload.json に埋め込まれてdistが激増するため、これも見送った。

期待効果はプリレンダー1,003秒→350〜450秒、総デプロイ26分→12〜14分。施策導入前の6分台まで戻らないのは、タグページ553枚が純増しているからで、そこはSEO施策として払うと決めたコストだ。

実装着手と、ついでに見つかった既存バグ

Codexの再レビューが致命的指摘なしで戻り、実装に入ってもらった。変更は新規ユーティリティ2本(app/utils/pages-lite.ts / app/utils/pages-index.ts)と既存ページ2ファイルの差し替え、ユニットテスト(tests/pages-lite.test.ts)の追加。pages-lite.tsはNuxt非依存の純粋関数群に切り出してあり、queryCollectionへの束縛はpages-index.ts側に寄せたので、キャッシュの挙動はVitestで直接テストできる。

検証スクリプトのドライランを回したところで、今日の変更とは無関係の既存バグが見つかった。公開記事19本が7/14の時点からプリレンダー対象に入っておらず、本番でずっと404になっていた。これも合わせて直してもらった。

テストとdevでの描画確認(関連記事5件・タグチップ・タグページ)までは通った。効果測定は次回のデプロイで数字を見る。

学び

  • 「遅くなった」の切り分けには、同一コードでの2回の計測が効いた。335秒→749秒がコード起因、749秒→1,003秒がCPU競合と、悪化が2層に分解できた
  • 全件クエリのないページが487ms→495msで止まっていたことが、CPU犯人説を捨てる根拠になった。疑わしい要因より先に、動いていないものを見る
  • 差分ビルドという発想は2つの問題を混ぜていた。「どのページをビルドするか」と「1ページの描画が何をするか」は別で、後者だけで3倍化の主因が消える
  • このテーマは3回目だった。2026-06-16の調査でも同じ全件スキャンを重い処理と特定し、当時はタグページごと消して解決していた。ページを復活させるとパターンも一緒に復活する。消すべきはページではなくクエリだった

次にやること

  • 次回デプロイでプリレンダー時間を計測し、1,003秒→350〜450秒の見込みと突き合わせる
  • 効果が足りなければ、tagIndex生成スクリプトに関連記事マップを同居させる事前計算(案A)へ進む