6111 字
31 分钟

三台物理机上把 Kubernetes 升到 1.35.9

2026-10-01,把三台物理节点上的 Kubernetes 从 1.34.12 升到了 1.35.9。 这次没有再把 etcd、containerd、CNI 和 DNS 全部升级一遍,主要变更是 kubeasz 管理源码、六个 Kubernetes 程序和 crictl。

范围比上一轮小,执行过程却并不轻松。第一台 API Server 因新的聚合鉴权校验启动失败;最后一台节点维护后,发现 PushDeer Redis 接上了另一套业务的 Redis;收尾时又遇到 CephFS 的硬链接回溯记录缺失。Kubernetes 的基础升级在 07:53 完成,存储问题的进一步调查和扩大修复持续到了当天晚上。

这篇记录当时的实际操作、停止点和恢复结果。时间均为北京时间,节点以 node-a、node-b、node-c 代称,省略内网地址、私有仓库、恢复目录、对象标识和凭据。它是一份具体环境的复盘,不能直接当成其他集群的执行脚本。

正文以 10 月 1 日的版本升级及当天故障处置为范围。10 月 2 日完成的 kubeadm 接管和 kubeasz 清理,是后续独立变更,放在文末交代。

这次改了什么#

升级时仍是 kubeasz 管理、systemd 运行二进制。三台主机都同时承载控制面、etcd、worker 和 Ceph,业务基线包括 13 台运行中的 KubeVirt 虚机、95 对业务 PV/PVC、7 套 PostgreSQL、5 套 MariaDB、9 套 Redis,以及 18 个 OSD。

Rook 和 CNPG 已经在前面的窗口完成升级,这次把它们作为回归对象,不重复变更。

对象窗口开始时本轮动作
kubeasz 管理源码3.6.8 加本地适配对齐到 3.6.9,保留适配
Kubernetes 六个程序1.34.12升到 1.35.9
crictl1.34.0升到 1.35.0
控制容器 Python / Ansible3.12.14 / 2.20.9保持,复用现有容器
etcd / containerd / runc3.6.15 / 2.3.6 / 1.4.3保持
Calico / 通用 CNI / Multus3.31.7 / 1.8.0 / 4.3.1保持
CoreDNS / NodeLocal DNS / metrics-server1.12.4 / 1.26.4 / 0.8.1保持镜像版本
Rook / Ceph CSI / CNPG1.20.8 / 3.17.1 / 1.30.1已提前完成,本轮回归
Ceph / PostgreSQL19.2.6 / 17.6保持

六个 Kubernetes 程序是 kube-apiserver、kube-controller-manager、kube-scheduler、kubelet、kube-proxy 和 kubectl。除了节点上的运行副本,最后还要同步管理端的分发副本与版本字段,避免日后又发回旧文件。

宿主机、内核、Docker daemon、Ceph 数据面和业务应用版本都不在本轮升级范围,物理机没有重启。继续保留现有证书、网络地址规划、IPVS、MTU,以及 Multus 的 connectionLimit: 16 和 1 GiB 内存限制。

kubeasz 决定管理方式 目标版本另行固定#

kubeasz 3.6.9 明确支持 Kubernetes 1.35,但它的发布默认版本是 Kubernetes 1.35.4。支持某个 minor,并不意味着必须使用它打包时选择的所有组件版本。 本次单独固定 Kubernetes 1.35.9 和 crictl 1.35.0,其他组件沿用已经验证的现网版本。kubeasz 3.6.9 发布说明

材料来源也明确分开:

  • kubeasz 源码固定到 3.6.9 对应提交 4aafea3b37da8ac69ac8a8a410cf118e2ae68d32。
  • Kubernetes 从官方获取 1.35.9 linux-amd64 server 包,对照官方 SHA512 校验,只提取批准的六个程序。
  • crictl 使用 cri-tools 1.35.0 官方发布包,核对架构、版本和上游校验值。
  • 暂存、跨节点传输和最终分发,再用 SHA256 清单核对每个文件。

K8S_VER 是管理配置的一部分,修改这个字段本身不会让磁盘上的文件或正在运行的进程自动变成目标版本。此次验收同时核对了文件摘要、实际进程版本、分发副本和配置字段。

管理代码先在独立候选目录里做差异合并。403 个受管文件中,最终有 73 项源码变化;保留了 Calico 3.31 模板、registry 路由、containerd 的 bin_dirs、config_path、use_local_image_pull 适配,以及 etcdutl 恢复工具适配。

3.6.9 新增了 containerd 独立实例相关路径变量,这次把它们映射回现有 root、state、配置目录和 service 名。控制容器继续挂载原管理目录,既没有换容器,也没有通过重启 Docker 来更新管理代码。

候选在原 Python/Ansible 环境下通过了升级 playbook 的语法检查,非敏感 containerd 渲染结果与现网结构一致。但全量 setup 入口仍缺少既有的 community.general.pam_limits 依赖,没有把“本次所需检查通过”写成“所有 playbook 都可用”。

02:13,按审阅后的文件清单更新管理源码,保留挂载根目录、集群配置、证书、凭据和分发目录。这个阶段没有调用生产部署 role,也没有给节点下发配置。整个升级没有直接运行通用 ezctl upgrade。

准备材料时先被磁盘空间拦住#

本次仍然跳过了整个组合的隔离演练,但新备份、目标包校验和单节点恢复材料没有省略。

三台主机分别保存原二进制、unit 和配置。新业务备份共 43 份、约 20.64 GB,覆盖 PostgreSQL、MariaDB、Redis、VictoriaMetrics 原生快照和 GitLab repository archive;50 个 Git bundle 做了 clone/fsck。etcd 在准备阶段、临近切换前和升级结束后分别建立新快照,重要材料保留两台主机的独立文件副本并核对摘要。

这些材料覆盖的是已确认范围,不等于全部 VM 磁盘、PV 和对象数据的备份,也不等于完成了全量恢复演练。两份副本仍然位于同一集群的故障域中。

备份本身还触发了第一个停止点。管理节点同时承载 MON,新材料占用了根盘空间,Ceph 报 MON_DISK_LOW。继续预缓存镜像和解包,只会进一步压缩余量。

于是先调整本轮业务备份的位置,再经过单独确认迁移两组历史业务备份。流程是完整复制到目标节点,验证目标和保留副本的全部摘要,再核对源文件没有变化,最后才移除源载荷并留下位置说明。etcd 快照、旧程序和 Ceph 数据没有跟着清理。

这次没有用全局 prune 腾空间。维护前的镜像、旧程序和恢复文件各有用途,按目录名或文件大小直接删掉,可能恰好删掉下一步要依赖的材料。

镜像列表里有名字仍然不够#

节点排空会迁移没有升级的业务,所以镜像准备必须覆盖当前运行的应用。最终盘点到 133 个实际使用或测试镜像引用,三台接收节点都要具备所需内容。

这里遇到了 containerd 2.3.6 的一个具体行为:已有 snapshot 时,默认 Transfer 拉取路径可能跳过 layer 获取。命令返回成功,镜像也能列出来,但压缩 blob 仍可能缺失,离线导出或后续搬迁就会失败。现场复现与固定版本的解包路径吻合。containerd 2.3.6 解包源码

处理时保留原 hosts-dir、registry 路由和固定 digest,改用 ctr images pull --local 补齐内容,再独立检查完整性与 CRI image config ID。没有因为拉取返回 0 就放行,也没有把 config ID 与 manifest digest 混成一个指标。

另一项阻断来自游戏服务的初始化容器:它引用的 kubectl:latest,远端内容已与当前实际运行的镜像不同。如果直接排空,重新调度可能顺带升级这个没有列入计划的容器。

最后只把该初始化镜像固定为当前使用内容的 digest,保留 Always,临时使用 OnDelete 控制逐节点重建。补丁先经过服务端 dry-run,应用时十个既有 Pod 的 UID、就绪和节点均未变化。维护后再恢复 RollingUpdate;没有借此修复当时既有的 failed Helm revision。

这一步完成后,三节点的全部 133 个镜像引用通过完整内容和身份检查,才进入控制面切换。

实际升级顺序#

三台 API Server 先全部到 1.35.9,再升级 controller-manager 和 scheduler,最后处理 kubelet、kube-proxy 和客户端工具。这样避免新 kubelet 连到更旧的 API Server,也让控制面混合版本保持在支持范围内。Kubernetes 版本偏差策略

管理源码对齐
→ 新备份、旧文件、镜像与测试环境准备
→ node-b → node-c → node-a 的 API Server
→ 相同节点顺序的 controller-manager / scheduler
→ node-b → node-c → node-a 排空与节点程序升级
→ 业务恢复、完整检查、控制端分发同步和最终快照

旧 1.34 脚本只作为参考,没有原样复用。里面有固定版本、节点和对象标识,也有异常时自动换回旧程序、在 finally 中无条件恢复调度和虚机的逻辑。这次把这些行为改成显式停止点:节点健康后才恢复调度,恢复条件成立后才启动虚机,不把全局自动降级当成保证。

时间实际进度
00:42–03:51基线、管理代码、备份、镜像与探针准备
04:17首台 API Server 启动失败,停止推进
04:23–04:37修正鉴权参数,三台 API Server 完成
04:41–04:44controller-manager / scheduler 完成
05:11–05:12node-b 节点程序完成,随后恢复并验收
05:28node-c 节点程序完成,随后恢复并验收
05:45–05:46node-a 节点程序完成
05:51–06:50Redis 事故隔离、数据恢复与业务验收
07:00–07:45首批 CephFS 索引恢复
07:53Kubernetes 基础验收与升级收尾完成
当天上午至 21:18CephFS 后续不同文件告警、扩大排查与恢复

第一台 API Server 启动失败#

04:17,node-b 替换程序后,新 API Server 没有起来。其余两台仍然健康,立即停止后续节点的推进。

原方案想保留现有 API 聚合鉴权参数,漏掉了一个条件:本集群的 --requestheader-client-ca-file 与 --client-ca-file 使用同一 CA,而 --requestheader-allowed-names 为空。1.35.9 在这两个 CA 文件含有重叠证书时,要求显式指定允许的代理证书 CN。这项检查避免普通客户端证书持有者借认证代理请求头声明其他用户。1.35.9 校验源码

核对三台正在使用的聚合代理公开证书后,CN 都是 aggregator,因此最小修正是:

--requestheader-allowed-names=aggregator

这个值来自现有证书,不是可以照抄到任意集群的默认值。本轮没有更换 CA、证书或其他鉴权参数,也没有扩大允许名单。后续两台的候选配置和管理模板同步同一修正。

04:23,首台实际进程版本和 /readyz 通过。接着通过该 API 写入临时 ConfigMap,从三个 API 读回,再验证 metrics 和 KubeVirt 聚合接口。确认这些路径都正常后,才继续另外两台。

三台 API 完成后,又检查了九条 API→kubelet proxy 路径,再进入 controller-manager 和 scheduler。不能只用负载均衡后的一个 /readyz 代替每个实例的验收。

每台节点都走完停机与恢复#

控制面完成后,CoreDNS 临时从一副本扩到两个,确认位于不同节点。Tunasync 的任务自然结束后暂停调度、保留临时数据和日志,再停止三个 worker,避免排空时清掉还在使用的同步工作目录。

每台节点的处理顺序是:

  1. 确认其他节点、etcd、Ceph 和接收容量正常,目标节点的六个 OSD 通过 ok-to-stop。
  2. cordon 后观察数据库,尤其是可能因此触发切主的 CNPG,等实际复制、主备 Service 和实例状态恢复。
  3. 如果 Rook operator 位于目标节点,先迁出并确认能正常访问 Ceph。
  4. 正常关闭该节点的虚机,处理固定绑定的服务,再按用途审阅 emptyDir。
  5. 尊重 PDB 排空,更新节点程序、unit 和配置;不使用强制驱逐来越过保护。
  6. 验证节点、CRI、API、etcd 和 Ceph,恢复调度,再恢复虚机和业务,完成该节点的全部验收。

13 台虚机按 9 / 3 / 1 分布在三台待维护节点。五台没有显式固定 MAC/节点的虚机,先使用经过记录的临时绑定,恢复网络身份后再还原模板和电源策略。这是正常停启,不是热迁移,也不要求停启前后 VMI UID 一样。

AnyLink 使用直接节点绑定,单独执行一副本到零、再回到一;管理员当时在内网,不依赖这条 VPN 维持操作连接。ShellCrash 则随正常驱逐重建,恢复后实际通过代理访问 Registry,得到预期的匿名 401 并通过 TLS 校验。仅有 Pod Ready 不足以证明代理功能恢复。

kubelet 的配置与二进制一起切换#

kubelet 更新时,把原 unit 中的运行时端点移到配置文件,保持同一个 socket,并显式设置 failCgroupV1:

containerRuntimeEndpoint: unix:///run/containerd/containerd.sock
failCgroupV1: true

这里保留了现网 cgroup v2 和已有 CRI 端点,没有迁移宿主 cgroup 层级。实际操作先停止 kubelet,完成新二进制、unit 和配置的受控替换,再执行 daemon-reload 和启动。没有让旧进程先读取一半更新的配置。

每个服务的停止和启动各设置 210 秒外层等待,另有 120 秒健康等待。它们不是整次切换共用的 210 秒预算;单项业务中断达到 60 分钟要转入恢复,数据完整性和 quorum 异常则立即停止,不等计时耗尽。

Redis 复制正常之前先确认复制给了谁#

前两台节点恢复后的数据库检查通过,最后一台完成后却出现了更严重的问题。05:51,PushDeer 的两个 Redis 都是副本,它们和三个 Sentinel 指向一个旧 Pod IP;该 IP 已经被复用给 GitLab Redis。GitLab 一侧也能看到这两个外来复制连接。

这时不能把 master_link_status:up 当成好消息。连接畅通,也可能是在把另一套业务的数据复制过来。PushDeer 的两份在线数据都不能继续作为可信恢复源。

首先停止 PushDeer API 和通知组件写入,建立双向网络隔离。现场发现,新增 NetworkPolicy 没有立即切断既存 TCP 连接,于是只按已确认的五个 PushDeer Pod IP,在对端精确断开八条连接。随后暂停该 RedisReplication 的调谐,停止本组 Sentinel 和 Redis,保全在线与停机后的文件。

没有对 GitLab 执行 SENTINEL RESET。它自身的主从和合法成员随后恢复一致,但多余 peer 自行消失的具体原因没有完全证明。旧 IP 复用、跨组发现和重配之间的完整因果链,也不能仅凭这次现场状态就写成已查明。

恢复到可信备份意味着接受数据时间缺口#

可用的事故前恢复点是 01:14:03 的 RDB。恢复前先在隔离实例中实际加载,核对逻辑内容,再处理两块原 PVC;污染后的材料保留双份,不能与可信备份混在一起。

第一次准备恢复文件还踩了一个大小写问题:辅助实例生成的是 appendonly.aof,生产配置使用的是 Appendonly.aof。真实 Pod 因此启动成空库。这个差异被数据检查拦截时,应用仍然停着,没有放开业务写入。

按生产精确文件名重新准备后,两个真实 Pod 都加载出与可信源一致的三个键,一主一从复制通过。之后才检查三台 Sentinel 的本组成员、仲裁,以及受控 SET、WAIT、副本 GET 和清理测试键;同时验证 PushDeer 与 GitLab Redis 之间的新连接被双向阻断。

06:45 恢复应用副本,06:50 由应用自身完成 MariaDB 查询、Redis PING 和缓存写读删除,HTTP 入口返回 200。没有发送真实测试通知,所以没有把通知投递链路记为已验收。

必须保留这次恢复的代价:01:14 之后的 Redis 变更不能保证保留,MariaDB 没有回滚。原 PVC/PV 身份不变,也不能证明里面的数据从未变化。

长期 Redis 网络边界保留,临时助手和临时隔离规则清理,Operator 调谐恢复。收尾时再检查九组 Redis、27 台 Sentinel,核对当前 Pod IP/UID、合法副本和主库 EndpointSlice,而不是只看角色名称和复制连接是否在线。

CephFS 的告警为什么会一项接一项出现#

07:00,准备收尾时出现 MDS_DAMAGE。最初六条 BACKTRACE 记录都指向公共 Debian 镜像的 experimental/main 索引,路径位于 by-hash/MD5Sum。

这些名称是同一 inode 的硬链接:SHA256 名称和 MD5Sum 名称应当读到相同内容。底层 EC 对象存在,内容摘要也正确,但 MD5Sum 路径可以返回 EIO,镜像 HTTP 请求因此返回 500。

调查后确认,正文位于 EC 数据池,而默认数据池缺少解析硬链接所需的 backtrace 跳转对象。CephFS 使用默认池保存这类回溯信息,即使文件正文位于另一个池,也仍然需要它。CephFS 默认数据池的作用

MD5Sum 硬链接请求
→ MDS 缓存里没有对应 inode
→ 从默认池读取 backtrace
→ 对象缺失,无法定位主路径
→ ESTALE / EIO 与 BACKTRACE 告警

读取 SHA256 主路径后,inode 进入缓存,MD5Sum 别名又可能暂时可读。一次 HTTP 200 不能证明缺失的持久化记录已经补齐。

scrub 的命令返回值也要分层看#

对第一条损坏别名执行定点 scrub,命令进程返回 0,但 JSON 中的 return_code 是 -5,实际没有成功入队。换成可访问的主路径后,scrub 虽然完成,旧 damage 仍然存在。

这里至少有三件不同的事:CLI 是否正常执行、管理命令是否接受任务、异步 scrub 是否完成并修复问题。只检查 shell 退出码会误判。CephFS scrub 的状态与结果

最终采用文件级恢复:暂停 Debian 写入,双份保存正文、原始 parent/layout 和文件属性;只重建确认受影响的那一对硬链接,生成新 inode,保留内容、属主、权限和 mtime。随后定点 flush,核对默认池的跳转与 EC 池的元数据,验证主路径、链接、摘要、scrub 和实际 HTTP 读取。

只有新文件通过验证、旧 inode 对象由 Ceph 正常清理后,才按准确 ID 移除已退役 inode 的旧 damage 记录。damage rm 本身不修复文件,也不能代替这一串验证。Ceph 对损坏记录处理的说明

本次没有重置 journal、手改 RADOS xattr,也没有直接对整个文件系统跑递归 repair。

告警为空没有覆盖尚未访问的文件#

首批六对文件恢复后,07:53 的 damage 表为空。08:22 又出现第七个旧 inode;它不在刚修复的六个新 inode 中。第七项恢复后,11:44:59 完成了一轮 Debian 同步,当时仍然没有新 damage。

16:49,另一个目录中的第八个旧 inode 又报错。对比确认,前面重建文件的默认池记录仍然存在,新的告警来自之前没有覆盖的旧文件。

晚间处理第八项时,把只读核查扩到整个 experimental 的 by-hash 索引。结果又找到 七个尚未进入 damage 表的默认池记录缺失。这些 inode 没有 dirtyparent/dirtypool 标记,不能按正常的“新文件尚待写回”放过;与之对照,曾观察到的一个新 dirty inode 后来确实正常完成了写回,没有被误判后重建。

七项完成双份保全后逐一恢复,加上当前告警文件,本轮共修复八对。最终范围是:

检查对象数量与结果
experimental 下的 by-hash 目录161 个
SHA256 索引路径510 个,对应 475 个不同 inode
MD5Sum 与 SHA256 路径合计1,020 个,均落在已核查 inode 集合中
默认池记录与回溯路径inode、pool 和完整路径对应关系通过
晚间本轮恢复8 对文件,两个池记录及限定 scrub 通过
晚间实际 HTTPS 验收16 个路径,正文双摘要通过

当天累计恢复了十五对文件:首批六对、上午一对、晚间八对。21:18 的基础检查恢复为 HEALTH_OK、damage 为空,Debian 同步重新启动。这个时间点的新一轮任务仍在运行,没有将它写成又一次完整同步验收。

直接原因确定了 触发根因仍未确定#

保存下来的七个潜在缺失 inode,创建时间集中在前一晚 22:49:38–39;已取得完整属性的九个问题文件,ctime 都集中在 23:08:21。它们有同批创建和元数据变更的特征,支持“旧问题被后续访问逐步暴露”的解释。

但 ctime、创建时间都不是默认池对象丢失的时间。不能因为文件在升级前创建,就断言问题一定在升级前发生;也不能因为告警在升级期间出现,就断言是 Kubernetes 导致。

另一个容易误读的字段是 old_pools。Ceph 正常创建位于非默认池的文件时,也会把默认池加入该列表,它本身不是一次人工迁移数据池的证据。Ceph 19.2.6 文件创建逻辑

已读取的当前 MDS Pod 日志不覆盖这些文件昨晚的创建窗口,现有证据还没有串起首次写入、确认、恢复和最终缺失的过程。优先调查方向是 MDS 写回与恢复链路;截至这次追查,尚未确认具体软件缺陷或某条操作。因此这里写的是受影响元数据已恢复,不是触发根因已根治。

升级完成时究竟验收了什么#

07:53 的 Kubernetes 收尾检查包括以下内容;当天 CephFS 后续调查是上述独立延伸,不能用一个时间点覆盖全部结果。

层次当次验收
版本三节点六个程序 1.35.9、crictl 1.35.0;五个常驻服务实际进程版本和摘要匹配
控制面三个 API 的读写、九条 API→kubelet proxy、exec/logs/port-forward、控制器调谐和调度
网络与存储DNS/Service、新镜像拉取、三节点各两次 Multus ADD/DEL、四类卷跨节点重挂与数据读回
虚机临时 PVC 虚机 SSH、13 台原 VM 的磁盘和网络身份恢复、临时绑定及电源策略还原
数据库7 PostgreSQL、5 MariaDB、9 Redis 查询和复制;Redis 额外核对真实成员、地址与端点
Ceph三 MON quorum、18 OSD up/in、745 PG clean,允许正常 scrub;维护保护退出
入口和监控35 条入口、10 个 VM TCP 目标、85 个监控 target up、无规则执行错误
收尾CoreDNS 还原一副本,临时测试 namespace 与五个测试卷清理,管理端分发同步,升级后 etcd 双份快照校验

已有 XCC 等基线异常仍单独记录,没有为了“全绿”扩大忽略范围。AnyLink 没有建立真实 VPN 会话,PushDeer 没有测试实际通知投递,备份也没有覆盖所有 VM/PV/对象数据。数据库可查询、PVC 身份未变和数据完全无损,是三个不同的结论。

后记与下一次维护#

10 月 2 日,另一个窗口完成了 kubeadm 接管与验收,之后清理了旧 kubeasz 管理容器、镜像和工具目录,Kubernetes 版本仍为 1.35.9。那一轮的工作不能倒填成这次版本升级的步骤,更不能在现在的集群上重新执行旧 kubeasz 流程。

同一天还主动卸载了游戏服务及其卷,原业务卷基线由 95 对变为 94 对。本文的 95 对是 10 月 1 日升级时的数量,不是写作当天要求恢复的目标。

下一次维护,我会继续保留这次新增的检查:提前核对所有可能迁移的业务镜像内容;逐实例验证 API 聚合接口;数据库验收同时核对连接对象与逻辑数据;对明确的元数据缺失按目录扩大检查,并区分尚待写回的新 inode。

最需要改进的仍是恢复能力。明确且新鲜的恢复点、隔离后的真实加载、生产配置下的内容核验,以及数据时间缺口的说明,都必须在恢复业务前完成。程序版本已经变成目标值,只完成了升级工作的一部分。