Nginx 反向代理与负载均衡深度实践:路径改写、真实 IP、健康检查与灰度发布
上一篇《Nginx 高性能调优实战》聊的是把 Nginx 当文件服务器用——worker 进程、压缩、静态缓存、限流,目标是把一个静态站的首屏和 QPS 拉满。但真实生产里,Nginx 扮演最多的角色其实是反向代理:站在所有后端前面,负责转发、负载、健康检查、限流、灰度。这一篇就往上一层走,把代理层的坑一次踩完。
一、proxy_pass 的路径改写:四种组合,四种结果
代理配置里最容易出错、也最难自查的就是 proxy_pass 的 URI 到底会不会被替换。规则其实只有一条:proxy_pass 后面带不带 URI(也就是域名后面那一段路径),决定了 location 匹配到的那部分前缀要不要被换掉。
| location | proxy_pass | 请求 /api/user/1 实际转发到 | 规则 |
|---|---|---|---|
/api/ | http://127.0.0.1:8080 | /api/user/1 | 不带 URI → 原样透传,路径不丢 |
/api/ | http://127.0.0.1:8080/ | /user/1 | 带 URI(单个 /)→ 用 / 替换掉 /api/ |
/api/ | http://127.0.0.1:8080/v2/ | /v2/user/1 | 带 URI → 替换后拼接,前缀被改写 |
/api(无尾斜杠) | http://127.0.0.1:8080/ | (配置有坑,见下) | location 不带尾斜杠时,URI 替换不做,/ 被当作普通 URI 处理 |
实践上的三条纪律:
- 要么两边都带尾斜杠,要么两边都不带。
location /api/配proxy_pass .../是最不容易出错的写法,语义清晰:把/api/整体换成/。 - 需要切换后端版本前缀时,用带 URI 的写法做映射,例如
location /api/ { proxy_pass http://backend/v2/; },后端升级版本无需改前端路由。 - 用正则 location(
~)时不能带 URI 的 proxy_pass,正则捕获组会失效——这时应该用rewrite ... break再配不带 URI 的 proxy_pass。
# 需要正则匹配 + 路径改写时的正确姿势
location ~ ^/img/(.+)$ {
rewrite ^/img/(.+)$ /static/$1 break;
proxy_pass http://127.0.0.1:8080; # 不带 URI,交给 rewrite 处理
}
二、真实 IP 链路:为什么后端拿到的全是 127.0.0.1
这是代理层第二大高频问题:应用日志里所有客户端 IP 都变成了代理的内网地址,限流、风控、审计全部失效。要彻底讲清楚,得先区分 Nginx 里两个变量的语义:
| 变量 | 含义 | 代理场景下的取值 |
|---|---|---|
$remote_addr | 与本机建立 TCP 连接的那一方的地址 | 前端代理的地址(如 127.0.0.1),不是真实客户端 |
$proxy_add_x_forwarded_for | 原 X-Forwarded-For 头 + $remote_addr 追加 | 形成「客户端, 代理1, 代理2」的链路串 |
所以正确做法分两步走:转发时把链路头带上,需要时把 $remote_addr 纠正过来。
# 第 1 步:转发时补齐四个头,缺一个应用侧就会有信息缺口
location / {
proxy_pass http://127.0.0.1:3003;
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; # 应用判断是否 https、生成绝对 URL 要用
}
# 第 2 步:本机前面还有一层可信代理时,把 $remote_addr 纠正为真实客户端
# 只信任明确的代理网段,不能写 0.0.0.0/0,否则伪造头就能冒充任意 IP
set_real_ip_from 10.0.0.0/8;
set_real_ip_from 172.16.0.0/12;
real_ip_header X-Forwarded-For;
real_ip_recursive on; # 从右往左找第一个非可信地址
X-Forwarded-For 是一个逗号分隔的列表,最左边是客户端,越往右越接近本机。但这条头是可以被客户端伪造的——如果应用直接取最左边的值做风控,攻击者传一个 X-Forwarded-For: 1.2.3.4 就能绕过。正确的取法是:从右往左,取第一个"不在可信代理列表里"的地址,Nginx 的 real_ip_recursive on 做的就是这件事。应用侧同理,不要盲信最左值。三、upstream 负载均衡:算法只是开始,连接复用才是性能关键
1. 调度算法怎么选
| 算法 | 语法 | 适用场景 |
|---|---|---|
| 轮询(默认) | upstream a { server ...; } | 后端规格一致、无状态 |
| 加权轮询 | server ... weight=3; | 新老机器混部、按规格分流量 |
| 最少连接 | least_conn; | 请求耗时差异大(长连接、慢接口) |
| IP 哈希 | ip_hash; | 无共享存储的会话粘性(优先级低,尽量做无状态) |
| 一致性哈希 | hash $request_uri consistent; | 缓存节点场景:节点增减时只迁移少量 key |
| 最短响应时间 | least_time header; | Nginx Plus / 商业版 |
还有一个常被忽略的语义:max_fails 与 fail_timeout 是一组参数。max_fails=3 fail_timeout=10s 表示「10 秒内失败 3 次,就在接下来 10 秒内把这个节点摘掉」——注意后半段的 10 秒既是计数窗口、也是熔断时长。
2. keepalive:代理层性能的最大杠杆
默认情况下,Nginx 每转发一个请求就要和后端重新建立一次 TCP 连接(三次握手 + 慢启动)。在高并发下,这会同时吃掉两边的端口资源和 CPU。开启 upstream 长连接池的收益通常远大于任何 worker 调参:
upstream backend {
server 127.0.0.1:3003 max_fails=3 fail_timeout=10s;
server 127.0.0.1:3004 max_fails=3 fail_timeout=10s backup;
least_conn;
keepalive 64; # 每个 worker 保留的空闲长连接数
keepalive_requests 1000; # 单条长连接最多复用多少次后关闭
keepalive_timeout 60s; # 空闲多久后关闭
}
location / {
proxy_pass http://backend;
# 这两行是 keepalive 生效的【必要前提】,缺了长连接池就形同虚设
proxy_http_version 1.1;
proxy_set_header Connection "";
}
Connection ""?HTTP/1.0 时代默认短连接,Nginx 默认会把 Connection: close 传给后端,后端收到就关连接,长连接池自然用不上。把 Connection 显式置空 + 声明 HTTP/1.1,才是「让后端保持连接」的开关。这三行是代理层性价比最高的一段配置,值得背下来。四、健康检查:被动是兜底,主动才不抖动
Nginx 开源版只有被动健康检查——它不会主动去探活,而是等真实请求失败后,再根据 max_fails 把节点标记为不可用。这意味着:节点挂掉后的头几个请求,一定会被转发到坏节点上。用户看到的就是「偶发 502」。
| 被动(开源版自带) | 主动(Plus / check 模块) | |
|---|---|---|
| 触发方式 | 真实请求失败累积到阈值 | 后台定时探测健康端点 |
| 发现延迟 | 取决于流量,低峰期可能很久 | 固定间隔,与流量无关 |
| 副作用 | 坏节点会先吃几个真实请求 | 几乎无用户可见抖动 |
开源版下的实用折中:把 proxy_next_upstream 打开,让请求在失败后自动重试到下一个健康节点,把「偶发 502」降级成「偶发的延迟增加」。
location / {
proxy_pass http://backend;
# 遇到连接错误/超时/502/503/504 时,换下一个节点重试
proxy_next_upstream error timeout http_502 http_503 http_504;
proxy_next_upstream_tries 2; # 最多换 2 个节点,防雪崩式重试
proxy_next_upstream_timeout 5s;
}
proxy_next_upstream 默认会重试非幂等请求(比如 POST),这会导致「后端已经处理成功,但客户端又发了一遍」的重复写。上线前务必确认业务幂等,或者把重试条件收紧为 error timeout 并排除 non_idempotent。五、超时与缓冲三兄弟:三个超时管的不是同一件事
| 指令 | 管的是什么 | 典型值 |
|---|---|---|
proxy_connect_timeout | 与后端建立 TCP 连接的耗时 | 3–5s |
proxy_send_timeout | 把请求体发给后端两次写操作之间的间隔 | 10–60s |
proxy_read_timeout | 等待后端返回响应两次读操作之间的间隔 | 30–60s;慢接口/导出任务需单独放大 |
最容易踩的是 proxy_read_timeout:它不是「整个请求的处理总时长」,而是「两次成功读取之间的空闲间隔」。所以一个持续输出数据的流式接口(SSE、大文件下载、日志推送)只要不断有字节流动,就不会超时;反而一个 10 秒内一声不吭去查大表的接口,即使它最终只要 12 秒也会被判定超时。
缓冲相关的两个开关,决定了「后端是不是被客户端网速绑架」:
# 关闭响应缓冲:后端不必等客户端慢速接收,适合流式输出
proxy_buffering off;
# 或者在应用侧显式关闭:Nginx 看到这个响应头就不缓冲该响应
proxy_set_header X-Accel-Buffering no; # 常用于 SSE / 分块下载
# 请求体缓冲:打开则先完整落盘再转发(后端更稳),
# 关闭则边收边转(大文件上传更省磁盘,但后端要能扛慢客户端)
proxy_request_buffering on;
client_max_body_size 50m;
proxy_buffering 默认开启,Nginx 会先把后端的响应攒到缓冲区(默认几 MB)再吐给客户端。文件越大、首字节越慢。对下载类、导出类接口,按 location 单独关掉 proxy_buffering 即可。六、限流、熔断与灰度:把风险关在入口
1. limit_req:漏桶算法,理解 burst 与 nodelay
# 定义两个共享内存区(10m 大约能存 16 万个 IP 的状态)
limit_req_zone $binary_remote_addr zone=general:10m rate=20r/s;
limit_req_zone $binary_remote_addr zone=convert:10m rate=1r/s; # 重接口单独限
limit_conn_zone $binary_remote_addr zone=perip:10m;
server {
limit_req zone=general burst=300 nodelay;
limit_conn perip 20; # 单 IP 并发连接上限,防慢速攻击
location = /jobs {
limit_req zone=convert burst=5 nodelay;
# ...
}
}
| 参数 | 语义 |
|---|---|
rate=20r/s | 令牌补充速率,漏桶的平均放行速度 |
burst=300 | 允许瞬时排队/溢出的请求数(桶容量) |
nodelay | burst 内的请求立即放行而不是按速率排队 |
「burst + nodelay」和「只有 burst」的区别很关键:只有 burst 时,超出速率的请求会被排队延迟处理,用户感知是「变慢」;加了 nodelay,桶里的请求立刻放行,只在桶满后才返回 503——用户感知是「正常或明确被拒」,不会体验到诡异的排队延迟。对静态站和 API,一般推荐 burst + nodelay。
2. 灰度发布:权重、分流与 A/B
# 方式一:按权重切流(最简单,适合新版本小流量验证)
upstream app {
server 127.0.0.1:3003 weight=9; # 老版本 90%
server 127.0.0.1:3005 weight=1; # 新版本 10%
}
# 方式二:按用户维度稳定分流(同一用户在灰度期内始终命中同一版本)
split_clients "${remote_addr}${http_user_agent}" $app_pool {
10% newver;
* oldver;
}
upstream newver { server 127.0.0.1:3005; }
upstream oldver { server 127.0.0.1:3003; }
location / {
proxy_pass http://$app_pool;
}
split_clients 对指定变量做哈希,同一变量值恒定映射到同一池,因此具备「用户级粘性」。这是灰度发布里最实用的一条技巧。七、一套可直接抄的生产模板
upstream backend {
least_conn;
server 127.0.0.1:3003 max_fails=3 fail_timeout=10s;
server 127.0.0.1:3004 max_fails=3 fail_timeout=10s backup;
keepalive 64;
keepalive_requests 1000;
}
location / {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
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_connect_timeout 5s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
proxy_next_upstream error timeout http_502 http_503 http_504;
proxy_next_upstream_tries 2;
}
# 长任务/流式接口单独放开超时并关闭缓冲
location /stream/ {
proxy_pass http://backend;
proxy_read_timeout 300s;
proxy_buffering off;
proxy_set_header X-Accel-Buffering no;
}
排障清单:五个问题五个动作
| 现象 | 先查什么 | 常见根因 |
|---|---|---|
| 后端拿到的 IP 是 127.0.0.1 | X-Forwarded-For 是否转发 | 漏了 proxy_set_header |
路径变成了 /user/1 而不是 /api/user/1 | proxy_pass 是否带 URI | 尾斜杠组合写错 |
| QPS 上不去、后端连接数暴涨 | ss -s 的 TIME_WAIT 数量 | upstream keepalive 没生效 |
| 偶发 502,重启后又好了 | error.log 里的 upstream 关键字 | 只有被动检查,坏节点未及时摘除 |
| 大文件下载首字节极慢 | 该 location 的 proxy_buffering | 响应被缓冲攒块 |