Tomcat 部署运维
本文范围
这篇文章讲传统 Java Web / Tomcat 应用的部署运维主线:版本选择、javax / jakarta 边界、离线安装包、目录结构、server.xml、WAR 和 Context 部署、setenv.sh、JVM 参数、systemd、Nginx 代理、日志、发布回滚、线程池、Session、静态资源和常见 404 / 502 / 线程耗尽 / OOM / 类加载故障。
本文不展开 Servlet/JSP 编程教程、Spring MVC 业务开发、应用重构、Tomcat 源码和高阶集群方案。后面所有配置都服务于一个目标:让传统 WAR 应用能被稳定安装、发布、回滚、观察和排障。
读完以后,你应该能判断一个老 Java Web 项目该跑 Tomcat 9、10.1 还是 11,能把 WAR 部署到隔离目录,并能在故障现场分清 Nginx、Tomcat、应用、JVM、日志和类加载问题。
前置条件
建议先准备:
- 一个可部署的 WAR 包,或者一个已经解压的 Web 应用目录。
- 一台 Linux 服务器和对应 JDK;截至 2026-07-10,Tomcat 官方版本页显示 Tomcat 9.0.x 支持 Java 8 及以上,Tomcat 10.1.x 支持 Java 11 及以上,Tomcat 11.0.x 支持 Java 17 及以上。
- 一个普通运行用户,例如
tomcat,以及独立的CATALINA_HOME、CATALINA_BASE、日志、dump、备份目录。 - 能使用
java、curl、ss、jar、systemctl、journalctl、jcmd等命令。 - 如果是企业内网或离线环境,要提前准备 Tomcat 包、JDK 包、校验文件、业务 WAR、配置模板和回滚包。
先说清楚 Tomcat 到底管哪一段
很多老 Java Web 项目部署时,现场其实就三件事:前面有 Nginx,后面有 Tomcat,中间放一个 WAR 包。访问出问题时,用户只看到 404、502、超时或者白屏,但运维要能马上判断问题卡在哪一层。
Tomcat 主要负责这些事情:
- 监听 HTTP 端口,例如
8080。 - 加载 WAR 包或解压后的 Web 应用。
- 按 Context Path 把
/app这样的路径交给对应应用。 - 用工作线程处理请求。
- 管 Session、JSP、Servlet、Filter、Listener 这些传统 Java Web 能力。
- 输出容器日志、访问日志和应用控制台日志。
所以这篇文章不把 Tomcat 当成一个“配置文件大全”来背。我们按现场部署顺序走一遍:先选版本,准备安装包,装起来,部署 WAR,接 Nginx,做 systemd,最后把 404、502、线程耗尽、OOM、日志爆盘这些故障按现象排掉。
这里先记住一个判断方法:
curl -i http://127.0.0.1:8080/app/health
curl -i http://app.example.com/app/health第一条是直接打 Tomcat,第二条是从外部入口打 Nginx。如果第一条正常,第二条不正常,优先查 Nginx、域名、安全组、代理路径。如果第一条也不正常,再查 Tomcat、WAR、Context、应用日志。
Tomcat 请求处理与部署拓扑图
这张图可以放到设计评审、上线检查和故障接管里用。评审时从左到右问:外部入口是否只到 Nginx,Tomcat 8080 是否只对本机或内网开放,server.xml 的 Connector、Host、Context 是否和发布目录一致,日志和 JMX 是否能在故障时给出证据。排障时则反过来走:先分清 Nginx、Connector、WebApp、下游依赖和日志观测到底是哪一层出了问题。
图里最容易被忽略的是两条虚线:一条是网络边界,另一条是管理端安全边界。Tomcat 不是公网入口,manager / host-manager 也不是给外部用户看的后台。上线前至少要能用 curl 验证 Nginx 外部入口,用 ss -lntp 验证 Tomcat 监听地址,用 access log、catalina.out、JMX 或线程栈验证请求有没有进入容器。
版本先别乱选
Tomcat 升级最容易被一句“换个新版本”带偏。传统 Java Web 老项目第一件事不是问 Tomcat 最新是多少,而是看应用用的是 javax.servlet.* 还是 jakarta.servlet.*。
截至 2026-07-09,Apache Tomcat 官方版本页显示当前支持的主线是:
| Tomcat 主线 | 最新版本 | Java 要求 | 适合什么应用 |
|---|---|---|---|
| Tomcat 9.0.x | 9.0.120 | Java 8 及以上 | 传统 Java EE 8、javax.servlet.* 老项目 |
| Tomcat 10.1.x | 10.1.57 | Java 11 及以上 | 已完成 Jakarta EE 10 迁移的项目 |
| Tomcat 11.0.x | 11.0.24 | Java 17 及以上 | 新 Jakarta EE 11 项目 |
一般老系统如果还是 javax.servlet、老 Spring MVC、老 Struts、旧 JSP taglib,就先按 Tomcat 9 部署。Tomcat 10/11 已经切到 jakarta.*,不是把包解压替换一下就能跑。
先在项目里查一下:
grep -R "javax.servlet\\|jakarta.servlet" -n src/main webapp 2>/dev/null | head这条命令是为了看源码里到底引用哪套 Servlet 包。如果看到大量 javax.servlet,就不要直接上 Tomcat 10/11。如果没有源码,只能拿到 WAR 包,可以这样看:
jar tf app.war | grep -E "WEB-INF/lib|WEB-INF/web.xml" | head这条命令先确认 WAR 包结构是否正常。后面还可以把 WAR 解压出来,再看 WEB-INF/lib 里的 Spring、Servlet、JSP 相关依赖版本。
Tomcat 和 JDK 版本也要一起确认:
java -version
/opt/tomcat/current/bin/version.sh正常情况下,你应该能看到 JDK 版本、Tomcat 版本、JVM Vendor、CATALINA_HOME 等信息。如果 version.sh 都跑不起来,先别急着部署业务 WAR,先把 JDK 路径和 Tomcat 解压目录修好。
这里要注意:JDK 太低会出现 UnsupportedClassVersionError,Tomcat 主线不匹配会出现 ClassNotFoundException: javax.servlet... 或 jakarta.servlet...。这类问题不是调端口能解决的。
国内、内网、离线环境先准备包
生产现场不要把“临时 wget 一下”当方案。尤其是中国大陆公网、企业内网、离线交付环境,真正要准备的是一套能重复安装的包。
公网下载机上先准备 Tomcat 9:
export TOMCAT_VERSION=9.0.120
mkdir -p /opt/offline/tomcat
cd /opt/offline/tomcat
curl -fLO "https://dlcdn.apache.org/tomcat/tomcat-9/v${TOMCAT_VERSION}/bin/apache-tomcat-${TOMCAT_VERSION}.tar.gz"
curl -fLO "https://downloads.apache.org/tomcat/tomcat-9/v${TOMCAT_VERSION}/bin/apache-tomcat-${TOMCAT_VERSION}.tar.gz.sha512"
curl -fLO "https://downloads.apache.org/tomcat/tomcat-9/v${TOMCAT_VERSION}/bin/apache-tomcat-${TOMCAT_VERSION}.tar.gz.asc"这里下载三个文件:Tomcat 安装包、SHA-512 校验文件、PGP 签名文件。下载慢不奇怪,企业里一般是在一台能访问公网的下载机上准备好,再放到内网制品库或文件服务器。
先做 SHA-512 校验:
sha512sum -c "apache-tomcat-${TOMCAT_VERSION}.tar.gz.sha512"看到 OK 才继续。如果失败,说明包不完整或者校验文件和安装包不是同一个版本。不要抱着侥幸心理继续装。
如果企业要求签名校验,再导入 Apache Tomcat 的 KEYS:
curl -fLO "https://downloads.apache.org/tomcat/tomcat-9/KEYS"
gpg --import KEYS
gpg --verify "apache-tomcat-${TOMCAT_VERSION}.tar.gz.asc" "apache-tomcat-${TOMCAT_VERSION}.tar.gz"看到 Good signature 才算通过。这里要注意,GPG 可能提示公钥信任级别未知,这和签名数学验证是两回事,企业里一般会把可信公钥和校验结果一起归档。
内网建议把这些东西放到固定仓库:
| 资产 | 为什么要准备 |
|---|---|
| JDK 安装包 | 没有 JDK,Tomcat 起不来 |
| Tomcat tar.gz、sha512、asc、KEYS | 可安装、可校验、可追溯 |
| WAR 包 | 业务制品,必须带版本号或构建号 |
server.xml、Context、setenv.sh | 部署配置不能靠现场手写 |
| systemd unit、Nginx 配置 | 服务托管和入口代理要可重复 |
| 发布脚本、回滚脚本 | 出问题时不能临时拼命令 |
离线交付时,最好找一台完全没有外网的机器演练一次。只要演练时还需要临时下载,就说明离线包没准备完整。
第一步:创建运行用户和目录
Tomcat 不要用 root 跑。用 root 跑起来确实省事,但后面日志、上传文件、应用漏洞、脚本误操作都会放大风险。
先建用户:
sudo groupadd --system tomcat
sudo useradd --system --gid tomcat --home-dir /opt/tomcat --shell /sbin/nologin tomcat这两条命令创建一个系统用户 tomcat。/sbin/nologin 表示这个用户不能直接登录,只负责运行服务。
再规划目录:
sudo mkdir -p /opt/tomcat
sudo mkdir -p /data/tomcat/app1/{conf,logs,temp,webapps,work,dump,bin}
sudo mkdir -p /srv/apps/app1/{releases,backups}
sudo mkdir -p /var/log/app1这几个目录分别干什么:
| 目录 | 用途 |
|---|---|
/opt/tomcat | 放 Tomcat 程序本身 |
/data/tomcat/app1 | 放某个 Tomcat 实例的配置、日志、临时文件 |
/srv/apps/app1/releases | 放每一次发布的 WAR 包 |
/srv/apps/app1/backups | 放回滚包或备份 |
/var/log/app1 | 放业务应用自己的日志 |
给权限:
sudo chown -R tomcat:tomcat /opt/tomcat /data/tomcat/app1 /srv/apps/app1 /var/log/app1执行后可以看一下:
ls -ld /opt/tomcat /data/tomcat/app1 /srv/apps/app1 /var/log/app1结果里 owner 应该是 tomcat tomcat。如果还是 root root,后面 Tomcat 很可能在写日志、解压 WAR、生成 dump 时失败。
第二步:安装 Tomcat
把下载好的安装包放到服务器后,解压到 /opt/tomcat:
export TOMCAT_VERSION=9.0.120
sudo tar -zxf "apache-tomcat-${TOMCAT_VERSION}.tar.gz" -C /opt/tomcat
sudo ln -sfn "/opt/tomcat/apache-tomcat-${TOMCAT_VERSION}" /opt/tomcat/current
sudo chown -R tomcat:tomcat /opt/tomcat
sudo chmod +x /opt/tomcat/current/bin/*.sh这里用 current 软链接指向当前 Tomcat 版本。以后升级 Tomcat,不需要改一堆脚本,只要把 current 切到新目录。
验证一下:
sudo -u tomcat /opt/tomcat/current/bin/version.sh正常应该看到 Tomcat 版本、Server built、JVM Version。如果提示 Permission denied,检查 bin/*.sh 是否有执行权限。如果提示找不到 Java,检查 JAVA_HOME 或系统 java 命令。
接着初始化应用实例配置:
sudo cp -a /opt/tomcat/current/conf/* /data/tomcat/app1/conf/
sudo chown -R tomcat:tomcat /data/tomcat/app1这里要注意两个变量:
| 变量 | 通俗解释 |
|---|---|
CATALINA_HOME | Tomcat 程序目录,也就是 /opt/tomcat/current |
CATALINA_BASE | 这个应用实例自己的配置、日志、webapps、work 目录 |
为什么要分开?因为一台机器可能跑多个 Tomcat 实例,Tomcat 程序可以共用,但端口、日志、WAR 包、配置不能混在一起。
第三步:先看懂目录结构
Tomcat 装完以后,不要一上来就改 server.xml。先知道每个目录是干什么的。
ls -lah /opt/tomcat/current
ls -lah /data/tomcat/app1常见目录这样理解:
| 目录 | 现场怎么用 |
|---|---|
bin | 启动停止脚本,startup.sh、shutdown.sh、catalina.sh |
conf | 配置目录,重点看 server.xml、context.xml、logging.properties |
lib | Tomcat 级公共 jar,不要乱放业务 jar |
logs | Tomcat 日志,启动失败先看这里 |
webapps | 默认 WAR 部署目录 |
work | JSP 编译和缓存目录,排查 JSP/旧包时可能清理 |
temp | 临时目录,上传、解压、运行时临时文件会用到 |
一般生产不要把用户上传文件放到 webapps 下面。比如这样就不太好:
/data/tomcat/app1/webapps/app/upload因为重新部署、清理 webapps、切换版本时,可能把用户上传文件一起干掉。更建议放到独立数据目录:
/data/app1/uploads
/data/app1/exports
/data/app1/tmp然后用应用参数告诉程序:
-Dapp.upload.dir=/data/app1/uploads第四步:配置 server.xml
server.xml 里内容很多,刚开始只看三块:shutdown 端口、HTTP Connector、Host/Context。
打开配置:
sudo -u tomcat vi /data/tomcat/app1/conf/server.xml先看最外层:
<Server port="8005" shutdown="SHUTDOWN">8005 是 shutdown 端口,不是业务访问端口。如果一台机器跑多个 Tomcat,这个端口也必须错开。端口冲突时,Tomcat 会启动失败。
再看 HTTP Connector:
<Connector
port="8080"
address="127.0.0.1"
protocol="org.apache.coyote.http11.Http11NioProtocol"
connectionTimeout="20000"
keepAliveTimeout="15000"
maxThreads="200"
minSpareThreads="20"
maxConnections="8192"
acceptCount="100"
URIEncoding="UTF-8"
redirectPort="8443" />这里几个参数要知道:
| 参数 | 解决什么问题 |
|---|---|
port | Tomcat 监听哪个端口 |
address | 绑定哪个网卡地址 |
maxThreads | 最多多少请求处理线程 |
maxConnections | 最多接住多少连接 |
acceptCount | 线程和连接忙不过来时,队列里能排多少 |
connectionTimeout | 连接建立后等请求的时间 |
URIEncoding | URL 中文和特殊字符按什么编码解析 |
如果 Nginx 和 Tomcat 在同一台机器,address="127.0.0.1" 就够了。这样外部访问不到 Tomcat,只能通过 Nginx 进来。
如果 Nginx 在另一台机器,Tomcat 要绑定内网 IP:
<Connector port="8080" address="10.0.0.10" protocol="org.apache.coyote.http11.Http11NioProtocol" />这里要配合安全组和防火墙,只允许 Nginx 或网关访问 8080。不要把 Tomcat 直接暴露到公网。
再看 Host:
<Host
name="localhost"
appBase="webapps"
unpackWARs="true"
autoDeploy="false"
deployOnStartup="true">
</Host>生产里一般建议 autoDeploy="false"。意思是不要让 Tomcat 运行中自动扫描、自动热部署。老项目如果以前靠“把 WAR 直接丢进去”上线,先把发布流程改掉,再关这个开关。
如果用 AJP,先问一句:真的需要吗?
<!--
<Connector protocol="AJP/1.3" address="127.0.0.1" port="8009" />
-->Nginx 代理 Tomcat,通常走 HTTP 就够了。AJP 如果必须使用,至少要绑定本地或内网地址,并配置 secret:
<Connector
protocol="AJP/1.3"
address="127.0.0.1"
port="8009"
secretRequired="true"
secret="change-this-to-a-long-random-secret"
redirectPort="8443" />看到 secretRequired="false" 不要照抄到生产。AJP 端口更不能暴露公网。
改完以后先不要启动业务,先查端口是否被占:
sudo ss -lntp | grep -E ':8080|:8005|:8009' || true如果这里已经有进程占着 8080,Tomcat 启动时就会报 Address already in use。
第五步:部署一个 WAR 包
最简单的方式是把 WAR 放到 webapps:
sudo -u tomcat cp app.war /data/tomcat/app1/webapps/app.war这个方式适合学习和测试。访问路径一般是:
http://服务器IP:8080/app/规则很好记:
| WAR 名称 | 访问路径 |
|---|---|
app.war | /app/ |
ROOT.war | / |
demo#v1.war | /demo/v1/ |
生产里更推荐用 Context 文件指向发布目录,这样发布和回滚更清楚。
先准备 release 目录:
export RELEASE_ID=20260709-153000
sudo mkdir -p "/srv/apps/app1/releases/${RELEASE_ID}"
sudo cp app.war "/srv/apps/app1/releases/${RELEASE_ID}/app.war"
sudo ln -sfn "/srv/apps/app1/releases/${RELEASE_ID}" /srv/apps/app1/current
sudo chown -R tomcat:tomcat /srv/apps/app1这几条命令做了三件事:新建一次发布目录,把 WAR 放进去,然后把 current 指向这次发布。以后回滚时,只要把 current 指回旧版本。
创建 Context 文件:
sudo mkdir -p /data/tomcat/app1/conf/Catalina/localhost
sudo -u tomcat vi /data/tomcat/app1/conf/Catalina/localhost/app.xml内容如下:
<Context docBase="/srv/apps/app1/current/app.war" reloadable="false" />这里文件名 app.xml 决定访问路径是 /app。如果要部署成根路径,就用 ROOT.xml。
这里要注意:
reloadable="false",生产不要靠自动 reload。- 不要一边写 Context,一边又在
webapps放同名 WAR,容易重复部署。 - 不要在 Tomcat 正运行时直接覆盖解压目录。
- 如果 WAR 很大,可以先解压到新 release 目录,再切软链接。
验证 WAR 结构:
jar tf app.war | head
jar tf app.war | grep -E 'WEB-INF/web.xml|WEB-INF/lib' | head如果 jar tf 都报错,说明 WAR 包不是一个正常 zip/jar 格式文件。不要部署,先让构建侧重新出包。
第六步:写 setenv.sh
不要直接改 catalina.sh。Tomcat 会自动读取 bin/setenv.sh,我们把 JVM 参数、应用环境参数放这里。
新建文件:
sudo -u tomcat vi /data/tomcat/app1/bin/setenv.sh写入:
#!/usr/bin/env bash
export JAVA_HOME=/usr/lib/jvm/java-17-openjdk
export CATALINA_HOME=/opt/tomcat/current
export CATALINA_BASE=/data/tomcat/app1
export CATALINA_TMPDIR=/data/tomcat/app1/temp
export CATALINA_OUT=/data/tomcat/app1/logs/catalina.out
JAVA_OPTS="${JAVA_OPTS} -Dfile.encoding=UTF-8"
JAVA_OPTS="${JAVA_OPTS} -Duser.timezone=Asia/Shanghai"
CATALINA_OPTS="${CATALINA_OPTS} -Xms2g -Xmx2g"
CATALINA_OPTS="${CATALINA_OPTS} -XX:+HeapDumpOnOutOfMemoryError"
CATALINA_OPTS="${CATALINA_OPTS} -XX:HeapDumpPath=/data/tomcat/app1/dump"
CATALINA_OPTS="${CATALINA_OPTS} -Dapp.env=prod"
CATALINA_OPTS="${CATALINA_OPTS} -Dapp.upload.dir=/data/app1/uploads"
export JAVA_OPTS
export CATALINA_OPTS保存后加执行权限:
sudo chmod +x /data/tomcat/app1/bin/setenv.sh这里解释一下:
JAVA_OPTS更适合放编码、时区这类通用 JVM 属性。CATALINA_OPTS更适合放 Tomcat 运行时参数,比如堆内存、GC、业务环境。-Xms2g -Xmx2g只是示例,不是万能值。- 密码、Token、密钥不要直接写在这个文件里。
JDK 11/17 的 GC 日志可以这样加:
CATALINA_OPTS="${CATALINA_OPTS} -Xlog:gc*,safepoint:file=/data/tomcat/app1/logs/gc-%t.log:time,uptime,level,tags:filecount=10,filesize=100m"这条参数解决的是:出问题时能看到 GC 是否频繁、停顿是否很长、堆是不是一直回收不下来。
JDK 8 用这个:
CATALINA_OPTS="${CATALINA_OPTS} -XX:+PrintGCDetails -XX:+PrintGCDateStamps"
CATALINA_OPTS="${CATALINA_OPTS} -Xloggc:/data/tomcat/app1/logs/gc.log"
CATALINA_OPTS="${CATALINA_OPTS} -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=10 -XX:GCLogFileSize=100M"验证参数是否生效:
PID=$(pgrep -f 'org.apache.catalina.startup.Bootstrap')
jcmd "$PID" VM.flags
jcmd "$PID" VM.system_properties | grep -E 'file.encoding|user.timezone|app.env'如果 jcmd 找不到进程,说明 Tomcat 还没启动,先到下一节启动。
第七步:先手工启动一次
第一次不要急着写 systemd,先手工跑通,方便看日志。
启动:
sudo -u tomcat CATALINA_HOME=/opt/tomcat/current CATALINA_BASE=/data/tomcat/app1 /opt/tomcat/current/bin/startup.sh看日志:
tail -f /data/tomcat/app1/logs/catalina.out正常启动时,你应该看到类似 Server startup in ... milliseconds。如果看到 SEVERE、Exception、Address already in use,先不要继续配 Nginx,先把 Tomcat 自己修好。
看端口:
ss -lntp | grep ':8080'如果能看到 LISTEN,说明端口起来了。
访问:
curl -i http://127.0.0.1:8080/app/返回 200、302 或应用自己的登录页,都说明请求到了应用。返回 404 也不要慌,先看 /data/tomcat/app1/logs/localhost.YYYY-MM-DD.log,确认应用有没有部署成功。
停止:
sudo -u tomcat CATALINA_HOME=/opt/tomcat/current CATALINA_BASE=/data/tomcat/app1 /opt/tomcat/current/bin/shutdown.sh这里要注意,不要上来就 kill -9。正常停止顺序是:
- 先
shutdown.sh或systemctl stop。 - 等几十秒,让请求处理完。
- 仍不退出,再
kill -15。 - 最后才是保存线程栈后
kill -9。
抓线程栈:
PID=$(pgrep -f 'org.apache.catalina.startup.Bootstrap')
jcmd "$PID" Thread.print > "/tmp/tomcat-thread-$(date +%Y%m%d-%H%M%S).txt"线程栈能告诉你进程为什么停不下来,是卡在数据库、HTTP 调用、锁,还是文件 IO。
第八步:交给 systemd 托管
手工启动适合验证,不适合生产。生产要让 systemd 管起来,机器重启后能自动拉起,服务挂了能看到状态。
新建 unit:
sudo vi /etc/systemd/system/tomcat-app1.service写入:
[Unit]
Description=Tomcat App1
After=network.target
[Service]
Type=simple
User=tomcat
Group=tomcat
Environment="JAVA_HOME=/usr/lib/jvm/java-17-openjdk"
Environment="CATALINA_HOME=/opt/tomcat/current"
Environment="CATALINA_BASE=/data/tomcat/app1"
Environment="CATALINA_TMPDIR=/data/tomcat/app1/temp"
WorkingDirectory=/data/tomcat/app1
ExecStart=/opt/tomcat/current/bin/catalina.sh run
ExecStop=/bin/kill -15 $MAINPID
Restart=on-failure
RestartSec=10
TimeoutStopSec=60
SuccessExitStatus=143
LimitNOFILE=65535
[Install]
WantedBy=multi-user.target这里用 catalina.sh run,让 Tomcat 前台运行,systemd 能直接管理主进程。LimitNOFILE=65535 是提高文件句柄上限,避免高并发连接、日志、jar 文件句柄不够。
加载并启动:
sudo systemctl daemon-reload
sudo systemctl enable tomcat-app1
sudo systemctl start tomcat-app1
sudo systemctl status tomcat-app1 --no-pager状态里看到 active (running) 才算起来。再看日志:
journalctl -u tomcat-app1 -n 200 --no-pager
journalctl -u tomcat-app1 -f如果启动失败,先看:
journalctl -u tomcat-app1 -n 300 --no-pager
tail -n 300 /data/tomcat/app1/logs/catalina.out
tail -n 300 /data/tomcat/app1/logs/localhost.$(date +%Y-%m-%d).log这里一般能看到真正原因,比如端口占用、权限不足、JDK 版本不对、XML 配错、WAR 部署失败。
第九步:接 Nginx
Tomcat 能本机访问以后,再接 Nginx。不要 Tomcat 还没跑通就调 Nginx,那样排障会绕远。
Nginx 配置示例:
upstream tomcat_app1 {
server 127.0.0.1:8080 max_fails=3 fail_timeout=30s;
keepalive 32;
}
server {
listen 80;
server_name app.example.com;
access_log /var/log/nginx/app1.access.log main;
error_log /var/log/nginx/app1.error.log warn;
location /app/ {
proxy_pass http://tomcat_app1;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 5s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
}检查配置:
sudo nginx -t看到 syntax is ok 和 test is successful 再 reload:
sudo nginx -s reload验证两层访问:
curl -i http://127.0.0.1:8080/app/health
curl -i http://app.example.com/app/health如果第一条正常,第二条 502,优先看 Nginx upstream、Tomcat 绑定地址、防火墙。如果第二条 404,优先看 location 和 proxy_pass 路径。
如果外部访问根路径 /,后端实际是 /app/,可以这样写:
location / {
proxy_pass http://tomcat_app1/app/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}这里要特别注意:proxy_pass 后面带不带路径,Nginx 转发给后端的 URI 会不一样。改完以后一定用 curl -i 看最终结果,不要只看页面感觉。
Tomcat 里建议加 RemoteIpValve:
<Valve
className="org.apache.catalina.valves.RemoteIpValve"
remoteIpHeader="x-forwarded-for"
protocolHeader="x-forwarded-proto"
protocolHeaderHttpsValue="https" />这解决的是应用拿真实客户端 IP 和 HTTPS scheme 的问题。不加的话,应用可能认为请求来自 127.0.0.1,也可能生成错误的 HTTP 回调地址。
第十步:日志要能看得懂
Tomcat 出问题,第一反应不是重启,而是看日志。日志要分清楚:
| 日志 | 看什么 |
|---|---|
catalina.out | 控制台输出、启动异常、线程 dump |
catalina.YYYY-MM-DD.log | Tomcat 容器自身日志 |
localhost.YYYY-MM-DD.log | Host、Context、WAR 部署异常 |
access_log.YYYY-MM-DD.log | 请求路径、状态码、耗时 |
| 应用日志 | 业务异常、SQL、下游调用 |
| GC 日志 | GC 频率、停顿、内存回收 |
建议打开访问日志,并带上耗时:
<Valve
className="org.apache.catalina.valves.AccessLogValve"
directory="logs"
prefix="access_log"
suffix=".log"
pattern="%h %l %u %t "%r" %s %b %D" />%D 是请求处理耗时,单位是微秒。排查慢接口、线程耗尽、Nginx 504 时很有用。
看最近的容器错误:
grep -R "SEVERE\\|Exception\\|ERROR" /data/tomcat/app1/logs | tail -n 100看最慢的请求:
tail -n 5000 /data/tomcat/app1/logs/access_log.$(date +%Y-%m-%d).log \
| awk '{print $NF, $0}' \
| sort -n \
| tail -n 20如果 catalina.out 越长越大,先看是谁在刷:
du -sh /data/tomcat/app1/logs/*
tail -n 200 /data/tomcat/app1/logs/catalina.out兜底可以加 logrotate:
/data/tomcat/app1/logs/catalina.out {
daily
rotate 14
compress
missingok
notifempty
copytruncate
}这里要注意,copytruncate 不需要重启进程,但极端情况下可能丢少量日志。更好的方式是让 logback/log4j 自己按大小和日期滚动,减少控制台输出。
第十一步:发布和回滚要固定流程
发布 Tomcat 应用,不是复制 WAR 然后重启这么简单。上线前先检查环境:
java -version
/opt/tomcat/current/bin/version.sh
df -h
ss -lntp | grep -E ':8080|:8005|:8009' || true
sudo nginx -t
systemctl is-active tomcat-app1这些命令分别检查 JDK、Tomcat、磁盘、端口、Nginx 配置和服务状态。任何一个失败,都不要继续发布。
再检查 WAR:
sha256sum app.war
jar tf app.war | head
jar tf app.war | grep -E 'WEB-INF/web.xml|WEB-INF/lib' | head发布脚本要加锁,避免两个人同时发:
LOCK_FILE=/var/lock/app1-deploy.lock
flock -n "$LOCK_FILE" bash -c '
set -euo pipefail
echo "deploy start: $(date)"
# backup, deploy, restart, health check
'简化版发布流程:
set -euo pipefail
APP=app1
RELEASE_ID=$(date +%Y%m%d-%H%M%S)
RELEASE_DIR="/srv/apps/${APP}/releases/${RELEASE_ID}"
WAR_FILE="/tmp/app.war"
sudo mkdir -p "$RELEASE_DIR"
sudo cp "$WAR_FILE" "${RELEASE_DIR}/app.war"
sudo chown -R tomcat:tomcat "$RELEASE_DIR"
sudo ln -sfn "$RELEASE_DIR" "/srv/apps/${APP}/current"
sudo systemctl restart "tomcat-${APP}"
for i in $(seq 1 30); do
if curl -fsS "http://127.0.0.1:8080/app/health" >/dev/null; then
echo "health check ok"
exit 0
fi
sleep 2
done
echo "health check failed"
sudo journalctl -u "tomcat-${APP}" -n 200 --no-pager
exit 1这里最重要的是健康检查。进程启动不代表应用可用,端口监听也不代表业务初始化完成。访问健康接口通过,才算这一台机器发布完成。
回滚也要提前写好:
sudo ln -sfn /srv/apps/app1/releases/20260708-210000 /srv/apps/app1/current
sudo systemctl restart tomcat-app1
for i in $(seq 1 30); do
curl -fsS http://127.0.0.1:8080/app/health && break
sleep 2
done多实例生产发布一般这么走:
- 从 Nginx 或负载均衡摘掉一台。
- 等老请求处理完。
- 部署新 WAR。
- 重启 Tomcat。
- 本机健康检查。
- 加回流量。
- 观察错误率、耗时、GC、线程。
- 再发下一台。
不要所有节点同时重启,除非业务明确允许停机。
调优先别只调 maxThreads
接口慢时,很多人第一反应是调大 maxThreads。这有时有用,但更多时候只是让更多请求一起卡在数据库、Redis、第三方接口或锁上。
先抓证据:
PID=$(pgrep -f 'org.apache.catalina.startup.Bootstrap')
jcmd "$PID" Thread.print > /tmp/tomcat-thread.txt
ss -ant state established '( sport = :8080 )' | wc -l线程栈里重点看 http-nio-8080-exec-*:
- 大量卡在 JDBC,查慢 SQL 和连接池。
- 大量卡在 HTTP 客户端,查下游超时和重试。
- 大量
BLOCKED,查锁竞争。 - 大量文件 IO,查磁盘、网络文件系统、日志同步写。
如果确认是入口线程不足,且 CPU、内存、DB、下游都有余量,再调:
<Executor
name="tomcatThreadPool"
namePrefix="http-nio-8080-exec-"
maxThreads="300"
minSpareThreads="50" />
<Connector
port="8080"
address="127.0.0.1"
protocol="org.apache.coyote.http11.Http11NioProtocol"
executor="tomcatThreadPool"
maxConnections="10000"
acceptCount="300"
connectionTimeout="20000"
URIEncoding="UTF-8" />这里要一起看:
| 组件 | 参数 |
|---|---|
| Tomcat | maxThreads、maxConnections、acceptCount |
| Nginx | proxy_connect_timeout、proxy_read_timeout |
| DB 连接池 | 最大连接数、等待超时 |
| HTTP 客户端 | 连接超时、读超时、连接池 |
| JVM | 堆、线程栈、GC |
| OS | ulimit -n、文件句柄 |
入口线程数不能脱离下游容量单独变大。否则看上去并发高了,实际上只是排队和超时变多了。
Tomcat 线程池与 5xx / 超时排查图
这张图适合故障现场贴在排障记录里。它不是让你照着改大 maxThreads,而是先把 Nginx 看到的 502 / 504、Tomcat 线程、JVM、下游连接池和日志证据串起来。只有确认 CPU、堆、DB 连接池、下游接口和文件句柄还有余量,才考虑调整 server.xml。
现场最怕把 504 当成“Tomcat 线程不够”一把梭。acceptCount 只是排队,proxy_read_timeout 只是等待,maxThreads 只是让更多请求进入应用;真正决定稳定性的,是 Tomcat 入口、应用慢接口、DB 连接池、下游超时和 JVM 资源有没有一起对齐。
Session 和静态资源也要提前想
老 Java Web 应用经常把登录态放在内存 Session。单机没问题,一到多实例和滚动发布就容易出事。
常见模式:
| 模式 | 适合场景 | 风险 |
|---|---|---|
| 内存 Session | 单机、小系统 | 重启丢会话 |
| 粘滞 Session | 多实例老系统 | 节点挂了会话仍可能丢 |
| Redis Session | 多实例共享 | 引入 Redis 依赖 |
| 无状态 Token | 新系统 | 要有认证和失效设计 |
如果继续用内存 Session,发布时要摘流,不要直接重启所有节点。
设置会话超时:
<session-config>
<session-timeout>30</session-timeout>
</session-config>静态资源一般交给 Nginx 更合适。JSP、WEB-INF 这类必须由 Tomcat 处理;前端构建产物、图片、下载文件可以放 Nginx、对象存储或独立目录。
这里要注意:上传文件不要放在 WAR 解压目录里。部署时一清目录,用户文件就没了。
故障一:端口冲突
现象一般是启动失败,日志里看到:
java.net.BindException: Address already in use先查端口:
sudo ss -lntp | grep -E ':8080|:8005|:8009'
sudo lsof -iTCP:8080 -sTCP:LISTEN -P -n如果看到旧 Tomcat 进程占着端口,先优雅停止:
sudo systemctl stop tomcat-app1
ps -ef | grep '[o]rg.apache.catalina.startup.Bootstrap'一台机器多个实例时,HTTP、shutdown、AJP 端口都要错开:
| 实例 | HTTP | Shutdown | AJP |
|---|---|---|---|
app1 | 8081 | 18051 | 禁用 |
app2 | 8082 | 18052 | 禁用 |
如果不用 AJP,直接禁用,少一个暴露面。
故障二:Tomcat 启动失败
启动失败先看 systemd,再看 Tomcat 日志:
sudo systemctl status tomcat-app1 --no-pager
journalctl -u tomcat-app1 -n 300 --no-pager
tail -n 300 /data/tomcat/app1/logs/catalina.out
tail -n 300 /data/tomcat/app1/logs/localhost.$(date +%Y-%m-%d).log常见原因:
| 现象 | 怎么判断 | 怎么处理 |
|---|---|---|
UnsupportedClassVersionError | java -version 太低 | 换符合应用要求的 JDK |
ClassNotFoundException: javax.servlet | Tomcat 10/11 跑老应用 | 换 Tomcat 9 或迁移 Jakarta |
| XML 解析失败 | 日志里有行号 | 回滚或修 server.xml / web.xml |
Permission denied | 写 logs、temp、work 失败 | 修目录 owner 和权限 |
NoSuchMethodError | 编译期和运行期 jar 版本不一致 | 查 WEB-INF/lib 和 Tomcat lib |
如果进程卡住不动,抓线程栈:
PID=$(pgrep -f 'org.apache.catalina.startup.Bootstrap')
jcmd "$PID" Thread.print > /tmp/tomcat-startup-hang.txt线程栈比“启动很慢”这种描述有用得多。
故障三:404
404 先判断是谁返回的。直接打 Tomcat:
curl -i http://127.0.0.1:8080/app/再打 Nginx:
curl -i http://app.example.com/app/如果 Tomcat 直接访问 404,一般查 Context 和 WAR:
find /data/tomcat/app1/conf/Catalina -maxdepth 3 -type f -name '*.xml' -print
ls -lah /data/tomcat/app1/webapps
readlink -f /srv/apps/app1/current
grep -E "Deploying|Deployment|Exception|SEVERE|FAIL" /data/tomcat/app1/logs/localhost.$(date +%Y-%m-%d).log常见坑:
app.war访问路径是/app/,不是/。- 根路径要用
ROOT.war或ROOT.xml。 - Nginx
location和proxy_pass改写了路径。 - Linux 文件路径大小写敏感。
- WAR 部署失败,但只看了浏览器 404。
404 不要先改业务代码。先确认请求进了哪个入口、哪个 Context、哪个 docBase。
故障四:502 和 504
502/504 多数是 Nginx 层看到的。
先看 Nginx 错误日志:
tail -n 100 /var/log/nginx/app1.error.log再看 Tomcat 是否可达:
ss -lntp | grep ':8080'
curl -v http://127.0.0.1:8080/app/health常见对应关系:
| Nginx 日志 | 可能原因 |
|---|---|
connect() failed (111: Connection refused) | Tomcat 没启动或端口错 |
upstream timed out | Tomcat 或下游处理太慢 |
No route to host | 防火墙、安全组、路由问题 |
upstream prematurely closed connection | Tomcat 进程异常、应用崩溃、连接被关闭 |
处理顺序:
- 本机
curl 127.0.0.1:8080。 - Nginx 机器
curl Tomcat内网IP:8080。 - 查安全组、防火墙、监听地址。
- 查 Tomcat access log 耗时。
- 抓线程栈看是不是线程耗尽。
不要一看到 504 就把 proxy_read_timeout 调到几分钟。超时变长只是让用户等更久,根因还是要看后端为什么慢。
故障五:线程耗尽
线程耗尽的现场很典型:CPU 不一定高,但请求越来越慢,Nginx 开始 504,应用日志又没有明显异常。
连续抓三次线程栈:
PID=$(pgrep -f 'org.apache.catalina.startup.Bootstrap')
jcmd "$PID" Thread.print > /tmp/tomcat-thread-1.txt
sleep 10
jcmd "$PID" Thread.print > /tmp/tomcat-thread-2.txt
sleep 10
jcmd "$PID" Thread.print > /tmp/tomcat-thread-3.txt连续抓三次是为了看线程是不是一直卡在同一个地方。如果三次都卡在同一条 SQL 或同一个下游 HTTP 调用,根因就很清楚了。
看连接数量:
ss -ant '( sport = :8080 )' | awk '{print $1}' | sort | uniq -c
ss -ant state established '( sport = :8080 )' | wc -l看慢请求:
tail -n 5000 /data/tomcat/app1/logs/access_log.$(date +%Y-%m-%d).log \
| awk '{print $NF, $0}' \
| sort -n \
| tail -n 20止血办法一般是:
- Nginx 临时限流或摘掉问题入口。
- 关闭高成本定时任务。
- 缩短下游超时,减少重试。
- 扩容 Tomcat 实例。
- 必要时重启,但重启前保存线程栈。
故障六:内存溢出
OOM 不能只说“内存不够”。先看是哪种:
| 错误 | 通常查哪里 |
|---|---|
Java heap space | 大对象、缓存、Session、批量查询 |
GC overhead limit exceeded | 堆快满了,GC 回收效果差 |
Metaspace | 类加载、热部署、动态代理 |
Direct buffer memory | NIO、Netty、上传下载、驱动 |
unable to create new native thread | 线程太多或系统限制 |
先保留现场:
grep -i "OutOfMemoryError" /data/tomcat/app1/logs/* | tail -n 20
ls -lh /data/tomcat/app1/dump
ls -lh /data/tomcat/app1/logs/gc*如果进程还活着:
PID=$(pgrep -f 'org.apache.catalina.startup.Bootstrap')
jcmd "$PID" GC.heap_info
jcmd "$PID" Thread.print > /tmp/tomcat-thread-oom.txt
jcmd "$PID" GC.heap_dump /data/tomcat/app1/dump/heap-$(date +%Y%m%d-%H%M%S).hprof看到 heap dump 后,不要立刻删除。拿去用 MAT、JProfiler、VisualVM 这类工具分析对象占用。生产上至少要知道是缓存长大、Session 太多、一次性查太多数据,还是某个集合没有释放。
这里要注意:heap dump 可能很大,HeapDumpPath 要放到有空间的数据盘。否则 OOM 时想留证据,结果磁盘不够。
故障七:日志过大和磁盘满
磁盘满以后,Tomcat 可能启动失败、写不了日志、上传失败、JSP 编译失败。先看磁盘:
df -h
du -h --max-depth=1 /data/tomcat/app1 | sort -h
du -h --max-depth=1 /data/tomcat/app1/logs | sort -h常见大文件:
| 文件 | 处理方式 |
|---|---|
catalina.out | 减少 console 输出,logrotate 兜底 |
| 应用日志 | logback/log4j 按大小和日期滚动 |
| access log | 压缩、保留固定天数 |
| GC log | 设置 filecount 和 filesize |
| heap dump | 分析后归档或清理 |
| 导出文件 | 定时清理过期文件 |
清理前先确认目录,不要一把梭:
sudo find /data/tomcat/app1/logs -name '*.gz' -mtime +30 -print
sudo find /data/app1/tmp -type f -mtime +7 -print确认无误再加 -delete。生产上建议先 -print,别上来就删。
故障八:类加载和 jar 冲突
Tomcat 每个 WebApp 有自己的类加载器,所以不同应用可以带不同版本的 jar。但也因为这个,jar 放错地方就会出怪问题。
一般原则:
- 业务 jar 放在 WAR 的
WEB-INF/lib。 - Tomcat
lib不要乱放业务 jar。 - JDBC 驱动、JNDI 数据源这种确实要共享的,再放 Tomcat
lib。 - 同一个 jar 不要 Tomcat
lib一份、WAR 里又一份,还版本不一样。
排查命令:
jar tf app.war | grep 'WEB-INF/lib' | sort | grep -E 'servlet|spring|log4j|slf4j|jackson'
find /opt/tomcat/current/lib -maxdepth 1 -type f -name '*.jar' -printf '%f\n' | sort常见异常:
| 异常 | 一般原因 |
|---|---|
ClassNotFoundException | 依赖没打进 WAR,或 javax / jakarta 不匹配 |
NoClassDefFoundError | 编译期有,运行期缺,或类初始化失败 |
NoSuchMethodError | 运行期 jar 比编译期旧 |
ClassCastException | 同名类被不同类加载器加载 |
LinkageError | 类重复、依赖冲突、字节码不兼容 |
如果只在服务器出问题,本地没问题,优先对比服务器 Tomcat lib、WAR 构建号、JDK 版本,以及 work 目录旧缓存。
必要时清理缓存:
sudo systemctl stop tomcat-app1
sudo rm -rf /data/tomcat/app1/work/*
sudo rm -rf /data/tomcat/app1/temp/*
sudo systemctl start tomcat-app1这里清理的是 Tomcat 工作目录,不要误删业务上传目录。
常用巡检命令
服务状态:
systemctl is-active tomcat-app1
systemctl status tomcat-app1 --no-pager
journalctl -u tomcat-app1 -n 100 --no-pager端口:
ss -lntp | grep -E ':8080|:8005|:8009'健康检查:
curl -fsS http://127.0.0.1:8080/app/health
curl -I http://app.example.com/app/health进程:
PID=$(pgrep -f 'org.apache.catalina.startup.Bootstrap')
ps -o pid,ppid,user,%cpu,%mem,etime,cmd -p "$PID"线程:
jcmd "$PID" Thread.print > /tmp/tomcat-thread.txt堆:
jcmd "$PID" GC.heap_info文件句柄:
ls /proc/"$PID"/fd | wc -l
ulimit -n最近错误:
grep -R "ERROR\\|Exception\\|SEVERE" /data/tomcat/app1/logs | tail -n 100落地工程深水区
Tomcat 项目多半是存量系统,真正的难点不是装一个容器,而是把老 WAR、JDK、Servlet 规范、目录隔离、线程池、Session 和回滚关系理清楚。
| 深水区 | 现场表现 | 落地做法 |
|---|---|---|
| Servlet 规范迁移 | Tomcat 10/11 上线后大量 javax.* 类找不到 | 老项目优先用 Tomcat 9;迁移到 10/11 前先完成 javax 到 jakarta 兼容评估 |
| 多实例目录混用 | 升级 Tomcat 影响所有应用,配置和日志难区分 | CATALINA_HOME 放程序,CATALINA_BASE 按应用拆分 conf、logs、webapps、temp、work |
| 热部署误用 | 生产自动 reload 导致请求中断、Session 丢失 | 生产关闭热部署和 reloadable;发布走备份、替换、健康检查、回滚脚本 |
| 线程池和连接池错配 | Tomcat 线程打满,DB 连接池也打满,Nginx 504 | 统一压测 maxThreads、accept 队列、DB 连接池、Nginx 超时和应用慢接口 |
| Session 策略不清 | 滚动发布后用户频繁掉线或状态串乱 | 明确 sticky session、外置 Session 或无状态改造;灰度前验证会话路径 |
| AJP 暴露风险 | 8009 端口被内网直接访问,绕过 Nginx | 不需要就禁用 AJP;必须用时只监听内网,并配置认证和网络访问控制 |
| 日志和控制台混杂 | catalina.out 爆盘,业务日志找不到 | 应用日志独立滚动;access log 打请求耗时;catalina.out 只做兜底 |
| WAR 解压目录放业务文件 | 回滚或清理 webapps 后上传文件丢失 | 上传、导出、临时文件放应用数据目录,不放 webapps 和 work |
| 类加载污染 | 本地正常,服务器出现 NoSuchMethodError | Tomcat lib 只放共享必需 jar;业务依赖放 WAR;上线前对比 WEB-INF/lib |
| 离线升级不可验证 | 下载包、签名、旧版本都找不到 | Tomcat、JDK、WAR、校验文件、配置备份统一入库;升级前做解压目录演练 |
判断一个 Tomcat 运维方案是否靠谱,可以看它是否能独立升级 Tomcat、独立回滚某个 WAR、独立清理日志,并且不会把用户上传文件、运行缓存和程序目录搅在一起。
生产上线前检查清单
上线前照着过一遍:
- Tomcat、JDK、应用依赖版本匹配,
javax/jakarta没搞错。 - Tomcat 使用普通用户运行,不用 root。
CATALINA_HOME和CATALINA_BASE分开。- HTTP、shutdown、AJP 端口不冲突。
- 不需要 AJP 时已经禁用。
- Nginx 到 Tomcat 使用本机或内网地址。
server.xml、Context、setenv.sh、systemd unit 有备份。reloadable=false,生产不依赖热部署。- JVM 堆、GC 日志、heap dump 路径配置清楚。
- access log 打开,并带请求耗时。
- 应用日志有滚动和保留周期。
- systemd 开机自启。
- 发布脚本有锁、备份、健康检查和回滚。
- 健康检查同时覆盖 Tomcat 本机和 Nginx 外部入口。
- Session 策略明确,滚动发布不误伤用户。
- 上传文件不放 WAR 解压目录。
- 线程池、连接数、DB 连接池、Nginx 超时一起压测。
- 磁盘、CPU、内存、GC、线程、请求耗时有监控。
- 国内、内网、离线环境的安装包和校验文件已经入库。
- 回滚命令演练过,不是只写在文档里。
官方入口
平时写脚本和定标准,建议固定看这些入口:
- Tomcat 版本选择:
https://tomcat.apache.org/whichversion.html - Tomcat 9 下载:
https://tomcat.apache.org/download-90.cgi - Tomcat 9 安装说明:
https://tomcat.apache.org/tomcat-9.0-doc/setup.html - HTTP Connector:
https://tomcat.apache.org/tomcat-9.0-doc/config/http.html - AJP Connector:
https://tomcat.apache.org/tomcat-9.0-doc/config/ajp.html - Context 配置:
https://tomcat.apache.org/tomcat-9.0-doc/config/context.html - Web 应用部署:
https://tomcat.apache.org/tomcat-9.0-doc/deployer-howto.html - Logging:
https://tomcat.apache.org/tomcat-9.0-doc/logging.html - Nginx proxy:
https://nginx.org/en/docs/http/ngx_http_proxy_module.html - Apache 包校验:
https://www.apache.org/info/verification.html
最后再强调一句:Tomcat 运维不是“能启动”就结束。真正能落地的是版本能解释、目录能追踪、配置能回滚、日志能定位、线程和内存能抓现场、发布失败能退回去。把这些做好,老 Java Web 项目就不会一上线出问题就只能靠猜。
