为什么要关心镜像大小
你有没有遇到过这种情况:本地写好的ref="/tag/47/" style="color:#EB6E00;font-weight:bold;">应用,打包成 Docker 镜像后足足有 800MB,推到服务器拉取时慢得像蜗牛?尤其是部署到云主机或者 CI/CD 流水线里,每次更新都要等好久。其实,很多“臃肿”的镜像问题,出在构建方式上。
传统的 Dockerfile 往往把编译、打包、运行环境全塞进一个镜像里。比如用 Go 或 Java 写的服务,编译需要完整的 SDK,但运行时只需要一个可执行文件和基础系统库。这就造成了浪费。
多阶段构建是怎么解决这个问题的
Docker 从 17.05 版本开始支持多阶段构建(multi-stage build),允许你在同一个 Dockerfile 中使用多个 FROM 指令,每个阶段可以基于不同的基础镜像,只把需要的产物传到最后阶段。
举个常见的例子:你用 Go 写了个 Web 服务。开发时你用了 golang:alpine 镜像来编译,但它自带了编译器和源码工具链,体积不小。而程序跑起来只需要一个静态二进制文件。多阶段构建就能帮你把编译环境和运行环境分开。
一个实际的 Go 示例
FROM golang:1.21-alpine AS builder
<WORKDIR /app>
<COPY . .>
<RUN go build -o myapp .>
FROM alpine:latest AS runtime
<WORKDIR /root/>
<COPY --from=builder /app/myapp .>
<CMD ["./myapp"]>第一阶段叫 builder,负责编译出 myapp 可执行文件。第二阶段从一个干净的 alpine 镜像开始,只把编译好的文件复制进来。最终生成的镜像不包含 Go 编译器,体积通常能压到 30MB 以内。
不只是 Go,其他语言也适用
Java 项目同样能用这招。Maven 构建需要 JDK 和一堆依赖,但运行时用 JRE 就够了。你可以第一阶段用 maven 镜像打包出 jar 包,第二阶段用 openjdk:jre-alpine 运行它。
FROM maven:3.8-openjdk-17 AS builder
<COPY pom.xml .>
<COPY src ./src>
<RUN mvn clean package -DskipTests>
FROM openjdk:17-jre-alpine AS runtime
<COPY --from=builder /target/myapp.jar /app.jar>
<CMD ["java", "-jar", "/app.jar"]>这样最终镜像不会带上 Maven 工具链和源码,安全又轻量。
前端项目也能受益
别以为只有后端才用得上。Node.js 项目构建时需要 node_modules,动辄几百 MB。但真正上线只需要打包后的 static 文件。用多阶段构建,先在 node 镜像里跑 npm run build,再用 nginx 镜像只复制 dist 目录:
FROM node:18 AS builder
<WORKDIR /app>
<COPY package*.json .>
<RUN npm install>
<COPY . .>
<RUN npm run build>
FROM nginx:alpine AS production
<COPY --from=builder /app/dist /usr/share/nginx/html>最后出来的镜像就是一个精简的 nginx,只服务于你的前端资源,没有 Node 环境,也不怕开发者误操作进容器改代码。
小技巧:给阶段命名更清晰
Docker 允许你用 AS 给每个阶段起名字,比如 AS builder、AS tester、AS production。这样别人看 Dockerfile 更容易理解流程。你也可以只构建某个阶段用于调试:
比如运行 docker build --target builder -t myapp:dev . 就只走到编译阶段,方便检查中间产物。
实际效果对比
我们公司有个 Python 服务,原本镜像基于 python:3.9-slim,直接 pip install + copy 代码,镜像 420MB。改成多阶段后,第一阶段安装依赖并编译 wheel,第二阶段从 scratch 复制可执行文件和依赖,最终降到 87MB。部署速度提升明显,Kubernetes 拉镜像时间从 40 秒降到不到 10 秒。
这不是什么黑科技,只是把原本“一锅炖”的做法拆开,各司其职。就像做饭时厨房和餐厅分开,既卫生又高效。