Claude CodeでPythonのメモリリークを特定する — tracemallocとmemray
tracemallocの差分出力やmemrayのstats・summaryをclaude -pに渡し、増え続ける割り当て元を絞り込む手順です。渡す前の絞り込みと、仮説を再計測で確かめる流れまで扱います。
Pythonのメモリリークは、スナップショットの差分を2枚取り、増えた行を上から読めば絞れます。この読みの部分をClaude Codeに任せるのが本記事の手順です。tracemallocなら compare_to の出力、memrayなら stats と summary の出力を、claude -p にパイプで渡します。
Claude Codeに渡すのは「メモリが増える」という症状ではなく、計測した数字です。数字があれば、Claudeは増加量の大きい行から該当コードを読み、仮説を立てられます。
tracemallocで差分を2枚取る
tracemallocはPython標準のメモリ割り当てトレーサーです。tracemalloc.start() で追跡を始め、take_snapshot() で時点ごとの割り当てを記録します。Snapshot.compare_to() に古いスナップショットを渡すと、増加量の大きい順に差分が並びます。
次のコードは、リクエストごとにバッファをキャッシュ用の辞書へ積み、消さない例です。
import tracemalloc
_cache = {}
def handle(request_id):
payload = bytearray(1024)
_cache[request_id] = payload
return len(payload)
tracemalloc.start(10)
for i in range(1000):
handle(i)
snap1 = tracemalloc.take_snapshot()
for i in range(1000, 6000):
handle(i)
snap2 = tracemalloc.take_snapshot()
for stat in snap2.compare_to(snap1, "lineno")[:3]:
print(stat)Python 3.12.2で実行すると、出力は次のようになりました(パスは省略しています)。
app.py:7: size=6334 KiB (+5278 KiB), count=12000 (+10000), average=540 B
app.py:8: size=288 KiB (+252 KiB), count=1 (+0), average=288 KiB
app.py:16: size=156 KiB (+156 KiB), count=5000 (+5000), average=32 B5,000回呼んだ後半だけで、7行目が約5.2MiB増えています。ここで数字の読み方に注意が要ります。
- 7行目の
countは +10000で、呼び出し回数の5,000の2倍です。bytearrayはオブジェクト本体とバッファの2ブロックを使うためです - 8行目の
count=1 (+0)は、辞書そのものが1つで、要素が増えて大きくなっただけという意味です - 16行目の +5000は、ループの整数オブジェクトです。リークではなく計測コード側の値です
この3行をそのまま渡すと、Claudeは「7行目が主因、8行目は同じ辞書の成長、16行目は無関係」と読み分けられます。読み分けのヒントを人が先に書いておくと、さらに速くなります。
出力をclaude -pに渡す
Claude Codeの非対話モードは標準入力を読めます。-p を付けて実行し、標準出力の結果をそのままファイルや次のコマンドへ流せます。Claude Codeのドキュメントの例は、ビルドログをパイプで渡して原因を説明させる形です。
python3 app.py > leak-diff.txt
cat leak-diff.txt | claude -p \
"このtracemalloc差分で、解放されずに増え続けている割り当て元を上位から挙げて。\
各行について、リークか計測コード由来かも書いて。コードは変更しない。" \
--allowedTools "Read"--allowedTools "Read" を付けると、Claudeは確認を挟まずにソースを読めます。差分に出てきた app.py:7 の周辺を読んで、その行の意味を確かめさせる狙いです。編集権限は渡していないので、この段階でコードは変わりません。
標準入力は10MBが上限で、超えるとエラーで終了します。大きな出力は一度ファイルに書き、プロンプトでパスを伝える方法が公式に案内されています。差分の件数が多いときは、次節の絞り込みで先に減らします。
手順をスクリプトにするなら、結果をJSONで受け取れます。--output-format json に --json-schema を組み合わせると、structured_output フィールドに指定した形で返ります。
cat leak-diff.txt | claude -p "リーク疑いの行を挙げて" \
--allowedTools "Read" \
--output-format json \
--json-schema '{"type":"object","properties":{"suspects":{"type":"array","items":{"type":"string"}}},"required":["suspects"]}' \
| jq '.structured_output'スキーマに合う出力が得られなかった場合、structured_output は付きません。スクリプト側で存在を確かめてから使います。CIやcronで回すなら、--bare を付けると、ホストのフックやCLAUDE.mdを読まずに起動できます。この場合は、サブスクリプションのログインではなく ANTHROPIC_API_KEY が必要です。非対話実行の全体像はclaude -pオプションとはにまとめています。
渡す前に出力を絞る
差分をそのまま渡すと、標準ライブラリ内部の行がノイズになります。実際の差分では、tracemalloc.py 自体の行が混ざることがあります。次の3つで、渡す前に絞れます。
| 手段 | 効果 |
|---|---|
filter_traces() | 効果指定したファイルを含める・除外する。除外フィルターに1つでも当たった行は落ちる |
compare_to(..., "traceback") | 効果行でなく呼び出し経路ごとに集計する |
tracemalloc.start(25) | 効果保存するフレーム数を増やし、呼び出し元まで見えるようにする |
"traceback" で集計し、先頭の1件の経路を出すと、割り当て元の呼び出し元までが一度に分かります。実機の出力は次のとおりです。
10000 blocks
File "app.py", line 17
handle(i)
File "app.py", line 7
payload = bytearray(1024)17行目の handle(i) から7行目の bytearray へ届く経路が見えます。「どこで確保されたか」はこれで分かりますが、「なぜ解放されないか」はまだ分かりません。後者を調べるのがClaudeの仕事です。8行目の _cache[request_id] = payload が保持し続けていることは、ソースを読めば確定できます。
フレーム数を増やすとメモリとCPUの負荷も増えます。tracemallocのドキュメントは、複数フレームの保存が役に立つのは traceback 集計や累積統計のときだけだと説明しています。まず既定の1フレームで行を特定し、経路が要るときだけ増やす順が無難です。
メモリが逼迫した環境では、Snapshot.dump() でスナップショットをファイルに書き、別の環境で Snapshot.load() して分析できます。本番のプロセスでは取得だけにとどめ、Claude Codeを動かす手元で読み込む構成にもできます。
memrayの捕捉ファイルからstatsとsummaryを渡す
tracemallocはPythonが割り当てたメモリだけを見ます。C拡張が確保した分は見えません。NumPyやpandasなどネイティブ拡張を含むアプリでは、memrayが選択肢になります。memrayはPythonコードと拡張モジュールの両方の割り当てを追跡するプロファイラーです。
流れは、捕捉してからレポーターで読む2段階です。
memray run -o capture.bin myprogram.py
memray stats capture.bin
memray summary capture.binmemray run は新しいPythonプロセスを起動し、その間の割り当てを -o で指定したファイルに記録します。既定の名前は memray-<script>.<pid>.bin です。memray stats は割り当て回数の合計、サイズの分布、割り当て位置の上位をターミナルに出力します。上位件数は既定で5で、-n で変えられます。--json を付けるとJSONファイルに書き出せます。
memray summary は、プロセスのメモリ使用量が最大だった時点での、関数ごとの自前の割り当て量と、呼び出し先を含む累計を表で出します。標準出力なので、そのままClaudeにパイプできます。
memray stats -n 10 capture.bin | claude -p \
"割り当てサイズ順と割り当て回数順の上位から、増え続ける可能性が高い箇所を挙げて" \
--allowedTools "Read"memray tree は、渡す用途には向きません。操作はキーボードで行うターミナルアプリで、割り当ての多い上位200箇所(--biggest-allocs で変更可)をツリー表示します。パイプで流す出力ではないので、人が見るために使い、Claudeには stats か summary を渡します。
リーク専用の見方を使う
リークの切り出しには、memray flamegraph --leaks が向いています。追跡開始後に割り当てられ、追跡終了時点でも解放されていないメモリだけを表示します。出力はHTMLなので、Claudeに読ませるのではなく、人が確認する用途です。
memrayのドキュメントは、この指定に1つ注意を添えています。Pythonのアロケーターは、オブジェクトを解放してもメモリをすぐシステムへ返しません。そのため、本来は問題のない割り当てまでリークに見えることがあります。--leaks を使うときは、PYTHONMALLOC=malloc を付けて実行するのが通常です。
PYTHONMALLOC=malloc memray run -o capture.bin myprogram.py
memray flamegraph --leaks capture.bin個々のPythonオブジェクトの生成と破棄まで見たいときは、memray run --trace-python-allocators があります。リーク調査に役立つとmemrayのドキュメントは述べていますが、記録量が増え、プロファイルも遅くなります。最初から付けず、stats で絞れなかったときの手段にします。
OOMで落ちる前に捕捉ファイルを確保する
メモリリークの調査では、OOMで落ちるプロセスを追うことがよくあります。コンテナがカーネルに落とされると、オーケストレーターが捕捉ファイルごと破棄することがあります。memrayはアプリと同じプロセスで動くので、落ちる瞬間にデータを逃がすことはできません。
memrayのドキュメントが案内する対策は、memray run をラッパースクリプトで包む方法です。
memray run --output /tmp/capture.bin myprogram.py
echo "Program finished"
memray summary /tmp/capture.binプロセスが落ちてもスクリプト自体は終わらないので、後処理を実行できます。集計結果を永続的な保存先へコピーする処理も、ここに書けます。ファイルを小さくしたいときは --aggregate もあります。個々の割り当てではなく、場所ごとの集計だけを保存します。集計の対象は、メモリ使用量が最大だった時点で解放されていない分です。
使い分けの目安
| 状況 | 選択肢 |
|---|---|
| Pythonコードだけで、どの行が増えるか知りたい | 選択肢tracemalloc(標準ライブラリ、導入不要) |
| C拡張が絡む疑いがある | 選択肢memray(--native でネイティブの呼び出しも含める) |
| 手元で数分再現できる | 選択肢どちらでもよい。差分をClaudeに渡しやすいのはtracemalloc |
| 本番でOOMが起きる | 選択肢memrayをラッパーで包んで捕捉ファイルを確保する |
手元で再現するなら、まずtracemallocの差分で行を絞ります。それで説明がつかないとき、またはネイティブ拡張を疑うときにmemrayへ進みます。
Claudeの答えを再計測で確かめる
Claudeが挙げた原因は仮説です。次の順で、数字で確かめます。
- Claudeに修正案を出させ、修正後に同じスナップショット手順を再実行する
- 7行目の
size_diffが小さくなったか、count_diffが消えたかを確認する - 消えないなら、新しい差分を再度渡す
上の例なら、キャッシュに上限を設けるか、functools.lru_cache(maxsize=...) のように件数を制限する修正になります。修正後の差分で7行目が増えなくなれば、原因の特定が正しかったと言えます。
Claudeに渡すプロンプトには、制約を入れておきます。たとえば「コードは変更しない」「候補は3件まで」「リークでない理由も書く」などです。調査と修正を1回の実行にまとめると、どの変更が効いたのかが分からなくなります。調査と修正を別の実行に分けると、再計測の結果とも結びつけやすくなります。
調査の指示を毎回書くのが面倒なら、CLAUDE.mdに手順を置けます。
## メモリ調査の手順
- 計測は tracemalloc の compare_to(snap1, "lineno") を使い、上位10件を見る
- 修正前に原因の仮説を書き、修正後に同じ計測を再実行して差分を比べる
- 計測コード自体の割り当て(tracemalloc.py や計測ループの整数)は除外して読むただし、--bare 付きの実行ではCLAUDE.mdを読み込みません。CIで回す場合は、プロンプトに直接書くか、--append-system-prompt で渡します。
読み違えやすい点
差分の読み方には、いくつか癖があります。
start()より前に確保したメモリは追跡の対象外で、差分にも現れません。早い段階から追跡したいときは、環境変数PYTHONTRACEMALLOC=1か、起動オプション-X tracemallocで起動時に開始できます- 差分の上位は「増えた場所」で、「リークしている場所」とは限りません。キャッシュが想定どおりに育っているだけの場合もあります
- スナップショットを2枚取る間隔が短いと、一時的な割り当てが差分に混ざります。ウォームアップの後に1枚目を取ります
- Claude Code自身のメモリ使用量が増える問題は、別の話です。それはClaude Codeのメモリリークで使用量が急増しOOM Killされる原因と対処で扱っています
数字の質がClaudeの答えの質を決めます。計測の手順を整え、再現性のある差分を渡すという考え方は、Claude Codeデバッグの精度は、渡す情報の質で決まると同じです。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。