多阶段构建精简镜像
本教程共 25 篇 · 第 14 篇 · 更新于 2026-07-26 · 约 8 分钟阅读
14. 多阶段构建精简镜像
本节目标:搞懂多阶段构建的写法,学会把编译环境留在构建阶段、只把运行产物带进最终镜像,让镜像又小又安全。
上一章我们优化了构建速度,这一章优化镜像体积。
你可能遇到过这种事:构建一个 Java 或 Go 应用,镜像动不动七八百 MB。其实应用本身可能就十几 MB,剩下的全是编译器、构建工具、SDK—这些玩意儿只在构建时需要,运行时根本用不上,却全被塞进了镜像。
多阶段构建就是来解决这个问题的。它让你在一个 Dockerfile 里写多个「阶段」,构建阶段用大而全的镜像编译代码,最终阶段用小而精的镜像只装运行产物。两个阶段之间用 COPY --from 传递产物。
为什么需要多阶段构建
先看传统单阶段构建的问题。以一个 Spring Boot 应用为例,Dockerfile 大概长这样:
FROM eclipse-temurin:21.0.8_9-jdk-jammy
WORKDIR /app
COPY .mvn/ .mvn
COPY mvnw pom.xml ./
RUN ./mvnw dependency:go-offline
COPY src ./src
CMD ["./mvnw", "spring-boot:run"]
构建出来多大?大约 880 MB。里面塞了完整的 JDK、Maven 工具链、所有构建依赖。但运行时只需要一个 JRE(Java Runtime Environment)和你那个 jar 包,根本用不着编译器。
这带来三个坏处:
- 拉取和分发慢,每次部署都要传几百 MB。
- 攻击面大,镜像里一堆构建工具,万一被入侵,攻击者手里工具齐全。
- 存储浪费,仓库、节点都要存这些大镜像。
多阶段构建能把这个镜像压到几百 MB 甚至几十 MB。
多阶段构建的语法
多阶段构建的核心是:一个 Dockerfile 里写多个 FROM,每个 FROM 开启一个新阶段。用 AS 给阶段起名,用 COPY --from=阶段名 把上一阶段的产物拷过来。
伪代码长这样:
# 阶段 1:构建环境
FROM builder-image AS build-stage
# 安装构建工具、拷源码、编译打包
# 阶段 2:运行环境
FROM runtime-image AS final-stage
# 从构建阶段拷贝产物,定义启动命令
COPY --from=build-stage /path/in/build /path/to/final
CMD ["..."]
关键点:
- 每个
FROM都是独立阶段,互不影响。 AS给阶段命名,方便后面引用。COPY --from=build-stage从指定阶段拷文件,而不是从宿主机拷。- 最终镜像是最后一个阶段,前面的阶段只是「脚手架」,不会出现在最终镜像里。
一个完整示例:Java 应用
把前面的 Spring Boot 例子改成多阶段:
# 阶段 1:构建,用完整 JDK
FROM eclipse-temurin:21.0.8_9-jdk-jammy AS builder
WORKDIR /opt/app
COPY .mvn/ .mvn
COPY mvnw pom.xml ./
RUN ./mvnw dependency:go-offline
COPY ./src ./src
RUN ./mvnw clean install
# 阶段 2:运行,只用精简 JRE
FROM eclipse-temurin:21.0.8_9-jre-jammy AS final
WORKDIR /opt/app
EXPOSE 8080
COPY --from=builder /opt/app/target/*.jar /opt/app/app.jar
ENTRYPOINT ["java", "-jar", "/opt/app/app.jar"]
拆开看:
- 阶段 1(builder):用包含 JDK 的镜像,把源码拷进去,跑
mvn clean install编译出 jar 包。这一阶段镜像很大,但没关系,它只是临时脚手架。 - 阶段 2(final):换成只含 JRE 的精简镜像,从 builder 阶段拷出编译好的 jar 包,定义启动命令。
构建完看看体积对比:
$ docker images
REPOSITORY TAG SIZE
spring-app-multistage latest 428MB
spring-app-singlestage latest 880MB
从 880 MB 降到 428 MB,砍掉了一半多。砍掉的就是 JDK、Maven、源码和构建缓存。
Tip想进一步精简,可以用
jlink工具生成只含所需模块的最小 JRE,而不是用现成的 JRE 镜像。这对 Java 应用能再压缩一大截,官方 Eclipse Temurin 镜像文档有详细说明。
编译型语言最能受益
多阶段构建对编译型语言效果最明显,因为编译器和运行时是完全分开的。
Go 的例子
Go 编译出来的是个静态二进制,运行时啥都不需要。多阶段构建能让最终镜像只有十几 MB:
# 阶段 1:编译
FROM golang:1.23 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /app/server ./cmd/server
# 阶段 2:运行,用极简基础镜像
FROM gcr.io/distroless/static-debian12 AS final
COPY --from=builder /app/server /server
ENTRYPOINT ["/server"]
最终阶段用的是 distroless(无发行版)镜像,里面连 shell 都没有,只有运行时必需的库,体积小、攻击面极小。
C / Rust 同理
C 和 Rust 编译出的二进制,如果静态链接,最终镜像甚至可以 FROM scratch—一个空镜像,只放你的二进制进去:
FROM rust:1.82 AS builder
WORKDIR /app
COPY . .
RUN cargo build --release
FROM scratch AS final
COPY --from=builder /app/target/release/myapp /myapp
ENTRYPOINT ["/myapp"]
scratch 是真正的「空」,镜像里只有你拷进去的那个二进制,体积等于二进制本身的大小,通常几 MB。
Warning用
scratch时要注意:里面没有 shell、没有ls、没有cat,调试很不方便。而且如果二进制是动态链接的,依赖的.so文件也得一起拷进去,否则跑不起来。静态编译是前提。
解释型语言也能用
解释型语言(Python、Node、Ruby)没有「编译」这一步,但多阶段构建依然有用。
典型场景是:构建阶段做转译、压缩、装全量依赖;运行阶段只带生产依赖和转译后的产物。
# 阶段 1:构建,装全部依赖并打包
FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json yarn.lock ./
RUN yarn install
COPY . .
RUN yarn build
# 阶段 2:运行,只装生产依赖,拷构建产物
FROM node:22-alpine AS final
WORKDIR /app
ENV NODE_ENV=production
COPY package.json yarn.lock ./
RUN yarn install --production --frozen-lockfile
COPY --from=builder /app/dist ./dist
CMD ["node", "dist/index.js"]
这样最终镜像里不会有 devDependencies、不会有源码、不会有 TypeScript 编译器,只有运行需要的东西。
只构建指定阶段
有时候你想单独测试构建阶段,不想跑完整流程。用 --target 指定构建到哪个阶段为止:
docker build --target builder -t myapp-builder .
这条命令只构建到 builder 阶段就停,产出的镜像里有完整的构建环境和编译产物,方便你进去排查编译问题。
不加 --target 时,Docker 默认构建最后一个阶段。
多阶段构建的好处再强调一遍
总结一下为什么多阶段构建是官方推荐的写法:
- 镜像更小:只带运行必需的东西,分发快、存储省。
- 更安全:构建工具、源码、密钥都不会进最终镜像,攻击面大幅收窄。
- 构建缓存友好:构建阶段改代码不影响最终阶段的拉取,CI 里推送和拉取都更快。
- 一个 Dockerfile 搞定:不用再写两个 Dockerfile 加一个脚本去拼,所有逻辑集中在一处,可维护。
Note多阶段构建依赖 BuildKit。Docker Engine 29.x 默认启用 BuildKit,开箱即用。如果你看到构建日志是老式的
Step 1/10 :而不是[+] Building的树状输出,说明没启用 BuildKit,多阶段构建虽然能用,但并行和缓存优化会打折扣。
下一章我们讲怎么把构建好的镜像推到 Docker Hub 和国内镜像仓库,让镜像能在任何机器上跑起来。