前阵子我维护的一套网关配置从测试环境同步到线上,后端接口突然开始报“域名不合法”。打开日志看了一圈,同一个请求在不同节点记下来的Host千奇百怪:有的是www.example.com,有的是www.example.com:8443,还有的直接变成内网IP。查到最后,问题全集中在proxy_set_header Host这一行——我把$http_host、$host、$proxy_host这三个长相极其相似的变量混用了。
这篇就把这三个变量的区别彻底讲清楚:它们各自的值从哪来、带不带端口、什么场景该用哪个、怎么用一条curl快速验证。不管你是刚接手Nginx配置的运维新手,还是写了几年反代配置的老手,下次再配proxy_set_header Host的时候,应该都能少踩几个坑。
1. 一次“串站点”事故:三个变量混用的代价
1.1 现象:后端拿到的Host和我以为的不是同一个
先还原我当时踩过的配置。网关负责把/api/下的请求转给后端集群,写法大概是这样的:
server { listen 80; server_name www.example.com api.example.com; location /api/ { proxy_pass http://backend_gateway; proxy_set_header Host $http_host; } }从表面看,proxy_set_header Host $http_host;的意思是“把客户端原始请求里的Host原样传给后端”,听起来人畜无害。问题出在客户端访问的地址上:如果用户是从http://www.example.com:8080/api/login这个入口进来的,那$http_host的值就是www.example.com:8080,带着端口。后端网关拿到这个Host,拿去和自己的路由表匹配,自然匹配不上——它只登记了www.example.com这种不带端口的业务域名。于是后端直接抛错,前端看到的就是一整片报错。
同样的请求,如果把配置改成proxy_set_header Host $host;,后端收到的就是干净的www.example.com,问题立刻消失。但另一个项目里,有人反着踩了一遍:后端服务就是要靠端口区分环境,开发环境跑8080,预发环境跑8081,结果统一改成$host之后端口全丢了,后端分不清环境。这两件事放在一起就很说明问题:这三个变量不能随便互相替换,选错了轻则日志难看,重则整个链路直接挂掉。
1.2 排查链路:从后端日志反推前端的“小动作”
那次事故的排查过程也值得复盘一下。一开始我看的是后端网关的访问日志,发现里面的host字段时不时出现带端口的域名,过一会儿又变成内网IP,毫无规律。因为后端日志只记录最终收到的Host,看不到中间链路发生了什么,我一度怀疑是某个浏览器插件或代理工具在捣乱。
后来我直接在Nginx层做了个调试动作:临时加了一个接口,把当前请求下的三个变量全部打印出来,再用同样的请求打一遍,真相立刻浮出水面。详细方法见第四章,这里先放结论——导致日志里域名不统一的,正是proxy_set_header Host $http_host;。它把客户端请求的Host原封不动转了过去,客户端用什么访问,后端就看到什么。如果客户端直接用IP加端口访问,后端看到的自然就是“IP:端口”这种奇怪的主机名。
所以排查这类问题的通用思路应该是:先确认后端业务到底依赖Host的哪个部分——是域名本身、域名加端口,还是upstream的名字;再看当前配置用的是哪个变量;最后用验证接口实际打一个请求确认真实取值。顺序一旦反了,很容易被各种中间环节带偏。
2. 三个变量的“出身”:取值逻辑与优先级
2.1 $http_host:请求头Host的“原样照搬”
$http_host最直观,也最好理解。它的值就是客户端发送的HTTP请求头里Host字段的完整字符串,一个字都不改。请求头写的是Host: www.example.com,它就是www.example.com;写的是Host: www.example.com:8443,它就是www.example.com:8443;客户端压根没发Host头,它就是空字符串。
这意味着两件事:
$http_host完全由客户端控制,你想让它是什么就是什么。用curl随手加一个-H 'Host: whatever.com',后端就能收到whatever.com。- 它不一定是“域名”,也可能是IP、IP加端口、甚至乱七八糟的非法值。只要请求头里存在,Nginx就照单全收。
在官方文档里,$http_host的定义就是“请求头中的Host字段”,没有任何加工逻辑。所以需要“还原用户原始请求地址”的场景——比如生成绝对URL、记录原始访问域名——用$http_host是合理的。但要注意,它只反映“客户端想让你看到的Host”,不一定代表真实业务域名。
2.2 $host:一套“三选一”的优先级规则
$host稍微复杂,但理解了就不乱。它的取值顺序来自Nginx官方文档:
- 请求行中如果带了主机名,优先用请求行里的;
- 否则用请求头
Host字段的主机名,但会去掉端口; - 如果请求头里也没有Host,就回退到当前请求匹配到的
server_name。
这里要解释一下第一条。绝大多数浏览器的HTTP请求长这样:
GET /api/login HTTP/1.1 Host: www.example.com请求行里只有路径,没有主机名。但HTTP/1.0时代是允许在请求行里写绝对URL的,比如:
GET http://www.example.com/api/login HTTP/1.0这种情况下,Nginx会优先使用请求行里的www.example.com作为$host,而不是请求头里的值。这个细节平时很少触发,但当你用自研监控工具、爬虫或者老旧的客户端做测试时,就可能遇到。后面我会给出用curl模拟这个场景的验证方法。
除了优先级,还有一个重要特征:$host默认不保留端口。Host: www.example.com:8080取出来后,$host是www.example.com。为什么要剥掉端口?我的理解是:$host在Nginx内部更多承担“主机名”的职责,需要和server_name匹配逻辑保持一致——server_name本身就不带端口,如果$host带了端口,跨场景引用时很容易把判断逻辑带歪。这个说法不是官方原话,但沿着这个思路去理解,很多设计就变得顺理成章了。
2.3 $proxy_host:和proxy_pass绑定的“目标主机”
第三个变量$proxy_host,作用范围小很多,只出现在反向代理场景,值取决于proxy_pass指令里写的主机名和端口:
proxy_pass http://backend; # $proxy_host = backend proxy_pass http://backend:8080; # $proxy_host = backend:8080 proxy_pass http://192.168.1.10:8080; # $proxy_host = 192.168.1.10:8080它和“客户端传过来的Host”没有任何关系,纯粹是Nginx根据proxy_pass目标地址计算出来的。如果当前请求命中的location里压根没有proxy_pass,那$proxy_host的值就是空字符串。
这里有三个容易忽略的点。第一,如果proxy_pass指向的是upstream组,比如proxy_pass http://backend;,且backend是upstream块定义的名字,那么$proxy_host就是backend这个字符串,不是里面某个真实节点的IP。第二,如果proxy_pass后面跟的是IP或域名,$proxy_host就会是这个IP或域名(带着配置里的端口)。第三,Nginx默认情况下,如果你在proxy_pass的location里不写proxy_set_header Host,转发给后端时用的是$proxy_host而不是$host。很多人不知道这个默认行为,结果后端收到的Host变成了upstream的名字或者内网地址,一脸蒙圈。
2.4 一张表快速看懂三者的默认差异
| 变量 | 值从哪里来 | 要不要端口 | 没有Host头时 | 没有proxy_pass时 |
|---|---|---|---|---|
$http_host | 请求头Host字段原值 | 保留 | 空字符串 | 不受影响 |
$host | 请求行主机名 > 请求头Host(去端口) > server_name | 去掉 | 回退server_name | 不受影响 |
$proxy_host | proxy_pass目标主机名 | 按配置来 | 按配置来 | 空字符串 |
表格里“按配置来”可能让你觉得有点含糊,实际意思是:端口有没有,取决于proxy_pass后面怎么写。写了端口就有,不写就按默认端口算,这个维度和客户端怎么请求无关。
3. 什么时候用谁:日志、反代与多级链路里的真实差异
3.1 访问日志域名统计:$host还是$http_host
先说日志场景。如果你在log_format里用$host记录访问域名,那么同一个服务的80端口和443端口请求会被聚合到同一个域名下,统计报表看起来非常整洁。如果你用$http_host,www.example.com:80和www.example.com:443会变成两个“域名”,因为端口不同字符串就不相等,按域名维度去重时直接拆成两行。
我见过有人因为这个差异导致月度报表里的域名数量翻了整整一倍。如果你确实需要还原用户原始的访问方式,比如分析“用户是不是从带端口的老链接进来的”,那$http_host反而更有价值。所以两者没有绝对好坏,完全是统计口径问题。我的习惯是:报表里同时留两个字段,一个$host用来聚合域名,一个$http_host用来排查端口相关的问题,反正日志多几个字段不亏。
3.2 反向代理的Host透传:三选一,想清楚再写
接后端时,proxy_set_header Host有两个常见选择,加上不写的情况,结果直接摆出来:
| 写法 | 后端收到的Host | 适合场景 |
|---|---|---|
proxy_set_header Host $host; | 去掉端口的业务域名 | 后端按域名路由,最常见 |
proxy_set_header Host $http_host; | 用户原始Host,含端口 | 后端需要重建原始URL,或按端口区分环境 |
proxy_set_header Host $proxy_host; | proxy_pass里的目标主机名 | 内部服务之间按upstream名识别,较少见 |
| 不写这一行 | 默认等于$proxy_host | 一般不推荐,除非你清楚自己在干什么 |
第一种写法是我推荐的最稳妥方案。它拿到的是干净的、和server_name口径一致的主机名,后端不管是用Nginx的server_name做路由,还是用常规Web框架解析请求域名,都能正常工作。第二种写法适合明确需要端口的场景,但要注意HTTP/1.0客户端没带Host头时,$http_host会是空字符串,直接传给后端可能导致400,这个风险必须心里有数。第三种写法主要出现在内部微服务之间,后端约定用upstream名来区分调用方,不过这种设计耦合比较重,我个人不推荐新项目这么搞。
还有个容易被忽略的默认行为:如果location里配了proxy_pass但没写proxy_set_header Host,Nginx不会把客户端Host传过去,而是把$proxy_host传过去。比如proxy_pass http://backend;,后端收到的Host就是backend。很多反代故障的根因就在这里——配置看起来没毛病,后端却始终拿不到真实域名。
3.3 多级代理:Host与X-Forwarded-Host的分工
再复杂一点的链路是“客户端 -> 外层Nginx -> 内层Nginx -> 后端应用”,比如常见的网关前置架构。这时候如果每一层都做一次proxy_set_header Host $host;,链路最后拿到的确实是干净域名,但有一个信息丢了:用户最初是用http://www.example.com:8080进来的,8080端口在中间被剥掉了。
解决思路是:每一层继续用$host作为Host转发,保证后端路由稳定;同时额外设置X-Forwarded-Host头,用来保留“最原始的用户Host”。
location /api/ { proxy_pass http://inner_nginx; proxy_set_header Host $host; proxy_set_header X-Forwarded-Host $http_host; proxy_set_header X-Forwarded-Port $server_port; proxy_set_header X-Forwarded-Proto $scheme; }内层Nginx再往后端转的时候,把这些头继续透传,后端就可以通过X-Forwarded-Host拿到原始入口域名和端口,通过X-Forwarded-Proto知道是HTTP还是HTTPS。很多框架生成绝对链接时,靠的就是这一组头。注意:如果某一层用的是$proxy_host,到了下一层$host就变成了upstream名字,整个链路的Host语义就全乱了。所以多级代理时,宁可每一层都显式指定,也不要依赖默认行为。
3.4 用$host做判断和跳转的坑:它没你想的那么可靠
网上很多配置喜欢用$host做域名判断,比如:
if ($host != 'www.example.com') { return 301 https://www.example.com$request_uri; }或者:
if ($host ~* '^(www|m)\.example\.com$') { return 200 'ok'; }这种写法在小流量场景下能用,但一定要清楚:$host在绝大多数情况下就是客户端Host头去掉端口后的值,客户端完全可以伪造。攻击者把Host改成www.example.com,判断照样通过。所以在选型上,我建议把$host当成“业务路由提示”,而不是“安全边界”。真要做访问控制,应该依赖server_name匹配本身——请求进来时Nginx已经匹配到了具体的server块,你在对应的server块里写死逻辑就好。再激进一点,可以在default_server里统一return 444;,让没匹配到任何业务域名的请求直接断掉,比逐个if判断干净得多。
4. 亲手验证三兄弟的真实取值:一个接口加一组curl
4.1 临时接口:让Nginx把变量直接打出来
与其在网上查文档,不如让Nginx自己把答案说出来。我在一台测试Nginx上加了这么一段配置,暴露9000端口:
server { listen 9000; server_name example.com; location /vars { default_type text/plain; return 200 "host=[$host]\nhttp_host=[$http_host]\nproxy_host=[$proxy_host]\n"; } location /echo { proxy_pass http://127.0.0.1:9000/vars; proxy_set_header Host $host; } location /echo_orig { proxy_pass http://127.0.0.1:9000/vars; proxy_set_header Host $http_host; } location /echo_default { proxy_pass http://127.0.0.1:9000/vars; } }/vars这个接口什么都不干,直接把三个变量拼到响应文本里返回。/echo和/echo_orig是两个对比接口,分别演示透传$host和透传$http_host时后端能看到什么。/echo_default则故意不写proxy_set_header Host,用来观察Nginx的默认行为。注意default_type text/plain;一定要写,不然Nginx默认按text/html处理,浏览器会直接渲染文本而不是展示出来。这也是一个实用的调试技巧:想快速看变量取值,用纯文本接口最直观。
4.2 测试矩阵:不同请求下三个变量的表现
拿到接口后,我用下面这组请求验证了一遍,结果整理在表格里:
| 请求命令 | host(响应) | http_host(响应) | proxy_host(响应) | 测试意图 |
|---|---|---|---|---|
curl -H 'Host: example.com:8080' http://127.0.0.1:9000/vars | example.com | example.com:8080 | (空) | 最常见的端口差异,$host去端口 |
curl -H 'Host: blog.example.com' http://127.0.0.1:9000/vars | blog.example.com | blog.example.com | (空) | 无端口时两者一致 |
curl -H 'Host: 192.168.1.5:9000' http://127.0.0.1:9000/vars | 192.168.1.5 | 192.168.1.5:9000 | (空) | IP加端口场景 |
curl --http1.0 -H 'Host:' http://127.0.0.1:9000/vars | example.com | (空) | (空) | HTTP/1.0无Host回退server_name |
curl -H 'Host: example.com:8080' http://127.0.0.1:9000/echo_default | 127.0.0.1 | 127.0.0.1:9000 | (空) | 未设置Host时默认透传proxy_host |
最后一行尤其关键:请求虽然带着Host: example.com:8080进来的,但/echo_default没有设置透传Header,Nginx默认把子请求的Host改成了$proxy_host,也就是127.0.0.1:9000。于是/vars返回的http_host变成了127.0.0.1:9000,而不是客户端原始的example.com:8080。这就是“默认透传$proxy_host”的直接证据。响应里的proxy_host为空,是因为最终执行点落在了没有proxy_pass的/vars上,变量为空是正常的。
4.3 用curl模拟请求行携带主机名的场景
前面2.2提过,$host的第一优先级是“请求行里的主机名”。这个场景可以用curl的--request-target参数模拟出来。先看基础请求:访问http://127.0.0.1:9000/vars时,curl会发出这样的请求行:
GET /vars HTTP/1.1 Host: 127.0.0.1:9000如果改用绝对URI作为请求目标:
curl -v --request-target 'http://example.com/vars' http://127.0.0.1:9000curl发出的请求行就会变成:
GET http://example.com/vars HTTP/1.1 Host: 127.0.0.1:9000注意,这里请求行里的主机名是example.com,请求头里的Host却是127.0.0.1:9000,两边对不上。按照$host的优先级,Nginx会优先采用请求行里的example.com,而$http_host则仍然是127.0.0.1:9000。实际跑一次你就能看到,这个优先级规则确实不是随便说说的。
5. 边界条件与长期实践体感
5.1 HTTP/1.0的“无Host”场景:为什么$host比$http_host更稳
HTTP/1.1强制要求请求必须带Host头,但HTTP/1.0没有这个规定。很多不更新的爬虫、老监控脚本、嵌入式设备上报程序,发的还是HTTP/1.0请求,很可能没有Host头。在这种请求下,$http_host是空字符串,而$host会回退到匹配到的server_name。
这个差异在反代场景里会被放大。如果写的是proxy_set_header Host $http_host;,一个无Host的HTTP/1.0请求到达后端时,后端看到的Host是空的,很多框架直接返回400。如果改用$host,后端至少能看到一个域名,运气好还能被路由到正确的虚拟主机。所以对对外开放的服务,我一般建议优先考虑$host,再通过X-Forwarded-Port这类头去补充端口信息,而不是把宝押在$http_host上。
5.2 通配符和正则server_name下的回退行为
还有一个容易迷糊的点:无Host请求回退到server_name时,到底回退到哪里?如果server块里写的是普通的精确域名,比如server_name example.com;,那$host就是example.com。如果是通配符,比如server_name *.example.com;,无Host请求进来时,$host通常会取实际匹配的那个域名,比如blog.example.com,而不是字面意义上的*.example.com。至于正则形式的server_name,回退行为会更依赖实现细节,一般不推荐在这种场景下依赖$host的值做任何逻辑处理。
在有Host头的正常请求里,不管server_name写得再花哨,$host都优先取Host头去掉端口后的值,所以这些回退规则只在“无Host”这个分支里才需要关心。也就是说,日常大多数请求根本走不到server_name回退这一层,你通常不用太焦虑。
5.3 安全校验不靠谱:Host可以被随便写
这是我最想强调的一条。无论是$host还是$http_host,都源自客户端输入,理论上可以被任意伪造。有人喜欢用if ($host != 'www.example.com') { return 403; }做防扫描、防乱入,但实际防护效果很有限:攻击者只需要在请求头里把Host写成www.example.com,判断就直接放行了。
更稳的做法是:让Nginx在server匹配阶段就完成甄别——业务域名写进server_name,匹配不到的业务请求会落到default_server。在default_server里直接return 444;或return 403;,比任何基于$host的if判断都干净可靠。链条再往后要多层防护,可以配合签名鉴权、证书校验或者更上层的安全设备,但无论如何,不要把一个客户端可篡改的字符串当成安全凭证。
5.4 我现在的一套固定配置习惯
最后分享几个我长期实践下来的习惯,你可以直接拿来参考。
第一,反向代理到内部服务,默认写成proxy_set_header Host $host;,同时补一组X-Forwarded-*头,把原始协议、原始端口都传下去。这套组合在绝大多数业务里都不会出问题。
第二,只有后端明确依赖端口区分环境,或者需要重建用户完整URL时,才换成$http_host,并且要评估无Host请求的兼容性,必要时在Nginx层对空Host做兜底。
第三,调试时往响应头里打变量非常高效,一行配置的事:
add_header X-Debug-Host $host always; add_header X-Debug-Http-Host $http_host always;always参数保证即使请求出错、返回4xx/5xx,响应头也会带上,不用反复改日志格式。
第四,多级代理链路里,每一层都显式写proxy_set_header Host $host;,同时保留X-Forwarded-Host为最原始的$http_host。不要依赖默认透传——默认透传真的会传$proxy_host。
这三个变量在我心里的定位一直是这样:$http_host是客户端塞过来的“原始凭证”,$host是Nginx规整后的“业务主机名”,$proxy_host则是转发链路里“目标地址的门牌号”。搞清楚它们的身份来源和去端口规则,配置proxy_set_header Host时基本就不会再犯方向性错误。下次再遇到后端日志里Host对不上的问题,先别急着改代码,到Nginx层把这个验证接口加上,跑一次curl,答案都在输出里。