凌晨3点,Kubernetes把我的Pod杀了
那是去年上线一个数据处理服务的时候。服务跑了两天,一切正常——直到第三天凌晨,Kubernetes的OOM Killer突然杀出来,把Pod干掉了。重启,再挂。重启,再挂。
一看监控:RSS内存从启动时的200MB,像爬坡一样稳稳涨到了2GB——典型的内存泄漏。
这篇就复盘一下当时我是怎么从一头雾水到定位根因、再到彻底修好的完整过程。会用到 tracemalloc、objgraph、gc 这三个工具,最后还会总结几个我踩过的 Python 内存泄漏坑,帮你防患于未然。
第一步:确认泄漏真实存在
Python 有垃圾回收(GC),很多人第一反应是「Python 不会内存泄漏」。这话对了一半:Python 的引用计数 + 循环检测器能处理绝大多数情况,但逻辑泄漏(logical leak)——你把对象放进一个全局容器里忘了删——GC 帮不了你。
首先确认是不是真的泄漏。最简单的办法是看 RSS:
# 每5秒采样一次,观察趋势
watch -n 5 'ps aux | grep python | grep -v grep | awk "{print \$6}"'
如果你看到 RSS 单调递增、GC 后不回落到初始值,那就是泄漏。我的服务当时是每小时涨 150MB,48 小时后踩到 2GB 上限。
也可以用 Python 自带的 tracemalloc 做快照对比——比看 RSS 更准,因为能直接告诉你哪行代码分配了最多内存。
第二步:tracemalloc 快照对比——精准定位泄漏点
tracemalloc 是 Python 3.4+ 内置模块,零依赖。核心思路:在服务的两个时间点分别拍快照,对比差异。
import tracemalloc
tracemalloc.start()
# === 拍第一张快照(服务启动后)
snapshot1 = tracemalloc.take_snapshot()
# ... 跑你的业务逻辑(比如处理1000条数据)...
# === 拍第二张快照
snapshot2 = tracemalloc.take_snapshot()
# 对比:找出增量最大的 10 个内存分配点
stats = snapshot2.compare_to(snapshot1, 'lineno')
for stat in stats[:10]:
print(stat)
输出会长这样:
data_processor.py:42: size=89.3 MiB (+78.2 MiB), count=1452000 (+1273000), average=64 B
cache_manager.py:128: size=34.1 MiB (+30.5 MiB), count=890000 (+780000), average=39 B
这一下就锁定了——data_processor.py 第 42 行和 cache_manager.py 第 128 行在疯狂分配内存。打开源码一看:
# data_processor.py:42
self._results_cache.append(processed_item) # 全局 list,永不清空!
# cache_manager.py:128
self._metrics_history[timestamp] = metrics # 全局 dict,key 是时间戳,无限增长
问题一目了然:两个全局容器充当了「黑洞」,数据只进不出。tracemalloc 从启动到定位,前后不到 5 分钟。
第三步:objgraph 追踪循环引用
修完上面两个明显的问题后,重启服务。内存涨得慢了,但还在涨——说明还有别的泄漏。这次 RSS 涨得慢且不规则,怀疑是循环引用。
循环引用在 Python 里会被分代 GC 处理,但如果对象定义了 __del__,GC 不会自动回收它们——因为 Python 不知道先调哪个 __del__。
用 objgraph 来可视化对象增长:
pip install objgraph
import objgraph
# 查看当前增长最多的对象类型
objgraph.show_growth(limit=10)
# 输出示例:
# function 1205 +12
# dict 4580 +8
# MyCustomClass 3210 +3200 ← 这个类在暴涨!
MyCustomClass 数量两天涨了 3200 个——显然没被回收。接着查是谁在引用它们:
# 随机选一个实例,画出引用链
objgraph.show_backrefs(
objgraph.by_type('MyCustomClass')[0],
max_depth=5,
filename='/tmp/backrefs.png'
)
引用链显示:每个 MyCustomClass 实例被一个 ThreadLocal 对象持有,而线程池里的线程在执行完后,thread-local 没有被清理。修复方案:在线程任务结束时显式清理:
# 修复前
def worker(task):
local = threading.local()
local.processor = MyCustomClass() # 泄漏!线程复用时 local 不清
local.processor.run(task)
# 修复后
def worker(task):
local = threading.local()
local.processor = MyCustomClass()
try:
local.processor.run(task)
finally:
del local.processor # 显式清理
线程池里用 threading.local() 是常见的坑——线程复用但 local 对象没清,相当于隐式全局变量。
第四步:修复前后对比
三个泄漏全部修完后,重新压测:
| 指标 | 修复前 | 修复后 | 改善 |
|---|---|---|---|
| 初始 RSS | 200 MB | 180 MB | -20 MB |
| 48h RSS | 2.0 GB (OOM) | 220 MB | -89% |
| 内存增长率 | ~150 MB/h | ~0.8 MB/h | -99.5% |
| GC 后回落到基线? | 否 | 是 | ✅ |
关键是 RSS 在 GC 后能回到基线附近,说明泄漏彻底堵住了。剩余的 0.8 MB/h 是正常的堆碎片波动,不用管。
第五步:常见 Python 内存泄漏场景速查
总结一下我在生产环境见过的 Python 内存泄漏元凶,排名分先后:
1. 全局容器无限增长(占比 40%)
# ❌ 泄漏:全局 list/dict 只 append 不清
processed_items = []
def handle(item):
processed_items.append(item) # 永不清空
# ✅ 修复:用 deque 设 maxlen 或用定期清理逻辑
from collections import deque
processed_items = deque(maxlen=10000) # 自动淘汰老的
2. 缓存未设上限(占比 25%)
# ❌ 泄漏:LRU 缓存无 maxsize
@lru_cache() # 默认 maxsize=128,但如果 key 是动态的……
def compute(key, data):
...
# ✅ 修复:显式设 maxsize
@lru_cache(maxsize=256)
def compute(key, data):
...
特别要注意:如果 lru_cache 的参数每次都不一样(比如带时间戳),即使 maxsize=128 也会导致缓存无限膨胀——因为每个新 key 都是 cache miss。
3. Thread-Local 不清理(占比 15%)
上面已经讲过了,线程池 + thread-local 是黄金搭档也是黄金陷阱。
4. 循环引用 + __del__(占比 10%)
# ❌ 泄漏:__del__ 阻止了循环引用的 GC
class Node:
def __init__(self, parent=None):
self.parent = parent
self.children = []
def __del__(self):
self.children.clear() # 有 __del__ 时,循环引用不会被回收!
# ✅ 修复:用 weakref 打破循环
import weakref
class Node:
def __init__(self, parent=None):
self.parent = weakref.ref(parent) if parent else None
self.children = []
5. 未关闭的文件/连接(占比 10%)
# ❌ 泄漏:文件句柄 + 缓冲区
def read_logs():
f = open('/var/log/app.log') # 异常时未 close
return f.read()
# ✅ 修复:context manager
def read_logs():
with open('/var/log/app.log') as f:
return f.read()
第六步:预防工具链
修好一个泄漏不算完,关键是别再引入新的。我在 CI 里加了这些检查:
# pytest fixture:每轮测试后检查是否有泄漏
import gc, tracemalloc
@pytest.fixture(autouse=True)
def check_memory_leak():
gc.collect()
tracemalloc.start()
before = tracemalloc.take_snapshot()
yield
after = tracemalloc.take_snapshot()
stats = after.compare_to(before, 'lineno')
# 如果测试后内存增长 > 5MB,直接报 FAIL
total_diff = sum(s.size_diff for s in stats)
assert total_diff < 5 * 1024 * 1024, \
f'疑似内存泄漏:{total_diff / 1024 / 1024:.1f} MB'
再配合生产环境的 RSS 监控告警(Prometheus + Grafana),只要 RSS 增长率超过阈值就触发告警,不用等 OOM。
加分技巧:用 pympler 做对象级别的内存账本
有时候 tracemalloc 告诉你某行代码分配了很多内存,但你搞不清是哪个对象类型在吃内存。这时候 pympler 就很有用——它能告诉你每种类型的对象占了多少字节:
pip install pympler
from pympler import summary, muppy
all_objects = muppy.get_objects()
sum1 = summary.summarize(all_objects)
summary.print_(sum1, limit=10)
# 输出示例:
# types | # objects | total size
# ===================================== | =========== | ============
# dict | 15320 | 12.45 MB
# numpy.ndarray | 4500 | 8.92 MB
# str | 120000 | 7.68 MB
# MyCustomClass | 8900 | 6.23 MB
这比 tracemalloc 的按代码行统计更直观——你一眼就能看出是 dict 太多还是自定义对象太多。我一般先用 tracemalloc 定位行号,再用 pympler 确认对象类型,两步交叉验证。
拓展:用 gc 模块的 DEBUG_LEAK 标志自动发现泄漏
Python 的 gc 模块有一个很少人知道的标志位 DEBUG_LEAK,能自动打印所有无法回收的循环引用:
import gc
gc.set_debug(gc.DEBUG_LEAK)
# 正常跑你的代码...
# 当 GC 检测到无法回收的循环引用时,会自动打印:
# gc: uncollectable
# gc: uncollectable
# gc: uncollectable
输出告诉你哪些对象被卡住了。结合上面提到的 __del__ 陷阱——有 __del__ 的循环引用会被标记为 uncollectable,DEBUG_LEAK 会在 stderr 直接打印出来。
不过 DEBUG_LEAK 在生产环境太吵了(每次 full GC 都打印),我一般在 debug 模式下手动触发一次 full GC 来看:
import gc, sys
gc.set_debug(gc.DEBUG_LEAK | gc.DEBUG_SAVEALL)
gc.collect() # 强制执行 full GC
if gc.garbage:
print(f"发现 {len(gc.garbage)} 个不可回收对象:", file=sys.stderr)
for obj in gc.garbage[:5]:
print(f" - {type(obj).__name__}: {str(obj)[:100]}", file=sys.stderr)
gc.garbage 列表会收集所有被 DEBUG_SAVEALL 标记的不可回收对象——你可以直接 inspect 它们的内容,进一步确认泄漏源头。
相关阅读
- 排查类问题经常伴随性能问题——Python asyncio性能调优实战:从Event Loop阻塞排查到并发上限突破
- 内存泄漏在并发场景下更难定位——Python并发编程选型指南:ThreadPoolExecutor vs asyncio vs ProcessPoolExecutor
- 排查工具箱的另一把利器——Linux生产环境CPU 100%问题排查实战:从发现到定位的完整复盘
📖 相关推荐:Redis生产环境踩坑实录:缓存穿透、雪崩、热点Key——从凌晨告警到根治
🔗 延伸阅读:排查完内存问题后,如果还遇到进程无响应但内存正常的诡异情况,strace 生产环境调试完全指南 能帮你从系统调用层面定位根因。
相关推荐:生产环境 OOM Killer 排查实战:从内存飙升到容器被杀的全链路诊断 — 跨进程视角的完整排查手册,涵盖 dmesg、cgroup、smem 工具链。
免责声明: 本文中的代码示例用于教学目的,在生产环境使用前请根据实际情况调整。内存分析工具(tracemalloc、objgraph)在生产环境开启会有性能开销,建议在 staging 环境复现问题。
Q: tracemalloc 对性能影响大吗?
tracemalloc 开启后大约有 5-15% 的性能开销,取决于分配频率。不建议在生产环境长期开启,推荐做法是:先在 staging 复现泄漏 → 开 tracemalloc 定位根因 → 修复后关闭。如果必须在生产开,用 tracemalloc.start(nframe=1) 只记录 1 帧可以显著降低开销。
Q: 为什么 gc.collect() 后内存还是不降?
Python 的垃圾回收释放的是 Python 对象,但归还给操作系统的内存由 pymalloc(Python 自己的内存分配器)管理。pymalloc 倾向于持有已释放的内存池以备复用,所以 RSS 不会立刻下降。这是正常行为,不是泄漏。判断标准是:RSS 是否持续增长不回落,而非是否一次性下降。
Q: 要不要用 memory_profiler 逐行分析?
memory_profiler 适合新代码开发时的逐行优化,但不适合生产泄漏排查——它太慢了(10x+ 减速),而且展示的是瞬时分配,不是增量趋势。排查泄漏首选 tracemalloc 快照对比。
总结
Python 内存泄漏排查的核心思路:
- 确认泄漏:RSS 趋势监控,GC 后不回落 = 泄漏
- 定位代码:tracemalloc 快照对比,直接指向具体行号
- 查循环引用:objgraph.show_growth() + show_backrefs()
- 修复 + 预防:全局容器设上限、lru_cache 设 maxsize、thread-local 显式清理、避免 __del__ 循环
- CI 拦截:pytest fixture 做内存回归测试
手里有这三个工具(tracemalloc、objgraph、gc),绝大多数 Python 内存泄漏都能在一个小时内定位到根因。别再等到凌晨 3 点被 OOM 叫醒了。
📎 推荐阅读:Python性能剖析三件套:py-spy、Scalene、memray实战对比 —— 系统了解Python性能剖析工具链,配合本文的排查经验效果更佳。