2026-08-29 15:49,监控发来一条 ContainerMemoryNearLimit(全文时刻都是 UTC):homelab 的 Prometheus 容器 7 天内存峰值 3055Mi,limit 是 3Gi,占 99.45%。告警正文里那句「一次尖峰就会 OOMKill,而 OOM 后干净重启、无人察觉」是我自己写的。

但经历那个峰值的容器,自己的终止状态是这样:

{ "startedAt": "2026-08-29T15:09:21Z", "finishedAt": "2026-08-29T15:33:22Z",
  "exitCode": 0, "reason": "Completed" }

它活过了峰值,24 分钟后正常退出——紧接着就是 15:35 的第二次节点重启。拆开看,3055Mi 里约 87% 是页缓存(page cache),同一时刻 RSS 只有 408Mi。后来的对照实验里,什么都不改,只挑页缓存热的时候把它再重启一次,峰值 728Mi(23.70%),差了 4 倍多。

下面按排查顺序写,数字都是当时取的。

背景#

这套 homelab 监控用的是 kube-prometheus-stack。Prometheus 跑在一台由笔记本改成的 Proxmox 宿主上的 K3s VM 里,memory limit 3Gi、requests 512Mi。

这条告警上一次响在 08-16,原因是 k3s 单进程把同一批 apiserver/etcd 指标重复暴露,active series 太多(那次的处理记在把重复 series 拦在入库前:我的 Prometheus 内存瘦身)。砍完 series 之后,max_over_time[7d] 还记着旧峰值。我挂了一条 7 天的 Alertmanager 静默(silence)等窗口滚过去,也在 values 文件里给自己留了一句注释:到期后仍然报,就别再续静默,去查原因。

这次告警确实又报了,但原因和上次无关。触发点是宿主机内核升级(7.0.0-287.0.0-30),k8s-node 在 15:08 和 15:35 各重启了一次。

现象:涨的不是堆#

15:09:21 容器拉起,按 30s 分辨率取样(单位 Mi):

时刻 working_set rss cache usage
15:08:00(重启前) 990 631 1160 1829
15:10:30(峰值) 3055(99.45%) 408(13.3%) 2652 3072
15:11:30 791 686 1800 2517
15:35:00(稳定后) 1147 615 2103 2767

峰值时刻的 RSS 反而是整个窗口里最低的 408Mi——新进程刚起来,堆还没长起来。涨的是 cache,3055 里约 87% 是它。

排查#

容器顶到了 limit,但没有 OOM#

container_memory_usage_bytes      15:10:30 → 3072 Mi(15:12:30 又顶了一次)
container_memory_max_usage_bytes  15:10:30 → 3072 Mi,此后一直钉在这个值
resources.limits.memory                     3 Gi = 3072 Mi

usage 精确等于 memory.max。cgroup 触到上限后,内核会先尝试回收:干净的 file page 随时可以丢,需要时再从磁盘读回来。cgroup v2 文档memory.max 的描述是「If a cgroup’s memory usage reaches this limit and can’t be reduced, the OOM killer is invoked in the cgroup」。这次的 file page 可以回收,所以没有 OOM kill,容器最后是 exitCode 0

working_set 掉下去不等于内存被回收#

第二次重启(15:38:30 拉起)可以用来对照。它没有顶到 limit,却同样出现了 working_set 断崖:

时刻 working_set rss cache usage
15:41:30 2054 651 1425 2084
15:42:00 660 652 1396 2055

working_set 掉了 1394Mi,而 usage 只掉 29Mi、cache 只掉 29Mi、rss 纹丝不动。这里没有发生实际回收,变化的是 active_file。这批刚读进来的文件页起初几乎都挂在 active LRU(working_set ≈ usage),过一两分钟没再被引用,就被降到 inactive_file;而 cAdvisor 算 working_set 的公式正是 usage 减去 inactive_file(cadvisor v0.53.0 container/libcontainer/handler.go):

workingSet := ret.Memory.Usage
if v, ok := s.MemoryStats.Stats[inactiveFileKeyName]; ok {
	ret.Memory.TotalInactiveFile = v
	if workingSet < v {
		workingSet = 0
	} else {
		workingSet -= v
	}
}
ret.Memory.WorkingSet = workingSet

同一个文件里还能看到,cgroup v2 下 container_memory_rss 取的是 memory.statanoncontainer_memory_cachefilecontainer_memory_usage_bytesmemory.current(这几个指标的完整定义见 cAdvisor Prometheus metrics 文档)。

所以这两次重启里,working_set 变高的原因并不一样:

第一次(15:09) 第二次(15:38)
峰值 working_set 3055 Mi 2054 Mi
usage 是否触顶 是,精确到 3072 = limit 否(上表窗口内最高 2084,生涯峰值 2153)
回落原因 真回收 + 叠加 LRU 降级(usage 掉约 555Mi,inactive_file 17→1726) 只有 LRU 降级(usage 几乎不变)

第二种情况下内存一点没还,只是换了个 LRU 链表。第一次其实两种机制同时发生,但 working_set 的断崖同样主要来自 LRU 降级:真回收那部分在 usage 上能看出来(约 555Mi),其余一千多 Mi 只是从 active 挪到 inactive。如果只看 working_set 回落,很容易误以为内存压力已经解除。

对照实验:热缓存下重启,峰值只剩 23.7%#

前面的读数让我怀疑,峰值高低和容器重启时节点页缓存的冷热有关。冷启动后,第一个容器要自己把 TSDB 文件从磁盘 fault 进来,这些页就记在它自己的 cgroup 账上;晚一点重启时,页还在,新容器读到的是已经常驻的页,不重复计费。cgroup v2 文档对此的描述是:「A memory area is charged to the cgroup which instantiated it and stays charged to the cgroup until the area is released.」

我没有改配置,只在同一天 22:31 手工 kubectl delete pod 重启一次,然后在节点上直接读 cgroup 采样。这样可以避开 Prometheus 重启期间的采集空档:

容器启动 22:31:20 · WAL replay 11.1s · 22:31:32 ready · 采样覆盖启动后 255s
  22:31:21  cur=334  anon=119  file=216
  22:32:54  cur=724  anon=503  file=218
  22:33:10  peak=728 Mi = 23.70%        ← 全程最高
  22:35:36  cur=723  anon=502  file=218  ← 没有迟到的尖峰

把三次重启放在一起看:

重启 节点页缓存 峰值 usage 占 limit 该容器 file
15:09 刚开机,全冷 3072 Mi 100.0% 2652 Mi
15:38 29 分钟后,半热 2153 Mi 70.1% ~1425 Mi
22:31 7 小时后,全热 728 Mi 23.70% 218 Mi

anon(Go 堆那部分)三次都在 400–550Mi 量级,唯一在变的是 file:2652 → 1425 → 218Mi。同一个容器、同样的数据、同样的 limit,读数在 85% 这条线的两边都待过。表里第一次按 cgroup 直读的 usage 口径是 100.0%(usage 精确顶到 limit);告警用的 working_set 同刻是 3055Mi = 99.45%,因为那会儿 inactive_file 只有 17Mi,几乎全部计入。

flowchart TD A[节点重启,页缓存全冷] --> B[新容器自己从磁盘 fault 进 TSDB 文件] B --> C["页记在它自己的 cgroup 账上
file 2652Mi"] C --> D["working_set = usage − inactive_file
刚读进来的页还在 active LRU,全部计入"] D --> E[峰值 3055Mi / 3Gi = 99.45%] F[7 小时后重启,页缓存全热] --> G["页已常驻,读到不重复计费
file 218Mi"] G --> H[峰值 728Mi = 23.70%]

实验前我预测大概 70%,实测是 23.7%,差得不少。问题在于我拿第二次重启当了参照,而它只是半热,距离冷启动才 29 分钟。方向猜对了,量级没猜对。

这条规则的四个问题#

线上规则长这样(limit 一侧的 kube_pod_container_resource_limits 来自 kube-state-metrics 的 Pod metrics 文档):

- alert: ContainerMemoryNearLimit
  expr: |
    max by (cluster, namespace, pod, container) (
      max_over_time(container_memory_working_set_bytes{container!=""}[7d])
    )
    / on (cluster, namespace, container) group_left
    max by (cluster, namespace, container) (
      kube_pod_container_resource_limits{resource="memory"}
    ) > 0.85    
  for: 30m
# 问题 说明
指标含页缓存 对 mmap、文件读得多的负载,它不是 OOM 风险的代理量。把分子换成 container_memory_rss、其余不动(窗口仍是 [7d]),同一个容器算出来是 810Mi / 3Gi = 26.4%
读数由页缓存冷热决定 三次重启 99.45% / 70.1% / 23.70%,和容器实际需要多少内存无关。拿它做 85% 判断,测的是节点缓存状态
for: 30m 基本不起作用 分子是 7 天的 max,顶上去就是平台期,for 去不了抖,只把首次投递推迟 30 分钟。实际持续时间由窗口长度决定,也就是 7 天
窗口和文档对不上 我自己排查文档里给的查询用 [2d],线上规则是 [7d]

「让它把内存还给系统」在这次不成立#

排查时我也想到过这个方向。但 limits.memory 不会预留内存:只有 requests 参与调度计算,limit 只是「最多能用到」,不是「已经占住」。节点侧的读数也对得上——尖峰那一分钟(15:10)k8s-node 的 MemAvailable 是 9299Mi,是整个窗口的最高值(刚重启、缓存冷)。节点全程没有压力。

想压的部分 旋钮 这次适用吗
Go 堆峰值 调低 GOGC 不适用。go_memstats_heap_inuse 只有 414Mi / 3Gi,GC 没有压力
Go 堆峰值 GOMEMLIMIT 同上
页缓存峰值 limits.memory 效果未知。第二个容器跑满 6.5 小时,cache 稳态平在 1206–1452Mi,从没往 3072 爬,页缓存在这里不像是被 limit 约束的。而且收益本身是假的:页缓存本来就计入节点 MemAvailable
调度层份额 requests 已经对了:稳态 RSS 504Mi vs requests 512Mi

Go 1.16 起,Linux 上的 runtime 默认用 MADV_DONTNEED 及时把内存交还给内核(Go 1.16 release notes)。匿名内存这边没有「占住不放」的问题;实测 RSS 全程在 330–690Mi 区间,没有爬升。

影响面:机制普遍,但目前只有这一个容器踩线#

页缓存主导 working_set 在这套集群里并不少见。08-30 现取的 cache / working_set 排名前几名是这样(超过 100% 是因为分母扣掉了 inactive_file):

vault/vault 258%   kube-system/coredns 254%   personal-services/nakama 210%
jobs-sg/web 209%   trivy-system/trivy-server 162%   monitoring/alertmanager 157%
monitoring/prometheus 149%   kube-system/apiserver 135%

但同一天没有第二个容器逼近阈值:按 7 天 working_set 峰值占 limit 排,双集群前六名里只有 Prometheus 这一个超过 85%,第二名是 oracle 的 kube-state-metrics(83.7%)。这类假阳性以后可能在别的容器上出现,但目前不能据此说已经有一堆误报。

想改规则,先看另一个集群采集了什么#

一个直接的改法,是 and 上一条 RSS 条件,把页缓存造成的假阳性滤掉。但两个集群采到的指标不一样(08-30 现取的 series 条数):

container_memory_working_set_bytes  homelab 162 条 · oracle-k3s  58 条   ← 唯一入库的
container_memory_rss                homelab 162 条 · oracle-k3s   0 条
container_memory_cache              homelab 162 条 · oracle-k3s   0 条
container_memory_usage_bytes        homelab 162 条 · oracle-k3s   0 条
container_memory_max_usage_bytes    homelab 162 条 · oracle-k3s   0 条

oracle 那边的 otel-collector 只 keep 了两个容器指标:

container_(cpu_usage_seconds_total|memory_working_set_bytes)

因此,oracle 唯一能送到中枢 Prometheus 的容器内存指标,正好是容易误导的那个。本文这套拆解在那边做不了,只能 SSH 上节点读 /sys/fs/cgroup/.../memory.stat。如果直接 and 一条 RSS,上游没有 RSS 数据的 oracle 侧告警就会一直不触发,而这种结果看起来和「没超阈值」一样。

所以得先补采集,再改规则:我把 container_memory_rss 加进了 oracle 的 keep 正则(多 58 条 series,成本可以忽略),等 series 到货再动规则。另一个选项是在规则里用 cluster="homelab" 限定 RSS 那半边、oracle 维持现状,代价是那边继续用有误导性的指标。

改了什么#

规则最后改成这样(已上线):分子从 container_memory_working_set_bytes 换成 container_memory_rss,窗口 [7d] 改到 [2d],阈值 85% 降到 80%。另外加了一条 ContainerMemoryRssAbsent 守着分子的来源。

- alert: ContainerMemoryNearLimit
  expr: |
    max by (cluster, namespace, pod, container) (
      max_over_time(container_memory_rss{container!=""}[2d])
    )
    / on (cluster, namespace, container) group_left
    max by (cluster, namespace, container) (
      kube_pod_container_resource_limits{resource="memory"}
    ) > 0.80    
  for: 30m

阈值这一档不是拍的。RSS 比 working_set 系统性地低,照搬 85% 会把判据悄悄抬高,所以我把手头能找到的真阳性拿出来回放:目前唯一有硬证据的是 08-25 的 multica 前端(cgroup v2 的 memory.eventsmax 计数 69——它数的是用量顶到 memory.max 的次数——且 memory.peak 等于 memory.max),它当时 RSS 峰值 870Mi、limit 1024Mi,按新口径是 85.0%。而现在能采到 RSS 的集群里,最高的是 litellm 71.1%,第二名 trivy-operator 62.3%,其余都在 40% 以下。

真阳性锚点   multica frontend   870Mi / 1024Mi = 85.0%
当前最高     litellm/litellm            71.1%
次高         trivy-operator             62.3%
本次的假阳性 monitoring/prometheus      25.7%([2d])   ← working_set 口径是 99.4%

80% 落在 71.1 和 85.0 中间,两边各留 9 个点和 5 个点。升回 85% 就正好压在 multica 那次的读数上,真阳性会变成掷硬币;降到 75% 则 litellm 常态就在附近,会变成常亮的黄灯。顺带这也把阈值拉回了我自己文档里写的「超过 80% 就该抬 limit 或查泄漏」——原先那 5 个点的余量是给 working_set 的噪声留的,噪声源没了,余量也就不用留。

换 RSS 有代价,得认下来。container_memory_rss 只是 cgroup v2 的 memory.stat.anon,不含 kernel、tmpfs、mlocked 那几块。这套集群里没有 medium: Memory 的 emptyDir,anon 是唯一有分量的不可回收项,真被杀了还有 ContainerOOMKilled 兜底,所以这个取舍能接受。但它会漏掉 08-24 Grafana 那种形状:GOMEMLIMIT 硬接 limit 的 Go 应用是 GC 加班而不是 OOM,RSS 未必到 80%。那一类得看 next_gc 和 GC 频率,本来就不归这条告警管。

上线前把两条新表达式直接丢给线上 Prometheus 跑了一遍:ContainerMemoryNearLimit 零命中,ContainerMemoryRssAbsentcluster="oracle-k3s" 命中一条。后面这条正是我想要的——那会儿 keep 正则还没上,守卫如实报告了 oracle 侧的分子是空的。它同时说明改动顺序不能反:先上 oracle 的采集,再改规则。

还没做的#

  • 没做「改 limit 复测峰值」的对照实验,所以抬或降 limit 有没有用,仍然未知。
  • 反方向的实验(drop_caches 之后重启,预期 working_set 口径能复现 95% 以上)也没做。
  • cache / usage / max_usage 在 oracle 仍然没采集,那边做不了本文这套 rss/cache 拆解。
  • oracle 的 RSS 历史从 keep 正则上线当天起算,max_over_time[2d] 头两天只会少报。
  • Prometheus 的 resources 没动。稳态 RSS 504Mi 对 requests 512Mi 是吻合的,抬或降都没有依据。
  • 静默没建,也不需要了:换口径之后这条告警自己就不再命中。

教训#

先确认告警测的量是否真的对应它声称的风险。

这次的告警测的是 working_set,含页缓存:同一条规则只把分子换成 RSS,读数就从 99.45% 变成 26.4%。而且这个读数主要由重启时节点页缓存的冷热决定——同一个容器,冷缓存 99.45%,热缓存 23.70%——它测的不是容器需要多少内存。

总结#

  • container_memory_working_set_bytes = memory.currentinactive_file,含页缓存。文件读得多的容器,这个值里可能大部分不是它的堆;
  • 容器 usage 顶到 memory.max 不等于要 OOM。干净的 file page 随时可回收,内核在 OOM 之前会先回收它们,这次的证据是容器 exitCode 0
  • working_set 断崖也不等于内存被回收。active_file 降级到 inactive_file 就会造出同样的曲线,判据是同时看 usagerss 有没有跟着掉;
  • 改这类跨集群的告警规则之前,先确认两边采集的指标集是不是一样。少一个指标,规则可能在那一侧恒不触发,外观和「一切正常」没有区别;这种依赖值得单独加一条 absent() 告警守着;
  • 换分子口径时阈值要跟着重标。RSS 系统性地低于 working_set,照搬旧阈值等于悄悄抬高判据——拿手头有硬证据的真阳性回放一遍,再挑一个两边都留余量的数。

参考资料#

相关文章#