从一个真实案例说起

之前帮一个项目 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 多了安全补丁。

总结

多阶段构建三步走:

  1. 第一阶段用完整工具链构建(golang:1.22、node:20 等)
  2. 只把需要的文件复制到第二阶段
  3. 第二阶段用最小化基础镜像(alpine、scratch、distroless)

记住:镜像越小,部署越快,安全面越小。


下篇聊聊分布式追踪,用 OpenTelemetry 把微服务的调用链路可视化。