作者:Mark Johnston
长期以来,FreeBSD 项目在下载站点提供了虚拟机 (VM) 磁盘镜像:只需访问 https://download.freebsd.org/snapshots/VM-IMAGES,即可找到一系列预构建的镜像来下载。这些镜像支持多种格式,常见的虚拟化平台如 QEMU、VirtualBox、VMware 和 bhyve 都可以识别。FreeBSD 项目还为多个云平台(如 EC2、Azure 和 GCP)提供了镜像。作为 FreeBSD 用户,你只需要选择镜像并创建实例,在几秒钟内即可获得一个完全预装的 FreeBSD 系统。
对于大多数用户来说,预构建的镜像已经足够使用了,但如果你有某些特殊需求,这些镜像可能无法满足。尤其是,直到最近,FreeBSD 项目的所有官方镜像都使用 UFS 作为根文件系统。当然,仍然可通过几种策略在虚拟机中使用 ZFS:
将根文件系统保留在 UFS 上,但增加额外的磁盘,并用它们来实现 ZFS 池。
在虚拟机中启动 FreeBSD 安装介质,使用它将 FreeBSD 安装到虚拟磁盘上,根文件系统使用 ZFS。然后可克隆该虚拟机镜像,将其用作其他镜像的模板。
手动创建镜像,设置一块 md(4)
磁盘,然后在该磁盘上创建并导入 ZFS 池,再在该池中安装 FreeBSD。例如,poudriere image
就是这样工作的。
虽然这些策略可行,但每种方法都有一些注意事项:
方案 1) 使得使用引导环境变得困难;
方案 2) 需要手动创建和自定义模板镜像;
方案 3) 需要 root 权限,且不能在 jail 完成(目前不允许在 jail 中创建 ZFS 池)。
在很长一段时间,我一直希望能够原生构建基于 ZFS 的虚拟机镜像,以便可以同时在 UFS 和 ZFS 上运行 FreeBSD 回归测试套件。因此,在 2022 年,我开始研究如何扩展我们已经使用的工具(即 makefs(8)
)来支持某种形式的 ZFS。
FreeBSD 项目使用 makefs(8)
来构建官方虚拟机镜像,makefs
是一款源自 NetBSD 的工具。它接受一个/多个路径作为输入,并生成一个包含这些路径内容的文件系统镜像文件。
makefs
支持多种文件系统,包括 UFS、FAT 和 ISO9660。它的基本使用方法是将 FreeBSD 安装到一个临时目录(例如,使用 make installworld
),然后将 makefs
指向该目录。结果是个包含文件系统镜像的文件,该镜像的根目录包含 FreeBSD 的安装文件。
对于熟悉从源码构建 FreeBSD 的用户,以下示例也许能更清楚地说明这个过程:
这将一个预构建的 FreeBSD 发行版安装到 /tmp/foo
,然后使用 makefs
生成了一个文件系统镜像 fs.img
。可以用命令 mdconfig(8)
挂载该镜像,创建一个由该文件支持的字符设备。/tmp/foo
中文件的属性(如模式位和时间戳)在生成的镜像中会得到保留。
与此相比,传统的 FreeBSD “live” 安装过程可能会像这样:
在这里,我们使用工具 newfs(8)
初始化目标设备上的空文件系统,然后将文件复制到其中。尽管这种方法可行,但它有一些缺点,类似于我之前提到的创建 ZFS 池时的问题:newfs(8)
需要 root 权限,并且生成的镜像不可重现。也就是说,如果从相同的预构建 FreeBSD 发行版创建两个镜像,它们不会逐字节相同,例如,因为文件的访问时间和修改时间在两个镜像之间会略有不同。可重现性是构建系统的重要安全属性,它有助于更容易检测到构建输出中的恶意篡改。
我之前已经熟悉了 makefs
,因为我写过一些脚本来为自己的使用创建虚拟机镜像。如前所述,我希望能够以类似的方式构建 ZFS 镜像,而且我不是唯一一个有这种需求的人;常见的用户抱怨是,虽然 ZFS 是 FreeBSD 上常用的根文件系统,可 FreeBSD 所有的官方云镜像都是基于 UFS 的。因此,我花了一些时间思考 makefs
生成的 ZFS 镜像可能是什么样的。
那么,makefs
实际上是干什么的呢?要回答这个问题,首先需要了解一些文件系统的内部实现,但简而言之:makefs
会初始化一些全局文件系统元数据,如 UFS/FFS 超级块;然后遍历输入的目录树,将其内容复制到镜像中;并添加元数据,如指向文件数据的目录条目。传统方法是从一个空文件系统开始,然后通过 cp(1)
等工具让内核向其中添加数据,而 makefs
则在单一操作中生成了一个已填充的文件系统。因此,虽然这意味着 makefs
需要了解文件系统数据和元数据在磁盘上的布局,但它比内核实现的文件系统要简单得多。
例如,makefs
不需要通过文件名查找文件,不需要处理空间不足的情况,也不需要执行缓存操作和删除文件。
ZFS 复杂且庞大,但根据上述观察,假设有 makefs -t zfs
,它能忽略很多细节。对于我来说,这点非常重要:当时我并非 ZFS 专家,对 ZFS 磁盘格式了解很少,因此简单化是关键。此时,我们可以问:makefs -t zfs
应该做什么?
我的目标是支持创建以 ZFS 为根文件系统的虚拟机镜像。更具体地说,makefs
需要:
创建一个具有单个磁盘 vdev 的 ZFS 池。由于虚拟机镜像中的冗余并不特别有用,因此不需要支持 RAID 和镜像布局。
在池中创建至少一个数据集。该数据集需要能够挂载为根文件系统。实际上,使用 ZFS 安装的 FreeBSD 通常会预创建十几个数据集,但为了简化,初步的概念验证可以忽略这一点。
用输入目录中的内容填充此数据集。更具体地说,对于每个输入文件,makefs
需要分配一个 dnode
并将文件复制到镜像中的某个位置。它还需要复制文件的属性,如文件权限。
特别地,很多影响磁盘布局的 ZFS 特性可以被忽略。例如,makefs
无需考虑压缩和快照。因此,尽管任务看起来有些艰巨,但通过排除最必要特性之外的内容,此目标似乎完全可行。
作为一名 FreeBSD 内核开发者,我对 OpenZFS 的内部结构已经有了些许了解,但 ZFS 是个复杂的系统。代码被划分成多个不同的子系统,其中大多数并不了解数据在磁盘上的实际布局,而且我对那些了解磁盘布局的子系统也缺乏经验。然而,事实证明,我们可以将 OpenZFS 内核模块编译为用户空间库:libzpool.so
。这个库主要用于测试 OpenZFS 的代码本身,但对我来说,它似乎是一个很好的开始:libzpool.so
了解 ZFS 的磁盘布局,因此我认为我可以避免深入学习其细节,而是编写使用高层操作的代码,类似于命令 zpool create
如何简单地要求内核在一组 vdev
上创建一个池。
不深入细节,使用这种方法最终得到了一个工作的原型,但最终证明它是死胡同。以下是一些原因:
libzpool.so
并不适合用于“生产”应用:它没有稳定的接口,而我的原型实际上是在使用一些没有文档的内核 API。如果继续沿着这条路走下去,结果将会非常脆弱且难以维护。
libzpool.so
中的代码大部分是未修改的内核代码,因此会创建大量线程并在 ARC 中缓存文件数据,这对于 makefs
来说是多余的。结果是原型非常慢,并且会消耗大量系统内存,有时会触发内存溢出这个杀手。
结果无法重现。如果我使用相同的输入运行两次原型,输出的结果不会逐字节相同。
尽管我必须丢弃大部分原型,但编写它的过程是一次有价值的学习经验,并促使我尝试了不同的方法。
此时,我意识到我必须深入了解 ZFS 的磁盘布局。我意识到,FreeBSD 的引导加载程序会面临类似的问题:为了从 ZFS 池启动 FreeBSD,加载程序需要能够找到内核文件并将其加载到内存中。引导加载程序在一个受限的环境中运行,因此不能使用内核的 ZFS 代码,因此显然其他人已经解决了类似的问题。
幸运的是,互联网上有本 ZFS 磁盘布局规范;它不太完整且过时,但总比什么都没有要好。除此之外,我还可以查看引导加载程序的代码。从某种意义上说,引导加载程序解决了与 makefs
相反的问题:它只读取 ZFS 池中的数据,而不写入任何内容,而 makefs
创建了一个新的池,但不用读取现有的池。
引导加载程序的双重性非常有用:我可以编写代码创建一个池,然后通过尝试使用引导加载程序从池中读取文件(内核)来测试它。更具体地说,我首先将 FreeBSD 内核安装到一个临时目录:
然后我可以创建一个 ZFS 镜像,并尝试使用传统的 bhyve 加载器加载它:
这里,bhyveload
使用 /boot/userboot.so
,它是一个经过编译以在用户空间运行的 FreeBSD 引导加载程序副本。它具有大部分真实引导加载程序的功能,但与直接使用 BIOS 调用或 EFI 启动服务从磁盘读取数据不同,它使用熟悉的 read(2)
系统调用从镜像文件 zfs.img
中获取数据。
最初的目标是让 userboot.so
能够找到并加载位于 zfs.img
中的内核文件 /boot/kernel/kernel
。这是一个非常方便的测试平台,因为我可以轻松地将调试器附加到 bhyveload
,或向加载程序添加打印语句并重新编译 userboot.so
。我的第一个里程碑是让 vdev_probe()
识别该镜像为一个有效的 ZFS 池。
vdev_probe()
会查看磁盘是否属于 ZFS 池;即,它判断磁盘是否看起来是个 vdev
,如果是,则开始加载更多元数据:
ZFS 磁盘规范的第 1 章详细说明了 vdev
标签和 uberblock。简而言之,vdev
包含一个元数据块,即 vdev
标签,标签中包含说明 vdev
所属池的元数据,以及多个“uberblock”副本,uberblock 指向 vdev
元数据树的根。因此,为了让 userboot.so
找到我的池,我编写了代码,将 vdev
标签添加到输出的镜像文件中。
此时,makefs
已经开始使用 ZFS 特定的数据结构,如 vdev_label_t
和 uberblock_t
。为了避免重复引导加载程序中使用的定义,makefs
与其共享了一个大型头文件,其中包含许多有用的磁盘数据结构定义。
当引导程序能够探测并识别由 makefs
生成的镜像后,下一步是让它能够从镜像中挂载数据集。处理这个任务的引导程序代码主要位于 zfs_get_root()
。
要理解 zfs_get_root()
的实现,值得阅读 ZFS 磁盘布局规范的第三章,它概述了对象集。尽管规范很快就进入了详细的实现细节,但了解 ZFS 用来表示数据的高层结构还是很有价值的。
ZFS 有“块指针”,它们实际上只是指向 vdev
上某个数据块的物理位置(从 makefs
的角度看,这仅仅是输出镜像文件中的一个偏移量)。ZFS 元数据对象有几十种类型,它们由一个 512 字节的“dnode”表示。dnode 包含关于对象的各种元数据,例如对象的类型和大小,也可能包含指向额外数据的块指针。例如,存储在 ZFS 数据集中的文件由一个 dnode 表示(类型为 DMU_OT_PLAIN_FILE_CONTENTS
),类似于传统 Unix 文件系统中的 inode。最后,“对象集”是一个包含多个 dnode 数组的结构;每个 dnode 都由它所属的对象集和在数组中的索引唯一标识。
ZAP(ZFS 属性处理器)是个包含一组键值对的 dnode。ZAP 用于表示许多高级 ZFS 元数据结构。例如,一个 Unix 目录由一个 ZAP 表示,其键是文件名,值是对应文件的 dnode ID。
MOS(元对象集)是池的根对象集。uberblock 包含指向 MOS 的指针,通过 MOS 可以访问池中的所有其他元数据(因此也能访问数据)。有了这些信息,就更容易理解 zfs_get_root()
:它获取 ID 为 1 的 dnode(期望它是一个 ZAP 对象),利用它查找包含池属性的 ZAP 对象,并查找“bootfs”属性的值,这个值用于查找根数据集的 dnode。
在创建池时,makefs
会在 pool_init()
中分配并开始填充 MOS。只要 userboot.so
能够处理 MOS,就可以导入由 makefs
生成的池,此时我开始使用 zdb(8)
来检查生成的池。zdb
的命令行用法比较晦涩,但像下面这样的简单调用:
它将从 MOS 中转储 dnode 1,对于了解 OpenZFS 在导入池时预期看到的内容非常有用。例如,当转储一个 ZAP 对象时,zdb
会打印出 ZAP 中所有的键值对。许多配置 ZAP 的键值是 dnode ID,因此 zdb
可以轻松地用来检查池和数据集配置的不同“层”。
ZFS 数据集有名称,并且组织成树形结构。根数据集的名称通常与池的名称相同(例如,“zroot”),子数据集的名称则以父数据集的名称为前缀。虽然我为 makefs
编写的 ZFS 支持的初始原型将所有文件自动放在根数据集中,但这不足以创建根文件系统位于 ZFS 上的虚拟机镜像:bsdinstall
和其他 FreeBSD 安装程序会自动创建多个子数据集。比如,zroot/var
这样的数据集不会被挂载,只是存在用来提供设置,供子数据集继承,如 zroot/var/log
。我的目标是让 makefs
能够创建一个数据集树,符合 bsdinstall
提供的布局。
发布镜像构建脚本演示了如何创建多个数据集的语法。每个数据集由一个参数 -o fs
描述,该参数包含数据集名称和一个以分号分隔的属性列表。如 zfsprops(8)
手册页中所述,目前只支持少量属性。
当 makefs -t zfs
初始化各种结构后,它会开始处理输入的目录树。每个输入文件由一个 fsnode
结构表示,这些结构被组织成一个表示文件树的树形结构。首先,makefs
确定哪个 fsnode
对应于每个挂载的数据集的根。然后,它遍历 fsnode
树,为每个文件分配一个 dnode;这发生在数据集的上下文中,数据集决定了从哪个对象集中分配 dnode。
要复制常规文件,makefs
从当前对象集中分配一个 dnode,并通过一个循环从输出文件中分配空间块,并将输入文件的数据复制到这些分配的块中。ZFS 支持从 4KB 到 128KB 的 2 的幂大小的块,因此较小的文件不会造成过多的内部碎片。所有在镜像中分配的块都通过位图进行追踪,位图由 vdev_space_alloc() 函数更新。
位图追踪的已分配空间必须记录在输出镜像中;ZFS 使用一个中央数据结构——“空间映射”来追踪 vdev 中当前已分配的区域。makefs
使用位图作为所有块分配的内部表示,并用它来生成空间映射,这是生成镜像的最后步骤之一,待所有块分配完成。
为 makefs
添加 ZFS 支持花费了相当多的精力,但最终实现了一个我认为对许多 FreeBSD 用户有用的功能,同时避免了大量的维护负担。makefs
中约有 2600 行与 ZFS 相关的代码(总共有 15,000 行),这相对较少。还有一个回归测试套件,提供了相当充分的测试覆盖。
在这 2600 行中,有 100 多行是 assert()
调用,用于验证不变性。在开发过程中,这些断言非常有用,因为很多代码是以不完整的方式编写的,最初仅为了让引导加载程序工作,后来才逐步完善;这些断言帮助文档化了各种函数的限制,并帮助我在添加更多功能时捕捉了许多错误。
现在,FreeBSD 14.0 已经发布,并且根文件系统位于 ZFS 上的虚拟机镜像已经可以使用,我希望许多用户能够充分利用这一新功能。在发布周期中发现并修复了不少 bug,因此至少有些用户已经开始尝试。目前没有为 makefs -t zfs
添加新功能的打算,但如果有反馈,可能会有所改变——如果你发现有改进的空间,请提交 bug 报告。
Mark Johnston 是一名软件开发者和 FreeBSD 源代码开发者,居住在加拿大安大略省多伦多市。当不坐在电脑前时,他喜欢和朋友们一起参加城市躲避球联赛。