事情发生在一个风和日丽的周五下午。我打算把我写的下班倒计时服务部署到生产环境——它就干一件事返回距离下班还有多少秒。简单、纯粹、毫无商业价值但每个打工人都需要。kubectl apply一气呵成然后我盯着 Pod 状态等它变成 Running。一分钟过去了。两分钟过去了。五分钟过去了它还在ContainerCreating。我人麻了。一个返回数字的服务启动比我周一早上起床还慢。去节点上一查docker images好家伙912MB。我的二进制文件本身才 7MB剩下的 905MB 是什么是 Go 编译器在里面度假吗案发现场我的 DockerfileFROM golang:1.22 WORKDIR /app COPY go.mod go.sum ./ RUN go mod download COPY . . RUN go build -o countdown . EXPOSE 8080 CMD [./countdown]看起来人畜无害对吧每一行都写得规规矩矩。但问题就出在第一行——FROM golang:1.22。这个镜像里有什么完整的 Go 工具链编译器、链接器、race detector、一整个 Debian 系统apt、bash、ls、cat、还有你这辈子都不会在容器里用的 500 个命令、以及 Go module 下载缓存。这些东西在编译的时候是你的战友但在运行的时候它们就是蹭吃蹭喝的房客——不交房租还占你 900MB 硬盘。更离谱的是我的源码也被COPY . .整个拷进去了。也就是说任何能进容器的人都能看到我写的烂代码——这才是最大的安全隐患。第一刀多阶段构建把蹭吃的赶出去核心思想就一句话编译是一回事运行是另一回事别让它们住一起。就像你不会把厨房的锅碗瓢盆都搬到卧室睡觉一样——虽然理论上可以但你妈会骂你。# ---------- 构建阶段工具箱随便造 ---------- FROM golang:1.22 AS builder WORKDIR /app COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED0 GOOSlinux go build -ldflags-s -w -o countdown . # ---------- 运行阶段只留必需品 ---------- FROM scratch COPY --frombuilder /app/countdown /countdown EXPOSE 8080 CMD [/countdown]两个关键改动AS builder给第一阶段起个名字后面可以引用FROM scratch最终镜像从一个完全空白的镜像开始里面什么都没有——没有 bash没有 ls没有 apt甚至没有 libcCOPY --frombuilder只把编译好的二进制从构建阶段拷过来其他东西全部留在构建阶段永不进入最终镜像还有两个小细节CGO_ENABLED0禁用 CGO静态编译这样二进制不依赖系统 libc才能在scratch里跑-ldflags-s -w去掉调试信息和符号表体积再砍一刀对运行无影响效果如何之前golang:1.22 基础 → 912MB 之后scratch 基础 → 6.8MB缩小了 134 倍。之前拉镜像的时间够我泡两杯咖啡现在拉镜像的时间……我咖啡还没撕开包装。等等scratch 太裸了怎么办用scratch很爽但有个问题里面连/etc/ssl/certs都没有。如果你的服务要调用 HTTPS 接口比如调用企业微信 API 提醒大家下班会直接报证书错误。而且容器里没有 shell你想kubectl exec进去调试门都没有——连门都没有。这时候有两个选择选择 A手动把证书拷进去FROM scratch # 从构建阶段把 CA 证书拷过来 COPY --frombuilder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ # 再拷个时区文件不然时间都是 UTC COPY --frombuilder /usr/share/zoneinfo /usr/share/zoneinfo COPY --frombuilder /app/countdown /countdown体积还是很小大概 7MB 出头。选择 B用 distroless比 scratch 多一点点FROM gcr.io/distroless/static-debian12 COPY --frombuilder /app/countdown /countdowndistroless是 Google 出的几乎空镜像有 CA 证书、有 timezone、有 nssswitch.conf但没有 shell、没有包管理器、没有任何多余的东西。体积大约 10-20MB比 scratch 友好一点比 alpine 安全得多。别用 alpine我知道它很小5MB但它用 musl libc 而不是 glibcCGO 程序经常出玄学 bug。而且 apk 包管理器意味着攻击面更大。Go 静态编译的程序scratch 或 distroless 才是正道。第二刀构建缓存优化让 CI 不再摸鱼镜像小了是一方面构建快了是另一方面。你有没有发现每次改一行代码go mod download就要重新跑一遍Docker 的层缓存机制是某一层变了后面所有层都失效重跑。所以把不常变的放前面常变的放后面FROM golang:1.22 AS builder WORKDIR /app # 先只拷依赖文件——这俩很少变缓存命中率极高 COPY go.mod go.sum ./ RUN go mod download # ← 只要 go.mod 不变这层永远用缓存 # 再拷源码——源码天天变但只影响编译这一层 COPY . . RUN CGO_ENABLED0 go build -o countdown .之前COPY . .放在go mod download前面改一行代码整个依赖重新下载。现在改代码只重跑编译CI 时间从 3 分钟变成 20 秒。省下来的时间够你多摸鱼 2 分 40 秒。第三刀.dockerignore把垃圾挡在门外你是不是从来没写过.dockerignore那你的每次COPY . .都在把这些东西拷进构建上下文.git/ # 整个 Git 历史可能几百MB node_modules/ # 如果你前后端同仓的话 *.log # 日志文件 .vscode/ # 你的编辑器配置 Dockerfile # Dockerfile 本身 README.md # 除非你要在容器里读 README写一个.dockerignore.git .gitignore *.md *.log Dockerfile docker-compose.yml .vscode .idea构建上下文从 500MB 变成 2MBSending build context to Docker daemon这一步从喝杯茶变成眨个眼。最终成绩单版本Dockerfile 写法镜像大小拉取时间能不能 exec 进去青铜FROM golang:1.22直接跑912MB45秒能但没必要白银多阶段 alpine12MB2秒能有sh黄金多阶段 scratch 静态编译6.8MB1秒不能连shell都没有王者上面全部 构建缓存 .dockerignore6.8MB编译20秒 拉取1秒不能但你不需要同样的代码同样的功能同样返回距离下班还有 3 小时 22 分。唯一的区别是之前它部署的时候你可以去拉个屎现在它部署完了你还没坐下。最后说句实在的多阶段构建不是什么黑科技Docker 17.05 就有了到现在快十年了。.dockerignore更是和 Docker 一样古老。但大多数人的 Dockerfile 还停留在能跑就行的阶段——就像大多数人的代码还停留在能跑就行的阶段。真正该问的问题不是怎么写多阶段构建而是你的镜像里每一个文件是运行时需要的还是只是你懒得把它分开想清楚这个问题900MB 变 7MB就是顺手的事。哦对了我的下班倒计时服务现在已经跑在生产了。它每次扩容都是秒级就绪。唯一的问题是老板发现大家开始频繁刷新这个接口了。