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 探针看起来最灵活,实际有三个代价:

  1. 开销高。 每次探测都要在容器里 fork 一个进程。3 个探针 × 每 5 秒一次 × 40 个 Pod = 每秒 24 次 fork。CPU 限额紧的容器里,光这个就能吃掉可观的 CPU 时间。
  2. 依赖容器里有那个命令。 用 distroless 或 scratch 镜像时,容器里连 sh 都没有,exec 探针直接失败,Pod 永远起不来。这是从 alpine 换到 distroless 时最常见的翻车点。
  3. 失败原因难查。 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% 的探针事故就不会发生。

查看「容器与编排」全部文章 →

同分类文章

查看 0 评论 0

评论

正在加载评论…

留言提交后自动审核:正常内容立即显示,含敏感信息的会转人工复核。