为什么构建一次快,再构建就更慢?
你有没有遇到过这种情况:第一次用 docker build 构建镜像时花了三分钟,改了一行代码再构建,结果又从头开始,还是三分钟?明明只改了一个配置文件,却要把依赖安装、编译步骤全走一遍。这背后其实和容器镜像的构建缓存机制密切相关。
Docker 和其他主流容器构建工具(比如 BuildKit、Podman)都内置了缓存机制,目的是避免重复执行相同的构建层。镜像不是一整个大文件,而是由一层层只读的“层(layer)”叠加而成。每条 Dockerfile 指令都会生成一个新的层。
缓存是怎么工作的?
当你运行 docker build 时,构建引擎会逐行读取 Dockerfile,并检查当前指令对应的层是否已经存在本地缓存中。判断依据是:这一层的输入是否完全一致,包括指令内容、基础镜像、上一层的内容,以及 COPY/ADD 的文件内容。
举个生活中的例子:就像你做三明治,第一步铺面包,第二步抹酱,第三步放生菜。如果某天你换了种酱,那从抹酱这一步开始,后面所有步骤都得重来——因为生菜是放在新酱上的,不能复用之前“抹原酱+放生菜”的组合。
在构建中也一样。假设你的 Dockerfile 是这样的:
FROM node:18
COPY package.json /app/
WORKDIR /app
RUN npm install
COPY . /app
RUN npm run build前四步会形成一个稳定的缓存链。只要 package.json 没变,npm install 这一层就能复用。但一旦你修改了源码,执行 COPY . /app 时,由于文件内容变了,这一层缓存失效,它之后的所有层(比如 build)也都无法使用缓存,必须重新执行。
怎么让缓存更高效?
关键在于“把容易变动的内容往后放”。比如上面的例子,先把不常变的 package.json 单独 COPY 进去并执行安装,再 COPY 其余源码。这样即使你改了 js 文件,也不会触发 npm install 重新运行。
另一个常见问题是挂载或临时文件污染缓存。比如你在构建过程中下载了一些测试数据,或者生成了日志,这些不该进入最终镜像的内容,如果被 COPY 进去了,就会导致缓存频繁失效。可以用 .dockerignore 忽略无关文件,就像 git 的 .gitignore 一样。
.dockerignore 示例:
node_modules
npm-debug.log
*.log
test/
.git多阶段构建也能提升缓存效率
如果你的项目需要编译,比如 Go 或 Rust,可以在第一阶段完成构建,第二阶段只拷贝二进制文件。这样最终镜像小,而且开发阶段调试时,可以单独重建编译层而不影响运行环境。
FROM golang:1.21 AS builder
WORKDIR /app
COPY go.mod .
RUN go mod download
COPY . .
RUN go build -o myapp .
FROM alpine:latest
RUN apk --no-cache add ca-certificates
COPY --from=builder /app/myapp /myapp
ENTRYPOINT ["./myapp"]在这个例子中,只要 go.mod 不变,go mod download 这一步就能命中缓存,极大加快依赖拉取速度。
现代构建器如 BuildKit 还支持更多的缓存优化,比如远程缓存导出导入,可以在 CI/CD 中跨机器复用缓存。通过设置 DOCKER_BUILDKIT=1 并配合 --cache-to 和 --cache-from,可以让团队在不同节点上共享构建成果,避免每次都从零开始。
别小看这几秒几十秒的节省,当你的服务每天构建上百次,缓存带来的效率提升是实打实的——省下的不仅是时间,还有服务器成本和开发者耐心。