实战实验室:如何调试卡住的 Kubernetes 滚动更新
你更新了一个 Deployment,执行 kubectl rollout status 然后等待,结果命令超时了。但向 Service 发请求时,它依然有响应。那么更新到底完成了没有?如果没有完成,现在由哪些 Pod 在处理流量?
你将在一个一次性的 Kubernetes 实验环境中调查这个场景。从一个健康的应用出发,你会依次制造三种故障:镜像无法拉取、readiness 探针始终不通过、Pod 无法调度。每一步你都要找出被阻塞的版本、检查相关证据,并在继续下一步之前确认已恢复正常。
在记录的镜像故障实验中,该 Deployment 同时上报了以下两个 condition。下表来自它的 JSON 快照:
| Condition | 状态 | 原因 |
|---|---|---|
| Available | True | MinimumReplicasAvailable |
| Progressing | False | ProgressDeadlineExceeded |
这两个 condition 回答的是不同的问题。接下来的实验会说明:为什么更新卡住了,应用却仍然能响应请求。
目录
如何搭建实验环境
你需要熟悉容器、Deployment、ReplicaSet、Pod、Service 以及常用的 kubectl 命令。本实验使用 kind 在 Docker 容器内运行一个单节点的 Kubernetes 集群。
记录实验时的环境如下:
| 组件 | 版本或配置 |
|---|---|
| 宿主机 | macOS 15.7.4,Apple 芯片,16 GiB 内存 |
| Colima | 0.10.3,Docker 运行时,4 核 CPU,Docker 可见内存约 5.77 GiB |
| Docker | 客户端 29.4.3,服务端 29.2.1 |
| kind | 0.33.0 |
| kubectl | 1.36.0 |
| Kubernetes 服务端 | 1.37.0 |
配套脚本要求 macOS ARM64 环境,并预装 Colima 和 kind 0.33.0。我未在 Linux、Windows 及 Intel 机器上测试。文中提到的内存数值仅为测试机器配置,并非最低系统要求。
请确保已安装 Docker CLI、Colima、kind 和 kubectl,且 Colima 正在运行并能访问 Docker Hub。
在此下载配套代码。将其解压至新目录,并在终端中打开该目录。启动 Bash(我使用的 Shell),然后运行 setup:
bash
bash setup.sh
Setup 会创建 fcc-rollout-lab 集群和 rollout-lab 命名空间。它会将凭据写入 ./kubeconfig,通过不可变 ConfigMap 挂载应用程序源码,并创建 Deployment、Service 以及一个 http-client Pod。无需自定义镜像构建。
随后,它会验证 v1 和 v2,将快照及 HTTP 响应保存至证据归档中。若检测到已有的实验集群或本地 kubeconfig,Setup 将拒绝覆盖。节点镜像和 Python 镜像在源码中通过摘要(digest)锁定版本。
待 Setup 成功后,从配套目录定义此函数:
LAB_ROOT="$PWD"
k() {
kubectl --kubeconfig "$LAB_ROOT/kubeconfig" \
--context kind-fcc-rollout-lab \
--namespace rollout-lab "$@"
}
配套资料中还包含 failures.sh,用于自动复现各类案例和检查。请跟随下文命令进行手动演练;自动运行器是复现该流程的另一种方式。
其中,k 是一个 Bash 函数,用于运行已选定 kubeconfig、上下文和命名空间的 kubectl。例如,k get pods 可列出实验中的 Pods。在此 Bash 终端中运行上述定义一次,并在整个演练过程中持续使用该终端和配套目录。
如何建立健康的滚动更新
在动手操作之前,先确认发布路径和 Service 功能正常。初始设置仅修改 Pod 模板中的 APP_VERSION 环境变量,执行从 v1 到 v2 的更新。
该应用是一个轻量级 Python HTTP 服务器。GET / 返回配置版本和 Pod 名称,/ready 返回 HTTP 200,其他路径返回 HTTP 404。Pod 名称通过 downward API 从 metadata.name 获取。
以下是 app/server.py 中的路由逻辑:
if self.path == "/":
status = 200
payload = {
"version": os.environ["APP_VERSION"],
"pod": os.environ.get("POD_NAME", "local-test"),
}
elif self.path == "/ready":
status = 200
payload = {"ready": True}
else:
status = 404
payload = {"error": "unknown path"}
就绪探针检查 8080 端口的 /ready 接口。这些配置位于 manifests/deployment.yaml 中 Deployment 的 spec 部分。这是一段摘录,并非完整独立的 manifest:
replicas: 2
minReadySeconds: 5
progressDeadlineSeconds: 180
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
根据这些发布设置,控制器只能添加一个替换 Pod。它会等待新 Pod 可用后,再减少两个健康的旧副本。新 Pod 需保持 Ready 状态五秒才计为可用。进度截止时间为 180 秒。
maxUnavailable: 0 限制了此发布过程,但并不保护旧 Pod 免受无关故障影响。正在终止的 Pod 也可能在活跃副本数量之外保持可见。这些设置属于教学用途,不代表生产环境可用性保证。
录制的控制场景均成功完成,并返回了十个正确的 Service 响应。下表基于两个 Deployment 快照生成:
| 控制项 | 修订版本 | 已更新 | 就绪 | 可用 | 正确的 HTTP 响应 |
|---|---|---|---|---|---|
| v1 | 1 | 2 | 2 | 2 | 10/10 |
| v2 | 2 | 2 | 2 | 2 | 10/10 |
例如,某条录制的 v2 请求其解码后的响应体为:
{"version": "v2", "pod": "rollout-demo-5b4b45f68f-qrntz"}
你的 Pod 名称会有所不同。查看当前状态:
k get deployment rollout-demo -o json
k get pods -l app=rollout-demo -o wide
继续之前,请确保恰好剩下两个应用 Pod,且都已 Ready、没有正在终止的。Deployment 应该有两个 updated、ready 且 available 的副本,并且 status.observedGeneration 与 metadata.generation 一致。
后续所有故障实验都会保持 APP_VERSION=v2 不变,所以响应中的 Pod 名称就成了关键:单看版本号无法区分新旧 revision。
如何诊断 Image-Pull 失败
第一个故障实验只是把镜像引用改成了公共 Python 仓库中一个故意不存在的 tag:
k apply -f failure-lab/manifests/image.json
k rollout status deployment/rollout-demo --timeout=10s
在演示录像中,这个短时监控命令以退出码 1 结束,并报错:
error: timed out waiting for the condition
在本练习中这是预期结果。如果是认证、连接或其他错误,则需要另外的诊断思路。不要把所有非零退出码都当作预期结果。
找到被阻塞的 Revision
检查 Deployment 及其相关对象:
k get deployment rollout-demo
k get deployment rollout-demo -o json
k describe deployment rollout-demo
k get replicasets -l app=rollout-demo -o wide
k get pods -l app=rollout-demo -o wide
对比 metadata.generation 和 status.observedGeneration。如果后者更小,说明控制器还没有观察到当前的期望状态,此时应先复查,再判断 rollout 状态。
演示录像中的故障快照显示三个副本:一个 updated、两个 ready、两个 available。也就是说,看起来健康的 ready 数量可能完全来自上一个 revision。
kubectl describe deployment 能识别出 NewReplicaSet。接下来检查该 ReplicaSet 以及它名下未就绪的 Pod。把下面两条命令中的值替换成你输出中的实际名称:
NEW_RS='replace-with-the-new-replicaset-name'
BAD_POD='replace-with-its-unready-pod-name'
k get replicaset "$NEW_RS" -o json
k get pod "$BAD_POD" -o json
k describe pod "$BAD_POD"
在 ReplicaSet 的 metadata.ownerReferences 中,带有 controller: true 标记的条目必须指向该 Deployment 的 UID。在 Pod 的所有者引用中,控制器的 UID 必须与对应 ReplicaSet 的 UID 一致。新 ReplicaSet 的 template 与 Deployment 当前 template 基本一致,仅多了一个自动生成的 template-hash 标签,其 revision 注解也与 Deployment 的 revision 相匹配。
标签可以缩小排查范围,而 UID 所有权链条则明确了哪些对象属于同一组。有关底层关系的详细说明,请参见所有者引用文档。
解读错误信息
获取该特定 Pod 实例的事件:
POD_UID="$(
k get pod "$BAD_POD" -o jsonpath='{.metadata.uid}'
)"
k get events --field-selector "involvedObject.uid=$POD_UID" \
--sort-by=.metadata.creationTimestamp -o yaml
该镜像 Pod 已成功调度:PodScheduled=True,且 nodeName 已设置。容器处于等待状态,先出现 ErrImagePull,随后在重试间隔中变为 ImagePullBackOff。Pod 阶段仍保持为 Pending。仓库错误信息的末尾明确写着:
docker.io/library/python:fcc-rollout-missing-20260928: not found
此信息指明了缺失的镜像标签。镜像拉取错误也可能由仓库凭据、限流、DNS 或网络问题引起。务必诊断事件中的具体消息,不要想当然地认为所有 ImagePullBackOff 的成因都相同。
此外,要区分 kubectl 显示的 STATUS 与 Pod 的 phase。ImagePullBackOff 是容器等待原因,而 Pending 是 Pod 阶段。Pod 生命周期参考对此有明确解释。
检查哪些 Pod 在处理请求
检查该 Service 的 EndpointSlices,并从集群内部通过 Service 发送请求:
k get endpointslices -l kubernetes.io/service-name=rollout-demo -o json
k exec --pod-running-timeout=30s --request-timeout=60s http-client -- \
python -u /app/probe.py --url http://rollout-demo:8080/ \
--expected-version v2 --count 10 --interval 0.2
探针会检查状态和版本,并记录响应 Pod 的名称。将这些名称与上一个健康 ReplicaSet 的 Pod 名称进行比对,同时比对 EndpointSlice 中 targetRef.uid 的值及其对应的 UID。
在记录的运行过程中,全部 10 次响应都来自前一版本的两个 Pod。它们的 EndpointSlice 条目处于就绪状态。受阻镜像的 Pod 也出现在列表中,但其端点的 ready: false 和 serving: false 均为假。
新 Pod 无法进入可用状态,因此控制器保留了两个健康的旧副本。Service 采样结果证实这些旧 Pod 正在响应。这 10 次成功请求说明了采样期间发生的情况,但无法证明测量间隔之间服务未被中断。
如何区分两种超时
第一种超时源自你的终端。kubectl rollout status --timeout=10s 在客户端超时后停止等待。它并未取消更新或恢复之前的配置。命令参考中描述了该 watch 超时。
第二种超时属于 Deployment 控制器。等待其截止期限原因出现,然后检查完整的 conditions:
k wait --for=jsonpath='{.status.conditions[?(@.type=="Progressing")].reason}'=ProgressDeadlineExceeded \
deployment/rollout-demo --timeout=240s
k get deployment rollout-demo -o json
240 秒的超时下限约束了这次独立的等待。确认 Progressing 为 False,且原因为 ProgressDeadlineExceeded。kubectl wait 参考中记录了 JSONPath 等待机制。
在原始记录中,Progressing 条件的最后更新时间从 04:57:14Z 变为了 05:00:15Z:历时 181 秒,而配置的截止期限为 180 秒。控制器的计时并不承诺精确的实时时间间隔。
Available=True 得以保持,是因为旧 Pod 仍提供所需的可用性。Progressing=False 报告了停滞的更新。相反,Progressing=True 也可能在成功 rollout 后保持,因此务必阅读其 reason 及副本数量。
Deployment 控制器只会报告超时,不会自动回滚。在超时那一刻的快照里,期望镜像仍然是那个缺失的镜像。
现在再执行一遍 Service 检查。超时后记录的十个请求仍然全部来自旧 Pod。本教程中,只有镜像错误这种情况需要等待控制器超时。
如何恢复健康配置
在引入下一个故障之前,先执行恢复操作。把 BAD_POD 保持为刚才检查的那个故障 Pod:
k apply -f failure-lab/manifests/healthy.json
k rollout status deployment/rollout-demo --timeout=300s
k wait --for=delete "pod/$BAD_POD" --timeout=120s
k get deployment rollout-demo -o json
k get replicasets -l app=rollout-demo -o wide
k get pods -l app=rollout-demo -o wide
k get endpointslices -l kubernetes.io/service-name=rollout-demo -o json
k exec --pod-running-timeout=30s --request-timeout=60s http-client -- \
python -u /app/probe.py --url http://rollout-demo:8080/ \
--expected-version v2 --count 10 --interval 0.2
要检查整个恢复过程,而不是只看 rollout 命令是否成功。期望 spec 应与 healthy.json 一致,且控制器已感知到它,replicas、updated replicas、ready replicas 和 available replicas 都等于 2。
应恰好剩下两个应用 Pod,都归属于当前 ReplicaSet,处于 Ready 状态且未在终止中。它们的 UID 应与就绪的 Service endpoints 一致,HTTP 采样也应从这些 Pod 名称返回 v2。
在原始实验中,恢复过程复用了现有的健康 ReplicaSet 和同样的两个 Pod,因为它们的模板本来就与恢复后的配置一致。故障的 ReplicaSet 则被缩容到零。
即使那两个健康 Pod 保持不变,revision 注解仍然发生了变化。以下是实验记录中的数值:
| 场景 | 故障前 | 故障 revision | 恢复后 |
|---|---|---|---|
| 镜像 | 2 | 3 | 4 |
| Readiness | 4 | 5 | 6 |
| 调度 | 6 | 7 | 8 |
你的数值可能不同。请以恢复已知健康的 spec 为准,不要依赖这些示例 revision 编号。
如何诊断 Readiness 失败
确认恢复后,应用第二个故障:
k apply -f failure-lab/manifests/readiness.json
k rollout status deployment/rollout-demo --timeout=10s
此处仅修改了 readinessProbe.httpGet.path,将路径从 /ready 改为 /not-ready。短时间的监听再次超时。
重复之前镜像故障时的排查步骤。识别新的 ReplicaSet,将 NEW_RS 和 BAD_POD 重置为当前案例中的名称,并使用新的 Pod UID 查询 Events。这次容器正常启动,但 Pod 未处于 Ready 状态。
以下是从记录的 Pod 快照中提取的值:
| 字段 | 值 |
|---|---|
| Phase | Running |
| PodScheduled | True |
| Ready | False |
| Container restart count | 0 |
Pod 的 Event 消息解释了原因:
Readiness probe failed: HTTP probe failed with statuscode: 404
应用没有针对 /not-ready 的处理程序。HTTP 探针接受 200 到 399 之间的响应,因此 404 会导致就绪检查失败。就绪探针失败时,容器继续运行,但被标记为未就绪,且探针会持续工作。参见探针文档。
记录中还有简短的启动 connection refused Event。仅凭这些无法确定此故障,因为在健康服务器开始监听之前也可能出现这种情况。后续的 404 证明该服务器已响应请求。结合单一变更的字段和已知的路由,可以确定探针路径配置错误。
就绪检查失败并未重启容器。捕获到的重启次数为零。此案例并非崩溃循环。
重复 EndpointSlice 和 Service 检查。新 Pod 在记录的 EndpointSlice 中保持列出,其状态为 ready: false、serving: false 和 terminating: false。两个旧 Pod 具有就绪端点,所有十个响应均来自它们。
此 Service 未启用 publishNotReadyAddresses。端点就绪和终止策略的设置很关键,因此不要将该结论泛化到所有 Service 配置中。EndpointSlice 文档对这些条件及例外情况有详细说明。
作为相关背景,freeCodeCamp 的 Kubernetes 自愈机制教程也探讨了就绪探针。在这里,失败的探针解释了为什么新的滚动更新无法完成。
再次执行完整的恢复流程,将此就绪探针失败的 Pod 作为 BAD_POD。在继续之前,先确认 v2 服务健康。
如何诊断调度约束
第三种故障场景在 Pod 模板中添加了这个选择器:
nodeSelector:
rollout-lab.example.com/placement: no-matching-node
确认没有节点匹配该选择器后,应用配置:
k get nodes -l rollout-lab.example.com/placement=no-matching-node
k apply -f failure-lab/manifests/scheduling.json
k rollout status deployment/rollout-demo --timeout=10s
如果第一条命令列出了某个节点,说明预期的“无匹配节点”场景不成立。继续操作前,请先检查标签配置。
重复检查和事件验证步骤,更新此场景下的 NEW_RS、BAD_POD 和 POD_UID。新 Pod 状态为 Pending,与镜像故障场景相同。但 Pending 既涵盖等待调度,也涵盖等待容器初始化(包括镜像拉取)。
以下是记录的关键差异:
| 证据 | 镜像故障 | 调度故障 |
|---|---|---|
| PodScheduled | True | False,原因为 Unschedulable |
| nodeName | 已分配实验节点 | 不存在 |
| containerStatuses | 容器等待中 | 不存在 |
| 诊断事件 | 镜像拉取失败 | FailedScheduling |
调度事件内容如下:
0/1 nodes are available: 1 node(s) didn't match Pod's node affinity/selector. preemption: 0/1 nodes are available: 1 Preemption is not helpful for scheduling.
没有任何节点满足该选择器,因此 Pod 从未被分配给 kubelet,镜像拉取也就无从开始。正如节点选择文档所解释的,Kubernetes 要求节点必须匹配所有指定的 nodeSelector 标签。
重复执行 Service 检查。这个未调度的 Pod 没有 Pod IP,也没有 EndpointSlice 条目。所有十个记录在案的响应都来自旧 Pod。
再次执行完整的恢复流程,把这个处于调度中的 Pod 当作 BAD_POD。确认 v2 运行正常、Service 响应正确。本实验测试的是选择器不匹配,而非资源不足或其他调度失败场景。
如何清理
保存好证据后,删除这个一次性实验环境:
bash cleanup.sh
该脚本会切换到 Colima 的 Docker context 并删除 fcc-rollout-lab,同时显式传入实验环境的 kubeconfig。它会记录执行结果,并确认其他 kind 集群名称未受影响,以此移除实验负载。
证据归档仍保留在本地配套目录中。若想重新运行本教程,请在清理后重新解压一份;setup 会故意拒绝复用旧的 kubeconfig。
结语
三个不同的故障都让新版本卡住了,而旧版本仍在继续响应记录的请求:
| 案例 | 卡在哪一步 | 最有力的证据 | 故障状态下的成功采样 |
|---|---|---|---|
| 镜像 | 容器镜像拉取 | Pod 已调度;缺失标签错误 | 两个批次共 20/20 |
| 就绪 | Readiness 检查 | Pod 运行中;HTTP 404;Ready=False | 10/10 |
| 调度 | 节点选择 | PodScheduled=False;选择器不匹配 | 10/10 |
下次遇到卡住的滚动更新时,沿着从 Deployment 到 ReplicaSet 再到 Pod 的归属链排查:Pod 是否已调度、容器是否启动、是否达到 Ready 状态。利用 Pod 的 conditions 和 Events 定位故障点,再用 Service 响应确认到底是哪些 Pod 在实际提供服务。
最后,要区分客户端 watch 超时和控制器进展截止时间,并在下结论前验证完整的恢复流程。这些结果来自单节点本地实验环境和有限的 HTTP 采样,无法证明生产环境的行为或持续可用性。