从15分钟到90秒:多阶段镜像构建与缓存重用加速Docker CI/CD流水线
上个月接手了一个Java微服务项目的CI/CD优化任务。开发抱怨每次代码提交后等镜像构建要15分钟,别说快速迭代了,改个日志级别都要等半天。
Docker镜像构建慢,根本原因就两个:一是构建过程中产生了大量临时层(比如Maven下载的依赖包),二是每次构建都从头开始,缓存没有被有效利用。
一、多阶段构建(Multi-stage Build)的威力
传统Dockerfile的痛点
先看一个"典型"的Java应用Dockerfile:
FROM maven:3.8-jdk-11 WORKDIR /app COPY . . RUN mvn clean package -DskipTests EXPOSE 8080 CMD ["java", "-jar", "target/app.jar"]这个Dockerfile有什么问题?
- 镜像太大:Maven镜像本身就700MB+,加上编译产物轻松上1GB
- 包含构建工具:生产环境不需要Maven和源码
- 缓存失效:只要任何源文件变化,整个
RUN mvn缓存都失效
多阶段构建重构
我们用多阶段构建把编译环境和运行环境分离:
# Stage 1: 编译阶段 FROM maven:3.8-eclipse-temurin-11 AS builder WORKDIR /build # 先复制依赖配置文件,利用Docker缓存层 COPY pom.xml . RUN mvn dependency:go-offline -B # 再复制源码 COPY src ./src RUN mvn clean package -DskipTests -B # Stage 2: 运行时阶段 FROM eclipse-temurin:11-jre-alpine WORKDIR /app # 只从builder阶段复制jar包 COPY --from=builder /build/target/app.jar app.jar # 添加tini作为init进程,处理僵尸进程 RUN apk add --no-cache tini ENTRYPOINT ["/sbin/tini", "--"] CMD ["java", "-jar", "app.jar"]这个优化后的Dockerfile:
- 镜像大小:从1.2GB降到180MB,减少85%
- 安全面:运行时镜像没有编译器、没有源码、没有Maven仓库
- 缓存利用:只要pom.xml不变,依赖下载层就命中缓存
二、缓存重用策略深度实践
Docker BuildKit缓存挂载
BuildKit是Docker的新一代构建引擎,支持更强大的缓存能力:
# syntax=docker/dockerfile:1.4 FROM golang:1.21-alpine AS builder WORKDIR /build # 使用cache mount缓存Go模块 RUN --mount=type=cache,target=/go/pkg/mod \ --mount=type=bind,source=go.sum,target=go.sum \ --mount=type=bind,source=go.mod,target=go.mod \ go mod download -x # 使用cache mount和ssh mount安全拉取私有仓库 RUN --mount=type=cache,target=/go/pkg/mod \ --mount=type=bind,source=.git,target=.git \ --mount=type=ssh \ go build -o app . FROM alpine:3.19 COPY --from=builder /build/app /app CMD ["/app"]这里的--mount=type=cache是关键:即使构建上下文发生变化,/go/pkg/mod目录也会保留在宿主机的缓存中,不会被清理。
CI/CD中的远程缓存共享
单机缓存还不够,GitLab CI的多runner需要共享缓存:
# .gitlab-ci.yml variables: DOCKER_BUILDKIT: "1" BUILDKIT_INLINE_CACHE: "1" services: - docker:24.0-dind before_script: - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY build: stage: build script: - docker build --cache-from $CI_REGISTRY_IMAGE:latest --cache-to type=registry,ref=$CI_REGISTRY_IMAGE:cache,mode=max -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA -t $CI_REGISTRY_IMAGE:latest . - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA - docker push $CI_REGISTRY_IMAGE:latest关键配置说明:
| 参数 | 作用 |
|---|---|
| BUILDKIT_INLINE_CACHE | 允许将缓存元数据写入镜像manifest |
| --cache-from | 从远程仓库拉取缓存层 |
| --cache-to | 构建完成后将新缓存推送到远程 |
| mode=max | 缓存所有层,包括中间层 |
缓存命中率优化技巧
实践中我总结了几条提升缓存命中率的方法:
- 依赖文件和源码分层拷贝:先COPY依赖配置,再COPY源码
- 固定基础镜像版本:不要用
:latest,用具体的digest - 最小化上下文:用
.dockerignore排除node_modules、.git等
# .dockerignore .git/ node_modules/ target/ *.md Dockerfile .dockerignore三、构建性能监控与对比
优化后我们做了对比测试:
# 测试命令 time docker build --no-cache -t app:test . # 优化前:平均 15分20秒 # 优化后(首次构建/无缓存):平均 4分50秒 # 优化后(缓存命中):平均 90秒 # 优化后(只改一行源码):平均 2分30秒对比数据看得更清楚:
| 场景 | 优化前 | 优化后 | 提升幅度 |
|---|---|---|---|
| 首次全量构建 | 15m20s | 4m50s | 68% |
| 依赖未变改代码 | 15m20s | 2m30s | 84% |
| 镜像大小 | 1.2GB | 180MB | 85% |
| 构建缓存占地 | 3.5GB | 1.2GB | 66% |
四、生产环境的注意事项
多阶段构建+缓存重用不是银弹,有几个坑需要避开:
- BuildKit的缓存目录默认在
/var/lib/docker/buildkit,要注意磁盘空间 - 并发构建冲突:多个pipeline同时写同一份缓存可能导致数据不一致
- 长期不清理:远程缓存会越积越大,需要定期GC
我们建了一个定时任务每周清理一次远程缓存:
#!/bin/bash # cleanup-docker-cache.sh # 保留最近5个版本的缓存 CACHE_TAGS=$(docker manifest inspect $REGISTRY_IMAGE:cache \ | jq '.manifests[].annotations["org.opencontainers.image.ref.name"]' \ | sort -r | tail -n +6) for tag in $CACHE_TAGS; do docker buildx rm --cache-to type=registry,ref=${REGISTRY_IMAGE}:cache-$tag done结语
多阶段构建+缓存重用是我在CI/CD优化中用过的最立竿见影的方案。核心思路就四句话:分离构建与运行环境、精确控制缓存失效粒度、利用BuildKit的cache mount、打通CI/CD的缓存共享链路。
从15分钟到90秒,开发体验的飞跃会让你觉得这优化做得值。
本文作者:侯万里(万里侯),云原生运维工程师,专注CI/CD流水线优化与容器化交付实践