docker images 里那个 1.11GB 的 api 镜像,改一行代码要等 47 秒才构建完,节点滚动更新时几十个进程同时 pull 同一个 1GB 的层。而它实际跑起来的东西,是一个 14MB 的静态二进制。这篇文章把它一路砍到 12.4MB,并且把砍完之后你要额外背的锅(时区、CA 证书、排障时没有 shell)一条条列清楚。
先看「能跑就行」的写法有多贵
大多数项目的第一版 Dockerfile 长这样:
# Dockerfile.naive
FROM golang:1.22
WORKDIR /src
COPY . .
RUN go build -o /app ./cmd/api
CMD ["/app"]
docker build -f Dockerfile.naive -t api:naive .
docker images api:naive --format '{{.Size}}'
# 1.11GB
用 docker history 看看这 1.11GB 是怎么堆出来的:
docker history api:naive --no-trunc --format '{{.Size}}\t{{.CreatedBy}}' | head -5
272MB RUN /bin/sh -c go build -o /app ./cmd/api # buildkit
3.1MB COPY . . # buildkit
0B WORKDIR /src
838MB COPY / / # buildkit
三层,一眼看清钱花在哪了:
- 838MB 是基础镜像。
golang:1.22基于 buildpack-deps,里面装着完整 Debian、gcc、git、make、Go 工具链。这些在运行时一个都用不到。 - 272MB 是
go build那一层。注意它比产物大 20 倍——因为go build顺手把 module cache 和GOCACHE全写进了这一层,而层是不可变的,删不掉。 - 3.1MB 是源码。这个反而是最健康的数字(后面会讲不写
.dockerignore时它能膨胀到 700MB)。
三个实测数字,是后面所有优化的靶子:镜像 1.11GB,冷构建 47 秒,改一行业务代码后重建 44 秒——因为 COPY . . 在 go build 之前,任何源码改动都让后面整层缓存作废。
多阶段构建:构建期和运行期本来就是两拨东西
多阶段构建没什么玄学,就是一句话:COPY --from=builder 只搬文件,前一阶段的所有层都不会进入最终镜像,也不会被推到 registry,只留在你本地磁盘的 build cache 里。
# syntax=docker/dockerfile:1.7
FROM golang:1.22 AS builder
WORKDIR /src
# 先只拷依赖清单。go.mod / go.sum 没变时,这一层永远命中缓存
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
COPY . .
# -trimpath 去掉构建机上的绝对路径,产物可复现
# -ldflags=-s -w 去掉符号表和 DWARF(panic 栈和 pprof 仍然正常)
# CGO_ENABLED=0 生成静态链接二进制,这是后面能用 scratch 的前提
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=linux go build \
-trimpath -ldflags="-s -w" \
-o /out/api ./cmd/api
# distroless/static 只有 CA 证书、/etc/passwd、/tmp、tzdata,没有 shell、没有包管理器
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /out/api /api
EXPOSE 8080
ENTRYPOINT ["/api"]
docker build -t api:slim .
docker images api:slim --format '{{.Size}}'
# 12.4MB
拆开就只有两层:
| 内容 | 大小 |
|---|---|
distroless/static-debian12:nonroot 基础层 |
2.3MB |
/api(-s -w 之后) |
10.1MB |
编译产物本身:默认 14.2MB,加了 -trimpath -ldflags="-s -w" 之后 10.1MB,直接少 29%,零运行时代价。
这里有个我第一次就翻车的点:-o 的输出路径千万别落在 cache mount 的目录里面。有人把构建命令写成 -o /go/bin/api,同时又 --mount=type=cache,target=/go,构建日志一切正常,到了下一阶段 COPY --from=builder /go/bin/api /api 直接 not found——因为 cache mount 的目录在构建结束后会被卸载,里面写的东西全没了。产物固定输出到 /out 这种独立路径上最稳。
体积只是结果,构建速度要靠分层和 cache mount
多阶段构建只解决「最终镜像多大」,不解决「构建多慢」。真正把 44 秒变成 3 秒的是另外三件事:
COPY go.mod go.sum ./单独一层,依赖不变就不重装--mount=type=cache,target=/go/pkg/mod复用 module cache--mount=type=cache,target=/root/.cache/go-build复用编译缓存——这条最关键,它让「改一行代码」只需要重新编译改动的那几个包
实测对比(同一个中等规模项目,8 核机器):
| 版本 | 冷构建(无缓存) | 改一行业务代码后重建 |
|---|---|---|
| naive 单阶段 | 47s | 44s |
| 多阶段,无 cache mount | 51s | 38s |
| 多阶段 + 分层 + cache mount | 51s | 3.2s |
冷构建反而慢了几秒,正常——多阶段多了一个 stage 的初始化开销,cache mount 也要建目录。这笔账只在「构建次数 ≥ 2」时回本,而 CI 上永远是回本的。
两个边界条件要注意:
RUN --mount=type=cache是 BuildKit 语法,需要 Docker 23+ 或显式DOCKER_BUILDKIT=1。文件头的# syntax=docker/dockerfile:1.7别省,否则老 daemon 会报unknown flag: mount。- Kaniko 和较老版本的 buildah 不支持
RUN --mount。如果你的 CI 还在用 Kaniko 构建,这段配置会直接失败。要么换 buildkitd,要么老老实实退回「依赖清单单独一层」的方案,能拿到 38 秒那个数字。
12MB 之后,这些问题会来找你
坑 1:exec /api: no such file or directory,但文件明明在
镜像里 ls / 能看到 /api,跑起来却报这个错。别怀疑文件,怀疑的是 ELF 解释器——动态链接的二进制需要 /lib64/ld-linux-x86-64.so.2,scratch 和 distroless/static 里没有。
# 在构建机上直接看一眼产物类型
CGO_ENABLED=1 go build -o /tmp/api-cgo ./cmd/api
file /tmp/api-cgo
# ELF 64-bit LSB executable, x86-64, dynamically linked,
# interpreter /lib64/ld-linux-x86-64.so.2 <- 元凶
问题是这个坑不会报错提醒你:golang 镜像里装了 gcc,所以 CGO_ENABLED 默认就是 1,只要你的代码间接引入了 os/user、net 的 cgo DNS resolver、或者任何 sqlite 驱动,产出的就是动态链接二进制。你什么都没改,镜像突然跑不起来了。
三个解法,按推荐顺序:
CGO_ENABLED=0,绝大多数纯 Go 服务都该这么干;- 确实要 cgo(比如用
go-sqlite3),换成gcr.io/distroless/base-debian12,它带 glibc; - 或者
-tags netgo,osusergo -ldflags "-extldflags \"-static\"",但 glibc 的静态链接会带来 NSS 警告,不如前两条干净。
坑 2:HTTPS 调用报 x509: certificate signed by unknown authority
distroless/static 自带 CA 证书,scratch 没有。选 scratch 就得自己搬:
FROM scratch
# golang 基础镜像里有 ca-certificates,从 builder 阶段拷过来
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /out/api /api
ENTRYPOINT ["/api"]
顺带一个容易被忘的维护成本:证书包是会过期的。如果你的 base image 钉死在 golang:1.22 又两年不重建,某天早上服务会集体连不上对象存储和第三方 API,而且报错是 x509 而不是「证书过期」,排查起来要绕一圈。定时重建 base 比什么都重要。
坑 3:time.LoadLocation("Asia/Shanghai") 直接报错
错误信息是 unknown time zone Asia/Shanghai。scratch 里没有 /usr/share/zoneinfo。两条路:
// 方案 A:把 tzdata 编译进二进制,+450KB,零外部文件依赖
import _ "time/tzdata"
# 方案 B:从 builder 拷整份时区库,+3.1MB
COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
纯服务端应用我更倾向方案 A:不用担心哪个 COPY 忘了写。反过来说,如果你的定时任务要支持用户在后台改时区,方案 B 更灵活。无论如何,TZ=UTC + 只在展示层转时区,永远是更省事的选择。
坑 4:没有 shell,HEALTHCHECK 和 docker exec 全废
# 在 distroless / scratch 里必然失败
HEALTHCHECK CMD curl -f http://localhost:8080/healthz || exit 1
报错是 exec: "curl": executable file not found in $PATH。同理 docker exec -it xxx sh 也进不去。
- 健康检查交给 k8s 的
httpGetprobe,本来就不该写在镜像里; - 真要进容器排查:
kubectl debug -it pod/api --image=busybox:1.36 --target=api,临时容器共享进程命名空间,能看到目标容器的进程和网络; -ldflags="-s -w"之后dlv/gdb用不了,但 pprof 和 panic 栈仍然正常——它们走的是 pclntab,不是被删掉的 ELF 符号表。需要调试时临时构建一个不带-s -w的 tag,别为了调试常年把这个优化去掉。
坑 5:写文件时报 permission denied 或 no such file or directory
distroless:nonroot 的 uid 是 65532,工作目录不可写;scratch 里连 /tmp 都不存在,os.MkdirTemp("", ...) 会失败。日志一律写 stdout;确实要落盘的,k8s 挂 emptyDir 到 /tmp,别指望容器内的文件系统。
坑 6:没写 .dockerignore,构建上下文 700MB
.git
bin/
dist/
*.test
不加这个文件,.git 里几年的历史全被打进 build context。两个后果:[internal] load build context 从 0.1 秒变成 12 秒;更隐蔽的是 .git/index 一直在变,导致 COPY . . 这层每次必失效,前面精心设计的缓存分层白做。这条最少人注意,但收益最大。
坑 7:多架构构建别用 QEMU 模拟
# builder 跑在本机架构,用 Go 交叉编译出目标架构
FROM --platform=$BUILDPLATFORM golang:1.22 AS builder
ARG TARGETOS TARGETARCH
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH \
go build -trimpath -ldflags="-s -w" -o /out/api ./cmd/api
buildx 默认用 QEMU 模拟目标架构,在 x86 机器上构建 arm64 镜像,中等项目要 4~6 分钟;交叉编译 40 秒。代价是 cgo 项目需要额外配 toolchain,纯 Go 无感。
该停在 18MB 还是 10MB
| 方案 | 未压缩大小 | 有 shell | 适合的场景 |
|---|---|---|---|
golang:1.22 单阶段 |
1.11GB | 有 | 只适合本地临时调试 |
alpine:3.19 多阶段 |
18MB | 有(busybox) | 需要 apk 装运行时依赖、需要 exec 进去排查 |
distroless/static |
12.4MB | 无 | 纯 Go、无 cgo,生产默认选这个 |
scratch |
10.6MB | 无 | 团队有 k8s ephemeral container 权限,且愿意自己维护 CA 和时区 |
从 18MB 到 10MB 省下的 8MB,在拉取时间上肉眼基本看不出来(几百毫秒量级)。真正值钱的是 1.11GB 到 18MB 那一段,后面这 8MB 纯粹是洁癖。所以如果你不确定,停在 distroless 或 alpine 就好。
alpine 版本长这样,多 6MB 换一个 busybox shell 和 apk:
FROM alpine:3.19
# --no-cache 等价于 apk add + rm -rf /var/cache/apk/*,必须一条 RUN 里完成
RUN apk add --no-cache ca-certificates tzdata \
&& adduser -D -u 10001 app
COPY --from=builder /out/api /api
USER 10001
ENTRYPOINT ["/api"]
这些情况下不要走到 distroless / scratch:
- 启动脚本里要改配置、等数据库、做迁移——没有 shell 就得把这些逻辑塞进二进制;
- 依赖外部动态库(ImageMagick、libpq、任何 cgo 库);
- 团队线上排查只能靠
docker exec,没有 k8s 调试权限; - 在 docker-compose 或裸 docker 环境里跑,需要
HEALTHCHECK指令。
另外别忘了:RUN apt-get install ... && rm -rf /var/lib/apt/lists/* 这种删除必须和安装在同一条 RUN 里。拆成两条 RUN,第二次只是加了个 whiteout 标记,前一层的几百 MB 该多大还多大。这是层机制的铁律,比任何技巧都重要。
在 CI 里加一道体积门禁
体积一旦没人盯着,三个月后必然反弹。加五行脚本就够:
#!/usr/bin/env bash
set -euo pipefail
IMG="registry.internal/api:${CI_COMMIT_SHA:0:8}"
docker build -t "$IMG" .
# .Size 是未压缩字节数,和 docker images 显示的口径一致
SIZE=$(docker image inspect "$IMG" --format '{{.Size}}')
echo "image size: $((SIZE / 1024 / 1024)) MB"
if [ "$SIZE" -gt 52428800 ]; then # 50MB 上限
echo "镜像超过 50MB,检查是不是误拷了 builder 阶段、或者 -s -w 被删了"
exit 1
fi
这道门禁比 code review 有用得多。我见过最常见的两种翻车:调试时把最终阶段临时改成 FROM golang:1.22 忘了改回去;以及为了「方便」把 COPY --from=builder /go /go 整体拷进来——两个都能被这行判断当场拦住。
镜像从 1.11GB 砍到 12.4MB,省下来的不是磁盘,是每次发版那 40 秒等待和节点上同时 pull 的几十个进程;代价是你以后半夜排查问题时少了一个 shell。这笔账划不划算,取决于你的团队在故障现场更缺哪个。