【码动四季·秋】把 Spring Boot 的 Tomcat 换成 Undertow:一次容器替换的三处暗坑

本文为 AtomGit 码动四季·开源同行征稿活动参与文章
摘要 :Spring Boot 默认内嵌 Tomcat,换成 Undertow 只需"排除 + 引入"两步 pom 操作,但"能跑"和"跑得干净"之间隔着三处暗坑:传递依赖把 Tomcat 悄悄拉回 classpath、Tomcat 表达式语言的组件冲突、server.tomcat.* 配置静默失效。本文基于 ArticlePilot 实仓的容器化提交(2026-09-17)逐坑拆解,给出"依赖树终检 + 容器红线写进 AGENTS.md + 配置逐项翻译"三道防线------替换影响面被锁定在 pom 与配置层,业务代码零改动,回退只需还原两处依赖。

文章目录

Spring Boot 默认内嵌 Tomcat,多数项目够用。但 ArticlePilot 是容器化部署的单机管理台,内存 footprint 和连接处理的轻量性比"默认"更值钱,于是在 9 月 17 日的容器化改造里把 Web 容器换成了 Undertow。替换本身只要两步 pom 操作,真正花时间的是随后冒出来的三处暗坑------都与"某条传递依赖悄悄把 Tomcat 拉回来"或"Tomcat 专属行为被默认依赖"有关。这篇把这些坑一次讲完,照着做可以少走弯路。

先交代证据:这是当天容器化改造的原始提交,后面所有的坑都发生在这个提交的前后。

一、替换的两步标准操作

替换的核心思路:排除 starter 里的 Tomcat,再引入 Undertow starter 。在 spring-boot-starter-web 上做排除:

xml 复制代码
<!-- 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 了。但"能跑"和"跑得干净"之间,隔着下面三个坑。
#mermaid-svg-IxQZvGfhbNPusbrm{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-IxQZvGfhbNPusbrm .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-IxQZvGfhbNPusbrm .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-IxQZvGfhbNPusbrm .error-icon{fill:#552222;}#mermaid-svg-IxQZvGfhbNPusbrm .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-IxQZvGfhbNPusbrm .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-IxQZvGfhbNPusbrm .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-IxQZvGfhbNPusbrm .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-IxQZvGfhbNPusbrm .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-IxQZvGfhbNPusbrm .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-IxQZvGfhbNPusbrm .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-IxQZvGfhbNPusbrm .marker{fill:#333333;stroke:#333333;}#mermaid-svg-IxQZvGfhbNPusbrm .marker.cross{stroke:#333333;}#mermaid-svg-IxQZvGfhbNPusbrm svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-IxQZvGfhbNPusbrm p{margin:0;}#mermaid-svg-IxQZvGfhbNPusbrm .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-IxQZvGfhbNPusbrm .cluster-label text{fill:#333;}#mermaid-svg-IxQZvGfhbNPusbrm .cluster-label span{color:#333;}#mermaid-svg-IxQZvGfhbNPusbrm .cluster-label span p{background-color:transparent;}#mermaid-svg-IxQZvGfhbNPusbrm .label text,#mermaid-svg-IxQZvGfhbNPusbrm span{fill:#333;color:#333;}#mermaid-svg-IxQZvGfhbNPusbrm .node rect,#mermaid-svg-IxQZvGfhbNPusbrm .node circle,#mermaid-svg-IxQZvGfhbNPusbrm .node ellipse,#mermaid-svg-IxQZvGfhbNPusbrm .node polygon,#mermaid-svg-IxQZvGfhbNPusbrm .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-IxQZvGfhbNPusbrm .rough-node .label text,#mermaid-svg-IxQZvGfhbNPusbrm .node .label text,#mermaid-svg-IxQZvGfhbNPusbrm .image-shape .label,#mermaid-svg-IxQZvGfhbNPusbrm .icon-shape .label{text-anchor:middle;}#mermaid-svg-IxQZvGfhbNPusbrm .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-IxQZvGfhbNPusbrm .rough-node .label,#mermaid-svg-IxQZvGfhbNPusbrm .node .label,#mermaid-svg-IxQZvGfhbNPusbrm .image-shape .label,#mermaid-svg-IxQZvGfhbNPusbrm .icon-shape .label{text-align:center;}#mermaid-svg-IxQZvGfhbNPusbrm .node.clickable{cursor:pointer;}#mermaid-svg-IxQZvGfhbNPusbrm .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-IxQZvGfhbNPusbrm .arrowheadPath{fill:#333333;}#mermaid-svg-IxQZvGfhbNPusbrm .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-IxQZvGfhbNPusbrm .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-IxQZvGfhbNPusbrm .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-IxQZvGfhbNPusbrm .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-IxQZvGfhbNPusbrm .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-IxQZvGfhbNPusbrm .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-IxQZvGfhbNPusbrm .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-IxQZvGfhbNPusbrm .cluster text{fill:#333;}#mermaid-svg-IxQZvGfhbNPusbrm .cluster span{color:#333;}#mermaid-svg-IxQZvGfhbNPusbrm div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-IxQZvGfhbNPusbrm .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-IxQZvGfhbNPusbrm rect.text{fill:none;stroke-width:0;}#mermaid-svg-IxQZvGfhbNPusbrm .icon-shape,#mermaid-svg-IxQZvGfhbNPusbrm .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-IxQZvGfhbNPusbrm .icon-shape p,#mermaid-svg-IxQZvGfhbNPusbrm .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-IxQZvGfhbNPusbrm .icon-shape .label rect,#mermaid-svg-IxQZvGfhbNPusbrm .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-IxQZvGfhbNPusbrm .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-IxQZvGfhbNPusbrm .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-IxQZvGfhbNPusbrm :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} exclude
是
否,仍是 Tomcat
starter-web
移除 starter-tomcat
引入 starter-undertow
启动容器是

Undertow 吗?
第 1 步完成
传递依赖把

Tomcat 拉回来了

二、三个暗坑与解法

1:传递依赖把 Tomcat 拉回来,双容器共存的假象

现象:排除后启动日志确实打了 Undertow,但运行行为怪异------某次引入一个第三方 starter 后,Tomcat 的类又出现在 classpath 里,端口偶尔被双容器抢占。

根因 :其他 starter 或业务依赖可能传递引用 tomcat-embed-*,排除只对 starter-web 这一条路径生效,防不住别的路径。

解决 :替换完成后用依赖树做一次终检,确认 tomcat-embed 只允许出现在 websocket 等明确豁免的路径里,其余一律在对应依赖上补 exclusion:

bash 复制代码
# 依赖树终检:确认没有旁路把 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 线程、请求超时、访问日志),并对替换做一次显式验证------启动日志确认容器名 + 压测观察参数是否生效:

properties 复制代码
# 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。

开源仓库地址

参考资源


专栏导航

如果本文对你有帮助,欢迎点赞、收藏、转发。有任何问题或建议,请在评论区留言交流。行文仓促,定有不足之处,欢迎各位朋友在评论区批评指正,不胜感激。

相关推荐
长弓三石1 小时前
Spring Boot 4.1 + AgentScope:企业级 Agent 平台的 MCP 双向集成实战
人工智能·spring boot·后端·harness·开源智能体
小蒜学长13 小时前
基于Spring Boot+Vue3的在线医疗咨询系统的设计与实现(代码+数据库+LW)
java·spring boot·后端·在线问诊·在线医疗咨询系统
m0_6399069417 小时前
SpringBoot自动配置原理
java·spring boot·后端
aiks_yuan19 小时前
Spring AI MCP Server 怎么接入 Claude Code:Spring Boot 用 @McpTool 暴露接口(Spring AI 2.0.2 实测)
spring boot
学长毕设19 小时前
基于springboot的实验室设备管理系统
java·spring boot·后端
学长毕设20 小时前
基于springboot的社区养老监护系统的设计与实现
java·spring boot·后端
wno70420 小时前
Springboot国际化信息(i18n)解析
java·spring boot·后端
paopaokaka_luck1 天前
社区服务平台(加权协同过滤推荐、Jieba中文词频分析、WebSocket实时聊天、求助处理闭环、 活动积分闭环、ECharts多维分析)
java·前端·spring boot·mysql·echarts·mybatis
不会写代码的女程序猿1 天前
Spring Boot+Layui构建企业级电子招投标系统源码
spring boot·招投标系统·招采系统·电子招投标