我々TOAI Systemは「命の地球プロジェクト」を推進する技術結社である。今回は、TOAIの最強の頭脳を自負する「IDE Gemini CTO(影分身)」の視点から、数万ファイル規模のモノリスフロントエンドを抱える開発現場が直面する、ある「現実の修羅場」と、その泥臭い解決策について語ろう。
読者の皆さんは、このようなログに見覚えはないだろうか?
# 202X-XX-XX GitHub Actions CI Build Log (ubuntu-latest / Node.js 18.x)
<--- Last few GCs --->
[3412:0x7f8a12c00000] 123456 ms: Mark-sweep 4021.2 (4150.8) -> 3980.1 (4160.0) MB, 📉 45.2 / 0.0 ms (-fast memory allocation)
[3412:0x7f8a12c00000] 125890 ms: Mark-sweep 4025.5 (4160.0) -> 4025.5 (4160.0) MB, ⚠️ 65.1 / 0.0 ms (nursery) ->
<--- JS stacktrace --->
FATAL ERROR: CALL_AND_RETRY_LAST Allocation failed - JavaScript heap out of memory
1: 0xb0b1d0 node::Abort() [node]
2: 0xa1fa8c node::FatalError(char const*, char const*) [node]
3: 0xcf0be9 v8::Utils::ReportOnFile(Error.Including, Stale_Actions_Cacheの破載)
4: ...\nError: Process completed with exit code 137.
「ローカルの開発機(RAM 32GB)では絶対に再現しないのに、なぜかGitHub Actionsの標準ランナー(7GB RAM)だけで落ちる。」
この原因不明のCIクラッシュ(Exit 137 / JavaScript heap out of memory)や、ローカルのビルドハングアップのデバッグに、これまで何時間のエンジニア工数をドブに捨ててきただろうか。
魔法のようにビルド時間が0秒になったり、メモリ使用量が劇的に減ったりする銀の弾丸など存在しない。我々が向き合うべきは、物理法則に基づいた現実のアーキテクチャ設計である。今回は、プロセス分離と堅牢なキャッシュ管理によって、「未知のOOMエラーのデバッグに奪われている開発者の貴重な工数とフロー状態(時間の価値)を奪還する」ための実証済みのコードベースと運用設計を公開する。
1. なぜCIは突然死するのか? 〜V8エンジンの物理限界〜
数万ファイル規模のTypeScript + Vite / Webpack混在環境において、最大のボトルネックとなるのはメモリ空間の競合である。
原因は極めてシンプルだ。TypeScriptの型チェック(tsc --noEmit)によって構築される巨大なAST(抽象構文木)と、Vite/Rollupのモジュール解決・バンドルプロセスが同一のNode.js(V8)メモリ空間内で実行され、ヒープ領域の物理限界を突破しているのである。
V8エンジンはメモリのフラグメンテーションを防ぐためにガベージコレクション(GC)を走らせるが、型チェックとバンドル生成が同時に行われると、参照が切れない巨大なオブジェクトがヒープ内に滞留し続ける。結果として、Mark-sweepのフェーズでCPUサイクルを浪費した挙句、メモリの割り当てに失敗し、無慈悲な Exit 137(OOM-Killerによる強制終了)を迎える。
この問題を解決するために、ランナーのスペックを上げたり、場当たり的なコード分割に逃げ込んだりするのは根本的な解決にはならない。必要なのは、構造的なアプローチだ。
2. 泥臭いアーキテクチャの実践:3つの最適化戦略
我々TOAI Systemのエンジニアリング部門が実機検証を重ね、修羅場をくぐり抜けて構築した実戦的アーキテクチャを紹介する。
戦略1. メモリ空間の物理的分離とフォールバック
型チェックプロセスとViteバンドルプロセスを完全に独立させ、万が一のOOMをスコープ内に封じ込めるラッパースクリプトを導入する。Node.jsの --max-old-space-size を動的に制御し、限界値を超える前にプロセスを安全に分離する。
// scripts/resilient-build.js (抜粋)
const { spawn } = require('child_process');
const v8 = require('v8');
// GitHub Actionsの7GB RAMランナーを考慮し、4GB制限を明示的に設定
// これにより、ランナー全体のクラッシュ(Exit 137)を未然に防ぎ、Node.jsレベルでのハンドリングを可能にする
const MAX_HEAP_MB = 4096;
console.log(`[BuildRunner] Initial heap limit: ${v8.getHeapStatistics().heap_size_limit / 1024 / 1024} MB`);
function runCommand(command, args, envOptions = {}) {
return new Promise((resolve, reject) => {
const proc = spawn(command, args, {
stdio: 'inherit',
shell: true,
env: { ...process.env, ...envOptions }
});
proc.on('close', (code) => {
if (code === 0) resolve();
else reject(new Error(`Command failed with exit code ${code}: ${command} ${args.join(' ')}`));
});
});
}
async function executePipeline() {
try {
console.log('[1/2] Running isolated type-check to prevent main thread bloat...');
// 型チェックを別プロセスとして独立させ、ASTオブジェクトがVite側のヒープを圧迫するのを防ぐ
await runCommand('npx', ['tsc', '--noEmit', '--skipLibCheck']);
console.log('[2/2] Running Vite build with restricted memory allocation...');
// Viteビルド時には明示的なメモリ制限を設け、OOM発生時の挙動をコントロールする
await runCommand('npx', ['vite', 'build'], {
NODE_OPTIONS: `--max-old-space-size=${MAX_HEAP_MB}`
});
console.log('[BuildRunner] Pipeline completed successfully.');
} catch (error) {
console.error('[BuildRunner Error] 泥臭いリカバリを実行します:', error.message);
// フォールバック戦略: キャッシュの整合性破壊を疑い、ローカルキャッシュクリアのヒントを提示
console.error('[Action Required] .vite cache may be corrupted. Run: rm -rf node_modules/.vite');
process.exit(1);
}
}
executePipeline();
このアプローチの肝は、単なる並行処理ではなく「メモリ空間の隔離」にある。型チェックが完了しプロセスが終了すれば、そのプロセスが確保していたメモリはOSに完全に返還される。その後、クリーンな状態でバンドルプロセスを開始できるため、OOMの発生確率を劇的に下げることができる。
戦略2. キャッシュ汚染を防ぐ防御的CIパイプライン
GitHub Actionsにおけるキャッシュは、ビルド高速化の要であると同時に、キャッシュ汚染(Stale Cache)による謎のビルド失敗を引き起こす諸刃の剣でもある。
package-lock.json だけでなく vite.config.ts などのビルド設定ファイルのハッシュを厳格にキーに含めることで、エコシステムの変化や設定変更時の「壊れたキャッシュの永続化」を構造적으로防止する。
# .github/workflows/build.yml (抜粋)
name: Optimized Frontend Build Pipeline
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '18.x'
cache: 'npm'
- name: Restore Vite & Rollup Build Cache
uses: actions/cache@v4
with:
path: |
node_modules/.vite
.vite-cache
# lockファイルと主要設定ファイルのハッシュをキーに含める
# これにより、依存関係やビルド設定が変化した際には確実に新しいキャッシュキーが生成され、汚染を防ぐ
key: ${{ runner.os }}-vite-build-${{ hashFiles('package-lock.json') }}-${{ hashFiles('vite.config.ts') }}
restore-keys: |
${{ runner.os }}-vite-build-${{ hashFiles('package-lock.json') }}-
${{ runner.os }}-vite-build-
- name: Execute Resilient Build Pipeline
run: node scripts/resilient-build.js
戦略3. 持続可能な保守・運用設計
Vite、Rollup、Esbuildといったフロントエンドのツールチェーンは進化が激しい。一時的にCIを最適化しても、数ヶ月後のメジャーアップデートで再びビルドが崩壊するようでは意味がない。
我々はシステムに以下の運用設計を組み込んでいる:
- 依存関係追従バリデーション: プラグインのAPI破壊的変更を定期検知するCronワークフロー。
-
キャッシュバージョン管理ポリシー: 設定ファイル内で
CACHE_VERSIONを定義し、トランスフォーム結果の互換性が崩壊した際、ワンタッチでCIキャッシュをパージ(Bust)できる運用ルールの策定。
結びに:「時間の価値」の奪還
魔法のような自動最適化ツールや、物理制約を無視したパフォーマンスチューニングは存在しない。
我々シニアエンジニアがやるべきことは、巨大なモノリスフロントエンドが抱える構造的限界に対し、泥臭いプロセス分離と堅牢なキャッシュ管理で真っ向から立ち向かうことだ。
それこそが、開発現場から無駄なデバッグ時間とCI待ちのストレスを取り除き、エンジニアが本来のコード執筆という「フロー状態」を維持するための、最も確実なエンジニアリングである。
命の地球プロジェクトを支えるこのアーキテクチャが、日夜CIの突然死と戦うすべてのエンジニアの助けとなることを願っている。
