使用 docker 是非常简单的-你只要会 build,run,inspect,pull 和 push 容器和镜像,但你有没有想过 docker 如何做到这些以及内部实际工作原理是什么?在这个简单的界面背后隐藏着许多很酷的技术,在本文中我们将探讨其中之一——联合文件系统——所有容器和镜像层背后的文件系统......
联合挂载是一种文件系统,它可以在不修改其原始(物理)源的情况下创建多个目录,并把内容合并为一个文件的错觉。这可能很有用,因为我们可能将相关文件集存储在不同位置中,但我们希望在单个合并视图中显示它们。例如,来自远程 NFS 服务器的/home 主目录全部联合到一个目录中,或者将分割的 ISO 镜像合并到一个完整的目录中。
联合挂载或联合文件系统是文件系统;但不是文件系统类型,而是一个包含许多实现的概念。其中一些速度更快性能更好,一些更简单,有不同的使用场景或不同的成熟度。因此,在我们开始深入了解细节之前,让我们快速浏览一下常见文件系统的实现:
如果您想更详细地探索与 Docker 相关的这些驱动程序,您可以查看 Docker 文档中的驱动程序。也就是说,除非您真的知道自己在做什么(此时您不会阅读本文),否则您应该坚持使用默认 overlay2,它也将在本文的剩下部分用于演示。
为什么 docker 等容器系统要使用类似的联合文件系统呢?
我们用来启动容器的许多镜像无论 ubuntu 是 72MB 还是 nginx 133MB 的大小都非常庞大。每次我们想从这些镜像创建一个容器时,分配这么多空间将是非常昂贵的。多亏了联合文件系统,Docker 只需要在镜像之上创建一个瘦文件层,其余的可以在所有容器之间共享。这还提供了减少启动时间的额外好处,因为无需复制镜像文件和数据。
联合文件系统还提供隔离功能,因为容器对共享镜像层具有只读访问权限。如果他们需要修改任何只读共享文件,他们会使用写时复制策略(稍后讨论)将内容复制到可以安全修改的可写层。
现在是时候问一个重要的问题了——它实际上是如何工作的?从上面描述的所有事情来看,整个联合文件系统似乎是某种黑魔法,但事实并非如此。让我们首先解释它在一般(非容器)情况下是如何工作的 - 假设我们想将两个目录(upper 和 lower)联合挂载到同一个挂载点并拥有它们的联合视图:
.
├── upper
│ ├── code.py # Content: `print("Hello Overlay!")`
│ └── script.py
└── lower
├── code.py # Content: `print("This is some code...")`
└── config.yaml
在联合挂载术语中,这些目录称为分支。这些分支中的每一个都被分配了优先级。此优先级用于确定在多个源分支中存在同名文件的情况下哪个文件将显示在合并视图中。查看上面的文件和目录 - 很明显,如果我们尝试覆盖它们,我们将创建这种冲突(code.py 文件)。所以,让我们试着看看会出现什么:
~ $ 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 带有类型的命令 overlay 将 lower 目录(只读;较低优先级)和 upper 目录(读写;较高优先级)合并到/mnt/merged. 我们还包括了一个 workdir=./workdir 选项,它作为准备合并视图的地方 lowerdir,upperdir 在它被移动到/mnt/merged 原子操作之前。
同样查看 cat 上面命令的输出,我们可以看到目录中文件的内容确实 upper 在合并视图中具有优先权。
所以,现在我们知道如何合并 2 个目录以及如果发生冲突会发生什么,但是如果我们尝试从合并视图修改某些文件会发生什么?这就是写时复制 (CoW)发挥作用的地方。那么,究竟是什么呢?CoW 是一种优化技术,如果两个调用者请求相同的资源,您可以为他们提供指向相同资源的指针,而无需复制它。只有当其中一个调用者尝试写入他们的副本时,才需要复制 - 因此术语 copy on (first try to) write。
在联合挂载的情况下,这意味着当我们尝试修改共享文件(或只读文件)时,它首先被复制到顶部可写分支(upperdir),该分支具有比只读较低分支(lowerdir)更高的优先级。然后 - 当它在可写分支中时 - 它可以安全地修改并且它的新内容将在合并视图中可见,因为顶层具有更高的优先级。
我们可能要执行的最后一个操作是删除文件。为了执行删除,在可写分支中创建一个空白文件以清除我们要删除的文件。这意味着该文件实际上并未被删除,而是隐藏在合并视图中。
我们讨论了很多关于联合挂载的一般工作原理,但它与 Docker 及其容器有什么关系?为了将它们重新连接在一起,让我们看一下 Docker 分层架构。容器的沙箱由一些镜像分支——或者我们都知道的层组成。这些层是 lowerdir 合并视图的只读部分,容器层可写顶部(upperdir)部分。
除了这个架构术语之外,它实际上是一样的 - 您从注册表中提取的镜像层是 lowerdir,当您运行容器时,upperdir 它会附加到镜像层的顶部,为您的容器提供可写的工作空间。听起来很简单,对吧?那么,让我们来试试吧!
为了演示 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 命令中看到的非常相似,进一步来说:
~ $ 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
上面的输出显示 docker inspect nginx 的输出中列出的目录与 MergedDir、UpperDir 和 WorkDir(id 为 3d963d191b2101b3406348217f4257d7374aa4b4a73b4b4a6dd4ab0f365d38dfbd)相同,现在是容器的 LowerDir 的一部分。这里的下半部分是由所有的 nginx 镜像层叠加在一起组成的。在它们之上是 UpperDir 中的可写层,它包含/etc、/run 和/var。同样,如果我们在上面列出 MergedDir,您将看到容器可以使用的整个文件系统,包括 UpperDir 和 LowerDir 中的所有内容。
最后,为了模拟 Docker 的行为,我们可以使用这些相同的目录来手动创建我们自己的合并视图:
~ $ mount -t overlay -o \
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,\
upperdir=/var/lib/docker/overlay2/59bcd145c580de3bb3b2b9c6102e4d52d0ddd1ed598e742b3a0e13e261ee6eb4/diff,\
workdir=/var/lib/docker/overlay2/59bcd145c580de3bb3b2b9c6102e4d52d0ddd1ed598e742b3a0e13e261ee6eb4/work \
overlay /mnt/merged
~ $ ls /mnt/merged
bin dev docker-entrypoint.sh home lib64 mnt proc run srv tmp var
boot docker-entrypoint.d etc lib media opt root sbin sys usr
~ $ umount overlay
在这里,我们只是从前面的代码片段中获取值并将它们传递给 mount 命令中的适当参数,唯一的区别是我们用于/mnt/merged 合并视图而不是/var/lib/docker/overlay2/.../merged.
这就是 Docker 中整个 OverlayFS 的真正含义——mount 跨多个堆叠层的单个命令。下面是负责这个的 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,但在我看来,学习它们并理解它们仍然是值得的。对该工具有更深入的了解,可以更轻松地做出正确的决策 - 在这种情况下 - 关于性能优化或安全影响。作为奖励,它还可以帮助您发现一些很酷的技术,这些技术将来可以为您提供更多用例。
在本文中,我们只探讨了 Docker 架构的一部分——文件系统——还有其他值得深入研究的部分——例如 cgroups、Linux 命名空间。所以,如果你喜欢类似文章,请留言。