Running 不等于可用
滚动发布一个需要 10 秒预热的版本:
kubectl get pods里 3 个新 Pod 都是Running、1/1,rollout status3.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/1 | kubelet 执行就绪探针 | 容器可以接收流量;没有就绪探针时,默认就是就绪 |
| 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 status3.5 秒返回成功; - 压测客户端 2,119 次请求中 456 次失败,全部是连接被拒绝——Service 把请求转发给了还没监听端口的 Pod。
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 秒,再恢复:
| 依赖停止 30 秒后的端点数 | 各 Pod 重启次数 | 请求结果 | 依赖恢复后到 3 个端点就绪 | |
|---|---|---|---|---|
| 探针检查依赖 | 0 | 2 | 先是 503,端点被摘光后变成连接被拒绝与超时 | 8.8 秒 |
| 探针只检查自身 | 3 | 0 | 全部是快速返回的 503 | 1.0 秒 |
依赖对所有副本都一样,探针检查它的结果也就对所有副本都一样:要么全部就绪,要么全部不就绪。全部不就绪时 Service 没有端点,调用方拿到的是连接被拒绝或超时,而不是一个能说明原因的 503;存活探针失败还会让 kubelet 反复重启本来正常的进程,依赖恢复之后,每个 Pod 要重新走一遍启动和预热。
官方文档对存活探针的要求很明确:只在应用自身出问题时失败,错误的存活探针会造成级联故障。对就绪探针,文档的建议是应用强依赖后端服务时,可以让它额外检查后端是否可用。这个建议要结合依赖的范围来看:如果只是部分实例连不上(比如某个 Pod 的连接池坏了),让这些实例不就绪,流量会转到正常的实例上;如果依赖由所有副本共用,所有实例会同时不就绪,结果就是上表的第一行。对共用的依赖,更稳妥的做法是在应用里处理:快速失败或降级,返回明确的错误码,由调用方的超时和重试策略决定下一步,见超时的分层。
六、上线前检查
- 每个接收流量的容器都配置就绪探针,检查的是「这个实例现在能不能处理请求」;预热、加载配置、建立连接池都应该在它通过之前完成。
- 启动慢的应用用启动探针,而不是把存活探针的
initialDelaySeconds调得很大。 - 存活探针只检查进程自身;不要让它依赖数据库、下游服务等共享依赖。
- 进程要处理 SIGTERM,并用
preStop给流量摘除留出时间;两者加起来小于terminationGracePeriodSeconds。 - 按业务可接受的容量损失设置
maxUnavailable;需要满容量发布时设为 0,并确认集群有多出一个 Pod 的余量。 - 发布流水线以
rollout status或Progressing条件判断成败,按实际启动时间设置progressDeadlineSeconds,失败后回滚到上一个版本的清单。
配套实验
- codesphere-labs/kubernetes/pod-readiness-rollout:kind 集群(Kubernetes 1.36.4)中,有无就绪探针的滚动发布、去掉 preStop 的对照、坏版本卡住与
rollout undo、两种maxUnavailable的容量、探针检查依赖与只检查自身的对比(验证记录)
参考资料