3、镜像分层机制:UnionFS、写时复制、层缓存原理与优化
还记得我刚开始用 Docker 时,最困惑的就是「镜像为什么能那么小?」后来才知道,全是靠分层搞的。说白了,镜像不是一个大文件,而是一堆只读层的堆叠。每一层只记录差异,最终通过联合文件系统(UnionFS)合并成一个完整视图。这一章我们就聊聊这三个核心概念:UnionFS、写时复制(Copy‑on‑Write)和层缓存,以及怎么在构建实践中用好它们。
3.1 UnionFS:把多个只读层「叠」成一个文件系统
UnionFS 是一种能把多个目录(层)「联合挂载」到同一个挂载点的文件系统。Docker 早期用的 AUFS,后来换成了 overlay2,现在 overlay2 是默认驱动。你可以把镜像的每一层想象成一张透明幻灯片,每张只画了新增或修改的文件。把它们叠在一起,就能看到完整的文件树。
我在生产环境里见过有人还在用 device mapper,结果 I/O 性能差得离谱。后来换成 overlay2,问题就解决了。我个人建议:只要内核 >= 4.0,就用 overlay2。
docker info | grep -i "Storage Driver"
为什么会需要这种机制?你想想看,如果每个镜像都完整复制一份操作系统文件,那一个 Ubuntu 镜像少说几百 MB,从 registry pull 回来岂不是要等半天?分层之后,基础层可以被所有子镜像共享,只有改动的部分才占新空间。
3.2 写时复制(Copy‑on‑Write):容器层「拦截」修改
容器启动时,会在镜像层之上创建一个可写的「容器层」。容器内对文件的任何修改,都发生在这个容器层里。具体原理是这样的:当你要写一个文件,UnionFS 会检查该文件是否存在于最上层。如果不存在,就逐层往下找,找到后复制到最上层(容器层)再修改。这就是写时复制。
我曾经在一个日志聚合项目里踩过坑:容器内不断追加一个很大的日志文件,每次追加都会触发整个文件的复制,磁盘很快就满了。后来我们把日志通过 stdout 输出,由 Docker 日志驱动处理,才避免了复制开销。所以记住一条:不要在容器里频繁写同一个大文件,尤其是要修改已有文件时。
3.3 层缓存原理:Docker Build 为什么那么快?
你每次执行 docker build 时,Docker 会一条一条解析 Dockerfile 的指令。对于每条指令,它会先检查本地有没有缓存:缓存的 key 是「上层镜像的 ID + 当前指令的字符串 + 构建上下文的变化」。如果命中缓存,直接复用这一层,跳过后面的执行。这就是为什么第二次构建通常比第一次快得多。
但缓存也有失效的时候。下面这张表总结了主要的失效原因:
| 缓存失效因素 | 示例 | 影响范围 |
|---|---|---|
| Dockerfile 指令变化 | 把 RUN apt-get update 改成 RUN apt-get update && apt-get install -y curl | 该条及之后所有层 |
| 构建上下文文件变化 | COPY ./src /app 对应的本地文件内容变了 | 仅该 COPY 层及后续层 |
| 父层 ID 改变 | 基础镜像标签没变但摘要变了(例如 ubuntu:22.04 有安全更新) | 所有层 |
| 环境变量带来顺序变化 | ENV VERSION=1.0 被改成 ENV VERSION=2.0 | 仅该 ENV 层及后续层 |
我在一个 CI 流水线里就吃过亏:COPY . /app 这条命令放在 RUN npm install 前面,导致每次代码提交的 SHA 变化都会让 npm install 重新执行。后来我把 package.json 单独先 COPY 一次,install 完再 COPY 其他代码,构建时间从 5 分钟降到了 1 分钟。这就是利用层缓存的典型优化。
RUN 指令中如果有随机数、时间戳或外部 API 调用,会导致缓存永久失效,每次构建都从头跑。尽量把这类操作放到容器启动后的入口脚本里。
3.4 优化实践:让镜像更小、构建更快
结合前面的原理,我们可以总结几条铁律:
- 减少层数:多阶段构建(multi‑stage build)是最实用的方法。把编译环境和运行环境分开,最终产出的镜像只有运行时需要的二进制文件和依赖。
- 合并 RUN 指令:用
&&串起来,避免每一条指令生成一个层。例如:
RUN apt-get update && \
apt-get install -y --no-install-recommends python3 curl && \
rm -rf /var/lib/apt/lists/*
- 把不变的指令放在前面:安装系统依赖、设置时区、添加通用配置文件。变化频繁的代码 COPY 放在后面。
- 利用 .dockerignore:排除缓存、日志、node_modules 等,避免它们导致 COPY 层失效。
我习惯把 Dockerfile 设计成「倒金字塔」结构——越基础、越稳定的指令越靠上,越易变的指令越靠下。这样既能最大化命中缓存,又能在迭代时减少不必要的构建。
docker history --no-trunc <image>
你会看到每一层的大小和创建指令。如果发现某层特别大,那可能就是优化目标。
最后,我们把这些概念画成一张图,方便理解:
docker build --cache-from another-image:tag,还可以引用其他构建的缓存。这在 CI 多阶段构建中特别有用。