一、前言
1.1 什么是 UnionFS
联合文件系统(UnionFS)是一种分层、轻量级并且高性能的文件系统,它支持对文件系统的修改作为一次提交来一层层的叠加,同时可以将不同目录挂载到同一个虚拟文件系统下(unite several directories into a single virtual filesystem)。
联合文件系统是 Docker 镜像的基础。镜像可以通过分层来进行继承,基于基础镜像(没有父镜像),可以制作各种具体的应用镜像。
另外,不同 Docker 容器就可以共享一些基础的文件系统层,同时再加上自己独有的改动层,大大提高了存储的效率。
Docker 中使用的 AUFS(AnotherUnionFS)就是一种联合文件系统。 AUFS
支持为每一个成员目录(类似 Git 的分支)设定只读(readonly)、读写(readwrite)和写出(whiteout-able)权限, 同时 AUFS
里有一个类似分层的概念, 对只读权限的分支可以逻辑上进行增量地修改(不影响只读部分的)。
Docker 目前支持的联合文件系统包括:
OverlayFS
、AUFS
、Btrfs
、VFS
、ZFS
、Device Mapper
。
各 Linux 发行版 Docker 推荐使用的存储驱动如下表。
Linux 发行版Docker 推荐使用的存储驱动:
Docker CE on Ubuntuaufs,
devicemapper,
overlay2 (Ubuntu 14.04.4 +, 16.04 +),
overlay,
zfs,
vfsDocker CE on Debianaufs,
devicemapper,
overlay2 (Debian Stretch), overlay,
vfsDocker CE on CentOSdevicemapper,
vfsDocker CE on Fedoradevicemapper,
overlay2 (Fedora 26 +),
overlay (实验性支持)
vfs
在可能的情况下,推荐使用 overlay2
存储驱动,overlay2
是目前 Docker 默认的存储驱动,以前则是 AUFS
。你可以通过配置来使用以上提到的其他类型的存储驱动。
1.2 Docker镜像中对UnionFS的使用背景
任何程序运行时都会有依赖,无论是开发语言层的依赖库,还是各种系统lib、操作系统等,不同的系统上这些库可能是不一样的,或者有缺失的。为了让容器运行时一致,docker将依赖的操作系统、各种lib依赖整合打包在一起(即镜像),然后容器启动时,作为它的根目录(根文件系统rootfs),使得容器进程的各种依赖调用都在这个根目录里,这样就做到了环境的一致性。
不过,这时你可能已经发现了另一个问题:难道每开发一个应用,都要重复制作一次rootfs吗(那每次pull/push一个系统岂不疯掉)?
比如,我现在用Debian操作系统的ISO做了一个rootfs,然后又在里面安装了Golang环境,用来部署我的应用A。那么,我的另一个同事在发布他的Golang应用B时,希望能够直接使用我安装过Golang环境的rootfs,而不是重复这个流程,那么本文的主角UnionFS就派上用场了。
Docker镜像的设计中,引入了层(layer)的概念,也就是说,用户制作镜像的每一步操作,都会生成一个层,也就是一个增量rootfs(一个目录),这样应用A和应用B所在的容器共同引用相同的Debian操作系统层或是Unbuntu系统层等、Golang环境层(作为只读层),而各自有各自应用程序层,和可写层。启动容器的时候通过UnionFS把相关的层挂载到一个目录,作为容器的根文件系统。
需要注意的是,rootfs只是一个操作系统所包含的文件、配置和目录,并不包括操作系统内核。这就意味着,如果你的应用程序需要配置内核参数、加载额外的内核模块,以及跟内核进行直接的交互,你就需要注意了:这些操作和依赖的对象,都是宿主机操作系统的内核,它对于该机器上的所有容器来说是一个“全局变量”,牵一发而动全身。
分层的收益总结:
- 分层最大的一个好处就是共享资源
- 有多个镜像都从相同的base镜像构建而来,那么宿主机只需在磁盘上保存一份base镜像;
- 同时内存中也只需加载一份base镜像,就可以为所有容器服务了,而且镜像的每一层都可以被共享。
1.3 存储驱动
Docker 使用了一系列不同的存储驱动管理镜像内的文件系统并运行容器,这些存储驱动与 Docker 卷(volume)有些不同,存储引擎管理着能够在多个容器之间共享的存储。
想要理解 Docker 使用的存储驱动,我们首先需要理解 Docker 是如何构建并且存储镜像的,也需要明白 Docker 的镜像是如何被每一个容器所使用的;Docker 中的每一个镜像都是由一系列只读的层组成的,Dockerfile 中的每一个命令都会在已有的只读层上创建一个新的层:
FROM ubuntu:15.04
COPY . /app
RUN make /app
CMD python /app/app.py
容器中的每一层都只对当前容器进行了非常小的修改,上述的 Dockerfile 文件会构建一个拥有四层 layer 的镜像:
当镜像被 docker run 命令创建时就会在镜像的最上层添加g zhi一个可写的层,也就是容器层,所有对于运行时容器的修改其实都是对这个容器读写层的修改。
容器和镜像的区别就在于,所有的镜像都是只读的,而每一个容器其实等于镜像加上一个可读写的层,也就是同一个镜像可以对应多个容器。
1.4 AUFS简述
AUFS又叫Another UnionFS,后来叫Alternative UnionFS。AUFS是Docker选用的第一种存储驱动。AUFS具有快速启动容器,高效利用存储和内存的优点,直到现在AUFS仍然是Docker支持的一种存储驱动类型。AUFS可以看成是 UnionFS 的升级版,它完全重写了早期的UnionFS 1.x,其主要目的是为了可靠性和性能,并且引入了一些新的功能,比如可写分支的负载均衡。AUFS在使用上全兼容UnionFS,而且比之前的UnionFS在稳定性和性能上都要好很多,后来的UnionFS 2.x开始抄AUFS中的功能。虽然AUFS没有进到Linux主干里,但是在很多Linux发行版都用了AUFS,比如:Ubuntu 10.04,Debian6.0, Gentoo Live CD支持AUFS。
AUFS 作为联合文件系统,它能够将不同文件夹中的层联合(Union)到了同一个文件夹中,这些文件夹在 AUFS 中称作分支,整个『联合』的过程被称为联合挂载(Union Mount):
每一个镜像层或者容器层都是 /var/lib/docker/ 目录下的一个子文件夹;在 Docker 中,所有镜像层和容器层的内容都存储在 /var/lib/docker/aufs/diff/ 目录中:
$ ls /var/lib/docker/aufs/diff/
00adcccc1a55a36a610a6ebb3e07cc35577f2f5a3b671be3dbc0e74db9ca691c
93604f232a831b22aeb372d5b11af8c8779feb96590a6dc36a80140e38e764d8
00adcccc1a55a36a610a6ebb3e07cc35577f2f5a3b671be3dbc0e74db9ca691c-init
93604f232a831b22aeb372d5b11af8c8779feb96590a6dc36a80140e38e764d8-init
019a8283e2ff6fca8d0a07884c78b41662979f848190f0658813bb6a9a464a90
93b06191602b7934fafc984fbacae02911b579769d0debd89cf2a032e7f35cfa
而 /var/lib/docker/aufs/layers/ 中存储着镜像层的元数据,每一个文件都保存着镜像层的元数据,最后的 /var/lib/docker/aufs/mnt/ 包含镜像或者容器层的挂载点,最终会被 Docker 通过联合的方式进行组装。
上面的这张图片非常好的展示了组装的过程,每一个镜像层都是建立在另一个镜像层之上的,同时所有的镜像层都是只读的,只有每个容器最顶层的容器层才可以被用户直接读写,所有的容器都建立在一些底层服务(Kernel)上,包括命名空间、控制组、rootfs 等等,这种容器的组装方式提供了非常大的灵活性,只读的镜像层通过共享也能够减少磁盘的占用。
1.5 其他存储驱动
AUFS 只是 Docker 使用的存储驱动的一种,除了 AUFS 之外,Docker 还支持了不同的存储驱动,包括 aufs、devicemapper、overlay2、zfs 和 vfs 等等,在最新的 Docker 中,overlay2 取代了 aufs 成为了推荐的存储驱动,但是在没有 overlay2 驱动的机器上仍然会使用 aufs 作为 Docker 的默认驱动。
不同的存储驱动在存储镜像和容器文件时也有着完全不同的实现,有兴趣的读者可以在 Docker 的官方文档 Select a storage driver 中找到相应的内容。
想要查看当前系统的 Docker 上使用了哪种存储驱动只需要使用以下的命令就能得到相对应的信息:
$ docker info | grep Storage
Storage Driver: aufs
1.5.1 以 OverlayFS 为例
OverlayFS 也是一种与 AUFS 类似的联合文件系统,同样属于文件级的存储驱动,包含了最初的 Overlay 和更新更稳定的 overlay2。Overlay 只有两层:upper 层和 Lower 层。Lower 层代表镜像层,upper 层代表容器可写层。
相当于可以将多个目录mount成为一个合并的目录,在合并的时候有多个源,需要指定哪个源是下层,哪个源是上层。合并之后就会做merge。
- 如果一个文件在下层,那么合并之后是可以看到的。
- 如果一个文件在上层,合并之后还是可以看的到的。
- 如果文件即来自上层和下层,上层文件会将下层文件覆盖掉。
二. Docker 如何使用 UnionFS?
容器镜像由多个镜像层组成,所有镜像层会联合在一起组成一个统一的文件系统。如果不同层中有一个相同路径的文件,比如 /text,上层的 /text 会覆盖下层的 /text,也就是说用户只能访问到上层中的文件 /text。
将中间只读的 rootfs 的集合称为 Docker 镜像,Docker 镜像构建时,会一层层构建,前一层是后一层的基础。每一层构建完就不会再发生改变,后一层上的任何改变只发生在自己这一层。UnionFS 使得镜像的复用、定制变得更为容易。甚至可以用之前构建好的镜像作为基础层,然后进一步添加新的层,以定制自己所需的内容,构建新的镜像。
当用docker run启动这个容器时,实际上在镜像的顶部添加了一个新的可写层。这个可写层也叫容器层。
Docker 镜像都是只读的,当容器启动时,一个新的可写层加载到镜像的顶部!这一层就是我们通常说的容器层,容器之下的都叫镜像层!容器启动后,其内的应用所有对容器的改动,文件的增删改操作都只会发生在容器层中,对容器层下面的所有只读镜像层没有影响。依然引用这张经典图来做形象示意:
使用 UnionFS 搭建的分层结构
图中的容器是运行在 debian 容器环境中的 apache 网页应用,这个环境还提供了 emacs 编辑器功能。
将之前我们自己构建的 rootfs 与上面这张图片对比,会发现我们将所有系统文件、运行库文件和上层应用,都放到了一个 rootfs 里面,这样做缺乏灵活性,增大了维护的复杂度。而 Docker 引入了层(layer)的概念,将 rootfs 的内容进行了分层管理,有系统层,运行库依赖层等等,可以一层接一层进行增量式挂载叠加。启动容器的时候通过 UnionFS 把相关的层挂载到一个目录,作为容器的根 rootfs。
借助于 UnionFS,容器内部的更改都被保存到了最上面的读写层,而其他层都是只读的,这样中间的只读 rootfs 是可以被多个容器复用的。UnionFS 将文件的更新挂载到老的文件之上,而不去修改那些不更新的内容,这就意味着即使虚拟的文件系统被反复修改,也能保证宿主机空间占用保持一个较低水平。
三. 衍生出的 Docker 概念
3.1 Docker 镜像
docker的镜像实际上由一层一层的文件系统组成,这种层级的文件系统UnionFS。
bootfs(boot file system)主要包含 bootloader 和 Kernel , bootloader 主要是引导加 kernel, Linux刚启动时会加载 bootfs 文件系统,在 Docker 镜像的最底层是 bootfs 。这一层与我们典型的 Linux/Unix系统是一样的,包含 boot 加载器和内核。当 boot 加载完成之后整个内核就都在内存中了,此时内存的使用权已由 bootfs 转交给内核,此时系统也会卸载 bootfs 。
rootfs(root file system),在 bootfs之上。包含的就是典型 Linux系统中 的 /dev,/proc,/bin,/etc 等标准目录和文件。 rootfs就是各种不同的操作系统发行版,比如 Ubuntu, Centos 等等。平时我们安装进虚拟机的CentOS都是好几个G,为什么Docker这里才200M?
对于精简的 OS,rootfs 可以很小,只需要包合最基本的命令,工具和程序库就可以了,因为底层直接用宿主机的kernel,自己只需要提供 rootfs 就可以了。由此可见对于不同的Linux发行版, bootfs 基本是一致的,rootfs会有差別,因此不同的发行版可以公用 bootfs。
docker本身是没有完整的操作系统的,它需要借助主机,无论是物理机还是虚拟机,docker启动的容器没有独立的操作系统,不需要自己的Bootloader ,所以没有bootfs的,因为主机已经启动起来了,但是它需要rootfs。
以在Linux操作系统主机的启动docker容器为例:
1. 在Linux操作系统启动后,首先将 rootfs 设置为 readonly, 进行一系列检查, 然后将其切换为 “readwrite”供用户使用。
2.Docker启动(以unionfs方式加载文件系统),初始化时也是将 rootfs 以 readonly 方式加载并检查;
3. 接下来,利用 union mount方式将一个readwrite文件系统挂载在readonly 的 rootfs 之上,并且允许再次将下层的 FS(file system) 设定为 readonly 并且向上叠加,这样一组readonly和一个writeable的层级结构就构成了一个 container 的运行时态, 每一个 FS 被称作一个 FS 层。但是在 Docker里,root文件系统永远只能是只读状态。
4. 这样一层一层堆叠,下面的层永远都是只读的,当所有层级加载完毕之后,它会将最上面的一层变为readwrite。所以针对这个容器的修改,事实上都是在最上面这一层进行的,并不会修改下面的readonly层。Union FS是层层叠加的,可以看到在做镜像构建的时候,差不多每条指令都会作为一个文件层保存下来。
可以查看到每一层里面执行了什么样的命令。
5. 在docker run具体容器的时候,就会去回放这个镜像,按照层级一级一级的去加载,通过unionfs方式去加载,这会有不同的驱动,会将dockerfile里面的每一层加载,每一层是readonly的层,然后不断的叠加,将下面一层变为readonly,最终将上面变为writeable,这个时候完整的操作系统所需要的文件系统就存在了,rootfs也就存在了,容器就可以去读取这些文件了。
目前有多种文件系统可以被当作联合文件系统,CentOS8 使用的是 overlay2,下面,我们尝试着挂载一个 overlay2 文件系统。
3.2 Docker 容器
Docker 容器与我们之前的容器在本质上没有区别,我们之前的容器更偏向抽象的技术概念,而受到在 Docker 管理约束的容器就是 Docker 容器,它会带有 Docker 产品的一些特征和功能。
Docker镜像 和 Docker容器 的关系,就像是面向对象程序设计中的 类 和 实例 一样,镜像是静态的定义,容器是镜像运行时的实体。
从文件系统来看,Docker容器比Docker镜像多一层可读写的文件系统挂载层,从生命周期来看,Docker容器可以被创建、启动、停止、删除、暂停等。
五. 小结
在 rootfs 的基础上,Docker 公司创新性地提出了使用 UnionFS,多个增量 rootfs 联合挂载一个完整 rootfs 的方案,通过“分层镜像”的设计,围绕 Docker 镜像,大家甚至可以协同工作,再加上 Docker 官方提供的镜像仓库,进一步减少了共享镜像的成本,这大大提高了开发部署的效率。