Linux 基础-Union File System
Linux 基础系列之 Union File System
UnionFS 介绍
Union File System,简称 UnionFS,是一种为 Linux 等操作系统设计的,是一种文件系统服务的实现方式。
UnionFS 最显著的特点是它支持将多个目录或文件系统层次「叠加」起来,这些「层」可以设置为 只读(read-only) 或 读写(read-write) 模式,同时提供 写时复制(Copy-on-Write, COW) 的机制。
其核心要点如下:
- 层(Layers):UnionFS 将多个底层的文件系统组织成 Layers。每个层可以是物理文件系统(如 ext4、xfs),也可以是一个目录(如
/dir1
,/dir2
),这些层被叠加(overlay),并形成一个统一的虚拟层次结构。这样应用在使用这个文件系统的时候,不用去关心它到底是个目录还是个物理磁盘 - 写时复制(Copy-on-Write, CoW):当 layer 是“只读”时,若用户试图修改文件,UnionFS 会将需要修改的文件复制到一份放到上层,成为一个写入层,并在该层进行修改,而底层的只读数据不会被直接更改,这保证了既保证了基础层的完整性,同时支持用户对该层的定制化。虚拟机快照也有 COW 机制,虽然虚拟机快照并未直接使用 UnionFS,但其实现机制与分层文件系统非常相似
- 优先级顺序:在文件查找时,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 的容器层与镜像层。
环境搭建
mkdir aufs-demo
创建一个文件夹,并通过mkdir aufs-demo/image-layer{1,2,3}
创建 3 个模拟镜像层的文件夹,一共是 3 层;然后在建一个容器层文件夹mkdir aufs-demo/container-layer
;最后建一个 mnt 文件夹,用于 aufs 挂载测试mkdir aufs-demo/mnt
。最后在容器层、镜像层的文件夹分别写入一些文件- 然后把
mnt
挂载成 aufs:mount -t aufs -o dirs=./container-layer:./image-layer1:./image-layer2:./image-layer3 none ./mnt
-t
表示指定文件系统的类型,这里是 aufs-o
用于指定挂载选项,dirs=...
表示 AUFS 的层次结构,定义了哪些目录或文件系统会参与联合挂载,以及它们的顺序,各层的目录通过:
分隔,顺序从左到右。因此这里./container-layer
即为“顶层”(Upper Layer)none
说明不绑定到具体的设备。AUFS 不需要挂载实际的存储设备(如硬盘分区或文件系统),它是基于目录联合挂载的逻辑文件系统./mnt
表示挂载点,挂载完成后,用户可以通过./mnt
这个目录访问联合后的文件视图
为了方便测试,可以写个简单的脚本,这样方便重复测试:
1 |
|
注意,这里要加 sleep,重建执行太快的话,会导致 aufs 挂载出现异常,现象是在只读层创建的文件不会反馈到 mnt 中,导致结论出现错误...原因不详
现在,作为顶层的 ./container-layer
即为可写层,所有新建或修改的文件都会存储在这个层中,如果修改了下层文件,AUFS 会通过写时复制(CoW)将这些文件复制到这里。
通过 /sys/fs/aufs/
下的目录可以看到,mnt 这个目录的确是一个分层的结构,并且只有 container-layer 是可写的,其他都是只读层:
查找文件
查询的顺序是:mnt => 读写层 => 只读层。且读写层中无法查到只读层的文件。因此如果一个文件同时存在于读写层和只读层,mnt 读到的是只读层的文件,此时文件认为存在于只读层。
修改文件
- 在 mnt 中修改:对于读写层文件,会被反馈到原本的
container-layer/
中,而其他只读层的修改并不会反馈到原目录中,而是在 mnt 下复制了一个一个同名的文件,修改内容反馈在这里面
- 在 读写层 中修改:不论是读写层独有的文件,还是只读层同名文件,修改都只会反馈到 mnt 中
- 在 只读层 中修改:修改只会反馈到 mnt 中。即使读写层有同名文件,也不会同步修改。
新增文件
- 在 mnt 中新增:读写层创建同名文件,只读层保持不变:
- 在读写层中新增,mnt 会创建同名文件,只读层不变
- 在只读层中新增,mnt 会创建同名文件,读写层不变
删除文件
删除逻辑是最为复杂的,测试过程也较为繁琐...
对于 AUFS 来说,删除文件本质上是在做 whiteout。AUFS 通过一种叫 whiteout 的机制来隐藏文件,使其看起来像是从读写层视角被删除了,但实际文件仍然存在于只读层中。白化操作会在读写层中生成对应文件的 .wh.
文件,内容为空
- 在 mnt 中删除文件:如果删除的文件位于读写层,则读写层文件被删除,且不会生产白化文件;只读层同名文件无变化
如果文件存在于只读层,则读写层会生成白化文件,但只读层文件不变:
- 在读写层中删除文件:mnt 文件同步被删除,不产生白化文件(注:如果此时只读层也有同名文件的话,见下方:“对 在 mnt 与只读层都有的 文件执行删除” 的测试);只读层同名文件无变化
- 在只读层中删除文件:那就看文件在哪了,如果文件在只读层,则 mnt 文件同步被删除,不产生白化文件;如果文件在读写层,则无变化,不会被删,因为此时文件从上到下找到的本来就是只读层的文件
手动新增白化文件的测试
反向思考一下,如果我们主动在读写层中创建白化文件,是不是就可以在 mnt 中隐藏这个文件?
看来的确如此。不论文件位于读写层还是只读层,mnt 中都会消失。但此时 mnt 目录对于这个文件还是可以访问的,只是看不到了,并且可以修改,那么这里就可以用来藏文件了
并且即使 umount 也不会恢复正常,因为白化文件并不会随着 umount 而删除。能发现这种文件的办法一个就是检查 mount 信息,另外一个就是扫描白化文件。
这种手法有几个细节:
- 如果文件是在 mnt 中直接创建的,不论是先创建文件再创建白化文件,还是顺序对调,白化文件都是没有效果的
- 对于挂载之后在读写层中新增的文件,不论是先新建白化文件,还是后新建白化文件,都能够进行隐藏(
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
文件。这里的原因我不过多深入研究了,总之现象非常奇葩
- 对于挂载之后在只读层中新增的文件,不论是先新建白化文件,还是后新建白化文件,都能够进行隐藏。不过注意,这里命令不能放在一行完成(同上测试现象类似),否则 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 |
|
结果如下:
查找文件
查询的顺序是:merged => 读写层(upper) => 只读层(lower)。且读写层中无法查到只读层的文件。因此如果一个文件同时存在于读写层和只读层,merged 读到的是只读层的文件,此时文件认为存在于只读层。
修改文件
- 在 merged 中修改:对于读写层文件,会被反馈到原本的
container-layer/
中,而其他只读层的修改并不会反馈到原目录中,而是在 merged 下复制了一个一个同名的文件,修改内容反馈在这里面
- 在 读写层 中修改:不论是读写层独有的文件,还是只读层同名文件,修改都只会反馈到 merged 中
- 在 只读层 中修改:修改只会反馈到 merged 中。即使读写层有同名文件,也不会同步修改
新增文件
- 在 merged 中新增:读写层创建同名文件,只读层保持不变:
- 在读写层中新增,merged 会创建同名文件,只读层不变
- 在只读层中新增,merged 会创建同名文件,读写层不变
删除文件
删除逻辑是最为复杂的,测试过程也较为繁琐...
overlayFS/overlay2 也是通过 whiteout 来标记删除文件,但这个白化文件是一个字符设备文件。
- 在 merged 中删除文件:如果删除的文件位于读写层,则读写层文件被删除,且不会生产白化文件;只读层同名文件无变化
如果文件存在于只读层,则读写层会生成白化文件,但只读层文件不变:
- 在读写层中删除文件:merged 文件同步被删除,不产生白化文件(注:如果此时只读层也有同名文件的话,见下方:“对 在 merged_dir 与只读层都有的 文件执行删除” 的测试);只读层同名文件无变化
- 在只读层中删除文件:那就看文件在哪了,如果文件在只读层,则 merged 文件同步被删除,不产生白化文件;如果文件在读写层,则无变化,不会被删,因为此时文件从上到下找到的本来就是只读层的文件
手动新增白化文件的测试
如果我们主动在读写层中创建白化文件(mknod container-layer/image-1.txt c 0 0
),就可以在 merged 中隐藏这个文件(但无法通过这种方式隐藏读写层或者 merged 中已有的文件,前者是因为在读写层这种白化文件的文件名与原文件是一模一样的,后者是因为白化文件会随着新增同名文件而被删除):
但是此时 merged 目录下无法访问这个文件
如果尝试重新创建这个文件,白化文件就会消失
此时如果在 merged 中删除这个文件,则白化文件又会重新出现。
如果要隐藏文件,可行的手法为:
- 在 merged_dir 中创建需要隐藏的文件(这个文件最好选择只读层中没有同名文件的,否则需要按照后面的说法搞个白化文件出来)
- 在读写层中删除这个文件
- 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 文件?
docker ps
确定目标容器 iddocker inspect --format='{{.GraphDriver.Data.UpperDir}}' bdbc559eb858
查看读写层- 这里面有被删除文件在容器内部目录的对应目录,查看即可
我们首先在容器里创建一个文件(对应上面实验中的 “在 merged_dir 中创建需要隐藏的文件”),然后在宿主机上该容器的读写层目录中删除这个文件(对应上面实验中的 “在读写层中删除这个文件”),查看容器内部,这个文件的确看着消失了,但其实这个文件还在,只是看不到了。删除会同样出现 rm: cannot remove 'hhh': Stale file handle
,所有现象都与上面的实验一模一样:
同理,这里只需要重新挂载这个文件就会消失,由于这里是由容器负责挂载的,所以需要停止容器再启动。如果只是重启的话,不会触发文件清理,这是因为如果该挂载点属于某个运行中的容器,停止容器会自动卸载挂载点,或者直接在宿主机上重启 Docker 服务,会重新初始化所有容器的挂载点。
总结
Union File System 是一种强大且灵活的文件系统技术,通过将多个文件系统或目录叠加成单一视图,广泛用于容器技术(如 Docker)、只读文件系统(如 LiveCD)和嵌入式系统等场景。其核心特点是「层叠叠加」和「写时复制」,极大地提升了系统的灵活性和存储效率。然而,在某些复杂场景中,它也可能引入一定的性能开销和实现复杂性。
由于我对 Linux 内核不是很了解,整个测试过程非常折磨人...算了,慢慢来吧
还是蛮佩服 Linux 内核的维护方的
这得写多少测试用例...