☰
单机多Tomcat实例部署避坑指南:端口冲突与误杀问题全解析
2026/10/15 1:47:14 网站建设 项目流程

这事发生在一次给测试环境做应用隔离的部署任务中。当时的需求很简单:一台Linux服务器,装好JDK 1.8,然后跑两个独立的Tomcat实例,各自部署不同的Java Web应用,互不干扰、可以分别重启。我原本以为半小时就能搞定——解压两个Tomcat、改改端口、启动就完了。结果现实给了我一记闷棍:第二个Tomcat怎么都起不来,后来好不容易让两个都跑起来了,执行其中一个的shutdown.sh,另一个也跟着退出了。这台服务器我反复折腾了大半天,最后把根因一个个挖出来,才算彻底稳住。这篇就把完整的部署过程、排查链路和最终方案写清楚,给后来做同样需求的人当个参考。

我用的版本组合是:JDK 1.8(1.8.0_202)+ Apache Tomcat 8.5.x。标题里的“tomcat version 1.8”,我更倾向于理解为JDK环境是1.8,Tomcat本体用8.5完全够用,这套操作对Tomcat 8/9都成立,Tomcat 10因为包名变更和配置差异需要另说。

1. 单台服务器跑多个Tomcat,为什么是件需要认真规划的事

1.1 需求场景:别把所有应用塞进同一个Tomcat

很多人在同一台服务器上部署多个Web应用时,第一反应是“都扔到一个Tomcat的webapps目录下面”。这种做法省事,但坑也不少:应用A的线程池和内存占用可能拖垮应用B的内存配置;应用A一旦触发Full GC或者死锁,整个JVM卡住,应用B跟着遭殃;最麻烦的是发布应用A时重启Tomcat,应用B也会断线。所以当几个应用都要求相互隔离、各自独立重启时,单机多Tomcat实例就成了更合理的方案。

我当时面对的正是这个局面:两个业务系统共用一个CentOS系统的测试服务器,资方要求两者部署在同一台机器上,但必须能够在互不影响的情况下单独启停。单Tomcat多应用直接出局,唯一合适的路子就是跑多个Tomcat实例。

1.2 多实例方案与单实例多应用方案怎么选

多Tomcat实例方案的代价是端口、内存、目录全部要独立规划,但换来的是每个应用拥有独立JVM,可以单独重启、单独调优、单独设置垃圾回收参数。下表是我在做方案选型时参照的一个对比:

对比项单Tomcat多应用单机多Tomcat实例
部署复杂度低,拷war包即可中,需规划端口、目录、脚本
进程隔离性差,共用一个JVM好,每个实例独立JVM
独立重启能力无法单独重启可以单独启停
资源隔离无法限制单应用内存可通过启动参数分别限制堆内存
异常影响范围一个应用OOM可能导致全部不可用单个实例崩溃不影响其他实例
维护成本低略高,需要脚本或systemd辅助管理

权衡之后我选了多实例方案。事实证明,前置规划做得越细,后面踩的坑越少。我这次在规划和脚本上偷了懒,后面立刻付出了“排查半天”的代价。

2. JDK 1.8安装与全局环境变量配置

2.1 下载解压:尽量用tar.gz包而不是yum装

服务器是内网测试机,没法直接在线拉包。我提前下载了jdk-8u202-linux-x64.tar.gz传到服务器上。解压路径我习惯统一放在/usr/local/java下面,方便多个组件共用环境变量时引用。

mkdir -p /usr/local/java tar -zxf jdk-8u202-linux-x64.tar.gz -C /usr/local/java/ ls /usr/local/java/

解压之后目录名是jdk1.8.0_202,这个路径后面就是JAVA_HOME。在这里我建议别用yum install java-1.8.0-openjdk,虽然省事,但OpenJDK的默认路径往往被包管理工具拆散到多个目录,后面配CATALINA_HOME时容易搞混。自己解压的tar包路径固定,出现问题也好排查。

2.2 环境变量:JAVA_HOME全局配置没问题

JDK是全局共用的,所以JAVA_HOME放进/etc/profile里没有任何问题。多个Tomcat共用同一份JDK,不会互相覆盖,这点可以放心。

# 编辑 /etc/profile,在文件末尾追加以下内容 export JAVA_HOME=/usr/local/java/jdk1.8.0_202 export PATH=$JAVA_HOME/bin:$PATH export CLASSPATH=.:$JAVA_HOME/lib/dt.jar:$JAVA_HOME/lib/tools.jar

配置完记得让变量立即生效并验证:

source /etc/profile java -version

能看到类似下面的输出就算装好了:

java version "1.8.0_202" Java(TM) SE Runtime Environment (build 1.8.0_202-b08) Java HotSpot(TM) 64-Bit Server VM (build 25.202-b08, mixed mode)

2.3 一个不太起眼但容易翻车的细节

如果这台服务器上原本用yum装过OpenJDK,你会发现source之后java -version还是旧版本。这是因为/usr/bin/java被alternatives机制软链到了OpenJDK。解决办法是把JAVA_HOME的bin目录放到PATH最前面,或者干脆删掉/usr/bin/java的软链重指。我在测试机上处理过一次:

which java ls -l /usr/bin/java

如果指向的是/etc/alternatives/java,确认它链到的路径不是我们的新JDK,就重新设置优先级。这里我的建议是:既然已经用tar包方式安装了JDK,直接移除旧软链,把新JDK的bin路径置顶,避免后面两个Tomcat启动时用一个版本、运行时依赖另一个版本,出现莫名其妙的问题。

3. 搭建前的目录与端口规划:多实例部署的准备工作

3.1 目录规划:不要用cp -r复制Tomcat

接下来是准备两个Tomcat实例。很多教程喜欢教你“cp -r tomcat1 tomcat2”,我强烈不推荐这样做,后面第五章节你会看到,复制出来的实例最容易带着上一份脚本里的“私货”。更干净的做法是重新解压一份官方tar包,分别命名:

mkdir -p /usr/local/tomcat tar -zxf apache-tomcat-8.5.100.tar.gz -C /usr/local/tomcat/ mv /usr/local/tomcat/apache-tomcat-8.5.100 /usr/local/tomcat/tomcat-app1 tar -zxf apache-tomcat-8.5.100.tar.gz -C /usr/local/tomcat/ mv /usr/local/tomcat/apache-tomcat-8.5.100 /usr/local/tomcat/tomcat-app2

解压完成后先给脚本加上执行权限:

chmod +x /usr/local/tomcat/tomcat-app1/bin/*.sh chmod +x /usr/local/tomcat/tomcat-app2/bin/*.sh

“tomcat-app1”这种命名方式也算一个隐形的坑:如果只是叫tomcat1和tomcat2,等哪天服务器上第三、第四个实例起来,日志里满屏都是tomcat1、tomcat2,你根本分不清哪个是哪个。用app名字或业务代号命名,日志和ps命令输出的可读性会好很多。

3.2 端口三件套:server.xml里必须改全

Tomcat实例之间互相隔离的关键就是端口。每个Tomcat启动时要监听三类端口:

  • HTTP端口:负责接收Web请求的Connector端口
  • SHUTDOWN端口:负责接收“SHUTDOWN”命令的关闭端口
  • AJP端口:用于与Apache或Nginx等前端服务器协作的AJP Connector端口

因为所有实例都跑在同一台机器上,这些端口必须全部错开,否则TCP端口绑定冲突会直接导致启动失败。我规划的端口表如下:

实例HTTP端口SHUTDOWN端口AJP端口redirectPort
tomcat-app18080800580098443
tomcat-app28081800680108444

对应的,修改tomcat-app2/conf/server.xml,找到三个地方:

<Server port="8006" shutdown="SHUTDOWN"> ... <Connector port="8081" protocol="HTTP/1.1" connectionTimeout="20000" redirectPort="8444" /> ... <Connector port="8010" protocol="AJP/1.3" redirectPort="8444" />

这里有个细节很多人会漏掉:redirectPort必须也跟着改。如果HTTP Connector的SSL跳转端口还是默认8443,那么当Tomcat-app2遇到HTTPS请求时,会尝试转发到8443,而8443通常被Tomcat-app1的SSL Connector占用,请求就会转发到错误实例上。虽然这个配置在纯HTTP测试环境下不容易暴露,但一旦线上走了HTTPS,就是隐蔽故障。

3.3 改完端口后的快速自检

修改server.xml后,别急着启动。Tomcat 8.5及以上版本自带配置检查命令:

/usr/local/tomcat/tomcat-app2/bin/catalina.sh configtest

如果输出类似“INFO [main] org.apache.catalina.startup.Catalina.start Server startup”且没有一行“FAILED”,说明XML语法和端口格式没问题。这个命令我在每次改动server.xml之后都会执行一遍,能省掉一堆启动时报错的核对时间。

4. “第二个Tomcat启不来”的完整排查过程

4.1 两种典型失败现象

准备工作做完,我踩的第一个坑如期到来。先启动tomcat-app1,一切正常。再启动tomcat-app2,控制台输出各异,但归纳起来逃不出两种现象:

现象一:tomcat-app2启动时直接抛异常,日志最后一行类似“java.net.BindException: Address already in use: bind”,或者堆栈里跟着“Protocol handler initialization failed”和“LifecycleException”。

现象二:启动脚本执行完,控制台显示“Tomcat started”,但访问8081端口没有任何响应,查看进程发现Tomcat-app2的进程存在却没绑定8081,或者8081被一个莫名其妙的进程占用。

这两种现象背后的根因完全不同,排查思路也不同。

4.2 根因一:端口冲突是最直接的拦路虎

第一种现象通常就是端口没改全。判断方法非常直接,看当前端口监听情况:

ss -lntp | grep -E '8080|8081|8005|8006|8009|8010'

如果发现8081端口已经被另一个java进程监听,或者Tomcat-app2在启动日志里明确写了“Address already in use: JVM_Bind 8081”,那问题基本就锁定在server.xml的端口配置上。这时候回到/tomcat-app2/conf/server.xml,逐个检查三处端口是否都与tomcat-app1不同。我遇到过不少次,HTTP端口改了,但AJP端口或者shutdown端口忘记改,于是第二个实例绑定到8009或8005上直接失败。

4.3 根因二:CATALINA_HOME被全局环境变量绑架了

第二种现象更阴险。控制台提示启动成功,但进程实际加载的配置和webapps根本不是tomcat-app2的。比如Tomcat-app1里有一个index.html,Tomcat-app2的webapps下放的是另一个应用,但你访问8081端口看到的却是Tomcat-app1的默认页面。

出现这种情况,不用怀疑,几乎可以断定是CATALINA_HOME环境变量被全局设置了。很多人在配置JDK时顺手编辑/etc/profile,把JAVA_HOME配置完之后又多写了一行:

# 这就是灾难的起点,多实例场景千万不要全局设置CATALINA_HOME export CATALINA_HOME=/usr/local/tomcat/tomcat-app1

这一行会让所有Tomcat的bin脚本都“跑偏”。Tomcat的启动脚本catalina.sh在解析实例路径时,逻辑是这样的:命令行参数优先于脚本自身推导,但环境变量CATALINA_HOME一旦存在,会被脚本直接采用。于是你执行tomcat-app2/bin/startup.sh,脚本一看环境变量CATALINA_HOME已经指向tomcat-app1,就不再推导自身目录,直接拿tomcat-app1的conf、webapps、lib来启动。端口虽然在你改好的tomcat-app2/conf/server.xml里,可脚本读的是tomcat-app1/conf/server.xml,自然怎么改8081都没反应。

定位这个问题的金标准是看启动日志。Tomcat每次启动时,catalina.sh会打印当前使用的实例路径:

tail -n 50 /usr/local/tomcat/tomcat-app2/logs/catalina.out

重点看日志开头的这两行输出:

Using CATALINA_BASE: /usr/local/tomcat/tomcat-app1 Using CATALINA_HOME: /usr/local/tomcat/tomcat-app1

如果这两行显示的路径不是tomcat-app2,那问题就不在端口,而是脚本没有识别到正确的实例目录。修复的方法,一个是彻底删掉/etc/profile里那个全局CATALINA_HOME,另一个是在每个实例的startup.sh和catalina.sh脚本顶部显式写入各自的路径。我更推荐后者,原因后面解释。

4.4 日志里那两行Using X,就是第一诊断信号

这里把我的排查经验浓缩成一句话:所有Tomcat实例启动后,第一件事永远是看catalina.out里那两行“Using CATALINA_BASE”和“Using CATALINA_HOME”。只要这两个路径和你本意不一致,后面看什么都可能是错的。这也是区分“端口出问题”和“目录出问题”的最快分界线。我后来再遇到任何Tomcat启动异常,几乎都靠这个特征做第一步分流。

5. “关一个Tomcat,另一个跟着挂”的根因重建

这是我最想重点写的一段,因为它的排查过程有点曲折,根因不止一个,而且都藏在脚本和进程机制里。

5.1 先搞清楚shutdown.sh到底做了什么

Tomcat的bin/shutdown.sh本身并没有写多少逻辑,它只是调用了catalina.sh stop:

exec "$PRGDIR"/"$EXECUTABLE" stop "$@"

而catalina.sh stop关闭实例时,本质上只有两种手段:

第一种:如果环境变量CATALINA_PID被设置了,catalina.sh会去读取这个PID文件里的进程号,然后向这个PID发送终止信号,由JVM自行走完关闭流程。

第二种:如果没有设置CATALINA_PID,catalina.sh会去连接server.xml中配置的SHUTDOWN端口,发送字符串“SHUTDOWN”,Tomcat收到后开始优雅关闭。

所以,关闭单个Tomcat导致其他Tomcat一起挂掉,问题往往就出在“目标没对准”上:要么PID文件是多个实例共享的,要么SHUTDOWN端口被多个实例共用,把关闭命令发到了错误对象身上。

5.2 根因三:复制目录带来的共享PID文件

我把tomcat-app2启动成功后,又遇到“关一个、全关”的问题。回想起来,最初我图省事,确实用“cp -r tomcat-app1 tomcat-app2”的方式复制过实例,然后在tomcat-app1/bin/catalina.sh里看到过自己加的一行:

export CATALINA_PID="$CATALINA_BASE/logs/tomcat.pid"

复制出来的tomcat-app2/bin/catalina.sh里也带着这行。看起来没问题,因为CATALINA_BASE应该自动解析成各自目录。问题在于,我复制实例后改了tomcat-app2的server.xml端口,却没有去看catalina.sh脚本是不是继承了tomcat-app1的信息。更早前由于环境变量CATALINA_HOME已在全局设置,tomcat-app2启动时CATALINA_BASE根本没能正确解析到自己的目录,于是两个实例的CATALINA_PID都指向了同一个文件——tomcat-app1/logs/tomcat.pid。

结果就是:tomcat-app1启动后写入一份PID,tomcat-app2启动时又把同一路径的PID文件覆盖成自己的进程号。此刻我执行tomcat-app2/bin/shutdown.sh,catalina.sh读到的PID是Tomcat-app1的进程号,于是一刀下去,Tomcat-app1被关掉,Tomcat-app2反而还活着。但因为页面访问都靠Tomcat-app1,所以看起来就是“关了一个,另一个跟着挂”。

修复的方法是让PID文件各自独立。在每个实例的catalina.sh脚本顶部明确设置自己的CATALINA_PID:

# tomcat-app1/bin/catalina.sh export CATALINA_HOME=/usr/local/tomcat/tomcat-app1 export CATALINA_BASE=/usr/local/tomcat/tomcat-app1 export CATALINA_PID=$CATALINA_BASE/logs/tomcat.pid
# tomcat-app2/bin/catalina.sh export CATALINA_HOME=/usr/local/tomcat/tomcat-app2 export CATALINA_BASE=/usr/local/tomcat/tomcat-app2 export CATALINA_PID=$CATALINA_BASE/logs/tomcat.pid

核心原则就一条:每个实例的CATALINA_HOME、CATALINA_BASE、CATALINA_PID必须完全独立且指向自己目录。

5.3 根因四:SHUTDOWN端口重复,命令发到别人家

另一个更隐蔽的根因是SHUTDOWN端口重复。假如Tomcat-app1和Tomcat-app2的server.xml里都保留了默认的8005端口(HTTP端口可能改成了8080和8081),这时候两个实例无法同时启动,因为Tomcat-app2的ServerSocket绑定8005时会失败。但如果tomcat-app2启动时因为某种原因没有立刻绑定失败,或者你在启动顺序上钻了空子——比如tomcat-app2先把8005占住了,Tomcat-app1后启动时反而报错——整个局面就会非常混乱。

更典型的情况是:Tomcat的SHUTDOWN端口被另一个实例占用时,启动日志里能看到“SEVERE: Failed to initialize end point associated with ProtocolHandler”之类的报错,这时候很多人会错误地把所有端口都改一遍,唯独留下8005,结果第二个实例一直绑不上8005,启动失败。一旦你强行让两个实例都起来(比如用iptables转发或修改绑定顺序),执行某个shutdown.sh时,它会连上8005端口发送SHUTDOWN字符串,而8005恰好是另一个实例在监听,于是被误关的就是那个“别人”,而不是你正要关的实例。

这类问题没有太多高深技巧,就是老老实实把每个实例的SHUTDOWN端口也错开,并确保server.xml里三处端口全部唯一。我列一个自查表:

检查项操作方式错误后果
HTTP端口ss -lntp第二个实例无法绑定,直接启动失败
SHUTDOWN端口修改server.xml Server port关闭命令发错对象,误杀其他实例
AJP端口修改server.xml Connector port与前端集成时端口冲突,启动异常
CATALINA_HOMEenv | grep CATALINA所有实例共用同一个Tomcat目录
CATALINA_BASE启动日志Using CATALINA_BASE日志、webapps、conf都指向错误路径
CATALINA_PIDcat catalina.shshutdown.sh读取错误PID,误杀其他实例

5.4 关闭操作不要用killall java,优雅关闭才是正道

修复完上述问题后,另一个容易引发误杀的“人的因素”是关闭方式太粗暴。我见过有人图省事直接执行:

killall java

或者:

pkill -f catalina

这两个命令一旦执行,所有Tomcat实例都会收到终止信号,直接“团灭”。如果你真的遇到过“关闭单个Tomcat导致其他Tomcat一起关闭”,先想一想自己是不是有意无意间用了这种全量匹配的命令。正确做法永远是用具体实例的shutdown.sh,或者systemctl stop对应的服务单元。

6. 规范化管理:自定义启动脚本与systemd双保险

6.1 不用systemd时候的可靠启动脚本

搞清楚了根因,接下来就是把方案固化下来。如果这台服务器没有systemd(比如某些老版本系统),或者你不想引入systemd服务,那就写一个实例专用的启动脚本。脚本可以放在每个Tomcat的bin目录下,也可以放在/usr/local/tomcat/下面统一管理。

我用的脚本大致是这个样子,以tomcat-app2为例:

#!/bin/bash # /usr/local/tomcat/tomcat-app2/bin/start-app2.sh export JAVA_HOME=/usr/local/java/jdk1.8.0_202 export CATALINA_HOME=/usr/local/tomcat/tomcat-app2 export CATALINA_BASE=/usr/local/tomcat/tomcat-app2 export CATALINA_PID=$CATALINA_BASE/logs/tomcat.pid export CATALINA_OPTS="-Xms256m -Xmx512m -Djava.awt.headless=true" start() { echo "Starting tomcat-app2..." $CATALINA_HOME/bin/startup.sh } stop() { echo "Stopping tomcat-app2..." $CATALINA_HOME/bin/shutdown.sh } case "$1" in start) start ;; stop) stop ;; restart) stop; sleep 2; start ;; *) echo "Usage: $0 {start|stop|restart}" ;; esac

这个脚本的关键在于:四个关键变量全部在脚本内部显式指定,完全不依赖当前shell里有没有被source过什么全局环境。我特别强调一点——不要依赖“我已经source过/etc/profile了”这种记忆,脚本一旦独立出来,就要自带全部所需环境变量,做到“双击即用”。这样才能彻底避免前面那些因为环境变量污染引发的连锁问题。

6.2 用systemd统一管理多实例:生产环境优先方案

如果你的服务器是CentOS 7及之后版本,或者Ubuntu 16.04及之后版本,我更推荐用systemd管理多实例。好处很明显:开机自启、崩溃自动重启、日志统一收集、启停顺序可控。每个Tomcat实例配一个service文件,互不干扰,从根本上杜绝误杀。

以tomcat-app1为例,创建/etc/systemd/system/tomcat-app1.service:

[Unit] Description=Apache Tomcat App1 After=network.target [Service] Type=forking Environment=JAVA_HOME=/usr/local/java/jdk1.8.0_202 Environment=CATALINA_HOME=/usr/local/tomcat/tomcat-app1 Environment=CATALINA_BASE=/usr/local/tomcat/tomcat-app1 Environment=CATALINA_PID=/usr/local/tomcat/tomcat-app1/tomcat.pid PIDFile=/usr/local/tomcat/tomcat-app1/tomcat.pid ExecStart=/usr/local/tomcat/tomcat-app1/bin/startup.sh ExecStop=/usr/local/tomcat/tomcat-app1/bin/shutdown.sh User=tomcat Group=tomcat TimeoutSec=60 Restart=on-failure [Install] WantedBy=multi-user.target

tomcat-app2的service文件完全同构,只是把所有tomcat-app1替换成tomcat-app2,以及端口相关目录同步替换。创建好后执行:

systemctl daemon-reload systemctl enable --now tomcat-app1 systemctl enable --now tomcat-app2 systemctl status tomcat-app1 systemctl status tomcat-app2

用systemd管理之后,关停某个实例只需要:

systemctl stop tomcat-app2

因为每个service单元都通过PIDFile精确指定了进程文件,systemctl stop只会杀掉自己那个单元跟踪的PID,和另一个Tomcat毫无关系。这个方案我在后面几台新服务器上直接复用,再也没出过“误杀”问题。

这里提醒一句:service文件里的User和Group建议用专门创建的tomcat用户,不要直接交给root。多实例共用同一个用户是可以的,只要目录权限给到tomcat用户即可:

useradd -r -s /sbin/nologin tomcat chown -R tomcat:tomcat /usr/local/tomcat/tomcat-app1 chown -R tomcat:tomcat /usr/local/tomcat/tomcat-app2

6.3 日常验收清单:启动后必须核对的三件事

不管用哪种管理方式,实例启动后我强烈建议按下面的清单过一遍,避免“看似启动成功、实则配置错误”的问题:

第一,看日志确认catalina.out开头两行Using路径是否指向实例自身目录。这一步1秒就能完成,却能拦下绝大多数CATALINA_HOME类问题。

第二,确认端口监听是否与规划表完全一致:

ss -lntp | grep -E '8080|8081|8005|8006|8009|8010'

第三,确认PID文件路径唯一且内容与当前进程对应。如果两个Tomcat实例只有一份tomcat.pid,立即把catalina.sh里的CATALINA_PID修掉。具体查看方式:

cat /usr/local/tomcat/tomcat-app1/logs/tomcat.pid cat /usr/local/tomcat/tomcat-app2/logs/tomcat.pid ps -ef | grep java | grep -v grep

这三个清单项基本成了我部署多个Tomcat时的肌肉记忆,只要每一项都对号入座,后面运维时就不会被奇怪的问题缠住。

7. 最后想说的话

这次折腾的经历让我对Linux下多实例部署有了更深的体会:问题从来不在Tomcat本身,而在于脚本继承、环境变量污染、端口管理这些“看起来无关紧要”的细节。所有踩过的坑,归根到底都是因为我没有在第一步把每个实例的边界划清楚。哪台服务器上只要出现了多个Tomcat,CATALINA_HOME、CATALINA_BASE、CATALINA_PID、SHUTDOWN端口,这四个东西就必须一字排开,各自独立,谁也别想借谁的。

如果你现在正被“第二个Tomcat启不来”或“关一个全挂”折磨,建议先停下改配置的手,打开catalina.out,看那两行Using CATALINA_HOME和Using CATALINA_BASE到底指向了哪里。这个问题解决掉,后面的路基本就顺了。我现在的习惯是:不管临时环境还是正式环境,多实例一律用systemd管理,每个实例一个service文件,所有路径全部显式指定,不依赖任何全局变量。这套方案跑下来,已经很久没在这个问题上浪费过时间了。

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

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

立即咨询