一看到这个标题我就想起上周刚处理完的一起升级事故。Rancher Prime v2.13.1 的多集群环境里我们用 system-upgrade-controller 下发了一批节点升级任务前两批节点顺顺利利跑完第三批突然全部卡死Pod 堆在 ImagePullBackOff日志里反复出现同一个 kubectl 镜像地址。折腾了一下午才确认问题根本不在这批节点上而是升级计划Plan里引用的那个 kubectl 图像——也就是 kubectl 镜像——指向了一个已被回收的错误标签。这篇文章会把这起故障的完整排查链路、修复命令以及我踩过之后总结的避坑清单一次性讲清楚。无论你是刚接手 Rancher 的中级运维还是被升级计划折磨过几轮的平台组老人以下内容都能直接落地。1. 故障现场与问题定位1.1 升级计划卡在第三批节点先说背景。这套环境是某内部平台纳管的业务集群一共 150 个节点通过 Rancher Prime v2.13.1 统一管理。当时要做一次 Kubernetes 小版本升级从 1.28.16 升到 1.29.9。升级计划的创建方式很常规在 Rancher 界面里按模板生成交给 system-upgrade-controller 分批滚动执行concurrency 设置成 5也就是一批 5 个节点同时升级。前两批一共 10 个节点状态都很漂亮节点被 cordon、Pod 被 drain、升级容器跑完、uncordon、节点恢复 Ready。但走到第三批时情况突然不对了。Rancher 界面里升级计划的状态一直不是Completed而是停在 False对应批次的升级 Job 全部处于 ContainerCreating 状态。我用命令查了一下 Pod 状态现象非常统一kubectl -n cattle-system get pods -l upgrade.cattle.io/plank8s-1-29-upgrade输出里凡是第三批节点的 Pod全部卡在 ImagePullBackOff。再往下看这批 Job 的 Pod 事件里都在报拉取镜像失败而且错的是同一个镜像地址。这种情况最迷惑人的地方在于前两批是好的控制器也没报什么致命错误就是 Pod 起不来。很多人第一反应是“网络抖动”“镜像仓库挂了”但实际上节点和镜像仓库之间的连通性完全正常。问题出在“要拉的那个东西不存在”。1.2 从 Pod 事件中挖出错误镜像定位过程其实不复杂Describe 一个失败的 Pod 就能看到真相kubectl -n cattle-system describe pod system-upgrade-k8s-1-29-upgrade-xxxxxEvents 区域里反复出现的核心事件是Failed to pull image registry.internal/base/kubectl:v1.29.9: ... manifest unknown: The named manifest is not known这里的registry.internal/base/kubectl:v1.29.9就是升级计划里给 prepare 阶段和 drain 阶段指定的 kubectl 镜像。注意错误信息里说的是“manifest unknown”不是网络超时也不是认证失败而是这个 tag 在内部镜像仓库里已经不存在了。所谓“错误的 kubectl 图像”翻译成大白话就是这个意思升级计划在执行时需要调用 kubectl 来做节点排空之类的操作而它引用的那个 kubectl 镜像要么地址写错了要么 tag 被回收了要么架构不匹配反正不是运行环境真正需要的那一个。在 v2.13.1 里这个错误直接导致对应批次的升级 Job 无法创建容器升级流程卡死后面的批次全部排队等待。1.3 镜像为什么会在升级当口消失确认了是镜像 tag 不存在之后很多人的第一反应是“那把这个 tag 补回来不就行了”。但复盘时我更关心的是“它为什么会消失”。这套环境走的是私有化离线架构所有容器镜像都从源头仓库同步到内部镜像仓库。安装 Rancher Prime v2.13.1 时平台同事在 Helm values 里指定了自定义镜像仓库地址升级计划是从旧环境复制过来的模板生成的模板里 prepare/drain 阶段的 kubectl 镜像还停留在上一轮升级用过的旧 tag也就是registry.internal/base/kubectl:v1.29.9。问题就出在内部镜像仓库的 GC 清理策略上。离线仓库会周期性清理没有任何运行记录、也没有被近期清单引用的旧 tag。由于这个 tag 在上一轮升级之后就没有再被拉取过GC 判断它是死数据直接回收了。升级计划这边完全不知情等控制器再次调度到第三批节点时按照模板引用旧 tag自然就拉不到了。这一节想强调的结论是控制器本身没有判断镜像是否有效的能力它只是忠实地执行 Plan 里写好的配置。所以在 Rancher Prime v2.13.1 这类环境里升级计划里的镜像引用一旦过期故障是必然的只是早晚问题。2. 根因拆解升级控制器为什么会引用一个错误镜像2.1 系统升级控制器的工作流程要理解“错误的 kubectl 镜像”为什么能卡住整个升级得先明白 system-upgrade-controller 到底在干什么。它的角色相当于“集群节点升级调度员”。你给它一个升级计划Plan它负责把节点分批处理每批节点执行一套固定动作先给节点打上 cordon 标记再把节点上的工作负载排空然后在节点上运行升级容器完成系统或 Kubernetes 版本切换最后解除 cordon 让节点恢复调度。这一套动作里排空节点这个动作必须通过 kubectl 来完成。控制器本身不会自己去调 Kubernetes API 操作节点它更安全的做法是启动一个临时容器容器里内置一个 kubectl 客户端用这个客户端向 apiserver 发出 drain 命令。此外升级正式开始之前还会有一个 prepare 阶段也经常使用 kubectl 镜像用来确认客户端版本、检查节点状态等。所以在一个 Plan 的配置里kubectl 镜像会出现在两个关键位置一个是 prepare 阶段一个是 drain 阶段。下面是简化后的 Plan YAML方便你把这两个位置看清apiVersion: upgrade.cattle.io/v1 kind: Plan metadata: name: k8s-1-29-upgrade namespace: cattle-system spec: concurrency: 5 nodeSelector: matchLabels: kubernetes.io/os: linux prepare: image: registry.internal/base/kubectl:v1.29.9 args: [version, --client] cordon: true drain: image: registry.internal/base/kubectl:v1.29.9 args: [drain, --force, --grace-period120, --ignore-daemonsets] upgrade: image: registry.internal/base/system-upgrade-controller:v1.29.9 args: [upgrade, --channel, v1.29.9]控制器调度到某个节点时会先动创建 prepare 对应的 Job然后再创建 drain 对应的 Job。任何一个 Job 的镜像拉不下来后续动作直接中断。所以 kubelet 在拉取镜像时发现registry.internal/base/kubectl:v1.29.9不存在立即返回 ImagePullBackOff整个升级流程就像被一根钉子钉住一样动弹不得。2.2 三类典型的“错误 kubectl 镜像”在实际环境里我盘点了一下升控制器引用错误 kubectl 镜像基本逃不出下面三类。这不是理论推演是我在多个集群里踩出来的经验。故障表现典型错误关键字根因Pod 停在 ImagePullBackOffmanifest unknown、not found镜像 tag 不存在通常是被仓库 GC 回收或源头未同步Pod 拉下来后 CrashLoopBackOffexec format error镜像架构与节点架构不匹配比如只有 amd64但目标节点是 arm64Pod 能启动但升级卡住不动the server could not find the requested resource、version mismatchkubectl 镜像版本与目标集群版本跨度太大连接 apiserver 失败这张表里第一种最隐蔽因为你不拉一次镜像根本发现不了 tag 已经没了第二种在混合架构集群里很常见镜像拉到节点上能下载但无法执行第三种也容易忽略kubectl 镜像如果明显旧于集群版本Drain 过程中一些新 API 资源就无法处理。值得注意的是这三种情况在 Rancher Prime v2.13.1 里都会表现为“升级计划状态异常”而不会直接提示“kubectl 镜像配置错误”。所以你排查时如果只盯着控制器状态看很可能绕弯路应该直接去看 Job 对应 Pod 的事件和日志。2.3 这次事故的根因链条我们对这次事故的做法是剖到最底层问一句“控制器为什么会引用一个错误镜像”而不是停在“这个 tag 拉不到”。分析下来根因链条是四层第一层镜像的使用方是升级计划模板它来自旧环境复制里面携带了上一轮升级的硬编码 tag。第二层内部镜像仓库的 GC 策略把所有长期未被引用的 tag 回收这是仓库侧的正常维护动作但它并不知道升级计划还在引用这些 tag。第三层系统升级控制器作为纯执行者没有任何“镜像是否存在”的预检机制拿着模板里的地址就去创建工作负载。第四层Rancher Prime v2.13.1 环境下平台侧也没有为升级计划维护一份镜像引用台账导致问题在升级前没有被发现。这四个环节任何一个能兜住本次故障就不会发生。最讽刺的是控制器其实在升级流程里做得非常规范每一批节点都严格按照计划执行但规范执行一个错误的配置结局比不执行还要难缠因为你会下意识觉得是执行环境出了问题。2.4 为什么 v2.13.1 更容易撞上这个坑这一段基于我自己的环境观察不一定适用于所有 v2.13.1 部署但很有代表性。升级到 v2.13.1 之后控制器本身的行为更严格了具体表现在它创建 Job 时对镜像地址的解析方式有变化如果你用的是私有化仓库必须把 Plan 里所有阶段的镜像都改成内部地址而不是只改 upgrade 镜像。很多从旧版本升上来的环境Plan 模板是从老集群复制过来的老模板里 prepare 和 drain 阶段写的是公共仓库镜像或者上一轮升级的旧地址。新控制器部署完成后第一轮升级往往能跑通因为公共仓库的 tag 还在但一旦你切到内部镜像仓库、或者仓库做了 GC这类遗留配置就会瞬间暴露。说白了v2.13.1 只是让错误镜像从“潜在隐患”变成了“必然故障”。3. 修复实操改镜像、清残留、恢复升级3.1 先确认正确的镜像地址动手修复前第一件事不是改 YAML而是先搞清楚“正确的镜像地址到底是什么”。在私有化环境里这一步尤其重要因为正确的地址不一定是公共仓库里的那个 tag而应该是内部仓库里真实存在、且与目标 Kubernetes 版本匹配的那个 tag。我当时用 skopeo 直接检查内部仓库里这个 tag 是否真实存在skopeo inspect docker://registry.internal/base/kubectl:v1.29.9命令返回 404确认了这个 tag 已经从仓库消失。接着我用同样的方式检查了相邻版本skopeo inspect docker://registry.internal/base/kubectl:v1.29.12这个 tag 是存在的。于是修复思路就明确了要么重新把v1.29.9从源头同步到内部仓库要么把 Plan 里的 kubectl 镜像统一改成已存在的v1.29.12。我选择了后者因为重新同步一个已经被 GC 判定为死数据的 tag属于给错误配置续命下次 GC 还是会删改成真实存在且版本更接近的 tag才是断根。如果你所在的环境没有 skopeo也可以用 crane 的crane manifest命令或者干脆用 Docker 直接拉一下docker pull registry.internal/base/kubectl:v1.29.12拉取成功说明 tag 有效拉取失败则说明不可用。这一步的产出物是一个明确结论正确的新 kubectl 镜像地址是什么。3.2 修正 Plan 中的 kubectl 镜像确认了正确地址后再动手编辑升级计划。我这里用的是直接修改运行中的 Plankubectl -n cattle-system edit plan k8s-1-29-upgrade在编辑态里找到 prepare 和 drain 两个位置把里面的 image 字段从registry.internal/base/kubectl:v1.29.9改成registry.internal/base/kubectl:v1.29.12。如果 upgrade 阶段也用到了同一个错误 tag同样要改。保存后用下面的命令确认所有镜像引用已经被替换kubectl -n cattle-system get plan k8s-1-29-upgrade -o yaml | grep image这一步操作本身不难真正容易踩坑的是后面。系统升级控制器不会因为你修改了 Plan 就去重建已经创建的 Job已经卡住的 Job 还在用旧的镜像地址因为 Job 内部的镜像地址在创建那一刻就被固定住了。所以如果你只修改 Plan不清理残留 Job你会发现升级计划纹丝不动还是卡在原来的位置。3.3 清理失败残留并重新触发正确的做法是先把失败的 Job 清掉让控制器重新进入调度逻辑。先找到这批残留 Jobkubectl -n cattle-system get jobs -l upgrade.cattle.io/plank8s-1-29-upgrade然后删除处于失败状态的 Job。我在那次操作里直接删除了这批问题 Jobkubectl -n cattle-system delete job job-name删除 Job 之后还有一个关键动作把失败节点上控制器用来记录状态的标签清理掉。在 system-upgrade-controller 的实际运行机制里控制器会在节点上打一个与当前计划关联的标签用来判断“这个节点是否已经处理过”。节点标签还在的话控制器会认为这个节点已经参与过升级不会再为它重新创建 Job。所以要把故障节点的状态标签删掉强制控制器重新处理。先查看节点上的相关标签kubectl get node node-name --show-labels | grep upgrade然后删除计划状态标签。这里说明一下具体标签名会因升级计划名而变化通常格式类似于upgrade.cattle.io/计划名删除时在标签名后面加一个减号即可。例如kubectl label node node-name upgrade.cattle.io/k8s-1-29-upgrade-执行完成后观察控制器日志kubectl -n cattle-system logs -f deploy/system-upgrade-controller正常情况下控制器会意识到有节点等待处理用新的 Plan 配置重新创建 Job新的 Job 会使用修正后的 kubectl 镜像容器正常启动升级流程继续往后走。3.4 升级恢复后的验证动作修复不能以“Pod 起来了”作为结束还要做三件事验证升级真正恢复第一看升级计划状态。kubectl get plan -n cattle-system如果还是 False 不要慌因为还有后续批次没跑完关键看当前正在执行的状态是否在往前推进。第二看节点版本。kubectl get nodes -o wide里已经升级完成的节点应该显示v1.29.12或目标版本未处理节点还是旧版本但不会再报错。第三看节点的 Ready 状态。确认升级完成后节点没有被一直 cordon 住工作负载能正常调度回来。我还习惯在升级跑完一个完整批次后再去看一眼控制器日志确认没有第二个报错类型冒出来。实际体验是修复后的第一二批次往往看不出问题因为错误镜像的暴露是有触发条件的得让升级流程跑完一批新的节点才能确认整个链路的健康。4. 高频问题与避坑指南4.1 为什么改了 Plan 镜像Job 还是用旧镜像这是几乎所有第一次碰这个坑的人都会经历的问题。原因很简单Job 是控制器根据当时 Plan 的配置创建出来的Job 的 spec 在创建那一刻就被写死了。你改的是 Plan不是 Job已经存在的 Job 不会感知后续变化。解决办法是删除旧 Job并且清理对应的节点标签让控制器重新创建 Job。如果你删了 Job 但没删节点标签控制器会认为这个节点已经处理过不会重建升级依旧不前进。这两步是连在一起的缺一不可。4.2 镜像拉下来了Pod 却报 exec format error这个问题在混合架构集群里特别常见。你以为镜像没问题实际上镜像的 manifest 是存在的但它的平台信息只支持 amd64目标节点是 arm64。Kubelet 能拉下来镜像启动容器时一执行二进制文件立刻报exec format error。遇到这种情况检查一下镜像的 manifest 列表crane manifest registry.internal/base/kubectl:v1.29.12 | jq查看platform.architecture字段是否包含目标节点的架构。如果包含说明是 multi-arch 镜像可以直接用如果只列了一个架构就要换镜像源或者按架构拆分升级计划用不同的 nodeSelector 处理不同架构的节点。4.3 离线环境里镜像同步遗漏怎么快速发现离线环境操作 Rancher 升级最大的痛点是镜像同步不完整。尤其是 kubectl 镜像它不像升级镜像那么显眼很容易在同步清单里被漏掉。快速检查的方式就是上面说的 skopeo 或 crane 逐个验证 tag 是否存在。但更彻底的做法是维护一张“升级依赖镜像清单”里面至少包含三类镜像升级主镜像、kubectl 镜像、以及任何作为 prepare 阶段引用的辅助镜像。每次规划升级之前用脚本把这几个镜像拉一遍确认无误再往上走。这个建议看着朴素但真的能救命。我碰到过太多回升级执行到一半发现某个辅助镜像没同步整个窗口期被拉长还搞得人仰马翻。4.4 升级之前一定要做的三道检查经过这次故障我把系统升级控制器的检查沉淀成一条强制流程任何 Rancher Prime 集群的升级前都要过三道第一道镜像存在性检查。用 skopeo 或 crane 验证升级计划引用的所有镜像 tag 在目标仓库里真实存在而不是“应该存在”。第二道架构兼容性检查。确认 kubectl 镜像的 manifest 列表覆盖目标节点架构混合架构环境宁可拆两个 Plan 也不要硬扛。第三道版本匹配检查。kubectl 镜像的客户端版本与目标 Kubernetes 集群版本的差距不宜过大我一般保持在同一个大版本内。除此之外还有一条硬规矩升级计划里绝对不能使用:latest标签来引用 kubectl 镜像。latest 一旦流动下一次升级时它指向的东西可能就不是你以为的东西了这类问题其实是技术债的一种形态早晚要还。写在最后踩过几次类似的坑之后我给团队立了一条规矩任何 Kubernetes 升级前置任务里必须有一条“镜像引用台账核查”的步骤。我自己的体会是这种故障的诡异之处不在于控制器坏了而在于它忠实地执行了一个已经过时的配置。越是在私有化环境越要把镜像仓库、tag 生命周期和升级计划当做一个整体来维护任何时候都不要假定“上一轮能用这一轮也能用”。最后分享一个小细节修完之后别急着删除所有失败 Pod先保存一份 describe 输出和事件记录后边写问题复盘的时候能省下大量重新查日志的时间。