二级目录上线:补斜杠补出死循环,子路径静态资源全 404
补一条 301 让 /admin 能打开,跳转链却变成两跳明文;另一个路径与 301 打成了死循环;子路径应用页面能开但样式全丢。三件事都指向同一处:前缀与尾斜杠的匹配边界。
二级目录上线那天,用户反馈很集中:/admin 打不开,/gitea 能打开但页面没有样式。我先用浏览器点了一遍,/admin 跳到主站登录页,地址栏是干净的;/admin/ 带斜杠却正常。这种"差一个斜杠"的差异,一般不是应用的问题,是入口匹配的问题。
后面查下去,一条斜杠牵出三件事:补斜杠补出了两跳明文、补斜杠和另一个服务的 308 打成了死循环、子路径应用没剥前缀导致静态资源全 404。
一、先分辨 404 是谁返回的
/admin、/tenant、/demo、/mobile 四个地址返回的都是 404,页面标题是"页面不存在"。但这个 404 不是网关的,是主站应用自己的 404 页——响应体 16920 字节,跟主站模板一致;网关的 404 只有 136 字节的 JSON。
道理很简单:这几条路由只写了带尾斜杠的匹配,/admin 不带斜杠,谁都不匹配,最后被主站那条 Prefix / 兜底接走,交给主站应用,主站自然不认识这个路径。
for p in /admin /admin/ /tenant /tenant/; do
printf '%-10s code=%s size=%s\n' "$p" \
"$(curl -s -o /dev/null -w '%{http_code}' https://example.com$p)" \
"$(curl -s https://example.com$p | wc -c)"
done
对比响应体大小就能定位是谁的 404,这一步比看状态码有用得多。
二、补斜杠为什么要写绝对地址
修法很直接:给这几条路径加一组精确匹配,命中后 301 跳到带斜杠的地址。第一版我写的是相对跳转:
# 第一版:相对 Location
if ($request_uri ~ ^/(admin|tenant|demo|mobile)$) {
return 301 /$1/;
}
结果 /admin 的跳转链是两跳:先 301 到 http://example.com/admin/,再被 WAF 的 80→443 规则 301 到 https。原因是 TLS 在最外层终结,到了 ingress 这一层 $scheme 已经是 http,而 nginx 默认 absolute_redirect on,相对 Location 会被补成 http://host/...。用户看到的是"打开了,但地址栏先闪一下明文"。
改成显式拼接 scheme 和 host:
# 修正:绝对地址,一跳到位
if ($request_uri ~ ^/(admin|tenant|demo|mobile)$) {
return 301 https://$host$request_uri/;
}
跳数从 2 降到 1。这类"看起来只是慢一点点"的问题,实际是多一次明文请求,在弱网下体感明显,也会让埋点里的落地页统计失真。
三、301 撞上 308 就是死循环
补斜杠不能一把梭。有两个路径绝对不能碰:/umami 和 /api。这两个后端的框架配置是 trailingSlash=false,收到 /umami/ 会 308 跳回 /umami;我们再给 /umami 补 301 到 /umami/,两边就互相踢,浏览器直接报重定向过多。
| 路径 | 后端对尾斜杠的行为 | 能否补 301 |
|---|---|---|
/admin /tenant /demo /mobile |
静态目录,需要尾斜杠 | 可以 |
/prod-api |
网关,根路径两种写法都 404,接口路径不需要斜杠 | 可以(只为统一入口) |
/umami |
框架 trailingSlash=false,/umami/ 会 308 回 /umami |
不可以,会死循环 |
/api |
同上 | 不可以 |
判断方法是先打一次带斜杠的地址,看返回 200 还是 308。308 就是在告诉你"我不接受尾斜杠",这时候补 301 只会制造循环。
四、子路径应用必须剥前缀
/gitea 是另一个问题。它的 ROOT_URL 我们改成了 https://example.com/gitea/,页面能打开,但样式全丢,控制台一片 404。
一开始我以为是静态资源路径没配对,后来才想明白:ROOT_URL 只负责"生成链接时带上 /gitea/",它不改变应用内部的路由表。Gitea 内部的路由仍然是 /user/login、/assets/css/index.css。请求进来是 /gitea/assets/...,原样转给后端,后端没有这个路由,404。
所以子路径必须剥前缀:
# 24-ingress-gitea.yaml(节选)
metadata:
name: web-top-gitea
annotations:
nginx.ingress.kubernetes.io/rewrite-target: /$1
spec:
rules:
- host: example.com
http:
paths:
- path: /gitea/?(.*)
pathType: ImplementationSpecific
backend:
service:
name: git-svc
port:
number: 3000
有个坑要记住:rewrite-target 是 Ingress 级注解,不是 path 级。如果直接给已有的 git-svc 那个 Ingress 加,它自己的 Prefix / 也会被重写成 /,等于把根路径规则改坏。正确做法是同 host 另建一个 Ingress,各自管各自的注解。
第一版我用的是 Prefix /gitea 且不剥前缀,现象是 /gitea 302 到 /gitea/user/login,然后 404,响应体 11 字节。11 字节这个细节很有用——它不是我们的应用返回的,是 Gitea 的空 404。
五、验收要覆盖"带斜杠/不带斜杠/静态资源"
这类改动最容易在"主页面能开"这一步就宣布完成。我列了一张清单,逐条打:
check() {
printf '%-42s %s\n' "$1" \
"$(curl -s -o /dev/null -w 'code=%{http_code} hops=%{num_redirects} size=%{size_download}' "$1")"
}
check https://example.com/admin
check https://example.com/admin/
check https://example.com/gitea
check https://example.com/gitea/user/login
check https://example.com/gitea/assets/css/index.css
check https://example.com/umami
结果:/admin 一跳 200,/admin/ 直接 200,/gitea 与登录页 200,CSS 372851 字节 200,/umami 仍是 307 到看板(这是它本来的行为,不能动)。
这里有个容易误判的点:Gitea 的 git info/refs?service=git-upload-pack 返回 401,/gitea/api/v1/version 返回 403。这两个都是应用自身的鉴权响应,路径是通的,不要当成入口故障去改配置。
六、回退要能在一条命令内完成
这次改动新增了三个 Ingress、改了一个环境变量。回退清单写清楚:
kubectl -n app delete ingress \
web-top-trailing-slash web-top-gitea gitea-nip-subpath
kubectl -n app set env sts/gitea \
GITEA__server__ROOT_URL=http://gitea.$INGRESS_VIP.nip.io/
删 Ingress 是即时的,改回 ROOT_URL 会滚动重启一次(约 30 秒)。两个动作顺序无所谓,但都要做——只删 Ingress 不改 ROOT_URL,页面里的链接仍然带 /gitea/,用户点进去还是 404。
小结
- 带斜杠和不带斜杠的差异,先看 404 的响应体大小,判断是谁返回的,比猜快得多。
- 补斜杠要用绝对地址,最外层终结 TLS 时
$scheme已经是 http,相对跳转会多一跳明文。 - 补斜杠之前先确认后端接受尾斜杠:返回 308 的后端一律排除,否则 301 与 308 会打成死循环。
- 子路径应用要剥前缀,
ROOT_URL只影响链接生成,不影响内部路由表。 rewrite-target是 Ingress 级注解,会连累同一对象下的其他 path,宁可另建一个 Ingress。- 验收要覆盖带斜杠、不带斜杠、静态资源三类地址;应用自身的 401/403 不要误判成入口故障。
正在加载评论…