一次 K8s Pod 频繁重启的故障复盘:从现象到根因的 40 分 原创
专栏:SRE 实战手记 · 第 1 篇
关键词:K8s Pod 重启、Kubernetes 故障排查、OOMKilled、CrashLoopBackOff、内存泄漏
适合读者:SRE / 平台工程师 / 后端开发
写在前面
故障复盘这种东西,写出来都是"40 分钟定位",但真坐在告警面前的时候,40 分钟里你脑子里过了不下二十个假设,其中十八个是错的。
这次复盘的是我们 order-query-svc 一次 CrashLoopBackOff。表面看是个再普通不过的 OOM,但挖下去是个挺典型的复合故障——上游一次"看起来人畜无害"的发版,叠加上我们自己的容错逻辑副作用,再叠加一次大促流量,最后在 K8s 的 OOMKilled 机制下引爆。
我把这 40 分钟里我们怎么想的、走错哪了、最后怎么定位的,都写下来。不是教程,是复盘。
一、故障背景
order-query-svc 是我们的订单查询服务,Go 1.21 写的,K8s 部署,3 副本。主要职责就是接上游的查询请求,回 DB 捞订单数据,带个本地缓存降低 DB 压力。
资源配置长这样:
resources:
requests:
cpu: 200m
memory: 512Mi
limits:
cpu: "1"
memory: 1Gi很标准的配置。这个服务上线两年多,一直很稳,重启次数基本是 0。直到那天下午。
二、现象:告警来了
14:23,企业微信告警群弹出一条告警:
[告警] order-query-svc Pod 重启次数 > 5 (10min)
namespace: prod pod: order-query-svc-7d8f... restarts: 7我第一反应是看了一眼时间——周三下午两点半,不是大促峰值,不是发版窗口,不应该是容量问题。但 restarts: 7 这个数字在 10 分钟内出现,已经说明 Pod 在反复崩溃重启了。
kubectl get pods 一看,状态在 Running 和 CrashLoopBackOff 之间反复横跳,RESTARTS 列的数字每过一两分钟就加一。
到这里,现象很清晰:Pod 在反复被 kill 然后重启。下一个问题就是——为什么被 kill。
三、40 分钟时间线
故障从 14:23 告警到 15:03 完全恢复,整整 40 分钟。下面是关键节点的时间线,对应的排查阶段后面会展开讲。
![故障时间线图]
| 时间 | T+ | 事件 | 阶段 |
|---|---|---|---|
| 14:23 | 0:00 | 告警:Pod 重启次数 > 5 | 发现 |
| 14:25 | 0:02 | 确认 CrashLoopBackOff 状态 | 发现 |
| 14:28 | 0:05 | kubectl describe:OOMKilled, Exit 137 | 阶段一 |
| 14:30 | 0:07 | 扩容 limits.memory 1Gi → 2Gi | 阶段一 |
| 14:33 | 0:10 | 重启依旧,推翻"内存不够"假设 | 阶段一→二 |
| 14:35 | 0:12 | 看监控:内存线性上涨,典型泄漏曲线 | 阶段二 |
| 14:38 | 0:15 | 质疑:上线 8 天为何今天才爆 | 阶段二 |
| 14:40 | 0:17 | pprof heap 定位到 orderCache map | 阶段二 |
| 14:43 | 0:20 | 审查 TTL 清理逻辑,看起来正常 | 阶段二 |
| 14:46 | 0:23 | 看缓存命中率:60% → 15%,key 在暴增 | 阶段二→三 |
| 14:49 | 0:26 | dump 缓存 key,发现大量 expireAt = 72h | 阶段三 |
| 14:52 | 0:29 | 对比上游返回,expireAt 字段格式变了 | 阶段三 |
| 14:55 | 0:32 | 根因明确:上游发版 + fallback 容错副作用 | 阶段三 |
| 14:57 | 0:34 | 联系上游团队,确认当天上午发版 | 阶段三 |
| 14:59 | 0:36 | 止血:回滚本服务版本 + 缓存大小硬上限 | 止血 |
| 15:03 | 0:40 | Pod 稳定,重启停止 | 恢复 |
四、阶段一:以为是 OOM(0:00 – 0:10)
拿到 OOM 告警,kubectl describe pod 是肌肉记忆。输出里有一段:
Containers:
order-query:
...
Last State: Terminated
Reason: OOMKilled
Exit Code: 137
Started: Wed ... 14:21:02
Finished: Wed ... 14:22:48
State: Running
Started: Wed ... 14:22:51
Restart Count: 8Exit Code: 137 = 128 + 9(SIGKILL)。K8s 的 OOMKilled 是 cgroup 层面干的事——进程内存超过了 limits.memory,内核 OOM killer 直接 SIGKILL,没有优雅退出的机会。
到这里,判断很自然:内存吃超了 1Gi 上限。
第一反应是先止血——把 limits.memory 从 1Gi 调到 2Gi,滚动更新。这是个 30 秒能做完的操作,先让服务稳住再查根因。
14:33,滚动更新完成。然后我盯着监控看了 3 分钟——重启没停。2Gi 上限也顶不住。
这是这次故障的第一个关键拐点:扩容没用,说明这不是一个"稳态内存不够"的问题,是一个"内存持续上涨直到被 kill"的问题。也就是我们常说的内存泄漏形状。
到这一步,"内存不够"的假设被推翻,进入阶段二。
五、阶段二:怀疑内存泄漏,但说不通(0:10 – 0:25)
打开 Grafana 看 Pod 的内存曲线,长这样:
内存(MB)
1000 | /\
800 | / \ <- kill,重启
600 | / \
400 | / \ <- kill,重启
300 | / \ <- kill,重启
|__________________________> 时间
14:15 14:20 14:25 14:30标准的锯齿形——从 300MB 起步,线性上涨,到 1Gi(或 2Gi)被 kill,重启回到 300MB,再涨上去。典型的内存泄漏曲线,没有之一。
按经验,内存泄漏的第一个排查动作是 pprof。我们的服务恰好开了 pprof 端口,直接 curl 拉一份 heap profile:
kubectl exec -it order-query-svc-7d8f... -- \
curl -s localhost:6060/debug/pprof/heap > heap.out
go tool pprof -top heap.out输出里 inuse_space 排第一的是一个我们叫 orderCache 的 map[string]*entry,占了 700 多 MB。剩下的所有对象加起来不到 100MB。凶手找到了。
orderCache 是订单缓存,结构很简单:
type entry struct {
order *Order
expireAt time.Time
}
var orderCache = make(map[string]*entry)写缓存的时候,key 是订单 ID,value 带个过期时间。另有一个 goroutine 每分钟扫一遍,把过期的删掉:
func cleanExpired() {
for range time.Tick(time.Minute) {
now := time.Now()
for k, v := range orderCache {
if v.expireAt.Before(now) {
delete(orderCache, k)
}
}
}
}代码看起来没问题。过期清理每分钟跑一次,正常的 TTL 缓存。一个 700MB 的 map,按一条订单缓存 ~1KB 算,那是 70 万条——这服务一天的总查询量也就几十万,缓存里不可能有这么多。
到这一步,我开始卡住了。
内存泄漏指向 orderCache,但清理逻辑看起来是对的。这是排查里最难熬的时刻——你手上有个明确的嫌疑人,但证据对不上。
更说不通的是另一个问题:这个版本上线 8 天了。如果是单纯的内存泄漏,为什么前 7 天没问题,偏偏今天下午爆?
我开始往两个方向怀疑:
- 流量侧:是不是今天 QPS 涨了,导致缓存写入速度 > 清理速度?
- 数据侧:是不是今天缓存写进去的东西和前 7 天不一样?
流量方向先排除——看了 QPS 监控,今天确实比平时高一些,但也只是高了一倍多,不至于撑爆。重点放在数据侧。
六、阶段三:挖到真正的根因(0:25 – 0:40)
转向数据侧的第一个动作是看缓存命中率。这是个被低估的指标,但它最能反映缓存内部到底发生了什么。
正常情况下我们的命中率稳定在 60% 左右——意味着 60% 的查询能命中缓存。今天一看:
命中率
80% |
60% |----\ /
40% | \ /
20% | \_____/ <- 掉到 15%
0% |________________> 时间
14:00 14:20 14:40命中率从 60% 掉到 15%。这意味着同一个订单 ID 第二次查的时候经常查不到了——缓存里全是"新面孔"。
这就对上了。问题不是"旧 key 没被清",是"新 key 灌得太快"。每分钟清理一次,但每分钟灌进来的新 key 远超清理速度,map 就一直涨。
下一个问题:为什么新 key 这么多?
我 dump 了一份当前缓存里的 key 样本(生产环境脱敏处理),按 expireAt 分组。结果让我愣了一下:
expireAt 分布(采样 10000 条):
- 1 小时内过期: 127 (1.3%)
- 1-3 小时过期: 89 (0.9%)
- 3-24 小时过期: 44 (0.4%)
- 72 小时过期: 9740 (97.4%) <- 异常集中97% 的 entry 的 expireAt 都在 72 小时之后。
我们的正常 TTL 是 30 分钟。哪来这么多 72 小时 TTL 的 entry?
回头看写入缓存的代码:
func cacheOrder(order *Order) {
var expireAt time.Time
if order.ExpireAt.IsZero() {
// 容错:上游没给过期时间,用默认 30 分钟
expireAt = time.Now().Add(30 * time.Minute)
} else {
// 解析上游的过期时间
parsed, err := time.Parse(time.RFC3339, order.ExpireAt)
if err != nil {
// 解析失败,兜底用 72 小时
expireAt = time.Now().Add(72 * time.Hour)
} else {
expireAt = parsed
}
}
orderCache[order.ID] = &entry{order: order, expireAt: expireAt}
}找到了。 这段容错逻辑有个看起来很合理的兜底——上游给的 ExpireAt 解析失败时,给一个 72 小时的 TTL,本意是"宁可多缓存一会,也别让 DB 压力变大"。
然后我去看上游接口当天的返回:
# 正常的返回(上周)
{"orderId": "A123", "expireAt": "2026-07-19T15:00:00Z"}
# 当天的返回
{"orderId": "A123", "expireAt": "7200"}上游把 expireAt 字段从 RFC3339 格式的时间,改成了"过期秒数"的整数字符串。一个看起来很合理的接口优化——"过期秒数"比"绝对时间"在跨时区场景下确实更清晰——但这是单方面破坏了契约。
time.Parse(time.RFC3339, "7200") 必然返回 error,于是全走到了 72 小时兜底分支。
根因链条拼完整了:
- 上游当天上午发版,把
expireAt字段格式从 RFC3339 改成秒数 - 我们这边解析失败,兜底给了 72 小时 TTL(容错逻辑的副作用)
- 当天下午恰好有个小活动,QPS 比平时高约 1 倍
- 72 小时 TTL × 高 QPS → 缓存 key 净增长远超每分钟清理速度
- map 持续膨胀 → 内存涨到 limits → OOMKilled → CrashLoopBackOff
14:57,联系上游团队,确认他们当天上午确实发过一次版,改了 expireAt 字段语义。下游没人收到通知——因为这次改动在他们内部被认为是"无破坏性"的。
14:59,执行止血:
- 回滚本服务到上一个版本(绕过新数据路径,老版本走的是另一个缓存逻辑,不受影响)
- 同时给
orderCache加上硬上限:if len(orderCache) > 50000 { /* 淘汰策略 */ }
15:03,Pod 重启停止,服务恢复。
七、根因深度分析
故障恢复不是终点,复盘才是。我们把根因拆成三层:
第一层:直接原因
orderCache 无限增长,内存超出 cgroup limit 被 OOMKilled。
第二层:设计缺陷
- 容错逻辑的副作用:72 小时兜底 TTL 是个"安全网",但它让脏数据也能被长期缓存。设计容错时只考虑了"解析失败怎么办",没考虑"解析失败的数据本身是不是有问题"。
- 缓存无上限:map 只管写和过期清理,没有硬上限。一旦写入速度异常,没有任何兜底机制。
- 字段解析静默失败:
Parse失败走了兜底,但没有任何告警或日志。我们从监控上看不到"有多少比例的 entry 走了兜底分支"。
第三层:协作问题
- 契约变更没通知:上游认为改字段格式是"无破坏性"的,因为他们自己的客户端用了动态解析。但他们不知道下游用了强类型解析。
- 缺乏契约测试:上下游之间没有契约测试(Consumer-Driven Contract Testing),上游改完跑自己的单测就发了。
- 依赖链不可见:上游不知道谁依赖这个字段,下游也不知道这个字段是上游控制的。契约边界模糊。
这三层里,第一层是技术问题,好修;第二层是架构问题,要改设计;第三层是组织问题,最难。一个故障能挖到第三层,才算是真正复盘到位。
八、改进措施
按"止血 → 根因修复 → 长期机制"三层做:
止血(当天完成)
- 回滚本服务版本
orderCache加 50000 条硬上限,超出走 LRU 淘汰
根因修复(3 天内)
- 上游回滚
expireAt字段格式,恢复 RFC3339 - 上下游约定:所有契约变更走 Breaking Change Review,需下游签字
- 解析失败改为短 TTL 兜底(5 分钟)而非 72 小时,并打
WARN日志 + 计数器 - 给容错分支加监控:
cache_fallback_total,比例超过 5% 触发告警
长期机制(1 个月内)
- 引入 Pact 做消费者驱动契约测试(CDC Testing),上游发版前自动跑下游契约
- 缓存层抽象成通用组件,强制要求
maxSize+ 淘汰策略 - 建立"字段级契约文档",标注每个字段的 owner 和 consumer
- 给所有 SRE 可观测的"容错兜底分支"加统一监控大盘
九、经验沉淀
这次故障里有几条可以复用的经验,我单独拎出来:
1. 扩容没用,是排除"稳态容量问题"的最快信号。 OOMKilled 第一反应都是加内存,这没错——先止血。但如果加了内存还不稳,立刻转向"持续增长型"问题(泄漏、缓存膨胀、队列堆积)。别在"再加一倍内存"上恋战。
2. 缓存命中率是被低估的排查指标。 内存涨 + 缓存命中率掉,几乎可以锁定"缓存 key 在异常增长"。这个组合比单看内存曲线信息量大得多。如果你做缓存服务,命中率监控是必须项,不是可选项。
3. 容错逻辑本身是隐蔽的故障源。 写兜底分支时人都会想"失败了怎么办",但很少想"走到这里的数据是不是已经脏了"。兜底分支必须配监控——你得知道有多少比例的请求在走兜底,否则等于盲飞。
4. 字段格式变更就是破坏性变更,不管上游觉得多"无害"。 "RFC3339 → 秒数"在上游眼里是优化,在下游眼里是协议破坏。任何字段的语义或格式变更,都应当按破坏性变更处理,走 review + 通知流程。
5. CrashLoopBackOff 排查要先分清"被 kill"还是"自己退出"。Exit Code 137 是被 kill(OOM 或外部信号),非 137 通常是进程自己 panic/exit。这一步分清,后面排查方向完全不同。我们这次是 137,所以直奔内存方向;如果是 2(panic),方向就完全不一样了。
十、附录:CrashLoopBackOff 排查 Checklist
把这次和历次排查沉淀成一个清单,遇到类似故障照着过一遍,省得每次都从头想。完整可下载版(含命令模板)放在了[官网资源页],这里放精简版:
第一步:确认退出原因
kubectl describe pod看Last State.Reason和Exit Code- 137 → 内存方向(OOMKilled)
- 非 137 → 看
kubectl logs --previous找 panic/error
第二步:区分稳态 vs 增长型(仅 OOM)
- 看内存曲线是平稳还是锯齿上涨
- 锯齿上涨 = 增长型(泄漏/缓存膨胀),扩容无效别恋战
- 平稳但贴近上限 = 稳态容量问题,扩容有效
第三步:定位增长对象
- pprof heap(Go)/ jmap(Java)/ 进行内存 dump
- 找 inuse 排名靠前的对象类型
第四步:查"为什么今天才爆"
- 当天是否有发版(本服务 + 上下游)
- 流量是否有变化
- 数据特征是否有变化(命中率、key 分布)
第五步:止血优先于根因
- 先回滚 / 限流 / 扩容让服务稳住
- 再回头查根因,别在生产上边查边炸
写在最后
这次故障的"40 分钟"听起来不长,但里面塞了三个错误假设(内存不够、内存泄漏、缓存逻辑有 bug)和一个正确转折(看命中率)。复盘的价值不在于"我们 40 分钟搞定了",而在于"下次能不能 10 分钟搞定"——上面那张 Checklist 就是干这个的。
如果你也踩过类似的坑,欢迎在评论区聊聊。尤其是容错兜底分支埋的雷这种,隐蔽性极强,谁手里没几个。
下一篇我们会写 SLO 落地,把"错误预算"这种听起来很理论的工具,拆成可落地的几个步骤。
作者:SRE 实战手记 · 内容团队本文首发于技术博客,掘金同步发布,公众号有精简版。转载请联系作者。