一行命令毁掉整个 Kubernetes 集群,然后我花了一天时间把它找回来
yq在多文件传入-i时静默合并到第一个文件,这是个反直觉的 API 设计,没有任何警告k3s reconciler 没有 dry-run 或 plan 功能,操作是不可逆的,且异步执行没有在运行命令前读 man page只看了git status而没看git diff疲劳状态下操作生产基础设施AI 给出的命令看起来很合理,但有一个非显而易见的致命副作用。
本文是对 Impromptu disaster recovery 的整理与翻译
背景
2025 年 3 月 18 日,作者 Amos 本来只是想找一个自托管的看板工具,顺便把 Kubernetes 配置文件的缩进从 2 个空格改成 4 个空格。结果这一天变成了一场长达数小时的灾难恢复演练。
这不是一篇"无责任事后分析报告"(blameless postmortem)。文章里有锅,有甩锅,有反思,有干货,也有对当下 LLM 辅助编程现状的冷静审视。
自建基础设施:我的服务器是怎么跑起来的
Amos 是个有多年自托管经验的老玩家。从最早的 PHP 共享主机,到便宜的 VPS,再到今天在 Hetzner 上跑的一套 k3s 集群。
集群的主节点是德国 Hetzner 的一台专用服务器,边缘节点分布在全球各地(也全在 Hetzner,他们现在有东南亚节点了)。这个集群上跑着他博客的自制 CDN、一个私有的 Forgejo 实例(类似 GitHub 的代码托管平台)以及其他内部服务。
当天他想部署一个叫 Taiga 的项目管理工具,在 ArtifactHub 上找到了几个非官方的 Helm Chart,于是打开了自己的基础设施仓库,开始写部署 manifest。
他是怎么往 k3s 上部署东西的
最直接的方式是用 kubectl apply,但 Amos 不喜欢这种方式,因为磁盘上的 YAML 文件和集群里实际存在的资源之间容易出现状态漂移——你可能忘了 apply 某个文件,也可能 apply 顺序不对导致资源被覆盖。
他没有去用 Spinnaker 这种复杂的 CD 工具,而是选择了一个更简单(但有代价)的方案:rsync。
他写了一个 deploy-manifests 脚本:
#!/bin/bash -eux
ssh root@brat "touch /var/lib/rancher/k3s/server/manifests/traefik.yaml.skip" &
rsync -av --delete ./manifests/ root@brat:/var/lib/rancher/k3s/server/manifests/custom/
ssh root@brat "journalctl -fxu k3s"
逻辑很简单:把本地 manifests/ 目录 rsync 到服务器上的特定目录,k3s 的 reconciler 会监听这个目录的变化,一旦有文件写入,就去比对现有资源并执行增删改操作。
为什么不从 CI 里跑这个 rsync? 因为 Forgejo 本身就是跑在 k3s 上的,用 Forgejo Actions 会有鸡和蛋的循环依赖问题;用 GitHub Actions 又要把生产服务器的私钥交给它,Amos 不放心。所以就这样手动跑着用了。
k3s reconciler:比你想象的更简单,也更危险
k3s 的 reconciler 有一个关键特性:启动时读取目录下所有 .yaml 文件,对比整个资源集合,然后执行必要的 CRUD 操作。
它没有"先预览再确认"的步骤(不像 OpenTofu 有 plan),也没有 dry-run 选项(不像 Ansible),出错了也不会主动报错到终端——你只能盯着 journalctl 的日志。
唯一比直接用 kubectl apply 差的地方:kubectl apply 会阻塞直到操作完成,并把错误打印出来;而 reconciler 是异步的,你只能靠日志来感知。
# journalctl -u k3s | grep -F 'custom/' | ccze -A | tail -4
用久了之后,Amos 已经能预判 reconciler 的行为,不会每次都去查日志。只要 30 秒内服务没起来,才会 SSH 进去看一眼。
直到 2025 年 3 月 18 日,事情彻底失控。
引发灾难的命令:格式化的代价
Amos 最近把代码编辑器改成了用 4 个空格缩进所有文件(包括 YAML)。打开旧的 manifest 发现还是 2 个空格,于是他向编辑器里内置的 AI 助手(Zed 编辑器的"Claude 3.5 Sonnet Fast Edit"模式)请教了一条批量重格式化的命令。
AI 给出了这条命令:
yq -i -P '.' manifests/**/*.yaml
看起来很合理:-i 原地修改,-P 美化输出,'.' 表示处理整个文档。Amos 想,反正这是个 git 仓库,出了问题还能 revert,就直接跑了。
运行之后,他查看了 git status:
Changes not staged for commit:
modified: manifests/cert-manager/300-cert-manager-chart.yaml
只有一个文件被修改了?他有点失望,觉得命令只处理了第一个文件,然后……就把它 commit 了,并跑了 ./deploy-manifests。
同时,他在第二块屏幕上挂着 k3s 的日志。
日志开始疯狂滚动。
他发现 k3s 在不断尝试创建……已经存在的资源的重复副本?
仔细一看,300-cert-manager-chart.yaml 被频繁提到。等等,这个文件为什么有 1.75MB?
yq 的"惊喜":多文件传入 -i 的实际行为
这里是事故的根本原因。
yq -i -P '.' manifests/**/*.yaml
这条命令并不是对每个文件分别格式化。
当 yq 收到多个文件参数时,它会把所有文件的内容合并成一个 YAML 流,然后因为有 -i 标志,把合并后的结果写回第一个文件。其他文件则完全不动。
所以 300-cert-manager-chart.yaml 变成了 1.75MB——它把所有 manifest 文件的内容都吃进去了。
而 git status 之所以只显示这一个文件被修改,是因为其他文件确实没有变化(它们本来的内容没被修改,只是第一个文件被撑爆了)。
如果 Amos 当时看的不是 git status 而是 git diff,哪怕扫一眼,也会立刻发现异常。
Cool Bear 的责任清单(非无责任复盘版):
- Amos 在运行命令之前没有读 yq 的 man page
- Amos 只看了
git status,没看git diff - Amos 当天压力很大,睡眠不足,本来就不应该去动基础设施
LLM 们的表现:集体翻车实录
Amos 后来把这条命令的问题拿去问了几个主流 LLM,看看他们是否能主动发现这个陷阱。结果是:没有一个模型在第一次能主动指出问题。
GPT-4o
GPT-4o 给了一个教科书般的解析,把每个参数的含义都说得头头是道,但对"多文件会被合并进第一个文件"这个关键行为只字未提。
Amos 主动告知这个行为之后,GPT-4o 认可了,但给出的"修复方案"是……把 manifests/**/*.yaml 改成 manifests/**/*(去掉了 .yaml 后缀),这和原命令有完全一样的问题。
再次指出之后,GPT-4o 才给出了两个真正有效的解法:
# 方案一:用 find + xargs
find manifests -type f -name '*.yaml' -print0 | xargs -0 -I{} yq -i -P '.' {}
# 方案二:用 for 循环
for file in manifests/**/*.yaml; do
yq -i -P '.' "$file"
done
Claude 3.7 Sonnet
同样,第一次的解析完全没提到多文件合并的问题。被纠正后,Claude 给出的方案更简洁:
find manifests -name "*.yaml" -exec yq -i -P '.' {} \;
Amos 评价说:Claude 的解法比 GPT-4o 的更好,不需要绕 xargs,也不用担心 IFS 的问题。
DeepSeek R1
同样先翻车,被纠正后承认"其实知道这个行为",但没在初次回答里提到。
Mistral Le Chat
引用了多个来源(yq 官方文档、第三方博客),但同样没能在首次回答时发现问题。
结论:关于 LLM 的公平判断
所有模型都在被纠正后承认了错误,并感谢了纠正。但这并不意味着任何事情得到了改变——这些模型不会从对话中学习,也许只有当这篇文章进了下一代的训练集,未来的模型才会知道 yq 的这个行为。
Amos 说,他对 LLM 的判断是偏袒的:怪 LLM 有点甩锅的嫌疑。毕竟是他自己按下了回车键。
他引用了一张 IBM 1979 年的内部培训图:
“计算机永远不应该被追责,因此计算机永远不应该做出管理决策。”
话虽如此,他也认为 yq 对多文件的处理方式是一个糟糕的 API 设计。这种"静默合并"行为极具破坏性,而且没有任何警告。只是此时改动会是 breaking change,所以可能就这样了。
雪上加霜:删掉重复资源触发大规模删除
发现问题之后,Amos 想到的第一个操作是:删掉那个 1.75MB 的合并文件里多余的重复资源。
然后 k3s reconciler 看到这个文件之后,发现大量资源"消失"了,于是立刻开始删除它们。而且删得非常快。
- Deployment 被删 → Pod 被删 → 容器停止
- 证书和 Secret 被删 → Traefik 开始用默认证书
- Service 和 IngressRoute 被删 → Traefik 返回 404
- 最终,Traefik 本身也没了
整个 Kubernetes 集群的资源几乎被清空。
Amos 本想研究一下怎么让 k3s 停止删除操作,但很快决定:算了,我有备份,有些服务本来就快废弃了,干脆趁此机会做一次"提前的春季大扫除"。
决定:抹掉服务器,从零开始。
灾难恢复全程记录
第一步:先用 rsync 把数据捞出来
在抹机器之前,Amos 先把 /var/lib/rancher/k3s 整个目录 rsync 出来作为保底:
rsync -avz --progress \
root@brat:/var/lib/rancher/k3s \
./var-lib-rancher-k3s
很快发现两个问题:
agent/containerd下藏了 60 万个小文件- rsync 是单线程的
带宽足够时,默认压缩反而成了瓶颈。他切换到了排除 containerd 目录的版本,很快就搞定了(尽管有 30GB)。
Amos 说:非常庆幸这件事发生在家里,家里有 2.5Gbps 的宽带。
第二步:通过 Hetzner Robot 重装 Debian 12
Hetzner 提供了一个叫 Hetzner Robot 的管理面板,可以进入救援模式。重启后 SSH 进去,运行:
installimage
然后就会看到一个 ncurses 的 TUI 安装界面(前提是你用的不是 Ghostty 终端——Ghostty 用的 TERM 值是 xterm-ghostty,很多地方没有对应的 terminfo,会直接跳进 nano)。
解决 Ghostty 的 terminfo 问题:
infocmp -x | ssh root@brat -- tic -x -
installimage 很小,但会帮你配置好 RAID 1,Amos 表示满意。
第三步:搭建新的 k3s 集群
Amos 用 Ansible 管理节点的配置,他写了一套 playbook:
./ansible-playbook playbooks/k3s.yaml -l brat
他的 Ansible inventory 是通过一个 Rust 脚本从 OpenTofu(Terraform 的开源 fork)的 state 文件自动生成的,用了 cargo 的实验性 -Zscript 特性(类似于在 Rust 文件里直接写 Cargo.toml 依赖),他非常喜欢这个特性。
顺便提一句:Ansible 有整整 22 个变量优先级层级,这是个令人叹为观止的设计。
第四步:CA 证书哈希不匹配
节点连接主节点时报错:
token CA hash does not match the Cluster CA certificate hash: de13... != d262...
k3s 集群有自己的证书颁发机构(CA),重装后新的 CA 密钥对和旧的不同,但 Ansible 变量里存的还是旧的哈希值,而且这个旧值通过 git-crypt 加密后提交在了 infra 仓库里。
更麻烦的是,k3s-leader 的 Ansible 角色还会把旧的 node-token 文件写回服务器,所以直接读文件也没用。
node-token 的结构是:
K10<token-ca-hash>::server:<random-token>
只需要把 K10 和 :: 之间的 CA 哈希替换成新的就行了。Amos 自己通过比对日志里的哈希值搞定了这个问题(GPT-4o 其实也解释了这个结构,只是他后来才注意到)。
最终,边缘节点成功连上了主节点。
恢复关键服务
Traefik v3
k3s 自带 Traefik v2,但 Amos 要用非实验性的 HTTP/3 支持,所以用的是自己部署的 Traefik v3。
首先在部署脚本里禁掉 k3s 内置的 Traefik:
touch /var/lib/rancher/k3s/server/manifests/traefik.yaml.skip
然后通过 HelmChart 资源部署 v3:
---
apiVersion: helm.cattle.io/v1
kind: HelmChart
metadata:
name: traefik
namespace: traefik
spec:
repo: https://traefik.github.io/charts
chart: traefik
version: 34.4.1
valuesContent: |
image:
repository: "traefik"
tag: "v3.3.4"
deployment:
kind: DaemonSet
logs:
general:
level: "INFO"
hostNetwork: true
cert-manager
用于自动申请 Let’s Encrypt 证书,同样是一个 HelmChart 资源:
---
apiVersion: helm.cattle.io/v1
kind: HelmChart
metadata:
name: cert-manager
namespace: kube-system
spec:
repo: https://charts.jetstack.io
chart: cert-manager
version: 1.17.1
valuesContent: |
crds:
enabled: true
Minio
专用服务器有 2×512GB SSD,Amos 用 Minio 做本地对象存储,物尽其用。
总结与反思
这次事故是多个因素叠加的结果:
工具层面:
yq在多文件传入-i时静默合并到第一个文件,这是个反直觉的 API 设计,没有任何警告- k3s reconciler 没有 dry-run 或 plan 功能,操作是不可逆的,且异步执行
操作层面:
- 没有在运行命令前读 man page
- 只看了
git status而没看git diff - 疲劳状态下操作生产基础设施
关于 LLM:
- AI 给出的命令看起来很合理,但有一个非显而易见的致命副作用
- 所有被测试的主流模型(GPT-4o、Claude 3.7 Sonnet、DeepSeek R1、Mistral Le Chat)在首次回答时都没能主动指出这个问题
- 但最终责任在人:计算机不应该被追责,所以不能让计算机做管理决策
- 在用 LLM 生成的命令操作生产环境之前,务必自己理解每一个参数的含义
正确的 yq 批量格式化写法(三选一):
# 方法一:find + exec(推荐,最简洁)
find manifests -name "*.yaml" -exec yq -i -P '.' {} \;
# 方法二:find + xargs
find manifests -type f -name '*.yaml' -print0 | xargs -0 -I{} yq -i -P '.' {}
# 方法三:shell for 循环
for file in manifests/**/*.yaml; do
yq -i -P '.' "$file"
done
这次灾难最终花了 Amos 一整天时间来恢复,但他的服务器在文章发布时已经完全恢复正常运行。他的博客、CDN、Forgejo 实例全部重新上线。
有时候,备份和快速恢复能力比避免事故更重要——当然,两者都有最好。
更多推荐



所有评论(0)