☰
Spring Cloud多模块微服务项目war包部署Tomcat完整指南
2026/10/14 1:37:11 网站建设 项目流程

我之前接手过一个Spring Cloud多模块微服务项目,平时在开发环境用jar包启动,IDEA里点一下就跑起来了,自测联调都很顺利。结果到了预上线阶段,运维扔给我一句话:所有服务必须打成war包,部署到统一Tomcat里。我当时是真的愁——Spring Boot默认不就是jar包吗?微服务不就应该独立起进程吗?但客户现场就是这么要求的,再不合理也得想办法落地。我硬着头皮研究了两天,踩了无数坑,终于把SpringCloud微服务多模块打war包部署到tomcat这件事完整跑通了。这篇就把整个过程写下来,包括pom怎么改、启动类怎么处理、多模块依赖怎么打包,以及部署后容易踩的端口、注册中心、日志这些坑,给正在为同样问题头疼的同学一份可以直接抄的作业。

1. 项目背景:为什么好好的jar不用,非要打成war

1.1 这种需求在真实项目中很常见

很多开发者觉得Spring Boot天生就是打jar包,环境里装个JDK直接java -jar就行,为什么还要退回war包放到Tomcat?我在项目里遇到的真实原因其实很简单:公司现有的运维体系,包括监控、日志采集、发布工具、端口管理,全都是围绕Tomcat建的。某人说得很直白:“你可以跑jar,但出了问题自己解决,监控和日志我们不看。”在这种情况下,强上jar部署会有很大的落地阻力。

还有一种情况是历史包袱。项目里有些老模块本来就是war包部署在同一个Tomcat上,新拆出来的Spring Cloud服务如果跑在旁边,运维同学需要维护两套方式:一套是Tomcat,一套是命令行进程。为了统一,他们宁愿所有新服务也打成war。说白了这是一个“非技术正确,但项目现实”的选择。所以如果你也碰到这种需求,别觉得奇怪,先把活儿干好更重要。

1.2 多模块项目打war的难点到底在哪

Spring Cloud微服务通常不是一个单独的Spring Boot应用,而是一个Maven多模块工程。常见结构是:一个父pom,下面挂着若干公共模块和业务服务模块。比如common模块放通用类、工具类、DTO,service-a和service-b是真正的微服务,它们都依赖common。这样多模块结构打war时,问题就来了:war包必须把common的代码和依赖一起打进去,否则部署到Tomcat里就会出现NoClassDefFoundError。

另外,Spring Cloud服务还要注册到注册中心。如果你有好几个服务,而且都要放在同一个Tomcat实例里,服务之间的调用URL会涉及端口和contextPath的匹配。这些细节单测和本地jar包启动时完全不会被发现,只有真正部署到Tomcat后才会炸出来。所以,表面上是“把jar改成war”,实际上是“改构建方式、改启动方式、改注册方式、改日志路径”一连串问题。这篇文章的核心就是把这些环节一一讲透。

2. 打包前的工程梳理与依赖结构

2.1 先看清你的Maven模块关系

动手改pom之前,我强烈建议你先在父工程目录下执行一遍:

mvn dependency:tree

这个命令会把当前模块的依赖树完整列出来。多模块工程里最常见的依赖关系是这样的:

parent-project (pom) ├── common (jar) ├── service-a (spring boot, 最终war) └── service-b (spring boot, 最终war)

父工程只是用来统一管理依赖版本,打包类型是pom。common模块会被打成jar,作为服务模块的依赖被引用。真正需要打成war的,是service-a、service-b这些包含main方法、带@SpringBootApplication的模块。

搞清楚这个关系非常重要,因为打包时要保证common模块先被安装到本地仓库,或者用Maven参数同时构建它。否则你单独跑到service-a目录下执行mvn package,Maven去本地仓库找common的jar,如果找不到,直接报错:

Could not resolve dependencies for project com.example:service-a:war:1.0-SNAPSHOT

这个坑在第一次尝试打war时特别容易出现。

2.2 Spring Boot版本决定了能用的Tomcat版本

Spring Boot 2.x和Spring Boot 3.x在Servlet这块有本质区别。Spring Boot 2.x使用的是javax.servlet包,外部Tomcat用8.5或9.0都能跑;Spring Boot 3.x换成了jakarta.servlet包,必须用Tomcat 10及以上,否则启动时直接报NoClassDefFoundError: jakarta/servlet/Servlet。我就是因为这个原因被卡了一晚上,最后排查到现场Tomcat版本还是8.5,但项目是Spring Boot 3.2,两个根本不兼容。

所以动手之前,先确认两件事:

  • 项目里的Spring Boot版本是2.x还是3.x
  • 目标环境里的Tomcat版本是多少

如果是Spring Boot 2.x + Tomcat 8.5/9.0,问题不大;如果是Spring Boot 3.x,必须确保Tomcat版本是10以上。另外,如果项目里用了javax的第三方依赖,即使Spring Boot 3.x也会面临迁移问题。这个在打war之前就要先过一遍,否则改完pom才发现底层版本不对,等于白改。

2.3 打包插件的本质:不是所有war都能直接部署

这里要先搞清楚一个概念:Spring Boot项目打的war,和传统Web项目的war并不是一回事。传统war结构很简单,就是WEB-INF/classes、WEB-INF/lib、META-INF等目录;Spring Boot在war基础上又增加了一层“可执行war”的结构。它靠Spring Boot Maven插件的repackage功能,把应用类和所有依赖重新整理,生成一个既能java -jar直接运行,也能丢到外部Tomcat里启动的war包。

如果项目里没有配置spring-boot-maven-plugin,那你打出来的war就是普通war,里面可能只有自己的业务代码,依赖jar一个都没进去。部署到Tomcat后能解压,但一启动就报ClassNotFoundException,因为Tomcat在WEB-INF/lib里找不到Spring相关jar。

判断war是否打包成功的方法很简单,打完包后执行:

unzip -l service-a.war | grep "WEB-INF/lib" | head

如果WEB-INF/lib下面有几十个jar文件,说明依赖已经被打进去了。如果只有一两个文件甚至为空,那基本就是没做repackage。

3. 一步步改造:从jar到war的完整实操

3.1 修改服务模块的packaging并排除内嵌Tomcat

改造的第一步,是把服务模块的packaging从默认的jar改成war。在service-a模块的pom.xml里加上:

<packaging>war</packaging>

然后处理内嵌Tomcat。Spring Boot的spring-boot-starter-web默认带了一个内嵌Tomcat,这个Tomcat在jar方式启动时很舒服,但放到外部Tomcat里就成了麻烦。两个Tomcat同时存在,会抢端口、加载重复类,启动时各种报错。

所以需要在spring-boot-starter-web里把内嵌Tomcat排除掉,再单独引入一份设置为provided作用域的Tomcat依赖,让它在打包时进到一个特殊目录,不参与外部Tomcat的运行。配置如下:

<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而不做排除?因为Maven依赖的传递性规则里,spring-boot-starter-web原本传递过来的spring-boot-starter-tomcat是compile作用域。就算你另外声明了provided作用域的同名依赖,Maven在默认情况下还是会保留compile的那份,两份都进war包。这会导致repackage后,内嵌Tomcat依然落在WEB-INF/lib,外部Tomcat启动时就会加载到内嵌容器,最后报端口冲突或者LifecycleException。

再配置Spring Boot插件:

<build> <finalName>service-a</finalName> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build>

finalName我故意去掉了版本号,目的是让war包名干净。因为Tomcat默认会用war包名作为访问路径的contextPath,比如service-a.war对应http://ip:8080/service-a。如果war包叫service-a-1.0-SNAPSHOT.war,访问路径也要跟着带上版本号,配置Nginx和网关时特别难受。统一成不带版本号的名字,后面会省很多事。

3.2 继承SpringBootServletInitializer,改启动类

这是很多人最容易漏掉的一步。Spring Boot应用打包成jar时,通过main方法跑起来就行;但部署到外部Tomcat后,Tomcat不会去执行你的main方法,它要通过Servlet容器初始化机制来启动Spring上下文。想要让Tomcat认识你的Spring Boot应用,启动类必须继承SpringBootServletInitializer并重写configure方法。

改造后的启动类长这样:

@SpringBootApplication public class ServiceAApplication extends SpringBootServletInitializer { @Override protected SpringApplicationBuilder configure(SpringApplicationBuilder application) { return application.sources(ServiceAApplication.class); } public static void main(String[] args) { SpringApplication.run(ServiceAApplication.class, args); } }

这里要注意包的路径。Spring Boot 2.x用的是:

import org.springframework.boot.web.servlet.support.SpringBootServletInitializer;

Spring Boot 3.x也一样,这个类从2.x开始就在这个包下,所以版本兼容性不用太担心。但如果你是从老项目迁移,网上有些文章会写org.springframework.boot.web.support.SpringBootServletInitializer,那个是Spring Boot 1.x的老路径,用了必报ClassNotFoundException。

还有一个经验:一个war包里只需要有一个继承SpringBootServletInitializer的启动类就够了。如果多个类都继承了它,Tomcat启动时会初始化多个Spring上下文,很可能互相干扰。尤其是多模块项目里,如果common模块里也放了一个带@SpringBootApplication的类,赶紧改掉,不要让公共模块变成可启动模块。

3.3 多模块Maven打包的正确姿势:-pl -am 的妙用

打包命令是这次操作里最大的坑之一。第一次我直接进到service-a目录执行mvn clean package -DskipTests,结果报依赖找不到。原因就是common模块还没有install到本地仓库,Maven在service-a目录下构建时,不会自动去父工程其他模块里找依赖。

正确做法是在父工程根目录执行:

mvn clean package -pl service-a -am -DskipTests

参数拆解一下:

  • -pl表示只构建指定模块
  • -am表示同时构建指定模块依赖的其他模块
  • -DskipTests跳过测试,节省时间

这样Maven会先构建common,再构建service-a,并且service-a打war时能从本地仓库找到common的jar。如果项目里还有多个服务模块要一起打包,比如service-a和service-b,可以这样写:

mvn clean package -pl service-a,service-b -am -DskipTests

打包完成后,war包生成在对应模块的target目录下,比如service-a/target/service-a.war。这个时候建议立刻验证一下war包体积,如果明显偏小,比如只有几十KB,那大概率是漏了spring-boot-maven-plugin。正常的Spring Boot war包因为有依赖,怎么看也有好几十MB。

多模块工程还有一种情况:如果公共模块改了代码,你不想每次都用-am重新构建,那就先执行一次:

mvn clean install -DskipTests

把common安装到本地仓库,之后再单独打包服务模块。

3.4 部署到Tomcat:war包名、contextPath和注册中心地址

部署方式比较老派:把war包复制到Tomcat的webapps目录下,启动Tomcat后会自动解压部署。如果你用的是Tomcat Manager,也可以直接在管理界面里上传,但生产环境一般不给你开Manager权限,所以最常用的还是复制war包。

war包名决定contextPath这一点,我在前面提过。如果你把war命名为service-a.war,部署后应用访问路径就是:

http://ip:8080/service-a

如果命名成ROOT.war,那访问路径就是:

http://ip:8080/

这两个方案在Spring Cloud场景下各有坑。

先说ROOT.war。好处是contextPath干净,注册中心上报的地址很简单,其他服务调用时不用处理路径前缀。坏处很明显:同一个Tomcat只能有一个ROOT。如果你打算把多个微服务全塞进一个Tomcat,让service-a做ROOT,service-b就只能带前缀了。

再说不带版本号的service-a.war。多个服务可以共用一个Tomcat,每个war一个名字,各自有独立的contextPath。这时候要注意,注册中心上报的服务地址默认是不带contextPath的。比如service-b通过Feign调用service-a,它从注册中心拿到的地址可能是http://ip:8080,然后去请求http://ip:8080/xxx,而实际接口在http://ip:8080/service-a/xxx,结果就是404。

这个问题的解决办法我推荐两种:

  • 每个服务独享一个Tomcat实例,war包统一命名为ROOT,从哪里都能规避contextPath问题
  • 如果必须共享Tomcat,在服务配置里显式设置server.servlet.context-path,并确保网关和Feign调用时带上这个前缀

我最终采取的是第一种,每个服务一个Tomcat,war包都叫ROOT。代码不需要改,配置不需要迁就,部署后一切正常。如果你的环境不允许每服务一个Tomcat,那就得花更多时间在路由和注册地址上调试,这个心理准备要有。

3.5 外部Tomcat的额外配置:端口和依赖目录

还有一个小细节是Tomcat的lib目录。传统项目有时候会把部分公共依赖放到$CATALINA_HOME/lib下,让多个应用共享。但Spring Boot项目最好不要这么干,因为不同服务可能依赖不同版本的Spring类库,放在共享目录里会导致类加载冲突。Tomcat的lib目录里只需要保留Servlet API等基础的容器类库,Spring相关依赖都别放进去。

端口方面,Spring Boot应用部署到外部Tomcat后,实际监听端口就是Tomcat的连接器端口,通常是8080。如果你的业务服务配置里server.port写的是8081,那它并不会在8081上开启一个新端口,这个配置只在jar包启动时生效。但注册中心在收集服务地址时,可能会读到这个8081配置,最后其他服务拿着8081去访问,自然连接不上。最好的做法是统一Tomcat端口和server.port,保持一致,避免这种隐性坑。

4. 部署后必查的4个坑:配置、日志、依赖和版本

4.1 启动直接报错的常见错误速查表

我把自己和身边同事踩过的坑整理成了一张表,遇到问题时可以按表排查:

错误现象可能原因解决方案
Port 8080 was already in use内嵌Tomcat没有排除,和外部Tomcat冲突排除starter-web中的tomcat并设置tomcat依赖为provided
LifecycleException: Failed to initialize componentTomcat lib下存在Spring相关jar导致类重复清空Tomcat lib下非Servlet API的jar
No Spring WebApplicationInitializer detected启动类没有继承SpringBootServletInitializer修改启动类,重写configure方法
ClassNotFoundException: org.springframework.web...war包缺少依赖,可能是没有repackage确认spring-boot-maven-plugin已配置,重新打包
Could not resolve dependencies... common:jar公共模块没有install或使用-am构建在父工程目录加-am参数打包
jakarta.servlet相关类找不到Spring Boot 3.x用了Tomcat 8.5换成Tomcat 10及以上

这里面最值得单独说的是第一行。很多人只改了packaging为war,没排除内嵌Tomcat,结果Tomcat启动时报端口占用。我当时第一反应是查宿主机有没有别的进程占用了8080,用netstat查了半天没查到,最后发现是war包里的内嵌Tomcat也尝试启动,两个Tomcat撞在一起。这个问题不看war包内部结构根本发现不了。

4.2 服务注册中心端口对不上,网关路由404

Spring Cloud服务部署到Tomcat后,最典型的故障是:服务能启动、注册中心也能看到服务,但服务间调用就是不通。我遇到过一次,service-b调service-a时报Connection refused。去Nacos控制台一看,service-a实例的端口是8081,而Tomcat实际监听的是8080,显然对不上。

原因就是刚才说的server.port配置问题。Spring Boot在jar包启动时,server.port直接决定监听端口;但在外部Tomcat里,这个端口配置失效。注册中心从Spring上下文里拿到端口可能是配置的8081,也可能是默认的8080,甚至在某些版本里会变成-1,不同版本行为还不一样。

我的处理思路很朴素:让Tomcat端口、配置里的server.port、注册中心的端口三者完全一致。如果项目中所有服务都在同一个Tomcat里,那就共用一个端口,用contextPath区分;如果每个服务独立Tomcat,那就各自用各自的端口,war包名统一为ROOT。这样注册中心拿到的地址就一定是实际可通的地址,不会出现端口对不上的问题。

除了端口,还有一个常被忽略的是健康检查。service-awar包部署后,建议先手动验证一下健康检查接口能不能通。比如:

curl http://localhost:8080/actuator/health

如果这个接口返回UP,再让其他服务去调,基本上就能确定网络链路是通的。如果健康检查都404,那问题大概率还是contextPath配置问题,先把路径理顺。

4.3 公共模块的类找不到,war包体积异常

多模块项目打war时,“类找不到”和“war包体积异常”这两个问题往往是一起出现的。war包只有几十KB,部署后启动报ClassNotFoundException,十有八九是spring-boot-maven-plugin没有生效。

出现这种问题,先回看一下pom的build节点。如果服务模块里连这个插件都没有加,只是加了<packaging>war</packaging>,那Maven只会按普通war的方式来打包,Spring Boot不会执行repackage,依赖根本不会完整打入WEB-INF/lib。

如果插件加了但war体积还是不对,那就检查一下是不是在父pom的插件管理里配置了,但子模块没有执行。Spring Boot多模块工程里,父pom的pluginManagement只能统一版本,不能自动让所有子模块执行repackage,每个需要打成war的服务模块都要显式声明这个插件。

还有一个隐蔽的问题:common模块本身被错误地配置成了war。如果你在common的pom里也写了<packaging>war</packaging>并加了Spring Boot插件,那它打出来的就是一个不能启动的“伪war”。更隐蔽的是,有些公共代码里带@SpringBootApplication,会在服务启动时被扫描到,导致Spring上下文被重复加载。遇到这种情况,把common的packaging改回jar,把启动相关注释和类清理掉,再重新打包。

验证公共模块是否进war,可以执行:

unzip -l service-a.war | grep common

如果能看到common-1.0-SNAPSHOT.jar,说明依赖已经正确打进去了。如果看不到,回头检查多模块打包命令和本地仓库里的common版本。

4.4 日志去哪了:Tomcat里不要按老思维找日志

还有一个特别容易把人绕晕的点:日志文件。jar方式启动时,日志路径会按照你application.yml里的相对路径生成,一般在项目工作目录或者指定的路径下。但war包部署到Tomcat后,工作目录变成了Tomcat的bin目录,如果你配置了相对路径,日志会出现在你完全想不到的地方。

我那次部署后,服务一直显示启动成功,但日志就是找不到。在service-a目录下找了半天,最后发现日志输出到了Tomcat的logs目录下,而且全部和Tomcat日志混在一起。排查起来特别费劲。

我建议在war包部署场景下,直接把日志路径配成绝对路径:

logging: file: path: /data/logs/service-a

这样日志会固定输出到指定目录,不管Tomcat从哪里启动都不会跑偏。如果你仍然想用相对路径,至少要知道它默认落在Tomcat的bin目录下,别按jar包部署的经验去找。另外,Spring Boot应用的启动日志在解压后的logs目录里通常不会单独生成文件,除非你显式配置了logging,否则大部分输出都进了Tomcat的catalina.out。生产环境排查问题时,第一件事先看catalina.out,第二件事再看你配置的日志目录。

5. 验证与收尾:亲测成功的判断标准

5.1 一套可复验的部署检查清单

这次打war部署踩了这么多坑之后,我自己总结了一套检查清单,每次部署新服务都按这个过一遍:

  1. 服务模块pom里packaging是war,spring-boot-starter-tomcat的scope是provided
  2. 启动类继承了SpringBootServletInitializer并重写configure
  3. 父工程根目录用-pl service-a -am打包,而不是单独进子模块目录打包
  4. war包体积正常,WEB-INF/lib下能看到common依赖和其他Spring依赖
  5. Tomcat版本和Spring Boot版本匹配,Spring Boot 3.x用Tomcat 10+
  6. Tomcat端口和server.port保持一致,注册中心显示的端口和实际监听端口一致
  7. war包名符合预期,访问路径和网关路由对得上
  8. 日志路径配置为绝对路径,启动后能立刻找到日志文件
  9. 健康检查接口通过curl验证返回UP,再放流量

这套清单我打印出来贴在工位上,连续几次部署都没有再出过之前那些低级问题。

5.2 我实际踩过的坑和最终体会

如果让我给这次打war部署经历总结一句,那就是:Spring Cloud微服务打成war部署到Tomcat,技术上完全可行,但并没有想象中那么简单。最大的成本不在“改pom”这一步,而在部署环境、注册中心、contextPath、日志路径这些边缘细节。很多时候你以为配置改对了,实际只有部署到目标环境跑起来才知道有没有问题。

我最终采用的方案是每个服务一个Tomcat实例,war包统一命名成ROOT,从根本上绕开了contextPath的坑。如果你所在的环境不允许这样搞,那就要特别注意注册中心上报的地址是否带contextPath,提前把网关路由和Feign路径设计好。另外,如果不是外部强制要求,我个人还是倾向于用jar包方式部署Spring Cloud服务,毕竟那才是Spring Boot的正常打开方式。但万一你躲不掉war部署,按这篇里的步骤走,至少能少踩一大半坑。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询