Docker 多阶段构建实战:把镜像从 1.1GB 压到 90MB 的五个手法
一、问题的起点
一个很常见的场景:一个 Go 或 Node 项目,直接 docker build 出来的镜像 1.1GB,推送到镜像仓库要几分钟,拉取更慢,节点磁盘很快被吃满。但真正运行时需要的产物,可能只有几十兆。
多阶段构建(multi-stage build)就是解决这个问题的标准手段:构建环境和运行环境解耦,构建依赖全部留在中间层,最终镜像只保留运行所需。
二、手法一:用多阶段把构建工具链剥离
以 Go 为例,单阶段写法的 Dockerfile 会把整个 Go SDK(约 800MB)打进镜像。CGO_ENABLED=0 编出的静态二进制根本不依赖 libc,所以完全可以只拷二进制:
FROM golang:1.22 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/app .
FROM gcr.io/distroless/static-debian12
COPY --from=builder /out/app /app
ENTRYPOINT ["/app"]
注意 -ldflags="-s -w":去掉符号表和调试信息,二进制通常能再小 20%~30%,代价是 panic 堆栈里的函数名会变少,需要权衡。
三、手法二:依赖安装与源码分离,保住缓存
把 go mod download 或 npm ci 放在 COPY . . 之前,是为了让依赖层能命中缓存。只改业务代码时,依赖层不会失效,构建时间从几分钟降到十几秒。
Node 项目里这条尤其关键,并且要记得装完就清缓存:
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY . .
CMD ["node","server.js"]
四、手法三:基础镜像换精简版
ubuntu:22.04 约 77MB,debian:bookworm-slim 约 75MB,alpine 约 7MB,distroless static 约 2MB。差距是数量级的。
但 Alpine 用的是 musl libc,遇到需要 glibc 的预编译二进制(部分 Python 包、部分 Java 工具)会直接报错,这时候别硬上,退回 debian-slim 更省事。distroless 更极端:没有 shell、没有包管理器,安全面最小,但进容器 docker exec -it sh 也进不去,排查问题要靠日志和指标。
五、手法四:合并 RUN,顺手清理
每一条 RUN 都会产生一个层。层里的东西后续即使删掉,体积仍记在镜像里(除非用多阶段或 --squash)。所以安装和清理必须在同一条 RUN:
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates tzdata \
&& rm -rf /var/lib/apt/lists/*
--no-install-recommends 能省掉大量推荐包,配合 rm -rf /var/lib/apt/lists/*,通常能少 30MB 以上。
六、手法五:.dockerignore 和 BuildKit 缓存挂载
很多人忽略了 .dockerignore。COPY . . 会把 .git、node_modules、本地构建产物一起塞进上下文,既拖慢构建又污染镜像。至少要有:
.git
node_modules
dist
*.log
.env
如果用的是 BuildKit,还可以把包管理器缓存挂进来,加速而不留痕迹:
RUN --mount=type=cache,target=/root/.cache/go-build \
--mount=type=cache,target=/go/pkg/mod \
go build -o /out/app .
七、效果对比
同一个 Go 服务,各阶段体积变化:
单阶段 golang:1.22 基础 → 1.12GB
剥离工具链 + distroless → 96MB
加 -ldflags="-s -w" → 78MB
换静态编译 + .dockerignore 收紧 → 约 72MB
推送时间从 3 分多钟降到十几秒,节点拉取几乎瞬间完成。
八、几个容易踩的坑
1. COPY --from 只能引用前面已定义的阶段名,顺序不能反。
2. distroless 镜像没有 /bin/sh,ENTRYPOINT 必须用 exec 数组形式,不能写 shell 形式。
3. 时区问题:精简镜像默认 UTC,需要 ENV TZ=Asia/Shanghai 并确保 tzdata 存在。
4. 多阶段并不会自动让镜像变小——如果把构建阶段的整个目录 COPY --from=builder / /,等于白做。
九、小结
多阶段构建的核心思路只有一句话:构建需要的东西,运行不需要。按这个思路做减法,配合精简基础镜像、缓存分层和 .dockerignore,绝大多数业务镜像都能压到原来的十分之一以内。
镜像小带来的收益是连锁的:推送快、拉取快、扩容快、攻击面小、存储成本低。这是投入产出比最高的容器优化之一。
- 点赞
- 收藏
- 关注作者
评论(0)