本节把 TaskHub 从「本机能跑的二进制」变成「能进仓库、能进集群的镜像」:先用多阶段构建把编译工具链挡在最终镜像之外,再换 distroless 基础镜像把体积压到 15MB。
前面 13 章我们一直在写代码,但代码要进生产,第一步是打包成镜像。镜像不是「把二进制丢进容器」这么随意——它的大小决定了拉取速度、扩容速度、安全扫描的噪音量,甚至决定了容器被攻破后攻击者能拿到多少工具。这一节我们用真实构建数据说明:同一个 Go 程序,镜像可以从 510MB 压到 15MB,代码一行不改。
本节所有镜像大小、构建步骤、运行结果都是本机 docker build 实测输出(Docker Engine 29.5.2,arm64)。
14.1.1 镜像大小为什么是工程问题
先说清楚为什么值得花力气压体积。一个镜像从 510MB 变成 15MB,带来的连锁反应是:
| 维度 | 510MB | 15MB | 影响 |
|---|---|---|---|
| 首次拉取(10MB/s) | ~51 秒 | ~1.5 秒 | 扩容时新 Pod 启动慢 |
| 滚动更新 | 每节点都要重拉 | 秒级 | 发布窗口长短 |
| 镜像仓库存储 | 每版本 510MB | 每版本 15MB | 成本、保留版本数 |
| 漏洞扫描告警 | 基础镜像几百个 CVE | 通常接近 0 | on-call 噪音 |
| 攻击面 | 含 shell、包管理器、编译器 | 只有你的二进制 | 被攻破后的可利用工具 |
最容易被忽略的是漏洞扫描。一个 golang:1.27-alpine 基础镜像里带着 Alpine 的整套用户态,扫描器会把其中过时的库全报成 CVE——哪怕你的 Go 程序根本不调用它们。安全团队天天被这些「噪音漏洞」淹没,真正的问题反而被埋掉。distroless 的价值有一半是「让扫描结果干净」。
14.1.2 朴素单阶段:为什么会是 510MB
最直觉的 Dockerfile 是这样的——用编译镜像直接当运行镜像:
FROM golang:1.27-alpine
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /out/taskhub ./cmd/taskhub
EXPOSE 8080
ENTRYPOINT ["/out/taskhub"]
它能跑,实测容器正常返回 200。但镜像里塞进了整个 Go 工具链(编译器、标准库源码、go 命令)。实测大小:
taskhub-naive:14.1 510MB
golang:1.27-alpine 373MB <- 基础镜像本身就 373MB
问题的本质是:编译需要的东西,运行根本不需要。go 编译器、标准库源码、git、gcc——这些在构建阶段用完就该扔掉。而镜像分层是「加法」,你在 Dockerfile 里删文件也只是加了一个「白化」层,底层数据还在。唯一干净的办法是多阶段构建。
14.1.3 多阶段构建:编译与运行分离
多阶段构建用两个 FROM:第一个阶段编译,第二个阶段只从第一阶段拷贝产物。中间的一切(工具链、源码、缓存)都不会进入最终镜像。
# syntax=docker/dockerfile:1
# ---- 阶段一:构建 ----
FROM golang:1.27-alpine AS build
WORKDIR /src
COPY go.mod ./
COPY cmd ./cmd
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/taskhub ./cmd/taskhub
# ---- 阶段二:运行 ----
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/taskhub /taskhub
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/taskhub"]
三个细节值得逐个说:
COPY --from=build是核心:它只从build阶段复制那一个二进制文件,其余全不要。最终镜像 = 基础镜像 + 你的二进制。-trimpath去掉二进制里嵌入的本机绝对路径(如/Users/xxx/...),既减小体积,也避免泄露构建者目录结构,还让构建结果可复现。-ldflags="-s -w"去掉符号表和 DWARF 调试信息。实测二进制从 9,177,887 字节降到 6,226,080 字节(减少约 32%)。代价是panic堆栈和pprof里看不到函数名——所以生产镜像通常保留一份带符号的版本单独归档,出事时用它对堆栈。
注意:COPY go.mod 和 COPY cmd 分开写,不是随手为之。 这个顺序让依赖层可以缓存,下一节 14.2 会用真实耗时数据说明它值多少钱。
14.1.4 静态链接:CGO_ENABLED=0 是前提
多阶段能成立的前提是二进制不依赖系统动态库。CGO_ENABLED=0 强制纯静态链接:
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/taskhub ./cmd/taskhub
为什么必须这样做:
- 一旦启用 CGO,二进制会链接
libc(Alpine 上是musl),换到scratch或 distroless 就会因为找不到动态链接器而exec format error。 - 纯静态二进制的运行环境要求为零:内核 + 你的二进制就够,所以能塞进任何最小基础镜像。
- 跨平台交叉编译变简单:
GOOS=linux GOARCH=arm64直接产出目标平台二进制,不需要目标平台的 C 工具链。
代价是纯 Go 的 DNS 解析器(而不是 glibc 的 getaddrinfo)。绝大多数场景没问题,但如果你依赖 /etc/nsswitch.conf、LDAP 或某些企业 DNS 特性,需要显式确认。TaskHub 用标准 DNS,没有这个问题。
14.1.5 distroless:只有你的二进制和证书
gcr.io/distroless/static-debian12:nonroot 是 Google 维护的最小基础镜像,实测只有 6.18MB,里面只有:
- CA 证书(
/etc/ssl/certs)—— 让程序能发 HTTPS 请求; - 时区数据;
/etc/passwd里的nonroot用户(uid 65532);- 没有 shell、没有包管理器、没有
ls/cat/curl。
这一点用一个命令就能证实——尝试在容器里执行 shell:
$ docker exec th14 /bin/sh -c 'id'
OCI runtime exec failed: exec failed: unable to start container process:
exec: "/bin/sh": stat /bin/sh: no such file or directory
这不是缺点,是安全特性。 如果攻击者通过某个漏洞拿到了命令执行,他连一个 shell 都起不来,curl 一个反弹 shell 都做不到。这就是「最小攻击面」的具体含义。
运行身份也验证了:
$ docker inspect -f '{{.Config.User}}' taskhub-distroless:14.1
nonroot:nonroot
static 变体适合纯静态 Go 二进制;如果程序需要 libc(比如用了 CGO 的 sqlite),要换成 gcr.io/distroless/base-debian12。选错变体会得到 no such file or directory——而 distroless 里没有 shell,你连排查工具都没有,所以一定要在本地先跑通再推仓库。
14.1.6 实测:510MB → 15MB
把上面两条路径都真实构建出来,对比结果:
| 镜像 | 基础镜像 | 大小 |
|---|---|---|
taskhub-naive:14.1(单阶段) | golang:1.27-alpine | 510MB |
taskhub-distroless:14.1(多阶段 + distroless) | gcr.io/distroless/static-debian12:nonroot | 15MB |
组成部分对照:
golang:1.27-alpine 373MB
gcr.io/distroless/static-debian12:nonroot 6.18MB
taskhub-distroless:14.1 15MB <- 6.18MB 基础 + 约 9MB 内容
510MB → 15MB,压到原来的约 1/34。 注意 15MB 里还有 6.18MB 是基础镜像,也就是说「你的代码 + 镜像元数据」实际只占约 9MB,其中二进制 6.2MB(stripped)。把二进制再压到 6MB 以下是可能的(upx 之类),但会拖慢启动、也容易被杀毒软件误报,不建议。
构建完成后跑起来验证:
$ docker run -d --name th14 -p 28080:8080 -e TASKHUB_VERSION=14.1.0 taskhub-distroless:14.1
$ curl -s -o /dev/null -w 'live=%{http_code}\n' http://127.0.0.1:28080/healthz/live
live=200
$ curl -s http://127.0.0.1:28080/healthz/ready
{"status":"ready","version":"14.1.0","uptime":"2s"}
distroless 镜像里没有 shell 和 curl,探针靠的是容器外的 curl 打进去——这也是为什么健康检查接口必须走 HTTP,而不能依赖容器内的脚本(下一节会看到用二进制自身做 HEALTHCHECK 的写法)。
一个本机踩到的坑值得记一笔:我第一次在 18080 端口测这个容器时得到
404,排查半天才发现是宿主机上有个残留的端口转发占着 18080,请求根本没打到容器。换一个端口立刻 200。排查网络问题时,先确认「请求真的到了目标」,再怀疑代码。
14.1.7 用 docker history 看清每一层
镜像是「层的叠加」,用 docker history 能看到每一层是谁贡献的。这是排查「镜像为什么这么大」的第一工具:
$ docker history taskhub-distroless:14.1 --format '{{.Size}}\t{{.CreatedBy}}'
0B /bin/sh -c #(nop) ENTRYPOINT ["/taskhub"]
0B /bin/sh -c #(nop) EXPOSE 8080
0B /bin/sh -c #(nop) USER nonroot:nonroot
6.23MB /bin/sh -c #(nop) COPY file:0f1a88732295a5b4...
319kB bazel build //common:cacerts_debian12_arm64...
16.4kB bazel build //common:os_release_debian12
读法:最终镜像里唯一「有重量」的层就是那 6.23MB 的 COPY(我们的二进制),其余是 distroless 基础镜像带来的 CA 证书、os-release、nsswitch 等几百 KB 的小件。ENTRYPOINT / EXPOSE / USER 这类元数据层都是 0B。
再看朴素镜像的层,对比就非常直观:
$ docker history taskhub-naive:14.1 --format '{{.Size}}\t{{.CreatedBy}}'
109MB /bin/sh -c CGO_ENABLED=0 GOOS=linux go build...
290MB COPY /target/ / # buildkit
...
290MB 是 golang 基础镜像本身,109MB 是那一层 go build(编译产物 + 构建缓存 + 源码都在这一层里)。这两块在 distroless 镜像里完全不存在——多阶段构建把它们的价值榨干后就丢掉了。
一个反直觉的事实:你在 Dockerfile 里 RUN rm -rf /src 不会减小镜像。删除只是新增一个「白化」标记层,底层数据仍然占据镜像体积。这就是为什么「在单阶段里清理」行不通,必须靠多阶段——没拷过去的东西,才算真的不在。
14.1.8 .dockerignore:别把垃圾送进构建上下文
docker build . 会把当前目录整个作为「构建上下文」发给 Docker 守护进程。如果仓库里有 .git、node_modules、testdata 大文件,每次构建都要传输它们,还可能导致缓存失效。加一个 .dockerignore:
.git
.gitignore
**/*_test.go
**/testdata
bin/
tmp/
*.log
*.md
效果有两个:构建上下文变小(传输更快),以及缓存更稳定(.git 每次提交都变,如果它在上下文里,COPY . . 层会每次都失效)。
不过更彻底的做法是根本不用 COPY . .:像 14.1.3 那样只 COPY go.mod 和 COPY cmd,需要什么拷什么。显式拷贝既是最小化上下文的办法,也是最稳定的缓存策略——这是下一节性能对比的基础。
14.1.9 基础镜像决策表
| 基础镜像 | 大小 | 有 shell | 适合 | 取舍 |
|---|---|---|---|---|
scratch | 0 | 否 | 极简静态二进制 | 无 CA 证书,发 HTTPS 会失败 |
distroless/static | ~2–7MB | 否 | 静态 Go 服务(推荐) | 无法进容器调试 |
distroless/base | ~20MB | 否 | 需要 glibc 的程序 | 体积略大 |
alpine | ~13MB | 是 | 需要调试/装包 | 有 shell,攻击面变大 |
debian-slim | ~80MB | 是 | 需要完整用户态 | 体积大 |
三条选择建议:
- 纯 Go 服务直接上
distroless/static。CA 证书已内置,HTTPS 出站没问题,体积最小。 - 需要进容器排查的场景(比如临时加个
tcpdump),不要为此换基础镜像,而是用一个「调试副本」:平时跑 distroless,出事时用kubectl debug挂一个alpine边车进同一个 Pod 的 namespace(第 15 章展开)。 scratch要慎用。它连 CA 证书都没有,程序发起 HTTPS 请求会报x509: certificate signed by unknown authority——很多人第一次用 scratch 就栽在这,还以为是代码问题。
小结
- 单阶段朴素镜像实测 510MB,罪魁是
golang:1.27-alpine基础镜像本身 373MB。 - 多阶段构建用
COPY --from=build只搬产物,工具链不进最终镜像。 -trimpath+-ldflags="-s -w"让二进制从 9,177,887 字节降到 6,226,080 字节(约 −32%)。CGO_ENABLED=0是静态链接的前提,也是能塞进 distroless 的前提。distroless/static-debian12:nonroot基础镜像只有 6.18MB,无 shell、无包管理器,攻击面极小。- 实测 510MB → 15MB(约 1/34),且容器正常返回
live=200/ready=200。 - 运行身份是
nonroot:nonroot(uid 65532),不以 root 跑进程。 - 镜像里没有 shell 意味着不能
docker exec进去调试,这是特性不是缺陷,调试要靠边车容器。
镜像压到 15MB 了,但每次改一行代码就重新编译整个项目、重下所有依赖,构建时间还是让人抓狂。下一节我们量化「构建缓存」到底省了多少时间,并处理多平台构建——毕竟 CI 机器和你的 Mac 架构可能不一样。
阅读导航:上一节:13.3 断点续传与大文件 · 下一节:14.2 构建缓存与多平台 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。