朝いちばんに ps を叩いたときの数字を、今でも覚えております。夜間に回していた Claude Code のプロセスが、常駐メモリ 3.4GB。前夜に起動した時点では 400MB 台でした。処理そのものは最後まで走り切っていて、成果物にも問題はありません。ただ、終盤のターンは目に見えて重く、ひとつの応答が返るまでに待たされていました。
原因を「長く回したから」で片づけていた時期が、私にはしばらくありました。個人開発でスケジュール実行を組んでいると、夜のセッションは誰も見ていません。翌朝の成果だけを確認して、重さは仕方のないものとして受け入れてしまいます。
2.1.209 と 2.1.208 の修正一覧を眺めていて、目が留まったのがメモリ関連の四件でした。読んでいくうちに、あの重さの正体は「長く回したから」という漠然としたものではなく、四つの別々の場所に別々の理由で溜まっていたものだったと分かってきます。そして、そのうちいくつかは私の構成が引き寄せていたものでした。
溜まっていたのは一箇所ではありませんでした
修正された四件は、性質がまったく違います。ひとまとめに「メモリリークが直った」と受け取ると、自分の環境でどれが効くのかを見誤ります。
| 蓄積源 |
何が起きていたか |
修正後 |
効きやすい構成 |
| MCP stdio サーバの stderr |
サーバごとに最大 64MB まで出力が保持されていた |
保持量が抑えられる |
自作 MCP サーバを複数常駐させ、ログを stderr に流している |
| LSP ドキュメント |
開いたドキュメントが解放されず積み上がっていた |
LRU 50 件が上限に |
大きなリポジトリを横断し、多数のファイルを次々に開く |
| async hook の出力 |
バックグラウンド化した後も出力が残り続けていた |
バックグラウンド化時に解放 |
hook を仕込み、長い処理をバックグラウンドに送る |
| ツール結果(headless / SDK) |
大きな結果が上限なく積み上がっていた |
蓄積が上限化 |
claude -p や SDK で無人実行し、ツールが長い出力を返す |
私の構成は、四つのうち三つに当たっていました。自作の MCP サーバを二つ常駐させ、そこから進捗を stderr に流しています。夜間タスクは claude -p の無人実行です。hook も使っています。当たっていなかったのは LSP くらいで、これは私が単一リポジトリの小さな範囲しか触らせていないからでした。
ここが分かれ目になります。「メモリリークが直った」という一行を、自分の構成に照らして四つに割ってから読む。そうすると、更新後にどれくらい改善するはずかの見当がつき、その見当と実測がずれたときに「残っている分は自分の設計側にある」と判断できます。
まず版を確かめてから測る
測り始める前に、手元の版を確認します。修正の入っていない版で測った数字を基準にしてしまうと、後の比較が全部ずれます。
claude --version
判断は単純です。
| 手元の版 |
やること |
| 2.1.208 より前 |
更新前に一晩だけ測る。更新後との差が、そのまま四件の修正の効きになります |
| 2.1.208 / 2.1.209 以降 |
そのまま測る。増え続けるなら残りは自分の構成側です |
更新前の一晩を測っておく価値は、思っているより大きいものでした。「更新したら軽くなった気がする」を「毎時 620MB の増加が毎時 90MB になった」に変えられます。前者は次に重くなったときに何の役にも立ちませんが、後者は基準線になります。
RSS を毎分刻む
測る対象は RSS(常駐メモリ)です。そして大切なのは、Claude Code 本体だけを見ないことでした。MCP サーバは子プロセスとして別に常駐しています。stderr が 64MB 溜まっていたのはその子プロセス側の話ですから、本体だけを見ていると、いちばん効いている蓄積を見落とします。
プロセスツリー全体の RSS を毎分 CSV に刻むサンプラーです。macOS と Linux の両方で動きます。
#!/usr/bin/env bash
# rss-sampler.sh — Claude Code とその子プロセスの RSS を毎分記録する
# 使い方: ./rss-sampler.sh out.csv 60
set -euo pipefail
OUT="${1:-rss.csv}"
INTERVAL="${2:-60}"
# 子孫プロセスの PID を再帰的に集める
descendants() {
local pid=$1
local children
children=$(pgrep -P "$pid" 2>/dev/null || true)
for c in $children; do
echo "$c"
descendants "$c"
done
}
echo "ts,pid,rss_kb,command" > "$OUT"
while true; do
TS=$(date +%Y-%m-%dT%H:%M:%S)
# claude 本体を探す(複数セッションがあれば全部拾う)
ROOTS=$(pgrep -f '(^|/)claude( |$)' 2>/dev/null || true)
if [ -z "$ROOTS" ]; then
echo "$TS,,,no-process" >> "$OUT"
sleep "$INTERVAL"
continue
fi
for root in $ROOTS; do
ALL="$root $(descendants "$root")"
for pid in $ALL; do
# ps は macOS / Linux 共通で rss(KB) と comm を返す
LINE=$(ps -o rss=,comm= -p "$pid" 2>/dev/null || true)
[ -z "$LINE" ] && continue
RSS=$(echo "$LINE" | awk '{print $1}')
CMD=$(echo "$LINE" | awk '{$1=""; sub(/^ /,""); print}')
echo "$TS,$pid,$RSS,\"$CMD\"" >> "$OUT"
done
done
sleep "$INTERVAL"
done
ここで一度つまずいた注意点があります。pgrep -f claude は、パターンが緩いと自分自身のサンプラーや無関係なプロセスまで拾ってしまい、RSS が水増しされます。正規表現でパス区切りと語尾を挟んで回避しました。数字を疑う前に、まず何を数えているかを疑ったほうが早く済みます。
pgrep -P で子を辿り、再帰で孫まで拾います。MCP サーバを node で動かしているなら、その node プロセスがここに現れます。ps -o rss=,comm= は macOS と Linux で同じ書式が使えるため、手元とサーバで同じスクリプトを回せます。
手順は三つだけです。
- 夜間タスクを起動する直前にサンプラーを走らせ、CSV の出力先を日付つきで固定します
- タスク本体を
claude -p で流し、終了を待ちます(サンプラーは裏で刻み続けます)
- タスクが終わったらサンプラーを止め、CSV を後述の分析にかけます
コードにするとこうなります。
./rss-sampler.sh ~/logs/rss-$(date +%Y%m%d).csv 60 &
SAMPLER=$!
claude -p "$(cat nightly-task.md)" --output-format stream-json > ~/logs/session.jsonl
kill "$SAMPLER"
増え方の「傾き」で切り分ける
CSV が溜まったら、ピーク値ではなく傾きを見ます。ピークだけを見ていると、「重いタスクだったから」で説明がついてしまうからです。時間あたり何MB増えているかを出すと、蓄積なのか、単にそのタスクが大きなデータを扱っただけなのかが分かれます。
#!/usr/bin/env python3
"""rss-slope.py — RSS サンプルから増加の傾き(MB/h)を出す。
使い方: python3 rss-slope.py rss.csv
"""
import csv
import sys
from collections import defaultdict
from datetime import datetime
def load(path):
"""タイムスタンプごとに全プロセスの RSS を合算する"""
totals = defaultdict(int)
per_cmd = defaultdict(lambda: defaultdict(int))
with open(path, newline="", encoding="utf-8") as f:
for row in csv.DictReader(f):
if not row["rss_kb"]:
continue
ts = datetime.fromisoformat(row["ts"])
kb = int(row["rss_kb"])
totals[ts] += kb
per_cmd[row["command"].strip('"')][ts] += kb
return totals, per_cmd
def slope_mb_per_hour(series):
"""最小二乗で傾きを出す(x=経過時間[h], y=RSS[MB])"""
items = sorted(series.items())
if len(items) < 3:
return None, None, None
t0 = items[0][0]
xs = [(t - t0).total_seconds() / 3600 for t, _ in items]
ys = [kb / 1024 for _, kb in items]
n = len(xs)
mx = sum(xs) / n
my = sum(ys) / n
denom = sum((x - mx) ** 2 for x in xs)
if denom == 0:
return None, None, None
slope = sum((x - mx) * (y - my) for x, y in zip(xs, ys)) / denom
return slope, ys[0], max(ys)
def main():
totals, per_cmd = load(sys.argv[1])
slope, start, peak = slope_mb_per_hour(totals)
if slope is None:
print("サンプルが足りません")
return
print(f"合計 起点 {start:7.0f} MB / ピーク {peak:7.0f} MB / 傾き {slope:+7.1f} MB/h")
print("-" * 58)
rows = []
for cmd, series in per_cmd.items():
s, st, pk = slope_mb_per_hour(series)
if s is None:
continue
rows.append((s, cmd, st, pk))
for s, cmd, st, pk in sorted(rows, reverse=True):
print(f"{cmd[:28]:28s} 起点 {st:6.0f} / ピーク {pk:6.0f} / 傾き {s:+7.1f} MB/h")
if __name__ == "__main__":
main()
プロセス名ごとに傾きを分けて出すところが要になります。合計だけを見ていると「増えている」で終わりますが、内訳を出すと、増やしているのが本体なのか自作 MCP サーバなのかが一目で分かります。
私の環境(Mac mini M2 / 夜間 3 時間前後の無人セッション / 自作 MCP サーバ 2 本)では、更新前後でこうなりました。
| プロセス |
更新前の傾き |
更新後の傾き |
| 合計 |
+620 MB/h |
+95 MB/h |
| claude(本体) |
+310 MB/h |
+70 MB/h |
| node(自作 MCP・進捗ログを stderr へ) |
+280 MB/h |
+15 MB/h |
| node(自作 MCP・ログ控えめ) |
+30 MB/h |
+10 MB/h |
合計で見ると +620 MB/h が +95 MB/h、およそ 85% の減少です。ただ、この一行だけを持ち帰ると判断を誤ります。内訳を見ると、減った分の大半は自作 MCP サーバ側で、その傾きは約 19 倍も違っていました。
数字を並べて、二つ気づいたことがあります。
ひとつは、いちばん増やしていたのが本体ではなく、進捗ログを stderr に垂れ流していた自作 MCP サーバだったこと。64MB という上限は、一晩あれば普通に埋まります。そして私は、そのサーバに「どのファイルを処理中か」を一行ずつ吐かせていました。デバッグのときに書いた出力を、そのまま夜間運用に持ち込んでいたわけです。
もうひとつは、更新後もゼロにはならないこと。+95 MB/h は残ります。3 時間なら 285MB ですから実害はありませんが、これが 8 時間になれば話は変わります。この残りは、修正の取りこぼしではなく、私の構成が持ち込んでいる分でした。
残った分は、自分の設計側にあります
蓄積が上限化されたことと、蓄積が起きないことは違います。上限化は「際限なく膨らむのを止める」対策であって、上限まで溜めていい理由にはなりません。上限に張りつく構成のままだと、セッションを長くするほど上限×サーバ数の常駐が固定費として乗ります。
私が最初に手を入れたのは、ツール結果の返し方でした。自作 MCP のツールが、ファイル一覧を数百行そのまま返していたのです。Claude Code 側は 200 行を超える大きなテーブルを先頭 200 行 +「… N more rows」に省略して描画するようになりましたが、これは描画側の話であり、モデルに渡るデータそのものが細くなるわけではありません。長い出力を渡したままなら、セッション内には残り続けます。
返す側で先に畳みます。
// mcp-server/src/truncate.ts
const MAX_ROWS = 60;
const MAX_CHARS = 8_000;
type ToolResult = { content: Array<{ type: "text"; text: string }> };
/**
* ツール結果を「行数」と「文字数」の二重で上限化する。
* 省略した事実と全体像(総件数)は必ず残す。数だけでも
* 「絞り込みが必要だ」という判断はモデル側でつきます。
*/
export function capResult(rows: string[], label: string): ToolResult {
const total = rows.length;
let shown = rows.slice(0, MAX_ROWS);
let text = shown.join("\n");
if (text.length > MAX_CHARS) {
// 文字数超過なら行数をさらに削る(1行が長いケースへの保険)
while (shown.length > 1 && text.length > MAX_CHARS) {
shown = shown.slice(0, Math.floor(shown.length * 0.8));
text = shown.join("\n");
}
}
const omitted = total - shown.length;
const footer =
omitted > 0
? `\n\n[${label}: ${total} 件中 ${shown.length} 件を表示 / ${omitted} 件を省略]`
: `\n\n[${label}: ${total} 件]`;
return { content: [{ type: "text", text: text + footer }] };
}
省略した件数を必ず添えるところが肝でした。黙って切ると、モデルは「これが全部だ」と受け取って先に進みます。件数さえ残っていれば、「480 件中 60 件しか見えていないから条件を絞ろう」という判断が向こう側で立ちます。私はここを黙って切っていた時期があり、その結果として「一覧の後半にあったファイルだけ処理されていない」という朝を迎えました。
stderr のほうは、もっと素朴です。
// 進捗ログは既定で出さない。必要なときだけ環境変数で開ける
const VERBOSE = process.env.MCP_VERBOSE === "1";
export function trace(msg: string) {
if (!VERBOSE) return;
process.stderr.write(`${new Date().toISOString()} ${msg}\n`);
}
デバッグのときは MCP_VERBOSE=1 で開け、夜間の無人実行では閉じておく。これだけで、私の環境では自作 MCP 側の傾きが +280 MB/h から +15 MB/h に落ちました。修正が入る前から、この一行を書いておけばよかったのだと思います。
しきい値を決めて、超えたら畳む
設計を細くしても、増加がゼロになるわけではありません。長い夜には、増え続ける前提で畳む仕掛けを置いておきます。
RSS がしきい値を超えたら、セッションを穏やかに終わらせて再開する watchdog です。強制終了ではなく SIGTERM を送り、猶予を与えてから次のセッションを起こします。
#!/usr/bin/env bash
# rss-watchdog.sh — RSS がしきい値を超えたらセッションを畳んで再開する
# 使い方: ./rss-watchdog.sh 2048 task.md checkpoint.json
set -euo pipefail
THRESHOLD_MB="${1:-2048}"
TASK_FILE="${2:?task file required}"
CHECKPOINT="${3:-checkpoint.json}"
MAX_ROUNDS=6
tree_rss_mb() {
local root=$1 total=0
local pids
pids="$root $(pgrep -P "$root" 2>/dev/null || true)"
for p in $pids; do
local kb
kb=$(ps -o rss= -p "$p" 2>/dev/null | tr -d ' ' || true)
[ -n "${kb:-}" ] && total=$((total + kb))
done
echo $((total / 1024))
}
for round in $(seq 1 "$MAX_ROUNDS"); do
echo "[round $round] start"
# チェックポイントを渡して再開できる形でタスクを組む
claude -p "$(cat "$TASK_FILE")
進捗は ${CHECKPOINT} を読み、完了分はスキップしてください。
1 ステップ終えるごとに ${CHECKPOINT} を更新してください。" \
--output-format stream-json > "session-${round}.jsonl" &
PID=$!
while kill -0 "$PID" 2>/dev/null; do
MB=$(tree_rss_mb "$PID")
if [ "$MB" -gt "$THRESHOLD_MB" ]; then
echo "[round $round] RSS ${MB}MB > ${THRESHOLD_MB}MB — 畳みます"
kill -TERM "$PID" 2>/dev/null || true
sleep 20
kill -KILL "$PID" 2>/dev/null || true
break
fi
sleep 30
done
wait "$PID" 2>/dev/null || true
# チェックポイントが完了を示していれば抜ける
if grep -q '"done"[[:space:]]*:[[:space:]]*true' "$CHECKPOINT" 2>/dev/null; then
echo "完了しました(round $round)"
exit 0
fi
echo "[round $round] 再開します"
done
echo "上限ラウンドに達しました。checkpoint を確認してください" >&2
exit 1
しきい値の決め方には、少し悩みました。低くしすぎるとセッションが細切れになり、そのたびに文脈を組み直す分だけ遅くなります。高くしすぎると畳む前に重さが実害になります。私は「起点の RSS + 想定時間 × 傾き × 1.5 倍」を目安に置きました。起点 400MB、3 時間、+95 MB/h なら 400 + 285 × 1.5 ≒ 830MB。ここに余裕を足して 1,200MB あたりで運用しています。
傾きがまだ測れていない、この場合はしきい値を決め打ちにせず、まず一晩サンプラーだけを回すことをお勧めします。基準線のないしきい値は、早すぎる分割か遅すぎる検知のどちらかにしかなりません。
checkpoint.json を挟むところは省けません。畳んで再開する設計は、再開したセッションが最初からやり直さないことが前提です。ステップごとに進捗を書かせておけば、畳まれても失うのは進行中の 1 ステップ分だけで済みます。
状況別に、私がいま置いている設定
| 状況 |
置いている設定 |
理由 |
| 対話で 30 分ほど触る |
何もしない |
この長さでは傾きが実害になりません。測る手間のほうが高くつきます |
| 夜間 3 時間の無人実行 |
stderr を閉じる + ツール結果の上限化 |
設計側を細くするだけで、しきい値まで届かなくなりました |
| 夜間 8 時間以上 / 週末の長回し |
上記 + watchdog + checkpoint |
傾きがゼロでない以上、時間が伸びれば必ずどこかで当たります |
| MCP サーバを 3 本以上常駐 |
サーバごとに傾きを分けて測る |
合計だけでは、どのサーバが引き起こしているか特定できません |
この表で自分に言い聞かせているのは、いちばん上の行です。測ることそのものが目的になると、30 分の作業にサンプラーを仕込んで満足してしまいます。傾きが効いてくるのは、任せる時間が長い場合だけでした。
次の一手
今夜のタスクにサンプラーを一本足すところから始めてみてください。CSV が一晩ぶん溜まったら、傾きを出します。合計ではなく、プロセス名ごとに。
私の場合、そこに現れたのは Claude Code ではなく、デバッグ用に書いたまま忘れていた自分の一行でした。修正のリリースノートを読んで期待するより先に、自分が何を垂れ流しているかを数字で見たほうが、たいてい早く終わります。
夜間の自動実行を長く続けている方ほど、思い当たる節が出てくるのではないでしょうか。私自身まだ手探りの部分が多いのですが、測った数字は正直で、そこだけは裏切られたことがありません。お読みいただきありがとうございました。