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)都完全一致,则直接复用。

下面这张图帮你理清整个逻辑:

Docker 构建缓存流程 构建上下文 .dockerignore 过滤 Dockerfile 指令 逐条检查缓存 缓存命中 ✅ 复用已有层 缓存未命中 ❌ 执行指令生成新层 新层生成 后续指令继续检查缓存 迭代 最终镜像(多层叠加) 仅访问缓存命中层 + 新构建层 💡 关键因素 指令顺序 · 文件变化

缓存失效的核心条件:

  • 指令文本变了(比如 RUN 里的 apt-get install 换了个包名)
  • 指令引用的文件内容变了(COPY 的文件哈希不同)
  • 父层哈希变了(前面某层重新构建,导致下游层全部失效)
  • 使用了 --no-cache 或环境变量 DOCKER_BUILDKIT=0 强制跳过缓存

实战技巧:如何最大化缓存命中率

我总结了一套「缓存友好」的 Dockerfile 编写原则,直接分享给你:

  1. 把不常变的部分放前面。比如基础镜像、系统依赖、全局工具安装。
  2. 把经常变的部分放后面。比如应用源码、配置文件、第三方包。
  3. 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 层比较合适,既享受了缓存红利,又不至于层数过多。