4. 多阶段构建:分离构建环境与运行环境,将最终镜像体积砍掉 80% 的实战方法
你有没有遇到过这种情况?写了一个 Go 或 Java 应用,Dockerfile 里从源码编译,最后打出来的镜像动辄 1GB 以上。部署的时候拉取慢、磁盘占用高、安全扫描还报一堆 CVE。嗯,问题出在哪?
说白了,你把构建工具和运行时依赖一股脑全塞进了同一个镜像层里。GCC、Maven、npm、临时源码、编译中间产物……这些在生产环境里根本不需要。我刚开始用 Docker 时也犯过这个错,直到有一次线上扩容,镜像拉取花了 8 分钟,被运维同事拉去“喝茶”了。
多阶段构建(Multi-stage Build)就是专门解决这个问题的。它的核心思路很简单:用多个 FROM 语句,前面的阶段负责编译,后面的阶段只拷贝最终产物。这样最终镜像里只有运行所需的最小文件集。
4.1 多阶段构建的基本原理
先看一个最直观的例子。假设我们有一个 Go 应用,传统的单阶段 Dockerfile 长这样:
# 单阶段构建 —— 不推荐
FROM golang:1.21-alpine AS builder
WORKDIR /app
COPY . .
RUN go build -o myapp .
# 最终镜像包含 Go SDK、编译器、源码……
# 体积:约 800MB
这个镜像里不仅有编译好的二进制,还有整个 Go 工具链、alpine 包管理器、甚至你本地的 .git 目录。我见过有人把 node_modules 也打包进去,镜像直接飙到 2GB。
用多阶段构建改写一下:
# 第一阶段:编译
FROM golang:1.21-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o myapp .
# 第二阶段:运行
FROM alpine:3.19
RUN apk add --no-cache ca-certificates tzdata
COPY --from=builder /app/myapp /usr/local/bin/myapp
CMD ["myapp"]
最终镜像只有 12MB。从 800MB 到 12MB,砍掉了 98.5%。
builder 或 build,第二阶段用 runtime 或直接不命名。命名清晰一点,后面 COPY --from= 时不容易搞混。
4.2 实战:Java Spring Boot 应用
Java 应用是镜像体积的重灾区。一个 Spring Boot 项目,如果用 Maven 打包,传统做法是这样的:
# 传统 Java 镜像 —— 体积巨大
FROM maven:3.9-eclipse-temurin-17 AS builder
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline
COPY src ./src
RUN mvn package -DskipTests
FROM eclipse-temurin:17-jre-alpine
COPY --from=builder /app/target/*.jar app.jar
ENTRYPOINT ["java", "-jar", "/app.jar"]
这个镜像大概 200–300MB。还能不能再优化?可以。我踩过一个坑:maven:3.9-eclipse-temurin-17 这个基础镜像本身就很大,而且 Maven 下载的依赖缓存也会被带进最终镜像。
更好的做法:
# 第一阶段:用 Maven 镜像编译
FROM maven:3.9-eclipse-temurin-17 AS builder
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline
COPY src ./src
RUN mvn package -DskipTests
# 第二阶段:用 JRE 精简版运行
FROM eclipse-temurin:17-jre-alpine
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
COPY --from=builder /app/target/*.jar /app/app.jar
USER appuser
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
体积从 800MB 降到 180MB 左右。如果再用 jlink 裁剪 JRE,可以进一步压到 60MB 以内。
USER,结果容器以 root 运行,安全扫描直接报高危。多阶段构建虽然减小了体积,但安全配置不能省。
4.3 多阶段构建的典型模式
我总结了三种最常见的模式,你可以根据项目类型直接套用:
| 模式 | 适用场景 | 第一阶段(构建) | 第二阶段(运行) | 典型体积缩减 |
|---|---|---|---|---|
| 编译型语言 | Go、Rust、C/C++ | 完整 SDK + 编译 | scratch 或 alpine | 90%–98% |
| 解释型语言 | Node.js、Python | 完整工具链 + 安装依赖 | 精简运行时 + 仅生产依赖 | 60%–80% |
| Java/JVM | Spring Boot、Quarkus | JDK + Maven/Gradle | JRE 或 jlink 定制运行时 | 70%–90% |
你想想看,这三种模式的核心逻辑是一样的:构建阶段用“重”镜像,运行阶段用“轻”镜像。中间通过 COPY --from= 传递产物。
4.4 避坑指南:多阶段构建的常见问题
多阶段构建虽然好用,但有几个细节不注意,反而会出问题。我把自己踩过的坑列出来:
- 缓存失效问题: 第一阶段里如果
COPY . .放在RUN go mod download前面,每次源码变动都会导致依赖重新下载。正确的顺序是先拷贝依赖描述文件,下载完再拷贝源码。 - 构建上下文过大: 如果你在项目根目录执行
docker build,而.dockerignore没配好,会把 node_modules、target、.git 都传到 Docker daemon。我见过一个团队构建上下文有 2GB,每次构建光传文件就要 30 秒。 - 多阶段命名冲突: 如果你有多个
FROM,每个阶段最好起一个唯一的名字。用数字索引(COPY --from=0)虽然可以,但可读性差,后期维护容易搞错。 - 忘记清理临时文件: 有些构建工具会在工作目录生成临时文件,如果你在第二阶段拷贝了整个目录,这些垃圾文件也会被带进去。建议只拷贝明确需要的文件或目录。
.dockerignore 里至少加上 .git、node_modules、target、__pycache__、.env。这能让构建上下文缩小 10 倍以上。
4.5 多阶段构建的完整流程
下面这张图展示了多阶段构建的完整数据流:
4.6 进阶技巧:多阶段 + 缓存挂载
多阶段构建还有一个隐藏技能:结合 BuildKit 的缓存挂载功能。我最近在 CI 里用这个组合,构建时间从 5 分钟降到了 40 秒。
# 使用 BuildKit 缓存挂载
# syntax=docker/dockerfile:1.4
FROM golang:1.21-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=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 go build -o myapp .
FROM alpine:3.19
COPY --from=builder /app/myapp /usr/local/bin/myapp
CMD ["myapp"]
这里用了 --mount=type=cache,把 Go 的模块缓存和构建缓存挂载到宿主机上。这样即使 docker build --no-cache,缓存目录也不会被清除。我建议你在 CI 里也加上这个配置,能省下大量重复下载依赖的时间。
DOCKER_BUILDKIT=1 即可。如果你还在用旧版 Docker,建议升级。
4.7 总结:多阶段构建的核心原则
说了这么多,其实核心就三条:
- 分离构建与运行: 构建阶段用完整工具链,运行阶段用最小基础镜像。
- 精确拷贝产物: 只拷贝编译后的二进制或打包后的 jar,不要拷贝整个工作目录。
- 善用缓存: 合理组织 Dockerfile 指令顺序,结合 BuildKit 缓存挂载,加速构建。
我个人习惯在项目根目录放一个 .dockerignore,然后在 Dockerfile 里用多阶段构建。这样每次改完代码,构建出来的镜像都干净利落。你试试看,把现有的 Dockerfile 改造成多阶段构建,镜像体积砍掉 80% 不是梦。