我们在容器里,运行 df 命令,你可以看到在容器中根目录 (/) 的文件系统类型是"overlay",它不是我们在普通 Linux 节点上看到的 Ext4 或者 XFS 之类常见的文件系统。
为什么不使用 Ext4 或者 XFS 之类的文件系统呢?个人觉得假如使用了 Ext4 或者 XFS 之后,那么容器镜像中相同文件内容就不好共享了。因为假如共享了的话,它们会这些共享的内容进行修改。而这明显不行,所以容器有了自己的文件系统,这个文件系统可以共享这些相同的内容,从而减少相同镜像文件在同一个节点上的数据冗余,可以节省磁盘空间,也可以减少镜像文件下载占用的网络资源。
1. Union File System
Union File System 就是把不同物理位置的目录合并 mount 到同一个目录中。比如可以把一张 CD/DVD 和一个硬盘目录给联合 mount 在一起,然后就可以对只读的 CD/DVD 上的文件进行修改,当然修改的文件是存于硬盘上的目录里。
1.1. AUFS
AUFS 是一种 Union File System,一开始叫 Another UnionFS,后来又叫 Alternative UnionFS,最后直接改为 Advance UnionFS。它是对 Linux 原生 UnionFS 的重写和改进。但是无论怎么改,它就是进不了 Linux 的主线。但是,我们可以在 Ubuntu 和 Debian 这些发行版上使用它。
1.1.1. AUFS 示例
那么 AUFS 的效果到底是怎么样的呢?下面根据耗子叔博客中的例子来演示一下。
-
首先我们建立两个目录
./fruits
和./vegetables
,并在目录中放入一些文件。 -
之后我们以 AUFS 的方式将这两个目录同时 mount 到
./mnt
目录中。我们可以看到./mnt
目录下有三个文件:apple、carrots、tomato,相当于./fruits
和./vegetables
这两个目录被 union 到了./mnt
目录。mount -t aufs -o dirs=./fruits:./vegetables none ./mnt/
-
接下去我们修改
./mnt/apple
这个文件的内容,可以看到./fruits/apple
的内容也被修改了。 -
再接下去我们修改
./mnt/carrots
这个文件的内容,但是我们可以看到./vegetables/carrots
文件的内容并没有改变,反而是./fruits
目录中多出了 carrots 文件,这个内容跟我们修改的内容是一样。这个主要是因为在 mount aufs 命令中,我们并没有指定 fruits 和 vegetables 的目录权限。那么,默认上来说,命令行第一个(最左边)的目录是可读可写的,后面的都是可读的。
假设修改一开始的 mount aufs 命令如下,那么上述修改
./mnt/carrots
文件时,./vegetables/carrots
这个文件才会被改变。mount -t aufs -o dirs=./fruits=rw:./vegetables=rw none ./mnt
-
假如我上面的两个目录都配置为可读可写,那修改
./mnt/tomato
这个文件的内容,影响到的其实是./fruits
这个目录下的。可见如果有重复的文件名,在 mount 命令中,越前面的目录中的文件的优先级就越高,也就是会被先改。
1.1.2. AUFS 特性
上述只阐述了简单的例子。实际上,AUFS 有所有 UnionFS 的特性,它可以把多个目录合并成同一个目录;并且为每个需要合并的目录指定相应的权限;实时地添加、删除、修改已经被 mount 好的目录;还能在多个可写的目录(分支)间实现负载均衡。
AUFS 中称要被 union 进来的目录为 Branch(也就是使用 mount 命令时 dirs 参数指定的目录),Branch 会根据 union 的顺序形成一个 stack,一般最上面的是可写的,下面是只读的。Branch stack 还是可以进行修改,比如修改顺序,加入新的 branch,或者删除其中的 branch,或者直接修改 branch 的权限。
AUFS 中被 Union 的目录(分支)有以下这些权限:
-
rw 表示可读可写 read-write
-
ro 表示只读 read-only,那么对于 ro 目录来说,是永远不会收到写操作的,也不会收到查找 whiteout 的操作。
-
rr 表示 read-read-only,与 read-only 不同的是,rr 标记的是天生就是只读的目录。这样一来, AUFS 可以提高性能,比如不再设置 inotify 来检查文件变动的通知。
-
wh 表示 whiteout,它通常和 ro 一起使用,比如
[dir]=ro+wh
。whiteout 主要用于隐藏底层分支的文件。当 union 中要删除的某个文件实际上位于 read-only 的目录上的,由于在 read-only 上我们无法做任何的修改,此时我们就可以对这个 read-only 目录里的文件做 whiteout。具体做法就是在可写的目录中创建对应的 whiteout 隐藏文件来实现,比如
demo
这个文件位于 read-only 目录中,那么要 union 的目录中要删除这个文件了,那么就在可写的目录中创建一个名为.wh.demo
的文件即可。除此之外,whiteout 还可以用于阻止 readdir 进入低层分支,此时的名字应该是.wh..wh..opq
或者.wh.__dir_opaque
。假设我们有三个目录,它们的情况如下所示
接下去对这三个目录进行 mount,结果如下图所示
mount -t aufs -o dirs=./test=rw:./fruits=ro:./vegetables=ro none ./mnt
之后,我们在
test
目录中创建一个 whiteout 的隐藏文件.wh.apple
,当查看./mnt
目录时,你会发现 该目录下的 apple 这个文件已经消失了。这个效果等同于rm ./mnt/apple
。
除此之外,AUFS 还有以下这些特性:
- 被 mount 到同一个目录下的文件,如果在原来的地方被修改了,那么 union 之后的目录中的内容会改变吗?这个主要看用户对 udba 的参数设置:
- udba=none,那些不在 union 之后的目录里发生的修改,aufs 是不会同步的,所以此时会有数据出错的问题,但是 AUFS 运转很快。
- udba=reval,AUFS 会检查有没有被修改,如果有的话,那么把修改 mount 到目录内
- udba=notify,AUFS 会为所有的目录注册 inotify,这样可以让 AUFS 在更新文件修改的性能更高一些
- 如果有多个 rw 的目录被 union 进来了,那么当创建文件时,aufs 会将这个文件创建在哪个 rw 目录中呢?这个主要是看 create 参数的设置:
- create=rr (round-robin),新创建的文件轮流写到每个 rw 目录中,使用方式为:
mount -t aufs -o dirs=./1=rw:./2=rw:./3=rw -o create=rr none ./mnt
- create=mfs[:second](most−free−space[:second]),选一个可用空间最好的分支。
- create=mfsrr:low[:second],选一个空间大于 low 的目录,如果空间小于 low 了,那么 aufs 会使用 round-robin 方式。
- create=rr (round-robin),新创建的文件轮流写到每个 rw 目录中,使用方式为:
更加具体得请 man aufs
1.1.3. AUFS 性能
性能上,AUFS 在查找文件上是比较慢的,因为要遍历所有的 branch。但是,一旦 AUFS 找到要读写的文件之后,因为有了这个文件 inode 所以之后的读写和操作和原文件基本是一样了的。
1.2. OverlayFS
OverlayFS 是目前使用广泛的一种 Union File System。在 Linux 内核 3.18 版本中,OverlayFS 代码正式合入 Linux 内核的主分支。从此,OverlayFS 也就逐渐成为各个主流 Linux 发行版本里缺省使用的容器文件系统了。在容器里运行 df 的时候,看到的文件系统类型"overlay"指的就是 OverlayFS。
OverlayFS 的一个 mount 命令牵涉到四类目录,分别是 lower,upper,merged 和 work。
-
首先是,最下面的"lower/",在 OverlayFS 中,最底下这一层里的文件是不会被修改的,它是只读的。 另外,OverlayFS 是支持多个 lowerdir 的。
-
然后我们看"uppder/",在 OverlayFS 中,如果有文件的创建,修改,删除操作,那么都会在这一层反映出来,它是可读写的。
-
接着是最上面的"merged" ,它是挂载点(mount point)目录,也是用户看到的目录,用户的实际文件操作在这里进行。
-
其实还有一个"work/",这个目录没有在这个图里,它只是一个存放临时文件的目录,OverlayFS 中如果有文件修改,就会在中间过程中临时存放文件到这里。
lower 层和 upper 层,这两层目录中的文件都会映射到挂载点上,并且 upper 层的文件会覆盖 lower 层的文件,比如"in_both.txt"这个文件,在 lower 层和 upper 层都有,但是挂载点 merged/ 里看到的只是 upper 层里的 in_both.txt.
如果我们在 merged/ 目录里做文件操作,具体包括这三种:
- 第一种,新建文件,这个文件会出现在 upper/ 目录中。
- 第二种是删除文件,如果我们删除"in_upper.txt",那么这个文件会在 upper/ 目录中消失。如果删除"in_lower.txt", 在 lower/ 目录里的"in_lower.txt"文件不会有变化,只是在 upper/ 目录中增加了一个特殊文件来告诉 OverlayFS,"in_lower.txt'这个文件不能出现在 merged/ 里了,这就表示它已经被删除了。
- 还有一种操作是修改文件,类似如果修改"in_lower.txt",那么就会在 upper/ 目录中新建一个"in_lower.txt"文件,包含更新的内容,而在 lower/ 中的原来的实际文件"in_lower.txt"不会改变。
容器镜像文件可以分成多个层,每层正好作为 OverlayFS 的 lowerdir 的一个目录。然后加上一个空的 upperdir 一起挂载好后,就组成了容器的文件系统。
1.3. Docker 实现
Docker 的镜像就采用了 UnionFS 技术,从而实现了分层的镜像。早期的 Ubuntu 使用的是 AUFS,但是在较新的 Ubuntu 发行版中 Docker 采用的文件驱动是 overlay2。Overlay 和 AUFS 还有 DeviceMapper 都是 UnionFS,在细节上会有所不同,但是不影响理解。
所谓镜像其实就是文件系统,也就是一些目录和文件的组合。相关的镜像文件可以在 /var/lib/docker/overlay2
中看到。下面我们使用 docker inspect
这个命令来查看 ubuntu 这个镜像文件,输出了以下内容。
"GraphDriver": {
"Data": {
"LowerDir": "/var/lib/docker/overlay2/0da6195c221c2cc469d542dc0977a4e820af1acecdee826e33327d180952523f/diff:/var/lib/docker/overlay2/a318ce550c10b22bc71ca5a382c82b70a3a808c2ec48740f13307c183c460ee1/diff",
"MergedDir": "/var/lib/docker/overlay2/6bd4e397e20f9e9e78f84fce335494daf7f903f9f95b917891c4493f7568d6bb/merged",
"UpperDir": "/var/lib/docker/overlay2/6bd4e397e20f9e9e78f84fce335494daf7f903f9f95b917891c4493f7568d6bb/diff",
"WorkDir": "/var/lib/docker/overlay2/6bd4e397e20f9e9e78f84fce335494daf7f903f9f95b917891c4493f7568d6bb/work"
},
"Name": "overlay2"
},
"RootFS": {
"Type": "layers",
"Layers": [
"sha256:d42a4fdf4b2ae8662ff2ca1b695eae571c652a62973c1beb81a296a4f4263d92",
"sha256:90ac32a0d9ab11e7745283f3051e990054616d631812ac63e324c1a36d2677f5",
"sha256:782f5f011ddaf2a0bfd38cc2ccabd634095d6e35c8034302d788423f486bb177"
]
},
可以看到 ubunut 这个镜像由三层镜像层组成,这些镜像层都位于 /var/lib/docker/overlay2
目录中。该目录中的 l
文件夹存放着到各层 diff 目录的软链接。
那么这三层镜像层的内容如下图所示,每一层镜像层都是 Ubuntu 操作系统文件与目录的一部分。每层镜像层的文件具体存放在 diff
子目录下,这些文件被组织起来之后只能是只读的;link 文件描述了该层标识符的精简版;lower 文件描述了层序的组织关系。其中 a318ce.../diff
子目录的内容跟 Ubuntu 的文件系统(Linux 文件系统)的内容几乎一致吧。
接下来,我们来看一下这些文件到底是如何被组织起来的。我们先启动一个容器
$ docker run -it --rm ubuntu
可以看到 /var/lib/docker/overlay2
目录多出了两个目录 d00891...
和 d00891...-init
,这两个目录是启动容器之后生成的。
其中 d00891...-init
的内容如下所示,diff/etc
子目录包含了 hostname、hosts、resolv.conf 等文件。
d00891...
目录的内容如下所示,发现这层镜像 merged
子目录的内容跟 ubuntu 文件系统的组织几乎一致。
下面,我们进入容器内部,查看一下容器的 mount 情况。可以看到 overlay2 将 lowerdir、upperdir、workdir 联合挂载。其中 lowerdir 是容器启动之后的只读层;upperdir 是容器的可读可写层;workdir 是 lowerdir 执行 copy_up 操作的中转层,copy_up 操作是指当要修改的文件不存在于 upperdir 而仅存在于 lower 时,要先将数据从 lower 拷贝到 upper 的这个操作。
AUFS 中会指明每个目录的权限,那么在 Overlay2 会找不到相关的目录权限(我是没找到),那么这个应该是 Overlay2 的规定,也就是说在采用 Overlay2 之后,如果是 lowerdir 中的目录,那么就是只读,如果是 upperdir 中的目录那么就是可读可写。
我们将这个挂载情况和上面的链接情况对照起来,可以发现 lowerdir 包含了 d00891...-init/diff
、6bd4e3.../diif
、0da619.../diff
和 a318ce.../diff
这四个目录;upperdir 则包含 d008919.../diff
目录;workdir 则包含 d008919.../work
目录。
那么,再结合前面看到的 d00891.../merged
目录,那么相当于 Docker 把 lowerdir、upperdir、workdir 涉及到的目录都以联合文件的方式挂载到了 d00891.../merged
目录。
$ mount |grep 'overlay'
overlay on / type overlay (rw,relatime,lowerdir=/var/lib/docker/overlay2/l/KQNKITRZAH2XGWS6CFE4ZPX7IN:/var/lib/docker/overlay2/l/2PH5HBCMRLYXSGO5YTKS7LK5I2:/var/lib/docker/overlay2/l/5QZ3LNRWDEOYCV7VO4QGJZ55NW:/var/lib/docker/overlay2/l/5O6GSST3GYTXRPKJZCZ4AZ4GF6,upperdir=/var/lib/docker/overlay2/d008919d5201db7980cba9b1ba8dd2908be58b396ca09d3b2cc98272a541154e/diff,workdir=/var/lib/docker/overlay2/d008919d5201db7980cba9b1ba8dd2908be58b396ca09d3b2cc98272a541154e/work)
接下去我们做个实验来看一下,先在容器内部创建一个文件 test.txt
。
root@b9585329155a:/# touch test.txt
之后我们分别查看 d00891.../merged
、d00891.../diff
、a318ce.../diff
这三个目录的内容,发现在根目录创建的 test.txt
只在前两个存在,也就是只在用于挂载的目录和可读可写层存在。
1.3.1. 总结
Docker 相当于把 /var/lib/docker/overlay2
中相应的只读镜像层文件的 diff 目录、容器启动之后新建的只读 init 镜像层文件的 diff 目录(hostname、hosts、resolv.conf )和容器启动之后新建的可读可写镜像层文件的 diff 目录以 UnionFS 方式的挂载到新建的可读可写镜像层文件的 merged 目录。因此,容器启动之后的文件系统如下图所示:
- 只读层是 Ubuntu 这个镜像的组成内容,这些只读层都以增量的方式包含了 Ubuntu 操作系统的一部分。
- Init 层,位于只读层和可读可写层之间。这是 Docker 项目单独生成的一个内部层,专门用来存放 /etc/hosts、/etc/hostname、/etc/resolv.conf 等信息。那么为什么需要这一层呢?因为这些文件本身是属于只读的 Ubuntu 镜像的一部分,但是用户往往需要在容器时写入一些指定的值,比如 hostname,那么假如没有这一层的话,那么修改就会在可读可写层了。但是,这些修改往往只对当前的容器有效,我们并不希望执行 docker commit 之后,这些信息也跟可读写层一起提交掉。所以 Docker 的做法是,在启动容器的时候,先修改这些值,然后以一个单独的层挂载出来(通过上面我们也可以看到这是单独的一层),并且设置为可读(这样容器使用的信息就是修改之后的了)。之后再创建一个层作为可读可写层,那么 docker commit 只会提交可读写层,但又不包括这些内容。
- 可读写层是 rootfs 最上面的一层,专门用来存放修改只读层文件后产生的增量,增删改都发生在这里。之后我们可以还可以使用 docker commit 命令将这个可读写层的内容保存下来,改为只读层,然后更新镜像的信息。
而这三层最终被联合挂载到同一个目录下,之后再结合 mount namespace,那么就能为容器中的进程构建出一个完善的文件系统隔离环境(当然还得感谢 chroot 这个系统调用切换根目录的能力)。
我相信也能更好地理解网上对容器文件系统的阐述了(附上网上的一张图):最上层是可读可写的,而下层是镜像。
巨人的肩膀
参考资料
- 极客时间.《深入剖析 Kubernetes》.张磊老师."白话容器基础(三):深入理解容器镜像"
- 极客时间.《容器实战》
- DOCKER基础技术:AUFS
- Docker笔记(一)- 镜像与容器,Overlay2
- 把玩overlay文件系统
推荐链接
2. 容器文件系统相关问题
2.1. 容器在 ubuntu18.04(Linux 内核 4.15)中读写要比在 ubuntu 20.04(Linux 内核 5.4)中读写要快?
下面使用 perf 工具可以查看在容器中运行 fio 的时候,ubuntu 18.04 和 ubuntu 20.04 在内核函数调用上的不同(自下而上是函数的调用顺序)。
从上图可以看到,从系统调用框架之后的函数 aio_read() 开始:Linux 内核 4.15 里 aio_read() 之后调用的是 xfs_file_read_iter(),而在 Linux 内核 5.4 里,aio_read() 之后调用的是 ovl_read_iter() 这个函数,之后再调用 xfs_file_read_iter()。新加的 ovl_read_iter() 这个函数是因为 Linux 为了完善 OverlayFS,增加了 OverlayFS 自己的 read/write 接口,从而不再直接调用 OverlayFS 后端文件系统(比如 XFS、Ext4)的读写接口。但是在 5.4 中它只实现了同步 IO,并没有实现异步 IO。虽然 fio 做文件系统性能测试的时候使用的是异步 I/O,但是由于这个限制,fio 在内核 5.4 上就无法对 OverlayFS 测出最高的性能指标了。
但是,在 Linux 内核 5.6 版本中,这个问题已经被相应的补丁给解决了。
commit 2406a307ac7ddfd7effeeaff6947149ec6a95b4e
Author: Jiufei Xue <jiufei.xue@linux.alibaba.com>
Date: Wed Nov 20 17:45:26 2019 +0800
ovl: implement async IO routines
A performance regression was observed since linux v4.19 with aio test using
fio with iodepth 128 on overlayfs. The queue depth of the device was
always 1 which is unexpected.
After investigation, it was found that commit 16914e6fc7e1 ("ovl: add
ovl_read_iter()") and commit 2a92e07edc5e ("ovl: add ovl_write_iter()")
resulted in vfs_iter_{read,write} being called on underlying filesystem,
which always results in syncronous IO.
Implement async IO for stacked reading and writing. This resolves the
performance regresion.
This is implemented by allocating a new kiocb for submitting the AIO
request on the underlying filesystem. When the request is completed, the
new kiocb is freed and the completion callback is called on the original
iocb.
Signed-off-by: Jiufei Xue <jiufei.xue@linux.alibaba.com>
Signed-off-by: Miklos Szeredi <mszeredi@redhat.com>
假如我将一个卷(宿主机上的某个目录)挂在到容器文件系统中的某个目录,我在容器中对这个卷中的数据做读写操作。这个时候,由于它是以 volume的方式挂载到容器中,那么它就不是以overlayfs的文件系统。因为 Volume 的本质其实就相当于将容器中的一个目录直接指向了这个宿主机目录,那么对容器中这个目录进行读写的时候,其实是对宿主机上对应的目录进行读写。那么这个时候就要看宿主机上使用的文件系统是啥了。
巨人的肩膀
- 极客时间.《容器实战》