深入Docker内部原理 - 联合文件系统
- Published on
使用Docker CLI非常简单直观 - 你只需要build
、run
、inspect
、pull
和push
容器和镜像,但你是否想过这个简单接口背后的内部原理是如何工作的?在这个简单的接口背后隐藏着许多很酷的技术,在本文中我们将探索其中之一 —— 联合文件系统 —— 所有容器和镜像层背后的底层文件系统...
什么是联合文件系统?
联合挂载是一种文件系统类型,它可以在不修改原始(物理)源的情况下,创造将多个目录内容合并成一个的假象。当我们可能有相关的文件集存储在不同位置或媒体中,但又想在单个合并视图中显示它们时,这种方式非常有用。例如,将来自远程NFS服务器的多个用户的/home
目录统一到单个目录中,或将拆分的ISO镜像合并为单个完整镜像。
然而,联合挂载或联合文件系统并不是文件系统类型,而是一个具有多种实现的概念。有些实现更快,有些更简单,具有不同的目标或不同的成熟度。在深入具体内容之前,让我们快速了解一下一些比较流行的实现:
UnionFS - 让我们从原始的联合文件系统开始。UnionFS似乎不再积极开发,最新的提交是在2014年8月。你可以在其网站上了解更多信息:https://unionfs.filesystems.org/ 。
aufs - 原始UnionFS的重新实现,添加了许多新功能,但被拒绝合并到主线Linux内核中。Aufs曾是Ubuntu/Debian上Docker的默认驱动,但被OverlayFS取代(对于Linux内核>4.0)。与其他联合文件系统相比,它有一些优势,这些在 Docker文档页面中有描述。
OverlayFS - OverlayFS自Linux内核3.18(2014年10月26日)以来就被包含在其中。这是默认
overlay2
Docker驱动使用的文件系统(你可以通过docker system info | grep Storage
来验证)。它通常比aufs性能更好,并且有一些不错的功能,如页面缓存共享。ZFS - ZFS是由Sun Microsystems(现为Oracle)创建的联合文件系统。它有一些有趣的功能,如分层校验和、原生快照处理和备份/复制,或原生数据压缩和重复数据删除。但是,由于由Oracle维护,它具有非OSS友好的许可证(CDDL),因此不能作为Linux内核的一部分发布。然而,您可以使用Linux上的ZFS(ZoL)项目,该项目在Docker文档中被描述为健康和成熟的.....但尚未准备好生产。如果你想尝试一下,那么你可以在这里找到它。
Btrfs - 另一种选择是Btrfs,它是包括SUSE、WD或Facebook在内的多家公司的联合项目,根据GPL许可证发布,是Linux内核的一部分。Btrfs是Fedora 33的默认文件系统。它还具有一些有用的功能,如块级操作、碎片整理、可写快照等等。如果你真的想经历切换到Docker非默认存储驱动程序的麻烦,那么Btrfs及其功能和性能可能是你的选择。
如果你想更详细地探索这些与Docker相关的驱动程序,你可以在Docker文档中查看驱动程序的比较。也就是说,除非你真的知道自己在做什么(此时你就不会阅读这篇文章),否则你应该坚持使用默认的overlay2
,这也将在本文的其余部分用于演示。
为什么要使用联合文件系统?
在前面的部分中,我们提到了这种类型的文件系统可能有用的一些原因,但为什么它对Docker和容器来说是一个好选择呢?
我们用来启动容器的许多镜像都相当庞大,无论是72MB的ubuntu
还是133MB的nginx
。每次我们想要从这些镜像创建容器时,分配那么多空间都会非常昂贵。多亏了联合文件系统,Docker只需要在镜像顶部创建一个薄层,其余部分可以在所有容器之间共享。这也提供了减少启动时间的额外好处,因为不需要复制镜像文件和数据。
联合文件系统还提供了隔离性,因为容器对共享镜像层具有只读访问权限。如果它们需要修改任何只读共享文件,它们使用写时复制策略(稍后讨论)将内容复制到其顶部可写层,在那里可以安全地修改。
它是如何工作的?
现在是时候问重要的问题了 - 它实际上是如何工作的?从上面描述的所有内容来看,整个联合文件系统可能看起来像某种黑魔法,但实际上并非如此。让我们从解释它在一般(非容器)情况下如何工作开始 - 假设我们想要将两个目录(upper
和lower
)联合挂载到同一个挂载点并获得它们的统一视图:
.
├── upper
│ ├── code.py # 内容: `print("Hello Overlay!")`
│ └── script.py
└── lower
├── code.py # 内容: `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!")
在上面的例子中,我们使用带有type overlay
的mount
命令将lower
目录(只读;较低优先级)和upper
目录(读写;较高优先级)组合成/mnt/merged
中的合并视图。我们还包含了workdir=./workdir
选项,它作为准备lowerdir
和upperdir
合并视图的地方,然后在原子操作中移动到/mnt/merged
。
通过查看上面cat
命令的输出,我们可以看到upper
目录中的文件内容确实在合并视图中具有优先权。
所以,现在我们知道如何合并2个目录,以及在发生冲突时会发生什么,但如果我们尝试修改合并视图中的某些文件会发生什么呢?这就是写时复制(CoW)发挥作用的地方。那么它到底是什么?CoW是一种优化技术,当两个调用者请求相同的资源时,你可以给他们指向相同资源的指针而无需复制它。只有当其中一个调用者试图写入他们的"副本"时才需要复制 - 因此称为写时复制。
在联合挂载的情况下,这意味着当我们尝试修改共享文件(或只读文件)时,它首先被复制到具有比只读下层分支(lowerdir
)更高优先级的顶部可写分支(upperdir
)。然后当它在可写分支中时,它可以安全地修改,并且由于顶层具有更高的优先级,其新内容将在合并视图中可见。
我们可能想要执行的最后一个操作是删除文件。要执行"删除",在可写分支中创建一个白出文件来清除我们想要删除的文件。这意味着该文件实际上并没有被删除,而是在合并视图中被隐藏。
我们讨论了很多关于联合挂载如何在一般情况下工作,但这一切与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
命令中看到的非常相似,对吗?更具体地说:
- 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
上面的输出显示,之前在docker inspect nginx
输出中列为MergedDir
、UpperDir
和WorkDir
的相同目录(ID为3d963d191b2101b3406348217f4257d7374aa4b4a73b4a6dd4ab0f365d38dfbd
)现在是容器的LowerDir
的一部分。这里的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,但在我看来,学习和理解它们仍然是一项值得的努力。对工具有更深入的理解可以让你更容易做出正确的决定 - 在这种情况下 - 关于性能优化或安全影响。作为额外的好处,它还能帮助你发现一些很酷的技术,这些技术在未来可能有更多的用途。