Linux 基础-Union File System

Linux 基础系列之 Union File System

UnionFS 介绍

Union File System,简称 UnionFS,是一种为 Linux 等操作系统设计的,是一种文件系统服务的实现方式。

UnionFS 最显著的特点是它支持将多个目录或文件系统层次「叠加」起来,这些「层」可以设置为 只读(read-only)读写(read-write) 模式,同时提供 写时复制(Copy-on-Write, COW) 的机制。

其核心要点如下:

  1. 层(Layers):UnionFS 将多个底层的文件系统组织成 Layers。每个层可以是物理文件系统(如 ext4、xfs),也可以是一个目录(如 /dir1, /dir2),这些层被叠加(overlay),并形成一个统一的虚拟层次结构。这样应用在使用这个文件系统的时候,不用去关心它到底是个目录还是个物理磁盘
  2. 写时复制(Copy-on-Write, CoW):当 layer 是“只读”时,若用户试图修改文件,UnionFS 会将需要修改的文件复制到一份放到上层,成为一个写入层,并在该层进行修改,而底层的只读数据不会被直接更改,这保证了既保证了基础层的完整性,同时支持用户对该层的定制化。虚拟机快照也有 COW 机制,虽然虚拟机快照并未直接使用 UnionFS,但其实现机制与分层文件系统非常相似
  3. 优先级顺序:在文件查找时,UnionFS 会按照层的优先级顺序,从上层依次向下查找文件,一旦找到所需文件,就停止继续查找

回想我们在使用容器技术的时候,对容器做出的任何修改都不会影响其镜像,其中便是 cow 的机制在发挥作用。UnionFS 是最早的一种实现,但因为其复杂的实现和性能问题,在实际应用中不够高效。在 UnionFS 的基础上涌现出了非常多的实现版本,无法逐一研究,本文只聚焦于最常见的 AUFS 和 overlayFS。

易混淆的另外一个概念是 UFS,UFS 通常指的是 Unix File System(也叫 伯克利快速文件系统,Berkeley Fast File System),这是一种在 Unix 系统中使用的传统文件系统。对于 Union File System,常用的缩写是 UnionFS。

AUFS

AUFS,英文全称是 Advanced Multi-Layered Unification Filesystem

AUFS 是对 UnionFS 的改进版本,是早期版本的 Docker 的默认存储驱动。但可惜,它并未被合并到 Linux 主线内核(没有被 Linux 官方完全接纳),需要额外安装补丁。随着 OverlayFS 的发展,AUFS 在主流容器技术中逐渐被淘汰。

我在学习的过程中发现 AUFS 理论看着挺好理解的,实际上还是蛮多坑的。为了理解,我做了几个实验模拟一下 docker 的容器层与镜像层。

环境搭建

  1. mkdir aufs-demo 创建一个文件夹,并通过 mkdir aufs-demo/image-layer{1,2,3} 创建 3 个模拟镜像层的文件夹,一共是 3 层;然后在建一个容器层文件夹 mkdir aufs-demo/container-layer;最后建一个 mnt 文件夹,用于 aufs 挂载测试 mkdir aufs-demo/mnt。最后在容器层、镜像层的文件夹分别写入一些文件
  2. 然后把 mnt 挂载成 aufs:mount -t aufs -o dirs=./container-layer:./image-layer1:./image-layer2:./image-layer3 none ./mnt
    1. -t 表示指定文件系统的类型,这里是 aufs
    2. -o 用于指定挂载选项,dirs=... 表示 AUFS 的层次结构,定义了哪些目录或文件系统会参与联合挂载,以及它们的顺序,各层的目录通过 : 分隔,顺序从左到右。因此这里 ./container-layer 即为“顶层”(Upper Layer)
    3. none 说明不绑定到具体的设备。AUFS 不需要挂载实际的存储设备(如硬盘分区或文件系统),它是基于目录联合挂载的逻辑文件系统
    4. ./mnt 表示挂载点,挂载完成后,用户可以通过 ./mnt 这个目录访问联合后的文件视图

为了方便测试,可以写个简单的脚本,这样方便重复测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
umount /root/zaemon/aufs-demo/mnt
sleep 1

rm -rf ./container-layer/ image-layer1/ image-layer2/ image-layer3/-layer/ ./image-layer1/ ./image-layer2/ ./image-layer3/ ./mnt/
sleep 2

mkdir ./image-layer{1,2,3}
mkdir ./mnt ./container-layer

echo "I am image layer 1" > image-layer1/image-1.txt
echo "I am image layer 3" > image-layer3/image-3.txt
echo "I am image layer 2" > image-layer2/image-2.txt
echo "I am container layer" > container-layer/container.txt

mount -t aufs -o dirs=./container-layer:./image-layer1:./image-layer2:./image-layer3 none ./mnt

注意,这里要加 sleep,重建执行太快的话,会导致 aufs 挂载出现异常,现象是在只读层创建的文件不会反馈到 mnt 中,导致结论出现错误...原因不详

现在,作为顶层的 ./container-layer 即为可写层,所有新建或修改的文件都会存储在这个层中,如果修改了下层文件,AUFS 会通过写时复制(CoW)将这些文件复制到这里。

通过 /sys/fs/aufs/ 下的目录可以看到,mnt 这个目录的确是一个分层的结构,并且只有 container-layer 是可写的,其他都是只读层:

查找文件

查询的顺序是:mnt => 读写层 => 只读层。且读写层中无法查到只读层的文件。因此如果一个文件同时存在于读写层和只读层,mnt 读到的是只读层的文件,此时文件认为存在于只读层。

修改文件

  1. 在 mnt 中修改:对于读写层文件,会被反馈到原本的 container-layer/ 中,而其他只读层的修改并不会反馈到原目录中,而是在 mnt 下复制了一个一个同名的文件,修改内容反馈在这里面
  2. 在 读写层 中修改:不论是读写层独有的文件,还是只读层同名文件,修改都只会反馈到 mnt 中
  3. 在 只读层 中修改:修改只会反馈到 mnt 中。即使读写层有同名文件,也不会同步修改。

新增文件

  1. 在 mnt 中新增:读写层创建同名文件,只读层保持不变:
  2. 在读写层中新增,mnt 会创建同名文件,只读层不变
  3. 在只读层中新增,mnt 会创建同名文件,读写层不变

删除文件

删除逻辑是最为复杂的,测试过程也较为繁琐...

对于 AUFS 来说,删除文件本质上是在做 whiteout。AUFS 通过一种叫 whiteout 的机制来隐藏文件,使其看起来像是从读写层视角被删除了,但实际文件仍然存在于只读层中。白化操作会在读写层中生成对应文件的 .wh. 文件,内容为空

  1. 在 mnt 中删除文件:如果删除的文件位于读写层,则读写层文件被删除,且不会生产白化文件;只读层同名文件无变化

    如果文件存在于只读层,则读写层会生成白化文件,但只读层文件不变:
  2. 在读写层中删除文件:mnt 文件同步被删除,不产生白化文件(注:如果此时只读层也有同名文件的话,见下方:“对 在 mnt 与只读层都有的 文件执行删除” 的测试);只读层同名文件无变化
  3. 在只读层中删除文件:那就看文件在哪了,如果文件在只读层,则 mnt 文件同步被删除,不产生白化文件;如果文件在读写层,则无变化,不会被删,因为此时文件从上到下找到的本来就是只读层的文件

手动新增白化文件的测试

反向思考一下,如果我们主动在读写层中创建白化文件,是不是就可以在 mnt 中隐藏这个文件?

看来的确如此。不论文件位于读写层还是只读层,mnt 中都会消失。但此时 mnt 目录对于这个文件还是可以访问的,只是看不到了,并且可以修改,那么这里就可以用来藏文件了

并且即使 umount 也不会恢复正常,因为白化文件并不会随着 umount 而删除。能发现这种文件的办法一个就是检查 mount 信息,另外一个就是扫描白化文件。

这种手法有几个细节:

  1. 如果文件是在 mnt 中直接创建的,不论是先创建文件再创建白化文件,还是顺序对调,白化文件都是没有效果的
  2. 对于挂载之后在读写层中新增的文件,不论是先新建白化文件,还是后新建白化文件,都能够进行隐藏(touch container-layer/test.txt; ls mnt/; bash -c "touch container-layer/.wh.test.txt; ls mnt/test.txt")。不过注意,mnt 里的文件过一小会才会消失,原因不详。如果你遇到报错:cat: mnt/test.txt: Input/output error,说明你重建环境的时候太快了,需要加上 sleep,或者是你 touch 文件没有使用新的进程,或者没有读取 mnt 文件。这里的原因我不过多深入研究了,总之现象非常奇葩
  3. 对于挂载之后在只读层中新增的文件,不论是先新建白化文件,还是后新建白化文件,都能够进行隐藏。不过注意,这里命令不能放在一行完成(同上测试现象类似),否则 mnt 下这个文件会读不到,原因不详。解决办法我测试了下有两个,要么用 bash 将命令拆开执行(touch image-layer1/test.txt; ls mnt/; bash -c "touch container-layer/.wh.test.txt; ls mnt/test.txt"),要么直接往该文件写内容:touch image-layer1/test.txt; touch container-layer/.wh.test.txt; echo 666 > mnt/test.txt; cat mnt/test.txt

手动删除白化文件的测试

主动删除一个白化文件,如果这个文件是在只读层,即使删除这个白化文件,mnt 中也不会重新出现该文件:

此时可以重新进行挂载,便能恢复正常。

如果文件位于读写层,文件会重新出现,内容与当前读写层的文件保持一致:

对 在 mnt 与只读层都有的 文件的删除测试

有趣的是,如果我们创建一个与只读层相同的文件,在读写层执行 rm 只会删除读写层的这个文件,但 mnt 中还会有这个文件,此时它其实就是只读层的那个文件内容;如果是在 mnt 中删除这个文件,则会直接产生白化文件,同时 mnt 中的文件也直接消失:

AUFS 实验总结

直接看图吧:

overlayFS/Overlay2

overlayFS 是目前最流行的 Union File System 实现,性能优越,被内核直接支持(从 Linux Kernel 3.18 开始)。它广泛用于 Docker 和 Kubernetes。比如 Docker 用的 UnionFS 自 18.09 后从 AUFS 改为 Overlay2。Overlay2 是基于 OverlayFS 技术的一种优化版本。可以将它们看作是父子关系,Overlay2 是 Docker 为容器存储场景优化了的 OverlayFS 的一种实现。

overlayfs/overlay2 通过三个目录来实现:lower 目录、upper 目录、以及 work 目录(可选)。三种目录合并出来的目录称为 merged 目录。

  • lower 目录:可以是多个,是处于最底层的目录,作为只读层
  • upper 目录:只有一个,作为读写层
  • work 目录:为工作基础目录,挂载后内容会被清空,且在使用过程中其内容用户不可见
  • merged 目录:为最后联合挂载完成给用户呈现的统一视图,也就是说 merged 目录里面本身并没有任何实体文件,给我们展示的只是参与联合挂载的目录里面文件而已,真正的文件还是在 lower 和 upper 中。所以,在 merged 目录下编辑文件,或者直接编辑 lower 或 upper 目录里面的文件都会影响到 merged 里面的视图展示。

同上,理论看着挺好理解的,但为了好理解,还是做了几个实验模拟一下 docker 的容器层与镜像层。

环境搭建

这里直接贴初始化脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
umount /root/zaemon/overlayFS/merged_dir

rm -rf ./container-layer/ image-layer1/ image-layer2/ image-layer3/-layer/ ./image-layer1/ ./image-layer2/ ./image-layer3/ ./work_dir ./merged_dir

mkdir ./image-layer{1,2,3}
mkdir ./work_dir ./container-layer ./merged_dir

echo "I am image layer 1" > image-layer1/image-1.txt
echo "I am image layer 3" > image-layer3/image-3.txt
echo "I am image layer 2" > image-layer2/image-2.txt
echo "I am container layer" > container-layer/container.txt

mount -t overlay overlay -o lowerdir=image-layer1:image-layer2:image-layer3,upperdir=container-layer,workdir=work_dir merged_dir

结果如下:

查找文件

查询的顺序是:merged => 读写层(upper) => 只读层(lower)。且读写层中无法查到只读层的文件。因此如果一个文件同时存在于读写层和只读层,merged 读到的是只读层的文件,此时文件认为存在于只读层。

修改文件

  1. 在 merged 中修改:对于读写层文件,会被反馈到原本的 container-layer/ 中,而其他只读层的修改并不会反馈到原目录中,而是在 merged 下复制了一个一个同名的文件,修改内容反馈在这里面
  2. 在 读写层 中修改:不论是读写层独有的文件,还是只读层同名文件,修改都只会反馈到 merged 中
  3. 在 只读层 中修改:修改只会反馈到 merged 中。即使读写层有同名文件,也不会同步修改

新增文件

  1. 在 merged 中新增:读写层创建同名文件,只读层保持不变:
  2. 在读写层中新增,merged 会创建同名文件,只读层不变
  3. 在只读层中新增,merged 会创建同名文件,读写层不变

删除文件

删除逻辑是最为复杂的,测试过程也较为繁琐...

overlayFS/overlay2 也是通过 whiteout 来标记删除文件,但这个白化文件是一个字符设备文件。

  1. 在 merged 中删除文件:如果删除的文件位于读写层,则读写层文件被删除,且不会生产白化文件;只读层同名文件无变化

    如果文件存在于只读层,则读写层会生成白化文件,但只读层文件不变:
  2. 在读写层中删除文件:merged 文件同步被删除,不产生白化文件(注:如果此时只读层也有同名文件的话,见下方:“对 在 merged_dir 与只读层都有的 文件执行删除” 的测试);只读层同名文件无变化
  3. 在只读层中删除文件:那就看文件在哪了,如果文件在只读层,则 merged 文件同步被删除,不产生白化文件;如果文件在读写层,则无变化,不会被删,因为此时文件从上到下找到的本来就是只读层的文件

手动新增白化文件的测试

如果我们主动在读写层中创建白化文件(mknod container-layer/image-1.txt c 0 0),就可以在 merged 中隐藏这个文件(但无法通过这种方式隐藏读写层或者 merged 中已有的文件,前者是因为在读写层这种白化文件的文件名与原文件是一模一样的,后者是因为白化文件会随着新增同名文件而被删除):

但是此时 merged 目录下无法访问这个文件

如果尝试重新创建这个文件,白化文件就会消失

此时如果在 merged 中删除这个文件,则白化文件又会重新出现。

如果要隐藏文件,可行的手法为:

  1. 在 merged_dir 中创建需要隐藏的文件(这个文件最好选择只读层中没有同名文件的,否则需要按照后面的说法搞个白化文件出来)
  2. 在读写层中删除这个文件
  3. merged_dir 下这个文件还能读到,可写,可执行

这个方式连白化文件都没有,非常非常隐蔽,不过重新挂载之后这个文件就没有了

如果希望实现重新挂载之后还可以隐藏,就需要在第 2 步之后,通过 mknod 在读写层中生成白化文件,实现该文件隐藏的效果,不过这样就能看到白化文件了。

能发现这种文件的办法,我感觉只能检查 mount 信息了。

手动删除白化文件的测试

主动删除一个白化文件,如果这个文件是在只读层,即使删除这个白化文件,mnt 中也不会重新出现该文件:

此时可以重新进行挂载,便能恢复正常。

如果文件位于读写层,删除时本来就不产生白化文件,即使手动新增白化文件后再删除,文件也不会重新在 merged_dir 中出现

对 在 merged_dir 与只读层都有的 文件的删除测试

有趣的是,如果我们创建一个与只读层相同的文件,在读写层执行 rm 只会删除读写层的这个文件(这里其实就是上面隐藏文件的手法的一个步骤),但 merged_dir 中还会有这个文件,区别于 AUFS,这个文件不能再被删除,否则会报 rm: cannot remove 'merged_dir/image-1.txt': Stale file handle。此时这个文件其实就是只读层的那个文件内容;如果是在 merged_dir 中删除这个文件,则会直接产生白化文件,同时 merged_dir 中的文件也直接消失。

overlayFS/Overlay2 实验总结

利用 overlay2 在容器中隐藏文件

既然现在 Docker 用的就是 overlay2,那是不是可以用上面的手法在容器内部隐藏文件?

对于容器的目录结构这里暂时不多说了,后面容器篇有会介绍。容器内部删除文件之后,如何在宿主机上查看 whiteout 文件?

  1. docker ps 确定目标容器 id
  2. docker inspect --format='{{.GraphDriver.Data.UpperDir}}' bdbc559eb858 查看读写层
  3. 这里面有被删除文件在容器内部目录的对应目录,查看即可

我们首先在容器里创建一个文件(对应上面实验中的 “在 merged_dir 中创建需要隐藏的文件”),然后在宿主机上该容器的读写层目录中删除这个文件(对应上面实验中的 “在读写层中删除这个文件”),查看容器内部,这个文件的确看着消失了,但其实这个文件还在,只是看不到了。删除会同样出现 rm: cannot remove 'hhh': Stale file handle,所有现象都与上面的实验一模一样:

同理,这里只需要重新挂载这个文件就会消失,由于这里是由容器负责挂载的,所以需要停止容器再启动。如果只是重启的话,不会触发文件清理,这是因为如果该挂载点属于某个运行中的容器,停止容器会自动卸载挂载点,或者直接在宿主机上重启 Docker 服务,会重新初始化所有容器的挂载点。

总结

Union File System 是一种强大且灵活的文件系统技术,通过将多个文件系统或目录叠加成单一视图,广泛用于容器技术(如 Docker)、只读文件系统(如 LiveCD)和嵌入式系统等场景。其核心特点是「层叠叠加」和「写时复制」,极大地提升了系统的灵活性和存储效率。然而,在某些复杂场景中,它也可能引入一定的性能开销和实现复杂性。

由于我对 Linux 内核不是很了解,整个测试过程非常折磨人...算了,慢慢来吧


还是蛮佩服 Linux 内核的维护方的
这得写多少测试用例...


Linux 基础-Union File System
https://www.tr0y.wang/2025/03/11/linux-unionfs/
作者
Tr0y
发布于
2025年3月11日
更新于
2025年3月11日
许可协议