内核角落 - Unionfs:文件系统汇聚
为了便于管理,将相关但不同的文件集保存在不同的位置可能很有用。但是,用户通常希望将这些相关文件放在一起查看。在这种情况下,联合允许管理员在物理上将这些文件分开保存,但在逻辑上将它们合并到单个视图中。合并目录的集合称为联合,每个物理目录称为分支。如图 1 所示,Unionfs 同时分层在多个文件系统上,或同一文件系统内的不同目录上。这种分层技术称为堆叠(有关堆叠的更多信息,请参阅在线资源)。Unionfs 向内核呈现文件系统接口,反过来,Unionfs 将自身呈现为内核的 VFS,以供其堆叠的文件系统使用。由于 Unionfs 向内核呈现文件系统视图,因此任何用户级应用程序或内核中的 NFS 服务器都可以使用它。由于 Unionfs 拦截了绑定到较低级别文件系统的操作,因此它可以修改操作以呈现统一的视图。与早期的可堆叠文件系统不同,Unionfs 是真正的扇出文件系统;它可以直接访问许多底层分支。
在 Unionfs 中,每个分支都被分配一个优先级。优先级较高的分支会覆盖优先级较低的分支。Unionfs 对目录进行操作。如果一个目录存在于两个底层分支中,则 Unionfs 目录的内容和属性是两个较低级别目录的组合。Unionfs 自动删除任何重复的目录条目,因此用户不会因重复的文件名或目录而感到困惑。如果一个文件存在于两个分支中,则 Unionfs 文件的内容和属性与优先级较高的分支中的文件相同,而优先级较低的分支中的文件将被忽略。
作为一个具体的例子,假设我们联合两个目录,/Fruits 和 /Vegetables
$ ls /Fruits Apple Tomato $ ls /Vegetables Carrots Tomato $ cat /Fruits/Tomato I am botanically a fruit. $ cat /Vegetables/Tomato I am horticulturally a vegetable.
要使用 Unionfs,您首先需要编译 Unionfs 模块并将其加载到内核中。接下来,像任何其他文件系统一样,Unionfs 被挂载。与其他文件系统不同,Unionfs 不挂载在设备之上;它挂载在指定为挂载时选项的目录之上。要创建一个联合,我们按如下方式挂载 Unionfs
# mount -t unionfs -o dirs=/Fruits:/Vegetables \ > none /mnt/healthy
在本例中,mount 选项 dirs 告诉 Unionfs 哪些目录构成联合。Unionfs 不挂载任何设备,因此我们使用 none 作为占位符。最后,/mnt/healthy 是合并视图的位置。现在 /mnt/healthy 包含三个文件:Apple、Carrots 和 Tomato。因为我们在 /Vegetables 之前指定了 /Fruits,所以 /mnt/healthy/Tomato 包含“我在植物学上是水果。” 如果我们颠倒 dirs= 选项,/mnt/healthy/Tomato 将包含“我在园艺学上是蔬菜。”(这与 1893 年美国最高法院对此事的裁决一致)。
这个过程是递归的。如果 Fruits 的子目录名为 Green,其中包含名为 Lime 的文件,而 Vegetables 的子目录也名为 Green,其中包含名为 Lettuce 的文件,则结果将是
$ ls /mnt/healthy Apple Carrots Green/ Tomato $ ls /mnt/healthy/Green Lime Lettuce
Unionfs 可以应用于多种方式。简单的例子包括统一来自多个服务器的家目录,或合并拆分的 ISO 镜像以创建发行版的统一视图。类似地,Unionfs 与写时复制语义结合使用,可用于修补 CD-ROM、进行源代码管理或进行快照。
通常,单个客户端机器从多个不同的 NFS 服务器挂载家目录。不幸的是,每个服务器都有一个不同的挂载点,这对用户来说很不方便。理想情况下,所有家目录都应该可以从同一个位置(例如 /home)访问。一些自动挂载器使用符号链接来创建联合的错觉。使用 Unionfs,这些链接是不必要的。只需将单独导出的目录统一到一个视图中即可。假设我们有两个文件系统,一个挂载在 /alcid 上,另一个挂载在 /penguin 上。我们可以将它们统一到 /home 中,如下所示
# mount -t unionfs -o dirs=/alcid,/penguin \ > none /home
现在,/alcid 和 /penguin 中的家目录都可以在 /home 中访问。
Unionfs 支持多个读写分支,因此用户的文件不会从一个目录迁移到另一个目录。这与以前的联合系统(如 BSD-4.4 的 Union Mounts)形成对比,后者通常只支持单个读写分支。
大多数 Linux 发行版都以 ISO 镜像和单个软件包的形式提供。ISO 镜像很方便,因为它们可以直接刻录到 CD-ROM 上,并且您只需要下载和存储少量文件。但是,要通过网络安装到机器,您通常需要将单个软件包放在一个目录中。使用环回设备,ISO 镜像可以挂载在单独的目录中,但这布局不适合网络安装,因为所有文件都需要位于单个树中。因此,许多站点都维护 ISO 镜像和单个软件包文件的副本,既浪费了磁盘空间和带宽,又增加了管理工作量。Unionfs 可以通过虚拟地组合来自 ISO 镜像的单个软件包目录来缓解这个问题。
在本例中,我们挂载在两个目录 /mnt/disc1 和 /mnt/disc2 之上。挂载命令如下
# mount -t unionfs -o dirs=/mnt/disc1,/mnt/disc2 \ > none /mnt/merged-distribution
在 ISO 镜像的先前示例中,联合中的所有分支都是只读的;因此,联合本身也是只读的。Unionfs 还可以混合使用只读和读写分支。在这种情况下,整个联合是可读写的,Unionfs 使用写时复制语义来产生您可以修改只读分支上的文件和目录的错觉。这可以用于修补 CD-ROM。如果 CD-ROM 挂载在 /mnt/cdrom 上,并且在 /tmp/cdpatch 中创建了一个空目录,则可以按如下方式挂载 Unionfs
# mount -t unionfs -o dirs=/tmp/cdpatch,/mnt/cdrom \ > none /mnt/patched-cdrom
通过 /mnt/patched-cdrom 查看时,似乎您可以写入 CD-ROM,但所有写入实际上都将在 /tmp/cdpatch 中进行。写入只读分支会导致一个名为 copyup 的操作。当以写入方式打开只读文件时,该文件将被复制到更高优先级的分支。如果需要,Unionfs 会自动创建任何需要的父目录层次结构。
在本 CD-ROM 示例中,较低级别的文件系统强制执行只读权限,而 Unionfs 尊重这些权限。在其他情况下,较低级别的文件系统可能确实是可读写的,但 Unionfs 不应修改该分支。例如,您可能有一个分支包含原始内核源代码,然后使用单独的分支来存放您的本地更改。通过 Unionfs,原始源代码应该是只读的,就像上一个示例中的 CD-ROM 一样。这可以通过在 dirs 挂载选项中向目录添加 =ro 来完成。假设 /home/cpw/linux 为空,而 /usr/src/linux 包含 Linux 内核源代码树。以下挂载命令使 Unionfs 的行为类似于源代码版本控制系统
# mount -t unionfs -o \ > dirs=/home/cpw/linux:/usr/src/linux=ro \ > none /home/cpw/linux-src
此示例使 /home/cpw/linux-src 中似乎存在整个 Linux 源代码树,但对该源代码树的任何更改(例如,更改的源文件或新的目标文件)实际上都会转到 /home/cpw/linux。
通过一个简单的修改,我们也可以使用覆盖挂载。也就是说,我们可以将 /home/cpw/linux 替换为统一视图
# mount -t unionfs -o > dirs=/home/cpw/linux:/usr/src/linux=ro > none /home/cpw/linux
Unionfs 中的大多数文件系统操作都从较高优先级的分支移动到较低优先级的分支。例如,LOOKUP 操作从父目录存在的最高优先级分支开始,然后移动到较低优先级的分支。在查找操作期间,Unionfs 缓存信息以供后续操作使用。
CREATE 操作尝试在父目录存在的最高优先级分支中创建文件。CREATE 操作使用缓存的查找信息直接在适当的分支上操作,因此实际上,它从较高优先级的分支移动到较低优先级的分支。
Unionfs 使用多种技术来提供修改只读分支的错觉,同时保持正常的 UNIX 语义。如果在创建文件时发生错误,则必须执行错误处理。错误处理从较低优先级的分支进行到较高优先级的分支。从父目录存在的最高优先级分支开始,Unionfs 尝试在每个更高优先级的分支中创建文件。最后,如果操作在整个联合的最高优先级分支中失败,则 Unionfs 向用户返回错误。
与 CREATE 相反,UNLINK 操作始终从较低优先级的分支进行到较高优先级的分支。由于要 UNLINK 的最后一个底层对象是最高优先级的对象,因此在 Unionfs 的 UNLINK 操作结束之前,用户可见的状态不会被修改。最复杂的处理情况是部分错误。如果删除中间文件失败,并且 Unionfs 只是删除了最高优先级的文件,则较低优先级的文件将对用户可见。为了处理这些错误情况,Unionfs 使用一个特殊的高优先级文件,称为白化文件。如果 Unionfs 遇到白化文件,它的行为就好像该文件在任何较低优先级的分支中都不存在一样。在内部,要为名为 F 的文件创建白化文件,Unionfs 会创建一个名为 .wh.F 的零长度文件。回到 UNLINK—如果中间 UNLINK 失败,Unionfs 会将文件重命名为相应的白化文件名,而不是删除最高优先级的文件。
这种谨慎的操作顺序有两个效果。首先,即使在面对错误或只读分支时,UNIX 语义也得以维护。用户可见的状态直到在最高优先级分支上尝试操作后才会被修改。操作的成功或失败取决于此分支的成功或失败。通过使用白化文件,即使文件存在于只读分支上,也可以删除该文件。第二个效果是,当没有错误发生时,文件和目录倾向于保留在它们最初所在的分支中。这很重要,因为 Unionfs 的目标之一是将文件保存在不同的位置。
默认情况下,Unionfs 尝试删除所有分支中文件的所有实例;此模式称为 DELETE_ALL。除了 DELETE_ALL 之外,Unionfs 还支持两种删除模式:DELETE_WHITEOUT 和 DELETE_FIRST。DELETE_WHITEOUT 在外部的行为类似于默认模式,但它不是删除联合中的所有文件,而是创建一个白化文件。这样做的好处是,较低优先级的文件仍然可以通过底层文件系统访问。DELETE_FIRST 偏离了经典的 UNIX 语义。它只删除联合中优先级最高的条目,因此允许显示较低优先级的条目。这些模式也用于 RENAME 操作,因为它是一个创建后跟删除的组合。
DELETE_FIRST 需要用户了解联合的组件。当 Unionfs 用于源代码版本控制时,这很有用,就像我们之前的内核源代码树示例一样。如果我们更改 /home/cpw/linux 中的文件,该文件将被复制到更高优先级的分支。如果使用标准的 DELETE_ALL 语义删除该文件,Unionfs 将在最高优先级分支中创建一个白化文件(因为它无法修改只读的较低优先级分支)。较低优先级分支中的原始源文件现在无法访问,因此必须从源复制到联合中,这很难成为方便的版本控制系统。这种情况正是 DELETE_FIRST 派上用场的地方。删除模式指定为挂载选项,如下例所示
# mount -t unionfs -o \ > dirs=/home/cpw/linux:/usr/src/linux=ro,\ > delete=first none /home/cpw/linux
使用 unionctl 实用程序,Unionfs 的分支配置可以动态更改。可以在联合中的任何位置添加新分支,可以删除分支,并且可以将读写分支标记为只读(反之亦然)。这种灵活性允许 Unionfs 创建文件系统快照。在本例中,我们使用 Unionfs 在安装新软件包时创建 /usr 的快照
# mount -t unionfs -o dirs=/usr none /usr
此时,Unionfs 只有一个分支,即读写分支 /usr。所有操作都传递到底层文件系统,就好像 Unionfs 不存在一样。
创建快照涉及两个步骤。第一步是通过添加一个分支(例如 /snaps/0)来指定快照文件的位置,如下所示
# unionctl /usr --add /snaps/0
此时,Unionfs 在 /snaps/0 中为 /usr 创建新文件,但 /usr 子目录中的文件在底层 /usr 中创建。这种看似矛盾的原因是,文件是在父目录存在的最高优先级分支中创建的规则。对于联合根目录中的文件 /usr,父目录存在于两个分支中。由于 /snaps/0 是优先级较高的分支,因此新文件和目录在物理上是在 /snaps/0 中创建的。但是,/snaps/0 是空的,因此如果在 /usr/local 中创建文件,则最高优先级的父目录实际上将在底层 /usr 分支中。
要完成迁移,原始的 /usr 分支需要是只读的。同样,我们使用 unionctl 修改分支配置
# unionctl /usr --mode /usr ro
现在,由于 Unionfs 认为底层 /usr 是只读的,因此所有写操作实际上都发生在 /snaps/0 中。只需添加另一个分支(例如 /snaps/1)并将 /snaps/0 标记为只读,即可拍摄多个快照。
第一个快照可以通过底层目录 /usr 查看。每个快照都由一个基本目录和几个具有增量差异的目录组成。要查看特定快照,我们只需要联合第一个快照和增量更改即可。例如,要查看由 /usr 和 /snaps/0 组成的快照,请按如下方式挂载 Unionfs
# mount -t unionfs -o ro,dirs=/snaps/0:/usr \ > none /mnt/snap
在本例中,Unionfs 本身是只读挂载的,因此底层目录不会被修改。
在确定快照中所做的更改良好之后,下一步通常是将快照合并回基本目录。Unionfs 发行版包含一个 snapmerge 脚本,该脚本将增量 Unionfs 快照应用于基本目录。这是通过递归地将快照目录中的文件复制到基本目录来完成的。复制过程完成后,新文件和更改的文件即已完成。最后一步是处理文件删除,这是通过创建白化文件列表并删除相应的文件来完成的。白化文件本身也被删除,以免使树结构混乱。
Unionfs 递归地将多个底层目录或分支合并到单个虚拟视图中。Unionfs 高效的扇出结构使其适用于许多应用程序。Unionfs 可用于提供合并的发行版 ISO、单个 /home 命名空间等。Unionfs 的写时复制语义使其可用于源代码版本控制、快照和修补 CD-ROM。我们对 Linux 2.4 下的 Unionfs 性能进行了基准测试。对于一个到四个分支的编译基准测试,Unionfs 开销仅为 12%。对于 I/O 密集型工作负载,对于单个分支,开销范围为 10%,对于四个分支,开销范围为 12%。
感谢 Puja Gupta、Harikesavan Krishnan、Mohammad Zubair 和 Jay Dave,他们也是 Unionfs 开发团队的成员。特别感谢 Mohammad 帮助准备本文的软件以供发布。
Charles P. Wright (cwright@cs.stonybrook.edu) 是石溪大学的计算机科学博士生。Charles 从事操作系统研究,重点是文件系统、安全性和可扩展性。他还积极参与石溪大学的 Linux 用户组 (LUGSB)。
Erez Zadok (ezk@cs.stonybrook.edu) 是石溪大学计算机科学系的教员,《Linux NFS 和 Automounter 管理》(Sybex,2001 年)的作者,FiST 可堆叠模板系统的创建者和维护者,以及 Am-utils(又名 Amd)自动挂载器的主要维护者。Erez 从事操作系统研究,重点是文件系统、安全性、通用性和可移植性。