Migrating a ZFS pool from RAIDZ1 to RAIDZ2

Michael Lynch

将 ZFS 存储池从 RAIDZ1 迁移至 RAIDZ2

原文由 Michael Lynch 发布,订阅该博客

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

巧妙之处在于,我只额外用了 3 块 8TB 硬盘,而且全程没有把数据转移到外部存储上。

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

  1. 无法原地将 RAIDZ1 存储池转换为 RAIDZ2 存储池。
  2. 无法将 ZFS 存储池缩减到更少的硬盘数量。
  3. 18TB 的数据放不进由 3 块 8TB 硬盘组成的 RAIDZ2 存储池(也就是那三块新硬盘)。

我是怎么做到的

步骤 0:初始状态

一开始,我有一个 4×8TB 的 RAIDZ1 ZFS 存储池,已用了其 23TB 容量中的 18TB。为了这次迁移,我购买了三块翻新的 8TB 硬盘。

步骤 1:借用一块硬盘来创建 RAIDZ2 存储池

首先,我从原来的 RAIDZ1 存储池中移除一块硬盘,让它处于降级状态。

然后,我用以下方式创建一个新的 5×8TB RAIDZ2 存储池:

  1. 我的三块新硬盘
  2. 来自 RAIDZ1 存储池的一块硬盘
  3. 一个 8TB 的稀疏文件,用来充当虚拟硬盘

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

步骤 2:将虚拟硬盘离线

接下来,我将 RAIDZ2 存储池中的虚拟硬盘(即那个 8TB 稀疏文件)离线。

将虚拟硬盘离线后,存储池依然健康,因为 RAIDZ2 可以在缺失两块硬盘的情况下正常运行。

步骤 3:将 RAIDZ1 存储池的快照迁移到 RAIDZ2 存储池

我为 RAIDZ1 存储池中的所有数据集创建快照,并使用 zfs send 将快照迁移到新的 RAIDZ2 存储池。

步骤 4:销毁旧存储池

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

步骤 5:用旧硬盘替换虚拟硬盘

我用旧 RAIDZ1 存储池中的一块硬盘来替换 RAIDZ2 存储池中的虚拟硬盘,这样它就不再缺盘了。

步骤 6:用旧硬盘扩容新存储池

最后,我使用 ZFS 扩容功能,将旧 RAIDZ1 存储池中最后两块硬盘添加到新的 RAIDZ2 存储池中,最终在一个健康的 7×8TB RAIDZ2 存储池上获得了总计 33TB 的可用存储。

更新:更安全的方案

更新(2025-07-25):感谢读者们提出了另一种能更好降低硬盘故障风险的方案。

读者们提出的这个变体方案,我认为比我原来的更好:

  1. 使用三块新硬盘和两个虚拟硬盘(稀疏文件)创建一个 5×8TB 的 RAIDZ2 存储池。
  2. 将 RAIDZ2 存储池中的虚拟硬盘离线。
  3. 将数据从旧的 RAIDZ1 存储池迁移到新的 RAIDZ2 存储池。
  4. 将 RAIDZ1 存储池中的一块硬盘离线,并用它替换 RAIDZ2 存储池上的一个虚拟硬盘。
  5. 销毁 RAIDZ1 存储池。
  6. 将已销毁的 RAIDZ1 存储池中剩下的三块硬盘迁移到 RAIDZ2 存储池。

我喜欢这个方案,因为它在整个迁移过程中都能抵御任意单块硬盘的故障。在我原来的方案中,迁移过程可以承受 RAIDZ2 存储池中任意一块硬盘的故障,但如果迁移期间 RAIDZ1 存储池中的任意一块盘坏掉,整个存储池就会失效。

新方案唯一的缺点是,我还没有像我原来的方案那样亲自测试过它。

为什么要从 RAIDZ1 换到 RAIDZ2?

我在 2022 年搭建了家里的 TrueNAS 服务器,当时用的是 4×8TB 的 RAIDZ1 存储池。我对 RAIDZ1 很放心,因为它能容忍一块硬盘故障,而且我觉得四块盘里同时坏两块的可能性很小。

我当时没考虑到的是,每往存储池里加一块硬盘,数据丢失的风险就会增加。四块盘里坏两块我不担心,但如果有六块或八块盘,同时出现故障的可能性就感觉大多了。

往 RAIDZ1 存储池里每加一块盘,之后再迁移到 RAIDZ2 就会更困难,因为这意味着新存储池必须更大,而可供新池使用的物理盘位却更少了。

RAIDZ2 可以在不丢失数据的情况下容忍两块硬盘同时故障。即使我扩容到 10 块盘(我当前机箱的最大容量),同时坏三块盘的可能性也小到让我不用担心。

为什么从 RAIDZ1 切换到 RAIDZ2 这么难?

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

最笨的办法是再买五块硬盘,建一个 RAIDZ2 存储池,然后把所有数据搬过去。这样一来,我就会有 4 块旧盘 + 5 块新盘 = 总共 9 块盘,而我其实只想要 6 到 7 块。

如果你在网上搜索如何将 RAIDZ1 存储池转换为 RAIDZ2,所有人都会告诉你要么用这种笨办法,要么把所有数据搬到一台备用的 ZFS 服务器上,销毁原来的 RAIDZ1 存储池,在原地重建一个 RAIDZ2 存储池,再把数据迁回来。可如果你和我一样,手头根本没有一台闲置的、能装下 18TB 数据的 ZFS 服务器——更别说停在游艇里的那种——又该怎么办呢?

之所以网上几乎找不到更高效的 RAIDZ1 到 RAIDZ2 转换方案,是因为我的方法依赖于RAIDZ 扩容功能,而该功能直到六个月前才在 ZFS 中上线。

从 RAIDZ1 迁移到 RAIDZ2:实战过程

理论上,我的 RAIDZ1 到 RAIDZ2 迁移计划很直接,但在实际操作中还是遇到了一些小挫折。

下面我会详细记录整个过程中使用的具体命令和输出,供想复现这一过程的人参考。

步骤 1:用 U 盘做一次演练

我的迁移方案包含一些有风险的操作。在主 TrueNAS 服务器上对所有数据动手之前,我先在一台测试服务器上试了一遍。

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

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

这不是一次完美的实验,因为这些 U 盘的大小各不相同,但整个流程跑通了。这让我有了信心在生产服务器上实施迁移计划。

步骤 2:备份数据

前面我说过我全程没有把数据搬到外部存储,但严格来说并不完全准确。我确实把外部存储用作了备份,只是最后没用上而已。

我本来就用restic每天晚上做备份,但那是文件系统层面的备份,而不是 ZFS 层面的。换句话说,如果我在迁移过程中损坏了 ZFS 存储池,就得重建每个 ZFS 数据集,再从备份中恢复文件,而不是直接从支持 ZFS 的备份中一键还原。

尽管我每天都做备份,但有一种数据我从不备份:影音媒体。我是个数据囤积狂,会保留所有 DVD 和蓝光光盘的原盘镜像。万一哪天我突然想看 1998 年电影《我为玛丽狂》DVD 里的导演评论音轨呢?

我的存储服务器上大部分空间都被我翻录的 DVD 和蓝光光盘占据了。

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

我一直安慰自己,就算媒体数据丢了,也可以把原盘重新翻录一遍。但在这次迁移开始时,一想到要一张张地重新翻录、转码 500 多张光盘,我还是决定破例,临时把媒体文件备份一下,以防迁移过程中出什么岔子。

哪里可以只备份 15TB 三天?

我只需要备份 15TB。如果能找到按天或按小时计费的服务商,费用应该会很便宜。

据我所知,有两家云服务商提供原生的 ZFS 备份:

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

所以,这两个选项都不适合只备份几天的场景。

也有一些工具可以把 ZFS 备份到兼容 S3 的存储上,但我不想尝试,感觉太复杂了。在没有一台拥有 15TB 容量的备用服务器的情况下,我也无法验证备份是否真的可用。

我决定还是用自己常用的 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 Box:0.09 美元/TB/天
    • 可惜我做完迁移后才发现这个选项。

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

把所有数据上传到 Backblaze 花了我将近一周时间,所以实际存储的时间比预期的要长,不过月底账单只比平时多了几美元,我也不太清楚他们是怎么计费的。

警告:留意云存储服务商的最低存储时长要求

我最初曾尝试备份到 Wasabi,但那次迁移失败了。好吧,没关系,我只需要为几天的备份付给 Wasabi 大约 30 美元。结果发现,Wasabi 虽然支持按天计费,但备份的任何数据都要至少按90 天收费。这样一来,我的账单就从 30 美元变成了 300 美元。

我给 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

根据这些结果,所有硬盘看起来都相当健康。唯一有差别的指标是通电时间

  • 现有硬盘
    • 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 函数,只需传入 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

sda2(即 DISK_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:用稀疏文件创建虚拟硬盘

我可以创建一个 4×8TB 的 RAIDZ2 存储池,但那样只有 16TB 可用容量,不够存放我的 18TB 数据。

相反,我创建一个 5×8TB 的 RAIDZ2 存储池,只不过第五块盘并不真实存在。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}"

最后,我需要给新存储池起个名字。之前我还不知道 ZFS 社区有把存储池命名为 tank 的传统,所以一直用的是 pool1。我觉得这次正好可以跟风,把新池命名为 tank,显得更酷一点:

NEWPOOL='tank'

可惜,这个 tank 的名字没能保留下来。TrueNAS 对存储池名称有很多依赖,所以我最终还是决定改回 pool1,而不是把所有的共享和定时任务都改成指向 tank(详见下文)。

步骤 9:创建 RAIDZ2 存储池

终于可以创建我的 5×8TB 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 的网页界面中也能看到新建的 RAIDZ2 存储池,它显示可用容量为 21.4 TiB(23.5TB):

在 TrueNAS 网页界面中可以看到新建的 RAIDZ2 存储池。

步骤 10:移除虚拟硬盘

虚拟硬盘只是我 /tmp 目录下的一个文件,并不能真正存储它所声称的 8TB 数据。为了防止虚拟文件耗尽 /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.5TB 容量,接下来我把旧 RAIDZ1 存储池中的 18TB 数据搬过去。

为了完整迁移,我对整个 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}"

输出显示已成功解析续传令牌,但进度总是重置为零,所以我也不确定这个命令是否真的生效了。

完成迁移

由于迁移耗时数小时,我又创建了一个新快照,并对自上次快照以来发生变化的所有数据执行增量发送:

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 的网络共享和定时任务都与存储池名称绑定。如果把主存储池从 pool1 改名为 tank,就得手动重建或修改一大堆网络共享和定时任务。

更简单的办法是让新存储池沿用旧存储池的名称,于是我选择了这个方案。

我最初尝试通过导出(export)来离线存储池,但 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 识别为我新的 5×8TB RAIDZ2 存储池:

步骤 13:对新的 RAIDZ2 存储池执行 scrub

我不确定是否有必要,但在销毁旧存储池之前,我还是对新存储池执行了一次 scrub,以便对数据完整性更有把握。scrub 顺利完成,没有发现任何错误:

步骤 14:销毁旧存储池

接下来是最让人紧张的一步:销毁旧存储池。我用 zpool destroy 将其彻底删除:

zpool destroy "${OLDPOOL}-old"

步骤 15:用真实硬盘替换虚拟硬盘

销毁旧的 RAIDZ1 存储池后,我现在有三块硬盘可以迁移到新的 RAIDZ2 存储池。

第一次硬盘迁移需要特殊处理,因为我要替换 RAIDZ2 存储池上的虚拟硬盘(那个 8TB 稀疏文件)。

我使用 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 正在将数据重新同步(resilvering,即重新整理)到刚换上的硬盘上:

$ 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 界面也显示我正在替换硬盘:

步骤 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% 后,我对最后一块硬盘重复同样的操作,最终达到了理想状态。

完整的 7×8TB RAIDZ2 存储池

所有硬盘迁移完成后,我现在拥有了一个健康、完整的 7×8TB RAIDZ2 存储池,可用容量为 33TB(30 TiB):

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

读者@intelfx 提醒我 RAIDZ 扩容功能中有一个坑。在扩容前就已存在于硬盘上的数据,不会自动从“3 份数据 + 2 份校验”重新条带化为“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 的标志应用到通过命令行创建的存储池上会有什么区别,但以下是我认为 iXsystems 截至 25.04 版本所推荐的标志:

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 纠正了其中的一些细节

本文章由 muse-spark-1.2-contributor 进行翻译

评论