danishpraka.sh

从零开始构建 Container Image — Danish Prakash

从零开始构建 Container Image

2024年11月30日

如果你更喜欢视频内容,这里有一个基于本文的演讲。链接

对于开发者来说,Container Image 本质上是运行 Container 所需的配置集合。但 Container Image 到底是什么?你可能知道 Container Image 是什么,它由多个 Layer 组成,并且是一个 tar 归档文件的集合。但仍然存在一些未解答的问题,例如 Layer 由什么组成,Layer 如何组合以形成完整的文件系统或多平台 Image 等。在本文中,我们将从头开始构建 Container Image,并尝试回答所有这些问题,以了解 Container Image 的内部机制。

OCI Image

在继续之前,先简单了解一下历史。大约 10 年前,docker-format 是唯一被使用的格式。随着围绕 Container 的工具的出现,需要进行标准化。大约在 2015 年,成立了 Open Containers Initiative(OCI),这是一个 Linux Foundation 项目,旨在标准化所有与 Container 相关的事项。他们为 Container Image 制定了一个规范,称为 OCI 规范。现代工具在处理 Container Image 时都符合并遵循运行时规范。因此,在本文中,OCI Image 和 Container Image 将互换使用。

一个 OCI Image 由四个核心组件组成:Layer、config、manifest 和 index:

我们将理解上述每个组件,并从头开始构建一个 "hello" Image,以实际理解所有这些组件。如果我们的 "hello" Image 有一个 Containerfile,它将如下所示:

FROM scratch
COPY ./hello ./
ENTRYPOINT ["./hello"]

该 Image 本身基于 scratch,一个空的 Base Image。然后,我们将 hello 二进制文件复制到 Image,并将其设置为 Container 的 entrypoint。让我们开始吧。

1. Layer — Image 内部的内容

Layer 表示 Container Image 内部的内容。通常被称为 Container Image 的基本构建块。它们由诸如你复制到 Image 的源代码、Container 文件系统或你添加到 Container Image 的几乎任何内容等组件组成。

从技术上讲,Container Image 是一个文件系统 变更集。文件系统变更集是两个文件系统之间的差异,序列化为 tar 归档文件。考虑以下 Container Image:

FROM Alpine
RUN rm -f /bin/ash \
  && apk add bash

我们使用 Alpine 作为 Base Image,删除了 /bin/ash 并安装了 bash。让我们以此为例来构建变更集,然后组装 Container 的最终文件系统。

1.1 Layer:创建变更集

为了创建一个 Layer,或者更准确地说,一个文件系统变更集,我们从一个最小的根文件系统开始,在本例中,我们使用来自 Alpine 的根文件系统,因为我们的示例 Containerfile 从 Alpine Base Image 开始:

$ wget https://dl-cdn.alpinelinux.org/alpine/v3.18/releases/x86_64/alpine-minirootfs-3.18.4-x86_64.tar.gz && tar -xvf $_
$ tree
.
├── alpine-minirootfs-3.18.4-x86_64.tar.gz
├── bin
│   ├── arch
│   ├── ash
│   ├── base64
│   ├── bbconfig
│   ├── busybox
│   ├── cat
│   ├── chattr
...

为了创建后续 Layer,我们创建 Base 文件系统的副本/快照——使用 cp -rp——并通过添加、删除或修改文件或目录来更改快照。

变更集仅包含已添加、修改和删除的文件。为了创建变更集,递归比较两个文件系统(前一步骤中的快照)。然后创建一个 tar 归档文件,其中包含 变更集。

假设我们从示例 Image 中删除了 /bin/ash 并添加了 /bin/bash,那么我们的变更集/Layer 将如下所示:

./bin/bash
./bin/.wh.ash

注意 .wh,它表示 whiteout,用于表示已删除的文件。上面的变更集告诉 Container 引擎,我们在先前的 Layer/变更集(在本例中为 Alpine Base)之上 添加/修改了 bash 并删除了 ash。这将构成一个变更集,一个 Layer。

1.2 Layer:创建文件系统

现在我们已经创建了一个 Layer。Container 引擎采用多个 Layer,并为生成的 Container 创建一个完整的文件系统。

假设我们有 3 个 Layer,一个没有文件系统的空 Layer——起始变更集。然后我们创建一个变更集并添加两个二进制文件,/bin/ash/bin/bash。然后最后一个 Layer 删除 /bin/ash 并修改 /bin/bash

Container 引擎会将作为 Image 一部分的左右 Layer 互相叠加,以生成最终的文件系统。在这种情况下,由于将 Layer 1 应用于 Layer 0 之上,并将 Layer 2 应用于 Layer 1 之上,因此最终的文件系统将仅具有 /bin/bash。这就是 Container 引擎如何从 Layer 集合(以 Container Image 的形式)创建文件系统。

1.3 Layer:生命周期

到目前为止,我们已经了解:

当你每天的工作流程中使用 Container 时,这一切是如何协同工作的?考虑 Container Image 的生命周期,从 Containerfile 到 Container Image,最后到运行的 Container:

你使用 podman build 并传入 Containerfile,然后 podman 将创建各种 Layer,并将其打包为“Container Image”。当你使用此 Image 运行 podman run 时,引擎会将作为 Image 一部分的所有各种 Layer 组合在一起,并为正在运行的 Container 创建根文件系统。

1.4 Layer — 我们的 scratch Image Layer

让我们为我们的 "hello" Image 创建 Layer。作为参考,如果它是一个 Containerfile,我们的 Image 将如下所示:

FROM scratch
COPY ./hello /root/
ENTRYPOINT ["./hello"]

在这里,我们的 Image 包含 2 个 Layer。第一个 Layer 来自 Base Image,即 Alpine 官方 Docker Image,也就是包含所有标准 Shell 工具的根文件系统。Containerfile 中的几乎每个指令都会生成另一个 Layer。因此,在上面的 Containerfile 中,COPY 指令创建了第二个 Layer,其中包括对先前 Layer 的文件系统更改。此处的更改是将新文件——hello 二进制文件——“添加”到现有文件系统,即 Alpine 根文件系统。

让我们为我们的 Image 创建 Layer。我们首先为我们简单的 "Hello world" 程序创建一个静态链接的 C 二进制文件:

$ cat hello.c
#include <stdio.h>
int main(int argc, char *argv[]) {
  if (argc < 2) {
    printf("Usage: %s <name>\n", argv[0]);
    return 1;
  }
  printf("Hello, %s!\n", argv[1]);
  return 0;
}
$ gcc -o hello hello.c -static
$ tar --remove-files -czvf layer.tar.gz hello
$ sha256sum layer.tar.gz
36c412b23a871c4afbec29a45b25faad76197f3a9dbf806f3aef779af926790a layer.tar.gz
$ mv layer.tar.gz 36c412b23a871c4afbec29a45b25faad76197f3a9dbf806f3aef779af926790a

然后,我们创建一个二进制文件的 gzip 压缩 tar 归档文件。这就构成了我们的第一个也是唯一的 Layer。

2. config — 如何运行 Container

Config 表示 如何运行 Container。它是一个 JSON 文件,用于存储配置 Container 的配置选项。诸如环境变量、Container 的 entrypoint 和卷等选项。可以在运行 Container 时通过命令行提供这些选项,也可以作为 Containerfile 的一部分提供,在这种情况下,将填充 config.json 文件。

考虑以下来自示例 config.json 的代码段:

$ vim sample_config.json
{
  "architecture": "amd64",
  "os": "linux",
  "config": {
    "Entrypoint": [
      "./bin/bash"
    ],
    "User": "danish",
    "ExposedPorts": {
      "8080/tcp": {}
    },
    "Env": [
      "FOO=bar",
    ],
    "Volumes": {
      "/var/logs": {}
    }
  }
}

你可以看到在文件的 config 部分中设置了各种配置选项以及其他元数据。你可以设置 entrypoint、用户、端口、环境变量等。让我们为我们的 Image 编写配置:

$ vim config.json
{
  "architecture": "amd64",
  "os": "linux",
  "config": {
    "Entrypoint": [
      "time",
      "./hello"
    ]
  }
}

你可以注意到它没有太多内容。如果你参考我们 Image 的 Containerfile 等效项,你将注意到我们将 "hello" 二进制文件设置为 Image 的 entrypoint,这是我们需要设置的唯一配置选项。

3. manifest — 定位 Layer 和 config.json

Container 引擎使用 manifest 来定位 Image 的 Layer 和 config.json。考虑以下来自示例 manifest.json 的代码段:

{
  "schemaVersion": 2,
  "mediaType": "application/vnd.oci.image.manifest.v1+json",
  "config": {
    "mediaType": "application/vnd.oci.image.config.v1+json",
    "digest": "sha256:<DIGEST>",
    "size": XYZ
  },
  "layers": [
    {
      "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
      "digest": "sha256:<DIGEST>",
      "size": XZY
    }
  ]
}

上面的代码段中,manifest 提到了 config 和 Layer,以及每个组件的额外元数据。但 manifest 没有引用路径,而是通过摘要来寻址组件。这是因为 Layer、config 和 manifest 共同构成了所谓的 内容可寻址存储

3.1 内容可寻址性

为了提高效率和完整性,OCI 要求 OCI Image 中的组件基于其内容进行标识,也就是说,你可以根据数据的内容而不是其位置(文件路径等)来标识数据。为了在 OCI Image 的情况下实现这一点,使用唯一的标识符(通常是加密哈希)作为文件名。这被称为 内容可寻址性

它有助于重复数据删除、Layer 共享,从而减少内存和性能开销,并确保数据完整性。

我们将在本文中使用 sha256 作为算法来生成各种组件的标识符。让我们使我们在前一步骤中创建的 Layer 归档文件内容可寻址:

$ sha256sum layer.tar.gz
c37c06cdec9d6a0f2a2d55deb5aa002b26b37b17c02c2eca908fc062af5f53eb layer.tar
$ mv layer.tar c37c06cdec9d6a0f2a2d55deb5aa002b26b37b17c02c2eca908fc062af5f53eb

目录结构

OCI 还定义了 OCI Image 的布局,Container 引擎希望 Image 以指定的格式打包,然后再进行解析。其定义如下:

$ tree
├── blobs/<alg>
│     ├── <内容可寻址的 config>
│     ├── <内容可寻址的 manifest>
│     └── <内容可寻址的 layer>
└── index.json

内容可寻址的内容位于 blobs 目录下的子目录中,该子目录标识用于编码内容的算法。由于我们使用 sha256 来计算 Blob 的摘要,因此我们创建目录 sha256

$ mkdir --parents blobs/sha256
$ mv c37c06cdec9d6a0f2a2d55deb5aa002b26b37b17c02c2eca908fc062af5f53eb $_
$ tree ../..
└── blobs/sha256
    └── c37c06cdec9d6a0f2a2d55deb5aa002b26b37b17c02c2eca908fc062af5f53eb

与此同时,我们还使我们的 config 内容可寻址,并为我们的 "hello" Image 创建 manifest.json 文件:

$ sha256 config.json
99c9d2dcbdbc6e28277d379c2b9a59443b91937720361773963d28d5376252a9 config.json
$ mv config.json 99c9d2dcbdbc6e28277d379c2b9a59443b91937720361773963d28d5376252a9
$ tree ../..
└── blobs/sha256
    ├── c37c06cdec9d6a0f2a2d55deb5aa002b26b37b17c02c2eca908fc062af5f53eb
    └── 99c9d2dcbdbc6e28277d379c2b9a59443b91937720361773963d28d5376252a9
$ vim manifest.json
{
  "schemaVersion": 2,
  "mediaType": "application/vnd.oci.image.manifest.v1+json",
  "config": {
    "mediaType": "application/vnd.oci.image.config.v1+json",
    "digest": "sha256:99c9d2dcbdbc6e28277d379c2b9a59443b91937720361773963d28d5376252a9",
    "size": 149
  },
  "layers": [
    {
      "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
      "digest": "sha256:36c412b23a871c4afbec29a45b25faad76197f3a9dbf806f3aef779af926790a",
      "size": 1372604
    }
  ]
}

从上面开始,我们使我们的 config.json 内容可寻址,然后查看我们当前的 Image 布局,该布局由包含我们的 Layer 和我们的 config 的 blobs/sha256 目录组成。然后,我们创建我们的 manifest 文件,并通过摘要以及大小和标签等其他元数据来引用 config 和 Layer。

现在我们的 manifest 准备好了,让我们也使其内容可寻址:

$ sha256 manifest.json
2e17c995558ebfa8faacfe64ff78c359ab9f28b3401076bb238fd28c5b3a648b manifest.json
$ mv manifest.json 2e17c995558ebfa8faacfe64ff78c359ab9f28b3401076bb238fd28c5b3a648b
$ tree .
└── blobs/sha256
    ├── c37c06cdec9d6a0f2a2d55deb5aa002b26b37b17c02c2eca908fc062af5f53eb
    ├── 99c9d2dcbdbc6e28277d379c2b9a59443b91937720361773963d28d5376252a9
    └── 2e17c995558ebfa8faacfe64ff78c359ab9f28b3401076bb238fd28c5b3a648b

到目前为止,我们解压缩的 OCI "hello" Image 具有一个 Layer 归档文件,其中包含我们的静态链接的 hello 二进制文件、Container 的配置以及 Image 的 manifest,所有这些都由其 sha256 摘要编码。

4. index — manifest 的 manifest

index.json 文件充当一组 Image 的索引,这些 Image 可以跨越不同的架构和操作系统。

一个示例可能是在一个 OCI Image 中包含两个不同架构的 Image,即 linux/arm64 和 linux/amd64 变体。在这种情况下,index.json 将包含两个条目,这些条目通过它们的 manifest 指向 Image 的两个变体,而这些 manifest 又将包含特定于 Image 的元数据,如上图所示。

考虑到这一点,我们为我们的 "hello" Image 创建 index.json。我们没有多个架构,所以我们的 index.json 文件仅通过其摘要指向我们 Image 中的一个 manifest.json:

$ cd ../..
$ vim index.json
{
  "schemaVersion": 2,
  "manifests": [
    {
      "mediaType": "application/vnd.oci.image.manifest.v1+json",
      "digest": "sha256:2e17c995558ebfa8faacfe64ff78c359ab9f28b3401076bb238fd28c5b3a648b",
      "size": 748,
      "annotations": {
        "org.opencontainers.image.ref.name": "hello:scratch"
      }
    }
  ]
}

此外,我们还添加了一个注释,表示由 manifest 引用的 Image 的名称和标签。如果大家还记得我们在开头讨论的高级 OCI Image 架构,那么 index.json 文件不需要编码,因此它位于我们 Image 的顶级目录中,而不是位于 blobs/ 下。完成所有组件后,让我们快速回顾一下:


$ tree
├── blobs/sha256
│     ├── c37c06cdec9d6a0f2a2d55deb5aa002b26b37b17c02c2eca908fc062af5f53eb
│     ├── 99c9d2dcbdbc6e28277d379c2b9a59443b91937720361773963d28d5376252a9
│     └── 2e17c995558ebfa8faacfe64ff78c359ab9f28b3401076bb238fd28c5b3a648b
└── index.json

(按上面显示的顺序)

  1. c37c06cdec9d6a0f2a2d55deb5aa002b26b37b17c02c2eca908fc062af5f53eb — Image 的 Layer,其中包括 hello 二进制文件。
  2. 99c9d2dcbdbc6e28277d379c2b9a59443b91937720361773963d28d5376252a9 — config.json,它告诉 Container 引擎将我们的 hello 二进制文件设置为将从此 Image 运行的 Container 的 entrypoint。
  3. 2e17c995558ebfa8faacfe64ff78c359ab9f28b3401076bb238fd28c5b3a648b — manifest.json 文件定位 Container 引擎的 config 和 Layer。
  4. index.json — 一个更高级别的 manifest,它引用多个 Image manifest,从而允许跨不同平台或架构分发 Container Image。

打包

我们已经准备好了 Image 的所有部分。让我们创建一个我们目前创建的组件的归档文件,并测试我们的 Image:

$ tree
├── blobs/sha256
│     ├── c37c06cdec9d6a0f2a2d55deb5aa002b26b37b17c02c2eca908fc062af5f53eb
│     ├── 99c9d2dcbdbc6e28277d379c2b9a59443b91937720361773963d28d5376252a9
│     └── 2e17c995558ebfa8faacfe64ff78c359ab9f28b3401076bb238fd28c5b3a648b
└── index.json
$ tar -cf hello.tar *
$ podman load < hello.tar
Getting image source signatures
Copying blob c37c06cdec9d done  |
Copying config cd12bca58e done  |
Writing manifest to image destination
Loaded image: localhost/hello:scratch
$ podman run localhost/hello:scratch world
Hello world!
$ podman image ls hello
REPOSITORY  TAG    IMAGE ID    CREATED  SIZE
hello      scratch  25e8b3bd9720  N/A    3.67MB

我们可以看到 Image 已被 podman 加载,并且我们能够运行基于我们 Image 的 Container,正确地将 hello world! 输出到 stdout。我们确切地知道 Image 是由什么组成的,我们放入了哪些文件,我们设置了哪些配置以及哪些元数据是 Image 的一部分。

使用 Base Image

让我们创建一个基于 alpine 而不是 scratch 的 Image 版本。这会在我们的 Image 中引入另一个 Layer,所以让我们看看如何处理多个 Layer。在这种情况下,Containerfile 如下所示。

FROM alpine:latest
COPY ./hello /root/hello
ENTRYPOINT ["time", "./hello"]

请注意,我们更改了 entrypoint 以使用 time 实用程序。这样做是为了演示两个 Layer 如何在 Image 中工作。

到目前为止,Image 只有一个 Layer,其中包含 hello 二进制文件。如前所述,可以将 Layer 视为文件系统差异。因此,如果我们使用 Alpine 作为 Base Image,我们需要相应的根文件系统,我们将在其上放置第二个 Layer(即带有二进制文件的 Layer)。你可以从该项目的官方网站获取 Alpine 根文件系统。

$ cd blobs/sha256
$ wget https://dl-cdn.alpinelinux.org/alpine/v3.18/releases/x86_64/alpine-minirootfs-3.18.4-x86_64.tar.gz
$ mv alpine-minirootfs-3.18.4-x86_64.tar.gz \
  $(sha256sum alpine-minirootfs-3.18.4-x86_64.tar.gz | awk '{print $1}')

下载的根文件系统已经是 gzipped 的,所以我们只需要用它的 sha256 摘要编码它就可以开始了。

$ cd ../..
$ tree
├── blobs/sha256
│     ├── 99c9d2dcbdbc6e28277d379c2b9a59443b91937720361773963d28d5376252a9
│     ├── 8289bd1bdc2a1fae2d2d717b7c40baaedc4c5c4d9c9f4f1a1b045287067e9f2c
│     ├── c37c06cdec9d6a0f2a2d55deb5aa002b26b37b17c02c2eca908fc062af5f53eb
│     └── 2e17c995558ebfa8faacfe64ff78c359ab9f28b3401076bb238fd28c5b3a648b
└── index.json

除了我们之前的三个组件——hello Layer、config 和 manifest——我们现在还有一个用于 Alpine 根文件系统的额外 Layer (8289bd1)。

接下来,我们需要使用新信息更新 config 和 manifest 文件。之后,我们还需要确保我们再次使用它们各自更新的 sha256 摘要对它们进行编码。

$ vim config.json
{
  "architecture": "amd64",
  "os": "linux",
  "config": {
    "Entrypoint": [
      "time",
      "./hello"
    ]
  }
}
$ mv config.json $(sha256sum config.json | awk '{print $1}')

我们修改了我们的 entrypoint,将 time preped 到我们的 hello 二进制文件,以便区分我们的 scratch Image 和 Alpine Base Image。config.json 不需要其他修改。

在我们的 manifest.json 文件中,我们更新了我们修改的 config.json 的摘要,并为我们的 Alpine Base Layer 添加了 Layer 信息。Layer 从上到下添加,所以我们的 Alpine Base Layer 是数组中的第一个条目,后跟我们的 hello Layer。

$ vim manifest.json
{
  "schemaVersion": 2,
  "mediaType": "application/vnd.oci.image.manifest.v1+json",
  "config": {
    "mediaType": "application/vnd.oci.image.config.v1+json",
-----> "digest": "sha256:75b148a9a5b61403d082f1930ccc779bfdac5acddb48ed8f90ccdc4219d51268",
-----> "size": 603
  },
  "layers": [
    {
      "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
      "digest": "sha256:8289bd1bdc2a1fae2d2d717b7c40baaedc4c5c4d9c9f4f1a1b045287067e9f2c",
      "size": 3279768
    },
    {
      "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
---------> "digest": "sha256:c37c06cdec9d6a0f2a2d55deb5aa002b26b37b17c02c2eca908fc062af5f53eb",
      "size": 1372611
    }
  ]
}
$ mv manifest.json $(sha256sum manifest.json | awk '{print $1}')

由于我们更改了我们的 manifest,我们需要使用新的摘要和文件大小更新 index.json:

$ vim index.json
{
  "schemaVersion": 2,
  "manifests": [
    {
      "mediaType": "application/vnd.oci.image.manifest.v1+json",
---------> "digest": "sha256:8289bd1bdc2a1fae2d2d717b7c40baaedc4c5c4d9c9f4f1a1b045287067e9f2c",
---------> "size": 748,
      "annotations": {
        "org.opencontainers.image.ref.name": "hello:alpine"
      }
    }
  ]
}

如果我们再次使用 podman 打包并加载 Image,我们可以看到修改后的 Image 在运行:

$ tree
├── blobs
│     └── sha256
│         ├── 99c9d2dcbdbc6e28277d379c2b9a59443b91937720361773963d28d5376252a9
│         ├── 8289bd1bdc2a1fae2d2d717b7c40baaedc4c5c4d9c9f4f1a1b045287067e9f2c
│         ├── c37c06cdec9d6a0f2a2d55deb5aa002b26b37b17c02c2eca908fc062af5f53eb
│         └── 2e17c995558ebfa8faacfe64ff78c359ab9f28b3401076bb238fd28c5b3a648b
└── index.json
$ tar -cvf hello-alpine.tar *
$ podman load < hello-alpine.tar
Getting image source signatures
Copying blob c59d5203bc6b done  |
Copying blob c37c06cdec9d done  |
Copying config 75b148a9a5 done  |
Writing manifest to image destination
Loaded image: localhost/hello:alpine
$ podman run --rm localhost/hello:alpine reader
Hello, reader!
real  0m 0.00s
user  0m 0.00s
sys   0m 0.00s
$ podman image ls hello
REPOSITORY  TAG    IMAGE ID    CREATED  SIZE
hello      scratch  25e8b3bd9720  N/A    3.67MB
hello      alpine  3d0268e9a91e  N/A    11MB

结论

我希望这篇文章能帮助你更详细地了解 Container Image,并以一种易于理解和接近的方式进行。本文中使用的示例不是你在日常工作流程中使用的示例。它们的目的是演示 Container Image 的内部工作原理。

如果你发现任何问题或改进,请提出问题或随时通过电子邮件联系。

感谢 Aleksa、Dan 和 Dmitri 对本文的所有帮助。 :wq 版权所有 © Danish Prakash 2025