容器内存限制命中 cgroup 上限后,内核会主动触发 OOM Killer 终止进程,而不是让应用正常抛异常。因此容器重启前应用日志很可能没有任何 OutOfMemoryError 或 panic 记录,只能看到 K8s 事件里标记 OOMKilled。要定位是哪个对象、哪段代码造成内存持续增长,必须结合内核 OOM 日志、容器运行时日志和进程的堆快照来还原现场。

一、容器 OOM 的触发机制与日志特征
在 Kubernetes 环境中,容器内存限制由 cgroup 的 memory.limit_in_bytes 或者 cgroup v2 的 memory.max 控制。当容器内所有进程的内存使用量逼近这个限制,而且无法通过回收 page cache 来释放时,内核会从当前 cgroup 中选择一个或多个进程发送 SIGKILL 信号。这个动作会被记录在内核日志里,关键字通常包含 oom-kill、constraint=CONSTRAINT_MEMCG、Memory cgroup out of memory 等。因此排查容器 OOM 的第一步不是去看应用日志,而是先确认内核是否真的执行了 OOM Killer。
如果使用 Kubernetes,最直观的入口是 kubectl describe pod。你会看到类似下面的输出。Last State 中的 Reason 为 OOMKilled,Exit Code 为 137。这里的 137 是 128 加 9,也就是进程被 SIGKILL 信号终止的经典标志。需要注意,OOMKilled 只说明容器因为内存压力被杀,不代表内存一定发生了泄漏,也可能只是当前 limit 设置过低。
kubectl describe pod my-app-7d5f6c9b8-abcde -n production
...
Last State: Terminated
Reason: OOMKilled
Exit Code: 137
...
内核日志则能提供更底层的计量信息。dmesg、journalctl -k 或者 /var/log/messages 中会出现类似 oom-kill 和 rss 字段。其中 anon-rss 表示匿名内存,通常对应堆、栈和动态分配;file-rss 是文件映射缓存。如果 anon-rss 占据了绝大多数内存,并且随运行时间单调递增,这就非常接近内存泄漏的特征。如果 file-rss 过高,则可能是 IO 缓存造成的一过性内存压力,通常可以通过降低 page cache 或者调整内核参数缓解。
二、从堆快照与类实例分布还原泄露对象
仅靠内核日志只能确认内存总量超限,还看不到具体是哪个结构或对象占用了大量空间。对运行在 JVM 上的服务,可以在启动参数中加入 HeapDumpOnOutOfMemoryError,让进程在真正 OOM 时自动生成堆快照。即使容器随后被杀死,只要 dump 文件被写入持久卷或外挂存储,就可以事后分析。参数示例如下。
java -XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/dumps/java_heap.hprof \
-jar app.jar
拿到 hprof 文件后,用 Eclipse MAT 或 jhat 打开。先看 Dominator Tree 或者 Leak Suspects 报告,通常最大的支配对象就是泄漏源头。比如某个 HashMap 持有几百万个 Key,或者某个线程的局部变量一直没有释放。也可以用 jcmd 查看类直方图,快速观察哪个类的实例数量和占用字节数异常。命令如下。
jcmd <PID> GC.class_histogram
不过 jcmd 需要进程还活着。容器 OOM 后进程往往已经被终止,因此更稳妥的方式是在容器重启前,通过 ReadinessProbe 或者监控脚本,在内存接近 limit 时主动触发一次 jmap dump。例如当 JVM 堆使用率超过 85% 时,调用 jmap -dump:live,format=b,file=/dumps/heap_live.hprof。拿到 live 堆后,再沿着 GC Root 路径分析引用链,就能定位到最终持有大量内存的根对象。
如果服务是用 Go 编写的,标准库自带 pprof,不需要等到进程死亡。导入 net/http/pprof 后,可以直接抓取 heap profile。相比 JVM 的 hprof,Go 的 heap profile 会显示分配栈,更容易定位到哪一行代码在不停分配。结合 go tool pprof 的 top 和 list 命令,可以快速看到内存热点。
import _ "net/http/pprof"
func main() {
go func() {
http.ListenAndServe(":6060", nil)
}()
...
}
go tool pprof -http=:8080 http://127.0.0.1:6060/debug/pprof/heap
这样既能实时观察,也能在线生成火焰图。对 Python 服务则可以使用 tracemalloc 模块,在内存增长前后各拍一次快照,对比分配差异,定位到具体对象和行号。
三、用监控趋势验证泄漏并修复关键代码
判断内存泄漏不能只看一次 OOM 事件。短时间的峰值和真正的泄漏在监控曲线上的表现完全不同。通过 cAdvisor 或 Prometheus 采集容器的 container_memory_working_set_bytes 指标,如果发现内存在没有流量增长的情况下持续上升,重启后再次从低位开始反复爬升,就基本可以判定存在泄漏。设置合理的 Grafana 告警阈值,比如连续 30 分钟内存使用超过 limit 的 85%,可以在 OOM 前介入抓取快照。
常见的泄漏代码模式很多,但本质都是对象生命周期被拉长。Java 中 ThreadLocal 如果没有 remove,线程池中的线程会被反复复用,导致线程私有变量无法回收。Python 的缓存如果不设上限,会把所有历史结果都留在内存里。Go 的 time.Ticker 或 time.After 不用 Stop 会导致定时器关联的 channel 和 Goroutine 无法释放。修复这些写法的核心原则是:短生命周期的数据不要挂在长生命周期的对象上。
下面是 ThreadLocal 使用显式清理的示例。注意 finally 块里的 remove 调用不能省略,否则在高并发线程池场景下很容易出现缓慢的内存增长。
public class ThreadLocalLeak {
private static final ThreadLocal<List<byte[]>> BUFFER = new ThreadLocal<>();
public void process() {
try {
BUFFER.set(new ArrayList<>());
// 处理业务
} finally {
BUFFER.remove();
}
}
}
Python 的缓存也需要显式限制容量。如果使用 functools.lru_cache,建议设置 maxsize;如果使用自定义 dict 缓存,最好配合过期时间或 LRU 淘汰策略,避免缓存无限增长。
from functools import lru_cache
@lru_cache(maxsize=128)
def query_user(user_id: int):
...
Go 里使用 time.NewTicker 时,defer ticker.Stop() 是必须的。如果只依赖 GC 清理 Ticker,底层 Timer 并不会及时被回收,长时间运行的高频任务会积累大量未被释放的资源。
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for range ticker.C {
// 定时任务
}
最后还需要注意,容器 OOM 的排查不是一次性动作。建议在 CI 流程中加入内存泄漏检测:对于 Java 服务可以跑一段时间的压测并对比堆直方图;对于 Go 服务可以采集 pprof 做基准对比;对于 Python 服务可以用 tracemalloc 快照差值。只有把监控、快照和代码修改结合起来,才能避免容器频繁重启不停消耗运维时间。