一次节点级 OOM 同时杀掉了缓存进程、日志代理和监控边车,根因不是单个内存泄漏,而是无上限的应用缓存、缺失的资源限制与过晚的驱逐阈值。
某业务集群的一个计算节点在流量高峰期进入内存压力状态。节点上的缓存服务响应时间从毫秒级抬升到数秒,随后多个 Pod 被驱逐,节点变为 NotReady,部分请求被负载均衡到其他节点。与普通的单 Pod OOM 不同,这次故障中同一节点上的多个无关进程几乎同时被杀,包括没有明显内存增长的日志代理和监控边车,说明问题发生在节点级别,而不是某个容器的 cgroup 限额。
节点上的 free -m 显示空闲内存很低、buff/cache 很高,最初有人据此判断“缓存可以回收,内存其实够用”。但应用进程持有的缓存是堆内对象,属于匿名内存,不是内核页缓存;真正可回收的 buff/cache 并没有想象中多。kubectl describe node 显示 MemoryPressure 为 True,kubelet 已经开始驱逐,但驱逐速度赶不上内存分配速度,内核 OOM killer 先一步介入。
kubectl describe node <node> | grep -A8 -E 'Conditions|Allocated resources'
kubectl get pods --field-selector spec.nodeName=<node> -o wide
kubectl top node <node>
kubectl top pod -n <cache-ns> --sort-by=memory
journalctl -k --since '1 hour ago' | grep -iE 'oom|out of memory|killed process'
dmesg -T | grep -iE 'oom|killed process'
cat /proc/meminfo
内核日志里能看到 OOM killer 的触发记录和受害者列表;如果约束字段显示为节点级约束,受害者又分散在多个 cgroup,就可以与“容器超过自身 limit 被杀”区分开。
memory.stat,确认增长主要来自匿名内存,而不是可回收的文件页缓存。故障持续约 40 分钟,直接影响一个节点上的二十余个 Pod。缓存服务副本减少后,剩余节点承担了更高并发,命中率下降,部分请求回源到数据库,导致数据库连接数和查询延迟上升。用户侧表现为部分接口变慢和少量 5xx,没有观察到数据丢失;缓存本身是可重建数据,重启后的预热成本主要体现在延迟而不是正确性。
排障能力也受到了影响。日志代理和监控边车被 OOM killer 选中后,节点在故障最关键的时间段缺少完整日志和指标,事后只能依靠内核日志和残留的容器日志拼时间线。这种“监控组件与业务一起被杀”的次生影响,比单个服务重启更值得警惕。由于同一部署模板还运行在其它节点上,故障后一度存在整批节点同时进入内存压力的风险,直到限制生效才解除。
第一个误导来自 free 的输出。空闲内存很低、buff/cache 很高,看起来像“内存都被页缓存占着,随时可以回收”,于是有人判断节点其实健康。但缓存服务把查询结果保存在进程堆内,属于匿名内存;free 里的 buff/cache 主要是文件页缓存,二者回收机制完全不同。/proc/meminfo 的 MemAvailable、cgroup 的 memory.stat 以及 kubectl top pod 的 working set 一起看,才能发现匿名内存才是增长主体。
第二个误导是批处理任务。故障时它占用了大量 CPU,时间上与延迟上升重合,很容易被当成“内存泄漏的元凶”。但 OOM 受害者列表里它自己也被杀,RSS 远低于缓存服务;CPU 高只是同一波流量带来的伴生现象。把“资源占用高”直接等同于“造成 OOM”,会漏掉真正持续增长且没有上限的缓存。
第三个误导是“kubelet 会兜底”。集群配置了驱逐阈值,理论上内存不足时 kubelet 会先驱逐 Pod,因此有人认为不需要应用侧限制。实际驱逐是周期性检查,面对快速分配存在滞后;同时节点没有设置系统保留资源,kubelet 把几乎所有内存都算作可分配,驱逐信号触发得太晚。内核 OOM killer 不会等待驱逐完成,也不保证只杀业务容器,这正是日志代理和监控边车被牵连的原因。
直接原因是缓存服务在进程内维护了一个无上限、无 TTL 的查询结果缓存。流量高峰时每个副本的匿名内存持续增长,多个副本叠加后吃掉了节点的大部分内存;这些 Pod 又没有设置 memory request/limit,调度器无法预留资源,kubelet 也无法通过容器限额提前限制增长。
节点层面的配置放大了后果。kubelet 只配置了较低的硬驱逐阈值,没有软驱逐和足够的宽限期;节点没有设置 system-reserved 与 kube-reserved,系统进程和 kubelet 自身没有安全余量;QoS 上大量 Pod 属于 BestEffort 或 Burstable,驱逐排序虽然会优先处理低优先级 Pod,但内核 OOM killer 的选择并不完全遵循 Kubernetes 的 QoS。最终,驱逐还没来得及释放足够内存,内核已经在节点范围触发 OOM,随机选择受害者。
应用缓存本身也不是“泄漏”:进程没有丢失对象引用,缓存在设计上就是只增不减。真正的问题是缺少容量边界和淘汰策略,加上资源配置缺位,把一个可预期的容量问题变成了节点级故障。把它称作“内存泄漏”会误导修复方向,导致只重启 Pod 而不改缓存上限。
先隔离节点,避免新的 Pod 继续调度上来,再分批迁移工作负载。迁移时注意 DaemonSet 和本地存储 Pod,避免把有状态负载直接驱逐造成二次故障;如果节点已经无法稳定运行 kubelet,重启节点是合理的恢复手段,但重启前要尽量保留内核日志和容器日志。
kubectl cordon <node>
kubectl drain <node> --ignore-daemonsets --delete-emptydir-data
对缓存服务,先把缓存容量改为可配置并有上限,设置合理的淘汰策略和 TTL,再给容器设置 request/limit。重启时按批次进行,避免所有副本同时冷启动把数据库打满。对关键服务使用 Guaranteed QoS,让 request 与 limit 相等;对批处理任务设置低于业务 Pod 的优先级,必要时使用优先级抢占,确保高峰期先牺牲可延迟的任务。
节点侧补齐保留资源和驱逐配置:为系统进程、kubelet、容器运行时预留内存,设置硬驱逐与软驱逐阈值,并给软驱逐留出宽限期,让 Pod 有机会优雅退出。完成后再逐步解除隔离,观察 MemAvailable、working set、驱逐次数和 OOM 计数,确认节点在业务高峰仍有余量。
资源限制要作为上线门槛,而不是故障后的补丁。通过 LimitRange 给命名空间设置默认 request/limit,通过 ResourceQuota 限制总量,并在 CI 或准入层拒绝没有内存限制的生产工作负载。缓存、队列、连接池这类“会自己长大”的组件,必须提供容量上限、淘汰策略和可观测的命中率、条目数、内存占用指标。
节点规划要区分“容量”和“余量”。按实际 working set 而不是申请值做容量评估,给系统组件留出固定比例,监控节点可用内存、PSI、驱逐次数和 OOM 计数;当可用内存接近阈值时提前告警,而不是等 MemoryPressure 出现。对关键服务使用 Guaranteed QoS 和拓扑分散,避免多个大内存副本落在同一节点。定期做一次受控的驱逐演练,确认 kubelet 能在内核 OOM 之前释放内存,也确认日志与监控组件不会和业务一起被牺牲。
合上页面,先回答,再展开对照。
free 显示大量 buff/cache,不能证明内存充足?MemAvailable、cgroup memory.stat 中的 anon 和 Pod working set,才能判断真实压力。
小程序版
微信扫码,随时查命令