先搭一个可观测的后端:谁能被代理接住
讲清楚代理之前,先让后端自己可见:一个返回 JSON 的 Node 服务加上请求头回显,每条请求都能读到它落到哪个实例。因为代理配置的九成错误,最后都要靠"到底哪台服务器接到了"这个事实来判断。
用 node:http 写一个几十行的探针,监听不同端口,返回 request 的 remote addr 和 host,前端即可直观看到代理是否有生效、有没有把路径剥错。
// probe.mjs
import http from "node:http";
const port = Number(process.env.PORT || 8201);
http.createServer((req, res) => {
res.setHeader("content-type", "application/json");
res.end(JSON.stringify({
port,
path: req.url,
host: req.headers.host,
forwarded: req.headers["x-forwarded-for"] || null,
}));
}).listen(port, "127.0.0.1", () => console.log("listening", port));location 匹配顺序:多数"永远走进 /"的由来
location 的坑集中在匹配规则的选择。nginx 按"前缀最长匹配优先"来找 location,但在几条前缀同时能命中时,选最长的那个;而 = 精确匹配和 ^~ 正则屏蔽又各有优先级,表述容易背反。
最常见错误是:把 /api 和 / 同级,以为 /api 会被优先命中,其实 / 是任意前缀也能匹配 /api 开头,两者都候选用最长匹配 = /api 更长所以会赢——可一旦把 /api 写成 /api/,末尾的差异就可能导致它永远落进 / 的普通 proxy_pass,转发路径随即错误。理解这一点,再看下面排序示例就顺了。
location / { # 最泛,兜底
proxy_pass http://backend;
}
location ^~ /static/ { # 优先于正则,前缀命中最高
alias /srv/www/static/;
}
location = /healthz { # 精确匹配
return 200 "ok\n";
}
location ~* \.(css|js)$ { # 正则(~* 忽略大小写)
proxy_pass http://front;
}proxy_pass 的 / 陷阱:结尾单斜杠是会改路径的手术刀
proxy_pass 结尾是否带 /,决定转发时是否丢弃匹配部分。带 / 时,location 独占的那段前缀会被剥掉,剩余部分拼到上游 URI 后面;不带 / 时,整个原始 URI(含匹配部分)原样转发。
举例:location /api { proxy_pass http://up/ },请求 /api/orders 会按 / + orders 转发到上游 -> /orders;若改成不带斜杠 proxy_pass http://up,则上游收到的是 /api/orders。配错这条正是"接口地址绝对正确,后端却 404"的最常见元凶,下面给两种写法与各自期望。
# 情况 A:要去掉 /api 前缀
location /api {
proxy_pass http://up:8201/; # /api/orders -> /orders
}
# 情况 B:要保留 /api 前缀
location /api {
proxy_pass http://up:8201; # /api/orders -> /api/orders
}
# 验证:curl 看返回路径
curl -s http://127.0.0.1/api/orders | python3 -m json.tool上游负载均衡:upstream 块与权重、健康检查
单向代理逐渐不够用后,把多台后端收进一个 upstream 做均衡,是自然演进的下一步。nginx 默认轮询(round-robin),可加 weight 做加权,也可配 least_conn 做最少连接调度,session 依赖则需 ip_hash。
健康检查分两类:被动式(proxy_next_upstream:上游 5xx 时自动换机,免费)与配合商业版或 nginx-plus 的主动 check。一个常见错误是后端某一台挂了导致 502 持续,而你没开 fail_timeout 让坏机池一直参与轮询,下面给出带超时与重试的上游配置并演示验证。
upstream app_cluster {
least_conn; # 或 ip_hash; 或留空=round-robin
server 127.0.0.1:8201 weight=3 max_fails=2 fail_timeout=10s;
server 127.0.0.1:8202 weight=1 max_fails=2 fail_timeout=10s backup;
}
server {
listen 80;
location / {
proxy_pass http://app_cluster;
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;
}
}
# 反复请求查看落点分布
for i in {1..8}; do curl -s 127.0.0.1 | python3 -c "import sys,json;print(json.load(sys.stdin)[\"port\"])"; done错误示范与修复:代理后丢失用户 IP 与原始协议
代理刚上线时,后端 log 里 RemoteAddr 全是 127.0.0.1,业务在 HTTPS 和 HTTP 之间也拿不到原始 scheme。这是因为默认 proxy_pass 不会透传客户端信息,必须用 proxy_set_header 补上。
更隐蔽的是 X-Forwarded-Proto 缺失导致后端生成错误的绝对链接,或者因为没清掉外部传入的 X-Forwarded-For 而给伪造 IP 留了后门。修法是在 server 上下文统一补头,并在可信内网才信任这些头。下面把错误的裸 proxy_pass 换成带透传头的完整写法。
# 错误示范:裸转发不带头
proxy_pass http://app_cluster;
# 修复对照:补全透传头
proxy_pass http://app_cluster;
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;
proxy_set_header Upgrade $http_upgrade; # websocket
proxy_set_header Connection "upgrade";
# 校验后端子可见真实 IP
curl -s http://127.0.0.1/ | python3 -m json.tool验证与生效:语法、配置切片、真实请求
改动后先跑 nginx -t 改语法关,再 nginx -s reload 平滑重载,理由是用 reload 而非 restart 能避免中断已有长连接。之后至少在三个维度验证:语法通过、目标 location 命中、后端日志出现带正确 X-Forwarded 头的记录。
如果你在测试一条诡异路径,用 curl -o /dev/null -w 查看 status 与耗时,再用日志片段确认命中哪个 location。把整组校验命令收进一个脚本,回归时一条命令跑完。
# 语法与重载
docker exec nginx nginx -t 2>&1 || sudo nginx -t
sudo nginx -s reload
# 维度核验
curl -s -o /dev/null -w "%{http_code} %{time_total}s\n" http://127.0.0.1/orders
# 命中验证:后端探针打点
journalctl -u probe-up1 --since=-2m | tail -n 3