Dockerfile系列(三) 多阶段构建-告别镜像obesity
多阶段构建:告别"镜像 Obesity"
本文基于 Docker 24.x + BuildKit,展示如何把 1GB+ 的镜像瘦身到 20MB。
场景引入:镜像胖到推不动
上篇咱们优化了构建速度,但构建完的镜像让我傻眼了:
$dockerimages|grepmy-app my-app latest abc1231.2GB1.2GB!就一个简单的 Web 服务,里面塞了啥?
- Node 基础镜像:~180MB
npm install的依赖:node_modules占了 800MB+- 构建工具(gcc、python、make):为了编译某些原生模块
- 源码、测试文件、
.git目录…
这就像你搬家时,把装修工具、建筑材料、设计图纸全塞进新房——能住人,但到处都是垃圾。
多阶段构建就是解决这个问题的大杀器。
核心原理:一个 Dockerfile,多个 “FROM”
传统 Dockerfile 只有一个FROM,构建产物和工具全塞一起。多阶段构建允许你写多个FROM,每个阶段是一个独立的构建环境,最后只把需要的产物拷贝到最终镜像。
类比:餐厅后厨 vs 前厅摆盘
想象你去高级餐厅吃饭:
- 后厨(构建阶段):锅碗瓢盆、厨师、食材、调料,乱七八糟但功能齐全
- 传菜口(COPY --from):只把做好的菜端出去
- 前厅摆盘(最终镜像):精致的盘子、菜品,看不到后厨的油烟
多阶段构建就是这个逻辑:后厨多乱都没关系,客人(生产环境)只看到精致的成品。
实战对比:Go 应用的单阶段 vs 多阶段
Go 是最能体现多阶段构建价值的语言之一,因为编译后只有一个二进制文件。
单阶段:胖镜像(约 1GB)
FROM golang:1.21 WORKDIR /app COPY go.mod go.sum ./ RUN go mod download COPY . . RUN go build -o myapp . EXPOSE 8080 CMD ["./myapp"]$dockerbuild-tmyapp-fat.$dockerimages|grepmyapp-fat myapp-fat latest1.05GB多阶段:瘦镜像(约 15MB)
# ========== 阶段 1:编译 ========== FROM golang:1.21 AS builder WORKDIR /app COPY go.mod go.sum ./ RUN go mod download COPY . . # 静态编译,不依赖系统库 RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o myapp . # ========== 阶段 2:运行 ========== FROM alpine:latest # 安全考虑:用非 root 用户(下篇细讲) RUN adduser -D -u 1000 appuser USER appuser WORKDIR /app # 只拷贝编译好的二进制文件 COPY --from=builder /app/myapp . EXPOSE 8080 CMD ["./myapp"]$dockerbuild-tmyapp-slim.$dockerimages|grepmyapp myapp-slim latest15.2MB myapp-fat latest1.05GB从 1GB 到 15MB,瘦了 98%!而且最终镜像里没有 Go 编译器、没有源码、没有go.mod,只有一个能跑的二进制。
前端项目:Node 构建 → Nginx serving
前端项目同样适合多阶段,用 Node 构建,用 Nginx 托管静态文件:
# ========== 阶段 1:构建 ========== FROM node:18-alpine AS builder WORKDIR /app COPY package*.json ./ RUN npm ci --only=production COPY . . RUN npm run build # 生成 dist/ 目录 # ========== 阶段 2:托管 ========== FROM nginx:alpine # 只拷贝构建产物 COPY --from=builder /app/dist /usr/share/nginx/html # 自定义 nginx 配置(可选) COPY nginx.conf /etc/nginx/conf.d/default.conf EXPOSE 80 CMD ["nginx", "-g", "daemon off;"]最终镜像基于 Nginx Alpine(约 25MB),没有 Node、没有node_modules、没有源码。
Java 项目:Maven 构建 → JRE 运行
Java 也能大幅瘦身,从 JDK 切换到 JRE,甚至用自定义 JRE(Java 11+ 的jlink):
# ========== 阶段 1:编译 ========== FROM maven:3.9-eclipse-temurin-17-alpine AS builder WORKDIR /app COPY pom.xml . RUN mvn dependency:go-offline # 缓存依赖 COPY src ./src RUN mvn clean package -DskipTests # ========== 阶段 2:运行 ========== FROM eclipse-temurin:17-jre-alpine WORKDIR /app # 只拷贝 jar 包 COPY --from=builder /app/target/*.jar app.jar EXPOSE 8080 ENTRYPOINT ["java", "-jar", "app.jar"]JDK 镜像约 400MB,JRE 镜像约 150MB,省了 60%+。
进阶:极致瘦身——Distroless 和 Scratch
Distroless:Google 出品,极简但能用
FROM golang:1.21 AS builder WORKDIR /app COPY . . RUN CGO_ENABLED=0 go build -o myapp . # 没有 shell、没有包管理器,只有运行必需的库 FROM gcr.io/distroless/static-debian12 COPY --from=builder /app/myapp / CMD ["/myapp"]Distroless 镜像只有几十 MB,而且没有 shell,安全性极高(攻击者进去啥命令都执行不了)。
Scratch:从零开始
Go 可以编译成完全静态链接的二进制,连操作系统库都不依赖:
FROM golang:1.21 AS builder WORKDIR /app COPY . . RUN CGO_ENABLED=0 go build -o myapp . # 空镜像,啥都没有 FROM scratch COPY --from=builder /app/myapp / # 需要拷贝 CA 证书才能发 HTTPS 请求 COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ CMD ["/myapp"]最终镜像:不到 10MB,这是 Docker 镜像的理论极限。
⚠️坑:Scratch 里没有sh,调试极其困难。生产环境用 Distroless 更平衡。
多阶段之间的数据传递
COPY --from是跨阶段拷贝的关键:
# 从指定阶段拷贝 COPY --from=builder /app/myapp . # 从指定镜像拷贝(甚至不需要前面定义过) COPY --from=nginx:alpine /etc/nginx/nginx.conf /tmp/ # 从索引号拷贝(第 0 个 FROM) COPY --from=0 /app/myapp .一句话总结
多阶段构建就像餐厅后厨和前厅分离:编译工具、源码留在构建阶段,只把二进制产物带到最终镜像——体积能从 GB 压到 MB,攻击面还小了一大圈。
