Spring Boot 项目写完之后,真正让人头疼的往往不是业务代码,而是"怎么把它变成一个能跑起来的东西"。我见过不少团队,本地mvn spring-boot:run跑得飞起,一到部署环节就各种状况:jar 包传上去启动报找不到主类、war 包丢进容器里访问 404、配置文件路径对不上、端口被占用、日志不知道往哪看。这些问题单拎出来都不难,但凑在一起就足够消耗掉一个下午。
这篇内容就围绕 Spring Boot 的两种主流打包方式——可执行 jar和war 包——把打包、部署、发布这条链路完整走一遍。我会说清楚两种方式各自适合什么场景、打包时那些容易忽略的配置细节、部署到不同环境时的操作差异,以及我在实际项目里踩过的坑和总结出来的排查思路。不管你是刚接触 Spring Boot 部署的新手,还是想把手上的发布流程理顺的老手,应该都能从里面找到能直接用的东西。
1. 先想清楚:为什么打包方式的选择值得认真对待
很多人觉得打包就是个mvn package的事,选 jar 还是 war 无非是习惯问题。但实际项目里,这个选择会直接影响你的部署架构、运维成本、甚至后续的扩展方式。我先把两种方式的本质差异讲透,后面操作起来你才知道每一步在干什么。
1.1 可执行 jar 到底"可执行"在哪
Spring Boot 打出来的 jar 和普通 Java jar 最大的区别,在于它把依赖和内置容器一起塞进去了。一个标准的 Spring Boot 可执行 jar 内部结构大致是这样:
BOOT-INF/ classes/ <- 你自己的编译产物 lib/ <- 所有第三方依赖 jar META-INF/ MANIFEST.MF <- 关键:Main-Class 和 Start-Class org/springframework/boot/loader/ <- 启动加载器关键在于MANIFEST.MF里的两个属性:Main-Class指向 Spring Boot 自己的JarLauncher,Start-Class指向你项目真正的启动类。JarLauncher会负责构建一个特殊的类加载器,把BOOT-INF/lib下的依赖和BOOT-INF/classes下的业务类都加载进来,然后再反射调用你的Start-Class。
这个设计带来的直接好处是:目标机器上只要有 JDK(或 JRE),java -jar xxx.jar就能跑,不需要预先装 Tomcat,不需要配CLASSPATH,不需要管依赖冲突。这也是为什么现在绝大多数微服务、云原生应用都选 jar——它天然适合容器化,一个 Dockerfile 里COPY进去就完事。
但要注意一个细节:Spring Boot 的 jar 不是"胖 jar"(fat jar)的简单堆叠,而是用了自定义的嵌套 jar 结构。这意味着你不能用普通的jar -xf解压后重新打包的方式去改内容,否则启动会失败。要改里面的东西,得用jar -xf配合正确的目录结构操作,或者干脆重新构建。
1.2 war 包存在的意义不是"过时"
有人会说,都什么年代了还用 war?这话对一半。war 包的核心价值在于它把应用和容器的生命周期解耦了。你的应用不再自己管理 Tomcat 的启动停止,而是交给外部的 Servlet 容器(比如一个统一部署的 Tomcat 实例)来管。
这种模式在几种场景下依然很香:
- 企业内有多应用共享一个容器:运维统一维护一个 Tomcat,多个 war 丢进
webapps目录,端口、线程池、连接数统一配置,省得每个应用各管一套。 - 需要容器级别的统一治理:比如统一的安全策略、统一的访问日志格式、统一的 JVM 参数调优,这些在外部容器上配一次就够。
- 历史系统迁移过渡期:老系统本来就是 war 部署,新模块用 Spring Boot 写但还得跟老的一起跑在同一个容器里。
代价也很明显:你得自己保证外部容器的版本兼容性,Spring Boot 的内嵌容器特性用不上了,而且 war 包对容器版本的依赖会让"换个环境就跑不起来"的概率变高。所以我的建议很直接:新项目、微服务、要上容器的,一律 jar;只有在明确的共享容器诉求下才选 war。
1.3 一张表看清两种方式的取舍
| 维度 | 可执行 jar | war 包 |
|---|---|---|
| 运行方式 | java -jar直接启动 | 丢进外部 Servlet 容器 |
| 内置容器 | 使用(Tomcat/Jetty/Undertow) | 不使用,依赖外部容器 |
| 部署依赖 | 只需 JDK | 需匹配版本的容器 |
| 容器化适配 | 极好,天然适合 Docker | 较差,需在镜像里装容器 |
| 多应用共享 | 不支持,一进程一应用 | 支持,一容器多应用 |
| 配置管理 | 应用内配置为主 | 容器配置 + 应用配置 |
| 启动类要求 | 标准main方法 | 需继承SpringBootServletInitializer |
| 典型场景 | 微服务、云原生、独立应用 | 传统企业部署、共享容器 |
这张表建议存下来,下次团队讨论部署方案时直接拿出来对照,比空口争论高效得多。
2. jar 方式:从 pom 配置到 java -jar 跑起来
选定了 jar,接下来就是把这条链路走通。这里面的坑主要集中在 pom 配置和启动参数上,我按实际操作顺序拆开讲。
2.1 pom 里那个 spring-boot-maven-plugin 到底做了什么
Spring Boot 项目默认的spring-boot-starter-parent已经帮你配好了打包插件,但很多人只是"知道有这么个东西",不清楚它具体干了啥。核心是这个插件在package阶段执行repackage目标,把普通的 jar 重新打成可执行 jar。
一个最小可用的配置长这样:
<build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <configuration> <mainClass>com.example.demo.DemoApplication</mainClass> </configuration> </plugin> </plugins> </build>mainClass这一项在只有一个启动类时其实可以省略,插件会自动找带@SpringBootApplication的类。但我强烈建议显式写上,原因有两个:一是多模块项目里自动探测偶尔会找错,二是显式声明让后来接手的人一眼就知道入口在哪,省去翻代码的时间。
还有一个常被忽略的点:如果你项目里同时有spring-boot-maven-plugin和其他打包插件(比如某些代码混淆、依赖分析插件),执行顺序会影响结果。repackage必须在普通jar打包之后执行,否则打出来的还是普通 jar。默认绑定是没问题的,但如果你手动调整过phase,一定要检查这个顺序。
2.2 打包命令与产物验证
配置好了,打包命令本身很简单:
mvn clean package -DskipTests-DskipTests跳过测试执行但仍然编译测试代码,如果你连测试代码都不想编译,用-Dmaven.test.skip=true。生产构建我一般用前者,保证测试代码至少能编译通过,避免提交了编译不过的测试还浑然不知。
打完包后,产物在target/目录下,通常会有两个 jar:xxx.jar和xxx.jar.original。.original是 repackage 之前的原始 jar,不能直接运行,真正能跑的是那个没有后缀的。这个细节坑过不少人——有人误把.original传到服务器上,java -jar一执行就报"no main manifest attribute"。
验证产物是否合格,我习惯做两步:
# 第一步:看 MANIFEST 里有没有 Start-Class unzip -p target/demo-0.0.1-SNAPSHOT.jar META-INF/MANIFEST.MF # 第二步:本地直接跑一下,确认能起来 java -jar target/demo-0.0.1-SNAPSHOT.jar第一步的输出里应该能看到Start-Class: com.example.demo.DemoApplication和Main-Class: org.springframework.boot.loader.JarLauncher。如果Main-Class指向的是你自己的启动类而不是JarLauncher,说明 repackage 没生效,回去检查插件配置。
2.3 启动参数:那些让 jar 在生产环境稳住的关键配置
java -jar能跑起来只是及格线,生产环境要考虑内存、时区、编码、配置文件外置这些事。一条我常用的启动命令长这样:
java -jar \ -Xms512m -Xmx1024m \ -Duser.timezone=Asia/Shanghai \ -Dfile.encoding=UTF-8 \ -Dspring.profiles.active=prod \ -Dspring.config.location=file:/opt/app/config/ \ app.jar逐项说一下为什么这么配:
-Xms和-Xmx:初始堆和最大堆设成一样(或接近),避免运行期频繁扩容带来的抖动。具体数值按应用实际内存占用调,别照抄。-Duser.timezone:容器环境默认时区经常是 UTC,不显式指定的话,日志时间、定时任务时间全对不上,排查问题时能把人逼疯。-Dfile.encoding:防止中文乱码,尤其是日志和文件读写场景。-Dspring.profiles.active:激活对应环境的配置,这个比在 jar 里改application.yml靠谱得多。-Dspring.config.location:把配置文件外置到 jar 外面,改配置不用重新打包,这是生产部署的基本素养。
提示:
spring.config.location用file:前缀指向外部目录时,注意结尾的斜杠不能少,否则会被当成文件路径而不是目录,导致配置加载失败。
关于配置文件外置,还有个更灵活的做法:把application.yml放在 jar 同级目录,Spring Boot 默认会优先加载外部配置。这样连-Dspring.config.location都省了,适合配置不复杂的场景。
2.4 后台运行与优雅停机
生产环境不可能开着终端跑java -jar,得让它后台运行。最朴素的方式是nohup:
nohup java -jar app.jar > app.log 2>&1 &但这方式有个硬伤:停的时候只能kill -9,应用来不及做优雅停机,正在处理的请求直接断掉,数据库连接、线程池都没正常释放。稍微讲究一点的做法是用kill发SIGTERM信号,配合 Spring Boot 的优雅停机配置:
server: shutdown: graceful spring: lifecycle: timeout-per-shutdown-phase: 30s这样kill <pid>(默认发 SIGTERM)时,应用会先停止接收新请求,等正在处理的请求完成(最多 30 秒)再退出。这个配置在 jar 和 war 两种方式下都适用,但 war 方式下最终停机由容器控制,效果取决于容器实现。
再进一步就是用 systemd 或进程管理工具托管,把启动命令、工作目录、重启策略、日志重定向都固化下来。这块内容展开能写一整篇,这里先记住核心:别用kill -9停生产应用,这是最基本的运维纪律。
3. war 方式:改造、打包与容器部署的完整链路
war 方式的改造点比 jar 多,因为你要让应用"交出"容器的控制权。我把改造步骤和部署细节拆开讲,每一步都说清楚为什么。
3.1 三步改造:让 Spring Boot 应用适配外部容器
从 jar 切到 war,代码和配置上要做三处改动,缺一不可。
第一步,改打包类型。在pom.xml里把 packaging 改成 war:
<packaging>war</packaging>第二步,排除内嵌 Tomcat 并标记为 provided。这一步是核心,目的是让内嵌容器在打包时不进 war,运行时由外部容器提供:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> <exclusions> <exclusion> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-tomcat</artifactId> </exclusion> </exclusions> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-tomcat</artifactId> <scope>provided</scope> </dependency>provided的作用是:编译时需要它(代码里可能引用了相关类),但打包时不放进去。这样 war 里就没有内嵌 Tomcat 了,避免和外部容器冲突。
第三步,改造启动类。让启动类继承SpringBootServletInitializer并重写configure方法:
@SpringBootApplication public class DemoApplication extends SpringBootServletInitializer { @Override protected SpringApplicationBuilder configure(SpringApplicationBuilder builder) { return builder.sources(DemoApplication.class); } public static void main(String[] args) { SpringApplication.run(DemoApplication.class, args); } }这个改造的意义在于:外部容器启动时,会通过 Servlet 3.0 的ServletContainerInitializer机制找到SpringBootServletInitializer的子类,调用configure方法来构建 Spring 上下文。main方法保留是为了兼容"还能用java -jar跑"的场景,方便本地开发调试。
注意:改造后如果本地还用
java -jar跑,会因为缺少内嵌 Tomcat 而启动失败。本地开发建议用 IDE 直接跑main方法,或者临时把provided改回默认 scope 验证。
3.2 war 包部署到外部容器的实操细节
打包命令和 jar 一样:
mvn clean package -DskipTests产物是target/xxx.war。部署到 Tomcat 的标准操作是把 war 丢进webapps目录,启动 Tomcat 即可。但这里有几个细节决定成败:
上下文路径问题。war 包默认的访问路径是/<war文件名>,比如demo-0.0.1-SNAPSHOT.war部署后访问路径就是/demo-0.0.1-SNAPSHOT/。这个路径带版本号很难看,通常有两种处理:一是打包时用finalName指定简洁名字:
<build> <finalName>demo</finalName> </build>二是部署时把 war 重命名为ROOT.war,这样应用就部署在根路径/下。两种方式按需选,我一般用finalName,因为重命名 war 会让版本追溯变麻烦。
容器版本兼容性。Spring Boot 不同版本对 Servlet 容器版本有要求,比如 Spring Boot 2.x 需要 Servlet 3.1+,Spring Boot 3.x 需要 Servlet 5.0+(对应 Tomcat 10+)。版本对不上,部署上去就是各种ClassNotFoundException或NoSuchMethodError。部署前务必确认外部容器的版本,这个信息在 Spring Boot 官方文档的"System Requirements"章节能查到。
JVM 参数配置位置变了。jar 方式下 JVM 参数写在启动命令里,war 方式下应用不负责启动 JVM,参数得配在容器的启动脚本里(比如 Tomcat 的bin/setenv.sh)。这一点经常被忽略,导致 war 部署后内存配置没生效。
3.3 外部容器下的配置外置与日志
war 方式下,application.yml的加载逻辑和 jar 基本一致,但有几个位置需要知道:
- war 包内的
WEB-INF/classes/application.yml:打包进去的默认配置。 - 容器工作目录下的
config/子目录:外部配置的常见放置位置。 - 通过
-Dspring.config.location指定:在容器启动脚本里配 JVM 参数。
日志方面,war 方式下应用日志和容器日志会混在一起。建议在logback-spring.xml里显式配置日志输出到独立文件,别依赖容器的catalina.out,否则日志文件会越滚越大且难以按应用切分。
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender"> <file>/opt/logs/demo/app.log</file> <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy"> <fileNamePattern>/opt/logs/demo/app.%d{yyyy-MM-dd}.%i.log</fileNamePattern> <maxFileSize>100MB</maxFileSize> <maxHistory>30</maxHistory> </rollingPolicy> </appender>这段配置的关键是SizeAndTimeBasedRollingPolicy,按天加按大小双维度滚动,maxHistory控制保留天数,避免日志把磁盘撑爆。这个配置在 jar 方式下同样适用,属于通用实践。
4. 发布环节:把"能跑"变成"可控地跑"
打包和部署解决的是"怎么跑起来",发布解决的是"怎么可控、可回滚、可追溯地跑起来"。这块是区分业余和专业的地方,我重点讲几个实操要点。
4.1 发布前的检查清单
每次发布前,我都会过一遍这个清单,能挡掉大部分低级事故:
| 检查项 | 检查方式 | 常见问题 |
|---|---|---|
| 产物类型正确 | 确认是 jar 还是 war,别传错 | 误传.originaljar |
| 配置文件就位 | 检查外部配置目录 | 配置缺失导致启动失败 |
| 端口未被占用 | netstat -tlnp | grep 端口 | 旧进程没停干净 |
| 磁盘空间充足 | df -h | 日志或临时文件撑爆磁盘 |
| 依赖服务可达 | 检查数据库、缓存连通性 | 网络策略变更导致连不上 |
| 回滚包已备份 | 保留上一版本产物 | 出问题无法快速回退 |
这个清单看着简单,但每一条背后都是真实踩过的坑。尤其是"旧进程没停干净"这条,kill之后进程还在优雅停机中,新进程起来抢端口失败,日志里报Address already in use,新手很容易懵。
4.2 滚动发布与停机窗口的取舍
单机部署时,发布必然有停机窗口。要减少影响,核心思路是先停流量再停应用:如果前面有负载均衡或网关,先把这台机器从后端摘掉,等存量请求处理完再停应用、换包、启动、健康检查通过后再挂回流量。
多机部署时就可以做滚动发布:逐台操作,保证任意时刻至少有一台在提供服务。滚动发布的关键是健康检查要真实反映应用状态,别只检查端口通不通,要检查应用是否真正能处理业务请求。Spring Boot Actuator 的/actuator/health端点就是干这个的,配合management.endpoint.health.show-details能看到各组件状态。
management: endpoints: web: exposure: include: health,info endpoint: health: show-details: when-authorized发布脚本里加一步"轮询健康检查直到通过或超时",比固定sleep 30靠谱得多。固定等待要么等太久浪费时间,要么没等够就切流量导致请求失败。
4.3 版本追溯:让每个包都能说清来历
发布出问题时,第一件事是确认"线上跑的到底是哪个版本"。我的做法是在构建时把版本信息打进产物,通过 Actuator 的/actuator/info端点暴露出来:
info: app: name: demo-service version: @project.version@ build-time: @maven.build.timestamp@配合spring-boot-maven-plugin的build-info目标,构建信息会自动生成。这样线上出问题时,访问一下/actuator/info就知道当前版本和构建时间,不用去翻发布记录对时间。这个习惯养成后,排查效率提升非常明显。
5. 踩坑实录:那些让我加班到深夜的部署问题
前面讲的都是"应该怎么做",这一节讲"做错了会怎样"。这些问题我都真实遇到过,把排查链路完整写出来,希望你能少走弯路。
5.1 启动报 "no main manifest attribute" 的完整排查
现象:java -jar app.jar执行后立刻报错退出,提示no main manifest attribute, in app.jar。
排查链路:
- 先确认跑的是不是正确的 jar。
ls -l target/*.jar看有没有.original后缀的文件,如果有,很可能传错了。 - 检查 MANIFEST:
unzip -p app.jar META-INF/MANIFEST.MF,看有没有Main-Class和Start-Class。 - 如果 MANIFEST 里
Main-Class指向的是自己的启动类而非JarLauncher,说明repackage没执行。 - 检查 pom 里
spring-boot-maven-plugin是否配置了executions且phase被改过,或者是否被其他插件覆盖了package阶段。
根因:最常见的是误传.original文件,其次是插件配置被改导致 repackage 未执行。修复方式就是重新mvn clean package并确认产物正确。
5.2 war 部署后 404:上下文路径的连环坑
现象:war 丢进 Tomcat 的webapps,Tomcat 启动日志显示部署成功,但访问预期路径返回 404。
排查链路:
- 看 Tomcat 日志里应用的实际 context path,通常在
Server startup附近有输出。 - 确认 war 文件名和访问路径的对应关系,
demo.war对应/demo/。 - 检查应用内有没有配置
server.servlet.context-path,这个配置在 war 方式下会和容器分配的路径叠加,导致路径翻倍。 - 确认访问的是正确的子路径,比如接口是
/api/hello,完整路径应该是/demo/api/hello。
根因:war 方式下server.servlet.context-path的行为和 jar 方式不同,容易配出双重路径。war 部署时建议不要配context-path,路径完全交给容器管理,避免混淆。
5.3 配置文件加载顺序引发的"本地好好的,线上全错"
现象:本地开发一切正常,部署到服务器后数据库连接、端口等配置全是默认值,明显没读到外部配置。
排查链路:
- 确认外部配置文件的实际路径和文件名,Spring Boot 对文件名有约定(
application.yml或application-{profile}.yml)。 - 检查
-Dspring.config.location的路径结尾有没有斜杠,没有斜杠会被当文件处理。 - 确认
spring.profiles.active是否生效,profile 不对会加载错误的配置文件。 - 用
--debug启动参数看配置加载日志,Spring Boot 会打印实际加载了哪些配置文件。
根因:路径结尾斜杠缺失是最常见的,其次是 profile 没激活。这个问题的隐蔽性在于应用能正常启动(用了默认配置),只是行为不对,不仔细看日志很难发现。
5.4 优雅停机没生效:信号与配置的双重确认
现象:配了server.shutdown: graceful,但kill之后应用还是瞬间退出,正在处理的请求直接失败。
排查链路:
- 确认发的是
SIGTERM(kill <pid>默认就是),不是SIGKILL(kill -9)。SIGKILL无法被捕获,优雅停机必然失效。 - 确认配置真的生效了,可以在启动日志里找
Graceful shutdown相关输出。 - 检查
spring.lifecycle.timeout-per-shutdown-phase是否设得太短,请求还没处理完就超时强制退出了。 - war 方式下确认外部容器是否支持优雅停机,部分老版本容器不支持。
根因:kill -9是最常见的元凶,其次是超时时间配置过短。这个问题的教训是:停机脚本里绝对不要出现kill -9,除非进程已经卡死无响应。
6. 两种方式之外的补充:容器化部署的衔接
现在越来越多项目直接上容器,jar 和 war 在容器化场景下的表现差异很大,这里补充一下衔接要点,算是给发布链路补上最后一环。
6.1 jar 的 Dockerfile 写法要点
jar 方式容器化非常直接,一个典型的多阶段构建 Dockerfile:
FROM maven:3.9-eclipse-temurin-17 AS build WORKDIR /app COPY pom.xml . COPY src ./src RUN mvn clean package -DskipTests FROM eclipse-temurin:17-jre WORKDIR /app COPY --from=build /app/target/*.jar app.jar EXPOSE 8080 ENTRYPOINT ["java", "-jar", "/app/app.jar"]几个要点:基础镜像用 JRE 而非 JDK,体积小很多;ENTRYPOINT用数组形式,保证SIGTERM能正确传递给 Java 进程(用 shell 形式的话信号会被 shell 拦截,优雅停机失效);构建阶段和运行阶段分离,最终镜像里不带 Maven 和源码。
6.2 war 容器化的现实考量
war 容器化意味着镜像里要装一个 Servlet 容器,镜像体积大、启动慢,而且容器编排的健康检查、扩缩容都不如 jar 灵活。除非有强制的共享容器诉求,否则不建议 war 上容器。如果确实要上,建议用官方 Tomcat 镜像,把 war 放到webapps目录,并通过环境变量或挂载卷管理配置。
6.3 发布流程的自动化衔接
无论 jar 还是 war,发布流程自动化的核心是把前面所有手工步骤脚本化:构建、产物校验、传输、备份旧版本、停应用、换包、启动、健康检查、失败回滚。这套脚本写一次,后面每次发布都能省下大量重复劳动,也避免了手工操作的不一致。
我个人的经验是,脚本里一定要有失败即回滚的逻辑:健康检查不通过就自动切回上一版本,别让人在深夜盯着日志手动操作。这个设计在关键时刻能救命。
最后分享一个我踩过好几次才养成的习惯:每次发布前,先在预发环境用完全相同的产物和配置跑一遍,确认无误再上生产。预发和生产的环境差异(尤其是网络策略和依赖服务版本)是很多"线上才出问题"的根源,提前暴露比事后排查划算得多。