4. 多阶段构建概念:语法、阶段命名与文件复制

多阶段构建(Multi-stage Build)是 Docker 17.05 引入的核心特性,它允许在单个 Dockerfile 中使用多个 FROM 语句,每个 FROM 定义一个独立的构建阶段。前一阶段的产物(如编译后的二进制文件、依赖包)可以通过 COPY --from 精确复制到后续阶段,而前一阶段的基础镜像、临时文件、构建工具链则被完全丢弃。这一机制从根本上解决了传统单阶段构建中镜像体积膨胀的问题。

核心价值: 构建环境与运行环境彻底分离。你可以在包含完整 SDK、编译器、头文件的大型基础镜像中完成编译,然后将仅包含运行时二进制和必要依赖的小型产物复制到精简的运行时镜像(如 alpine、distroless)中,最终镜像只保留运行所需的最小文件集。

4.1 多阶段构建的语法结构

多阶段构建的语法极其简洁,核心只有三个关键词:FROM、AS、COPY --from。下面是一个典型的 Go 语言后端服务多阶段构建 Dockerfile 示例:

# 第一阶段:编译阶段
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/server ./cmd/server

# 第二阶段:运行阶段
FROM alpine:3.19

RUN apk add --no-cache ca-certificates tzdata

WORKDIR /app
COPY --from=builder /app/server ./server

EXPOSE 8080
CMD ["./server"]

上述 Dockerfile 清晰地展示了多阶段构建的三个基本要素:

语法要素 作用 示例
多个 FROM 语句 每个 FROM 开启一个新的构建阶段,可基于不同的基础镜像 FROM golang:1.21-alpine AS builder
FROM alpine:3.19
阶段命名(AS) 为当前阶段指定一个逻辑名称,供后续阶段引用 AS builder
阶段间文件复制(COPY --from) 从指定阶段复制文件到当前阶段,不依赖镜像层缓存 COPY --from=builder /app/server ./server
最佳实践: 第一阶段(builder)通常使用包含完整工具链的镜像(如 golang、node、maven),第二阶段使用极简运行时镜像(如 alpine、distroless/static)。阶段名称建议使用语义化命名(builder、dependencies、test、release),便于维护和团队协作。

4.2 阶段命名(AS)的引用机制

AS 关键字为阶段赋予一个字符串别名,后续阶段可以通过该别名引用前一阶段的产物。阶段名称区分大小写,建议统一使用小写加下划线或驼峰命名。除了通过名称引用,Docker 还支持通过阶段索引(从 0 开始)引用:

# 通过阶段名称引用(推荐)
COPY --from=builder /app/server ./server

# 通过阶段索引引用(不推荐,维护性差)
COPY --from=0 /app/server ./server

阶段索引的引用方式在 Dockerfile 调整阶段顺序时容易出错,因此生产环境中应始终使用 AS 命名并引用名称。阶段名称的作用域是整个 Dockerfile,即使某个阶段在后续未被引用,其名称依然有效。

4.3 阶段间文件复制(COPY --from)的细节

COPY --from 是多阶段构建的核心操作,它允许你从任意一个已完成的阶段中复制文件或目录到当前阶段。其语法与普通 COPY 指令一致,但多了 --from 参数:

COPY --from=<阶段名称或索引> <源路径> <目标路径>

关键行为规则:

  • 源路径:相对于前一阶段的工作目录(WORKDIR),也可以是绝对路径。
  • 目标路径:相对于当前阶段的工作目录,或绝对路径。
  • 文件权限:复制过来的文件保留原始权限(包括可执行权限),无需额外 RUN chmod。
  • 符号链接:默认复制符号链接指向的实际文件,而非链接本身(与普通 COPY 行为一致)。
  • 多文件复制:支持通配符,如 COPY --from=builder /app/dist/*.js ./。
常见陷阱: 如果 COPY --from 指定的阶段名称不存在或拼写错误,Docker 会在构建时报错 failed to copy: stage not found。另外,COPY --from 只能复制已完成阶段的文件系统内容,不能复制未执行指令的中间状态。

4.4 多阶段构建的流程示意图

下图展示了多阶段构建的完整数据流:第一阶段(builder)完成编译和依赖安装,第二阶段(runtime)只接收最终产物,中间层(SDK、临时文件、缓存)全部被丢弃。

多阶段构建流程:编译阶段 → 运行阶段 第一阶段:builder FROM golang:1.21-alpine 1. COPY go.mod go.sum ./ 2. RUN go mod download 3. COPY . . 4. RUN go build -o server 产物:/app/server(二进制) COPY --from=builder 第二阶段:runtime FROM alpine:3.19 1. RUN apk add ca-certificates 2. COPY --from=builder /app/server ./server 3. EXPOSE 8080 4. CMD ["./server"] 被丢弃的内容(不进入最终镜像) golang:1.21-alpine 基础镜像(~300MB) | Go 模块缓存 | 临时编译文件 | 未使用的系统工具 最终镜像仅包含 alpine:3.19 + server 二进制 + ca-certificates(~15MB)

4.5 多阶段构建 vs 传统单阶段构建

下表从镜像体积、构建速度、安全性、维护性四个维度对比两种构建模式:

对比维度 传统单阶段构建 多阶段构建
镜像体积 包含 SDK、编译工具、临时文件,体积通常 300MB~1GB+ 仅包含运行时二进制和必要依赖,体积可压缩至 10~50MB
构建速度 依赖层缓存,但每次修改源码需重新下载依赖(若层设计不佳) 依赖分层缓存,且编译阶段可独立缓存依赖层,增量构建更快
安全性 镜像中残留编译器、调试工具、源码,攻击面大 运行镜像极简,无 shell、无编译器,攻击面显著缩小
维护性 需手动清理临时文件,Dockerfile 复杂且易出错 Dockerfile 结构清晰,阶段职责单一,易于理解和维护
实战建议: 对于 Java 后端服务,可以在第一阶段使用 maven:3.9-eclipse-temurin-17 完成编译和打包,第二阶段使用 eclipse-temurin:17-jre-alpine 运行 jar 包。对于 Node.js 服务,第一阶段使用 node:20-alpine 执行 npm ci 和构建,第二阶段使用 node:20-alpine(仅复制 dist 和 node_modules --production)运行。

4.6 多阶段构建的扩展用法

除了标准的编译+运行两阶段模式,多阶段构建还支持更复杂的拓扑:

  • 三阶段模式:依赖安装阶段 → 编译阶段 → 运行阶段,将依赖下载与编译分离,进一步提升缓存命中率。
  • 测试阶段:在编译阶段之后插入一个测试阶段,运行单元测试或 lint,测试通过后再进入运行阶段。
  • 多产物输出:一个 Dockerfile 中定义多个运行阶段(如 API 服务、Worker 服务),通过 --target 参数选择构建哪个阶段。
# 多目标构建示例:通过 --target 选择构建产物
FROM node:20-alpine AS base
WORKDIR /app
COPY package*.json ./
RUN npm ci

FROM base AS build
COPY . .
RUN npm run build

FROM base AS development
RUN npm install --only=dev
CMD ["npm", "run", "dev"]

FROM nginx:alpine AS production
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

构建时通过 docker build --target production -t my-app:latest . 指定目标阶段,实现一个 Dockerfile 同时支持开发、测试、生产三种场景。

关键总结: 多阶段构建的语法只有三个核心要素——多个 FROM、AS 命名、COPY --from。理解这三者的组合规则,就能设计出体积小、安全性高、维护性好的 Docker 镜像。在实际后端发布拓扑中,多阶段构建是镜像瘦身的第一道也是最重要的一道防线。