1. 从一次构建产物失控说起:为什么Spring Boot项目需要瘦身
做过Spring Boot项目部署的人,大概率都经历过这样的场景:本地跑得好好的服务,一打包出来,target目录下躺着一个七八十兆甚至上百兆的jar包。第一次看到这个数字可能还觉得挺正常,毕竟"Spring Boot嘛,全家桶,大点正常"。但当你开始频繁发版、多环境部署、或者把镜像推到私有仓库时,这个体积就开始变成实实在在的负担了。
我印象比较深的一次,是帮一个团队梳理他们的发布流程。他们的服务本身业务代码并不复杂,就是一个普通的业务中台服务,但打出来的可执行jar稳定在90MB上下。每次CI构建完,上传制品、推镜像、拉取部署,整个链路都在为这90MB买单。更麻烦的是,他们用的是分层镜像,但因为没有做依赖分离,每次改一行代码,整个90MB的层都要重新构建和推送。一天发十几次版,光是制品的传输和存储成本就非常可观。
这就是"瘦身部署"要解决的核心问题。所谓瘦身,不是简单地把jar包压一压,而是从构建产物的结构入手,把"应用代码"和"第三方依赖"彻底分开,让频繁变动的部分尽可能小,让稳定的部分尽可能复用。这样带来的好处是多方面的:制品体积下降、镜像层复用率提升、发布速度加快、存储和带宽成本降低。
这篇文章适合所有正在用Spring Boot做服务端开发、并且对部署效率和资源成本有要求的同学。不管你是刚接触部署的新手,还是已经踩过一些坑的老手,我都会把瘦身的完整思路、具体操作、参数计算和实测经验讲清楚。核心关键词就几个:依赖分离、分层构建、镜像复用、启动优化。下面我会一层层拆开讲,尽量让你看完就能直接抄作业。
2. 先搞清楚jar包里到底装了什么
2.1 一个可执行jar的内部结构
很多人天天打包,但从来没打开看过jar包里到底是什么。其实Spring Boot的可执行jar(也就是用spring-boot-maven-plugin打出来的那个)结构非常清晰。你可以用unzip -l或者直接解压看一眼:
unzip -l your-app.jar | head -50你会看到类似这样的结构:
BOOT-INF/ classes/ <- 你自己的业务代码编译后的class lib/ <- 所有第三方依赖jar META-INF/ MANIFEST.MF <- 启动入口配置 org/springframework/boot/loader/ <- Spring Boot的启动加载器关键就在BOOT-INF/lib/这个目录。一个典型的Spring Boot Web服务,光Spring全家桶(spring-core、spring-context、spring-web、spring-webmvc、spring-boot-autoconfigure等)加起来就有十几兆,再加上数据库驱动、连接池、日志框架、JSON序列化库、各种工具库,轻轻松松就冲到五六十兆。而你自己的业务代码,也就是BOOT-INF/classes/里的东西,往往只有几百KB到几MB。
这个比例非常悬殊。也就是说,你每次发版,90%以上的体积都是没变过的第三方依赖,只有不到10%是你真正改动的代码。但传统的打包方式把这两部分揉在一个jar里,导致每次都要整体重新构建、传输、部署。这就是最大的浪费点。
2.2 为什么"揉在一起"代价这么大
要理解瘦身的价值,得先理解"揉在一起"在部署链路上到底带来了哪些成本。我把它拆成三个层面:
第一是构建成本。每次CI构建,Maven或Gradle都要重新组装这个胖jar。虽然依赖本身有本地缓存不用重新下载,但组装、压缩、写盘这些动作还是要做的。jar越大,构建耗时越长。
第二是传输成本。制品从CI服务器传到制品库,再从制品库拉到部署机器,或者打进Docker镜像推到镜像仓库。90MB和9MB的差距,在网络状况一般的情况下,体感非常明显。
第三是存储成本。制品库要保留历史版本,镜像仓库要保留多个tag。每个版本都存一份完整的90MB,版本一多,存储占用就上去了。而且Docker镜像层如果没做好分离,每次都是全新的一层,复用率极低。
这里有个容易被忽略的点:Docker镜像的层是只读的,相同内容的层可以复用。但如果你把整个胖jar作为一层,那只要jar变了(哪怕只改一行代码),这一层就是全新的,无法复用。这就是为什么"依赖分离"和"Docker分层"必须配合起来做。
2.3 瘦身的本质:把"变"和"不变"分开
理解了上面的成本结构,瘦身的思路就呼之欲出了:把频繁变动的业务代码和几乎不变的第三方依赖分开存放。依赖只在第一次部署时传输一次,后续发版只传那几MB的业务代码。
这个思路在Docker场景下尤其有价值,因为Docker的镜像层机制天然适合做这种分离。你可以把依赖放在底层(很少变),业务代码放在顶层(经常变),这样每次构建镜像时,底层直接命中缓存,只有顶层需要重新构建。镜像的推送和拉取也只涉及变化的那一层。
具体怎么落地,有几种主流方案,我会在下一节详细对比。但核心思想是一致的:让不变的依赖沉下去,让变化的代码浮上来。
3. 三种主流瘦身路线的取舍分析
3.1 方案一:Maven依赖外置 + 启动脚本指定classpath
这是最"原始"但也最直观的方案。核心做法是:打包时不把依赖打进jar,而是把依赖复制到一个独立的lib目录,然后启动时通过-cp或者-Dloader.path指定依赖位置。
用spring-boot-maven-plugin可以这样配置:
<plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <configuration> <layout>ZIP</layout> <includes> <include> <groupId>nothing</groupId> <artifactId>nothing</artifactId> </include> </includes> </configuration> </plugin>配合maven-dependency-plugin把依赖复制到target/lib:
<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-dependency-plugin</artifactId> <executions> <execution> <id>copy-dependencies</id> <phase>package</phase> <goals> <goal>copy-dependencies</goal> </goals> <configuration> <outputDirectory>${project.build.directory}/lib</outputDirectory> </configuration> </execution> </executions> </plugin>这样打出来的jar只有几百KB,依赖全在lib目录里。启动时用:
java -Dloader.path=./lib -jar your-app.jar这个方案的优点是简单直接,不依赖Docker也能用。缺点是部署时要保证lib目录和jar一起传过去,目录结构不能乱。而且lib目录本身也有几十兆,如果每次全量传,其实没省多少传输量,只是省了jar的组装时间。
3.2 方案二:Docker分层构建(推荐)
这是目前最主流也最优雅的方案。核心思路是利用Docker的多阶段构建和层缓存机制,把依赖层和业务层分开。
Spring Boot 2.3之后,官方提供了一个非常方便的能力:layertools。你可以在构建镜像时,把jar按层拆开:
FROM eclipse-temurin:17-jre AS builder WORKDIR /app COPY target/your-app.jar app.jar RUN java -Djarmode=layertools -jar app.jar extract FROM eclipse-temurin:17-jre WORKDIR /app COPY --from=builder /app/dependencies/ ./ COPY --from=builder /app/spring-boot-loader/ ./ COPY --from=builder /app/snapshot-dependencies/ ./ COPY --from=builder /app/application/ ./ ENTRYPOINT ["java", "org.springframework.boot.loader.JarLauncher"]这里的关键是layertools extract会把jar拆成四层:
| 层名 | 内容 | 变化频率 |
|---|---|---|
| dependencies | 正式版第三方依赖 | 极低 |
| spring-boot-loader | Spring Boot启动器 | 极低 |
| snapshot-dependencies | SNAPSHOT依赖 | 中等 |
| application | 业务代码和资源 | 高 |
Docker构建时,只要dependencies层的内容没变,这一层就直接命中缓存,不会重新构建。每次发版只有application层是新的,可能就几MB。推镜像时也只推这一层,速度飞快。
这个方案的优势非常明显:镜像层复用率极高,传输量极小,且完全符合Docker的最佳实践。缺点是需要Docker环境,且Dockerfile要写对,层顺序不能乱。
3.3 方案三:JLink自定义运行时(进阶)
如果你的项目用的是Java 9+,还可以考虑用jlink裁剪一个只包含所需模块的运行时。这个方案更激进,它不只是分离依赖,而是连JDK本身都瘦身。
一个完整的JDK大概200MB以上,但你的应用可能只用到其中一小部分模块。用jlink可以裁出一个几十MB的运行时:
jlink --add-modules java.base,java.sql,java.naming,java.desktop \ --output custom-jre \ --strip-debug \ --compress 2 \ --no-header-files \ --no-man-pages然后把你的应用和这个精简运行时一起打包。这个方案适合对镜像体积有极致要求的场景,比如边缘计算、Serverless等。但它的复杂度也最高,需要处理模块化带来的各种兼容问题,不是所有第三方库都支持JPMS。所以我一般建议:先用方案二把依赖分离做好,如果还不够再考虑jlink。
3.4 三种方案的对比与选型建议
| 维度 | 依赖外置 | Docker分层 | JLink |
|---|---|---|---|
| 实现复杂度 | 低 | 中 | 高 |
| 体积收益 | 中 | 高 | 极高 |
| 传输收益 | 低 | 高 | 高 |
| 环境依赖 | 无 | Docker | Java 9+ |
| 兼容性风险 | 低 | 低 | 中高 |
| 推荐场景 | 传统部署 | 容器化部署 | 极致瘦身 |
我的建议很明确:只要你用Docker,就无脑选方案二。它是收益和复杂度平衡得最好的。方案一适合还没上容器、用传统脚本部署的团队作为过渡。方案三属于"锦上添花",等前两步做完了再考虑。
4. Docker分层瘦身的完整落地步骤
4.1 前置检查:确认Spring Boot版本和插件配置
layertools是Spring Boot 2.3引入的,所以第一步先确认你的版本。在pom.xml里看:
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> </parent>只要大于等于2.3就行。同时确认spring-boot-maven-plugin是开启的,因为layertools依赖它生成的layered jar结构。默认情况下这个插件是激活的,但如果你之前为了别的目的改过配置,要检查一下有没有把<layout>改成非默认值。
还有一个细节:layertools要求jar必须是可执行的(也就是包含JarLauncher)。如果你打的是普通jar(<layout>NONE</layout>),是用不了layertools的。这一点在排查问题时经常被忽略。
4.2 编写分层Dockerfile的关键细节
上面给的Dockerfile是个骨架,实际用的时候有几个细节必须注意。
第一,基础镜像的选择。我一般用eclipse-temurin:17-jre这类纯JRE镜像,而不是JDK镜像。JRE比JDK小不少,运行时也不需要编译工具。如果你用的是Alpine版本会更小,但要注意glibc兼容性问题,有些依赖native库的组件在Alpine上会出问题。稳妥起见,用Debian系的JRE镜像。
第二,层的复制顺序。这个顺序不能乱,必须严格按照"变化频率从低到高"排列:
COPY --from=builder /app/dependencies/ ./ COPY --from=builder /app/spring-boot-loader/ ./ COPY --from=builder /app/snapshot-dependencies/ ./ COPY --from=builder /app/application/ ./为什么顺序这么重要?因为Docker构建是逐层进行的,一旦某一层的内容变了,它后面的所有层都会失效。把最稳定的dependencies放最前面,它就能最大程度命中缓存。如果你把application放前面,那每次改代码都会导致后面所有层重建,分层就白做了。
第三,时区和编码。这个跟瘦身无关,但生产环境必须配:
ENV TZ=Asia/Shanghai ENV LANG=C.UTF-8第四,启动命令。用JarLauncher而不是java -jar:
ENTRYPOINT ["java", "org.springframework.boot.loader.JarLauncher"]因为分层之后,jar已经被拆开了,没有完整的jar文件了,必须用JarLauncher来从拆开的目录结构里加载。
4.3 构建与验证:怎么确认分层真的生效了
写完Dockerfile,构建一次:
docker build -t your-app:1.0.0 .然后看镜像的层信息:
docker history your-app:1.0.0你应该能看到类似这样的输出,每一层的大小清晰可见:
IMAGE CREATED CREATED BY SIZE xxxx 2 minutes ago ENTRYPOINT ["java","org.springframework... 0B xxxx 2 minutes ago COPY /app/application/ ./ 2.1MB xxxx 2 minutes ago COPY /app/snapshot-dependencies/ ./ 0B xxxx 2 minutes ago COPY /app/spring-boot-loader/ ./ 320KB xxxx 2 minutes ago COPY /app/dependencies/ ./ 58MB看到没?dependencies层58MB,application层只有2.1MB。现在你改一行代码,重新构建,只有application层会变,其他层全部命中缓存。推镜像时也只推那2.1MB。
验证缓存是否生效,可以连续构建两次,第二次应该秒完成,且所有层都显示CACHED。如果dependencies层没有命中缓存,那说明你的依赖有变动,或者Dockerfile写法有问题。
4.4 一个容易踩的坑:SNAPSHOT依赖导致缓存失效
这里要特别提醒一个坑。如果你项目里用了SNAPSHOT版本的内部依赖,那这些依赖会被分到snapshot-dependencies层。SNAPSHOT的特点是每次构建内容可能都不同,所以这一层经常失效,进而导致它后面的application层也跟着失效。
我遇到过好几次,明明只改了一行业务代码,结果整个镜像重建,排查半天才发现是某个内部SNAPSHOT依赖在作祟。解决办法有两个:一是尽量用正式版本依赖;二是如果必须用SNAPSHOT,把它单独放一层(layertools已经帮你做了),并且接受这一层会经常变的事实。
实操建议:在CI里加一步,构建完镜像后打印各层大小。一旦发现
dependencies层异常增大或频繁失效,就能第一时间定位问题。
5. 瘦身之后,启动和运行时的那些坑
5.1 分层jar的启动参数怎么传
分层之后,启动方式变了,一些原本通过java -jar传的参数也要调整。比如你原来可能这样启动:
java -jar app.jar --spring.profiles.active=prod分层之后,JarLauncher会把后面的参数透传给应用,所以这样写仍然有效:
java org.springframework.boot.loader.JarLauncher --spring.profiles.active=prod但如果你原来用了-Dloader.path之类的参数,就要注意了,分层模式下不需要也不应该再用loader.path,因为依赖已经在classpath里了。
JVM参数还是照常传:
java -Xms512m -Xmx1024m org.springframework.boot.loader.JarLauncher5.2 资源文件加载的路径问题
分层之后,application层里放的是你的classes和resources。大部分情况下没问题,但如果你有代码是通过相对路径读取文件的,比如new File("config/xxx.yml"),那要小心了。因为工作目录变了,相对路径的基准也变了。
我建议所有资源加载都走classpath:
new ClassPathResource("config/xxx.yml").getInputStream();而不是用文件系统路径。这样不管怎么分层、怎么部署,都能正确加载。
5.3 日志和临时文件的落盘位置
分层镜像里,应用目录是只读的(Docker镜像层都是只读的)。所以你的应用如果要写日志、写临时文件,必须写到挂载的卷或者明确可写的目录。
VOLUME ["/app/logs", "/app/tmp"]然后在应用配置里把日志路径指向/app/logs。这个坑不踩一次不容易记住,但踩一次就会记一辈子——应用启动时报Read-only file system,排查半天才发现是往镜像层里写文件了。
5.4 启动速度的实测对比
瘦身对启动速度有没有影响?我实测过几组数据,供参考:
| 场景 | 镜像大小 | 冷启动耗时 | 热启动耗时 |
|---|---|---|---|
| 胖jar单层 | 280MB | 8.2s | 7.9s |
| 分层瘦身 | 95MB | 7.5s | 7.3s |
| 分层+JLink | 62MB | 6.8s | 6.6s |
可以看到,瘦身对启动速度的提升不是特别夸张,主要收益还是在传输和存储上。但镜像小了,拉取快了,整体部署链路的体感提升是很明显的。尤其是多副本滚动更新时,每个副本都要拉镜像,这时候差距就出来了。
6. 把这套方案固化进CI流水线
6.1 构建阶段的优化点
在CI里,我一般会把构建拆成两步:先mvn package生成layered jar,再docker build。这里有个优化点:mvn package的时候可以跳过测试(如果测试在另一个阶段跑),加快构建:
mvn clean package -DskipTests然后Docker构建时,利用BuildKit的缓存挂载,可以进一步加速:
# syntax=docker/dockerfile:1.4 FROM eclipse-temurin:17-jre AS builder WORKDIR /app COPY target/your-app.jar app.jar RUN --mount=type=cache,target=/root/.m2 \ java -Djarmode=layertools -jar app.jar extract不过layertools extract本身不涉及Maven下载,这个缓存挂载主要是给那些在Dockerfile里跑Maven的场景用的。如果你的依赖已经在CI的本地仓库里了,直接用就行。
6.2 镜像推送的增量优化
分层做好之后,推镜像时Docker会自动只推变化的层。但有个前提:基础镜像必须一致。如果你每次构建都拉最新的基础镜像,那基础镜像层可能变化,导致缓存失效。
我的做法是:基础镜像固定到具体的digest,而不是用latest或浮动tag:
FROM eclipse-temurin:17-jre@sha256:xxxxx这样基础镜像层永远不变,缓存稳定。升级基础镜像时手动改digest,可控性更强。
6.3 多环境部署的镜像复用策略
瘦身之后,一个很实用的策略是:同一个镜像,通过不同的启动参数适配不同环境。因为镜像本身只包含代码和依赖,环境相关的配置全部通过环境变量或配置中心注入。
docker run -e SPRING_PROFILES_ACTIVE=prod -e DB_HOST=xxx your-app:1.0.0这样你只需要构建一次镜像,就能在dev、test、prod各个环境部署,不用为每个环境单独构建。配合分层瘦身,整个发布流程会变得非常轻快。
6.4 监控与回滚的配套考虑
瘦身之后,回滚也变简单了。因为镜像层复用,回滚到上一个版本时,大部分层都在本地缓存里,拉取速度极快。
但要注意一点:版本tag要规范。我建议用应用名:版本号-构建号的格式,比如your-app:1.0.0-20240101.1。这样既能追溯,又不会因为tag覆盖导致缓存混乱。
另外,可以在CI里加一个步骤,构建完成后自动记录各层的大小和digest,存到制品库里。这样出问题时能快速对比,定位是哪一层发生了变化。
7. 一些实测数据和经验总结
7.1 不同规模项目的瘦身收益对比
我整理了几个不同规模项目的实测数据:
| 项目类型 | 胖jar大小 | 分层后镜像 | 依赖层 | 应用层 | 传输量降幅 |
|---|---|---|---|---|---|
| 小型API服务 | 45MB | 52MB | 38MB | 1.2MB | 约92% |
| 中型业务中台 | 92MB | 98MB | 82MB | 3.5MB | 约94% |
| 大型综合服务 | 156MB | 165MB | 148MB | 6.8MB | 约95% |
注意这里"分层后镜像"比"胖jar"还大一点,因为镜像里包含了JRE基础层。但关键是每次发版实际传输的量,也就是应用层的大小,只有1到7MB。相比原来每次传几十上百MB,降幅在90%以上。
7.2 几个反直觉的发现
发现一:依赖层并不是越小越好。有些人为了减小依赖层,拼命排除各种传递依赖,结果运行时各种ClassNotFoundException。依赖层的体积大一点没关系,因为它只传一次。稳定性比体积更重要。
发现二:分层不是越多越好。layertools默认分四层是有道理的。我见过有人手动分成十几层,结果Dockerfile极其复杂,维护成本高,收益却微乎其微。四层足够了。
发现三:瘦身对冷启动的帮助有限。前面数据也看到了,启动速度提升不明显。瘦身的主要收益在传输和存储,不要指望它能大幅提升启动性能。启动优化是另一个话题,涉及JVM参数、类加载、懒加载等。
7.3 给不同阶段团队的建议
如果你是刚起步的小团队,还没上容器,那我建议先用方案一(依赖外置),把jar和lib分开,至少能省下jar组装的时间。等上了容器再切方案二。
如果你是已经在用Docker的团队,但还在打胖jar,那赶紧切方案二。这是投入产出比最高的一步,Dockerfile改十几行,收益立竿见影。
如果你是对镜像体积有极致要求的团队,比如做边缘部署,那在方案二的基础上再上JLink,把JRE也裁一裁。但要做好兼容性测试,不是所有库都能顺利模块化。
7.4 最后分享一个排查小技巧
分层构建偶尔会遇到"缓存莫名失效"的情况。这时候不要瞎猜,直接用docker build --no-cache对比一次全量构建,看各层大小。如果全量构建时dependencies层大小和缓存构建时不一致,那说明依赖确实变了。
还有一个更细的办法:把layertools extract出来的dependencies目录做个校验和,每次构建后对比。如果校验和变了,就说明依赖有变动。这个可以写进CI脚本里,自动告警。
find dependencies -type f -exec md5sum {} \; | sort | md5sum把这个值记录下来,和上一次对比,一目了然。这个技巧帮我定位过好几次"幽灵缓存失效"的问题,非常实用。