Docker 多阶段构建实战:把镜像体积缩小 50 倍的工程姿势

第一次写 Dockerfile 的人,往往会得到一个 1GB+ 的镜像:把整个构建工具链、源代码、依赖、临时文件全打了进去。问题不在 Docker,在没有用多阶段构建(multi-stage build)。这是 Docker 17.05+ 引入的特性,几乎是免费的镜像瘦身术 —— 同样的功能,镜像从 1GB 缩到 50MB 不是难事。这篇文章用真实例子讲透。

先看一个反面教材

用 Go 写一个 hello-world 服务,新手 Dockerfile 经常这样:

FROM golang:1.22

WORKDIR /app
COPY . .
RUN go build -o server main.go
EXPOSE 8080
CMD ["./server"]

构建出来的镜像:

$ docker build -t hello:bad .
$ docker images hello:bad
hello:bad   latest   abc...   2 minutes ago   980MB

问题:Go 编译完之后,运行只需要那个二进制,但镜像里还带着 800MB+ 的 Go 工具链、依赖源码、编译缓存。生产部署里这些都是纯粹的浪费 —— 占磁盘、增加镜像拉取时间、扩大攻击面。

多阶段构建:Builder + Runner

多阶段构建让你在同一个 Dockerfile 里写多个 FROM,每个 FROM 开启一个新阶段。最终镜像只包含最后一个阶段的内容,中间阶段只用来生成产物。把上面的例子改造:

# --- 阶段 1:builder,只用来编译 ---
FROM golang:1.22-alpine AS builder
WORKDIR /app

# 先拷依赖文件,利用缓存层
COPY go.mod go.sum ./
RUN go mod download

# 再拷源代码,改源代码不会让 mod download 重跑
COPY . .

# 静态编译:CGO_ENABLED=0 让二进制不依赖 libc,可以放进 scratch / alpine
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/server main.go

# --- 阶段 2:runner,运行时的最小镜像 ---
FROM alpine:3.19
RUN apk add --no-cache ca-certificates tzdata
COPY --from=builder /out/server /usr/local/bin/server
EXPOSE 8080
USER nobody
ENTRYPOINT ["/usr/local/bin/server"]
$ docker build -t hello:good .
$ docker images hello:good
hello:good   latest   def...   30 seconds ago   18MB

从 980MB 到 18MB,体积差 54 倍,功能完全一样。关键操作:

  • FROM ... AS builder 给构建阶段命名。
  • COPY --from=builder 从命名阶段把指定文件拷贝过来。
  • builder 阶段的镜像不会出现在最终镜像里,Docker 会自动丢弃。

极致瘦身:用 scratch 或 distroless

如果你的二进制是完全静态编译的(Go、Rust 都能做到),连 alpine 都可以省掉,直接用 scratch(空镜像)或 Google 的 distroless(只有运行时所需的最小文件)。

FROM scratch
# scratch 没有 /etc/ssl,需要从 builder 拷过来才能 HTTPS
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /out/server /server
ENTRYPOINT ["/server"]
# 体积约 7MB

# 用 distroless 更稳妥,有基本的 /tmp、用户、CA 证书
FROM gcr.io/distroless/static:nonroot
COPY --from=builder /out/server /server
USER nonroot:nonroot
ENTRYPOINT ["/server"]

语言案例 1:Node.js

Node 项目的多阶段构建关注两件事:devDependencies 不进生产镜像,本地源代码尽可能少进镜像

FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci                          # 装所有依赖(含 dev)

FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build                   # 编译 TS / 打包 webpack
RUN npm prune --production          # 删掉 devDependencies

FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]

关键技巧:

  • npm cinpm install 更适合 CI:严格按 lock 文件装,装不上就报错。
  • 分两阶段拷 node_modules:deps 阶段装,builder 阶段用 + prune,这样 prune 失败不会让 deps 缓存失效。
  • USER node 让容器以非 root 运行,减小攻击面。

语言案例 2:Python

Python 没有"二进制产物"那么干脆,但仍能通过多阶段构建避免把 pip 缓存、编译工具链带进生产镜像。

FROM python:3.12-slim AS builder
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
WORKDIR /app

# 装编译型依赖(psycopg2、grpcio 这类要本地编译)
RUN apt-get update && apt-get install -y --no-install-recommends \
    build-essential libpq-dev && rm -rf /var/lib/apt/lists/*

COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt

FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PATH=/root/.local/bin:$PATH
WORKDIR /app

# 运行时只装运行所需库,不需要 build-essential
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
COPY . .

USER nobody
EXPOSE 8000
CMD ["gunicorn", "-w", "4", "-b", "0.0.0.0:8000", "app:app"]

pip install --user 把所有依赖装到 /root/.local,这样在多阶段之间拷贝一个目录就能复用所有依赖,简洁又干净。

语言案例 3:Java / Maven

FROM maven:3.9-eclipse-temurin-21 AS builder
WORKDIR /app

# 先单独拷 pom,利用 Maven 依赖缓存
COPY pom.xml .
RUN mvn dependency:go-offline -B

COPY src ./src
RUN mvn package -DskipTests -B

FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=builder /app/target/*.jar app.jar

# JVM 容器友好参数
ENV JAVA_OPTS="-XX:+UseContainerSupport -XX:MaxRAMPercentage=75"
EXPOSE 8080
USER nobody
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]

缓存与层顺序:别让一次小改动引发全量重建

Docker 把每条指令的结果缓存成一层。只要前面的层没变、本条指令也没变,就直接复用缓存。这意味着 Dockerfile 指令的顺序直接决定缓存命中率

# 反面教材:每改一行代码,依赖都得重装
COPY . .
RUN npm install      # 改了一行 src/index.js,这里也得重跑

# 正确:把"不常变"的放前面
COPY package.json package-lock.json ./
RUN npm install      # 只有 package.json 变了才重跑
COPY . .             # src 变化只影响这一层及之后

这条规则在所有语言都适用:先拷 lock 文件 → 装依赖 → 再拷源代码。"先拷不常变的,后拷常变的" 是 Dockerfile 设计的第一原则。

.dockerignore 不能漏

没有 .dockerignore,COPY . . 会把 node_modules.git、本地缓存、IDE 配置全打进 build context,既慢又泄漏。一个像样的 .dockerignore:

.git
.gitignore
.env
.env.local
node_modules
dist
build
coverage
*.log
.vscode
.idea
.DS_Store
Dockerfile
docker-compose*.yml
README.md

构建参数与运行时变量的区分

# ARG:构建时变量,可以在 Dockerfile 里用,镜像内运行时拿不到
ARG NODE_VERSION=20
FROM node:${NODE_VERSION}-alpine

# ENV:写进镜像,运行时仍可读
ENV APP_PORT=3000

# 构建命令传 ARG
docker build --build-arg NODE_VERSION=18 -t app:v1 .

# 运行时传 ENV
docker run -e APP_PORT=8080 app:v1

千万不要把密钥写进 ENV。镜像层是公开的,docker inspect 一眼就能看到。生产用 docker secrets / 挂载 / 环境变量传入。

BuildKit 与并行构建

新版本 Docker 默认用 BuildKit,它能并行跑那些没有依赖关系的阶段。下面这个 Dockerfile 里,builder 和 builder-frontend 会并行跑,显著加快构建。

# syntax=docker/dockerfile:1.6
FROM golang:1.22 AS builder
COPY backend/ .
RUN go build -o server

FROM node:20-alpine AS builder-frontend
COPY frontend/ .
RUN npm ci && npm run build

FROM alpine:3.19
COPY --from=builder /server /server
COPY --from=builder-frontend /app/dist /static
CMD ["/server"]

BuildKit 还支持缓存挂载,把 npm / pip / go mod 缓存挂到一个持久卷,跨构建复用:

# syntax=docker/dockerfile:1.6
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
    npm ci

查镜像里到底装了啥

docker image inspect hello:good           # 元数据、历史层
docker history hello:good                 # 每一层的大小、命令
docker run --rm hello:good ls -la /       # 直接看文件
dive hello:good                           # 第三方工具,可视化每层差异

dive 强烈推荐,它能交互式查看每一层加进来的文件,一眼看出哪些是冗余 —— 几乎每次跑都能再挖出一两层可优化的内容。

体积之外:还有哪些维度值得优化

多阶段构建主要解决体积,但生产镜像的好坏还有几个常被忽略的维度。

启动速度

镜像越小,从 registry 拉到 K8s 节点的时间越短,扩容也越快。每砍掉 100MB,在高并发扩容场景里都能感知到。配合 image streamingnydus 这类懒加载方案,效果更明显。

安全与扫描

# trivy 扫描镜像漏洞 —— 强烈建议加入 CI
trivy image --severity HIGH,CRITICAL myapp:latest

# 用 distroless / scratch 后,扫描出来的漏洞通常只剩个位数,
# 因为镜像里几乎没有第三方包

每多一个 OS 包就多一份漏洞表面。生产镜像里不应该有 bash、curl、ping 这些"调试时方便但生产用不到"的东西 —— 它们也是攻击者爆破后立刻能用的武器。需要调试时,通过 kubectl debug 或专门的 sidecar 临时注入工具。

可重复性

# 锁定基础镜像 digest,避免 latest 漂移
FROM golang:1.22-alpine@sha256:abc123...

# 锁定依赖版本,而不是用 latest / 范围版本
RUN apk add --no-cache curl=8.5.0-r0

有了 digest 锁定,六个月后重新 build 还能得到完全相同的镜像 —— 这对线上故障复现、合规审计、安全溯源都至关重要。

调试技巧:进入运行中的容器

# 进入一个跑着的容器
docker exec -it <container> sh

# 容器是 distroless / scratch 没有 shell?
# kubectl debug 注入一个临时 sidecar
kubectl debug pod/myapp -it --image=busybox --target=app

# docker 1.41+ 也能 docker debug
docker debug <container>

这条命令让你不需要为了"能调试"就在生产镜像里塞 shell —— 调试镜像和运行镜像可以分开。

docker compose 与 dev/prod 拆分

开发环境希望"改代码立刻生效",生产环境希望"代码全打进镜像"。多文件覆盖让你用同一份基础配置:

# docker-compose.yml — 基础
services:
  app:
    build: .
    ports: ["3000:3000"]

# docker-compose.dev.yml — 开发覆盖
services:
  app:
    volumes:
      - ./src:/app/src         # 挂源码,改了立刻生效
    environment:
      NODE_ENV: development
    command: npm run dev

# docker-compose.prod.yml — 生产覆盖
services:
  app:
    restart: unless-stopped
    environment:
      NODE_ENV: production
    deploy:
      resources:
        limits: { cpus: '1', memory: 512M }

# 启动:
docker compose -f docker-compose.yml -f docker-compose.dev.yml up

常见的体积陷阱

陷阱 1:apt-get 不清缓存。

# 错:apt 索引留在镜像里,每层多几十 MB
RUN apt-get update
RUN apt-get install -y curl
# 对:一行内 update + install + 清理
RUN apt-get update && apt-get install -y curl \
    && rm -rf /var/lib/apt/lists/*

陷阱 2:多层 COPY 大文件然后又删。 Docker 镜像是分层的,某一层加进来的文件,即使下一层删掉它,前一层那份还在里面。永远在同一层里完成"创建 + 使用 + 清理"。

陷阱 3:把日志写进容器的可写层。 容器停了日志就丢,可写层也会越涨越大。日志应该输出到 stdout / stderr,让 Docker 或 Kubernetes 的日志驱动接管。

写在最后

多阶段构建的核心思路一句话:"用什么环境编译"和"用什么环境运行"是两件事,Dockerfile 应该把它们分开。一旦接受这个思路,优化就成了机械工作:把编译依赖留在 builder,把运行依赖装进 runner,产物只拷过去那几个必要文件。

给你的项目做一次镜像审计:跑 docker images 看哪些镜像 > 500MB,挑出来按这篇的套路重写一次。大多数情况下你能砍掉 80% 以上 —— 拉取速度、磁盘占用、Kubernetes 调度时间、扫描漏洞的攻击面,全部跟着改善。这是一次性的投入,长期的回报。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

Linux 进程与信号全解:fork、exec、wait 与信号处理的实战指南

2026-5-15 11:04:01

技术教程

HTTP 缓存机制详解:Cache-Control、ETag 与协商缓存的正确配置

2026-5-15 11:04:02

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索