我们有个 Java 服务跑在自建的 K8s 集群上,最近一周 Pod 一直不消停,平均每隔 40 多秒就重启一次,看板上重启计数刷得飞起。但服务的日志里什么都看不到每次刚打印到「Spring 开始初始化数据源」,进程就被直接干掉了,连堆栈都没有。当时排查方向完全跑偏:先怀疑内存不够、接着怀疑镜像拉取慢、还去翻了节点上的 kubelet 日志,全部无果。
真正的原因藏在一行 YAML 里:livenessProbe 的 initialDelaySeconds 我写成了 10,而应用冷启动到真正能服务大概要 40 秒。也就是说,容器起来 10 秒后 kubelet 就来探活,应用还没热完,探针直接判定「这个容器死了」,然后把它杀掉重启。杀完再起,再 10 秒再杀,无限循环。
如果你也在自建集群上跑容器,先把机器和线路摸清楚会更省事:ishosting(全球云服务器) 节点覆盖多个区域,看规格和节点线路比较直观;kfcloud(全球云服务器) 也可以一起比一比,同一集群跨可用区拉节点的时候,延迟和丢包直接影响探针的判定。

新手最容易混的就是这三个。它们的区别不在于「谁去发请求」,而在于「失败之后系统会做什么动作」:
livenessProbe(存活探针):回答「这个容器还活着吗」。失败的后果最重kubelet 会直接杀掉容器并按重启策略重启它。它是最后一道保险,用来兜住那种「进程还在但内部死锁、线程池打满、GC 卡死」的情况。
readinessProbe(就绪探针):回答「这个容器现在能接流量吗」。失败的后果轻得多只是把 Pod 从 Service 的 Endpoints 里摘掉,不重启。滚动更新时它尤其重要:就绪探针不通过,新 Pod 不会接流量,老 Pod 也不会被提前干掉。
startupProbe(启动探针):回答「它启动完了吗」。一旦 startupProbe 成功,livenessProbe 和 readinessProbe 才开始接手。在它成功之前,kubelet 完全不会执行存活探测,这正是解决「启动慢 + 存活探针会误杀」这个矛盾的标准姿势。

最直觉的改法是:既然冷启动要 40 秒,那就把 initialDelaySeconds 改成 45。这个做法能立刻止血,但它是脆的。为什么?
第一,初始化的耗时不是常数。JVM 要 JIT 预热、要连数据库、缓存是冷的要回源、配置中心偶尔抽风多等两秒。平时 40 秒,周五上游接口变慢,可能就变成 90 秒。你按 45 秒配,下周又炸一次,然后你再改成 95,再改成 120……这个数字只会单向上涨,永远没有终点。
第二,initialDelaySeconds 的语义是「先等这么久,再开始探测」,它和 periodSeconds、failureThreshold 是叠加的,不是并列的。很多人算错了这个叠加关系,以为「等了 40 秒 + 失败 3 次 × 10 秒 = 40 秒就杀」,实际是「40 秒后才开始探,连续失败 3 次(每次间隔 10 秒)才动手」,真正的容忍窗口是 40 + 3×10 = 70 秒。
第三,如果 periodSeconds 设成 5 而 timeoutSeconds 设成 1,那每次探测几乎必然超时应用在启动期 CPU 占用高、GC 频繁,本来响应就慢,你给它的超时窗口却只有 1 秒。
而 startupProbe 的思路是:允许它慢慢启动,但一旦启动成功,存活判定立刻切换到严格模式。具体配置大概是这样:
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: 8080
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
startupProbe:
httpGet:
path: /actuator/health/readiness
port: 8080
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 24
这段的含义是:启动阶段每 5 秒探一次,连续失败 24 次才判定启动失败,等于给了 5×24 = 120 秒的启动窗口;一旦探针成功,后续的存活探测就按 10 秒一次、失败 3 次的标准严格执行。也就是说,启动慢不会误杀,启动完成后卡死照样能被抓出来。

再强调一遍怎么快速定位,不要再像我们一样把时间全花在猜内存上。
一是看事件流。kubectl describe pod xxx 末尾的 Events 会明确写出 Liveness probe failed: Get "http://10.x.x.x:8080/actuator/health/liveness": context deadline exceeded,以及 Container will be restarted。有这两行,方向就已经确定了,不需要猜。
二是看容器上一次退出的原因。kubectl logs xxx --previous 能拿到被强杀前那一轮的日志。我们就是靠这个发现日志永远停在「初始化数据源」这一步,才意识到进程每次都死在同一个位置这压根不是运行期的问题,而是启动期被探针提前枪毙了。
三是算一下容忍窗口。把 initialDelaySeconds + periodSeconds × failureThreshold 和应用实际冷启动耗时对一下。前者小于后者,就一定会重启。这个公式粗暴但有效,比盯着配置猜要快得多。

探针接口本身不能依赖数据库。我们有个同事把 liveness 探针指向了一个要查库的接口,结果数据库一抖动,K8s 认定容器全部死亡,把整个集群的服务全重启了一遍雪上加霜。liveness 应该只反映进程自身的存活状态,查库的接口放到 readiness 里。
别忘了给 Pod 配够资源。启动期 CPU 被 limit 卡住的话,JVM 编译再慢也不只是慢的问题。requests/limits 设置不合理会直接让启动时间翻倍,继而触发上面那一整套重启循环。我们后来把 limits 从 500m 提到 1 核,冷启动时间直接砍掉一半。
restartPolicy 别乱设。生产环境务必是 Always。如果配成 OnFailure,探针误杀一次容器就不会再起来了,症状会变成「Pod 一直是 CrashLoopBackOff 但不再重启」,更让人摸不着头脑。
最后一点:Deployment 的 maxUnavailable 和 minAvailable 要配合就绪探针。就绪探针配好了,滚动更新才是真的平滑。maxUnavailable 设成 0 加上 minAvailable 设成 1,新 Pod 只有真能服务了才会替换老 Pod,发布过程中不会有 502。

存活探针误杀的根因只有一个:探针的判定节奏跑在了应用启动节奏的前面。解法也有两条路把容忍窗口算宽(initialDelaySeconds 调大),或者把两件事分开(用 startupProbe 兜住启动期)。前者是止血,后者是治本,我更推荐后者。
顺带说一句,如果你想先在本地把这套东西复现出来,摸清探针的整个生命周期,minikube 这种轻量级的单节点集群工具比完整搭一套 K8s 省事得多,一个命令就能把集群拉起来,配合上面那套 YAML 反复验证参数,非常适合在真正改生产配置前先把边界摸清楚。
最后补一个提醒:改了探针配置之后,一定要用 kubectl rollout restart 触发一次滚动更新,确认新配置真的生效了改 Deployment 的 YAML 但忘了重启 Pod,是这条路上第二个最常见的时间黑洞。