Docker 构建慢如龟速?用层缓存、BuildKit 缓存挂载和 .dockerignore 把时间砍半
Docker 构建慢如龟速?用层缓存、BuildKit 缓存挂载和 .dockerignore 把时间砍半
改一行代码,docker build 却把整个 npm install / pip install / go mod download 重新跑一遍,等上几分钟才出镜像——这是几乎每个团队都遇到过的痛。问题不在网速,而在 Dockerfile 没写对,把缓存全废了。这篇从缓存原理讲起,把三个立竿见影的提速手段——层缓存顺序、BuildKit 缓存挂载、.dockerignore——一次讲清,配可直接抄的 Dockerfile。
先理解:Docker 缓存是怎么失效的
Docker 镜像是分层的,每条 RUN/COPY/ADD 指令生成一层。构建时 Docker 从上往下逐条检查:只要某一层的输入没变,就直接复用缓存;一旦某一层变了,它和它之后的所有层缓存全部作废、重新构建。
关键就是这句「和它之后的所有层」。看这个几乎人人写过的错误 Dockerfile:
# 反面教材:先 COPY 全部代码,再装依赖
FROM node:20
WORKDIR /app
COPY . . # 只要任何一个源码文件变了,这层就失效
RUN npm install # 于是这层也跟着失效,依赖每次都重装
RUN npm run build
CMD ["node", "dist/index.js"]
问题在于 COPY . . 把源码和 package.json 一起拷进去了。你改一行业务代码,COPY . . 这层的输入就变了,它之后的 RUN npm install 缓存也一起作废——明明依赖一个都没动,却每次都要重装。
提速一:按「变动频率」排列指令,把依赖安装往前提
正确做法:变得少的放上面,变得多的放下面。 依赖清单(package.json)变得少,先单独拷进去装依赖;源码变得多,放到依赖装完之后再拷。
FROM node:20
WORKDIR /app
# 先只拷依赖清单,这一层只在 package.json / lock 文件变化时才失效
COPY package.json package-lock.json ./
RUN npm ci # 依赖没变时,这层一直命中缓存,不重装
# 再拷源码,源码天天变,但它在依赖层之后,不影响上面的缓存
COPY . .
RUN npm run build
CMD ["node", "dist/index.js"]
这样一改,只改业务代码时 npm ci 那层始终命中缓存,构建从几分钟降到几秒。同样的模式适用于所有语言:
# Python:先拷 requirements.txt 再装
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
# Go:先拷 go.mod/go.sum 下载依赖,再拷源码
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o app
顺带一提:用 npm ci 而不是 npm install——ci 严格按 lock 文件安装、可复现,更适合构建环境。
提速二:开启 BuildKit,用缓存挂载持久化下载缓存
上面的层缓存解决了「依赖没变时不重装」,但有个场景它救不了:当你确实改了 package.json(加了一个新包),整个 npm ci 还是从零跑,连之前下载过的几百个包都要重新下。
BuildKit 的**缓存挂载(cache mount)**能解决这个:把包管理器的下载缓存目录挂成一个跨构建持久化的卷,即使 RUN 层本身失效重跑,已经下载过的包也直接从缓存拿,不走网络。
先确认用的是 BuildKit(Docker 20.10+ 默认开启;老版本设 DOCKER_BUILDKIT=1)。然后在 Dockerfile 顶部声明语法版本,用 --mount=type=cache:
# syntax=docker/dockerfile:1
FROM node:20
WORKDIR /app
COPY package.json package-lock.json ./
# 把 npm 的缓存目录挂成持久缓存,即使这层重跑,已下载的包直接复用
RUN --mount=type=cache,target=/root/.npm \
npm ci
COPY . .
RUN npm run build
CMD ["node", "dist/index.js"]
顶部那行 # syntax=docker/dockerfile:1 不是注释废话,是启用 BuildKit 增强语法的开关,必须放第一行。
各语言的缓存目录不同,对应挂法:
# Python pip:缓存 ~/.cache/pip
RUN --mount=type=cache,target=/root/.cache/pip \
pip install -r requirements.txt
# Go:缓存 build cache 和 module 下载
RUN --mount=type=cache,target=/root/.cache/go-build \
--mount=type=cache,target=/go/pkg/mod \
go build -o app ./...
# apt(装系统包时):缓存 apt 列表和归档
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt/lists,sharing=locked \
apt-get update && apt-get install -y --no-install-recommends curl
缓存挂载的内容不会进最终镜像,它只在构建期间存在,所以还顺带帮你减小镜像体积。
提速三:用 .dockerignore 把无关文件挡在构建上下文外
执行 docker build . 时,Docker 先把整个当前目录(构建上下文)打包发给 daemon。如果目录里有 node_modules、.git、日志、编译产物,这一步就慢,而且这些文件参与 COPY . . 会拖累缓存、还可能把敏感文件打进镜像。
在项目根目录加 .dockerignore(语法和 .gitignore 一样):
# .dockerignore
node_modules
.git
.gitignore
*.log
dist
build
coverage
.env
.env.*
Dockerfile
.dockerignore
**/__pycache__
*.pyc
几个要点:
node_modules、dist这类会在容器内重新生成的目录必须排除,否则宿主机的产物混进镜像,还可能和容器内平台不兼容(比如 macOS 上装的原生模块拷进 Linux 镜像)。.git通常几十上百 MB,绝对要排除。.env、密钥文件排除,避免机密被COPY . .打进镜像层——镜像层是可以被扒出来的,机密进了层就等于泄露。
整合:一个用满三招的多阶段 Dockerfile
把层缓存、BuildKit 缓存挂载、.dockerignore,再加上多阶段构建(构建产物和运行环境分离,减小体积)整合起来:
# syntax=docker/dockerfile:1
# ---- 构建阶段:装依赖 + 编译 ----
FROM node:20 AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci
COPY . .
RUN npm run build
# ---- 运行阶段:只带产物和生产依赖,镜像更小 ----
FROM node:20-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci --omit=dev
# 只从构建阶段拷编译产物,不带源码和 devDependencies
COPY --from=builder /app/dist ./dist
CMD ["node", "dist/index.js"]
这套下来:改业务代码时依赖层全命中缓存;加了新包时缓存挂载让已下载的包免于重下;.dockerignore 让上下文精简;多阶段让最终镜像只含运行时必需内容。
排查:缓存到底有没有命中
构建时看输出,CACHED 标记表示该层命中了缓存:
=> CACHED [builder 3/6] RUN --mount=type=cache,target=/root/.npm npm ci
=> [builder 4/6] COPY . .
如果本该命中的层没有 CACHED,顺着排查:
- 是不是
COPY的文件真变了?哪怕文件内容没变,文件的元数据(权限、修改时间)在某些情况下也会影响——但通常 Docker 只看内容哈希,元数据不影响,真正常见的原因还是顺序写错了(源码 COPY 在依赖安装前面)。 - 是不是构建上下文里有本该 ignore 的大文件在变?加
.dockerignore。 - CI 环境每次都是全新机器、没有本地层缓存?那要配远程缓存:
docker build --cache-from拉之前推送的镜像做缓存源,或用--mount=type=cache配合 BuildKit 的持久化后端。
小结
- Docker 层缓存的铁律:某层输入一变,它和之后所有层缓存全废。所以把变动少的(依赖清单)放上面、变动多的(源码)放下面,别一上来就
COPY . .。 - 依赖清单和源码分两次 COPY:先拷
package.json/requirements.txt/go.mod装依赖,再拷源码,这样改代码不会触发重装。 - 用 BuildKit 缓存挂载
--mount=type=cache持久化包管理器的下载缓存,连「加了新依赖」时也不用重下老包,记得顶部加# syntax=docker/dockerfile:1。 - 写
.dockerignore排除node_modules、.git、.env,精简上下文、加速传输、防机密泄露。 - 看构建输出的
CACHED标记确认命中;CI 无本地缓存要配--cache-from或远程缓存后端。
一句话记忆:先拷依赖清单装依赖、再拷源码,配 .dockerignore 和 --mount=type=cache——改代码秒出镜像。
更多推荐
所有评论(0)