在容器化部署的浪潮中,Docker 镜像的体积大小直接影响着镜像拉取速度、存储成本以及部署效率。尤其是 Go 和 Java 这类后端项目,传统构建方式很容易生成几百兆甚至上百兆的臃肿镜像。而 Docker 多阶段构建(Multi-stage Builds)技术,正是解决这一问题的"利器"------它能精准分离构建环境与运行环境,只保留运行所需的核心产物,实现镜像的极致瘦身。
本文将从"镜像臃肿的根源"入手,通俗讲解多阶段构建的核心原理,再分别针对 Go 语言的静态编译特性和 Java(以 Spring Boot 为例)的打包运行特性,提供完整的实战示例代码,最后拓展多阶段构建的进阶用法,帮助大家彻底掌握镜像瘦身技巧。
一、先搞懂:为什么传统镜像会"臃肿"?
在多阶段构建出现之前,我们构建 Docker 镜像通常有两种方式,这两种方式都容易导致镜像臃肿:
-
单阶段构建:把"代码编译/打包"和"程序运行"放在同一个 Dockerfile 中。为了完成编译,需要在镜像中安装编译器、依赖库等工具(比如 Go 的 SDK、Java 的 JDK),这些工具在程序运行时完全用不上,但会一直留在镜像中,导致体积飙升。
-
外部构建+镜像打包:先在本地或 CI 环境中完成代码编译,再将编译产物复制到镜像中。这种方式虽然能避免冗余工具,但需要额外维护构建脚本,步骤繁琐,且容易出现"本地环境与镜像环境不一致"的问题。
而多阶段构建的核心思路的是:用多个"构建阶段"分别完成不同的任务,最终只将"运行阶段"需要的产物保留下来。每个构建阶段都可以使用独立的基础镜像,互不干扰,多余的工具和依赖会被自动丢弃,从根源上实现镜像瘦身。
二、多阶段构建核心原理:分层打包,去芜存菁
Dockerfile 中,每个 FROM 指令都代表一个新的构建阶段,我们可以给每个阶段起一个别名(比如 FROM xxx AS builder),方便后续引用。其核心流程如下:
-
构建阶段(Builder Stage):使用包含编译工具的基础镜像(比如 Go SDK、JDK),完成代码的编译、打包等操作,生成可运行的产物(比如 Go 的二进制文件、Java 的 Jar 包)。
-
运行阶段(Runtime Stage):使用极简的基础镜像(比如 alpine、openjdk:jre-slim),从构建阶段复制所需的运行产物,不需要安装任何编译工具,最终生成的镜像体积极小。
简单来说,多阶段构建就像"包饺子":构建阶段负责"和面、调馅、擀皮"(准备核心产物),运行阶段只保留"包好的饺子"(可运行程序),把"面粉、擀面杖、调料罐"(多余工具)全部丢弃。
三、实战一:Go 项目镜像瘦身(静态编译优势拉满)
Go 语言支持静态编译,编译后的二进制文件可以不依赖任何系统库独立运行,这和多阶段构建搭配起来堪称"天作之合"。我们以一个简单的 Go Web 项目为例,演示从传统构建到多阶段构建的瘦身过程。
3.1 准备 Go 测试代码
创建一个简单的 HTTP 服务(main.go),监听 8080 端口,返回"Hello Docker Multi-stage Build":
go
package main
import (
"fmt"
"net/http"
)
func main() {
// 定义路由,返回问候语
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Hello Docker Multi-stage Build!")
})
// 启动服务,监听 8080 端口
fmt.Println("Server is running on :8080")
err := http.ListenAndServe(":8080", nil)
if err != nil {
fmt.Printf("Server start failed: %v\n", err)
}
}
3.2 传统单阶段构建(反面示例)
传统方式会直接使用 Go SDK 镜像完成编译和运行,Dockerfile 如下:
dockerfile
# 基础镜像:包含 Go SDK(约 900MB)
FROM golang:1.21-alpine
# 设置工作目录
WORKDIR /app
# 复制代码到镜像中
COPY main.go .
# 编译 Go 程序(静态编译,不依赖系统库)
RUN CGO_ENABLED=0 GOOS=linux go build -o go-demo main.go
# 暴露端口
EXPOSE 8080
# 启动程序
CMD ["./go-demo"]
构建并查看镜像体积:
bash
# 构建镜像
docker build -t go-demo-traditional .
# 查看镜像体积(约 900MB)
docker images | grep go-demo-traditional
问题很明显:镜像中包含了 Go SDK 等编译工具,但运行时只需要编译后的二进制文件,这些工具完全是冗余的。
多阶段构建(瘦身核心方案)
使用多阶段构建,分离"编译阶段"和"运行阶段",Dockerfile 如下:
dockerfile
# ====================================
# 第一阶段:构建阶段(Builder Stage)
# 作用:使用 Go SDK 编译代码,生成二进制文件
# ====================================
FROM golang:1.21-alpine AS builder
# 设置工作目录
WORKDIR /app
# 复制代码到镜像中
COPY main.go .
# 静态编译:CGO_ENABLED=0 禁用 CGO,生成不依赖系统库的二进制文件
# GOOS=linux 指定目标操作系统为 Linux
RUN CGO_ENABLED=0 GOOS=linux go build -o go-demo main.go
# ====================================
# 第二阶段:运行阶段(Runtime Stage)
# 作用:仅运行程序,使用极简基础镜像
# ====================================
# 基础镜像:alpine(轻量级 Linux,约 5MB)
FROM alpine:3.18
# (可选)安装时区工具(如果程序需要时区支持)
RUN apk --no-cache add tzdata
# 设置工作目录
WORKDIR /app
# 从构建阶段(builder)复制二进制文件到当前镜像
# --from=builder 表示从别名为 builder 的阶段复制
COPY --from=builder /app/go-demo .
# 暴露端口
EXPOSE 8080
# 启动程序
CMD ["./go-demo"]
构建并查看镜像体积:
bash
# 构建镜像
docker build -t go-demo-multi-stage .
# 查看镜像体积(约 10MB,瘦身 99%!)
docker images | grep go-demo-multi-stage
瘦身效果一目了然:从 900MB 左右缩减到 10MB 左右,核心原因就是运行阶段只保留了二进制文件和极简的 alpine 系统,完全丢弃了 Go SDK。
关键说明
-
Go 静态编译:必须设置
CGO_ENABLED=0,否则编译后的文件会依赖系统的 C 库,在 alpine 镜像中可能运行失败。 -
alpine 镜像优化:alpine 是极简 Linux 发行版,体积仅 5MB 左右,但默认没有时区工具,若程序需要时区支持,需额外安装
tzdata(如上述代码所示)。
四、实战二:Java 项目镜像瘦身(以 Spring Boot 为例)
Java 项目的构建逻辑和 Go 不同:需要先通过 JDK 编译源码生成 Class 文件,再打包成 Jar 包(Spring Boot 会将依赖一起打包进 Jar),运行时需要 JRE(Java 运行环境)而非 JDK。传统方式用 JDK 镜像运行 Jar 包,会包含大量冗余的开发工具,多阶段构建能有效解决这一问题。
4.1 准备 Spring Boot 测试项目
创建一个简单的 Spring Boot 项目(使用 Spring Initializr 快速生成,仅引入 spring-web 依赖),编写一个接口:
java
// DemoController.java
package com.example.dockerdemo;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class DemoController {
@GetMapping("/")
public String hello() {
return "Hello Docker Multi-stage Build (Java)!";
}
}
使用 Maven 打包项目,生成 Jar 包(默认在 target 目录下,名称类似 docker-demo-0.0.1-SNAPSHOT.jar)。
4.2 传统单阶段构建(反面示例)
传统方式使用 JDK 镜像运行 Jar 包,Dockerfile 如下:
dockerfile
# 基础镜像:包含 JDK 17(约 600MB)
FROM openjdk:17-jdk-alpine
# 设置工作目录
WORKDIR /app
# 复制 Jar 包到镜像中(注意:Jar 包名称需与实际一致)
COPY target/docker-demo-0.0.1-SNAPSHOT.jar app.jar
# 暴露端口(Spring Boot 默认 8080)
EXPOSE 8080
# 启动程序
ENTRYPOINT ["java", "-jar", "app.jar"]
构建并查看镜像体积:
bash
# 构建镜像
docker build -t java-demo-traditional .
# 查看镜像体积(约 650MB)
docker images | grep java-demo-traditional
问题:运行时只需要 JRE(约 200MB),但镜像中包含了 JDK(开发工具,如 javac、javadoc 等),冗余了近 400MB。
多阶段构建(瘦身核心方案)
使用多阶段构建,第一阶段用 JDK 编译打包(若本地未打包,可在镜像中完成),第二阶段用 JRE 运行 Jar 包,Dockerfile 如下:
dockerfile
# ====================================
# 第一阶段:构建阶段(Builder Stage)
# 作用:使用 JDK 编译源码,生成 Jar 包
# 说明:若本地已用 Maven/Gradle 打包,可省略此阶段,直接复制本地 Jar 包
# ====================================
FROM maven:3.8.8-openjdk-17 AS builder
# 设置工作目录
WORKDIR /app
# 复制 Maven 配置文件和源码
COPY pom.xml .
COPY src ./src
# 编译打包:跳过测试(加快构建速度),生成 Jar 包到 target 目录
RUN mvn clean package -DskipTests
# ====================================
# 第二阶段:运行阶段(Runtime Stage)
# 作用:使用 JRE 运行 Jar 包,体积更小
# ====================================
# 基础镜像:仅包含 JRE 17(约 200MB,比 JDK 小很多)
FROM openjdk:17-jre-alpine
# (可选)安装时区工具
RUN apk --no-cache add tzdata
# 设置工作目录
WORKDIR /app
# 从构建阶段复制 Jar 包到当前镜像
# 注意:Jar 包路径需与构建阶段的输出路径一致
COPY --from=builder /app/target/docker-demo-0.0.1-SNAPSHOT.jar app.jar
# 暴露端口
EXPOSE 8080
# 启动程序(优化:使用非 root 用户运行,提高安全性)
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
ENTRYPOINT ["java", "-jar", "app.jar"]
构建并查看镜像体积:
bash
# 构建镜像
docker build -t java-demo-multi-stage .
# 查看镜像体积(约 250MB,瘦身 60%+)
docker images | grep java-demo-multi-stage
进一步瘦身:使用更极简的 Java 基础镜像
如果想进一步缩小体积,可以使用 eclipse-temurin:17-jre-alpine(Eclipse 提供的开源 JRE 镜像,体积比官方 openjdk:17-jre-alpine 更小),或 bellsoft/liberica-openjre-alpine,替换运行阶段的基础镜像:
dockerfile
# 运行阶段:使用 eclipse-temurin JRE(约 180MB)
FROM eclipse-temurin:17-jre-alpine
重构后镜像体积可缩减到 200MB 以内,瘦身效果更明显。
关键说明
-
构建阶段优化:若本地已完成打包,可省略构建阶段的 Maven 编译步骤,直接复制本地 Jar 包到运行阶段,加快构建速度。
-
安全性优化:运行程序时使用非 root 用户(如上述代码中的
appuser),避免程序拥有过高权限,降低安全风险。 -
Jar 包优化:可通过 Spring Boot 分层打包(Layered Jar)进一步优化,将依赖和业务代码分离,提高镜像构建缓存命中率(下文拓展部分详细说明)。
五、进阶拓展:多阶段构建的实用技巧
5.1 命名构建阶段,提高可读性
给每个构建阶段起一个清晰的别名(如 AS builder、AS runner),不仅能提高 Dockerfile 的可读性,还能在多阶段构建中灵活引用不同阶段的产物。例如:
dockerfile
# 构建阶段(编译代码)
FROM golang:1.21-alpine AS code-builder
# ... 编译步骤 ...
# 依赖阶段(下载额外依赖)
FROM alpine:3.18 AS deps
# ... 下载依赖步骤 ...
# 运行阶段(复制多个阶段的产物)
FROM alpine:3.18
# 从 code-builder 复制二进制文件
COPY --from=code-builder /app/go-demo .
# 从 deps 复制额外依赖
COPY --from=deps /usr/local/lib/libxxx.so /usr/local/lib/
5.2 利用构建缓存,加快构建速度
Docker 构建时会缓存每一层的结果,若某一层的指令未发生变化,会直接使用缓存。在多阶段构建中,可通过"先复制配置文件,再复制源码"的方式优化缓存:
以 Java Maven 项目为例(构建阶段优化):
dockerfile
FROM maven:3.8.8-openjdk-17 AS builder
WORKDIR /app
# 先复制 pom.xml(依赖配置文件),单独构建一层
COPY pom.xml .
# 下载依赖(若 pom.xml 未变,会直接使用缓存)
RUN mvn dependency:go-offline -DskipTests
# 再复制源码(源码频繁变化,不影响依赖层的缓存)
COPY src ./src
# 编译打包
RUN mvn clean package -DskipTests
这样一来,只要 pom.xml 不变,依赖下载层就会复用缓存,无需每次都重新下载依赖,大幅加快构建速度。
5.3 Spring Boot 分层打包,进一步优化镜像
Spring Boot 2.3+ 支持分层打包(Layered Jar),可将 Jar 包中的依赖、业务代码、资源文件等分离成不同的层。结合 Docker 缓存机制,能进一步提高镜像构建效率,同时减少镜像体积。
实现步骤:
- 在 pom.xml 中添加分层打包配置:
xml
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<layers>
<enabled>true</enabled>
</layers>
</configuration>
</plugin>
</plugins>
</build>
- 在 Dockerfile 中分层复制 Jar 包内容:
dockerfile
FROM eclipse-temurin:17-jre-alpine AS runner
WORKDIR /app
# 复制 Jar 包到镜像中
COPY --from=builder /app/target/docker-demo-0.0.1-SNAPSHOT.jar app.jar
# 分层提取 Jar 包内容(依赖层、业务代码层等)
RUN java -Djarmode=layertools -jar app.jar extract
# 分层复制(依赖层不变时,可复用缓存)
COPY --from=builder dependencies/ ./dependencies/
COPY --from=builder snapshot-dependencies/ ./snapshot-dependencies/
COPY --from=builder spring-boot-loader/ ./spring-boot-loader/
COPY --from=builder application/ ./application/
# 启动程序(使用分层启动命令)
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]
分层打包后,依赖层(dependencies)几乎不会变化,每次构建时会复用缓存,仅重新打包业务代码层(application),构建速度更快。
5.4 多阶段构建的适用场景
-
需要编译的项目:如 Go、Java、C/C++、Rust 等,分离编译和运行环境。
-
前端项目打包:如 Vue、React 项目,先用 Node.js 镜像构建静态资源(npm run build),再用 nginx 镜像部署静态资源。
-
需要清理冗余文件的场景:无需手动编写清理脚本(如
rm -rf),多阶段构建自动丢弃冗余内容。
六、总结
Docker 多阶段构建的核心价值在于"分离构建与运行环境",通过极简的运行阶段基础镜像,配合精准的产物复制,实现镜像的极致瘦身。对于 Go 项目,借助静态编译特性,瘦身效果可达到 90% 以上;对于 Java 项目,通过 JRE 替代 JDK 以及分层打包优化,也能实现 60% 以上的瘦身。
除了瘦身,多阶段构建还能提高构建效率(利用缓存)、增强镜像安全性(减少冗余工具和权限控制),是容器化部署中的必备技巧。建议大家在实际项目中尝试使用,结合本文的示例代码和进阶技巧,根据项目特性优化 Dockerfile,让镜像更"轻量"、部署更高效。