Skip to content

Running 不等于可用 ​

滚动发布一个需要 10 秒预热的版本:kubectl get pods 里 3 个新 Pod 都是 Running、1/1,rollout status 3.5 秒就报告成功,而这期间通过 Service 发出的请求有 456 次被拒绝。Kubernetes 并没有出错,它只是不知道「进程起来了」和「能处理请求了」之间还隔着 10 秒。

Running 说的是容器进程在运行,READY 列的 1/1 说的是 kubelet 认为这个容器已经就绪。没有配置就绪探针时,kubelet 直接把容器当作就绪;Service 按就绪状态决定把流量发给谁,Deployment 按就绪状态决定滚动发布能不能继续。探针配错,影响的就不只是一个 Pod,而是发布节奏和整条流量路径。

本文在 kind 搭建的 Kubernetes 1.36.4 单节点集群里,用一个可配置预热时间、配置检查和下游依赖的小服务,分别验证:没有就绪探针的滚动发布、就绪探针与 preStop、新版本永远不就绪时的发布卡住与回滚、探针检查下游依赖时的故障放大。压测客户端在集群内每 20ms 通过 Service 发一个新连接请求,统计成功与失败。实验见文末。

一、三种「状态」各由谁决定 ​

看到的状态由谁决定含义
Pod STATUS 为 Running容器运行时至少一个容器的进程在运行
READY 为 1/1kubelet 执行就绪探针容器可以接收流量;没有就绪探针时,默认就是就绪
Deployment 可用副本数Deployment 控制器就绪并持续 minReadySeconds(默认 0)的副本
Service 端点EndpointSlice 控制器只包含就绪的 Pod

后三者都建立在「就绪」上。所以就绪探针回答的问题是:这个 Pod 现在能不能处理请求。它直接决定 Service 把请求发给谁,以及滚动发布什么时候可以删掉下一个旧 Pod。

二、没有就绪探针:发布越快,中断越彻底 ​

3 个副本,默认的滚动策略是 maxSurge: 25%、maxUnavailable: 25%。副本数乘以百分比后,maxSurge 向上取整为 1,maxUnavailable 向下取整为 0,也就是「最多多出 1 个 Pod,任何时候都不能少于 3 个可用 Pod」。看起来是最稳妥的组合。

但「可用」要看就绪状态。新版本启动需要 10 秒预热才开始监听端口,而没有就绪探针时,新 Pod 一启动就被当作就绪:

  • 发布 3 秒后,3 个 v2 Pod 都是 Running、ready 为 true,日志最后一行还是 v2 starting, warm-up 10.0s;
  • 控制器认为新 Pod 已经可用,于是立即删除旧 Pod,再创建下一个新 Pod,3 个旧 Pod 在 1 秒左右全部被替换,rollout status 3.5 秒返回成功;
  • 压测客户端 2,119 次请求中 456 次失败,全部是连接被拒绝——Service 把请求转发给了还没监听端口的 Pod。
没有就绪探针:rollout 3.5 秒就返回成功Pod 1Pod 2Pod 3新 Pod Ready 但未监听:请求被拒绝有就绪探针:逐个就绪、逐个替换,33.7 秒Pod 1Pod 2Pod 30s10s20s30s40s旧版本(在 Service 中)新版本预热中新版本可以处理请求
图 1 · 同一个需要 10 秒预热的版本做滚动发布。没有就绪探针时,新 Pod 一启动就被算作可用,3 个旧 Pod 在 1 秒内全部被替换,接下来约 9 秒请求全部被拒绝;有就绪探针时,每个新 Pod 预热完成、就绪之后才替换一个旧 Pod,整个发布约 34 秒,没有失败请求

maxUnavailable: 0 没有起作用,因为它限制的是「不可用」的 Pod 数,而没有就绪探针时每个 Pod 从一开始就是可用的。滚动更新的所有参数都以就绪为前提。

三、就绪探针与 preStop ​

给同一个版本加上就绪探针(每秒检查一次 /ready),其余不变:

  • 发布 3 秒后,只有 1 个 v2 Pod,状态是 Running,ready 为 false——Running 与 1/1 在这里分开了;
  • 每个新 Pod 预热完成、探针通过之后,控制器才删除一个旧 Pod,再创建下一个新 Pod。整个发布用了 33.7 秒,是没有探针时的 10 倍;
  • 2,856 次请求,0 次失败。

发布变慢是正确的代价:发布速度本来就应该受新版本真正可用的速度约束。

旧 Pod 的退出也有一个时间窗口。删除 Pod 时,kubelet 开始关闭容器,与此同时控制面把它从 EndpointSlice 中摘除;各节点上的 kube-proxy 看到变化并更新转发规则还需要一点时间。如果进程收到 SIGTERM 立即退出,这段时间里发往它的请求就会失败。上面的实验给容器加了 preStop 等待 3 秒(Kubernetes 1.36 可以直接写 lifecycle.preStop.sleep,不需要镜像里有 sleep 命令),先让摘除生效,再发 SIGTERM。去掉 preStop 再发布一次,2,708 次请求中出现 4 次失败(3 次超时、1 次连接被拒绝)。这个实验只有一个节点,转发规则更新很快;节点越多,摘除传播到所有节点所需的时间越长。

preStop 和进程自己的处理加在一起不能超过 terminationGracePeriodSeconds(默认 30 秒),超时后容器会被 SIGKILL。另外,进程必须真的处理 SIGTERM:第一版实验里服务作为容器的 1 号进程没有注册 SIGTERM 处理函数,信号被忽略,旧 Pod 每次都要等满 30 秒被强杀,原因见容器不是轻量虚拟机。

四、新版本永远不就绪:卡住、标记失败,但不会自动回滚 ​

新版本依赖一个新配置项,而发布时忘了加。它能启动,但就绪检查返回 503。策略设为 maxSurge: 1、maxUnavailable: 0,progressDeadlineSeconds: 20:

  • 新 Pod 一直是 Running、0/1,事件里反复出现 Readiness probe failed: HTTP probe failed with statuscode: 503;
  • 3 个旧 Pod 全部保留,Service 仍有 3 个端点,压测 2,367 次请求 0 次失败,全部由旧版本处理;
  • 超过 20 秒没有进展,rollout status 以 exceeded its progress deadline 失败退出,Deployment 的 Progressing 条件变为 False,原因是 ProgressDeadlineExceeded;
  • Deployment 不会自动回滚,新 Pod 会一直留在那里。执行 kubectl rollout undo 后,3.5 秒内新 Pod 被删除,回到 3 个旧版本 Pod。

把策略换成 maxSurge: 0、maxUnavailable: 1,同样的坏版本会先删掉一个旧 Pod 再创建新 Pod,卡住期间 Service 只剩 2 个端点:发布失败时的容量损失,正好是 maxUnavailable 允许的数量。

这说明两件事。第一,就绪探针把「坏版本」挡在了流量之外,但发布是否成功要由发布流水线判断:kubectl rollout status 的退出码、或者 Deployment 的 Progressing 条件,都可以作为流水线的失败信号,再由流水线决定回滚。progressDeadlineSeconds 默认 600 秒,按实际启动时间调小,失败才会被及时发现。第二,rollout undo 会让集群状态与 Git 里的清单不一致,kubectl 也会提示 last-applied-configuration 注解不会被更新。用声明式方式管理的集群,回滚应该是重新应用上一个版本的清单。

至于数据结构变更导致旧版本也跑不起来的情况,回滚就不只是 Kubernetes 的问题了,见能重新部署旧版本,不等于能回滚。

五、探针检查下游依赖:一个依赖故障,所有副本一起下线 ​

最后一个场景:3 个副本共用一个下游服务。比较两种探针配置,都带启动探针(启动探针通过之前不执行存活和就绪探针):

配置就绪探针存活探针
探针检查依赖/ready-with-dep:配置正确且下游可达同左
探针只检查自身/ready:配置正确/healthz:进程能响应

停掉下游 30 秒,再恢复:

探针检查依赖探针只检查自身下游依赖停止3 个副本共用下游依赖停止3 个副本共用就绪探针失败端点 3 → 0存活探针失败每个 Pod 重启 2 次请求到不了 Pod连接被拒绝或超时依赖恢复后8.8 秒才重新就绪就绪、存活探针都通过端点保持 3 个,不重启请求快速返回 503依赖恢复后1.0 秒内恢复官方文档建议存活探针只反映进程自身;依赖由所有副本共用时,就绪探针检查它等于让整个服务一起下线
图 2 · 3 个副本共用的下游依赖停止 30 秒。探针也检查这个依赖时,所有 Pod 同时不就绪、被摘出 Service,请求连 Pod 都到不了;存活探针还把每个 Pod 重启了 2 次,依赖恢复后要等重启和预热,8.8 秒才重新就绪。探针只检查自身时,Pod 保留在 Service 里快速返回 503,依赖恢复后 1.0 秒内恢复
依赖停止 30 秒后的端点数各 Pod 重启次数请求结果依赖恢复后到 3 个端点就绪
探针检查依赖02先是 503,端点被摘光后变成连接被拒绝与超时8.8 秒
探针只检查自身30全部是快速返回的 5031.0 秒

依赖对所有副本都一样,探针检查它的结果也就对所有副本都一样:要么全部就绪,要么全部不就绪。全部不就绪时 Service 没有端点,调用方拿到的是连接被拒绝或超时,而不是一个能说明原因的 503;存活探针失败还会让 kubelet 反复重启本来正常的进程,依赖恢复之后,每个 Pod 要重新走一遍启动和预热。

官方文档对存活探针的要求很明确:只在应用自身出问题时失败,错误的存活探针会造成级联故障。对就绪探针,文档的建议是应用强依赖后端服务时,可以让它额外检查后端是否可用。这个建议要结合依赖的范围来看:如果只是部分实例连不上(比如某个 Pod 的连接池坏了),让这些实例不就绪,流量会转到正常的实例上;如果依赖由所有副本共用,所有实例会同时不就绪,结果就是上表的第一行。对共用的依赖,更稳妥的做法是在应用里处理:快速失败或降级,返回明确的错误码,由调用方的超时和重试策略决定下一步,见超时的分层。

六、上线前检查 ​

  1. 每个接收流量的容器都配置就绪探针,检查的是「这个实例现在能不能处理请求」;预热、加载配置、建立连接池都应该在它通过之前完成。
  2. 启动慢的应用用启动探针,而不是把存活探针的 initialDelaySeconds 调得很大。
  3. 存活探针只检查进程自身;不要让它依赖数据库、下游服务等共享依赖。
  4. 进程要处理 SIGTERM,并用 preStop 给流量摘除留出时间;两者加起来小于 terminationGracePeriodSeconds。
  5. 按业务可接受的容量损失设置 maxUnavailable;需要满容量发布时设为 0,并确认集群有多出一个 Pod 的余量。
  6. 发布流水线以 rollout status 或 Progressing 条件判断成败,按实际启动时间设置 progressDeadlineSeconds,失败后回滚到上一个版本的清单。

配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权