我拿一个很小的 Go 服务——一个健康检查接口,两个很小的依赖——在同一台机器上用五种方式构建,然后用 docker images 和 docker history 逐一量了它们的体积。最差的是 1.35 GB,最好的是 7.95 MB。同一个程序、同样的功能、同一台宿主。容器镜像体积就是那种你不去管它就没事、直到某次部署从三秒变成九分钟的数字。所以这篇就是逐层的账本:这些兆字节到底从哪来,多阶段构建究竟丢掉了什么,distroless、alpine、scratch 各自怎么取舍,以及怎么从 docker history 里把这一切读出来。
为什么容器镜像体积很重要
有四项成本会随镜像变大,其中只有一项是磁盘:
- 拉取时间。 在 20 Mbit/s 下,1.35 GB 意味着容器跑起来之前要先花大约九分钟下载;8 MB 只要三秒多一点。共享层能帮忙,但只对节点上已经有的层有效。
- 冷启动与自动扩缩容。 新节点必须先拉完才能响应。如果你的自动扩缩容要 90 秒才能真正干活,那原因往往就是这个镜像。
- 攻击面。 基础镜像里的每一个包,都是你发布出去、并且今后要跟着打补丁的代码。光是
golang:1.23这个基础镜像解压后就有 927 MB,带着 shell、包管理器、编译器和 C 工具链——而一个正在运行的 Go 二进制一个都不需要。 - 存储与带宽。 镜像仓库按存储计费,每个拉取的节点都要付带宽。把 1.35 GB 乘以每天十次部署和一个小机群。
这些字节到底从哪来
先看最朴素的 Dockerfile:单阶段,全都发出去。
FROM golang:1.23 # docker images 报告 1.24 GB
WORKDIR /src
COPY . .
RUN go build -o /app ./...
CMD ["/app"]
$ docker images --format '{{.Repository}}:{{.Tag}} {{.Size}}' | grep sizedemo
sizedemo:naive 1.35GB
sizedemo:multistage 14.1MB
sizedemo:alpine 21.8MB
sizedemo:scratch 7.95MB
sizedemo:layers-bad 572MB
sizedemo:layers-good 537MB
docker history 把 1.35 GB 的位置指得清清楚楚:
$ docker history sizedemo:naive
IMAGE CREATED CREATED BY SIZE COMMENT
50b707acd047 12 minutes ago /bin/sh -c #(nop) CMD ["/app"] 0B
078f104cfbcd 12 minutes ago /bin/sh -c #(nop) EXPOSE 8080 0B
9b07b4f3180c 12 minutes ago |1 GOPROXY=https://goproxy.cn,direct /bin/sh… 92.1MB
c1f7af6a6cc0 13 minutes ago /bin/sh -c #(nop) COPY dir:2c88684b0f50c202f… 24.6kB
a4a8e2d02a18 13 minutes ago /bin/sh -c #(nop) WORKDIR /src 8.19kB
60deed95d388 14 months ago WORKDIR /go 4.1kB buildkit.dockerfile.v0
...
肥胖有三个彼此独立的来源,全都藏在这六行里:
- 臃肿的基础镜像。
WORKDIR /go下面那十一层就是golang:1.23——927 MB 的工具链,被一路发到了生产环境。 - 构建工具链和它的缓存挤在同一层里。 那个 92.1 MB 的
RUN go build层并不是二进制本身。二进制只有 5.5 MB,其余都是 Go 的构建缓存(/root/.cache/go-build),go build把它写进了这一层,之后你删不掉。 - 一个
RUN一层,而删除删不掉。 层是不可变的。你在一个RUN里装了东西、在下一个RUN里rm掉,字节依然留在更早的那一层。换成 Debian 是同一个道理:
# 差:在后面的层里才清理
FROM debian:bookworm-slim
RUN apt-get update
RUN apt-get install -y --no-install-recommends build-essential
RUN rm -rf /var/lib/apt/lists/*
# 好:装和清放在同一个 RUN 里
FROM debian:bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential \
&& rm -rf /var/lib/apt/lists/*
$ docker history sizedemo:layers-bad
IMAGE CREATED CREATED BY SIZE COMMENT
c5f17e613c10 8 minutes ago /bin/sh -c rm -rf /var/lib/apt/lists/* 20.5kB
7b414c164866 8 minutes ago /bin/sh -c apt-get install -y --no-install-r… 319MB
ee94f9007749 9 minutes ago /bin/sh -c apt-get update 19.8MB
7c7b2c966bc9 7 days ago # debian.sh --arch 'amd64' out/ 'bookworm' … 85.3MB debuerreotype 0.17
$ docker history sizedemo:layers-good
IMAGE CREATED CREATED BY SIZE COMMENT
a0d01049fd3f 5 minutes ago /bin/sh -c apt-get update && apt-get instal… 319MB
7c7b2c966bc9 7 days ago # debian.sh --arch 'amd64' out/ 'bookworm' … 85.3MB debuerreotype 0.17
那个“清理”层只有 20.5 kB——一个 whiteout 标记——而它删掉的 19.8 MB apt-get update 元数据,仍然留在它上面那层里。docker images 也同意:差版本 572 MB,好版本 537 MB。包一样,差 35 MB。
多阶段构建:构建时胖,发布时瘦
多阶段构建让 builder 阶段想多胖就多胖,然后只把产物拷进最终镜像。builder 阶段永远不会进镜像仓库。
FROM golang:1.23 AS build
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:nonroot
COPY --from=build /out/app /app
USER nonroot:nonroot
ENTRYPOINT ["/app"]
$ docker history sizedemo:multistage
IMAGE CREATED CREATED BY SIZE COMMENT
b06b0fe9bf91 6 minutes ago /bin/sh -c #(nop) ENTRYPOINT ["/app"] 0B
4b61455d93d7 6 minutes ago /bin/sh -c #(nop) USER nonroot:nonroot 0B
1b3aafa8afee 6 minutes ago /bin/sh -c #(nop) EXPOSE 8080 0B
e2fc9ebc7585 6 minutes ago /bin/sh -c #(nop) COPY file:6e308bbd6fccc620… 5.51MB
afa5c872c891 N/A bazel build //common:cacerts_debian12_amd64_… 319kB
<missing> N/A bazel build //common:os_release_debian12 16.4kB
<missing> N/A bazel build //static:nsswitch 12.3kB
<missing> N/A bazel build @bookworm//tzdata/amd64:data_sta… 4.24MB
...
这就是整个镜像:一个 5.51 MB 的二进制拷贝,压在约 5.4 MB 的 distroless 层之上。-ldflags="-s -w" 会剥掉符号表和 DWARF,-trimpath 则把本地路径从二进制里去掉。
关于这些数字。 这台机器跑的是 Docker 29 + containerd 镜像存储,它会把压缩后的 blob 和解压后的层都算进去,所以
docker images读出来大约是docker history之和的 1.3–1.4 倍。我用一个只装了一个 1 MiB 不可压缩文件的 scratch 镜像验证过:docker images说 2.1 MB,docker history说 1.05 MB。在传统的 overlay2 宿主上,你看到的大致就是下面docker history那一列。但比值——也就是整件事的重点——两种算法完全一致。
| 构建方式 | docker images | docker history 之和 |
|---|---|---|
单阶段 golang:1.23 | 1.35 GB | 1019 MB |
多阶段 → distroless/static | 14.1 MB | 11.0 MB |
多阶段 → alpine:3.20 | 21.8 MB | 15.4 MB |
多阶段 → scratch | 7.95 MB | 5.5 MB |
怎么选基础镜像:debian-slim、alpine、distroless 还是 scratch
builder 丢掉不心疼;运行时的基础镜像才是真正要长期承受的选择。
| 基础镜像 | 解压后 | libc | shell / 包管理器 | 说明 |
|---|---|---|---|---|
golang:1.23 | 927 MB | glibc | 有 / apt | 只做构建阶段 |
debian:bookworm-slim | 85 MB | glibc | 有 / apt | 最稳妥的“常规”运行时 |
alpine:3.20 | 8.5 MB | musl | busybox sh / apk | 极小,但用的是 musl |
distroless/static | 约 5.4 MB | 无 | 无 | 带 ca-certs 和 tzdata,非 root 用户 |
scratch | 0 | 无 | 无 | 一切自理,证书也得自己加 |
- glibc 还是 musl。 Alpine 是 musl。静态链接的二进制不在乎,但如果你用
cgo,或者依赖 glibc 的 NSS 解析行为(某些 LDAP / mDNS 场景),Alpine 就会给你意外。debian-slim和distroless给你 glibc。 - shell 是调试工具。
distroless和scratch没有 shell:
$ docker run --rm --entrypoint /bin/sh gcr.io/distroless/static-debian12:nonroot -c 'echo hi'
docker: Error response from daemon: ... exec: "/bin/sh": stat /bin/sh: no such file or directory
$ docker run --rm --entrypoint /bin/sh sizedemo:scratch -c 'echo hi'
docker: Error response from daemon: ... exec: "/bin/sh": stat /bin/sh: no such file or directory
$ docker run --rm --entrypoint /bin/sh sizedemo:alpine -c 'echo hi'
hi
scratch意味着什么都没有。 没有 CA 证书、没有/etc/passwd、没有时区数据;而且除非你USER一个数字 uid,否则你就是以 root 跑的。distroless是务实的中间路线:它就是scratch加上上面这几样东西,而且默认已经非 root。
<rect x="40" y="95" width="130" height="185" fill="currentColor" opacity="0.10" stroke="currentColor" stroke-opacity="0.5"/>
<line x1="40" y1="130" x2="170" y2="130" stroke="currentColor" opacity="0.18"/>
<line x1="40" y1="165" x2="170" y2="165" stroke="currentColor" opacity="0.18"/>
<line x1="40" y1="200" x2="170" y2="200" stroke="currentColor" opacity="0.18"/>
<line x1="40" y1="235" x2="170" y2="235" stroke="currentColor" opacity="0.18"/>
<text x="105" y="178" text-anchor="middle" font-size="11" opacity="0.85">golang:1.23</text>
<text x="105" y="194" text-anchor="middle" font-size="11" opacity="0.85">工具链基础层</text>
<text x="105" y="210" text-anchor="middle" font-size="11" opacity="0.6">927 MB</text>
<rect x="40" y="76.6" width="130" height="18.4" fill="#0f766e" opacity="0.9"/>
<text x="105" y="89" text-anchor="middle" font-size="10" fill="#ffffff">92 MB</text>
<line x1="172" y1="86" x2="206" y2="86" stroke="currentColor" opacity="0.5"/>
<text x="212" y="83" font-size="10" opacity="0.8">一个 RUN 层</text>
<text x="212" y="96" font-size="10" opacity="0.8">二进制 + 构建缓存</text>
<rect x="330" y="270" width="130" height="5" fill="currentColor" opacity="0.14" stroke="currentColor" stroke-opacity="0.5"/>
<rect x="330" y="275" width="130" height="5" fill="#0f766e" opacity="0.9"/>
<line x1="395" y1="264" x2="395" y2="270" stroke="currentColor" opacity="0.4"/>
<text x="395" y="246" text-anchor="middle" font-size="10" opacity="0.85">distroless 基础层 6.2 MB</text>
<text x="395" y="259" text-anchor="middle" font-size="10" opacity="0.85">+ 二进制 5.5 MB</text>
<rect x="610" y="272" width="130" height="8" fill="#0f766e" opacity="0.9"/>
<text x="675" y="253" text-anchor="middle" font-size="10" opacity="0.85">二进制 5.5 MB</text>
<line x1="24" y1="280" x2="736" y2="280" stroke="currentColor" stroke-width="1" opacity="0.35"/>
<g font-size="11" font-weight="600" fill="#0f766e">
<text x="105" y="298" text-anchor="middle">1.35 GB</text>
<text x="395" y="298" text-anchor="middle">14.1 MB</text>
<text x="675" y="298" text-anchor="middle">7.95 MB</text>
</g>
<g font-size="10" opacity="0.65">
<text x="105" y="313" text-anchor="middle">解压后 1019 MB</text>
<text x="395" y="313" text-anchor="middle">解压后 11.0 MB</text>
<text x="675" y="313" text-anchor="middle">解压后 5.5 MB</text>
</g>
<text x="380" y="332" text-anchor="middle" font-size="10" opacity="0.55">纵向约 0.2 px/MB;不足 6 px 的柱按 6 px 画,好让细堆栈仍可见</text>
docker images
与 docker history。
指令顺序会同时改变 docker history 和构建缓存
同样的指令换个顺序,缓存能复用的程度就完全不同。把 COPY . . 放在 go mod download 前面,意味着任何源码改动——哪怕只是改个 README——都会让下载层一起失效:
# 缓存不友好
COPY . .
RUN go mod download
RUN go build -o /app ./...
# 缓存友好
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o /app ./...
两个都构建一遍,然后改 README.md 再重建。友好版本:
Step 4/7 : COPY go.mod go.sum ./
---> Using cache
Step 5/7 : RUN go mod download
---> Using cache
Step 6/7 : COPY . .
---> dcfa290888f3
Step 7/7 : RUN CGO_ENABLED=0 go build ...
---> Running in ac7b2a5acc56
不友好版本,同样的改动:
Step 4/6 : COPY . .
---> 67f4c393ba1a
Step 5/6 : RUN go mod download
---> Running in 593cf506afae
Step 6/6 : RUN CGO_ENABLED=0 go build ...
---> Running in 1eedbea7f723
两个教训。第一,把依赖清单拷在源码前面,这样昂贵的“拉依赖”步骤能挺过日常改动。第二,docker history 和构建缓存读的是同一张层图,所以能把镜像缩小的顺序,通常也正是缓存命中良好的顺序。
.dockerignore 是性价比最高的一招
COPY . . 会把整个构建上下文都拷进去——包括你根本没打算发布的东西。一个 43 MB 的上下文,里面混了一个 debug.log 和一个 node_modules/:
$ du -sh ctx
43M ctx
$ docker build -f Dockerfile.ctx -t szprobe:ctx-raw ctx # COPY . /
$ docker build -f Dockerfile.ctx -t szprobe:ctx-ignored ctx # 带 .dockerignore
$ docker history szprobe:ctx-raw | head -1
3433821b1065 ... /bin/sh -c #(nop) COPY dir:... 44.1MB
$ docker history szprobe:ctx-ignored | head -1
4dd05186d44a ... /bin/sh -c #(nop) COPY dir:... 12.3kB
.dockerignore 里写上 debug.log 和 node_modules/,拷贝出来的层就从 44.1 MB 变成了 12.3 kB。永远把 .git、node_modules、构建产物、本地环境文件和日志忽略掉。它还能加快构建:上下文在一切开始之前就要被送到 daemon。
一句实话:一个你没法调试的 20 MB 镜像
最小的镜像并不自动就是最好的。scratch 和 distroless 没有 shell、没有 ps、没有 curl、没有 nc。出了事故你没法 docker exec 进去东看看西看看;你只能准备一个仅用于调试的变体(distroless:debug),或者一个带工具的 sidecar。这个成本是字节计数器看不到的。用 -s -w 剥掉符号表,也会让堆栈信息变得更差,除非你另外留着未剥符号的二进制用来做符号化。
我的原则是:发布 distroless(如果二进制是静态的、运行时什么都不需要,就用 scratch),但为同一次构建留一个 debug 镜像标签;绝不用你真在用的可观测性,去换那些你根本不会注意到的兆字节。一个你没法调试的 20 MB 镜像,并不比一个你能调试的 60 MB 镜像更好。
如果花时间的其实是构建流程本身,Docker Compose 实战手册讲了周边的工程实践;如果你精简镜像是为了安全而不是体积,容器保护了什么、又没保护什么是有用的对照——更小的镜像是更小的攻击面,但它并不是一条安全边界。