Docker 多阶段构建:把镜像从 1.2GB 砍到 15MB
你有没有遇到过这种情况:一个 Go 或 Node 服务,代码没多少,打出来的镜像却一两个 G,推到镜像仓库慢、拉到生产环境也慢。问题几乎都出在------你把编译环境一起打进了运行镜像。
这篇用一个 Go 服务和一个前端项目为例,讲清楚多阶段构建(multi-stage build)怎么把镜像体积砍掉 95% 以上,以及几个能进一步压榨体积、还能加速构建的实战技巧。
先看问题:为什么镜像这么大
一个朴素的 Go 服务 Dockerfile 常常长这样:
dockerfile
FROM golang:1.22
WORKDIR /app
COPY . .
RUN go build -o server .
CMD ["./server"]
docker build 出来一看:
bash
$ docker images
REPOSITORY TAG SIZE
myapp latest 1.21GB
1.2GB。可你的服务编译出来的二进制可能就 10MB。剩下的 1.19GB 是什么?是 golang:1.22 这个基础镜像------它带着完整的 Go 工具链、编译器、git、一堆构建时才用得到的东西。这些在运行时一个都用不上,但全被打进了最终镜像。
编译需要工具链,运行只需要那个二进制。多阶段构建就是把这两件事拆开。
多阶段构建:编译和运行用不同的镜像
dockerfile
# ---- 构建阶段 ----
FROM golang:1.22 AS builder
WORKDIR /app
COPY . .
# CGO_ENABLED=0 生成静态链接的二进制,不依赖宿主机的 C 库,
# 才能塞进极简的 scratch/alpine 镜像里跑
RUN CGO_ENABLED=0 go build -o server .
# ---- 运行阶段 ----
FROM alpine:3.20
WORKDIR /app
# 只从 builder 阶段拷出编译好的二进制,工具链全部丢弃
COPY --from=builder /app/server .
CMD ["./server"]
关键在两处:
FROM ... AS builder给第一阶段起了个名字。COPY --from=builder只把上一阶段的产物(那个二进制)捞出来,放进一个干净的alpine里。最终镜像只包含 alpine + 你的二进制,构建工具链被完全抛弃。
再看体积:
bash
$ docker images
REPOSITORY TAG SIZE
myapp latest 17.3MB
从 1.21GB 到 17MB。推拉速度、冷启动、被攻击面(镜像里没有 shell 之外一堆工具)全都改善了。
再进一步:用 scratch 做到极致
如果你的二进制是纯静态的(CGO_ENABLED=0 保证了这点),连 alpine 都可以省掉,直接用空镜像 scratch:
dockerfile
FROM golang:1.22 AS builder
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 go build -o server .
FROM scratch
# scratch 里什么都没有,连 CA 证书都没有。
# 如果服务要发 HTTPS 请求,必须手动把证书拷进去
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /app/server /server
ENTRYPOINT ["/server"]
这样能到 10MB 出头,几乎就是二进制本身的大小。代价是镜像里没有 shell ,docker exec -it xxx sh 进不去,调试得靠日志。生产服务追求极简可以用 scratch,需要偶尔进容器排查的用 alpine 更稳妥。
前端项目:构建产物和 Nginx 分离
多阶段构建对前端同样好用。一个 React/Vue 项目,node_modules 动辄几百 MB,但最终上线只需要 npm run build 出来的静态文件 + 一个 web 服务器:
dockerfile
# ---- 构建阶段:用 node 打包 ----
FROM node:20-alpine AS builder
WORKDIR /app
# 先只拷 package 文件,利用缓存(见下一节)
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build # 产物在 /app/dist
# ---- 运行阶段:只要 nginx + 静态文件 ----
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
最终镜像里没有 Node、没有 node_modules、没有源码,只有编译好的静态资源和 nginx,通常 30-50MB。
加速构建:善用层缓存
上面前端例子里有个细节:先单独 COPY package*.json 再 npm ci,最后才 COPY . .。这不是随手写的,是为了利用 Docker 的层缓存。
Docker 构建是分层的,某一层的输入没变就直接复用缓存。依赖变化频率远低于业务代码。如果你写成:
dockerfile
COPY . . # 只要任何一个源码文件改了,这层缓存就失效
RUN npm ci # 于是每次都重新装依赖,慢
那每次改一行业务代码,npm ci 都要重跑一遍,几分钟没了。正确的顺序是把变化慢的放前面:
dockerfile
COPY package*.json ./ # 只有依赖清单变了才失效
RUN npm ci # 依赖没变就命中缓存,秒过
COPY . . # 业务代码变化不影响上面的依赖层
RUN npm run build
Go 项目同理,先 COPY go.mod go.sum 再 go mod download,最后拷源码。
用 .dockerignore 避免把垃圾拷进去
COPY . . 会把当前目录所有东西塞进构建上下文,包括 node_modules、.git、日志文件。加一个 .dockerignore:
node_modules
.git
*.log
dist
.env
好处有二:一是构建上下文更小、传给 Docker daemon 更快;二是避免本地的 node_modules(可能是另一个平台编译的)污染镜像,也避免 .env 里的密钥被意外打进镜像。
小结
- 镜像大的根因是编译环境混进了运行环境 ,多阶段构建用
FROM ... AS builder+COPY --from=builder把二者拆开,只保留运行产物。 - Go 服务用
CGO_ENABLED=0生成静态二进制,运行阶段用alpine(可调试)或scratch(极致小,但没 shell、要手动拷 CA 证书)。 - 前端项目用 node 阶段打包、nginx 阶段托管静态文件,体积和攻击面双降。
- 层缓存靠顺序 :变化慢的依赖声明放前面单独
COPY + install,业务代码放后面,改代码不再重装依赖。 - 配
.dockerignore挡住node_modules、.git、.env,构建更快也更安全。
一句话记忆:构建镜像负责「造」,运行镜像负责「跑」,COPY --from 是它们之间唯一该传递的东西------只传产物,不传工具。