pnpm generate が exit 134 で落ちた日——ヒープOOMと検証スクリプト偽陽性の二段落ち

開発未分類メモ

pnpm generate が exit 134 で落ちた日——ヒープOOMと検証スクリプト偽陽性の二段落ち

結論

このサイト(Nuxt 3 / SSG / Cloudflare Pages)のデプロイが同じ朝に2回、別々の理由で落ちた。

  1. 1回目: exit 134。V8のヒープが上限4GBに到達したOOM(out of memory)だった。NODE_OPTIONS=--max-old-space-size を4096から8192に引き上げて解決した
  2. 2回目: ビルド時検証スクリプトの偽陽性。prerenderは完走したのに、postgenerateの verify-blog-payload.mjs が「当月の記事16本がブログページに載っていない」と報告して exit 1。実装側が持つ除外リストに検証側が追随していないのが原因で、基準を揃えて解決した

exit 134 を見たらビルドログの最終行ではなく、その少し上にある Last few GCs を探す。 そこに Reached heap limit があれば、コードのバグではなくメモリ上限の問題である。

何が起きたか

タイムラインは次のとおり。

時刻出来事
08:23デプロイスクリプト実行。Nuxt Setup・Client Build・Server Build まで成功
08:31Prerenderer 初期化直後に exit 134 で異常終了
08:46ヒープ上限を8GBに引き上げて再実行
08:57prerender 2,457ルート完走。ただし postgenerate の検証で exit 1
09:05検証スクリプトの偽陽性を修正し、全検証グリーン

エラー1: exit 134 = JavaScript heap out of memory

ログの読み方

ビルドログの末尾はこうなっていた。

[nitro] ℹ Initializing prerenderer

<--- Last few GCs --->
Mark-Compact 4033.7 (4135.8) -> 4031.0 (4133.8) MB, ... allocation failure;
Mark-Compact 4046.7 (4133.8) -> 4046.0 (4165.8) MB, ... allocation failure;

FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory
ELIFECYCLE  Command failed with exit code 134.

読み取れることは3つある。

  • Client Build(5,908モジュール)と Server Build(4,081モジュール)は成功しており、コンパイルの問題ではない
  • ヒープ使用量が 4,033〜4,046MB で頭打ちになっている。--max-old-space-size=4096 の設定値とぴったり一致する
  • exit 134 は「128 + 6(SIGABRT)」で、V8が自らプロセスを中断したときの終了コードである

なぜ上限が4096だったのか

この4096という値には歴史がある。 2026年1月、GitHub Actions(メモリ7GBのランナー)で pnpm generate がOOMを連発し、試行錯誤の末に NODE_OPTIONS=--max-old-space-size=4096 を package.json に直接埋め込んで一時しのぎした。 最終的にはActionsでのビルドを諦め、ローカルビルド+wrangler直接デプロイに移行している(経緯は SSGスケーラビリティ課題とローカルデプロイ移行 に書いた)。

当時はこの値で通っていた。 しかし半年でコンテンツと学習ツールページが増え、prerenderルートは2,457件になった。 今朝そこに security-cases の詳細321ルートを足したことが引き金になり、ローカル(物理メモリ32GB)でも4GBの壁を踏み抜いた。

修正

package.json の generate スクリプトを1か所変えるだけで済んだ。

- "generate": "cross-env NODE_OPTIONS=--max-old-space-size=4096 nuxt generate",
+ "generate": "cross-env NODE_OPTIONS=--max-old-space-size=8192 nuxt generate",

物理メモリ32GBの手元マシンなら8GBヒープは問題なく確保できる。 再実行でprerenderは完走した。

なお「ビルド(transform)は通るのにprerenderer初期化で死ぬ」のはこの障害の典型パターンである。 Viteのビルドで確保したメモリを抱えたまま、Nitroがサーバーバンドルとルート一覧をメモリに展開する瞬間がピークになる。

エラー2: verify-blog-payload の偽陽性

症状

ヒープを増やした再ビルドでprerenderは完走したが、今度はpostgenerateの検証スクリプトが落ちた。

[verify-blog-payload] calendar month 2026-07: 32/48 content articles linked in HTML
✖ blog payload checks failed:
  - 16 article(s) in calendar month 2026-07 are missing from /blog page HTML
    (e.g. 2026-07-02 /genai-ideas/analysis-stock, ...)

このスクリプトは、過去に本番で起きた「payloadシリアライズで記事一覧がnullに化け、ブログカレンダーが空になる」事故の再発を検知するために置いてある。 「content/ にある当月記事が /blog ページのHTMLからリンクされているか」を突き合わせる仕組みである。

原因: 実装と検証が同じ基準を別々に持っていた

missing と報告された16本は、すべて /genai-ideas/ 配下の連載記事だった。

  • ブログ一覧を組み立てる useBlogArticles.ts は、/takken/(教材)と /genai-ideas/(連載)を意図的に一覧から除外している。どちらも専用のハブページから読む独立シリーズという設計で、ブログカレンダーに出ないのは仕様である
  • 一方、検証スクリプト側の除外リストは /takken/ だけだった。/genai-ideas/ の除外を実装に足したとき、検証側の更新が漏れていた

この連載の記事16本が公開されたのは7月2〜3日だった。 それらを含む最初のビルドが今朝だったため、今日まで誰もこの地雷を踏まなかった。

修正

検証スクリプト側の除外リストを実装と同じにした。

// scripts/verify-blog-payload.mjs
const STANDALONE_PATH_PREFIXES = ['/takken/', '/genai-ideas/']
const isStandalonePath = (p) => STANDALONE_PATH_PREFIXES.some((prefix) => p.startsWith(prefix))

あわせて、useBlogArticles.ts 側の除外フィルタに「ここを増減したら verify-blog-payload.mjs も揃えること。揃えないと generate が偽陽性で落ちる」というコメントを入れ、双方から相手を指すようにした。

同じ基準を2か所で持つ構造は、片方だけが更新されていつか必ず食い違う。 本来は定義を1か所にまとめるのが筋だが、アプリ側はTypeScript、検証側は素のNodeスクリプトで実行環境が違うため、今回は相互ポインタコメントで運用することにした。

おまけ: パイプは exit code を隠す

再ビルドを pnpm generate 2>&1 | tail -60 の形で走らせていたため、シェルに返る終了コードが tail の 0 になり、監視上は「成功」に見えていた。 ログ本文を読んで、実際はexit 1で落ちていたことに気づいた。

ビルドコマンドの成否を終了コードで判定するなら、パイプで包まないか、bashなら set -o pipefail を先頭に置く。

再発防止チェックリスト

  • exit 134 を見たら Last few GCs を探す(Reached heap limit ならヒープ上限の問題)
  • prerenderルート数が増えたらメモリのピークも伸びる前提でヒープ上限を見直す(今回4096→8192)
  • 実装とビルド時検証が同じリストを二重に持つ箇所には、双方向のポインタコメントを書く
  • ビルドコマンドをパイプで包むときは pipefail を意識する
  • ルート数がさらに増えて8GBに近づいたら、prerenderの分割やルート削減を検討する