Build a Container Image from Scratch
2025-3-22
| 2025-3-22
Words 5820Read Time 15 min
type
status
date
slug
summary
tags
category
icon
password
There’s also a talk based on this article if you prefer video based content. You can find it here.
对于开发者来说,容器镜像本质上是一组用于运行容器的配置集合。但容器镜像究竟是什么呢?你也许知道容器镜像是由多层(layer)组成的,是一组 tar 归档文件的集合。但仍然有一些问题尚未解答,比如:一层到底由什么组成?这些层是如何组合在一起,形成完整的文件系统,甚至是多平台镜像的?
在本文中,我们将从零开始构建一个容器镜像,并尝试解答这些问题,从而深入理解容器镜像的内部结构。

OCI 镜像

在继续之前,我们先简单回顾一下历史。大约在十年前,Docker 格式几乎是唯一被广泛使用的容器镜像格式。随着围绕容器技术的各种工具逐渐涌现,业界开始意识到标准化的必要性。于是,在 2015 年左右,开放容器倡议(Open Containers Initiative,OCI) 成立(现在是 Linux 基金会的一个项目),其目标是对容器相关的内容进行标准化。
OCI 制定了一个关于容器镜像的规范,被称为 OCI 镜像规范(OCI spec)。现代的容器工具都遵循这套运行时规范来处理容器镜像。因此在本文中,OCI 镜像容器镜像 将被视为等同概念,可以互换使用。
一个 OCI 镜像由四个核心组件组成:层(layer)配置(config)清单(manifest)索引(index)
notion image
我们将逐一了解上述每个组件,并通过从零构建一个 “hello” 镜像,来实际理解它们的作用。如果我们为这个 “hello” 镜像编写一个 Containerfile,它大致如下所示:
这个镜像是基于 scratch 构建的,也就是一个空白的基础镜像。我们将本地编译好的 hello 可执行文件复制到镜像中,并将其设置为容器的入口点(entrypoint)。
让我们开始动手吧。

1. Layer — 镜像中的内容

Layer(层)代表了容器镜像中的实际内容,通常被称为容器镜像的基本构建单元。它们包含了你复制进镜像的源代码、容器的文件系统,或者任何你添加到容器镜像中的内容。 从技术角度看,容器镜像其实是一个文件系统变更集(filesystem changeset)。简单来说,就是两个文件系统之间的差异(diff),以 tar 归档文件的形式序列化保存。 来看下面这个容器镜像的例子: FROM alpine RUN rm -f /bin/ash \ && apk add bash 在这个示例中,我们使用了 alpine 作为基础镜像,删除了 /bin/ash,并安装了 bash。我们将用这个例子来构建各个变更集(changeset),并最终组合出容器的完整文件系统。 ——现在我们将从每一层开始,分析这些变更是如何构成容器镜像的。

1.1 Layer:创建一个变更集(Changeset)

要创建一个 Layer(更准确地说,是一个文件系统变更集),我们需要从一个最小的根文件系统开始。在我们的示例中,由于使用的是 Alpine 作为基础镜像,我们就从 Alpine 的 Mini Root Filesystem 开始:
解压之后的目录结构大致如下:
为了创建后续的层,我们会先对基础文件系统做一次快照(可以使用 cp -rp 命令),然后对这个快照进行修改:新增、删除或更改文件或目录
一个变更集只包含这些被新增、修改或删除的文件。要创建这个变更集,我们需要将两个快照(原始的和修改后的)进行递归对比,找出所有差异,并将这些差异打包成一个 tar 归档。
以我们之前的例子为例(删除了 /bin/ash,添加了 /bin/bash),这个变更集或 Layer 的内容将是:
注意这里的 .wh.ash:前缀 .wh. 表示 whiteout 文件,用于标记某个文件在该层被删除。这个变更集告诉容器引擎:我们在上一层(Alpine 基础层)上新增/修改了 bash,并删除了 ash
这就是一个标准的 Layer(变更集)的组成。

1.2 Layer:创建完整的文件系统

现在我们已经创建了一个 layer(变更集)。容器引擎接下来的工作,就是将多个 layer 组合在一起,构建出容器运行时所需的完整文件系统。
容器镜像通常是由多个层堆叠组成的,每一层都表示相对于上一层的文件系统变更。容器引擎会按照从底到顶的顺序依次叠加这些层,构建出一个最终的统一文件系统视图,供容器使用。
notion image
假设我们有三个 Layer(层):
  • Layer 0:一个空层,没有任何文件系统内容,作为起始变更集。
  • Layer 1:我们创建了一个变更集,添加了两个二进制文件:/bin/ash/bin/bash
  • Layer 2:删除了 /bin/ash,并修改了 /bin/bash
容器引擎会按照顺序(从左到右)依次将这些层“叠加”在一起,以构建出最终的文件系统。
具体过程如下:
  1. Layer 0 是一个空白的起点。
  1. 应用 Layer 1:文件系统现在包含 /bin/ash/bin/bash
  1. 应用 Layer 2/bin/ash.wh.ash 标记为删除,/bin/bash 被替换为新版本。
最终构建出的文件系统 只包含一个文件:/bin/bash。这是容器引擎通过顺序应用层,生成出的最终文件系统。

1.3 Layer:生命周期

到目前为止,我们已经了解了以下几点:
  • 容器镜像由一个或多个 层(Layer) 构成;
  • 每一层是如何通过 Containerfile 中的指令生成的;
  • 容器引擎如何将所有层组合起来,构建出容器的最终文件系统。
那么,在你日常使用容器的工作流程中,这一切是如何协同运作的呢?我们来看看一个容器镜像从编写到运行的完整生命周期:
notion image
你使用 podman build 并传入一个 Containerfile,Podman 会根据文件中的每一条指令生成多个 Layer(层),并将这些层打包成一个完整的 容器镜像(container image)
当你使用该镜像运行容器时:
容器引擎会将镜像中的所有 Layer 逐层叠加,最终构建出容器的 根文件系统(root filesystem),并以此为基础启动容器。
这个过程使得容器镜像具备了高度的可移植性、可复用性与层级缓存能力,是现代容器技术的核心机制之一。

1.4 Layer — 构建我们的 Scratch 镜像层

现在我们来为我们的 “hello” 镜像创建一个 Layer。回顾一下,如果我们是通过 Containerfile 来定义这个镜像,它看起来会是这样:

镜像中的两层 Layer

在这个示例中,镜像理论上包含两个 Layer:
  1. 基础层(Base Layer):通常来自一个官方基础镜像,例如 alpine,它包含了完整的 Alpine 根文件系统和 shell 工具;
  1. 变更层(COPY 指令):这是由 COPY ./hello /root/ 创建的 Layer,它将 hello 可执行文件添加到了上一个层的文件系统中。
但由于我们使用的是 FROM scratch,它是一个完全空白的基础镜像,因此 没有基础层,最终镜像只会有 一个单独的 Layer,即添加 hello 文件的那一层。

现在开始动手创建这个 Layer

① 编写并编译静态链接的 C 程序

使用静态链接编译它(以便运行时不依赖外部库):

② 创建 tar 归档,作为镜像层(Layer)

  • -remove-files 会在打包完成后删除源文件;
  • 生成的 layer.tar.gz 就是我们的 变更层:将一个名为 hello 的可执行文件添加到文件系统中的变更。

③ 获取 tar 文件的哈希值(可作为内容寻址 ID)

重命名文件为该哈希值(符合 OCI 镜像规范中的内容寻址方式):

这样我们就成功地创建了一个容器镜像的 Layer,它是一个 gzip 压缩的 tar 归档,包含了一个静态链接的 hello 可执行文件。这就是我们从 scratch 构建镜像的第一步

2. config — 如何运行容器

config 表示如何运行容器。这是一个 JSON 文件,用于存储运行容器所需的各种配置选项,比如:
  • 容器的入口点(Entrypoint)
  • 环境变量(Env)
  • 用户(User)
  • 暴露端口(ExposedPorts)
  • 卷挂载(Volumes) 等等。
这些选项可以在运行容器时通过命令行指定,也可以在 Containerfile 中提前定义——当你使用 podman build 或类似工具构建镜像时,它们会被写入最终生成的 config.json 文件中。

示例配置文件:sample_config.json

这里你可以看到几个典型的配置:
  • 设置容器启动后执行的命令为 ./bin/bash
  • 以用户 danish 身份运行;
  • 暴露端口 8080;
  • 定义环境变量 FOO=bar
  • 声明一个挂载卷 /var/logs

为我们的 "hello" 镜像写配置

我们之前构建的镜像非常简单,只包含一个可执行文件 hello,我们只需要设置它作为入口点就可以了。
内容如下:
解释一下:
  • architectureos 是镜像的目标平台,这里设置为 Linux/amd64;
  • Entrypoint 是容器启动后执行的命令,这里我们先加了一个 time,表示运行时显示耗时;
  • ./hello 是我们之前构建的静态可执行文件。
这个配置已经足够让容器在启动时直接执行 ./hello 程序。由于我们使用的是 scratch 镜像,没有 shell 和工具链,所有配置都必须精简且正确。

3. manifest — 定位层(layers)和 config.json

容器引擎通过 manifest.json 文件来定位容器镜像的各个组成部分,包括:
  • 配置文件(config.json
  • 镜像层(layers)
来看一个典型的 manifest.json 示例片段:

manifest 的作用

这个清单(manifest)文件列出了:
  • 一份 config.json 的元信息(包括媒体类型、sha256 哈希摘要、文件大小);
  • 镜像包含的所有层(每个 layer 都有自己的媒体类型、摘要、大小);
  • 所有内容都是以 digest(摘要)来引用的,而不是路径。

为什么用摘要(digest)而不是文件路径?

这是因为容器镜像遵循的是所谓的 内容寻址存储(content-addressable store)
  • 每个文件(如 config.json 或某一层 layer.tar.gz)都通过其 内容生成的哈希值(如 SHA256) 来标识;
  • 文件名即为其哈希值(例如:36c4...790a);
  • 容器引擎通过清单中的 digest 找到对应的 blob,实现内容校验与复用。

总结一下 manifest 的作用:

  • 告诉容器引擎:这个镜像有哪些层,config 是哪个文件;
  • 每个部分都通过内容哈希精确定位,确保一致性和去重;
  • 是 OCI 镜像结构中不可或缺的索引目录
至此,我们就已经了解了构成 OCI 镜像的三个关键组成部分:

3.1 内容可寻址性(Content Addressability)

为了实现高效性完整性验证,OCI 镜像规范要求镜像中的所有组件都必须通过其内容本身来标识,而不是依赖文件路径或其他位置性引用。

什么是内容可寻址性?

内容可寻址性(Content Addressability)是一种机制,允许你通过内容来识别和定位数据,而不是依赖于文件名或目录结构。也就是说:
如果两个文件的内容完全一样,它们的标识符(例如哈希)也将完全一致。

在 OCI 镜像中如何实现?

在 OCI 镜像中:
  • 所有 blob(包括 layer.tar.gzconfig.json 等)都使用 加密哈希算法(如 SHA256) 生成一个唯一标识符;
  • 这个哈希值既是验证文件完整性的手段,也是其文件名
  • manifest.jsonindex.json 中,各组件通过其 digest(哈希摘要) 来引用;
例如:
这个 digest 对应的文件路径就是:
notion image

内容可寻址性的优势包括:

  • 去重(Deduplication):相同的 Layer 可以被多个镜像共享,无需重复存储;
  • Layer 共享:多个容器可复用相同的 Layer,提高缓存命中率;
  • 降低内存和性能开销:由于无需重复加载相同内容,系统资源使用更高效;
  • 数据完整性保障:通过校验哈希值,可以防止内容被篡改或损坏。

在本文中,我们将使用 SHA256 作为生成组件标识符(digest)的算法。
下面是如何让我们前面创建的 layer 变得“内容可寻址”的操作流程:

为 Layer 归档生成 SHA256 内容摘要

这个哈希值是我们这个 Layer 的唯一标识。

将 Layer 重命名为其 digest

现在,这个 Layer 文件就可以作为一个内容可寻址的 blob 被引用进 manifest 中了。

目录结构:OCI 镜像的组织方式

OCI 镜像规范不仅定义了内容(如 Layer、Config、Manifest),还规定了镜像文件的目录结构,以便容器引擎能够正确地解析和加载镜像。

OCI 镜像标准结构

其中:
  • blobs/ 是所有实际内容的存放目录;
  • <alg> 是你所使用的哈希算法(如 sha256);
  • 所有文件都通过它们的 digest(如 sha256 值)命名;
  • index.json 是容器引擎读取时的入口点,用于定位 manifest。

创建实际目录结构

我们使用的是 sha256,所以我们先创建对应子目录:
然后将前面我们生成的 Layer 移动进去,并使用其 digest 重命名:
接着,为 config.json 生成摘要并移动:
此时目录结构如下:

编写 manifest.json

创建 manifest,并引用 Layer 和 Config 的 digest:
将 manifest 文件也做成可寻址 blob:
最终目录结构如下:

到目前为止,我们已经完成了一个符合 OCI 标准的、未打包的 hello 镜像目录结构:
  • 1 个 Layer(包含静态编译的 hello 程序)
  • 1 个 Config 文件(定义 Entrypoint)
  • 1 个 Manifest 文件(索引 layer 和 config)
  • 全都通过 SHA256 digest 命名和引用
接下来只需创建 index.json,就可以把这个镜像导入到本地容器引擎并运行了。

4. index — 清单的清单

index.json 文件充当可跨越不同架构和操作系统的一组图像的索引。
notion image

index.json — 多架构支持的入口清单

index.json 是一个顶层清单文件,容器引擎会从这里开始读取镜像结构。它的作用是:
  • 支持多个平台架构(如 linux/amd64linux/arm64)的镜像;
  • 每个架构对应一个独立的 manifest.json
  • 即使你只有一个镜像(如本例),index.json 依然是必需的。

创建 index.json(针对单架构)

内容如下:
说明:
  • digest 是我们在前一步生成的 manifest.json 的 SHA256 值;
  • mediaType 表明这是一个标准的 OCI 镜像清单;
  • annotations 用来给镜像打上标签,这里是 hello:scratch
  • index.json 本身不需要哈希命名,所以它直接位于镜像目录的根部。

最终目录结构回顾

各组件的关系总结

文件名(digest)
说明
c37c06...
Layer:包含静态编译的 hello 二进制
99c9d2...
Config:定义了镜像的入口为 ./hello
2e17c9...
Manifest:描述镜像所包含的 config 和 layer
index.json
顶层索引,指向 manifest,可支持多平台镜像

打包镜像(Packing)

现在我们已经完成了构建 OCI 镜像的所有组件,是时候把它们打包成一个归档文件,并测试它是否可以正常加载和运行了!

当前镜像结构如下:


打包为 OCI 镜像归档:

这会将当前目录下的 blobs/index.json 全部打包进 hello.tar 中,成为一个完整的、符合 OCI 标准的镜像归档文件。

使用 Podman 加载镜像:

Podman 成功加载了我们手动构建的镜像,并自动根据 index.json 中的注解给它打上了 hello:scratch 的标签。

运行镜像并测试输出:

我们的 hello 程序正确运行,并将输出打印到 stdout,说明镜像构建是完全成功的!

检查镜像信息:

  • 从零构建了一个符合 OCI 标准的镜像;
  • 编写并打包了所有必要的组件(layer、config、manifest、index);
  • 使用 podman load 成功加载并运行了镜像;
  • 得到了预期输出 Hello, world!
这一过程不仅演示了容器镜像的运行机制,也让你完全掌握了 OCI 镜像的内部结构和构建原理。

使用 Alpine 基础镜像构建 OCI 镜像

在前面的 scratch 版本基础上,我们现在为 hello 容器镜像添加了一个实际的基础层 —— Alpine Linux。这使得镜像拥有两个层(Layers):
  1. Alpine RootFS Layer(基础层):提供标准 Linux 用户空间工具(如 time)。
  1. Hello Binary Layer:添加我们静态编译的 hello 可执行文件。

使用 Alpine RootFS:

我们从 Alpine 官网 下载最小根文件系统,并将其打包重命名为其 SHA256 digest,使其具备内容可寻址性

更新 config.json:

添加 time 工具作为入口点的一部分:
并重命名为它的 digest 以符合内容可寻址要求。

更新 manifest.json:

我们在 layers 中加入两个条目,先是 alpine 层,后是 hello 层(自底向上):
同时更新 config digest 和 size。

更新 index.json:

更新为引用新的 manifest.json 的 digest,并添加注解标签:

打包并测试镜像:

成功加载后,我们可以运行它:
镜像已正常运行,time 工具也如预期地打印了运行耗时。

镜像列表对比:

  • scratch 版本:极简,仅包含可执行文件;
  • alpine 版本:功能更完整,可使用标准 Linux 工具。

🎯 结语(Conclusion)

希望这篇文章帮助你以清晰且易懂的方式深入理解了容器镜像的内部构造与运作机制。
我们一步步从零构建了一个符合 OCI 规范 的镜像:
  • 理解了镜像由 layers、config 和 manifest 构成;
  • 学会了内容可寻址性;
  • 熟悉了镜像目录结构和多架构支持;
  • 完整体验了构建、打包、加载与运行流程。
这些示例虽然不一定会出现在日常开发中,但它们让你掌握了容器镜像背后真正的“魔法”。
  • docker
  • Let's code a TCP/IP stack, 2: IPv4 & ICMPv4Let's code a TCP/IP stack, 1: Ethernet & ARP
    Loading...
    Catalog
    0%