Tomcat 部署与 JVM 调优实战:从安装到生产基线

第一次独立接手 Java 应用时,开发只丢过来一个 war 包和一句”部署到服务器上”,剩下的全是我自己的事:装什么环境、配哪些参数、日志放哪、内存给多少。Tomcat 本身不难装,难的是把它调到一个”生产敢用”的状态——这套配置后来成了我部署所有 Java 应用的标准动作。

一、先弄清楚 Tomcat 在生产里的位置

Tomcat 是 Servlet 容器,Java 应用跑在它里面。类比一下:PHP 站点要 Nginx 配 PHP-FPM 才能跑,Java 应用就需要 Tomcat 这个容器。

它自己带 HTTP 服务能力(默认 8080),但静态文件处理比 Nginx 差得远,所以生产上标准姿势是:

  • 用户请求 → Nginx(80/443)→ Tomcat(8080);
  • 静态资源(图片、CSS、JS)Nginx 直接返回,动态请求才转发给 Tomcat;
  • 8080 端口只对内网负载均衡节点开放,绝不对公网暴露。

这里有个我踩过的坑:最初图省事直接把 8080 开到公网,扫描器几天就找上门了——Tomcat 的指纹太好认,默认页面加上版本号,等于把攻击面白送出去。

二、安装与托管

1. JDK 环境

1
2
3
4
5
6
7
8
# 安装 JDK 1.8(兼容性最好的版本,生产上跑老项目的首选)
yum install -y java-1.8.0-openjdk java-1.8.0-openjdk-devel
java -version

# 配 JAVA_HOME(Tomcat 启动脚本依赖它)
echo 'export JAVA_HOME=/usr/lib/jvm/java-1.8.0-openjdk' >> /etc/profile
echo 'export PATH=$JAVA_HOME/bin:$PATH' >> /etc/profile
source /etc/profile

2. 安装 Tomcat

1
2
3
4
cd /usr/local
wget https://archive.apache.org/dist/tomcat/tomcat-9/v9.0.80/bin/apache-tomcat-9.0.80.tar.gz
tar -zxvf apache-tomcat-9.0.80.tar.gz
mv apache-tomcat-9.0.80 tomcat # 目录名改短,脚本里不用到处写版本号

3. 交给 systemd 托管

裸跑 startup.sh 的问题是进程崩了没人拉起来,也没法开机自启。写一个 unit:

1
vim /usr/lib/systemd/system/tomcat.service
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
[Unit]
Description=Apache Tomcat Web Application Container
After=network.target

[Service]
Type=forking
JAVA_HOME=/usr/lib/jvm/java-1.8.0-openjdk
CATALINA_HOME=/usr/local/tomcat
CATALINA_BASE=/usr/local/tomcat
PIDFile=/usr/local/tomcat/tomcat.pid
ExecStart=/usr/local/tomcat/bin/startup.sh
ExecStop=/usr/local/tomcat/bin/shutdown.sh
Restart=on-failure
User=root

[Install]
WantedBy=multi-user.target
1
2
3
4
5
systemctl daemon-reload
systemctl start tomcat && systemctl enable tomcat
systemctl status tomcat

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

访问 http://服务器IP:8080 看到 Tomcat 欢迎页就算通了。

User=root 是先跑通的写法,生产要换成普通用户,见后面安全基线一节。

三、server.xml:三个端口和一个层级结构

conf/server.xml 是 Tomcat 的主配置,地位等同于 Nginx 的 nginx.conf,结构是「Server → Service → Connector + Engine → Host → Context」层层嵌套。真正需要动的是这几个:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<!-- 1. 关闭端口:默认 8005,收到 SHUTDOWN 字符串就关服务。生产必须改 -->
<Server port="18005" shutdown="随机字符串" address="127.0.0.1">

<Service name="Catalina">
<!-- 2. HTTP 连接器:默认 8080,用户访问入口 -->
<Connector port="8080" protocol="HTTP/1.1"
connectionTimeout="20000"
redirectPort="8443" />

<!-- 3. AJP 连接器:默认 8009,与 Apache httpd 配合用,生产用不到 -->
<!-- 幽灵猫漏洞(CVE-2020-1938)就出在它身上,直接注释掉 -->
<!-- <Connector port="8009" protocol="AJP/1.3" redirectPort="8443" /> -->
</Service>
</Server>

Host 对应 Nginx 的 server 块(一个域名站点),Context 对应 location(一个访问路径指向哪个应用):

1
2
3
4
5
6
<Engine name="Catalina" defaultHost="localhost">
<Host name="localhost" appBase="webapps" unpackWARs="true" autoDeploy="true">
<!-- 访问 /admin 时代码从 /data/project/admin.war 加载 -->
<Context path="/admin" docBase="/data/project/admin.war" reloadable="true" />
</Host>
</Engine>

默认 8005 那个关闭端口很危险——本机任意用户都能发个字符串让 Tomcat 退出,所以生产环境把它改掉、绑到 127.0.0.1、关闭命令换成随机串。

四、war 包部署:三种方式,生产选第三种

方式 操作 适用
自动部署 war 丢进 webapps/,自动解压加载 测试环境,图快
改 server.xml 加 Context 路径自定义,但要重启 少用
独立 xml 配置文件 conf/Catalina/域名/xxx.xml,一个项目一个文件 生产首选

第三种是我现在的标准做法:不碰主配置、热部署、回滚方便。

1
2
mkdir -p /usr/local/tomcat/conf/Catalina/localhost/
vim /usr/local/tomcat/conf/Catalina/localhost/test.xml
1
<Context docBase="/data/project/test.war" reloadable="true" />

文件名就是访问路径,访问 http://IP:8080/test/ 即可。想让项目直接挂根路径,把 war 命名为 ROOT.war。

发布规范上我们后来固定成一条流程:war 包先放 /data/release/ 带版本号归档 → 停实例(集群节点轮流停)→ 替换 → 起服务 → curl -I 验证 → 观察 5 分钟再动下一个节点。目的是任何一次发布都能在 3 分钟内退回上一个版本。

五、Connector 线程池:先搞清楚参数关系

Tomcat 8 之后默认就是 NIO,日常够用;追求更高并发可以上 NIO2。真正要调的是线程和连接数参数:

1
2
3
4
5
6
7
8
9
<Connector port="8080" protocol="org.apache.coyote.http11.Http11Nio2Protocol"
maxThreads="500" <!-- 最大工作线程:同一时刻能处理多少请求 -->
minSpareThreads="50" <!-- 最小空闲线程,低于这个数就补 -->
acceptCount="200" <!-- 排队队列:线程满了之后最多排多少 -->
maxConnections="10000" <!-- 最大连接数:含排队中的,超过直接拒 -->
connectionTimeout="10000"
enableLookups="false" <!-- 关 DNS 反查 -->
URIEncoding="UTF-8"
server="Unknown" /> <!-- 隐藏版本号 -->

有两组关系值得记住:

  • maxThreads + acceptCount 就是这台机器能扛的瞬时请求上限,超了就是连接被拒;
  • 线程数不是越大越好,500 以上还压不住,通常是后端有慢逻辑,加大线程只会让上下文切换把 CPU 吃光。

我们压测过一次:4 核 8G 的机器,maxThreads 从 200 加到 500,QPS 提升不到 10%,但 Load 上升明显——线程池要按压测结果调,不要拍脑袋。

六、JVM 参数:默认值就是等着出事故

Tomcat 默认堆小得可怜,稍微有点流量就 OOM。参数写在 bin/catalina.sh 顶部:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
JAVA_OPTS="
-server
-Xms2048m
-Xmx2048m
-Xmn768m
-XX:MetaspaceSize=256m
-XX:MaxMetaspaceSize=512m
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
-Xloggc:/data/logs/tomcat/gc.log
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/data/logs/tomcat/oom.hprof
-Djava.awt.headless=true
-Dfile.encoding=UTF-8
"

几个关键项:

  • -Xms 和 -Xmx 设成一样,避免堆动态伸缩带来的抖动;
  • 元空间默认太小,Spring 系应用类加载多,不调大很容易 Metaspace OOM;
  • -XX:+HeapDumpOnOutOfMemoryError 必开——OOM 发生时现场只出现一次,有 dump 才有得分析;
  • GC 日志必开,Full GC 频率是判断”内存够不够”最直接的指标。

OOM 排查路径

真出事的时候,按这条线走,不要瞎猜:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 1. 先确认是什么类型的 OOM
grep -i "OutOfMemory" /usr/local/tomcat/logs/catalina.out
# Java heap space → 堆满,对象太多或泄漏
# Metaspace → 类加载泄漏,常见于反复热部署
# GC overhead limit → 对象多到 GC 都来不及回收

# 2. 看堆的实时状态
jps -l # 找到 Tomcat 的 PID
jmap -heap PID # 堆配置和当前占用
jstat -gcutil PID 1000 5 # 每秒刷新,重点看 FGC 次数

# 3. 定位"谁在占内存"
jmap -histo PID | head -30 # 对象占用 TOP,疑似泄漏类一眼能看出来

# 4. 有 dump 文件就用 MAT 打开,直接看 Leak Suspects 报告

处理顺序也有讲究:先重启恢复业务(止血)→ 再决定调大 -Xmx(临时)→ 最后必须推动开发修代码里的泄漏(根治)。只做前两步的,过几天还会再来一次。

七、日志:两块必须配置到位

1
2
# catalina.out 无限增长,不切割几个月就能占满磁盘
cat /etc/logrotate.d/tomcat
1
2
3
4
5
6
7
8
9
10
/data/logs/tomcat/catalina.out {
daily
rotate 30
compress
delaycompress
missingok
notifempty
copytruncate # 不重启进程,先拷贝再清空
dateext
}

访问日志要加耗时字段,这是排查慢接口的依据:

1
2
3
4
<Valve className="org.apache.catalina.valves.AccessLogValve" directory="logs"
prefix="localhost_access_log" suffix=".txt"
pattern="%h %l %u %t &quot;%r&quot; %s %b %D" />
<!-- %D 是请求耗时(微秒),超过 3 秒的接口值得单独拉出来看 -->

另外两个约定:日志目录必须放到数据盘,别跟系统盘抢空间;日志级别生产固定 INFO,排查问题时临时开 DEBUG,查完立刻改回去——DEBUG 的日志量是 INFO 的几十倍,开一晚上能写满一块盘。

八、上线前安全基线

这份清单每次部署新实例都过一遍,一条都不能少:

1
2
3
4
5
6
7
8
9
10
11
# 1. 删掉所有默认应用(管理页面是爆破重灾区)
rm -rf webapps/{ROOT,manager,host-manager,docs,examples}

# 2. 注释 AJP 连接器(Ghostcat 任意文件读取/命令执行)
# 3. 改关闭端口 + 随机命令 + 绑 127.0.0.1
# 4. Connector 加 server="Unknown",错误页不暴露版本
# 5. 禁止 root 运行:建 tomcat 用户,chown 整个目录
useradd tomcat -s /sbin/nologin -M
chown -R tomcat:tomcat /usr/local/tomcat

# 6. 8080 只放行 Nginx 节点

漏掉默认应用那次印象很深:manager 页面被外部扫到,一晚上几十万次爆破尝试,虽然密码复杂没被攻破,但日志量直接把磁盘告警打响了。

最后

Tomcat 的配置项不多,但要踩的坑基本都在”默认值”里:默认端口、默认堆、默认应用、默认权限。新人最容易犯的错是先跑起来再补配置——正确顺序是先按基线配好,再部署第一个应用。这份基线后来帮我省了很多次半夜被叫起来的时间。