logo

深入Docker内部原理 - 联合文件系统

Published on

使用Docker CLI非常简单直观 - 你只需要buildruninspectpullpush容器和镜像,但你是否想过这个简单接口背后的内部原理是如何工作的?在这个简单的接口背后隐藏着许多很酷的技术,在本文中我们将探索其中之一 —— 联合文件系统 —— 所有容器和镜像层背后的底层文件系统...

什么是联合文件系统?

联合挂载是一种文件系统类型,它可以在不修改原始(物理)源的情况下,创造将多个目录内容合并成一个的假象。当我们可能有相关的文件集存储在不同位置或媒体中,但又想在单个合并视图中显示它们时,这种方式非常有用。例如,将来自远程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只需要在镜像顶部创建一个薄层,其余部分可以在所有容器之间共享。这也提供了减少启动时间的额外好处,因为不需要复制镜像文件和数据。

联合文件系统还提供了隔离性,因为容器对共享镜像层具有只读访问权限。如果它们需要修改任何只读共享文件,它们使用写时复制策略(稍后讨论)将内容复制到其顶部可写层,在那里可以安全地修改。

它是如何工作的?

现在是时候问重要的问题了 - 它实际上是如何工作的?从上面描述的所有内容来看,整个联合文件系统可能看起来像某种黑魔法,但实际上并非如此。让我们从解释它在一般(非容器)情况下如何工作开始 - 假设我们想要将两个目录(upperlower)联合挂载到同一个挂载点并获得它们的统一视图:

.
├── 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 overlaymount命令将lower目录(只读;较低优先级)和upper目录(读写;较高优先级)组合成/mnt/merged中的合并视图。我们还包含了workdir=./workdir选项,它作为准备lowerdirupperdir合并视图的地方,然后在原子操作中移动到/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输出中列为MergedDirUpperDirWorkDir的相同目录(ID为3d963d191b2101b3406348217f4257d7374aa4b4a73b4a6dd4ab0f365d38dfbd)现在是容器的LowerDir的一部分。这里的LowerDir由所有nginx镜像层堆叠在一起组成。在它们之上是UpperDir中的可写层,其中包含/etc/run/var。此外,如果我们列出上面的MergedDir,你会看到容器可用的整个文件系统,包括来自UpperDirLowerDir的所有内容。

最后,为了模拟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,但在我看来,学习和理解它们仍然是一项值得的努力。对工具有更深入的理解可以让你更容易做出正确的决定 - 在这种情况下 - 关于性能优化或安全影响。作为额外的好处,它还能帮助你发现一些很酷的技术,这些技术在未来可能有更多的用途。