刚接触Docker时,很多团队都会遇到一个尴尬的场景:一个简单的Go或Node.js应用,打包出来的镜像动辄几百MB甚至上GB。每次拉取、部署都要漫长等待,CI/CD pipeline也因此变得迟缓。更令人担忧的是,镜像中包含了编译器、依赖库、临时文件、甚至测试代码——这些冗余不仅浪费存储空间,还为安全漏洞提供了藏身之处。
解决这个问题的经典方法有两种:一是使用Alpine等精简基础镜像,二是将构建和运行分到两个Dockerfile,再通过脚本或外部逻辑组合。但前者可能遇到兼容性问题(比如某些底层库依赖glibc),后者则让CI流程变得笨重。直到Docker 17.05引入多阶段构建(Multi-stage build),一切才变得优雅起来。
多阶段构建的核心思路是:在同一个Dockerfile里定义多个阶段(stage),每个阶段使用不同的基础镜像。前面的阶段负责编译、测试、生成工件,后面的阶段只从前面复制必要的文件到最终镜像。这样最终的镜像干净、最小化,同时Dockerfile也保持单一文件的管理便利性。
一、多阶段构建如何实现瘦身?先看原理
传统单阶段构建的做法通常是这样的:
# 传统方式:一个阶段完成所有事
FROM golang:1.20 AS builder
WORKDIR /app
COPY . .
RUN go build -o myapp .
FROM ubuntu:22.04
COPY --from=builder /app/myapp /myapp
CMD ["/myapp"]
等等,这看似用了两个FROM,但实际上这不是多阶段构建吗?不,上面这个例子确实是多阶段构建(因为它用了AS别名和COPY --from)。但更常见的反模式是:
# 反例:一个巨大的单体阶段
FROM golang:1.20
WORKDIR /app
COPY . .
RUN go build -o myapp .
CMD ["./myapp"]
这样打包出来的镜像包含了Go编译器、系统包管理器、完整的操作系统工具链,体积轻松超过800MB。而多阶段构建可以将最终镜像缩小到10MB左右(仅包含静态编译的可执行文件)。
其瘦身本质是分离构建时依赖与运行时依赖。编译阶段需要gcc、头文件、npm、pip等,但运行阶段只需要二进制文件和运行时库。多阶段构建允许我们在构建阶段使用富镜像(如golang:1.20),而在运行阶段使用最小镜像(如alpine甚至scratch)。
二、实战:用多阶段构建一个Go HTTP服务
假设我们有一个极简的Go HTTP服务器(main.go):
package main
import (
"fmt"
"net/http"
)
func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Hello, Docker Multi-stage!")
})
http.ListenAndServe(":8080", nil)
}
第一步:编写构建阶段的Dockerfile(约定使用AS命名阶段)
# 阶段1:构建阶段
FROM golang:1.20 AS builder
WORKDIR /app
# 先复制go.mod和go.sum以利用缓存
COPY go.mod go.sum ./
RUN go mod download
# 复制源码并编译
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o myapp .
这里我们使用CGO_ENABLED=0禁用CGO以生成纯静态二进制,并用-ldflags="-s -w"去掉调试信息,进一步缩减体积。
第二步:添加运行阶段(第二个FROM)
# 阶段2:运行阶段
FROM alpine:3.19
# 安装必要的运行时库(如果应用需要)
RUN apk add --no-cache ca-certificates tzdata
# 从builder阶段复制编译好的二进制
COPY --from=builder /app/myapp /myapp
# 设置容器启动命令
CMD ["/myapp"]
最终的Dockerfile合并后:
FROM golang:1.20 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o myapp .
FROM alpine:3.19
RUN apk add --no-cache ca-certificates tzdata
COPY --from=builder /app/myapp /myapp
CMD ["/myapp"]
效果验证:
运行docker build -t my-app .后,使用docker images查看:传统单阶段镜像约800MB,多阶段镜像仅约15MB(其中Alpine本身约7MB,二进制约8MB)。如果改为FROM scratch(完全空的基础镜像),还可以再压缩到8MB左右,但需要额外处理证书和时区文件。
三、多阶段构建的进阶技巧
1. 命名阶段与选择性复制
阶段名不一定要用builder,可以是dev、test、build-linux等。从其他阶段复制文件时,可以指定阶段名和源路径。例如:
COPY --from=builder /app/myapp /
COPY --from=config-stage /app/config.yaml /config/
2. 利用构建缓存加速
多阶段构建的每个阶段都是独立的缓存层。在COPY . .之前先COPY go.mod go.sum ./,可以让依赖下载层只在go.mod等文件变化时才失效。对于Node.js项目同理:先复制package.json和package-lock.json,再npm install,最后复制源码。
3. 合并多个阶段实现测试-构建-部署流水线
你可以在同一个Dockerfile完成单元测试、代码检查、构建、最终镜像产出:
FROM golang:1.20 AS test
WORKDIR /app
COPY . .
RUN go test -v ./...
FROM golang:1.20 AS builder
WORKDIR /app
COPY --from=test /app /app
RUN go build -o myapp .
FROM alpine:3.19
COPY --from=builder /app/myapp /
CMD ["/myapp"]
这样,如果测试阶段失败,构建过程会提前终止,确保只有通过测试的代码才能进入最终镜像。
4. 多架构支持(交叉编译)
编译阶段可以交叉编译多个架构的二进制,然后在运行阶段分别复制。例如:
FROM golang:1.20 AS builder
WORKDIR /app
COPY . .
RUN GOARCH=amd64 go build -o myapp.linux.amd64 .
RUN GOARCH=arm64 go build -o myapp.linux.arm64 .
FROM alpine:3.19
ARG TARGETARCH
COPY --from=builder /app/myapp.linux.${TARGETARCH} /myapp
CMD ["/myapp"]
使用时配合docker build --platform linux/amd64 --build-arg TARGETARCH=amd64 ...。
四、常见陷阱与避坑指南
陷阱1:忘记复制运行时依赖
如果应用是动态链接的(如apt-get install的某些库),直接复制到scratch镜像中会报“No such file or directory”。解决方法:要么静态编译,要么在运行阶段安装所需的共享库。例如:
RUN ldd /myapp # 在builder阶段检查依赖
然后根据输出在最终阶段安装对应的libc6、libssl等。
陷阱2:过多层导致镜像膨胀
每个RUN、COPY都会创建新层。尽量合并RUN命令,并在同一层中清理临时文件。例如:
RUN apk add --no-cache curl &&
curl -Lo /tmp/some-tool.tar.gz https://... &&
tar -xzf /tmp/some-tool.tar.gz -C /opt &&
rm -rf /tmp/*
陷阱3:混淆阶段中的变量作用域
每个阶段的ENV是隔离的。如果想在多个阶段共享环境变量,需要使用--build-arg或通过文件传递。
陷阱4:忽略安全扫描
多阶段构建虽然瘦身,但不能保证镜像安全。建议在CI中加入docker scout或trivy扫描最终镜像,特别是从Alpine安装的软件包可能存在漏洞。
五、总结
Docker多阶段构建是容器化应用从“能用”到“好用”的关键技术。它不需要额外工具、不需要修改CI脚本,只靠一个Dockerfile就能将镜像体积减少80%~95%,同时保持构建过程透明可复现。
在实际项目中,建议从以下三点入手:
- 静态编译(如Go的CGO_ENABLED=0)得到纯二进制,可以配合
scratch镜像实现极致瘦身。 - 利用缓存优化构建速度,将经常变化的部分放到底部。
- 合并阶段时注意层数管理和依赖完整性。
花半小时重构你项目中的Dockerfile,换来的是更快的部署、更低的存储成本和更小的攻击面。如果你还在使用单阶段构建,现在就可以开始尝试——多阶段构建的回报几乎立竿见影。
延伸阅读
