ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Spring Boot WAR包部署实战:改造、打包、部署外部Tomcat全攻略

Spring Boot WAR包部署实战:改造、打包、部署外部Tomcat全攻略

“Spring Boot 项目打 jar,内嵌 Tomcat,java -jar 一键启动”,这套开发完直接跑的方式确实省心,但真到了生产环境,你会发现有些团队和客户死活只要 war 包。要么是基础设施早就固定成了外部 Tomcat 统一管理,要么是机房运维不接受多掏一个进程,再要么是历史系统里已经跑着好几个传统 Web 应用,必须把新服务塞进同一个 Servlet 容器。把 Spring Boot 工程改成 war 包并部署到远端 Linux 服务器,这条路看着“复古”,却是很多同学实际工作中绕不开的操作。这篇文章会把改造、打包、上传、部署、启动、排障这一整条链路完整过一遍,你可以直接照着做,关键是知道自己每一步在改什么、为什么这么改。

先说一句实在话:如果条件允许用独立的进程、用容器、用云平台,我不主张你非要用外部 Tomcat 跑 Spring Boot。war 部署本身不难,难的是后面那些和环境相关的边界问题。但既然问题已经摆在这儿了,那就把它彻底搞清楚。

1. 先想清楚:为什么要把 Spring Boot 打成 WAR 包

1.1 JAR 与 WAR 在运行方式上的根本区别

Spring Boot 默认推荐的部署方式是可执行 JAR。JAR 内部把应用类、依赖、内嵌 Tomcat 全部塞在一起,java -jar启动时会调用main方法,自己拉起一个完整的 Servlet 容器,应用和容器的生命周期绑定在同一个进程里。这种方式对单机部署非常友好,环境变量、启动参数都可以通过命令行传入,想迁到哪台机器基本是拷贝即用。

WAR 则是 Java Web 开发的传统交付产物,它本身不能独立运行,必须丢给一个外部 Servlet 容器,比如 Tomcat、Jetty。容器负责监听端口、解析 HTTP 协议、按部署约定去webapps目录下找 WAR 包,再把包解压到临时目录,最终启动应用。Spring Boot 工程要变成这样的 war 包,不是把所有东西塞进去就行,还得让容器能够识别和加载 Spring Boot 上下文,这就是后面要改启动类的根本原因。

1.2 哪些场景下你绕不开 WAR 包

我在实际项目里碰到的 WAR 需求,基本离不开下面这几种情况:

第一,基础设施被固化。某些公司的生产服务器上已经有一套标准化的 Tomcat 环境,由专门的运维团队统一升级、统一备份、统一监控。这时候你递交一个 jar 上去,等于还要在服务器上单独维护一套运行环境,运维从流程上就不会同意。

第二,同一个端口上要混跑多个应用。比如你们的系统里已经有传统的 SSM 项目、老的报表系统,都部署在同一个 Tomcat 的webapps目录下,通过不同路径访问。新上的 Spring Boot 服务如果自己占一个端口,和现有体系格格不入,也不方便统一入口管理。

第三,甲方在招标或交付验收时明确要求制品格式必须是 WAR。这种情况在政企项目里很常见,不是技术问题,是流程和合规问题。你没法跟评审专家讲“jar 比 war 先进”,要的是照单交付。

第四,考虑依赖复用。外部 Tomcat 可以在lib目录里放公用的驱动、工具类,多个应用共享。虽然现代微服务倾向于每个应用自带全部依赖,但在某些保守环境里,共享库是运维希望保留的机制。

1.3 选 WAR 之前,先想清楚这几项代价

WAR 部署不是免费的,它有明显的附加成本:

  • 多了一个外部运维对象。Tomcat 本身的升级、参数调优、日志轮转、安全防护都要有人管。
  • 依赖冲突的问题会变复杂。和内置 Tomcat 运行干净的环境不同,外部 Tomcat 的父类加载器里可能有servlet-api、jsp-api这些公共类,版本一旦和 Spring Boot 期望的不一致,就会在启动时报奇怪错误。
  • 应用打包体积通常更大,部署步骤更多。上传、解压、重启、清理缓存,每一环都要人工关注。
  • 上下文路径不再是你想起什么就是什么。外部容器里,WAR 包文件名直接决定访问路径,如果不想带路径,必须叫ROOT.war。
  • 自动重启和自愈能力要自己补。jar 方式下可以用守护进程或容器编排来拉起进程,外部 Tomcat 方案通常需要借助 systemd 或脚本管理。

把这些想清楚再动手,后续就不会走几步就后悔。

2. 改造一个 Spring Boot 工程需要动的地方

2.1 pom.xml 三处关键修改

假设你现在手里是一个标准 Spring Boot 工程,用 Maven 管理。第一步就是先改pom.xml,让它能产出 war 包。

<groupId>com.example</groupId> <artifactId>demo-web</artifactId> <version>1.0.0</version> <packaging>war</packaging>

packaging从默认的jar改成war,这是最基础的一步。但只改这一项远远不够。如果你的spring-boot-starter-web依赖里带了内嵌 Tomcat,它也就会被一起打进 war 包。外部容器里已经有自己的 Tomcat,再把内嵌 Tomcat 塞进去,等于同一个 Servlet 运行时出现两套实现,类加载顺序稍一失控就是问题。

所以需要把spring-boot-starter-tomcat设为provided作用域:

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-tomcat</artifactId> <scope>provided</scope> </dependency>

这段的意思很直白:编译、打包的时候这个依赖仍然生效,但真正部署到运行环境时,它由外部 Tomcat 提供,不参与最终 WAR 包内容。换句话说,我们“借用”了容器自带的 Tomcat,不再自带一份。

如果工程里有一些只在开发阶段用的依赖,比如spring-boot-devtools,这个依赖也不要作为运行期带进包,它影响的是开发热加载,不该出现在生产产物里。

另外建议顺手配置一下finalName,控制最终 WAR 包的文件名:

<build> <finalName>demo-web</finalName> </build>

以后跑mvn package得到的就是demo-web.war,干净、好记、能直接预测访问路径。

2.2 启动类改造成 Servlet 初始化的入口

这是整个改造里最容易漏的一步。Spring Boot 的默认启动类是带main方法的:

@SpringBootApplication public class DemoWebApplication { public static void main(String[] args) { SpringApplication.run(DemoWebApplication.class, args); } }

JAR 方式下,这个main方法由 JVM 直接调用。但放到外部 Tomcat 里,容器不会跑你的main方法,它是通过 Servlet 规范里的WebApplicationInitializer机制来初始化的。Spring Boot 为此提供了一个专门的基类SpringBootServletInitializer。改造方式就是把启动类继承这个基类,并重写configure方法:

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

这段代码并不复杂,但有两个细节值得说清楚。

第一,configure方法返回的SpringApplicationBuilder是给外部容器用的“应用入口”。Tomcat 启动时会自动找到这个类,然后通过它去加载 Spring 的容器上下文。很多同学直接照抄了代码,却忘了把.sources(DemoWebApplication.class)里的类名改成自己实际的主类,结果打出来的 war 包一直在报找不到配置类的错。

第二,不要把main方法删掉。虽然外部容器不需要它,但本地用 IDE 启动调试、或者做某些自动化测试时,main方法仍然是方便的入口。保留它没有任何坏处。

2.3 检查 JDK 编译级别和 Servlet API 兼容性

改完以上代码,IDE 里本地启动一次,确认功能没问题,再考虑打包。有一个很容易在本地看不出问题、一到服务器上就翻车的点:JDK 编译级别和外部 Tomcat 版本的关系。

如果你的工程是在 JDK 17 环境下编译的,那么远端 Tomcat 运行时也必须跑 JDK 17 或更高,否则字节码版本不兼容,启动会直接报UnsupportedClassVersionError。这个错误信息里会写清楚不支持的主版本号,比如 61.0 就是 JDK 17。

另外注意 Spring Boot 2.x 和 Tomcat 10 的兼容性。Spring Boot 2.x 使用的是javax.servlet命名空间,而 Tomcat 10 是jakarta.servlet命名空间,两者不能混用。要确认你的 Boot 版本和 Tomcat 版本互相匹配,最稳妥的组合是 Boot 2.x 配 Tomcat 9,Boot 3.x 配 Tomcat 10.1 及以上。这个不匹配导致的异常不是“找不到类”,而是各种ClassNotFoundException、NoSuchMethodError,非常容易误判为代码问题。实际排查经验告诉我,看到这类错误第一步先检查双方版本,而不是急着翻业务代码。

2.4 本地用外部 Tomcat 模拟验证

如果你手头有本地 Tomcat,改造完成后不要直接往远端服务器上试,先在本地容器里把 war 跑一遍。我曾经见过有人跳过本地验证,直接把包传到生产服务器,结果半天查不出问题,最后发现只是开发阶段的配置把服务名写错了。本地模拟部署步骤很简单:

  • 先把本地 Tomcat 的webapps目录里的旧应用清掉。
  • 把构建好的 war 包复制进去。
  • 启动本地 Tomcat,访问对应上下文路径。
  • 观察日志,确认Started日志输出。

这套本地流程跑通了,远端部署基本就只是环境差异和网络问题,能把变量范围控制到最小。

3. 构建 WAR 包并检查产物到底对不对

3.1 执行 Maven 打包命令

改造完成后,进入工程根目录执行:

mvn clean package -DskipTests

clean先清掉旧的target目录,避免上次残留文件污染产物;-DskipTests跳过单元测试执行,但会保留编译测试代码,比-Dmaven.test.skip=true少踩一些静态扫描的坑。如果你的工程有很多测试类要花大量时间,可以先用-DskipTests,不要一上来就跳过编译。

如果项目里有多个环境配置,用-P指定 Profile。比如:

mvn clean package -Pprod -DskipTests

打包完成之后,在target目录下会看到demo-web.war。用jar tf或者unzip -l可以查看 war 包内结构:

jar tf target/demo-web.war | head -50

正常的外部容器 WAR 包应该能看到:

  • WEB-INF/classes/:存放编译出来的业务 class 和资源文件。
  • WEB-INF/lib/:存放项目依赖的三方 jar。
  • WEB-INF/lib-provided/:存放 scope 为 provided 的依赖,常见的就是内嵌 Tomcat。如果用了 Spring Boot Maven 插件,这个目录可能出现。

有一个新手特别容易盯错的地方:不要只看BOOT-INF。Spring Boot 打包插件支持把 war 包做成“可执行”形式,这时候target目录下的 war 可能在根目录里既有WEB-INF也有BOOT-INF。外部 Tomcat 真正使用的主要是WEB-INF/classes和WEB-INF/lib。如果发现WEB-INF/lib里有完整的 Spring 库,但缺少了某些业务依赖,就要反查是依赖漏了还是 scope 定错了。

3.2 确认内嵌 Tomcat 有没有被正确排除

这一步是很多故障的源头。检查WEB-INF/lib里有没有tomcat-embed-core之类的 jar。

如果发现内嵌 Tomcat 仍然被打进去了,大概率是spring-boot-starter-web里透传过来的依赖没有被正确处理。最常见的处理方式有两个:

一种是在spring-boot-starter-web里直接排除掉 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>

另一种是显式保留spring-boot-starter-tomcat并设为 provided。两种思路都行,但第二种更常见,因为有些旧版本 Boot 插件在排除后可能影响 IDE 内的默认 Servlet 容器识别。我个人习惯用 provided,改动小、语义清晰。

3.3 控制 WAR 包体积和重复依赖

正常的 Spring Boot Web 工程打出来的 war 包,几十兆甚至上百兆都很正常,这不算问题,但要注意两类不该有的东西:

  • 测试类、测试资源不应该出现在 war 包里。如果你发现自己不测试某个类也被打进去了,检查maven-jar-plugin的配置或者是不是 build 目录里有残留。
  • 不应该看到多个冲突版本的同名 jar。比如log4j-core-2.x.jar和logback-classic同时出现,日志框架已冲突,启动时虽然不会立刻报错,但运行到某些地方会诡异失效。

如果要进一步优化体积,可以把一些明显用不到的依赖 scope 调成provided或runtime。不过我要提醒一句:优化体积之前先保证功能正确,别为了“包小一点”把compile改成provided,导致远端容器里根本没有这个库,运行时报 NoClassDefFoundError。

4. 远端 Linux 服务器的环境准备

4.1 JDK 安装与版本对齐

远端服务器的 Java 环境是整个部署的地基。很多第一次部署的同学把全部精力放在怎么打 war 上,到了服务器才发现java -version都跑不出来,或者版本和本地不一致。

登录到远端服务器,先看当前环境:

java -version

如果没有 Java,以 CentOS 系为例可以通过包管理器安装:

sudo yum install -y java-11-openjdk-devel

如果是 Ubuntu,则是:

sudo apt update sudo apt install -y openjdk-11-jdk

我强烈建议不要只靠系统自带的java命令,而是明确配置JAVA_HOME。先找到 JDK 安装路径,比如/usr/lib/jvm/java-11-openjdk,然后在环境变量里设置:

export JAVA_HOME=/usr/lib/jvm/java-11-openjdk export PATH=$JAVA_HOME/bin:$PATH

如果要让这些配置持久生效,写入/etc/profile.d/java.sh并确保有执行权限。如果你的服务器上安装了多个 JDK 版本,用update-alternatives --config java选择默认版本。这里最容易踩的坑是:Tomcat 启动脚本里的JAVA_HOME和你在 Shell 里看到的JAVA_HOME不一致,明明环境里装的 JDK 17,但定时任务或 systemd 服务拿到的却是 JDK 8。配置一处还不行,所有运行入口都得对齐。

4.2 Tomcat 安装与目录结构理解

如果服务器上没有现成的 Tomcat,需要手动下载解压。到 Apache Tomcat 官网选择对应大版本,这里尤其注意 Boot 版本:

  • Boot 2.x 用 Tomcat 9。
  • Boot 3.x 用 Tomcat 10.1。

下载后解压到统一目录,假设是/opt/tomcat:

sudo mkdir -p /opt/tomcat sudo tar -zxvf apache-tomcat-9.0.x.tar.gz -C /opt/tomcat --strip-components=1

解压完先看一眼核心目录:

  • bin/:启动关闭脚本,startup.sh、shutdown.sh。
  • conf/:核心配置,server.xml、web.xml、context.xml。
  • logs/:日志输出目录,部署排查主力都在这儿。
  • webapps/:WAR 包的默认部署目录。
  • work/:Tomcat 运行期编译缓存。如果你遇到过“改了文件不生效”的情况,大概率是这个目录的缓存问题。

有个操作习惯要养成:不要以 root 用户跑 Tomcat。虽然开发环境无所谓,但生产环境用 root 跑 Web 容器,一旦应用被攻击,整个系统都会被带进去。常规做法是新建一个专用用户:

sudo useradd -r -s /bin/false tomcat sudo chown -R tomcat:tomcat /opt/tomcat

用普通用户跑 Tomcat,后面部署 war 的写权限处理也更顺手。

4.3 环境变量与端口配置

Tomcat 默认读取两个环境变量:JAVA_HOME和CATALINA_HOME。在/etc/profile.d/tomcat.sh里配置:

export CATALINA_HOME=/opt/tomcat export PATH=$CATALINA_HOME/bin:$PATH

Tomcat 默认监听 8080 端口。如果生产运维要求用 80 端口,可以改conf/server.xml里的 Connector 节点,把port="8080"改成port="80",但注意普通用户无法直接绑定 80 端口,需要配合防火墙端口转发或给进程提权,这个牵扯到系统安全和权限设计,我建议保持 8080 或换一个非特权端口,对外通过负载均衡或反向代理暴露服务。

防火墙放行也要检查一下。CentOS 系的命令:

sudo firewall-cmd --permanent --add-port=8080/tcp sudo firewall-cmd --reload

如果服务器是云主机,还要看安全组是否已放行对应端口。这是我最常遇到的环境问题之一:Tomcat 明明起来了,本地 curl 也通,外部就是访问不了。

5. WAR 包部署与启动验证全流程

5.1 上传 WAR 包到 webapps 目录

本地构建出的demo-web.war上传到服务器的/opt/tomcat/webapps/目录即可。上传方式最常用的还是scp:

scp target/demo-web.war user@服务器IP:/tmp/demo-web.war

我个人不建议直接把本地target下的包传过去落地到webapps。宁可先传到/tmp,再手动移动到目标目录。原因很简单:如果传输中断或者包不完整,Tomcat 解压时会卡在中间状态,弹出各种解析异常,排查起来让人头大。先传临时目录,然后用:

sudo mv /tmp/demo-web.war /opt/tomcat/webapps/demo-web.war

使命名和执行权限一次到位。

这里有一个非常关键的机制要理解:Tomcat 检测到webapps下出现新的 war 包后,默认会自动解压为一个同名目录。demo-web.war会被展开成demo-web/。所以应用的实际上下文路径就是“/demo-web”。要是你希望访问根路径,把 war 包命名为ROOT.war,部署后访问的地址就是http://IP:8080/。

理解了这个规则,你再去运维已有环境时就不会疑惑为什么有的应用有路径、有的应用没有。

5.2 启动 Tomcat 与观察日志

启动命令看起来很简单:

/opt/tomcat/bin/startup.sh

但新手最容易犯的毛病是启动完看一眼没报错就走了。Tomcat 的启动脚本本身就是“启动后立即返回”,它只负责把 Java 进程拉起来,并不保证应用一定部署成功。日志里那两行启动信息,不代表你的 war 包真的没问题。

正确的做法是同时盯着日志看:

tail -f /opt/tomcat/logs/catalina.out

整个启动过程会先出现 Tomcat 初始化日志,然后出现类似这样的内容:

INFO: Deploying web application archive [/opt/tomcat/webapps/demo-web.war] ... Started DemoWebApplication in 8.123 seconds

如果应用成功启动,Spring Boot 输出里的Started DemoWebApplication in xx seconds是重要的判定信号。注意不要只看业务日志,要结合catalina.out和localhost.log交叉看。catalina.out记录 JVM 和容器级日志,localhost.log按 Host 记录应用部署情况。

5.3 验证访问是否真的通了

启动成功后,用 curl 在服务器本机先验证:

curl -v http://127.0.0.1:8080/demo-web/

如果本机能返回 200 或预期的业务响应,说明 Tomcat 和应用都正常。接下来再确认外部能访问,检查防火墙和安全组。

访问路径这块我要单独拎出来说,因为这是 war 部署和 jar 部署在体验上一个很大的差异。demo-web.war的默认路径是/demo-web/,如果你在本地 jar 运行时代码里写的接口路径是/api/user/list,部署后完整访问 URL 是http://IP:8080/demo-web/api/user/list。如果你的前端配置、网关转发没有考虑到这层路径前缀,应用起来后 404 会接踵而来。

如果想消除路径前缀,要么用ROOT.war,要么在conf/server.xml的 Host 节点里配置:

<Context path="" docBase="demo-web" reloadable="false" />

不过直接改server.xml的做法在运维层面不如ROOT.war直观,而且两个方案容易混出问题,选定一个就不要来回切换。

5.4 关闭与重启的注意点

需要更新应用时,尽量走受控重启,不要直接杀进程。先:

/opt/tomcat/bin/shutdown.sh

如果进程迟迟不退出,再用ps -ef | grep tomcat找到进程号检查。强行kill -9可能导致work目录缓存损坏,下次启动反而出奇怪问题。

重启之后如果应用日志没有如期更新,很多老 Tomcat 的work目录里缓存了解压编译产物。稳妥做法是:

rm -rf /opt/tomcat/work/Catalina/localhost/demo-web

这个操作不会丢业务数据,只是清掉临时编译缓存,但能解决很大一部分“代码明明更新了,访问起来还是旧逻辑”的困惑。

6. 常见部署故障排查实录

6.1 端口被占用导致启动失败

故障现象:执行startup.sh后,catalina.out里出现BindException: Address already in use。

排查方法:

netstat -tlnp | grep 8080 lsof -i:8080

如果端口被另一个 Java 进程占用,说明你可能有重复的 Tomcat 实例。用kill结束旧进程后,再重启。如果端口被其他非 Java 服务占用,建议直接给新 Tomcat 换端口,在server.xml里改 Connector 的 port 属性。

这个错误还有一个变种:端口本身没被占用,但 Tomcat 显示 bind 失败,那要检查是不是当前用户没有权限绑定该端口,特权端口低于 1024 需要 root,非 root 用户绑定不了。

6.2 Spring Boot 和 Tomcat 版本不匹配

故障现象:应用启动时报java.lang.NoClassDefFoundError: javax/servlet/...或者java.lang.SecurityException。

这类问题 80% 发生在Spring Boot 2.x + Tomcat 10的组合下。Boot 2.x 使用的是javax.servletAPI,Tomcat 10 使用jakarta.servlet,两边版本不匹配,类根本对不上。就算有些包碰巧能加载,运行到具体方法时也会炸。

处理方式很简单:Boot 2.x 用 Tomcat 9,Boot 3.x 用 Tomcat 10 及以上。版本对齐后重新打包部署,问题基本消失。

6.3 应用启动成功但访问 404

故障现象:日志里出现了Started DemoWebApplication,但访问接口时返回 404。

先分清楚是容器层 404 还是应用层 404。容器层 404 一般返回 Tomcat 默认的 404 页面,说明请求根本没进到应用里。这时候优先查上下文路径。访问http://IP:8080/demo-web/和访问http://IP:8080/是完全不同的路径。如果依赖里配了网关,还要看网关转发时有没有保留/demo-web前缀。

应用层 404 则是接口不存在或路由不匹配。先访问应用的/actuator/health这类内置端点,如果这个端点能通,说明应用活着,问题出在业务路由;如果这个也不通,说明应用没有真正完成注入。

还有一种情况是${server.servlet.context-path}配置导致的前缀重置。比如你在application.yml里写了:

server: servlet: context-path: /myapp

那么实际访问路径就是/myapp/demo-web/。这类双重前缀的问题是我见过的最窝火的一种 404,明明路径看起来都对,就是访问不到。

6.4 类加载和依赖冲突

外部 Tomcat 有一个全局lib/目录,如果里面放了某个 jar,应用自身WEB-INF/lib里又带了一个不同版本的同名 jar,Tomcat 的类加载机制默认是父优先。也就是说,全局 lib 里的类优先级更高,两个版本不一致时,应用可能加载到旧方法、新字段,运行到某些位置就报NoSuchMethodError。

排查方法:

grep -r "目标类名" /opt/tomcat/lib

如果发现冲突,优先把公共 jar 统一版本,或者把应用里不需要的公共 jar 排除掉。尽量让外部 Tomcat 的lib目录保持精简,而不是把所有东西都往里面塞,这是很多老 Tomcat 环境“莫名其妙跑不动”的根源。

6.5 日志分类速查表

我把几个常见问题整理成速查表,方便以后直接对照。

故障现象大概率原因优先排查方向
启动卡住,日志停在某个类数据库连接池连接不上看localhost.log,确认数据源配置
页面 502,Tomcat 无响应线程池耗尽或内存溢出jstack看线程,jstat看内存
接口响应变慢外部 Tomcat 线程数配置过小检查server.xml的maxThreads
日志乱码或中文变问号文件编码不统一检查JAVA_OPTS里的-Dfile.encoding=UTF-8
找到不到配置类启动类继承错误确认SpringBootServletInitializer配置
解压失败war 包损坏清理 war 后重新上传

7. 部署之后的运维小优化

7.1 用 systemd 把 Tomcat 变成守护服务

手动执行startup.sh有一个明显缺陷:机器重启后不会自动拉起 Tomcat。我在生产环境里最后都会把它注册成 systemd 服务。

创建文件/etc/systemd/system/tomcat.service:

[Unit] Description=Apache Tomcat Web Application Container After=network.target [Service] Type=forking Environment=JAVA_HOME=/usr/lib/jvm/java-11-openjdk Environment=CATALINA_HOME=/opt/tomcat ExecStart=/opt/tomcat/bin/startup.sh ExecStop=/opt/tomcat/bin/shutdown.sh User=tomcat Group=tomcat [Install] WantedBy=multi-user.target

Type=forking表示startup.sh启动后返回,Tomcat 作为后台进程继续运行,这符合 Tomcat 脚本本身的行为。执行:

sudo systemctl daemon-reload sudo systemctl enable tomcat sudo systemctl start tomcat

之后就可以用systemctl status tomcat查看运行状态,机器重启后服务会自动恢复。

7.2 JVM 与运行参数统一配置

外部 Tomcat 环境下,应用启动参数不该散落在多个地方。Tomcat 官方留了一个专门的口子:在bin/setenv.sh里配置CATALINA_OPTS。

export CATALINA_OPTS="-Xms512m -Xmx2048m -XX:MetaspaceSize=256m -Dspring.profiles.active=prod -Dfile.encoding=UTF-8"

CATALINA_OPTS是只传给 Tomcat 主进程的参数,不影响 shutdown 脚本,和JAVA_OPTS有区别。把堆内存、Metaspace、Spring Profile、文件编码都统一放在这一处,运维只看这个文件就知道应用怎么跑的。

关于堆内存设置,我踩过一次坑:给一个并发量不小的应用只分配了 256MB 堆,高峰期接口大面积超时,日志里频繁出现OutOfMemoryError。先把-Xmx2048m调上去了,然后加-Xms512m保持堆初始和最小值拉开,避免一启动就申请过大内存导致启动慢。具体数字要根据服务器物理内存和应用内存用量来定,原则是留出系统余量,不要给满。

7.3 用脚本把部署变成一键操作

war 部署如果全是手工工序,每次更新都可能出错。我写了个简单的部署脚本作为模板,你可以按自己业务调整:

#!/bin/bash set -e APP_NAME=demo-web TOMCAT_HOME=/opt/tomcat BACKUP_DIR=/opt/backup/${APP_NAME} mkdir -p ${BACKUP_DIR} cp ${TOMCAT_HOME}/webapps/${APP_NAME}.war ${BACKUP_DIR}/${APP_NAME}-$(date +%Y%m%d%H%M%S).war sudo systemctl stop tomcat rm -rf ${TOMCAT_HOME}/webapps/${APP_NAME} ${TOMCAT_HOME}/webapps/${APP_NAME}.war cp /tmp/${APP_NAME}.war ${TOMCAT_HOME}/webapps/${APP_NAME}.war sudo systemctl start tomcat for i in {1..30}; do if curl -f http://127.0.0.1:8080/${APP_NAME}/actuator/health; then echo "deploy success" exit 0 fi sleep 1 done echo "deploy timeout" exit 1

脚本逻辑很直白:先备份旧包,停服务,清掉旧的解压目录和 war 包,再复制新包,启动,最后通过健康检查接口确认部署成功。提前备份这步特别重要,不然线上出问题你想回滚都没有旧包可回。

7.4 从这次部署里能沉淀下来的东西

war 包部署这件事,对很多用惯了 jar 的人来说可能是“倒退”,但它反映的是真实世界里的约束条件。掌握它不是为了标榜技术老练,而是当遇到外部容器限制、运维流程规定、历史架构包袱时,你能多一套应对手段。

实际操作里我有一个很深的体会:外部 Tomcat 部署最怕的不是“部署不下去”,而是“容器环境不可控”。同一个 war 包,在本地 Tomcat 跑得好好的,放到生产 Tomcat 上就是各种版本不兼容、类加载冲突。所以部署文档一定要把环境信息写全,包括 JDK 版本、Tomcat 版本、启动参数、防火墙端口,甚至 Java 发行版是哪家都没跑过。环境越透明,后面排障越省力。

再补充一个小技巧:如果你写完这套部署流程后,强烈建议把 war 包、部署脚本、环境说明打包放在同一个目录下,标记好版本号。下次接手的人拿到的是一个完整的发布包,而不是一个孤零零的 war 文件。毕竟线上最怕的,就是“开发说打包了,运维说部署了,最后谁都不知道线上跑的是什么”。统一的发布物和清晰的部署动作,才是这整套流程里最值钱的部分。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进