在 Nginx 里做反向代理,最容易踩的坑之一,就是 location 前缀和 proxy_pass 尾部斜杠的搭配。表现往往是:本地用 curl 直连后端 200,经过 Nginx 就变成 404;或者用 /api/health 探活一直 404,但日志里看不出任何”配置错误”——因为 Nginx 认为一切正常,它只是把你请求的 URI 按规则改写后转发出去,后端按改写后的 URI 找不到资源,返回 404。这篇内容给出一套可以直接套用的排查模板:先把改写规则讲透,再给可复制的配置片段、日志采集命令、逐层定位顺序,最后附上真实报错原文和修复方式。
一、先搞清楚:proxy_pass 带不带斜杠,URI 到底怎么变
Nginx 的 proxy_pass 行为只有两种,分界线就是指令值里有没有 URI 部分。这里的”URI 部分”指协议和主机之后的内容,写不写尾部斜杠决定了走哪条分支。
| 写法 | 是否带 URI | 转发时的 URI | 典型用途 |
|---|---|---|---|
proxy_pass http://backend; |
否(只有主机) | 原样保留客户端 URI | 后端路径与前端一致,或需要一个完整透传 |
proxy_pass http://backend/; |
是(/) |
用 / 替换匹配到的 location 前缀 |
剥掉前缀,例如 /api/ → / |
proxy_pass http://backend/v2/; |
是(/v2/) |
用 /v2/ 替换匹配到的 location 前缀 |
前缀重写,例如 /api/ → /v2/ |
关键点在第三列:带 URI 时,Nginx 不是”拼”而是”替换”。替换的对象是 location 中匹配到的那部分前缀。所以下面这组配置,请求 /api/users 最终打到后端的路径是 /users:
location /api/ {
proxy_pass http://127.0.0.1:8080/;
}
而把尾部斜杠去掉,同一个请求打到后端仍然是 /api/users:
location /api/ {
proxy_pass http://127.0.0.1:8080;
}
404 就出在这里。后端只注册了 /users,你却把 /api/users 发给它,Spring Boot 抛 NoResourceFoundException、Express 返回 Cannot GET /api/users、FastAPI 返回 {"detail":"Not Found"},网关层看到的都是 404。反过来,后端注册了 /api/users,你却在 proxy_pass 上加了斜杠,前缀被吃掉,同样是 404。
还有一个高频陷阱:
location /api(无尾斜杠)配proxy_pass http://backend/;,请求/api/users会被替换成//users——因为匹配到的前缀是/api,剩下的/users原样接在替换目标/之后,形成双斜杠。多数后端能容忍,但部分框架的路由匹配、静态资源服务器(如 Nginx 自身、Tomcat 的 default servlet)会因此 404 或 301。统一”location 带斜杠、proxy_pass 也带斜杠”能规避这一类问题。
快速验证改写结果不靠猜,用 nginx -T 看生效配置,再用后端访问日志看真实 URI:
# 1. 打印合并后的完整配置,确认没有 include 覆盖
nginx -T 2>/dev/null | grep -n -A3 'location /api'
# 2. 发一个已知存在的路径,看后端 access log 里记录的是什么 URI
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1/api/users
# 3. 后端是 Nginx 时,直接看它自己的 access.log
tail -n 5 /var/log/nginx/access.log
第 3 步最有价值:后端日志里出现的 URI 就是 Nginx 改写后的最终结果,比任何推理都快。若后端不是 Nginx,用框架自带的请求日志,Spring Boot 开 logging.level.org.springframework.web=DEBUG,Flask 用 app.logger 或 gunicorn 的 --access-logfile -。
二、排查模板:五层定位法,从 404 现象倒推到配置行
下面这套顺序照着做,一般 10 分钟内能定位到具体是哪一行配置把 URI 改坏了。每层都给出判断依据和命令,不要跳步。
第 1 层:确认 404 是谁返回的
Nginx 自己返回 404 和后端返回 404,现象一样但原因完全不同。看响应头和响应体:
curl -i http://127.0.0.1/api/users
- 响应头里有
Server: nginx/1.24.0且响应体是 Nginx 默认的<html><head><title>404 Not Found</title>...</head><body><center><h1>404 Not Found</h1></center><hr><center>nginx/1.24.0</center>,说明请求根本没匹配到能代理的 location(比如 location 写成了/api/但请求是/api),或者root/alias在本地找文件失败。 - 响应体是后端风格(JSON 错误、框架的 HTML 错误页),说明请求已经转发出去了,问题在 URI 改写或后端路由,回到第一节对照斜杠规则。
- 如果响应头有
X-Powered-By、Content-Type: application/json这类后端特征,基本可以锁定是第二种。
第 2 层:确认命中的是哪个 location
Nginx 的前缀匹配是”最长优先”,配置里同时存在 location /、location /api/、location /api/admin/ 时,/api/admin/x 会命中最后者。常见事故是有人在别处加了一个更长的 location /api/users/,把你以为的规则覆盖了。用下面的命令把候选 location 全列出来:
nginx -T 2>/dev/null | grep -nE '^\s*location\s'
更稳妥的做法是在测试环境临时开 error_log 的 debug 级别(生产慎用,日志量极大):
error_log /var/log/nginx/error.log debug;
tail -f /var/log/nginx/error.log | grep -i 'using configuration'
日志里会出现类似 using configuration "/api/" 的行,直接告诉你命中的是哪个 location 块。
第 3 层:拿到改写后的真实 URI
如果后端是 Nginx,直接看它的 access log 第一列请求行;也可以临时在代理侧加一个调试头,把 Nginx 最终发出去的 URI 回显给客户端:
location /api/ {
proxy_pass http://127.0.0.1:8080/;
add_header X-Upstream-URI $uri always;
proxy_set_header Host $host;
}
注意 $uri 表示的是 Nginx 内部处理到当前阶段的规范化 URI。若 proxy_pass 带 URI,实际发给上游的是替换后的结果,可能和 $uri 不完全一致。更精确的办法是用后端的访问日志,或者临时把 proxy_pass 指向一个 nc -l 端口,直接打印原始请求:
# 终端 A:起一个监听,打印收到的请求行和头
nc -l -p 18080 -k
# 终端 B:把 proxy_pass 临时改到 18080,然后 curl
# 观察终端 A 输出的第一行,例如:GET /users HTTP/1.1
这一条是排查利器:不管你的斜杠怎么写,nc 输出的第一行就是上游真实收到的请求行,写错在哪一目了然。
第 4 层:核对后端注册的路由
拿到上游 URI 后,和后端实际注册的路径逐字比对。常见差异不只是前缀,还有:
- 后端路由区分大小写,
/Api/users与/api/users不同。 - 后端路由带不带尾斜杠:Spring MVC 默认
/users和/users/可同时匹配(依赖版本与PathMatchConfigurer设置),但 Django 的APPEND_SLASH或 FastAPI 的严格匹配会直接 404。 - 后端挂在 context-path 下,例如
server.servlet.context-path=/svc,实际路径是/svc/users,而 Nginx 只转/users。
第 5 层:用最小复现固定结论
把怀疑的配置单独抽出来,起一个临时 server 块验证,避免被其他 server、include、upstream 干扰:
server {
listen 18081;
server_name _;
location /api/ {
proxy_pass http://127.0.0.1:8080/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
nginx -t && nginx -s reload
curl -i http://127.0.0.1:18081/api/users
最小复现通过后,再把这段配置合并回原文件,逐个 include 回滚,定位到冲突来源。
三、按场景给出正确配置:四种前缀形态的对照
下面是四组”前端路径 → 后端路径”的实际映射,覆盖绝大多数业务。每组的 location 都带尾斜杠,避免双斜杠问题。
场景 A:后端不感知 /api,需要剥前缀
# 请求 /api/users -> 上游 /users
location /api/ {
proxy_pass http://127.0.0.1:8080/;
}
场景 B:后端自己也挂 /api,需要保留前缀
# 请求 /api/users -> 上游 /api/users
location /api/ {
proxy_pass http://127.0.0.1:8080;
}
场景 C:灰度到新版本前缀
# 请求 /api/users -> 上游 /v2/users
location /api/ {
proxy_pass http://127.0.0.1:8080/v2/;
}
场景 D:把根路径整体反代,但静态资源走本地
location /static/ {
alias /var/www/app/static/;
expires 7d;
}
location / {
proxy_pass http://127.0.0.1:8080;
}
如果希望在不改后端路由的前提下让 /api 和后端某个路径对齐,也可以不用斜杠技巧,改用 rewrite 显式表达,语义更清晰、审查时更好读:
location /api/ {
rewrite ^/api/(.*)$ /$1 break;
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
}
注意 rewrite 的 break 标记:它让重写后的 URI 继续用当前的 proxy_pass 转发,而不重新进入 location 匹配,避免死循环。这种写法下 proxy_pass 不要带 URI,否则 rewrite 的结果会被再次替换,容易把人绕晕。一个 location 里,确认 URI 的方式只能选一种:要么靠 proxy_pass 的斜杠,要么靠 rewrite,不要混用。
上游是 HTTPS 或者需要长连接、WebSocket 时,补充这几个参数,属于同一段配置里必带的基础项:
location /ws/ {
proxy_pass http://127.0.0.1:8080/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 3600s;
}
四、真实报错与修复对照表
下面这些都是排查过程中实际会看到的原文,按报错反查配置。
| 现象 / 报错原文 | 根因 | 修复 |
|---|---|---|
后端日志 GET //users HTTP/1.1 后返回 404 |
location /api 无尾斜杠 + proxy_pass .../ |
location 改为 /api/,保持两侧都带斜杠 |
Spring Boot:NoResourceFoundException: No static resource users. |
上游收到 /users,但 controller 注册在 /api/users |
去掉 proxy_pass 尾斜杠,保留前缀 |
Express:Cannot GET /api/users |
上游收到 /api/users,路由只写了 /users |
给 proxy_pass 加尾斜杠 |
FastAPI:{"detail":"Not Found"},访问 /api/users/ 正常 |
后端严格匹配尾斜杠,Nginx 传的路径没带 | 统一约定路径写法,或在 location 内用 rewrite 补斜杠 |
Nginx 返回自带 404 页,日志 open() "/usr/share/nginx/html/api/users" failed (2: No such file or directory) |
请求没命中代理 location,落到了默认静态根 | 检查 location 前缀与请求路径是否匹配,确认没有更长的 location 抢占 |
nginx: [emerg] "proxy_pass" cannot have URI part in location given by regular expression, or inside named location... |
把带 URI 的 proxy_pass 写在了正则 location ~ 里 |
改用前缀 location,或用 rewrite 处理,proxy_pass 只写主机 |
| 301 跳转到错误地址,浏览器地址栏前缀重复 | 上游返回的 Location 头带自己的内网前缀,Nginx 未改写 |
加 proxy_redirect 调整,或让上游使用相对跳转 |
关于最后一条,proxy_redirect 的默认值 default 会把上游返回的 http://127.0.0.1:8080/ 改写成 Nginx 自己的地址;但上游若返回的是绝对路径加自建域名,就需要显式声明,例如 proxy_redirect http://127.0.0.1:8080/ /;。这类问题表象是”先 301 再 404″,容易被误判成斜杠问题,实际是重定向头没改写。
五、把检查写进流程:上线前的三条硬规则
斜杠问题靠人眼审查不可靠,建议固化成三条规则,写进 MR 检查清单或 CI 脚本。
规则一:所有代理 location 必须显式声明前缀策略
在仓库里维护一份 proxy-mapping.md,按表格记录”前端前缀 → 上游前缀 → 是否剥前缀”,配置评审时对照。经验上,团队统一成”剥前缀”(location 带斜杠、proxy_pass 带斜杠)最不容易出错,因为映射关系最简单。
规则二:用脚本断言改写结果
#!/usr/bin/env bash
# check-proxy.sh:上线前跑一遍,验证关键前缀的映射
set -euo pipefail
BASE="${1:-http://127.0.0.1}"
declare -A CASES=(
["/api/users"]="200"
["/api/health"]="200"
["/api/not-exist"]="404"
)
fail=0
for path in "${!CASES[@]}"; do
code=$(curl -s -o /dev/null -w '%{http_code}' "${BASE}${path}")
expected="${CASES[$path]}"
if [[ "$code" == "$expected" ]]; then
echo "PASS ${path} -> ${code}"
else
echo "FAIL ${path} -> ${code} (expected ${expected})"
fail=1
fi
done
exit "$fail"
更彻底的做法是直接断言上游收到的 URI,把 nc 监听嵌进测试,或者在后端暴露一个回显 URI 的调试端点(仅测试环境),返回值里包含 request.originalUrl,测试脚本比对字符串。
规则三:统一使用尾部斜杠,且拒绝对 location /api 无斜杠写法
# 在 CI 中拦截常见错误写法:location 无尾斜杠却配了带 URI 的 proxy_pass
if grep -rnE 'location\s+/[A-Za-z0-9_-]+\s*\{' conf/ | grep -v '/$'; then
echo "检测到无尾斜杠的 location,请改为 /path/ 形式"
exit 1
fi
这个 grep 只是粗筛,会误报,建议在 code review 阶段人工确认,避免把简单脚本变成新的维护负担。真正可靠的还是上面第 3 条的资产化记录。
小结
Nginx 的 proxy_pass 尾部斜杠不是”加不加都行”的风格问题,而是决定 URI 是保留还是替换的开关。排查顺序固定为:先用 curl -i 判断 404 由谁返回,再用 nginx -T 和 debug 日志确认命中的 location,然后用后端日志或 nc -l 拿到上游真实 URI,最后对照后端路由逐字比对。修复时优先保持”location 与 proxy_pass 同时带尾斜杠”的一致写法,需要更复杂的映射时改用 rewrite ... break 显式表达,一个 location 内只保留一种 URI 改写机制。
FAQ
Q1:proxy_pass http://backend; 和 proxy_pass http://backend/; 到底差在哪一步?
差在 Nginx 是否用 proxy_pass 里的 URI 去替换命中 location 的前缀。不含 URI 时保留客户端原始 URI;含 URI 时,把 location 匹配到的前缀替换为该 URI,剩余部分拼接。所以 /api/ + http://backend/ 得到 /users,/api/ + http://backend 得到 /api/users。
Q2:为什么用 nc -l 能直接看到上游收到的请求行?
因为这一步观察的是 Nginx 与上游之间的真实 HTTP 报文,绕开了所有框架日志的干扰。把 proxy_pass 临时指到监听端口,客户端发一次请求,监听端第一行就是上游视角的请求行,例如 GET /users HTTP/1.1,改写结果无可辩驳。
Q3:Spring Boot 下 /users 和 /users/ 都能访问,为什么还是 404?
因为 404 的原因不是尾斜杠,而是前缀。Spring MVC 的路由匹配容忍尾斜杠,但不容忍多余前缀。若 Nginx 剥掉了 /api 而后端 controller 注册在 /api/users,上游收到的 /users 就一定 404。先看上游 URI,再下结论。
Q4:location /api 不加尾斜杠,一定会出问题吗?
不一定,但风险不可控。不加尾斜杠时,/api 本身也能命中该 location,此时剩余部分为空。一旦 proxy_pass 带 URI,就可能拼出双斜杠,或因匹配前缀不同导致改写结果与预期不一致。统一加尾斜杠,是让行为可预测的代价最小的做法。
Q5:正则 location 里为什么不能给 proxy_pass 写 URI?
Nginx 在 location ~ ...、命名 location 中无法确定”被替换的前缀”是哪一段,因此直接报 nginx: [emerg] "proxy_pass" cannot have URI part in location given by regular expression, or inside named location...,nginx -t 就会失败。这类场景改用 rewrite 完成路径变换,proxy_pass 只写主机。
Q6:怎么快速验证一组改完后不影响其他前缀?
用上文 check-proxy.sh 的套路,把每个前缀的典型路径、期望状态码列成表,上线前跑一次。含动态参数、登录态的接口用固定 Cookie 或 token 传进去,确保断言的是路由是否命中,而不是鉴权失败返回的 401/403。