把镜像从 1.2G 压到 80M
一个 Go 服务的镜像,初始版本 1.2G。压到 80M 花了大概两个下午,中间走了一些弯路。
先看每层占了多少
别急着换基础镜像,先看清楚体积花在哪儿:
docker history --no-trunc your-image:tag
dive your-image:tag
当时的结果很典型:编译工具链 600M,源码和缓存 300M,剩下一堆 apt 装的调试工具。
多阶段构建解决了大头
FROM golang:1.22 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /out/app .
FROM gcr.io/distroless/static-debian12
COPY --from=build /out/app /app
ENTRYPOINT ["/app"]
这样最终镜像里只有编译产物和运行时依赖。编译工具链全部留在 build 阶段,不进最终镜像。
-ldflags="-s -w" 去掉符号表和调试信息,一个中等规模的 Go 程序能少几 MB。CGO_ENABLED=0 让它变成静态链接,才能放进 distroless 这种连 libc 都没有的镜像。
层缓存的问题
把 COPY go.mod go.sum 和 RUN go mod download 放在 COPY . . 之前,是为了让依赖下载这一层独立缓存。不然每次改一行代码都要重新拉一遍依赖。
关于 alpine
很多人第一反应是换 alpine。但如果你用的是 CGO,alpine 的 musl libc 和 glibc 行为不完全一样,踩过 DNS 解析超时的坑。
我的结论是:Go 静态编译的用 distroless 或 scratch,需要 glibc 的用 debian:slim。alpine 的优势主要是体积,但代价是兼容性排查成本。
最后
加 .dockerignore 也是必须的。.git 目录、本地编译产物、测试数据,这些东西被 COPY . . 打包进去的话,白白增加体积还泄露信息。