资讯动态

Spring Cloud微服务Maven分离打包:告别fat jar,实现快速增量部署

发布时间:2026/10/13 5:52:46 来源:尧图企业网站定制
一个维护过 Spring Cloud 微服务的人多数都有这种经历每次发版把一个动不动就五六十MB、甚至上百MB的 fat jar 传到服务器带宽稍差一点等得人焦躁。明明代码只改了几行却要反复搬运一堆第三方依赖。SpringBoot / SpringCloud 下的 Maven 分离打包就是在解决这个“重复搬运行李”的问题把应用自身的代码和所有第三方依赖拆开发布时只传那个几MB的应用包依赖目录稳定不动增量部署就快很多。这篇内容主要讲怎么用 Maven 插件把 Spring Boot / Spring Cloud 项目从默认的 fat jar 改造成“瘦包 外部 lib 目录”的结构包含完整的 pom 配置、启动脚本、发布同步命令以及我在实际项目里踩过的坑。适合正在维护多模块微服务、觉得每次发布都太慢的团队也适合刚接触 Spring Boot 打包机制、想理解 fat jar 和瘦包区别的开发者。1. 为什么要把打包拆开fat jar 背后的成本1.1 默认打包方式到底做了什么Spring Boot 默认的mvn package之所以能打出一个可以直接运行的 jar靠的是spring-boot-maven-plugin的repackage目标。它会对maven-jar-plugin生成的原始 jar 做二次加工把项目编译后的 class 和资源挪到BOOT-INF/classes下把依赖 jar 放到BOOT-INF/lib下同时把入口类替换成org.springframework.boot.loader.JarLauncher。这个 JarLauncher 就是关键。普通 JVM 加载java -jar时只会按 classpath 找类并不会去解析 jar 里面嵌套的 jar。Spring Boot 的 Launcher 本质上是一个自定义类加载器它先把BOOT-INF/lib/下的嵌套依赖读出来再交给 JVM 去加载。这套机制让“一个 java -jar 命令跑起整个应用”变成了现实但也带来了两个问题打包体积大、增量发布成本高。fat jar 不是把依赖压缩了它只是把所有 jar 原样塞进了外层 jar。所以一个依赖有 50 个 jar 的项目最终产物可能接近百兆这是物理体积不是错觉。1.2 多模块项目里的放大型问题在 Spring Cloud 架构下业务被拆成很多个微服务模块。每个模块都是独立的 Spring Boot 应用。如果每个模块都打 fat jar发布时就会遇到这样的场景服务有 6 个每个 fat jar 60MB每次更新其中 3 个服务要传 180MB。而实际上这 3 个模块自身的代码加起来往往不到 10MB。带宽紧张、服务器较多时这个传输成本每天都会被放大很多倍。有人可能会想能不能让所有微服务共享一份依赖这是另一个话题。依赖共享虽然在容器化、模块化场景里有价值但在传统多模块部署中版本冲突和维护成本经常超过收益。更稳妥、也更容易落地的思路是每个服务的依赖各自保留但做增量传输。这次发布只更新service-user.jarlib/目录完全不动。分离打包的本质就是把“全量传输”变成“增量同步”。另外还有一个很现实的好处依赖目录固定后静态扫描、安全漏洞排查的范围就变小了。比如某些公司要求对生产环境的 jar 包做 CVE 扫描全量 fat jar 每次都把几十个依赖重新扫一遍瘦包方案可以只扫描有变动的几个依赖效率差距很大。2. 两种主流分离打包方案怎么选2.1 方案 Amaven-jar-plugin maven-dependency-plugin 打瘦包这个方案的思路很直接不使用 Spring Boot Launcher而是把项目当成普通 Java 应用来处理依赖复制到外部目录运行时用 classpath 加载。核心有三个动作maven-jar-plugin正常生成一个不包含依赖的瘦 jar里面只有项目自身的 class 和资源maven-dependency-plugin的copy-dependencies目标把所有 runtime 依赖复制到target/lib启动时通过java -cp app.jar:lib/*指定 classpath或者干脆利用 jar 包 Manifest 里的Class-Path字段让java -jar也能直接跑。这个方案的优点是它跟 Spring Boot 的内部机制解耦了。不管你的项目是 Boot 2.7 还是 Boot 3.x只要 JVM 版本匹配标准 classpath 方式都能跑。缺点是很多新人对-cp方式不熟悉或者担心 Boot 的自动配置会不会失效。实际上不会因为 Spring Boot 的自动配置是在运行时读取 classpath 里的类来决定的依赖都在 classpath 上它一样会生效。2.2 方案 B利用 Boot 自带 Launcher 外置依赖Spring Boot 在很早的版本里就提供了PropertiesLauncher。它的名字里带 “Properties”是因为它支持通过loader.path这个系统属性指定外部依赖目录。打包时要让 repackage 使用 ZIP layout生成的结构配合-Dloader.pathlib -jar app.jar启动。老项目里偶尔能看到这种配置。它保留了java -jar的入口又实现了依赖外置。不过在 Spring Boot 2.x 后期和 3.x 时代官方对 layout 的支持已经不像以前那么友好社区也开始转向更标准的瘦包方案。如果项目还停留在 Spring Boot 2.0 到 2.3 之间的版本方案 B 能减少改造量但我个人不推荐新项目选它。2.3 两个方案的对比与选型对比项方案 A标准瘦包方案 BPropertiesLauncher启动方式java -cp app.jar:lib/*或 Manifest 类路径java -Dloader.pathlib -jar app.jar对 Spring Boot 版本要求基本无要求Boot 2.x 体验较好3.x 不推荐配置复杂度中两个插件中还要理解 loader 机制排障难度低就是普通 classpath中出错点藏在 Launcher 里适合场景新项目、微服务多模块老项目小步改造我实际做下来方案 A 是更省心的那条路。它不依赖 Spring Boot 的任何魔法打出来的瘦包即使哪天不去做 Boot 应用了普通 classpath 也还能启动。3. 多模块实战父 pom 统一配置与子模块打包3.1 先搭一个多模块工程结构为了演示我虚构了一个简单的微服务项目包含两个业务模块和一个父工程spring-cloud-demo ├── pom.xml ├── service-user └── service-order父工程的packaging是pom主要职责是统一依赖版本和插件配置。Spring Cloud 的依赖版本用dependencyManagement导入这样各子模块只管声明依赖不用关心版本号。父 pom 的关键结构如下parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version relativePath/ /parent groupIdcom.example/groupId artifactIdspring-cloud-demo/artifactId version1.0.0/version packagingpom/packaging modules moduleservice-user/module moduleservice-order/module /modules properties java.version1.8/java.version spring-cloud.version2021.0.8/spring-cloud.version /properties dependencyManagement dependencies dependency groupIdorg.springframework.cloud/groupId artifactIdspring-cloud-dependencies/artifactId version${spring-cloud.version}/version typepom/type scopeimport/scope /dependency /dependencies /dependencyManagement这里有两个细节要注意。第一我用了spring-boot-starter-parent作为 Spring Boot 的 parent它的好处是统一管理了大量依赖版本子 pom 不用再写版本号。第二Spring Cloud 的版本号用属性统一维护升版本时只改一个地方。3.2 插件管理放 pluginManagement子模块各自引用很多项目的插件配置习惯直接写在父 pom 的buildplugins里这样所有子模块都会继承执行。这么做好处是省事但有个问题不是所有子模块都需要打成 Spring Boot 应用包。比如将来加一个公共模块用于存放工具类它也会被套上应用的打包逻辑挺别扭。更干净的做法是两步走父 pom 的pluginManagement里定义好插件和完整配置子模块需要时再在buildplugins里引用。引用时不需要重复 version 和 configuration只需要写插件坐标Maven 会自动从 pluginManagement 里取配置。在父 pom 的pluginManagement中配置两个插件build pluginManagement plugins plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-jar-plugin/artifactId version3.3.0/version configuration archive manifest addClasspathtrue/addClasspath classpathPrefixlib//classpathPrefix mainClass${start-class}/mainClass /manifest /archive /configuration /plugin plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-dependency-plugin/artifactId version3.5.0/version executions execution idcopy-dependencies/id phaseprepare-package/phase goals goalcopy-dependencies/goal /goals configuration outputDirectory${project.build.directory}/lib/outputDirectory includeScoperuntime/includeScope /configuration /execution /executions /plugin /plugins /pluginManagement /buildmaven-jar-plugin的配置里有两个值得解释的点。一个是addClasspath和classpathPrefix。把它设为 truemaven-jar-plugin 会自动计算当前项目的依赖列表写进 jar 包的 MANIFEST.MF 的Class-Path属性。这样做的效果是即使你直接java -jar app.jarJVM 也会根据Class-Path去 jar 同级的lib/目录找依赖不会因为依赖外置而报找不到类。这个功能省去了启动脚本里拼-cp的痛苦。另一个是${start-class}。这是一个 Maven 属性需要在每个子模块里定义成该模块的启动类。因为在多模块场景下每个模块的 main 类不一样。把这个属性抽出来父 pom 就不用知道每个子模块的类名子模块只需要在properties里指定。3.3 子模块怎样配置才能打出瘦包以service-user为例子模块的 pom 大概长这样project parent groupIdcom.example/groupId artifactIdspring-cloud-demo/artifactId version1.0.0/version /parent modelVersion4.0.0/modelVersion artifactIdservice-user/artifactId packagingjar/packaging properties start-classcom.example.serviceuser.ServiceUserApplication/start-class /properties dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.cloud/groupId artifactIdspring-cloud-starter-bootstrap/artifactId /dependency /dependencies build plugins plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-jar-plugin/artifactId /plugin plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-dependency-plugin/artifactId /plugin /plugins /build /project这个 pom 里没有spring-boot-maven-plugin。在瘦包方案里一定要刻意避开它否则打出来的 jar 会被 repackageMain-Class 会被改成 JarLauncher依赖又被塞回包内前面的努力就白费了。prepare-package阶段绑定copy-dependencies是我比较推荐的做法。它比直接绑package稍微提前一点确保依赖先被复制到target/lib随后 maven-jar-plugin 在打包时如果需要参照 lib 目录结构也能保证状态一致。实际执行时先跑一次mvn clean package然后看target目录你会看到这样的输出target/ ├── service-user.jar ├── service-user.jar.original └── lib/ ├── spring-boot-starter-web-2.7.18.jar ├── spring-boot-2.7.18.jar └── ...如果这个项目原来是用spring-boot-maven-plugin打 fat jar 的你会在target里见到一个*.jar.original备份文件。瘦包方案下没有这个文件。这个细节可以帮你确认旧的打包插件是不是已经彻底移除。3.4 多模块如何避免重复配置上面的子模块配置每个业务模块都要写一遍。服务少的时候无所谓但微服务一旦多起来重复代码就会很烦。这时我建议在父 pom 的buildplugins里直接声明这两个插件让所有模块自动继承。前提是你的模块绝大多数都是要打包的应用模块而且都能接受统一的 manifest 配置。不过这样做的风险是如果某个子模块是普通的工具库模块它也会生成自己的 lib 目录虽然不影响运行但会把 target 弄乱。我一般会等模块数量确实变多后再合并初期保持简单的显式声明排查问题也更容易定位。4. 发布部署启动脚本与增量同步的细节4.1 用 Manifest 实现最简单的启动脚本因为 maven-jar-plugin 已经在 MANIFEST.MF 里写好了Main-Class和Class-PathLinux 部署目录下只要一个极简启动脚本就可以跑起来#!/usr/bin/env bash BASE_DIR$(cd $(dirname $0)/.. pwd) APP_NAMEservice-user exec java ${JAVA_OPTS} -jar ${BASE_DIR}/${APP_NAME}/${APP_NAME}.jar这里要注意目录规范。我习惯的部署结构是这样/opt/release/ ├── service-user/ │ ├── service-user.jar │ └── lib/ └── service-order/ ├── service-order.jar └── lib/lib/目录必须和service-user.jar在同级这样 manifest 里的相对路径lib/xxx.jar才能解析正确。如果你把 lib 放在别的地方或者启动时不在 jar 所在目录Class-Path就会失效。如果你的团队更喜欢启动脚本自己拼 classpath也有一种做法是不在 maven-jar-plugin 里配置addClasspath启动脚本显式拼-cp。脚本大概是exec java ${JAVA_OPTS} -cp ${APP_DIR}/${APP_NAME}.jar:${APP_DIR}/lib/* com.example.serviceuser.ServiceUserApplication注意类路径分隔符在 Linux 下是冒号Windows 下是分号。而且在脚本里用lib/*这种写法时通配符是由 Java 本身识别的不是 shell 去展开的所以即使有几个 jar 文件名里有空格或者特殊字符Java 也能正确处理这一点比 shell 的$(ls lib/*.jar)稳得多。4.2 增量发布到底怎么传文件分离打包最直接的收益体现在增量发布。第一次上线时需要把瘦包和 lib 目录全部同步到服务器rsync -av service-user/target/service-user.jar service-user/target/lib deploy-host:/opt/release/service-user/后续再发小版本比如只改了service-user的代码就只同步 jarrsync -av service-user/target/service-user.jar deploy-host:/opt/release/service-user/不要一来就习惯性加--delete。增量同步的目录是service-user/如果加--delete远端旧的 lib 目录会被一起删掉然后再由后续命令重新同步等于没有增量而且中间那段窗口期服务会起不来。正确的做法是只在考虑同步整个lib/变化时才用--delete而且目标路径要精确到lib/rsync -av --delete service-user/target/lib deploy-host:/opt/release/service-user/这条命令只清理目录产生的变更文件不会动旁边的 jar。发布脚本在 CI 流水线里建议拆成两步先构建出瘦包和 lib再按需调用同步命令。不要在构建机上去做rsync构建机的权限和网络环境往往不适合直接连生产服务器中间应该由发布平台来控制。4.3 本地调试的坑Spring Boot DevTools 和 IDE本地用 IDE 直接启动 Spring Boot 应用依赖来源是 Maven 仓库的 jar跟打包无关。但有些项目引入了spring-boot-devtools它默认 scope 是 runtime会被复制进 lib 目录。启动时会看到两个 devtools 相关 jar没问题但它们确实会让 lib 目录多出没必要的文件。我的处理方式是部署包场景下在 pom 里把 devtools 标成optional或者明确排除dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-devtools/artifactId scoperuntime/scope optionaltrue/optional /dependencyoptionaltrue的依赖不会传递到其他模块也不会被copy-dependencies复制因为 dependency-plugin 默认遵循 optional 依赖的处理规则。本地开发不受影响打包输出更干净。还有一个容易忽略的点如果你用 IDE 里的 Maven 窗口执行mvn package看到target下生成了瘦包但 manifest 里Class-Path是空的多半是因为start-class属性没有传进去。这时先检查子模块的properties里是不是真的写了start-class。spring-boot-starter-parent 本身也会传递一些默认属性如果哪个地方拼错名字Maven 不会构建失败只是静默地把 mainClass 留空等到部署才会炸。5. 常见问题与排查技巧实录5.1 启动提示 NoClassDefFoundError / ClassNotFoundException这套方案踩到最多的坑就是启动时报类找不到。先区分两种情况。第一种使用java -cp app.jar:lib/*启动时报找不到。这不是依赖缺失多半是lib/*这个 classpath 字面量没有被正确传递。如果在 Windows 的 cmd 里启动S参与者建议看分隔符是不是用成了冒号。Windows 的 classpath 分隔符是分号。另外如果脚本里用了单引号包裹lib/*部分环境可能把星号当成字面量交给 JVMJava 会尝试加载一个叫*的文件自然失败。第二种依赖真的缺失。这时打开target/lib目录看看是不是只有部分依赖。最常见的原因是maven-dependency-plugin的includeScoperuntime/includeScope配置。这个配置的意思是只复制 runtime 作用域及以上的依赖但compile、provided、test会受影响。如果项目里有自定义依赖被标成了provided或者某个框架的扩展包用了provided它就不会进 lib。解决办法是看依赖树mvn dependency:tree找到缺失的依赖后确认它的 scope 定义是否合理。有些依赖属于编译期需要、运行期不需要不复制是正确的。真正该出现却没出现的检查是否在某个父 pom 里被排除了。5.2 jar 包打出来了但 lib 目录是空的这个我遇到过两次。第一次是插件执行顺序的问题第二次是打包用的 Maven 命令太“干净”了。第一个问题的特征很典型mvn clean package执行完target/lib不存在或者只有一两个文件。原因通常是copy-dependencies绑定到了package阶段而maven-jar-plugin:jar在 super pom 默认 lifecycle 中也绑定package两者执行顺序可能不按你预期来。虽然最终一般都能跑通但在极端情况下lib 拷贝发生在 jar 打包之前或之后都会导致衔接不顺畅。我把copy-dependencies放到prepare-package就是为了在 jar 阶段开始前先把依赖目录准备好后续逻辑不会依赖“jar 已存在”这个状态。第二个问题的触发条件是有些人为了跳过测试执行的是mvn jar:jar而不是生命周期命令。jar:jar是直接调用插件目标它不会触发prepare-package之前的流程copy-dependencies自然不执行。所以在 Maven 术语里“mvn package”和“mvn jar:jar”是完全不同的两件事前者是生命周期后者是直接目标。平时用前者。5.3 同时存在两个插件导致瘦包被覆盖成 fat jar改造不彻底的项目经常出现这种情况pom 里既有maven-jar-plugin又保留着spring-boot-maven-plugin的 repackage 执行结果构建完以后target下只有一个体积很大的可执行 jar瘦包被覆盖了或者出现一个*.jar.original。判断方法很简单看target下最终产物的体积。如果体积明显大于“本地代码 资源”应有的体量说明 repackage 还在执行。或者看 jar 包内部结构jar tf service-user.jar | head出现BOOT-INF/目录说明这是 fat jar。没出现BOOT-INF/而是正常的 class 目录和META-INF/MANIFEST.MF才是我们想要的瘦包。5.4 强行共享 lib 目录版本冲突早晚找上来有的团队为了省磁盘空间让多个服务共享一个公共 lib 目录。第一次这样部署确实爽几个服务加起来才留一份依赖。但后面会有问题A 服务要升级 Spring Boot 版本B 服务还停留在旧版本共享目录里的 jar 只能有一个版本。今天 A 服务升级了依赖明天 B 服务启动时莫名的类冲突、NoSuchMethodError、Bean 初始化失败都来了。我的态度是微服务场景下不要省这份空间。磁盘比排查诡异问题的时间便宜。每个服务保留自己的 lib 目录增量发布时反正也不会重复传代价有限。如果确实想优化存储用 Linux 的符号链接把公共 jar 指过去都比直接共享目录可控但符号链接方案又引入了运维复杂度综合收益不算高。5.5 测试环境没问题生产环境启动失败这个坑经常出现在 manifest 类路径方案下。测试环境里项目目录叫service-user生产环境目录叫svc-user或带了版本号Class-Path里的lib/本来是按相对路径解析的正常情况下目录名不影响。但如果生产环境用了绝对路径脚本脚本里拼的目录和 jar 实际所在目录不一致就会找不到 lib。更隐蔽的一种是部署平台把 jar 和 lib 分别放在不同挂载目录比如 jar 在/apps/service-user/service-user.jarlib 在/data/lib/。这种和 manifest 相对路径的假设冲突启动完全失败。遇到这个情况建议在启动脚本里显式设置exec java -Dloader.path/data/lib -jar /apps/service-user/service-user.jar但注意-Dloader.path是 PropertiesLauncher 的参数在标准瘦包方案下不生效。所以标准瘦包 manifest 方案必须坚持“lib 和 jar 同级”的目录约定。如果部署平台一定要用固定目录宁可改成启动脚本拼-cp也别去迁就 manifest。6. 我在实践中沉淀的几个判断做了几次瘦包改造后我对方案选择有了更明确的判断。如果你用的是 Spring Boot 2.x 且 Java 8方案 B 有快速改造成效但如果是新项目或者已经在用 Spring Boot 3.x方案 A 是唯一值得考虑的。因为 Boot 3 的内部结构更复杂Launcher 机制变化也大不值得为了一个java -jar的执着去引入额外层。还有一个建议改造后一定要在本地模拟一次完整发布流程。我在一个模拟项目里做过一次“从构建到同步到启动”的全流程演练发现本地直接跑和打瘦包后跑表现还是有一点差异的。比如某个本地编译期依赖被标成了provided本地启动时 IDE 会把所有依赖都放到 classpath瘦包部署时它没被复制应用启动才报错。这种差异在单纯mvn package时不会暴露只有把打出来的包丢到干净环境跑一遍才能发现。最后分享一个小技巧我把瘦包和 lib 复制到部署目录后习惯在启动脚本开始处加一个简单的依赖数量检测if [ ! -d ${APP_DIR}/lib ] || [ -z $(ls -A ${APP_DIR}/lib) ]; then echo lib directory is missing or empty exit 1 fi别小看这几行它能在部署前就把“lib 没同步完整”这种问题拦下来而不是等服务进程起了又立刻挂掉才去排查。这套东西跑顺以后我这边发一个微服务的完整时间从原来的几分钟降到了几十秒而且再也不用对着一个 80MB 的 jar 犹豫要不要传了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑