Docker镜像构建慢的问题在CI环境里被放大得特别明显。本地构建的时候,Docker daemon常驻,大部分层都能命中缓存,改一行代码可能只需要几秒钟就能出新镜像。可一旦到了CI环境,执行器每次都是全新的容器或虚拟机,本地缓存荡然无存,如果不做任何缓存配置,每次构建都是从零开始拉基础镜像、装依赖、编译,一条原本30秒就能完成的流水线硬生生拖到十几分钟。要解决这个问题,核心思路就一条:让构建层缓存在CI任务之间持久化下来,并且尽可能让Dockerfile的结构保持缓存友好。

先搞懂Layer Cache的命中规则
想提高缓存命中率,第一步是理解Docker判定缓存是否命中的逻辑。Docker构建是基于层的,Dockerfile里的每一条指令(RUN、COPY、ADD等)都会生成一个层,层的标识由两部分共同决定:指令本身的内容,以及父层的状态。也就是说,只要某一层的内容发生了变化,这一层以及它之后的所有层都会失效,必须重新构建。
这里面有几个容易被忽略的细节。第一,RUN apt-get update && apt-get install -y xxx这样的指令,只要安装的包列表变了一个字符,整条指令都会重新执行,所以把不常变的依赖放在前面、经常变的业务代码放在后面是基本原则。第二,COPY . .这种把整个上下文目录一股脑拷进去的写法是大忌,哪怕只改了一个README文件,这一层的校验值也会变,后续所有层全部作废。第三,如果使用了--no-cache或者基础镜像标签是latest这种会漂移的标签,缓存也会不稳定。
举个典型的反面例子:
FROM node:20 # 反例:先拷业务代码再装依赖 COPY . /app WORKDIR /app RUN npm install CMD ["npm", "start"]
这个Dockerfile里,任何一行业务代码的改动都会导致COPY . /app这一层失效,进而npm install跟着重跑,装依赖可能要花好几分钟。正确的做法是把package.json单独拷出来先装依赖,代码改动就只影响后面真正需要重建的层:
FROM node:20 WORKDIR /app # 先只拷依赖清单,这一层只要清单不变就永远命中缓存 COPY package.json package-lock.json ./ RUN npm ci --omit=dev # 再拷业务代码,频繁变化的部分放到最后 COPY . . CMD ["npm", "start"]
利用BuildKit的缓存挂载加速依赖安装
传统的层缓存解决的是“这一层要不要重跑”的问题,但有些场景下即使重跑,也希望利用上一次执行时留下的数据。最典型的就是包管理器的下载缓存。npm、pip、apt这些工具在安装依赖时都会先把包下载到本地缓存目录,如果这些缓存目录能在多次构建之间复用,即使npm install必须重新执行,也不需要重新从网络上下载所有包,速度会快非常多。
BuildKit提供了--mount=type=cache语法来实现这一点。它会在RUN指令执行期间把一个持久化的缓存目录挂载进来,执行完毕后自动卸载,缓存内容不进入镜像层,既不增大镜像体积,又能跨构建复用。用法如下:
# syntax=docker/dockerfile:1
FROM python:3.12
WORKDIR /app
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
pip install -r requirements.txt
COPY . .
CMD ["python", "app.py"]几个注意点值得展开说说。首先,不同的包管理器缓存路径不同,npm是/root/.npm,pip是/root/.cache/pip,apt是/var/cache/apt,需要按实际工具指定target。其次,如果并行构建多个不同项目,可以给缓存挂载加id参数做隔离,比如--mount=type=cache,id=myapp-npm,target=/root/.npm,避免缓存相互覆盖或污染。最后,sharing=locked选项可以保证同一缓存在并发构建时不会被同时写入,适合CI环境里同项目高频构建的场景。
另外,多阶段构建配合缓存挂载效果更好。编译工具链只留在builder阶段,最终镜像只包含运行时产物,既减小了体积,也让编译阶段的缓存策略可以更激进,不用担心把中间产物带进生产镜像。
# syntax=docker/dockerfile:1
FROM golang:1.22 AS builder
WORKDIR /src
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 -o /out/app .
FROM alpine:3.19
COPY --from=builder /out/app /usr/local/bin/app
CMD ["app"]让缓存在CI执行器之间持久化
Dockerfile写得再好,如果CI每次都在一台全新的机器上跑,本地缓存永远存在不过一条流水线的时间。所以真正的关键在于把缓存“搬”出去。目前主流的方案有两类:一类是利用CI平台自带的缓存机制,另一类是使用BuildKit的远程缓存导出功能。
BuildKit从Docker 19.03开始内建支持buildx,可以把构建缓存导出到远程registry,下次构建时再从registry导入。命令形式如下:
# 构建时同时导出缓存 docker buildx build \ --cache-from type=registry,ref=registry.ippipp.com/myapp/build-cache \ --cache-to type=registry,ref=registry.ippipp.com/myapp/build-cache,mode=max \ -t registry.ippipp.com/myapp:latest --push . # 首次使用前需要先创建并引导builder docker buildx create --driver docker-container --name cached-builder --use docker buildx inspect --bootstrap
这里有两个容易踩坑的地方。一是cache-to的mode参数,默认的min只导出最终镜像的层,中间构建层(比如多阶段构建里builder阶段的层)不会被缓存,建议显式指定mode=max,把所有中间层都导出,这样多阶段构建的编译缓存才能真正复用。二是需要配合BuildKit的容器驱动,也就是docker-containerdriver,默认的docker driver不支持registry类型的缓存导出。
除了registry方案,也可以用本地目录模式。比如在GitLab CI里把/root/.cache/buildkit目录挂载出来配合type=local导出缓存,或者更简单直接的做法:给执行器配置持久化的Docker数据卷。GitLab Runner配置中把volumes = ["/var/run/docker.sock:/var/run/docker.sock", "buildcache:/cache"]这样的卷挂上,让同一个runner上的构建共享Docker的层缓存。
GitHub Actions用户则推荐使用docker/build-push-action,它对BuildKit缓存做了封装,支持直接把缓存写到GitHub自带的Cache存储里,配置相当简洁:
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v5
with:
context: .
push: true
tags: registry.ippipp.com/myapp:latest
cache-from: type=gha
cache-to: type=gha,mode=max验证与持续监控缓存效果
优化做完之后,怎么知道缓存到底有没有生效?最直观的方式是看构建日志。BuildKit默认开启进度输出,每一层后面会标注CACHED字样,如果大部分层都显示CACHED而只有最后几层在执行,说明缓存策略生效了。也可以用docker buildx build --progress=plain拿到更详细的输出,方便排查到底哪一层开始失效。
如果某一层经常意外失效,优先检查这几件事:这一层之前的层是否有内容不稳定的输入(比如COPY进来的文件里有时间戳或随机文件);.dockerignore是否配置合理,有没有把.git目录、日志文件这类无关内容排除掉;基础镜像标签是否固定,建议始终使用具体的版本号甚至digest而非latest。一个常见的坑是构建参数通过ARG传入时,如果传入了构建时间戳这类每次都不同的值,并且该ARG出现在某条指令里,那么对应的层必然每次都失效,这类参数要放到不影响缓存的指令位置。
最后建议在CI里加一个简单的度量,把每次构建的耗时和缓存命中层数记录下来,可以只是往日志里打一行统计。有了数据之后,后续调整Dockerfile结构或者缓存策略时就能量化对比效果,避免凭感觉优化。整体来说,缓存优化这件事的原则并不复杂:变化慢的放前面,变化快的放后面,随机性因素彻底排除,再把缓存持久化到执行器之外,做好这几点,大部分项目的镜像构建时间都能降到原来的三分之一以下。
Docker Layer Cache镜像构建优化CI缓存命中率修改时间:2026-09-03 23:23:21