Spring Boot 镜像瘦身实战:从 450MB 瘦到 180MB

一个 Spring Boot 项目,本地 mvn package 跑得好好的,一到容器化部署就出幺蛾子:Docker 镜像动辄 400 多 MB,推送仓库要等半天,集群节点磁盘被塞满,发一次版 CI/CD 流水线要跑十几分钟。根子在哪?十有八九是你用 JDK 全量镜像当底座,把编译器、调试器、监控工具全打包进了生产环境。示例基于 Spring Boot 4.1.1(Maven Central 实查的最新 GA)和 Java 21,用三个 Dockerfile 把镜像从约 450MB 瘦到约 180MB。

一、Spring Boot 镜像为什么动辄 400 多 MB

先说结论:Spring Boot 应用的 Docker 镜像之所以大,九成是因为基础镜像选错,而不是你的业务代码有多大。

一个最典型的 Spring Boot Web 项目,打出来的 fat jar 通常也就 40~80MB。但很多人写的第一版 Dockerfile 长这样:

dockerfile 复制代码
FROM eclipse-temurin:21
WORKDIR /app
COPY target/*.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]

eclipse-temurin:21 这个标签背后是一个完整的 JDK,压缩后就有 450MB 左右。加上你的 jar,镜像轻松破 480MB。问题来了:生产环境跑 Java 程序,真的需要 JDK 吗?

答案是不需要。JDK 是「开发工具包」,里面塞了 javac 编译器、jdb 调试器、jconsole 监控器、还有一堆给开发用的 jar 和文档。而线上程序只需要「运行环境」------也就是 JRE(Java Runtime Environment),它只包含跑字节码必需的组件。你把一个开发工具箱整个搬进生产容器,就像带着整套五金工具箱去拧一颗螺丝:不是不能用,是太重了。

镜像大带来的问题不是「数字不好看」这么简单,而是真金白银的成本:

影响 具体表现
部署慢 镜像推拉仓库慢,尤其内网/跨机房带宽受限时,几分钟起步
磁盘压力 每个节点存多个镜像版本,K8s 节点磁盘容易被打满
攻击面大 JDK 里的组件越多,安全扫描报的 CVE 漏洞越多,修复成本越高
冷启动慢 镜像层越多,容器首次拉取解压越慢,扩缩容受影响

所以「瘦身」不是洁癖,是省钱、提速、减风险。下一节我们讲清楚底层原理,你就知道每一步为什么能瘦下来。

二、底层原理:镜像为什么能被瘦下来

这一节讲三个核心概念:Docker 分层、多阶段构建、分层 JAR。理解了它们,你自己也能设计出更小的镜像。

第一,Docker 镜像是一层层叠起来的。 你可以把镜像想象成一摞透明塑料片,每片上面印一点内容。Dockerfile 里每一条指令(FROM、COPY、RUN......)都会生成一张新塑料片,叫「层(layer)」。这些层是只读的、按顺序叠加的。有个关键好处:层可以被缓存和复用。如果两张塑料片内容一模一样,Docker 不会重复保存,多个镜像之间也能共享同一张片。

这跟「瘦身」有什么关系?关系在于:基础镜像本身就是一大摞层 。eclipse-temurin:21(JDK)比 eclipse-temurin:21-jre(JRE)多了「编译器」「调试器」这些层。你换成 JRE,等于直接扔掉一大摞用不上的塑料片,体积自然降下来。

第二,多阶段构建:编译工具不进最终镜像。 想象你要做一道菜,厨房里需要各种厨具(刀、锅、烤箱),但端上桌的只有一盘菜。多阶段构建就是这个思路:Dockerfile 里可以写多个 FROM,前面的阶段叫「构建阶段」,负责用完整环境(Maven + JDK)编译出 jar;最后一个 FROM 才是「运行阶段」,只把编译好的 jar 拷过来,配上最小运行时。厨具留在厨房,桌上只有菜。

dockerfile 复制代码
FROM maven:3.9-eclipse-temurin-21 AS build   # 厨房:有 Maven + JDK
# ... 在这里编译出 jar ...

FROM eclipse-temurin:21-jre                  # 餐桌:只有 JRE
COPY --from=build /app/target/*.jar app.jar  # 把菜端上桌

多阶段构建本身不减小「运行阶段」的体积,但它能让你在构建阶段放心用全量 JDK 编译,运行阶段只带 JRE,两者互不拖累。这是瘦身里最关键的一步,也是「从 450MB 到 230MB」的来源。

第三,分层 JAR:把「常变的」和「不变的」拆开。 前面说了 Docker 层能缓存。但你如果每次都把整个 fat jar 打成一层,那么只要业务代码改一行,整个 jar 变了,这一层缓存就全废,每次构建都要重新拷贝几十 MB。

Spring Boot 的解决办法是「分层」。打包时它会在 jar 里塞一个 layers.idx 索引文件,把 jar 拆成四层:

层名 装的是什么 变化频率
dependencies 第三方依赖(Spring、Jackson 等) 几乎不变
spring-boot-loader Spring Boot 的启动器类 几乎不变
snapshot-dependencies 快照版依赖 偶尔变
application 你的业务代码和资源 每次发版都变

这样一来,改一行业务代码,只有 application 这一层重新构建,dependencies 层直接用缓存。构建速度从「每次几十秒」降到「改代码后几秒」。这招主要优化的是构建速度,不是镜像体积,但它和瘦身常常一起出现,是官方推荐的镜像构建方式。

理解了这三条,实战部分你就能看懂每一步在干什么了。

三、实战:一个项目,三个 Dockerfile 逐步瘦身

下面给一个完整可运行的项目,然后写三个 Dockerfile,让你直观看到体积是怎么一步步降下来的。所有代码复制即可运行。

3.1 完整项目代码

pom.xml(Spring Boot 4.1.1 + Java 21):

xml 复制代码
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>4.1.1</version>
        <relativePath/>
    </parent>

    <groupId>com.example</groupId>
    <artifactId>image-slim-demo</artifactId>
    <version>1.0.0</version>
    <name>image-slim-demo</name>
    <description>Spring Boot 镜像瘦身实战示例</description>

    <properties>
        <java.version>21</java.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-actuator</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

这里没有任何特殊配置。新版 Spring Boot 打包时默认就生成 layers.idx 索引并带上分层解包工具,你不需要额外开启。spring-boot-starter-actuator 是为了后面演示健康检查用的。

启动类 src/main/java/com/example/imageslim/ImageSlimApplication.java:

java 复制代码
package com.example.imageslim;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class ImageSlimApplication {

    public static void main(String[] args) {
        SpringApplication.run(ImageSlimApplication.class, args);
    }
}

控制器 src/main/java/com/example/imageslim/HelloController.java:

java 复制代码
package com.example.imageslim;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import java.time.Instant;
import java.util.Map;

@RestController
public class HelloController {

    @GetMapping("/hello")
    public Map<String, Object> hello() {
        return Map.of(
                "msg", "镜像瘦身成功",
                "time", Instant.now().toString()
        );
    }
}

配置文件 src/main/resources/application.yml:

yaml 复制代码
spring:
  application:
    name: image-slim-demo

server:
  port: 8080

management:
  endpoints:
    web:
      exposure:
        include: health,info

构建产物 :在项目根目录执行 mvn package -DskipTests,会得到 target/image-slim-demo-1.0.0.jar。

3.2 方案一:新手常见写法(约 450MB)

这是最直接的写法,也是大多数教程里教的。它的问题我们第二节已经分析过------用了全量 JDK。

dockerfile 复制代码
FROM eclipse-temurin:21
WORKDIR /app
COPY target/image-slim-demo-1.0.0.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]

构建命令:

bash 复制代码
docker build -t image-slim-demo:v1 .

docker images 看体积,大概在 450MB 上下。基础镜像 eclipse-temurin:21 本身压缩后就接近 450MB,你的 jar 只是九牛一毛。

3.3 方案二:多阶段构建 + JRE + 分层(约 230MB)

这一步同时做三件事:多阶段构建隔离编译环境、用 JRE 替换 JDK、用 jarmode=tools 把 jar 拆层。这是官方推荐、绝大多数团队够用的最终形态。

dockerfile 复制代码
# 阶段1:构建。用带 Maven 和 JDK 的镜像编译,编译工具只留在这个阶段
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /app
COPY . .
RUN mvn package -DskipTests -B

# 阶段2:解包。把 fat jar 拆成 dependencies/application 等四个目录
FROM eclipse-temurin:21-jre AS builder
WORKDIR /builder
COPY --from=build /app/target/image-slim-demo-1.0.0.jar application.jar
RUN java -Djarmode=tools -jar application.jar extract --layers --destination extracted

# 阶段3:运行。只带 JRE 和分层后的内容,每个 COPY 各成一层便于缓存
FROM eclipse-temurin:21-jre
WORKDIR /application
COPY --from=builder /builder/extracted/dependencies/ ./
COPY --from=builder /builder/extracted/spring-boot-loader/ ./
COPY --from=builder /builder/extracted/snapshot-dependencies/ ./
COPY --from=builder /builder/extracted/application/ ./
ENTRYPOINT ["java", "-jar", "application.jar"]

逐段解释这段 Dockerfile 在干什么:

  • 阶段1(build) :maven:3.9-eclipse-temurin-21 这个镜像自带 Maven 和 JDK,用来执行 mvn package 编译出 fat jar。这些编译工具只存在这个阶段,不会被带进最终镜像。
  • 阶段2(builder) :java -Djarmode=tools -jar application.jar extract --layers --destination extracted 是 Spring Boot 官方提供的解包命令。它会读 jar 里的 layers.idx,把内容按 dependencies / spring-boot-loader / snapshot-dependencies / application 四个目录拆到 extracted/ 下。
  • 阶段3(运行) :四个 COPY --from=builder 把四个目录分别拷进最终镜像,各自成为独立的一层。依赖层几乎不变,业务代码层每次发版才变,Docker 能精确命中缓存。最后 ENTRYPOINT ["java", "-jar", "application.jar"] 启动的是解包后那个「瘦身版」jar,它只含业务代码和对各分层的引用。

构建:

bash 复制代码
docker build -t image-slim-demo:v2 .

体积降到约 230MB,直接砍掉一半。而且以后改代码重发,只有 application 层需要重建,构建速度飞快。

3.4 方案三:换成 Alpine 基础镜像(约 180MB)

在方案二基础上,把运行阶段的基础镜像从 eclipse-temurin:21-jre 换成更精简的 Alpine 版,能再省几十 MB。

dockerfile 复制代码
# 阶段1:构建(同上)
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /app
COPY . .
RUN mvn package -DskipTests -B

# 阶段2:解包(同上)
FROM eclipse-temurin:21-jre AS builder
WORKDIR /builder
COPY --from=build /app/target/image-slim-demo-1.0.0.jar application.jar
RUN java -Djarmode=tools -jar application.jar extract --layers --destination extracted

# 阶段3:运行。改用 Alpine 版 JRE,体积更小
FROM eclipse-temurin:21-jre-alpine
WORKDIR /application
COPY --from=builder /builder/extracted/dependencies/ ./
COPY --from=builder /builder/extracted/spring-boot-loader/ ./
COPY --from=builder /builder/extracted/snapshot-dependencies/ ./
COPY --from=builder /builder/extracted/application/ ./
ENTRYPOINT ["java", "-jar", "application.jar"]

Alpine 是一个极简的 Linux 发行版,体积只有几 MB,所以基于它的 JRE 镜像更小。但 Alpine 有个隐患:它用的是 musl 这个 C 标准库,而主流的 Debian/Ubuntu 用的是 glibc。如果你的项目里有 JNI 本地库(比如某些加密、图像处理库),Alpine 下可能直接崩。所以方案三适合纯 Java、没有本地依赖的项目;稳妥起见,方案二(Debian 系 JRE)是默认首选。

到这里,镜像从约 450MB → 230MB → 180MB。如果还想更狠,下一节讲 jlink。

四、踩坑经验和最佳实践

这一节全是真金白银的教训,每一条都是我或者同行在生产环境里踩过的坑。

坑一:Alpine 的 musl 库让程序莫名崩溃。 现象是本地(Debian/Ubuntu)跑得好好的,换成 Alpine 镜像启动就报 UnsatisfiedLinkError 或者直接段错误。根因是 Alpine 用 musl libc,而很多 JNI 本地库是按 glibc 编译的,两者不兼容。解决:要么换回 eclipse-temurin:21-jre(Debian 系),要么给这个本地库单独找 Alpine 兼容版本。判断标准:纯 Java + 官方 starter 的项目一般没事,只要引了 *.so/*.dll 之类的本地库就要警惕。

坑二:容器里日志时间差了 8 小时。 很多精简镜像默认时区是 UTC,中国用户看日志会发现时间总是慢 8 小时。解决:构建时设置时区。

dockerfile 复制代码
FROM eclipse-temurin:21-jre-alpine
# Alpine 需要先装 tzdata 才能用 Asia/Shanghai
RUN apk add --no-cache tzdata && \
    cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && \
    echo "Asia/Shanghai" > /etc/timezone

Debian 系 JRE 镜像则可以直接设置环境变量 ENV TZ=Asia/Shanghai。

坑三:容器内存和 JVM 内存不对齐,程序被 OOM 杀掉。 JVM 默认按「宿主机的物理内存」来推算堆大小。如果宿主机是 64GB、容器只限制了 512MB,JVM 还傻乎乎地以为有 64GB 可用,直接把内存吃爆然后被 K8s 杀掉。解决:显式告诉 JVM 按容器配额来算。

dockerfile 复制代码
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75.0", "-jar", "application.jar"]

-XX:MaxRAMPercentage=75.0 意思是「堆最大用到容器内存的 75%」,剩余留给元空间和本地内存。这是容器化部署的标配参数,别省略。

坑四:用 root 用户跑容器。 默认容器里是 root 用户,一旦程序被攻破,攻击者直接拿到容器内最高权限。最佳实践是建一个非 root 用户来跑:

dockerfile 复制代码
FROM eclipse-temurin:21-jre
RUN useradd -r -u 1001 -m appuser
WORKDIR /application
# ... COPY 分层内容 ...
USER appuser
ENTRYPOINT ["java", "-jar", "application.jar"]

坑五:没有健康检查,容器「起来了」其实是假活。 容器进程还在、端口没监听的情况很多。配一个健康检查,让编排系统(K8s/Docker)知道服务到底能不能用。因为前面引入了 actuator,可以直接探活:

dockerfile 复制代码
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \
  CMD ["java", "-cp", "application.jar", "org.springframework.boot.loader.launch.JarLauncher", "--server.port=8080"] || exit 1

更简单可靠的方式是用 curl/wget 探 /actuator/health,但精简镜像里往往没装这些工具。生产上更推荐用编排系统(K8s 的 readinessProbe)直接探 HTTP 端口,而不是在 Dockerfile 里写 HEALTHCHECK。

坑六:忘了写 .dockerignore,把 target 目录和 .git 全拷进构建上下文。 构建上下文过大,docker build 会先花很久把整个目录传给 Docker daemon。建一个 .dockerignore:

复制代码
target/
.git/
.idea/
*.iml

这些是最容易踩的坑。记住一条总原则:生产镜像里,只放运行时需要的东西,其余一律挡在门外。

五、性能对比和技术选型

三个方案放一起对比,结论一目了然:

方案 基础镜像 镜像体积(约) 构建速度 适合场景
方案一 eclipse-temurin:21(JDK) 450MB 慢 只在本地临时验证
方案二 eclipse-temurin:21-jre 230MB 快(分层缓存) 绝大多数团队默认选择
方案三 eclipse-temurin:21-jre-alpine 180MB 快 纯 Java 无本地库的项目

除了手写 Dockerfile,还有两条路线可以选,各有取舍:

Jib(Google 出品)。 不用写 Dockerfile,甚至不用装 Docker 守护进程,在 pom.xml 里配个插件,一条 mvn jib:build 直接推镜像。它自动做分层、用 distroless 精简镜像,体积很小。缺点是定制能力弱,一些需要 RUN 装东西的场景(比如装证书、装 tzdata)它处理起来别扭。

jlink 自定义运行时(进阶玩法)。 JDK 自带 jlink 工具,能按模块裁剪出一个最小运行时,体积能压到 60~90MB:

bash 复制代码
jlink --add-modules java.base,java.logging,java.sql,java.naming,java.management,java.net.http,jdk.unsupported \
      --no-header-files --no-man-pages --strip-debug --output /javaruntime

这套能把镜像压到 100MB 以内,但对 Spring Boot 不太友好------Spring 大量用反射,jlink 很容易裁掉它运行时真正需要的模块,导致启动报 ClassNotFoundException。上面那个模块列表只是起点,不同项目要按需增删,排查起来费劲。结论:普通团队用方案二就够了,jlink 留给追求极致体积、且有精力折腾的团队。

选型建议一句话:默认方案二,纯 Java 项目且追求更小体积上方案三,追求极简运维体验考虑 Jib,追求极致体积再上 jlink。

六、总结

回顾一下这篇文章的核心结论:

Spring Boot 镜像大的根因,是用全量 JDK 当基础镜像,把编译器、调试器这些开发工具全打包进了生产环境。而瘦身的本质,就是「让镜像里只留运行时必需的东西」。

三个手段层层递进:

  1. JRE 换 JDK:直接扔掉开发工具,体积从约 450MB 砍到约 230MB,这是收益最大的一步。
  2. 多阶段构建:编译环境(Maven + JDK)留在中间阶段,不污染最终镜像。
  3. 分层 JAR :用 jarmode=tools 把 jar 拆成四层,让依赖层和业务层分开缓存,构建速度从分钟级降到秒级。

再叠加 Alpine 精简镜像,能到约 180MB;进阶玩家用 jlink 能压到 100MB 以内,但要付出反射踩坑的成本。

最后强调一句:瘦身不是玄学,是「分层 + 最小运行时 + 缓存」三条朴素原理的组合。你的项目今天就可以照着方案二改一遍 Dockerfile,跑一次 docker images,感受一下体积和构建速度的双重变化。

相关推荐
Shaoshing1 小时前
LeetCode100 283.移动零
java·数据结构·算法·leetcode
海绵宝宝转agent1 小时前
LeetCode100个人题解+java面经总结
java·开发语言·笔记·学习·算法
文丑颜不良啊1 小时前
LeetCode1201~1220题目解析
java·leetcode
香蕉大香蕉(06敬畏市场)1 小时前
高频八股 · JVM(7):方法区、元空间与字符串常量池
java·jvm·ai·博客·八股·大厂实习
LazyHunter91 小时前
Spring Boot 3 微服务实践指南
java·spring boot
zzu123zsw1 小时前
从 SPI 契约到独立 ClassLoader 隔离:Spring Boot 插件热插拔架构设计与内存泄露防御
java·spring boot·后端·架构设计
文丑颜不良啊1 小时前
LeetCode1161~1180题目解析
java·leetcode
古法老登瞎折腾9 小时前
大数据智能运维实战(十一):运维 MCP 的工程化——权限、限流、审计与错误处理
java·大数据·运维·spring ai·mcp
智能运维指南10 小时前
研制过程“两张皮“怎么破:研制一体化支撑平台的定位、能力边界与落地前提
java·前端·人工智能·嘉为蓝鲸