先说一个我最近遇到的场景。某公司给客户做微服务交付,现场环境完全隔离,没有CI/CD流水线,连内网的依赖仓库都不能访问,所有服务只能靠手工在本地构建、拷贝、部署。那天我抱着笔记本电脑捣鼓了一整天,把一套微服务项目从多模块代码变成一个个可以独立启动的jar包,中间踩了无数坑。这篇文章就把整个手动打包过程、背后的原理和排查经验完整记录下来,适合需要离线部署、临时交付、或者单纯想搞懂构建产物是怎么来的人。
不管你平时是用IDE右侧的Maven面板点一下打包,还是写脚本调CI工具,我都建议至少手动打一次jar包。因为只有自己敲过命令、看过构建日志、处理过依赖冲突,你才能真正理解那个十几兆甚至上百兆的jar包里装了什么,以及为什么有些包在本地能跑,换台机器就起不来。
1. 手动打包的前置认知:先搞清楚你在打什么
先说一个容易翻车的事。很多人习惯在IDE里点一下package按钮,包就打好了,然后传到服务器上发现启动失败,才开始怀疑人生。手动打包这件事,难的不是敲命令,而是你根本不了解构建过程到底干了什么。
微服务项目大多是标准的多模块结构:一个父工程,下面挂十几个子模块。子模块之间互相依赖,典型的关系是:订单服务依赖公共组件模块,用户服务又依赖自己的API模块和公共配置模块。这种结构下有个硬性逻辑——依赖模块必须先被安装到本地仓库,或者跟随本次构建一起编译,否则被依赖方在编译阶段根本找不到类。
这里有两个非常容易混淆的概念:
mvn package:把当前模块打包成jar或war,但不会把产物安装到本地Maven仓库。mvn install:打包之后,把jar安装到本地仓库,供其他模块在本地构建时引用。
对于单模块项目,这两个命令差别不大。但对于多模块微服务项目,差别就大了。如果子模块B依赖子模块A,你只对B执行package,而A的jar没有进入本地仓库,构建B时会报"无法解析依赖"或"找不到符号"。
所以手动打包的第一步,是先梳理整个项目的模块依赖结构。最快的办法不是一个个打开pom文件翻,而是在根目录执行:
mvn dependency:tree这个命令会把整个项目的依赖树打印出来,包括模块间依赖和第三方依赖,信息量非常大。第一次看的时候可能会被刷屏,建议把输出重定向到文件里再慢慢看:
mvn dependency:tree -Doutput=/tmp/dep-tree.txt1.1 微服务构建产物到底是什么
一个典型Spring Boot微服务的最终构建产物,并不是一个"只包含业务代码的轻量jar",而是一个能直接java -jar启动的可执行jar。它里面至少包含四类东西:
- 编译后的业务代码,即
com/xxx/...下的class文件 - 所有第三方依赖jar,以嵌套jar的形式放进
BOOT-INF/lib目录 - 项目自身的资源和模板文件,包括
application.yml、静态页面、Mapper XML等 - Spring Boot框架自带的
JarLauncher类以及MANIFEST.MF清单文件
这里就引出一个关键认知:普通jar和Spring Boot可执行jar完全是两回事。
| 产物类型 | 内容 | 能否直接 java -jar |
|---|---|---|
| 普通jar | 仅包含模块自身编译类 | 不行,会报"no main manifest attribute" |
| Spring Boot可执行jar | 自身类 + 全部依赖 + 启动器 | 可以,独立启动 |
手动打包首先要确认项目里是否引入了spring-boot-maven-plugin。如果项目继承了spring-boot-starter-parent,但不加这个构建插件,打包出来的多半是普通jar,拿到服务器上用java -jar跑,直接报清单属性错误。
另一个常见的依赖关系问题是:父pom里用<properties>统一管理版本,但某个子模块手滑写死了版本号。本地仓库里如果存着旧版本,依赖解析时可能不会报错,等传到干净环境的服务器上,依赖拉取失败或者版本不一致的问题就全冒出来了。手动打包前花十分钟跑一遍mvn dependency:tree,能省掉后面对着启动日志发呆的几小时。
1.2 环境准备:三处版本必须对齐
手动打包最容易翻车的就是环境不一致,打包机器、构建工具、运行机器三方版本对不上。我总结过三个必须核对的位置:
- 打包机的JDK版本:用
java -version确认,不能低于项目要求的编译目标版本。 - Maven版本:用
mvn -v查看,过旧的Maven可能不兼容新插件的参数。 - 运行机器的JDK版本:服务器上跑的JDK不能低于编译目标版本。
举个例子,项目pom里用<java.version>11</java.version>指定编译目标,打包机是JDK 17没问题,但你拷到一台只装了JDK 8的服务器上,启动瞬间就会抛UnsupportedClassVersionError。这个错误信息很直白,但很多人为什么没提前发现?因为本地IDE用的是内置JRE,版本早就被IDE接管了,平时根本注意不到。
这里还要提醒一个命令行新手必踩的坑:Maven本身运行依赖JAVA_HOME环境变量。很多人IDE里打包正常,一换到纯命令行就报JAVA_HOME is not set或找不到javac,就是因为没配环境变量。手动打包前先开一个干净终端执行mvn -v,能正常打印版本信息再继续。
版本对齐还有一个隐性坑:Spring Boot版本和Spring Cloud版本是有对应关系的,配错的话本地单个模块启动没问题,多个微服务互相调用时某些注解或组件初始化才报错,排查起来非常痛苦。建议在父pom里统一引入对应版本的spring-cloud-dependencies,使用BOM方式控制版本:
<dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-dependencies</artifactId> <version>${spring-cloud.version}</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement>这样所有子模块都不需要再写Spring Cloud组件的版本号,从根本上避免版本漂移。
2. 核心打包实操:从单模块到多模块
环境确认好了,模块结构也摸清了,下面进入正题:手动打包的命令怎么敲。
2.1 单模块项目的标准打包命令
一个独立的Spring Boot服务,最简单的打包命令是:
mvn clean packageclean的目的是清掉上一次构建遗留的target目录,避免旧class文件混进新jar。package阶段会依次执行编译、测试、打包。但如果本地单元测试配置了外部中间件,比如需要连数据库或Redis的测试,打包时经常挂在测试环节,很烦人。
所以实践中最常用的是跳过测试:
mvn clean package -DskipTests注意-DskipTests只是不执行测试用例,但会编译测试代码。如果想连测试代码编译都跳过,用:
mvn clean package -Dmaven.test.skip=true这两个参数的区别很多新手分不清。简单说:-DskipTests是"不跑但保留编译产物",-Dmaven.test.skip=true是"整块测试代码都当不存在"。微服务项目打包为了速度,我一般直接用后者。
如果项目有生产环境和测试环境之分,还可能需要指定Profile:
mvn clean package -Dmaven.test.skip=true -Pprod这样打包出来的jar里,内置的application-prod.yml会被激活为默认配置。注意,Profile生效的前提是application.yml里声明了对应的环境配置,否则-Pprod只是把临时变量传给Maven,对Spring Boot的运行环境没有任何影响。
2.2 多模块项目的聚合打包与顺序问题
多模块微服务项目,十几个子模块分布在各自目录里。有人习惯一个模块一个模块地cd进去打包,结果就是:先打公共模块,再打API模块,再打业务服务模块,顺序一旦搞错就构建失败。
更好的做法是在根目录执行聚合构建。父pom的<packaging>是pom,<modules>节点列出了所有子模块,根目录一条命令就能驱动整个项目按依赖顺序构建:
mvn clean install -Dmaven.test.skip=true这里用install而不是package,原因就是前面讲的:子模块之间有依赖,必须把被依赖的模块安装到本地仓库,后续模块才能正确引用。这一步打包完成后,你会在各个子模块的target目录里看到生成的可执行jar。
如果只需要打包其中某一个服务,不想每次都全量构建十几个模块,用-pl和-am组合:
mvn clean package -pl order-service -am -Dmaven.test.skip=true-pl(project list):指定要构建的模块列表,多个用逗号分隔。-am(also make):同时构建该模块依赖的其他模块。
这个组合非常实用。比如我只改了订单服务,依赖的公共模块没动,加-am就能保证依赖模块也一并打包安装,又不会把无关的十几个模块全部构建一遍。实测下来,多模块这样打包能省一半时间。
聚合构建还有个细节:父pom的<modules>声明顺序并不强制等于构建顺序,Maven会按依赖关系自动排序。但如果你是第一次接触一个遗留项目,发现模块关系乱成一团,别慌,mvn install会自动处理依赖拓扑。真正该担心的是某两个模块循环依赖,这种项目手动打包时Maven直接报The projects in the reactor contain a cyclic reference,这种基本只能靠重构解决。
2.3 Gradle项目的手动打包对照
微服务项目并不都是Maven,Gradle也占了相当比例。Gradle手动打包的核心命令是:
gradle clean bootJarSpring Boot的Gradle插件会注册bootJar任务,产出可执行jar。如果项目里既有普通jar任务又有bootJar任务,要注意只启用一个,否则会产生同名jar互相覆盖。
实际项目中更常用的是:
gradle clean build -x test-x test等同于Maven的跳过测试。Gradle的依赖管理不像Maven那样有固定的"install到本地仓库"逻辑,多模块项目用gradle build时,依赖模块会在本次构建的reactor内一起编译,所以通常不需要像Maven那样显式install。
这里给个小建议:不要盲目在Maven和Gradle混用场景下手动打包。同一个微服务项目里,不同子模块如果用不同构建工具,依赖处理就很容易乱。我在某个项目里见过父工程是Maven、某个子模块却带了Gradle wrapper,最终只能统一收敛到一个工具,否则手动打包的复杂度直接翻倍。
3. Spring Boot可执行jar的生成原理
很多人在这一步就停了:jar打包出来,能用,然后呢?如果你不想以后遇到"换了台机器就起不来"这种诡异问题,就值得花十分钟理解一下Spring Boot可执行jar的机制。
3.1 spring-boot-maven-plugin到底做了什么
一个普通的Maven项目执行mvn package,打出来的是一个标准jar,里面只有自己编译的class和资源文件。这种jar无法用java -jar启动,因为没有主类信息、没有依赖库。
真正让jar变成可执行的是spring-boot-maven-plugin的repackage目标。这个插件会在package阶段之后对jar做二次处理:把原本的普通jar变成三层结构的可执行jar,同时重写MANIFEST.MF清单。
pom里典型的配置是这样的:
<build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <executions> <execution> <goals> <goal>repackage</goal> </goals> </execution> </executions> </plugin> </plugins> </build>这段配置非常重要。很多人打包出来没有repackage,是因为用了独立插件但没配置executions,或者项目根本没有引入这个插件。排查方法很简单:用压缩工具打开jar,看看有没有BOOT-INF目录,有就是可执行jar,没有就是普通jar。
jar tf app.jar | head -50或者:
unzip -l app.jar如果输出里能看到BOOT-INF/classes和BOOT-INF/lib,恭喜,这个jar是Spring Boot插件的产物。
3.2 BOOT-INF目录结构与双类加载器设计
把Spring Boot可执行jar解压开,目录结构是这样:
app.jar ├── BOOT-INF │ ├── classes # 业务代码和资源 │ └── lib # 所有第三方依赖jar ├── META-INF │ ├── MANIFEST.MF # 启动入口定义 │ └── maven-metadata.xml └── org └── springframework └── boot └── loader └── JarLauncher.class这里有几个关键点:
Main-Class是org.springframework.boot.loader.JarLauncher,负责引导。Start-Class才是你自己写的@SpringBootApplication启动类。BOOT-INF/lib里嵌套的jar会被JarLauncher自定义的类加载器读取,不依赖外部ClassPath。
Spring Boot用了一套自定义类加载器机制,专门用来从嵌套jar里加载类。这就是为什么这种jar可以在不把依赖解压到系统目录的情况下独立启动。手动打包完,建议直接查看MANIFEST.MF确认一下关键属性:
unzip -p app.jar META-INF/MANIFEST.MF正常输出里能看到:
Main-Class: org.springframework.boot.loader.JarLauncher Start-Class: com.example.DemoApplication有这两行,说明插件执行成功。这个操作在排查"jar包启动报no main manifest attribute"时极其有用。
3.3 验证jar包可执行性的标准流程
打包完成后的第一件事不是急着拷到服务器,而是在本地先做一次启动验证。我的一贯流程是:
java -jar order-service.jar --server.port=18080观察日志是否正常打印出端口监听信息和项目启动完成标志。验证完用Ctrl+C停掉,再确认jar是否能正常关闭,不会留下僵死进程。
如果项目包含多个微服务,建议在本地挨个启动一遍,再检查各个服务之间是否能够通过注册中心发现。这一步提前做,能避免把坏包传到现场才发现的尴尬。我在实际交付中就遇到过:某个服务本地启动正常,但打包机的资源编译时被限流,产生了一个不完整的jar,一启动就OutOfMemoryError。本地验证当时没做,到现场才发现,来回浪费了两天时间。
4. 配置文件外置与启动参数设置
jar包一旦打好,里面的application.yml就固定了。但微服务部署场景中,开发、测试、生产环境的数据库地址、注册中心地址往往是不同的。手动打包不可能为每个环境都重新打一次包,所以必须理解配置外置。
4.1 多环境Profile的正确选择方式
Spring Boot的多环境配置通常是这样组织的:
src/main/resources/ ├── application.yml ├── application-dev.yml └── application-prod.ymlapplication.yml里写公共配置,并通过spring.profiles.active指定默认环境:
spring: profiles: active: dev但如果把环境写死在jar里,每次切换环境都要重新打包,非常低效。更推荐的做法是:打一个不带环境配置的jar,启动时用参数指定Profile。
启动命令对应变为:
java -jar app.jar --spring.profiles.active=prod命令行参数--spring.profiles.active=prod的优先级高于jar内部的application.yml。所以即使jar里写死了dev,命令行也能覆盖。这是微服务手动运维中最高频的操作,一定要记牢。
4.2 外置配置文件与优先级详解
如果环境相关的配置量很大,比如包含几百行数据库连接和队列配置,全部塞在Profile文件里会导致jar包体积膨胀,也不方便运维调整。更好的做法是让jar自动读取jar包外部的配置文件。
在jar的同一级目录下放一个config文件夹,或者直接把application.yml放在jar同级目录,Spring Boot启动时就会优先读取外部配置而不是jar内的配置。完整的优先级从高到低大致是:
- 命令行参数
- 外部
config目录下的配置文件 - 外部jar同级的配置文件
- jar包内部的配置文件
所以当你在jar同级的config/application-prod.yml里写了数据库密码,而jar内application.yml里写的是占位符或者默认值,最终生效的会是你外部的配置。这个特性非常适合手动部署场景:代码、依赖全部固化在jar里,环境差异完全由外部配置控制。
启动时还可以显式指定配置文件路径:
java -jar app.jar --spring.config.additional-location=/opt/app/config/这样即使配置文件不放在jar同级,也能被加载到。跨机器部署时,我习惯把所有服务的配置文件统一下发到固定目录,再配合启动脚本统一拼接参数,避免出现"这台机器读配置A、那台机器读配置B"的混乱。
4.3 JVM参数与后台启动的推荐写法
手动部署微服务,启动命令不能裸奔。一个生产环境可用的启动方式至少包含三部分:JVM内存参数、Spring Boot业务参数、日志重定向。
推荐写法:
nohup java -Xms512m -Xmx1024m \ -XX:+UseG1GC \ -jar app.jar \ --spring.profiles.active=prod \ --server.port=8080 \ > /opt/logs/app.log 2>&1 &逐个解释:
-Xms512m -Xmx1024m:初始堆内存和最大堆内存。生产环境建议-Xms等于-Xmx,避免运行期堆扩容带来的性能抖动。-XX:+UseG1GC:使用G1垃圾回收器,对多核机器上的微服务比较友好。--server.port=8080:覆盖jar内的端口配置。> app.log 2>&1:把标准输出和错误输出都重定向到日志文件。&:放到后台运行,终端关闭后服务不停止。nohup:终端挂断后进程继续运行。
关闭进程时不要用kill -9,应该先找到进程PID,再优雅停止:
ps -ef | grep app.jar | grep -v grep kill 12345如果用了kill -9强杀,Spring Boot来不及释放注册中心连接、数据库连接池和本地资源,在微服务体系里会留下大量残留会话,可能引起后续服务调用异常。
5. 常见打包与启动问题排查实录
手动打包这件事,命令就那么几条,最花时间的永远在排查阶段。我把工作里遇到频率最高的四类问题整理成速查,每一个都有对应的排查路径。
5.1 no main manifest attribute:打包成了普通jar
这个报错几乎可以断定jar里没有META-INF/MANIFEST.MF中的Main-Class,或者根本没有执行repackage。排查顺序:
- 先运行
unzip -p app.jar META-INF/MANIFEST.MF,看看有没有Main-Class。 - 确认pom里有没有
spring-boot-maven-plugin,有没有配repackage的execution。 - 确认模块的
<packaging>是不是jar,如果写成了pom,根本不会产出可执行jar。 - 确认启动类上有没有
@SpringBootApplication注解,并且main方法签名是public static void main(String[] args)。
最后一类问题尤其隐蔽:某个模块的启动类不在聚合构建中被扫描的路径下,或者主类名写错了。手动打包时建议在pom的插件配置里显式指定主类:
<plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <configuration> <mainClass>com.example.OrderApplication</mainClass> </configuration> </plugin>这个配置在模块多、存在多个main方法时尤其必要,能绕开"找到多个主类"或"找不到主类"的尴尬。
5.2 ClassNotFoundException:依赖没有被正确打入jar
启动后报找不到某个类,通常有几种可能:
- 依赖以
provided或optional声明,这类依赖不会进入可执行jar。 - 依赖作用域配置错误,导致repackage时被排除。
- 启动类依赖了运行时才加载的类,但打包时没包含。
排查方法第一是先看报错类名属于哪个jar。用dependency:tree确认它是否出现在依赖树中,再确认它是否出现在最终jar的BOOT-INF/lib目录里:
unzip -l app.jar | grep 'common-util'如果依赖树里有但jar里没有,基本就是scope配置问题。把pom里对应的<scope>provided</scope>改成默认的compile,重新打包即可。
还有一种情况是依赖冲突:应用里有两个版本的同一个类,编译时不报错,运行时按classpath顺序加载了旧版类。这种问题需要用mvn dependency:tree -Dverbose查看重复依赖,并通过pom里的<exclusions>排除不需要的版本。
5.3 端口冲突、进程残留与启动缓慢
微服务部署中最常见的问题是端口被占用。启动报Port already in use: 8080时,先查是哪个进程占用了端口:
lsof -i:8080如果是Java进程,而且看起来像是上一个没关干净的服务实例,慎用kill -9,先找进程的启动命令确认不会误杀,再优雅停止。
启动缓慢这个问题,往往被忽视。微服务启动慢不完全是性能问题,很多时候是配置导致的。比如某个外网地址不通,服务启动时同步调用外部组件超时,一等就是几十秒。手动部署时如果启动日志停在某个位置长时间不动,优先看是不是在请求外部的注册中心或配置中心,检查网络连通性:
telnet 127.0.0.1 8848如果目标地址不可达,要么改配置,要么等本地缓存生效。遇到这种问题,最有效的办法是把启动日志级别打开,让框架输出更详细的信息:
java -jar app.jar --logging.level.root=DEBUG注意这个参数只在排查时用,正常启动不要开DEBUG,否则日志量爆炸,反而更难定位。
5.4 包体过大与瘦身策略
微服务jar包动辄上百兆,手动拷贝到隔离环境的时候非常磨人。有几个实用的瘦身方案:
- 排除不需要的日志框架依赖,统一日志实现。
- 排查重复依赖,去掉版本冲突带来的冗余。
- 将第三方jar外置,再用启动参数指定loader路径。比如用
-Dloader.path=/opt/libs,可以把依赖放到jar外部,jar本身只保留业务代码,体积大幅缩小。
我在一次现场交付中试过外置依赖方案,jar从200多兆缩小到十几兆,拷贝速度快了非常多。代价是启动命令变长,且所有运行机器上的外部依赖目录必须一致。这个取舍按实际场景决定,不是任何时候都适合。
6. 手动打包的完整落地顺序
讲了这么多原理和命令,最后整理一份可以直接参照的完整操作序列。这是我目前用手动方式交付微服务项目的标准流程:
- 确认环境:检查JDK、Maven版本,验证
JAVA_HOME。 - 清理本地仓库缓存(可选,但干净环境建议执行
rm -rf ~/.m2/repository,再重新拉依赖,避免脏jar干扰)。 - 在根目录执行
mvn clean install -Dmaven.test.skip=true,全量构建并安装到本地仓库。 - 如果只需要某个服务,执行
mvn clean package -pl order-service -am -Dmaven.test.skip=true。 - 查看每个子模块target目录下的jar,确认是Spring Boot可执行jar。
- 在本地启动验证,检查关键配置和注册中心连接。
- 拷贝jar和外部配置文件到目标服务器,按需修改外部配置。
- 用
nohup java -jar启动,查看日志确认启动完成。 - 执行健康检查接口,确认服务注册成功并往外提供服务。
这套流程看着简单,但每步都有细节。我在第3步用过package而不是install,结果后续模块构建全部失败;在第7步漏拷贝外部配置,服务启动后连不上数据库;在第6步跳过本地验证,结果到现场赔进去一整天。
7. 写在最后的一点体会
手动打jar包这件事,在CI/CD工具大行其道的今天,看起来有点"落后",但它恰恰是理解Java服务构建链路的最好方式。当你亲眼看着一个多模块项目沿着依赖关系依次编译、测试、打包,再看到最终那个几十兆的jar在空荡荡的服务器上成功启动,你对"MyBatis-Plus为什么打不进去""为什么Nacos的配置没生效""为什么换了台机器就报Port already in use"这些问题的理解,会一下子通透很多。
我个人现在即使在有流水线工具的环境下,也会保持每月至少手动打包一次的习惯。不是为了重复劳动,而是为了让自己始终清楚构建过程中每一步发生了什么。工具能省时间,但不能替你理解问题。这套手动打包的经验,至少在需要临时搭环境、排查依赖疑难杂症、甚至帮别人解决"本地能跑服务器上不行"这类问题时,都不会白费。