
节点已经显示新版本，是否就代表集群升级完成？如果 API Server 已经升级，kubelet 仍是旧版本，集群是否处在故障状态？升级后应用无法创建，问题又一定出在节点吗？

Kubernetes 的升级需要同时处理组件版本、API 和数据、扩展组件，以及工作负载的连续运行。版本号是检查入口，不能单独代表业务已经恢复。本文用 Kubernetes v1.35.1 到 v1.35.2 的隔离补丁升级观察这些层次，并用固定版本源码解释 kubeadm 实际修改了什么。

<!--more-->

## 升级顺序受通信关系约束

kubelet 不直接读取 etcd，而是通过 API Server 观察分配到本节点的 Pod，并回写节点和 Pod 状态。Scheduler、Controller Manager 和 kube-proxy 也通过 API Server 获取对象。组件升级过程中，这些通信仍然会发生，所以需要保证新旧版本之间的协议和行为处在支持范围内。

按 [Kubernetes v1.35 版本偏差策略](https://v1-35.docs.kubernetes.io/releases/version-skew-policy/)，多个 API Server 之间最多相差一个小版本；kubelet 和 kube-proxy 不应比 API Server 更新，允许的向后偏差最多为三个小版本。Scheduler 和 Controller Manager 应与所访问的 API Server 同小版本，升级窗口允许落后一个小版本。kubectl 则允许比服务端新或旧一个小版本。部署工具和插件可能施加更严格的限制。

这里的“小版本”指 `1.35` 中的 `35`，不是补丁号。允许向后偏差不等于建议长期保留旧节点，也不能据此跳过 API Server 的中间小版本。在 HA 集群中，组件可能连接负载均衡后的任意 API Server，因此应按照它可能访问到的全部实例取兼容范围的交集。

这会约束升级顺序：先让 API Server 进入能接纳后续组件的版本，再推进其他控制面组件和节点。实际使用 kubeadm 时，应遵循[对应目标版本的升级流程](https://v1-35.docs.kubernetes.io/docs/tasks/administer-cluster/kubeadm/kubeadm-upgrade/)，不能从这几条原则自行拼出一套与部署工具冲突的命令。本文的补丁升级不构成跨小版本升级已经验证的证据。

## kubeadm 检查的是集群与目标版本的组合

`kubeadm upgrade apply` 并不是简单替换一个镜像标签。在 v1.35.2 的 [`newCmdApply`](https://github.com/kubernetes/kubernetes/blob/v1.35.2/cmd/kubeadm/app/cmd/upgrade/apply.go#L131-L155) 中，命令通过工作流依次注册预检、控制面升级、集群配置上传、kubeconfig、kubelet 配置、插件和升级后处理阶段。前一阶段失败后，正常流程不会继续假定后续动作已经完成。

{{< github-file url="https://github.com/kubernetes/kubernetes/blob/v1.35.2/cmd/kubeadm/app/cmd/upgrade/apply.go#L131-L155" >}}

预检中的 [`runPreflight`](https://github.com/kubernetes/kubernetes/blob/v1.35.2/cmd/kubeadm/app/cmd/phases/upgrade/apply/preflight.go#L58-L96) 读取实际集群信息，检查健康状态并调用版本策略。版本检查的输入包括目标版本、当前集群版本、执行命令的 kubeadm 版本，以及节点报告的 kubelet 版本。

{{< github-file url="https://github.com/kubernetes/kubernetes/blob/v1.35.2/cmd/kubeadm/app/cmd/phases/upgrade/apply/preflight.go#L58-L96" >}}

关键分支位于 [`EnforceVersionPolicies`](https://github.com/kubernetes/kubernetes/blob/v1.35.2/cmd/kubeadm/app/phases/upgrade/policy.go#L47-L104)：

{{< github-file url="https://github.com/kubernetes/kubernetes/blob/v1.35.2/cmd/kubeadm/app/phases/upgrade/policy.go#L47-L104" >}}

该版本的 `MaximumAllowedMinorVersionUpgradeSkew` 为 `1`。策略把错误分为 `Mandatory` 和 `Skippable`；`enforceVersionPolicies` 遇到 Mandatory 错误会返回，`--force` 只影响允许跳过的那一类错误。它不会把任意跨版本组合变成受支持的升级路径。

{{< github-file url="https://github.com/kubernetes/kubernetes/blob/v1.35.2/cmd/kubeadm/app/cmd/phases/upgrade/apply/preflight.go#L138-L160" >}}

kubeadm 必须先具备理解目标版本配置的能力，因此升级流程需要先准备匹配的 kubeadm，再由它执行计划和变更。`upgrade plan` 是根据当前状态形成的计划；它不是对业务流量、外部 Webhook 或云盘插件的完整兼容性认证。

首个控制面节点执行 `kubeadm upgrade apply`，其余控制面和工作节点执行 `kubeadm upgrade node`，不能把首节点命令复制到全部机器。`newCmdNode` 注册本节点预检、控制面、kubeconfig、kubelet 配置和插件等阶段，再根据节点角色决定适用动作；它不会重新执行首节点的全部集群级升级流程。执行变更前还应准备可校验的 etcd 快照、控制面配置和证书备份，并为业务卷另行准备恢复点；能恢复某个 manifest 不代表能恢复这些数据。

{{< github-file url="https://github.com/kubernetes/kubernetes/blob/v1.35.2/cmd/kubeadm/app/cmd/upgrade/node.go#L115-L124" >}}

## 控制面升级靠 kubelet 落实静态 Pod 变化

在 kubeadm 管理的静态 Pod 控制面中，kubeadm 生成新 manifest，kubelet 观察节点上的 manifest 后，才通过运行时替换组件。API Server 的 Deployment 控制器不参与这些静态 Pod 的创建。

v1.35.2 的 [`upgradeComponent`](https://github.com/kubernetes/kubernetes/blob/v1.35.2/cmd/kubeadm/app/phases/upgrade/staticpods.go#L223-L273) 先比较新旧 manifest，相同时可跳过该组件。需要变更时，它记录旧 manifest 的备份位置，将旧文件移出正式目录，再移入新文件。

{{< github-file url="https://github.com/kubernetes/kubernetes/blob/v1.35.2/cmd/kubeadm/app/phases/upgrade/staticpods.go#L223-L273" >}}

代码随后等待 mirror Pod 的配置 hash 变化，并检查组件 Pod 的状态。这一步有具体原因：旧组件可能还在运行，如果只检查旧组件的健康端点，就可能把“旧进程仍然健康”误判成“新版本已经成功启动”。hash 变化让后续检查能够关联到这次配置替换。

```mermaid
sequenceDiagram
    participant O as 操作者
    participant A as kubeadm
    participant F as 节点 manifest 文件
    participant K as kubelet
    participant R as 容器运行时
    participant API as API Server
    O->>A: upgrade apply 目标版本
    A->>API: 读取集群状态并检查版本
    A->>F: 备份旧 manifest 并放入新 manifest
    K->>F: 观察静态 Pod 配置变化
    K->>R: 协调旧容器终止与新容器启动
    K->>API: 更新 mirror Pod 信息
    A->>API: 等待配置 hash 变化及组件就绪
    API-->>A: 返回观测状态
    A-->>O: 当前控制面升级结果
```

图中 API Server 是被访问的 API 服务；升级 API Server 自身时，它会经历进程切换，检查可能需要重试。图上没有把 mirror Pod 更新画成新进程已经服务所有业务请求的统一完成屏障。

发生部分文件移动、证书续期或等待失败时，源码会尝试通过 `rollbackOldManifests` 恢复已记录的 manifest。但这个恢复动作针对当前升级步骤，不能推导出任意已完成的升级都能通过更换旧二进制安全降级。对象数据、插件状态、证书和外部系统变更不属于同一个原子事务。

## 节点维护先解决工作负载去向

`cordon` 让节点不再接收普通的新调度；它不会把已有 Pod 搬走。`drain` 继续枚举节点上的 Pod，过滤不能按普通方式处理的对象，并尝试驱逐可处理的 Pod。控制器随后观察副本缺口，创建新的 Pod，再由调度器选择节点。

在 [`DeleteOrEvictPods`](https://github.com/kubernetes/kubernetes/blob/v1.35.1/staging/src/k8s.io/kubectl/pkg/drain/drain.go#L247-L269) 中，客户端优先发现并使用 Eviction API；`evictPods` 遇到预算导致的 TooManyRequests 会重试，直到成功或达到超时。等待删除时还比较 UID，避免把同名新 Pod 当作旧 Pod 一直等待。

{{< github-file url="https://github.com/kubernetes/kubernetes/blob/v1.35.1/staging/src/k8s.io/kubectl/pkg/drain/drain.go#L247-L269" >}}

所以，维护前需要确认剩余节点上有可用容量、存储可以满足重新调度、约束允许迁移，以及 PDB 有足够预算。只有 PDB 并不能创造替代副本，也不能消除卷的拓扑约束。预算为零时绕开 Eviction 直接删除 Pod，会改变保护语义，不应作为默认的“解除升级阻塞”手段。具体保护范围见已有的 [PDB 与驱逐文章](/posts/kubernetes-pdb-eviction/)。

DaemonSet、静态 Pod、无控制器的独立 Pod 和带本地数据的 Pod 需要分别处理。`--ignore-daemonsets` 允许 drain 继续处理其他对象，并不表示已经删除 DaemonSet Pod 或关闭该节点上的系统进程。

## 控制面完成后，节点版本可能仍然是旧值

kubeadm 升级控制面时可以更新 kubelet 的配置，却不会代替操作系统包管理器安装并重启所有节点的 kubelet。

这一点可以从 [`runKubeletConfigPhase`](https://github.com/kubernetes/kubernetes/blob/v1.35.2/cmd/kubeadm/app/cmd/phases/upgrade/kubeletconfig.go#L55-L77) 看清：核心操作是 `WriteKubeletConfigFiles`，将配置写入节点目录，并处理对应节点信息。这不是下载一个新 kubelet 可执行文件再启动它。

{{< github-file url="https://github.com/kubernetes/kubernetes/blob/v1.35.2/cmd/kubeadm/app/cmd/phases/upgrade/kubeletconfig.go#L55-L77" >}}

因此，需要分别核对 API Server 版本、节点报告的 kubelet 版本、kube-proxy 镜像和插件状态。`kubectl get nodes` 中的 VERSION 主要反映 kubelet，不能当作 API Server 已升级或未升级的唯一证据。包升级和进程重启后，还需要等待节点 Ready，再验证该节点真正能够创建工作负载。

## API 兼容不是改一个 apiVersion

API 移除意味着旧端点不再提供服务；资源对象可能仍通过另一个稳定版本存在。清单、Helm 模板、GitOps 控制器和程序中的 typed client 如果继续请求旧端点，仍可能失败。

迁移时应同时核对字段、默认值和语义。例如，旧版本的字段可能已被替换，稳定版本也可能要求新的必填结构。只改 `apiVersion` 而不核对 schema，可能从“找不到端点”变成“对象校验失败”，也可能生成与原意不同的配置。具体移除版本与迁移字段应查阅[官方 API 迁移指南](https://kubernetes.io/docs/reference/using-api/deprecation-guide/)。

CRD 还存在 served version、storage version 和转换逻辑的区别。停止提供旧版本前，需要确认客户端已经迁移，以及历史存储对象能够被当前转换链处理。修改 CRD 的存储版本声明，不等于已经迁移所有历史对象。

扩展组件也要进入升级检查：CNI 与内核数据面、CSI 与存储服务、Device Plugin 与驱动、Admission Webhook 与 AdmissionReview 版本及新字段。API Server 和节点全部 Ready，只能说明一部分基础条件成立；它们不会替这些扩展完成兼容性验证。

## 实验：先验证维护条件，再升级补丁版本

本轮通过 SSH 在 `zj` 创建专用 kind v0.33.0 集群 `article-upgrade`，拓扑为一个控制面、两个工作节点。起始 API Server、kubectl 和 kubelet 均为 Kubernetes v1.35.1，提交 `8fea90b45245ef5c8ba54e7ae044d3e777c22500`；目标为 v1.35.2，提交 `fdc9d74cbf2da6754ebf81d56f80ae2948cd6425`。没有修改 Kubernetes 源码，也没有升级 etcd 或执行证书续期。

实验文件与准备、运行、结果查看及清理说明见 [k8sdev/article-upgrade](https://github.com/jimyag/k8sdev/tree/3cfa654905a750b1716de8c32aa42d7573220954/article-upgrade)。先按该目录 README 的固定提交准备步骤创建独立工作目录，再运行实验。

维护实验检查 PDB 拒绝、原 Pod UID 仍存在，以及成功 drain 后的副本位置，不能只以命令返回值代替这些检查。

{{< github-file url="https://github.com/jimyag/k8sdev/blob/3cfa654905a750b1716de8c32aa42d7573220954/article-upgrade/run.py#L61-L85" >}}

先创建单副本 Deployment，并设置 PDB 的 `minAvailable: 1`。drain 将节点设为不可调度后，反复收到：

```text
Cannot evict pod as it would violate the pod's disruption budget.
global timeout reached: 15s
```

脚本检查原 Pod UID 仍存在。这个结果说明维护被预算阻塞，不能把 drain 返回非零理解为节点仍然完全保持原状态：cordon 已经发生，需要明确决定后续是继续维护还是 uncordon。

恢复调度、扩为两副本并确认分散在两个工作节点后，再次执行 drain。该次操作成功，两个就绪副本最终都位于剩余工作节点；原来被驱逐的副本由新 UID 替代。它验证了控制器补副本与重新调度，没有测试真实 HTTP 业务的无损切流。工作负载使用软反亲和，以允许维护期间副本临时共处一台节点。

本轮还直接请求旧的 `extensions/v1beta1` Deployment 端点，得到 NotFound，而 `apps/v1` discovery 成功。这个对照说明旧 API 不可用时，应检查请求端点和清单版本，不能把错误归因于工作节点没有升级。它不代表已经验证任意旧清单的字段迁移。

kubeadm 首次预检发现 kind 容器内没有宿主内核的配置文件，因此失败。确认 `zj` 的 `/boot` 存在与实际运行内核匹配的配置后，脚本把该配置提供给本轮节点，再次执行预检；没有忽略 SystemVerification。这里验证的是容器节点环境的准备条件，不应让读者通过绕过预检照搬结果。

实际升级输出包含：

```text
[upgrade/versions] Cluster version: v1.35.1
[upgrade/versions] kubeadm version: v1.35.2
[upgrade/staticpods] Waiting for the kubelet to restart the component
[upgrade] SUCCESS! A control plane node of your cluster was upgraded to "v1.35.2".
```

在这条成功输出之后，API Server 已报告 v1.35.2，但三个节点的 kubelet 仍全部报告 v1.35.1。脚本保存了这个中间状态，随后逐节点完成维护、配置更新、二进制更新和 kubelet 重启。最终 API Server 与三个 kubelet 都为 v1.35.2，kube-proxy rollout 完成，两个实验副本重新 Ready。

kind 提供的是可抛弃的容器节点，本轮安装二进制的方法用于验证机制，不是生产节点包管理教程。跨小版本升级、etcd 升级、HA 滚动升级、插件兼容性和数据恢复都需要各自验证，不能用这次补丁升级的通过结果替代。

## 恢复方案要对应已经改变的状态

升级失败时，先确认停在哪一阶段：预检失败通常还没有替换控制面 manifest；控制面部分升级时，各组件可能已经处于不同版本；节点升级后，配置文件和二进制又可能分别处于新旧状态。只看最后一条报错，容易遗漏前面已经成功的动作。

恢复路径应对应这些实际变化。某个插件的新配置不兼容时，可能只需恢复插件配置；单个节点升级失败时，可以保留其他健康节点，修复或替换该节点。若需要从旧 etcd 快照恢复，就涉及对象状态回退和控制器重新协调，不能把它当作一个“撤销软件更新”的按钮；业务卷和外部资源也不会随 API 元数据自动回到同一时刻。

因此，升级完成的判断应落到实际能力上：能够读写 API、完成调度与调谐、在目标节点启动工作负载，并满足应用自己的连接、存储和恢复要求。版本号对齐是其中一项证据，业务验证仍需覆盖这次升级真正改变的路径。

