Docker 多阶段构建实战:把服务镜像从 1.2GB 压到 20MB
过去两年里,我们的交付镜像一直卡在 1.2GB。推一次镜像要等三分多钟,节点拉取又要两分钟,扩容的时候更是在和 CI 的耐心赛跑。后来我们把服务改成 Go 单体并引入 Docker 多阶段构建,镜像体积直接掉到 20MB,构建时间压到 90 秒以内。本文记录这次改造的完整过程和踩过的几个坑。
一、先搞清楚体积到底花在哪
动手改之前,团队里多数人凭直觉认为“大镜像都是基础镜像太大”。真实情况往往不是。一个最省事的检查办法:
docker history --human --no-trunc <image> | head -30
这条命令会按层列出每层的大小和创建命令,对着输出看,通常一两分钟内就能定位到元凶。我们这个项目里,第一层就占了 840MB——那是构建阶段的 golang:1.22 镜像。
二、多阶段构建怎么写
核心思想很简单:构建阶段可以随便用大镜像,运行阶段只保留真正需要的产物。
FROM golang:1.22 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/app ./cmd/server
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /out/app /app
EXPOSE 8080
ENTRYPOINT ["/app"]
这里有四个细节值得强调:
1. 先 COPY go.mod go.sum 再 RUN go mod download,这样依赖层能被单独缓存住,改代码时不会让依赖重新下载一遍。
2. CGO_ENABLED=0 去掉 C 依赖,程序才能跑在没有 libc 的 distroless 这类精简镜像上。
3. -trimpath 去掉编译时写入的本地路径,-ldflags="-s -w" 去掉调试符号和符号表,这两项还能再省下几 MB。
4. 运行阶段用 distroless,容器里连 shell 都没有,可被利用的入口比完整发行版少得多,日常维护也更省心。
三、最容易漏掉的一步:.dockerignore
改完之后我们的镜像还是 900MB,原因就是漏了 .dockerignore,COPY . . 把整个 node_modules 和 .git 一起塞进了构建上下文。最小可用版本长这样:
.git
node_modules
**/target
**/__pycache__
*.log
.env
四、效果与代价
改造前后的对比:
- 镜像体积:1.2GB → 20MB
- 构建耗时:3min → 90s
- 节点拉取:2min → 5s
代价是失去了进入容器排查问题的能力,distroless 里没有 bash。折中做法是保留一个带 shell 的 target 专门用于排查,平时 CI 只构建 release 标签,需要排查时再构建 debug 标签:
FROM alpine:3.20 AS debug
RUN apk add --no-cache curl strace
COPY --from=builder /out/app /app
五、两个容易被忽略的坑
一是镜像瘦身后,“同一个 tag 推两次”的问题会被放大。如果流水线只 push latest,新旧镜像会互相覆盖,出现“明明没改代码,服务却变了”的诡异现象。我们的做法是 tag 里带上 git short sha,同时让 latest 指向最新构建。
二是基础镜像建议锁 digest,不要用 latest 标签。否则不同机器、不同时间构建出来的镜像会有细微差异,排查问题时非常痛苦。
总结
镜像优化不是一步到位的事,建议按这个顺序推进:
- docker history 定位体积大头
- 补上 .dockerignore
- 拆分多阶段构建
- 换最小化运行基础镜像
- 用 distroless 或 alpine 替代完整发行版
前两步通常能解决一半问题,后三步才是把收益拉满的关键。改完之后最大的感受是:部署终于不再是 CI 流水线里最慢的那个环节了。
- 点赞
- 收藏
- 关注作者
评论(0)