每位开发者都经历过这种情况:你提交了一个只需一行代码的修复方案,然后去喝咖啡等待结果。然而十二分钟后,Docker镜像却因为缓存系统再次出现故障而不得不从头开始重建。
去年有一段时间,我一直在帮助多个团队解决Docker构建速度缓慢的问题。这些问题的模式始终如一:本应只需要两分钟就能完成的构建过程,最终会耗时十五分钟,而且没有人知道原因何在。但一旦我弄清楚了底层机制,问题背后的原因其实相当明显。
本指南将一步步指导你如何解决Docker构建速度缓慢的问题。我们会首先解释缓存系统的工作原理,接着分析最常见的错误所在,最后提供一些可以直接应用到你的项目中的解决方案。
目录
先决条件
要顺利完成学习,您需要具备以下条件:
-
已安装并可以正常使用的 Docker(Docker Desktop 或 Docker Engine 20.10 及更高版本)
-
具备编写 Dockerfile 的基本能力
-
能够使用 GitHub Actions、GitLab CI 或 Jenkins 等 CI/CD 工具
Docker 构建缓存的工作原理
Dockerfile 中的每一条指令都会生成一层镜像。Docker 会保存这些镜像,并在检测到没有变化时重新使用它们,这就是缓存机制。理论上来说这个过程很简单,但实际上细节非常重要。
缓存键的计算方式
不同的指令会用不同的方式来计算它们的缓存键:
| 指令类型 | 缓存键的生成依据 | 什么情况会导致缓存键失效 |
|---|---|---|
RUN |
具体的命令字符串 | 任何对命令内容的修改都会导致缓存键失效 |
COPY / ADD |
源文件的校验和 | 对复制文件的任何修改都会导致缓存键失效 |
ENV / ARG |
变量的名称和值 | 变更变量的值会导致缓存键失效 |
FROM |
基础镜像的摘要信息 | 基础镜像的新版本会导致缓存键失效 |
缓存链规则
大多数人都会忽略这一点:Docker 的缓存机制是按顺序执行的。如果某一层的缓存失效,那么其后所有的层都会被重新构建,即使这些后续层本身并没有发生任何变化。
可以把这想象成一排多米诺骨牌——中间的一块倒下后,后面的所有骨头牌都会依次倒下。因此,Dockerfile 中指令的顺序非常重要。
关键提示:您可以采取的最有效的优化措施就是重新排列 Dockerfile 中指令的顺序,将那些变化最频繁的指令放在文件的最后面。
如何识别常见的导致缓存失效的错误
在开始解决问题之前,我们先来看看目前可能是哪些因素导致了缓存机制的失效。在我审查过的几乎所有未经过优化的 Dockerfile 中,都能看到这些常见的问题。
错误 1:过早地复制所有文件
这是一个非常严重的错误。如果将 COPY . . 这条指令放在 Dockerfile 的开头,即在安装依赖项之前就执行这条指令,那么项目中任何文件的修改都会导致之后的缓存失效。比如修改了 README 文件,那么依赖项就会被重新安装。
# 错误示例:任何文件修改都会导致依赖项重新安装
FROM node:20-alpine
WORKDIR /app
COPY . . # 每次提交都会导致缓存失效
RUN npm ci # 每次都会重新安装依赖项
RUN npm run build
错误2:未将依赖文件分开存放
你的依赖关系配置文件(如package.json、requirements.txt、go.mod、Gemfile)更改的频率远低于源代码的修改频率。如果你不将这些文件分开复制,那么每次修改源代码时,都需要重新安装所有的依赖项。
错误3:使用“ADD”命令而非“COPY”命令
ADD命令有一些特殊行为,比如会自动解压压缩文件以及从远程地址下载资源。这些特性会导致其缓存机制变得不可预测。除非确实需要解压文件,否则应优先使用COPY命令。
错误4:将“apt-get update”和“apt-get install”分开执行
如果将apt-get update和apt-get install放在不同的RUN命令中执行,那么更新操作会使用过时的包索引进行缓存,从而导致安装步骤失败或下载到陈旧的软件包。
# 错误做法:使用过时的包索引
RUN apt-get update
RUN apt-get install -y curl # 使用过时的索引可能会导致安装失败
# 正确做法:将这两个命令合并执行
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
错误5:过早嵌入时间戳或Git哈希值
如果在Dockerfile的早期阶段就通过ARG或ENV变量插入构建时的信息(如时间戳或Git提交哈希值),那么每次构建时缓存都会被破坏。应将这些内容放在Dockerfile的最后一步才进行插入。
⚠️ 需要注意这一点: CI/CD系统通常会自动将
BUILD_NUMBER或GIT_SHA等变量作为构建参数注入到Dockerfile中。如果这些变量的声明位置较靠前,那么每次构建时缓存都会被清除。
如何构建Dockerfile以实现最大程度的缓存复用
现在让我们来纠正这些错误。按照以下五个步骤依次操作,就能大大优化你的构建流程。
步骤1:采用依赖项优先的原则
首先只复制依赖关系配置文件,然后安装这些依赖项,之后再复制其余的源代码。仅这一处改动就能将你的构建时间缩短一半。
# 正确做法:对于Node.js项目,应采用依赖项优先的原则
FROM node:20-alpine
WORKDIR /app
# 仅复制依赖关系文件
COPY package.json package-lock.json ./
# 安装依赖项(除非包文件发生变化,否则会使用缓存)
RUN npm ci --production
# 复制源代码(只有当源代码发生变化时,这一层才会被重新构建)
COPY . .
# 执行构建操作
RUN npm run build
这个原则适用于所有编程语言:
| 语言 | 首先需要复制的内容 | 安装命令 |
|---|---|---|
| Node.js | package.json, package-lock.json |
npm ci |
| Python | requirements.txt 或 pyproject.toml |
pip install -r requirements.txt |
| Go | go.mod, go.sum |
go mod download |
| Rust | Cargo.toml, Cargo.lock |
cargo fetch |
| Java (Maven) | pom.xml |
mvn dependency:go-offline |
| Ruby | Gemfile, Gemfile.lock |
bundle install |
步骤 2:添加一个高效的 `.dockerignore` 文件
` .dockerignore` 文件可以确保不将无关文件纳入构建过程。如果构建环境中包含的文件较少,那么就越不容易出现导致缓存失效的问题。
# .dockerignore
.git
node_modules
dist
*.md
*.log
.env*
docker-compose*.yml
Dockerfile*
.github
tests
coverage
__pycache__
步骤 3:使用多阶段构建方式
多阶段构建允许你先使用完整的开发镜像进行编译,然后再将生成的最终文件复制到轻量级的运行时镜像中。这种方式不仅能生成体积更小的镜像,还能提升安全性,并改善缓存性能,因为构建工具和中间文件不会被保留下来。
# 第一阶段:构建
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# 第二阶段:生产环境构建
FROM node:20-alpine AS production
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY package.json ./
EXPOSE 3000
CMD ["node", "dist/index.js"]
步骤 4:按照更改频率对构建层进行排序
可以把 Dockerfile 视为一组堆叠的层。将那些变化较少、稳定性较高的内容放在最上层,而将容易发生变化的部分放在最底层:
-
基础镜像及系统依赖项(很少会发生变化)
-
语言运行时配置(偶尔会更改)
-
应用程序依赖项(当你添加或删除包时会发生变化)
-
源代码(每次提交都会发生变化)
-
构建时的元数据,如 Git 哈希值或版本标签(每次构建都会更新)
步骤 5:利用 BuildKit 功能挂载缓存目录
Docker BuildKit 支持使用 `RUN –mount=type=cache` 命令来挂载一个持久性的缓存目录,这个缓存目录会在多次构建过程中保持不变。对于那些需要维护自身下载缓存的包管理工具来说,这一功能简直具有革命性意义。
# syntax=docker/dockerfile:1
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
# 挂载 pip 缓存目录,使下载内容在多次构建中保持一致
RUN --mount=type=cache,target=/root/.cache/pip \
pip install -r requirements.txt
COPY . .
最棒的是:即使某个构建层被标记为无效,挂载的缓存目录仍然会保留下来。因此,当你添加一个新的包时,pip 只会下载这个新包,而不会重新下载所有依赖项。
以下是一些常见包管理工具所使用的缓存目录位置:
| 包管理工具 | 缓存目录 |
|---|---|
| pip | /root/.cache/pip |
| npm | /root/.npm |
| yarn | /usr/local/share/.cache/yarn |
| go | /go/pkg/mod |
| apt | /var/cache/apt |
| maven | /root/.m2/repository |
如何配置CI/CD缓存后端
在这里,事情就变得有点复杂了。在笔记本电脑上使用本地的Docker缓存效果很好,因为构建过程中各层数据会得以保留。但CI/CD执行器通常是临时使用的:每次任务开始时,缓存都是空的。如果没有进行明确的缓存配置,那么每一次CI构建都相当于从零开始。
选项A:基于注册表的缓存
BuildKit可以从容器注册表中读取或写入缓存数据。这是一种最具通用性的方法,适用于任何CI系统。
docker buildx build \
--cache-from type=registry,ref=myregistry.io/myapp:buildcache \
--cache-to type=registry,ref=myregistry.io/myapp:buildcache,mode=max \
--tag myregistry.io/myapp:latest \
--push .
💡 建议使用
mode=max选项,这样就可以缓存所有构建阶段的数据。默认的mode=min只会缓存最终阶段的数据,这意味着中间阶段的构建结果会被丢弃。
选项B:GitHub Actions缓存
如果你使用的是GitHub Actions,那么可以通过GitHub Actions的缓存API与BuildKit进行集成。这种方式的效率很高,而且设置也很简单。
# .github/workflows/build.yml
- name: 设置Docker Buildx环境
uses: docker/setup-buildx-action@v3
- name: 构建并推送结果
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: myregistry.io/myapp:latest
cache-from: type=gha
cache-to: type=gha,mode=max
选项C:S3或云存储
对于使用AWS、GCP或Azure的团队来说,云对象存储是一个非常合适的缓存后端。它速度快、数据持久,并且适用于任何CI系统。
docker buildx build \
--cache-from type=s3,region=us-east-1,bucket=my-docker-cache,name=myapp \
--cache-to type=s3,region=us-east-1,bucket=my-docker-cache,name=myapp,mode=max \
--tag myapp:latest .
选项D:使用持久化执行器的本地缓存
如果你的CI执行器具有持久化存储空间(例如自托管的执行器,或者GitLab中使用了共享卷的执行器),那么你可以将缓存数据保存到本地目录中。
docker buildx build \
--cache-from type=local,src=/ci-cache/myapp \
--cache-to type=local,dest=/ci-cache/myapp,mode=max \
--tag myapp:latest .
如何实现高级缓存策略
一旦掌握了基础知识,这些高级缓存策略就能帮助你进一步提升性能。
并行构建阶段
BuildKit可以同时并行执行不同的构建阶段。如果你的应用程序包含前端和后端部分,且这两者在构建过程中互不依赖,那么可以将它们分成独立的阶段,让BuildKit同时运行这些阶段。
# 这些阶段会并行进行构建
FROM node:20-alpine AS frontend
WORKDIR /frontend
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ .
RUN npm run build
FROM python:3.12-slim AS backend
WORKDIR /backend
COPY backend/requirements.txt .
RUN pip install -r requirements.txt
COPY backend/ .
# 最终阶段会将前后两部分合并在一起进行构建
FROM python:3.12-slim
COPY --from=backend /backend /app
COPY --from=frontend /frontend/dist /app/static
CMD ["python", "/app/main.py"]
针对功能分支的缓存预热机制
功能分支在创建之初,其缓存通常是空的,因为它们与主分支是分开发展的。你可以通过指定多个--cache-from参数来预热缓存。Docker会按照指定的顺序检查这些缓存源。
docker buildx build \
--cache-from type=registry,ref=registry.io/app:cache-${BRANCH} \
--cache-from type=registry,ref=registry.io/app:cache-main \
--cache-to type=registry,ref=registry.io/app:cache-${BRANCH},mode=max \
--tag registry.io/app:${BRANCH} .
如果某个分支的缓存可以被利用,Docker就会直接使用它;否则,它会回退到主分支的缓存,因为主分支的缓存通常包含大部分共享层。对于那些生命周期较短的分支来说,这种机制会带来显著的性能提升。
通过构建参数实现选择性缓存失效
你可以使用ARG指令来设定缓存的有效范围:位于ARG指定值以下的代码部分会被保留在缓存中,而高于该值的代码部分则会在参数值发生变化时重新被编译。
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
# 这个参数仅会导致低于它的那些代码层被重新编译
ARG CACHE_BUST_CODE=1
COPY . .
RUN npm run build
# 这个参数只会影响标签的生成
ARG GIT_SHA=unknown
LABEL git.sha=$GIT_SHA
如何衡量你的改进效果
如果不进行测量,优化工作就仅仅是一种猜测。以下是验证你的修改是否真正有效的方法。
用于测试的四种场景
每种场景至少运行三次,并取其平均值作为结果:
-
首次构建:完全不使用任何缓存(第一次构建或执行
docker builder prune后) -
缓存已预热的情况:没有进行任何修改,直接使用缓存
-
仅修改代码:只有源代码发生了变化
-
修改依赖关系:包清单文件被修改了
实际应用前的后的数据对比
以下是在一个中等规模的Node.js项目中应用这些优化技巧后所得到的数据:
| 测试场景 | 优化前 | 优化后 | 性能提升幅度 |
|---|---|---|---|
| 首次构建 | 12分钟34秒 | 8分钟10秒 | 35% |
| 缓存已预热且未做修改 | 12分钟34秒 | 14秒 | 98% |
| 仅修改代码 | 12分钟34秒 | 1分钟52秒 | 85% |
| 修改依赖关系 | 12分钟34秒 | 4分钟20秒 | 65% |
“之前”这一列对所有行来说都是相同的,因为在没有缓存优化的情况下,每次构建实际上都是一次全新的构建过程。对于仅涉及代码更改的情况而言,85%的效率提升才是真正重要的数据,因为绝大多数提交操作都属于这种类型。
如何检查缓存命中率
将BUILDKIT_PROGRESS=plain设置为默认值,即可获得详细输出,从而了解哪些构建步骤使用了缓存:
BUILDKIT_progress=plain docker buildx build . 2>&1 | grep -E 'CACHED|DONE'
请注意检查那些以CACHED开头的构建步骤。你的目标应该是确保除了确实需要更改的那些层之外,其他所有层的后面都显示“CACHED”这一字样。
经过优化的Dockerfile示例
这里有一些可以直接用于实际项目的、已经过优化处理的Dockerfile模板,你可以根据自己的需求进行修改使用。
Node.js全栈应用
# syntax=docker/dockerfile:1
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 appgroup \
&adduser --system --uid 1001 appuser
COPY --from=builder --chown=appuser:appgroup /app/dist ./dist
COPY --from=deps /app/node_modules ./node_modules
COPY package.json ./
USER appuser
EXPOSE 3000
CMD ["node", "dist/index.js"]
Python FastAPI应用
# syntax=docker/dockerfile:1
FROM python:3.12-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
pip install --user -r requirements.txt
FROM python:3.12-slim
WORKDIR /app
COPY --from=builder /root/.local /root/.local
ENV PATH=/root/.local/bin:$PATH
COPY . .
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
Go微服务应用
# syntax=docker/dockerfile:1
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod go mod download
COPY . .
RUN --mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 go build -ldflags='-s -w' -o /app/server ./cmd/server
FROM gcr.io/distroless/static-debian12
COPY --from=builder /app/server /server
EXPOSE 8080
ENTRYPOINT ["/server"]
故障排除指南
当遇到问题时,请先查看下表:
| 症状 | 可能的原因 | 解决方法 |
|---|---|---|
| 每次构建时所有层都会被重新生成 | 命令COPY . .执行得太早,或者缺少文件.dockerignore |
将COPY . .命令放在依赖项安装之后执行;同时添加文件.dockerignore |
| 在持续集成环境中缓存从未被使用过 | 没有配置任何缓存后端 | 为构建过程配置缓存后端,例如registry、gha或s3 |
| 在本地可以使用缓存,但在持续集成环境中却无法使用 | 使用的Docker版本不同,或者没有启用BuildKit功能 | 将DOCKER_BUILDKIT=1设置为默认值,并确保使用相同版本的Docker |
| 依赖项对应的构建层总是会被重新生成 | 在安装依赖项之前就先复制了源代码文件 | 应采用“先安装依赖项再复制代码文件”的顺序 |
| 镜像的大小一直在不断增加 | 构建过程中产生的临时文件被包含在了最终生成的镜像中 | 应使用多阶段构建方式,只复制运行时所需的文件 |
| 注册中心的缓存速度非常慢 | 由于设置了mode=max选项,导致太多层都被缓存在内存中 |
可以将mode=min设置为默认值,或者改用gha/s3作为缓存后端 |
快速参考检查清单
将这份清单打印出来,然后贴在显示器旁边:
-
[ ] 启用BuildKit:将
DOCKER_BUILDKIT=1设置为默认值,或使用docker buildx -
[ ] 添加一份详细的
.dockerignore文件 -
[ ] 采用“先安装依赖项再复制源代码”的构建方式
-
[ ] 按照文件修改程度的顺序来排列构建层
-
[ ] 将相关的
RUN命令组合在一起执行(例如apt-get update && install) -
[ ] 使用多阶段构建方式,将构建过程与运行时环境分开
-
[ ] 为包管理器缓存添加
RUN --mount=type=cache指令 -
[ ] 将那些会频繁变化的参数(如git哈希值、构建编号等)放在构建层的最后位置
-
[ ] 配置CI/CD缓存后端(例如注册库、gha或s3)
-
[ ] 为从主分支分出的特性分支启用缓存预热功能
-
除非需要解压文件,否则应使用COPY而非ADD -
[ ] 对四种不同的构建场景进行性能测试:冷启动、缓存预热、代码修改以及依赖项变更
结论
我以前认为Docker构建速度慢是不可避免的。但在为几个项目实施了这些优化措施后,我发现只要理解了一个核心原则,问题其实很容易解决:缓存数据是按顺序存储的,因此排序非常重要。
首先采用“先安装依赖项再复制源代码”的构建方式,并添加.dockerignore>文件。仅仅这两项改动就很可能使你的构建时间缩短一半。之后可以根据实际需要再添加多阶段构建、缓存机制以及CI/CD缓存后端。
我合作过的团队在花费几个小时进行这些优化后,通常都能将CI/CD管道的运行时间减少70%到85%。这意味着每次提交代码时,你们都能节省大量时间。
如果这篇文章对你有帮助,请考虑与你的团队分享。很有可能,上次编写Dockerfile的人并不知道其中很多技巧。我并不是在批评他们,因为在了解这些方法之前,我也同样不知道。
祝你们的构建工作顺利!


