主题
02 · Harvester v1.8.2 ISO 装机故障排查与修复记录
30 个实测案例,来自 2026-09-05 ~ 09-08 的真实部署、接入与运维过程。 编号沿用项目
README.md§8 的原始编号(1~18),本次新增 19~23(部署与认证)、 24~27(接入内网 Rancher,对应README.md§9)、28(两类密码分离)、 29~30(node03 guest 硬死锁恢复、离线镜像引用),便于与代码注释交叉引用。 每条格式:现象 → 定位过程 → 根因 → 修复 → 验证。
0. 速查表(按根因分类)
| # | 类别 | 一句话根因 | 状态 |
|---|---|---|---|
| 1 | 环境探测 | 网卡名靠猜会写错,必须探测引导实测 | ✅ 已规避 |
| 2 | libvirt 兼容 | discard='unpin' 不支持;UEFI 域 undefine 需 --keep-nvram | ✅ 已规避 |
| 3 | 日志可读性 | <serial type='file'> 被置 root:root 600,普通用户读不到 | ✅ 已修 |
| 4 | 预演验证 | ISO 未下完先跑真实引导,提前验证整条 HTTP 链路 | ✅ 方法论 |
| 5 | 介质完整性 | HEAD 的 x-goog-stored-content-length 不是真实大小 | ✅ 方法论 |
| 6 | 嵌套虚拟化 | migratable='on' 过滤 vmx → guest 内 KubeVirt 跑不了虚机 | ✅ 已修 |
| 7 | 安装卡死 | 预加载 >30min → chroot 内引导用 rke2 自杀 → ctr-check-images.sh 无界重试 | ✅ 已修(OS 盘迁 NVMe + 兜底救援) |
| 8 | 安装失败 | 收尾 pkill rke2 无匹配返回 1 + heredoc 里 set -e → 整个安装中止 | ✅ 已修(诱饵进程) |
| 9 | 编排缺陷 | dumpxml 偶发返回空被误判;shut off 时从不 virsh start | ✅ 已修 |
| 10 | bash 陷阱 | local a="$1" b="$BASE/$a" 同行引用刚声明的变量 → unbound variable | ✅ 已修 |
| 11 | 节点环境 | sudo kubectl 找不到(无 /usr/local/bin/kubectl,secure_path 不含 rke2 bin) | ✅ 已修(绝对路径) |
| 12 | 操作纪律 | bash 按字节偏移读脚本,编辑运行中的脚本会从错误位置继续执行 | ✅ 纪律 |
| 13 | 安装卡死 | nm-online 亚秒级竞态:NM 还在 ASLEEP 时它不等待直接返回 1 | ✅ 已修(97/98 注入) |
| 14 | 自愈缺失 | 安装器报错后永不自愈,只能干等超时 | ✅ 已修(96 自动重启重试) |
| 15 | 误判救援 | pgrep -f 经 SSH 执行会自匹配父 shell 的 cmdline | ✅ 已修([x] 写法 + 多条件 + 原子锁) |
| 16 | 编排缺陷 | 静默 || exit 1 导致后续节点根本不开始,日志无线索 | ✅ 已修(FAILED + continue) |
| 17 | 日志/进程 | 只杀 virsh console 子进程没杀重连循环;: > log 覆盖失败现场 | ✅ 已修 |
| 18 | 环境风险 | /mnt/xfs/harvester 被整体回滚到旧快照,当天编辑与日志全丢 | ⚠️ 需警惕 |
| 19 | 就绪误判 | grep Ready 把 NotReady 当就绪;Ready,SchedulingDisabled 又被精确匹配漏掉 | ✅ 已修 |
| 20 | 行为认知 | 节点自动提升为 control-plane,etcd 是正常行为,不是故障 | ✅ 已确认 |
| 21 | 认证 | 装机配置无法设 dashboard 密码 → 首登 xxx + mustChangePassword 时 token 全 401 | ✅ 已修(75 号脚本) |
| 22 | 文档正确性 | credentials.txt 把 RKE2 加入令牌标成 API token、SSH 密码标成 dashboard 密码 | ✅ 已修 |
| 23 | 端到端验证 | KubeVirt 准入拒绝 VM:doc is missing path: …/cpu/maxSockets | ❌ 未解决 |
| 24 | DNS | CoreDNS forward . /etc/resolv.conf + policy=random → 域名约 50% 解析到公网 | ✅ 已修(DNS 收敛三处) |
| 25 | 私仓 | 私仓是 HTTP 且节点无外网 → http: server gave HTTP response to HTTPS client | ✅ 已修 |
| 26 | 配置管理 | 手写 registries.yaml 被控制器回收(167B→0B)→ 必须改 containerd-registry 设置 | ✅ 已修 |
| 27 | 操作细节 | CoreDNS forward 只启动时读;crictl 需绝对路径+--runtime-endpoint;SSH host key 变过 | ✅ 已规避 |
| 28 | 凭据管理 | dashboard 密码与 SSH 密码同值→ 拿 SSH 密码登 dashboard 反复失败;浏览器 401 而 API 201 属客户端问题 | ✅ 已修(两类密码拆分) |
| 29 | guest 硬死锁 | CDI 导入中 Longhorn 拆掉其 iSCSI 块设备(limit=0)→ blkid soft lockup + RCU stall → 节点挂 22h,且 softlockup_panic=0 使其永不自愈 | ✅ 已恢复(安全判定后 destroy+start);内核加固待定 |
| 30 | 离线镜像引用 | cattle-fleet-local-system/fleet-agent 引用 docker.io 短名,而节点无外网 + DNS 被污染;内网私仓其实有同一镜像 | ✅ 已修(方案 A:containerd-registry 加 docker.io → 私仓 mirror,138ms 拉取成功) |
案例 1 · 网卡名不能靠猜(环境探测)
现象:装机配置里 management_interface.interfaces[].name 若写错,bond mgmt-bo / bridge mgmt-br 会建在错误接口上,节点无网络。
定位:可预测网卡名依赖 PCI 拓扑,不同机型/顺序会得到 enp1s0、ens3、eth0 等不同结果。
根因:guest 内命名由 PCI 地址推导,只有实测才可靠。
修复:用 40-define-vm.sh 01 probe 做一次探测引导(加 rd.debug、不带 root=, 因此不落盘不装机),从串口日志实测得到 enp1s0,再写进装机配置。 同时把域 XML 的 PCI 地址显式固定(网卡 0x01、os 盘 0x02、data 盘 0x03), 使命名从此确定,配置里可以安全写死设备名。
验证:探测日志中稳定出现 enp1s0/vda/vdb;正式装机后 ip -br a 与配置一致。
案例 2 · libvirt 8.0 兼容性三连(环境)
| 现象 | 根因 | 修复 |
|---|---|---|
virsh define 报 XML 不合法 | libvirt 8.0 不支持 driver discard='unpin' | 从 <disk><driver> 移除该属性 |
cannot undefine domain with nvram | UEFI 域有独立 NVRAM 文件 | virsh undefine harvester-NN --keep-nvram |
domain 'harvester-01' already exists with uuid ... | 未先 undefine 就重复 define | 脚本内先 undefine --keep-nvram 再 define |
案例 3 · 串口日志读不到(日志可读性)
现象:域 XML 用 <serial type='file'> 落盘串口日志,但宿主机普通用户 cat 报 Permission denied。
定位:ls -l 显示日志文件属主 root:root、权限 600。
根因:libvirt(以 root 运行)创建 type='file' 的串口输出文件时会置为 root:root 600。
修复:改用 <serial type='pty'> + <console type='pty'>,再由 30-console-capture.sh 用 virsh console + script 在用户态后台录制到 logs/harvester-NN-console.log。 附带收益:FIFO 支持向控制台注入按键(应对偶发的交互提示)。
验证:tail -f logs/harvester-01-console.log 全程可读,装机 8 万+ 行完整留存。
案例 4 · 装机链路预演(方法论,强烈建议照做)
做法:ISO 还没下载完时,就先跑一次真实引导,把整条 HTTP 链路验证掉。
实测结果:
| 环节 | 结果 |
|---|---|
root=live:http://…rootfs.squashfs 拉取 | HTTP 200 ✓ |
config_url 拉取 | HTTP 200 ✓ |
bond mgmt-bo + bridge mgmt-br 建立在 enp1s0 | ✓ |
Checking ISO URL | 按预期 404(ISO 尚未下完) |
| 磁盘写入 | 0 字节(未污染盘) |
价值:把"网络/引导/配置"三类问题在装机前一次性排除,之后真装机失败就只可能是安装器自身问题。
案例 5 · ISO 完整性怎么核(方法论)
可信判据(实测):
- 文件大小
8,232,370,176字节,是 2048 的整数倍(ISO9660 扇区对齐); - PVD(Primary Volume Descriptor)魔数
CD001; - 卷标
COS_LIVE。
不可信判据:HEAD 请求返回的 x-goog-stored-content-length 并非真实对象大小 (GCS 对复合对象返回的是分片信息),不能作为校验依据——曾据此误判文件损坏。
案例 6 · 嵌套虚拟化跑不起来(VM 定义)
现象:guest 内 KubeVirt 无法创建虚机,virt-handler 报无硬件虚拟化支持。
定位:guest 内 grep -c vmx /proc/cpuinfo 为 0。
根因:域 XML 用 <cpu mode='host-passthrough'> 但 migratable 默认为 on, libvirt 为保证迁移兼容性会过滤掉 vmx 等非迁移安全特性。
修复:显式 <cpu mode='host-passthrough' check='none' migratable='off'>。
验证:guest 内 vmx 计数 >0;os.modules 里同时加载 kvm。
案例 7 · ★ 安装永久卡在"镜像预加载"(本次最主要的坑,node01 实测踩中)
现象
串口日志每 2 秒重复刷两行,VM 永不关机:
ctr: failed to list images: ... dial unix /run/k3s/containerd/containerd.sock: connect: connection refused
level=warning msg="Failed to check deprecations"登录安装环境(sshpass -p rancher ssh rancher@192.168.150.11)可见 ctr-check-images.sh 在 sleep 2 死循环,而 containerd 与 rke2 进程都已不存在。
⚠️ 易误判点:
Failed to check deprecations只是症状,真因是 containerd/socket 没了。 只盯着这行 warning 会一路查错方向。
根因链(读 /usr/sbin/harv-install 得出)
- 非 ISO 模式(即 PXE +
iso_url)下,脚本chroot到/run/cos/target后启动一个 引导用rke2 server,目的只是借它内嵌的 containerd 来导入/var/lib/rancher/rke2/agent/images/*.tar.zst; - chroot 内没有 systemd,kubelet 必然失败:kubelet 每 5 秒
Failed to create cgroup: systemd not running on this host, cannot use systemd cgroups manager → Failed to start ContainerManagerexit status 1; - static pods 永远同步不了,rke2 在启动约 30 分钟后自杀(chroot 内
/rke2.log):containerd 随之消失;level=fatal msg="Failed waiting for static pods to sync: context deadline exceeded" /usr/sbin/ctr-check-images.sh是while true无上限重试 → 永久卡死。 镜像列表按 glob 序逐个核对,实测卡在最后一个rke2-images.linux-amd64-v1.35.7-rke2r1.txt(前 4 个列表共 180 个镜像已导入完成)。
为什么本机必然踩中:触发条件是"预加载 > 30 分钟"
OS 盘原先在 5400rpm 机械盘(Seagate ST3000VX010,/mnt/xfs)上,而 get_iso() 用 mktemp -p ${TARGET}/usr/local 把 8 GB ISO 下载到目标盘,随后又从同一块盘读回 tar 包并写入 containerd 内容库 —— 读写同轴争抢。
实测时间线:
| 时刻 | 事件 |
|---|---|
| 12:57 | 引导用 rke2 启动(30 分钟死线开始计时) |
| 13:25 | 最后一个镜像列表才开始核对 |
| 13:27:59 | rke2 自杀 → containerd 消失 → 永久卡死 |
预加载耗时约 34 分钟,刚好越过 30 分钟死线。
修复(治本):OS 盘实体迁到 NVMe
/home/<user>/harvester-disks/harvester-NN-os.qcow2 ← 实体(NVMe,宿主根文件系统,283GB 可用)
/mnt/xfs/harvester/disks/harvester-NN-os.qcow2 ← 符号链接(兼容所有既有脚本路径)
/mnt/xfs/harvester/disks/harvester-NN-data.qcow2 ← 实体仍留机械盘(体积大、只在运行期用)数据盘故意留在机械盘:避免 Longhorn 三副本撑爆宿主根分区。 该主机 SELinux 未启用,故无需 semanage/chcon 处理镜像标签。
验证:ISO 下载 ~95 MB/s(原 55–70 MB/s),预加载从 ~34 分钟降到数分钟; 修复后 node02 装机实测 powered off after install (~1006s),node01/03 同级。
兜底(治标):95-rescue-image-preload.sh
已挂进 60-deploy.sh: wait_poweroff 与 65-deploy-all.sh: ensure_booted_from_disk, 每 3 轮(约 30s)用 detect 探测一次,命中则推送救援体执行一次, 并写 logs/rescue-NN.done 标记避免重复。
救援体(95-rescue-image-preload-guest.sh)做三件事:
- 用与
harv-install的 ISO 分支完全相同的参数重启 containerd:→ 已导入的 18 GB 内容库立即可见(参数不一致会看到空库,前功尽弃);-c .../config.toml -a /run/k3s/containerd/containerd.sock \ --state /run/k3s/containerd --root /var/lib/rancher/rke2/agent/containerd - 逐个列表比对,缺哪个就
zstd -d+ctr -n k8s.io images import --no-unpack补哪个; - 核对阶段一结束(连续 0.6s 看不到核对进程)就停掉 containerd —— 否则后续
umount proc/umount ${TARGET}/${RKE2_IMAGES_DIR}这些普通调用在set -e下会因 EBUSY 直接失败。
手工操作:
bash
bash scripts/95-rescue-image-preload.sh 01 run # 推送并执行救援
bash scripts/95-rescue-image-preload.sh 01 log # 看客户机内 /tmp/rescue-image-preload.log案例 8 · ★ 补齐镜像后仍报 ** Installation Failed **(同一根因的第二个坑)
现象:把 17 个缺失镜像导入后,两个 rke2 列表都打印了 done,但紧接着 Stop RKE2... 之后 2 秒就 Install failed: exit status 1。
根因:harv-install 第 291–292 行的收尾动作:
bash
echo "Stop RKE2..."
pkill rke2 # rke2 早已自杀 → 无匹配 → 返回 1而该 heredoc 在第 220 行开了 set -e,于是整个安装中止。
后果远不止"失败":主流程是
elemental install → do_data_disk_format → get_iso → save_configs → save_nm_state
→ do_preload(此处失败)→ update_grub_settings → save_installation_log所以 GRUB 设置补丁与安装日志都没做,只能重装:/oem 里虽有配置, 但 update_grub_settings 负责写 /oem/grubenv、grubcustom 与 third_party_kernel_args, 缺了会导致启动时在 grub 文件搜索上卡约 30 分钟。
修复:救援脚本先起一个名为 rke2 的诱饵进程(cp /bin/sleep /tmp/rke2 后运行), 让 pkill rke2 能返回 0;诱饵随后被 pkill rke2 自己收掉,不留残留。
验证:救援后安装走完 update_grub_settings,节点正常 poweroff 并从硬盘启动。
💡 这两个案例合起来的教训:在
set -e的脚本里,任何"清理类"命令(pkill/umount/rm) 都必须显式容错。上游把pkill rke2写在set -e区块里,是一个只在 "rke2 已死"这条罕见路径上才暴露的缺陷。
案例 9 · 编排器 ensure_booted_from_disk 的两处自愈缺陷(已修)
缺陷 A:virsh dumpxml 偶发返回空 → 误判"已从硬盘运行"
旧代码:
bash
if ! virsh dumpxml "harvester-$n" | grep -q '<kernel>'; then
log "harvester-$n: running from disk"; return 0 # ← 空输出也会走到这里
fi实测事故:09-05 21:29 的日志里误报 harvester-01: running from disk, 而当时域 XML 明明含 <kernel>,VM 还在网络安装中 → 编排器跳过了全部恢复动作, 后续节点再没被推进。
修复:先取 xml 变量并判空,空则记日志继续等,绝不据此下结论。
缺陷 B:shut off + 已是硬盘定义时,从不执行 virsh start
旧代码:只打印 shut off and already defined for disk boot 就 return 0 → 节点永远不会开机,而调用方以为成功了。
修复:补上 virsh start,并保留失败重试(|| { log …; sleep 10; continue; })。
验证:08:03:50 那轮 node01 走 shut off + disk 分支被正确开机, 10 秒后 ssh up,流程继续。
案例 10 · bash 陷阱:local 同行引用刚声明的变量(已修)
现象:刚修好的 65-deploy-all.sh 第一次启动就崩在 ensure_booted_from_disk:
n: unbound variable根因:写法是
bash
local n="$1" mark="$BASE/logs/rescue-${n}.done" # ❌local 的所有参数是先整体展开、再逐个赋值的,所以展开 ${n} 时 n 还不存在; 在 set -u 下直接报 unbound variable。
修复:拆成两行。
bash
local n="$1" mark
mark="$BASE/logs/rescue-${n}.done" # ✅💡 同类陷阱:
declare/export/typeset的多变量单行赋值都一样。
案例 11 · ★ sudo kubectl 在 Harvester 节点上不可用(已修,影响面最大)
现象:wait_node_ready 空转 40 分钟后 abort,进而 node02/node03 永不部署。
定位(节点内实测):
| 检查 | 结果 |
|---|---|
/var/lib/rancher/rke2/bin/kubectl | 存在 |
/usr/local/bin/kubectl | 不存在 |
sudo sh -c 'command -v kubectl' | 输出为空 |
根因:sudo 的 secure_path 不含 rke2 的 bin 目录,Harvester OS 也没有把 kubectl 软链到 /usr/local/bin。于是 sudo kubectl … 静默失败(2>/dev/null 把 command not found 也吞了),循环只能一直等到超时。
修复:所有脚本统一改用绝对路径 + 显式 kubeconfig(已验证 RC=0):
bash
sudo /var/lib/rancher/rke2/bin/kubectl --kubeconfig /etc/rancher/rke2/rke2.yaml get nodes -o wide改动点:60-deploy.sh 与 65-deploy-all.sh 的 kctl()、70-verify.sh 与 80-smoke-vm.sh 的 $K。
💡 排障提示:远程执行时不要把 stderr 全吞掉。本例若保留 stderr, 第一轮就能看到
command not found,不至于白等 40 分钟。
案例 12 · 操作纪律:不要编辑正在运行的 shell 脚本
根因:bash 按字节偏移增量读取脚本文件。运行中修改文件(即使只是插入几行), 会让进程从错误的偏移继续执行,表现为莫名其妙的语法错误或跑错分支。
本次实操:为修 kubectl 路径,先按 PID 精确 kill 掉 65-deploy-all.sh (不用 pkill -f,避免误杀同名子进程),改完再重启。
同理:也不要试图修改安装环境里正在执行的 /usr/sbin/harv-install; 需要改行为就用案例 13 的"注入包装/替换命令"手法。
案例 13 · ★★ node02 卡死 8 小时的真正根因:nm-online 亚秒级竞态
排除法先行:这个故障不是镜像预加载(案例 7)、不是 DHCP、不是配置错误、 不是 网桥 STP 延迟、也不是海外连通性探测超时。前面每一条都曾被认为是根因并白费数小时。
现象
安装器在 Configuring network 步骤报 Can't apply networks: exit status 1, 随后永久停在错误界面:harvester-installer / start-installer.sh 两个进程一直挂着, 磁盘一个字节都没写,VM 既不 poweroff 也不 reboot。
根因
安装器应用管理网络时先 nmcli networking off 再 on,紧接着调用 nm-online; 若那一刻 NetworkManager 还在 ASLEEP,nm-online 不会等待,而是立即返回 1。
guest 内 /var/log/console.log 实测时间线(注意毫秒):
15:02:04.938 manager: disable requested -> NetworkManager state is now ASLEEP
15:02:05 level=error msg="exit status 1\rConnecting............... 30s [offline]\n"
15:02:05.31 dhcp4 (mgmt-br): activation: beginning transaction
15:02:05.44 device (mgmt-br): Activation: successful, device activated
15:02:06.90 manager: NetworkManager state is now CONNECTED_GLOBAL网络其实 0.5 秒后就完全就绪,安装器却已经报错退出了。差 0.5 秒。
两个极容易看错的地方(都曾把排查带偏)
- 输出里的
30s是超时设置值,不是已耗时。 实测nm-online0 秒成功时同样打印 23 个点加30s。 → 看到Connecting..... 30s就以为"等了 30 秒超时"是错的,它可能一秒都没等。 nm-online判定的是"有没有激活的连接",与 NM 的 connectivity 探测无关。 实测把探测地址设成不可达的http://192.0.2.1/,nm-online依然[online] rc=0(而nmcli的CONNECTIVITY会变成limited)。 → 改conncheck-SLE.conf那条路是无效的,别走弯路。 另实测把uri指向本地 HTTP 服务会让 NM 判成portal而非full,同样无效。
修复:注入 nm-online 兜底包装(98 号脚本 + 97 号注入器)
落地依据全部实测,不是猜的:
| 依据 | 实测结论 |
|---|---|
live 环境 /usr 是否可写 | 是可写 overlay → 可以直接放文件 |
安装器如何调用 nm-online | 用裸命令名(二进制里 /usr/bin/nm-online 出现 0 次)→ 靠 PATH 命中 |
| 安装器进程的 PATH | 取自 /proc/<pid>/environ:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin → 注入 /usr/local/bin/nm-online 即命中 |
sudo 的 secure_path | /usr/sbin:/usr/bin:/sbin:/bin:/usr/local/bin:/usr/local/sbin → 另在 /usr/sbin/nm-online 放一份以覆盖 |
NetworkManager-wait-online.service | 用绝对路径 /usr/bin/nm-online,不受影响(不会破坏系统正常逻辑) |
包装体逻辑:用短超时反复重试真 nm-online,把"亚秒级竞态"变成"最多等几秒"。
窗口很紧,怎么抢
| 事件 | 开机后时刻 |
|---|---|
| sshd 开始监听 | +47 ~ 55s |
安装器调 nm-online | +50 ~ 54s |
所以注入器 97-installer-netfix.sh:从 virsh start 起每 0.5s 轮询, 并把三次 SSH 往返压成一次(实测 1.4s 完成注入)。 由 60-deploy.sh: install_node 以 setsid nohup … & 后台启动,不阻塞主流程。
没赶上也不要紧 —— 由案例 14 的自动重试兜底。
案例 14 · 安装器失败后不会自愈,必须自动重启重试(已修)
根因:Harvester 自动安装跑在 netboot 起来的 live 环境里,任何一步失败, 安装器就停在错误界面永远不动,而 60/65 号脚本只会老老实实等到超时 (40~90 分钟)才发现不对。node02 就是这样白等了一夜。
修复:新增 96-install-retry.sh,由 60-deploy.sh: wait_poweroff 与 65-deploy-all.sh: ensure_booted_from_disk 每约 30s 调用一次;判定失败就 virsh reboot (域定义里 <on_reboot>restart</on_reboot>,netboot 参数不变 → 自动安装会重跑)。
限流:每节点最多 3 次、两次间隔至少 120s;重启后会重新调用 97 号脚本注入 (live 环境是内存 overlay,重启即失效)。 wait_poweroff 也改成基于截止时间而非固定轮数,命中重试就把等待预算顺延, 否则重试反而更容易超时。
★ 判据设计的坑:不能用"日志里存在 level=error"
健康安装也会有无害错误。实测本次 node02 安装日志里就有:
level=error msg="unable to determine NIC speed from /sys/class/net/enp1s0/speed (got -1)"若以此为判据,会对正在健康安装的节点反复重启,把成功装机搞成死循环。
现行三条件(必须同时满足):
level=error出现在最后 5 行非空行内;harv-install/elemental/ctr-check-images/rsync/mkfs/qemu-img/parted等干活进程全都不在;⚠️
harvester-installer、start-installer.sh失败后会一直存活, 不能把它们当作"在干活"。- 日志静默 ≥60s。
双向验证:对健康安装中的 node02 返回 1(不触发);对上次真失败的归档日志能命中。
案例 15 · pgrep -f 经 SSH 执行会自匹配(误救援事故)
事故:开机仅 90 秒、安装还停在网络阶段时,看门狗就误判并"救援"了 node02 (rescue-02.done 时间戳 23:02:29,正好在 23:02:05 报错之后 24 秒)。 救援脚本对着不存在的 /run/cos/target 白跑一遍,结尾还 pkill 了 containerd, 把本来还能救的安装彻底打死。
根因:95 号脚本的旧判据只有一条
bash
pgrep -f "ctr-check-images.sh /tmp/images-lists/"而这条命令是 ssh 过去由 bash -c '<整条命令>' 执行的 —— 那个父 shell 的 cmdline 里就含模式串本身,于是该条件恒为真。 再加上它不检查 /run/cos/target 是否已挂载,必然误判。
修复(四管齐下):
- 所有模式串用
[x]括号写法避免自匹配(如ctr-check-image[s].sh); - 必须同时满足:
/run/cos/target已挂载 +images-lists存在 +ctr-check-images.sh已跑 >300s + containerd 不在 + 真正的引导用 rke2 不在;判据用
rke2 serve[r]而非pgrep -x rke2:后者会把救援自己放的诱饵/tmp/rke2(见案例 8)也算进去,导致永远判不出"rke2 已死"。 - 加
mkdir原子锁,避免两个看门狗(60与65各挂了一份)同一秒各救援一次; - guest 侧救援脚本也补前置校验:没进入预加载阶段就什么都不做直接退出 (纵深防御:即使宿主侧误判,guest 侧也不会乱动)。
案例 16 · 编排器的静默 || exit 1(已修)
现象:node02 卡住时,node03 从未被启动过,而 deploy-all.log 里 看不出任何原因——脚本就那么安静地消失了。
根因:
bash
ensure_booted_from_disk "$n" || exit 1 # ❌ 一超时整个脚本无声退出
wait_ssh "$n" || exit 1
wait_node_ready "$n" || exit 1修复:改为显式记录 ERROR + 计入 FAILED + continue 下一个节点, 最后跳过整体验证并以非 0 退出:
bash
if ! ensure_booted_from_disk "$n"; then
log "ERROR: harvester-${n} 未能完成安装(仍在 netboot, 或已停机但没切到硬盘启动)。"
log "ERROR: 排查 logs/harvester-${n}-console.log, 以及安装器内 /var/log/console.log 的 level=error 行。"
log "ERROR: 标记该节点失败, 但**继续**部署后续节点。"
FAILED="$FAILED $n"; continue
fi
…
if [ -n "$FAILED" ]; then
log "=== 部署结束, 但以下节点失败:$FAILED ==="
log "已跳过整体验证; 修复后重跑: bash scripts/65-deploy-all.sh"
exit 1
fi💡 编排脚本的铁律:单点失败要局部化,并且在日志里留下"下一步查什么"。
|| exit 1只适合"后面全依赖它"的场景。
案例 17 · 30-console-capture.sh start 的两个坑(已修)
| 坑 | 现象 | 根因 | 修复 |
|---|---|---|---|
| ① 串口被互抢 | 控制台输出错乱/丢失 | 只 pkill 了 virsh console 子进程,没杀外层重连循环;旧循环 sleep 3 后把它重新拉起,于是新旧两个循环用 --force 反复互抢同一串口。而 install_node 每次都调 start,必然踩到 | 先杀循环、再杀子进程 |
| ② 失败现场丢失 | 复盘时无上次日志 | : > "$LOG" 直接覆盖上次日志 | 轮转为 <log>.<时间戳>.prev |
验证:logs/ 下可见 harvester-02-console.log.20260906-080352.prev、 harvester-03-console.log.20260906-084251.prev 等轮转文件, 本次 node02 的失败现场(案例 13 的毫秒级时间线)正是靠 .prev 复盘出来的。
案例 18 · ⚠️ /mnt/xfs/harvester 曾被整体回滚(环境风险)
现象:排查中途(约 4.5 小时空档后)发现 60-deploy.sh / 65-deploy-all.sh / 70-verify.sh / 80-smoke-vm.sh / README.md 与 logs/全部退回到 9 月 5 日 22:56–23:21 的旧快照:体积、mtime 都变了, 当天凌晨的编辑与日志内容全部丢失,全盘也找不到副本。 而 VM 与常驻进程不受影响(它们在内存/磁盘中独立运行)。
应对纪律:
- 编辑前先核对 mtime/体积是否符合预期:bash
ls -l --time-style=full-iso scripts/ - 关键修复尽量落在独立的共享脚本里(
95/96/97/98), 与"哪一代编排脚本"解耦——本次正是靠这一点,回滚后无需重做案例 13~17 的修复。 - 阶段性成果及时另存(本目录
deploy/scripts/即为一份归档副本)。
案例 19 · ★ wait_node_ready 把 NotReady 当就绪(本次新修)
现象
70-verify.sh 在 node03 还没入集群时就跑完了;日志显示 node02 提前 22 秒、 node03 提前被宣告 Ready。
两个根因(修一个又踩另一个)
根因 A:子串匹配。 旧写法:
bash
kctl get nodes --no-headers | grep "harvester-${n}.*Ready" # ❌NotReady 里也含 "Ready",刚加入尚未就绪的节点会被误判成就绪。
根因 B:过度精确匹配。 改成 $2=="Ready" 后又漏掉一种合法就绪态:
harvester-02 Ready,SchedulingDisabled control-plane,etcd ...这是 Harvester 自动提升节点角色期间的正常状态(见案例 20),节点其实已就绪。 若严格等 Ready,会白等到超时。
修复:精确节点名 + ^Ready(,|$) 状态匹配
bash
kctl get nodes --no-headers 2>/dev/null \
| awk -v n="harvester-${n}" '$1==n && $2 ~ /^Ready(,|$)/ {f=1} END{exit !f}'| 设计点 | 作用 |
|---|---|
$1==n | 精确节点名匹配,杜绝子串/前缀误命中 |
$2 ~ /^Ready(,|$) | 只接受 Ready 或 Ready,<任何后缀>;NotReady 因 ^ 锚定被排除 |
END{exit !f} | 把 awk 的判定结果转成 shell 退出码,便于 && 串接 |
判据对照表(可直接抄用)
| 节点状态 | 应判定 | grep Ready | $2=="Ready" | ^Ready(,|$) |
|---|---|---|---|---|
Ready | ✅ 就绪 | ✅ | ✅ | ✅ |
Ready,SchedulingDisabled | ✅ 就绪(提升期) | ✅ | ❌ 漏判 | ✅ |
NotReady | ❌ 未就绪 | ❌ 误判 | ✅ | ✅ |
NotReady,SchedulingDisabled | ❌ 未就绪 | ❌ 误判 | ✅ | ✅ |
落点:60-deploy.sh 的 node02/node03 分支、65-deploy-all.sh 的 wait_node_ready() (两处都加了注释说明为什么不能用 grep Ready)。
验证:修复后重跑验证,三节点判定时刻与 kubectl get nodes 实际状态一致, 70-verify.sh 在 node03 真正入集群之后才执行。
案例 20 · 节点自动提升为 control-plane,etcd 是正常行为(认知纠偏)
现象:node02 加入后一度显示
harvester-02 Ready,SchedulingDisabled <none> ...曾被当成"节点故障/被误 cordon",差点手工去 uncordon。
定位:观察 harvester-promote-node-controller 的行为与 get nodes 的角色列变化, 发现 SchedulingDisabled 是短暂的,随后角色从 <none> 变为 control-plane,etcd。
结论:Harvester 在 3 节点场景下会自动把 join 节点提升为 control-plane + etcd, 以达成 HA quorum;提升期间临时 cordon 是设计行为,不是故障。
因此:装机配置里 install.role 留空即可,无需手工指定 etcd/control-plane—— 本次三节点全部靠自动提升拿到了正确角色。
验证(HA 硬指标):
harvester-01 Ready control-plane,etcd
harvester-02 Ready control-plane,etcd
harvester-03 Ready control-plane,etcd
kube-system: etcd-harvester-01 / -02 / -03 三个 Pod 均 Running → etcd 成员 3/3,quorum 成立
kube-system: kube-vip 三节点各 1 个连带教训:正因为存在这个合法中间态,就绪判定不能用严格 $2=="Ready" (见案例 19 根因 B);也不要在提升期手工 uncordon,会干扰控制器。
案例 21 · ★ Dashboard 密码正确却 401(认证,详见 03)
现象:configs/credentials.txt 里写的 \1xxx\2 登录 Dashboard 报 401 authentication failed;即使换成登录成功拿到的 token,调 /v1、/v3、/apis 仍全部 401 must authenticate。
根因(三层):
- 装机配置无法设定 dashboard 密码(
xxx§5.3 已用二进制字符串统计证明); - 全新集群初始为
admin/admin(取自cattle-system/bootstrap-secret的bootstrapPassword),且 `mustChangePassword=xxx - 该状态下登录会返回 201 并发出 token,但 token 对所有业务 API 一律 401—— 必须先改密。
修复:新增 75-set-admin-password.sh(幂等),并挂进 65-deploy-all.sh 在整体验证之前自动执行。
完整根因链、API 调用要点(
.tokenvs.id、changepassword必须 POST)、 逐步验证数据见03-凭据与首登认证.md。
案例 22 · credentials.txt 三个标签全错(文档正确性)
现象:照着凭据文件操作,dashboard 登录失败、把 token 当 API token 用也失败。
根因:该文件由 50-gen-configs.sh 生成,原始内容把三类凭据混为一谈:
| 原标签 | 实际语义 | 正确标签 |
|---|---|---|
| `password : xxx | 装机配置的 os.password,即 OS/SSH 密码;当时并不是 dashboard 密码 | xxx + 单列 dashboard password |
| `token : xxx | RKE2 集群加入令牌(node02/03 join 用) | rke2 token(并注明"不是 API token") |
| (缺失) | Dashboard 首登流程与初始密码来源 | 新增"首登说明"段 |
修复:
- 重写
50-gen-configs.sh的凭据块:分列dashboard/rke2 token/ssh, 写明首登流程、.tokenvs.id、changepassword用 POST 等要点; - 同步更新现存
configs/credentials.txt为实测后的真实状态(权限保持 600)。
验证:见 03 §5 的实测输出。
💡 教训:凭据文件里的标签比值更容易出错。值是从变量展开的、不会错; 标签是人写的、会随实现漂移。生成凭据文件时应把"这个值来自哪个配置字段"一并写出来。
案例 23 · ❌ smoke VM 创建被 KubeVirt 准入拒绝(未解决)
现象:bash scripts/80-smoke-vm.sh create 的输出——PVC 建成功,VM 建失败:
persistentvolumeclaim/test-vm-disk0 created
Error from server (InternalError): error when creating "/tmp/test-vm.yaml":
Internal error occurred: replace operation does not apply:
doc is missing path: /spec/template/spec/domain/cpu/maxSockets: missing value残留:default 命名空间下 PVC test-vm-disk0 为 Bound(20Gi / RWO / harvester-longhorn),但没有 VM、没有 importer Pod → 是个空卷。 原因:脚本用裸 PVC + cdi.kubevirt.io/storage.import.* 注解,导入需由 VM/DataVolume 触发, VM 没建起来就没人触发导入。
分析:KubeVirt 的 mutating webhook 会给 VM 打 JSON Patch,其中一条是 replace /spec/template/spec/domain/cpu/maxSockets;而脚本的 VM spec 里 只有 resources.requests.cpu: "2",完全没有 spec.template.spec.domain.cpu 段, 路径不存在 → replace 失败 → 整个 apply 被拒。
候选修法(按优先级,均未验证):
- 在 VM spec 里显式写出 CPU 拓扑,让 patch 路径存在:yaml必要时再补
spec: template: spec: domain: cpu: sockets: 1 cores: 2 threads: 1maxSockets。 - 改用 DataVolume 而非"裸 PVC + CDI 注解",与 Harvester UI 的创建路径保持一致 (UI 走
harvesterhci.io/volumeClaimTemplates+ DV)。 - 核对
virt-api/ webhook 版本与 CRD 是否匹配(本集群 KubeVirt 随 Harvester v1.8.2 内置)。
清理残留:
bash
ssh rancher@192.168.150.11 'sudo /var/lib/rancher/rke2/bin/kubectl \
--kubeconfig /etc/rancher/rke2/rke2.yaml delete pvc test-vm-disk0 -n default'案例 24 · ★ CoreDNS 随机上游 → 域名间歇解析到公网(最隐蔽的一个)
现象:cattle-cluster-agent 反复报 websocket: bad handshake,Rancher 侧集群 state=error,而且时好时坏——重启 agent 有时能好一阵子,随后又坏。
定位:在节点上直查 CoreDNS 20 次并统计分布:
bash
for i in $(seq 1 20); do dig +short ai-ear.cn @10.53.0.10 | tr '\n' ' '; echo; done \
| sort | uniq -c
# 10 次 → 192.168.122.20 21 22(内网 ingress)
# 10 次 → 8.153.84.140(公网文档站) ← 问题所在根因:节点 /etc/resolv.conf 并列两个上游(192.168.150.1 宿主 dnsmasq + 223.5.5.5 公网 DNS),而 Harvester 的 CoreDNS 配置是 forward . /etc/resolv.conf, 默认 policy=random → 每次查询随机选一个上游。 公网 443 上是 VitePress 文档站,于是 agent 连 wss://ai-ear.cn/v3/connect/register 拿到的是首页 HTML → websocket: bad handshake。
因为 CoreDNS 有 cache 30 + 上游 TTL 过期后会自愈,所以表现为反复抖动而非持续失败, 极易被误判成"网络不稳"或"agent 版本问题"。
修复(三处,缺一不可):
- 节点 DNS 收敛为只用宿主 dnsmasq:
nmcli con mod bridge-mgmt ipv4.dns 192.168.150.1+nmcli device reapply mgmt-br(★ 用device reapply而不是con up,不断链); - 重启 CoreDNS:
rollout restart deploy/rke2-coredns-rke2-coredns(forward插件只在启动时读/etc/resolv.conf,见案例 27); - 装机配置同步收敛:
50-gen-configs.sh与config-node0*.yaml的dns_nameservers只留192.168.150.1(原文件备份为*.bak-dns), 避免重装后又引入随机上游。
验证:20/20 全部返回内网 IP;agent 稳定 Running;Rancher 侧 state=active。
去掉公网 DNS 无副作用:宿主 dnsmasq 自身会转发公网域名(实测 www.baidu.com 正常解析)。
💡 教训:"间歇性"故障优先怀疑随机/轮询类配置(DNS policy、LB 算法、重试抖动)。 定位手段就是把"随机"变成可统计的分布(跑 N 次数结果),而不是多试几次碰运气。
案例 25 · agent 镜像拉不下来:私仓是 HTTP,而节点没有外网
现象:
crictl pull 192.168.122.156:30000/rancher/rancher-agent:v2.14.3
→ http: server gave HTTP response to HTTPS client定位:
| 检查 | 结果 |
|---|---|
节点出厂 /etc/rancher/rke2/registries.yaml | 0 字节(没有任何私仓声明) |
直连 auth.docker.io | HTTP 000(节点无外网) |
根因:containerd 默认按 HTTPS 访问 registry;而 Rancher 的 system-default-registry=192.168.122.156:30000 是纯 HTTP 私仓,必须显式声明 insecure/http endpoint。又因为节点没有外网,agent 镜像只能走私仓,没有退路。
修复:见案例 26——必须走 Harvester 的 containerd-registry 设置,不能手写文件。
案例 26 · ★ 手写 registries.yaml 会被控制器回收
现象:手工写入 167B 的 registries.yaml,约 1 分钟后被清成 0 字节。
根因:Harvester 会按 containerd-registry 设置渲染这个文件; 设置里没内容 → 渲染出空文件,覆盖手写内容。
修复:改设置而不是改文件(99 号脚本步骤 F):
bash
kubectl patch settings.harvesterhci.io containerd-registry --type merge \
--patch-file /tmp/containerd-registry.patch.json其中 value 是一段 JSON 字符串:
json
{"mirrors":{"<reg>":{"endpoints":["http://<reg>"]}},
"configs":{"<reg>":{"tls":{"insecureSkipVerify":true}}}}字段名是怎么确定的(无官方文档可查时):从 /usr/bin/rancherd 二进制的 struct tag 反查,确认是 mirrors.<reg>.endpoints[] + configs.<reg>.tls.insecureSkipVerify; 渲染到 rke2 时会转成 endpoint / insecure_skip_verify。
验证:设置生效后 3 节点 registries.yaml 均为 266B;rke2 随即重写 certs.d, 文件头变成 # File generated by rke2. DO NOT EDIT.;crictl pull 成功。
💡 教训:在"有控制器管文件"的系统里,永远改声明源(CRD/设置),不要改被渲染的产物。 产物文件头的
DO NOT EDIT就是明确提示。写进去能生效一小会儿,正是最迷惑人的地方。
案例 27 · 接入 Rancher 的三个操作细节坑(合并记录)
| 坑 | 现象 | 修复 |
|---|---|---|
CoreDNS forward 只在启动时读 resolv.conf | 改完节点 DNS 后仍解析到公网 | kubectl -n kube-system rollout restart deploy/rke2-coredns-rke2-coredns |
crictl/ctr/kubectl 需绝对路径 | sudo crictl → command not found(同案例 11 的 secure_path 问题) | /var/lib/rancher/rke2/bin/{kubectl,crictl,ctr};crictl 还需 --runtime-endpoint unix:///run/k3s/containerd/containerd.sock(节点没有 /etc/crictl.yaml) |
| node02/03 的 SSH host key 变过 | WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! | 脚本统一加 -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no(重装节点必然换 key) |
另外两条认知类坑:
- 集群名
harvester已被占用:Rancher 里已有一个provider=rke2的容器集群叫harvester(c-m-l66tnt9v),与本次导入无关。本次用harvester-hci避免混淆; 两者分属 Cluster Management 与 Virtualization Management 两个页面,互不冲突。 harvesterconfig/harvestercredentialconfig不是导入用的:那是"用 Harvester 当 基础设施 provider 去创建下游 K8s 集群"的路径。导入已有 Harvester 走标准POST /v3/clusters,provider 由 Rancher 从下游节点标签provider.cattle.io: harvester自动识别。
案例 28 · ★ dashboard 密码与 SSH 密码同值导致误用(+ 浏览器 401 但 API 正常)
现象(两种表现)
- 照凭据文件登录 Dashboard 反复失败,但同一串密码 SSH 登录节点完全正常;
- 浏览器登录报 401,而用
curl拿同一个密码调登录 API 却返回 201。
定位:用"三态判定"把问题一刀切开
bash
API=https://192.168.150.200
for pw in 'xxx' 'xxx' 'admin'; do
printf '%-18s -> %s\n' "$pw" "$(curl -sk -m 10 -o /dev/null -w '%{http_code}' \
-X POST "$API/v3-public/localProviders/local?action=login" \
-H 'Content-Type: application/json' \
-d "$(jq -n --arg u admin --arg p "$pw" '{username:$u,password:$p,ttl:3600000}')")"
done实测输出(2026-09-06 10:15):
xxx -> 201 ← 当前 dashboard 密码
xxx -> 401 ← 只是 SSH 密码,dashboard 已失效
admin -> 401 ← 引导密码已失效判别口径:curl 能拿 201 而浏览器 401 ⇒ 服务端凭据是对的,问题在客户端。
根因
| # | 根因 | 说明 |
|---|---|---|
| 1 | 两类密码同值 | xxx 既是装机配置的 os.password(SSH),又被 75 号脚本设成了 dashboard 密码。凭据文件里一行 `password : xxx 既像 dashboard 又像 SSH,使用者自然会拿 SSH 密码去登 dashboard |
| 2 | 客户端三类问题 | ① 输入法把 @ 打成全角 @(看起来一模一样);② 浏览器自动填充了旧密码;③ Cookie / 会话残留(之前 401 过就一直 401) |
修复
- 两类密码拆成不同值:
xxx新增DASHBOARD_PW='xxx',PASSWORD='xxx'保持为os.password;75-set-admin-password.sh的NEW_PW默认值改为DASHBOARD_PW的值。 脚本注释里写明了拆分理由,防止后人"顺手统一"回去。 - 凭据文件显式声明:
credentials.txt增加 "dashboard 密码与 ssh 密码不是同一个(故意分开)", 并附浏览器 401 的三条客户端排查提示。 - 执行二次改密(引导密码已失效,故
xxx传现有密码):bashBOOT_PW='xxx' bash scripts/75-set-admin-password.sh 'xxx'
验证(实测)
| 检查 | 结果 |
|---|---|
changepassword | 200 |
| 新密码登录 | 201 |
mustChangePassword | false |
/v1/harvester/nodes(业务 API) | 200 |
| 旧 dashboard 密码 | 立即 401 |
引导密码 xxx | 401 |
教训
💡 同类但不同用途的凭据不要同值。 同值时"登录失败"这个信号失去信息量—— 你无法从失败中判断是"密码错了"还是"用错了哪一类密码"。 拆成不同值后,失败本身就能指认错误类型。
💡 "浏览器失败 + API 成功"是客户端问题的铁证:先用无痕窗口重试, 别急著去改服务端密码(改了反而制造出第二个问题)。
案例 29 · ★ harvester-03 guest 硬死锁:blkid soft lockup → 节点 NotReady 22 小时(硬重启恢复)
现象
kubectl get nodes:harvester-01/02 Ready,harvester-03 NotReady(control-plane,etcd); kubelet lease 停止续约,节点被打上node.kubernetes.io/unreachable等污点。- guest 完全失联:SSH 超时、
virsh console无回显、不响应 ARP/ICMP。 - 宿主机侧一切"正常":
virsh domstate= running、virsh domblkerror= No errors found、 宿主dmesg无 I/O 错误、无 OOM。 - 但 QEMU CPU 长期打转,且
virsh domblkstat两次采样 I/O 计数不增长 → guest 在"空转",不是宿主机卡住。 - 连带影响:Longhorn 两个卷
faulted(harvester-longhorn-r1单副本卷,唯一副本就在 node03)、test-vm-01根盘卷degraded;两个 CDIimporter-prime-*Pod 卡ContainerCreating21h+,rhel96-c2/c3-disk-0PVC 一直Pending。
定位过程(宿主机 → guest 逐层排除)
| 层 | 检查 | 结果 |
|---|---|---|
| 宿主机块设备 | virsh domblkerror harvester-03、宿主 dmesg | grep -iE "i/o error|nvme|ata" | 干净 → 不是宿主盘/后端问题 |
| 宿主机资源 | free、load average、OOM 记录 | 无 OOM(负载高但与本故障无因果) |
| libvirt 域 | virsh domstate/dominfo、domblkstat 两次采样 | running;I/O 计数不增长 |
| guest 存活性 | SSH / console / virsh reboot(ACPI) | 全部无响应 → guest 内核死锁 |
| guest 事后日志 | 恢复后 journalctl -b -1 | 见"根因" |
💡 区分"guest 硬死锁 vs 宿主机故障"的关键两步:
virsh domblkerror(宿主侧有无块错误) +virsh domblkstat两次采样(I/O 计数是否还在走)。两者"干净 + 不动" → 问题在 guest 内部。
根因(journalctl -b -1 实证)
上一轮启动的日志在 2026-09-07 05:06:01 戛然而止,死前 5 分钟是一条完整因果链:
04:59:37 iscsid: Connection125:0 to iqn.2019-10.io.longhorn:pvc-b5aa4ce3... is operational now
05:01:31 kernel: EXT4-fs error (device sdc) in ext4_init_inode_table:1622: IO failure
05:01:31 kernel: EXT4-fs error (device sdc) ... Journal has aborted (comm virt-cdi-import)
05:01:32 kernel: Buffer I/O error on dev sdc, logical block 0, lost sync page write
kernel: sdc: rw=12288, sector=74120, nr_sectors = 8 limit=0 ← ★ 设备容量被置 0
05:01:33 kernel: virt-cdi-import: attempt to access beyond end of device
05:01:45 kernel: sd 11:0:0:1: [sdf] 266760192 512-byte logical blocks ← iSCSI 重新挂上新设备
05:01:49 kernel: EXT4-fs (sdc): unmounting filesystem 4f9dd7af-...
05:02:17 kernel: watchdog: BUG: soft lockup - CPU#1 stuck for 22s! [blkid:3846941]
05:02:17 kernel: watchdog: BUG: soft lockup - CPU#3 stuck for 22s! [blkid:3847019]
05:02:53 kernel: rcu: INFO: rcu_preempt detected stalls on CPUs/tasks:
05:03:13 → 05:06:01 stuck for 75s → 101s → 127s → 153s → 179s → 205s → 231s(日志到此为止)一句话:CDI 导入进行中,Longhorn 把它底层的 iSCSI 块设备(sdc)拆了(limit=0) → virt-cdi-import 的 ext4 出现 I/O 失败、journal 中止、被迫卸载;紧接着对同一设备的 blkid 探测 在内核里死循环(栈顶 __x64_sys_close,syscall 号 ORIG_RAX=3 即 close()),把 CPU#1/CPU#3 双双锁死, rcu_preempt 随即 stall —— 整机进入"活着但不干活":不 panic、不重启、不理 ACPI。
- 为什么会拆设备:这两个导入卷用
harvester-longhorn-r1(单副本),唯一副本在 node03 本机; 导入是 6GB 级顺序大写 + 机械盘后端,副本/引擎超时后 Longhorn 判卷faulted并 detach/attach, 于是有 04:59~05:01 的 iSCSI 会话反复 up/down 与设备重建(sdc→sdf)。 - 为什么"死锁"而不是"自动重启":三节点一致为
kernel.softlockup_panic = 0、kernel.hung_task_panic = 0、kernel.hung_task_timeout_secs = 0(COS 默认)。 soft lockup 只打印告警不触发 panic,所以kernel.panic = 10(panic 后 10s 自动重启)这条自愈路径 根本没被走到 → 节点可无限期挂着(本次 22 小时)。
修复(本次实际操作,含安全判定)
virsh reboot(ACPI)被死锁 guest 忽略,只能硬重启。硬重启前先确认"不会连带打死集群/数据":
bash
# ① 集群还能容忍这台消失吗(3 节点 etcd,quorum=2)——在 node01/02 上跑
sudo /var/lib/rancher/rke2/bin/etcdctl --endpoints=https://127.0.0.1:2379 \
--cacert=/var/lib/rancher/rke2/server/tls/etcd/server-ca.crt \
--cert=/var/lib/rancher/rke2/server/tls/etcd/client.crt \
--key=/var/lib/rancher/rke2/server/tls/etcd/client.key endpoint health
# ② 这台上面有没有正在跑的虚机(VMI)
kubectl get vmi -A -o wide | grep harvester-03 # 本次:无
# ③ 有没有 Longhorn 卷"仅靠"这台(活跃附着 / 唯一副本)
kubectl -n longhorn-system get volumes.longhorn.io -o wide
kubectl -n longhorn-system get replicas.longhorn.io \
-o custom-columns=V:.spec.volumeName,N:.spec.nodeID,S:.status.currentState
# ④ 硬重启
virsh destroy harvester-03 && sleep 5 && virsh start harvester-03
virsh console harvester-03 # 看引导;Ctrl+] 退出本次判定:etcd quorum 健康、node03 上无 VMI、无卷"活跃附着且只靠它"(faulted 卷的唯一副本 本就不可用,硬重启不会让情况更坏)→ 执行 destroy + start。
验证(实测,2026-09-08 03:02 → 03:10 UTC)
| 项 | 结果 |
|---|---|
kubectl get nodes | 3 节点全部 Ready control-plane,etcd(node03 约 3 分钟回归,lease 恢复续约) |
| 节点污点 | unreachable/not-ready 自动清除,无需手工 uncordon |
| 管理面 | harvester 3/3、harvester-webhook 3/3、rancher 3/3、kube-vip 3/3 |
| Longhorn | 原 faulted 卷恢复 attached/healthy;degraded 卷副本自动重建中 |
| VIP / Dashboard | https://192.168.150.200/ping 200、/dashboard 200 |
bash scripts/70-verify.sh | 三节点 SSH OK;输出留档 logs/postmortem-node03/verify-after-recovery.txt |
| CDI 导入 | importer-prime-* 从卡死 21h → 重新调度到 node03 且 Running,进度继续(scratch PVC 自动 Bound) |
证据留档 /mnt/xfs/harvester/logs/postmortem-node03/:console-node03-full.log、 prev-boot-journal-tail.txt、describe-node03.txt、longhorn-{volumes,replicas}.txt、pods-all.txt、 pvcs.txt、nodes.txt、host-side.txt、reboot-timeline.txt、verify-after-recovery.txt、README-postmortem.md。
⚠️ 复发信号(恢复后 45 分钟内又出现同一套签名,2026-09-08 03:45→03:57 UTC,node03)
注意是 journalctl -b 0(本次启动),不是 -b -1:
| 时刻(UTC) | 内核日志 | 含义 |
|---|---|---|
| 03:45:29 | EXT4-fs error (device sdb) in ext4_init_inode_table:1622: IO failure + Detected aborted journal | scratch 卷底层设备 I/O 失败,EXT4 日志被 abort |
| 03:46:22 | EXT4-fs error (device sdc): Detected aborted journal | 第二个 scratch 设备同样下场 |
| 03:46:30–31 | umount: attempt to access beyond end of device sdc: rw=12288, sector=84xx, nr_sectors = 8 **limit=0** ×9 | 与 9/7 死锁前完全相同的签名:设备已被 Longhorn 拆掉(容量归零) |
| 03:55:07 | EXT4-fs error (device sda): … comm **virt-cdi-import** | 这次卡住的是导入线程本身 |
| 03:55:08 | virt-cdi-import: attempt to access beyond end of device sda: … limit=0 | 同上 |
| 03:55:43 / 03:57:04 | scsi 6:0:0:1 [sda] … Attached SCSI disk / EXT4-fs (sda): mounted … r/w | Longhorn 经 tgtd/iSCSI 重新挂上新 scratch 设备,导入自动续跑 |
上层现象(describe dv rhel96-c3-disk-0):write /scratch/tmpimage: input/output error → read-only file system → ImportFailed(x16 over 25h)→ 新建 importer Pod + 新 scratch PVC (pvc-64291252 → pvc-2e80f9a4)→ 进度先回退再继续(63.67% → 49.53% → 62.29% → 72.81%, CDI checkpoint 生效,未归零)。
复发原因(本次补齐了"负载侧"根因):node03 只有 8 vCPU,恢复后同时承担 ① 两个 CDI 导入(各写一个 127GiB / 3 副本 scratch 卷,数据经 tgtd/iSCSI 走网络写三份) ② Longhorn 副本重建(pvc-2e80f9a4-r-1ea6c780 在 node02 重建)③ 控制面(apiserver 23%、etcd 15%)。 实测 load average 35.10、%Cpu 42.0 wa / 27.0 sy / 10.0 si、jbd2/sda-8 与 jbd2/vdb-8 长期 D 状态。 I/O 饱和 → Longhorn 副本心跳超时 → 引擎断连 → 设备被拆(limit=0) → guest EXT4 日志 abort → 导入失败重来 → 新卷再重建 → 正反馈。
这次拿到了"心跳超时"的直接证据(Longhorn 引擎日志,04:25–04:26,node03 longhorn-manager):
text
pvc-8e5f453d-…-e-0: level=error msg="R/W Timeout. No response received in 8s" func="dataconn.(*Client)…"
pvc-8e5f453d-…-e-0: level=error msg="Setting replica tcp://10.52.1.13:11440 to ERR"
pvc-8e5f453d-…-e-0: level=error msg="Setting replica tcp://10.52.0.57:11332 to ERR"
pvc-8e5f453d-…-e-0: level=error msg="Ignoring error because tcp://10.52.2.13:10273 is mode RW"即:跨节点副本 8 秒内没回 ack → 引擎把它标 ERR;副本被打光后卷 faulted → 前端设备被拆(limit=0) → guest 里就是上面那串 EXT4 错误。所以 R/W Timeout. No response received in 8s 是比内核报错更早的前兆, 巡检应优先 grep 它(已写进 04 §4.4 第 5 步"预警信号"②)。
代价是进度真的大幅回退(实测 thrash 循环):
| 时刻(UTC) | c2 | c3 | 事件 |
|---|---|---|---|
| 04:05 | 95.62%(已在 qemu.go 转换阶段 8.15%) | 77.89% | — |
| 04:23–04:24 | — | — | sde/sda 再次 Detected aborted journal + limit=0 |
| 04:24 | — | — | 两个 importer Pod 被重建(AGE 归零)、scratch 卷再换代(pvc-8e5f453d、pvc-f32dcf2f) |
| 04:28 | 35.89%(退回 prometheus.go 下载阶段) | 35.13% | 转换阶段成果全丢 |
| 04:38 | 80.88% | 78.18% | 重新爬升(约 8%/2min),kernErr 稳定在 26 未再增 |
📌 两个 DV 是独立对象(无
ownerReferences,由kubectl apply创建):源http://192.168.150.1:8080/images/rhel96-c{2,3}.qcow2,目标120Gi+harvester-longhorn-r1(单副本) +volumeMode: Block; 对应 VM(rhel96-c2/c3)尚未创建(当前只有rhel96=Paused、rhel96-c1=Running)。 → 必要时可安全delete dv再重放 YAML(完整定义就在last-applied-configuration注解里),不牵连 VM。📌 高危窗口是"转换阶段":下载阶段只写 3 副本 scratch;转换阶段要同时读 scratch + 写单副本目标卷, 两个导入并行时最容易触发 8s 超时。要保进度就串行(一次只跑一个导入),别把两个大导入压在同一节点。
🚨 9/7 死锁的温床仍在:只要
softlockup_panic=0,某次拆解正好卡住内核路径(上次是blkid)就会重演 "挂死但永不自愈"。导入期间务必压低 node03 的 I/O 并发,并尽快决策内核加固(见下)。
本次处置(全部只读,未改变导入):
- 起只读监控
logs/postmortem-node03/import-watch.log:每 120s 记录 DV 进度、异常 Pod 数、 degraded 卷数、node03 load / D 状态进程数 / 内核告警计数。脚本/tmp/watch-imports.sh。 - 排除"孤儿卷"误判:
pvc-b4800aff(=test-vm-01-disk-0) 与pvc-cacb6efb(=test-vm-disk0) 的robustness=unknown+ longhorn-manager 每 30sFailed to get purge status for engine …是卷已 detached、引擎没运行导致的正常噪声,PVC 均Bound完好 → 无需处理。 rhel96-c2-disk-0的95.62%不是卡死:importer 日志显示已进入qemu.go:283转换阶段(3.05→8.15 递增)。
教训
💡 节点 NotReady ≠ 集群问题,先分清"宿主机 / guest 内核 / kubelet"三层。
virsh domblkerror+domblkstat两次采样 +journalctl -b -1三招即可锁定 guest 内核。💡 ACPI 重启对死锁 guest 无效,别反复
virsh reboot干等;判定死锁后直接destroy+start, 但务必先做 ①etcd quorum ②VMI ③卷副本归属 三项安全判定。💡 单副本卷(
harvester-longhorn-r1)+ 大写入导入是高危组合:唯一副本所在节点一旦 I/O 超时, 卷 faulted → 设备被拆 → 上层(CDI/ext4/blkid)连锁炸。导入类负载建议用 3 副本 SC, 且导入期间不要对同节点叠加其他 I/O 压力。💡 "挂了但不会自愈"是最贵的状态。 建议开
kernel.softlockup_panic=1(配合已有的kernel.panic=10→ panic 后 10s 自动重启),并考虑kernel.hung_task_timeout_secs=120+kernel.hung_task_panic=1;代价是"误杀"(短暂卡顿也重启),需按业务权衡。本次未改动,列为 follow-up。
案例 30 · 离线环境里 fleet-agent ErrImagePull:镜像引用指向 docker.io,而节点根本没有外网(✅ 已修:给 docker.io 配私仓 mirror)
现象
node03 恢复后管理面全绿,唯独 cattle-fleet-local-system/fleet-agent 起不来:
fleet-agent-569cb6b598-4cz62 0/1 ImagePullBackOff harvester-03
Failed to pull image "rancher/fleet-agent:v0.15.4": failed to resolve reference
"docker.io/rancher/fleet-agent:v0.15.4": ... dial tcp 108.160.161.83:443: connect: connection refused定位过程
- 不是 node03 配置漂移:三节点
/etc/rancher/rke2/registries.yaml完全一致(266B,md50eb1e67b…,只声明内网私仓192.168.122.156:30000);RKE2 渲染出的/var/lib/rancher/rke2/agent/etc/containerd/certs.d/(★ 不是/etc/rancher/rke2/certs.d, 后者在本集群根本不存在;路径由 containerdconfig.toml的[plugins.'io.containerd.cri.v1.images'.registry] config_path指定)三节点同样只有192.168.122.156:30000/一个条目,node03 的在重启后03:05被正确重新渲染 → 案例 26 的机制工作正常。 关键发现:设置里从来没有docker.io的 mirror,所以任何docker.io/*短名引用都只能直连公网。 - 节点确实没有外网:
curl -sI https://1.1.1.1→000;默认路由走192.168.150.1(宿主 dnsmasq), 而 DNS 应答是污染的(registry-1.docker.io→2a03:2880:…face:b00c…、108.160.161.83、173.208.182.68、45.114.11.25,每次不同且全部connection refused)。 - 镜像其实在本地:node03 containerd 里
docker.io/rancher/fleet-agent:v0.15.4与192.168.122.156:30000/rancher/fleet-agent:v0.15.4并存,同一个 image ID7d99da6683786(complete (3/3)、UNPACKED=true);Pod 也确实是单容器 +imagePullPolicy: IfNotPresent+ 无 pull secret。 但 kubelet 仍然发起了 PullImage(事件failed to resolve reference说明它先去 registry 解析 tag, 而不是直接用本地记录)。⚠️ 排查工具坑:本集群没有
/etc/crictl.yaml,crictl会去连默认的/run/containerd/containerd.sock(不存在)而 fatal;必须显式crictl -r unix:///run/k3s/containerd/containerd.sock。ctr同理必须--address /run/k3s/containerd/containerd.sock,漏了这个参数ctr会静默返回空, 极易误判成"镜像不存在"(本次排查就踩到,ctr -n k8s.io images ls | grep fleet输出为空)。 - 对照组一步定位:正常 Running 的那个 fleet-agent(
cattle-fleet-system,node02)用的是192.168.122.156:30000/rancher/fleet-agent:v0.15.4(内网私仓全名)。 实测 node03 从私仓拉同一镜像成功:crictl pull 192.168.122.156:30000/rancher/fleet-agent:v0.15.4→Image is up to date for sha256:7d99da6683786…。 - 删除 Pod 复验:新 Pod 仍被调度到 node03(重启后资源最空),仍 ErrImagePull → 不是调度偶然。
根因
Rancher 渲染 cattle-fleet-local-system/fleet-agent 用的是 docker.io 短名 (settings.management.cattle.io/system-default-registry 为空),而本环境是离线内网: 所有镜像必须走内网私仓。它此前能跑,纯粹因为 node01/02 早已缓存该镜像; 故障后 Pod 被重新调度到刚重启的 node03,触发了一次真实拉取,于是暴露了这个长期存在的引用错误。
影响面
只影响内网 Rancher 的 local cluster fleet agent(Rancher 侧本地集群会显示不可用)。 Harvester/RKE2 本身、cattle-cluster-agent、cattle-fleet-system/*(fleet-controller/gitjob/helmops/fleet-agent) 均正常。与 node03 故障无因果关系——该 Pod 在故障期间已 Pending 21h。
修复(✅ 已实施 · 方案 A · 2026-09-08 03:52 UTC)
做法:改 Harvester containerd-registry 设置(案例 26 的正规路径,绝不手写产物文件), 在原有私仓条目基础上追加 docker.io → 内网私仓的 mirror:
bash
K='sudo /var/lib/rancher/rke2/bin/kubectl --kubeconfig /etc/rancher/rke2/rke2.yaml'
# 1) 先备份现值(回滚用)
$K get settings.harvesterhci.io containerd-registry -o yaml > backup-containerd-registry.yaml
# 2) merge patch(value 是一段 JSON 字符串,注意转义)
cat > /tmp/cdr-patch.json <<'EOF'
{"value":"{\"Mirrors\":{\"192.168.122.156:30000\":{\"Endpoints\":[\"http://192.168.122.156:30000\"],\"Rewrites\":null},\"docker.io\":{\"Endpoints\":[\"http://192.168.122.156:30000\"],\"Rewrites\":null}},\"Configs\":{\"192.168.122.156:30000\":{\"Auth\":null,\"TLS\":{\"CAFile\":\"\",\"CertFile\":\"\",\"KeyFile\":\"\",\"InsecureSkipVerify\":true}}},\"Auths\":null}"}
EOF
$K patch settings.harvesterhci.io containerd-registry --type=merge --patch-file=/tmp/cdr-patch.json
# → setting.harvesterhci.io/containerd-registry patched⚠️ 字段名大小写:集群里现存的 value 是 PascalCase(
Mirrors/Endpoints/Rewrites/Configs/Auth/TLS/CAFile/InsecureSkipVerify/Auths),与案例 26 里从rancherdstruct tag 反查到的 小写写法不同。Go 的json.Unmarshal字段匹配大小写不敏感,两种都能解析,但照抄集群现存结构最稳妥。 私仓是 HTTP + 免认证,故Auth: null+InsecureSkipVerify: true,mirror 复用同一条Configs; Harbor 里的仓库路径与 docker.io 同名(rancher/fleet-agent),所以Rewrites: null就够 (只有私仓路径不同,例如放在别的项目下,才需要Rewrites做正则改写)。
控制器自动做了什么(实测,无需人工干预):harvester-node-manager 逐节点重写 /etc/rancher/rke2/registries.yaml(266B → md5 794d5153…)并滚动重启 rke2-server (node01 03:52:26 → node02 03:52:43 → node03 03:53:00,间隔约 17s)。 containerd 是 rke2 的子进程(没有独立 systemd unit),所以必须重启 rke2 才能生效。 RKE2 随即渲染出 …/containerd/certs.d/docker.io/hosts.toml:
toml
# File generated by rke2. DO NOT EDIT.
server = "https://registry-1.docker.io/v2" # ← 兜底:mirror 失败仍回落原仓
capabilities = ["pull", "resolve", "push"]
[host]
[host."http://192.168.122.156:30000/v2"]
capabilities = ["pull", "resolve"] # ← 不含 push
skip_verify = true验证链(时间戳即证据):
| 时刻(UTC) | 事件 |
|---|---|
| 03:52:18 / 03:52:54 | node01 / node03 registries.yaml 被重写(新增 docker.io) |
| 03:52:26 / 03:52:43 / 03:53:00 | 三节点 rke2-server 依次重启(滚动,etcd quorum 全程保持) |
| 03:53:04 | node03 渲染出 certs.d/docker.io/hosts.toml |
| 03:53:08 | kubelet Successfully pulled image "rancher/fleet-agent:v0.15.4" in **138ms** → 容器 Started |
| 03:54 | Pod 1/1 Running;全集群异常 Pod 归零(3 节点 Ready,/healthz 各项 ok 含 etcd ok) |
bash
$K -n cattle-fleet-local-system get pod -o wide
# fleet-agent-569cb6b598-z4nxj 1/1 Running 0 10.52.2.25 harvester-03
# containerStatuses.imageID → 192.168.122.156:30000/rancher/fleet-agent@sha256:014ae8c3…(确实走了 mirror)
# 手工复验:
crictl -r unix:///run/k3s/containerd/containerd.sock pull docker.io/rancher/fleet-agent:v0.15.4
# → Image is up to date for sha256:7d99da6683786…副作用(都可接受,但必须知道):
- rke2/kubelet 重启后两个 CDI
importer-prime-*Pod 被重建(新 Pod、RESTARTS=0,DataVolumeRESTARTS计数 → 4);进度没有归零:rhel96-c2-disk-043.14%、rhel96-c3-disk-063.67%(CDI 断点续传)。 - CDI scratch 卷随之换代(旧
pvc-45e7d22e/pvc-65a31c5c→ 新pvc-64291252/pvc-e2d5b613),新卷degraded重建中。 - ⚠️ 别在重负载导入/迁移期间随手改集群级设置:这类改动会滚动重启 rke2-server。本次是有意为之且已验证无损。
- 设置的
status.conditions[type=configured].lastUpdateTime仍停在旧值(2026-09-06T01:39:18Z), 不随重新下发刷新 → 别拿它当"已生效"的证据,要看节点上的registries.yaml/certs.d实物。
回滚:用备份里的原 value 再 patch 一次即可,控制器同样会滚动重启 rke2。 备份留档 logs/postmortem-node03/backup-containerd-registry-20260908-034649.yaml。
未采用的备选方案(当时待决策,现已选 A)
| 方案 | 做法 | 代价 / 风险 |
|---|---|---|
| B(Rancher 层) | system-default-registry = 192.168.122.156:30000 | Rancher 全局生效,会重新渲染其托管 Deployment → 刚恢复完可能带来一波 Pod churn |
| C(临时) | 手工把 Deployment 镜像改成私仓路径 / 让 Pod 别落 node03 | 会被 Rancher 的 fleet-controller reconcile 回滚,治标不治本 |
教训
💡 离线环境里"镜像拉不下来"要先问"这个引用该走哪个仓",而不是先怀疑网络或私仓。 对照"能跑的同类 Pod 用的是什么引用"往往一步定位(本例:私仓全名 vs
docker.io短名)。💡 "本地已有镜像 + IfNotPresent"不是保险:kubelet 仍会去 registry 解析 tag(本例即如此), 离线环境里指向公网仓的引用必失败。镜像引用要与分发方式一致,别依赖"碰巧缓存过"。
💡 离线集群的正解是给
docker.io配 mirror,而不是逐个改镜像名:一次真实拉取(Pod 重调度、 节点重装、kubelet 校验 tag)就会暴露所有短名引用。改设置而非手写产物(案例 26), 并让server = registry-1.docker.io保留兜底 → 私仓缺镜像时行为与改之前一致,无回归。💡 先确认 Harbor 能匿名提供该镜像再配 mirror:
/v2/_catalog返回UNAUTHORIZED是正常的 (Harbor 默认禁止匿名列仓库),不代表拉不了;用ctr --address … -n k8s.io images pull --plain-http <私仓>/<repo>:<tag>实测一次才算数(本例 0.2s 成功)。
附 · 排障方法论(从这 30 个案例里提炼)
1. 先分层,每层都要有可观测点
| 层 | 可观测点 | 本次踩的坑 |
|---|---|---|
| 宿主(libvirt/磁盘/网桥) | virsh list/domstate/dumpxml、df、ls -l disks/、iptables -L FORWARD | 案例 7(机械盘同轴争抢)、9(dumpxml 空)、24(跨网桥被 REJECT) |
| 引导(HTTP/dracut) | logs/harvester-NN-console.log、HTTP 状态码 | 案例 1、3、4 |
安装器(harv-install) | guest 内 /var/log/console.log、/rke2.log、进程表 | 案例 7、8、13 |
| 集群(RKE2/K8s) | kubectl get nodes/pods、etcd 成员 | 案例 11、19、20 |
| guest 内核(节点 OS) | journalctl -b -1、virsh domblkerror、virsh domblkstat 两次采样、watchdog: soft lockup / EXT4-fs error / limit=0 | 案例 29 |
| 应用(Harvester/KubeVirt) | API 响应码、webhook 报错 | 案例 21、23 |
| DNS / 镜像分发 | dig @<coredns> 跑 N 次看分布、crictl pull、registries.yaml 字节数与文件头 | 案例 24、25、26 |
| 接入(Rancher) | cattle-cluster-agent 日志、Rancher /v3/clusters/<id> 的 state/provider/nodeCount | 案例 24、27 |
2. 判据必须双向验证
一个自愈判据要同时证明两件事:对健康态不触发、对故障态能命中。 案例 14(不能用"存在 level=error")、15(pgrep -f 自匹配)、19(Ready 子串) 全是只验了一边就上线,结果要么误伤、要么漏判。
3. 状态匹配永远不要用子串
NotReady 含 Ready、Succeeded 含 ceed……一律用锚定正则或精确字段比较, 并列出所有合法态做对照表(见案例 19 的表)。
4. 找竞态只能靠时间戳对齐到毫秒
案例 13 的突破点是把 guest 内 console.log 的毫秒时间线与安装器报错时刻对齐, 发现"网络 0.5 秒后就绪、安装器已经放弃"。只看"卡住了"永远找不到竞态。
5. 远程执行不要吞 stderr
2>/dev/null 让案例 11 的 command not found 隐形,白等 40 分钟。 调试期保留 stderr,稳定后再收敛。
6. 自愈必须限流 + 幂等 + 加锁
案例 14(≤3 次、间隔≥120s、重启后预算顺延)、15(mkdir 原子锁、rescue-NN.done 标记、 guest 侧前置校验)。没有限流的自愈比不自愈更危险。
7. 先预演,再正式
案例 4:ISO 还没下完就先跑一次真实引导,把网络/引导/配置三类问题一次排除。 成本低、收益大。
8. 修复要落在"稳定的层"
案例 18 的目录回滚之所以没造成灾难,是因为关键修复都在独立共享脚本(95~98)里, 与编排脚本的代际无关。把修复放在被依赖最少的独立文件里。
9. 上游脚本在 set -e 下的清理命令是雷区
案例 8:pkill rke2 无匹配返回 1 → 整个安装中止,且后续 GRUB 补丁不再执行, 导致"只能重装"。读上游脚本时要特别盯 set -e 区块里的 pkill/umount/rm。
10. "间歇性"故障:把随机变成可统计的分布
案例 24 的突破口不是"多试几次",而是跑 20 次统计结果分布:
bash
for i in $(seq 1 20); do dig +short <域名> @<coredns>; done | sort | uniq -c一旦看到 10:10 的分裂分布,policy=random 就浮出水面了。 凡是间歇性故障,先怀疑随机/轮询类配置(DNS policy、LB 算法、重试抖动、缓存 TTL)。
11. 改"声明源",不要改"被渲染的产物"
案例 26:手写 registries.yaml 能生效约 1 分钟,随后被控制器按设置回收成 0 字节—— "先成功后失效"恰恰是最迷惑人的表现。判断方法:看文件头有没有 # File generated by … DO NOT EDIT.;有就去找它的声明源(CRD / settings / values)。
12. 无官方文档时,从二进制的 struct tag 反查字段名
案例 26 里 containerd-registry 设置的 JSON 字段名(mirrors.<reg>.endpoints[]、 configs.<reg>.tls.insecureSkipVerify)是从 /usr/bin/rancherd 的 struct tag 反查确认的。 与 §2.2 的 strings 统计是同一类手法:当文档缺失时,二进制就是最权威的文档。
13. 同类不同用途的凭据不要同值
案例 28:dashboard 密码与 SSH 密码同为 xxx 时,"登录失败"这个信号 失去信息量——无法判断是密码错了还是用错了凭据类型。拆成不同值后, 失败本身就能指认错误类型。配套做法:在凭据文件里显式写明"这两个不是同一个"。
14. 用"三态判定"切开客户端与服务端问题
案例 28 的定位手法:把所有候选密码逐一用 xxx 打一遍登录接口,看 HTTP 码分布 (201 / 401 / 401)。一旦确认服务端某个密码有效,浏览器仍失败就必然是客户端问题 (全角字符、自动填充、Cookie),不必再动服务端。