记一次 K8s Pod OOMKilled 触发的服务雪崩

某服务 Pod 频繁 OOMKilled 重启, readiness 探针失败引发滚动更新卡死,上游调用全部超时雪崩。

一、问题背景

我们的订单中心 order-service 跑在 Kubernetes 集群(v1.27,containerd 运行时)上,Deployment 配置 6 个副本,平时负载稳定,内存常驻约 600MB。该服务是交易链路的核心,下游由支付、库存等服务调用,上游又被 API 网关和多个内部业务系统依赖。

某天上午,运营临时做了一个营销活动,订单量较平日上涨约 3 倍。活动开始约 20 分钟后,订单成功率告警触发。此时我们并未发版,纯属流量驱动的突发故障。集群节点规格为 8C16G,节点本身剩余内存充足,看起来不像是节点级资源问题。

二、故障现象

监控面板在 09:05 左右集中飘红:

  • order-serviceCrashLoopBackOff 副本数从 0 涨到 4,6 个副本里一度只剩 2 个在 Running
  • 上游 API 网关大量返回 504,order-service 调用超时率从 0.2% 升到 35%;
  • 订单创建成功率从 99.6% 跌到 58%,部分用户出现重复下单;
  • 滚动更新状态卡在 Progressing:新副本 ready 数量始终达不到 minAvailable,Deployment 既不成功也不回滚。

最迷惑的是:节点 kubectl top node 显示内存还有 6G+ 空闲,但 Pod 就是不断重启。这说明问题出在 Pod 级别内存限制(limit),而不是节点容量。

三、排查过程

第一步,看 Pod 事件,直接命中关键字:

1
2
3
4
5
6
7
8
9
$ kubectl describe pod order-service-7d9c8b6f4-xyz12
...
Last State:     Terminated
  Reason:       OOMKilled
  Exit Code:    137
Events:
  Type     Reason     Age   From               Message
  Warning  Unhealthy  2m    kubelet            Readiness probe failed: HTTP probe failed with statuscode: 503
  Warning  BackOff    1m    kubelet            Back-off restarting failed container

Reason: OOMKilledExit Code: 137 明确说明容器是被 cgroup 杀了——进程实际用量超过了 memory limit

第二步,看内存配置和真实用量:

1
2
3
4
$ kubectl get deploy order-service -o jsonpath='{.spec.template.spec.containers[0].resources}'
{"limits":{"memory":"1Gi"},"requests":{"cpu":"500m","memory":"512Mi"}}
$ kubectl top pod | grep order-service
order-service-7d9c8b6f4-abc 980Mi

limit 设了 1Gi,但流量上涨后 RSS 已逼近 980Mi,剩余空间几乎为 0。一旦有请求峰值或 GC 不及时,立刻越线被杀。

第三步,确认是不是内存泄漏。拉取一个还没被杀的 Pod 的堆信息:

1
2
3
4
5
$ kubectl exec order-service-xxx -- jmap -histo:live 1 | head -15
 num     #instances         #bytes  class name
   1:        420183       71234560  java.util.concurrent.ConcurrentHashMap$Node
   2:        318902       25512160  com.xxx.OrderCacheEntry
   3:        120033       14403960  java.lang.String

OrderCacheEntry 实例高达 31 万个且持续增长——这是一个本地订单缓存,使用 ConcurrentHashMap没有设置容量上限和过期策略,活动期间订单不断写入,缓存无限膨胀,导致内存只涨不落。

第四步,解释"雪崩"链条。Pod 被 OOMKilled 后重启,重启期间 readinessProbe 在应用冷启动 + 缓存重建完成前一直返回 503,于是 Endpoint 不把流量切给它;而剩余存活副本要承接全部流量,内存进一步上涨,再次 OOMKilled——形成重启→探活失败→流量集中→再 OOM的死亡螺旋。滚动更新因为新副本始终 ready 不过半,卡死在 Progressing,无法自愈。

四、解决方案

应急(先止血):

  1. 立即调大 memory limit 到 2Gi、request 提到 1Gi,并临时把副本数扩到 12,分散单 Pod 压力,打破越线被杀的循环:
1
$ kubectl scale deploy order-service --replicas=12
  1. readinessProbe 增加 initialDelaySeconds: 30 与更宽容的 failureThreshold,避免冷启动期被误判摘流(临时缓解,非根治)。

  2. 紧急重启有泄漏风险的缓存:对 OrderCacheEntry 增加容量上限(LRU,最大 5 万条)+ TTL(10 分钟),重新发版。

  3. 给 Deployment 配置 minReadySecondsprogressDeadlineSeconds: 300,并在探针上加 startupProbe,让冷启动阶段不被 readiness 误杀:

1
2
3
4
5
6
startupProbe:
  httpGet:
    path: /health
    port: 8080
  failureThreshold: 30
  periodSeconds: 5

发版后副本稳定 Running,上游超时率 5 分钟内回落到 0.3%,订单成功率恢复到 99.4%。

五、根因分析

根因是无界本地缓存导致的内存泄漏,在流量上涨时触顶 memory limit 被 cgroup OOMKilled。雪崩的放大器是两点:

  • readiness 探针在冷启动期误摘流:Pod 重启后缓存未预热完即被判定不健康,Endpoint 不接流量,存活副本承压更重。
  • 滚动更新卡死:新副本始终达不到 ready 阈值,Deployment 既不能完成也不能自动回滚,故障窗口被人为拉长。

节点有空余内存是假象——Kubernetes 的 OOM 是按 Pod cgroup 边界触发的,和节点总内存无关。

六、预防措施

  1. 所有本地缓存必须有界:用 Caffeine/Guava Cache 显式设置 maximumSizeexpireAfterWrite,禁止裸用 ConcurrentHashMap 当缓存。

  2. 内存 limit 留足 headroom:limit 不应紧贴常驻用量,建议 limit ≥ 常驻峰值 × 1.5,并配置 requestslimits 一致以避免被调度到紧张节点。

  3. 探针三件套合理配置startupProbe 保护冷启动、readinessProbe 控制接流、livenessProbe 控制重启,三者职责分离,避免互相误伤。

  4. 设置 PodDisruptionBudget + progressDeadlineSeconds,配合告警,让滚动更新卡死能被及时发现并人工/自动干预。

  5. 内存监控与压测:对 container_memory_working_set_bytes 设 80% 告警,大促前按 3 倍流量做容量压测,提前暴露泄漏。

七、总结

这次故障的教训是:Kubernetes 的 OOM 是 Pod 级的,不是节点级的。看到节点有内存就以为安全,是典型的认知陷阱。更深的坑在于 OOMKilled 与探针、滚动更新的耦合——单点重启会被链路放大成整服务雪崩。排查时 kubectl describe pod 里的 Reason: OOMKilled + Exit Code 137 是第一线索,再用 jmap -histo:live 定位泄漏对象。容器环境下,任何"无限增长"的本地状态都是隐患,给缓存设边界、给探针分职责、给更新加兜底,三者缺一不可。

使用 Hugo 构建
主题 StackJimmy 设计