结论先行:liveness 探针的
timeoutSeconds默认只有 1 秒,而一次 Full GC 的 STW 停顿可以轻松到 10 秒以上。当两者错位,一个「健康的」JVM 服务会被 kubelet 误判为死亡并杀掉——更糟的是,重启的新 Pod 因为要预热、要重新加载缓存,启动时又触发 GC,于是整组副本在几分钟内连环重启,形成「越重启越慢、越慢越重启」的风暴。探针本身没错,错的是把「应用是否存活」和「这一次探针请求能否在 1 秒内被响应」绑在了一起。下面是我一次真实故障的完整复盘。
一、现象与背景:一次「无变更」引发的整组重启
先给规模。一个商品搜索服务,Kubernetes 1.27.4、containerd 1.6.20,JDK 8u392(G1,-Xmx4g -Xms4g,堆 4G 基本常驻),Spring Boot 2.7.18,8 副本(每副本 4C8G,分布在 5 个节点),稳态 QPS 约 2100。
2024 年 8 月某下午 15:42,业务方反馈「搜索接口集体超时」。监控上我看到的不是延迟曲线变陡,而是一条更刺眼的东西:
- 8 个副本的
Ready状态在同一 90 秒窗口内先后翻转(1/1 → 0/1 → 1/1); kubectl get pods里RESTARTS计数 15 分钟内从 0 涨到 47(8 副本合计),平均每个 Pod 重启 5~6 次;- 应用自己的 GC 日志(
-Xlog:gc*)显示:那段时间有 3 次 Full GC,最长一次 STW 停顿 12.4s; - 诡异的是:
kubectl describe pod的Last State全是Completed / Exit Code 0之外的Error,原因字段写着Liveness probe failed: context deadline exceeded (Timer...took 1.0003s, timeout 1s)。
也就是说:应用没崩、没 OOM、业务代码没异常,是被探针「合法地」杀死的。
二、根因:探针超时与 GC 停顿的「时间错位」
拆开看是三层机制叠加,单独一层通常都不出事:
第一层:timeoutSeconds: 1 撞上 12s 的 STW。
我们当时的 liveness 探针是 httpGet /actuator/health/liveness,配 periodSeconds: 10、timeoutSeconds: 1、failureThreshold: 3。JVM 在 Full GC 的 STW 期间,整个进程——包括处理 HTTP 请求的线程、包括响应探针的那个 Netty/线程池——全部冻结。探针请求发出去,进程根本没法在 1s 内回包,context deadline exceeded,一次失败。连续 3 次(failureThreshold: 3)×10s 周期 ≈ 30s 内 kubelet 就 kill 容器。而一次 Full GC 往往不止 1 次,停顿窗口完全能覆盖掉 3 个探针周期。
第二层:重启放大——新 Pod 的「冷」又触发 GC。
被杀的 Pod 重建后,JIT 还没预热、本地缓存(我们有一个 ~1.2GB 的商品索引缓存)要从远端重新加载,加载期堆压力骤升,启动后 20~40s 内又触发 GC 停顿。此时 startup 探针(我们没配 startupProbe)还没接管,liveness 已经开始计时,于是新 Pod 也在「刚活过来」的最脆弱期被杀。8 个副本的 GC 高峰又因为流量重新灌入而彼此叠加(一个 Pod 死,流量打到其余 Pod,其余 Pod 堆压力上升,更多 Pod GC),形成正反馈。
第三层:把「存活」和「这一次响应快不快」混为一谈。 liveness 的语义应该是「进程是否还活着 / 是否死锁」,而不是「这一次请求能否 1 秒内被应答」。GC 停顿属于「活着但暂时没响应」,恰恰不该触发 kill。这是概念层面的根因——探针的判据选错了维度。
(探针三件套职责本就该分开:liveness 决定重启、readiness 决定流量、startup 决定冷启动窗口。这次事故里,startup 该挡的冷启动期 GC,被 liveness 越权接管了。)
三、具体做法:把「存活判定」从「响应延迟」上解绑
按性价比从高到低,我落地的四步(前两步就能止血):
1)拉长 liveness 的容错窗口,让它能「扛过」一次 GC 停顿。
livenessProbe:
httpGet: { path: /actuator/health/liveness, port: 8080 }
periodSeconds: 10
timeoutSeconds: 5 # 从 1s 提到 5s
failureThreshold: 6 # 从 3 提到 6:3*5s=15s,能覆盖 12.4s 的 STW
successThreshold: 1
关键点:failureThreshold × periodSeconds 的「存活判定总窗口」必须 > 你观测到的最长 STW。12.4s 的停顿,配 15s 窗口才安全。这不是「让探针变迟钝」,而是把探针的语义对齐到「进程死亡」而非「单次慢响应」。
2)给 JVM 服务配 startupProbe,把冷启动期从 liveness 手里接走。
startupProbe:
tcpSocket: { port: 8080 } # 启动期只探「端口起来了」,不要求业务健康
periodSeconds: 5
failureThreshold: 40 # 允许最长 200s 启动(含缓存加载)
# startupProbe 成功后,liveness/readiness 才生效
有了 startup,新 Pod 那 20~40s 的「冷 GC 期」不会再被 liveness 追杀,正反馈环被打断。
3)把探针端点做成「GC 免疫」的轻量路径。
/actuator/health/liveness 会聚合一堆 health indicator,任何一个慢/抛异常都可能拖垮响应。我们换成一个几乎零依赖的专用端点:
@GetMapping("/health/alive")
public Map<String,String> alive(){ return Map.of("status","UP"); }
它不进 health indicator 聚合、不查 DB / 缓存 / 下游,GC 恢复后能立刻 200。liveness 指向它,readiness 才指向真正的业务健康端点。存活判据越轻越好——这是这次事故最值钱的教训。
4)从源头压 GC:把「启动期缓存加载」移出堆内存尖峰。
1.2GB 索引一次性 loadAll 是 STW 的放大器。改成分片流式加载(每片 100MB,CompletableFuture 分 12 片并行,单片加载完即用),把单次堆峰值从 ~2.4G 压到 <1G,Full GC 频率从每小时 2~3 次降到接近 0。这条是治本,前三条是止血。
改完一周,RESTARTS 计数归零,搜索 P99 从事故期的 800ms+ 回落到稳态 42ms。
四、踩过的坑与反直觉之处
Exit Code会骗你。 被 liveness 杀的容器,describe里看到的往往不是OOMKilled也不是业务异常,而是Liveness probe failed+ 一个看起来「正常」的退出码。只看RESTARTS计数和 OOM,会漏掉这类「合法谋杀」。排查 JVM 服务重启,第一步永远是开-Xlog:gc*对齐时间线,而不是先怀疑代码。- 调大
failureThreshold不等于「掩盖故障」。 很多人本能地把探针参数调小「更灵敏」,方向反了。对 JVM 这类会 STW 的运行时,探针的容错窗口要大于最长停顿,而不是更小。 - 没有 startupProbe 的 JVM 服务,liveness 一定会误杀冷启动。 只要你的服务启动期有缓存加载 / JIT 预热,
startupProbe几乎是必配项,不是可选项。 - 探针端点聚合了下游健康 = 给存活判定埋雷。 一旦某个下游抖动,liveness 端点也跟着慢,活着的进程被杀。存活端点必须「自闭环」。
- 流量重灌会制造 GC 正反馈。 一个 Pod 死,其余 Pod 压力上升 → 更多 GC → 更多 Pod 死。所以「重启风暴」往往不是 8 个 Pod 各自独立坏,而是互相放大的,止血要打断环(startup + 轻量端点),而不是逐个调参。
五、结论与可复用清单
liveness 探针的语义是「进程死没死」,不是「这一次请求快不快」。对会 STW 的 JVM/Go(GC)/任何有长停顿的运行时,配探针时必须让「存活判定窗口 > 最长停顿」,并用 startup 接走冷启动期。
避坑清单(贴墙上):
- [ ]
timeoutSeconds默认 1s 太短,JVM 服务至少 5s; - [ ]
failureThreshold × periodSeconds总窗口 > 观测到的最长 STW(本次 12.4s,配 15s); - [ ] 有启动期缓存加载 / JIT 预热的服务,必配
startupProbe(只探端口,不探业务健康); - [ ] liveness 端点做成「GC 免疫」的零依赖轻量路径,别聚合 DB/缓存/下游;
- [ ] readiness 才指向真正的业务健康端点,负责摘流量;
- [ ] 排查 JVM 重启,第一步开
-Xlog:gc*对齐探针失败时间线,别先怀疑业务代码; - [ ] 启动期大对象一次性
loadAll是 STW 放大器,改分片流式加载; - [ ] 看到
RESTARTS暴涨 +Liveness probe failed,先怀疑探针参数与 GC 错位,而不是应用「坏了」。
探针不是加上了就对了,加错了就是故障放大器——尤其是当你把「活着的进程」和「慢了一次的进程」当成同一种东西去杀的时候。