记一次(奇幻的) HomeLab 数据损坏和抢救

 

就在不久之前, Bob 的 HomeLab 系统盘挂了, 我还在群里围观他恢复数据. 想着我的 HomeLab 服务器有着十分完备的数据备份, 绝对不会出事(不应该立 flag).

然后… 如题所示, 我也翻车了.

一切的开始 - 服务器托梦(?)

4 月 19 号的晚上, 我睡觉的时候做了一个噩梦, 朦胧之中好像梦到了我的服务器崩溃了, 然后一大堆服务都挂了无法访问, 不过当时也没特别在意.

4 月 20 号当天白天我正好要乘高铁回南京大学, 没去管我的服务器, 似乎一切安好… 直到吃完晚饭回到宿舍 - 打开电脑就看到一大堆监控报警: 我的 HomeLab 真的挂了!

而且挂掉的时间正好就是 4 月 20 号的凌晨 01:33, 那时候我正在睡觉(并且做梦)… 看来是服务器托梦给我让我赶快去修.

已知的小问题

虽然我已经在南京了, 但我有完善的基建 - 直接用 PiKVM 连接查看服务器状况, 发现 PVE 主机报了一个 general protection fault, 相关的调用栈里基本是 zfs 相关的函数, 然后整个系统卡死了, 我直接 Alt+SysRQ+REISUB 重启.

这其实一个已知问题: 我上次换了一块很灵车的 10900 ES CPU 之后, 过了几个月突然发现系统在长时间高负载下会随机把东西”算错”, 导致一堆神秘的问题. 我还没来得及更换硬件, 暂时的观察结果是问题的出现和温度有关, CPU 核心温度达到 70-80 度时就会出现问题. 这显然不正常, 当时我偷懒用限制功耗和频率的方法作为 work around. (由于调试繁琐且如果要买 CPU 就不太划算, 后续打算是直接升级整个平台, 但还在拖延症等合适的硬件.)

不过最近天气热了, 而且每周日一点正好会运行负载比较重的备份任务, 查了下 Netdata 的历史当时 CPU 温度直接干到了 75 度, 然后这个问题就复发了…

我继续偷懒, 直接把 CPU 功耗限制到 45W 左右, 问题看起来又解决了.

小问题…吗? - Windows VM 损坏

我手动重启了所有虚拟机和容器, 看起来已经恢复了. 所以我手动重试了之前出问题的备份任务.

然而它报错了 I/O Error, 在读取我的一台 Windows VM 的系统盘 zvol 时, ZFS 出错了. zpool status 得到了如下结果:

1
2
3
4
5
6
7
8
9
10
11
  pool: rpool
state: ONLINE
status: One or more devices has experienced an error resulting in data
corruption. Applications may be affected.
action: Restore the file in question if possible. Otherwise restore the
entire pool from backup.
see: https://openzfs.github.io/openzfs-docs/msg/ZFS-8000-8A
......
errors: Permanent errors have been detected in the following files:

rpool/pve/vm-204-disk-0:<0x1>

虽然此时这台 Windows VM 还能启动并看起来一切正常, 但 ZFS 报告它的系统盘上有一个无法自动修正的错误, 并要求我从备份恢复这个已经损坏了的 zvol. 我当时查看了自动快照, 发现这个 zvol 的所有快照也同样出错, 不能使用.

“倒也不是什么大问题, 可能只是 CPU 算错或者 unsafe shutdown 导致的”, 那时的我这么想…

上次 VM 的自动备份是一周前 4 月 13 号, 我检查了一下这台 Windows 的数据, 在近一周几乎没产生什么重要的数据. 所以我直接从 PVE 的 Web 管理界面选择恢复了 4 月 13 号的备份.

寄! - 本地备份也损坏

PVE 自带的备份的恢复流程是: 先删掉目前这台 VM, 再从保存的备份中恢复数据.

然而… 在恢复到一半的时候, 恢复过程也报错了, 同样是 I/O Error! 这意味着不仅现有的虚拟机被删了, 虚拟机的唯一一份本地备份也损坏了, 并且我为了省空间只会保留了最近一份的备份.

大事不妙… 再查看 zpool status, 发现备份所在的 pool 也报错了:

1
2
3
4
5
6
7
8
9
10
11
  pool: hdd
state: ONLINE
status: One or more devices has experienced an error resulting in data
corruption. Applications may be affected.
action: Restore the file in question if possible. Otherwise restore the
entire pool from backup.
see: https://openzfs.github.io/openzfs-docs/msg/ZFS-8000-8A
......
errors: Permanent errors have been detected in the following files:

/hdd/pool_backup/dump/vzdump-qemu-204-2025_04_13-01_27_32.vma.gz

在操作前我完全没想到原数据和备份能恰好一同出错: 虚拟机磁盘原本在 SSD 上, 每周会自动备份到 HDD 上, 这完全是两个独立的 ZFS 池, 并且我在操作前检查过 4 月 13 号的备份日志, 备份写入是成功的.

4 月 20 号的一次不安全关机完全没理由影响一个 4 月 13 号就已经创建, 并且之后就没人动过的备份文件…

我当即对两个 ZFS 池进行了 scrub 操作, 等了整整一天 scrub 结束了, 然而发现真的恰好只有这两个文件 (虚拟机和其对应的备份) 损坏了, 这真的是… 太巧合了.

现在想想, 唯一合理的解释可能是, 备份任务中这台 Windows VM 正好顺序靠后, 此时 CPU 已经长时间工作温度较高不稳定, 4 月 13 号已经在 ZFS 中写入了错误的数据, 只是当时没有触发系统 panic 或其他报错, 也没有人去读备份文件, 所以问题没被发现. 其他时候系统没有长时间高负载的工况触发 CPU 的不稳定问题, 所以其他文件也没损坏. 直到 4 月 20 号系统 panic, 要恢复才被我发现.

大寄特寄! - 云端备份罢工

本地的两份数据都寄了, 那只能去找远端备份了…

然而, 由于之前 OneDrive 空间被我几乎写满, 想着最近也不会新增什么特别重要的数据, 且所有数据本地至少都有两份了, 我就暂停了 restic 备份脚本. 上一次成功的备份已经是 1 月 12 日了…

开始拯救数据…

虽然这台 Windows VM 主要只挂着我的 QQ 和微信, 恢复三个月前的备份最多丢失一些聊天记录, 问题也不大. 但还是觉得不明不白丢数据心有不甘, 所以就开始尝试恢复数据了.

ddrescue

本来的 VM zvol 和所有快照已经在恢复前被 destroy 了, 之后还写入了几十 G 的数据, 恢复肯定是无望了.

那只能从剩下的 vzdump-qemu-204-2025_04_13-01_27_32.vma.gz 这个损坏的备份文件入手了.

先用 ddrescue 尝试尽可能多的读取出没有损坏的部分:

1
ddrescue /hdd/pool_backup/dump/vzdump-qemu-204-2025_04_13-01_27_32.vma.gz 204.vma.gz 204.log

读出来发现这个 gzip 文件中有一个 128 KB 长的坏块(读取会得到 I/O Error):

1
2
3
4
5
6
7
8
9
10
11
# Mapfile. Created by GNU ddrescue version 1.27
# Command line: ddrescue /hdd/pool_backup/dump/vzdump-qemu-204-2025_04_13-01_27_32.vma.gz 204.vma.gz 204.log
# Start time: 2025-04-22 11:46:25
# Current time: 2025-04-22 11:57:47
# Finished
# current_pos current_status current_pass
0x14960BFC00 + 1
# pos size status
0x00000000 0x14960A0000 +
0x14960A0000 0x00020000 -
0x14960C0000 0xBDD6A72C4 +

GZIP 修复

现在的状态是 ZFS 中有连续的 128KB 损坏了, 并且 scrub 也没法修复, 看起来这 128KB 数据已经彻底丢失了.

现在要想办法从一个有 128KB 长的”洞”的 gz 文件里尽可能的解压出数据.

网络上关于修复这种损坏的 gzip 的说明并不多, 我在 Web Archive 上找到了 gzip.org 曾经有过的一份说明. (不知道为什么现在 404 了)

根据文档的说明, 我可以先无损的解压出坏块前面的部分.

1
gzip -dc 204.vma.gz > part1.vma

解压到坏块的地方肯定会报错, 接下来需要尝试解压坏块后面的部分.

gzip 的格式是这样, 而其中 DEFLATE 压缩的 body 又是包含了一系列数据块:

“gzip” is often also used to refer to the gzip file format, which is:

  • a 10-byte header, containing a magic number (1f 8b), the compression method (08 for DEFLATE), 1-byte of header flags, a 4-byte timestamp, compression flags and the operating system ID.
  • optional extra headers as allowed by the header flags, including the original filename, a comment field, an “extra” field, and the lower half of a CRC-32 checksum for the header section.
  • a body, containing a DEFLATE-compressed payload
  • an 8-byte trailer, containing a CRC-32 checksum and the length of the original uncompressed data, modulo 232.[4]

根据上面那份文档的说法, 我们要从坏块后面的部分找到一个 DEFLATE 数据块的开头, 并在前面拼上一个 gzip 的头, 并尝试解压.

不过, DEFLATE 的数据块是一个比特流, 这意味着它的数据块开头甚至不一定是字节对齐的.

我实在是不想写一套的代码去做 bit shift, 所以我偷懒的直接尝试在 0x14960C0000 后的每个字节开头处搜索, 看能不能找到字节对齐的下一个块.

1
2
3
4
5
6
7
8
9
10
11
12
INPUT_FILE="204.vma.gz"
INITIAL_SKIP=88416714752
MAX_ATTEMPTS=5000

for ((i=0; i < MAX_ATTEMPTS; i++)); do
CURRENT_SKIP=$((INITIAL_SKIP + i))
echo $i
(
printf '\x1f\x8b\x08\x00\x84\xa2\xfa\x67\x00\x03' # gzip 头
dd if="$INPUT_FILE" bs=1M iflag=skip_bytes,fullblock skip="$CURRENT_SKIP" status=none 2>/dev/null
) | gzip -t
done

执行脚本, 发现在 i 运行到 700 时, gzip 不再报错顺利开始解压后面的部分.

于是可以直接再跳过 700 字节, 解压出后面的数据, 结尾当然会有 CRC 错误, 但这不影响我们得到数据.

1
(printf '\x1f\x8b\x08\x00\x84\xa2\xfa\x67\x00\x03'; dd if=204.vma.gz bs=1M iflag=skip_bytes skip=88416715452 status=progress) | gzip -dc > part2.vma

如果按比特搜索的话, 可能可以在更早的地方找到下一个块, 但都已经丢了 128KB 了, 也不差这 700B 了.

VMA 修复

现在我们解压出了 VMA 文件的前后两半, 中间有个大小不明的洞, 需要把这个 VMA 文件救回来.

搜索发现 VMA 这个文件格式是 PVE 自己发明的一种格式, 也有具体的 spec.

具体文档比较长就不在此引用了, 简单来说 VMA 文件有一个整体的文件头, 包含了一些配置以及磁盘的定义. 随后是一系列的 VMA Extent 保存着实际的磁盘内容.

每个 VMA Extent 的头标记了本 Extent 里保存的所有簇号(每个磁盘按 64KB 分为了多个簇, 乱序存储在 VMA 中), 随后就是簇的实际内容.

观察一下我们得到的两个部分(VMAEVMA Extent Header 中的 magic):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# tail -c 10485760 part1.vma | grep -oab 'VMAE'
2341376:VMAE
6142976:VMAE
9944576:VMAE

# tail -c 10485760 part1.vma | xxd -s 9944576 -l 64
0097be00: 564d 4145 0000 03a0 1700 453d 6193 44e9 VMAE......E=a.D.
0097be10: 8c41 548f 9821 242e ba86 5dc6 ac40 cd80 .AT..!$...]..@..
0097be20: d1f5 a647 c2b6 d450 ffff 0002 0020 44d4 ...G...P..... D.
0097be30: ffff 0002 0020 44c4 ffff 0002 0020 44b4 ..... D...... D.

# head -c 10485760 part2.vma | grep -oab 'VMAE'
2492812:VMAE
6294412:VMAE
10096012:VMAE

# xxd -l 64 part2.vma
00000000: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000010: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000020: 0000 0000 0000 0000 0000 0000 0000 0041 ...............A
00000030: 0000 0000 0000 0000 0000 4c00 0000 0000 ..........L.....

看起来中间的坏块大概率是把一个 VMAE 拦腰斩断了. 我直接尝试把 part1 的最后不完整的部分和 part2 开头不完整的部分丢掉, 拼接成”完整的” VMA 文件.

1
(dd if=part1.vma bs=1M count=137516301824 iflag=count_bytes; dd if=part2.vma bs=1M skip=2492812 iflag=skip_bytes) > try_fix.vma

官方的 vma extract 工具会检查每个磁盘是否有缺失的 cluster, 如果缺失会拒绝工作.

我在 GitHub 上找到一个用于提取 VMA 文件的 python 脚本, 它没有检查缺少的 cluster, 能直接提取出磁盘文件.(缺失的簇就全是 0)

1
python3 vma.py try_fix.vma out

等待一会儿后就成功提取出了 VM 的磁盘镜像和配置文件.

NTFS 检查

直接用得到的磁盘创建一台 VM, 尽管这块磁盘上的不明位置上散落着一些洞, Windows 还是非常鲁棒的开机了.

进入系统看起来一切正常. 使用 chkdsk 也没有发现任何问题.

不过为了防止给将来的自己挖坑, 还是来检查一下究竟哪些文件可能损坏了.

(让 GPT 帮我)写一个 python 脚本读取刚刚的 VMA 文件, 记录下所有簇号就能找出缺失的簇号, 运行出来发现缺失了一打:

1
Missing cluster numbers: [2114645, 2114646, 2114647, 2114677, 2114678, 2114679, 2114694, 2114695, 2114696, 2114708, 2114709, 2114710, 2114711, 2114725, 2114726, 2114727, 2114740, 2114741, 2114742, 2114743, 2114756, 2114757, 2114758, 2114759, 2114772, 2114773, 2114774, 2114775, 2114788, 2114789, 2114790, 2114791, 2114804, 2114805, 2114806, 2114807, 2114820, 2114821, 2114822, 2114823, 2114836, 2114837, 2114838, 2114839, 2114852, 2114853, 2114854, 2114855, 2114868, 2114869, 2114870, 2114871, 2114884, 2114885, 2114886, 2114900, 2114901, 2114902]

这个是从磁盘开头开始的, 以 64K 分簇的簇号, 再写个脚本转换成从分区开头开始的, 以 4K 分簇的簇号, 就是 NTFS 的逻辑簇号了 (顺便转换成了 a-b 区间的格式).

1
['33804368-33804415', '33804880-33804927', '33805152-33805199', '33805376-33805439', '33805648-33805695', '33805888-33805951', '33806144-33806207', '33806400-33806463', '33806656-33806719', '33806912-33806975', '33807168-33807231', '33807424-33807487', '33807680-33807743', '33807936-33807999', '33808192-33808239', '33808448-33808495']

使用 Linux 中 ntfs-3gntfscluster 工具可以找到对应的逻辑簇号范围内有哪些文件:

1
2
3
4
5
losetup -Pf drive-scsi0 --show
ntfscluster -c 33804368-33804415 /dev/loop0p3 2>/dev/null
ntfscluster -c 33804880-33804927 /dev/loop0p3 2>/dev/null
ntfscluster -c 33805152-33805199 /dev/loop0p3 2>/dev/null
......

运行出来的结果是… 没有文件:

1
2
3
4
5
6
7
Searching for cluster range 33804368-33804415
* no inode found
Searching for cluster range 33804880-33804927
* no inode found
Searching for cluster range 33805152-33805199
* no inode found
......

我对上面的所有区间都执行了 ntfscluster, 发现这些簇全部都是空闲的, 没有文件在使用, 那就意味着所有文件安好, 没有损坏.

为确保我程序没有写错, 我尝试在 1 月 12 号的备份上执行同样的查找:

1
2
3
Searching for cluster range 33804368-33804415
Inode 731838 /System Volume Information/{c784ba3d-bd06-11ef-9dec-ed26e76be199}{3808876b-c176-4e48-b7ae-04046e6cc752}/$DATA
* one inode found

看起来这里曾经存着一个 Windows VSS 使用的临时文件, 后续可能确实被删除了.

后记

整个数据损坏和恢复的过程都充满了一些神奇的运气 - 先是运气非常差导致原数据和备份一起损坏, 云备份又恰好空间满了. 但这个损坏又十分恰好的在经过了 GZ-VMA-NTFS 三层映射后, 不仅没有导致某层挂掉彻底无法读取, 还最终全部散落在了未使用的簇上, 没有损坏任何文件.

虽然写出来的博客看着一气呵成且比较顺利, 但实际过程中要写一大堆脚本, 花很多时间查找资料, 试错, 调试以及等待文件传输, 还是一个比较折磨的过程, 这种事情还是不要发生第二次比较好.

果然 3-2-1 备份原则还是有它的道理的, 我要马上去把我的 restic 异地备份开回来…

本文采用 CC BY-NC-SA 4.0 许可协议发布.

作者: lyc8503, 文章链接: https://blog.lyc8503.net/post/pve-vm-data-recovery/
如果本文给你带来了帮助或让你觉得有趣, 可以考虑赞助我¬_¬