ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Nginx反向代理必知:$host、$http_host、$proxy_host三变量深度解析

Nginx反向代理必知:$host、$http_host、$proxy_host三变量深度解析 上次帮同事排一个反代问题前端所有登录跳转都指向内网IP用户一登录就被踢出去。抓包看后端请求发现后端收到的Host根本不是用户访问的域名而是proxy_pass里写的那个内网地址。问题就出在proxy_set_header Host到底该写$http_host还是$host还是$proxy_host上——这三个变量名字像三胞胎实际在Nginx里各管一摊写错一个请求头就变了味。这篇文章就把这三个变量掰开揉碎讲清楚它们分别从哪来、取值规则是什么、在反向代理场景下默认会把哪个Host传给上游以及遇到SSL证书不生效、上游报host not defined这类问题时该怎么顺着Host头一路排查。适合刚接触Nginx反代的读者也适合配置过不少反代但从没深究过Host头的同学。看完你不仅知道该写哪个还能明白为什么这么写。1. 三个变量看着像实则在Nginx里各管一摊1.1 一个真实场景开场反代后应用拿到的域名不对劲先说那个让我印象深刻的排错过程。系统架构很简单用户访问example.comNginx做反向代理把请求转发到内网的一台应用服务器192.168.1.10:8080。问题是用户一登录页面跳转的地址就变成http://192.168.1.10:8080/login浏览器直接访问内网地址当然失败。最开始怀疑后端应用的域名配置写死了翻了一圈代码没发现问题。后来在Nginx上抓请求发现后端收到的HTTP请求头里Host字段的值是192.168.1.10:8080压根不是example.com。后端应用拿到这个Host去生成跳转链接自然就拼出了内网地址。这就是典型的Host头被改写导致的“反代跳转错乱”。解决方式一句话在location里加上proxy_set_header Host $host;让后端知道用户实际访问的是哪个域名。但为什么有人写$host有人写$http_host还有人写$proxy_host这三者在很多场景下输出还是一样的这就让问题更乱了。1.2 三个变量各自管什么先看官方定义先把官方的定义摆出来后面再逐个拆解$http_host等于客户端请求头中Host字段的原始值原样保留大小写和端口。$host按优先级返回“请求行中的主机名、请求头Host字段、匹配到的server_name”中的值并统一转小写、去掉端口。$proxy_host来自proxy_pass指令中指定的目标主机名和端口。只看定义就能发现前两个变量描述的是“客户端要访问谁”第三个变量描述的是“Nginx要把请求转给谁”。一个是“来路”一个是“去处”混在一起用自然要出问题。1.3 为什么这三个变量容易被混用我观察下来混用的原因主要有三个第一名字太像。$http_host和$host就差一个前缀写起来几乎没区别。第二很多简单场景下它们的值恰好相同。比如客户端直接访问example.com不带端口Nginx的server_name也是example.com那么$http_host和$host都返回example.com看不出差异一换复杂场景就露馅。第三不少教程只告诉你怎么写不解释为什么照抄的时候变量就抄乱了。理解这三个变量关键在于弄清楚它们的取值来源和计算规则。下面逐个过。2. $http_host请求头里的原始Host原样保留不带修饰2.1 $http_host到底从哪来HTTP头到变量的映射规则Nginx有一类内建变量是用$http_前缀加上请求头字段名组成的。比如Host请求头对应$http_hostUser-Agent请求头对应$http_user_agentContent-Type请求头对应$http_content_type。规则就是把请求头字段名转成小写、把中间的减号换成下划线然后加上$http_前缀。所以$http_host的值完全取决于客户端发过来的Host请求头长什么样。客户端发Host: Example.COM:8080Nginx的$http_host就是Example.COM:8080一点不改。这个变量和Nginx自己的配置没有任何关系。你设置了什么server_name、监听哪个端口都不影响它的取值它就是对客户端请求头的一次“照抄”。2.2 $http_host的三个特性和两个坑三个特性分别是保留原始大小写、保留端口、可能为空。保留大小写这个特性在大多数场景下没啥用因为HTTP/1.1规范要求Host字段大小写不敏感。但如果你要在Nginx里做精确的字符串比对比如if ($http_host Example.COM)就会踩坑——客户端大小写一变匹配就失败了。保留端口这个特性很实用。用户访问http://example.com:8080Nginx监听8080端口做反代$http_host的值是example.com:8080。后端应用拿到这个值就知道用户是从8080端口进来的生成跳转链接时不会丢端口。可能为空的坑比前两个都隐蔽。HTTP/1.0协议不强制要求客户端发送Host头一些老客户端、监控探活脚本、自己写的HTTP客户端都可能不带Host头。这时候$http_host就是空字符串。你需要排查“反代后端收到空Host”的问题时十有八九是这里出了问题。2.3 什么场景必须用$http_host最典型的就是涉及非默认端口的反代场景。Nginx监听8080端口对外域名是example.com用户实际访问的是http://example.com:8080。如果proxy_set_header Host $host;后端收到的Host是example.com端口信息丢了。后端应用生成重定向时默认拼80端口用户点过去直接404。这时候用$http_host就对了端口原封不动传给后端。类似的场景还有需要在Nginx日志里精确还原用户原始请求域名和端口时用$http_host记录是最准确的。3. $host被Nginx“清洗”过的主机名取值优先级有讲究3.1 $host的取值优先级请求行、请求头、server_name$host的取值不是简单地从请求头里读它有明确的三级优先级请求行中的主机名。也就是HTTP请求行里直接带的absolute-form URI比如GET http://example.com/path HTTP/1.1这种形式。实际业务里很少见但规则确实存在。请求头Host字段的原始值也就是$http_host的值。匹配到当前请求的server_name。大多数情况下我们遇到的都是第二种来源请求头里带了Host$host就跟着$http_host走。但注意它跟$http_host的输出不一样见下一节。3.2 端口与大小写自动清洗的底层逻辑$host和$http_host最大的区别是$host会做两件事统一转小写、去掉端口号。客户端发Host: Example.COM:8080$http_host返回Example.COM:8080$host返回example.com。这个设计是有原因的HTTP/1.1规范定义Host字段不区分大小写Nginx干脆统一成小写方便你做路由判断和日志统计。端口单独拆出来是因为$host的定位是“主机名”不是“主机名加端口”。举个例子你做了这样一个路由判断if ($host Example.com) { return 301 http://www.example.com$request_uri; }如果直接用$http_host客户端一旦用Example.COM访问就匹配不上。用$host就稳了大小写已经被Nginx归一化。端口被去掉这个特性在上一节已经提过会导致跳转丢端口。这里再补充一个场景Nginx前面还有一层负载均衡器负载均衡器转发请求时把端口信息弄丢了这时候后端看到的是$host没有端口但$http_host可能带着端口——前提是负载均衡器在转发时保留了原始Host头。这也是排查问题时需要分辨的。3.3 通配符、正则server_name下的$host表现很多初学者以为$host就是server_name的值这是错的。$host优先取请求头Host只有当请求头里没有Host时才退回server_name。更微妙的是即使退回server_name如果你配置的是通配符或正则形式$host也不是返回配置里的那个通配符字符串而是返回实际匹配上的server_name。比如这样的配置server { listen 80; server_name *.example.com; # ... }客户端请求Host: foo.example.com$host的值是foo.example.com而不是*.example.com。而$server_name这个变量返回的恰好是配置里写的*.example.com。所以如果你在日志里看到$host和$server_name不一样别奇怪它们的定义就决定了结果不同。3.4 $host和$server_name不是一回事把这两个变量放在一起对比能帮助理解$host用户实际请求的主机名清洗过跟用户走。$server_nameNginx配置里匹配到的server块的名字跟配置走。当请求头Host为foo.example.com、server块配置为*.example.com时$host是foo.example.com$server_name是*.example.com。反过来当客户端请求没带Host头时$host会回退成$server_name的值。所以$host在绝大多数情况下不会为空这是它做反代Host头时比$http_host更稳的原因。4. $proxy_host只认proxy_pass的目标主机和客户端请求无关4.1 $proxy_host的定义来自proxy_pass的目标主机$proxy_host和前两个变量完全不是一个体系。它不关心客户端请求了什么域名只认proxy_pass指令里写的那台目标主机。比如配置是这样location /api/ { proxy_pass http://10.0.0.5:8080; }那么$proxy_host就是10.0.0.5:8080。如果你写的是location /api/ { proxy_pass http://backend_upstream; }backend_upstream是一个upstream组的名字那$proxy_host的值是backend_upstream而不是组里某个真实后端的IP。这个细节很多人不知道。默认情况下如果你不写proxy_set_header Host上游服务器收到的Host头恰恰就是$proxy_host的值。换句话说客户端明明访问的是example.com上游看到的却是10.0.0.5:8080或者backend_upstream这种自己人才能看懂的名字。4.2 默认行为Nginx到底把哪个Host传给上游这是全文最核心的知识点之一。Nginx官方文档写得很清楚默认情况下proxy模块会重新定义Host请求头默认值就是$proxy_host。也就是说不做任何显式配置时上游收到的Host不是你网站的域名而是proxy_pass里写的那个目标主机。开篇那个排错案例根因就在这里。很多反代导致后端应用跳转错乱、域名校验失败、链接生成错误的问题都是因为这个默认行为。理解了这一点再看网上各种教程里写的proxy_set_header Host $host;本质就是在覆盖这个默认行为让上游知道用户真正访问的域名。同理proxy_set_header Host $http_host;则是连端口、大小写一起原样透传。4.3 proxy_pass带变量时$proxy_host会发生什么还有一个进阶场景。当proxy_pass里的目标不是写死的而是用变量拼出来的比如location / { proxy_pass http://$backend_addr; }这时候$proxy_host的值不再是配置加载时就确定的静态主机名而是请求处理时$backend_addr这个变量解析出来的值。它可能是10.0.0.5:8080也可能是10.0.0.6:8080取决于你的map或if逻辑。这种动态反代写法下默认的Host头也会跟着$proxy_host变。如果你想让上游始终收到固定的Host就必须显式写proxy_set_header Host。另外注意proxy_pass里使用变量时Nginx有可能要求你在http块里配置resolver来解析域名因为配置加载阶段无法确定目标主机。5. 实际配置时到底用哪个反代、路由分发、日志三类场景全拆解5.1 场景一普通反代透传原始域名最常见的场景Nginx对外提供服务反代到后端应用希望后端看到的是用户访问的真实域名。server { listen 80; server_name example.com www.example.com; location / { proxy_pass http://backend_app; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Host $host; } }这里用$host而不是$http_host主要考虑两点一是统一小写后端做域名匹配时更省心二是当客户端没带Host头时$host会回退到server_name保证上游至少能收到一个合法的域名。如果后端应用对端口敏感比如Nginx监听的是8080端口优先考虑$http_host。5.2 场景二固定上游Host让后端认为请求来自目标域名有些后端应用会校验Host是否属于自己不接受任意域名。如果反代的目的是“借用Nginx的域名来访问一个只认固定域名的后端”需要把Host固定成上游认可的域名location / { proxy_pass http://upstream_internal; proxy_set_header Host internal.service.local; }这种写法下不论用户访问的是a.example.com还是b.example.com上游收到的Host都是internal.service.local。$proxy_host在这里反而不合适因为如果upstream组名是upstream_internal默认Host就变成了组名后端还是不认。所以手动写死Host最直接。5.3 场景三按Host做流量路由多域名共用一个Nginx时经常需要根据用户访问的域名分发给不同后端。这种需求用map加$http_host或$host都能实现关键在于你想要“带端口判断”还是“纯域名判断”。map $host $backend_addr { app1.example.com 10.0.0.11:8080; app2.example.com 10.0.0.12:8080; default 10.0.0.10:8080; } server { listen 80; server_name _; location / { proxy_pass http://$backend_addr; proxy_set_header Host $host; } }这里用$host做map的key好处是大小写已被归一化用户用APP1.EXAMPLE.COM访问也能正确路由。如果用$http_host还得自己在map里兼容大小写变体。但要注意$host不带端口如果多个域名在不同端口上提供服务就要改回$http_host。proxy_pass里用变量的写法会让$proxy_host变成动态值此时建议显式设置Host避免上游拿到不确定的域名。5.4 场景四日志和分析中该记哪个Nginx的access_log可以通过log_format自定义字段。我建议按用途选择域名维度的流量统计、QPS分析用$host。因为值稳定不会因为端口、大小写产生多余的维度方便聚合。排查具体用户的跳转、鉴权问题用$http_host。它能精确还原用户请求的原始Host包含端口。排查反代链路把$host和$proxy_host同时记上一眼能看出“用户想访问谁”和“Nginx实际转发给谁”。实际配置示例log_format trace $remote_addr - $host | upstream_host: $proxy_host | req_host: $http_host; access_log /var/log/nginx/access.log trace;这样一条日志同时记录三个变量问题定位效率会高很多。6. 结合现实报错SSL证书不生效、证书未发送、内网跳转的排查记录6.1 案例“替换SSL证书不生效”的排查链路有一个热搜词是“nginx替换ssl证书不生效”我遇到过好几次。先说结论这类问题大多和三个变量没关系但排查思路和Host头有千丝万缕的联系因为TLS握手时客户端发送的SNI本质上就是域名。现象改了ssl_certificate指向新证书nginx -t也通过了reload之后浏览器看到的还是旧证书。完整排查链路先用nginx -T | grep ssl_certificate确认当前生效的配置到底是哪个证书文件。有时候你改的是A server块的证书但用户访问的域名实际命中B server块。用openssl命令直接探测openssl s_client -connect example.com:443 -servername example.com 2/dev/null | openssl x509 -noout -subject -dates-servername参数就是手动指定TLS握手时的SNI。如果你不指定抓到的可能是不带SNI的default_server证书自然不是你新替换的那个。确认命中正确的server块后检查证书文件本身有没有更新成功。有时候是软链接指向旧文件或者证书链文件里只追加了新证书、没有替换干净。最后检查浏览器缓存和系统证书缓存。Chrome对HTTP公钥钉扎和证书缓存比较顽固开无痕窗口测最干净。这个排查过程里$host、SNI、server块匹配三者是联动的Nginx就是靠SNI来选server块、选证书的。如果你在配置层面把多个域名的证书搞混了或者default_server块在“抢”流量就会看到“证书不生效”的假象。6.2 案例“no required ssl certificate was sent”这个报错常见于双向TLS场景。Nginx配置了ssl_verify_client on;要求客户端必须出示证书但客户端没有发送握手就会失败并产生这个错误。排查思路看Nginx错误日志路径一般在/var/log/nginx/error.log里面会有完整的TLS握手失败原因。确认本端是否配置了ssl_client_certificate指向CA证书文件。确认客户端是否安装了正确的客户端证书并且证书链完整。检查Nginx是否把客户端证书校验要求透传给了上游。如果Nginx做SSL终止再代理给上游而上游也开启了客户端证书校验Nginx本身又没有配置ssl_client_certificate和proxy_ssl_certificate上游就会给Nginx报这个错。这里顺带说一句遇到SSL类报错先确认你连的是不是正确的域名和端口再往下查证书。用curl -vI https://域名看一眼握手过程能少走很多弯路。6.3 案例反代后应用跳转地址错乱回到开篇的问题。后端应用根据Host头生成跳转链接Host不对就全乱套。根因是默认情况下Nginx把Host设置成了$proxy_host。解决方式分两种后端希望看到原始用户域名proxy_set_header Host $host;不要端口或proxy_set_header Host $http_host;保留端口。后端配置了域名白名单把Host固定成后端认可的域名。排查这类问题时我建议在后端服务器上抓一次HTTP头看Host字段到底是什么。抓包工具可以用tcpdump更轻量的做法是在后端临时加个接口把请求头打出来或者看后端access_log里的请求域名。基本上一眼就能判断是Host设置的问题。6.4 排查时的通用思路先分清“来路”和“去处”综合这几个案例我总结了一个排查Host相关问题的通用顺序先确定用户实际请求的域名和端口也就是$http_host应该是什么。再确定Nginx配置里匹配到的server_name也就是$host会回退成什么。然后看proxy_pass目标确认$proxy_host是什么。最后看location里有没有写proxy_set_header Host写了哪个变量。大多数Host相关的诡异问题走完这四步都能定位。如果后端报host not defined之类的错误别急着查应用代码先看Nginx传给后端的Host是不是空字符串——$http_host在客户端没带Host头时就是空的。7. 一张对照表加三步自测下次配置不再犹豫7.1 一张表记住全部差异变量取值来源大小写端口可能为空典型用途$http_host客户端请求头Host原样保留保留可能为空精确还原用户原始请求排查跳转问题$host请求行 请求头Host server_name统一小写去掉基本不为空域名路由、日志统计、稳定透传$proxy_hostproxy_pass目标主机按配置按配置可能为空默认上游Host用于定位反代转发目标这张表是我自己整理时最常看的版本。核心记忆点就三条$http_host是“原样照抄”$host是“清洗归一”$proxy_host是“转发目标”。7.2 三步自测法用log_format实测如果你还是记不牢建议花五分钟实测一把。配置一个临时server把所有变量打进日志然后用curl模拟不同Host头观察输出。log_format host_test $http_host | $host | $proxy_host; server { listen 8080; server_name example.com; location / { proxy_pass http://10.0.0.5:8080; proxy_set_header Host $http_host; access_log /var/log/nginx/host_test.log host_test; } }然后逐个测试curl -H Host: Example.COM:8080 http://127.0.0.1:8080/ curl --http1.0 -H Host: http://127.0.0.1:8080/第一条命令日志里$http_host是Example.COM:8080$host是example.com$proxy_host是10.0.0.5:8080。第二条命令$http_host是空的$host回退成example.com。再把proxy_set_header Host分别改成$host和$proxy_host去后端看实际收到的Host头。亲眼看到差异比死记硬背强得多。7.3 我的几条实操建议最后分享几条踩过坑之后的经验。第一写反代配置时不要依赖默认行为。默认的上游Host是$proxy_host绝大多数情况下不是你想要的结果显式写proxy_set_header Host是必须的。第二对外提供服务的反代优先用$host做Host头如果服务端口非80、443改用$http_host否则端口会丢。第三做域名判断、路由分发、流量统计优先用$host它已经帮你处理了大小写和端口这两个干扰项。第四排查问题不要只盯Nginx配置。客户端可能压根没发Host头上游可能在做二次跳转CDN可能改写了Host头。用日志和抓包把整条链路打通再回来看是哪个环节动了Host。这几个变量的区别说到底就一句话明白用户想访问谁清楚Nginx要转给谁再决定让上游看见谁。
返回列表