python-memory-leak

Python 服务 RSS 持续上涨、容器反复 OOMKilled,而单次请求并不大。

现象

进程 RSS 或 cgroup 内存随时间单调上升,重启后恢复;容器被 OOM Killer 终止(退出码 137),或在分配大对象时抛 MemoryError。常见于长期运行的服务、定时任务和 worker 进程。

快速定位

先确认内存是否真的在涨,再看是 Python 对象还是 native 分配:

ps -o pid,rss,vsz,etime,cmd -p <PID>
grep -E 'VmRSS|VmSwap|VmData' /proc/<PID>/status
cat /sys/fs/cgroup/memory.current
cat /sys/fs/cgroup/memory.max
cat /sys/fs/cgroup/memory.events
grep -E '^(anon|file|slab) ' /sys/fs/cgroup/memory.stat

memory.current 包含 page cache,判断泄漏要看 anon 和 memory.events 里的 oom_kill;VmRSS 才是进程常驻内存。接着看分配栈:

py-spy dump --pid <PID>
python -X tracemalloc=25 app.py
memray run --output /tmp/app.bin app.py
memray flamegraph /tmp/app.bin
memray attach <PID>

tracemalloc 只覆盖 Python 分配器,memray 能覆盖 C 扩展和 native 分配。在代码里做快照差分:

python - <<'PY'
import tracemalloc
tracemalloc.start(25)
run_workload()
for stat in tracemalloc.take_snapshot().statistics("lineno")[:10]:
    print(stat)
PY

易误判:释放对象后 RSS 不下降不等于泄漏 —— pymalloc 和 glibc arena 会保留已释放的内存。要用 gc.collect() 后的 live 对象数、anon 斜率和 oom_kill 共同判断,而不是只看一次 RSS。

常见根因

  1. 模块级/全局缓存无上限:dict、list、@lru_cache(maxsize=None) 持续累积。
  2. 长生命周期容器持有对象:全局 registry、类属性、线程局部变量、回调/监听器未注销。
  3. 资源未释放:DB session/cursor、文件、socket、HTTP response 未 close()。
  4. 一次性读入大文件/大 JSON,或 pandas 全量 DataFrame、切片副本常驻。
  5. 引用环配合 __del__ 或 C 扩展,导致对象进入 gc.garbage 无法回收。
  6. C 扩展/native 内存泄漏(图像、加密、科学计算库),tracemalloc 看不到。
  7. 多线程下 glibc malloc arena 碎片,MALLOC_ARENA_MAX 未限制。
  8. 后台线程/子进程不断创建未回收;fork 后 CoW 页面被写脏。

修复

相关命令

读完自测

  1. RSS 上涨但 gc.collect() 后对象数不涨,最可能是什么问题?
  2. 为什么 tracemalloc 看不到 numpy/C 扩展的分配?
  3. cgroup memory.current 很高但进程 RSS 不高,应该看哪个字段?
答案
  1. 多半是分配器碎片、arena 保留或 native 内存,而不是 Python 对象泄漏;结合 anon、memray 和 RSS 斜率判断。
  2. tracemalloc 只跟踪 Python 内存分配器;未经过它的 C malloc/mmap 不会出现,需用 memray 或 valgrind。
  3. 看 memory.stat 的 file 与 anon;file 高通常是 page cache,可回收,不能据此判定进程泄漏。