Docker多阶段构建(Multi-stage build)瘦身镜像实战

Docker镜像体积过大不仅拖慢部署,还增加安全风险。多阶段构建(Multi-stage build)通过在一个Dockerfile中叠加多个FROM指令,将构建环境与运行环境彻底分离,只保留运行所需的最小文件。本文从原理到实战,带你一步步掌握瘦身技巧,打造轻量级生产镜像。

Docker多阶段构建(Multi-stage build)瘦身镜像实战
封面图:ZuCDN · ZuCDN 原创

刚接触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,可以是devtestbuild-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.jsonpackage-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阶段检查依赖

然后根据输出在最终阶段安装对应的libc6libssl等。

陷阱2:过多层导致镜像膨胀

每个RUNCOPY都会创建新层。尽量合并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 scouttrivy扫描最终镜像,特别是从Alpine安装的软件包可能存在漏洞。

五、总结

Docker多阶段构建是容器化应用从“能用”到“好用”的关键技术。它不需要额外工具、不需要修改CI脚本,只靠一个Dockerfile就能将镜像体积减少80%~95%,同时保持构建过程透明可复现。

在实际项目中,建议从以下三点入手:

  • 静态编译(如Go的CGO_ENABLED=0)得到纯二进制,可以配合scratch镜像实现极致瘦身。
  • 利用缓存优化构建速度,将经常变化的部分放到底部。
  • 合并阶段时注意层数管理和依赖完整性。

花半小时重构你项目中的Dockerfile,换来的是更快的部署、更低的存储成本和更小的攻击面。如果你还在使用单阶段构建,现在就可以开始尝试——多阶段构建的回报几乎立竿见影。

延伸阅读