はじめに
GitHub Actions の CI が特定の shard で不定期に OOM(Out of Memory)により失敗する問題が発生しました(´・-・`)
- 環境:GitHub Actions / Node.js v22 / Jest
- 原因:巨大 HTML のテスト展開 + 4096MB が v22 デフォルト以下
-
解決:
--max-old-space-sizeを 4096 → 5120 に変更
原因調査
失敗は特定の shard(テストを複数に分割して並列実行する単位)のみで発生し、他の shard は安定していました。再実行すると通ることも多く、不安定な状態が続いていました。
GitHub Actions のログを確認すると、失敗した shard に以下の出力がありました。
<--- Last few GCs --->
[2503:0x3fd07000] 318472 ms: Mark-Compact 4038.4 (4134.0) -> 4027.9 (4139.0) MB
[2503:0x3fd07000] 324431 ms: Mark-Compact 4043.8 (4139.3) -> 4032.7 (4144.0) MB
FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory
FAIL tests/.../SomeImageManager.test.ts
● Test suite failed to run
A jest worker process (pid=2503) was terminated by another process: signal=SIGKILL
<--- Last few GCs ---> 以下は直前に実行された V8 の GC ログです。
Mark-Compact はヒープ全体を対象に「到達可能なオブジェクトをマークし、フラグメンテーションを解消するためにメモリを詰め直す」V8 の GC 処理です。
4038.4 (4134.0) -> 4027.9 (4139.0) MB は「GC 前の使用量(確保量)→ GC 後の使用量(確保量)」を示しています。使用量は 4038MB → 4027MB とほとんど減っていません。
GC 前後でメモリがほとんど変わらないのは、ヒープが生存オブジェクトでほぼ埋まっていたためです。「GC が間に合わなかった」のではなく、そもそも回収できるメモリが残っていなかったのです。最終的にヒープ上限に達して FATAL ERROR が発生しました。
OS は SIGKILL でワーカープロセスを強制終了しています。原因は OOM(Out of Memory) でした。
ところが、CI は即座に失敗せず長時間止まり続けていました。
🥕 補足
GC(ガベージコレクション)とは、不要になったメモリを自動的に回収する仕組みです。
V8(Node.js の JavaScript エンジン)が自動的に実行します。
なぜメモリが足りなかったのか
OOM の直接原因は、複数の変更が積み重なった結果でした。
大きな HTML を beforeEach で繰り返していた
OOM が発生したテストファイルは、beforeEach で次のようなコードを実行していました。
beforeEach(() => {
document.body.innerHTML = pageHtml;
});
pageHtml は実際のページ HTML をそのまま定数化したもので、23,000 行・約 1.6MB ありました。
beforeEach に記述されているため、テストケースごとに毎回 DOM の構築・破棄が繰り返されます。jsdom のパースが積み重なることで、1 テストファイルだけで大量のメモリを消費します。
設定した上限値がデフォルトを下回っていた
Node.js を v22 にアップグレードしました。v22 の 7GB RAM 環境でのデフォルトヒープは 約 4144MB です。
# Node.js v22 / 7GB RAM 環境での実測値
$ node -e "console.log(require('v8').getHeapStatistics().heap_size_limit / 1024 / 1024, 'MB')"
4144 MB
以前、メモリ消費が問題になったとき、v18 のデフォルト(約 2GB)より余裕を持たせるために --max-old-space-size=4096 を設定していました。ところがこの値は v22 のデフォルト(4144MB)をわずかに下回っています。
Node.js v22 デフォルト: 4144MB
設定値: 4096MB ← デフォルトより 48MB だけ低い
「上限を設けて安定させる」つもりが、何も設定しないより低い上限を明示的に課す結果になっていました。
CI が止まり続けた理由
Jest はワーカープロセスを使ってテストを並列実行します。
OOM 発生時の挙動は次のようになります。
- ワーカー A: 重いテストを実行中
- ワーカー B: 別のテストを実行中
- ワーカー A: OOM でクラッシュ(SIGKILL)
- Jest メインプロセス: ワーカー A の異常終了を検知
- Jest: ワーカー A が担当していたテストをエラー(失敗)として記録
- ワーカー B: メモリ不足状態でテストを継続
↓ GC が頻繁に発動し続け、テストが止まっているように見える - Jest: 全テスト完了を待ち続ける
Jest はデフォルトではワーカーがクラッシュしてもテストを継続します。ステップ 5 でテストが失敗として記録された後も、ワーカー B が残りのテストを実行し続けます。
しかし、メモリ不足状態では V8 GC が頻繁に発動し続けるため、Jest が全テストの完了を待ち続け、CI が長時間止まっているように見えました。
再実行すると通ることがあるのは、GC の発動タイミングが非決定的であるため、たまたまメモリが間に合えば成功するためです。
🥕 補足:Jest の bail オプション(設定ファイルまたは CLI)を使うと、失敗した時点でテストを停止できます。
解決方法
変更内容
package.json の CI 用スクリプトで、V8 のヒープ上限を 4096MB から 5120MB に引き上げます。
- NODE_OPTIONS='--max-old-space-size=4096'
+ NODE_OPTIONS='--max-old-space-size=5120'
NODE_OPTIONS は Node.js にコマンドラインオプションを渡す環境変数です。
--max-old-space-size は V8 のヒープ上限をメガバイト単位で設定するオプションで、NODE_OPTIONS 経由で指定することで、スクリプト内のすべての Node.js プロセスに一括適用できます。
値の選び方
GitHub Actions の標準ランナー(ubuntu-latest)はプライベートリポジトリで 8GB(8192MB)RAM です。5120MB はその約 63% にあたり、OS・他プロセス用に約 3GB の余裕があります。
Node.js 公式ドキュメントによると、メモリ消費がヒープ上限に近づくほど V8 は GC に多くの時間を費やします。制限値に余裕を持たせることで GC が効率的に動作します。
おわりに
- 問題:CI の特定 shard でヒープメモリが枯渇し、OOM でワーカーがクラッシュしていた
-
解決:
--max-old-space-sizeを 4096MB → 5120MB に引き上げ
Node.js のメジャーバージョンアップを行った際は、既存のヒープ設定が今の環境で意図通りに機能しているかを確認してみると良さそうです。
また、今回の根本には beforeEach で大きな HTML を毎回パースしているテストの書き方もあります。ヒープ上限の引き上げは応急処置であり、テストの書き方も合わせて見直す余地があります。
なお、この記事は調査段階でまとめたもので、ヒープ上限の引き上げはまだ CI に適用しておらず、効果は確認できていません。
参考

