容器镜像(2):containerd 视角下的镜像

一、为什么需要了解 containerd

如果你只用 docker run 跑容器,从来不关心底层,那可以不了解 containerd。但如果你在用 Kubernetes,或者想真正理解"容器运行时"是什么,containerd 是绕不开的。

事实上,当你执行 docker run 的时候,containerd 早就在后台悄悄工作了——Docker 从 1.11 版本开始,就把核心运行时剥离出来交给 containerd 负责。

1.1 Docker 的架构演变

早期的 Docker(1.10 及之前)是一个"大一统"的单体程序:一个 dockerd 进程负责镜像拉取、存储、构建、网络、运行容器的全部工作。这种设计带来了几个问题:

  • 职责混杂:容器运行时的核心功能(创建/运行容器)和上层功能(网络、存储卷、构建)耦合在一起,难以单独维护和替换。
  • Kubernetes 集成困难:Kubernetes 需要一个标准化的容器运行时接口(CRI),而不是一个功能庞杂的 Docker 客户端。
  • 安全隐患dockerd 以 root 运行,权限过大。

从 1.11 开始,Docker 经历了一次架构重构,逐步把组件拆分出来:

Docker 1.10(单体):
┌─────────────────────────────────────────────────────┐
│                      dockerd                        │
│  镜像管理  容器运行  网络  存储卷  构建  API 服务     │
└─────────────────────────────────────────────────────┘

Docker 27.x(分层):
 docker CLI
     │ REST API / Unix socket
     ▼
 dockerd          ← 负责 API、网络、Volume、BuildKit
     │ gRPC
     ▼
 containerd       ← 负责镜像管理、快照、容器生命周期
     │ gRPC
     ▼
 containerd-shim  ← 每个容器一个,负责保持容器进程独立于 containerd
     │
     ▼
 runc             ← OCI 运行时,真正调用 Linux namespace/cgroup 创建容器

可以在机器上验证这个进程树:

$ ps aux | grep -E 'docker|containerd|runc' | grep -v grep
root       1023  0.2  2.1 1823456 87432 ?  Ssl  Mar15  45:23 /usr/bin/dockerd -H fd:// --containerd=/run/containerd/containerd.sock
root       1041  0.1  1.3  729344 53204 ?  Ssl  Mar15  30:11 /usr/bin/containerd
root      28471  0.0  0.1  714456  5632 ?  Sl   10:23   0:00 /usr/bin/containerd-shim-runc-v2 -namespace moby -id f3a91c2d...

dockerdcontainerd 是两个独立进程,dockerd 通过 Unix socket(/run/containerd/containerd.sock)与 containerd 通信。

1.2 整体架构全景图

文字描述容易让人迷失在各组件之间的关系里,下面这张图把本文涉及的所有层次——客户端、dockerd、containerd 内部模块、运行时、Linux 内核,以及镜像仓库——整合在一张图里,后续各节的内容都可以对照这张图来理解:

图中几个值得注意的细节:

  • containerd 内部四个核心模块(Content Store、Metadata DB、Snapshotter、Runtime Plugin)职责完全分离,通过 gRPC 插件接口组合在一起,各自可独立升级替换。
  • 命名空间隔离:Docker 使用 moby 命名空间,Kubernetes 使用 k8s.io 命名空间,两者在同一个 containerd 实例上共存互不干扰,但底层 blob 存储可以共享。
  • Registry 的两条路径:dockerd 和 containerd 都可以独立与 Registry 通信,实线是 dockerd 的路径,虚线是 containerd 直接拉取(ctr pull / nerdctl)的路径。
  • containerd-shim 的位置:它夹在 containerd 和 runc 之间,生命周期独立于两者——即使 containerd 重启,正在运行的容器也不受影响。

1.3 containerd 的职责范围

containerd 是一个工业级的容器运行时,它关注的是容器运行时的核心问题:

  • 镜像管理:拉取、推送、存储镜像(Content Store)
  • 快照管理:把镜像层变成可用的文件系统(Snapshotter)
  • 容器生命周期:创建、启动、暂停、删除容器
  • 任务管理:容器内进程(Task)的管理

负责的事情:网络配置、存储卷管理、镜像构建——这些更高层的功能由 Docker、Kubernetes 或其他上层工具来做。

可以直接使用 containerd 自带的 CLI 工具 ctr 和它交互:

$ ctr version
Client:
  Version:  v1.7.24
  Revision: 0c44b43f3a28d5d1ce4c09c6e84ccea0a3bf7a68
  Go version: go1.22.9

Server:
  Version:  v1.7.24
  Revision: 0c44b43f3a28d5d1ce4c09c6e84ccea0a3bf7a68
  UUID: 9a4f3e12-b71c-4d8a-a193-2f56e0c8dea4

二、Content Store:内容寻址存储

2.1 核心思想:用内容的摘要作为地址

containerd 管理镜像数据的核心机制叫 Content Store,它的设计思想是内容寻址(Content-Addressable Storage)

所谓内容寻址,就是:一个对象的地址由它的内容决定,具体来说就是内容的 SHA256 摘要。sha256:abc123... 既是这个对象的名字,也是验证它完整性的依据——只要摘要对得上,内容就一定没有被篡改。

这有别于传统的"路径寻址"(你得记住文件在 /home/user/nginx/config/ 这个路径下)。内容寻址的好处是:

  • 天然去重:两个镜像共享同一个层,这个层的 tar 包在 Content Store 里只存一份,因为摘要相同。
  • 可验证:拉取时验证摘要,自动保证数据完整性。
  • 原子性:写入是原子操作,要么写完整,要么不写,不会有损坏的中间状态。

2.2 Content Store 的目录结构

containerd 的数据默认存放在 /var/lib/containerd/

$ sudo ls /var/lib/containerd/
io.containerd.content.v1.content/
io.containerd.grpc.v1.cri/
io.containerd.metadata.v1.bolt/
io.containerd.runtime.v1.linux/
io.containerd.runtime.v2.task/
io.containerd.snapshotter.v1.native/
io.containerd.snapshotter.v1.overlayfs/
tmpmounts/

目录名的命名规范遵循插件系统的约定:io.containerd.<插件类型>.<版本>.<插件名>

Content Store 的核心目录是 io.containerd.content.v1.content/

$ sudo ls /var/lib/containerd/io.containerd.content.v1.content/
blobs/  ingest/

$ sudo ls /var/lib/containerd/io.containerd.content.v1.content/blobs/
sha256/

$ sudo ls /var/lib/containerd/io.containerd.content.v1.content/blobs/sha256/ | head -10
1a7bfe56e4a4831b36c241be139a6a9ebfe353e7eb6f3437ccc5e458d27e9bcb
5da12b4bf8f7e2ed7e9cce4e3af9dfa5f0a9e83efba5bcca48f6b1db6fe9a578
92a4e81be50c0cacd37cd0bcf27b35c1e1d6b86e9893e1d4bc2dbef3c4efcc22
c15da6c91de8b17bc4e4e7b733b12c28b1a2a7b0fb14c97e67b7eac08d3f5e77
d3f9a04e9aac0b8f5bb6d21f4f0e25afce1f0e0a9e65b81f7a4d9e0e77a1e4f
...

这个目录里的每一个文件,就是一个 blob——可能是镜像的 Manifest JSON、Image Config JSON,也可能是某一层的 tar.gz 压缩包。文件名就是它内容的 SHA256 摘要,没有扩展名。

ctr content ls 可以列出所有已存储的内容对象:

$ sudo ctr content ls
DIGEST                                                                  SIZE      AGE       LABELS
sha256:1a7bfe56e4a4831b36c241be139a6a9ebfe353e7eb6f3437ccc5e458d27e9bcb 3.384 MB  2 days    containerd.io/gc.ref.content.uncompressed=sha256:adb6...
sha256:5da12b4bf8f7e2ed7e9cce4e3af9dfa5f0a9e83efba5bcca48f6b1db6fe9a578 7.023 kB  2 days    containerd.io/gc.ref.snapshot.overlayfs=sha256:d3f9...
sha256:92a4e81be50c0cacd37cd0bcf27b35c1e1d6b86e9893e1d4bc2dbef3c4efcc22 10.73 MB  2 days    containerd.io/gc.ref.content.uncompressed=sha256:cc4d...
sha256:c15da6c91de8b17bc4e4e7b733b12c28b1a2a7b0fb14c97e67b7eac08d3f5e77 1.234 kB  2 days    containerd.io/distribution.source.docker.io=library/nginx
sha256:d3f9a04e9aac0b8f5bb6d21f4f0e25afce1f0e0a9e65b81f7a4d9e0e77a1e4f 851.4 kB  2days    containerd.io/gc.ref.content.uncompressed=sha256:f1a2...

注意 LABELS 列里有 containerd.io/gc.ref.* 前缀的标签——这是 containerd 的垃圾回收(GC)机制用来追踪对象引用关系的,只要有引用链条指向某个 blob,它就不会被 GC 删除。

直接查看某个 blob 的内容(以 Manifest 为例):

$ sudo cat /var/lib/containerd/io.containerd.content.v1.content/blobs/sha256/c15da6c9... \
  | python3 -m json.tool
{
    "schemaVersion": 2,
    "mediaType": "application/vnd.oci.image.manifest.v1+json",
    "config": {
        "mediaType": "application/vnd.oci.image.config.v1+json",
        "digest": "sha256:5da12b4b...",
        "size": 7023
    },
    "layers": [
        {
            "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
            "digest": "sha256:1a7bfe56...",
            "size": 3548234
        },
        ...
    ]
}

这和上一篇用 docker save 解包看到的格式完全一样——因为它们都遵循同一套 OCI Image Spec。

2.3 ingest 目录:下载进度的临时存放

blobs/ 旁边还有一个 ingest/ 目录:

$ sudo ls /var/lib/containerd/io.containerd.content.v1.content/ingest/
# 平时为空,拉取镜像时会出现临时文件

当 containerd 正在拉取一个层时,数据会先写入 ingest/ 下的临时文件,完成后验证摘要,通过才原子性地移动到 blobs/sha256/ 目录。这保证了 blobs/ 里永远不会出现不完整的文件。


三、Metadata 数据库:连接名字与内容

有了 Content Store,所有数据都能按摘要存取,但用户不可能记住一串 SHA256 哈希值。Content Store 只管存内容,不管"这个内容叫什么名字"。镜像名称(nginx:1.27-alpine)到具体摘要的映射,存放在另一个地方:

$ sudo ls /var/lib/containerd/io.containerd.metadata.v1.bolt/
meta.db

meta.db 是一个 BoltDB 文件(Go 语言生态里常见的嵌入式 KV 数据库)。containerd 在这里维护:

  • 镜像名称 → Manifest 摘要的映射
  • 容器元数据(ID、快照引用、配置)
  • Snapshot 链(后面会详细说)
  • GC 引用关系

ctr images ls 可以查询这个数据库:

$ sudo ctr -n moby images ls
REF                                    TYPE                                              DIGEST                  SIZE      PLATFORMS   LABELS
docker.io/library/nginx:1.27-alpine    application/vnd.oci.image.manifest.v1+json       sha256:c15da6c9...       43.4 MiB  linux/amd64 -

注意这里有一个 -n moby 参数。containerd 使用**命名空间(namespace)**来隔离不同客户端的资源——Docker 使用的命名空间是 moby,Kubernetes 的 containerd 使用的是 k8s.io,直接用 ctr 命令默认进入的是 default 命名空间。

$ sudo ctr namespaces ls
NAME    LABELS
default
moby          # Docker 使用这个命名空间
k8s.io        # Kubernetes 使用这个命名空间(如果同时运行了 k8s)

这意味着 Docker 拉下来的镜像和 ctr pull 拉下来的镜像,默认情况下互不可见,尽管底层的 blob 文件可能是共享存储的(如果摘要相同,Content Store 只存一份)。


四、Snapshotter:从只读层到可用文件系统

Content Store 里存的是压缩的 tar.gz blob,容器运行时需要的是一个可直接挂载的文件系统视图。连接这两者的,是 containerd 的另一个核心机制——Snapshotter

4.1 从 Docker 的做法说起

上一篇我们看到,Docker 的 overlay2 存储驱动直接操作 OverlayFS,把镜像层的 tar 包解压到 /var/lib/docker/overlay2/<hash>/diff/ 目录,然后用 mount -t overlay 把这些目录叠加起来。这个做法直接但不灵活——换一种底层存储(比如 btrfs、ZFS),就要改驱动代码。

containerd 把这个过程抽象成了一个插件接口,叫做 Snapshotter。Snapshotter 的核心职责是:把一个"层链"(从基础层到最顶层的有序列表)变成一个可以挂载使用的文件系统视图,并以可插拔的方式支持不同的底层实现。

4.2 Snapshot 的概念

在 containerd 里,Snapshot 是一个文件系统状态的快照。类比 Git 的概念来理解:

  • 每个镜像层对应一个已提交的快照(committed snapshot),只读,不可修改。
  • 要运行容器时,基于最顶层的镜像层快照创建一个活跃快照(active snapshot),可读写,类似于 Git 的工作区。
  • docker commit 就相当于把工作区的变更提交,活跃快照变成一个新的已提交快照。

快照之间通过 parent 关系形成一条链:

snapshot: sha256:adb6...  (Alpine 基础层,parent: 无)
    └── snapshot: sha256:cc4d...  (nginx 二进制层,parent: adb6)
        └── snapshot: sha256:f1a2...  (nginx 配置层,parent: cc4d)
            └── snapshot: container-f3a91c2d  (容器可写层,parent: f1a2,active)

ctr snapshots ls 查看:

$ sudo ctr -n moby snapshots ls
KEY                                                              PARENT                                                           KIND
sha256:adb6f21df23ddc3b0ffd1e5e7218e5ba31f0a8cc8484a67a0f2a78bc       -                                                                Committed
sha256:cc4d8512a7c98a62b2ab0de4699b9c71cc80ec7ce01b7b32b27ed59c63bc74aa sha256:adb6f21df23ddc3b0ffd1e5e7218e5ba31f0a8cc8484a67a0f2a78bc Committed
sha256:f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2 sha256:cc4d8512a7c98a62b2ab0de4699b9c71cc80ec7ce01b7b32b27ed59c63bc74aa Committed
f3a91c2d0e7b8a9e1c2d3e4f5a6b7c8                                 sha256:f1a2b3c4d5e6...                                             Active

Kind 列:Committed 是只读的镜像层快照,Active 是正在运行的容器的可写快照。

4.3 overlayfs Snapshotter 的目录结构

containerd 默认使用 overlayfs snapshotter,数据存放在:

$ sudo ls /var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/
metadata.db   snapshots/

$ sudo ls /var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/
1/   2/   3/   4/

这里的目录是按整数编号的(1, 2, 3...),和 Docker 用 hash 命名不同:

$ sudo ls /var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/1/
fs/   work/

$ sudo ls /var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/1/fs/
bin/  dev/  etc/  home/  lib/  media/  mnt/  opt/  proc/  root/  run/  sbin/  srv/  sys/  tmp/  usr/  var/
# 这就是 Alpine 基础层解压后的文件系统

每个快照目录里只有 fs/work/

  • fs/:该层解压后的文件内容(对应 Docker 里的 diff/ 目录)
  • work/:OverlayFS 的工作目录(仅 active 快照使用)

snapshotter 自己的 metadata.db(同样是 BoltDB)维护了快照编号与摘要、parent 关系的映射:

snapshot 1  →  sha256:adb6...  (parent: 无)
snapshot 2  →  sha256:cc4d...  (parent: 1)
snapshot 3  →  sha256:f1a2...  (parent: 2)
snapshot 4  →  container-f3a91c2d (parent: 3, active)

当 containerd 要挂载快照 4(容器的文件系统)时,它查询这条 parent 链,构造出 OverlayFS 的参数:

lowerdir = snapshots/3/fs:snapshots/2/fs:snapshots/1/fs
upperdir = snapshots/4/fs
workdir  = snapshots/4/work

本质上和 Docker 的 overlay2 是完全相同的 OverlayFS 挂载操作,差别只在目录组织方式和元数据管理方式上。

4.4 其他 Snapshotter 实现

Snapshotter 是一个插件接口,overlayfs 只是其中一种实现:

$ sudo ctr plugins ls | grep snapshotter
io.containerd.snapshotter.v1  aufs          linux/amd64    skip          # 旧内核,不推荐
io.containerd.snapshotter.v1  btrfs         linux/amd64    error         # 需要 btrfs 文件系统
io.containerd.snapshotter.v1  devmapper     linux/amd64    error         # 需要 device mapper 配置
io.containerd.snapshotter.v1  native        linux/amd64    ok            # 全量复制,无联合挂载
io.containerd.snapshotter.v1  overlayfs     linux/amd64    ok            # 默认,最常用
io.containerd.snapshotter.v1  zfs           linux/amd64    error         # 需要 ZFS 文件系统

native snapshotter 是一个有趣的对比:它不做 OverlayFS 挂载,而是把每一层的内容全量复制到目标目录。这样每个容器都有完整独立的文件系统副本,没有层共享——速度慢、占用空间多,但在不支持 OverlayFS 的环境(比如某些 NFS 挂载的文件系统、CI 环境)里是有效的 fallback。

这种插件化设计正是 containerd 相比 Docker 更灵活的地方:只需要实现 Snapshotter 接口,就可以接入任何底层存储机制,不需要改动 containerd 核心代码。


五、containerd 拉取镜像的完整流程

把上面几节串起来,看一次完整的 ctr images pull 流程:

$ sudo ctr images pull docker.io/library/alpine:3.21
docker.io/library/alpine:3.21:                                            resolved
manifest-sha256:a8560b36e8b8210634f77d9f7f9efd7ffa463e380b75e2e74aff4511df3ef88c: done
layer-sha256:38a8310d387e:                                                done
config-sha256:aded1e1a5b37:                                               done
elapsed: 3.2s                                                             total:   3.9 MiB (1.2 MiB/s)
unpacking linux/amd64 sha256:a8560b36e8b8...:
unpacking layer sha256:38a8310d387e...
done: 891.234ms

containerd 在这背后依次做了六步:

  1. 解析名称:访问 Registry,把 docker.io/library/alpine:3.21 解析为 Manifest 摘要(sha256:a8560b36...)。
  2. 拉取 Manifest:将 Manifest JSON 写入 Content Store(blobs/sha256/a8560b36...)。
  3. 读取 Manifest:从 Manifest 中获取 Image Config 和所有 Layer blob 的摘要列表。
  4. 并发拉取 blobs:Config 和各 Layer tar.gz 先落入 ingest/ 临时目录,完整写入后验证摘要,通过才原子移动到 blobs/sha256/
  5. unpacking:把每个 Layer blob 解压到 Snapshotter 的 fs/ 目录,并在 metadata.db 里建立快照链。
  6. 注册镜像名:在 meta.db 里写入 docker.io/library/alpine:3.21 → sha256:a8560b36... 的映射关系。

值得注意的是,步骤 4 和步骤 5 刻意分离:Content Store 负责原始压缩 blob,Snapshotter 负责解压后的可用文件树,两者各自独立存储,都会占用磁盘空间。执行 ctr content rm 只删压缩包,解压后的快照仍在;ctr images rm 删除的是元数据引用,要等 GC 运行后才会真正清理两边的数据。


六、Docker 与 containerd 的存储架构对比

前面几节分别介绍了 containerd 的三个核心组件。接下来把视角拉回来,把 Docker 和 containerd 放在一起,看清楚两者在存储分工上的真实关系——这也是实际使用中最容易产生困惑的地方。

6.1 架构层次与分工边界

"Docker 底层用 containerd"这句话只说对了一半,容易造成误解。准确的说法是:Docker 把容器的进程生命周期委托给了 containerd,但文件系统的准备工作(镜像解压、OverlayFS 挂载)仍然由 dockerd 自己的 overlay2 存储驱动完成,containerd 的 snapshotter 在默认配置下根本不参与

┌─────────────────────────────────────────────────────────────┐
│                         dockerd                             │
│                                                             │
│  ┌──────────────────────────────────┐                       │
│  │  overlay2 存储驱动(自管)        │  ← containerd 的      │
│  │  /var/lib/docker/overlay2/       │    snapshotter        │
│  │  • 镜像层解压(diff/)            │    完全不参与          │
│  │  • OverlayFS 挂载(merged/)      │                       │
│  │  • 容器可写层(upperdir/)        │                       │
│  └──────────────┬───────────────────┘                       │
│                 │ rootfs 就绪后,把 bundle path 交给 containerd │
└─────────────────┼───────────────────────────────────────────┘
                  │ gRPC: NewTask(bundle_path)
                  ▼
┌─────────────────────────────────────────────────────────────┐
│           containerd(moby 命名空间)                        │
│  只负责:启动 shim → 调 runc → 管理进程生命周期              │
│  不碰:文件系统、镜像层、OverlayFS 挂载                      │
└─────────────────────────────────────────────────────────────┘

直接使用 containerd(nerdctl / Kubernetes)时,两个层次才合并:

  nerdctl / kubelet
       │ gRPC
       ▼
  containerd
       │ 同时负责镜像拉取、Content Store、Snapshotter、进程生命周期
       ▼
  /var/lib/containerd/   ← 全部数据在这里

6.2 容器启动时 Docker 实际做了什么

docker run nginx 为例,文件系统的准备全程在 dockerd 内部完成,containerd snapshotter 不参与其中。

第一步,dockerd 调用自己的 overlay2 驱动,将镜像层和容器可写层挂载为一个完整的 rootfs:

mount -t overlay overlay \
  -o lowerdir=/var/lib/docker/overlay2/<layer3>/diff:...,
     upperdir=/var/lib/docker/overlay2/<containerID>/diff,
     workdir=/var/lib/docker/overlay2/<containerID>/work \
  /var/lib/docker/overlay2/<containerID>/merged

第二步,dockerd 以这个 merged/ 目录为根,生成符合 OCI 规范的 bundle(rootfs + config.json),bundle 临时落在 /run/docker/containerd/daemon/.../moby/<containerID>/

第三步,dockerd 通过 gRPC 调用 containerd,把 bundle 路径交过去:

NewContainer(id, spec)
NewTask(id, bundle_path)   # containerd 拿到的是"已就绪的 rootfs 路径"

第四步,containerd 启动 containerd-shim,shim 调用 runc,runc 用现成的 rootfs 和 config.json 创建容器进程。至此 containerd 完成了自己的职责——进程管理,文件系统从头到尾没有经过 containerd 的 snapshotter。

运行中的容器,其 OverlayFS 挂载路径因此是 Docker 的路径,而非 containerd 的路径:

# 查看运行中容器的 OverlayFS 挂载(路径在 Docker 的目录下)
$ docker inspect my-nginx --format '{{.GraphDriver.Data.MergedDir}}'
/var/lib/docker/overlay2/f3a91c2d.../merged

# mount 命令也能直接看到两套独立的挂载并存:
$ mount | grep overlay
# Docker 管理的(路径在 /var/lib/docker/)
overlay on /var/lib/docker/overlay2/f3a91c2d.../merged type overlay
    (lowerdir=/var/lib/docker/overlay2/l/ABC:..,
     upperdir=/var/lib/docker/overlay2/f3a91c2d.../diff,
     workdir=/var/lib/docker/overlay2/f3a91c2d.../work)

# containerd 管理的(路径在 /var/lib/containerd/,供 nerdctl/k8s 使用)
overlay on /var/lib/containerd/.../snapshots/4/fs type overlay
    (lowerdir=.../snapshots/3/fs:.../snapshots/2/fs:.../snapshots/1/fs,
     upperdir=.../snapshots/4/fs,
     workdir=.../snapshots/4/work)

两者都调用同一个内核的 OverlayFS 模块,但由两个完全独立的用户态管理器负责 mount/umount,互不感知。

6.3 何时才真正统一:containerd image store

Docker 23.0+ 引入了 containerd image store 特性(默认关闭),开启后 dockerd 才真正放弃自己的 overlay2 驱动,把镜像解压和 OverlayFS 挂载也委托给 containerd 的 snapshotter:

// /etc/docker/daemon.json
{
  "features": {
    "containerd-snapshotter": true
  }
}

开启前后的对比:

默认配置 开启 containerd image store
镜像层路径 /var/lib/docker/overlay2/ /var/lib/containerd/.../snapshots/
blob 存储 Docker 自管,解压后丢弃 containerd Content Store
OverlayFS 谁挂载 dockerd 的 overlay2 驱动 containerd snapshotter
容器 merged 路径 /var/lib/docker/overlay2/<id>/merged /var/lib/containerd/.../snapshots/<id>/fs
与 k8s 共享镜像层

这也是为什么这个特性叫"containerd image store"——它让 Docker 把存储层完全下沉给 containerd,而不是像以前一样只借用 containerd 来跑进程。

6.4 镜像存储目录对比

维度 Docker(dockerd 层) containerd
根目录 /var/lib/docker/ /var/lib/containerd/
镜像元数据 image/overlay2/imagedb/ io.containerd.metadata.v1.bolt/meta.db
Layer 内容 overlay2/<hash>/diff/ io.containerd.snapshotter.v1.overlayfs/snapshots/<id>/fs/
目录命名 基于 ChainID 的 SHA256 自增整数 ID
原始 blob 无单独存储 io.containerd.content.v1.content/blobs/sha256/
元数据格式 文件系统(JSON 文件) BoltDB
命名空间 无,单租户 有,多租户隔离

6.5 最重要的区别:blob 的存储策略

Docker 的 overlay2 存储驱动在拉取时就解压,只保留解压后的文件树(diff/ 目录),不保留原始的压缩 tar 包。节省了存储空间,但无法快速"重新解压"或验证原始内容。

containerd 则同时保留压缩 blob(Content Store)和解压后的快照(Snapshotter)。这多占了一份空间,但带来了几个好处:

  • 跨命名空间共享:对于直接使用 containerd 的客户端(nerdctl、Kubernetes),同一个层的 blob 在 Content Store 里只存一份,defaultk8s.io 命名空间共享。
  • 懒加载支持:如 stargz snapshotter,可以按需拉取层内容,不必一次性下载完整镜像。
  • GC 更清晰:通过引用计数精确判断哪些数据可以安全删除。

这里需要特别说明的是,跨命名空间共享有一个前提:客户端必须把 blob 写入 containerd 的 Content Store。默认配置的 Docker 不满足这个前提——dockerd 自己管理镜像内容,拉取时把 blob 解压后存入 /var/lib/docker/overlay2/,压缩包直接丢弃,始终不写进 containerd 的 Content Store。所以默认 Docker(moby 命名空间)与 Kubernetes(k8s.io 命名空间)之间没有 blob 共享,两套镜像数据完全独立。

只有在 daemon.json 中开启 Docker 23.0+ 引入的 containerd image store 特性后,dockerd 才真正把镜像内容委托给 containerd,moby 命名空间的 blob 才与其他命名空间共享同一份 Content Store(具体配置见 6.3 节)。

6.6 验证默认 Docker 与 containerd 的存储隔离

我们通过实际操作来验证上面的结论:默认配置下,Docker 的镜像内容和 containerd 的 Content Store 是两套独立的存储。

# Docker 拉取 alpine:3.21
$ docker pull alpine:3.21
3.21: Pulling from library/alpine
38a8310d387e: Pull complete
Digest: sha256:a8560b36e8b8...
Status: Downloaded newer image for alpine:3.21

# ── 检查 Docker 自己的存储 ──────────────────────────────────
$ docker image inspect alpine:3.21 --format '{{.GraphDriver.Name}}'
overlay2     # Docker 用自己的 overlay2 驱动管理

# 镜像层解压在这里,没有压缩 blob
$ sudo ls /var/lib/docker/overlay2/ | head -3
3f8a1b2c4d...   # 解压后的 diff/ 目录,不是 tar.gz

# ── 检查 containerd 的 Content Store ────────────────────────
# ctr -n moby 能看到镜像名称(dockerd 注册的元数据引用)
$ sudo ctr -n moby images ls | grep alpine
docker.io/library/alpine:3.21  ...  sha256:a8560b36...  7.8 MiB  linux/amd64

# 但 Content Store 里找不到对应的 blob 文件
$ sudo ls /var/lib/containerd/io.containerd.content.v1.content/blobs/sha256/ | grep a8560b36
# (无输出)—— blob 根本不在这里

# ── 对比:nerdctl 拉取的镜像,blob 才真正进入 Content Store ──
$ sudo nerdctl pull alpine:3.21
$ sudo ls /var/lib/containerd/io.containerd.content.v1.content/blobs/sha256/ | grep a8560b36
a8560b36e8b8210634f77d9f7f9efd7ffa463e380b75e2e74aff4511df3ef88c   # ← 这才是真正的 blob

可以看到,ctr -n moby images ls 能列出 Docker 镜像,是因为 dockerd 在 containerd 元数据库里登记了镜像名称(用于容器 snapshot 的关联),但 blob 文件本体从未写入 Content Store,仍在 Docker 自己的 /var/lib/docker/ 下。通过 nerdctl 拉取的同一个镜像,blob 才真正出现在 Content Store 里,并可与 Kubernetes 的 k8s.io 命名空间共享。

各客户端与 Content Store 的关系汇总如下:

客户端 命名空间 blob 是否进入 containerd Content Store
docker(默认) moby ✗ 否,存在 /var/lib/docker/
docker(开启 containerd image store) moby ✓ 是
nerdctl default ✓ 是
kubectl / kubelet k8s.io ✓ 是
ctr default ✓ 是

七、nerdctl:Docker 兼容的 containerd CLI

ctr 是 containerd 的原生 CLI,接口设计偏底层,日常使用并不方便。nerdctl 是一个行为和 docker CLI 几乎完全兼容的 containerd 前端,是直接操作 containerd 的推荐工具:

# 安装 nerdctl(需要同时安装 CNI 插件才能支持网络)
$ wget https://github.com/containerd/nerdctl/releases/download/v2.0.3/nerdctl-full-2.0.3-linux-amd64.tar.gz
$ sudo tar Cxzvf /usr/local nerdctl-full-2.0.3-linux-amd64.tar.gz

# 用法与 docker 几乎一致
$ sudo nerdctl pull nginx:1.27-alpine
$ sudo nerdctl images
$ sudo nerdctl run -d --name test-nginx -p 8080:80 nginx:1.27-alpine
$ sudo nerdctl ps
$ sudo nerdctl inspect test-nginx

nerdctl 默认使用 default 命名空间,所以它管理的镜像和容器与 Docker(moby 命名空间)互相独立,不会混淆。


八、小结

这一篇从 containerd 的架构定位出发,系统梳理了它在镜像存储上的设计:

Content Store 是基于内容寻址的 blob 存储仓库。所有原始数据(Manifest、Config、Layer tar 包)按 SHA256 摘要存储于 blobs/sha256/ 目录下,天然去重、可验证。

Metadata 数据库(BoltDB)负责维护"名字到内容"的映射,以及多租户的命名空间隔离——Docker 用 moby,Kubernetes 用 k8s.io,互不干扰。命名空间之间的 blob 共享仅对直接使用 containerd 的客户端有效,默认配置的 Docker 不在此列(详见第六节)。

Snapshotter 是一个可插拔接口,负责把只读的镜像层快照和可写的容器快照组织成可挂载的文件系统。默认的 overlayfs 实现在底层仍然使用 OverlayFS,与 Docker 本质相同,差异在于目录组织方式(自增整数 ID vs ChainID hash)和元数据管理方式(BoltDB vs 文件系统 JSON)。

Docker 和 containerd 最根本的区别在于存储策略:Docker 只保留解压后的文件树,containerd 同时保留压缩 blob 和解压快照。containerd 的方式更占空间,但为懒加载、跨命名空间共享等高级特性提供了基础。

containerd 的完整存储布局(以 nginx:1.27-alpine 为例):

/var/lib/containerd/
│
├── io.containerd.content.v1.content/
│   └── blobs/sha256/
│       ├── c15da6c9...   ← Manifest JSON(入口)
│       ├── 5da12b4b...   ← Image Config JSON
│       ├── 1a7bfe56...   ← Layer 1 tar.gz(Alpine 基础系统)
│       ├── 92a4e81b...   ← Layer 2 tar.gz(nginx 二进制)
│       └── d3f9a04e...   ← Layer 3 tar.gz(nginx 配置)
│
├── io.containerd.metadata.v1.bolt/
│   └── meta.db           ← 镜像名称索引 + 快照链 + GC 引用图
│
└── io.containerd.snapshotter.v1.overlayfs/
    ├── metadata.db       ← 快照编号 ↔ 摘要/parent 映射
    └── snapshots/
        ├── 1/fs/         ← Layer 1 解压后的文件树(Alpine)
        ├── 2/fs/         ← Layer 2 解压后的文件树(nginx 二进制)
        ├── 3/fs/         ← Layer 3 解压后的文件树(nginx 配置)
        └── 4/fs/         ← 容器可写层(active snapshot,运行时创建)
            work/

下一篇,我们把目光从存储转向分发,引入多架构镜像(Multi-platform Image)的概念——一个 docker pull nginx 背后,可能有十几个不同 CPU 架构的镜像,客户端是怎么自动选择正确的那个的?这个机制的本质,和我们将用 Docker buildx 与 Podman 分别构建一个 Golang 项目的多架构镜像,是下一篇的主题。

Read more

容器镜像(4):镜像的常用工具箱

容器镜像(4):镜像的常用工具箱

前几篇在讲多架构镜像时已经用过 skopeo 和 crane 做镜像复制,这篇系统整理这两个工具的完整能力,同时介绍几个日常操作镜像时同样好用的工具。 一、skopeo:不依赖 Daemon 的镜像瑞士军刀 skopeo 的核心价值是绕过 Docker daemon,直接与 Registry API 交互。上一篇用它做镜像复制和离线传输,但它的能力远不止于此。 1.1 安装 # Ubuntu / Debian sudo apt install -y skopeo skopeo --version # skopeo version 1.15.1 1.2 inspect:免拉取检查镜像元数据 docker inspect 需要先把镜像拉到本地,skopeo inspect 直接向 Registry

容器镜像(3):多架构镜像构建

容器镜像(3):多架构镜像构建

一、什么是多架构镜像 1.1 OCI Image Index 上一篇介绍了单平台镜像的结构:一个 Manifest 指向 Config 和若干 Layer blob。多架构镜像在此之上多了一层——OCI Image Index(也叫 Manifest List),是一个轻量的索引文件,把多个单平台 Manifest 组织在一起: $ docker manifest inspect golang:1.22-alpine { "schemaVersion": 2, "mediaType": "application/vnd.oci.image.index.v1+json", "manifests&