K8s 三种探针:liveness、readiness、startup 配错会怎样
滚动更新卡在 0/3 ready、Pod 明明在跑却一直不 ready —— 三种探针配错的五种典型现象、参数推导方法与固定排查套路。
上线第一天就遇到了一个很典型的问题:滚动更新卡在 0/3 ready,Pod 明明在跑、日志里没有任何报错、手动 kubectl exec 进去 curl 也能通,但始终不 ready。原因不复杂——readinessProbe 的 httpGet.path 写的是 /health,而应用实际暴露的是 /actuator/health。
这类问题之所以折腾人,是因为探针配错时的现象和「服务真的挂了」几乎一模一样,但排查路径完全不同。这篇文章把 liveness、readiness、startup 三种探针的职责边界、参数取值和五个高频坑讲清楚。
一、先分清三种探针到底在问什么
很多人把三种探针理解成「健康检查的三个级别」,其实它们问的是三个完全不同的问题。
| 探针 | 它在问 | 答错(失败)的后果 |
|---|---|---|
startupProbe |
你启动完了吗? | 重启容器 |
livenessProbe |
你还活着吗? | 重启容器 |
readinessProbe |
你能接流量吗? | 从 Service Endpoints 摘除,不重启 |
关键差异在最后一行:只有 readiness 不重启容器。这个区别决定了配错的代价。
startupProbe 是 k8s 1.16 引入的(1.18 GA)。在它出现之前,慢启动的 Java 应用只能靠把 livenessProbe.initialDelaySeconds 调到很大来兜底,而这会带来一个副作用:应用启动 90 秒,如果第 40 秒就死锁了,还得再等 50 秒才会被发现重启。startupProbe 把「启动慢」和「运行中挂了」这两件事拆开判断——启动阶段只看 startupProbe,它成功了才开始跑 liveness。
二、一个可以直接抄的配置
下面这份是我在用的 Java 服务探针配置,注释写清楚了每个数字的来源:
containers:
- name: api
image: registry.example.com/api:1.4.2
ports:
- containerPort: 8080
startupProbe:
httpGet: { path: /actuator/health/liveness, port: 8080 }
periodSeconds: 5
failureThreshold: 36 # 5 × 36 = 180s,覆盖最慢的冷启动
timeoutSeconds: 3
livenessProbe:
httpGet: { path: /actuator/health/liveness, port: 8080 }
periodSeconds: 10
failureThreshold: 3 # 连续 3 次失败 = 30s 后重启
timeoutSeconds: 3
readinessProbe:
httpGet: { path: /actuator/health/readiness, port: 8080 }
periodSeconds: 5
failureThreshold: 2 # 10s 内两次失败就摘流量,但要连续
successThreshold: 1
timeoutSeconds: 3
lifecycle:
preStop:
exec: { command: ["sh", "-c", "sleep 8 && curl -s -X POST localhost:8080/actuator/shutdown"] }
terminationGracePeriodSeconds: 40
几个数字的推导逻辑:
startupProbe.failureThreshold × periodSeconds 是启动预算。 先测出应用最慢一次冷启动耗时(容器限 CPU 时最慢,别用开发机数据),再乘 1.5~2。上面 180 秒就是测出来最慢 95 秒定下的。超了就让容器重启,因为启动超过 3 分钟基本说明卡死了。
livenessProbe 要迟钝,readinessProbe 要灵敏。 这是最容易配反的一对。liveness 失败会重启容器,误判一次就是一次中断,所以要给足 failureThreshold(3 次 = 30 秒)。readiness 失败只是摘流量,代价小,可以敏感(2 次 = 10 秒)。反过来配——liveness 2 次、readiness 5 次——就会出现「数据库抖动两秒,整个 Pod 被重启」的惨剧。
preStop 的 sleep 不是凑数。 kubelet 在删除 Pod 时会并行做两件事:把 Pod 标记为 Terminating 并从 Endpoints 移除,以及执行 preStop。但 Endpoints 变更要经过 kube-proxy / ingress controller 同步到实际转发规则,这个延迟通常是 1~10 秒。如果不 sleep 直接退出,容器已经关了,转发规则还在把请求打过来,就会出现一批 502。sleep 8 就是在等这个同步窗口。
三、坑一:liveness 里查了下游依赖
这是杀伤力最大的一个。看这段配置:
livenessProbe:
httpGet:
path: /actuator/health # ← 注意,不是 /health/liveness
port: 8080
Spring Boot 的 /actuator/health 默认聚合所有 HealthIndicator,包括 DataSourceHealthIndicator、RedisHealthIndicator。于是链路变成:
MySQL 抖动 30 秒
→ /actuator/health 返回 503
→ liveness 连续 3 次失败
→ 所有 Pod 同时被重启
→ 重启中的 Pod 也要连 MySQL,连接风暴
→ MySQL 更扛不住
一个数据库抖动,被放大成了全站重启。 而且重启后应用还要跑一遍冷启动,恢复时间被拉长好几倍。
正确做法是在 application.yml 里把探针和业务健康检查分组:
management:
endpoint:
health:
probes:
enabled: true # 开启 /health/liveness 和 /health/readiness
group:
liveness:
include: livenessState,ping # 只查「进程还在不在」
readiness:
include: readinessState,db,redis # 这里才查依赖
health:
livenessstate:
enabled: true
readinessstate:
enabled: true
原则一句话:liveness 只回答「这个进程还能不能自救」,绝不要包含任何外部依赖。 依赖挂了应该摘流量(readiness),而不是重启一个无辜的进程。
如果确实有「必须重启才能恢复」的死锁场景,那也应该基于内部状态判断(比如工作线程池全阻塞超过 60 秒),而不是外部依赖。
四、坑二:readiness 太敏感导致流量抖动
把 readiness 配成 periodSeconds: 2, failureThreshold: 1 看起来很「灵敏」,实际会出问题:一次 GC 停顿 2.1 秒、一次慢查询、一次偶发超时,都会让 Pod 被摘掉几秒再挂回来。在 Service 层面,这会让连接池反复重建,客户端看到的是间歇性 connection reset。
更隐蔽的是滚动更新时的连锁反应。三个副本,其中一个因为偶发失败被摘掉,可用副本变两个;新 Pod 还没起来,此时如果只剩一个能接,负载压上去又可能触发新的 readiness 失败——雪崩就这么来的。
合理取值:periodSeconds: 5, failureThreshold: 2 或 3,也就是连续 10~15 秒不健康才摘。宁可多给 5 秒,也不要制造抖动。
五、坑三:探针超时比业务超时还长
timeoutSeconds 默认是 1 秒。这个默认值对 Java 应用很不友好——JVM 在 GC 或类加载时,一次 HTTP 响应超过 1 秒太正常了。
# 常见错误
readinessProbe:
httpGet: { path: /health, port: 8080 }
timeoutSeconds: 1 # 默认值,Java 应用在 JIT 预热期经常超
配成 1 秒 + failureThreshold: 1 + periodSeconds: 3,意味着只要有一次请求卡了 1.2 秒,Pod 立刻被摘流量。这就是「服务明明没事,但就是时不时 502」的经典成因。
timeoutSeconds 应该大于 P99 响应时间。 健康检查接口是极轻量的(应该只做内存态判断),正常应该在 5 毫秒内返回。如果它需要 1 秒,说明你的 /health 实现有问题——可能在里面查了数据库、读了配置中心、甚至调了第三方接口。健康检查接口的第一原则是:不能有 I/O。
六、坑四:exec 探针的成本被严重低估
livenessProbe:
exec:
command: ["cat", "/tmp/healthy"]
exec 探针看起来最灵活,实际有三个代价:
- 开销高。 每次探测都要在容器里 fork 一个进程。3 个探针 × 每 5 秒一次 × 40 个 Pod = 每秒 24 次 fork。CPU 限额紧的容器里,光这个就能吃掉可观的 CPU 时间。
- 依赖容器里有那个命令。 用 distroless 或 scratch 镜像时,容器里连
sh都没有,exec探针直接失败,Pod 永远起不来。这是从alpine换到distroless时最常见的翻车点。 - 失败原因难查。
httpGet失败时kubectl describe会给出 HTTP 状态码,exec只给一个 exit code。
除非要检查的是「进程内部状态」(比如 pgrep 确认某个 worker 还活着),否则一律用 httpGet 或 tcpSocket。用了 httpGet 还有一个附加好处:它走的是完整的 HTTP 栈,能顺带验证端口监听、路由注册、甚至 TLS 配置。
七、坑五:多端口应用配错了探测端口
一个容器里跑主服务(8080)和管理端口(9090,只监听 127.0.0.1)时,很容易写成:
livenessProbe:
httpGet: { path: /health, port: 9090 } # 管理端口只绑了 loopback
kubelet 的探测是从节点发起的,不是从容器内部。即使都在这台机器上,源地址也不是 127.0.0.1,所以绑定 loopback 的端口探测必然失败。
解决方式二选一:把管理端口绑到 0.0.0.0(靠 NetworkPolicy 或 SecurityGroup 限制访问),或者让探针走主端口的路径。
八、调试探针问题的固定套路
探针失败时不要瞎猜,按这个顺序查,基本三次之内能定位:
# 1. 看事件里探针的具体失败原因(HTTP 状态码 / 超时 / 连接拒绝)
kubectl describe pod api-7d9f-abc | sed -n '/Events/,$p'
# 2. 从节点侧验证:起一个临时 Pod,NetworkPolicy 相同的位置
kubectl run probe-test --rm -it --image=curlimages/curl --restart=Never -- \
curl -v http://<pod-ip>:8080/actuator/health/liveness
# 3. 进容器验证端口绑定地址(重点看是 0.0.0.0 还是 127.0.0.1)
kubectl exec -it api-7d9f-abc -- ss -lntp
# 4. 确认响应时间是否接近 timeoutSeconds
kubectl exec -it api-7d9f-abc -- \
sh -c 'for i in $(seq 1 20); do curl -s -o /dev/null -w "%{time_total}\n" localhost:8080/actuator/health/liveness; done'
第 4 步特别值得做。如果响应时间在 0.8~1.2 秒之间波动,那问题不是「服务挂了」,而是「超时阈值定得太紧」,改 timeoutSeconds: 3 就解决了,不需要动代码。
九、探针与优雅停机的配合
探针配好只解决了一半问题,另一半是终止流程。一个完整的 Pod 终止时序是这样的:
1. Pod 状态 -> Terminating,从 Endpoints 移除(异步)
2. 并行执行 preStop hook
3. preStop 结束后发 SIGTERM 给 PID 1
4. 等待 terminationGracePeriodSeconds
5. 超时则 SIGKILL
三个地方会出错:
应用的 server.shutdown 没设成 graceful。 Spring Boot 默认是 immediate,收到 SIGTERM 直接断连,正在处理中的请求全部 500。
server:
shutdown: graceful
spring:
lifecycle:
timeout-per-shutdown-phase: 20s
terminationGracePeriodSeconds 小于「preStop 耗时 + 应用优雅关闭耗时」。 上面 preStop sleep 8 秒 + 应用最多 20 秒关闭 = 28 秒,所以给 40 秒留余量。如果配成默认的 30 秒,偶尔会被 SIGKILL。
preStop 里调用 curl 但镜像里没有 curl。 这条和 exec 探针的坑是同一个根因。稳妥写法是用 sleep 兜底,或者在构建镜像时明确装好 curl/wget。
十、一张速查表
把上面的结论压缩成一张表,配探针时对着填:
| 项 | liveness | readiness | startup |
|---|---|---|---|
| 查什么 | 进程内部是否死锁 | 能否接流量(含依赖) | 启动是否完成 |
| 失败后果 | 重启 | 摘流量 | 重启 |
periodSeconds |
10 | 5 | 5 |
failureThreshold |
3 | 2 | 总预算/period |
timeoutSeconds |
3 | 3 | 3 |
| 能否含外部依赖 | 绝对不能 | 应该包含 | 不涉及 |
| 实现方式 | httpGet |
httpGet |
httpGet |
最后一句总结:探针不是「越敏感越好」,而是「让每种失败对应正确的动作」。 liveness 管进程,readiness 管流量,startup 管时间。三件事拆开配,80% 的探针事故就不会发生。
正在加载评论…