4. 多阶段构建核心概念
多阶段构建(Multi-stage Build)是 Docker 17.05 引入的关键特性,它允许在单个 Dockerfile 中使用多个 FROM 指令,每个 FROM 可以基于不同的基础镜像,并选择性地将产物从一个阶段复制到另一个阶段。这一机制从根本上解决了传统构建中镜像体积膨胀、构建依赖残留、安全风险高等痛点。
4.1 多阶段构建语法
基本语法结构如下:
# 第一阶段:编译环境
FROM golang:1.21-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /app/service ./cmd
# 第二阶段:运行环境
FROM alpine:3.19
RUN apk add --no-cache ca-certificates tzdata
COPY --from=builder /app/service /usr/local/bin/service
EXPOSE 8080
CMD ["service"]
语法要点:
- 多个 FROM 指令:每个
FROM开启一个新阶段,阶段编号从 0 开始递增。 - AS 命名:使用
AS <name>为阶段命名,便于后续引用。 - COPY --from:从指定阶段复制文件,而非从构建上下文或远程仓库。
- 阶段隔离:每个阶段独立缓存,未使用的阶段不会被包含在最终镜像中。
最佳实践:始终为阶段命名(如
AS builder),避免使用数字索引。当 Dockerfile 重构或插入新阶段时,数字索引容易出错,而命名引用更稳定、可读性更强。
4.2 阶段命名与引用
阶段命名支持三种引用方式:
| 引用方式 | 语法示例 | 适用场景 |
|---|---|---|
| 阶段名称 | COPY --from=builder |
最常用,语义清晰,推荐使用 |
| 阶段索引 | COPY --from=0 |
快速原型,但重构时易断裂 |
| 外部镜像名称 | COPY --from=nginx:alpine |
从已发布的镜像中提取文件 |
阶段命名规则:
- 名称区分大小写,建议使用小写加下划线(如
build_frontend)。 - 同一 Dockerfile 中阶段名称必须唯一。
- 阶段名称仅在当前 Dockerfile 内有效,不可跨文件引用。
- 未命名的阶段可通过索引引用,但索引从 0 开始,且随阶段顺序变化。
注意:使用
COPY --from=nginx:alpine 引用外部镜像时,Docker 会拉取该镜像并从中复制文件,但不会将其作为最终镜像的基础层。这种方式适合提取静态资源、配置文件或二进制工具,但会增加构建时间。
4.3 构建产物传递机制
多阶段构建的核心价值在于精确控制产物传递。产物传递遵循以下机制:
- 文件级传递:通过
COPY --from=<阶段> <源路径> <目标路径>复制文件或目录。 - 仅复制需要的文件:不复制整个文件系统,只复制指定的构建产物(如二进制文件、JAR 包、静态资源)。
- 层继承:每个阶段从基础镜像开始构建,但
COPY --from引入的文件会形成新的镜像层。 - 未使用阶段自动丢弃:最终阶段未引用的中间阶段不会出现在最终镜像中,但会保留在构建缓存中。
产物传递示例:Java 微服务
第一阶段使用 maven:3.8-openjdk-11 编译并打包为 JAR;第二阶段使用 openjdk:11-jre-slim 运行,仅复制 JAR 文件。最终镜像体积从 700MB 降至 180MB,且不包含 Maven、编译器、源码等构建依赖。
第一阶段使用 maven:3.8-openjdk-11 编译并打包为 JAR;第二阶段使用 openjdk:11-jre-slim 运行,仅复制 JAR 文件。最终镜像体积从 700MB 降至 180MB,且不包含 Maven、编译器、源码等构建依赖。
4.4 与传统构建对比
传统单阶段构建与多阶段构建在多个维度存在显著差异:
| 对比维度 | 传统单阶段构建 | 多阶段构建 |
|---|---|---|
| 基础镜像 | 通常使用包含完整 SDK 的大镜像(如 golang:1.21) | 编译阶段使用 SDK 镜像,运行阶段使用精简镜像(如 alpine) |
| 构建依赖 | 构建工具、依赖库、临时文件全部保留在镜像中 | 构建依赖仅存在于中间阶段,最终镜像只包含运行时所需文件 |
| 镜像体积 | 通常 500MB ~ 2GB | 通常 10MB ~ 200MB,体积减少 70%~95% |
| 安全风险 | 包含编译器、调试工具、未使用的库,攻击面大 | 仅包含运行时最小依赖,攻击面显著缩小 |
| 构建速度 | 每次构建需重新下载 SDK 和依赖 | 利用 Docker 层缓存,未变更的阶段可复用缓存 |
| Dockerfile 复杂度 | 单一 FROM,逻辑简单但体积失控 | 多 FROM,需规划阶段分工,但可维护性更高 |
| 部署效率 | 镜像拉取慢,占用磁盘和网络带宽 | 镜像小,拉取快,适合 CI/CD 流水线和频繁部署 |
下图展示了传统构建与多阶段构建在产物传递路径上的核心差异:
4.5 多阶段构建的典型场景
多阶段构建在微服务架构中尤其适用,以下为常见场景:
- Go / Rust / C++ 编译型语言:编译阶段使用完整 SDK,运行阶段使用 scratch 或 alpine,镜像可压缩至 5~20MB。
- Java / Kotlin 应用:编译阶段使用 JDK + Maven/Gradle,运行阶段使用 JRE,去除编译器、依赖缓存和源码。
- 前端 + 后端分离:第一阶段构建前端静态资源(Node.js),第二阶段构建后端服务,第三阶段合并产物。
- Python / Node.js 解释型语言:第一阶段安装依赖并清理缓存,第二阶段仅复制依赖和代码,避免保留 pip/npm 缓存。
- 安全合规场景:最终镜像不包含任何构建工具、调试器、shell(使用 scratch 镜像),满足最小权限原则。
微服务实战案例:某电商平台将 12 个微服务从传统构建迁移到多阶段构建后,镜像总存储从 8.5GB 降至 1.2GB,CI/CD 流水线平均耗时从 18 分钟降至 6 分钟,安全漏洞扫描报告中的高危项从 47 个降至 3 个。
4.6 常见误区与避坑指南
| 误区 | 错误做法 | 正确做法 |
|---|---|---|
| 阶段命名混乱 | 使用 FROM node AS 1 或数字开头 |
使用语义化名称如 AS build_frontend |
| 复制整个目录 | COPY --from=builder /app /app |
只复制需要的文件:COPY --from=builder /app/dist /usr/share/nginx/html |
| 忽略缓存策略 | 所有阶段写在一个 RUN 指令中 | 拆分 RUN 指令,利用 Docker 层缓存加速构建 |
| 过度使用外部镜像引用 | 频繁使用 COPY --from=nginx:alpine |
仅在确实需要提取外部镜像文件时使用,避免增加构建网络开销 |
| 最终阶段使用大镜像 | 最终阶段仍使用 FROM ubuntu:22.04 |
优先选择 alpine、distroless 或 scratch 作为运行基础镜像 |