OOM 之前,留下现场:HUATUO 的多语言内存快照方案
容器恢复了,问题却没有结束
一个服务的内存持续上涨,最终被 OOM 终止。容器重启后,流量恢复,监控曲线也回落了。排查的人打开告警:容器名、时间、内存峰值都有,唯独不知道那一刻内存里装着什么。
以请求处理服务为例,缓存变大、下游变慢造成队列积压、对象被意外持有,都可能画出相似的曲线。第一反应往往是加大限额,或者等下次复现再抓 heap dump。
如果只是限额低于正常工作集,调整限额可以解决问题;如果内存持续泄漏,扩容往往只是争取时间。heap dump 也可以自动触发,但需要提前配置,并考虑生成时的暂停、CPU 和存储开销。
即使知道 OOM killer 最终选择了哪个进程,也不能据此判断是哪类对象、哪条分配路径导致了增长。尤其一个容器里存在多个进程时,容器水位与单个进程的运行时内存之间,还隔着一层选择和解释。
HUATUO 的 memsnapshot 把采集提前到内存压力阶段:收到内存通知后复查水位,达到阈值时读取仍在运行的进程,保存能够帮助定位的内存摘要。 进程后来即使重启,已保存的线索仍然可以继续分析。不过,通知时机、内存增长速度和采集冷却状态都会影响能否赶上,不能保证每次 OOM 前都能留下快照。
先选容器,再选进程
采集从容器的内存水位开始。HUATUO 获取运行中容器的 memory cgroup 路径,监听内存通知;收到通知、完成监听注册或硬限额发生变化后,重新读取使用量与限额。使用比例达到配置阈值,才有机会进入采集,默认阈值为 90%。未设置内存上限的容器不参与比例判断。
这个阈值用于检查时判断是否采集,并不代表内核会在该水位发出通知。
先选容器,再选进程。 多个容器同时触发时,调度器会合并候选,复查水位并按使用比例排序,每批只尝试最高的一项。采集串行执行,并受节点级冷却限制,因此一次触发并不会扫描节点上的所有容器。
选定容器后,HUATUO 会从该 cgroup 的直接成员中选择一个进程。选择方式参考 memcg OOM 的评分思路,将 RSS、Swap、页表占用相加,再叠加按容器限额缩放的 oom_score_adj,选择分数最高的候选,并排除 oom_score_adj = -1000 的进程。
这个分数用于确定诊断目标,不保证与内核最终终止的进程一致。采集前后还会核对容器实例、cgroup 目录身份,以及 PID 和启动时间;目标退出或身份变化时,取消采集或丢弃结果。
一份快照里留下哪些线索
选定目标进程后,autotracing 将 PID 和启动时间交给 memsnapshot,开始一次进程内存采集。memsnapshot 负责读取进程摘要、识别语言运行时,再调用对应的采集器,返回统一格式的结果。
先看进程占用了哪些内存。 memsnapshot 从 /proc/PID/status 读取 RSS、匿名驻留内存、文件映射驻留内存、共享内存驻留部分、Swap 和页表占用等,形成 process_memory 摘要。数值单位为字节,读取不到的字段会省略,不代表为零。这部分不依赖语言支持。
下面是结果中 process_memory 字段的示例,数值仅作示意,单位均为字节:
|
|
语言层面的线索。 memsnapshot 识别目标语言及运行时布局,通过 process_vm_readv 将所需内存片段读到 HUATUO 进程中,交给对应采集器解析和聚合。业务无需主动生成 heap dump,HUATUO 也不复制整个进程的内存。各语言具体读取什么,下一节展开。
设计上尽量减少业务进程承担的额外工作。 开源工具中,Memray 可以跟踪 Python 与 native 分配,async-profiler 可以采样 Java 堆分配;这些能力可以观察一段时间内的分配行为。memsnapshot 则在内存压力下读取已有运行时信息,提供一次取证结果,两者的统计范围和用途不同。
采集时,HUATUO 不向业务注入采集代码,不主动暂停进程,将解析和聚合所需的 CPU、临时缓冲区主要放在 HUATUO 中;通过复用已有 profile、限制扫描范围、串行采集和冷却间隔,尽量控制额外 CPU 与内存消耗。
进程外读取仍会消耗节点 CPU 和内存带宽,访问非驻留页时还可能触发缺页与 I/O。运行时结构也可能在采集期间变化,因此结果不是一致性堆快照。语言采集默认有 2 秒预算,采用协作式检查,并非全流程的硬性上限。这些设计说明了如何控制开销;是否比其他工具消耗更少、对业务延迟影响多大,仍需要在相同负载下测量。
不同采集器的结果统一放入 snapshot,包含运行时版本、采集状态、耗时和排名靠前的条目;不完整或不可用的原因也会随结果记录。采集受时间预算和输出大小限制,语言不受支持或采集失败时,仍可能保留已经读到的进程摘要。
memsnapshot 本身不负责保存。结果返回自动追踪模块后,还会再次核对进程身份和容器归属,再将容器使用量、限额、目标进程与采集结果关联起来,按存储配置写出。这样,后续查看一条记录时,既能知道当时的内存压力,也能找到对应进程的诊断线索。
这套采集能力也可以复用于其他事件。 memsnapshot 不依赖 Before-OOM 的触发条件,也不负责选择容器、调度任务或写入存储。其他事件选定目标进程后,可以通过 collector.Capture 传入 PID、启动时间、采集预算和结果条目上限,获取同样的进程摘要与语言快照。事件自身负责触发策略、必要的目标归属校验和结果保存,无需重复实现运行时解析。
从分配栈和对象类型追查内存
以下实现与配置说明基于 HUATUO 36175d6e。采集器依赖具体运行时版本、符号和内存布局,支持某种语言并不意味着支持它的所有版本与构建方式;部署时应检查目标运行时的实际采集结果。
Go:沿分配调用栈回到业务代码。 采集器从 runtime.mbuckets、runtime.MemProfileRate 等信息定位 runtime 已有的内存 profile,读取分配、释放记录和调用栈,结合采样率换算为存量内存与对象数估计,再按分配栈聚合、符号化。
调用栈记录的是分配发生时的调用关系。即使栈顶落在 JSON 编码等标准库函数中,沿调用者仍能找到业务入口。结果受 runtime 采样和统计更新时机影响;应用关闭内存采样时,采集器返回不可用,不替应用开启采样。
下面节选 tracer_data 中的语言结果,名称与数值均为示意,省略通用字段;Go 调用栈仅展示部分帧:
|
|
这条记录将约 1.5 MiB 的存量估计归到一条分配栈。stack 从分配位置向调用者展开,可以沿 JSON 编码路径找到业务入口 main.serve.func2,再检查相关对象的使用与释放。
Java:从 G1 堆分区识别对象类型。 当前实现面向 HotSpot G1,暂不支持启用 compact object headers(紧凑对象头)的 JVM。采集器从 libjvm.so 导出的 VM 元数据获得字段偏移和结构布局,定位 G1 region,再通过对象头中的 Klass 信息识别类名和对象大小。
普通 region 使用窗口采样,识别有效对象后估算类型分布;对于跨越多个 region 的 Humongous 大对象,主要读取起始对象头和类元数据,无需复制完整对象内容。结果按类汇总对象数量与浅层大小,不包含对象引用的整棵对象图。
对应的语言结果节选如下,名称与数值均为示意:
|
|
这条记录中,PendingOrder 的浅层大小估计合计为 192 MiB,可以继续检查待处理队列与消费速度。它不包含这些对象引用的其他对象;partial 提醒读者结合采样范围解释结果。
Python:沿 GC 跟踪结构汇总对象。 采集器通过可执行文件或 libpython 的符号定位 _PyRuntime,根据 CPython 版本找到解释器的 GC 结构,再沿链表读取对象,通过 ob_type 识别类型并汇总数量和大小。
大小估算包含对象自身、部分管理开销,以及 list、dict 直接持有的部分缓冲区,不递归统计其引用对象。业务自定义类型也可能出现在结果中,便于排查任务和请求上下文积压。未被 GC 跟踪的对象、扩展模块持有的 native 内存不在完整覆盖范围内,因此不能将它当作 Python 全堆清单。
对应的语言结果节选如下,名称与数值均为示意:
|
|
这条记录表示本次扫描统计到的 dict 大小估计合计为 64 MiB。complete 只表示在该采集器的统计范围内完成,不代表覆盖所有 Python 对象,也不能据此判断这些字典属于哪段业务代码。
容器使用量、单进程 RSS、语言条目字节数采用不同统计口径,不能直接相加减。C/C++ 或不受支持的运行时仍可能获得进程摘要,但不意味着已经支持 native 堆分析。各语言 provider 源码
让内存快照在节点上用起来
memory_threshold_snapshot 默认启用。使用时,先确认 HUATUO 能获取容器元数据、访问宿主机 /proc 和 memory cgroup,并具备加载 BPF、读取目标进程内存的权限。目标容器需要设置有限的内存上限,并使用独立的 memory cgroup。
按需要调整采集参数。 默认读取启动工作目录下的 huatuo-bamai.conf;通过 --config 指定了配置文件时,应修改实际加载的那一份。下面四项均为默认值,已有同名配置节时直接修改,不要重复添加:
|
|
| 参数 | 使用时如何理解 |
|---|---|
ThresholdPercent |
检查时内存使用比例达到 90%,才进入采集候选;降低它不等于一定能更早收到内核通知 |
IntervalTracing |
节点级冷却间隔,单位为秒;缩短间隔允许更频繁地采集;冷却结束不会自动启动采集,仍需新的触发机会 |
RunTracingToolTimeout |
语言采集预算,单位为秒;调大可能获得更多信息,也会增加采集工作量,不是全流程硬性超时 |
MaxMemoryObjectEntries |
每份语言快照保留的条目数,可配置 1–100;调大便于查看更多分配栈或类型,但不代表覆盖完整堆 |
修改配置后重启 HUATUO,并检查全局 BlackList 中没有 memory_threshold_snapshot。升级时使用当前的 AutoTracing.MemoryThresholdSnapshot 配置,不要沿用旧的 EventTracing.BeforeOOMMemsnap 等字段。
先确认能触发,再等待业务现场。 启动后检查日志中的监听初始化和注册情况,用受控的内存增长确认产生采集记录。cgroup v2 下还要核对 memory.high:HUATUO 不会自动设置它,只配置 90% 并不保证在 90% 时收到通知;调整该水位前,应验证它对业务回收、节流和延迟的影响。
到配置的存储后端查看结果。 快照沿用 [Storage] 配置。使用 LocalFile 时,到 [Storage.LocalFile] 的 Path 目录查看 memory_threshold_snapshot 文件。例如,将 Path 配置为 /var/log/huatuo/snapshots 后,可直接查看:
|
|
拿到记录后,先核对目标进程、snapshot.status、reason 和 runtime_version,确认结果是否可用,再查看 entries 中的分配栈或对象类型。output_truncated 表示输出发生裁剪;语言采集失败时,仍可能从 process_memory 获得进程摘要。victim_pid 表示采集目标,不代表它已经被 OOM killer 终止。
需要关闭时,将 memory_threshold_snapshot 加入全局 BlackList,保留其中已有的其他项,然后重启 HUATUO。
OOM 后最难补回的是进程退出前的内存内容。这里留下的分配栈、对象类型和进程摘要,可以帮助确定下一步该检查哪段代码、哪个队列或哪类缓存。它们也许不会直接给出根因,但能为我们提供更多线索。