LabHub
学习 学习路径 课程

构建镜像

把镜像变小的顺序

在 LabHub 中继续学习

一句话总结

多阶段构建的关键不在语法,而在于只把哪些内容复制到最终阶段。构建阶段的镜像层根本不会进入最终镜像的清单。

概念图: 只把哪些内容复制到最终阶段 · 这 408MB 会被传输和存储,却在容器中根本看不到。 · 不会减少一个字节 · 又增加了 2.1MB

为什么需要它

曾经有一个 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 意味着什么。