4、Docker镜像原理:联合文件系统(UnionFS)、分层构建、写时复制、镜像仓库与标签

聊到Docker镜像,很多人第一反应就是「镜像嘛,就是个打包好的环境」。嗯,这话没错,但太笼统了。我刚开始用Docker那会儿,也以为镜像就是个ISO文件,后来踩了不少坑才明白——镜像的本质,是一套分层堆叠的文件系统快照

说白了,Docker镜像不是一个大文件,而是一层一层叠起来的「千层饼」。每一层都只记录变化的部分。这个设计,才是Docker能火起来的核心原因之一。

4.1 联合文件系统(UnionFS)——镜像的底层基石

联合文件系统,英文叫UnionFS。它的作用很简单:把多个目录挂载到同一个挂载点,看起来像一个目录

举个例子。你有两个目录:layer1/layer2/。layer1里有个 a.txt,layer2里有个 b.txt。用UnionFS把它们合并挂载到 /merged 下,你就能同时看到 a.txtb.txt

Docker用的就是这种机制。每个镜像层都是一个只读的目录,容器运行时再叠加上一个可写层。你想想看,这多省空间?

核心要点: UnionFS让Docker能做到「共享基础层,差异化上层」。同一个Ubuntu基础镜像,可以被几百个容器共用,每个容器只保存自己修改的那一点点数据。

我在项目中遇到过一个问题:某个微服务镜像构建出来居然有2GB。后来一查,发现是每个构建步骤都复制了完整的JDK,没有利用分层缓存。嗯,这就是不懂UnionFS的代价。

4.2 分层构建——Dockerfile的精髓

Dockerfile里的每一行指令,都会生成一个新的镜像层。比如:

FROM ubuntu:20.04          # 层1:基础层
RUN apt-get update          # 层2:更新包列表
RUN apt-get install -y nginx  # 层3:安装nginx
COPY index.html /var/www/   # 层4:复制文件
CMD ["nginx", "-g", "daemon off;"]  # 层5:元数据层

每一层都是只读的,并且会被缓存。如果你下次构建时,前三行没变,Docker就会直接复用缓存,只重新构建第四层和第五层。这速度,快得飞起。

但这里有个坑——层的顺序很重要。我习惯把「不常变」的指令放在前面,比如安装系统包;把「经常变」的指令放在后面,比如复制代码。这样能最大化利用缓存。

我的习惯: 写Dockerfile时,先写 RUN apt-get install 这类稳定指令,再写 COPY 代码。如果反过来,每次改代码都要重新安装所有包,那构建时间就爆炸了。

4.3 写时复制(Copy-on-Write)——容器启动的秘密

你启动一个容器时,Docker会在镜像层之上,创建一个薄薄的可写层。这个可写层初始是空的。容器里任何写操作——创建文件、修改配置——都发生在这个可写层里。

这就是写时复制(CoW)。不写则不复制,写了才复制

举个例子。你启动100个Nginx容器,它们共享同一个Nginx镜像。每个容器只拥有一个几KB的可写层。如果某个容器修改了 /etc/nginx/nginx.conf,Docker会把那个文件从镜像层复制到可写层,然后修改。其他容器不受影响。

我曾经犯过一个错误:在生产环境里,直接在容器里写日志文件。结果日志越写越大,可写层膨胀到几十GB,容器磁盘爆了。后来我改用挂载卷(volume)来存日志,才解决这个问题。

注意: 不要在可写层里写大量数据!可写层是临时的,容器删除后数据就丢了。而且可写层性能不如挂载卷。日志、数据库文件、上传文件,都应该用volume或bind mount。

4.4 镜像仓库与标签——分发与版本管理

镜像构建好了,得有个地方存着,方便别人拉取。这就是镜像仓库(Registry)干的事。最常用的当然是Docker Hub,但企业内部一般用Harbor或Nexus。

镜像的完整名称格式是:

[仓库地址]/[命名空间]/[镜像名]:[标签]

比如:

docker.io/library/nginx:1.21
myregistry.com/myteam/myapp:v2.0.0

标签(Tag)就是版本号。我建议你不要用 latest 标签。为什么?因为 latest 是动态的,今天拉和明天拉可能不一样。生产环境里,版本必须锁定。

标签策略 说明 推荐场景
v1.0.0 语义化版本 正式发布
v1.0.0-rc.1 候选版本 测试环境
sha-xxxxxx Git提交哈希 CI/CD自动构建
latest 最新版(不推荐生产用) 本地开发

我在团队里推行过一个规则:每个镜像必须打两个标签——一个语义化版本(如 v2.3.1),一个Git提交哈希(如 sha-a1b2c3d)。这样既能方便人类阅读,又能精确定位到代码版本。

4.5 镜像的存储结构——深入看一眼

如果你好奇镜像在磁盘上长什么样,可以看看 /var/lib/docker/overlay2/ 目录。里面是一堆哈希命名的目录,每个目录对应一个镜像层。

/var/lib/docker/overlay2/
├── l/                    # 符号链接,指向各层
├── 3b8f7a2.../           # 层1
│   ├── diff/             # 该层的文件内容
│   └── link              # 指向l/下的链接
├── 9c1e4d5.../           # 层2
│   ├── diff/
│   └── link
└── ...

每个 diff/ 目录里,只保存该层新增或修改的文件。这就是分层构建的物理体现。

我记得有一次排查磁盘空间问题,发现 /var/lib/docker 占了100多GB。用 docker system df 一看,发现大量悬空镜像(dangling images)——就是那些没有标签、没有被任何容器引用的中间层。执行 docker image prune 清理后,瞬间释放了40GB。

日常维护命令:
  • docker system df — 查看Docker磁盘使用情况
  • docker image prune — 清理悬空镜像
  • docker system prune -a — 清理所有未使用的资源(慎用)

4.6 总结一下

Docker镜像的原理,说白了就是三句话:

  • UnionFS 让多层文件系统合并成一个视图
  • 分层构建 让镜像可复用、可缓存、可增量
  • 写时复制 让容器启动快、占用少

再加上镜像仓库和标签,你就有了一个完整的镜像生命周期管理体系。从构建、存储、分发到运行,每一步都有章可循。

嗯,下一章我们会聊聊Docker网络。到时候你会发现,网络这块比镜像复杂多了——但理解了原理,一切都不难。