Docker 多阶段构建:把镜像从 1.2GB 砍到 15MB

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*.jsonnpm 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.sumgo 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 是它们之间唯一该传递的东西------只传产物,不传工具。

相关推荐
Web4Browser1 小时前
Headless 浏览器自动化适合哪些任务?巡检、截图和提交动作要分级
运维·前端·自动化
難釋懷2 小时前
Nginx浏览器强制缓存
运维·nginx·缓存
花和满楼2 小时前
K8s 1.36 ImageVolume GA:OCI 镜像不再只能跑容器
java·容器·kubernetes
大博士.J2 小时前
视频号主体违规与认证上限?正确的解决方式
运维·python·自动化
hey you~2 小时前
400电话选型技术测评:一个开发视角的线路质量评估方案
运维·网络·数据库·400电话·企业通信
筱羽_筱羽3 小时前
跟着“Ubuntu18.04/20.04编译Linux5.10.76+Xenomai-3.2.1+IgH”教程遇到的问题
linux·运维·ubuntu
DolphinScheduler社区3 小时前
Apache DolphinScheduler 6 月治理优化,补齐调度运维全链路细节
大数据·运维·云原生·apache·海豚调度
hj2862514 小时前
Docker 核心知识点整理(网络 + Dockerfile + 原理 + 命令)
网络·docker·容器
无忧.芙桃4 小时前
线程同步与线程互斥(上)
linux·运维·操作系统·运维开发