凌晨三点,告警炸了
2026年6月的某个周三凌晨,我被 PagerDuty 的连环电话炸醒。打开监控面板一看,Django 服务的三个 Pod 全红了——CrashLoopBackOff。kubectl describe pod 最后一行写着:
State: Waiting
Reason: CrashLoopBackOff
Last State: Terminated
Reason: OOMKilled
Exit Code: 137
Exit code 137 = 128 + 9(SIGKILL)。被 Linux 内核的 OOM Killer 干掉了。
那天晚上我花了两个小时排查根因,修完之后又花了两天写复盘文档。这篇文章就是我把它整理成一份可复用的排查手册——从原理到诊断工具,再到具体修复策略,一步步来。
OOM Killer 到底是个什么东西?
简单说:Linux 内核里住着一个”刽子手”。当系统内存(包括 swap)都被榨干了,它就得挑一个最”该死”的进程杀掉,释放内存让系统活下去。
选谁杀?不是随机的。内核给每个进程打了一个 OOM Score(0~1000),分数越高越危险。计算公式大概长这样:
oom_score = (进程内存占用 / 总内存) * 1000 + oom_score_adj
但实际远比这个复杂。内核还会考虑:
- 子进程的内存也算在父进程头上——你 fork 了一堆 worker,它们的 RSS 全加到你身上
- root 进程默认有 -30 的 oom_score_adj——超级保护
- cgroup 里的进程独立计分——容器场景下尤其重要,后面会细说
你可以直接看自己的 OOM Score:
$ cat /proc/self/oom_score
12
$ cat /proc/self/oom_score_adj
0
oom_score 是实时计算的、只读的。如果你想让某个进程被”豁免”,改 oom_score_adj 就行(-1000 到 1000,负数=保护,正数=优先被宰)。Docker 给每个容器默认设了 -1000,意味着容器整体作为一个 cgroup 被评估,而不是容器内部单个进程——这个细节很多人忽略,恰恰是排查的关键。
诊断工具箱:出事了先查这四样
1. dmesg — OOM Killer 的”行刑记录”
内核每次执行 OOM Kill,都会在 ring buffer 里留一份详细日志。直接看:
$ dmesg -T | grep -i "killed process" | tail -5
[Wed Jun 10 03:17:42 2026] Killed process 28471 (celery) total-vm:1856432kB, anon-rss:873456kB, file-rss:12456kB, shmem-rss:0kB
[Wed Jun 10 03:17:42 2026] oom-kill:constraint=CONSTRAINT_MEMCG,nodemask=(null),cpuset=...,mems_allowed=0,oom_memcg=/kubepods/burstable/pod...,task_memcg=/kubepods/burstable/pod...,task=celery,pid=28471,uid=1000
注意这里的 CONSTRAINT_MEMCG——说明不是系统整体 OOM,而是 cgroup 级别的 OOM。容器场景下最常见的类型。
2. /var/log/kern.log — 完整行刑报告
$ grep -A 30 "invoked oom-killer" /var/log/kern.log | tail -40
这份报告里有所有关键信息:
- 谁触发的:哪个 cgroup 先超限
- 内存快照:触发时 total-vm、anon-rss、file-rss 各是多少
- 候选名单:每个进程的 oom_score 和 oom_score_adj
- 最终判谁死刑:被杀进程的名字、PID、内存占用
3. cgroup memory.stat — 容器内存全景
在 Kubernetes 环境下,每个 Pod 有自己的 cgroup。直接查:
# 找到 Pod 的 cgroup 路径
$ kubectl exec <pod> -- cat /sys/fs/cgroup/memory/memory.stat
# 或者在宿主机上(需要 root)
$ cat /sys/fs/cgroup/memory/kubepods/burstable/pod<UID>/memory.stat
关键字段:
cache 524288000 # 页缓存(文件IO缓存,可回收)
rss 943718400 # 常驻内存(不能被回收的)
rss_huge 0
shmem 104857600 # 共享内存
mapped_file 209715200 # 文件映射内存
swap 0 # swap 使用(容器一般没 swap)
看到 rss 接近 limit 但 cache 很大?说明文件 IO 导致内存”虚高”——cache 在压力下是可以回收的,但内核有时不够积极。
4. smem — 看清每个进程到底吃了多少
top 和 ps 看的是 RSS,但 RSS 会把共享库重复计算。真实内存占用要看 PSS(Proportional Set Size):
$ smem -tk -s pss | head -20
PID User Command Swap USS PSS RSS
28471 app celery -A tasks worker 0 523456 540123 873456
28102 app gunicorn: master 0 102400 112345 256789
...
- USS:进程独占内存——这个进程挂了就能回收的
- PSS:按共享比例分摊后的内存——最接近”真实占用”
- RSS:包括共享库的完整常驻内存——高估了
举个例子:3 个 Celery worker,每个加载同一个 200MB 的 ML 模型,RSS 各显示 +200MB(总共 600MB),但 PSS 只算 200MB / 3 ≈ 67MB 每人。OOM 排查时如果你只看 RSS,会严重高估。
实战复盘:Django + Celery,谁在暗中吃内存?
回到那个凌晨。我查了 dmesg,发现被杀的是 Celery worker 主进程。但问题是:这个 Pod 配了 1Gi 内存 limit,平时 RSS 也就 500MB 左右,怎么就 OOM 了?
我用下面这个脚本在 Pod 里连续采集了 5 分钟的快照(事后加的,但问题已经复现了):
#!/bin/bash
# 每 5 秒采集一次进程内存快照
while true; do
echo "=== $(date +%H:%M:%S) ==="
ps -eo pid,comm,rss,vsz --sort=-rss | head -10
cat /sys/fs/cgroup/memory/memory.usage_in_bytes
echo "---"
sleep 5
done
数据出来后画了张图(就是上面那张),一眼就看出来了:
- Django worker(gunicorn):RSS 从 200MB 稳步涨到 450MB,斜率稳定——典型的慢泄漏
- Celery worker:前 30 小时正常(300~400MB),然后加速飙升,最后 6 小时从 420MB 冲到 780MB
Celery 的加速泄漏是导致 OOM 的直接凶手。那问题来了——什么任务会让 Celery 这样吃内存?
定位根因:三个误区的纠正
误区 1:”内存泄漏一定是指针没 free”
Python 有 GC,纯 Python 代码很少发生经典的内存泄漏。我们这次的问题是 Celery 任务里调用了 Pandas 处理一个 80MB 的 CSV,中间产生了多个临时 DataFrame 没显式 del——Python 的引用计数没清掉它们是因为这些 DataFrame 被缓存在某个模块级字典里了。
用 memory_profiler 一跑就现原形:
$ python -m memory_profiler tasks.py
Line # Mem usage Increment Line Contents
================================================
42 312.5 MiB 312.5 MiB df = pd.read_csv('/data/huge_report.csv')
43 423.8 MiB 111.3 MiB df_clean = df.dropna().pipe(transform)
44 502.1 MiB 78.3 MiB df_merged = pd.merge(df_clean, lookup, on='id')
...
52 502.1 MiB 0.0 MiB return result # ← df_clean 和 df_merged 没释放
Celery 的 worker 进程是长生命周期的——它不会在每次任务结束后重启。任务函数的局部变量理论上应该被 GC 回收,但如果有个全局 cache dict 或者类属性引用了这些 DataFrame,GC 就收不走。
误区 2:”加内存 limit 就行了”
我们确实加了 limit: 1Gi,但这不是万能药。memory.limit_in_bytes 只管硬上限,到了就杀。对于慢泄漏,你需要的是提前感知而不是等到被杀。
正确的做法:监控 memory.usage_in_bytes / memory.limit_in_bytes 的比例,设告警阈值(比如 80%)。Kubernetes 里用 Prometheus + node-exporter 或直接在应用里暴露 metrics。
误区 3:”OOM 之后重启就好了”
这句话跟”蓝屏之后重启就好了”一样——治标不治本。我们的 Celery worker 被杀后,Kubernetes 确实会自动重启,但重启后又会接同样的任务、触发同样的泄漏,6 小时后再次 OOM。CrashLoopBackOff 就是这种循环达到上限后的状态。
修复策略:三条路选哪条?
| 方案 | 做法 | 适用场景 |
|---|---|---|
| 治本:修泄漏 | 任务结束时显式 del + gc.collect(),避免全局引用 | 你已经定位到了泄漏源 |
| 治标:worker 回收 | Celery 的 --max-tasks-per-child 参数,worker 处理 N 个任务后自动重启 |
泄漏源不明或短期无法修复 |
| 防御:预判式 Kill | 应用内监控 RSS,接近阈值时主动 Graceful Shutdown | 必须零宕机的核心服务 |
我们最终选的是组合拳:
# 1. 修泄漏:Celery task 最后加清理
@app.task
def process_report(filepath):
try:
df = pd.read_csv(filepath)
# ... 处理逻辑 ...
return result
finally:
# 关键三行
del df
import gc; gc.collect()
# 2. Celery worker 配置加固
# celery_app.py
app.conf.worker_max_tasks_per_child = 50 # 50个任务后重启
app.conf.worker_max_memory_per_child = 400000 # 400MB 后重启(单位KB)
worker_max_memory_per_child 是 Celery 4.0+ 才有的参数,会在 worker 内存超过阈值后完成当前任务再重启,比 OOM Killer 优雅得多。
FAQ
Q: 为什么容器里 dmesg 看不到 OOM 日志?
dmesg 读的是宿主机的内核 ring buffer,容器内部默认没有权限。解决:在宿主机上执行,或者开 privileged 模式(不推荐),或者用 Loki/Promtail 把宿主机 /var/log/kern.log 采集进日志系统。
Q: OOM Score 能和 RSS 不成比例吗?
会。如果一个进程 fork 了 20 个子进程,每个子进程 RSS 100MB,父进程的 OOM Score 会计入全部 2GB——即使父进程自己只用了 50MB。这就是为什么被 kill 的常常是父进程(比如 Celery 主进程),而不是某个子 worker。
Q: Kubernetes Pod OOM 和节点 OOM 有什么区别?
Pod OOM(cgroup级别):Pod 的内存使用超过了 resources.limits.memory,只杀该 Pod 内的进程。节点 OOM:节点上所有 Pod 的总内存超出物理内存,内核按全局 OOM Score 挑一个杀——被杀的可能是别人的 Pod。dmesg 里 CONSTRAINT_MEMCG = Pod OOM,CONSTRAINT_NONE = 节点 OOM。
Q: 为什么我的应用内存没涨但 OOM 了?
检查文件缓存(page cache)。大量文件读写会让 cache 占满容器内存,虽然 cache 理论上可回收,但如果应用分配新内存的速度超过了内核回收 cache 的速度,OOM Killer 就会被触发。解决:调整 vm.vfs_cache_pressure 或加内存 limit 的 buffer。
总结
OOM Killer 的排查说难不难,说易不易——它有固定的套路,但每个案例的根因都不一样。我自己的 checklist:
- dmesg 看行刑记录 → 确定是谁被杀了、是 cgroup 级别还是节点级别
- cgroup memory.stat → 看 RSS vs cache 比例,判断是泄漏还是缓存膨胀
- smem -s pss → 找到真实内存大户(不看 RSS,RSS 骗人)
- 连续采集快照 → 绘制内存增长曲线,区分慢泄漏 vs 突增
- 修复 + 监控 → 修根因 + 设 80% 告警 + Celery worker_max_memory_per_child 兜底
那次之后,我们的所有 Python 服务都加了内存告警,Celery worker 统一配置了 worker_max_memory_per_child。我甚至写了个小脚本,每分钟检查 RSS,超过 85% limit 就主动发 SIGTERM——自己杀自己,比被内核杀体面多了。
顺带一提,如果你在排查类似问题,Python 性能剖析三件套(py-spy、Scalene、memray) 和 Python 生产环境内存泄漏排查实战 这两篇也有不少可复用的诊断方法。
