把镜像变小的顺序
一句话总结
多阶段构建的关键不在语法,而在于只把哪些内容复制到最终阶段。构建阶段的镜像层根本不会进入最终镜像的清单。
为什么需要它
曾经有一个 1.9GB 的 Node 镜像。每增加一个节点,Pod 进入 Ready 都要 90 秒,回滚一次又要 90 秒。不要猜测,先查看 docker history。
RUN npm install 901MB
COPY . . 286MB
RUN apt-get update && ... 412MB
ADD file:07cf5b0f8bd1d5d5… 74.8MB
源代码不可能有 286MB,这说明 .git 或本地 node_modules 被完整复制进去了。再用工具测量,会发现浪费了 408MB,效率只有 71.8%。这 408MB 会被传输和存储,却在容器中根本看不到。
工作原理
这里有一个最反直觉的事实:即使像下面这样清理,镜像也不会减少一个字节。
0B RUN rm -rf /var/lib/apt/lists/*
2.1MB RUN apt-get purge -y build-essential && apt-get autoremove -y
18.4MB RUN make -C /src all
396MB RUN apt-get update && apt-get install -y build-essential
purge 层非但没有缩小镜像,反而又增加了 2.1MB。因为删除标记和更新后的软件包数据库被记录到了新层中,原来的 396MB 仍然存在。
规则只有一条:在创建内容的同一个 RUN 中将其删除。 但这并不意味着“应该合并所有 RUN”,那会破坏缓存复用。真正需要合并的,只有成对出现的创建与清理命令。
多阶段构建的反模式也很明确。
FROM node:22 AS builder
COPY . . ; RUN npm install && npm run build
FROM node:22-slim
COPY --from=builder /app /app # devDeps, 소스, 테스트, 빌드 캐시 전부
这相当于只更换了基础镜像,几乎节省不了空间。必须只挑选构建产物进行复制。
实际现场中的情况
选择基础镜像时,如果只看大小,反而可能吃亏。
| 基础镜像 | 大小 | libc | 注意事项 |
|---|---|---|---|
debian:bookworm-slim |
约 75MB | glibc | 稳妥的默认选择 |
python:3.12-slim |
约 130MB | glibc | 不含编译器 |
alpine:3.20 |
约 8MB | musl | 重新构建 wheel、DNS、malloc |
distroless/static |
约 2MB | 无 | 使用 cgo 时失败 |
scratch |
0B | 无 | 需自行复制 CA 与 tzdata |
Alpine 得不偿失的典型场景是 Python wheel。同样安装 pandas,在 slim 中只需 9.4 秒,而在 Alpine 中会转为源码编译,耗时11 分 38 秒,CI 时间相差 70 倍。此外,使用 scratch 时容易忘记三样东西:CA 证书、时区数据和 /etc/passwd。如果 TLS 验证报 x509: certificate signed by unknown authority,通常就是缺少第一项。
最后还要转变一个关注方向:层复用率比大小更重要。 一个 400MB 的镜像实际可能只需拉取 12MB;而一个 200MB 的“小”镜像,如果依赖层在每次提交时都失效,就会每次传输完整的 200MB。应以冷启动拉取时间而不是镜像大小验证成效。
为什么删除的文件没有从镜像中消失
学习多阶段构建前,人们常用“安装后再删除”的方法。但这样写,镜像一个字节也不会减少。
RUN apt-get install -y build-essential # 레이어 A: 400MB 늘어남
RUN apt-get purge -y build-essential # 레이어 B: 삭제 표시만 기록
每个 RUN 都会创建一个镜像层,而镜像层只能叠加,无法删除前面层中的内容。 B 只记录“让这些文件看起来不存在”的标记,A 中的 400MB 仍会照常部署和下载。而且,这些文件依然可以被提取出来——构建期间短暂加入后又删除的秘密之所以会残留在镜像中,走的就是这条路径。
在同一个镜像层中完成,就能真正缩小。 把安装和删除合并到一个 RUN,镜像层只会记录该命令结束后的状态。
RUN apt-get update && apt-get install -y --no-install-recommends build-essential && make install && apt-get purge -y build-essential && rm -rf /var/lib/apt/lists/*
即便如此,多阶段构建仍然更好。 上述方法的代价是整体失去缓存。源代码哪怕只改一个字符,也会从 apt-get install 开始重跑。多阶段构建保留构建阶段的缓存,同时通过 COPY --from 只把明确决定要带入的内容放进最终镜像。重点不只是产物,而是形成了清晰的边界。
按变化频率安排缓存顺序。 先复制依赖清单并安装依赖,再复制源代码。顺序反过来,就会在每次修改一行源代码时重新下载依赖。
COPY go.mod go.sum ./
RUN go mod download # go.mod 가 그대로면 캐시가 산다
COPY . .
RUN go build -o /app ./cmd/server
根据运行时真正需要的内容选择最底层镜像。 静态链接的 Go 二进制文件可直接运行在 scratch 上。如果使用 TLS,就需要 ca-certificates;如果要查询用户信息,就需要 /etc/passwd。Alpine 虽小,却使用 musl libc,因此按 glibc 前提构建的二进制文件可能出现难以察觉的不同行为。对于 Python 这类需要编译扩展模块的语言,Alpine 反而经常让镜像更大、构建更慢。
下一项实验要做什么
在构建阶段生成产物,最终阶段只复制该产物,测量镜像缩小了多少;再确认一个仅在 scratch 上放置静态二进制文件的镜像是否真的可以运行,并体验其中没有 shell 意味着什么。