Docker 缓存层:镜像分层原理、构建上下文、缓存复用
说实话,我最早接触 Docker 时,最头疼的就是构建速度。每次改一行代码,都要等好几分钟重新 build。后来我才明白 —— 问题出在缓存上。Docker 不是从头到尾重新执行每一行指令,而是利用镜像分层机制来复用已构建的层。搞懂这个机制,你的构建时间能砍掉 80%。
镜像分层原理 —— 就像乐高积木
Docker 镜像不是铁板一块,它由多个只读层叠加而成。每一层对应 Dockerfile 里的一条指令(除了 FROM 和 ARG)。这些层用 UnionFS 联合挂载,对外看起来就像一个完整的文件系统。
举个例子:
FROM ubuntu:22.04 # 层 0
RUN apt-get update && apt-get install -y python3 # 层 1
COPY app.py /app/ # 层 2
CMD ["python3", "/app/app.py"] # 不产生层,仅元数据
每条 RUN、COPY、ADD 都会生成一个新层。层与层之间共享父层文件,但每个层只记录自己的变更(增、删、改)。嗯,这里要注意:如果你在 RUN 里删了一个文件,并不是真的擦掉物理数据,而是生成一个 "whiteout" 标记,覆盖掉下一层的文件。所以层数多了,镜像反而更大。
我在项目中遇到过一种情况:把 apt-get install 和 apt-get clean 分成了两个 RUN 指令。结果呢?前一层下载的 .deb 文件还在,第二层只是删了缓存,但 .deb 残留导致镜像体积暴增。后来我养成习惯,把清理工作放到同一个 RUN 里:
RUN apt-get update && apt-get install -y python3 \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
这看起来是小事,但能少一层,缓存也更高效。
构建上下文 —— 别把整个项目目录带进去
执行 docker build 时,你指定的那个目录就是构建上下文(Build Context)。Docker 守护进程会把整个上下文打包发送给构建引擎。你想想看,如果你项目目录里有 5GB 的 node_modules 或 .git 文件夹,每次构建都得上传这些垃圾,缓存也白搭。
我的习惯是:
- 在项目根目录放一个
.dockerignore文件,把不需要的文件排除掉。 - 只把真正需要 COPY 的文件留在上下文中。
举个例子:
# .dockerignore
.git
node_modules
*.log
dist/
.vscode
我踩过一个很深的坑:有一次把 COPY . /app 写在 Dockerfile 最前面,而且忘了加 .dockerignore。结果每次改一行代码,整个项目目录的哈希都变了,所有后续层缓存全部失效。构建时间从 30 秒飙升到 8 分钟。嗯,从那以后我就把 COPY 指令的顺序放到了最讲究的位置。
缓存复用机制 —— 匹配规则与失效条件
Docker 构建缓存是如何命中的?它对比当前指令和之前构建的层,如果指令文本、基础镜像、父层哈希、以及指令所涉及的文件(对于 COPY/ADD)都完全一致,则直接复用。
下面这张图帮你理清整个逻辑:
缓存失效的核心条件:
- 指令文本变了(比如 RUN 里的 apt-get install 换了个包名)
- 指令引用的文件内容变了(COPY 的文件哈希不同)
- 父层哈希变了(前面某层重新构建,导致下游层全部失效)
- 使用了
--no-cache或环境变量DOCKER_BUILDKIT=0强制跳过缓存
实战技巧:如何最大化缓存命中率
我总结了一套「缓存友好」的 Dockerfile 编写原则,直接分享给你:
- 把不常变的部分放前面。比如基础镜像、系统依赖、全局工具安装。
- 把经常变的部分放后面。比如应用源码、配置文件、第三方包。
- COPY 指令尽量精确。不要用
COPY . /app,而是逐层拷贝:先拷贝 package.json 等依赖文件,安装完依赖后再拷贝其余代码。
看看这个优化前后的例子:
不推荐(每次改代码都得重新安装依赖):
COPY . /app
RUN npm install # 缓存几乎永远不命中
推荐(缓存高效):
COPY package.json /app/
RUN npm install # 只有 package.json 变化才重新安装
COPY src/ /app/src/ # 代码变化不影响依赖层
我记得有一次优化前端项目,把 npm install 单独抽出来,构建时间从 3 分钟降到了 12 秒。team 里的小伙伴都惊呆了。
💡 小提示: 使用 docker build --cache-from 可以从远程仓库或之前构建中拉取缓存层。配合多阶段构建效果更佳,比如只把编译后的产物拷贝到最终镜像,中间层缓存可以复用。
⚠️ 曾经我犯过的错:
我曾经把 ARG 放在 RUN 之后,导致每次构建都因为 ARG 默认值变化而缓存失效。记住:ARG 的生效范围会影响它后面所有层,而且 ARG 本身也会影响缓存(如果改变了值,后续层失效)。解决方案:把 ARG 尽量写在前面,或者用 --build-arg 时只传递真正需要的变量。
常见指令的缓存行为一览
| 指令 | 缓存判定依据 | 注意事项 |
|---|---|---|
| FROM | 镜像名 + digest | 本地镜像存在且 tag 相同则命中 |
| RUN | 指令文本 + 父层哈希 | 命令里如果有随机值或时间戳,永远不命中 |
| COPY / ADD | 源文件内容(计算哈希) | 文件元数据(权限、修改时间)会影响哈希 |
| ENV | 变量名 + 值 | 仅影响本层及其下游 |
| WORKDIR | 路径文本 | 不影响文件内容,只影响上下文路径 |
说白了,缓存优化的核心就是「把闪电分开放,把石头留在前面」。闪电是经常变的代码,石头是几乎不变的系统环境。我在指导团队写 Dockerfile 时,总是强调一句:问自己一句话 —— 这条指令要是不缓存,后面还会受影响吗? 如果答案是肯定的,就把它往前挪。
最后提醒一点:缓存不是银弹。有时候你为了提高缓存命中率,硬是把所有步骤塞进一个 RUN 里,导致镜像层数过少、Docker 无法细粒度复用。适度分层才是最佳实践。嗯,我的经验是一般控制在 8~15 层比较合适,既享受了缓存红利,又不至于层数过多。