第2章:Docker 镜像构建原理:层与缓存
我记得刚学 Docker 那会儿,最让我困惑的就是“镜像层”这个概念。明明一个 1GB 的镜像,为什么下载时只拉了 200MB?为什么改了一行代码,重新构建却要重装整个操作系统?这些问题不搞清楚,后面做空间分析根本无从下手。本章我就把自己踩过的坑和总结的规律一起聊透。
2.1 镜像的本质:一个只读层的堆叠
说白了,Docker 镜像就是一堆只读层的集合。每一层代表一个文件系统的变更——比如安装了一个包、复制了一个文件、设置了一个环境变量。这些层叠在一起,最终形成一个完整的根文件系统。你运行容器时,Docker 会在这些只读层之上再加一个可写层(容器层),所有写操作都在这层发生。
为什么会这样设计?核心原因就两个:
- 节省存储:相同的基础层被多个镜像共享,磁盘上只存一份。
- 加速分发:推送或拉取时只传输变化的层,不重复传已有的。
举个我项目中真实遇到的例子:有段时间公司内部 Jenkins 构建的镜像巨大,每个构建都重新拉一遍基础镜像。后来一查,基础镜像的层根本没变,但构建脚本把整个 /usr/local 复制到了新层里——硬生生多出 800MB。这就要理解层的粒度了。
来看一个典型的 Dockerfile 和它的层结构:
FROM ubuntu:22.04 # 层1:基础OS
RUN apt-get update && apt-get install -y python3 # 层2:安装Python
COPY app.py /app/ # 层3:复制应用代码
CMD ["python3", "/app/app.py"]
上面的镜像一共 4 层(包括基础层)。如果另一个镜像也用 FROM ubuntu:22.04,那么层1就在磁盘上共享一份。这就是基础镜像能节约空间的原因。
2.2 缓存机制:构建加速与空间陷阱
Docker 的构建缓存(Build Cache)依赖于层哈希。每构建完一层,Docker 会计算该层内容的哈希值,并缓存起来。下一次构建时,如果 Dockerfile 指令没变,且前置层哈希一致,就直接复用缓存层。
但这里有坑:缓存有效性是逐层检查的。只要某一层发生变化,它之后的所有层都会失效并重新构建。这就导致很多人在开发时频繁改代码,结果每次都要重装依赖,构建效率极低。
我自己踩过的一个典型场景:
FROM node:18
COPY package*.json ./
RUN npm install
COPY src/ ./
CMD ["node", "index.js"]
这套写法很常见。注意 COPY package*.json ./ 和 RUN npm install 是独立的层。只要 package.json 没变,npm install 层就命中缓存,能省掉几十秒的安装时间。但如果你把 COPY src/ ./ 写在 RUN npm install 前面,那么改 src/ 里的代码会导致 npm install 层也失效——依赖得重装一遍。这就是层顺序的艺术。
缓存的另一面是空间占用。docker build 会在 /var/lib/docker 下存放缓存层,特别是多阶段构建时会产生大量中间层。如果不清理,缓存可能吃掉几十 GB 磁盘。定期执行 docker builder prune 很有必要。我曾在 CI 节点上看到缓存占了 60GB 空间,差点导致构建失败。
2.3 层的可视化与空间分析
你想想看,如果我们能看清楚每个镜像里有哪些层、每层多大,空间分析就好办了。Docker 提供了 docker history 命令,可以列出镜像的层及其大小。
docker history my-image:latest
输出类似:
IMAGE CREATED CREATED BY SIZE
a1b2c3d4e5f6 2 hours ago CMD ["python3" "/app/app.py"] 0B
b2c3d4e5f6a7 2 hours ago COPY app.py /app/ 1.2kB
c3d4e5f6a7b8 2 hours ago RUN apt-get update && apt-get install -y ... 248MB
d4e5f6a7b8c9 2 hours ago FROM ubuntu:22.04 78MB
注意:CREATED BY 只显示构建时记录的命令摘要,不一定是完整的 Dockerfile 指令。另外 SIZE 列显示的都是该层新增的字节数,不是累积大小。要算最终镜像大小,得把所有层大小加起来(不包括共享基础层)。
还有更直观的工具:dive 是我常用的 CLI 工具,它能用树形图展示每一层的文件和空间占用。命令:
dive my-image:latest
它会展示左侧的层列表和右侧的文件浏览器,你可以逐层对比哪些文件被加入、修改或删除。对排查“为什么镜像这么大”特别有用。
| 命令/工具 | 功能 | 适用场景 |
|---|---|---|
docker history |
列出镜像各层的大小和创建命令 | 快速查看层分布 |
dive |
交互式分析层内文件变动 | 深度排查空间浪费 |
docker system df |
查看所有镜像、容器、卷的磁盘占用 | 整体空间审计 |
2.4 本章核心逻辑图
下面这张 SVG 把镜像层、缓存和空间分析的关系串起来了。建议你仔细看一遍,理解各组件之间的数据流向。
2.5 避坑指南 & 实用技巧
- 避免在 RUN 指令中产生不必要的中间文件:比如先下载再删除,删除操作只是把文件标记为“已删除”,但文件内容仍占用空间。正确的做法是在一条 RUN 中完成下载、解压、编译、清理,利用
&&串联。 - 注意 .dockerignore 文件:忘记排除
node_modules、.git、build等目录是导致镜像超标的常见原因。我见过一个镜像因为没写 .dockerignore,把 3GB 的构建产物也 COPY 进去了。 - 使用多阶段构建:编译阶段用完整工具链,运行阶段用精简镜像。最后只拷贝编译产物到运行阶段。这样最终镜像体积能缩小 70% 以上。
- 缓存清理命令:
docker builder prune --all --force会移除所有构建缓存。但注意,如果缓存被其他正在使用的镜像依赖(很少见),清理后镜像本身不受影响,只是下次构建需要重新下载依赖。
docker system prune -a --volumes。这条命令会删除所有未使用的镜像、容器、网络、卷,包括你刚构建好的中间产物。我同事有一次在 CI 服务器上跑这个,导致所有缓存和本地镜像全被干掉,后续构建多花了 40 分钟。