从一个真实案例说起
之前帮一个项目 Dockerize,初始镜像 800MB,每次部署慢得让人怀疑人生。后来用多阶段构建优化完,8MB,部署时间从 3 分钟变成 10 秒。
这篇文章讲讲多阶段构建的原理和实战技巧。
为什么镜像那么大
看个典型 Dockerfile:
FROM golang:1.22
WORKDIR /app
COPY . .
RUN go mod download
RUN go build -o myapp .
ENTRYPOINT ["./myapp"]
问题在哪?
golang:1.22镜像本身就 800MB+,因为包含了完整的编译工具链- 你的源代码、依赖缓存、编译中间产物全在里面
- 运行一个简单程序,凭什么需要 gcc、git、make ?
多阶段构建:原理很简单
Dockerfile 可以有多个 FROM 指令,每个阶段是独立的。前面的阶段可以复制文件到后面,最终镜像只包含最后一个阶段的内容。
# 第一阶段:构建
FROM golang:1.22 AS builder
WORKDIR /app
COPY . .
RUN go mod download
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o myapp .
# 第二阶段:运行
FROM alpine:3.19
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app
COPY --from=builder /app/myapp .
USER appuser
ENTRYPOINT ["./myapp"]
关键点:
AS builder给第一阶段起个名字COPY --from=builder从第一阶段复制文件,而不是用当前阶段的CGO_ENABLED=0静态编译,不依赖 glibc-ldflags="-w -s"去掉调试信息和符号表
结果:800MB → 8MB。
Go 项目的完整示例
# syntax=docker/dockerfile:1
# ============================================
# 第一阶段:依赖下载 + 缓存
# ============================================
FROM golang:1.22-alpine AS deps
RUN apk add --no-cache git
WORKDIR /app
COPY go.mod go.sum* ./
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
# ============================================
# 第二阶段:构建
# ============================================
FROM golang:1.22-alpine AS builder
RUN apk add --no-cache git ca-certificates
WORKDIR /app
COPY --from=deps /go/pkg/mod /go/pkg/mod
COPY . .
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=linux go build \
-ldflags="-w -s -extldflags=-static" \
-o myapp .
# ============================================
# 第三阶段:运行
# ============================================
FROM scratch AS runtime
IMPORT_SSL_CERTS=/etc/ssl/certs
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /app/myapp /myapp
COPY entrypoint.sh /entrypoint.sh
EXPOSE 8080
ENTRYPOINT ["/myapp"]
几个优化点解释
--mount=type=cache:缓存目录,即使重新构建也复用,不重新下载依赖。
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
FROM scratch:最精简的基础镜像,只有你复制进去的东西。
导入 SSL 证书:从 builder 复制 CA 证书,否则 HTTPS 请求会失败。
IMPORT_SSL_CERTS=/etc/ssl/certs
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
静态链接:CGO_ENABLED=0 + -ldflags=-extldflags=-static,连 libc 都不要。
对比其他语言
Node.js 多阶段构建:
# 构建阶段
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build
# 运行阶段
FROM node:20-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
CMD ["node", "dist/main.js"]
Python(需要编译的包):
FROM python:3.12-slim AS builder
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
libpq-dev
WORKDIR /app
COPY requirements.txt .
RUN pip install --user -r requirements.txt
FROM python:3.12-slim AS runtime
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq5 \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /root/.local /root/.local
ENV PATH=/root/.local/bin:$PATH
COPY . .
CMD ["python", "main.py"]
构建速度优化:并行 + 缓存
分层缓存失效的痛
# 这样每次改代码都会导致依赖重新下载
COPY . .
RUN go mod download
RUN go build .
正确的顺序
# 先复制依赖文件,缓存住
COPY go.mod go.sum* ./
RUN go mod download
# 再复制源代码
COPY . .
# 最后才 build
RUN go build .
改源代码时,go mod download 那一层会被缓存,只有最后 go build 重新执行。
用 BuildKit 获得更多特性
启用 BuildKit:
DOCKER_BUILDKIT=1 docker build .
# 或
docker buildx build .
好处:
--mount=type=cache缓存- 并行构建多个阶段
- 更好的错误信息
在 docker-compose.yml 里启用:
services:
web:
build:
context: .
dockerfile: Dockerfile
buildkit:
frontend: dockerfile.v0
features:
cache: true
镜像大小对比
| 方案 | 大小 | 构建时间 |
|---|---|---|
直接 golang:1.22 |
~800MB | ~2min |
| 多阶段 + alpine | ~15MB | ~3min |
| 多阶段 + scratch | ~8MB | ~3min |
| Distroless | ~12MB | ~3min |
Distroless 是 Google 出的最小化镜像,只包含运行时,比 scratch 多了安全补丁。
总结
多阶段构建三步走:
- 第一阶段用完整工具链构建(golang:1.22、node:20 等)
- 只把需要的文件复制到第二阶段
- 第二阶段用最小化基础镜像(alpine、scratch、distroless)
记住:镜像越小,部署越快,安全面越小。
下篇聊聊分布式追踪,用 OpenTelemetry 把微服务的调用链路可视化。