译|理解容器镜像层
容器非常神奇。它让简单的进程可以像虚拟机一样运行。在这背后的优雅设计中,有一套模式和实践使得一切可以正常运作。而设计的核心就是层(Layer)。层是存储和分发容器化文件系统内容的基本方式。这个设计出奇地简单又非常强大。在今天的文章中,我将解释什么是层,以及它们在概念上是如何工作的。
构建分层镜像
当你创建镜像时,通常会使用一个 Dockerfile
来定义容器的内容。Dockerfile
包含了一系列命令,例如:
FROM scratch
RUN echo "hello" > /work/message.txt
COPY content.txt /work/content.txt
RUN rm -rf /work/message.txt
在底层,容器引擎会按顺序执行这些命令,并为每个命令创建一个「层」。但是实际上发生了什么呢?最简单的理解是,将每一层看作是一个包含所有已修改文件的目录。
让我们通过一个示例来详细说明可能的实现方法。
FROM scratch
表示此容器从空内容开始。这是第一层,可以表示为一个空目录/img/layer1
。创建第二个目录
/img/layer2
,并将/img/layer1
中的所有内容复制到其中。然后从 Dockerfile 执行下一条命令(写入数据到/work/message.txt
文件)。这是第二层。创建第三个目录
/img/layer3
,并将/img/layer2
中的所有内容复制到其中。接下来的一条 Dockerfile 命令需要将宿主机上的content.txt
文件复制到该目录中。该文件会被写入到/img/layer3/work/content.txt
文件。这是第三层。最后,创建第四个目录
/img/layer4
,并将/img/layer3
中的所有内容复制到其中。接下来的一条命令会删除/img/layer4/work/message.txt
文件。这是第四层。
为了共享这些层,最简单的方法是为每个目录创建一个 .tar.gz
压缩文件。为了减小总文件大小,任何未修改的、从前一层复制过来的文件都会被移除。为了明确地表示某个文件已被删除,可以使用一个删除标记文件作为占位符。该文件会在原文件名之前加上 .wh.
前缀。举个例子,在第四层,会使用一个名为 .wh.message.txt
的占位符文件代替被删除的文件。当解压某一层时,任何以 .wh.
开头的文件都会被删除。
继续我们的例子,压缩文件将包含:
文件 | 内容 |
---|---|
layer1.tar.gz | 空文件 |
layer2.tar.gz | 包含 /work/message.txt |
layer3.tar.gz | 包含 /work/content.txt (因为 message.txt 未修改) |
layer4.tar.gz | 包含 /work/.wh.message.txt (因为 message.txt 已删除)content.txt 文件未修改,因此它不包含在内。 |
使用这种方式构建镜像会产生很多叫「layer1」的目录。为了确保名称唯一,压缩文件会使用内容的摘要作为名称。这有点类似于 Git 的工作方式。这样做的好处是可以识别相同的内容,同时在下载过程中可以识别出文件是否损坏。如果内容的摘要与文件名不匹配,文件就会被认为已损坏。
为了使结果可以复现,还需要做一件事情:创建一个解释层顺序的文件(清单)。清单会说明需要下载哪些文件以及解压它们的顺序。这使得可以重新创建目录结构。它还提供了一个重要的好处:层可以在不同的镜像之间重复使用和共享。这最小化了本地存储需求。
实际上,还有很多优化方法。例如,FROM scratch
其实意味着没有父层,所以我们的例子实际上是从 layer2
的内容开始的。引擎还可以检查构建过程中使用的文件,以确定是否需要重新创建某一层。这是层缓存的基础,它最小化了构建或重新创建层的需求。作为额外的优化,当不依赖前一层时,可以使用 COPY --link
来表明该层不需要删除或修改前一层的任何文件。这允许压缩的层文件与其它步骤并行创建。
快照
在容器运行之前,需要一个文件系统进行挂载。本质上,它需要一个包含所有可用文件的目录。压缩的层文件包含了文件系统的组件,但它们不能直接挂载和使用。相反,这些文件需要被解压并组织到一个文件系统中。这个解压后的目录被称为快照(好吧,这是为数不多叫这个名字的东西之一 😄)。
创建快照的过程与镜像构建相反。它先下载清单并生成要下载的层列表。对于每一层,都会创建一个包含该层父目录内容的目录。这个目录被称为活跃快照。接着,差异应用器负责解压压缩的层文件,并将更改应用到活跃快照。由此生成的目录称为已提交快照。最终,已提交快照将作为容器文件系统的挂载目录。
使用我们之前的例子:
初始层
FROM scratch
表示我们可以从下一层和一个空目录开始。它没有父级。创建一个
layer2
的目录,这个空目录现在是一个活跃快照。文件layer2.tar.gz
被下载,并通过将摘要与文件名进行比较来验证,然后被解压到该目录中。结果是一个包含/work/message.txt
的目录。这是第一个已提交快照。创建一个
layer3
的目录,并将layer2
的内容复制到其中。这是一个新的活跃快照。文件layer3.tar.gz
被下载、验证和解压。结果是一个包含/work/message.txt
和/work/content.txt
的目录。这个目录现在是第二个已提交快照。创建一个
layer4
的目录,并将layer3
的内容复制到其中。文件layer4.tar.gz
被下载、验证和解压。差异应用器识别到删除标记文件/work/.wh.message.txt
并删除/work/message.txt
,只剩下/work/content.txt
。这是第三个已提交快照。由于
layer4
是最后一层,它是容器的基础。为了支持读写操作,会创建一个新的快照目录,并将layer4
的内容复制到其中。该目录被挂载作为容器的文件系统。运行中的容器所做的任何更改都会发生在这个目录中。
如果这些目录中的任何一个已经存在,这表明另一个镜像具有同样的依赖。因此,引擎可以跳过下载和差异应用器,可以直接是使用现有的层。实际上,每个目录和文件都是根据内容的摘要命名的,以便于易于识别。举个例子,一组快照可能会像这样:
/var/path/to/snapshots/blobs
└─ sha256
├─ 635944d2044d0a54d01385271ebe96ec18b26791eb8b85790974da36a452cc5c
├─ 9de59f6b211510bd59d745a5e49d7aa0db263deedc822005ed388f8d55227fc1
├─ fb0624e7b7cb9c912f952dd30833fb2fe1109ffdbcc80d995781f47bd1b4017f
└─ fb124ec4f943662ecf7aac45a43b096d316f1a6833548ec802226c7b406154e9
或者:
Image | Parent |
---|---|
sha256:635944d2044d0a54d01385271ebe96ec18b26791eb8b85790974da36a452cc5c | |
sha256:9de59f6b211510bd59d745a5e49d7aa0db263deedc822005ed388f8d55227fc1 | sha256:635944d2044d0a54d01385271ebe96ec18b26791eb8b85790974da36a452cc5c |
sha256:fb0624e7b7cb9c912f952dd30833fb2fe1109ffdbcc80d995781f47bd1b4017f | sha256:9de59f6b211510bd59d745a5e49d7aa0db263deedc822005ed388f8d55227fc1 |
sha256:fb124ec4f943662ecf7aac45a43b096d316f1a6833548ec802226c7b406154e9 | sha256:fb0624e7b7cb9c912f952dd30833fb2fe1109ffdbcc80d995781f47bd1b4017f |
真实的快照系统支持插件来改进其中的一些行为。例如,它可以允许对快照进行预组合和解压,从而加快处理速度。这样就可以远程存储快照。它还可以进行特殊优化,例如按需即时下载所需的文件和层。
覆盖层
虽然挂载很简单,但是我们刚介绍的快照方法会产生大量文件变化和重复文件。这会减慢容器首次启动的过程并浪费空间。幸运的是,这是容器化过程中许多可以由文件系统处理的方面之一。Linux 原生支持将目录作为覆盖层进行挂载,为我们实现了大部分过程。
在 Linux 中(以 --privileged
或 --cap-add=SYS_ADMIN
运行的 Linux 容器中):
- 创建
tmpfs
挂载(基于内存的文件系统,将用于探索覆盖过程)
mkdir /tmp/overlay
mount -t tmpfs tmpfs /tmp/overlay
- 为实验创建一些目录。我们将会使用
lower
作为低层(父层),upper
作为高层(子层),work
作为文件系统的工作目录,以及merged
用来包含的合并文件系统。
mkdir /tmp/overlay/{lower,upper,work,merged}
- 为实验创建一些文件。你可以随意地添加一些文件到
upper
。
cd /tmp/overlay
echo hello > lower/hello.txt
echo "I'm only here for a moment" > lower/delete-me.txt
echo message > upper/upper-message.txt
- 作为
overlay
类型文件系统挂载这些目录。这将创建一个新的文件系统在merged
目录,该目录包含了lower
和upper
目录结合后的内容。work
目录会用来追踪文件系统的变更。
mount -t overlay overlay -o lowerdir=lower,upperdir=upper,workdir=work merged
- 探索文件系统。你会注意到
merged
包含lower
和upper
目录结合后的内容。然后,做一些改动:
rm -rf merged/delete-me.txt
echo "I'm new" > merged/new.txt
echo world >> merged/hello.txt
- 正如预期的那样,
delete-me.txt
从merged
中被移除和一个新文件,在相同目录中创建了new.txt
。如果你使用tree
命令查看目录结构,会看到一些有趣的事情:
|-- lower
| |-- delete-me.txt
| `-- hello.txt
|-- merged
| |-- hello.txt
| |-- new.txt
| `-- upper-message.txt
|-- upper
| |-- delete-me.txt
| |-- hello.txt
| |-- new.txt
| `-- upper-message.txt
运行 ls -l upper
显示:
total 12
c--------- 2 root root 0, 0 Jan 20 00:17 delete-me.txt
-rw-r--r-- 1 root root 12 Jan 20 00:20 hello.txt
-rw-r--r-- 1 root root 8 Jan 20 00:17 new.txt
-rw-r--r-- 1 root root 8 Jan 20 00:17 upper-message.txt
虽然 merged
显示了我们的更改效果,upper
(作为父层)存储了类似于我们手工处理示例的更改。它包含了新文件 new.txt
和修改后的文件 hello.txt
。还创建了一个删除标记文件。对于覆盖文件系统,这涉及用一个字符设备(以及设备号 0, 0)替换文件。简而言之,它拥有我们打包目录所需的一切!
你可以看到使用这种方法也可以实现一个快照系统。mount
命令本身可以接受一个用冒号(:
)分隔的 lowerdir
路径列表,这些将被合并到一个单一的文件系统中。这是现代容器的本质之一 —— 容器是由本地操作系统特性组成的。
这就是创建一个基本系统的所有内容。实际上,Kubernetes(以及最近发布的 Docker Desktop 4.27.0 )使用的 containerd
运行时采用了一种类似的方法来构建和管理它们的镜像(更多细节可以参考 Content Flow)。希望这能帮助你揭开容器镜像工作方式的神秘面纱!