一、说明
Docker CLI 操作起来比较简单——您只需掌握Create、Run、InspPull和Push容器和图像,但是谁想过Docker 背后的内部机制是如何工作的?在这个简单的表象背后隐藏着许多很酷的技术, UnionFS(统一文件系统)就是其中之一。
二、UnionFS是所有容器和镜像层背后的底层文件系统
2.1 联合文件系统是个理念
联合挂载或联合文件系统是一种实现的概念,并不是一种具体的文件系统类型。
联合挂载是一种文件系统,它是在不修改其原始(物理)源的情况下创建将多个目录的内容合并为一个逻辑目录。更有创意的用法是:能将相关的文件集存储在不同的磁盘或媒体中,但我们在单个视图中显示它们。总之,将多个、种类不同的数据源整合成一个逻辑数据源是有意义的。
一个例子是来自远程 NFS 服务器的一堆用户的 /home 目录全部合并到一个目录中,另一个例子是:将拆分的 ISO 映像合并到一个完整的目录中。
而对于Docker这种分层结构,需要将每一层的文件展示到用户观察的一个平面上,换句话就是要用户感觉不出分层的存在。
2.2 当今UnionFS观念下的产品
在联合文件系统的概念之下,有许多具体产品。其中有的更快一些,有的更简单一些,总之,对于不同的目标或不同的成熟度都有对应的设计理念。因此,在我们开始深入研究细节之前,让我们快速浏览一下目前这些产品发展现状:
-
UnionFS
最原始的可堆叠的统一文件系统,它可以合并多个目录(分支)的内容,同时保持它们的物理内容独立。 Unionfs 用于统一源码树管理、合并拆分光盘内容、合并单独的软件包目录、数据网格等。 Unionfs 允许任意混合只读和读写分支,以及在扇出的任何位置插入和删除分支。为了维护 unix 语义,Unionfs 处理消除重复项、部分错误条件等。 Unionfs 是更大的 FiST 项目的一部分,该项目包括 Wrapfs 包装器可堆叠文件系统。(2014年停止更新)
- aufs
原始 UnionFS 的改进版,添加了许多新功能,但被拒绝合并到主线 Linux 内核中。 Aufs 是 Ubuntu/Debian 上 Docker 的默认驱动程序,但已被 OverlayFS 取代(对于 Linux 内核 >4.0)。与 Docker 文档页面中描述的其他联合文件系统相比,它具有一些优势。
-
OverlayFS
也是UnionFS的改进版,OverlayFS 自 3.18(2014 年 10 月 26 日)起包含在 Linux 内核中。这是 overlay2 Docker 驱动程序默认使用的文件系统(您可以使用 docker system info | grep Storage 进行验证)。它通常比 aufs 具有更好的性能,并且具有一些不错的功能,例如页面缓存共享。
- ZFS - ZFS
是由 Sun Microsystems(现为 Oracle)创建的联合文件系统。它具有一些有趣的功能,例如分层校验和、快照的本机处理和备份/复制或本机数据压缩和重复数据删除。但是,由 Oracle 维护,它具有非 OSS 友好许可证 (CDDL),因此不能作为 Linux 内核的一部分发布。但是,您可以使用 ZFS on Linux (ZoL) 项目,该项目在 Docker 文档中被描述为健康且成熟的......,但尚未准备好用于生产。
- Btrfs
另一款产品是 Btrfs,它是多家公司(包括 SUSE、WD 或 Facebook)的联合项目,在 GPL 许可下发布,是 Linux 内核的一部分。 Btrfs 是 Fedora 33 的默认文件系统。它还有一些有用的功能,例如块级操作、碎片整理、可写快照等等。如果您真的想经历为 Docker 切换到非默认存储驱动程序的麻烦,那么 Btrfs 及其功能和性能可能是您的不二之选。
总之:在翻阅Docker资料中,统统将overlay2和UnionFS看成是一个概念,这一点是不会有错的。
三、采用UnionFS的理由
我们用来启动容器的许多镜像都非常庞大,无论是大小为 72MB 的 ubuntu 还是大小为 133MB 的 nginx。每次我们想从这些图像创建一个容器时,分配那么多空间是非常昂贵的。多亏了联合文件系统,Docker 只需要在镜像之上创建薄层,其余部分可以在所有容器之间共享。这还提供了减少启动时间的额外好处,因为无需复制图像文件和数据。
联合文件系统还提供隔离,因为容器对共享图像层具有只读访问权限。如果他们需要修改任何只读共享文件,他们会使用写时复制策略(稍后讨论)将内容复制到可以安全修改的顶层可写层。
四、UnionFS是如何工作的
现在需要问一个重要的问题:UnionFS——它实际上是如何工作的?从上面描述的所有事情来看,整个联合文件系统在云里雾里,但也并非不能模拟。
下面我们将用shell语句进行模拟这种UnionFS,假定有以下路径和文件:
.
├── upper
│ ├── code.py # Content: `print("Hello Overlay!")`
│ └── script.py
└── lower
├── code.py # Content: `print("This is some code...")`
└── config.yaml
- 在联合挂载术语中,这些目录称为分支。
- 这些分支中的每一个都被分配了优先级。
此优先级用于确定在多个源分支中存在同名文件的情况下哪个文件将显示在合并视图中。查看上面的文件和目录 - 很明显,如果我们尝试覆盖它们,就会产生这种冲突(code.py 文件)。
那么,让我们试着下列shell代码后出现什么:
~ $ mount -t overlay \
-o lowerdir=./lower,\
upperdir=./upper,\
workdir=./workdir \
overlay /mnt/merged
~ $ ls /mnt/merged
code.py config.yaml script.py
~ $ cat /mnt/merged/code.py
print("Hello Overlay!")
在上面的示例中,我们使用带有覆盖类型的 mount 命令将低级目录(只读;低优先级)和上级目录(读写;高优先级)合并到 /mnt/merged 中的合并视图中。我们还包含了 workdir=./workdir 选项,workdir在 lowerdir 和 upperdir 以原子操作移动到 /mnt/merged 之前用作准备 lowerdir 和 upperdir 的合并视图的地方。
1)读取数据
查看上面 cat 命令的输出,我们可以看到上层目录中的文件内容确实在合并视图中优先。
所以,现在我们知道如何合并 2 个目录以及如果存在冲突会发生什么,但是如果我们尝试从合并视图中修改某些文件会发生什么?这就是写时复制 (CoW) 发挥作用的地方。那么,它到底是什么? CoW 是一种优化技术,如果两个调用者请求相同的资源,您可以向他们提供指向相同资源的指针而无需复制它。仅当其中一个调用者尝试写入其“副本”时才需要复制 - 因此术语复制(首次尝试)写入。
2)写入数据
在联合挂载的情况下,这意味着当我们尝试修改共享文件(或只读文件)时,它首先被复制到顶部可写分支(upperdir),它比只读的较低分支(lowerdir)具有更高的优先级。然后 - 当它在可写分支中时 - 它可以被安全地修改并且它的新内容将在合并视图中可见,因为顶层具有更高的优先级。
3)删除数据
我们可能要执行的最后一个操作是删除文件。为了执行“删除”,在writeable 分支中创建一个whiteout 文件来清除我们要删除的文件。这意味着该文件实际上并未被删除,而是隐藏在合并视图中。
我们讨论了很多关于 union mount 的一般工作原理,但它与 Docker 及其容器有何关系?为了将它们重新连接在一起,让我们看一下 Docker 分层架构。容器的沙箱由一些镜像分支——或者我们都知道的——层组成。这些层是合并视图的只读 (lowerdir) 部分,容器层是薄的可写顶部 (upperdir) 部分。
除了这个架构术语,它实际上是一回事——你从Registry中提取的图像层是 lowerdir,当你运行一个容器时,upperdir 附加到镜像层的顶部,为你的容器提供可写的工作空间。听起来很简单,对吧?那么,让我们试试吧!
五、分层和优先级
- 按照分层可以将读写成看成优先级最高。lowdir最低优先。
- 在用户层面,将若干层投影到一个透明画布上,看起来大家都在一个目录展示。
六、在真实Docker中尝试
为了演示 Docker 如何使用 OverlayFS,我们将尝试模拟 Docker 如何挂载容器和图像层。在动手做之前,我们首先需要清理我们的工作区并获取一个镜像来玩:
~ $ docker image prune -af
...
Total reclaimed space: ...MB
~ $ docker pull nginx
Using default tag: latest
latest: Pulling from library/nginx
a076a628af6f: Pull complete
0732ab25fa22: Pull complete
d7f36f6fe38f: Pull complete
f72584a26f32: Pull complete
7125e4df9063: Pull complete
Digest: sha256:10b8cc432d56da8b61b070f4c7d2543a9ed17c2b23010b43af434fd40e2ca4aa
Status: Downloaded newer image for nginx:latest
docker.io/library/nginx:latest
我们有一个图像 (nginx) 可以玩,接下来,让我们检查它的层。我们可以通过对图像运行 docker inspect 并检查 GraphDriver 字段或通过存储所有图像层的 /var/lib/docker/overlay2 目录来检查图像层。所以,让我们两者都做,看看里面有什么:
~ $ cd /var/lib/docker/overlay2
~ $ ls -l
total 0
drwx------. 4 root root 55 Feb 6 19:19 3d963d191b2101b3406348217f4257d7374aa4b4a73b4a6dd4ab0f365d38dfbd
drwx------. 3 root root 47 Feb 6 19:19 410c05aaa30dd006fc47d8c23ba0d173c6d305e4d93fdc3d9abcad9e78862b46
drwx------. 4 root root 72 Feb 6 19:19 685374e39a6aac7a346963bb51e2fc7b9f5e2bdbb5eac6c76ccdaef807abc25e
brw-------. 1 root root 253, 0 Jan 31 18:15 backingFsBlockDev
drwx------. 4 root root 72 Feb 6 19:19 d487622ece100972afba76fda13f56029dec5ec26ffcf552191f6241e05cab7e
drwx------. 4 root root 72 Feb 6 19:19 fb18be50518ec9b37faf229f254bbb454f7663f1c9c45af9f272829172015505
drwx------. 2 root root 176 Feb 6 19:19 l
~ $ tree 3d963d191b2101b3406348217f4257d7374aa4b4a73b4a6dd4ab0f365d38dfbd/
3d963d191b2101b3406348217f4257d7374aa4b4a73b4a6dd4ab0f365d38dfbd/
├── diff
│ └── docker-entrypoint.d
│ └── 20-envsubst-on-templates.sh
├── link
├── lower
└── work
~ $ docker inspect nginx | jq .[0].GraphDriver.Data
{
"LowerDir": "/var/lib/docker/overlay2/fb18be50518ec9b37faf229f254bbb454f7663f1c9c45af9f272829172015505/diff:
/var/lib/docker/overlay2/d487622ece100972afba76fda13f56029dec5ec26ffcf552191f6241e05cab7e/diff:
/var/lib/docker/overlay2/685374e39a6aac7a346963bb51e2fc7b9f5e2bdbb5eac6c76ccdaef807abc25e/diff:
/var/lib/docker/overlay2/410c05aaa30dd006fc47d8c23ba0d173c6d305e4d93fdc3d9abcad9e78862b46/diff",
"MergedDir": "/var/lib/docker/overlay2/3d963d191b2101b3406348217f4257d7374aa4b4a73b4a6dd4ab0f365d38dfbd/merged",
"UpperDir": "/var/lib/docker/overlay2/3d963d191b2101b3406348217f4257d7374aa4b4a73b4a6dd4ab0f365d38dfbd/diff",
"WorkDir": "/var/lib/docker/overlay2/3d963d191b2101b3406348217f4257d7374aa4b4a73b4a6dd4ab0f365d38dfbd/work"
}
查看上面的输出,它看起来与我们使用 mount 命令看到的非常相似,对吧?进一步来说:
- LowerDir:是只读镜像层的目录,用冒号隔开
- MergedDir:图像和容器中所有层的合并视图
- UpperDir:写入更改的读写层
- WorkDir:Linux OverlayFS 用来准备合并视图的工作目录
- 接下来,让我们更进一步,运行一个容器并检查它的层:
~ $ docker run -d --name container nginx
~ $ docker inspect container | jq .[0].GraphDriver.Data
{
"LowerDir": "/var/lib/docker/overlay2/59bcd145c580de3bb3b2b9c6102e4d52d0ddd1ed598e742b3a0e13e261ee6eb4-init/diff:
/var/lib/docker/overlay2/3d963d191b2101b3406348217f4257d7374aa4b4a73b4a6dd4ab0f365d38dfbd/diff:
/var/lib/docker/overlay2/fb18be50518ec9b37faf229f254bbb454f7663f1c9c45af9f272829172015505/diff:
/var/lib/docker/overlay2/d487622ece100972afba76fda13f56029dec5ec26ffcf552191f6241e05cab7e/diff:
/var/lib/docker/overlay2/685374e39a6aac7a346963bb51e2fc7b9f5e2bdbb5eac6c76ccdaef807abc25e/diff:
/var/lib/docker/overlay2/410c05aaa30dd006fc47d8c23ba0d173c6d305e4d93fdc3d9abcad9e78862b46/diff",
"MergedDir": "/var/lib/docker/overlay2/59bcd145c580de3bb3b2b9c6102e4d52d0ddd1ed598e742b3a0e13e261ee6eb4/merged",
"UpperDir": "/var/lib/docker/overlay2/59bcd145c580de3bb3b2b9c6102e4d52d0ddd1ed598e742b3a0e13e261ee6eb4/diff",
"WorkDir": "/var/lib/docker/overlay2/59bcd145c580de3bb3b2b9c6102e4d52d0ddd1ed598e742b3a0e13e261ee6eb4/work"
}
~ $ tree -l 3 /var/lib/docker/overlay2/59bcd145c580de3bb3b2b9c6102e4d52d0ddd1ed598e742b3a0e13e261ee6eb4/diff # The UpperDir
/var/lib/docker/overlay2/59bcd145c580de3bb3b2b9c6102e4d52d0ddd1ed598e742b3a0e13e261ee6eb4/diff
├── etc
│ └── nginx
│ └── conf.d
│ └── default.conf
├── run
│ └── nginx.pid
└── var
└── cache
└── nginx
├── client_temp
├── fastcgi_temp
├── proxy_temp
├── scgi_temp
└── uwsgi_temp
这里我们只是从前面的代码片段中获取值并将它们传递给 mount 命令中的适当参数,唯一的区别是我们使用 /mnt/merged 作为合并视图而不是 /var/lib/docker/overlay2/.../合并。
这就是 Docker 中整个 OverlayFS 的真正含义 - 跨多个堆叠层的单个挂载命令。下面是负责此的 Docker 代码的一部分 - 替换 lowerdir=...,upperdir=...,workdir=... 值,然后是 unix.Mount
// https://github.com/moby/moby/blob/1ef1cc8388165b2b848f9b3f53ec91c87de09f63/daemon/graphdriver/overlay2/overlay.go#L580
opts := fmt.Sprintf("lowerdir=%s,upperdir=%s,workdir=%s", strings.Join(absLowers, ":"), path.Join(dir, "diff"), path.Join(dir, "work"))
mountData := label.FormatMountLabel(opts, mountLabel)
mount := unix.Mount
mountTarget := mergedDir
rootUID, rootGID, err := idtools.GetRootUIDGID(d.uidMaps, d.gidMaps)
// ...
七、结论
从外面看 Docker 的界面,它可能看起来像一个黑盒子,里面有很多晦涩的技术。这些技术 - 虽然晦涩难懂 - 但非常有趣且有用,虽然您不需要了解它们就可以有效地使用 Docker,但在我看来,学习和理解它们仍然是值得的。