切换语言
切换主题

Docker构建加速:善用缓存让构建快10倍的实战指南

Docker构建缓存优化示意图

凌晨一点,我盯着终端上的进度条。改了个typo,重新docker build,然后——npm install又开始了。10分钟过去了,我已经刷了20条朋友圈,喝了两杯咖啡,进度条还在那儿转。

这种崩溃感,做过容器化开发的人都懂吧?

但其实,这事儿有解。我花了一周时间研究Docker的缓存机制,把构建时间从10分钟压到了30秒。说实话,第一次看到这个效果时,我愣了好几秒——原来Docker可以这么快。

这篇文章我会跟你聊聊3个立马就能用的技巧:配置.dockerignore理解层缓存原理优化Dockerfile指令顺序。最后还有个进阶的BuildKit缓存挂载,算是锦上添花。

你的构建慢吗?慢的话,接着往下看。

为什么你的Docker构建这么慢?

构建上下文太大了

先说个很多人都踩过的坑:构建上下文(Build Context)。

你知道吗,当你执行docker build .的时候,Docker做的第一件事不是运行Dockerfile,而是把.这个目录下的所有文件打包发给Docker daemon。对,是所有。包括你的node_modules,包括.git文件夹,包括你下载的几百M的测试数据。

我见过最夸张的,一个前端项目的构建上下文有800MB。光是传输这些文件,就要花2-3分钟。而实际上,镜像里真正需要的,可能只有不到10MB的源代码。

这就好比你要快递一本书,结果连书架一起打包了。

层缓存失效的连锁反应

第二个问题:不懂Docker的层缓存机制。

Docker镜像是分层的。Dockerfile里每一条指令——FROM、RUN、COPY——都会创建一层。Docker构建的时候,会检查每一层有没有缓存可用。如果指令完全一样,依赖的文件也没变,就直接用缓存,不用重新执行。

听起来很美好对吧?

但问题是,一旦某一层的缓存失效了,后面所有层都得重新构建。就像多米诺骨牌,推倒第一张,后面全倒。

很多人的Dockerfile是这么写的:

FROM node:18
COPY . /app
WORKDIR /app
RUN npm install

看起来没毛病?其实大问题。

COPY . /app这一行,把整个项目复制进去。你改了任何一个文件——哪怕只是README.md里改了个typo——这层缓存就失效了。然后呢?后面的npm install也得重新跑一遍。

这就是为什么你改一行代码,要重装整个依赖树的原因。

指令顺序不合理

第三个坑:不知道指令该怎么排序。

Docker的缓存策略很简单:从上到下依次检查,一旦失效就停止使用缓存。这意味着,你应该把不常变的指令放前面,常变的放后面

但现实中很多Dockerfile是反着来的:先复制代码(最常变),再安装依赖(不常变)。结果每次改代码,依赖缓存全废。

说白了,就是没搞清楚什么东西变化频繁、什么东西相对稳定。

技巧1 - 配置.dockerignore减小构建上下文

好了,问题摆出来了。先聊聊最简单、立竿见影的优化:.dockerignore。

它是个什么东西?

你用过.gitignore吧?.dockerignore和它是一个意思,告诉Docker哪些文件不用打包进构建上下文。

创建方式超简单:在项目根目录(跟Dockerfile同级)新建一个.dockerignore文件,然后往里写规则就行了。

Node.js项目怎么配置?

直接上代码,这是我自己用的配置:

# 依赖目录
**/node_modules/
**/npm-debug.log
**/.npm

# Git相关
.git/
.gitignore
.gitattributes

# 测试和文档
**/test/
**/tests/
**/docs/
**/*.md
!README.md

# IDE和编辑器
.vscode/
.idea/
*.swp
*.swo
.DS_Store

# 环境变量和配置
.env
.env.*
*.local

# 构建产物
dist/
build/
coverage/

几个要点:

  1. node_modules一定要排除。这东西可能几百MB,而且镜像里会重新安装,不需要从本地复制。
  2. **/前缀匹配所有嵌套目录。比如**/node_modules/会匹配./node_modules/./packages/lib/node_modules/,不会漏掉。
  3. 目录要加斜杠node_modules/表示目录,node_modules表示文件。虽然看起来差不多,但Docker认真得很。

效果有多明显?

我实测了一个Next.js项目:

  • 配置前:构建上下文520MB,传输耗时2分15秒
  • 配置后:构建上下文4.8MB,传输耗时3秒

对,是3秒。一下子省了2分钟。

这还没算上镜像体积的减小——不再把.git和node_modules打进去,最终镜像从1.2GB瘦到了680MB。

常见陷阱

陷阱1: .dockerignore只在构建上下文的根目录生效。如果你的构建命令是docker build -f subfolder/Dockerfile .,那.dockerignore要放在项目根目录,不是subfolder里。

陷阱2: 写成node_modules(没斜杠)可能不生效。加斜杠:node_modules/

陷阱3: 忘记排除.git。.git目录动辄几百MB,却从来不会用到。

技巧2 - 理解并善用Docker层缓存机制

.dockerignore解决了传输速度问题,但核心还是要搞懂缓存怎么工作的。

层缓存到底是怎么回事?

Docker镜像就像千层蛋糕,每一层都是一条Dockerfile指令的执行结果。

比如这个Dockerfile:

FROM node:18          # 层1
RUN apt-get update    # 层2
COPY package.json .   # 层3
RUN npm install       # 层4
COPY . .              # 层5

构建的时候,Docker会逐层检查:

  1. 层1: FROM指令,检查本地有没有node:18镜像。有?用缓存。
  2. 层2: RUN指令,检查指令文本是否一样。一样?用缓存。
  3. 层3: COPY指令,会计算package.json的校验码(checksum)。文件没变?用缓存。
  4. 层4: RUN指令,继续检查。
  5. 层5: 同理。

关键点:一旦某层缓存失效,后面所有层都得重新构建

这就是我之前说的多米诺骨牌效应。你在第3层改了package.json,第4层的npm install和第5层的代码复制都得重跑。

如何判断缓存是否生效?

看构建输出:

Step 3/5 : COPY package.json .
 ---> Using cache
 ---> 3a8f29e7c5b1

看到Using cache就说明用上缓存了。没看到?那就是在重新构建。

你也可以用docker history <镜像ID>查看镜像的层历史,每层的SIZE和创建时间一目了然。

为什么COPY指令特殊?

RUN指令只看命令文本。RUN npm install这条命令,只要文本不变,Docker就认为可以用缓存。

但COPY和ADD不一样。Docker会计算被复制文件的内容校验码,哪怕文件名不变,只要内容变了,缓存就失效。

这个设计很聪明——因为文件内容变了,后续构建步骤可能受影响,不能直接用老缓存。

但也因为这个,COPY . .这种写法特别危险:只要项目里任何文件改了(哪怕是README.md),这层缓存就废了。

技巧3 - 优化Dockerfile指令顺序

懂了缓存原理,接下来就是实战了:怎么写Dockerfile才能最大化利用缓存?

黄金法则:从稳定到易变

核心就一句话:把不常变的指令放前面,把常变的放后面

为什么?因为Docker是从上往下检查缓存的。如果前面的层稳定,后面改来改去也不影响前面的缓存。

具体来说:

  1. 基础镜像 - 几乎不变
  2. 系统依赖 - 偶尔变
  3. 项目依赖 - 有时变
  4. 源代码 - 天天变

按这个顺序排列,就能最大化缓存利用率。

错误示例:先复制代码再装依赖

很多人一开始都这么写:

FROM node:18
WORKDIR /app

# 错误:直接复制整个项目
COPY . .

# 然后安装依赖
RUN npm install

# 启动命令
CMD ["npm", "start"]

问题在哪?你改了源代码,COPY . .这层缓存失效,后面的npm install也得重跑。

结果就是:改一行JS代码,要重装几百个npm包。10分钟又没了。

正确示例:先装依赖再复制代码

优化后的写法:

FROM node:18
WORKDIR /app

# 第一步:只复制依赖文件
COPY package.json package-lock.json ./

# 第二步:安装依赖(这层会被缓存)
RUN npm ci --only=production

# 第三步:复制源代码
COPY . .

# 启动命令
CMD ["npm", "start"]

这样做的好处:

  1. 只要package.json不变,npm ci那层就用缓存。
  2. 你改源代码,只有COPY . .这层失效,依赖安装的缓存还在。
  3. 第二次构建直接跳过npm安装,速度飞起。

我实测过,这个调整能把后续构建时间从7-8分钟压到30秒左右。

其他语言也是一样的道理

Python项目:

FROM python:3.11
WORKDIR /app

# 先复制requirements.txt
COPY requirements.txt .

# 再pip install
RUN pip install --no-cache-dir -r requirements.txt

# 最后复制代码
COPY . .

Go项目:

FROM golang:1.21
WORKDIR /app

# 先复制go.mod和go.sum
COPY go.mod go.sum ./

# 下载依赖
RUN go mod download

# 再复制代码
COPY . .

# 编译
RUN go build -o main .

核心思路都是一样的:把依赖管理文件和源代码分开复制,让依赖安装这一步尽可能复用缓存。

进阶技巧:细粒度COPY

如果项目结构复杂,你甚至可以更细致:

# 先复制不常变的配置文件
COPY .eslintrc.json .prettierrc ./

# 再复制依赖文件
COPY package*.json ./
RUN npm install

# 然后复制公共库(如果有)
COPY ./lib ./lib

# 最后复制业务代码
COPY ./src ./src

这种做法比较少见,但在某些场景下(比如monorepo项目)很有用。

进阶技巧 - BuildKit缓存挂载

前面说的都是基础优化,现在聊个进阶点的:BuildKit的缓存挂载。

BuildKit是啥?

BuildKit是Docker 18.09引入的新构建引擎,比旧引擎快不少,而且支持更强大的缓存功能。

启用方式超简单:

# 临时启用
export DOCKER_BUILDKIT=1
docker build .

# 或者直接在命令前加
DOCKER_BUILDKIT=1 docker build .

如果你的Docker版本够新(19.03+),BuildKit应该默认开启了。不确定的话可以跑docker version确认一下。

缓存挂载是个啥?

前面讲的层缓存有个问题:一旦那层失效了,就得完全重新执行。

比如你改了package.json,加了个新依赖,npm install那层缓存就没了。结果所有包——包括之前已经下载过的——都要重新下载一遍。

缓存挂载就是为了解决这个问题的。它的逻辑是:即使层缓存失效,也能保留包管理器的下载缓存

说白了,就是给包管理器一个持久化的缓存目录,让它跨构建共享。

怎么用?

Node.js项目示例:

FROM node:18
WORKDIR /app

COPY package*.json ./

# 关键在这里:挂载npm缓存目录
RUN --mount=type=cache,target=/root/.npm \
    npm ci --only=production

COPY . .
CMD ["npm", "start"]

--mount=type=cache,target=/root/.npm这段是重点:

  • type=cache表示这是个缓存挂载
  • target=/root/.npm是npm的缓存目录

有了这个,即使package.json变了、层缓存失效了,npm也不用从头下载所有包。它会从/root/.npm里读取已有的缓存,只下载新增或更新的包。

其他包管理器怎么配?

Yarn:

RUN --mount=type=cache,target=/root/.yarn \
    yarn install --frozen-lockfile

pip (Python):

RUN --mount=type=cache,target=/root/.cache/pip \
    pip install -r requirements.txt

apt (系统包):

RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
    apt-get update && apt-get install -y gcc

注意apt那个sharing=locked参数。因为apt需要独占访问它的缓存,所以要加这个参数避免并发构建冲突。

效果怎么样?

我在一个有200+依赖的项目上测试过:

  • 层缓存失效但缓存挂载生效:依赖安装从8分钟降到1分30秒
  • 完全冷启动(没有任何缓存):还是8分钟

也就是说,缓存挂载是层缓存的”二道防线”。层缓存没失效时最快(直接跳过),失效了还有缓存挂载顶着,至少不用完全重新下载。

注意事项

  1. 默认保留时间不长:BuildKit默认会清理超过2天且超过512MB的缓存。如果你在CI/CD环境里用,可能需要调整策略。

  2. 不是所有场景都需要:如果你的项目依赖很少(比如就十几个包),加不加缓存挂载区别不大。

  3. 路径要对:每个包管理器的缓存目录不一样,要查文档确认。

结论

说了这么多,核心就三件事:

第一件事,立即去做:在项目根目录创建.dockerignore文件,把node_modules、.git、测试文件这些排除掉。花不了5分钟,构建上下文能小90%+。

第二件事,今天就改:调整你的Dockerfile指令顺序。先COPY依赖文件,再RUN安装,最后COPY源代码。这个调整能让你的后续构建时间从10分钟降到30秒。

第三件事,有空研究:如果项目依赖很多、更新频繁,可以试试BuildKit的缓存挂载。它在层缓存失效时能救你一命。

我自己的项目做完这三步,构建时间从10分钟压到30秒,镜像体积从1.2GB瘦到680MB。说实话,这个投入产出比算是我见过最高的优化了。

你的Docker构建慢吗?慢的话,不妨照着这篇文章试试看。搞定了记得回来评论区说一声,我挺好奇你能提速多少倍的。

10 分钟阅读 · 发布于: 2025年12月17日 · 修改于: 2025年12月26日

评论

使用 GitHub 账号登录后即可评论

相关文章