Docker 多阶段构建:Go 镜像从 1.1GB 到 12MB

格 格达软件 2026-10-28 0 次阅读

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 秒的是另外三件事:

  1. COPY go.mod go.sum ./ 单独一层,依赖不变就不重装
  2. --mount=type=cache,target=/go/pkg/mod 复用 module cache
  3. --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 的 httpGet probe,本来就不该写在镜像里;
  • 真要进容器排查: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。这笔账划不划算,取决于你的团队在故障现场更缺哪个。

格
格达软件

专注软件定制开发,分享技术实践与行业观察。有开发需求?欢迎随时咨询。

咨询我们

有软件开发需求?我们 24 小时内响应

提交您的需求,格达软件将为您提供免费的需求评估与技术方案建议,咨询全程不收取任何费用。