别再用单阶段构建了,真的
上周我们一个 Go 微服务的镜像居然有 1.2GB。我盯着那个数字看了半天,心想这他妈是个什么鬼——一个不到 2000 行代码的 HTTP API,打包完比一个 Ubuntu 桌面镜像还大。查了一下 Dockerfile,标准的单阶段构建:FROM golang:1.21,然后把整个 Go 工具链、源码、依赖全塞进去了。
这问题其实太普遍了。我见过团队把 Node.js 应用打包到 2GB+ 的镜像里,就因为用了 FROM node:18 作为基础镜像,然后整个 node_modules 都带进了生产环境。更离谱的是,还有人把构建工具链、测试框架、甚至 linter 都留在最终镜像里——这不仅是空间浪费,更是安全灾难。
多阶段构建不是什么新鲜玩意儿,Docker 17.05 就引入了。但说真的,我见过太多人把它用成了"伪多阶段"——看起来分了几个 FROM,实际上该大的还是大,该慢的还是慢。
多阶段构建到底解决了什么问题?
先别急着抄代码,搞明白原理才能写出好配置。
传统单阶段构建的痛点说白了就三个:
- 镜像体积膨胀——构建时需要的所有工具链、中间文件、缓存,全打包进最终产物
- 安全攻击面扩大——gcc、make、curl、wget 这些你开发时离不开的东西,在生产环境里就是定时炸弹
- 构建缓存失效——稍微改一行代码,整个依赖层可能都要重下
多阶段构建的核心思路其实特别朴素:你盖房子的时候不会把脚手架也搬进新家吧?
每个 FROM 指令开启一个新阶段,你可以从一个阶段里复制文件到另一个阶段,但只有最后一个阶段会被打包成最终镜像。这意味着你可以把整个构建环境放在第一阶段,然后只把编译好的二进制文件复制到一个干净的 scratch 或 alpine 镜像里。
# 第一阶段:构建
FROM golang:1.21 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 .
# 第二阶段:运行
FROM alpine:3.19
RUN apk --no-cache add ca-certificates
COPY --from=builder /app/server /server
EXPOSE 8080
CMD ["/server"]
你看,最终镜像里只有 alpine 系统、ca-certificates、和一个编译好的二进制文件。Go 工具链?没了。源码?没了。依赖缓存?没了。这个 Dockerfile 打出来的镜像大概 15MB 左右,对比之前的 1.2GB,差了将近 80 倍。
真实案例:一个 Python ML 服务的镜像瘦身
上面那个 Go 的例子太简单了,我来分享一个我们团队最近做的真实案例。
我们有一个 Python 机器学习推理服务,依赖了一堆科学计算库——numpy、pandas、scikit-learn、torch。初始 Dockerfile 是这样的:
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "serve.py"]
猜猜多大?4.7GB。对,你没看错,快 5 个 G。主要是因为 PyTorch 的 CUDA 依赖。
我们用多阶段构建重构了一下:
# 第一阶段:安装依赖
FROM python:3.11-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
# 第二阶段:运行
FROM python:3.11-slim
WORKDIR /app
COPY --from=builder /install /usr/local
COPY . .
CMD ["python", "serve.py"]
等等,这有用吗?其实效果有限,因为 Python 的依赖还是带过去了。真正的优化在于第二阶段用 python:3.11-slim 而不是 python:3.11,并且通过 --prefix 把依赖安装到独立目录。
但这还不够。我们又做了两步:
- 把 PyTorch 的 CUDA 版本换成 CPU 版本(我们推理不用 GPU)
- 用
pip install --no-deps手动管理依赖,去掉不必要的传递依赖
最终镜像从 4.7GB 降到了 890MB。还是很大?对,但这是 Python 的科学计算生态决定的。关键是,这个镜像里没有任何构建工具链,安全扫描通过率从 60% 升到了 95%。
多阶段构建的高级技巧
命名阶段和选择性构建
别用默认的 0、1、2 这种数字索引,给阶段起个有意义的名字:
FROM node:18 AS dependencies
FROM node:18 AS build-stage
FROM nginx:alpine AS production
然后你可以用 --target 参数只构建到某个阶段:
# 只构建开发阶段,不执行后面的生产优化
docker build --target dependencies -t myapp:dev .
这在你调试构建过程时特别有用。我经常在 CI 里先跑 dependencies 阶段来缓存依赖,再跑完整的构建。
利用外部镜像作为阶段
这个技巧很多人不知道——你可以从外部镜像复制文件:
# 从官方 Alpine 镜像复制证书
FROM alpine:latest AS certs
RUN apk --no-cache add ca-certificates
# 从 distroless 镜像复制基础文件
FROM gcr.io/distroless/static:nonroot AS base
FROM scratch
COPY --from=certs /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=base / /
COPY --from=builder /app/server /server
这样你就能用 scratch 镜像作为基础,同时拥有 SSL 证书和基本的系统文件。scratch 镜像是 Docker 里最小的镜像——它真的是空的,没有任何文件系统。这意味着你的镜像只有你放进去的东西,攻击面几乎为零。
构建缓存优化
多阶段构建最大的坑之一就是缓存失效。来看一个常见的错误:
FROM golang:1.21 AS builder
WORKDIR /app
COPY . . # 先复制所有文件
RUN go mod download # 然后下载依赖
RUN go build -o server .
这段代码的问题在于,你每次修改任何源码文件,都会导致 go mod download 这层缓存失效,因为 COPY . . 这层变了。正确的做法是先复制依赖文件,下载依赖,再复制源码:
FROM golang:1.21 AS builder
WORKDIR /app
COPY go.mod go.sum ./ # 只复制依赖文件
RUN go mod download # 这层缓存只会在 go.mod/go.sum 变化时失效
COPY . . # 源码变化不影响依赖缓存
RUN go build -o server .
这个优化在 CI 里能省掉大量时间。我们有个项目,加上这个优化后,构建时间从 8 分钟降到了 2 分钟——因为 90% 的提交都不改 go.mod。
不同语言的最佳实践对比
| 语言 | 基础镜像选择 | 构建阶段镜像 | 最终镜像大小参考 | 关键技巧 |
|---|---|---|---|---|
| Go | scratch 或 distroless | golang:alpine | 5-20MB | CGO_ENABLED=0 静态编译 |
| Rust | scratch | rust:slim | 5-30MB | –target=x86_64-unknown-linux-musl |
| Node.js | node:alpine 或 distroless | node:18 | 80-200MB | npm ci –only=production |
| Python | python:slim 或 distroless | python:3.11-slim | 100-900MB | pip install –no-cache-dir –prefix |
| Java | eclipse-temurin:alpine | maven:3.9 | 80-300MB | 分层构建,分离依赖和代码 |
| .NET | mcr.microsoft.com/dotnet/aspnet:8.0 | mcr.microsoft.com/dotnet/sdk:8.0 | 100-400MB | dotnet publish –self-contained false |
这里有个关键点:别盲目追求最小镜像。scratch 镜像虽然只有 0 字节,但调试起来很痛苦——你连 sh 都没有,出问题了只能干瞪眼。我一般推荐 Go 和 Rust 用 gcr.io/distroless/static,它比 scratch 多了 /etc/passwd、SSL 证书、和一些基本的调试工具,但只有 2MB 左右。
一个完整的 CI/CD 多阶段构建模板
下面这个是我在实际生产环境里用的模板,包含了开发、测试、构建、安全扫描四个阶段:
# Stage 1: 开发依赖
FROM node:18-alpine AS deps
WORKDIR /app
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile
# Stage 2: 测试
FROM deps AS test
COPY . .
RUN yarn lint && yarn test
# Stage 3: 构建
FROM deps AS build
COPY . .
RUN yarn build
# Stage 4: 生产镜像
FROM nginx:alpine AS production
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
在 CI 里,你可以这样用:
# GitHub Actions 示例
jobs:
build:
steps:
- uses: actions/checkout@v4
- name: Build test stage
run: docker build --target test -t myapp:test .
- name: Run security scan
run: docker run myapp:test yarn audit
- name: Build production image
run: docker build --target production -t myapp:${{ github.sha }} .
常见翻车现场
翻车 1:COPY –from 没有使用正确的路径
# 错误
COPY --from=builder /app/server /app/server
# 正确
COPY --from=builder /app/server /server
如果你在第二阶段没有设置 WORKDIR /app,那 /app/server 这个路径就不存在,文件会被复制到根目录下的 app/server 目录。我见过有人因为这个 bug 调试了一下午。
翻车 2:在最终阶段保留包管理器
FROM ubuntu:22.04
COPY --from=builder /app/server /server
RUN apt-get update && apt-get install -y curl # 别这么干!
如果你在最终阶段安装了 curl、wget、vim 这些工具,那多阶段构建的意义就没了。这些工具每个都可能引入漏洞。真要调试,用 docker exec -it 配合 kubectl debug 的临时容器。
翻车 3:多阶段构建的层数太多
Docker 镜像最多支持 127 层,多阶段构建每多一个 FROM 就多一层。虽然大多数情况下不会超过限制,但层数太多会影响镜像拉取性能。我一般控制在 3-5 个阶段。
从社区看到的真实反馈
最近在 Reddit 上看到一个帖子,有人分享了一个 ComfyUI 的 Docker 安全部署方案,用了多阶段构建来隔离环境。评论区的讨论很有意思——很多人说他们之前一直在用单阶段构建,因为"懒得改"。
有个开发者的评论让我印象很深:“我花了 3 小时优化 Dockerfile,把镜像从 1.8GB 降到了 120MB。结果部署的时候发现网络带宽是瓶颈,镜像大小根本无所谓。” 哈哈,确实,有时候你费劲优化半天,发现真正的瓶颈在别处。但话说回来,小镜像的部署速度、安全性和资源占用,长期来看还是值得的。
另一个常见的抱怨是:多阶段构建让 Dockerfile 变得复杂,新人看不懂。我的建议是,在 Dockerfile 里加注释,或者在 README 里画个简单的流程图。别为了"简洁"牺牲可维护性。
性能对比:多阶段 vs 单阶段
graph TD
A[源码] --> B[单阶段构建]
A --> C[多阶段构建]
B --> D[镜像大小: 1.2GB]
B --> E[构建时间: 180s]
B --> F[安全漏洞: 45个]
C --> G[镜像大小: 15MB]
C --> H[构建时间: 200s]
C --> I[安全漏洞: 2个]
注意构建时间——多阶段构建通常会比单阶段慢一点,因为多了文件复制操作。但这个差距在 CI 缓存命中时会缩小。而且,小镜像的拉取和启动时间优势,远远超过那几十秒的构建时间差异。
| 指标 | 单阶段构建 | 多阶段构建 | 优化幅度 |
|---|---|---|---|
| 镜像大小 | 1.2 GB | 15 MB | 98.75% |
| 构建时间(首次) | 180s | 200s | -11% |
| 构建时间(缓存命中) | 45s | 50s | -11% |
| 镜像拉取时间(100Mbps) | 96s | 1.2s | 98.75% |
| 容器启动时间 | 3s | 0.5s | 83% |
| 安全漏洞数(CVE) | 45 | 2 | 95.6% |
总结:什么时候该用,什么时候别用
多阶段构建不是银弹。以下情况我建议你一定要用:
- 你的应用是编译型语言(Go、Rust、Java、C++)
- 你的生产环境对镜像大小有严格要求(比如边缘设备、IoT)
- 你的安全团队要求最小化攻击面
- 你的 CI/CD 流程中镜像拉取时间是瓶颈
以下情况可以不用或谨慎使用:
- 你的应用是纯解释型脚本,不需要构建步骤(比如简单的 Python 脚本)
- 你的团队对 Docker 不太熟悉,维护复杂 Dockerfile 成本太高
- 你的基础镜像已经很小了(比如
python:alpine),再优化收益不大
最后说一句:别为了炫技而用多阶段构建。我见过有人把一行 npm install 也单独拆成一个阶段,结果 Dockerfile 写了 50 行,实际效果跟 5 行的单阶段构建差不多。优化要有目标,不是为了优化而优化。
FAQ
多阶段构建能减少多少镜像大小?
取决于语言和依赖。编译型语言(Go、Rust)通常能减少 90-99%,解释型语言(Python、Node.js)通常能减少 30-60%。极端情况下,一个 Go 微服务可以从 1.2GB 降到 5MB。
多阶段构建和 distroless 镜像有什么区别?
多阶段构建是 Dockerfile 的构建策略,distroless 是 Google 提供的一组最小基础镜像。两者可以结合使用:在多阶段构建的最终阶段使用 distroless 镜像,效果最佳。
如何调试多阶段构建中的某个阶段?
使用 docker build --target <阶段名> -t <镜像名> . 只构建到指定阶段,然后用 docker run -it <镜像名> sh 进入该阶段调试。
多阶段构建对 CI/CD 性能有影响吗?
首次构建会比单阶段慢 10-20%,因为有额外的文件复制。但缓存命中后差异很小。而且小镜像的拉取和部署速度优势远超构建时间的微小增加。
可以在多阶段构建中使用不同操作系统吗?
可以。每个阶段可以使用不同的基础镜像。比如第一阶段用 Ubuntu 编译,第二阶段用 Alpine 运行。但要注意二进制兼容性——在 Ubuntu 上编译的二进制可能无法在 Alpine 上运行(因为 glibc 和 musl 的差异)。
