先把结论放这儿
| 项 | 实测值 |
|---|---|
| 灾难 | kubectl delete ns prod-app(手滑删掉整个业务命名空间) |
| 恢复耗时 | 37 秒 (从停控制平面到 /readyz 变绿) |
| 数据完整性 | 5/5 对象 UID 完全一致,Secret 明文正确 |
| 快照大小 | 5.7 MB(etcd DB 6.0 MB / 721 keys) |
| 备份耗时 | 51 ms,对线上无感 |
但这 37 秒不是我第一次就做到的。我第一次「成功」只花了 6 秒 ------ 而那是假的。
环境:VMware 上自建的 3 节点实验集群,K8s v1.34.10,etcd v3.6.5,单 master。这是 lab,不是生产,所以后面我会明确说清哪些结论不能照搬。
一、为什么 etcd 值得单独写一篇
K8s 里能重建的东西很多:kubelet 能重启,Pod 能重建,节点能重装。
etcd 不一样,它是唯一不能重建的东西。
你的 Deployment、Service、ConfigMap、Secret、RBAC、CRD 定义、CA 证书、ServiceAccount token、甚至 Leader 选举租约,全都只存在 etcd 里。它一丢,集群不是「坏了」,是失去了记忆。
而现实里最常见的 etcd 灾难不是硬盘坏 ------ 是人:
kubectl delete ns敲快了kubectl delete crd以为只删定义,结果该 CRD 下所有实例一起没- apply 了一份写错的 RBAC,把自己锁在门外
- 有人「清理磁盘」把
/var/lib/etcd删了
我审计自己这个实验集群的时候,发现一个备份都没有:没有 crontab,没有备份目录,什么都没有。这就是这个演练的起点。
二、备份:snapshot save 到底存了什么
先说一个容易搞错的点。
kubectl get -A -o yaml > backup.yaml 不是集群备份。它只是把你导出的那几类对象存成了文本,里面没有 CRD 定义、没有租约、没有 CA,而且 UID 会变。
真正的集群备份是物理快照:
bash
etcdctl snapshot save /var/backups/etcd/etcd-$(date +%Y%m%d-%H%M%S).db
它通过 etcd 的 Maintenance.Snapshot gRPC 接口,让 etcd 在某个一致性点上把整个 bbolt 数据库流给你。注意「一致性点」这四个字 ------ 它不是边写边拷,那样会拷出一个撕裂的库。
两者对比:
| 物理快照 | YAML 导出 | |
|---|---|---|
| 存什么 | bbolt 数据库文件 | 一堆 YAML 文本 |
| 含 UID / resourceVersion / Secret / CRD / 租约 | 全都含 | 基本不含 |
| 恢复后 UID | 保持一致 | 全部重新生成 |
| 适合场景 | 灾难恢复 | 变更审计、迁移 |
我的结论是两个都要:快照用来救命,Git 仓库(GitOps)用来审计「谁在什么时候改了什么」。
2.1 备份完必须马上校验,不然等于没备份
bash
etcdutl snapshot status /var/backups/etcd/etcd-20260913-164401.db -w json
# {"hash":"f9dffe70...","revision":139650,"totalKey":721,"totalSize":5967872,"version":"3.6.0"}
如果 revision 是 0,或者字段是空的,这个快照就是废的。
我在备份脚本里把这条写死了:校验不通过就删掉文件、返回非 0 退出。
理由很直白:一个「备份了但恢复不了」的快照,比没有备份更危险 ------ 它会给你虚假的安全感,让你在真出事那天以为身后有退路。
2.2 这里有个坑,而且是静默的
etcd 3.5 起 把 snapshot status / snapshot restore 从 etcdctl 挪到了独立的 etcdutl;3.6 直接移除。
问题在于,如果你在 etcd 3.6 上敲:
bash
etcdctl snapshot status /var/backups/etcd/etcd-20260913-164401.db
它不报错。 它只是打印一屏 help 文本,然后 exit 0。
任何「只看退出码」的脚本都会认为校验通过了。我就是这么被坑的 ------ 备份脚本跑了很久,校验其实一次都没真正执行过。
修法是脚本里探测 etcd 版本,优先用 etcdutl,老版本回退 etcdctl。
这条的通用教训比 etcd 本身更值钱:「没报错」不等于「执行成功」。校验脚本必须检查输出内容,不能只看退出码。
三、灾难现场
先造数据。一个模拟业务的命名空间 prod-app:3 副本 Deployment + Service + ConfigMap + Secret。
为了让后面能证明「恢复的是原对象」而不是「重建了同名对象」,我先把 5 个对象的 UID 记了下来:
Namespace prod-app d51f9126-c2c8-4a5e-937b-b226f98d3834
Deployment web 6a00afee-75ca-4faf-a0fc-6b8c2713a4b9
Service web-svc a29da680-baac-4502-a344-64ef59ec6e63
ConfigMap app-config e0b5a150-5719-4594-babf-f45bab9704a2
Secret db-credential bfa04906-2f4b-494b-945f-555752b11590
然后备份,然后开删:
bash
kubectl delete ns prod-app
# namespace "prod-app" deleted ← 10 秒清完
10 秒,一个业务命名空间没了。
四、第一次恢复:6 秒「成功」,其实是假的
第一版恢复流程长这样:
[1/5] 停 kubelet → kubelet: inactive
[2/5] 保全现场 → /var/lib/etcd → /var/lib/etcd.bak-20260913-164456
[3/5] etcdutl snapshot restore → 数据目录已重建
[4/5] 启动 kubelet → API Server 就绪(耗时 6 秒)
6 秒。我当时还以为自己发现了什么不得了的东西。
结果 kubectl get ns 一看 ------ prod-app 根本不存在。
4.1 排查
第一反应是「快照坏了」,查下去发现不是:
bash
# 数据目录确实写进去了
ls -la /var/lib/etcd/member/snap/
# -rw-------. 1 root root 5967872 Sep 13 16:44 db ← 16:44 的新数据,5.9 MB
旧数据也还在
ls -d /var/lib/etcd.bak-*
/var/lib/etcd.bak-20260913-164456
数据和退路都对。那问题在哪?去看容器启动时间:
bash
docker ps --filter name=etcd --format 'table {{.Names}}\t{{.Status}}'
# k8s_etcd_etcd-k8s-master01_... Up 49 minutes ← 49 分钟没重启过
再看 apiserver:
bash
ss -lntp | grep 6443
# LISTEN ... users:(("kube-apiserver",pid=2705,fd=3)) ← 还是 1 小时前的 PID
数一下还在跑的容器:
bash
docker ps --filter name=k8s_ -q | wc -l
# 18
18 个 k8s 容器,一个都没停。
4.2 根因
systemctl stop kubelet不会杀掉它管理的容器。
这不是 bug,是 kubelet 的正确设计 ------ 重启 kubelet 不应该导致业务容器中断。但对 etcd 恢复来说这是致命的:
① mv /var/lib/etcd (旧数据)
↓
运行中的 etcd 仍持有旧文件的 inode 句柄
→ 它继续用【旧数据】对外服务,完全不受影响
② etcdutl snapshot restore → /var/lib/etcd (新数据)
↓
新数据老老实实落到磁盘了
但没有任何进程会去读它
③ systemctl start kubelet
↓
kubelet 一看「容器已经在运行」→ 什么都不做
→ etcd 仍在用旧数据 → 恢复等于没做
那为什么 /readyz 是绿的?因为 apiserver 从头到尾就没停过,它一直连着那个还活着的旧 etcd。绿色的 healthz 只说明「apiserver 能连上 etcd」,不说明「你恢复成功了」。
4.3 修法
bash
systemctl stop kubelet
sleep 3
关键:kubelet 停了容器还在,必须手工停
docker ps --filter "name=k8s_" -q | xargs -r docker stop -t 20
必须确认端口真释放了
ss -lntp | grep -E ':6443|:2379' # 应该没有任何输出
systemctl start kubelet # 从已恢复的数据目录重建静态 Pod
bash
待停止容器数: 18
剩余运行容器: 0
✅ 6443 已释放(apiserver 确实停了)
✅ 2379 已释放(etcd 确实停了)
✅ API Server 就绪(本轮耗时 32 秒)
这一步的教训一句话:恢复流程里必须有「端口释放校验」,不能只看 systemctl is-active kubelet。
五、完整恢复流程(5 步)
① 停控制平面
mv /etc/kubernetes/manifests/*.yaml /tmp/hold/ # 逼 kubelet 删掉静态 Pod
显式停掉所有 k8s_* 容器
验证 6443 / 2379 已释放 # ★ 不验证会白忙
② 保全现场
mv /var/lib/etcd /var/lib/etcd.bak-20260913-164456 # 别 rm!这是回滚退路
③ 从快照恢复
etcdutl snapshot restore /var/backups/etcd/etcd-20260913-164401.db
--name k8s-master01
--initial-cluster "k8s-master01=https://192.168.16.11:2380"
--initial-advertise-peer-urls "https://192.168.16.11:2380"
--data-dir /var/lib/etcd
④ 恢复控制平面
mv /tmp/hold/*.yaml /etc/kubernetes/manifests/
systemctl start kubelet
轮询 kubectl get --raw=/readyz 直到就绪
⑤ 验证
比对 UID、排查异常 Pod、记录 RTO
③ 那三个参数(--name / --initial-cluster / --initial-advertise-peer-urls)不是可选的,原因是:snapshot restore 不是「把数据塞回正在运行的 etcd」 ,而是在你指定的 --data-dir 里重建一个全新的 etcd 数据目录。既然是个新成员,它就得知道「我叫什么、属于哪个集群、peer 地址是什么」。
也正因为如此,多节点 etcd 的恢复会比单节点麻烦得多:每个成员都要用相同的 --initial-cluster-token ,各自的 --name 和 --initial-cluster 一起恢复,才能重建 Raft 关系。
六、验证:UID 才是决定性证据
恢复完:
【命名空间】 prod-app Active ✅
【业务对象】
pod/web-5c4bbcf55-2j56z 1/1 Running ← Pod 名字与删除前完全相同
pod/web-5c4bbcf55-5hs2k 1/1 Running
pod/web-5c4bbcf55-95pwn 1/1 Running
service/web-svc ClusterIP 10.0.24.193 ← ClusterIP 完全相同
deployment.apps/web 3/3
secret/db-credential Opaque
【UID 比对】★ 决定性证据
✅ NS_UID 一致 d51f9126-c2c8-4a5e-937b-b226f98d3834
✅ DEPLOY_UID 一致 6a00afee-75ca-4faf-a0fc-6b8c2713a4b9
✅ SVC_UID 一致 a29da680-baac-4502-a344-64ef59ec6e63
✅ CM_UID 一致 e0b5a150-5719-4594-babf-f45bab9704a2
✅ SECRET_UID 一致 bfa04906-2f4b-494b-945f-555752b11590
【Secret 明文】✅ SuperSecret2026 (演练用的假密码)
为什么一定要比 UID,只比名字不行?
因为「名字对得上」完全可以被伪造成「重新创建了一批同名对象」。而 UID 是存在 etcd value 里的普通字段,物理快照把它原样存下来、原样恢复出来,apiserver 读到的就是原来的 UID。
这件事在生产上很要命:很多系统拿 UID 当外部引用 ------ OwnerReference、Event 的 involvedObject.uid、审计日志、外部 CMDB、备份工具。UID 一变这些引用全断,典型表现是 OwnerReference 失效导致级联删除不工作,或者外部系统认为「这是新对象」。
<