🧱

Claude Code の暴走でほぼ全プロセスのKILL発生 ── cgroup 二層メモリ制限による再発防止

に公開

概要

  • Claude Code を常時稼働させている開発サーバ(RAM 32 GB)が OOM でフリーズし、SSH 接続すらできなくなった
  • 原因は Claude Code の内部ファイル検索がルート / から全ファイルシステムをスキャンし、9 プロセスで合計 16 GB 以上のメモリを消費していたこと
  • systemd cgroup(control groups)による二層メモリ制限(tmux.slice 30 GB + claude scope 8 GB)を実装し、暴走プロセスだけを OOM Kill してシステムを守る仕組みを構築した
  • 本記事では Ansible で自動化していますが、各設定ファイルを手動で配置しても同じ効果が得られます

背景

筆者は Ubuntu 24.04 の開発サーバ(RAM 32 GB + Swap 8 GB)で Claude Code を常時稼働させています。サーバの構成は Ansible で宣言的に管理しており、tmux + ttyd(Web ターミナル)でブラウザからも操作できるようにしています。

普段の運用では、tmux の複数ペインでそれぞれ別プロジェクトの Claude Code セッションを同時に走らせています。各セッションが独立して Bash コマンドの実行やファイル操作を行うため、ピーク時にはそれなりのメモリを消費しますが、32 GB あれば問題なく動いていました。

障害の発生と原因調査

サーバフリーズの発生

ある日、サーバが極端に重くなり、SSH からのログインもできなくなりました。操作手段がなくなったため、ハイパーバイザー(Proxmox)から仮想マシンを強制再起動して復旧しました。

dmesg で原因を特定

再起動後、dmesg と journalctl のログを調査したところ、OOM Killer が発動していたことがわかりました。

時刻 対象プロセス 仮想メモリ 物理メモリ (RSS)
01:00 claude (pid 1006330) ~81 GB ~9.5 GB
01:45 claude (pid 3080772) ~88 GB ~16 GB

OOM 発動時点で 9 個の claude プロセスが同時に動いており、合計で約 16.3 GB の物理メモリを消費していました。

PID RSS (実メモリ)
1006330 ~9.7 GB (kill された)
1872587 ~2.1 GB
442870 ~1.1 GB
349514 ~574 MB
1099213 ~787 MB
21315 ~665 MB
5688 ~534 MB
1008976 ~518 MB
2069074 ~337 MB
合計 ~16.3 GB

根本原因: ルート / からのファイルスキャン

ログをさらに追うと、以下のようなコマンドが実行されていたことがわかりました。

/home/ubuntu/.local/share/claude/versions/2.1.42 --ripgrep -l . /

Claude Code の内部ファイル検索ツール(Glob/Grep)がルート / を起点にファイルシステム全体をスキャンしていました。これが大量のメモリ消費の直接原因です。なお、上記はバージョン 2.1.42 時点のログですが、内部のファイル検索の仕組みはバージョンによらず同様です。

  • ファイルシステム全体のトラバースで、ファイルエントリのメタデータがメモリ上に蓄積
  • NAS(CIFS)の automount まで発動し、ネットワークストレージ上のファイルもスキャン対象に
  • Claude Code のサブエージェント機能により、9 プロセスが並行してこの操作を実行

OOM Kill 後の連鎖障害

OOM Killer がプロセスを kill しても、すぐには復旧しませんでした。

ttyd(Web ターミナル)の systemd サービスが Restart=always で設定されていたため、以下のループが発生しました。

  1. OOM Killer が claude プロセスを kill
  2. ttyd が再起動 → Claude Code セッションも再開
  3. Claude Code が再びメモリを大量消費
  4. 再度 OOM Kill が発動

このループにより、システム全体が応答不能な状態に陥りました。

対策の検討

比較した選択肢

選択肢 概要 採否
earlyoom ユーザースペースの OOM killer。メモリ残量が閾値を下回るとプロセスを kill 不採用: プロセス単位の制限ができない
swap 増量 swap を増やして OOM 発動までの猶予を延ばす 補助的に有効だが根本対策にならない
cgroup メモリ制限 プロセスグループ単位でメモリ上限を設定 採用

cgroup(control groups)は Linux カーネルのリソース制限機構で、プロセスグループ単位で CPU・メモリなどの使用量を制限できます。Ubuntu 24.04 では cgroup v2 がデフォルトで有効になっています。

earlyoom はシステム全体のメモリ残量に基づいて動作するため、「特定の Claude Code プロセスだけを制限したい」という要件には合いません。cgroup であればプロセスグループ単位で制限をかけられるため、暴走した Claude Code だけを kill し、他のセッションやシステムを保護できます。

二層構造の設計

メモリ制限を 1 段だけ設けるのではなく、2 層の制限を設計しました。

  • Layer 1 (tmux.slice: 30 GB): tmux 全体に対する上限。OS のカーネルやその他のサービスが使うメモリを確保するための安全弁
  • Layer 2 (claude scope: 8 GB): 個別の claude 呼び出しごとの上限。暴走した 1 セッションだけを隔離して kill する

Layer 2 だけでも暴走は防げます。しかし Layer 1 がないと「8 GB x 5 セッション = 40 GB」のように、多数のセッションが同時に上限近くまで消費した場合にシステムが危険になります。Layer 1 は tmux 配下のプロセス全体に対する最終防衛線です。

tmux を systemd user service 化する判断

tmux はクライアント-サーバアーキテクチャで動作します。すべてのセッション・ペインが同一の tmux server プロセスの子プロセスとして実行されます。つまり、tmux server を特定の cgroup 配下に置けば、そこから起動されるすべてのプロセスが同じ制約を受けます。

従来は ttyd が tmux server を起動していました。これを systemd user service として独立させることで、ttyd 経由でも SSH 経由でも同じ cgroup 階層に入る設計にしました。

cgroup 二層制限の実装

cgroup 階層の全体像

systemd cgroup hierarchy:
├── user.slice
│   └── user-1000.slice
│       └── user@1000.service
│           └── tmux.slice (MemoryMax=30G, MemoryHigh=28G)  ← Layer 1
│               ├── tmux.service
│               │   ├── tmux server
│               │   └── pane processes (bash shells)
│               ├── claude-main-12345.scope (MemoryMax=8G, MemorySwapMax=0)  ← Layer 2
│               │   └── claude + subagents
│               ├── claude-main-12346.scope
│               │   └── claude + subagents
│               └── ...

MemoryHigh は「ソフトリミット」で、この値を超えるとカーネルがメモリ回収を積極的に行います。MemoryMax は「ハードリミット」で、これを超えると OOM Kill が発動します。Layer 1(tmux.slice)には MemoryHigh=28G / MemoryMax=30G の両方を設定し、2 GB の余裕を持たせることで MemoryMax に到達する前にメモリ回収が始まることを期待しています。

一方、Layer 2(claude scope)には MemoryMax のみを設定し、MemoryHigh は設定しません。これは後述する「MemoryHigh によるフリーズ問題」の教訓によるものです。また MemorySwapMax=0 を設定して swap への退避を禁止し、MemoryMax 到達時に即座に OOM Kill が発動するようにしています。

Layer 1: tmux.slice と tmux.service

tmux.slice はメモリ上限を定義するユーザースライスです。

tmux.slice
[Unit]
Description=tmux resource control slice

[Slice]
MemoryMax={{ tmux_memory_max }}
MemoryHigh={{ tmux_memory_high }}

tmux.service は tmux server をこのスライス配下で起動するユーザーサービスです。

tmux.service
[Unit]
Description=tmux server

[Service]
Type=forking
Slice=tmux.slice
Environment=TERM=xterm-256color
ExecStartPre=-/bin/sh -c '/usr/bin/tmux kill-server 2>/dev/null; rm -f /tmp/tmux-$(id -u)/default'
ExecStart=/usr/bin/tmux new-session -d -s main
ExecStop=/usr/bin/tmux kill-server
Restart=on-failure
RestartSec=5

[Install]
WantedBy=default.target

ポイントは以下の通りです。

  • Type=forking: tmux は -d(デタッチ)で起動するとフォークしてコマンド自体はすぐ終了するため、forking を指定
  • Slice=tmux.slice: このサービスから起動されるプロセスすべてが tmux.slice の cgroup 制約を受ける
  • Environment=TERM=xterm-256color: systemd の環境には TERM が設定されていないため、明示的に指定が必要
  • ExecStartPre で kill-server とソケットファイルの削除を実行し、前回の残骸をクリーンアップ

Layer 2: claude-cgroup-wrapper

claude コマンドを呼び出すたびに systemd-run --scope で独立した cgroup scope を作成するラッパースクリプトです。scope は一時的な cgroup で、systemd-run が指定されたコマンドをその scope 内で実行します。service と異なり systemd がプロセスのライフサイクルを管理するのではなく、コマンドの終了とともに scope も消滅します。

claude-cgroup-wrapper.sh
#!/bin/bash
# Claude Code cgroup wrapper - Managed by Ansible
# Runs claude in a memory-limited systemd scope under tmux.slice
#
# This wrapper is called by the claude() shell function in .bashrc.
# It uses systemd-run to create a transient scope with memory limits,
# ensuring each claude invocation (and its subagents) is contained.

REAL_CLAUDE="${REAL_CLAUDE:-/home/ubuntu/.local/bin/claude}"
MEMORY_MAX="${MEMORY_MAX:-8G}"
SLICE="tmux.slice"

# tmux セッション名から scope 名を生成
if [ -n "${TMUX:-}" ]; then
    SESSION_NAME=$(tmux display-message -p '#S' 2>/dev/null || echo "unknown")
else
    SESSION_NAME="no-tmux"
fi
SESSION_NAME="${SESSION_NAME//[^a-zA-Z0-9_-]/_}"
SCOPE_NAME="claude-${SESSION_NAME}-$$"

# tmux.slice が有効なら scope 内で実行、そうでなければ直接実行
if systemctl --user is-active "${SLICE}" &>/dev/null; then
    exec systemd-run --user --scope \
        --unit="${SCOPE_NAME}" \
        --slice="${SLICE}" \
        -p MemoryMax="${MEMORY_MAX}" \
        -p MemorySwapMax=0 \
        -p OOMPolicy=kill \
        -- "${REAL_CLAUDE}" "$@"
else
    exec "${REAL_CLAUDE}" "$@"
fi

設計上のポイントをいくつか補足します。

  • scope 名にセッション名と PID を含める: claude-main-12345.scope のような名前になり、どのセッションの Claude Code か systemctl --user で識別できる
  • MemorySwapMax=0: swap への退避を禁止し、MemoryMax 到達時に即座に OOM Kill を発動させる。swap を許可すると kill までに大幅な遅延が生じる(後述)
  • OOMPolicy=kill: scope 内のプロセスが OOM Kill された際に、scope 全体(サブエージェント含む)を確実に停止させる
  • フォールバック: tmux.slice が起動していない環境(ローカル開発マシンなど)では制限なしで直接実行する
  • 環境変数によるオーバーライド: MEMORY_MAX や REAL_CLAUDE を環境変数で上書きできるため、テスト時に値を変更しやすい

bash 関数で claude コマンドをインターセプト

claude-bashrc.sh
# Claude Code cgroup wrapper - Managed by Ansible
claude() {
    /home/ubuntu/.local/bin/claude-cgroup-wrapper "$@"
}

.bashrc から読み込むことで、ユーザーが claude と打つだけでラッパー経由の実行になります。既存のワークフローを一切変更する必要がありません。

実装中のハマりどころ

TERM 未設定で tmux.service が起動しない

systemd のサービス環境には TERM 環境変数が設定されていません。tmux はターミナル情報を必要とするため、TERM がないとサーバの起動に失敗します。

Environment=TERM=xterm-256color を追加することで解決しました。一見些細ですが、systemd サービスとして動かす際に見落としやすいポイントです。

Type=oneshot では scope が作れない

当初 Type=oneshot で tmux.service を定義していましたが、systemd-run --scope との組み合わせで問題が発生しました。oneshot はサービスの起動が完了するまで「activating」状態のままであり、その間は配下の scope 作成がブロックされる場合があります。

tmux は -d オプションでデタッチした状態でセッションを作成し、コマンド自体はフォークして終了します。この動作に合致する Type=forking に変更することで解決しました。

kill-server 後にソケットが残る

ExecStartPre で tmux kill-server を実行した後、tmux のソケットファイル(/tmp/tmux-<uid>/default)が残存していると new-session が失敗します。

kill-server の直後に rm -f /tmp/tmux-$(id -u)/default を追加してソケットファイルを明示的に削除することで、確実にクリーンな状態から起動できるようになりました。

stress-ng では実メモリを埋め切れない

OOM 動作の検証で最初に stress-ng --vm を試しました。しかし mmap で仮想メモリだけ確保して実メモリを十分に消費しないケースがありました。cgroup の MemoryMax は物理メモリ(RSS)に対して制限をかけるため、仮想メモリだけの確保では OOM Kill が発動しません。

実際にメモリを確保して書き込む Python スクリプトに切り替えて検証を行いました(詳細は次のセクション)。

swap があると即座に kill されない

MemoryMax=512M を設定してテストしたところ、プロセスが 512 MB を超えても即座には kill されませんでした。MemoryMax は物理メモリに対する制限として機能しますが、超過分は swap に退避するためです。

テストでは Python プロセスが 8640 MB まで確保した時点で、swap(8 GB)も枯渇して exit_code=137(SIGKILL)で終了しました。この問題を解決するため、最終的に MemorySwapMax=0 を設定して swap への退避自体を禁止しました。これにより MemoryMax に到達した時点で即座に OOM Kill が発動します。

cgroup の動作検証

cgroup の設定値確認

# Layer 1: tmux.slice
$ cat /sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/tmux.slice/memory.max
32212254720  # 30G

$ cat /sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/tmux.slice/memory.high
30064771072  # 28G

# Layer 2: claude scope (tmux.slice 配下)
$ cat /sys/fs/cgroup/.../tmux.slice/claude-test-verify.scope/memory.max
8589934592   # 8G

$ cat /sys/fs/cgroup/.../tmux.slice/claude-test-verify.scope/memory.swap.max
0            # swap 禁止

二層とも期待通りの値が設定されていることを確認しました。

OOM 動作テスト

ラッパーの REAL_CLAUDE を Python に差し替えて、無限にメモリを確保するスクリプトで OOM Kill の動作を検証しました。

REAL_CLAUDE=python3 MEMORY_MAX=512M claude-cgroup-wrapper -c "
chunks = []
while True:
    chunks.append(b'x' * (64*1024*1024))
    print(f'Allocated: {len(chunks)*64}MB', flush=True)
"

結果は以下の通りです。

  • exit_code=137: SIGKILL による強制終了 = cgroup OOM Kill が発動した
  • tmux セッション・他のプロセスは無事: kill されたのは scope 内のプロセスだけ
  • システム全体は安定: SSH 接続やその他のサービスに影響なし

dmesg で OOM の種類を確認

# カーネルログ
$ sudo dmesg | grep "oom-kill\|Killed process"
# → constraint=CONSTRAINT_MEMCG

CONSTRAINT_MEMCG は cgroup のメモリ制限による OOM Kill であることを示します。これがシステム全体のメモリ枯渇による CONSTRAINT_NONE ではないことを確認することで、cgroup の制限が正しく機能していると判断できます。

# systemd journal でも確認可能
$ journalctl --user -u claude-*.scope --no-pager | grep -i oom
# → Failed with result 'oom-kill'.

振り返り

うまくいった点

  • 暴走プロセスだけが kill される: cgroup により影響範囲が scope 内に限定され、他のセッションやシステムサービスは保護されます
  • 既存のワークフローを変更しない: bash 関数で claude コマンドを透過的にラップしているため、ユーザーの操作は一切変わりません
  • Ansible で再現可能: すべての設定が Ansible ロールとして管理されており、サーバを再構築しても同じ制限が自動的に適用されます

MemoryHigh によるフリーズ問題

初期実装では Layer 2(claude scope)にも MemoryHigh=6G / MemoryMax=8G の二段構えを設定していました。MemoryHigh を超えた時点でカーネルがメモリ回収を始め、MemoryMax に到達する前に収まることを期待していました。

しかし実際に Claude Code が暴走した際、MemoryHigh を超えた段階でプロセスがフリーズに近い状態に陥り、MemoryMax まで到達せず OOM Kill も発動しませんでした。カーネルのメモリ回収処理とプロセスのメモリ確保が拮抗し、プロセスは生きているが応答しないという最悪の状態です。kill されれば tmux セッションに制御が戻りますが、フリーズではそれすら起きません。

この経験から、Layer 2 では MemoryHigh を廃止し、MemoryMax + MemorySwapMax=0 の構成に変更しました。メモリ使用量が上限に達したら、中途半端にスロットリングするのではなく、即座に OOM Kill で終了させます。「緩やかに制限する」より「超えたら即 kill」の方が、自律的に動作するツールの暴走対策としては適切でした。

改善の余地

  • 閾値のチューニング: 現在の 8 GB / 30 GB はえいやで決めた値です。実際の運用データを蓄積し、適切な値に調整する余地があります
  • 通知機能: 現状では OOM Kill が発生しても気づくのはログを確認したときです。Slack 通知などを追加すれば、問題の早期発見につながります
  • CPU 制限: メモリだけでなく CPU の暴走も起こりうるため、CPUQuota の設定も検討しています

今後の展望

Claude Code の利用がさらに拡大すると、メモリだけでなく I/O やネットワーク帯域の制限も必要になるかもしれません。systemd の cgroup v2 は IOWeight や IOMax もサポートしているため、同じ仕組みの延長で対応できます。

また、今回の障害の根本原因である「ルート / からのファイルスキャン」については、Claude Code 側のプロンプト設定(CLAUDE.md の --ignore 設定など)でも防げる可能性があります。ただし、Claude Code のバージョンアップや予期しない操作でスキャン範囲が変わる可能性もあるため、cgroup による制限はフェイルセーフとして維持する方針です。

まとめ

Claude Code を常時稼働させる運用では、メモリ暴走への備えが必要です。今回の障害では、内部のファイル検索がルート / から全ファイルシステムをスキャンし、9 プロセスで 16 GB 以上のメモリを消費してサーバ全体が応答不能になりました。

対策として systemd cgroup の二層メモリ制限を実装しました。tmux.slice(30 GB)で tmux 配下のプロセス全体を制限し、claude-cgroup-wrapper(8 GB)で個別の Claude Code セッションを制限する構成です。暴走した Claude Code だけが OOM Kill され、他のセッションやシステムは影響を受けません。

すべての設定は Ansible で管理しており、ansible-playbook 一発で同じ制限を再現できます。Claude Code のような自律的に動作するツールを本番運用する際には、ツール自体の設定だけでなく、OS レベルでのリソース制限を組み合わせることで、安全性を高められます。

Discussion