Migrating a ZFS pool from RAIDZ1 to RAIDZ2

Michael Lynch

将 ZFS 池从 RAIDZ1 迁移到 RAIDZ2

我最近升级了我的家用 TrueNAS 服务器,把 18 TB 的数据从一个由 4 块磁盘组成的 RAIDZ1 ZFS 池迁移到了一个新的 RAIDZ2 池。

妙处在于,我只额外用了三块 8 TB 磁盘就完成了迁移,而且从未把数据转移到外部存储。

在不把数据移到外部存储的情况下从 RAIDZ1 升级到 RAIDZ2 之所以棘手,是因为:

  1. 你无法把 RAIDZ1 池原地转换为 RAIDZ2 池。
  2. 你无法把 ZFS 池缩减到更少的磁盘。
  3. 18 TB 的数据装不进一个由 3 块 8 TB 磁盘组成的 RAIDZ2 池(也就是那三块新磁盘)。

我是怎么做到的

第 0 步:初始状态

一开始,我有一个由 4x8TB 磁盘组成的 RAIDZ1 ZFS 池,23 TB 的总容量中已用掉 18 TB。我为这次迁移购买了三块翻新的 8 TB 磁盘。

第 1 步:借出一块磁盘来创建 RAIDZ2 池

首先,我从原来的 RAIDZ1 池中移出一块磁盘,使该池进入降级(degraded)状态。

然后我用以下内容创建一个新的 5x8TB RAIDZ2 池:

  1. 我的三块新磁盘
  2. 从我的 RAIDZ1 池借来的一块磁盘
  3. 一个充当假磁盘的 8 TB 稀疏文件(sparse file)

这个稀疏文件放在我的 /tmp 目录里,尽管那里的文件系统实际上装不下 8 TB 数据。没关系,因为这个稀疏文件只是临时的。它是一个让我能创建 5 磁盘池的取巧手段,但我不会向它写入任何数据。

第 2 步:将假磁盘离线

接下来,我把假磁盘(那个 8 TB 稀疏文件)从 RAIDZ2 池中离线。

假磁盘离线后,池仍然是健康的,因为 RAIDZ2 可以在缺少两块磁盘的情况下运行。

第 3 步:把 RAIDZ1 池的快照迁移到 RAIDZ2 池

我为 RAIDZ1 池中的所有数据集创建快照,然后用 zfs send 把快照迁移到新的 RAIDZ2 池。

第 4 步:销毁旧池

确认数据已成功迁移到 RAIDZ2 池后,我销毁旧的 RAIDZ1 池,这样就多出三块磁盘。

第 5 步:用旧磁盘替换假磁盘

我用旧 RAIDZ1 池中的一块磁盘替换 RAIDZ2 池中的假磁盘,这样它就不再缺磁盘了。

第 6 步:用旧磁盘扩展新池

最后,我使用 ZFS 扩容功能把旧 RAIDZ1 池剩下的两块磁盘加入新的 RAIDZ2 池,最终得到一个健康的 7x8TB RAIDZ2 池,可用容量共 33 TB。

更新:一种更安全的策略

更新(2025-07-25):感谢读者提出一种能更好降低磁盘故障风险的替代策略。

读者们对这一策略提出了一种变体,我个人更喜欢:

  1. 用三块新磁盘和两个假磁盘(稀疏文件)创建一个 5x8TB RAIDZ2 池。
  2. 把假磁盘从 RAIDZ2 池中离线。
  3. 把数据从旧的 RAIDZ1 池迁移到新的 RAIDZ2 池。
  4. 从 RAIDZ1 池离线一块磁盘,用它替换 RAIDZ2 池中的一个假磁盘。
  5. 销毁 RAIDZ1 池。
  6. 把被销毁的 RAIDZ1 池剩下的三块磁盘迁移到 RAIDZ2 池。

我喜欢这个策略,因为它对迁移过程中的任何单块磁盘故障都有容错能力。在我最初的流程中,迁移可以承受 RAIDZ2 池中任何一块磁盘的故障,但如果 RAIDZ1 池的任何一块磁盘在迁移期间挂掉,RAIDZ1 池就会失败。

新策略唯一的缺点是我没有像测试我最初的策略那样实际测试过它。

为什么要从 RAIDZ1 换到 RAIDZ2?

我于 2022 年搭建了家用 TrueNAS 服务器,采用 4x8TB 的 RAIDZ1 池。当时我觉得 RAIDZ1 没问题,因为它能容忍一块磁盘故障,而我认为四块磁盘中有两块同时故障的可能性不大。

我没有考虑到的是,当我向池中添加磁盘时,每一块新磁盘都会增加我丢数据的风险。我不担心四块磁盘坏两块,但如果我有六块或八块磁盘,同时故障的可能性就开始显得更高了。

每往 RAIDZ1 池中加一块磁盘,向 RAIDZ2 迁移就更难一步,因为这意味着新池必须更大,而留给新池的物理磁盘插槽也更少。

RAIDZ2 可以在不丢数据的情况下容忍两块磁盘故障。即使我扩展到 10 块磁盘(当前服务器机箱的最大容量),三块磁盘同时故障的概率也低到让我不担心。

为什么从 RAIDZ1 换到 RAIDZ2 很难?

ZFS 不支持从 RAIDZ1 切换到 RAIDZ2。RAIDZ 模式在创建池时就已确定,永远无法更改。

最直白的办法是再买五块磁盘,组建一个 RAIDZ2 池,然后把所有数据搬过去。但这样一来我就有 4 块旧盘 + 5 块新盘 = 9 块磁盘,而我只想要 6-7 块。

如果你在网上搜如何把 RAIDZ1 池转换为 RAIDZ2,所有人都会说要么按最直白的方式做,要么把所有数据搬到一台备用 ZFS 服务器上,销毁 RAIDZ1 池,在原位置创建 RAIDZ2 池,再把数据迁回来。可万一你和我一样,游艇里没有闲置的 18 TB ZFS 服务器呢?

网上之所以没有多少关于更高效转换方法的指引,是因为我的方案依赖于 RAIDZ 扩容功能,而它六个月前才在 ZFS 中可用。

从 RAIDZ1 迁移到 RAIDZ2:实战

理论上,我的 RAIDZ1 到 RAIDZ2 迁移计划很直接,但实际执行时遇到了几个小波折。

我把确切的命令和输出的详细记录分享出来,供想重复这一过程的人参考。

第 1 步:用 U 盘演练迁移

我的迁移计划包含一些高风险操作。在把它用到存有全部数据的主 TrueNAS 服务器上之前,我先在一台测试服务器上试了一遍。

我在一台旧迷你 PC 上安装了 TrueNAS Core 25.04,然后接上了 7 个 U 盘。

在一台备用的 TrueNAS 服务器和七个 U 盘上测试我的迁移策略

由于磁盘大小不一,这并不是完美的实验,但流程跑通了。这给了我信心,让我敢在生产服务器上执行迁移计划。

第 2 步:备份数据

我在上面说过我从未把数据移到外部存储,但这并不完全属实。我确实用外部存储做了备份,只是最终没有用上。

我已经在用 restic 做每夜备份,但那是文件系统层面的备份,而不是 ZFS 层面的。也就是说,如果迁移期间我的 ZFS 池损坏了,我就得重建每个 ZFS 数据集并从备份恢复文件,而不是从一个感知 ZFS 的备份中整体恢复。

虽然我每夜都备份,但有一类数据我不备份:媒体文件。我是个数据囤积者,保存了所有 DVD 和蓝光碟的原盘镜像。万一哪天我想看我 1998 年的 DVD 版《There’s Something About Mary》(《情色喜剧》)的导演评论音轨呢?

我的磁盘服务器上大部分空间都是我抓取的 DVD 和蓝光碟。

算上原盘镜像和转码后的视频文件,我的 TrueNAS 服务器上有大约 15 TB 的电影和电视剧。我不把这些文件备份到云存储,因为即使在 Wasabi 或 Backblaze B2 这类低价云存储服务商那里,每月也要花约 90 美元。

我告诉自己,如果媒体数据丢了,我可以从原盘重新抓取。但在这个流程开始时,一想到要把 500 多张盘一张张重新抓取和转码,我决定破例把媒体文件短期备份一下,以防迁移出什么问题。

哪里能备份 15 TB 三天?

我只需要备份 15 TB。如果能找到按天或按小时计费的服务商,应该相当划算。

我知道两家提供 ZFS 原生备份的云服务商:

  • rsync.net:最低按一个月计费(15 TB 为 150 美元)。
  • zfs.rent:需要我把自己的硬盘寄给他们。

所以这两个方案都不适合只备份几天数据。

有一些把 ZFS 备份到 S3 兼容存储的工具,但我不想试,感觉太复杂了。没有一台有 15 TB 空余容量的备用服务器,我没法验证备份是否可用。

我决定用我常用的 restic 备份脚本,把媒体文件备份到一家按天或更短粒度计费的云存储服务商。我找到的选项有:

  • Amazon S3(Standard):$0.76/TB/天
  • Google Cloud Storage(Standard):$0.66/TB/天
  • Cloudflare R2:$0.50/TB/天
  • Backblaze B2:$0.20/TB/天
  • Hetzner storage boxes:$0.09/TB/天
    • 可惜我是在迁移完成之后才发现这个选项的。

我选了 Backblaze B2,结果不知怎么,我的 15 TB 数据在 Backblaze 那边变成了 24.6 TB。

把所有东西上传到 Backblaze 花了我将近一周,所以数据存放时间比预期的长,但月底的账单只比平时多了几美元,我都不知道他们是怎么算的。

警告:查看云存储服务商的最低保留期政策

我最初在一次失败的迁移尝试中备份到了 Wasabi。好吧,没关系,我欠 Wasabi 大约 30 美元几天的备份费就行。结果发现 Wasabi 确实支持按天计费,但你必须为备份的任何数据支付至少 90 天的费用。于是,本来 30 美元的 Wasabi 账单变成了 300 美元。

我给 Wasabi 客服发邮件求情,他们给我的建议很奇怪:删掉你的账户。

apparently,删除整个 Wasabi 账户可以绕开 Wasabi 的最低存储期限要求。这不是什么黑技巧;这是 Wasabi 官方客服给出的指引。

但还没等我反驳,另一位 Wasabi 客服加入了工单,告诉我不用删账户。作为一次性的善意,他们会把我的超额费用抵扣掉(谢谢,Wasabi!)。

第 3 步:更新 TrueNAS

这次迁移需要 ZFS 的 RAIDZ 扩容功能,它包含在 OpenZFS 2.3.0 和 TrueNAS 的 24.10(Electric Eel)版本中。

我在 TrueNAS shell 中验证我的 ZFS 版本:

root@truenas:~# zfs --version
zfs-2.3.0-1
zfs-kmod-2.3.0-1

陷阱:更新时注意你的更新通道。

我差点漏掉这一步,因为 TrueNAS 告诉我已经是最新版本。结果发现我是“我的更新通道”内的最新版本,而通道默认还停留在 24.x。所以,如果你看不到 ZFS 2.3.0 或更高版本,可能需要把 TrueNAS 更新到更高的主版本。

第 3 步:识别磁盘

开始迁移前,我需要识别所有磁盘,以便在 ZFS 命令行工具中引用它们。

最简单的查看方式是使用 fdisk 工具:

fdisk --list

我觉得直接在 TrueNAS 的 Storage > Disks 仪表板里看更方便:

根据上面的截图,我的磁盘 ID 如下:

  • 现有池中的磁盘:sdasdcsdesdf
  • 新磁盘:sdbsdgsdh

第 4 步:找出最弱的磁盘

迁移中风险最高的时段,是从我从旧 RAIDZ1 池借出一块磁盘到我把数据迁移到新 RAIDZ2 池之间的时间。从 RAIDZ1 池中移出一块磁盘会让它进入降级状态。如果数据迁移期间旧池中任何其他磁盘挂掉,我就会丢数据。

正因为有这个风险窗口,我希望从旧池中借出最弱的磁盘去组建新池。RAIDZ2 池能容忍弱盘故障,但 RAIDZ1 池不能。

为了找出最弱的磁盘,我运行了下面这段简短的 bash 脚本来查询磁盘的 SMART 诊断数据

for drive in /dev/sd?; do
  [ -e "$drive" ] && echo -e "\n=== $drive ===" && smartctl -A $drive | \
    grep -E '(Power_On_Hours|Wear_Leveling|Media_Wearout|Reallocated_Sector)'
done
=== /dev/sda ===
  5 Reallocated_Sector_Ct   0x0033   100   100   050    Pre-fail  Always       -       0
  9 Power_On_Hours          0x0032   032   032   000    Old_age   Always       -       27228

=== /dev/sdb ===
  5 Reallocated_Sector_Ct   0x0033   100   100   005    Pre-fail  Always       -       0
  9 Power_On_Hours          0x0012   100   100   000    Old_age   Always       -       423

=== /dev/sdc ===
  5 Reallocated_Sector_Ct   0x0033   100   100   010    Pre-fail  Always       -       0
  9 Power_On_Hours          0x0032   077   077   000    Old_age   Always       -       20998

=== /dev/sdd ===
  9 Power_On_Hours          0x0032   100   100   000    Old_age   Always       -       29606

=== /dev/sde ===
  5 Reallocated_Sector_Ct   0x0033   100   100   010    Pre-fail  Always       -       0
  9 Power_On_Hours          0x0032   067   067   000    Old_age   Always       -       29599

=== /dev/sdf ===
  5 Reallocated_Sector_Ct   0x0033   100   100   010    Pre-fail  Always       -       0
  9 Power_On_Hours          0x0032   067   067   000    Old_age   Always       -       29603

=== /dev/sdg ===
  5 Reallocated_Sector_Ct   0x0033   100   100   005    Pre-fail  Always       -       0
  9 Power_On_Hours          0x0012   100   100   000    Old_age   Always       -       141

=== /dev/sdh ===
  5 Reallocated_Sector_Ct   0x0033   100   100   005    Pre-fail  Always       -       0
  9 Power_On_Hours          0x0012   100   100   000    Old_age   Always       -       147

从结果看,它们似乎都挺健康。唯一的差别指标是 Power-on hours(通电时长):

  • 旧磁盘
    • sda:032
    • sdc:077
    • sde:067
    • sdf:067
  • 新磁盘
    • sdb:100
    • sdg:100
    • sdh:100

新磁盘都是 100%,这很合理,因为它们是刚翻新的。sda 最低,只有 32%,所以我把它定为最弱的磁盘,先把它移到新池。

第 5 步:创建稳定的磁盘标识符

/dev/sdX 路径在重启后并不稳定。这次是 /dev/sda 的磁盘,下次重启可能就变成 /dev/sdf。我不确定 ZFS 是否会把磁盘解析成更稳定的标识符,但我不想冒任何风险。

我写了这个 bash 函数,把 /dev/sdX 路径转换成该磁盘的稳定标识符:

get_disk_id() {
    local dev=$1
    local target="/dev/$dev"
    for path in /dev/disk/by-id/*; do
        if [ -L "$path" ] && [ "$(readlink -f "$path")" = "$target" ] &&
           [[ "${path: -2:1}" != ":" ]]; then
            echo "$path"
            return 0
        fi
    done
    echo "Disk ID not found for device: $dev" >&2
    return 1
}

使用我的 get_disk_id bash 函数时,传入 sdX 标识符,它就会返回该磁盘的稳定路径:

$ get_disk_id sda
/dev/disk/by-id/ata-HGST_HUS728T8TALE6L1_VGGGYUEG

为了让流程可复用,我把磁盘 ID 存到环境变量里:

# Old disks
DISK_1="$(get_disk_id sda)"
DISK_2="$(get_disk_id sdc)"
DISK_3="$(get_disk_id sde)"
DISK_4="$(get_disk_id sdf)"

并为新磁盘创建环境变量:

# New disks
DISK_5="$(get_disk_id sdb)"
DISK_6="$(get_disk_id sdg)"
DISK_7="$(get_disk_id sdh)"

我还创建一个环境变量来指代现有的 RAIDZ1 池:

OLDPOOL='pool1'

第 6 步:将弱盘离线

在动手之前,我先确认旧池是健康的,并且包含我预期的磁盘:

$ sudo zpool status ${OLDPOOL}
  pool: pool1
 state: ONLINE
  scan: scrub repaired 68K in 10:40:08 with 0 errors on Wed May 14 06:25:26 2025
config:
        NAME        STATE     READ WRITE CKSUM
        pool1       ONLINE       0     0     0
          raidz1-0  ONLINE       0     0     0
            sde2    ONLINE       0     0     0
            sdf2    ONLINE       0     0     0
            sdc2    ONLINE       0     0     0
            sda2    ONLINE       0     0     0
errors: No known data errors

sda2DISK_1)就是我要移到新 RAIDZ2 池的弱盘,所以我运行以下命令:

DISK_TO_OFFLINE='sda2'
sudo zpool offline "${OLDPOOL}" "${DISK_TO_OFFLINE}"

检查 zpool status,可以看到离线该磁盘后池进入了降级状态,正如预期:

$ sudo zpool status ${OLDPOOL}
  pool: pool1
 state: DEGRADED
status: One or more devices has been taken offline by the administrator.
        Sufficient replicas exist for the pool to continue functioning in a
        degraded state.
action: Online the device using 'zpool online' or replace the device with
        'zpool replace'.
  scan: scrub repaired 68K in 10:40:08 with 0 errors on Wed May 14 06:25:26 2025
config:
        NAME        STATE     READ WRITE CKSUM
        pool1       DEGRADED     0     0     0
          raidz1-0  DEGRADED     0     0     0
            sde2    ONLINE       0     0     0
            sdf2    ONLINE       0     0     0
            sdc2    ONLINE       0     0     0
            sda2    OFFLINE      0     0     0
errors: No known data errors

第 7 步:擦除弱盘

创建 RAIDZ2 池时有个小麻烦。如果我尝试用弱盘(DISK_1)创建新池,ZFS 会告诉我它已经属于另一个池。即使我已经把磁盘离线,ZFS 仍坚持认为我的 RAIDZ1 池拥有这块磁盘。

为了阻止 RAIDZ1 池继续占用弱盘,我需要把它完全擦除。

我定义一个环境变量来跟踪要移动的磁盘:

MOVED_DISK="${DISK_1}"

然后擦除磁盘上的所有数据:

小心wipefs 命令会不加任何确认地擦除整个磁盘,请谨慎运行。
# Be careful! This command completely wipes the disk with no confirmation.
wipefs --all "${MOVED_DISK}"

第 8 步:用稀疏文件创建假磁盘

我可以创建一个 4x8TB 的 RAIDZ2 池,但那样可用容量只有 16 TB,装不下我的 18 TB 数据。

于是,我创建一个 5x8TB 的 RAIDZ2 池,只是第五块磁盘并不真正存在。ZFS 允许我用一个稀疏文件作为磁盘来创建 ZFS 池。

要用稀疏文件创建假磁盘,我需要知道池中其他磁盘的确切大小,可以用 fdisk 查到:

$  fdisk --list | grep "^Disk.*bytes"
Disk /dev/sdf: 7.28 TiB, 8001563222016 bytes, 15628053168 sectors
Disk /dev/sdd: 111.79 GiB, 120034123776 bytes, 234441648 sectors
Disk /dev/sda: 7.28 TiB, 8001563222016 bytes, 15628053168 sectors
Disk /dev/sdc: 7.28 TiB, 8001563222016 bytes, 15628053168 sectors
Disk /dev/sde: 7.28 TiB, 8001563222016 bytes, 15628053168 sectors
Disk /dev/sdb: 7.28 TiB, 8001563222016 bytes, 15628053168 sectors
Disk /dev/mapper/sdd3: 16 GiB, 17179869184 bytes, 33554432 sectors
Disk /dev/zd0: 10 GiB, 10737418240 bytes, 20971520 sectors
Disk /dev/sdg: 7.28 TiB, 8001563222016 bytes, 15628053168 sectors
Disk /dev/sdh: 7.28 TiB, 8001563222016 bytes, 15628053168 sectors

据此,我看到每块真实磁盘都是 8001563222016 字节,所以我用 truncate 命令创建一个假磁盘:

FAKE_DISK='/tmp/fake-drive.img'
truncate --size 8001563222016 "${FAKE_DISK}"

最后,我还需要给新池起个名字。我以前用 pool1 这个名字时还不知道 ZFS 圈子里习惯把池命名为 tank。我以为这次是机会了,可以把新池命名为 tank,跟上潮流:

NEWPOOL='tank'

可惜,我最终没能保住 tank 这个池名。TrueNAS 对池名有很多依赖,所以我选择改回 pool1,而不是把所有共享和 cron 任务都改成指向 tank(见下文)。

第 9 步:创建 RAIDZ2 池

终于到了创建我的 5x8TB RAIDZ2 池的时候:

zpool create \
  -f \
  ${NEWPOOL} \
  raidz2 \
  -m "/mnt/${NEWPOOL}" \
  "${DISK_5}" \
  "${DISK_6}" \
  "${DISK_7}" \
  "${MOVED_DISK}" \
  "${FAKE_DISK}"

创建池成功了,我可以用 ZFS 工具查看它:

$ zpool status "${NEWPOOL}"
  pool: tank
 state: ONLINE
config:
        NAME                                   STATE     READ WRITE CKSUM
        tank                                   ONLINE       0     0     0
          raidz2-0                             ONLINE       0     0     0
            ata-HGST_HUS728T8TALE6L1_VGGGYUEG  ONLINE       0     0     0
            ata-HGST_HUS728T8TALE6L1_VRGMRVJK  ONLINE       0     0     0
            ata-HGST_HUS728T8TALE6L1_VRGNZU9K  ONLINE       0     0     0
            ata-TOSHIBA_HDWG480_71R0A14YFR0H   ONLINE       0     0     0
            /tmp/fake-drive.img                ONLINE       0     0     0
errors: No known data errors

$ zpool list "${NEWPOOL}"
NAME   SIZE  ALLOC   FREE  CKPOINT  EXPANDSZ   FRAG    CAP  DEDUP    HEALTH  ALTROOT
tank  36.4T  1.27M  36.4T        -         -     0%     0%  1.00x    ONLINE  -

我也能在 TrueNAS 的 Web UI 里看到新的 RAIDZ2 池,它报告可用容量为 21.4 TiB(23.5 TB):

我的新 RAIDZ2 池在 TrueNAS Web UI 中可见。

第 10 步:移除假磁盘

假磁盘只是我 /tmp 目录里的一个文件,它根本存不下它声称的 8 TB。为了防止假文件耗尽 /tmp 文件系统空间时出现怪异行为,我立即把假磁盘从 RAIDZ2 池中移除:

zpool offline "${NEWPOOL}" "${FAKE_DISK}" && \
  rm "${FAKE_DISK}"

不出所料,ZFS 工具报告我的 RAIDZ2 池处于降级状态,但它可以容忍最多两块磁盘故障,所以一块磁盘离线没问题:

$ zpool status "${NEWPOOL}"
  pool: tank
 state: DEGRADED
status: One or more devices has been taken offline by the administrator.
        Sufficient replicas exist for the pool to continue functioning in a
        degraded state.
action: Online the device using 'zpool online' or replace the device with
        'zpool replace'.
config:
        NAME                                   STATE     READ WRITE CKSUM
        tank                                   DEGRADED     0     0     0
          raidz2-0                             DEGRADED     0     0     0
            ata-HGST_HUS728T8TALE6L1_VGGGYUEG  ONLINE       0     0     0
            ata-HGST_HUS728T8TALE6L1_VRGMRVJK  ONLINE       0     0     0
            ata-HGST_HUS728T8TALE6L1_VRGNZU9K  ONLINE       0     0     0
            ata-TOSHIBA_HDWG480_71R0A14YFR0H   ONLINE       0     0     0
            /tmp/fake-drive.img                OFFLINE      0     0     0
errors: No known data errors

$ zpool list "${NEWPOOL}"
NAME   SIZE  ALLOC   FREE  CKPOINT  EXPANDSZ   FRAG    CAP  DEDUP    HEALTH  ALTROOT
tank  36.4T  1.41M  36.4T        -         -     0%     0%  1.00x  DEGRADED  -

第 11 步:把数据迁移到新池

我的新 RAIDZ2 池已上线,容量 23.5 TB,于是我把 18 TB 数据从旧的 RAIDZ1 池搬过去。

为了传输所有数据,我为整个 RAIDZ1 池创建快照,然后把快照从旧池发送到新池:

SNAPSHOT_1="migrate1"
zfs snapshot -r "${OLDPOOL}@${SNAPSHOT_1}" && \
  zfs send --verbose --raw --replicate "${OLDPOOL}@${SNAPSHOT_1}" \
    | zfs receive -v -F "${NEWPOOL}"

奇怪的是,命令完成时没有报任何错误,但我注意到 RAIDZ2 池上有一些数据集缺失。

排查迁移问题

为完成数据迁移,我逐个发送缺失的数据集:

DATASET='photos'
zfs send --verbose --raw --replicate --skip-missing \
    "${OLDPOOL}/${DATASET}@${SNAPSHOT_1}" \
  | zfs receive -v -F -s "${NEWPOOL}/${DATASET}"

当我把池挂载到 /mnt/tank/photos 时,目录仍然显示为空。

我尝试了 zpool exportzpool import,但没有任何变化。

最后,我重启了整个 TrueNAS 服务器,神奇地解决了问题。我能在 /mnt/tank/photos 里看到文件了。

恢复中断的传输

通过中断大传输,我发现 ZFS 默认发送的数据不支持断点续传。要恢复传输,我得做一套复杂的操作:先获取一个“resume token”,再把它加进 zfs send 命令里:

RESUME_TOKEN="$(zfs get -H -o value receive_resume_token "${NEWPOOL}/${DATASET}")"
zfs send -v -t "${RESUME_TOKEN}" | zfs receive -v -s "${NEWPOOL}/${DATASET}"

输出声称成功解析了 resume token,但进度总是归零,所以我不知道这条命令到底有没有生效。

收尾迁移

由于迁移花了好几个小时,我创建一个新快照,并对自上个快照以来发生变化的所有数据做增量发送:

SNAPSHOT_2="migrate2"
zfs snapshot -r "${OLDPOOL}@${SNAPSHOT_2}"
zfs send -v \
  -i "${OLDPOOL}/${DATASET}@${SNAPSHOT_1}" \
     "${OLDPOOL}/${DATASET}@${SNAPSHOT_2}" \
  | zfs receive -v "${NEWPOOL}/${DATASET}"

第 12 步:互换池名

我原本想把新池命名为 tank,但我意识到 TrueNAS 把网络共享和 cron 任务都绑定在池名上。如果我把主池从 pool1 改名为 tank,就得手动重建或修改一堆网络共享和 cron 任务。

更简单的办法是让新池接管旧池的名字,我就这么做了。

我先尝试导出池使其离线,但 TrueNAS 的各种系统服务阻止了我。于是,我强行停掉一堆服务:

sudo systemctl stop k3s
sudo umount -l /var/lib/kubelet
sudo systemctl stop smbd
sudo systemctl stop nmbd
sudo systemctl stop winbind
sudo systemctl stop middlewared
sudo systemctl stop netdata

之后,我就可以把池离线来重命名了:

# Rename my old pool with the suffix `-old`.
zpool export -f "${OLDPOOL}" && \
  zpool export -f "${NEWPOOL}" && \
  zpool import "${OLDPOOL}" "${OLDPOOL}-old"
# Rename my new pool to my old pool's name.
zpool import ${NEWPOOL} ${OLDPOOL}
zfs set mountpoint="/mnt/${OLDPOOL}" "${OLDPOOL}"

然后,我重启之前停掉的所有服务:

sudo systemctl start middlewared
sudo systemctl start smbd
sudo systemctl start nmbd
sudo systemctl start winbind
sudo systemctl start netdata
sudo systemctl start k3s

但没起作用,所以我再次重启。

重启后,TrueNAS 把 pool1 识别为我新的 5x8TB RAIDZ2 池:

第 13 步:对新 RAIDZ2 池做 scrub

我不确定是否有必要,但在销毁旧池之前,我对新池跑了一次 scrub,为数据完整性多添一份信心。scrub 完成且没有错误:

第 14 步:销毁旧池

现在到了最吓人的一步:销毁旧池。我用 zpool destroy 把它干掉:

zpool destroy "${OLDPOOL}-old"

第 15 步:用真实磁盘替换假磁盘

旧 RAIDZ1 池销毁后,我现在有三块磁盘要迁到新的 RAIDZ2 池。

第一块磁盘的迁移需要一个特殊流程,因为我要替换的是 RAIDZ2 池中的假磁盘(那个 8 TB 稀疏文件)。

我用 zpool replace 把假磁盘替换成真实磁盘:

FAKE_DISK='/tmp/fake-drive.img'
REPLACEMENT_DISK="$(get_disk_id sde)"
NEWPOOL='pool1'
zpool replace "${NEWPOOL}" "${FAKE_DISK}" "${REPLACEMENT_DISK}"

检查 zpool status 显示 ZFS 正在把我刚换入的磁盘进行 resilver(数据重组):

$ zpool status "${NEWPOOL}"
  pool: pool1
 state: DEGRADED
status: One or more devices is currently being resilvered.  The pool will
        continue to function, possibly in a degraded state.
action: Wait for the resilver to complete.
  scan: resilver in progress since Sat May 31 19:58:51 2025
        2.04T / 28.5T scanned at 56.6G/s, 0B / 28.5T issued
        0B resilvered, 0.00% done, no estimated completion time
config:
        NAME                                   STATE     READ WRITE CKSUM
        pool1                                  DEGRADED     0     0     0
          raidz2-0                             DEGRADED     0     0     0
            ata-HGST_HUS728T8TALE6L1_VGGGYUEG  ONLINE       0     0     0
            ata-HGST_HUS728T8TALE6L1_VRGMRVJK  ONLINE       0     0     0
            ata-HGST_HUS728T8TALE6L1_VRGNZU9K  ONLINE       0     0     0
            ata-TOSHIBA_HDWG480_71R0A14YFR0H   ONLINE       0     0     0
            replacing-4                        DEGRADED     0     0     0
              /tmp/fake-drive.img              OFFLINE      0     0     0
              ata-ST8000VN004-2M2101_WSD5B9XY  ONLINE       0     0     0
errors: No known data errors

TrueNAS UI 也显示我正在替换磁盘:

第 15 步:吸收剩下的两块磁盘

最后,是时候用旧 RAIDZ1 池剩下的两块磁盘来扩展新的 RAIDZ2 池了:

结果又碰到了一个问题。

$ NEWDISK=$(get_disk_id sdd)
$ zpool attach "${NEWPOOL}" raidz2-0 "${NEWDISK}"
cannot attach /dev/disk/by-id/ata-ST8000VN004-3CP101_WRQ02GX5 to raidz2-0: raidz_expansion feature must be enabled in order to attach a device to raidz

原来 RAIDZ 扩容功能默认是关闭的。也许只有像我这样从旧版 TrueNAS 升级上来的情况下才是关闭的:

$ zpool get feature@raidz_expansion "${NEWPOOL}"
NAME   PROPERTY                 VALUE                    SOURCE
pool1  feature@raidz_expansion  disabled                 local

我用 zpool upgrade 启用 RAIDZ 扩容功能:

zpool upgrade -o feature@raidz_expansion=enabled "${NEWPOOL}"

然后,我确认 RAIDZ 扩容功能已启用:

$ zpool get feature@raidz_expansion "${NEWPOOL}"
NAME   PROPERTY                 VALUE                    SOURCE
pool1  feature@raidz_expansion  enabled                  local

现在,我可以再试一次添加磁盘:

zpool attach "${NEWPOOL}" raidz2-0 "${NEWDISK}"

zpool status 确认它正在添加磁盘:

$ zpool status "${NEWPOOL}" | grep --after-context=1 "expand:"
+ grep --after-context=1 expand:
+ zpool status "${NEWPOOL}"
expand: expansion of raidz2-0 in progress since Sun Jun  1 08:08:03 2025
        17.1G / 28.5T copied at 501M/s, 0.06% done, 16:36:03 to go

zpool status 显示扩展达到 100% 后,我对剩下那块磁盘重复这一过程,最终达到目标状态。

我完整的 7x8TB RAIDZ2 池

所有磁盘迁移完成后,我现在拥有一个健康快乐的 7x8TB RAIDZ2 池,可用容量 33 TB(30 TiB):

可选:强制数据重新条带化

读者 @intelfx 提醒我注意 RAIDZ 扩容功能的一个陷阱。在 RAIDZ 扩容之前就存在于磁盘上的数据不会自动从 3 份数据 + 2 份校验重新条带化(restripe)为 5 份数据 + 2 份校验,因此无法利用 7 磁盘阵列更高的存储效率。最终效果是,你无法用满 RAIDZ2 池的全部容量。

你可以通过强制重写迁移前的数据集来绕过这一点(例如,用 zfs send 把每个数据集发送到备份,再用 zfs receive 恢复)。OpenZFS 贡献者 Rob Norris 指出,OpenZFS 2.4.x 将引入一个 zfs rewrite 命令,能更优雅地实现同样的效果。

可选:对齐 TrueNAS 的功能配置

iXsystems 团队(维护 TrueNAS 的团队)的一位成员在 reddit 上评论说,TrueNAS 在技术上并不支持用户从命令行创建 ZFS 池。他建议我在 TrueNAS 里创建一个测试池,导出它的功能标志(feature flags),然后把这些标志应用到我通过命令行创建的新 RAIDZ2 池上。

我不清楚把 TrueNAS 的标志应用到命令行创建的池上到底有什么区别,但以下是我认为截至 25.04 版本 iXsystems 推荐的标志:

zpool set feature@lz4_compress=enabled "${NEWPOOL}"
zpool set feature@async_destroy=enabled "${NEWPOOL}"
zpool set feature@empty_bpobj=enabled "${NEWPOOL}"
zpool set feature@multi_vdev_crash_dump=enabled "${NEWPOOL}"
zpool set feature@spacemap_histogram=enabled "${NEWPOOL}"
zpool set feature@enabled_txg=enabled "${NEWPOOL}"
zpool set feature@hole_birth=enabled "${NEWPOOL}"
zpool set feature@extensible_dataset=enabled "${NEWPOOL}"
zpool set feature@embedded_data=enabled "${NEWPOOL}"
zpool set feature@bookmarks=enabled "${NEWPOOL}"
zpool set feature@filesystem_limits=enabled "${NEWPOOL}"
zpool set feature@large_blocks=enabled "${NEWPOOL}"
zpool set feature@large_dnode=enabled "${NEWPOOL}"
zpool set feature@sha512=enabled "${NEWPOOL}"
zpool set feature@skein=enabled "${NEWPOOL}"
zpool set feature@edonr=enabled "${NEWPOOL}"
zpool set feature@userobj_accounting=enabled "${NEWPOOL}"
zpool set feature@encryption=enabled "${NEWPOOL}"
zpool set feature@project_quota=enabled "${NEWPOOL}"
zpool set feature@device_removal=enabled "${NEWPOOL}"
zpool set feature@obsolete_counts=enabled "${NEWPOOL}"
zpool set feature@zpool_checkpoint=enabled "${NEWPOOL}"
zpool set feature@spacemap_v2=enabled "${NEWPOOL}"
zpool set feature@allocation_classes=enabled "${NEWPOOL}"
zpool set feature@resilver_defer=enabled "${NEWPOOL}"
zpool set feature@bookmark_v2=enabled "${NEWPOOL}"
zpool set feature@redaction_bookmarks=enabled "${NEWPOOL}"
zpool set feature@redacted_datasets=enabled "${NEWPOOL}"
zpool set feature@bookmark_written=enabled "${NEWPOOL}"
zpool set feature@log_spacemap=enabled "${NEWPOOL}"
zpool set feature@livelist=enabled "${NEWPOOL}"
zpool set feature@device_rebuild=enabled "${NEWPOOL}"
zpool set feature@zstd_compress=enabled "${NEWPOOL}"
zpool set feature@draid=enabled "${NEWPOOL}"
zpool set feature@zilsaxattr=enabled "${NEWPOOL}"
zpool set feature@head_errlog=enabled "${NEWPOOL}"
zpool set feature@blake3=enabled "${NEWPOOL}"
zpool set feature@block_cloning=enabled "${NEWPOOL}"
zpool set feature@vdev_zaps_v2=enabled "${NEWPOOL}"
zpool set feature@redaction_list_spill=enabled "${NEWPOOL}"
zpool set feature@raidz_expansion=enabled "${NEWPOOL}"
zpool set feature@fast_dedup=enabled "${NEWPOOL}"
zpool set feature@longname=enabled "${NEWPOOL}"
zpool set feature@large_microzap=enabled "${NEWPOOL}"

感谢 TrueNAS 论坛上的 @NugentS 教我用稀疏文件的巧妙取巧方法来创建更大的 RAIDZ2 池。感谢 iXsystems 的 Chris 纠正了其中的一些细节

原文由 Michael Lynch 发布

本文章由 stealth/ox-alpha 进行翻译