Spring Boot打包部署实战:从JAR/WAR选型到全流程配置

发布时间:2026/10/19 7:43:47
Spring Boot打包部署实战:从JAR/WAR选型到全流程配置
1. 打包方案选型为什么 Spring Boot 首选 JAR 而非 WAR1.1 JAR / WAR 的真实差异做过 Java Web 开发的人学生时代基本都经历过把项目打成 WAR 丢进 Tomcat 的 webapps 目录。到了 Spring Boot 时代官方主推的部署方式变成了 java -jar 直接跑很多刚从传统 Servlet 项目转过来的同学第一反应是不适应不用装 Tomcat 了依赖全塞进一个文件里启动的时候不会出问题吗先说结论在绝大多数应用场景下Spring Boot 项目打成可执行 JAR 是目前最合理的部署方式。JARJava Archive和 WARWeb Application Archive本质上都只是 ZIP 格式的压缩包区别在于里面的内容和启动方式。WAR 需要放到外部 Servlet 容器里由容器负责加载和启动而 Spring Boot 的可执行 JAR 自带了一个内嵌的 Web 服务器默认是 Tomcat也可以换成 Jetty 或 Undertow项目启动时由 JAR 内部定义的启动器来引导应用不需要外部容器的参与。对比维度可执行 JAR传统 WAR对外部容器依赖无内置服务器依赖外部 Tomcat/Jetty部署步骤上传运行单条命令上传配置容器启动容器多实例部署天然支持起多个进程即可需要容器多实例或虚拟主机配置环境迁移拷贝文件即可需要考虑容器版本、配置同步运维复杂度相对低适合自动化发布相对高适合传统中间件团队一个小型 Spring Boot 项目打出来的 JAR 通常在 50MB 到 200MB 之间看起来比 WAR 大不少但这是把内置 Tomcat 和所有三方依赖都收进去的代价。你想想看传统 WAR 部署是不是也要在目标机器上装一个对应版本的 Tomcat那份重量只是从项目包里转移到了服务器环境里而已。1.2 内置服务器与 Fat JAR 的构建原理Spring Boot 项目默认使用 spring-boot-maven-plugin 的 repackage 目标来生成可执行 JAR。这里有个容易忽略的点如果直接执行 mvn package在没有这个插件的情况下也可以打出 JAR但这个 JAR 只包含项目自身的 class 和资源文件没有把依赖打进去运行时会直接报 ClassNotFoundException 或者 NoClassDefFoundError。只有经过 repackage 处理后才会生成真正的 Fat JAR也常被称作 uber JAR。Fat JAR 的内部结构大致是这样的demo-app.jar ├── META-INF │ ├── MANIFEST.MF │ └── ... ├── BOOT-INF │ ├── classes # 项目编译后的 class 和 resources │ └── lib # 所有第三方依赖 jar └── org └── springframework └── boot └── loader # Spring Boot 内置的 JarLauncher打开 MANIFEST.MF你会看到两个关键属性Main-Class 指向 org.springframework.boot.loader.JarLauncherStart-Class 指向我们自己项目里带 main 方法的那个类。这里不直接把项目的主类作为 Main-Class是因为 Fat JAR 里嵌了那么多依赖普通 java -jar 的类加载机制没法正确处理嵌套 JAR 的加载。JarLauncher 会按照 BOOT-INF/lib 的结构去加载依赖然后反射调用 Start-Class 指定的入口类从而把业务项目启动起来。这个设计最大的好处是JAR 文件本身是自包含的。不管目标机器上装没装 Tomcat也无论对方用的什么 Java 版本在兼容范围内的只要有个 JDK/JRE 就能跑起来。1.3 什么时候才需要放弃 JAR话说回来JAR 不是银弹。如果你的项目处于以下环境还是得回到 WAR 方案公司运维体系强制要求在传统中间件里运行监控、告警、日志收集都是绑定容器的历史遗留系统里存在只能在 Servlet 容器内初始化的旧组件团队对 Java EE 依赖很深用了不少需要容器提供的服务。在这些场景下可以在 pom.xml 里声明 war 同时把 spring-boot-starter-tomcat 的依赖前后改成 provided 这样打出来的 WAR 既能依赖外部 Tomcat 运行也能在需要的时候直接执行 main 方法。但如果不是被硬性条件卡住我还是建议默认走 JAR 路线省掉的中间件维护成本不是一点半点。2. 打包前的关键配置构建插件、资源与多环境处理2.1 Maven 工程的核心配置与版本匹配绝大多数新项目是基于 Maven 构建的所以先重点看 Maven 这边的配置。Spring Boot 项目通常会在 pom.xml 里继承 spring-boot-starter-parent它的作用不只是省了几行 dependencyManagement 配置更关键的是帮我们把 Spring Boot 相关依赖的版本统一锁好避免自己手动指定版本号时出现依赖冲突或 API 不兼容的问题。parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version relativePath/ /parent properties java.version1.8/java.version project.build.sourceEncodingUTF-8/project.build.sourceEncoding /properties注意 java.version 这个属性。Maven 编译器插件会根据这个值来决定源码编译的字节码版本也就是 .class 文件的目标 JVM 版本。如果你本地用的 JDK 17但生产服务器上的 Java 版本只有 8这里最好显式指定 1.8否则打出来的 JAR 在服务器上会报 UnsupportedClassVersionError。这个错误在部署时非常常见多数是本地开发环境和部署环境 JDK 版本不一致导致的。决定 Java 版本还要考虑 Spring Boot 版本本身的约束。比如 Spring Boot 3.x 系列要求 Java 17 起步没法降级到 8。如果项目还想跑在 Java 8 上就不能把 Spring Boot 升到 3.x得停留在 2.7.x 分支。这一点在项目选型或者升级的时候就要想清楚不是只改一个 java.version 属性那么简单。2.2 spring-boot-maven-plugin 的细节repackage、mainClass 与 excludesSpring Boot 项目的核心构建插件是 spring-boot-maven-plugin它的关键配置项值得逐一看一遍。build finalNamedemo-app/finalName plugins plugin groupIdorg.springframework.boot/groupId artifactIdspring-boot-maven-plugin/artifactId configuration mainClasscom.example.demo.DemoApplication/mainClass layoutZIP/layout excludes exclude groupIdorg.projectlombok/groupId artifactIdlombok/artifactId /exclude /excludes /configuration executions execution goals goalrepackage/goal /goals /execution /executions /plugin /plugins /buildmainClass 的配置不是必需的因为当项目中只有一个 main 方法时插件可以自动检测。但如果你在测试目录里也放了一个带 main 方法的类或者项目里并存了多个启动入口比如管理工具、批量任务入口插件就可能猜错或者干脆在 repackage 时报错。提前显式指定可以避免这种不确定。excludes 的作用是把某些依赖排除在 Fat JAR 之外。典型的场景是 Lombok它在编译期起作用运行期根本不需要打进 JAR 里只会白白增加体积。虽然这个排除不是强制的但干净体积的 JAR 对上传部署更友好。还有一个值得提的小技巧如果项目里有多个模块或者某些依赖本身也是 Spring Boot 可执行 JAR那么 repackage 时可能遇到嵌套可执行 JAR的识别问题。常规做法是给这些模块的插件配置里加上 参数让它们打出一个带有 classifier 的普通 JAR 作为依赖同时保留可执行版本用于部署。这个在实际多模块项目中很容易踩到先记住有这回事。2.3 多环境 profile 下的打包策略一个 Java 项目通常要面对开发、测试、生产等多套环境Spring Boot 本身提供了强大的 profile 机制。常见做法是在 resources 目录里放多个配置文件application.yml application-dev.yml application-test.yml application-prod.ymlapplication.yml 里只留公共配置各环境的差异放在对应文件里。启动时通过 spring.profiles.active 指定使用哪个环境。这里最大的坑在于很多人习惯在打包时就决定环境于是给 Maven 配置搞资源过滤试图在构建时把 application.yml 里的 profile.active 占位符替换成 prod。这个方案不是不行但很容易和 Spring Boot 自身的占位符处理机制冲突导致配置被意外替换或者打包后启动报错。我更推荐的方案是构建时不做任何 profile 过滤JAR 里同时保留所有环境的配置文件在启动阶段才决定激活哪个 profile。因为配置文件本身不敏感泄露到测试机、生产机上面的只是内容不是运行环境。这样同一份 JAR 可以在不同环境复用发布流程更加统一。指定 profile 的方式也很灵活可以 java -jar app.jar --spring.profiles.activeprod也可以设置环境变量 SPRING_PROFILES_ACTIVEprod还可以在 application.yml 里写默认值。2.4 Gradle 用户的等价配置如果项目是用 Gradle 构建的Spring Boot 插件的等价配置如下plugins { id org.springframework.boot version 2.7.18 } bootJar { mainClass com.example.demo.DemoApplication archiveFileName demo-app.jar }Gradle 8 之后bootJar 任务默认生成可执行 JAR。它的内部结构与 Maven 插件生成的一致同样采用 BOOT-INF 布局。Gradle 的排除依赖写法也类似bootJar { excludes { exclude group: org.projectlombok, module: lombok } }大多数团队用 Maven 更多但 Gradle 的思路基本是相通的核心还是理解 Fat JAR 的组织方式和运行时入口机制。3. 实战全过程构建命令、产物结构、本地验证与服务器部署3.1 一条标准构建命令及其执行细节构建前先把目标目录清理掉避免老的 class 文件残留导致结果不可预期。最常用的构建命令是mvn clean package -DskipTestsclean 会删除 target 目录把项目恢复到干净状态。package 阶段会把项目编译、测试、打包成 JAR。-DskipTests 的含义是跳过测试的执行但测试代码仍然会被编译。如果你连测试代码都不想编译可以使用 -Dmaven.test.skiptrue这个参数会直接跳过测试代码的编译和运行构建速度更快一些但代价是如果测试代码里有编译错误也不会暴露出来。构建过程正常结束后控制台会出现 BUILD SUCCESS并在 target 目录下生成 xxx.jar 文件。这里提醒一下Maven 构建过程中Spring Boot 的 repackage 目标会在原 JAR 基础上做增强最终生成的可执行 JAR 文件大小比普通的 JAR 大很多这一点不用惊讶。构建命令中还可以叠加 Maven Profile。比如 mvn clean package -DskipTests -Pprod 会同时激活 Maven 侧的 prod profile如果 pom.xml 里配置了针对该 profile 的构建行为如资源替换、额外插件就会生效。不过如前所述运行时 profile 我更推荐放在启动参数里而不是绑定在构建阶段。3.2 看懂 JAR 内部结构打包完成后第一件事是对产物做个检查不要直接丢到服务器上。查看 JAR 内部结构可以用jar tf demo-app.jar或者unzip -l demo-app.jar输出内容里你会看到 BOOT-INF/lib 下面密密麻麻的依赖文件这就是 Fat JAR 体积大的原因。BOOT-INF/classes 里面则是你自己项目的所有类文件、配置文件、静态资源。META-INF/MANIFEST.MF 则记录了启动入口。如果你发现打出来的 JAR 里没有 BOOT-INF 目录说明 spring-boot-maven-plugin 的 repackage 没有生效这时候 java -jar 会报错因为普通 JAR 没有指定可执行的 Main-Class。产物体积虽然没有硬性规范但如果 JAR 大到几百 MB 还不正常可以查查 lib 里面是不是打进了重复的依赖。有些情况下Maven 会把不同版本的同名依赖全部解析进来导致打包体积异常。3.3 本地运行与参数覆盖拿到 JAR 之后先在本地跑一遍确认没问题再去碰服务器。本地启动的命令非常简单java -jar demo-app.jar默认情况下它会读取 JAR 内部的 application.yml并按默认 profile 启动。如果需要临时覆盖端口java -jar demo-app.jar --server.port9090命令行的 key-value 参数优先级是最高的这样可以不改代码、不改配置就完成端口切换。同样的方式也可以指定配置中心地址、数据库连接等参数只要 Spring Boot 的配置项允许从外部传入。还有一种方式是通过 Java 系统属性传递java -Dserver.port9090 -jar demo-app.jar这两种方式在优先级上略有差异。Spring Boot 的配置加载顺序里命令行参数也就是 --xxx 形式优先级高于 Java 系统属性-Dxxx 形式系统属性又高于 application.yml 里面的内容。实际使用时如果出现了配置不生效的情况多半是没搞清这个优先级顺序。3.4 Linux 服务器后台运行从 nohup 到 systemd本地验证通过后把 JAR 上传到服务器。初学阶段最直接的后台运行方式是用 nohupnohup java -jar demo-app.jar --spring.profiles.activeprod app.log 21 这里 app.log 是把标准输出重定向到日志文件21 是把错误输出也合并到同一个文件里 表示放入后台执行。nohup 的作用是让进程在 shell 退出后继续存活避免因终端断开导致进程被杀。注意一个细节如果用 nohup 启动后发现日志文件里没有内容服务也没起来可以先去掉 和重定向直接在前台跑一遍把启动日志完整地打印出来看。JVM 进程的启动比较重很多问题在启动阶段就会暴露出来。想查看进程是否存在可以使用ps -ef | grep demo-app杀掉进程用 kill 命令不到万不得已不要用 kill -9。先让 JVM 有机会执行优雅停机逻辑比如释放资源、等待存量请求处理完成。不过 nohup 方式管理起来还是太粗糙进程崩溃后不会自动重启服务器重启后也需要手动拉起来。更推荐用 systemd 托管服务。在 /etc/systemd/system/ 下新建一个 demo-app.service 文件内容大致如下[Unit] DescriptionDemo Spring Boot Application Afternetwork.target mysqld.service [Service] Userdeploy WorkingDirectory/opt/app/demo EnvironmentJAVA_HOME/usr/local/java/jdk8 EnvironmentSPRING_PROFILES_ACTIVEprod ExecStart/usr/local/java/jdk8/bin/java -Xms512m -Xmx1024m -jar /opt/app/demo/demo-app.jar ExecStop/bin/kill -s TERM $MAINPID SuccessExitStatus143 Restarton-failure RestartSec10 [Install] WantedBymulti-user.target写好后依次执行systemctl daemon-reload systemctl enable demo-app systemctl start demo-appRestarton-failure 的意思是非正常退出时自动拉起进程RestartSec 是重启前的等待秒数。SuccessExitStatus143 是给 Spring Boot 应用优雅停机用的因为 systemd 发送 SIGTERM 后JVM 退出码就是 143如果不声明这个状态systemd 会认为是错误退出并尝试重启有时候反而造成“杀不掉”的假象。日志查看可以通过 journalctl -u demo-app -f 实时跟踪也可以重定向到指定文件里统一由日志平台收集。4. 常见问题排查与避坑实录4.1 常见错误速查表把这些年遇到的高频问题整理一张速查表按错误现象、根因、处理方式三列列出实际排查时可以直接对照。错误现象根因处理方式UnsupportedClassVersionError编译版本的 JDK 高于运行环境的 JDK统一两边 JDK 版本或降低编译目标版本no main manifest attributerepackage 未生效JAR 不是 Fat JAR检查 spring-boot-maven-plugin 配置ClassNotFoundException / NoClassDefFoundError依赖未正确打入或依赖冲突导致类加载失败检查 BOOT-INF/lib排查依赖树 mvn dependency:tree端口被占用Port already in use应用重复启动或端口被其他服务占用netstat -tlnp / lsof -i:port杀旧进程或改端口配置未生效参数优先级或 profile 设置不对核对命令行参数、环境变量、配置文件三者的优先级启动缓慢日志卡住JVM 配置不当、数据库/注册中心连接超时调整 JVM 堆参数排查外部依赖连接情况JAR 包无法执行双击无反应服务器上没有 java 命令或 JAVA_HOME 未设置安装 JDK/JRE 并配置 PATH4.2 配置文件外置的正确姿势生产环境中经常需要在不重新打包的前提下修改配置。Spring Boot 提供了一个非常实用的约定JAR 包所在目录下如果存在 config/ 子目录那么这个目录下的 application.yml 优先级高于 JAR 包内部同名文件。如果你把配置文件放在 JAR 的同级目录也优先于内部配置。比如说工作目录是 /opt/app/demo里面除了 demo-app.jar还有一个 config/application.yml那么用 systemd 启动时会自动加载 config 目录下的配置。这个特性非常适合需要现场微调的环境比如数据库密码临时调整、某个功能开关切换不用重新构建发布改完配置重启服务就行。除了 application.yml外置配置还可以通过启动参数指定java -jar demo-app.jar --spring.config.location/etc/demo/application.yml注意路径写法指向文件时用 file:/etc/demo/application.yml 更稳妥。如果路径写错启动时会直接报找不到配置文件。实际操作中如果 config/ 目录和 --spring.config.location 同时使用后者的优先级更高这是配置加载顺序决定的。4.3 日志、时区等“小问题”一些小问题往往在部署后才会暴露。第一个是中文乱码。有些服务器系统默认字符集不是 UTF-8应用日志里的中文可能变成乱码。稳妥做法是在启动参数里强制指定java -Dfile.encodingUTF-8 -jar demo-app.jar第二个是时区问题。数据库里存的时间比当前实际时间少 8 小时大概率是 JVM 默认时区不对。在启动脚本中加上java -Duser.timezoneAsia/Shanghai -jar demo-app.jar或者通过环境变量 TZAsia/Shanghai 来设置。第三个是应用日志文件的路径。无论用 logback 还是 log4j2日志文件路径建议使用绝对路径不要依赖相对路径。因为 systemd 启动时的工作目录由 WorkingDirectory 指定如果日志配置用了相对路径一旦工作目录变了日志就会散落在不同的地方排查起来很头痛。4.4 我实际踩过的几个坑有一次部署本地构建没有任何问题上传到测试环境后启动直接 NoSuchMethodError。查了半天发现本地用的 JDK 17 编译服务器 JDK 8 运行。这类错误不像 UnsupportedClassVersionError 那么直白而是发生在某些类库调用 Java 高版本 API 的时候非常容易误判成依赖冲突。从那以后我每次打包前都会确认一下构建机的 java -version 和运行时环境是否一致并在 pom 里固定 java.version 属性。还有一次是改了数据库密码运维直接在服务器上改 config/application.yml结果重启后应用连的还是旧库。排查后发现 config/ 目录没在 JAR 所在目录下而是放在了 systemd 配置的 WorkingDirectory 里目录层级错位导致 Spring Boot 压根没扫描到外置配置文件。教训是外置配置目录的位置必须确认正确启动时打开日志看配置加载路径比猜更高效。最近一次踩坑是 JVM 内存参数没调默认启动时堆内存拉满服务器本身内存紧张导致系统频繁换页服务一直处于半死不活的状态。加了 -Xms512m -Xmx512m 之后应用内存收敛到可控范围内稳定性立马上来了。部署不是 java -jar 这五个字母内存参数、日志策略、进程托管方式都是运行质量的一部分。我个人体会是Spring Boot 项目打包部署看起来简单真正可靠地跑起来靠的是对构建产物结构的理解和对运行环境细节的把控。把 JAR 的组成、启动器的工作方式、配置加载顺序这三块吃透再遇到部署问题就不会抱着日志瞎猜而是能顺着系统的设计逻辑一步步定位到根因。