如果你维护过 Kubernetes 集群,大概率遇到过这种诡异情况:某个 Pod 状态明明显示 Running 且 Ready,但业务方反馈接口一直超时;另一个 Pod 看着一切正常,却在凌晨毫无征兆地被重启了好几回。查到最后,问题往往都出在同一个地方——健康检查探针的配置。Kubernetes 的健康检查机制本身并不复杂,文档里的概念十几分钟能看完,但真正放到生产环境,探针用不好引发的连锁故障,能让你在监控屏前耗上一整天。
这篇内容不是把官方文档翻译一遍,而是我在维护集群和帮业务团队排障过程中,沉淀下来的探针配置思路、参数计算逻辑和故障复盘。无论你是在图形界面上点按钮部署,还是直接写 Deployment YAML,这些经验应该都能帮上忙。
1. 三种探针各管一段生命周期,别只盯着 liveness 一个
很多团队最早接触健康检查,抄的第一份 YAML 基本只有 livenessProbe,因为看到的效果很直接:探针失败、Pod 被杀、重启,看起来服务被"保护"住了。但真实生产里,只配 liveness 往往是不够的,甚至可能起反作用。
1.1 探针机制的分工逻辑:为什么 K8s 要把健康检查拆成三份
Kubernetes 在 Pod 级别提供三种可配置的探针:
- livenessProbe:存活性探针。探测失败后 kubelet 会杀掉容器,按 restartPolicy 重启。它回答的问题是"进程还活着吗"。
- readinessProbe:就绪性探针。探测失败后 Pod 会从对应的 Service Endpoints 中移除,不再接收新流量,但 Pod 不会被杀。它回答的问题是"这个 Pod 能接流量了吗"。
- startupProbe:启动探针。用于保护启动很慢的应用,在它成功之前,另外两种探针都不会启动。它回答的问题是"应用真正起来了吗"。
我见过不少团队把"存活"和"就绪"混为一谈,总觉得探针失败就该重启。实际上,readiness 失败只是隔离流量,让 Pod 暂时"离线",等恢复之后再挂回去;只有 liveness 才涉及杀容器。两者职责完全不同。
1.2 三种探针同时配置时的生效顺序
实际执行顺序是这样的:Pod 启动后,kubelet 先看有没有配置 startupProbe,如果配置了,就先执行 startupProbe;startupProbe 一旦成功,kubelet 才开始执行 livenessProbe 和 readinessProbe。
这个顺序很容易被忽略。很多人以为 startupProbe 只是用来"延迟"检查的,其实它是三道闸门的第一道。对一个启动需要 30 秒的 Java 应用,如果不配 startupProbe,就得把 liveness 的 initialDelaySeconds 调成很大的值;配了 startupProbe,initialDelaySeconds 就可以保持很小,因为 startupProbe 会在应用真正起来之后才放行后续探针。
1.3 三种探针失败后的不同结果,决定了你的选择
方便对照,我列一个表:
| 探针类型 | 探测失败后果 | 典型用途 | 常见误区 |
|---|---|---|---|
| livenessProbe | 容器被杀并重启 | 检测死锁、OOM、进程僵死 | 用它做业务依赖检查 |
| readinessProbe | 从 Endpoints 摘除,不接收新流量 | 检测依赖服务、连接池是否就绪 | 以为它也会重启 Pod |
| startupProbe | 容器被杀并重启 | 保护冷启动时间长的应用 | 用固定 initialDelay 代替 |
我实际遇到的典型事故是:团队把 liveness 指向一个查数据库的接口,数据库抖动,所有实例的 liveness 同时失败,整个服务在一分钟内被全部重启,流量直接雪崩。所以第三章我会专门拆一个"探针端点设计"的细节。
2. 探测方式与参数计算:从 HTTP 到 TCP,选错就会误杀
2.1 三种探测机制的原理与选型建议
Kubernetes 支持三种探测动作,对应不同的服务形态:
- exec:在容器内执行命令,退出码为 0 视为健康。适合进程内状态可以自检、又不想额外暴露端口的组件。但 exec 有个隐藏成本:命令是 kubelet 在容器里 fork 执行的,如果命令本身耗资源或可能卡住,会拖累 kubelet。
- tcpSocket:向容器 IP 的指定端口发起 TCP 连接,三次握手成功即视为健康。适合数据库、缓存、消息队列这类"端口通基本可用"的组件。
- httpGet:请求指定路径的 HTTP 接口,返回状态码在 200-399 之间视为健康。适合 Web 服务。
值得说一点:tcpSocket 是最"轻"的探测方式,但也是最容易误判的。比如一个缓存组件连接池耗尽,端口照样在监听,TCP 握手照常成功,readiness 依然通过,但业务请求进来就是超时。所以对关键无状态服务,能用 httpGet 就不要贪省事用 tcpSocket。
2.2 探针参数的计算逻辑,别拍脑袋填
无论哪种探针,都有五个参数需要理解:
- initialDelaySeconds:容器启动后,等多久开始第一次探测。
- periodSeconds:每隔多少秒探测一次。
- timeoutSeconds:单次探测等待响应的超时时间。
- successThreshold:连续成功多少次才视为"健康"。
- failureThreshold:连续失败多少次才触发对应动作。
一个常见的误判是只算 periodSeconds,忘了 timeoutSeconds 也算进失败判定时间。假设 failureThreshold=3、periodSeconds=10、timeoutSeconds=1,那么从第一次探测失败开始,大约需要 33 秒才会触发重启;如果 timeoutSeconds 是 30、periodSeconds 是 10,实际判定时间会明显变长。
数值之间还有一个容易踩的雷:timeoutSeconds 最好小于 periodSeconds。如果 timeout 比 period 还大,上一轮探测还没结束,下一轮又发起了,kubelet 侧会出现探针叠加,日志里全是超时,业务看到的则是探针请求密集涌入。
2.3 到底该怎么填?我用一个场景算一遍
假设一个网关服务,正常启动要 5 秒,依赖 MySQL、Redis 和两个内部 RPC。健壮的配置大概是:
- readinessProbe:periodSeconds=5,timeoutSeconds=1,failureThreshold=3。也就是说依赖任何一个抖动,最多 18 秒左右就会把实例摘掉。
- livenessProbe:periodSeconds=10,timeoutSeconds=1,failureThreshold=3。
- startupProbe:periodSeconds=2,timeoutSeconds=2,failureThreshold=15,最多给它 30 秒完成启动。
有人会问:failureThreshold 调大一点是不是更稳?对,也不对。调大了确实能减少瞬时抖动导致的误杀,但也会延长故障发现时间。一个已经死锁的实例,本来 30 秒内能被清理,failureThreshold=10 可能要等 2-3 分钟才被处理,这期间流量一直打在一个坏实例上。我的习惯是:liveness 的 failureThreshold 保持 3-5,readiness 的 failureThreshold 可以略大,因为它只是摘流量,不涉及杀进程。
3. 三个真实落地场景的探针配置拆解
3.1 常规 Web 服务:liveness 和 readiness 路径必须分开
大多数 HTTP 服务的正确姿势是提供两个健康检查端点:/livez 和 /readyz。一个只管进程活着,一个真正检查依赖。
readinessProbe: httpGet: path: /readyz port: 8080 initialDelaySeconds: 10 periodSeconds: 5 timeoutSeconds: 1 successThreshold: 1 failureThreshold: 3 livenessProbe: httpGet: path: /livez port: 8080 initialDelaySeconds: 10 periodSeconds: 10 timeoutSeconds: 1 failureThreshold: 3/livez 的实现要足够简单:内存、goroutine 正常就返回 200;不要查数据库,不要查远程调用。/readyz 可以去查依赖,但要控制超时,比如 1 秒内必须返回结果,否则直接判失败。
原因:如果把依赖检查放进 liveness,数据库一抖动,所有实例的 liveness 同时失败,K8s 会把整个服务快速重启,这是最典型的自残式故障。
3.2 缓存和存储组件:tcpSocket 更适合
对 Redis、MySQL 这类组件,通常配置 tcpSocket 就够了。
readinessProbe: tcpSocket: port: 6379 periodSeconds: 10 timeoutSeconds: 3注意事项:tcpSocket 只能证明端口通了。如果你的缓存组件状态需要在 readiness 里体现(比如主从切换或集群握手状态),建议在业务里提供一个命令行检查工具,用 exec 方式探测。但 exec 命令不能太重,控制在 100ms 内完成,避免每次探活都在容器里做一次完整的状态汇总。
3.3 冷启动很慢的应用:startupProbe 兜底
一个典型的 Java 微服务,依赖配置中心、注册中心,冷启动经常需要 60-90 秒。如果按传统方式把 initialDelaySeconds 调成 120,又会导致真正故障时发现太慢。这个时候 startupProbe 最有用。
startupProbe: httpGet: path: /healthz port: 8080 periodSeconds: 5 timeoutSeconds: 2 failureThreshold: 30即最多等待 30 次探测,每次最长 2 秒超时,相当于给应用约 210 秒的启动窗口。startupProbe 成功之前,liveness 和 readiness 不会执行,所以 initialDelaySeconds 可以放心保持较小值。
我在一个线上团队接手过一个服务,启动要 50 秒,但原来的 YAML 里 initialDelaySeconds=60,periodSeconds=5。表面看刚好能过,但一旦启动过程中 CPU 被抢占,第 60 秒还没监听端口,马上被杀重启,陷入"启动失败-被杀-再启动"的死循环。改用 startupProbe 之后,这个问题一次都没再出现。
3.4 健康检查端点的实现细节
无论用哪种探针,端点本身的实现质量决定整套机制的可靠性。我的建议是:
- 端点要轻。不要在健康检查里打印大量调用栈、采集完整指标。
- 端点响应要快。探针 timeoutSeconds 通常只有 1-3 秒,不能做重计算。
- 端点要稳定。不要把一些偶发抖动也判定成失败,可利用状态码区分:200 表示健康,503 表示依赖未就绪,让 readiness 和 liveness 能够按不同的状态码处理。
这些建议看起来是常识,但我在生产环境里见过太多人把健康检查端点写成一个"迷你压测接口",每次探测都要查全量配置、预热缓存,结果探针请求本身把 CPU 打满了。
4. 一次滚动更新卡死的完整排查链路与级联故障复盘
4.1 案例一:Pod 反复 CrashLoopBackOff
那次事故发生在一次普通版本升级之后。新版本发布上去,Pod 没有像预期那样 Running,而是进入了 CrashLoopBackOff。
我按顺序做了这几步排查:
kubectl get pods看到新版本 Pod 状态 CrashLoopBackOff,重启次数一直在涨。kubectl describe pod <name>看到 Events 里有一行关键信息:Liveness probe failed: HTTP probe failed with statuscode: 500。kubectl logs <容器名>没发现异常堆栈,应用进程本身是起来的。- 进入容器手动
curl /livez,返回 500。 - 查路由配置,发现新版本把 /livez 放进了需要鉴权的路由组,探针请求不带 token 返回 500。
修复方式就是:把健康检查路由放在独立分组,不加鉴权;或者给探针配置 httpHeaders,让它带上内部 token。
复盘下来,这条经验最通用:健康检查端点不要和服务业务路由混在一起。有时候开发为了省事,把 healthz 塞进了 web 框架的全局拦截器里,一旦做权限改造,探针就跟着挂。
4.2 案例二:滚动更新卡死,新 Pod 一直不 Ready
另一个 case 是滚动更新一直不完成,新 Pod 起不来,旧 Pod 也不退出。kubectl describe pod看到的是 Readiness probe failed,而不是 Liveness probe failed,所以 Pod 不会被杀,只是永远不 Ready。
进入容器检查后惊讶地发现:服务监听的是 8081,而探针配的是 8080。原因很简单,测试环境容器端口恰好和探针配置一致,部署到集群时端口映射变了,但探针配置还在用老端口。
这类问题用一条命令就能区分:kubectl exec进容器,再curl localhost:端口,立刻能判断是监听问题还是探针配置问题。
4.3 案例三:数据库抖动引发全站级联雪崩
前面提过这种事故,我展开讲一下完整链路。某个晚上数据库出现了一次 5 秒的抖动,正常情况下这只是一次小告警。但因为 A 服务的 /livez 内部查询数据库,探针超时,liveness 连续失败,A 服务的所有 Pod 被快速重启。A 不可用之后,依赖 A 的下游服务开始报错,B 服务的 /readyz 又检查了 A 服务,B 的 Pod 也被摘流或重启。
最终结果是数据库一次 5 秒的抖动,演变成 20 多个服务同时雪崩。
正确的做法是:
- liveness 永远不检查依赖。
- readiness 可以检查依赖,但要给依赖检查设置很短的超时(比如 300ms),并考虑退避策略。
4.4 排查健康检查问题的实用命令
kubectl get pod <name> -o wide:看状态、重启次数、IP。kubectl describe pod <name>:重点看 Events 里 probe failure 的报错。kubectl logs <name>:对照容器日志和探针时间点。kubectl get events --sort-by=.lastTimestamp:看全集群事件。
建议是:把探针请求日志在应用侧打印一下(或统计到 metrics),这样对比 kubelet 事件的时间线,定位是"探针没发出来"还是"端点响应失败",能省下大量瞎猜时间。
5. readiness 失效之后,优雅停机也在保护你的流量
5.1 从 readiness 失败到摘流量之间存在时间窗口
很多人以为探针一失败,Pod 马上就从 Service Endpoints 摘掉了。实际不是:探针是周期性执行的,失败后 kubelet 更新 Pod 状态,控制平面再更新 Endpoints,这中间有几秒到十几秒的延迟。在这段时间里,如果 Pod 还继续收到新请求,就会出现部分请求失败。
所以当我们要下线一个 Pod 时,应该先让它"失去 readiness",给控制平面一点时间把流量摘空,再真正退出。这就要靠优雅停机机制。
5.2 preStop 钩子和 terminationGracePeriodSeconds 的配合
lifecycle: preStop: exec: command: ["/bin/sh","-c","sleep 15"] terminationGracePeriodSeconds: 30流程是:kubelet 收到删除 Pod 请求后,把 Pod 标记为 Terminating,发送 SIGTERM 信号给主进程,同时执行 preStop 钩子。preStop 执行完之后,容器才会进入后续终止流程。如果 preStop 执行时间超过了 terminationGracePeriodSeconds,kubelet 会直接发 SIGKILL 强杀。
所以配置要保证 terminationGracePeriodSeconds 大于 preStop 的 sleepTime。我见过有人把 preStop sleep 设为 30,但宽限期还是默认 30,结果 sleep 还没跑完就被强杀,等于白配。
5.3 应用侧也要响应 SIGTERM
Kubernetes 之外的问题:如果应用不捕获 SIGTERM,优雅停机无从谈起。常见做法是在应用里注册一个 shutdown hook,收到 SIGTERM 后按顺序做这几件事:从注册中心摘除自己、关闭 HTTP 连接池、等待存量请求结束、刷新缓冲数据、退出进程。
很多老服务只处理了 SIGINT(Ctrl+C),没处理 SIGTERM,到了 K8s 里一删 Pod 就直接被杀,连接池里的请求全部断掉。这类问题排起来很隐蔽,因为 Pod 状态和探针都正常,只有业务反馈"发布时总有报错"。
6. 复盘完这几个故障,我重新设计了探针规范
最后分享一些我现在的做法,不算标准答案,但都是被生产环境教育的:
- 探针路径在所有服务中统一:/livez、/readyz,不允许各自起名。
- liveness 只允许做进程存活检查,readiness 才能检查依赖,依赖检查超时必须小于 500ms。
- 启动阶段一律用 startupProbe,initialDelaySeconds 只保留 5-10 秒。
- 探针的 successThreshold、failureThreshold 进入发布规范,改动要走评审。
- 端点耗时和成功率纳入监控,探针平均耗时超过 200ms 就告警排查。
这些规范看起来严格,但都是从事故里反推出来的。我原来也觉得健康检查"能用就行",直到连续经历了几次探针引发的故障,才明白它决定了 Kubernetes 对服务状态的判断边界。你把它当普通接口配,它就会在关键时刻用一场事故教你重新认识它。
多说一句,健康检查只是"判断健康"的第一步,真正的稳定性还要靠资源限制、弹性伸缩、容灾设计配合。但把探针这件事做对了,至少能让 K8s 在异常发生时,做出一个你不会后悔的决定。