第一次写 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 ci比npm 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 streaming 或 nydus 这类懒加载方案,效果更明显。
安全与扫描
# 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