第2章:Docker 镜像构建原理:层与缓存

我记得刚学 Docker 那会儿,最让我困惑的就是“镜像层”这个概念。明明一个 1GB 的镜像,为什么下载时只拉了 200MB?为什么改了一行代码,重新构建却要重装整个操作系统?这些问题不搞清楚,后面做空间分析根本无从下手。本章我就把自己踩过的坑和总结的规律一起聊透。

2.1 镜像的本质:一个只读层的堆叠

说白了,Docker 镜像就是一堆只读层的集合。每一层代表一个文件系统的变更——比如安装了一个包、复制了一个文件、设置了一个环境变量。这些层叠在一起,最终形成一个完整的根文件系统。你运行容器时,Docker 会在这些只读层之上再加一个可写层(容器层),所有写操作都在这层发生。

为什么会这样设计?核心原因就两个:

  • 节省存储:相同的基础层被多个镜像共享,磁盘上只存一份。
  • 加速分发:推送或拉取时只传输变化的层,不重复传已有的。

举个我项目中真实遇到的例子:有段时间公司内部 Jenkins 构建的镜像巨大,每个构建都重新拉一遍基础镜像。后来一查,基础镜像的层根本没变,但构建脚本把整个 /usr/local 复制到了新层里——硬生生多出 800MB。这就要理解层的粒度了。

核心要点:每一条 Dockerfile 指令(RUN, COPY, ADD 等)都会生成一个新层。层是镜像空间占用的基本单位。

来看一个典型的 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就在磁盘上共享一份。这就是基础镜像能节约空间的原因。

小心:千万不要在一条 RUN 指令里频繁切换目录、创建删除临时文件 —— 这些操作都会成为永久层的一部分,最终导致镜像臃肿。我曾经见过一个镜像,RUN 了 20 条指令,每层只做了很小的改动,最后镜像比完整包还大 3 倍。

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 层也失效——依赖得重装一遍。这就是层顺序的艺术。

建议:把“变更频率低的指令”放在前面,“变更频率高的指令”放在后面。比如先 COPY package.json,再 RUN 安装依赖,最后 COPY 源代码。

缓存的另一面是空间占用。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 把镜像层、缓存和空间分析的关系串起来了。建议你仔细看一遍,理解各组件之间的数据流向。

Docker 镜像层与缓存核心逻辑 Docker 镜像层构建与缓存机制 基础层 (FROM) ubuntu:22.04 / node:18 依赖安装层 RUN apt-get install 应用代码层 COPY src/ ./ 容器 可写层 构建缓存区 (/var/lib/docker 下的缓存层) 层哈希 层哈希 层哈希 docker history 查看各层大小 dive 交互式层分析 关键原则:变动频率越低→层越靠前→缓存命中率越高→空间浪费越少

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 分钟。