4. 多阶段构建:分离构建环境与运行环境,将最终镜像体积砍掉 80% 的实战方法

你有没有遇到过这种情况?写了一个 Go 或 Java 应用,Dockerfile 里从源码编译,最后打出来的镜像动辄 1GB 以上。部署的时候拉取慢、磁盘占用高、安全扫描还报一堆 CVE。嗯,问题出在哪?

说白了,你把构建工具和运行时依赖一股脑全塞进了同一个镜像层里。GCC、Maven、npm、临时源码、编译中间产物……这些在生产环境里根本不需要。我刚开始用 Docker 时也犯过这个错,直到有一次线上扩容,镜像拉取花了 8 分钟,被运维同事拉去“喝茶”了。

多阶段构建(Multi-stage Build)就是专门解决这个问题的。它的核心思路很简单:用多个 FROM 语句,前面的阶段负责编译,后面的阶段只拷贝最终产物。这样最终镜像里只有运行所需的最小文件集。

核心收益: 镜像体积减少 70%–90%,安全攻击面大幅缩小,部署速度提升 3–5 倍。

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 多阶段构建的完整流程

下面这张图展示了多阶段构建的完整数据流:

多阶段构建流程图 多阶段构建数据流 第一阶段:构建环境 基础镜像:golang:1.21-alpine 包含:Go SDK、编译器、源码 产物:编译后的二进制文件 COPY --from=builder 只拷贝产物,不拷贝工具链 第二阶段:运行环境 基础镜像:alpine:3.19 仅包含:ca-certificates、tzdata 最终镜像体积:~12MB 产物传递(二进制文件) 💡 关键点: 构建环境可以很大(包含完整工具链),但运行环境必须最小化。通过 COPY --from= 只传递最终产物, 构建阶段的临时文件、依赖缓存、源码都不会进入最终镜像。

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 里也加上这个配置,能省下大量重复下载依赖的时间。

注意: 缓存挂载需要启用 BuildKit。在 Docker 18.09+ 中,设置环境变量 DOCKER_BUILDKIT=1 即可。如果你还在用旧版 Docker,建议升级。

4.7 总结:多阶段构建的核心原则

说了这么多,其实核心就三条:

  • 分离构建与运行: 构建阶段用完整工具链,运行阶段用最小基础镜像。
  • 精确拷贝产物: 只拷贝编译后的二进制或打包后的 jar,不要拷贝整个工作目录。
  • 善用缓存: 合理组织 Dockerfile 指令顺序,结合 BuildKit 缓存挂载,加速构建。

我个人习惯在项目根目录放一个 .dockerignore,然后在 Dockerfile 里用多阶段构建。这样每次改完代码,构建出来的镜像都干净利落。你试试看,把现有的 Dockerfile 改造成多阶段构建,镜像体积砍掉 80% 不是梦。