Skip to content

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环境探测网卡名靠猜会写错,必须探测引导实测✅ 已规避
2libvirt 兼容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✅ 已修
10bash 陷阱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 ReadyNotReady 当就绪;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未解决
24DNSCoreDNS 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 属客户端问题✅ 已修(两类密码拆分)
29guest 硬死锁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-registrydocker.io → 私仓 mirror,138ms 拉取成功)

案例 1 · 网卡名不能靠猜(环境探测)

现象:装机配置里 management_interface.interfaces[].name 若写错,bond mgmt-bo / bridge mgmt-br 会建在错误接口上,节点无网络。

定位:可预测网卡名依赖 PCI 拓扑,不同机型/顺序会得到 enp1s0ens3eth0 等不同结果。

根因: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 nvramUEFI 域有独立 NVRAM 文件virsh undefine harvester-NN --keep-nvram
domain 'harvester-01' already exists with uuid ...未先 undefine 就重复 define脚本内先 undefine --keep-nvramdefine

案例 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.shvirsh 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.shsleep 2 死循环,而 containerd 与 rke2 进程都已不存在

⚠️ 易误判点:Failed to check deprecations 只是症状,真因是 containerd/socket 没了。 只盯着这行 warning 会一路查错方向。

根因链(读 /usr/sbin/harv-install 得出)

  1. 非 ISO 模式(即 PXE + iso_url)下,脚本 chroot/run/cos/target 后启动一个 引导用 rke2 server,目的只是借它内嵌的 containerd 来导入 /var/lib/rancher/rke2/agent/images/*.tar.zst
  2. chroot 内没有 systemd,kubelet 必然失败:
    Failed to create cgroup: systemd not running on this host, cannot use systemd cgroups manager
    → Failed to start ContainerManager
    kubelet 每 5 秒 exit status 1
  3. static pods 永远同步不了,rke2 在启动约 30 分钟自杀(chroot 内 /rke2.log):
    level=fatal msg="Failed waiting for static pods to sync: context deadline exceeded"
    containerd 随之消失;
  4. /usr/sbin/ctr-check-images.shwhile 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/local8 GB ISO 下载到目标盘,随后又从同一块盘读回 tar 包并写入 containerd 内容库 —— 读写同轴争抢

实测时间线:

时刻事件
12:57引导用 rke2 启动(30 分钟死线开始计时)
13:25最后一个镜像列表才开始核对
13:27:59rke2 自杀 → 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_poweroff65-deploy-all.sh: ensure_booted_from_disk, 每 3 轮(约 30s)用 detect 探测一次,命中则推送救援体执行一次, 并写 logs/rescue-NN.done 标记避免重复。

救援体(95-rescue-image-preload-guest.sh)做三件事:

  1. 用与 harv-install 的 ISO 分支完全相同的参数重启 containerd:
    -c .../config.toml -a /run/k3s/containerd/containerd.sock \
    --state /run/k3s/containerd --root /var/lib/rancher/rke2/agent/containerd
    → 已导入的 18 GB 内容库立即可见(参数不一致会看到空库,前功尽弃);
  2. 逐个列表比对,缺哪个就 zstd -d + ctr -n k8s.io images import --no-unpack 补哪个;
  3. 核对阶段一结束(连续 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/grubenvgrubcustomthird_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 bootreturn 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'输出为空

根因sudosecure_path 不含 rke2 的 bin 目录,Harvester OS 也没有把 kubectl 软链到 /usr/local/bin。于是 sudo kubectl … 静默失败(2>/dev/nullcommand 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.sh65-deploy-all.shkctl()70-verify.sh80-smoke-vm.sh$K

💡 排障提示:远程执行时不要把 stderr 全吞掉。本例若保留 stderr, 第一轮就能看到 command not found,不至于白等 40 分钟。


案例 12 · 操作纪律:不要编辑正在运行的 shell 脚本

根因:bash 按字节偏移增量读取脚本文件。运行中修改文件(即使只是插入几行), 会让进程从错误的偏移继续执行,表现为莫名其妙的语法错误或跑错分支。

本次实操:为修 kubectl 路径,先按 PID 精确 kill65-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 offon紧接着调用 nm-online; 若那一刻 NetworkManager 还在 ASLEEPnm-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 秒

两个极容易看错的地方(都曾把排查带偏)

  1. 输出里的 30s 是超时设置值,不是已耗时。 实测 nm-online 0 秒成功时同样打印 23 个点加 30s。 → 看到 Connecting..... 30s 就以为"等了 30 秒超时"是错的,它可能一秒都没等。
  2. nm-online 判定的是"有没有激活的连接",与 NM 的 connectivity 探测无关。 实测把探测地址设成不可达的 http://192.0.2.1/nm-online 依然 [online] rc=0 (而 nmcliCONNECTIVITY 会变成 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_nodesetsid nohup … & 后台启动,不阻塞主流程。

没赶上也不要紧 —— 由案例 14 的自动重试兜底。


案例 14 · 安装器失败后不会自愈,必须自动重启重试(已修)

根因:Harvester 自动安装跑在 netboot 起来的 live 环境里,任何一步失败, 安装器就停在错误界面永远不动,而 60/65 号脚本只会老老实实等到超时 (40~90 分钟)才发现不对。node02 就是这样白等了一夜。

修复:新增 96-install-retry.sh,由 60-deploy.sh: wait_poweroff65-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)"

若以此为判据,会对正在健康安装的节点反复重启,把成功装机搞成死循环。

现行三条件(必须同时满足):

  1. level=error 出现在最后 5 行非空行内;
  2. harv-install / elemental / ctr-check-images / rsync / mkfs / qemu-img / parted干活进程全都不在

    ⚠️ harvester-installerstart-installer.sh 失败后会一直存活不能把它们当作"在干活"。

  3. 日志静默 ≥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 是否已挂载,必然误判。

修复(四管齐下):

  1. 所有模式串用 [x] 括号写法避免自匹配(如 ctr-check-image[s].sh);
  2. 必须同时满足/run/cos/target 已挂载 + images-lists 存在 + ctr-check-images.sh 已跑 >300s + containerd 不在 + 真正的引导用 rke2 不在

    判据用 rke2 serve[r] 而非 pgrep -x rke2:后者会把救援自己放的诱饵/tmp/rke2(见案例 8)也算进去,导致永远判不出"rke2 已死"。

  3. mkdir 原子锁,避免两个看门狗(6065 各挂了一份)同一秒各救援一次;
  4. 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 的两个坑(已修)

现象根因修复
① 串口被互抢控制台输出错乱/丢失pkillvirsh console 子进程,没杀外层重连循环;旧循环 sleep 3 后把它重新拉起,于是新旧两个循环--force 反复互抢同一串口。而 install_node 每次都调 start必然踩到先杀循环、再杀子进程
② 失败现场丢失复盘时无上次日志: > "$LOG" 直接覆盖上次日志轮转为 <log>.<时间戳>.prev

验证logs/ 下可见 harvester-02-console.log.20260906-080352.prevharvester-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.mdlogs/全部退回到 9 月 5 日 22:56–23:21 的旧快照:体积、mtime 都变了, 当天凌晨的编辑与日志内容全部丢失,全盘也找不到副本。 而 VM 与常驻进程不受影响(它们在内存/磁盘中独立运行)。

应对纪律

  1. 编辑前先核对 mtime/体积是否符合预期:
    bash
    ls -l --time-style=full-iso scripts/
  2. 关键修复尽量落在独立的共享脚本里95/96/97/98), 与"哪一代编排脚本"解耦——本次正是靠这一点,回滚后无需重做案例 13~17 的修复。
  3. 阶段性成果及时另存(本目录 deploy/scripts/ 即为一份归档副本)。

案例 19 · ★ wait_node_readyNotReady 当就绪(本次新修)

现象

70-verify.shnode03 还没入集群时就跑完了;日志显示 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(,|$)只接受 ReadyReady,<任何后缀>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.shwait_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

根因(三层):

  1. 装机配置无法设定 dashboard 密码xxx §5.3 已用二进制字符串统计证明);
  2. 全新集群初始为 admin/admin(取自 cattle-system/bootstrap-secretbootstrapPassword),且 `mustChangePassword=xxx
  3. 该状态下登录会返回 201 并发出 token,但 token 对所有业务 API 一律 401—— 必须先改密。

修复:新增 75-set-admin-password.sh(幂等),并挂进 65-deploy-all.sh 在整体验证之前自动执行。

完整根因链、API 调用要点(.token vs .idchangepassword 必须 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 : xxxRKE2 集群加入令牌(node02/03 join 用)rke2 token(并注明"不是 API token")
(缺失)Dashboard 首登流程与初始密码来源新增"首登说明"段

修复

  1. 重写 50-gen-configs.sh 的凭据块:分列 dashboard / rke2 token / ssh, 写明首登流程、.token vs .idchangepassword 用 POST 等要点;
  2. 同步更新现存 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-disk0Bound(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 被拒。

候选修法(按优先级,均未验证)

  1. 在 VM spec 里显式写出 CPU 拓扑,让 patch 路径存在:
    yaml
    spec:
      template:
        spec:
          domain:
            cpu:
              sockets: 1
              cores: 2
              threads: 1
    必要时再补 maxSockets
  2. 改用 DataVolume 而非"裸 PVC + CDI 注解",与 Harvester UI 的创建路径保持一致 (UI 走 harvesterhci.io/volumeClaimTemplates + DV)。
  3. 核对 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 拿到的是首页 HTMLwebsocket: bad handshake

因为 CoreDNS 有 cache 30 + 上游 TTL 过期后会自愈,所以表现为反复抖动而非持续失败, 极易被误判成"网络不稳"或"agent 版本问题"。

修复(三处,缺一不可):

  1. 节点 DNS 收敛为只用宿主 dnsmasq: nmcli con mod bridge-mgmt ipv4.dns 192.168.150.1 + nmcli device reapply mgmt-br (★ 用 device reapply 而不是 con up不断链);
  2. 重启 CoreDNSrollout restart deploy/rke2-coredns-rke2-corednsforward 插件只在启动时/etc/resolv.conf,见案例 27);
  3. 装机配置同步收敛50-gen-configs.shconfig-node0*.yamldns_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.yaml0 字节(没有任何私仓声明)
直连 auth.docker.ioHTTP 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 会被控制器回收

现象:手工写入 167Bregistries.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 crictlcommand 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)

另外两条认知类坑:

  1. 集群名 harvester 已被占用:Rancher 里已有一个 provider=rke2 的容器集群叫 harvesterc-m-l66tnt9v),与本次导入无关。本次用 harvester-hci 避免混淆; 两者分属 Cluster ManagementVirtualization Management 两个页面,互不冲突。
  2. harvesterconfig / harvestercredentialconfig 不是导入用的:那是"用 Harvester 当 基础设施 provider 去创建下游 K8s 集群"的路径。导入已有 Harvester 走标准 POST /v3/clusters,provider 由 Rancher 从下游节点标签 provider.cattle.io: harvester自动识别

案例 28 · ★ dashboard 密码与 SSH 密码同值导致误用(+ 浏览器 401 但 API 正常)

现象(两种表现)

  1. 照凭据文件登录 Dashboard 反复失败,但同一串密码 SSH 登录节点完全正常
  2. 浏览器登录报 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)

修复

  1. 两类密码拆成不同值xxx 新增 DASHBOARD_PW='xxx'PASSWORD='xxx' 保持为 os.password75-set-admin-password.shNEW_PW 默认值改为 DASHBOARD_PW 的值。 脚本注释里写明了拆分理由,防止后人"顺手统一"回去。
  2. 凭据文件显式声明credentials.txt 增加 "dashboard 密码与 ssh 密码不是同一个(故意分开)", 并附浏览器 401 的三条客户端排查提示。
  3. 执行二次改密(引导密码已失效,故 xxx 传现有密码):
    bash
    BOOT_PW='xxx' bash scripts/75-set-admin-password.sh 'xxx'

验证(实测)

检查结果
changepassword200
新密码登录201
mustChangePasswordfalse
/v1/harvester/nodes(业务 API)200
旧 dashboard 密码立即 401
引导密码 xxx401

教训

💡 同类但不同用途的凭据不要同值。 同值时"登录失败"这个信号失去信息量—— 你无法从失败中判断是"密码错了"还是"用错了哪一类密码"。 拆成不同值后,失败本身就能指认错误类型。

💡 "浏览器失败 + API 成功"是客户端问题的铁证:先用无痕窗口重试, 别急著去改服务端密码(改了反而制造出第二个问题)。


案例 29 · ★ harvester-03 guest 硬死锁:blkid soft lockup → 节点 NotReady 22 小时(硬重启恢复)

现象

  • kubectl get nodesharvester-01/02 Readyharvester-03 NotReadycontrol-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 两个卷 faultedharvester-longhorn-r1 单副本卷,唯一副本就在 node03)、 test-vm-01 根盘卷 degraded;两个 CDI importer-prime-* Pod 卡 ContainerCreating 21h+, rhel96-c2/c3-disk-0 PVC 一直 Pending

定位过程(宿主机 → guest 逐层排除)

检查结果
宿主机块设备virsh domblkerror harvester-03、宿主 dmesg | grep -iE "i/o error|nvme|ata"干净 → 不是宿主盘/后端问题
宿主机资源freeload average、OOM 记录无 OOM(负载高但与本故障无因果)
libvirt 域virsh domstate/dominfodomblkstat 两次采样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=0virt-cdi-import 的 ext4 出现 I/O 失败、journal 中止、被迫卸载;紧接着对同一设备的 blkid 探测 在内核里死循环(栈顶 __x64_sys_close,syscall 号 ORIG_RAX=3close()),把 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 与设备重建(sdcsdf)。
  • 为什么"死锁"而不是"自动重启":三节点一致为 kernel.softlockup_panic = 0kernel.hung_task_panic = 0kernel.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 nodes3 节点全部 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
Longhornfaulted 卷恢复 attached/healthydegraded 卷副本自动重建中
VIP / Dashboardhttps://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.logprev-boot-journal-tail.txtdescribe-node03.txtlonghorn-{volumes,replicas}.txtpods-all.txtpvcs.txtnodes.txthost-side.txtreboot-timeline.txtverify-after-recovery.txtREADME-postmortem.md

⚠️ 复发信号(恢复后 45 分钟内又出现同一套签名,2026-09-08 03:45→03:57 UTC,node03)

注意是 journalctl -b 0(本次启动),不是 -b -1

时刻(UTC)内核日志含义
03:45:29EXT4-fs error (device sdb) in ext4_init_inode_table:1622: IO failure + Detected aborted journalscratch 卷底层设备 I/O 失败,EXT4 日志被 abort
03:46:22EXT4-fs error (device sdc): Detected aborted journal第二个 scratch 设备同样下场
03:46:30–31umount: attempt to access beyond end of device sdc: rw=12288, sector=84xx, nr_sectors = 8 **limit=0** ×9与 9/7 死锁前完全相同的签名:设备已被 Longhorn 拆掉(容量归零)
03:55:07EXT4-fs error (device sda): … comm **virt-cdi-import**这次卡住的是导入线程本身
03:55:08virt-cdi-import: attempt to access beyond end of device sda: … limit=0同上
03:55:43 / 03:57:04scsi 6:0:0:1 [sda] … Attached SCSI disk / EXT4-fs (sda): mounted … r/wLonghorn 经 tgtd/iSCSI 重新挂上新 scratch 设备,导入自动续跑

上层现象(describe dv rhel96-c3-disk-0):write /scratch/tmpimage: input/output errorread-only file systemImportFailed(x16 over 25h)→ 新建 importer Pod + 新 scratch PVC (pvc-64291252pvc-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 sijbd2/sda-8jbd2/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)c2c3事件
04:0595.62%(已在 qemu.go 转换阶段 8.15%)77.89%
04:23–04:24sde/sda 再次 Detected aborted journal + limit=0
04:24两个 importer Pod 被重建(AGE 归零)、scratch 卷再换代(pvc-8e5f453dpvc-f32dcf2f
04:2835.89%(退回 prometheus.go 下载阶段)35.13%转换阶段成果全丢
04:3880.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 每 30s Failed to get purge status for engine …卷已 detached、引擎没运行导致的正常噪声,PVC 均 Bound 完好 → 无需处理
  • rhel96-c2-disk-095.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

定位过程

  1. 不是 node03 配置漂移:三节点 /etc/rancher/rke2/registries.yaml 完全一致(266B,md5 0eb1e67b…,只声明内网私仓 192.168.122.156:30000);RKE2 渲染出的 /var/lib/rancher/rke2/agent/etc/containerd/certs.d/(★ 不是 /etc/rancher/rke2/certs.d, 后者在本集群根本不存在;路径由 containerd config.toml[plugins.'io.containerd.cri.v1.images'.registry] config_path 指定)三节点同样只有 192.168.122.156:30000/ 一个条目,node03 的在重启后 03:05 被正确重新渲染 → 案例 26 的机制工作正常。 关键发现:设置里从来没有 docker.io 的 mirror,所以任何 docker.io/* 短名引用都只能直连公网。
  2. 节点确实没有外网curl -sI https://1.1.1.1000;默认路由走 192.168.150.1(宿主 dnsmasq), 而 DNS 应答是污染的(registry-1.docker.io2a03:2880:…face:b00c…108.160.161.83173.208.182.6845.114.11.25,每次不同且全部 connection refused)。
  3. 镜像其实在本地:node03 containerd 里 docker.io/rancher/fleet-agent:v0.15.4192.168.122.156:30000/rancher/fleet-agent:v0.15.4 并存,同一个 image ID 7d99da6683786complete (3/3)UNPACKED=true);Pod 也确实是单容器 + imagePullPolicy: IfNotPresent + 无 pull secret。 但 kubelet 仍然发起了 PullImage(事件 failed to resolve reference 说明它先去 registry 解析 tag, 而不是直接用本地记录)。

    ⚠️ 排查工具坑:本集群没有 /etc/crictl.yamlcrictl 会去连默认的 /run/containerd/containerd.sock(不存在)而 fatal;必须显式 crictl -r unix:///run/k3s/containerd/containerd.sockctr 同理必须 --address /run/k3s/containerd/containerd.sock漏了这个参数 ctr 会静默返回空, 极易误判成"镜像不存在"(本次排查就踩到,ctr -n k8s.io images ls | grep fleet 输出为空)。

  4. 对照组一步定位:正常 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.4Image is up to date for sha256:7d99da6683786…
  5. 删除 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-agentcattle-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 是 PascalCaseMirrors/Endpoints/Rewrites/Configs/ Auth/TLS/CAFile/InsecureSkipVerify/Auths),与案例 26 里从 rancherd struct 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:54node01 / node03 registries.yaml 被重写(新增 docker.io
03:52:26 / 03:52:43 / 03:53:00三节点 rke2-server 依次重启(滚动,etcd quorum 全程保持)
03:53:04node03 渲染出 certs.d/docker.io/hosts.toml
03:53:08kubelet Successfully pulled image "rancher/fleet-agent:v0.15.4" in **138ms** → 容器 Started
03:54Pod 1/1 Running全集群异常 Pod 归零(3 节点 Ready/healthz 各项 oketcd 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,DataVolume RESTARTS 计数 → 4);进度没有归零rhel96-c2-disk-0 43.14%、rhel96-c3-disk-0 63.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:30000Rancher 全局生效,会重新渲染其托管 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 <私仓>/&lt;repo&gt;:&lt;tag&gt; 实测一次才算数(本例 0.2s 成功)。


附 · 排障方法论(从这 30 个案例里提炼)

1. 先分层,每层都要有可观测点

可观测点本次踩的坑
宿主(libvirt/磁盘/网桥)virsh list/domstate/dumpxmldfls -l disks/iptables -L FORWARD案例 7(机械盘同轴争抢)、9(dumpxml 空)、24(跨网桥被 REJECT)
引导(HTTP/dracut)logs/harvester-NN-console.log、HTTP 状态码案例 1、3、4
安装器(harv-installguest 内 /var/log/console.log/rke2.log、进程表案例 7、8、13
集群(RKE2/K8s)kubectl get nodes/pods、etcd 成员案例 11、19、20
guest 内核(节点 OS)journalctl -b -1virsh domblkerrorvirsh domblkstat 两次采样、watchdog: soft lockup / EXT4-fs error / limit=0案例 29
应用(Harvester/KubeVirt)API 响应码、webhook 报错案例 21、23
DNS / 镜像分发dig @<coredns> 跑 N 次看分布、crictl pullregistries.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. 状态匹配永远不要用子串

NotReadyReadySucceededceed……一律用锚定正则精确字段比较, 并列出所有合法态做对照表(见案例 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/rancherdstruct tag 反查确认的。 与 §2.2 的 strings 统计是同一类手法:当文档缺失时,二进制就是最权威的文档

13. 同类不同用途的凭据不要同值

案例 28:dashboard 密码与 SSH 密码同为 xxx 时,"登录失败"这个信号 失去信息量——无法判断是密码错了还是用错了凭据类型。拆成不同值后, 失败本身就能指认错误类型。配套做法:在凭据文件里显式写明"这两个不是同一个"

14. 用"三态判定"切开客户端与服务端问题

案例 28 的定位手法:把所有候选密码逐一用 xxx 打一遍登录接口,看 HTTP 码分布 (201 / 401 / 401)。一旦确认服务端某个密码有效,浏览器仍失败就必然是客户端问题 (全角字符、自动填充、Cookie),不必再动服务端。