容器镜像瘦身实录:多阶段构建把 800MB 压到 90MB
上周把一个 Go 写的内部工具做成容器镜像推到仓库,CI 流水线拉取镜像时每次都要等两分多钟,部署到新节点更是慢得明显。查了一下镜像大小:800MB。一个只有十几兆二进制的服务,镜像却快 1GB,显然哪里不对。最后用多阶段构建把镜像压到了 90MB,拉取时间从两分多钟降到十几秒。这次把排查和改造过程记录下来。
一、镜像为什么会胖
镜像是一层一层叠上去的,每一层都算体积。最常见的“胖”有三个来源。
第一,基础镜像选得太重。为了图省事直接用完整版的语言镜像,里面自带编译器、调试工具和一大堆系统包,光底座就有六七百兆。运行时其实只需要一个能跑二进制的环境。
第二,构建产物和构建工具混在同一层。源码、依赖缓存、编译中间文件都留在镜像里,而运行时根本用不到它们。
第三,历史层没清理。RUN 里下载的安装包、解压的临时文件,即使后面删掉了,也只是又叠了一层“删除标记”,底层那份数据还在镜像里占着地方。
二、多阶段构建的思路
多阶段构建就是把“编译”和“运行”拆成两个阶段:第一个阶段用重型镜像编译出二进制,第二个阶段只把编译好的二进制拷进一个极小的基础镜像。最终镜像只含运行阶段的内容,编译工具、源码和中间文件一个都不带。
原理很简单,但效果直接:编译环境再重也不影响最终体积,因为它根本不会进最终镜像。
三、实操:改造 Dockerfile
改造前是单阶段写法,编译和运行都在同一个镜像里:
FROM golang:1.22
WORKDIR /app
COPY . .
RUN go build -o server ./cmd/server
CMD ["./server"]
改造后拆成两个阶段:
FROM golang:1.22 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o server ./cmd/server
FROM alpine:3.19
WORKDIR /app
COPY --from=builder /app/server ./server
EXPOSE 8080
CMD ["./server"]
有几个细节值得注意。先单独复制 go.mod 和 go.sum 再下载依赖,这样只有依赖变化时才重新下载,改业务代码不会让依赖层失效,构建缓存命中率明显提高。CGO_ENABLED=0 产出静态链接的二进制,放到不带 C 库的精简基础镜像里也能跑。COPY --from=builder 只取编译产物,源码和构建缓存都留在第一个阶段里。
四、效果验证
改造后重新构建并对比:镜像从 800MB 降到 90MB,缩小了将近九成。CI 拉取时间从两分多钟降到十几秒,新节点扩容时镜像拉取不再是瓶颈。安全扫描的漏洞数也少了一大截——镜像里少了编译器和几百个系统包,攻击面自然小了。
验证时别只看大小,还要确认服务真的能跑:进容器看进程是否正常启动、调一次健康检查接口、再看日志有没有缺失动态库之类的报错。静态编译这一步没做对的话,服务会在启动时直接报错,排查起来反而费时间。
五、几个容易踩的坑
一是基础镜像不是越小越好。极简镜像缺时区数据、缺 CA 证书,服务调外部 HTTPS 接口或打带时区的时间戳时会出问题。缺什么就显式装什么,别等线上报错再补。
二是构建缓存失效。COPY . . 写在依赖下载之前,改一行代码就让依赖层失效,构建时间反而变长。先拷依赖文件、下载依赖,再拷源码,这个顺序不能乱。
三是忽略多架构。团队里有人用 ARM 机器本地构建,推上去的镜像在 x86 节点上跑不起来。CI 里固定用 docker buildx 指定目标平台,本地构建和线上构建用同一套参数,避免架构不一致。
六、小结
镜像瘦身不是为了数字好看,而是直接换来更快的部署和更小的攻击面。记住三件事:编译和运行分阶段、只把运行必需品拷进最终镜像、构建顺序先依赖后源码。按这个思路改造,大多数服务的镜像都能瘦掉八成以上,剩下的再按实际缺失补时区和证书这类运行时依赖即可。
- 点赞
- 收藏
- 关注作者
评论(0)