Claude Codeセルフホストでメモリ上限をセッション単位に分ける
セルフホストRunnerの--capacityはメモリを分配しません。1セッションの暴走で他のセッションを巻き込まないよう、capacity 1とコンテナ単位のメモリ上限を組み合わせる設計をまとめます。
セルフホストRunnerでセッションごとにメモリ上限を分けるには、Runnerを --capacity 1 で動かし、その1台にコンテナ単位(systemd単位でも可)のメモリ上限を1つ付けます。--capacity は同時に動かすセッション数の上限であって、CPUやメモリを分配する仕組みではないからです。
capacityを2以上にしたままメモリ上限だけ付けても、1セッションの使いすぎで他のセッションが落ちる問題は防げません。この記事では、その理由と、形態ごとの置き方、分離に使えない手段を順に見ます。
capacityを上げるとメモリはどうなるか
Runner自体の仕事は、仕事の取得、セッションのチェックアウト準備、ライフサイクルフックの実行、セッションプロセスの起動と監視です。負荷の大半はセッション側から来ます。1セッションは、Claude Codeのプロセスと、そこから起動されるビルド、テスト、パッケージのインストール、MCPサーバーなどの合計です。
--capacity が決めるのは、Runnerが同時に処理するセッション数の上限だけです。CPUもメモリも分けません。そのため同じRunnerのセッションは、コンテナのメモリを共有します。
コンテナがメモリ上限に達すると、カーネルがコンテナ内のプロセスを終了させます。このときカーネルは、どのセッションのプロセスかを区別せずに終了させる対象を選びます。そのため、あるセッションのビルドが膨らむと、別のセッションのプロセスが落ちることがあります。
対策は単純で、1つの上限を共有するセッションを1つに絞ります。上限に達した場合でも、落ちるのはそのセッションの中のプロセスだけになります。
形態別の置き方
メモリ上限を付ける場所は、Runnerをどこで動かすかで変わります。
| 動かし方 | 上限を付ける場所 | 注意点 |
|---|---|---|
| Kubernetes | 上限を付ける場所コンテナの limits.memory | 注意点Podを1セッション分にする |
| Docker | 上限を付ける場所--memory | 注意点1コンテナに1Runner |
| Docker Compose | 上限を付ける場所mem_limit | 注意点同上 |
| systemd(コンテナなし) | 上限を付ける場所Runnerのunitの MemoryMax | 注意点1ホストに1Runner |
どの形態でも、--capacity 1 が前提です。以下は公式の手順とKubernetes・Composeのレシピをもとにした例で、値は4GiBの出発点を流用しています。
Kubernetesの場合
公式のDeploymentレシピは --capacity 4 でCPUとメモリの resources を持ちません。1セッション分の上限を付けるなら、capacityを1にして resources を足します。
containers:
- name: runner
image: <your-registry>/claude-runner:latest
args:
- self-hosted-runner
- --environment-secret-file
- /etc/claude/environment-secret
- --capacity
- "1"
- --base-dir
- /workspace
resources:
requests:
cpu: "2"
memory: 4Gi
limits:
cpu: "4"
memory: 4Giメモリはrequestとlimitを同じ値にします。公式はその理由を、スケジューラがコンテナのメモリ全量を見込めるようにするためと説明しています。CPUのlimitは、超えても殺されずに遅くなるだけです。
DockerとComposeの場合
Dockerなら --memory を付けます。
docker run --memory 4g \
<your-registry>/claude-runner:latest \
self-hosted-runner \
--environment-secret-file /run/secrets/environment-secret \
--capacity 1 --base-dir /workspaceComposeのサービス定義では mem_limit です。
services:
claude-runner:
image: <your-registry>/claude-runner:latest
mem_limit: 4g
command:
- self-hosted-runner
- --environment-secret-file
- /run/secrets/environment-secret
- --capacity
- "1"
- --base-dir
- /workspaceただし公式のComposeレシピは、restart: always で同じコンテナを再起動するため、書き込み層が再利用されます。本番運用では、実行ごとにコンテナを作り直すか、オーケストレーターを使う前提です。
コンテナを使わないsystemdの場合
Runnerのunitに MemoryMax を付けます。
[Service]
MemoryMax=4G
ExecStart=/usr/local/bin/claude self-hosted-runner \
--environment-secret-file /etc/claude/environment-secret \
--capacity 1 --base-dir /workspaceこの形態では、1ホストに1Runnerという制約があります。同じ --base-dir を同じファイルシステムで使う複数のRunnerは、そこにクローンされたリポジトリを共有するためです。--capacity 1 で checkout フックも無い構成だと、同じリポジトリのセッションが同じチェックアウトを同時に触ることがあります。1ホストで複数のRunnerを動かしたいなら、それぞれをコンテナに入れます。
合計がホストのメモリを超えない設計
1台のホストに複数のコンテナを置くときは、各コンテナの上限の合計をホストのメモリより小さくします。ホスト自体のメモリが尽きると、カーネルはホスト上のどのプロセスでも終了させられるため、コンテナごとの上限が意味を失うからです。
ホストの見積もりでは、Runnerごとのcapacityの合計を使います。各Runnerは自分のセッション数だけを --capacity に数えます。メモリは「1セッション分 × 合計」にホスト自身のプロセスを足した量です。CPUは、1セッションのrequestに合計を掛けます。全セッションが同時にバーストしてよいなら、limitのほうを掛けます。
分離に使えない2つの手段
公式は、メモリの分離に使えない手段を2つ名指しで挙げています。
Kubernetesのsingle-process OOM kill
Kubernetesでは、kubeletの singleProcessOOMKill 設定を、どちらの値にしてもセッション同士は分離できません。capacityが2以上のPodにこの設定を足して調整しても、セッション単位の分離にはなりません。設定で解決する余地は無いので、Pod(コンテナ)を分ける方向で考えます。
ラッパースクリプトのulimit
Runnerのラッパースクリプトで ulimit -v のようなメモリの ulimit を設定しても、セッション全体のメモリは縛れません。カーネルは、この値を1プロセスごとに別々に適用します。ビルドが多数のプロセスを起動すれば、1つ1つが上限以下でも、合計はいくらでも膨らみます。
つまり、セッション全体の合計に上限を付けられるのは、コンテナ(cgroup)のメモリ上限だけです。
落とし穴
capacity 4のままlimits.memoryだけ4Giにする
4セッションが4GiBを取り合うため、1セッションあたり1GiBの計算になり、公式の最小要件(4GB)を満たしません。さらに、上限に達したときに誰が落ちるかはカーネル任せです。capacityを増やす場合は、1セッション分の値にcapacityを掛けた上限を付けます。
上限に当たって落ちたときの見分け方
メモリ上限で落ちたRunnerは、ログに何も残さないことがあります。公式のトラブルシューティングでは、Runnerを外部から終了させた場合は [runner:fatal] 行も error: 行も出ない、と説明しています。出力がそれらの行なしで終わっているときは、ホストやオーケストレーターが止めたのではないかを疑います。例として挙げられているのが、メモリ上限の超過です。
Claude Code側でBashコマンドなどのメモリを絞る環境変数もありますが、あちらはツールが実行するコマンドを対象とした別の層です。Runnerを動かすコンテナ全体の上限とは、役割が違います。
capacity 1にすると変わること
capacityを1にすると、運用面でも変わる点がいくつかあります。
- チェックアウトの置き場所:
--capacity 1はチェックアウトを--base-dirの直下に置き、capacityが1より大きいとセッションごとのworktreeを使います。 - 値は全Runnerで揃える: 同じEnvironmentのRunnerで
--capacityが違うと、再割り当て後にセッションの作業ディレクトリが変わり、エージェントが記録した絶対パスが使えなくなります。1台だけ後からcapacity 1に変えるのではなく、Environment全体で揃えます。 - Anthropic git proxy:
--use-anthropic-git-proxyは--capacity 1が必須で、満たさないとRunnerは起動しません。 - ハードニングの推奨と一致する: 公式のハードニング項目は、実行ごとに破棄されるコンテナを
--capacity 1と既定の--drain-grace-sec 0で動かし、1コンテナで1セッションだけを担当させる構成です。
メモリ上限の分離は、この推奨構成に自然に重なります。オンデマンドRunnerを使う場合は、spawn-runner フックが投入するワークロード(KubernetesのJobのPodテンプレートなど)に上限を書きます。オンデマンドRunnerの起動手順では、スポーンしたRunnerを --capacity 1 にする点も扱っています。
プロセス数の上限は別の設定になる
メモリ上限を付けても、プロセスとスレッドの数は縛れません。こちらは別の上限で、置き場所もKubernetesの podPidsLimit(kubeletの設定)、Dockerの --pids-limit、systemdの TasksMax と分かれています。上限に達すると、カーネルが新しいプロセスやスレッドを拒否し、コマンドやビルドが Resource temporarily unavailable で失敗します。メモリが足りて見えるのにビルドだけ落ちるときは、メモリではなくこちらの上限を疑います。
上限の値をどう決めるか
本番運用の全体は本番運用ガイドにまとめてあり、この記事はセッション間のメモリ分離を扱います。
出発点は、1セッションにつきrequestとlimitを4GiBずつです。これは、Claude Codeのシステム要件の最小メモリ4GBに合わせた値で、公式も必須ではなく出発点として扱っています。
ビルドとテストが負荷の中心になりやすいので、自分のリポジトリで代表的なビルドを実行し、メモリとCPUのピークを測ります。Claude Codeのプロセスの分が残らないくらいピークが大きいなら、出発点の値を上げます。
セッションの終了後も残るプロセスを抱えたまま保持したい場合は、hold-idle-fileの使い方を合わせて確認します。残プロセスの分もコンテナのメモリを使い続けるため、上限の見積もりに入ります。
構成を選ぶときの見取り図
- 他のセッションを巻き込みたくない: コンテナ(またはunit)ごとに1Runner、
--capacity 1、メモリ上限は1セッション分 - ホストを使い切りたくない: 各上限の合計をホストのメモリ未満にする
- capacityを上げて密に詰めたい: 1セッション分 × capacityの上限を付け、1つのセッションの暴走が他へ及ぶ前提で運用する
- Runnerの基本から確認したい: self-hosted-runnerの概要に、起動と
--capacityの基本を載せています
密に詰める運用は、セッション同士の巻き込みを許容するかどうかの判断です。公式の推奨は、セッションごとにメモリ上限を分ける側にあります。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。