無人で回している定期タスクが、ビルドの途中で終わっていたことがあります。
真っ先に疑ったのはディスク不足でした。個人開発で App Store 向けのアプリと公開しているサイトを同じマシンでビルドしているため、ENOSPC は珍しい事故ではありません。けれどその時は空き容量が十分に残っていて、犯人はどうやら別にいました。
残る容疑者はメモリです。ビルドが一時的に数 GB を掴むと、同じマシンで動いている作業セッションごと巻き込まれます。
Claude Code v2.1.233 の変更履歴に、Bash ツールのコマンドを Linux の memory cgroup に入れる CLAUDE_CODE_TOOL_MEMORY_LIMIT が加わったと書かれていました。「暴走したビルドがセッションを止められないようにする」という説明です。
では自分の環境では上限をどこに置けばよいのか。そして上限を置けば本当にセッションを守れるのか。Ubuntu 22.04(カーネル 6.8)のサンドボックスで手を動かして確かめたところ、上限を置く場所によって「止まり方」がまったく別物になることが分かりました。
上限を「仮想アドレス空間」に置くか「実メモリ」に置くか
Linux でプロセスのメモリを縛る方法は、大きく2つに分かれます。
| 手段 | 縛る対象 | 超えたときの挙動 | 設定の単位 |
|---|---|---|---|
ulimit -v(RLIMIT_AS) | 仮想アドレス空間の確保量 | 確保の呼び出しが失敗する(プロセスは生きたまま) | シェルとその子プロセス |
cgroup v2 の memory.max | 実際に使っている物理メモリ | 回収しきれなければ OOM killer が SIGKILL を送る | cgroup に入れたプロセス群 |
言葉にすると近く見えますが、動かすと差がはっきり出ます。前者は「これ以上は確保させない」と申し出を断る仕組みで、後者は「使いすぎたら殺す」仕組みです。
断られたプログラムがどう振る舞うかは、そのプログラム次第です。ここが今回いちばん腑に落ちた点でした。
ulimit -v で止めた Python は、例外として捕まえられた
まず ulimit -v を使い、少しずつメモリを掴む Python を走らせました。
( ulimit -v 524288; python3 -c "
b = []
n = 0
try:
while True:
b.append(bytearray(4 * 1024 * 1024))
n += 4
except MemoryError:
print(n)
" )ulimit -v の単位は KiB なので、524288 は 512 MiB です。結果は3回とも同じでした。
492
492
492
上限 512 MiB に対して、実際に確保できたのは 492 MiB。差の 20 MiB ほどはインタプリタ自身が先に使っている分です。上限を 256 MiB にすると 232 MiB まで、1024 MiB にすると 1000 MiB まで確保できたので、この目減り分はおおむね一定でした。
大事なのは数字よりも、MemoryError として捕まえられたことです。プロセスは殺されず、except に入って自分で後始末できました。終了コードも自分で決められます。
つまり、Python で書いた処理スクリプトなら、ulimit -v は「行儀のよい失敗」を作れる手段になります。
同じ制限で、Node は起動すらできませんでした
同じ 512 MiB を Node.js(v22.22.3)に掛けたところ、様子がまったく違いました。
( ulimit -v 524288; node -e "console.log('started')" )started は表示されず、終了コードは 133。標準エラー出力の先頭にはこう書かれていました。
# Fatal process out of memory: Failed to reserve virtual memory for CodeRange
try / catch は一切通っていません。V8 が起動時にまとめて仮想アドレス空間を予約するため、ビルドが始まる前に Node 自体が落ちていたのです。
どこが境目なのか、刻んで探しました。
ulimit -v | node -e "0" の終了コード |
|---|---|
| 512 MiB | 133(起動失敗) |
| 640 MiB | 133(起動失敗) |
| 704 MiB | 133(起動失敗) |
| 768 MiB | 0(起動する) |
| 896 MiB | 0(起動する) |
この環境では 768 MiB が起動の下限でした。空の node -e "0" を動かすだけで、これだけの仮想アドレス空間が要ります。
なお上限を 2 GiB まで上げると、今度は確保の失敗をきちんと捕まえられました。
caught@1056MiB Array buffer allocation failed
起動さえ通れば、実行中の確保失敗は例外になります。落ちていたのは起動時の予約だけでした。
ここから引き出せる結論は、実務的にはかなり強いものです。Node のツールチェーン(npm・Vite・webpack・Metro など)に ulimit -v で低い上限を掛けると、暴走を止めるどころか、正常なビルドまで起動時に殺してしまいます。 「メモリを 512 MB に制限してビルドさせよう」という素朴な発想は、この時点で成立しません。
CLAUDE_CODE_TOOL_MEMORY_LIMIT が ulimit ではなく cgroup を選んでいるのは、この差を踏まえれば自然な設計だと感じました。
cgroup で止めると、失敗は「例外」ではなく SIGKILL になります
cgroup v2 の memory.max は物理メモリの使用量を見ます。仮想アドレス空間の予約は素通りするので、Node は普通に起動します。そのうえで、実際に使いすぎたときだけ OOM killer が働きます。
このとき送られるのは SIGKILL です。捕まえられません。シェルから見た終了コードは 137(128 + 9)になります。
bash -c 'sleep 20 & P=$!; kill -9 $P; wait $P'
echo $?137
運用する側から見ると、この違いはログの読み方に直結します。
- 終了コード 137 が出たら、まずメモリを疑う
- 133 のような起動時の即死は、仮想アドレス空間の制限やコンテナ設定を疑う
MemoryErrorなどの例外がログに残っているなら、プロセス自身は生きていた
再試行の設計も変わります。SIGKILL された処理は途中経過を残せないので、そのまま再実行すると同じ場所でもう一度殺されるだけです。上限を上げるか、並列数を下げるか、どちらかを選ぶ判断が先に要ります。
ひとつ正直に書いておきます。今回のサンドボックスは非特権ユーザーで動いており、/sys/fs/cgroup 配下にディレクトリを作ろうとすると Permission denied になりました。systemd-run --user もユーザーバスがなく使えません。そのため cgroup 側の停止は、終了コードの規約を確認するところまでに留めています。自分のマシンで試す場合は、systemd-run --user --scope -p MemoryMax=2G -- npm run build のように、まず捨ててよいビルドで一度殺されてみるのが確実です。
もうひとつ、2026年8月16日時点の状況として。CLAUDE_CODE_TOOL_MEMORY_LIMIT は変更履歴には記載がありますが、環境変数のリファレンスページにはまだ載っていません。値の書式は変わる可能性があるため、設定する前に一次情報を確認してください。この記事で断定しないのはそのためです。
自分の環境で最初に決める2つのこと
上限を入れると決めたら、決めるべきことは2つだけでした。
1つめは、上限値そのものです。 単体のビルドが平常時にどれだけ使うかを一度測り、その 1.5〜2 倍を置くのが出発点になります。加えて、同時に走りうるビルドの本数を掛けた合計が、マシンの実メモリを超えないことを確かめます。無人タスクを複数並べている環境では、この「掛け算」を忘れると、上限を入れたのにマシン全体が苦しくなります。
2つめは、失敗の見え方です。 137 で終わったコマンドを、ログのどこに、どう残すか。無人で回している場合、ここを決めていないと「なぜか終わっていた」という朝を繰り返します。無音の失敗そのものを止める設計は「成功」と記録されたのに成果がゼロだったで扱っています。Bash ツール側のエラーを切り分けたい場合はClaude Code の Bash ツール実行エラーの原因と対処が近い話です。
今日からできる最初の一歩としては、いちばん重いビルドを一度だけ /usr/bin/time -v 付きで走らせ、Maximum resident set size を控えておくことをおすすめします。上限値の議論は、その1行がないと始まりません。
私自身、ディスクの方ばかり見ていてメモリを測っていませんでした。同じ抜けをお持ちの方の役に立てば嬉しいです。お読みいただきありがとうございました。