本文来自一次真实迁移复盘。节点、地址、域名、命名空间、业务数量和内部仓库信息均已脱敏或泛化,示例不能直接用于生产环境。

系列导航:Kubernetes 重建迁移教程系列。本篇的目标是从零构建一个健康的空集群。

这次升级没有在旧控制平面上连续执行多次 kubeadm upgrade,而是创建一套全新的 Kubernetes v1.36.3 集群,再分阶段迁移节点、数据、中间件、配置和业务。

下面不只是讲结论,而是按照实际执行顺序,从创建工作目录开始,一直写到业务切换完成。你可以把命令复制到自己的运维仓库,再替换所有 <...> 占位符。

选择重建而不是原地升级,主要因为旧环境同时存在操作系统老化、节点用途混乱、固定 Service IP、历史命名空间、网络规则不一致和有状态服务分散等问题。单纯升级控制平面,只会把这些问题原封不动地带到新版本。

最终证明,迁移最难的部分不是让 Pod 显示 Running,而是把下面这条链路全部打通:

节点
→ CNI / kube-proxy
→ PVC 与真实数据
→ Pod Ready
→ Service Endpoint
→ Service DNS / 服务注册
→ Gateway / Ingress
→ 登录与真实业务请求
→ 外部入口

迁移目标与最终形态

最终集群采用:

  • Rocky Linux 10.2。
  • Kubernetes v1.36.3。
  • containerd,使用 systemd cgroup。
  • 三节点控制平面和 etcd。
  • kube-vip 提供 API 虚拟地址。
  • Calico 提供 Pod 网络。
  • NGINX Ingress 三副本。
  • NFS 动态存储与少量本地持久卷。
  • metrics-server 提供节点和 Pod 指标。
  • Nacos 三节点集群。
  • 按业务、数据、入口、监控、共享和预留用途划分节点池。

网络规划在创建控制平面之前冻结。Pod CIDR、Service CIDR、节点网段三者不能重叠,应用也不能混淆这三类地址。

flowchart LR
  A["旧集群与虚拟化平台盘点"] --> B["etcd、资源和数据备份"]
  B --> C["批量重装 Rocky Linux"]
  C --> D["创建高可用控制平面"]
  D --> E["Calico、Ingress、存储、指标"]
  E --> F["资源以零副本预部署"]
  F --> G["恢复数据服务"]
  G --> H["Nacos 与 Service DNS 治理"]
  H --> I["分批启动业务"]
  I --> J["真实请求与入口切换"]

开始前:建立版本化工作目录

不要把下载文件、备份、临时 patch 和最终清单散落在运维机的下载目录。先建立一个独立目录:

mkdir -p cluster-migration/{inventory,backup,images,manifests,scripts,reports}
cd cluster-migration

git init
printf 'backup/\n*.key\n*.kubeconfig\n.env\n' > .gitignore

建议目录结构如下:

cluster-migration/
├── inventory/       # 节点、服务、存储和域名清单
├── backup/          # 不进入 Git 的备份及校验值
├── images/          # 镜像和 digest 清单
├── manifests/       # kubeadm、CNI、存储和业务清单
├── scripts/         # 可重复执行的初始化与验证脚本
└── reports/         # 每个阶段的验收结果

再创建一个不提交到 Git 的 .env:

cat > .env <<'EOF'
K8S_VERSION=v1.36.3
API_VIP=<api-vip>
POD_CIDR=<pod-cidr>
SERVICE_CIDR=<service-cidr>
CONTROL_PLANE_1=<control-plane-1>
CONTROL_PLANE_2=<control-plane-2>
CONTROL_PLANE_3=<control-plane-3>
EOF

chmod 600 .env

本文不会给出真实地址。对于公开文档,可以使用 192.0.2.0/24、198.51.100.0/24 等专用文档网段;真实执行必须替换成自己的规划。

第 1 步:把服务器清单变成可校验数据

不要只维护一张自由格式 Excel。至少额外导出一份 CSV:

hostname,ip,role,pool,cpu,memory_gib,disk_gib,local_data
cp-a,<node-ip>,control-plane,control-plane,8,32,100,false
worker-a,<node-ip>,worker,app-dev,8,32,100,false
data-a,<node-ip>,worker,data,16,64,500,true

先检查 IP、主机名是否重复:

cut -d, -f1 inventory/nodes.csv | tail -n +2 | sort | uniq -d
cut -d, -f2 inventory/nodes.csv | tail -n +2 | sort | uniq -d

两个命令都不应输出任何内容。再核对节点池数量:

cut -d, -f4 inventory/nodes.csv | tail -n +2 | sort | uniq -c

这一步虽然简单,却能提前发现一台节点被写入两个业务池、预留节点被误当数据节点等问题。

第 2 步:导出旧集群资源

先确认自己操作的是旧集群:

kubectl config current-context
kubectl cluster-info
kubectl get nodes -o wide

然后分别导出 namespaced 和 cluster-scoped 资源。生产环境建议用经过评审的脚本逐类导出;下面只展示结构:

kubectl get deploy,statefulset,daemonset,job,cronjob -A -o yaml \
  > backup/workloads.yaml

kubectl get svc,ingress,configmap,pvc -A -o yaml \
  > backup/application-resources.yaml

kubectl get pv,storageclass,clusterrole,clusterrolebinding,crd -o yaml \
  > backup/cluster-resources.yaml

Secret 必须单独处理:

umask 077
kubectl get secret -A -o yaml > backup/secrets.yaml

backup/secrets.yaml 不得提交到 Git,也不得通过聊天工具或普通文件共享分发。完成后对备份计算校验值:

sha256sum backup/*.yaml > backup/SHA256SUMS

还要保存副本数,因为后续会把新集群中的工作负载统一改成零副本:

kubectl get deployment,statefulset -A \
  -o custom-columns='KIND:.kind,NAMESPACE:.metadata.namespace,NAME:.metadata.name,REPLICAS:.spec.replicas' \
  > inventory/workload-replicas.txt

第 3 步:创建并验证 etcd 快照

在控制平面节点上找到 etcd 证书和 endpoint,再执行:

ETCDCTL_API=3 etcdctl snapshot save /backup/etcd-snapshot.db \
  --endpoints=https://127.0.0.1:2379 \
  --cacert=<etcd-ca> \
  --cert=<etcd-client-cert> \
  --key=<etcd-client-key>

ETCDCTL_API=3 etcdctl snapshot status /backup/etcd-snapshot.db \
  --write-out=table

把快照复制到独立存储,并在隔离主机上做一次 restore:

etcdutl snapshot restore /backup/etcd-snapshot.db \
  --data-dir /var/lib/etcd-restore-test

恢复目录能成功创建、member 信息可读取,才说明快照至少具备基础可恢复性。正式演练还应启动隔离的 etcd 和 API Server 来验证对象。

第 4 步:制作统一节点初始化脚本

不要在每台节点上手敲命令。创建 scripts/prepare-node.sh,脚本至少完成:

#!/usr/bin/env bash
set -euo pipefail

swapoff -a
sed -ri '/\sswap\s/s/^#?/#/' /etc/fstab

cat >/etc/modules-load.d/kubernetes.conf <<'EOF'
overlay
br_netfilter
EOF

modprobe overlay
modprobe br_netfilter

cat >/etc/sysctl.d/99-kubernetes.conf <<'EOF'
net.bridge.bridge-nf-call-iptables=1
net.bridge.bridge-nf-call-ip6tables=1
net.ipv4.ip_forward=1
EOF

sysctl --system
systemctl enable --now containerd
systemctl enable kubelet

containerd 配置生成后,确认:

containerd config dump | grep -E 'SystemdCgroup|sandbox_image'
crictl info

期望 SystemdCgroup=true,pause 镜像应来自你已经验证过可拉取的仓库。

第 5 步:在加入集群前完成防火墙

在所有节点放行规划好的 Pod CIDR:

firewall-cmd --permanent --zone=trusted --add-source=<pod-cidr>
firewall-cmd --reload
firewall-cmd --zone=trusted --query-source=<pod-cidr>

最后一个命令必须输出 yes。再把结果写入报告:

hostname
firewall-cmd --zone=trusted --list-sources

不要简单地长期关闭 firewalld。关闭防火墙看似能让网络快速恢复,却会丢失可审计的安全边界。

第 6 步:生成 kubeadm 配置并预拉镜像

创建 manifests/kubeadm.yaml:

apiVersion: kubeadm.k8s.io/v1beta4
kind: ClusterConfiguration
kubernetesVersion: v1.36.3
controlPlaneEndpoint: <api-vip>:6443
imageRepository: <kubernetes-image-mirror>
networking:
  dnsDomain: cluster.local
  podSubnet: <pod-cidr>
  serviceSubnet: <service-cidr>
apiServer:
  certSANs:
    - <api-vip>
    - <api-dns-name>
    - <control-plane-1-ip>
    - <control-plane-2-ip>
    - <control-plane-3-ip>

在三台控制平面节点上预拉镜像:

kubeadm config images list --config manifests/kubeadm.yaml
kubeadm config images pull --config manifests/kubeadm.yaml

如果这里失败,先解决镜像仓库、DNS 和证书问题,不要继续执行 kubeadm init。

第 7 步:初始化第一台控制平面

在第一台控制平面执行:

sudo kubeadm init --config manifests/kubeadm.yaml --upload-certs \
  | tee reports/kubeadm-init.log

初始化成功后,保存输出中的两条 kubeadm join 命令。它们分别用于控制平面和工作节点,包含短期有效的 token,不能提交到 Git。

为当前管理员配置 kubeconfig:

mkdir -p "$HOME/.kube"
sudo install -m 600 /etc/kubernetes/admin.conf "$HOME/.kube/config"
sudo chown "$(id -u):$(id -g)" "$HOME/.kube/config"

kubectl get nodes
kubectl get pods -A

此时第一台节点通常处于 NotReady,CoreDNS 也可能处于 Pending。这是因为 CNI 还没有安装,不要把它误判为 kubeadm 失败。

第 8 步:部署 API 高可用虚拟地址

三节点控制平面不能让客户端固定连接某一台节点。本文使用 kube-vip 的静态 Pod 模式提供 <api-vip>。

先在第一台控制平面节点上生成 manifest。镜像版本必须在迁移前固定,并完成镜像扫描:

export VIP=<api-vip>
export INTERFACE=<node-interface>
export KVVERSION=<approved-kube-vip-version>

sudo ctr image pull <kube-vip-image>:${KVVERSION}
sudo ctr run --rm --net-host <kube-vip-image>:${KVVERSION} vip /kube-vip manifest pod \
  --interface "$INTERFACE" \
  --address "$VIP" \
  --controlplane \
  --services \
  --arp \
  --leaderElection \
  | sudo tee /etc/kubernetes/manifests/kube-vip.yaml >/dev/null

检查虚拟地址和 API:

ip address show dev <node-interface>
curl --cacert /etc/kubernetes/pki/ca.crt https://<api-vip>:6443/readyz

预期 /readyz 返回 ok。如果没有 VIP,先查 kubelet 日志和静态 Pod:

sudo crictl ps -a --name kube-vip
sudo journalctl -u kubelet -n 100 --no-pager

第 9 步:安装 Calico,让节点真正 Ready

把经过评审、版本固定的 Calico 清单保存到 manifests/calico.yaml。安装前确认其中的地址池与 kubeadm 的 <pod-cidr> 完全一致:

grep -n '<pod-cidr>' manifests/calico.yaml
kubectl apply -f manifests/calico.yaml
kubectl -n kube-system rollout status daemonset/calico-node --timeout=10m
kubectl get nodes

所有节点应逐步进入 Ready,CoreDNS 也应进入 Running。继续做一次 DNS 测试:

kubectl run dns-check --image=<approved-busybox-image> --restart=Never \
  --command -- nslookup kubernetes.default.svc.cluster.local
kubectl logs dns-check
kubectl delete pod dns-check

只看到 Calico Pod 在运行还不够。至少再创建两个位于不同节点的测试 Pod,互相访问 Pod IP,并访问一个 ClusterIP Service,证明跨节点路由和 Service 转发都正常。

第 10 步:加入其余控制平面和工作节点

先加入另外两台控制平面节点,再加入工作节点。控制平面命令形如:

sudo kubeadm join <api-vip>:6443 \
  --token <bootstrap-token> \
  --discovery-token-ca-cert-hash sha256:<ca-hash> \
  --control-plane \
  --certificate-key <certificate-key>

工作节点不带最后两项:

sudo kubeadm join <api-vip>:6443 \
  --token <bootstrap-token> \
  --discovery-token-ca-cert-hash sha256:<ca-hash>

token 失效后,在现有控制平面节点上重新生成:

kubeadm token create --print-join-command
sudo kubeadm init phase upload-certs --upload-certs

全部加入后检查控制平面和 etcd 静态 Pod 是否均匀分布:

kubectl get nodes -o wide
kubectl -n kube-system get pods -o wide | grep -E 'kube-apiserver|etcd'
kubectl get --raw='/readyz?verbose'

第 11 步:安装指标、存储和 Ingress

基础组件的安装顺序建议固定为:

metrics-server → StorageClass → 动态制备器 → Ingress Controller

每安装一个组件就完成一次小验收,不要一次性 kubectl apply -f manifests/:

kubectl apply -f manifests/metrics-server.yaml
kubectl -n kube-system rollout status deployment/metrics-server --timeout=5m
kubectl top nodes

kubectl apply -f manifests/storage/
kubectl get storageclass

kubectl apply -f manifests/ingress-nginx/
kubectl -n ingress-nginx rollout status deployment/ingress-nginx-controller --timeout=10m

存储必须用临时 PVC 做读写测试,Ingress 必须用临时域名做 Host 路由测试。详细清单放在本系列的“基础组件篇”,不要仅凭 Pod 处于 Running 就进入业务迁移。

第 12 步:完成集群级验收

创建 reports/cluster-baseline.txt,记录以下命令输出:

kubectl get nodes -o wide
kubectl get pods -A -o wide
kubectl get storageclass
kubectl top nodes
kubectl get ingressclass
kubectl get --raw='/readyz?verbose'

到这里,新的空集群才算搭建完成。接下来按专题继续:

  • 基础组件:Calico、metrics-server、Ingress 和 NFS 的逐项部署与验收。
  • 节点调度:标签、污点、亲和性和数据服务反亲和。
  • 业务迁移:零副本预部署、数据恢复、分批放量、入口切换和回滚。
  • Nacos:三节点集群搭建、Service DNS 治理和平滑升级。

复盘:为什么第一步不是安装,而是盘点

迁移前先建立服务器与工作负载清单。每台节点至少记录:

  • 虚拟化宿主机和虚拟机标识。
  • IP、主机名、CPU、内存和磁盘。
  • 是否承载本地盘、数据库、Ingress、代理或控制平面。
  • 旧主机名到新主机名的映射。
  • 目标节点池、标签、污点和容忍关系。

旧集群则要单独导出:

  • Deployment、StatefulSet、DaemonSet、CronJob。
  • Service、Ingress、Endpoint、ConfigMap、Secret。
  • PVC、PV、StorageClass 和本地目录。
  • RBAC、CRD 和集群级资源。
  • 各工作负载当前副本数和 CronJob suspend 状态。

只保存 YAML 还不够。恢复时最容易丢失的是“运行状态”和“数据实际在哪里”。因此副本数、节点亲和性、存储位置和数据校验必须另行记录。

备份必须证明能恢复

重装节点前完成三类备份:

  1. etcd snapshot。
  2. Kubernetes 全量资源归档。
  3. 数据库、消息系统、NFS 和 hostPath 数据备份。

最低限度的校验包括:

etcdctl snapshot status /backup/etcd-snapshot.db --write-out=table
tar -tzf /backup/kubernetes-resources.tgz | head
sha256sum /backup/* > /backup/SHA256SUMS

真正可靠的做法是在隔离环境执行一次 etcd restore,并对数据库做抽样恢复。snapshot status 正常只能证明文件可读,不能证明恢复流程完整。

操作系统基线要先于 kubeadm

所有节点统一使用同一份版本化初始化脚本:

swapoff -a
modprobe overlay
modprobe br_netfilter
sysctl --system

systemctl enable --now containerd
systemctl enable kubelet

containerd 统一使用:

SystemdCgroup = true

同时启用桥接流量和 IPv4 转发:

net.bridge.bridge-nf-call-iptables=1
net.bridge.bridge-nf-call-ip6tables=1
net.ipv4.ip_forward=1

这次最典型的网络故障来自 firewalld:Nacos 返回的 Pod IP 健康,但跨节点客户端访问时出现 No route to host。原因不是 Nacos,而是节点没有信任 Calico Pod CIDR。

正确做法是在 kubeadm 之前把 <pod-cidr> 永久加入可信区域,并在 Calico 安装后用一次性 DaemonSet 对所有节点再校准。不能等业务启动后逐台补规则。

对于仍使用 hostNetwork 的数据服务,还要单独验证目标节点端口是否允许来自 Pod SNAT 后的流量。长期方案应是减少 hostNetwork,通过普通 Pod 网络和 Service 暴露服务。

控制平面与基础组件

高可用控制平面使用三个节点。kubeadm 配置中的版本、API VIP、Pod CIDR、Service CIDR 和 DNS 域一旦冻结,就不应在业务迁移过程中修改。

apiVersion: kubeadm.k8s.io/v1beta4
kind: ClusterConfiguration
kubernetesVersion: v1.36.3
controlPlaneEndpoint: <api-vip>:6443
networking:
  dnsDomain: cluster.local
  podSubnet: <pod-cidr>
  serviceSubnet: <service-cidr>

控制平面完成后按顺序安装:

  1. Calico。
  2. NFS provisioner。
  3. metrics-server。
  4. NGINX Ingress。
  5. 日志、监控和必要系统组件。

运维机的 kubectl 应与 API Server 保持官方支持的版本偏差。Kubernetes 文档规定,kubectl 与 kube-apiserver 的版本差只能在一个 minor 版本以内。参见 Version Skew Policy。

节点池解决的不是整齐,而是故障边界

节点划分为控制平面、入口、平台基础服务、业务、数据、监控、共享和预留池。调度策略同时使用:

  • nodeSelector 指定目标池。
  • 污点阻止未授权工作负载进入专用池。
  • toleration 允许对应工作负载调度。
  • required Pod Anti-Affinity 分散 Kafka、Zookeeper、Nacos 等副本。

一个常见错误是只加标签,不加污点。这样“指定了节点池”的业务看起来正常,但没有 nodeSelector 的其他 Pod 仍会落到专用节点。另一个错误是先加污点、后补 toleration,结果造成大批 Pod Pending。

标签、污点和 toleration 必须在 Git 中成对生成并提前 dry-run。

资源先落地,副本保持为零

旧对象不能原样导入。需要移除:

  • uid
  • resourceVersion
  • managedFields
  • creationTimestamp
  • status
  • 自动生成的 ServiceAccount Token
  • Helm release Secret
  • 旧集群管理平台的标签和资源

Service、Ingress、ConfigMap、Secret、PVC 等先创建;Deployment 和 StatefulSet 初始副本设为 0;CronJob 先 suspend。

这一步可以提前发现:

  • YAML 与新 API 不兼容。
  • 固定 ClusterIP 冲突。
  • PVC 无法绑定。
  • 镜像不存在或拉取凭据缺失。
  • Service selector 与 Pod label 不匹配。
  • 节点标签和容忍配置不完整。

有状态服务必须逐层恢复

启动顺序固定为:

存储与 PVC
→ MySQL / MongoDB / Redis / RabbitMQ / Zookeeper
→ Kafka
→ PostgreSQL 与辅助服务
→ Nacos
→ 平台依赖
→ 业务服务

数据库不能用“创建了同名空库”代替迁移完成。至少要验证库表、权限、关键行数、索引和真实应用账号查询。

Redis 的排查则分成四层:

redis-cli PING
→ Pod Endpoint
→ ClusterIP
→ Service DNS
→ 真实应用接口

如果 Endpoint 正常而 ClusterIP 失败,优先检查请求源节点的 kube-proxy;重启业务 Pod 或修改 Nacos 都不会修复 Service 转发表。

Nacos:进程启动不等于注册中心可用

Nacos 使用三个 StatefulSet Pod。Headless Service 为每个成员提供稳定 DNS,普通 ClusterIP Service 为客户端提供统一入口。

nacos-0.nacos-hs.platform.svc.cluster.local
nacos-1.nacos-hs.platform.svc.cluster.local
nacos-2.nacos-hs.platform.svc.cluster.local

nacos-cs.platform.svc.cluster.local:8848

迁移时曾出现这样的问题:Nacos 日志显示启动成功,但客户端仍无法注册。最终发现运行模式开关与 gRPC 注册不兼容。修复后按三层验收:

  1. 三个 Pod Ready,三个成员全部健康。
  2. 三个节点运行模式一致。
  3. 同一个测试客户端在三个节点的实例列表中都可见。

只有这三层通过,才能继续启动 OAuth、网关和业务服务。

从固定 IP 迁移到 Service DNS

旧配置中大量内部服务使用 ClusterIP:

rbac.api.url=http://<cluster-ip>:8082
spring.data.redis.host=<cluster-ip>

迁移后改为:

rbac.api.url=http://rbac-api.app-test.svc.cluster.local:8082
spring.data.redis.host=redis-service.app-test.svc.cluster.local

但不能用正则把所有 IP 都替换为 DNS:

  • 已删除的 Service 没有可替换目标。
  • Kafka advertised listener 需要单独设计。
  • 外部数据库、短信和专网地址不是 Kubernetes Service。
  • 少量 Jenkins 或入口依赖可临时保留固定 ClusterIP,但必须进入白名单并写入 Git。

Pod Running 只是验收起点

业务按命名空间逐批启动。每批至少检查:

kubectl -n <namespace> get deployment,statefulset,pod
kubectl -n <namespace> get svc,endpointslice
kubectl -n <namespace> get event --sort-by=.lastTimestamp

然后执行真实请求:

  • 登录与 Token 刷新。
  • 用户、角色和权限。
  • Redis Session。
  • 文件上传下载。
  • RabbitMQ 与 Kafka 生产消费。
  • 数据库读写。
  • WebSocket 建连。
  • CI/CD 发布接口。

未登录接口返回“未登录”,只能证明入口到业务服务的链路可达,不能替代登录后的业务验证。

这次最有价值的故障模式

现象根因正确处理
服务注册为空Nacos 运行模式或客户端注册失败查三个节点实例列表,不只看启动日志
Pod IP 跨节点不可达firewalld 未信任 Pod CIDR全节点固化规则并做跨节点测试
Endpoint 正常、ClusterIP 超时单节点 kube-proxy 规则异常只处理故障源节点的 kube-proxy
Redis 正常但应用超时Service 地址过期或 hostNetwork 端口被拒绝分层验证 Endpoint、Service 和防火墙
指标为空metrics-server 无法访问 kubelet验证网络路径,必要时使用 hostNetwork
集群内 200、公网失败外层路由或 SNI 未配置修改入口层,不反复重启业务 Pod
Job Pending仍绑定旧主机名或缺少 toleration同步修改 selector、PV affinity 和 toleration

回滚必须在迁移前设计

回滚分为四层:

  1. 入口回滚:恢复旧路由、VIP 或 DNS。
  2. 对象回滚:从资源归档恢复 Service、Ingress、RBAC、PVC/PV。
  3. 配置回滚:按 Nacos 的 namespace、group、dataId 精确回滚。
  4. 数据回滚:使用数据库备份、binlog/WAL 和存储快照恢复。

一旦新环境开始接受写入,回滚就不再只是切换入口。必须明确增量数据如何处理,避免新旧环境双写或数据倒退。

最终结论

重建式升级的价值,不只是获得一个新版本集群,而是顺便清理旧系统积累的隐式依赖:固定 IP、未登记节点用途、不可恢复备份、混乱污点、失效路由和不可追踪的手工配置。

真正可复用的原则只有四条:

  1. 所有设计在切换前冻结。
  2. 所有有状态数据在重装前证明能恢复。
  3. 每个阶段都有不可跳过的验收门槛。
  4. 最终用真实业务请求定义完成,而不是用 Pod 状态定义完成。