rootfs
上节最后讲到的 rootfs 只是一个OS所包含的文件、配置和目录,并不包括内核。在 Linux 中这两部分是分开存放的,OS只有在开机启动时才会加载指定版本的内核镜像。
所有容器都共享宿主机OS的内核。如果程序需要配置内核参数、加载额外的内核模块,以及跟内核直接交互,这些操作和依赖的对象,都是宿主机的内核,它对于所有容器来说是一个“全局变量”。
这也是容器相比虚拟机的主要缺陷之一:后者不仅有模拟出来的硬件机器充当沙盒,沙盒里还运行着一个完整的 OS 给应用折腾。
正是由于 rootfs,容器才有了一个被反复宣传至今的重要特性:一致性。
由于云端与本地服务器环境不同,应用的打包过程,一直是使用 PaaS 时最“痛苦”的步骤。
但有了容器镜像后,问题迎刃而解。
rootfs 打包的不只是应用,而是整个OS的文件和目录,应用以及它运行所需要的所有依赖,都被封装在一起。
对应用来说,OS本身才是它运行所需要的最完整的“依赖库”。
有了“打包OS”的能力,这个最基础的依赖环境也变成了应用沙盒的一部分。这就赋予了容器所谓的一致性:无论在本地、云端,还是在任何的机器上,用户只需要解压打包好的镜像,这个应用运行所需要的完整的执行环境就被重现。
这种深入到OS级别的运行环境一致性,打通了应用在本地开发和远端执行环境之间难以逾越的鸿沟。
unionfs
假设宿主机上运行 100 个容器。每个容器都需要一个镜像。如果只是普通的文件系统,每启动一个容器,就需要把一个镜像下载并存储在宿主机上。
假设一个镜像 500MB,100 个容器就需要下载 50GB 的文件,而这 50GB 里的内容,库文件都是差不多的。在容器运行时,这类文件也不会被改动,基本上都是只读的。
假如这 100 个容器镜像都是基于"ubuntu:18.04"的,每个镜像只是额外复制了 50MB 左右自己的应用程序到"ubuntu: 18.04"里,那么有 90% 的数据是冗余的。
若在宿主机上只存储存一份"ubuntu:18.04",所有基于"ubuntu:18.04"镜像的容器都可以共享。不同容器启动时,只要下载自己独特的部分就可以。
为了有效地减少冗余的镜像数据,出现针对容器的文件系统,称为 UnionFS。
UnionFS 实现的主要功能是把多个目录(处于不同的分区)挂载在一个目录下。这种多目录挂载的方式可以解决容器镜像的问题。
把 ubuntu18.04 基础镜像的文件放在一个目录 ubuntu18.04/ 下,容器额外的程序文件 app_1_bin 放在 app_1/ 目录下。
把两个目录挂载到 container_1/ 目录下,作为容器 1 看到的文件系统; 对于容器 2,就可以把 ubuntu18.04/ 和 app_2/ 一起挂载到 container_2 下。
这样就只要保留一份 ubuntu18.04。
OverlayFS
UnionFS 类似的有很多种实现,包括在 Docker 里最早使用的 AUFS,还有目前使用的 OverlayFS。
# !/bin/bash
umount ./merged
rm upper lower merged work -r
mkdir upper lower merged work
echo "I'm from lower" > lower/in_lower.txt
echo "I'm from upper" > upper/in_upper.txt
echo "I'm from lower" > lower/in_both.txt
echo "I'm from upper" > upper/in_both.txt
sudo mount -t overlay overlay -o lowerdir=./lower,upperdir=./upper,workdir=./work ./merged
OverlayFS 的 mount 命令牵涉到四类目录:lower,upper, merged 和 work
最下面的"lower/",也就是被 mount 两层目录中底下的这层(lowerdir)。
在 OverlayFS 中,这层的文件是不会被修改的,(只读)。支持多个 lowerdir。
"uppder/"在 OverlayFS 中,如果有文件的创建,修改,删除操作,都会在这一层反映出来,是可读写的。
"merged" 是挂载点(mount point)目录,也是用户看到的目录,用户的实际文件操作在这里进行。
"work/"目录没有在图里,它是一个存放临时文件的目录, OverlayFS 中如果有文件修改,就会在中间过程中临时存放文件到这里。
从这个例子可以看到,OverlayFS 会 mount 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/)创建一个whiteout文件,名为
.wh.<filename>
的特殊文件。该文件在文件系统上不会占用实际的磁盘空间,当联合挂载后,源文件会被whitout文件遮盖,即标记源文件或目录已经被删除。但源文件仍然存在于lower/中。 - 修改文件,如果修改"in_lower.txt",就会在 upper/ 目录中新建一个"in_lower.txt"文件,包含更新的内容,而在 lower/ 中的原来的实际文件"in_lower.txt"不会改变。
docker 的 unionfs
容器镜像可以分成多个层(layer),用户制作镜像的每一步操作,都会生成一个层,也就是一个增量 rootfs。每层可以对应 OverlayFS 里 lowerdir 的一个目录,lowerdir 支持多个目录,也就可以支持多层的镜像文件。
在容器启动后,对镜像文件中修改就会被保存在 upperdir 里了。
现在,我们启动一个容器(这里是我自己的容器,有多层),比如:
docker run -d resource_monitor:0.3 sleep 3600
这个所谓的“镜像”,实际上就是一个 Ubuntu 的 rootfs,它的内容是 Ubuntu 的所有文件和目录。不过,与之前我们讲述的 rootfs 稍微不同的是,Docker 镜像使用的 rootfs,往往由多个“层”组成:
镜像由8个层组成。这8个层就是8个增量 rootfs,每一层都是 OS 文件与目录的一部分;在使用镜像时,Docker 会把这些增量联合挂载在一个统一的挂载点上。
我们可以通过以下命令查看到挂载点
docker image inspect resource_monitor:0.3
...
"GraphDriver": {
"Data": {
"LowerDir": "/var/lib/docker/overlay2/8zluiov26yhy96sz3aviu93qu/diff:/var/lib/docker/overlay2/feqypcrm1mjsg9zqziftgp7t3/diff:/var/lib/docker/overlay2/a5m5rxcli1kauedyx7yqzly17/diff:/var/lib/docker/overlay2/8uoxp60prqu0wfso93wjqjds5/diff:/var/lib/docker/overlay2/9ixnorz9yrl3gxwpku89ktw2m/diff:/var/lib/docker/overlay2/xx4fdjpjqbbebgamdzfge11i3/diff:/var/lib/docker/overlay2/d95e9dd8b4ab3103a62246eafe0ce668362472b234d61d363135ec5018ff823e/diff",
"MergedDir": "/var/lib/docker/overlay2/jsvw4qtm14w4s2l0dsgjzxpap/merged",
"UpperDir": "/var/lib/docker/overlay2/jsvw4qtm14w4s2l0dsgjzxpap/diff",
"WorkDir": "/var/lib/docker/overlay2/jsvw4qtm14w4s2l0dsgjzxpap/work"
},
MergedDir
:挂载点,也就是容器的根文件系统。当容器启动时,Docker
将所有层堆叠并映射到此目录中,从而创建一个容器的完整文件系统。能在挂载点中看到整个 Ubuntu OS:
LowerDir
:所有的只读层存放位置,而只读层比上面 docker image inspect 展示的rootfs的少一层的原因是lowerDir不包含容器的根文件系统。根文件系统层是容器中所有只读层的叠加。
UpperDir
:可读写层,包含容器的所有更改。当容器运行时,所有写入文件系统的操作都会被记录在UpperDir
中,而原始镜像则保持不变。
WorkDir
:工作目录。一种临时目录,Docker
在联合文件系统中使用它来处理文件系统的更改。在容器启动时,WorkDir
目录为空,并随着文件系统更改的应用而逐渐填充。