容器镜像(1):镜像是什么,以及它是怎么存储的

可以把 Docker 镜像想象成一张**光盘**——刻录好之后,里面的内容就不再改变。每次放进光驱,读到的内容都是一样的。把这张光盘"放进光驱"这个动作,对应的就是 `docker run`;而运行起来的那个"播放中的实例",就是容器(Container)。

一、先建立直觉:镜像是一个"只读的文件系统快照"

在真正动手之前,先来建立一个直觉模型。

可以把 Docker 镜像想象成一张光盘——刻录好之后,里面的内容就不再改变。每次放进光驱,读到的内容都是一样的。把这张光盘"放进光驱"这个动作,对应的就是 docker run;而运行起来的那个"播放中的实例",就是容器(Container)。

同一张光盘可以同时放在多台机器的光驱里,互不干扰——对应的是同一个镜像可以同时启动多个容器,每个容器有自己独立的可写空间,彼此隔离。

但光盘的比喻还不够准确,因为 Docker 镜像有一个光盘没有的特性:它是分层的

更精确的比喻是:镜像像一叠透明的胶片。每一层胶片上只记录"相对于下面那层,增加了什么或删除了什么"。把所有胶片叠在一起从上往下看,就得到了完整的文件系统视图。这种设计带来了两个好处:

  1. 共享层:两个镜像如果底层相同(比如都基于 ubuntu:22.04),这些相同的层在磁盘上只存储一份。
  2. 构建缓存:构建镜像时,只要某一层没有变化,就可以直接复用缓存,不必重新构建。

二、镜像的内部结构

我们先把一个镜像拉下来,然后一层层剥开看。

$ docker pull nginx:1.27-alpine
1.27-alpine: Pulling from library/nginx
f18232174bc9: Pull complete   # 第1层:基础 Alpine 文件系统
5ec9f2f0e7cc: Pull complete   # 第2层:添加 nginx 二进制及依赖
694fe30e4b5b: Pull complete   # 第3层:配置文件、入口脚本等
...
Digest: sha256:c15da6c91de8b17bc4e4e7b733b12c28b1a2a7b0fb14c97e67b7eac08d3f5e77
Status: Downloaded newer image for nginx:1.27-alpine
docker.io/library/nginx:1.27-alpine

docker image ls 查看本地镜像:

$ docker image ls nginx
REPOSITORY   TAG           IMAGE ID       CREATED       SIZE
nginx        1.27-alpine   5da12b4bf8f7   2 weeks ago   43.4MB

docker image inspect 可以暴露镜像内部的完整元数据:

$ docker image inspect nginx:1.27-alpine
[
    {
        "Id": "sha256:5da12b4bf8f7e2ed7e9cce4e3af9dfa5f0a9e83efba5bcca48f6b1db6fe9a578",
        "RepoTags": [
            "nginx:1.27-alpine"
        ],
        "RepoDigests": [
            "nginx@sha256:c15da6c91de8b17bc4e4e7b733b12c28b1a2a7b0fb14c97e67b7eac08d3f5e77"
        ],
        "Created": "2025-03-15T10:23:44.123456789Z",
        "Architecture": "amd64",
        "Os": "linux",
        "Size": 45487821,
        "VirtualSize": 45487821,
        "GraphDriver": {
            "Data": {
                "LowerDir": "/var/lib/docker/overlay2/abc123.../diff:/var/lib/docker/overlay2/def456.../diff",
                "MergedDir": "/var/lib/docker/overlay2/ghi789.../merged",
                "UpperDir": "/var/lib/docker/overlay2/ghi789.../diff",
                "WorkDir": "/var/lib/docker/overlay2/ghi789.../work"
            },
            "Name": "overlay2"
        },
        "RootFS": {
            "Type": "layers",
            "Layers": [
                "sha256:1a7bfe56e4a4831b36c241be139a6a9ebfe353e7eb6f3437ccc5e458d27e9bcb",
                "sha256:92a4e81be50c0cacd37cd0bcf27b35c1e1d6b86e9893e1d4bc2dbef3c4efcc22",
                "sha256:d3f9a04e9aac0b8f5bb6d21f4f0e25afce1f0e0a9e65b81f7a4d9e0e77a1e4f"
            ]
        }
    }
]

这里有几个关键字段值得关注:

  • RootFS.Layers:这是镜像的层列表,每一项是一个层的 SHA256 摘要。层按从下到上的顺序排列,最底层是基础系统,越往上越"具体"。
  • GraphDriver:说明 Docker 当前使用的存储驱动是 overlay2,并且列出了这个镜像对应的目录路径。
  • Architecture / Os:镜像是为哪个平台构建的(后续多架构文章会重点讲这一块)。

2.1 镜像到底存了什么:tar 包解剖

docker save 可以把镜像导出成一个 tar 包,让我们直接看它的内容:

$ docker save nginx:1.27-alpine -o nginx-alpine.tar
$ mkdir nginx-inspect && tar xf nginx-alpine.tar -C nginx-inspect
$ ls nginx-inspect/
blobs/  index.json  oci-layout

这就是标准的 OCI Image Layout 格式(后面会详细介绍)。继续往里挖:

$ cat nginx-inspect/index.json | python3 -m json.tool
{
    "schemaVersion": 2,
    "mediaType": "application/vnd.oci.image.index.v1+json",
    "manifests": [
        {
            "mediaType": "application/vnd.oci.image.manifest.v1+json",
            "digest": "sha256:c15da6c91de8b17bc4e4e7b733b12c28b1a2a7b0fb14c97e67b7eac08d3f5e77",
            "size": 1234
        }
    ]
}

index.json 是整个镜像包的入口,它指向一个 Manifest。Manifest 是镜像的"目录":

$ cat nginx-inspect/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
        },
        {
            "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
            "digest": "sha256:92a4e81b...",
            "size": 11234561
        },
        {
            "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
            "digest": "sha256:d3f9a04e...",
            "size": 892341
        }
    ]
}

一个镜像,在存储层面由三类对象组成:

对象 作用
Manifest 镜像的"目录",列出 config 和所有 layer 的摘要及大小
Image Config 镜像的元数据:环境变量、启动命令、构建历史、各层的 diff ID 等
Layer blobs 每一层的实际文件内容,以 tar.gz 格式存储

每一层 blob 是一个 tar 包,里面是该层相对于下一层的文件差异:新增的文件直接包含,删除的文件用一种叫 whiteout 的特殊文件表示(文件名格式为 .wh.<原文件名>)。


三、OCI 规范:镜像格式的统一标准

上面看到的 mediaType 字段里,反复出现了 vnd.oci.image 这个前缀。这里简单交代一下背景。

Docker 早期使用自己的私有镜像格式。2015 年,Docker、CoreOS 等公司联合发起了 OCI(Open Container Initiative),目标是把容器镜像格式和运行时接口标准化,避免厂商锁定。现在主流的容器工具——Docker、containerd、Podman、CRI-O——都遵循 OCI 规范。

OCI 定义了两个核心标准:

  • OCI Image Spec:规定了镜像的存储格式(就是上面看到的 index.json / Manifest / Config / Layer 结构)。
  • OCI Runtime Spec:规定了容器运行时的接口(runc 就是这个规范的参考实现)。

对于本文来说,记住一点就够了:只要符合 OCI Image Spec 的镜像,Docker、containerd、Podman 都能认识并运行,它们用的是同一套"语言"。


四、分层存储的实现:OverlayFS

理解了镜像"是什么"之后,我们来解决"怎么用"的问题:这些分层的 tar 包,是如何变成一个容器可以运行的完整文件系统的?

答案是 OverlayFS——一种 Linux 内核原生支持的联合挂载(Union Mount)文件系统。

4.1 联合挂载的思想

"联合挂载"这个词听起来抽象,用一个例子来理解:

假设你有两个目录:

dir_base/
  ├── a.txt  (内容: "hello")
  └── b.txt  (内容: "world")

dir_patch/
  ├── b.txt  (内容: "docker")   # 覆盖了 base 里的 b.txt
  └── c.txt  (内容: "new file") # 新增文件

把这两个目录"叠加"在一起挂载到 merged/,你看到的会是:

merged/
  ├── a.txt  (来自 base,内容: "hello")
  ├── b.txt  (来自 patch,内容: "docker")  # patch 层的优先级更高
  └── c.txt  (来自 patch,内容: "new file")

这就是联合挂载的核心思想:多个目录叠加后,呈现为一个统一的视图,上层优先

4.2 OverlayFS 的四个目录

OverlayFS 在实现联合挂载时,使用了四个关键目录:

lowerdir   —— 只读层,可以有多个,用冒号分隔,靠左的优先级更高
upperdir   —— 可写层,所有写操作都落在这里
workdir    —— OverlayFS 的工作目录(内核用,不要手动修改)
merged     —— 挂载点,呈现叠加后的统一视图

用一张图来表达这种关系:

┌─────────────────────────────┐
│         merged/             │  ← 你(和容器)看到的视图
│   a.txt  b.txt  c.txt       │
└──────────────┬──────────────┘
               │ overlay 挂载
       ┌───────┴────────┐
       │                │
┌──────┴──────┐  ┌──────┴──────┐
│  upperdir/  │  │  lowerdir/  │  (可以有多层,用冒号叠加)
│  (可写)     │  │  (只读)     │
└─────────────┘  └─────────────┘
      ↑
  容器写入在这里
  镜像层永远不变

4.3 动手挂载一个 OverlayFS

不需要 Docker,用内核原生命令就可以体验 OverlayFS。在我们的演示机上操作:

# 创建工作目录
$ mkdir -p /tmp/overlay-demo/{lower1,lower2,upper,work,merged}

# 在 lower1 层写入基础文件(模拟"操作系统层")
$ echo "I am from base layer" > /tmp/overlay-demo/lower1/base.txt
$ echo "original content"     > /tmp/overlay-demo/lower1/shared.txt

# 在 lower2 层写入(模拟"应用依赖层",叠在 lower1 上面)
$ echo "I am from layer2"     > /tmp/overlay-demo/lower2/layer2.txt
$ echo "overridden content"   > /tmp/overlay-demo/lower2/shared.txt  # 覆盖 lower1 的同名文件

# 执行联合挂载
$ sudo mount -t overlay overlay \
    -o lowerdir=/tmp/overlay-demo/lower2:/tmp/overlay-demo/lower1,\
upperdir=/tmp/overlay-demo/upper,\
workdir=/tmp/overlay-demo/work \
    /tmp/overlay-demo/merged

# 查看合并后的视图
$ ls /tmp/overlay-demo/merged/
base.txt  layer2.txt  shared.txt

$ cat /tmp/overlay-demo/merged/shared.txt
overridden content        # lower2 的内容覆盖了 lower1,符合预期

$ cat /tmp/overlay-demo/merged/base.txt
I am from base layer      # lower1 的内容透过 lower2 正常可见

现在在 merged 目录里写入新内容,看看发生了什么:

# 在合并视图里写入新文件
$ echo "written in container" > /tmp/overlay-demo/merged/new.txt

# 修改一个来自 lower 层的文件
$ echo "modified!" > /tmp/overlay-demo/merged/base.txt

# 检查 upper 层(可写层)
$ ls /tmp/overlay-demo/upper/
base.txt  new.txt     # 只有被写入/修改的文件出现在 upper 层

# lower1 里的原始文件完全没有被动过
$ cat /tmp/overlay-demo/lower1/base.txt
I am from base layer      # 原始内容保持不变

这个行为就是 写时复制(Copy-on-Write,CoW):读操作直接穿透所有层找到文件;写操作先把文件从 lower 层复制一份到 upper 层,再修改 upper 层里的副本,lower 层永远保持只读。

删除一个 lower 层的文件时,OverlayFS 不会真的去修改 lower 层,而是在 upper 层创建一个 whiteout 文件

$ rm /tmp/overlay-demo/merged/layer2.txt

$ ls -la /tmp/overlay-demo/upper/
total 0
-rw-r--r-- 1 root root  9 Apr 15 10:23 base.txt
-rw-r--r-- 1 root root  21 Apr 15 10:23 new.txt
c--------- 1 root root 0, 0 Apr 15 10:24 layer2.txt   # 这是 whiteout 文件(字符设备,主次设备号均为 0)

# 从 merged 视图看,文件已经消失了
$ ls /tmp/overlay-demo/merged/
base.txt  new.txt     # layer2.txt 不见了

# lower2 里的原始文件依然存在
$ ls /tmp/overlay-demo/lower2/
layer2.txt  shared.txt

五、Docker 是如何使用 OverlayFS 的

理解了 OverlayFS 的原理,再来看 Docker 的存储目录,就豁然开朗了。

5.1 Docker 的存储目录布局

Docker 所有的镜像和容器数据默认存放在 /var/lib/docker/。先看顶层结构:

$ sudo ls /var/lib/docker/
buildkit/  containers/  image/  network/  overlay2/  plugins/  runtimes/  swarm/  tmp/  volumes/

最关键的两个目录是 image/overlay2/

$ sudo ls /var/lib/docker/image/overlay2/
distribution/  imagedb/  layerdb/  repositories.json

image/ 目录是 Docker 维护的镜像元数据索引

  • repositories.json:本地镜像名称到 ID 的映射表(nginx:1.27-alpinesha256:5da12b4b...
  • imagedb/content/:每个镜像的 Image Config JSON
  • layerdb/:层的元数据,记录每一层的 diff ID、chain ID、parent 关系
$ sudo cat /var/lib/docker/image/overlay2/repositories.json | python3 -m json.tool
{
    "Repositories": {
        "nginx": {
            "nginx:1.27-alpine": "sha256:5da12b4bf8f7...",
            "nginx@sha256:c15da6c91de8...": "sha256:5da12b4bf8f7..."
        }
    }
}

5.2 overlay2 目录:层的真实存储

/var/lib/docker/overlay2/ 是各层 文件内容 的实际存储位置:

$ sudo ls /var/lib/docker/overlay2/
1a7bfe56e4a4831b.../
92a4e81be50c0cac.../
d3f9a04e9aac0b8f.../
l/                    # 短链接目录(避免 mount 参数超长)

每个子目录对应一个层(目录名是根据 chain ID 派生的哈希):

$ sudo ls /var/lib/docker/overlay2/1a7bfe56.../
diff/   link    lower   work/
  • diff/:该层的实际文件内容(就是 tar 包解压后的结果)
  • link:该层在 l/ 目录中对应的短名称
  • lower:指向父层的链接列表(l/ 短名称,冒号分隔),最底层没有这个文件
  • work/:OverlayFS 的工作目录(只在容器运行时使用)
# 查看 nginx 基础层里有什么文件
$ sudo ls /var/lib/docker/overlay2/1a7bfe56.../diff/
bin/  dev/  etc/  home/  lib/  media/  mnt/  opt/  proc/  root/  run/  sbin/  srv/  sys/  tmp/  usr/  var/
# 这就是 Alpine Linux 的完整文件系统根目录

5.3 容器启动时的 OverlayFS 挂载

当你执行 docker run nginx:1.27-alpine,Docker 会做什么?

$ docker run -d --name my-nginx nginx:1.27-alpine
f3a91c2d0e7b...

# 查看这个容器的目录
$ sudo ls /var/lib/docker/overlay2/ | grep -v "^l$"
# 比刚才多出来一个新目录,这就是容器的可写层
f3a91c2d0e7b8a9e.../

Docker 为这个容器创建了一个新的 overlay2 子目录作为 upperdir(可写层),然后把镜像的所有层作为 lowerdir 叠在下面,执行类似下面的挂载操作:

# Docker 内部实际执行的挂载(简化版)
$ sudo mount -t overlay overlay \
    -o lowerdir=l/ABC:l/DEF:l/GHI,\
       upperdir=/var/lib/docker/overlay2/f3a91c2d.../diff,\
       workdir=/var/lib/docker/overlay2/f3a91c2d.../work \
    /var/lib/docker/overlay2/f3a91c2d.../merged

可以通过 inspect 直接验证这个挂载:

$ docker inspect my-nginx | python3 -c "
import sys, json
data = json.load(sys.stdin)
gd = data[0]['GraphDriver']['Data']
print('LowerDir:', gd['LowerDir'])
print('UpperDir:', gd['UpperDir'])
print('MergedDir:', gd['MergedDir'])
"
LowerDir:  /var/lib/docker/overlay2/d3f9a04e.../diff:\
           /var/lib/docker/overlay2/92a4e81b.../diff:\
           /var/lib/docker/overlay2/1a7bfe56.../diff
UpperDir:  /var/lib/docker/overlay2/f3a91c2d.../diff
MergedDir: /var/lib/docker/overlay2/f3a91c2d.../merged

也可以直接用 mount 命令确认内核实际的挂载情况:

$ mount | grep overlay
overlay on /var/lib/docker/overlay2/f3a91c2d.../merged type overlay
    (rw,relatime,lowerdir=l/ABC:l/DEF:l/GHI,upperdir=.../diff,workdir=.../work)

5.4 容器写入后发生了什么

进入容器,做一些修改,然后观察 upperdir 的变化:

# 进入容器,创建一个文件
$ docker exec my-nginx sh -c "echo 'hello from container' > /tmp/test.txt"
$ docker exec my-nginx sh -c "echo 'custom config' > /etc/nginx/conf.d/custom.conf"

# 在宿主机上查看这个容器的 upperdir
$ sudo ls /var/lib/docker/overlay2/f3a91c2d.../diff/
etc/  tmp/         # 只有被修改过的目录出现在这里

$ sudo ls /var/lib/docker/overlay2/f3a91c2d.../diff/tmp/
test.txt           # 刚才创建的文件在这里

$ sudo ls /var/lib/docker/overlay2/f3a91c2d.../diff/etc/nginx/conf.d/
custom.conf        # 新增的配置文件也在这里

原始镜像层里的 /etc/nginx/conf.d/ 中只有 default.conf,我们新增的 custom.conf 只存在于容器的可写层(upperdir),不影响镜像本身。

删除容器后,这个 upperdir 目录也随之删除,所有写入的内容消失——这正是容器"无状态"特性的来源。如果想要持久化数据,需要使用 Volume 挂载到宿主机目录,但那是另一个话题了。


六、docker commit:把可写层变成新层

既然容器的修改都保存在 upperdir,Docker 允许你把这些修改"固化"成一个新镜像层:

$ docker commit my-nginx nginx-custom:v1
sha256:8b4c2f1a...

$ docker image inspect nginx-custom:v1 | python3 -c "
import sys, json
data = json.load(sys.stdin)
layers = data[0]['RootFS']['Layers']
print(f'层数: {len(layers)}')
for i, l in enumerate(layers):
    print(f'  Layer {i+1}: {l[:20]}...')
"
层数: 4          # 原来 3 层,加上 commit 产生的新层,现在 4 层
  Layer 1: sha256:1a7bfe56e4a483...
  Layer 2: sha256:92a4e81be50c0c...
  Layer 3: sha256:d3f9a04e9aac0b...
  Layer 4: sha256:e5f1a2b3c4d5e6...  # 新增的层,包含我们对 nginx 配置的修改

这也正是 Dockerfile 每条 RUN/COPY/ADD 指令产生一个新层的底层机制:每条指令都会在一个临时容器里执行,执行完后 commit 成新层。


七、用 dive 查看每一层的内容

dive 是一个非常实用的镜像分析工具,可以交互式地查看每一层引入了哪些文件变化:

# 安装 dive(Ubuntu)
$ wget -q https://github.com/wagoodman/dive/releases/download/v0.12.0/dive_0.12.0_linux_amd64.deb
$ sudo dpkg -i dive_0.12.0_linux_amd64.deb

# 分析镜像
$ dive nginx:1.27-alpine

输出会分成左右两栏:左边是各层的摘要和大小,右边是该层的文件树变化(绿色新增、黄色修改、红色删除)。这个工具对于分析"为什么我的镜像这么大"特别有用,可以快速定位哪一层引入了不必要的大文件。


八、小结

我们从最直觉的比喻出发,一路深入到内核文件系统层面,完成了对 Docker 镜像的完整解剖。用一张图来总结整个链路:

镜像仓库(Registry)
       │
       │  docker pull
       ▼
  Layer blobs(tar.gz 文件)
  Image Config(JSON 元数据)
  Manifest(目录索引)
       │
       │  解压 & 组织到
       ▼
/var/lib/docker/overlay2/
  <layer1-hash>/diff/   ← 最底层(如 Alpine 基础系统)
  <layer2-hash>/diff/   ← 第二层(如 nginx 二进制)
  <layer3-hash>/diff/   ← 第三层(如 nginx 配置)
       │
       │  docker run(OverlayFS 联合挂载)
       ▼
  lowerdir = layer3:layer2:layer1(只读,镜像层)
  upperdir = <container-hash>/diff(可写,容器层)
  merged   = 容器看到的完整文件系统视图
       │
       │  容器内的写操作(CoW)
       ▼
  所有写入都落在 upperdir,lower 层永远不变
  docker commit → upperdir 成为新的镜像层
  docker rm     → upperdir 删除,写入数据消失

关键结论:

  • 镜像是由 Manifest、Image Config 和若干 Layer blobs 组成的 OCI 标准对象
  • 每个 Layer blob 是一个 tar 包,记录相对于下层的文件差异(增/删/改)
  • Docker 使用 OverlayFS 将多个只读层和一个可写层联合挂载,呈现给容器一个完整的文件系统视图
  • 写时复制(CoW)机制保证了镜像层的不可变性:修改文件时先复制到可写层,原始层不受影响
  • 这种设计使得层可以在镜像间共享,既节省磁盘空间,又加快了拉取速度

下一篇,我们将把视角切换到 containerd——看看同样的镜像,在这个更底层的运行时里,是如何被组织和管理的,以及 Snapshotter 机制和 Docker 的 overlay2 驱动有什么本质上的联系与区别。

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&

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

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

一、为什么需要了解 containerd 如果你只用 docker run 跑容器,从来不关心底层,那可以不了解 containerd。但如果你在用 Kubernetes,或者想真正理解"容器运行时"是什么,containerd 是绕不开的。 事实上,当你执行 docker run 的时候,containerd 早就在后台悄悄工作了——Docker 从 1.11 版本开始,就把核心运行时剥离出来交给 containerd 负责。 1.1 Docker 的架构演变 早期的 Docker(1.10 及之前)是一个"大一统"的单体程序:一个 dockerd