☰
Tomcat安装配置、部署调优与常见故障排查指南
2026/11/2 14:41:27 网站建设 项目流程

1. 装个 Tomcat 而已,为什么值得单独占一章

刚入行那会儿,我对"Tomcat安装和配置"这七个字的理解就是:下个压缩包,解压,敲startup.sh,浏览器打开 8080 看到那只猫,收工。直到第一次把项目往测试环境上一扔,页面 404 找不到路、日志里一堆ClassNotFoundException、同事连不上我的端口、重启机器后服务自己没了——我才明白,装起来和配好之间,隔着一整条生产环境的鸿沟。这一章我想聊的就是这条鸿沟怎么填。

Tomcat 本质上是一个 Servlet 容器,说白了它是个"能跑 Java Web 程序的壳子"。你写的那些@WebServlet、Filter、JSP,自己不成立,得有个东西负责接收 HTTP 请求、解析、找到对应的处理类、把结果写回响应。Tomcat 干的就是这活儿。它不像 Nginx 那样只做静态转发,也不像完整的应用服务器那样把 EJB、事务、消息全包进来,它的定位很清晰——轻量、够用、启动快。正因如此,绝大多数中小规模的 Java Web 项目、内部管理后台、接口服务,最终落地的时候都是往 Tomcat 里一塞。

这篇文章适合谁看?如果你是完全没碰过 Java Web 部署的新手,我会从目录结构、端口、启动脚本一层层拆开,你能照着抄;如果你已经装过几次但总在权限、端口、版本上翻车,中间关于 JDK 匹配、systemd 服务化、连接器参数的部分应该能帮你把坑填上;如果你是在做运维或者带团队,第七章的排查速查表和第八章的调优点,可以直接拿去当内部文档用。

我不打算写成一份官方手册的复述。官方文档写得比我严谨,但它不会告诉你为什么unpackWARs和autoDeploy一起开会把你的上传目录清空,也不会提醒你 1024 以下的端口为什么普通用户绑不上。这些是踩出来的。下面我按"先想清楚为什么这么装、再一步步装、装完怎么配、配完怎么排错"的顺序来。

2. 动手之前先把方案定下来

2.1 三种装法,别一上来就选最省事的

实际工作中装 Tomcat 大致三条路,选择哪条取决于你要拿它干什么。

第一条是包管理器直接装,yum install tomcat或者apt install tomcat9这类。好处是省心,依赖、服务脚本、配置文件路径、日志目录全都按发行版规范摆好了,systemctl start就能起。缺点是版本往往滞后,配置文件被拆散到/etc/tomcat9/、/var/lib/tomcat9/、/usr/share/tomcat9/好几个地方,CATALINA_HOME和CATALINA_BASE分离,新手第一次找server.xml能在文件系统里转十分钟。另外包管理器装的版本升级会覆盖配置,你改过的server.xml可能在某次apt upgrade之后被打回原形。

第二条是官方二进制压缩包,下载apache-tomcat-x.y.z.tar.gz解压到自选目录。这是我在生产环境最常用的方式,原因很简单:所有东西都在一个目录里,conf、logs、webapps、bin一目了然,迁移的时候整目录打包带走,升级的时候新目录并排放着,改个软链接或者 systemd 单元里的路径就能回滚,出问题不慌。缺点是环境变量、服务脚本、权限都得自己来。

第三条是容器镜像。适合 CI/CD 流水线、需要横向扩缩的场景。但对"学习 Tomcat 到底怎么跑起来"这件事其实是种遮蔽——容器里往往已经把server.xml改好、JVM 参数设好、日志重定向好了,你看到的是一层封装,真出问题时不知道往哪看。我的建议是:先用二进制包在自己的机器或虚拟机上完整装一遍、改一遍、踩一遍,把机制搞明白,再去用镜像。

注意:如果你只是想跑个 Demo 验证代码,包管理器那条路最快;如果你要交付给别人的环境,二进制包那条路最可控。别在图省事和可控性之间反复横跳,来回换装法是最浪费时间的。

2.2 版本和 JDK 的对应关系,配错了后面全是白工

这一段是重点,因为它是新手翻车率最高的地方,而且报错信息往往不指向真正原因。

Tomcat 的版本和 Java 版本、Servlet 规范是绑定的。大概对应关系是这样的:

Tomcat 版本最低 Java 版本Servlet 规范包命名空间
8.5.xJava 7Servlet 3.1javax.servlet
9.0.xJava 8Servlet 4.0javax.servlet
10.0.xJava 8Servlet 5.0jakarta.servlet
10.1.xJava 11Servlet 6.0jakarta.servlet
11.xJava 17Servlet 6.1jakarta.servlet

表里最要命的是最后一列。从 Tomcat 10 开始,Servlet API 的包名从javax.servlet.*变成了jakarta.servlet.*。这意味着一个用javax.servlet写的老项目,扔进 Tomcat 10 里,编译好的 class 是不会变的,运行时找不到javax.servlet.http.HttpServletRequest这个类,直接NoClassDefFoundError,页面 500,日志里刷一屏。这时候你去查"Tomcat 启动报错 找不到 servlet 类",能翻出一堆互相矛盾的答案,实际上根因就一句话:版本不匹配。

我的判断原则很粗暴:老项目、第三方组件依赖javax的,老老实实用 Tomcat 9;新项目、框架已经迁到jakarta的,用 10.1 或更高。如果非要让老项目跑在新 Tomcat 上,业内有个过渡方案是把jakarta的包重命名回javax的转换工具,但那属于权宜之计,长期还是得让上游框架升级。

JDK 这边同样有坑。Tomcat 9 官方要求 Java 8 及以上,但如果你的项目用到某些新语法或者依赖的库要求 Java 17,那就得往上顶。反过来,Java 17 跑 Tomcat 9 是没问题的,别被"最低版本"这三个字误导成"只能这个版本"。真正需要注意的是 JDK 实现的选择:OpenJDK 各发行版都有,装哪个都行,但要确认java -version输出的主版本号和你预期的完全一致——机器上装了两三个 JDK,JAVA_HOME指错是很常见的低级错误,而它引发的报错往往长得很像"配置错误"。

2.3 目录规划和运行用户,一开始就该想好

很多人装 Tomcat 的习惯是直接解压到/root或者当前用户的家目录,然后用 root 跑。开发机上这么干问题不大,但只要这台机器将来要对外提供服务,这就是个定时炸弹。Tomcat 是网络服务,监听端口、解析外部请求、解析上传的文件,用 root 跑意味着一旦应用层出现漏洞,攻击者拿到的就是最高权限。哪怕你不考虑安全,用 root 启动还会带来另一个麻烦:应用写出来的日志、上传的文件、临时目录全都属主是 root,后续换个普通用户去维护,动不动就 permission denied。

我的做法是固定一套目录约定,无论在哪个环境都保持一致:

  • 安装目录:/opt/tomcat(软链接指向具体版本目录,比如/opt/tomcat-9.0.xx)
  • 数据基目录:/var/lib/tomcat(放webapps、temp、work,如果有必要可以跟安装目录分离)
  • 日志目录:/var/log/tomcat(单独挂盘,方便轮转和清理)
  • 运行用户:一个专用的系统用户,比如tomcat,禁止登录 shell

这里有个概念值得提前说清楚:CATALINA_HOME是安装目录,CATALINA_BASE是运行时基目录。单实例部署时两者通常是同一个路径,不用管。但当你需要在一台机器上跑多个 Tomcat 实例、共享同一份程序文件时,CATALINA_BASE就派上用场了——每个实例有自己的conf、logs、webapps、temp、work,程序文件共用一份,省空间也好升级。这个设计在资源紧张的小机器上很实用,一开始不了解也没关系,知道有这回事就行。

3. 从零到跑起来:二进制包安装全流程

3.1 下载、校验、解压

拿到安装包的方式,我一般是在一台能访问外网的机器上下好,再传到目标机器,或者用内网的制品库。不管哪种方式,拿到之后先做完整性校验,这一步经常被跳过,但压缩包下了一半断掉、传输过程被截断,解压出来的文件是残缺的,后面启动时报的错会非常离谱,你会花几个小时往配置文件上找原因。

校验的方式是比对官方公布的哈希值,sha512sum或者sha256sum看一眼就行:

sha512sum apache-tomcat-x.y.z.tar.gz

比对一致后再解压。解压到一个带版本号的目录,比如/opt/tomcat-9.0.xx,然后建一个current软链接指向它。这个软链接的技巧看起来多此一举,实际上非常有用:配置文件里、systemd 单元里、脚本里统一写/opt/tomcat/current,将来升级只要把软链接指到新版本目录,再重启服务,回滚就是再把链接指回去,全程不用改任何配置。

tar -xzf apache-tomcat-x.y.z.tar.gz -C /opt ln -sfn /opt/tomcat-9.0.xx /opt/tomcat

解压之后先别急着启动,花两分钟看一眼目录,因为后面所有操作都围绕这几个目录转:

目录作用你会经常动它吗
bin启动、停止、版本检查脚本偶尔,主要加setenv.sh
conf全部配置文件经常,server.xml是主战场
lib容器和应用的公共依赖 jar偶尔,放数据库驱动之类
logs日志输出天天看
temp临时文件基本不管,但要注意磁盘
webapps应用部署目录每次发布都动
workJSP 编译产物、会话序列化基本不管,出问题时可清空

work目录值得单独提一句。JSP 第一次被访问时会被编译成 Java 再编译成 class,产物就放在这里。偶尔会遇到改了 JSP 页面但效果没变的情况,多半是这个目录里的旧产物没被清掉,停服务后把work下的内容删空再启动,问题通常消失。这是新手最容易被"我刚才明明改了"折磨的地方。

3.2 运行用户与文件权限

建用户这一步不要省:

useradd -r -s /sbin/nologin -d /opt/tomcat tomcat

-r表示系统用户,-s /sbin/nologin表示不给登录 shell,-d指定家目录。然后把需要的目录属主改过去:

chown -R tomcat:tomcat /opt/tomcat-9.0.xx chown -R tomcat:tomcat /var/log/tomcat chmod -R u+rwX /opt/tomcat-9.0.xx

这里有个实操细节:chmod用的是u+rwX而不是u+rwx,大写的X表示"只给目录和已经有执行位的文件加执行权限",不会把一堆配置文件也变成可执行。虽然对功能没影响,但审计的时候看起来会清爽很多。

至于 8080 端口,它大于 1024,普通用户直接就能绑,不需要任何额外操作。只有当你打算让 Tomcat 直接监听 80 或 443 时才需要处理低端口问题——要么加CAP_NET_BIND_SERVICE能力,要么在前面放一层反向代理把 80 转给 8080。后者更常见,也更灵活,因为证书、压缩、限流这些通常都在代理层做。

注意:千万别用chmod -R 777去解决权限问题。第一次遇到"写不进日志"的时候,很多人条件反射就上 777,当下确实通了,但它把问题从"权限不足"变成了"任何进程都能改你的程序文件",而且真正的原因(属主错了、SELinux 策略、父目录没有执行位)被彻底掩盖。

3.3 环境变量与 setenv.sh

Tomcat 的启动脚本会读JAVA_HOME,如果没设,脚本会尝试从PATH里找java。为了确定性强,我倾向于显式设置。设置的位置有两个选择:写进/etc/profile.d/全局生效,或者写进bin/setenv.sh只对 Tomcat 生效。我更推荐后者,因为这是 Tomcat 官方支持的扩展点,catalina.sh启动时会自动 source 它,而且升级 Tomcat 时这个文件不会被覆盖:

# /opt/tomcat/current/bin/setenv.sh export JAVA_HOME=/usr/lib/jvm/java-17-openjdk export CATALINA_OPTS="-Xms512m -Xmx1024m -XX:MetaspaceSize=128m -XX:MaxMetaspaceSize=256m" export JAVA_OPTS="-Dfile.encoding=UTF-8 -Duser.timezone=Asia/Shanghai"

这里要区分JAVA_OPTS和CATALINA_OPTS。前者会传给所有由脚本启动的 JVM,包括shutdown.sh那个只负责发关闭指令的短命进程;后者只传给真正跑 Tomcat 的那个 JVM。所以内存参数应当放在CATALINA_OPTS,避免关闭脚本也去申请一大块堆内存。编码和时区这类通用参数放JAVA_OPTS也无妨,但如果机器上有多个 Java 应用共用环境变量,还是分开写更保险。

-Dfile.encoding=UTF-8这条在近几年的 JDK 上其实已经默认了,但显式写着不吃亏,尤其是那些容器里 locale 被设成POSIX的环境,不写就容易出现中文文件名乱码、日志里中文变问号的情况。

3.4 注册成系统服务

startup.sh起的进程是脱离终端的,但机器重启之后它不会自动回来,而且进程挂了也没有任何守护。生产环境必须交给 init 系统管。现在主流发行版都用 systemd,写一个单元文件放/etc/systemd/system/tomcat.service:

[Unit] Description=Tomcat Servlet Container After=network.target [Service] Type=forking User=tomcat Group=tomcat Environment="JAVA_HOME=/usr/lib/jvm/java-17-openjdk" Environment="CATALINA_HOME=/opt/tomcat/current" Environment="CATALINA_BASE=/opt/tomcat/current" Environment="CATALINA_PID=/opt/tomcat/current/temp/tomcat.pid" ExecStart=/opt/tomcat/current/bin/startup.sh ExecStop=/opt/tomcat/current/bin/shutdown.sh Restart=on-failure RestartSec=10 LimitNOFILE=65535 [Install] WantedBy=multi-user.target

几个参数值得解释。Type=forking是因为startup.sh本身会退出、把真正的 Java 进程留在后台,systemd 需要知道这一点。CATALINA_PID指定 pid 文件路径,这样shutdown.sh知道该给谁发信号,Restart=on-failure才能准确判断进程是否异常退出。LimitNOFILE=65535是打开文件句柄上限,默认值在很多发行版上是 1024,Tomcat 在并发稍高的时候会撞到这个限制,日志里报Too many open files,而这个问题在小流量测试时根本不会出现,一上生产就来,非常隐蔽。

写完之后systemctl daemon-reload,再enable加start。验证分三步走:systemctl status看进程状态,ss -lntp | grep 8080看端口监听,curl -I http://127.0.0.1:8080/看有没有返回 200。三步都过,才算真的起来了。

4. 配置文件逐个拆开看

4.1 server.xml 的骨架逻辑

conf/server.xml是 Tomcat 的核心配置,第一眼看上去层层嵌套很唬人,其实结构就四层:Server是最外层,代表整个容器;里面一到多个Service,把连接器和引擎绑在一起;Service里是Connector加Engine;Engine里是Host;Host里是Context。用生活化的说法,Server是整栋楼,Service是一个业务单元,Connector是大门(决定什么样的请求能进来、怎么进来),Engine是楼里的调度中心,Host是其中一户人家(一个域名/虚拟主机),Context是这户人家里的某个房间(一个 Web 应用)。

大多数情况下你只需要动Connector和Host的属性,Engine和Server保持默认。Server上那个port="8005"的关闭端口,官方默认允许本机通过这个端口发送SHUTDOWN字符串来停止服务,这在多用户机器上有风险,要么改成随机强口令,要么直接port="-1"禁用它,改用shutdown.sh发信号的方式关。我一般选后者,简单干净。

4.2 Connector 的端口、协议与线程参数

一个典型的 HTTP 连接器长这样:

<Connector port="8080" protocol="org.apache.coyote.http11.Http11NioProtocol" maxThreads="400" minSpareThreads="50" acceptCount="200" maxConnections="8192" connectionTimeout="20000" URIEncoding="UTF-8" compression="on" compressionMinSize="2048" compressibleMimeType="text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json" server="web" />

protocol那一行,Tomcat 8 以后写HTTP/1.1也默认走 NIO,但显式写全类名更直观,也方便将来换成 NIO2。maxThreads是处理请求的工作线程上限,默认 200。这个值不是越大越好,每个线程都要占栈内存(默认约 1MB 虚拟地址空间),线程过多会导致上下文切换开销上升,反而拖慢。经验上的估算方法是看并发:并发数大致等于 QPS 乘以平均响应时间(秒)。比如峰值 500 QPS、平均响应 100 毫秒,那并发约 50,maxThreads设到 150 到 200 就足够留出余量了。反过来如果不算,直接按网上抄来的 2000 设置,很可能把机器拖垮。

acceptCount是等待队列长度,工作线程全忙时新来的连接先在这里排队,队列也满了就开始拒绝。它和maxThreads的关系是"前面排队、后面干活",队列太短会造成瞬时高峰直接报错,太长则会让请求等待时间变得不可控。200 是个比较中庸的值。

maxConnections是 NIO 模式下的连接数上限,注意它和maxThreads不是一回事——一个连接在 keep-alive 空闲时可以不占用工作线程,所以maxConnections通常远大于maxThreads,默认 8192,多数场景不用改。

server="web"这个属性比较冷门,它控制 HTTP 响应头里Server字段的值。默认会暴露 Tomcat 的版本号,改成web或者干脆留空,能减少一点信息泄露。这属于安全基线里的小项,成本几乎为零,顺手就做了。

4.3 虚拟目录与外部部署

默认情况下应用要放在webapps下面,但现实里经常遇到几种情况:上传的文件放在数据盘上、前端构建产物在别的目录、多个应用共享一份静态资源。这时候就需要把外部路径挂进来。

第一种方式是在Host里加一个Context:

<Host name="localhost" appBase="webapps" unpackWARs="true" autoDeploy="true"> <Context path="/uploads" docBase="/data/uploads" reloadable="false" /> </Host>

docBase指向外部绝对路径,path是访问前缀。但要注意,把Context直接写在server.xml里,意味着改一次配置就得重启整个容器。更灵活的做法是在conf/Catalina/localhost/下建一个 XML 文件,文件名就是访问路径,内容只有一行Context定义。这种方式支持热加载,改完不用重启全容器,而且不会因为一次配置改动影响到其他应用。

注意:path属性在外部配置文件方式下应当省略,因为文件名已经决定了路径,两个都写且不一致会导致启动报错。这个错很常见,报错信息也不够直白,新手容易卡住。

4.4 日志配置,别等出事了才想起它

Tomcat 有两套日志容易混淆。一套是logs/catalina.out,它是启动脚本把标准输出重定向过去产生的,包含 JVM 的报错、System.out.println的输出、未捕获异常堆栈,属于"什么都往里扔"的杂货铺。另一套是logging.properties配置的按天分割的日志,比如catalina.YYYY-MM-DD.log、localhost.YYYY-MM-DD.log,通过 Java 自带的日志框架管理,能配级别、能配格式。

catalina.out最大的问题是不会自动轮转。服务跑上几个月,这个文件能涨到几个 G,某天磁盘满了,整个服务崩掉,而且崩得很莫名其妙。处理方式有两种:一是改用 systemd 管理后,标准输出进了 journal,catalina.out不再生成,日志交给系统的日志服务去轮转;二是保留文件,但用系统的日志轮转工具(logrotate)定期切割。我倾向于前者,少一个需要操心的文件。

应用自身的日志(比如用 Logback 或 Log4j2 输出的)则完全由应用自己配置,Tomcat 不管。这也是个常见误区——有人改了logging.properties发现业务日志的格式没变,那是因为业务日志走的是应用自己的配置,两者是不同体系。

5. 部署应用:几种姿势和它们的代价

5.1 自动部署的机制与陷阱

Host上有两个属性配合工作:unpackWARs="true"表示把 WAR 包解压成目录再运行,autoDeploy="true"表示定期扫描webapps目录,发现新的 WAR 就发布,发现 WAR 被删掉就把对应的应用卸载。

这两个开关在开发环境很方便,扔个 WAR 进去等几秒就好了。但在生产环境,它有两个真实的坑。

第一个坑是删除行为。当你把xxx.war删掉时,Tomcat 不只会卸载应用,通常还会把解压出来的xxx目录一起删掉。如果你的应用把用户上传的文件存在了自己的目录下(这是很不好的做法,但确实有人这么干),删 WAR 的那一刻,数据就没了。我见过一次上线回滚,运维把 WAR 换回旧版本,顺手删了新 WAR,结果把上传目录清了,事后从备份恢复花了大半天。

第二个坑是部署时机不可控。autoDeploy的扫描是有间隔的,你无法精确知道应用什么时候被加载,这在需要严格发布窗口的场景下不合适。

生产环境的稳妥做法是关掉自动部署(autoDeploy="false"、unpackWARs="false"),把 WAR 的发布纳入发布流程:停服务或停应用、放包、启动、验证。如果嫌停整个容器太重,可以用外部Context配置文件的方式单独控制某个应用的重载。牺牲一点便利,换来的是可预期。

5.2 部署到根路径与上下文路径

访问路径由 WAR 包的文件名决定。app.war部署后访问路径是/app,想部署到根路径(直接http://host/访问),WAR 包要命名为ROOT.war。这个大小写是固定的,改成root.war只会得到一个/root路径的应用,很多人第一次配置根路径失败就是因为这个。

还有一个容易踩的点:如果webapps下已经存在一个ROOT目录(默认自带的欢迎页),你再放一个ROOT.war,行为取决于unpackWARs的配置,可能出现"新的没生效、旧的还在"的诡异现象。干净的做法是先清掉默认的ROOT目录和docs、examples、manager、host-manager这些用不上的自带应用,再放自己的包。

顺便说一句,那几个自带的管理应用在生产环境应该直接删掉。它们不是必需的,留着反而多几个可能的入口。删的时候把webapps下对应目录和 WAR 一起删,光删目录的话,下次启动如果开了自动部署,它可能又从 WAR 里解出来。

5.3 静态资源与上传目录的处理

前端构建产物(HTML、JS、CSS、图片)如果也交给 Tomcat 服务,有两种做法:塞进 WAR 包里,或者用虚拟目录映射到外部磁盘。

塞进包里简单,但每次改一个 CSS 都要重新打包发布,代价太大。更实际的做法是用Context映射外部目录,前端单独发布,只更新文件不动应用。这时候要注意DefaultServlet的几个参数,它在conf/web.xml里全局配置:

<servlet> <servlet-name>default</servlet-name> <servlet-class>org.apache.catalina.servlets.DefaultServlet</servlet-class> <init-param> <param-name>listings</param-name> <param-value>false</param-value> </init-param> <init-param> <param-name>readonly</param-name> <param-value>true</param-value> </init-param> <load-on-startup>1</load-on-startup> </servlet>

listings控制目录下没有默认首页时是否列出文件清单,默认就是false,但有些老版本的默认配置或者被改过的环境里是true,那等于把整个目录结构暴露给任何访客,必须关掉。readonly为true时禁止通过 HTTP 的 PUT、DELETE 方法修改文件,这个默认也是true,除非你有特殊需求,否则别动。

如果静态资源量大,更好的选择是在前面放一层反向代理,让代理直接读磁盘返回,请求根本不进 Tomcat。Tomcat 处理静态文件的效率不如专业的 Web 服务器,把这块卸掉,容器就能专心处理动态请求,这是很划算的分工。

6. 安全与运行规范上不能省的几件事

6.1 关掉一切用不到的东西

装完先做减法。自带的示例应用、文档、管理控制台,全删。conf/tomcat-users.xml里如果配了管理账号,要么删掉整个文件内容,要么确认它没有任何可用角色。server.xml里被注释掉的 AJP 连接器不要随手打开——那个协议是给前置 Web 服务器用的,如果代理层配置不严,存在请求头被伪造的风险。现代的部署方式里,反向代理走 HTTP 就够了,没必要启用 AJP。

关闭端口8005前面提过,改成-1禁用。错误页也要处理一下,默认的 500 页面会带上异常堆栈和 Tomcat 版本信息,对攻击者来说是现成的情报。可以在Host里加一个ErrorReportValve:

<Valve className="org.apache.catalina.valves.ErrorReportValve" showReport="false" showServerInfo="false" />

这样对外只显示一个干净的错误页,详细信息进日志,自己人看日志排查。

6.2 反向代理后面的真实 IP

只要前面套了代理,应用里拿到的request.getRemoteAddr()就会变成代理的地址,所有用户的 IP 都一样,风控、限流、审计全废。解决办法是配RemoteIpValve,让它从代理传来的头里解析真实地址:

<Valve className="org.apache.catalina.valves.RemoteIpValve" remoteIpHeader="X-Forwarded-For" protocolHeader="X-Forwarded-Proto" internalProxies="10\.\d+\.\d+\.\d+|192\.168\.\d+\.\d+" />

internalProxies这个正则很关键,它声明了哪些来源地址是自己人。只有来自这些地址的请求,头里的X-Forwarded-For才会被信任;否则任何人都能伪造一个头把自己的 IP 改成任意值。这个参数写错的后果比不配还严重——不配只是拿不到真实 IP,配错了是把身份伪造的口子敞开。

6.3 非 root 运行与文件权限的持续性

前面建了tomcat用户,但要确认它真的有权限写所有需要写的目录。容易漏掉的是temp、work、logs这三个,它们在任何时候都可能被写入,包括运行时动态创建的临时文件。另外,如果你用了外部docBase映射上传目录,那个目录的属主也得是对的,而且要注意 Web 进程有写权限就意味着能写代码进去(比如上传一个 JSP 文件),所以上传目录必须禁止执行脚本——可以在应用层校验扩展名,也可以让上传目录单独由代理服务,不进 Tomcat。

还有一个实际问题:将来换人维护,用另一个账号登录去查日志,发现logs目录进不去。要么把维护账号加进tomcat组,要么在日志目录上设setgid让新文件继承组权限。这类小事不定规矩,团队里每来一个人就要重新踩一遍。

7. 排查实录:那些年卡住我的错误

7.1 启动不起来,先看这三个地方

第一个地方是logs/catalina.out的末尾。启动失败的原因九成写在最后几十行里,关键是要能认出典型的几种。Address already in use是端口被占,用ss -lntp | grep 8080找出占用进程。JAVA_HOME is not defined correctly是环境变量问题,别去改server.xml,那个文件跟它没关系。NoSuchMethodError或UnsupportedClassVersionError基本就是 JDK 版本不对,后者尤其明确,会直接告诉你 class 文件版本号和当前 JVM 支持的版本号。

第二个地方是conf目录下的配置文件语法。XML 对格式很敏感,少一个引号、多一个标签闭合,启动就会失败,而且报错位置往往指向文件开头而不是真正的错误行。改完server.xml之后,用个 XML 校验工具过一遍能省很多时间。

第三个地方是权限。用systemctl status看到进程退出码非零,但日志里啥也没有,很可能是因为进程压根没起来——比如bin/*.sh没有执行权限,或者logs目录不可写导致重定向失败。切到tomcat用户手动执行一次catalina.sh run,所有问题都会直接打在终端上,这是最快的定位方式。

7.2 起来了但访问不通

服务在跑,端口在听,浏览器就是连不上,这个现象要分层排查。

先确认监听地址。ss -lntp的输出里,如果显示的是127.0.0.1:8080而不是0.0.0.0:8080或:::8080,说明只监听了本机回环,外部当然连不上。这通常是因为Connector上配了address="127.0.0.1",去掉即可。

再确认防火墙。这个不用展开,ss能看到监听但外部连不上,八成是这层拦的。

最后确认应用路径。返回 404 说明容器是通的,只是路径不对——检查 WAR 包名、Context的path、有没有配ROOT。返回 403 通常是权限或者DefaultServlet的readonly;返回 502/503 则要看是不是应用启动失败但容器还活着。

7.3 常见问题速查表

现象大概率原因处理方向
启动报Address already in use端口被其他进程占用ss -lntp定位,换端口或停占用进程
启动报UnsupportedClassVersionErrorJDK 版本低于 class 编译版本核对 JDK 主版本,改JAVA_HOME
启动报NoClassDefFoundError: javax/servlet/...Tomcat 10+ 跑javax老应用降级到 Tomcat 9 或迁移到jakarta
页面 500,日志有ClassNotFoundException依赖 jar 没打包进WEB-INF/lib检查打包配置,补依赖
中文参数变乱码编码不一致确认URIEncoding="UTF-8"与file.encoding
日志报Too many open files文件句柄上限太低systemd 单元加LimitNOFILE
改了 JSP 不生效work目录有旧编译产物停服务清空work后重启
上传目录文件消失删 WAR 触发自动部署清理关闭autoDeploy,数据目录独立
服务运行一段时间后无响应内存溢出或线程耗尽看是否OutOfMemoryError,加堆转储参数
应用里拿到的 IP 全是同一个前面有代理未配真实 IP 解析配RemoteIpValve并限定内网段

表里最后一行那个问题,我印象特别深。当时做登录风控,所有用户都被判定成同一个 IP,登录次数限制把所有人一起锁了。查了半天应用代码,最后一层一层往上翻才发现是代理层没透传,跟应用逻辑一点关系没有。从那以后我养成一个习惯:任何"线上和本地表现不一致"的问题,先看请求经过了哪些环节,再看代码。

8. 跑稳之后还能再调的地方

8.1 JVM 参数不是随便填的

-Xms和-Xmx我一般设成一样大,比如都是 1G。原因是如果初始堆和最大堆差距大,JVM 会在运行过程中反复扩堆,每次扩堆都伴随一次较重的回收,表现为服务偶发的卡顿。设成一样,堆一开始就到位,运行期行为更稳定。

-XX:MetaspaceSize和-XX:MaxMetaspaceSize管的是类的元数据区。Tomcat 里频繁热部署、反复加载卸载应用,会导致元数据区增长,如果不设上限,极端情况下会吃光机器内存。设一个 256M 的上限,超了就早点暴露问题,而不是拖到整机 OOM 才被发现。

再补两个排查用的参数:-XX:+HeapDumpOnOutOfMemoryError和-XX:HeapDumpPath=/path/to/dumps。内存溢出的时候自动把堆转储下来,事后用分析工具打开就能看到是哪个对象撑爆的。这个参数平时完全无感,出事的时候价值极高,属于必须提前加、临时加没用的一类配置。

8.2 连接器的经验参数

线程数那个部分前面说过计算方法。补一点:minSpareThreads是保持的最小空闲线程数,默认 10。如果流量有明显的高峰低谷,把它适当调大(比如 50),可以让高峰来的时候不用临时创建线程,响应更平滑;代价是平时多占一点内存。纯内网后台、流量很小的系统,默认值就够。

connectionTimeout是连接建立后等待请求行的超时,设太小会误伤慢网络的正常用户,设太大则慢连接会长期占着资源。20 秒是官方默认,多数场景合适。真正要注意的是 keep-alive 的超时和最大请求数,如果前面有代理,代理的连接复用策略和 Tomcat 这边的超时最好对齐,否则会周期性出现一批 502——代理认为连接还活着,Tomcat 已经把它关了。这种问题很难复现,只能靠对齐参数预防。

8.3 监控与日志的三个必看项

服务跑起来之后,至少盯三样东西。一是进程存活,交给 systemd 或者监控系统,挂了要能告警。二是堆内存使用率,用 JVM 自带的管理端口或者采集工具看,长期在 80% 以上徘徊就要考虑扩容或者查内存泄漏。三是logs目录里的异常日志量,几个业务异常是正常的,突然成倍增长一定有问题。

日志轮转的规则要提前定,至少保留 7 到 15 天,具体看磁盘容量。只保留一天的日志,出了问题根本没法回溯;保留一年的日志,磁盘迟早被撑爆。折中方案是按天切割、按大小二次切割、按天数清理,三条规则一起用。

最后分享一个我在实际操作中的体会:Tomcat 这东西,装一次只要十分钟,配好却需要理解它的每一个默认值为什么是那个值。我前几次装的时候,凡是能用默认值的都用默认值,跑得也挺好;等到流量上来、等到要做安全加固、等到别人接手维护,那些默认值一个个都成了需要解释的东西。现在我装完的第一件事不是启动,而是把server.xml从头到尾读一遍,把每个属性对应到它的实际作用上。这个习惯花不了二十分钟,但能省掉后面无数个"为什么它突然这样了"的夜晚。

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

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

立即咨询