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_modulesdist 这类会在容器内重新生成的目录必须排除,否则宿主机的产物混进镜像,还可能和容器内平台不兼容(比如 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——改代码秒出镜像。

更多推荐