容器非常了不起。它们允许简单的进程像虚拟机一样运行。这种优雅的背后是一套模式和实践,最终使一切都能正常工作。设计的根源在于层。层是存储和分发容器化文件系统内容的基本方式。这种设计既非常简单,同时又非常强大。在今天的文章中,我将解释什么是层,以及它们在概念上是如何工作的。
构建分层镜像
当你创建一个镜像时,你通常会使用一个 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/layer2/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
文件。为了减小总文件大小,任何未经修改、只是从上一层复制的文件都会被删除。为了明确何时删除了文件,可以使用 whiteout 文件
作为占位符。该文件只需在原始文件名前加上前缀 .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
的内容复制到其中。此目录作为容器的文件系统挂载。运行中的容器所做的任何更改都将发生在此目录中。
如果这些目录中的任何一个已经存在,则表明另一个镜像具有相同的依赖项。因此,引擎可以跳过下载和差异应用器。它可以按原样使用该层。在实践中,这些目录和文件中的每一个都根据内容的摘要进行命名,以便更容易识别。例如,一组快照可能如下所示:
1/var/path/to/snapshots/blobs
2└─ sha256
3 ├─ 635944d2044d0a54d01385271ebe96ec18b26791eb8b85790974da36a452cc5c
4 ├─ 9de59f6b211510bd59d745a5e49d7aa0db263deedc822005ed388f8d55227fc1
5 ├─ fb0624e7b7cb9c912f952dd30833fb2fe1109ffdbcc80d995781f47bd1b4017f
6 └─ fb124ec4f943662ecf7aac45a43b096d316f1a6833548ec802226c7b406154e9
或者,换句话说:
镜像 | 父镜像 |
---|---|
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
包含upper
和lower
的组合内容。然后,进行一些更改: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
。它还创建了一个 whiteout 文件。对于 overlay 文件系统,这涉及将文件替换为字符设备(以及设备号 0,0)。简而言之,它拥有打包目录所需的一切!
你可以看到这种方法如何也可以用于实现快照系统。 mount
命令本身可以接受一个以冒号 (:
) 分隔的 lowerdir
路径列表,所有这些路径都合并到一个文件系统中。这是现代容器本质的一部分——容器是使用原生操作系统特性组成的。
这就是创建一个基本系统的全部内容。事实上,Kubernetes(以及最近发布的 Docker Desktop 4.27.0)使用的 containerd
运行时使用类似的方法来构建和管理其镜像(更详细的内容在 Content Flow 中介绍)。希望这有助于揭开容器镜像工作方式的神秘面纱!