译|理解容器镜像层

2024-07-28
次阅读
7 分钟阅读时长

原文:Understanding Container Image Layers

容器非常神奇。它让简单的进程可以像虚拟机一样运行。在这背后的优雅设计中,有一套模式和实践使得一切可以正常运作。而设计的核心就是层(Layer)。层是存储和分发容器化文件系统内容的基本方式。这个设计出奇地简单又非常强大。在今天的文章中,我将解释什么是层,以及它们在概念上是如何工作的。

构建分层镜像

当你创建镜像时,通常会使用一个 Dockerfile 来定义容器的内容。Dockerfile 包含了一系列命令,例如:

FROM scratch
RUN echo "hello" > /work/message.txt
COPY content.txt /work/content.txt
RUN rm -rf /work/message.txt

在底层,容器引擎会按顺序执行这些命令,并为每个命令创建一个「层」。但是实际上发生了什么呢?最简单的理解是,将每一层看作是一个包含所有已修改文件的目录。

让我们通过一个示例来详细说明可能的实现方法。

  1. FROM scratch 表示此容器从空内容开始。这是第一层,可以表示为一个空目录 /img/layer1

  2. 创建第二个目录 /img/layer2,并将 /img/layer1 中的所有内容复制到其中。然后从 Dockerfile 执行下一条命令(写入数据到 /work/message.txt 文件)。这是第二层。

  3. 创建第三个目录 /img/layer3,并将 /img/layer2 中的所有内容复制到其中。接下来的一条 Dockerfile 命令需要将宿主机上的 content.txt 文件复制到该目录中。该文件会被写入到 /img/layer3/work/content.txt 文件。这是第三层。

  4. 最后,创建第四个目录 /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 来表明该层不需要删除或修改前一层的任何文件。这允许压缩的层文件与其它步骤并行创建。

快照

在容器运行之前,需要一个文件系统进行挂载。本质上,它需要一个包含所有可用文件的目录。压缩的层文件包含了文件系统的组件,但它们不能直接挂载和使用。相反,这些文件需要被解压并组织到一个文件系统中。这个解压后的目录被称为快照(好吧,这是为数不多叫这个名字的东西之一 😄)。

创建快照的过程与镜像构建相反。它先下载清单并生成要下载的层列表。对于每一层,都会创建一个包含该层父目录内容的目录。这个目录被称为活跃快照。接着,差异应用器负责解压压缩的层文件,并将更改应用到活跃快照。由此生成的目录称为已提交快照。最终,已提交快照将作为容器文件系统的挂载目录。

使用我们之前的例子:

  1. 初始层 FROM scratch 表示我们可以从下一层和一个空目录开始。它没有父级。

  2. 创建一个 layer2 的目录,这个空目录现在是一个活跃快照。文件 layer2.tar.gz 被下载,并通过将摘要与文件名进行比较来验证,然后被解压到该目录中。结果是一个包含 /work/message.txt 的目录。这是第一个已提交快照。

  3. 创建一个 layer3 的目录,并将 layer2 的内容复制到其中。这是一个新的活跃快照。文件 layer3.tar.gz 被下载、验证和解压。结果是一个包含 /work/message.txt 和 /work/content.txt 的目录。这个目录现在是第二个已提交快照。

  4. 创建一个 layer4 的目录,并将 layer3 的内容复制到其中。文件 layer4.tar.gz 被下载、验证和解压。差异应用器识别到删除标记文件 /work/.wh.message.txt 并删除 /work/message.txt,只剩下 /work/content.txt。这是第三个已提交快照。

  5. 由于 layer4 是最后一层,它是容器的基础。为了支持读写操作,会创建一个新的快照目录,并将 layer4 的内容复制到其中。该目录被挂载作为容器的文件系统。运行中的容器所做的任何更改都会发生在这个目录中。

如果这些目录中的任何一个已经存在,这表明另一个镜像具有同样的依赖。因此,引擎可以跳过下载和差异应用器,可以直接是使用现有的层。实际上,每个目录和文件都是根据内容的摘要命名的,以便于易于识别。举个例子,一组快照可能会像这样:

/var/path/to/snapshots/blobs
└─ sha256
   ├─ 635944d2044d0a54d01385271ebe96ec18b26791eb8b85790974da36a452cc5c
   ├─ 9de59f6b211510bd59d745a5e49d7aa0db263deedc822005ed388f8d55227fc1
   ├─ fb0624e7b7cb9c912f952dd30833fb2fe1109ffdbcc80d995781f47bd1b4017f
   └─ fb124ec4f943662ecf7aac45a43b096d316f1a6833548ec802226c7b406154e9

或者:

ImageParent
sha256:635944d2044d0a54d01385271ebe96ec18b26791eb8b85790974da36a452cc5c
sha256:9de59f6b211510bd59d745a5e49d7aa0db263deedc822005ed388f8d55227fc1sha256:635944d2044d0a54d01385271ebe96ec18b26791eb8b85790974da36a452cc5c
sha256:fb0624e7b7cb9c912f952dd30833fb2fe1109ffdbcc80d995781f47bd1b4017fsha256:9de59f6b211510bd59d745a5e49d7aa0db263deedc822005ed388f8d55227fc1
sha256:fb124ec4f943662ecf7aac45a43b096d316f1a6833548ec802226c7b406154e9sha256:fb0624e7b7cb9c912f952dd30833fb2fe1109ffdbcc80d995781f47bd1b4017f

真实的快照系统支持插件来改进其中的一些行为。例如,它可以允许对快照进行预组合和解压,从而加快处理速度。这样就可以远程存储快照。它还可以进行特殊优化,例如按需即时下载所需的文件和层。

覆盖层

虽然挂载很简单,但是我们刚介绍的快照方法会产生大量文件变化和重复文件。这会减慢容器首次启动的过程并浪费空间。幸运的是,这是容器化过程中许多可以由文件系统处理的方面之一。Linux 原生支持将目录作为覆盖层进行挂载,为我们实现了大部分过程。

在 Linux 中(以 --privileged--cap-add=SYS_ADMIN 运行的 Linux 容器中):

  1. 创建 tmpfs 挂载(基于内存的文件系统,将用于探索覆盖过程)
mkdir /tmp/overlay
mount -t tmpfs tmpfs /tmp/overlay
  1. 为实验创建一些目录。我们将会使用 lower 作为低层(父层),upper 作为高层(子层),work 作为文件系统的工作目录,以及 merged 用来包含的合并文件系统。
mkdir /tmp/overlay/{lower,upper,work,merged}
  1. 为实验创建一些文件。你可以随意地添加一些文件到 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
  1. 作为 overlay 类型文件系统挂载这些目录。这将创建一个新的文件系统在 merged 目录,该目录包含了 lowerupper 目录结合后的内容。work 目录会用来追踪文件系统的变更。
mount -t overlay overlay -o lowerdir=lower,upperdir=upper,workdir=work merged
  1. 探索文件系统。你会注意到 merged 包含 lowerupper 目录结合后的内容。然后,做一些改动:
rm -rf merged/delete-me.txt
echo "I'm new" > merged/new.txt
echo world >> merged/hello.txt
  1. 正如预期的那样,delete-me.txtmerged 中被移除和一个新文件,在相同目录中创建了 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)。希望这能帮助你揭开容器镜像工作方式的神秘面纱!

本文作者:她和她的猫
本文地址https://her-cat.com/posts/2024/07/28/understanding-container-image-layers/
版权声明:本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明出处!