容器镜像(1):镜像是什么,以及它是怎么存储的
可以把 Docker 镜像想象成一张**光盘**——刻录好之后,里面的内容就不再改变。每次放进光驱,读到的内容都是一样的。把这张光盘"放进光驱"这个动作,对应的就是 `docker run`;而运行起来的那个"播放中的实例",就是容器(Container)。
一、先建立直觉:镜像是一个"只读的文件系统快照"
在真正动手之前,先来建立一个直觉模型。
可以把 Docker 镜像想象成一张光盘——刻录好之后,里面的内容就不再改变。每次放进光驱,读到的内容都是一样的。把这张光盘"放进光驱"这个动作,对应的就是 docker run;而运行起来的那个"播放中的实例",就是容器(Container)。
同一张光盘可以同时放在多台机器的光驱里,互不干扰——对应的是同一个镜像可以同时启动多个容器,每个容器有自己独立的可写空间,彼此隔离。
但光盘的比喻还不够准确,因为 Docker 镜像有一个光盘没有的特性:它是分层的。
更精确的比喻是:镜像像一叠透明的胶片。每一层胶片上只记录"相对于下面那层,增加了什么或删除了什么"。把所有胶片叠在一起从上往下看,就得到了完整的文件系统视图。这种设计带来了两个好处:
- 共享层:两个镜像如果底层相同(比如都基于
ubuntu:22.04),这些相同的层在磁盘上只存储一份。 - 构建缓存:构建镜像时,只要某一层没有变化,就可以直接复用缓存,不必重新构建。
二、镜像的内部结构
我们先把一个镜像拉下来,然后一层层剥开看。
$ 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-alpine→sha256:5da12b4b...)imagedb/content/:每个镜像的 Image Config JSONlayerdb/:层的元数据,记录每一层的 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 驱动有什么本质上的联系与区别。