CDK snapshotテストでJestがKilledされる、原因はOOM killerだった

  • Jest
  • CDK
  • Testing
  • Node.js

この記事は、構成と文章の整理にAIを使っています。
あくまで読みやすくするための補助で、内容そのものをAIにすべて任せて書いているわけではありませんが、ところどころ相談しながら整えています。

CDKのsnapshotテストをjest --updateSnapshotで回してたら、エラーメッセージも出さずにKilledとだけ表示されてプロセスが落ちた。エラースタックがないので最初は原因が分からなかった。

何が起きたか

jest --silent --passWithNoTests --updateSnapshotを実行すると、途中でKilledとだけ出て終了する。Jest自体が例外を吐いているわけではなく、プロセスが外側から強制終了されている挙動。

原因

Killedはほぼ確実にOSのOOM killer(Linuxのカーネル機能)による強制終了。Dockerやコンテナ環境ではcgroupでメモリ上限が管理されていて、プロセス(と子プロセス)の合計メモリがその上限を超えると問答無用でkillされる。

Jestはデフォルトでテストを複数の子プロセス(worker)に分散して並列実行する。CDKのsynth処理はテストごとにCloudFormationテンプレート全体を生成するので、並列数が増えるほど子プロセスの合計メモリも増える。CDK + snapshotの組み合わせだとここが特に刺さりやすいらしい。

対策

優先順位が高い順に試した。

1. 並列数を落とす

CDKのsynthは並列化しても速くならないことが多く、むしろメモリだけ増える傾向がある。

ターミナル
jest --silent --passWithNoTests --updateSnapshot --runInBand

--runInBandは子プロセスを作らず、メインプロセス1つでテストを直列実行する。子プロセスがない分、cgroupの合計メモリがそのまま1テスト分の使用量で収まる。

2. Node側のヒープ上限を上げる

ターミナル
NODE_OPTIONS=--max-old-space-size=4096 jest --silent --passWithNoTests --updateSnapshot --runInBand

NODE_OPTIONSに設定しておくと、forkされる全プロセスに確実に反映される。4096(MB)から試して、それでも落ちるならコンテナ側のメモリ割り当てが足りていない可能性が高い。

3. メモリ使用量を可視化する

原因の切り分けには--logHeapUsageが使えるが、これ単体では機能しない。--runInBandと、node側の--expose-gcを併用する必要がある。

ターミナル
node --expose-gc node_modules/.bin/jest --runInBand --logHeapUsage --updateSnapshot

4. Docker assetのhash揺れを減らす

CDKでDockerイメージアセットを使っていると、synthのたびにハッシュが微妙に変わってsnapshotの差分が出やすくなる。差分が出るたびに-uが必要になり、そのぶん重い処理(Dockerビルドを伴うsynth)が頻繁に走ってメモリも食う。

テスト用のフラグでDockerビルドを迂回する、もしくはダミーのイメージ参照に差し替えるとsynthが軽くなり、hash揺れも消える。

5. snapshot全体比較をやめて部分アサーションに寄せる

CDKの公式ドキュメントでも、snapshotテスト(テンプレート全体比較)はコード変更と関係ない差分でも落ちることがあり、リグレッション検知としては向いていないと書かれている。

import { Template, Match } from 'aws-cdk-lib/assertions';
template.hasResourceProperties('AWS::Lambda::Function', {
Runtime: Match.stringLikeRegexp('nodejs'),
});

hasResourcePropertiesやhasResourceで必要な箇所だけ検証する形にすると、巨大なJSON全体を保持しなくて済むのでメモリにも効く。

結論

--runInBand + NODE_OPTIONS=--max-old-space-sizeの組み合わせでまず安定した。それでも落ちる場合はコンテナ側のメモリ割り当てが根本原因になっていることが多い。

あと、そもそも--updateSnapshotを毎回のCI実行に含めず、手元で明示的に叩くときだけに絞るのも有効だった。重い処理の頻度自体を減らせる。