本文为 AtomGit 码动四季·开源同行征稿活动参与文章
摘要:Spring Boot 默认内嵌 Tomcat,换成 Undertow 只需"排除 + 引入"两步 pom 操作,但"能跑"和"跑得干净"之间隔着三处暗坑:传递依赖把 Tomcat 悄悄拉回 classpath、Tomcat 表达式语言的组件冲突、
server.tomcat.*配置静默失效。本文基于 ArticlePilot 实仓的容器化提交(2026-09-17)逐坑拆解,给出"依赖树终检 + 容器红线写进 AGENTS.md + 配置逐项翻译"三道防线——替换影响面被锁定在 pom 与配置层,业务代码零改动,回退只需还原两处依赖。
文章目录
- 一、替换的两步标准操作
- 二、三个暗坑与解法
- 1:传递依赖把 Tomcat 拉回来,双容器共存的假象
- 2:Tomcat 表达式语言的传递组件冲突
- 3:按 Tomcat 行为写的配置悄悄失效
- 三、验证与回退
- 四、总结
Spring Boot 默认内嵌 Tomcat,多数项目够用。但 ArticlePilot 是容器化部署的单机管理台,内存 footprint 和连接处理的轻量性比"默认"更值钱,于是在 9 月 17 日的容器化改造里把 Web 容器换成了 Undertow。替换本身只要两步 pom 操作,真正花时间的是随后冒出来的三处暗坑——都与"某条传递依赖悄悄把 Tomcat 拉回来"或"Tomcat 专属行为被默认依赖"有关。这篇把这些坑一次讲完,照着做可以少走弯路。
先交代证据:这是当天容器化改造的原始提交,后面所有的坑都发生在这个提交的前后。
一、替换的两步标准操作
替换的核心思路:排除 starter 里的 Tomcat,再引入 Undertow starter。在spring-boot-starter-web上做排除:
<!-- pom.xml:排除默认 Tomcat,引入 Undertow --><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><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-undertow</artifactId></dependency>这一步做完启动就是 Undertow 了。但"能跑"和"跑得干净"之间,隔着下面三个坑。
二、三个暗坑与解法
1:传递依赖把 Tomcat 拉回来,双容器共存的假象
现象:排除后启动日志确实打了 Undertow,但运行行为怪异——某次引入一个第三方 starter 后,Tomcat 的类又出现在 classpath 里,端口偶尔被双容器抢占。
根因:其他 starter 或业务依赖可能传递引用tomcat-embed-*,排除只对starter-web这一条路径生效,防不住别的路径。
解决:替换完成后用依赖树做一次终检,确认tomcat-embed只允许出现在websocket等明确豁免的路径里,其余一律在对应依赖上补 exclusion:
# 依赖树终检:确认没有旁路把 Tomcat 拉回来mvn dependency:tree-Dincludes=org.apache.tomcat.embed效果:终检跑完后依赖树里org.apache.tomcat.embed零意外命中,双容器抢占端口的问题此后未再复现。
感受:排除是"点防御",依赖树是"面验收"——单点排除永远防不住全图的传递路径,终检这一步不能省。
教训:容器替换的验收标准是"依赖树里没有多余容器",不是"启动日志换了名字"。
2:Tomcat 表达式语言的传递组件冲突
现象:启动期报 EL 相关的类冲突——项目里某个依赖携带了 Tomcat 的表达式语言实现,与 Undertow 环境下的 JSP/EL 期望不一致。
根因:个别老组件默认按 Tomcat 环境打包,把tomcat-embed-el当成必备依赖传递进来。
解决:在该依赖上显式排除 Tomcat 表达式语言,EL 统一走标准实现;这条约束后来写进了 AGENTS.md 的安全红线:“后端 Web 容器是 Undertow,不要引入依赖 Tomcat 的传递组件”。pom 里的实际排除块长这样——starter-tomcat和tomcat-embed-el是两处独立的排除,缺一不可:
效果:双排除落地后 EL 冲突消失,容器红线写进 AGENTS.md 至今,同类问题零复发——包括 AI 编程助手生成的新依赖引入代码也会遵守这条约定。
感受:一次排查的成本换一条长期生效的红线,是这次替换里性价比最高的动作。
教训:给仓库写一条"容器红线",比每次事后排查传递依赖便宜得多——AI 编程助手和后来的协作者都会读这条约定。
3:按 Tomcat 行为写的配置悄悄失效
现象:替换后个别连接参数不生效。逐项核对才发现,server.tomcat.*前缀的配置项在 Undertow 下是死配置,Undertow 有自己的一套server.undertow.*。
根因:Spring Boot 的配置前缀与容器绑定,换容器等于换配置命名空间,旧配置不报错、只是静默不生效。
解决:按server.undertow.*重写容器配置(工作线程、IO 线程、请求超时、访问日志),并对替换做一次显式验证——启动日志确认容器名 + 压测观察参数是否生效:
# server.undertow.* 替代 server.tomcat.*(Boot 3.2.x 实际键名) server.undertow.threads.io=8 server.undertow.threads.worker=64 server.undertow.no-request-timeout=30s server.undertow.accesslog.enabled=true常用配置项的迁移对照,替换前逐项过一遍:
| 用途 | Tomcat 配置项 | Undertow 配置项 |
|---|---|---|
| IO 线程数 | server.tomcat.threads.min-spare(近似) | server.undertow.threads.io |
| 工作线程数 | server.tomcat.threads.max | server.undertow.threads.worker |
| 请求超时 | server.tomcat.connection-timeout | server.undertow.no-request-timeout |
| 访问日志 | server.tomcat.accesslog.enabled | server.undertow.accesslog.enabled |
效果:配置逐项翻译后,线程与超时参数在压测下按预期生效,没有"配置了但没反应"的残留项。
感受:这张对照表是整个替换里最费时间也最值得的部分——静默失效比报错可怕得多,报错会逼你修,静默失效只在生产上等你。
教训:换容器不是依赖替换,是配置命名空间的迁移,server.tomcat.*要逐项翻译而不是留着当摆设。
三、验证与回退
三个坑对应三道防线,收拢成一张图,替换前照着设防:
验收动作按固定顺序走一遍,任何一步不达标就退回上一步排查:
替换的验证三件套:mvn dependency:tree终检依赖路径、启动日志确认容器、冒烟测试覆盖核心接口。回退方式也很简单——pom 里恢复 starter-tomcat 依赖并删掉 Undertow starter 即可,数据层与业务代码不感知容器差异,这也是容器层替换值得做的原因:影响面被 Spring Boot 的抽象锁在很小的范围里。
四、总结
Tomcat 到 Undertow 的替换,动手只要五分钟,做干净要半天。三处暗坑的共同规律是:替换的边界不是你改的那个依赖,而是整个依赖图里所有隐式依赖 Tomcat 的路径。把"依赖树终检 + 容器红线写进 AGENTS.md + 配置前缀逐项翻译"三件事做完,这次替换才真正结束。
真实性声明
本文操作与坑例来自 ArticlePilot 仓库 2026-09-17 提交(feat(infra): 容器 Undertow 化)与 pom.xml 实际配置,可对照复核;环境基准为 Spring Boot 3.2.5 / Java 17。
开源仓库地址
- ArticlePilot(pom.xml 排除配置与 AGENTS.md 容器红线可直接对照):https://atomgit.com/dickeryang/articlepilot
参考资源
- Undertow 官方文档
- Spring Boot 官方文档:Web Server 配置
专栏导航
- 上一篇:ArticlePilot 从 0 到开源的迭代复盘
- 下一篇:稿件并发编辑的乐观锁保护
如果本文对你有帮助,欢迎点赞、收藏、转发。有任何问题或建议,请在评论区留言交流。行文仓促,定有不足之处,欢迎各位朋友在评论区批评指正,不胜感激。