💀 OOM kill:内存超限直接枪毙

working_set 顶到限额 → 内核 memcg 直接 SIGKILL:不打招呼、不留遗言(日志空白)。现场三联:容器重启 + 一波 5xx + 用户掉线。覆盖 容器盘 k1 / k2 / k5 / k6 · 内存盘 m128(rag 有前科)。

OOM kill:working_set 顶到限额,内核直接枪毙 SIGKILL:无法捕获、无法优雅退出 —— 日志停在半句话,曲线不会说谎 容器 = cgroup 配额管家圈的地盘(rag:限额 2G) 限额 limit 2G(working_set 的枪毙线) working_set 一路爬升(LibreOffice 解析大文件) 💥 顶到限额 → 开枪 被杀瞬间:日志停在半句话——SIGKILL 不给写遗言的机会 ① working_set 顶到限额 用量爬到 cgroup 划下的天花板(判决定理见上一课) ② memcg 当场裁决:OOM cgroup 内存子系统直接判死,没有「警告再警告」的环节 ③ SIGKILL:当场击毙 无法捕获、无法优雅退出——进程来不及任何收尾 ④ 容器重启,一切归零 新容器从冷启动开始,期间该服务不可用 现场表现三联(OOM 的指纹:三条同时出现基本实锤) 🚨 容器重启 k5 重启线跳 1 · k6 存活 2 → 1 发布窗口外的重启才可疑 🚨 一波 5xx c17 错误率尖峰,超过 1 req/s 直奔日志 打到被杀容器上的请求全断 🚨 用户掉线 c1 在线数骤降,WS / SSE 全断 用户体感:突然集体转圈重连 四两限额表(cgroup):backend 10G · biz 3G · rag 2G · review 2G · frontend 1G(每服务两台机器各 1 容器) rag 有前科:曾被 LibreOffice 解析大文件顶爆 2G 限额 → m128 单独体检:冲高不回落 + 逼近 2G = 下一个受害者 观察面板:k1 使用 vs 限制 · k2 使用率 90% 红线 · k5 重启与 OOM 事件 · m128 rag/review 内存 日志查不到不等于没发生:曲线不会说谎

💡 一句话理解

自助餐厅规矩:每人限取一盘。你叠了三盘,服务员不会来劝你少吃点——直接请你出餐厅。容器世界一模一样:每个容器被 cgroup 配额管家划了内存限额(backend 10G、rag 2G……),working_set 顶到限额那一刻,内核的 memcg 子系统触发 OOM kill,进程收到 SIGKILL——无法捕获、无法优雅退出,不打招呼、不留遗言。

「不留遗言」是 OOM 最坑的地方:优雅退出时程序来得及写日志、发告别指标;SIGKILL 是当场击毙,日志常常停在半句话、什么 stack trace 都没有。所以 OOM 的破案现场不在日志里,在曲线里:k5 的重启线跳一下、k1 的使用线此前一直贴着限额爬、m128 的 rag 内存冲高不回落。现场三联——容器重启 + 一波 5xx + 用户掉线——同时出现,基本就是实锤。

🧩 费曼拆解 讲给完全没接触过的小白

先理解「限额」从哪来。容器不是整台机器,是被 Linux 的配额管家 cgroup 圈出来的地盘:内存给你划 2G、CPU 给你划几核。这不是机器没资源了,是你这桌的份就这么多——自助餐「每人一盘」是店规,不是厨房没菜。四两的店规是:backend 10G / biz 3G / rag 2G / review 2G / frontend 1G,每个服务两台机器各一个容器。

然后在你的地盘里,内存用量(working_set,上一课讲过——地板上摊开的东西)会随业务波动:rag 解析一个大文档,内存蹭蹭往上涨。涨了不可怕,可怕的是顶到天花板还不回头。此时内核不会发警告、不会给你时间清理——memcg 直接裁决 OOM,发出 SIGKILL。这个信号的特殊之处在于进程无法捕获:try/except 接不住,优雅关闭钩子不执行,日志线程来不及刷盘。于是现场留下经典一幕:日志的最后一行停在半句话。

枪响之后的三件事几乎同时发生:容器重启(管家把桌子重新摆好,但客人全被请走了)、一波 5xx(打到这个容器上的请求全断)、用户掉线(WS/SSE 连接全部断开,在线数骤降)。三联齐了就是 OOM 实锤。四两的 rag 有前科——曾被 LibreOffice 解析大文件顶爆 2G 限额——所以内存盘给它留了单独的体检单(m128):冲高属预期,关键看之后回不回落;冲高不回落还逼近 2G,它就是下一个受害者。

类比里的东西系统里对应的东西
每人限取一盘(店规)cgroup 内存限额:backend 10G / biz 3G / rag 2G / review 2G / frontend 1G
叠到第三盘working_set 顶到限额(内核 OOM 的判决定理,见上一课)
直接请出餐厅memcg OOM kill → SIGKILL:无法捕获、无法优雅退出
被请出时手里还端着菜日志空白:来不及写遗言——破案靠曲线(k5 重启线 / OOM 事件)
换桌重吃容器重启:内存归零冷启动,期间服务不可用(k6 存活 2 → 1 → 2)
rag 的案底LibreOffice 解析大文件曾顶爆 2G 限额 → m128 单独盯防,冲高必须回落

🛠 动手验证(4 步,在 Grafana 里亲手做一遍)

  1. 看盒子还剩多少空间——打开容器盘 k1:每个容器「working_set 实线 vs 限额虚线」的距离就是余量。(当前 cAdvisor 未部署暂无数据,先看图理解口径;部署后这是日常巡检位。)
  2. 看使用率刻度——切到 k2:working_set ÷ limit 的百分比,80% 黄 / 90% 红;超过 90% 持续 5 分钟 = OOM 前的最后窗口。
  3. 看枪声记录——打开 k5:1h 重启次数 + OOM 事件两条线。记住判读规则:发布窗口内的重启属预期,窗口外的重启才可疑;OOM 事件出现一次就该排查。
  4. 盯有前科的那位——打开内存盘 m128:rag 处理大文件时内存冲高属预期,盯它之后回不回落;冲高不回落 + 逼近 2G 限额 = 提前介入,别等枪响。

🧠 必知必会 看懂本课全部面板的地基

memcg OOM 是什么
容器内存用量的判定口径是 working_set(上一课),它顶到 cgroup 限额时,内核的内存子系统 memcg 直接杀进程——没有软警告阶段。
# 判定链:working_set 逼近 limit → 顶到 → OOM(k1/k2 的观察对象)
sum by (service) (container_memory_working_set_bytes)
SIGKILL 不留遗言
普通退出能写日志、flush、发告别指标;SIGKILL 无法捕获无法处理——所以 OOM 的日志现场常常是「半句话」,破案靠监控曲线。
# 日志空白时的破案三件套:重启线 + OOM 事件 + 使用率回放
# 顺序:k5 定时 → k1/k2 回放 → m128 找冲高源头
限额表要背下来
五盘子各有各的天花板。判断「还有多少空间」永远相对限额算,不比绝对值。
# 四两限额(compose cgroup,每服务两台机器各 1 容器)
# backend 10G · biz 3G · rag 2G · review 2G · frontend 1G
重启次数 = 时间戳变了几次
容器重启在指标里的痕迹是「启动时间戳变了」:1h 窗口内变几次就是重启几次。发布窗口内的重启属预期。
# k5 第一条线:1h 内重启次数
changes(container_start_time_seconds[1h])
OOM 事件是直接证据
重启可能是发布、可能是崩溃,而 OOM 事件计数器就是枪声记录:出现即排查。注意部分 cAdvisor 版本不支持该指标,此时此线无数据。
# k5 第二条线:枪声记录(版本不支持时无数据,用 k1/k2 互证)
increase(container_oom_events_total[1h])
90% 是最后窗口
使用率超过 90% 且持续 5 分钟,就该当「OOM 倒计时」处理——这是动手止损的最后机会,别等枪响。
rag 有前科要单独盯
rag 用 LibreOffice 解析大文件,内存冲高属预期;但冲高后必须回落。不回落 + 逼近 2G = 下一个 OOM 受害者。
# m128 口径:rag / review 每实例 RSS(1m 窗口最大值)
max_over_time(siliang_worker_process_resident_memory_bytes{job=~"siliang-rag|siliang-review"}[1m])
存活点名器
两台机器各跑一个容器,每个 service 应数出 2;1 = 单机在撑(冗余没了),0 = 服务整体不可用。
# k6 口径:按 service 点名,预期 2
count by (service) (container_start_time_seconds{service=~"siliang-.*"})
防患于未然:deriv 抓爬升
OOM 最好的处理时机是枪响之前:deriv 持续为正说明在真累积,配合限额距离算「还能撑几小时」。
# 内存盘核心告警口径:持续爬升先报警,别等顶到限额
deriv(siliang_process_resident_memory_bytes[1h])  # 持续 > 5MB/h = 真累积

🔗 在四两监控里哪里用到 理论落回你的 89 张图

① 容器内存使用 vs 限制(k1)——working_set 实线 vs 限额虚线一实一虚,使用线贴上限制线 = memcg OOM 倒计时:🟡 容器内存使用 vs 限制 ↗

② 内存使用率(k2)——working_set ÷ limit 百分比版,80% 黄 / 90% 红,>90% 持续 5m 应告警——OOM 前的最后窗口:🟡 内存使用率(working_set / limit)↗

③ 容器重启与 OOM 事件(k5)——枪声记录仪:changes 重启次数 + increase OOM 事件;发布窗口外的重启 + OOM 事件出现 = 实锤:🟡 容器重启与 OOM 事件 ↗

④ RAG / Review Worker 内存(m128)——有前科者的单独体检单:rag 冲高回落属预期,不回落 + 逼近 2G = 下一个受害者:👷 RAG / Review Worker 内存 ↗

⑤ 各服务存活实例数(k6)——点名器:预期 2;1 黄 = 单机在撑(另一台被杀还没起来),0 红 = 服务整体不可用:🟡 各服务存活实例数 ↗

🏭 生产实战 real world

场景 1 · 疑似 OOM 事故:10 分钟定位 SOP

半夜被告警吵醒,症状是服务闪断 + 一波 5xx。按顺序走四步:定时(k5)→ 回放(k1/k2)→ 点名(k6)→ 找源头(m128),十分钟内把「是不是 OOM、哪台、为什么」说清楚。

# 第一步:定时——哪个 service 重启了?有没有 OOM 事件(k5)
changes(container_start_time_seconds[1h]) by (service)
increase(container_oom_events_total[1h]) by (service)

# 第二步:回放——被杀前使用率是不是贴着 90% 爬(k2 口径)
sum by (service) (container_memory_working_set_bytes)
  / clamp_min(sum by (service) (container_spec_memory_limit_bytes), 1)

# 第三步:点名——现在还剩几个容器在撑(k6,预期 2)
count by (service) (container_start_time_seconds{service=~"siliang-.*"})

# 第四步:找源头——rag 是不是大文件冲高不回落(m128 口径)
max_over_time(siliang_worker_process_resident_memory_bytes{job=~"siliang-rag|siliang-review"}[1m])

判读收尾:重启 + OOM 事件 + 被杀前贴 90% = OOM 实锤;接下来按场景 4 防复发。

场景 2 · 两级告警:90% 预警 + 枪响实锤

一条告警抓「快被杀」,一条抓「已经被杀」。前者让你有时间止损,后者确保枪响必被知道(哪怕日志一片空白)。

groups:
- name: siliang-oom
  rules:
  - alert: ContainerMemNearLimit
    expr: |
      sum by (service) (container_memory_working_set_bytes)
        / clamp_min(sum by (service) (container_spec_memory_limit_bytes), 1) > 0.9
    for: 5m              # 持续 5 分钟:OOM 倒计时(口径以 k0 建议为准)
    labels:
      severity: critical
    annotations:
      summary: "{{ $labels.service }} working_set 超 90% 持续 5m:OOM 倒计时"

  - alert: ContainerOomFired
    expr: increase(container_oom_events_total[5m]) > 0
    labels:
      severity: critical
    annotations:
      summary: "{{ $labels.service }} 发生 OOM kill,立即走定位 SOP"

场景 3 · 区分发布重启与 OOM 重启

k5 的重启线跳了不一定是事故:滚动发布本来就会重启。判断方法是拿重启时刻对照发布窗口,窗口外 + 无 OOM 事件记录的奇怪重启才另立案子。

# 重启发生的准确时刻(把时间戳转成可读时间)
changes(container_start_time_seconds[1h]) by (service)

# 互证一:发布窗口内每个旧 worker 的 ops 排空线应逐个归零(c18 口径)
sum(siliang_ops_http_in_flight) by (instance)

# 互证二:OOM 事件为 0 + 重启在发布窗口内 = 计划内,别误报
increase(container_oom_events_total[1h])
# 判读:窗口外重启且 OOM=0 → 查崩溃日志;OOM>0 → 走 OOM SOP

场景 4 · rag 的防复发三板斧

有前科就要重点帮教:一看冲高回不回落(m128)、二看护栏拦了多少超大文件(m114 上限拦截)、三看爬升斜率(deriv 提前报警)。

# 一看:冲高后回不回落(m128 口径,关键形态)
max_over_time(siliang_worker_process_resident_memory_bytes{job="siliang-rag"}[1m])

# 二看:斜率——持续爬升提前报警(内存盘核心告警口径)
deriv(siliang_worker_process_resident_memory_bytes{job="siliang-rag"}[1h])

# 三看:护栏工作频率——上传/下载预检拦截了多少超规行李(m114 口径)
rate(siliang_upload_precheck_rejected_total[5m])
  + rate(siliang_media_download_rejected_total[5m])
# 判读:deriv 持续为正 + 逼近 2G = 提前介入(限流大文件/扩限额/重启预案)

场景 5 · 容量巡检:五个盒子的余量排行

每周例行看一眼「谁的盒子最满」。余量 = 限额 − working_set,排行最小的两个优先复查它们的 deriv。

# 五个容器的剩余空间(字节),按紧张程度排序
topk(5,
  sum by (service) (container_spec_memory_limit_bytes)
    - sum by (service) (container_memory_working_set_bytes)
)

# 读法:backend 10G 余量最多属正常;rag/review 2G 盒子最小最紧;
# frontend 1G 只跑静态页面,余量骤降反而最可疑(查有没有人塞了大东西)

⚠️ 常见坑 pitfalls

坑 1 · 日志空白就断定「什么都没发生」 — 症状:事后翻日志找不到报错,宣布「无异常」,几天后同款事故再来。原因:SIGKILL 不留遗言,OOM 的证据根本不在日志里。正解:破案靠曲线:k5 重启线 + OOM 事件 + k1 使用率回放。
# 错: 日志无 stack trace → 结案「未知原因闪断」
# 对: changes(container_start_time_seconds[1h]) + increase(container_oom_events_total[1h])
坑 2 · 把发布重启当 OOM、把 OOM 重启当发布 — 症状:重启线一跳就喊事故,或真事故被当「正常发布」放过。原因:不对照发布窗口和 OOM 事件。正解:发布窗口内 + 排空归零 = 计划内;窗口外 + OOM 事件大于 0 = 枪响。
# 错: 重启线跳 1 → 直接 @所有人            # → 可能只是滚动发布
# 对: 先看 increase(container_oom_events_total[1h]) 再定性质
坑 3 · 拿 usage_bytes 判快 OOM 了 — 症状:usage 逼近限额全员戒备,结果永远不会被杀;或者反过来以为安全却被杀了。原因:usage 含可回收 page cache 虚高,和内核判定口径不同。正解:只认 working_set(k1/k2 口径)。
# 错: container_memory_usage_bytes / limit > 0.9 当告警依据
# 对: container_memory_working_set_bytes / limit > 0.9(k2 口径)
坑 4 · 重启完就结案 — 症状:容器起来了、服务恢复了,工单关了;一周后同样被杀。原因:没找冲高源头——是泄漏在爬,还是大文件搬运,还是限额给小了。正解:每次 OOM 都要回答「为什么涨到限额」:查 m128/m101 形态 + deriv + 在途面板。
# 错: 容器重启成功 → 关单                    # → 必复发
# 对: 关单前回答:deriv 回零了吗?冲高源头找到了吗?
坑 5 · 把 cAdvisor 无数据当「一切健康」 — 症状:容器盘整盘空白,被解读为「没有任何内存问题」。原因:cAdvisor 未部署,整盘没数据——不是健康也不是故障,是没装电表。正解:过渡期用进程面板(m101/m128)+ deriv 间接盯内存,部署后容器盘自动生效。
# 错: k1 无数据 = 不会 OOM                # → 没电表不等于没用电
# 对: 过渡期盯 deriv(siliang_worker_process_resident_memory_bytes[1h])
坑 6 · OOM 事件线没数据就以为从没 OOM 过 — 症状:「从没见过 OOM 事件」当安全证据。原因:部分 cAdvisor 版本不支持该指标,此线本来就无数据。正解:用 k1/k2 使用率回放 + 重启线互证,别把「没数据」当「没发生」。
# 错: OOM 事件线空白 → 「我们从未被 OOM 杀过」
# 对: 三件套互证:重启线 + 使用率回放 + m128 冲高史
坑 7 · 90% 告警不设 for,毛刺满天飞 — 症状:大文件过手时使用率瞬间冲过 90% 又回落,告警响个不停。原因:单点越过阈值就触发。正解:加 for: 5m 要求持续,冲高会自己回落,真倒计时不会。
# 错: expr: working_set/limit > 0.9            # → 毛刺也响
# 对: 同款 expr + for: 5m                     # → 只抓持续贴线

🎓 费曼自测 合上书,能讲出来才算懂

Q1 · 向新人解释:为什么 OOM 事故的日志经常是空白的?

参考答案:因为内核杀进程用的是 SIGKILL——这个信号进程无法捕获、无法处理,写日志、flush 缓冲、执行优雅关闭钩子的机会全都没有,日志常常停在半句话。所以 OOM 的破案现场不在日志里,在监控曲线里:k5 的重启线、OOM 事件计数、k1/k2 的使用率回放,这三样不会说谎。

Q2 · 「现场三联」是什么?为什么三条同时出现基本就是 OOM 实锤?

参考答案:容器重启 + 一波 5xx + 用户掉线。内存被顶爆的瞬间进程被当场击毙:容器必须重启(k5/k6 可见)、打到这个容器上的请求全部失败(c17 出现 5xx 尖峰)、挂在上面的 WS/SSE 连接全断(c1 在线数骤降)。三条由同一次击毙同时引发、时间戳完全重合——这种「一因三果」的组合别的故障很难伪造。

Q3 · 四两哪家服务最有 OOM 前科?为什么?平时盯哪块面板?

参考答案:rag。它用 LibreOffice 解析大文件,处理时内存冲高是业务天性,历史上曾把 2G 限额顶爆被内核杀过。平时盯内存盘 m128:冲高本身属预期,关键是之后回不回落——冲高不回落且逼近 2G 限额,就是下一个受害者的预告;配合 deriv 斜率和 m114 上限拦截面板一起看。

Q4 · working_set 使用率超过 90% 的告警为什么一定要加 for: 5m?

参考答案:因为业务高峰或大文件过手时,working_set 会在几秒内冲过 90% 然后自己回落——这是正常的呼吸,不是 OOM 倒计时。不加 for 的告警会让毛刺天天狼来了,最后没人再理会告警。持续 5 分钟贴着 90% 不下来,才说明内存真的下不去——那才是动手止损的最后窗口。

← 上一课:RSS / VMS / working_set:三种「内存」 📚 课程目录 下一课:Python GC 分代回收:三区垃圾站 →