Vitestがメモリ不足で落ちる時にparallelの設定を見直す

公開日:
目次

Vitestのテストが FATAL ERROR: ... JavaScript heap out of memory で突然落ち始めることがあります。自分のプロジェクトでもテストファイルが増えてきたタイミングで踏みました。原因の多くは並列実行の設定で、poolmaxWorkers あたりを見直すだけで解消できたので、何を触れば直るかを整理します[1]

なぜVitestがメモリ不足で落ちるか

Vitestはテストファイルを並列で動かします。デフォルトの poolforks で、ファイルごとに子プロセスを立ち上げて実行する仕組みです[2]

並列度はCPUのコア数に応じて自動で決まります。8コアのマシンなら最大で7プロセスほどが同時に立ち上がり、それぞれが独立したNode.jsプロセスとしてヒープを抱え込みます。

V8のデフォルトのヒープ上限はおよそ1.5GBで、8並列なら理論上12GB近く食う可能性があります。メモリ8GBのCIコンテナだとこの時点で詰まり、heap out of memory で1ワーカーが落ち、結果としてテスト全体が失敗します。

つまり「テストが重くなった」のではなく「並列度がマシンのメモリに対して大きすぎた」というのが、多くの場合の正体です。

pool を用途に合うものに固定する

Vitest 1.x 以降、pool には4種類あります。CIの安定性とメモリ使用量はここの選択でかなり変わります[3]

  • forks(デフォルト): 子プロセスで動かす。互換性が高い
  • threads: スレッドで動かす。起動は速いが、bcryptやcanvasのようにC++拡張に依存する依存と相性が悪い
  • vmThreads: Node.jsの仮想実行領域内で動かす。速いが副作用が残りやすい
  • vmForks: vmThreadsの子プロセス版

ネイティブな依存を使っている、もしくは process.chdir を使うようなテストがある場合は forks が無難です。逆に純粋なJSのみで構成されていて起動時間を縮めたいなら threads に寄せる、というのが判断の目安になります。

vmThreads / vmForks は速度が出ますが、Vitestのドキュメントでも「VMモジュールが不安定でメモリリークの懸念がある」と明記されています。原因不明のメモリリークに悩まされているなら、まず forksthreads に戻すのが先決です。

// vitest.config.ts
import { defineConfig } from 'vitest/config'

export default defineConfig({
  test: {
    pool: 'forks',
  },
})

maxWorkers で並列度を絞る

pool を選んだら、次は同時に動かす数を制限します。maxWorkers でワーカー数の上限を、minWorkers で下限を指定できます。値は数値(個数)か '50%' のような割合の文字列です。

test: {
  pool: 'forks',
  maxWorkers: 2,
  minWorkers: 1,
}

CIで使うときは数値で固定するほうが事故りにくいです。GitHub ActionsのubuntuランナーはコアごとにvCPUが付いていて、os.cpus().length が想定より多く返ることがあり、割合指定だとマシンが入れ替わったタイミングで挙動が変わります。

ローカルでは速度を取りに行きたいが、CIではメモリを抑えたい、ということもよくあります。その場合は環境変数で出し分けるのが楽です。

test: {
  maxWorkers: process.env.CI ? 2 : undefined,
}

fileParallelism で一旦並列を止める

「とにかくいま落ちている」「再現条件を切り分けたい」というときは、ファイル並列を丸ごと止めるのが効きます。

test: {
  fileParallelism: false,
}

これを設定すると maxWorkers の指定に関わらず自動で1ワーカーに固定され、テストファイルは順番に実行されます[4]。並列が原因かどうかの切り分けに使いやすい設定です。

順次実行に切り替えれば速度は落ちますが、データベース接続のような共有資源にテストごとに触っているプロジェクトでは、この設定のほうが結局速く・安定して回ることもあります。

VM系poolを使っているなら memoryLimit

どうしても vmThreads / vmForks を使い続けたい場合は、poolOptions.vmThreads.memoryLimitpoolOptions.vmForks.memoryLimit でVMあたりのメモリ上限を絞れます。

test: {
  pool: 'vmForks',
  poolOptions: {
    vmForks: {
      memoryLimit: '512MB',
    },
  },
}

VM内で実行することでテストファイル間でワーカーを使い回す設計のため、メモリが蓄積しやすい性質があります。上限を絞れば、ワーカーが上限に達した時点で再起動され、結果としてヒープが定期的にリセットされます。

最後の手段としてNode.jsのヒープ上限を上げる

ここまでで解決しない、または「並列度は落としたくない」というときの最後の手段が、Node.js自身のヒープ上限を上げることです。

NODE_OPTIONS='--max-old-space-size=4096' vitest

--max-old-space-size の単位はMBで、上の例は1ワーカーあたり4GBまでヒープを使える設定です。Node.jsプロセス1個ごとの上限なので、maxWorkers=4--max-old-space-size=4096 なら最大で16GBほど使う計算になります。マシンの物理メモリを意識して決める必要があります。

これは根本解決ではないので、メモリ消費が本当に妥当か(テストの中で巨大なオブジェクトをモジュールスコープに保持していないか、beforeAll で確保したものを開放していないか、など)を後で見直す前提で使うのが安全です。

まとめ

並列度の設定だけで直る場面は意外と多いですが、これを繰り返し踏むようなら、テストが特定のグローバル状態を握り続けているサインだったりもします。vitest run --logHeapUsage でテストファイルごとのヒープ使用量を見ると、特定のファイルだけ突出していることがあり、そこから本当のリーク源にたどり着けることがあります。Vitestの設定で一旦しのいだあとは、テスト自体に踏み込んだ調査もセットで進めると、根本対処につながります。

脚注
  1. Parallelism - Vitest ↩︎

  2. pool - Vitest Config ↩︎

  3. Improving Performance - Vitest ↩︎

  4. fileParallelism - Vitest Config ↩︎