我先把现象描述清楚,方便后面排查时对照。我这次遇到的是 Apache Knox 网关转发 Trino Web UI 的场景:Trino 协调节点直接访问 Web UI 一切正常,但一旦通过 Knox 的 https://knox-host:8443/gateway/default/trino/ui/ 访问,页面要么打不开,要么打开后大量资源请求直接返回 406 Not Acceptable,查询接口也完全不可用。这个问题看起来像是“玄学”,但其实 406 背后有一套非常明确的 HTTP 语义,只要顺着链路抓包,就能一步一步定位到根因。
这篇文章我会把整个排查过程、原理分析、最终修复方案都写下来。内容偏实操,适合正在做 Knox 统一入口、或者准备把 Trino 网关化的运维和平台工程师。我会尽量把“为什么会出现 406”“为什么只有 UI 受影响”“REST API 反而正常”这几个关键点讲透。
1. 复现 406:现象、环境与第一条判断线索
1.1 我当时的环境
先交代一下环境,方便你对号入座:
- Apache Knox Gateway 版本:1.5.0,部署在独立的边缘节点
- Trino 版本:Trino 350 系(PrestoSQL 更名后的版本),协调节点端口为默认的 8080
- Knox 拓扑名:default
- 服务访问形式:
https://knox-host:8443/gateway/default/trino/... - 认证方式:Knox 使用 LDAP 认证,Trino 侧未开启额外认证,Knox 以明文 HTTP 方式向后端 Trino 转发
这里有一个很常见的部署假设:Knox 在前端做统一认证和 TLS 终结,后端 Trino 开在 8080 端口,内网环境,Knox 直接通过 HTTP 转发到 Trino。这个架构本身没问题,但正是因为 Knox 和后端之间走的是“网关代理 + URL 重写”这套机制,才会在 Web UI 这一类 HTML/静态资源请求上踩坑。
1.2 复现步骤
我当时的复现路径很简单:
- 浏览器直接访问
http://trino-coordinator:8080/ui/,页面正常加载,查询任务也好使。 - 浏览器访问
https://knox-host:8443/gateway/default/trino/ui/,页面返回 406。 - 用 curl 直接请求 Knox 的 Trino API 接口
https://knox-host:8443/gateway/default/trino/v1/statement,POST 查询语句,能正常拿到查询结果。 - 直接请求
http://trino-coordinator:8080/v1/statement,同样正常。
换句话说,所有走 Kn控制 /v1/statement 的 REST 请求都通,唯独 /ui 页面 406。这就把问题的范围缩小到了“Knox 对 Trino UI 路径的转发行为”上,而不是 Trino 协调节点本身挂了或者端口不对。
1.3 第一条判断线索:406 是“协商”失败
看到 406,第一反应不应该是看 Knox 配置,而是先理解 406 在 HTTP 协议里的含义。406 的全称是 Not Acceptable,RFC 7231 里定义得很清楚:服务器根据请求里的 Accept 头判断,发现自己没有能力返回客户端可接受的资源表示形式。
这句话翻译成人话就是:客户端在请求里说了“我只要 text/html”或者“我接受 application/json”,但服务端发现这个资源根本没有对应的类型可以给,于是干脆回一个 406。
所以 406 通常不是 Knox 的“连接失败”,也不是端口拒绝,而是“请求头和响应体协商不一致”。由于这条线索,我把后续排查重心从网络连通性转移到了 HTTP 头和内容协商逻辑上。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 406 不是谜语:先定位返回者
2.1 4xx 错误必须先确认是谁返回的
反向代理场景里最忌讳的事情,就是看到一个 4xx/5xx 就默认是后端服务返回的。网关可以在任何一层返回错误。Knox 本身出错时也会返回自己的错误页面,而且有时状态码是 406,所以必须先确定到底是谁把 406 甩给了浏览器。
我用的方法是三层定位:
- 看 Knox 的访问日志和审计日志,确认 Knox 是否把请求成功转发给了 Trino。
- 看 Trino 的日志,确认 Trino 是否收到过这个请求。
- 在外围用 curl 直接打到 Knox 再打到 Trino,对比响应头。
2.2 Knox 日志里能看到什么
Knox 的日志默认在 $KNOX_HOME/logs/gateway.log,还有一些按拓扑名拆分的审计日志。我打开日志后发现,访问 /trino/ui/ 时,Knox 记录了一次转发动作,目标地址是 http://trino-coordinator:8080/ui/,响应状态码是 406。
也就是说:Knox 已经成功把请求转给了 Trino,Trino 返回了 406,然后 Knox 把 406 原样透传给了浏览器。
这一条非常关键。它说明 Knox 和 Trino 之间的网络是通的,Knox 并没有因为自己的安全策略拒绝请求,真正产生 406 的是 Trino 协调节点的 HTTP 服务。
2.3 Trino 侧的日志证据
Trino 的日志在 var/log/trino/ 下,默认是 server.log,里面会记录 HTTP 请求级别的异常。出现 406 时,日志里通常能看到类似这样的内容:
text复制2025-01-15T10:33:21.458Z WARN http-worker-42 javax.ws.rs.NotFoundException
Message not found for HTTP method: GET, path: /ui/
这行日志看起来是 404 的写法,但在我这个版本里,UI 路径的 JAX-RS 资源在没有匹配到可返回表示时,会以 406 收尾。我的具体日志里确实记录了一个 Not Acceptable 级别的告警,指向 /ui/ 路径。
到这里,问题已经可以确认:406 是 Trino 自己返回的,不是 Knox 伪造的。于是下一步就得搞清楚 Trino 为什么会对通过 Knox 转发过来的请求返回 406,而对直接访问的请求返回 200。
2.4 抓包对比:直接访问与 Knox 访问差在哪
我用了 tcpdump 在 Knox 节点上抓后端流量,同时在浏览器开发工具里看请求头。对比时发现一个非常明显的差异:
直接访问时,浏览器到 Trino 的请求头是:
http复制GET /ui/ HTTP/1.1
Host: trino-coordinator:8080
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
通过 Knox 访问时,Knox 转发给 Trino 的请求头里出现了两个变化:
http复制GET /ui/ HTTP/1.1
Host: trino-coordinator:8080
Accept: application/json
X-Forwarded-For: 10.x.x.x
X-Forwarded-Proto: https
X-Requested-With: XmlHttpRequest
关键就在 Accept: application/json。这个头被 Knox 给“标准化”了,或者说,被 Knox 默认的 REST 服务处理逻辑给覆盖了。
3. 深入排查链路:Knox 转发与 Trino 内容协商
3.1 Knox 的服务角色决定转发行为
Knox 是一个很典型的“按服务角色”做代理的网关。你在拓扑文件里声明了什么服务角色,Knox 就会按那套服务的默认规则去处理请求、重写 URL、处理响应头。
我当时的拓扑文件 topologies/default.xml 里是这样写的:
xml复制<topology>
<gateway>
<provider>
<role>authentication</role>
<name>ShiroProvider</name>
<enabled>true</enabled>
<param>
<name>session.maxDurationMinutes</name>
<value>60</value>
</param>
</provider>
<provider>
<role>identity-assertion</role>
<name>Default</name>
<enabled>true</enabled>
</provider>
</gateway>
<service>
<role>TRINO</role>
<url>http://trino-coordinator:8080</url>
</service>
</topology>
问题就出在这个 <role>TRINO</role> 上。Knox 发行版自带的服务定义里,有 PRESTO、HIVE、WEBHDFS 等角色,但往往没有完整的 TRINO 角色定义。于是 KNox 会使用一套默认的 REST 服务处理逻辑,把 /trino/** 下面的所有路径都当作“Web API 服务”来对待。
所谓的“对待”,核心动作就两个:
- 请求转发时,给请求设置默认的
Accept: application/json - 响应返回时,根据响应的
Content-Type判断是否需要重写 HTML/JS/CSS 里的 URL
这套逻辑对 REST API 是友好的,因为 /v1/statement、/v1/info 这些接口本来就是 JSON。可一旦访问 /ui/,Knox 仍然以“API 的姿势”转发请求,Trino 的 JAX-RS 资源在拿到 Accept: application/json 后,发现自己无法输出 JSON 格式的 HTML 页面,于是返回 406。
3.2 Trino 的 UI 资源为什么要讲究 Accept
很多人会问:Trino 的 Web UI 不是一堆静态文件吗?为什么它的 HTTP 服务还要看 Accept 头?
这里需要稍微讲一下 Trino 的 Web UI 实现。Trino 协调节点的 HTTP 服务由 Jetty 承载,Web UI 并非完全是静态文件,它有一部分路径是 JAX-RS 资源动态生成的。/ui/ 这个入口返回的是 HTML 页面,而 HTML 页面的 @Produces 声明是 text/html。按照 JAX-RS 规范,如果客户端请求头里的 Accept 不包含 text/html,资源方法可以直接拒绝,返回 406。
直接访问时,浏览器发送的 Accept 头里明确包含了 text/html,所以 JAX-RS 资源能正常返回页面。而经过 Knox 后,Accept 头变成了 application/json,JAX-RS 找不到符合要求的表示,只能回 406。
这个原因刚好解释了为什么 REST API 正常、UI 挂掉。因为 REST API 本身就是 application/json,Knox 把这个头伪装成 JSON 请求,反而和后端接口的期望完全一致。
3.3 Knox 确实改写了 Accept 头吗
我在抓包时观察到 Knox 把 Accept 改成了 application/json。其实这并非 Knox 的“主动攻击”,而是 Knox 的服务级 Web 描述符在起作用。
Knox 里每个服务角色对应一套 WebApp 描述符,里面定义了 webapprewrite.xml 和输出规则。服务角色的 rewrite.xml 里通常会有类似这样的配置:
xml复制<rule pattern="/trino/v1/statement">
<rewrite template="proxy://${host}:${port}/v1/statement"/>
</rule>
而某些 Knox 版本里,如果服务角色没有配置显式的重写规则,请求会落到一个默认分发器。这个分发器会用内部 WebApp 的信息构造请求,部分实现会直接给入站请求附加一个 Accept: application/json 头。
这个行为在不同版本里可能有差异,但结论是一致的:Knox 在把请求转给后端时,并不是百分之百“透传”浏览器原始头的,它会对部分服务做规范化。
3.4 其他可能触发 406 的因素
在排查时,除了 Accept 头,我还观察到另外两个容易被忽略的点:
一个是 X-Requested-With: XmlHttpRequest。这个头不是浏览器主动加的,而是 Knox 或前端框架加的。Trino UI 的前端 JavaScript 会在发起 XHR 请求时带上这个头,但正常浏览器直接访问 HTML 页面时不会带。Knox 统一加上这个头,让 Trino 的 UI 可能误以为自己只接收 AJAX 请求,虽然这通常不会直接导致 406,但会混淆抓包判断。
另一个是 Knox 对响应头的重写。某些配置下,Knox 会把后端的 Content-Type 改写掉,比如把 text/html; charset=utf-8 改成 application/octet-stream。浏览器一旦发现响应体类型和自己期望的 Accept 不匹配,虽然一般不会显示 406,但会被 CORS、MIME 嗅探策略拦下来,最终表现很类似“资源加载失败”。
在我这次排查里,最核心的因子还是 Accept 头被改成了 application/json。
4. 根因复盘:为什么 UI 路径单独中招
4.1 Trino UI 的资源组织方式
要彻底理解问题,得看看 Trino UI 到底是怎么组成的。Trino Web UI 页面主体是 HTML,但渲染时还会请求大量的 JS、CSS、图片和 API 接口:
/ui/:入口 HTML/ui/assets/...:前端静态资源/v1/statement:提交 SQL 的 REST API/v1/query/...:查询状态接口/v1/cluster/...:集群状态接口
直接访问时,浏览器对 /ui/ 发 HTML 请求,返回 text/html。随后浏览器再对 /ui/assets/xxx.js 发 JS 请求,返回 application/javascript。这些请求的 Accept 头都符合资源本身的类型。
但通过 Knox 转发时,Knox 把整个 /trino/** 路径按统一规则处理。如果只针对 API 路径做了配置,UI 路径的 HTML、JS、CSS 就全部落入默认规则,最后全部被强制成 JSON 请求。这里有一个关键区别:/ui/assets/xxx.js 虽然是静态文件,但如果请求头是 Accept: application/json,Jetty 的静态资源处理器可能不会直接 406,而是会协商失败,最终同样出现 406 或 404。
所以,问题不是某一个请求坏了,而是整条 UI 访问链路都不匹配。
4.2 Knox 默认规则只覆盖 API 的“偏科”现象
我们再来看看 Knox 对 Trino/Presto 这类服务常见的重写规则写法。很多人在网上抄到的配置长这样:
xml复制<rule pattern="/trino/v1/statement/**">
<rewrite template="proxy://${host}:${port}/v1/statement/**"/>
</rule>
<rule pattern="/trino/v1/info/**">
<rewrite template="proxy://${host}:${port}/v1/info/**"/>
</rule>
这种写法把 v1 下面的 API 路径全部映射进去了,但完全没有提 /ui/**。访问 /trino/ui/ 时,Knox 找不到精确匹配规则,会走兜底逻辑,把原始路径尽量原样转发。理论上这也能通,可问题在于 Knox 的兜底逻辑会使用服务级别默认请求头,导致 Accept 头变成 JSON。
这就像一个门户保安:你告诉它“这个区域只放行 REST API 请求”,它就把所有进出的人都按 REST API 请求的安检标准来查。结果一个程序员拿着浏览器要进去看页面,保安非要他出示 JSON 格式的凭证,页面自然进不去。
4.3 响应重写带来的二次影响
如果 Knox 配置里还开了响应重写(response rewrite),那问题会更隐蔽。Knox 会对后端返回的 HTML 做一次“URL 重写”,把 Trino 页面里的 /ui/xxx 改成 /gateway/default/trino/ui/xxx,这样才能让浏览器在 Knox 域名下面继续加载资源。
但 UI 页面可能不是一次 HTML 就能加载完的,页面里 JS 运行时还要动态拼接 URL,例如调用 /v1/statement。浏览器里实际请求的地址是 /gateway/default/trino/v1/statement,Knox 再把这个路径转发到 Trino。整个链路里,任何一环的 URL 拼接错位,都可能导致后续资源请求落到一个 Trino 根本不认识的自然路径上,返回 404 或 406。
所以综合来看,“为什么 UI 单独中招”可以归纳成两个原因:
- 请求头层面的 Accept 协商失败,最直接,导致 406。
- URL 重写规则覆盖不全,导致后续资源请求错乱,间接引发 4xx。
5. 修复方案:按优先级可落地的三种手段
5.1 方案一:在 Knox 里增加 UI 资源重写规则
这是我最终采用的方案,推荐优先尝试。思路是:让 Knox 对 /trino/ui/** 这个路径单独走一套“静态资源转发”规则,不要在请求头上强制 Accept: application/json,也不要对响应内容做过度的重写。
重写规则需要落到 Knox 对应服务角色的 webapp 描述文件里。以我用的 Knox 1.5.0 为例,在 $KNOX_HOME/conf/topologies/ 下有一个 default.xml 对应 default 拓扑,但每个服务角色的重写规则通常配置在 service 目录下,或者直接在拓扑中用 <rewrite> 参数引用。
如果是自定义服务,最直接的做法是给 /trino/ui 路径加一个从 Knox 到后端的 URL 重写映射,示例参考:
xml复制<rule pattern="/trino/ui/**" name="TrinoUI">
<rewrite template="proxy://${host}:${port}/ui/**"/>
</rule>
<rule pattern="/trino/assets/**" name="TrinoUIAssets">
<rewrite template="proxy://${host}:${port}/assets/**"/>
</rule>
需要注意:不同 Knox 版本对自定义服务角色的重写文件命名和组织形式有差异,上述内容旨在说明“把 UI 路径和后端路径做显式映射”的核心思路,实际落地时一定要对照当前版本的 rewrite 规范来写,不要直接复制到一个不存在的文件里。
改完之后,重点检查请求头。从 Knox 发往后端时,Accept 头应该尽量保持浏览器传入的原始值,而不是被替换成 application/json。如果你的 Knox 版本允许对单一服务角色关闭默认的 REST 头部注入,可以让 ui 路径走固定参数。
5.2 方案二:让 Knox 只代理 Trino API,Web UI 单独暴露
如果你的业务场景里,用户其实不太需要在一个网关入口里打开 Trino 的 Web UI,那更简单的方案是:
- Knox 只负责
v1/statement、v1/info等 API 路径的转发 - Trino Web UI 通过独立负载均衡或直连方式暴露给可视化平台
这个方案本质上是“架构回避”,不是说 Knox 不能代理 UI,而是如果你对 UI 的转发需求不高,没必要在 Knox 上额外处理静态资源重写的复杂度。
我见过不少团队最终选了这条路:Knox 管 API,Trino UI 走另一条独立的 HTTPS 入口,由 Nginx 或云负载均衡做 TLS 终止。这样两边互不影响,配置更清晰。
但要注意一个口碑问题:Trino UI 本身依赖 /v1/statement 等接口来执行查询和管理任务,如果 UI 走独立入口,它直接访问 Trino 的 API 端口,不会经过 Knox。你要是想给 API 做统一鉴权,那 UI 的查询请求就无法被 Knox 拦截。所以这个方案只适合“对内网可视化查询做展示”,不适合“把所有访问都收口到 Knox”的强管控场景。
5.3 方案三:用 API 客户端或代码访问 Trino,绕过 UI
如果只是排查问题,肯定建议用 curl 验证。但如果是日常使用,尤其是写自动化脚本或者做数据平台集成,根本不需要开 Trino Web UI。Trino 客户端(比如 JDBC/ODBC)走的是 v1/statement 这个 REST 通道,完全不受 UI 406 影响。
我当时的验证命令是这样的:
bash复制curl -k -u knoxuser:password \
-H "Content-Type: application/json" \
-H "X-Trino-User: admin" \
-d '{"query": "SELECT 1"}' \
"https://knox-host:8443/gateway/default/trino/v1/statement"
返回 200,说明 Knox 对 Trino API 的转发链路是健康的。如果你的团队主要用 BI 工具,也会碰到类似情况,BI 工具一般默认就走 JDBC 或 REST,并不会打开 Trino 的浏览器控制台。
5.4 修复验证清单
无论采取哪种方案,改完之后我都建议按这套清单验证:
| 检查项 | 直接访问 | 通过 Knox 访问 | 预期结果 |
|---|---|---|---|
| Trino UI 入口 | http://trino:8080/ui/ |
https://knox/gateway/default/trino/ui/ |
200,页面可加载 |
| 页面静态资源 | http://trino:8080/ui/assets/xxx.js |
https://knox/gateway/default/trino/ui/assets/xxx.js |
200,JS/CSS MIME 正常 |
| REST API | POST /v1/statement |
POST /gateway/default/trino/v1/statement |
200,返回查询结果 |
| UI 内动态查询 | 浏览器内执行 SQL | 浏览器经 Knox 执行 SQL | 查询状态可刷新 |
我的经验是:如果改动后 UI 还是异常,那就继续抓一次包,重点看 /ui/ 相关请求的 Accept 头和返回响应里的 Content-Type。只要这两者对不上,问题依然在。
6. 延伸提醒与优化建议
6.1 不同 Knox 版本对自定义服务的行为差异很大
Apache Knox 从 1.x 到 2.x 的演进里,对自定义服务的处理逻辑做了不少调整。早期版本中,你在拓扑里加一个不存在的服务角色,Knox 可能直接用默认 API 分发器去代理;到了更新的版本,反而会要求必须有对应服务定义,否则直接报错。
所以如果你遇到“配置一样,但另一个版本的 Knox 没有出现 406”,不要惊讶。先到官方文档里查一下当前版本对 service role 的处理方式,尤其是 webapprewrite.xml 的加载路径。
6.2 建议把 Trino 的 REST API 和 UI 资源路径分开维护
通过这次问题,我最大的体感是:在 Knox 这类重写型网关后面接一个“既提供 REST API 又提供 Web UI”的服务,一定要在配置层面把 API 路径和 UI 静态资源路径分开维护。
一个可参考的规范是:
/v1/**走 API 规则,保持 JSON 语义/ui/**走静态资源规则,透传原始 Accept 头/assets/**走静态资源规则,不强制重写 HTML
在代码里就是多写几条 rule,运维收益很高。
6.3 顺手可以开启 Knox 的响应头保留策略
如果 Knox 支持配置响应头透传或保留策略,建议确认一下它不会覆盖后端返回的 Content-Type、Content-Length。有些跨域或浏览器安全策略会因此拦下资源。
Trino UI 对 Content-Type 很敏感,Chrome 的 MIME 嗅探(X-Content-Type-Options: nosniff)一旦发现 JS 文件返回类型不是 text/javascript,直接拒收。这类问题不会显示 406,但页面同样白屏,排查时别漏掉。
6.4 一个小技巧:直接看 UI 请求链
最后分享一个真实排查的小技巧。不要盯着页面的第一个 406,先把浏览器的 Network 面板里所有请求按状态排个序。你往往会发现,一开始的 HTML 可能是 200,但页面里某个 assets 请求是 406,或者某个 v1/statement 请求是 406。这种情况下,其实根因只是“后面某一个请求的 Accept 头不对”,而不是整个 UI 入口挂了。
顺着这条链改 Knox 针对那类路径的重写规则,比盲目改全局配置高效得多。我这次就是先看到 HTML 返回 200,但后续 CSS 加载失败,再抓到 Accept 头被改写成 JSON,才锁定了根因。
