Nginx proxy_pass 路径拼接实战:结尾斜杠的替换规则、rewrite 之后谁说了算与 upstream keepalive 三行配置

为什么反向代理的路径总是拼错

只要你在个人服务器上跑过反向代理,大概率遇到过这种诡异现象:后端应用在本地用 curl 127.0.0.1:8080/api/user 一切正常,套上 Nginx 之后访问 https://example.com/api/user 却返回 404,日志里后端收到的路径变成了 /api/api/user,或者干脆只剩一个 /。九成以上的情况,问题就出在 proxy_pass 后面那个结尾的斜杠上。

Nginx 处理 proxy_pass 的 URI 有一条非常明确但很多人忽略的规则:如果 proxy_pass 的值带 URI(也就是在主机端口后面还有路径,哪怕只是一个 /),那么 Nginx 会用配置里的 URI 去替换掉 location 匹配到的那一段;如果 proxy_pass 的值不带 URI(只有 http://host:port),则原样透传完整请求路径。斜杠的存在与否,直接决定走哪条分支。

四组对照实验,一次把规则钉死

与其背规则,不如亲手做一遍。下面四组配置覆盖了 95% 的实战场景,假设后端应用的真实路由是 /api/user

# 实验一:proxy_pass 带 URI(结尾有斜杠)
location /api/ {
    proxy_pass http://127.0.0.1:8080/;
}
# 请求 /api/user -> 后端收到 /user   (/api/ 被替换成 /)

# 实验二:proxy_pass 带 URI(结尾无斜杠,但仍带路径)
location /api/ {
    proxy_pass http://127.0.0.1:8080;
}
# 请求 /api/user -> 后端收到 /api/user (原样透传,这是最容易被误认的)

# 实验三:proxy_pass 带 URI,且路径不同
location /api/ {
    proxy_pass http://127.0.0.1:8080/v1/;
}
# 请求 /api/user -> 后端收到 /v1/user

# 实验四:location 用正则,proxy_pass 绝对禁止带 URI
location ~ ^/api/(.*)$ {
    proxy_pass http://127.0.0.1:8080/$1;   # 只能用捕获组手动拼
}

请特别注意实验二和实验一之间的差别。很多人以为"只要是反向代理就会自动去掉前缀",其实并非如此——只有 proxy_pass 明确写了 URI 才会发生替换。实验二这种写法里,虽然末尾没写斜杠,但它仍然算"带 URI"吗?答案是:http://127.0.0.1:8080 属于"不带 URI",所以路径原样透传。而 http://127.0.0.1:8080/ 属于"带 URI(URI 为 /)",所以前缀被替换掉。一个斜杠的有无,行为天差地别。

实验四是最容易踩坑的地方:正则 location 中使用 proxy_pass 时,如果带 URI,Nginx 会直接报错,提示 "proxy_pass" cannot have URI part in location given by regular expression,原因是 Nginx 无法确定该替换哪一段。此时唯一正确的做法是用命名捕获组或者数字捕获组手动拼接,像上面的 /$1 那样。

怎么确认后端到底收到了什么路径

别靠猜。proxy_pass 的路径规则可以通过日志直接观测:在代理的 location 里加上一行,把上游真正收到的 URI 记下来。

location /api/ {
    proxy_pass http://127.0.0.1:8080/;
    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;

    # 关键:$upstream_http_* 拿不到请求行,用 $request_uri 看原始请求,
    # 上游真实路径靠后端自己打印。这里先用一个自定义 header 埋点。
    add_header X-Debug-Upstream-URI $uri always;
}

更可靠的办法是在后端打日志。Nginx 的 $uri 是"经过 rewrite 和 location 处理、去掉参数后的规范化路径",它反映的是 Nginx 视角,不能证明后端收到了什么。真正能证明的只有上游应用自己的访问日志。所以排查顺序应该是:先在 Nginx 侧确认 $request_uri(原始请求行),再去后端确认实际收到的 PATH_INFO,两边一对照,立刻知道是多了前缀还是少了前缀。

常见连带问题:Host 头、gzip 与 WebSocket

路径问题解决后,反向代理还有三个高频连带坑,顺手一起处理掉。

第一是 Host 头。默认情况下 Nginx 会把 Host 设成 proxy_pass 里的主机名,也就是 127.0.0.1:8080。很多框架(尤其是 Python 的 Django、Flask,以及一些 PHP 框架)会用 Host 头来生成绝对 URL、校验 CSRF、判断是否走 HTTPS。如果不显式设置 proxy_set_header Host $host;,就会出现"页面能打开,但所有静态资源链接都指向 127.0.0.1"或者"提交表单报 CSRF 错误"这类症状。同理,X-Forwarded-Proto 不传,后端就以为自己在跑 HTTP,会一直做 301 跳转,形成死循环。

第二是 gzip 重复压缩。如果后端(比如 PHP-FPM 或者 Tomcat)已经启用了压缩,Nginx 又开一次,可能造成响应异常或浪费 CPU。更隐蔽的情况是 Nginx 把上游已经压缩的响应又解压再压缩。稳妥做法是:在代理层统一处理压缩,用 proxy_set_header Accept-Encoding ""; 屏蔽掉后端压缩,让 Nginx 独占 gzip 职责。

location /api/ {
    proxy_pass http://127.0.0.1:8080/;
    proxy_http_version 1.1;
    proxy_set_header Connection "";
    proxy_set_header Accept-Encoding "";
    proxy_buffering on;
    proxy_buffer_size 16k;
    proxy_buffers 8 32k;
    proxy_busy_buffers_size 64k;
    proxy_read_timeout 60s;
}

第三是 WebSocket 无法升级。HTTP/1.0 不支持 Connection 升级,Nginx 默认给上游发的是 1.0,所以 WebSocket 握手必然失败。必须显式声明 proxy_http_version 1.1; 并把 Connection 头清空(注意是清空而不是设成 upgrade,因为 $http_connection 里可能带着其他值),再补上 Upgrade 头映射。

map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}
server {
    listen 443 ssl http2;
    server_name example.com;
    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 $connection_upgrade;
        proxy_read_timeout 3600s;   # 长连接别用默认 60s,会被掐断
    }
}

把结论固化成检查清单

下次接手一个反向代理配置,按这个顺序过一遍,基本能一次性排掉绝大多数问题:

第一步,看清楚 proxy_pass 是"带 URI"还是"不带 URI",前者替换 location 前缀,后者原样透传;第二步,确认 location 是不是正则,正则下 proxy_pass 绝对不允许带 URI;第三步,检查是否设置了 HostX-Real-IPX-Forwarded-ForX-Forwarded-Proto 四个头,尤其是用了框架的时候;第四步,WebSocket 场景单独确认版本号和 Connection 映射;第五步,改完配置用 nginx -t 校验再 systemctl reload nginx,别直接 restart 造成连接中断。

最后提醒一句:proxy_pass 的替换行为只发生在字符串前缀层面,不做任何路径归一化。也就是说,如果请求是 /api//user(双斜杠)或者 /api/../user,Nginx 不会帮你折叠,后端收到的就是你写的那样。有些框架能自动处理,有些会直接 404,遇到"本地好线上坏"的情况,记得把原始请求路径原封不动抓下来对比,往往答案就藏在那几个多出来的斜杠或者点号里。

rewrite 之后再 proxy_pass,谁说了算

rewriteproxy_pass 同时出现在一个 location 里,路径到底怎么算就成了另一个高频疑惑点。规则是:先执行 rewrite,把请求 URI 改掉,然后 proxy_pass 再拿改完之后的 URI去做替换判断。如果你写了 rewrite ^/old/(.*)$ /new/$1 break;,那么后续 proxy_pass 看到的就是 /new/xxx,替换基准也跟着变。

location /old/ {
    rewrite ^/old/(.*)$ /new/$1 break;
    proxy_pass http://127.0.0.1:8080/;
}
# 请求 /old/user -> rewrite 成 /new/user -> 替换掉 /new/ -> 后端收到 /user

这里最容易出错的是 breaklast 的区别。用 rewrite ... last;,Nginx 会在改完 URI 后重新走一遍 location 匹配,可能会落到完全不同的 location 里去;而 break; 表示就在当前 location 内停下,不再重新匹配。在代理场景下,通常应该用 break,因为你要的是「改个路径继续代理」,而不是「改完路径换一套规则」。如果误用 last,很容易陷入自己 rewrite 自己、最终报 500 或循环重定向的死局。当你不确定时,打开 rewrite_log on; 并把 error_log 级别设为 notice,Nginx 会把每一轮 rewrite 的输入输出都打印出来,路径变化过程一目了然。

上游是 HTTPS 或带虚拟主机时怎么办

还有两类场景值得单列。一是后端本身是 HTTPSproxy_pass https://backend.internal:8443/; 这种写法 Nginx 会把上游当 TLS 客户端。如果后端证书是自签的或者用的是内网 CA,Nginx 会因为校验失败返回 502,错误日志里会写 SSL_do_handshake() failed 或者 certificate verify failed。此时可选 proxy_ssl_verify off;(关掉校验,内网可接受)或者 proxy_ssl_trusted_certificate /path/ca.crt; 指定信任链。同时 SNI 也要配对:proxy_ssl_server_name on; 会把 Host 头里的主机名作为 SNI 发给上游,很多云厂商的网关(比如 AWS ALB、Cloudflare Tunnel)必须带 SNI 才能正确路由,否则会遇到「握手成功却返回 403」。

二是后端有虚拟主机:一台上游服务器上跑了多个站点,靠 Host 头区分。这时你要把 proxy_set_header Host 设成上游期待的那个域名,而不是客户端请求的域名,例如 proxy_set_header Host backend.internal;。否则上游会按默认站点返回内容,出现「代理到了正确的机器、却返回了错误的站」这种迷惑现象。排查时先用 curl -H "Host: backend.internal" http://127.0.0.1:8080/api/user 直连后端确认它的行为,再回头调 Nginx 的头设置,比在代理链里反复试要快得多。

用 upstream 与 keepalive 把这层做扎实

路径调通只是第一步,反向代理的性能和稳定性还得靠 upstream 块来兜。很多人习惯直接写 proxy_pass http://127.0.0.1:8080;,单机单实例时这没问题,但只要你有多台后端、或者需要做健康检查和失败摘除,就必须把后端提出来放进 upstream

upstream backend {
    least_conn;                       # 按当前连接数选后端,比默认轮询更稳
    server 127.0.0.1:8080 max_fails=3 fail_timeout=30s;
    server 127.0.0.1:8081 max_fails=3 fail_timeout=30s backup;
    keepalive 32;                     # 关键:保持到上游的长连接池
}

server {
    location /api/ {
        proxy_pass http://backend/;
        proxy_http_version 1.1;       # keepalive 的前提
        proxy_set_header Connection "";   # 必须清空,否则每次都是短连接
        proxy_next_upstream error timeout http_502 http_503;
        proxy_connect_timeout 3s;
        proxy_send_timeout 30s;
        proxy_read_timeout 30s;
    }
}

这里最值钱的是 keepalive 32; 加上 proxy_http_version 1.1; 再加 proxy_set_header Connection ""; 这三行组合。Nginx 默认对上游使用 HTTP/1.0 短连接,每个请求都要重新三次握手,在高并发下会累积出大量 TIME_WAIT,甚至把本地端口耗尽,表现为间歇性的 502 和「Cannot assign requested address」。开启 keepalive 复用连接后,握手开销和端口压力都会大幅下降。三行缺一不可:只写 keepalive 不改版本号,Nginx 仍然按 1.0 发;改了版本号不清空 Connection 头,上游可能收到 Connection: close 主动断开。这是代理调优里最容易被漏掉的一处,也是「配置看起来没问题、压测就是上不去」的常见根因。

另外 max_failsfail_timeout 的配合也要理解清楚:max_fails=3 表示连续失败 3 次后摘除,fail_timeout=30s 表示摘除后 30 秒内不再尝试、之后放行一次探测。如果后端只是短暂抖动,这个机制能自动恢复;但如果你的健康检查逻辑配得太激进(比如 fail_timeout 只有 2 秒),后端一旦被摘除就会反复在「摘除—探测—失败—再摘除」之间震荡,日志里会出现大量 no live upstreams 错误。个人站后端数量少,建议把 fail_timeout 设在 10 秒以上,给后端留出喘息时间。

Last modification:September 23rd, 2026 at 01:24 pm

Leave a Comment