← 文章 / 云原生与基础设施
freeCodeCamp 2小时前 · 2026-10-02 07:25:19 · 1 阅读

实战实验室:如何调试卡住的 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 采样,无法证明生产环境的行为或持续可用性。

原始来源: freeCodeCamp

评论 (0)