python-memory-safe-scripts について
systemd のメモリ制限内で動く Python スクリプトを書くための実践的なスキルです。データ処理スクリプトをもとに、アロケーターの解放、オブジェクトの寿命、リソースの後処理を扱います。
主な機能
- 01アロケーターのメモリを明示的に解放
- 02HTTP レスポンスをすぐに閉じる
- 03不要になった大きなオブジェクトを削除
- 04ガベージコレクションを定期実行
- 05スレッドプールを後処理し接続を再利用
- 06systemd のメモリ制限を設定
活用シーン
- 01バッチスクリプトを systemd のメモリ上限内で実行
- 02大量データ処理中の RSS 増加を抑制
- 03ワーカープールでのファイルディスクリプター枯渇を回避
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ terrylica/ cc-skills --skill python-memory-safe-scriptsインストール後、Claude Code で/python-memory-safe-scriptsを入力して読み込まれたか確認します。
詳細
メモリ安全性を高める Python スクリプトのパターン
systemd の MemoryMax 制約下で Python スクリプトを稼働させ続けるための、実戦で検証済みのパターンです。62 GB の GPU ワークステーションで、5 回の OOM 最適化サイクルを経て、repair_direct_parquet.py(24 ワーカーによる並列修復)と exness_tick_cache_seeder.py(10 銘柄の日次シーダー)から抽出しました。
重要なポイント:Python のガベージコレクターはオブジェクトを解放しますが、C アロケーター(glibc ptmalloc2)は解放済みページを OS に返しません。malloc_trim(0) を明示的に呼び出さない限り、del と gc.collect() の後でも RSS は増加し続けます。mimalloc と MIMALLOC_PURGE_DELAY の併用も役立ちますが、明示的なパージの方が高速です。
進化し続けるスキル:このスキルは使用を通じて改善されます。手順に誤りがある、パラメーターがずれている、または回避策が必要だった場合は、先延ばしせず、すぐにこのファイルを修正してください。実際に再現可能な問題がある場合に限り更新してください。
7 つのパターン
1. キャッシュされたアロケーターのパージ
最も重要なパターンです。初回の呼び出し時に ctypes ライブラリハンドルをキャッシュすれば、以降のパージは割り当てオーバーヘッドなしの単一 FFI 呼び出しで済みます。
import ctypes
import gc
import sys
_purge_lib = None
_purge_method = None # "mimalloc" | "glibc" | "none"
def _force_allocator_purge():
"""Force mimalloc/glibc to return freed pages to the OS."""
global _purge_lib, _purge_method
if sys.platform != "linux":
return
if _purge_method is None:
try:
_purge_lib = ctypes.CDLL("libmimalloc.so.2")
_purge_method = "mimalloc"
except OSError:
try:
_purge_lib = ctypes.CDLL("libc.so.6")
_purge_method = "glibc"
except OSError:
_purge_method = "none"
if _purge_method == "mimalloc":
_purge_lib.mi_collect(ctypes.c_bool(True))
elif _purge_method == "glibc":
_purge_lib.malloc_trim(0)
def _force_gc():
"""Python GC + allocator purge. Call every 50 iterations + between work units."""
gc.collect()
_force_allocator_purge()
ハンドルのキャッシュが重要な理由:ctypes.CDLL("libc.so.6") は dlopen() を呼び出し、それ自体がメモリを割り当てます。ループ内で 1400 回呼び出すのは逆効果です。一度だけキャッシュしてください。
mimalloc を優先する理由:LD_PRELOAD=libmimalloc.so.2 が有効な場合、mimalloc がすべての割り当てを横取りしているため、glibc の malloc_trim は何もしません。mimalloc では mi_collect(True) が適切なパージ方法です。
2. HTTP レスポンスのライフサイクル
必要な内容を取り出したら、すぐにレスポンスを閉じてください。requests ライブラリはレスポンス本文、コネクションプールへの参照、urllib3 の内部状態を保持します。
# CORRECT: extract content, close, delete
resp = requests.get(url, timeout=60)
if resp.status_code != 200:
resp.close()
return None
content = resp.content # Extract what you need
resp.close() # Release connection pool reference
del resp # Drop the Python object
# Process content...
del content # Release after processing
# WRONG: response lives until end of function scope
resp = requests.get(url, timeout=60)
data = parse(resp.content) # resp still alive, holding ~18 MB
return data # resp GC'd eventually... maybe
重要な理由:各 requests.Response は content(本文全体)、urllib3.HTTPResponse への参照、およびコネクションプールの PoolManager を保持します。4 つのワーカーが同時に 1400 個の URL を処理する場合、閉じていないレスポンスが蓄積し、数百 MB に達します。
3. オブジェクトの明示的な削除
大きなオブジェクトについて、Python の GC に頼らないでください。オブジェクトが不要になったら、すぐに del を使用してください。
# After writing a DataFrame to Parquet
_atomic_write_parquet(df, path)
del df # Only reference gone → immediate refcount GC
# After extracting data from a ZIP
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
df = pl.read_csv(zf.open(zf.namelist()[0]), ...)
del zip_bytes # Release raw ZIP content after parsing
# After processing a list of work items
results = process_all(missing_days)
del missing_days # Release the 1400-element date list
del を使うタイミング:不要になった約 1 MB を超えるオブジェクトです。DataFrame、HTTP レスポンスのバイト文字列、ZIP の内容、大きなリストが該当します。
4. GC の定期的な実行間隔
次の 2 つのレベルで _force_gc() を呼び出してください。
# Level 1: Every 50 iterations within a work unit
for i, item in enumerate(items):
process(item)
if (i + 1) % 50 == 0:
_force_gc()
# Level 2: Between major work units
for symbol in symbols:
seed_symbol(symbol)
_force_gc() # Release all per-symbol state before next symbol
50 が適切な理由:32 コアのワークステーションで実証されています。100 ではパージ前に RSS が増えすぎます。25 ではパージのオーバーヘッドが測定可能になります(スループットが約 2% 低下)。repair_direct_parquet.py で 50 が最適な値でした。
5. ThreadPoolExecutor のクリーンアップ
エグゼキューターの終了後、残存する状態を明示的にクリーンアップしてください。
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as ex:
pending = {}
# ... bounded future submission pattern ...
# After pool exits:
del pending # Future objects hold references to results
del missing # Work item list
_force_gc() # Release worker thread memory + allocator pages
より高度なケース(ワーカー内の DB 接続など)では、スレッドローカルリソースを明示的に閉じてください。
pool.shutdown(wait=False, cancel_futures=True)
for t in threading.enumerate():
if t.name.startswith("ThreadPoolExecutor"):
_close_worker_cache() # Close DB connections
gc.collect()
_force_allocator_purge()
6. スレッドローカルな接続の再利用
ループ内でデータベース接続や HTTP セッションを作成しないでください。threading.local() を使って、ワーカースレッドごとに 1 つの接続を取得してください。
import threading
_thread_local = threading.local()
def _get_worker_cache():
"""One DB connection per worker thread, reused across all iterations."""
cache = getattr(_thread_local, "cache", None)
if cache is None:
cache = DatabaseClient()
_thread_local.cache = cache
return cache
def _close_worker_cache():
"""Explicit cleanup at shutdown."""
cache = getattr(_thread_local, "cache", None)
if cache is not None:
cache.close()
_thread_local.cache = None
ファイルディスクリプターの枯渇を防げる理由:各 urllib3.PoolManager(maxsize=20) は最大 20 個のファイルディスクリプターを保持します。24 ワーカーのプール内で反復ごとに新しいものを作成すると、数分で ulimit -n 1024 を使い切ります。スレッドローカルな再利用により、ワーカー数を N とした場合のファイルディスクリプター数を約 4N+50 に抑えられます。
7. systemd サービスの設定
[Service]
# Memory limits — hard kill prevents runaway RSS
MemoryHigh=2G # Soft limit: triggers reclaim pressure
MemoryMax=4G # Hard limit: SIGKILL on breach
MemorySwapMax=0 # No swap escape — fail fast, don't thrash
# mimalloc: replaces glibc ptmalloc2, returns freed pages faster
Environment=LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libmimalloc.so.2
Environment=MIMALLOC_PURGE_DELAY=1000
# OOM priority (lower = more likely to survive)
OOMScoreAdjust=-200
ManagedOOMMemoryPressure=kill
MemoryHigh と MemoryMax の違い:MemoryHigh はカーネルによるメモリ回収(cgroup のメモリー圧力)を引き起こします。プロセスの動作は遅くなりますが、終了しません。MemoryMax は強制終了を伴う上限です。カーネルが強制終了前にメモリを回収できるように、MemoryHigh は MemoryMax の 50〜66% に設定してください。
アンチパターン
| アンチパターン | 失敗する理由 | 修正方法 |
|---|---|---|
ループ内での ctypes.CDLL("libc.so.6") | dlopen() はメモリを割り当てます。1000 回の呼び出しで約 50 MB を浪費します | ハンドルをモジュールのグローバル変数にキャッシュする |
resp.close() を呼び出さない requests.get() | レスポンスボディと接続プールが GC まで保持されます | コンテンツを抽出した直後に resp.close() と del resp を実行する |
作業単位の間に gc.collect() を呼び出さない | 循環参照がシンボル/バッチをまたいで蓄積します | 主要な作業単位ごとに _force_gc() を実行する |
| ループの反復ごとに新しい DB 接続を作成する | 各接続で urllib3 PoolManager 経由の fd を 20 個消費します | スレッドごとに接続を再利用するため threading.local() を使う |
OOM の修正に MemoryMax を引き上げる | メモリリークを隠すだけです。RSS はどんな制限も埋めるまで増加します | まずメモリリークを修正してください。修正方法は常にパターン 1~6 のいずれかです |
gc.collect() を呼び出さずに del df する | 参照カウントでオブジェクトは解放されますが、glibc はページを保持します | del + gc.collect() + _force_allocator_purge() を実行する |
MemorySwapMax が設定されていない | プロセスは終了せずにディスクへスワップし、何時間もスラッシングします | MemorySwapMax=0 を設定して、スラッシングさせずに速やかに失敗させる |
診断チェックリスト
systemd の下でスクリプトが SIGKILL(status=9)を受けた場合:
- OOM であることを確認する:
journalctl --user -u service.service | grep -E "killed|signal|KILL" - ピーク RSS を確認する:
systemctl --user status service.service | grep Memory(ピーク値が表示されます) - 定常状態の RSS をプロファイルする:スクリプトを手動で実行し、30 秒間隔で 3 回、
/proc/PID/statusのVmRSSを確認します - fd 数を確認する:
ls /proc/PID/fd | wc -l— 500 を超える場合は、接続の頻繁な生成を疑います(パターン 6) - アロケーターを確認する:サービスファイルに
LD_PRELOAD=libmimalloc.so.2がありますか?glibc の場合は、malloc_trimが呼び出されているか確認します - 定期的なログを追加する:50 回の反復ごとに
logger.info("RSS=%d MB", psutil.Process().memory_info().rss // 1048576)を実行します
参考実装
| スクリプト | 使用パターン | RSS プロファイル |
|---|---|---|
scripts/repair_direct_parquet.py | 7 つすべてのパターン | 3 GB で開始し、24 ワーカーで約 13 GB で頭打ちになります |
scripts/exness_tick_cache_seeder.py | パターン 1~5、7 | 10 シンボル × 1400 日間、163 MB で一定です |
scripts/tick_cache_seeder.py | パターン 3、7 | mimalloc ではピーク 2.5 GB(glibc では 4.47 GB) |
実行後の振り返り
このスキルの実行が完了したら、終了する前に確認してください:
- コマンドは成功しましたか? — 成功しなかった場合は、失敗の原因となった指示またはエラーテーブルを修正してください。
- パラメーターまたは出力に変更はありましたか? — 基盤となるツールのインターフェースに変更があった場合は、使用例とパラメーター表を更新して、それに合わせてください。
- 回避策が必要でしたか? — 別のフラグや追加手順などの工夫が必要だった場合は、次回同じ回避策が必要にならないよう、この SKILL.md を更新してください。
問題が実在し、再現可能な場合にのみ更新してください。推測に基づいて更新しないでください。