Skip to content
Published on

给 Docker 镜像瘦身 — 层的真相、multi-stage build,以及基础镜像的真实代价

分享
Authors

引言 — 那个 1.9GB 的 Node 镜像是怎么来的

部署是能部署的。问题在于镜像有 1.9GB。镜像仓库的存储费用每个月都在涨,节点每次扩容,Pod 要花 90 秒才能进入 Ready 状态。回滚一次又是 90 秒。

打开 docker images 会看到,作为基础镜像的 node:22 本身就已经 1.1GB,而剩下的 800MB 从哪来,没人说得清。在这种状态下去搜索,得到的答案大多是"改用 alpine"或者"把 RUN 合并成一条"。两者都只对了一半,另一半则视情况而定,反而是亏的。

本文从镜像变大的机制讲起。理解了联合文件系统如何堆叠层,三件事会同时理清:为什么 RUN rm -rf 一个字节也减不掉,为什么多阶段构建是唯一确定有效的解法,以及为什么对某些团队来说体积本身就是错误的优化目标。

镜像在哪里变大,不要靠猜,要定位

优化的第一步不是改 Dockerfile,而是把层打开来看。Docker 会原样记录每条指令产生的层的大小。

docker history myapp:latest
IMAGE          CREATED         CREATED BY                                       SIZE
a3f91c2e77b1   3 minutes ago   CMD ["node" "dist/server.js"]                    0B
<missing>      3 minutes ago   RUN /bin/sh -c npm run build # buildkit          48.2MB
<missing>      3 minutes ago   RUN /bin/sh -c npm install # buildkit            901MB
<missing>      4 minutes ago   COPY . . # buildkit                              286MB
<missing>      4 minutes ago   RUN /bin/sh -c apt-get update && apt-get inst…   412MB
<missing>      2 weeks ago     /bin/sh -c #(nop)  CMD ["node"]                  0B
<missing>      2 weeks ago     /bin/sh -c #(nop) COPY file:4d192565a7220e13…    388B
<missing>      2 weeks ago     /bin/sh -c set -ex && for key in 4ED778F539E…    5.3MB
<missing>      2 weeks ago     /bin/sh -c #(nop)  ENV NODE_VERSION=22.14.0      0B
<missing>      2 weeks ago     /bin/sh -c #(nop) ADD file:07cf5b0f8bd1d5d5…     74.8MB

这里已经能看出三件事。npm install 占 901MB,安装构建工具占 412MB,COPY . . 占 286MB。源代码不可能有 286MB,说明 .git 或者本地的 node_modules 被整个塞了进去。

docker history 只显示每层的总量。层内部到底浪费在什么地方,要用 dive 来看。

dive myapp:latest --ci --lowestEfficiency=0.95
  Analyzing image
  efficiency: 71.8443 %
  wastedBytes: 407583744 bytes (408 MB)
  userWastedPercent: 21.4127 %

Filename                                        Total Space
/root/.npm/_cacache                                  214 MB
/var/lib/apt/lists                                    48 MB
/app/.git                                             61 MB
/usr/share/doc                                        23 MB
/tmp/build-deps                                       62 MB

wastedBytes 指的是在上层被覆盖或删除、但在下层原封不动留着的那些字节。这 408MB 会被传输、被存储,却在容器里连看都看不到。这个数字为什么会产生,就是下一节的主题。

为什么删了文件镜像也不会变小

Docker 镜像是只读层堆叠起来的结构,而这个栈永远不会回退。overlayfs 在上层删除文件时,并不擦掉原始数据,而是增加一条 whiteout 记录。在挂载后的结果里文件看起来消失了,但下层的数据在物理上仍然存在,也仍然原样包含在镜像里。

所以下面这个 Dockerfile 什么也没省下来。

FROM debian:bookworm-slim

RUN apt-get update && apt-get install -y build-essential
RUN make -C /src all
RUN apt-get purge -y build-essential && apt-get autoremove -y
RUN rm -rf /var/lib/apt/lists/*
docker build -t bad-cleanup . && docker history bad-cleanup --format '{{.Size}}\t{{.CreatedBy}}'
0B      RUN /bin/sh -c rm -rf /var/lib/apt/lists/* # buildkit
2.1MB   RUN /bin/sh -c apt-get purge -y build-essential && apt-get autoremove -y # buildkit
18.4MB  RUN /bin/sh -c make -C /src all # buildkit
396MB   RUN /bin/sh -c apt-get update && apt-get install -y build-essential # buildkit
74.8MB  /bin/sh -c #(nop) ADD file:...

purge 那一层,非但没有减少体积,反而多加了 2.1MB。因为删除标记和更新后的软件包数据库被写进了新层。那 396MB 一点没动。

规则只有一条:造出来的东西,必须在造它的那条 RUN 里删掉。

FROM debian:bookworm-slim

RUN apt-get update \
 && apt-get install -y --no-install-recommends build-essential \
 && make -C /src all \
 && apt-get purge -y --auto-remove build-essential \
 && rm -rf /var/lib/apt/lists/*

这样层只有一个,而且只有该层的最终状态会被保留。这里常被错误放大的建议是"那就把所有 RUN 合并成一条"。那会摧毁缓存复用。需要合并的只有一类:生成与清理成对出现的那些指令。依赖安装和源码构建反而应该分开。

每种包管理器要清理的对象都是固定的。

# Debian / Ubuntu
RUN apt-get update \
 && apt-get install -y --no-install-recommends ca-certificates curl \
 && rm -rf /var/lib/apt/lists/*

# Alpine
RUN apk add --no-cache ca-certificates curl

# Python
RUN pip install --no-cache-dir -r requirements.txt

# Node
RUN npm ci --omit=dev && npm cache clean --force

apk add --no-cachepip install --no-cache-dir 是不留缓存的选项,而不是事后清理。当问题能靠选项而不是靠顺序解决时,用选项总是更安全。

多阶段构建 — 到底该复制什么

即便遵守了在同一条 RUN 里清理的规则,编译器和头文件终究还是得存在于某处。多阶段构建把这种存在本身从最终镜像里彻底移除。构建阶段的层根本不会进入最终镜像的 manifest。

关键不在语法,而在于判断该把什么带进最后一个阶段。先看写错的例子。

# 反模式:用了多阶段构建,却整个复制过来
FROM node:22 AS builder
WORKDIR /app
COPY . .
RUN npm install && npm run build

FROM node:22-slim
WORKDIR /app
COPY --from=builder /app /app
CMD ["node", "dist/server.js"]

COPY --from=builder /app /app 会把 devDependencies、源代码、测试夹具、构建缓存统统搬过来。等于只换了个基础镜像,省下的几乎为零。正确的写法是这样。

# syntax=docker/dockerfile:1.7
FROM node:22-bookworm-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci

FROM node:22-bookworm-slim AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

FROM node:22-bookworm-slim AS prod-deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force

FROM gcr.io/distroless/nodejs22-debian12:nonroot
WORKDIR /app
COPY --from=prod-deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY package.json ./
USER nonroot
CMD ["dist/server.js"]

分成四个阶段是有原因的。deps 连 devDependencies 一起装上用于构建,prod-deps 则单独只装生产依赖。两个阶段彼此独立,所以 BuildKit 会并行执行它们。最终镜像里只有运行时依赖、打包产物,以及 package.json

编译型语言的效果更戏剧化。

FROM golang:1.23-bookworm 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/api ./cmd/api

FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /out/api /api
USER nonroot
ENTRYPOINT ["/api"]
docker images --format '{{.Repository}}:{{.Tag}}\t{{.Size}}' | head -3
api:distroless      14.8MB
api:naive-golang    1.24GB
node-app:optimized  198MB

CGO_ENABLED=0 很关键。cgo 一旦开启,二进制就会动态链接到 glibc,在 distroless/staticscratch 上会立刻挂掉。如果没法做静态链接,用 distroless/base,里面带着 glibc 和 CA 证书。

scratch 时最容易忘掉的三样是 CA 证书、时区数据,以及 /etc/passwd。如果 TLS 校验以 x509: certificate signed by unknown authority 失败,多半是第一样。

FROM scratch
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
COPY --from=builder /out/api /api
ENTRYPOINT ["/api"]

基础镜像的选择 — alpine 什么时候反而是亏的

"用 alpine 会变小"这是事实。问题是你要付出什么作为代价。

alpine 用的是 musl libc 而不是 glibc。在 Python 生态里,这立刻就变成成本。PyPI 上的二进制 wheel 大多以 manylinux 标签发布,而它以 glibc 为前提。在 musl 环境下,只有提供了 musllinux wheel 的包才能以二进制安装,没有的话 pip 就会从源码编译。

# python:3.12-slim
$ time pip install pandas==2.2.3
Downloading pandas-2.2.3-cp312-cp312-manylinux_2_17_x86_64.whl (12.7 MB)
Successfully installed pandas-2.2.3
real    0m9.412s

# python:3.12-alpine
$ time pip install pandas==2.2.3
Downloading pandas-2.2.3.tar.gz (4.4 MB)
  Building wheel for pandas (pyproject.toml) ... done
Successfully installed pandas-2.2.3
real    11m38.204s

因为得装构建工具,镜像又变大了,而 CI 时间变成 70 倍。近来主流包也会一并发布 musllinux wheel,但只要有一个内部包或者一个陈旧依赖卡住,同样的情况就会原样重现。

第二项成本是 DNS。musl 的解析器长期以来对超过 512 字节的 UDP 响应不会改用 TCP 重试,并且把 A 和 AAAA 查询用同一个套接字同时发出。一旦碰上 Kubernetes 默认的 ndots: 5 设置,就会附加多个搜索域使响应变大,再叠加 conntrack 竞争,于是出现间歇性的 Name does not resolve。musl 1.2.4 加入了 TCP 回退,Alpine 3.18 及以上版本包含了它,但仍有很多现场在使用被钉死的旧版 alpine 镜像。

第三项是性能。musl 的 malloc 在多线程分配上比 glibc 慢。对于大量使用线程的 JVM,或者原生扩展很多的负载,可能省下 100MB 镜像却丢掉了延迟。

基础镜像未压缩大小libcshell / 包管理器适用场景注意点
debian:bookworm约 117MBglibc构建阶段作为运行时过重
debian:bookworm-slim约 75MBglibc多数运行时文档/区域设置已剥离
python:3.12-slim约 130MBglibcPython 服务的默认选择没有编译器
alpine:3.20约 8MBmusl静态二进制、需要 shellwheel 重新编译、DNS、malloc
distroless/base约 20MBglibc动态链接的二进制无法用 shell 调试
distroless/static约 2MBGo/Rust 静态构建用 cgo 时会失败
scratch0B完全静态的二进制需自己复制 CA/tzdata

实务上的默认值很简单。Go 和 Rust 用 distroless static,Python 和 Java 用 slim,alpine 只在最终镜像是静态二进制、或者确实需要 shell 工具时才用。

选 distroless 时真正要决定的不是体积,而是运维方式。容器里没有 shell,所以 docker exec -it ... sh 不起作用。要改用附加调试容器的方式。

kubectl debug -it api-7d9f8c6b4-x2ktl \
  --image=busybox:1.36 --target=api --share-processes

.dockerignore 减小的是构建上下文,不是镜像

.dockerignore 的作用经常被误解。它不是镜像体积的优化工具,而是传输量的优化工具。执行 docker build . 时,当前目录会先被整个传给构建器。如果里面有 .git 和本地的 node_modules,那每次构建都要发送几百 MB。

docker build -t myapp .
[+] Building 41.6s (12/12) FINISHED
 => [internal] load build context                                        18.3s
 => => transferring context: 612.44MB                                    18.1s

18 秒只花在了复制文件上。来看看加上 .dockerignore 之后。

.git
.gitignore
node_modules
dist
coverage
**/*.log
.env
.env.*
Dockerfile
docker-compose*.yml
README.md
[+] Building 21.9s (12/12) FINISHED
 => [internal] load build context                                         0.4s
 => => transferring context: 3.71MB                                       0.3s

如果你用了 COPY . .,那它对镜像体积也会有直接影响。不过排除 .env 的理由不是体积而是安全。进入上下文的密钥,会被 COPY . . 这一行永久烙进镜像里。

BuildKit 会增量传输上下文,所以从第二次构建开始只发送变更部分。即便如此,CI 里每次都是全新的构建器,所以最初那 18 秒会在每条流水线里重复一遍。

比体积更重要的 — 层复用率与真实的拉取时间

到这里需要调转一次方向。目标不是镜像体积,而是部署时间和成本。而这两者并不像想象中那样成正比。

从镜像仓库拉取镜像时,Docker 不会重新下载本地已有的层。而且传输的是压缩后的层。

docker pull registry.example.com/api:v312
v312: Pulling from api
9c704ecd0c69: Already exists
2f8a1e3b7d44: Already exists
b17d2a9e4c31: Already exists
7e2c9f04a8b6: Pull complete
d3a91b2e5f77: Pull complete
Digest: sha256:4f9a...
Status: Downloaded newer image for registry.example.com/api:v312

一个 400MB 的镜像,实际下载的可能只有 12MB。反过来,即使是 200MB 的"小"镜像,只要依赖层在每次提交时都被失效,那每次就要传输 200MB。层的顺序比体积更能左右部署时间

所以 Dockerfile 要按变更频率从低到高排列。

FROM python:3.12-slim

# 1. 几乎不变:系统软件包
RUN apt-get update \
 && apt-get install -y --no-install-recommends libpq5 \
 && rm -rf /var/lib/apt/lists/*

# 2. 偶尔变:依赖清单
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# 3. 每次提交都变:源码
COPY src/ ./src/

CMD ["python", "-m", "src.main"]

要测量的值有三个:压缩后的传输大小、冷启动时的拉取时间,以及滚动更新时实际重新下载的层的比例。

# 压缩大小从 manifest 里确认
crane manifest registry.example.com/api:v312 \
  | jq '[.layers[].size] | add / 1048576 | round'
71
# 测量冷缓存下的拉取时间
docker image rm registry.example.com/api:v312 >/dev/null
time docker pull registry.example.com/api:v312
real    0m6.284s

如果这些数字都在目标范围内,那么为了把 200MB 削到 150MB 而迁到 alpine、重新编译 Python wheel,就是净亏损。反过来,如果每次部署都在重新下载 900MB 的 node_modules 层,那在动镜像总体积之前,应该先修层的顺序。

结语 — 不是删掉,而是一开始就不要放进去

镜像优化只需记住一句话。层不会回退,所以不该进入最终镜像的东西,从一开始就不应该在那个阶段被造出来

优先级按这个顺序最实用。先用 .dockerignore 整理上下文,需要构建工具就用多阶段构建分离,清理放在与生成相同的 RUN 里,基础镜像按运行时特性来选,最后按变更频率排列层。而所有这些工作的成效,要用冷启动的拉取时间而不是镜像体积来验证。