有段时间帮一个电商项目排查后端日志,发现所有订单都记成了同一个客户端IP,而用户分布在五湖四海。一开始后端同事怀疑代码问题,后来抓包一看,问题出在Nginx反代那一层:请求头里压根没有真实IP信息,后端拿到的全是Nginx本机内网地址。
后来我在Nginx的location里补上几行proxy_set_header,把Host、X-Real-IP、X-Forwarded-For、X-Forwarded-Proto这些头都处理好,问题立刻消失。
如果你也经常跟Nginx反向代理打交道,会发现proxy_set_header是绕不开的参数。它不复杂,但坑特别多:设置错了,轻则后端拿不到真实IP,重则首页302重定向到内网地址、登录态丢失、HTTPS链接被改写。这篇文章就把这个参数彻底拆开,讲清楚原理、常用配置和排查思路,希望能帮你少踩一些我踩过的坑。
1. proxy_set_header到底是什么:先搞懂它管的是“出口请求头”
1.1 Nginx把请求转给上游时,头信息并不会自动“全量透传”
很多人以为Nginx做反向代理,就是把客户端请求原封不动转给后端,所以后端自然能看到所有请求头。这个想法只对了一半。
Nginx转发请求前,会重新构造一份发给上游的HTTP请求。哪些头保留、哪些头改掉、哪些头新增,由proxy_set_header指令控制。如果不显式配置,Nginx会使用模块里的默认行为:比如把Host设成$proxy_host(也就是proxy_pass后面写的上游地址),把Connection设成close,而像X-Real-IP、X-Forwarded-For这类头,默认根本没有,自然也不会凭空出现在上游请求里。
所以,后端能不能拿到用户真实IP,取决于作为反向代理的Nginx有没有用proxy_set_header把信息“塞”进转发请求。这个指令管的是Nginx发给上游的请求头,不是客户端发给Nginx的请求头,也不是Nginx返回给客户端的响应头。想明白这一点,很多配置混乱就不会出现。
我见过有人把proxy_set_header写进了if块里,也见过有人把它写在location外面想让整个server都生效,结果被其他location意外覆盖,排查半天。核心原则是:先明确这个指令只有http、server、location三个层级能写,而且以下层配置为准,同级里同名头后者覆盖前者。
1.2 指令的执行层级与同名头覆盖规则
proxy_set_header可以放在http块、server块和location块。越具体的位置优先级越高:
- location里配置了某组头,会覆盖server块同名的配置;
- server块里配置了,会覆盖http块里的同配置;
- 同一层级内如果重复设置同名Header,后面的覆盖前面的,不会发两个相同的头给后端。
实际工作中,我建议把“所有反代请求都需要”的通用头放在location级别或server级别。
如果一个Nginx同时跑静态文件、PHP和反向代理,不要把proxy_set_header全局乱写。比如静态文件请求根本不需要X-Forwarded-Proto,但你写上去了也不影响返回内容,只是显得不够干净。更好的是在反代专用location中集中配置,这样别人看配置也直观。
常用的组合长这样:
nginx复制server {
listen 80;
server_name example.com;
location /app/ {
proxy_pass http://backend_server;
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_pass后面的地址可以是upstream名,也可以是具体IP端口。只要进到这个location转发,Nginx就会在出口请求上覆盖这四个头。下面逐个展开讲。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 高频必配的4个头:Host、X-Real-IP、X-Forwarded-For与协议头
2.1 Host头:别让后端拿到内网IP当域名
Host是HTTP/1.1里很重要的头,很多后端程序依靠它来做域名路由、拼接URL、生成重定向地址。Nginx默认会把Host设成$proxy_host,即proxy_pass里写的上游地址,比如10.0.0.2:8080。后端程序如果按这个值去拼跳转链接,用户就会看到一个类似http://10.0.0.2:8080/login的内网地址,直接访问自然失败。
如果你希望后端收到的域名和用户在浏览器地址栏里输入的一致,常见做法是设成$host或$http_host。两者区别:
$host:取请求行中的域名,或请求头Host,或server_name,统一转小写,去掉端口。如果你的nginx监听80端口对外服务,用它就够了。$http_host:原样取自客户端请求里的Host头,会保留端口号和原始大小写。如果外部通过非标准端口访问,比如https://example.com:8443,需要保留端口时用$http_host。
我自己的偏好是:对外统一走80/443端口时,用$host;涉及特殊端口映射时,用$http_host。比如代理一个监听8080端口的内部系统,反代对外用http://example.com:8080/,这时候$host会把端口丢掉,后端有些框架校验Host会把请求拦掉,改成$http_host更稳妥。
还有一种情况:多个域名共用一个后端,想在后端区分请求来自哪个站点,建议用$host并配合后端的虚拟主机配置。如果后端容器或应用需要识别原始Host来做白名单校验,绝不能用$proxy_host,否则校验的一律是内网IP,永远不通过。
2.2 X-Real-IP:最直接的真实IP方案
X-Real-IP不是HTTP协议标准头,而是反向代理场景里一种约定俗成的写法。Nginx通过$remote_addr能拿到“直连它的那个IP”,如果Nginx直接对用户提供服务,那这个IP就是用户IP;如果Nginx前面还有CDN或负载均衡,那$remote_addr是上一级设备的IP。
要把它传给后端,配置就一行:
nginx复制proxy_set_header X-Real-IP $remote_addr;
加了这行后,后端的日志和程序里就能直接用X-Real-IP拿到来源IP。这个方法简单直接,也是我生产环境里最常用的方案。很多后端框架也内置了“从X-Real-IP取客户端IP”的逻辑,因此这个头设置得好不好,直接影响你是否还要在后端代码里做二次解析。
不过要特别注意:如果Nginx前面不是一个可信的代理设备,用户其实可以自己伪造请求头X-Real-IP。但Nginx在转发时用$remote_addr覆盖了它,所以用户伪造的值不会生效,安全性是OK的。真正的问题是,如果Nginx前面还有一层可信CDN,并且CDN已经设置了它自己的X-Real-IP,你这行配置会把CDN传过来的用户真实IP覆盖成CDN的IP。这时候就要改成透传CDN的头,或者用后文提到的X-Forwarded-For链路来取值。
2.3 X-Forwarded-For:代理链更长,但别盲信
X-Forwarded-For简称XFF,标准格式是逗号分隔的IP列表,离客户端越近的IP越靠左。每经过一层代理,代理通常会把自己直连的上游IP追加到最右侧。
Nginx提供了现成变量$proxy_add_x_forwarded_for,它的逻辑等于“客户端传来的XFF” + , + $remote_addr。也就是说:
- 如果客户端请求没带XFF,这个变量值就是
$remote_addr; - 如果客户端带了伪造的XFF,这个变量会把伪造的内容原样接上,再追加当前连接IP。
所以直接用是最省事的:
nginx复制proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
很多教程写到这里就结束了,但你应该再往下想一步:如果Nginx是直接对外的入口,而且你根本不需要关心中间代理链,那更安全的做法是丢弃客户端传来的XFF,只保留Nginx看到的直连IP。因为XFF可以被客户端任意伪造,后端一旦不加验证地信任它,就可能被用来绕过基于IP的限流或访问控制。
这时候可以强制覆盖:
nginx复制proxy_set_header X-Forwarded-For $remote_addr;
如果客户端直接连Nginx,这样设置后端看到的XFF永远只有一个IP,就是真实来源IP,伪造不了。如果客户端经过多层CDN或代理,这样设置会丢失前面链路信息,所以“直接对外的边界Nginx”和“内网链路中的Nginx”要区别对待,不能一刀切。
我处理过多层Nginx场景,比如客户端经过CDN再到达Nginx,此时Nginx的$remote_addr是CDN节点IP,CDN已经在X-Forwarded-For里带了用户IP。那就应该用$proxy_add_x_forwarded_for保留链路,让后端既能拿到前面的用户IP,也能看到当前Nginx直连的CDN节点IP。
2.4 X-Forwarded-Proto:让后端知道原始请求是http还是https
后端还经常需要知道客户端和Nginx之间用的是HTTP还是HTTPS。如果不告诉它,有些框架会认为所有请求都是HTTP,导致在HTTPS页面里生成一堆http://的资源地址,或者反过来,在HTTP请求时强制要求HTTPS,造成重定向循环。
Nginx下设置方法:
nginx复制proxy_set_header X-Forwarded-Proto $scheme;
$scheme变量在Nginx层面表示它与客户端之间建立连接时使用的协议,取值通常是http或https。如果你的Nginx本身对外就监听443并终止了TLS,那么通过这个头传给后端的一定是https,后端可以据此重写链接、判断安全请求。
顺便说一下,很多框架(比如Django、Spring Boot之类的)默认不信任代理传过来的X-Forwarded-Proto,你还需要在后端配置文件里把“信任反向代理”打开,它才会读取这个头。否则你在Nginx配得再对,框架依然认为连接是HTTP。这个坑我踩过不止一次,建议排查问题时同时看Nginx和后端两边的配置。
另外有类似作用的还有X-Forwarded-Host和X-Forwarded-Port,不少老系统会通过它们拼接完整URL。如果项目遇到CDN回源或反代后链接生成异常,可以根据后端框架需要把它们一并补上,例如:
nginx复制proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
但这类头不是所有后端都认,加之前先确认后端读取逻辑,不然可能画蛇添足。
3. 不能只背参数:WebSocket、日志追踪等场景要这么配
3.1 WebSocket升级要处理Upgrade与Connection
如果你通过Nginx代理WebSocket服务,会发现只配上面的四个头根本不够。WebSocket连接建立前需要HTTP Upgrade机制,客户端会发起一个带Connection: Upgrade和Upgrade: websocket的GET请求,Nginx默认转发时会把Connection设成close,导致后端根本不识别这是在请求升级协议。
此时必须增加两个关键配置:
nginx复制proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
$http_upgrade取自客户端请求头里的Upgrade字段,如果客户端没要求升级,它就是空字符串;如果要求升级,值就是类似websocket这样的协议名。把Connection固定为upgrade,等于告诉后端:这个连接要转成WebSocket,请保持长连接。没有这两个头,浏览器控制台会报类似“WebSocket握手失败”的错误,而Nginx错误日志却看不到明显异常,排查起来很费劲。
另外在WebSocket场景下,我还会同时加上proxy_read_timeout和proxy_send_timeout配置。因为WebSocket是长连接,默认的60秒超时会导致空闲连接被断开。一般我会设成3600秒甚至更高:
nginx复制proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
这三类配置组合起来,WebSocket才能稳定跑在Nginx后面。
3.2 X-Request-ID:自定义头把整个链路串起来
遇到线上问题需要看Nginx日志、后端应用日志,最烦的就是两边日志里没有同一个请求ID,只能靠时间和IP去猜。这时可以靠Nginx内置的$request_id生成一个唯一ID,再用proxy_set_header传给后端:
nginx复制proxy_set_header X-Request-ID $request_id;
$request_id是每个请求自动生成的32位随机字符串,无需自己额外生成。设置之后,后端只要在日志里记录这个头的值,就可以和Nginx的access log里同一个请求对应起来,排障效率提升非常明显。
如果你有多层Nginx或API网关,建议每一层都把上游传过来的X-Request-ID原样透传,没有才生成新的。如果每层都随手覆盖,请求链路的日志就没法串起来。Nginx里可以用类似这样的逻辑:
nginx复制set $request_id_final $http_x_request_id;
if ($request_id_final = "") {
set $request_id_final $request_id;
}
proxy_set_header X-Request-ID $request_id_final;
这里需要注意,if块里只能做简单的变量赋值,不要塞太多指令。逻辑其实就是“有就用客户端的,没有就用Nginx生成的”。
3.3 Cookie、Origin等业务头什么时候需要显式传递
默认情况下,客户端发来的Cookie、User-Agent、Referer等普通请求头,只要Nginx没有专门覆盖,转发给上游时基本会保留。因此很多场景不用手动写proxy_set_header Cookie $http_cookie;。但有一个例外:如果你在location或server里覆盖了Cookie头,或者某些安全设备会剥掉Cookie,那就要自己显式传,否则后端登录态会全部丢失。
我遇到过一种典型问题:Nginx同时配置了多个location,其中一个location为了特殊需求写了proxy_set_header Cookie "";,结果其他location也继承了这个配置,导致整个站点登录失效。排查了很久才发现是空字符串把Cookie头清掉了。所以使用proxy_set_header xxx ""一定要小心,空值意味着主动删除这个头,而不是“不加配置”的意思。
另外,一些前后端跨域场景会用到Origin头。浏览器在发起跨域请求时会带上Origin,Nginx反代默认会把请求头透传给后端,但如果后端校验严格,而Nginx层又重新组装了Origin,就可能拦截。遇到这种问题,建议用如下方式显式透传客户端原值,不要自己写死域名:
nginx复制proxy_set_header Origin $http_origin;
如果配置的是$http_origin为空(比如同源请求不带Origin),后端逻辑不判空可能会报错,这需要根据业务调整。跨域这种问题牵一发动全身,别只看Nginx配置,要把浏览器实际发出的请求头和后端接收到的放一起对比。
4. 完整配置示例和调试方法
4.1 一个带upstream的生产环境配置
把上面讲的内容整合到一起,一个比较完整的反向代理配置长这样:
nginx复制upstream app_backend {
server 10.10.1.2:8080 max_fails=3 fail_timeout=10s;
server 10.10.1.3:8080 max_fails=3 fail_timeout=10s;
keepalive 32;
}
server {
listen 443 ssl http2;
server_name example.com;
ssl_certificate /etc/nginx/certs/example.com.pem;
ssl_certificate_key /etc/nginx/certs/example.com.key;
access_log /var/log/nginx/example.access.log main;
error_log /var/log/nginx/example.error.log warn;
location / {
proxy_pass http://app_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_set_header X-Request-ID $request_id;
proxy_connect_timeout 5s;
proxy_read_timeout 30s;
proxy_send_timeout 30s;
}
location /websocket/ {
proxy_pass http://app_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
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_read_timeout 3600s;
}
}
这里有几个细节值得说明一下。
proxy_http_version 1.1和proxy_set_header Connection "";是配了upstream keepalive后的要求。Nginx默认用HTTP/1.0转发请求,无法复用连接,要发挥keepalive作用就必须开启HTTP/1.1,并清空Connection头,否则每请求都会重建TCP连接,性能上不去。
Host $host通常能覆盖大部分场景。如果你通过非标准端口对外提供服务,建议改成Host $http_host。
在线上的Nginx配置里,我习惯把通用的反代头抽到一个单独的proxy_params文件里,然后用include引入。例如:
nginx复制location / {
include /etc/nginx/proxy_params;
proxy_pass http://app_backend;
}
proxy_params内容就是一堆proxy_set_header。这样配置简洁,也不容易漏。但要注意,如果你在某个location里需要覆盖个别头,include之后重新写一次同名头即可,后写的会覆盖先写的。
4.2 三种验证头是否生效的方式
配置写完了,怎么确认Nginx真的把X-Real-IP、X-Forwarded-For这些头传给了后端?我一般用三种方式。
第一种,看后端日志。比如Java应用或PHP-FPM日志里如果能记录X-Real-IP,就最直观。但前提是后端代码得先打印,没打印就等于没有。
第二种,临时用Nginx的echo能力?Nginx官方默认不带echo模块,我不会为了验证去编译第三方模块。更简单的方法是临时在后端Nginx或后端应用前面放一个能看到请求头的服务,或者在开发环境写个临时接口,打印所有请求头。比如用Spring Boot写个Controller,或PHP里用getallheaders()打印。这种验证最准确,能直接看到Nginx发给它的完整请求头。
第三种,在Nginx层面用日志变量来反推。比如在access日志里加入$http_host、$http_x_real_ip、$http_x_forwarded_for等变量。注意:$http_x_forwarded_for是Nginx从“客户端请求”里看到的X-Forwarded-For,不是它转发后生成的头。如果你想验证的是“Nginx转发给上游时设置了什么头”,用$proxy_add_x_forwarded_for可以看到结果,用它拼到access日志里能间接确认。
推荐的log_format可以这样临时加:
nginx复制log_format debug_proxy '$remote_addr [$time_local] "$request" '
'host=$http_host xff=$http_x_forwarded_for '
'proxy_add_xff=$proxy_add_x_forwarded_for '
'scheme=$scheme';
在需要排查的server块里临时指定access_log /tmp/debug.log debug_proxy;,用curl多打几个请求,然后看日志内容。这个方法不需要动后端代码,只改Nginx就能初判问题方向。
5. 常见问题与避坑清单
5.1 高频问题速查表
我整理了反代场景里常见的几个问题,多半都与proxy_set_header有关:
| 现象 | 常见原因 | 推荐处理 |
|---|---|---|
| 后端日志里所有IP都一样,都是Nginx内网地址 | 没有设置X-Real-IP或X-Forwarded-For | 加proxy_set_header X-Real-IP $remote_addr;等 |
| 后端收到Host是内网IP,重定向或链接拼接错误 | 用了默认Host $proxy_host |
改成Host $host或Host $http_host |
| HTTPS页面资源被生成成http链接 | 缺少X-Forwarded-Proto,或后端不信任代理头 | 设置X-Forwarded-Proto $scheme,并调整后端信任配置 |
| WebSocket握手失败,或不断重连 | 缺Upgrade与Connection相关头 | 加Upgrade $http_upgrade; Connection "upgrade"; |
| 登录态丢失,Cookie无法写入 | 某处把Cookie头置空,或多层代理没透传 | 检查所有proxy_set_header Cookie配置,避免误删 |
| 使用了keepalive但不生效,连接数很高 | 仍用HTTP/1.0转发,或Connection头没有清空 | 设置proxy_http_version 1.1; proxy_set_header Connection ""; |
| 后端限流把同IP请求计数到一起 | XFF被客户端伪造,或没有正确传递真实IP | 边界Nginx强制用$remote_addr,内网链再用$proxy_add_x_forwarded_for拼接 |
对照这个表排查,大部分问题都能快速定位到Nginx的配置层级和具体参数。
5.2 关于伪造XFF的边界处理
再补充一个安全向的边界处理。X-Forwarded-For头可以被客户端直接伪造。比如用户手动发一个请求,头里写X-Forwarded-For: 8.8.8.8,如果你的Nginx直接用$http_x_forwarded_for并信任它,后端拿到的IP就是伪造的。
如果Nginx是直接面向用户的那道边界,最安全的做法是用$remote_addr覆盖整个XFF,或者直接信任$remote_addr并把它放进X-Real-IP。这样客户端伪造的XFF到了Nginx这一层会被丢掉。
如果Nginx前面还有一层你信任的CDN或负载均衡,情况就不同。此时用户和Nginx中间夹着代理,Nginx的$remote_addr是代理IP,不是用户IP。你必须读取代理传过来的头,并把当前连接IP追加进去,否则就丢掉了用户IP。一般建议使用$proxy_add_x_forwarded_for,因为它能自动处理“有则追加,无则用当前IP”的逻辑。
分层之后还需要注意头覆盖顺序。比如链路是“用户 -> CDN -> Nginx1 -> Nginx2 -> 后端”,在Nginx2看来,$remote_addr是Nginx1的地址,这时候它再拿$proxy_add_x_forwarded_for去拼接,会把Nginx1的地址也加进XFF。如果Nginx1已经加了用户IP,后端看到的XFF至少有两个IP,取最左边第一个即可;但如果这一链路中某层用了覆盖式配置,把XFF写成$remote_addr,那么用户IP就被冲掉了。所以多层代理下,每一层的配置策略要想清楚,不能每层都覆盖。
我在实际项目里还会在后端入口处加一道校验:只信任链路中最后一层可信代理传过来的IP,忽略更左边的内容。比如让后端取XFF列表里从右往左数第一个非可信代理IP,这样即使客户端伪造了左边的IP,也不会影响真实来源的判定。这个逻辑很多WAF和网关都内置了,如果没有内置,自己实现也不复杂,关键是别把所有头都无脑信任。
另一个容易被忽略的问题是,Nginx日志里看到的$http_x_forwarded_for是请求进来时的值,而转发给上游的XFF可能是另一套值。排查时一定要分清楚“客户端带进来的”和“Nginx将要发出去的”是两个不同阶段,否则你会对着日志白看半天。
这些细节看起来琐碎,但在生产环境里几乎每一条都能救人一命。配置proxy_set_header之前,先把当前请求链路画出来,想清楚到底谁是可信代理、谁是不可信客户端,再决定用$remote_addr还是$proxy_add_x_forwarded_for,就不容易写错。这也是我做Nginx反代这么久最深刻的一条体会。
