多阶段构建(Multi-stage Build)
是什么
Dockerfile 17.05+ 引入的特性。一个 Dockerfile 里可以写多个 FROM 指令,每个 FROM 开启一个新的构建阶段,前一阶段的产物可以被后续阶段选择性 COPY --from 进来,最终只输出最后一个阶段的镜像。
传统构建: 多阶段构建:
┌──────────────────────┐ ┌──────────────────────┐
│ FROM golang:1.22 │ │ FROM golang:1.22 AS build│
│ RUN go build │ │ RUN go build │
│ # 编译工具全留在镜像里 │ │ COPY --from=build ... │
│ # 镜像 800MB+ │ │ FROM alpine │
└──────────────────────┘ │ # 镜像 20MB │
└──────────────────────┘
本质目的:让编译环境和运行环境解耦,扔掉所有构建期依赖。
三个核心机制
| 机制 | 作用 | 关键语法 |
|---|---|---|
AS <名字> |
给当前阶段起名,后续 --from 引用 |
FROM golang:1.22 AS build |
COPY --from=<阶段> |
从指定阶段拷贝文件到当前阶段 | COPY --from=build /app/myapp . |
| 隐式阶段编号 | 不命名时按 0, 1, 2... 自动编号 |
COPY --from=0 ... |
FROM golang:1.22 AS build # 显式命名 build
FROM node:18 AS frontend # 显式命名 frontend
FROM alpine:3.19 # 最后一个 FROM 不用命名,是最终镜像
COPY --from=build /app/server /bin/
COPY --from=frontend /app/dist /www/
PS:
AS名字必须小写,不能大写(Docker 规范)- 只有最后一个阶段会被作为最终镜像输出,前面的全部丢弃
- 中间阶段仍会作为镜像缓存层保存在本地(用
docker images能看到<none>标签的中间镜像),可以用docker build --target <阶段名>单独输出某一阶段
实战模板
Go 二进制
# 阶段一:编译
FROM golang:1.22 AS build
WORKDIR /src
# 先 copy go.mod 单独一层,依赖没变就不重新 go mod download
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /out/app .
# 阶段二:scratch 极简运行时
FROM scratch
COPY --from=build /out/app /app
# scratch 没有 ca-certificates,访问 https 要复制
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
ENTRYPOINT ["/app"]
# 最终镜像通常 < 20MB
docker build -t myapp:v1 .
docker images myapp
Node 前端 + Go 后端 同构建
# 阶段一:编译前端
FROM node:18-alpine AS frontend
WORKDIR /web
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build # 输出到 /web/dist
# 阶段二:编译后端
FROM golang:1.22 AS backend
WORKDIR /go/src/app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /out/server .
# 阶段三:最终运行时(前端产物嵌进后端镜像)
FROM alpine:3.19
RUN apk add --no-cache ca-certificates tzdata
COPY --from=frontend /web/dist /www/dist
COPY --from=backend /out/server /bin/server
EXPOSE 8080
ENTRYPOINT ["/bin/server"]
阶段作为测试环境
FROM golang:1.22 AS test
WORKDIR /src
COPY . .
RUN go test ./... # 这一阶段失败 = 整个 build 失败
FROM golang:1.22 AS build
WORKDIR /src
COPY . .
RUN go build -o /out/app .
FROM alpine:3.19
COPY --from=build /out/app /app
ENTRYPOINT ["/app"]
# 想跑测试不构建最终镜像
docker build --target test -t myapp:test .
docker run --rm myapp:test
进阶技巧
1. 复用 ARG 跨阶段
ARG VERSION=1.0.0
ARG GO_VERSION=1.22
FROM golang:${GO_VERSION} AS build
ARG VERSION
RUN go build -ldflags="-X main.version=${VERSION}" -o /out/app .
FROM alpine:3.19
ARG VERSION
LABEL version=${VERSION}
COPY --from=build /out/app /app
2. 外部镜像作为阶段源
--from 不只能引用当前 Dockerfile 的阶段,还能直接用任意已存在的镜像:
FROM alpine:3.19
# 直接从官方 nginx 镜像里挖配置文件
COPY --from=nginx:1.25 /etc/nginx/nginx.conf /etc/nginx/nginx.conf
COPY ./dist /usr/share/nginx/html
用途:从官方镜像里挑工具用,避免自己装。
3. 利用构建缓存
构建缓存按指令顺序和文件内容 hash 命中。多阶段构建的缓存策略:
FROM golang:1.22 AS build
WORKDIR /src
# 先 copy 依赖文件 → 这一层独立缓存
COPY go.mod go.sum ./
RUN go mod download
# 再 copy 源码 → 源码改了不影响 go mod download 那层
COPY . .
RUN go build -o /out/app .
源码改动 go mod download 那层 go build 那层
↓ 已缓存,跳过 ↓ 重新执行
时间: 30s → 1s + 5s = 6s (而不是 30s+)
# 跨机器复用缓存
docker buildx build \
--cache-to type=registry,ref=ghcr.io/me/myapp:cache,mode=max \
--cache-from type=registry,ref=ghcr.io/me/myapp:cache \
-t myapp:v1 .
4. BuildKit 内联构建(更现代的写法)
Docker 23+ 配合 BuildKit,支持单 RUN 内多阶段、挂载缓存等:
# syntax=docker/dockerfile:1.7
FROM golang:1.22 AS build
WORKDIR /src
COPY . .
RUN --mount=type=cache,target=/root/.cache/go-build \
--mount=type=bind,source=go.sum,target=go.sum \
CGO_ENABLED=0 go build -o /out/app .
| 挂载类型 | 作用 |
|---|---|
type=cache |
编译缓存持久化到独立卷,构建间复用 |
type=bind |
精确控制哪些文件变更触发重建 |
type=tmpfs |
临时内存盘(如挂 /tmp) |
type=secret |
注入密钥,不入镜像层(如 npm token) |
最佳实践
| 实践 | 为什么 |
|---|---|
最终阶段用 alpine 或 scratch |
基础镜像小,CVE 少 |
CGO_ENABLED=0 编译 Go |
产出静态二进制,可直接跑在 scratch |
-ldflags="-s -w" |
去掉符号表和 DWARF,体积再缩 ~25% |
| 阶段名字用动词或角色 | build / frontend / test,比 stage1 清晰 |
每个 FROM 用精确 tag |
别用 latest,构建不可复现 |
| 依赖文件先 COPY 单独一层 | 利用缓存,源码改动不重装依赖 |
COPY 而非 ADD |
ADD 会自动解压还支持 URL,副作用多 |
| 多阶段别超过 3-4 段 | 阶段太多 Dockerfile 难读,缓存粒度也细 |
| 最终镜像不装 curl/wget | 调试可以临时 --target build 进去看 |
常见坑
scratch 镜像跑不起来
scratch 是空镜像,没有 /etc/passwd、/etc/ssl/certs、/lib/ld-linux.so 等:
症状:
exec /app: no such file or directory # 动态链接库缺失
x509: certificate signed by unknown authority # ca 证书缺失
解决:
1. 用 CGO_ENABLED=0 编译,产出纯静态二进制
2. 复制 ca-certificates
3. 非必要别用 scratch,用 alpine 更省心
中间阶段镜像堆积
每次构建都生成一堆 <none> 标签的中间镜像:
docker images | grep none
docker image prune -f # 清掉 dangling 镜像
# 想全清(包括 build cache)
docker builder prune -af
用了 BuildKit 特性但 engine 不支持
# 现象:--mount=type=cache 报 unknown flag
# 解决:
DOCKER_BUILDKIT=1 docker build . # 旧 docker
# 或直接装 docker 23+,默认开 BuildKit