运维笔记

Docker 多阶段构建优化配置指南:从 2GB 到 150MB 的镜像瘦身实战

Developer Tools 技术可视化

别再用单阶段构建了,真的

上周我们一个 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,实际上该大的还是大,该慢的还是慢。

多阶段构建到底解决了什么问题?

先别急着抄代码,搞明白原理才能写出好配置。

传统单阶段构建的痛点说白了就三个:

  1. 镜像体积膨胀——构建时需要的所有工具链、中间文件、缓存,全打包进最终产物
  2. 安全攻击面扩大——gcc、make、curl、wget 这些你开发时离不开的东西,在生产环境里就是定时炸弹
  3. 构建缓存失效——稍微改一行代码,整个依赖层可能都要重下

多阶段构建的核心思路其实特别朴素:你盖房子的时候不会把脚手架也搬进新家吧?

每个 FROM 指令开启一个新阶段,你可以从一个阶段里复制文件到另一个阶段,但只有最后一个阶段会被打包成最终镜像。这意味着你可以把整个构建环境放在第一阶段,然后只把编译好的二进制文件复制到一个干净的 scratchalpine 镜像里。

# 第一阶段:构建
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 把依赖安装到独立目录。

但这还不够。我们又做了两步:

  1. 把 PyTorch 的 CUDA 版本换成 CPU 版本(我们推理不用 GPU)
  2. pip install --no-deps 手动管理依赖,去掉不必要的传递依赖

最终镜像从 4.7GB 降到了 890MB。还是很大?对,但这是 Python 的科学计算生态决定的。关键是,这个镜像里没有任何构建工具链,安全扫描通过率从 60% 升到了 95%。

多阶段构建的高级技巧

命名阶段和选择性构建

别用默认的 012 这种数字索引,给阶段起个有意义的名字:

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

不同语言的最佳实践对比

语言基础镜像选择构建阶段镜像最终镜像大小参考关键技巧
Goscratch 或 distrolessgolang:alpine5-20MBCGO_ENABLED=0 静态编译
Rustscratchrust:slim5-30MB–target=x86_64-unknown-linux-musl
Node.jsnode:alpine 或 distrolessnode:1880-200MBnpm ci –only=production
Pythonpython:slim 或 distrolesspython:3.11-slim100-900MBpip install –no-cache-dir –prefix
Javaeclipse-temurin:alpinemaven:3.980-300MB分层构建,分离依赖和代码
.NETmcr.microsoft.com/dotnet/aspnet:8.0mcr.microsoft.com/dotnet/sdk:8.0100-400MBdotnet 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  # 别这么干!

如果你在最终阶段安装了 curlwgetvim 这些工具,那多阶段构建的意义就没了。这些工具每个都可能引入漏洞。真要调试,用 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 GB15 MB98.75%
构建时间(首次)180s200s-11%
构建时间(缓存命中)45s50s-11%
镜像拉取时间(100Mbps)96s1.2s98.75%
容器启动时间3s0.5s83%
安全漏洞数(CVE)45295.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 的差异)。

Elvin Hui

关于作者:Elvin Hui

Elvin 拥有 10+ 年企业级数据中心、云原生架构和网络安全经验。持有 CCNA、AWS 解决方案架构师认证。我致力于将一线的“踩坑”经验沉淀为真实、硬核的技术指南,拒绝空洞理论。