每位开发者都经历过这种情况:你提交了一个只需一行代码的修复方案,然后去喝咖啡等待结果。然而十二分钟后,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.jsonrequirements.txtgo.modGemfile)更改的频率远低于源代码的修改频率。如果你不将这些文件分开复制,那么每次修改源代码时,都需要重新安装所有的依赖项。

错误3:使用“ADD”命令而非“COPY”命令

ADD命令有一些特殊行为,比如会自动解压压缩文件以及从远程地址下载资源。这些特性会导致其缓存机制变得不可预测。除非确实需要解压文件,否则应优先使用COPY命令。

错误4:将“apt-get update”和“apt-get install”分开执行

如果将apt-get updateapt-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的早期阶段就通过ARGENV变量插入构建时的信息(如时间戳或Git提交哈希值),那么每次构建时缓存都会被破坏。应将这些内容放在Dockerfile的最后一步才进行插入。

⚠️ 需要注意这一点: CI/CD系统通常会自动将BUILD_NUMBERGIT_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.txtpyproject.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 视为一组堆叠的层。将那些变化较少、稳定性较高的内容放在最上层,而将容易发生变化的部分放在最底层:

  1. 基础镜像及系统依赖项(很少会发生变化)

  2. 语言运行时配置(偶尔会更改)

  3. 应用程序依赖项(当你添加或删除包时会发生变化)

  4. 源代码(每次提交都会发生变化)

  5. 构建时的元数据,如 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

如何衡量你的改进效果

如果不进行测量,优化工作就仅仅是一种猜测。以下是验证你的修改是否真正有效的方法。

用于测试的四种场景

每种场景至少运行三次,并取其平均值作为结果:

  1. 首次构建:完全不使用任何缓存(第一次构建或执行docker builder prune后)

  2. 缓存已预热的情况:没有进行任何修改,直接使用缓存

  3. 仅修改代码:只有源代码发生了变化

  4. 修改依赖关系:包清单文件被修改了

实际应用前的后的数据对比

以下是在一个中等规模的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的人并不知道其中很多技巧。我并不是在批评他们,因为在了解这些方法之前,我也同样不知道。

祝你们的构建工作顺利!

Comments are closed.