搞过Nginx反向代理的同学,基本都绕不开这三个变量:$http_host、$host、$proxy_host。表面上看都是“Host”,但实际含义差了十万八千里,用错了轻则日志记录不准,重则转发到上游后业务直接异常,比如重定向地址不对、WebSocket握手失败、IP白名单失效、路径拼接错误等。我见过不少资深的运维同事在这上面栽过跟头,而且这类问题排查起来特别隐蔽,因为Nginx本身不报错,业务方反馈的报错信息又五花八门,绕来绕去最后才发现是Host头处理出了问题。
这篇文章就把这三兄弟彻底讲透。我会直接对比它们各自的来源、取值逻辑、适用场景,再给出几个踩坑典型和完整的配置示例。不管你是刚入门Nginx的小白,还是已经在生产环境里维护反代服务的老手,这篇文章都能帮你省下不少排查时间。
1. 内容整体设计与思路拆解
1.1 为什么很多人分不清这三个变量
先说结论:这三个变量确实都跟“主机名”相关,但它们的来源不同、作用阶段不同、服务对象不同。
$http_host来自客户端HTTP请求头里的Host字段,理论上最接近用户原始意图;$host是Nginx自己解析后的主机名,规则更加“规范化”;$proxy_host则是Nginx作为代理时,proxy_pass指令里指定的目标主机地址。换句话说,前两个变量描述的是“客户端想访问谁”,最后一个描述的是“Nginx打算转发给谁”。
如果你在server块里用proxy_pass http://backend这种方式做反代,Nginx默认会把$proxy_host作为上游请求的Host头传过去,而这个值通常是后端服务器的内网IP或者upstream组名,不是用户请求里的域名。这就是很多反代场景下业务出问题的根源。
1.2 搞懂变量,才能真正看懂Nginx的转发逻辑
Nginx的核心能力就是“接收请求、处理请求、转发请求”。接收阶段,Nginx需要知道客户端访问的是哪个域名,所以会解析请求头里的Host字段,得到$http_host,再结合server_name匹配规则,确定进入哪个server块。处理阶段,Nginx会通过一系列变量辅助条件判断、重写URL、记录日志。转发阶段,Nginx根据proxy_pass拼装上游请求,同时决定把什么值放到上游请求的Host头里,这里就会用到$proxy_host。
这三个阶段环环相扣,任何一环理解不到位,配置就容易出错。比如你只在server块里配置了listen 80 default_server,没有对应server_name,那么$host和$http_host在特定请求下表现会完全不一样。再比如你配置了国际化域名的Punycode转换,$host会返回解码后的Unicode值,而$http_host保留的可能是转换前的ASCII形式。这些细枝末节平时用不到,但关键时刻就是排查思路的分水岭。
1.3 本文的阅读路径建议
我建议你先通读第一部分的变量对比,搞清楚三者的定义和边界,再去看第二部分的使用场景。如果你已经遇到了具体问题,可以直接跳到第四部分的排查实录,对照速查表可能更快定位问题。第三部分是一整套配置示例,适合在了解原理后,照着搭一个反向代理环境,把各种Host处理方式都跑一遍,彻底形成体感。
2. 核心变量细节解析与实操要点
2.1 $http_host:客户端原始请求头里的Host
$http_host直接取自客户端HTTP请求头里的Host字段,它的值完全由客户端决定,Nginx不会做任何改写。这里有个非常关键的点:HTTP/1.1协议规定客户端必须携带Host头,所以正常情况下$http_host不会为空。但如果你用HTTP/1.0协议发起请求,或者某些畸形客户端不发送Host头,那$http_host就是一个空字符串。
另外注意,$http_host里包含端口号。假设用户访问https://example.com:8443/login,$http_host的值就是example.com:8443,端口号原封不动地保留。这一点在写日志模板或者做请求头转发时一定要留意,不然很可能把端口号丢失,导致后端重定向地址没有端口,跳转后链接不可用。
还有一个很容易被忽略的细节:如果客户端请求头里存在多个相同名称的Host字段,Nginx会拿第一个值作为$http_host。这种请求比较少见,但一旦出现,日志里记录的值可能和预期不一致,不要觉得是Nginx有bug,这是它的固有行为。
2.2 $host:Nginx内部解析后的规范化主机名
$host的取值优先级是这样:首先是请求行里的host参数,如果没有,再看请求头里的Host字段,如果连请求头里都没有,就用当前server块配置的server_name。这三个来源逐级降级,保证了$host在绝大多数场景下都有值。
和$http_host最大的区别是,$host不包含端口号。它会自动把请求头Host字段里的端口部分去掉,只保留主机名。如果你在server_name里配置的是Punycode编码的国际化域名,$host会返回解码后的Unicode字符串,比如客户端请求xn--fsqu00a.xn--0zwm56d,$host可能返回例子.测试,而$http_host返回的是原始的ASCII编码形式。所以在做域名比较、拼接跳转地址、做缓存key这类逻辑时,优先使用$host,避免因格式不一致引发歧义。
这里要专门强调一下:$host的值虽然经过Nginx规范化处理,但它并不等于请求一定匹配到了某个server_name。即使没有匹配任何server块,Nginx采用了默认server,$host依然会拿请求头里的Host字段来解析。也就是说,$host反映的是“请求里携带的主机名”经过规范化后的值,而不代表“Nginx配置里声明的某个server_name”。
2.3 $proxy_host:proxy_pass的目标主机地址
$proxy_host是Nginx在HTTP代理模块里定义的变量,只有当配置里使用了proxy_pass指令,且Nginx实际执行了转发动作时,这个变量才会有值。它的取值来自proxy_pass里指定的目标主机,包括协议、主机名或IP、端口,但不包括URI部分。
一个典型场景:你配置了proxy_pass http://backend_server;,而backend_server是upstream组的名字,那么$proxy_host就是backend_server。注意,这里是upstream组的名称字符串,而不是组里某个具体后端节点的IP地址。Nginx在处理upstream时,本身也不知道应该用哪个IP作为Host头,所以只能拿组名字符串去填充。
如果你写的是proxy_pass http://192.168.1.10:8080;,那$proxy_host就是192.168.1.10:8080。这意味着什么?意味着默认情况下,Nginx转发给上游的Host头是上游服务器的IP和端口,而不是用户访问的域名。很多后端应用依赖Host头来识别域名、生成绝对链接、做多租户隔离,默认行为直接导致业务错乱。
2.4 三者对比速查
| 变量名 | 来源 | 是否含端口 | 典型值示例 | 说明 |
|---|---|---|---|---|
$http_host | 客户端请求头Host字段 | 含 | example.com:8080 | 原样取请求头,不做任何处理 |
$host | 请求行/请求头/server_name | 不含 | example.com | Nginx规范化后的主机名 |
$proxy_host | proxy_pass指令目标 | 含 | 192.168.1.10:8080或backend_server | 仅在执行代理转发时有值 |
表格看下来,核心记忆点就是:$http_host是“用户嘴里说的”,$host是“Nginx记下来的”,$proxy_host是“Nginx最终递出去的”。理清这个链条,后面所有配置都顺了。
3. 实操过程与核心环节实现
3.1 动手搭建测试环境
我在本地用Docker快速搭了一个测试环境,用来验证这三个变量在不同配置下的输出值。Docker的好处是隔离干净,随便折腾也不会污染宿主环境,而且可以同时起Nginx和后端服务,完整模拟真实转发的链路。如果你也想手动验证,可以直接参考下面的命令。
先创建一个测试目录,写一个后端服务,这里直接用Python起一个简单的HTTP服务器,专门打印收到的请求头。Python的http.server虽然轻量,但能非常直观地展示上游收到什么东西,对排查这类问题很有帮助。
# server.py from http.server import HTTPServer, BaseHTTPRequestHandler import json class Handler(BaseHTTPRequestHandler): def do_GET(self): headers = {k: v for k, v in self.headers.items()} body = json.dumps({"path": self.path, "headers": headers}, indent=2).encode("utf-8") self.send_response(200) self.send_header("Content-Type", "application/json") self.end_headers() self.wfile.write(body) def log_message(self, format, *args): print(f"[backend] {self.address_string()} - {format % args}") HTTPServer(("0.0.0.0", 8080), Handler).serve_forever()这个后端服务监听8080端口,收到请求后会把路径和所有请求头以JSON格式返回。通过观察返回的Host头,就能知道Nginx转发时到底带了什么值。
3.2 场景一:保持客户端Host头不变
这是最常见的需求,尤其是后端要根据用户访问的域名来做路由或者生成业务链接时。配置非常简单,用proxy_set_header强制把上游请求的Host头设置成客户端原始值。
server { listen 80; server_name example.com; location / { proxy_pass http://backend:8080; proxy_set_header Host $http_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; } }用curl -H "Host: example.com:8888" http://127.0.0.1/测试,后端返回的JSON里Host头就是example.com:8888,和客户端请求完全一致。这个场景下使用$http_host就能完整保留端口信息。但要注意,如果客户端没有带端口,那$http_host就没有端口号,这和$host的表现是一样的。
3.3 场景二:隐藏内部端口,只保留域名
有时候客户端访问的是http://example.com:8000,但后端应用需要看到的Host头是example.com,不带端口。这时候继续用$http_host就会把8000端口透传过去,后端如果拿这个端口做重定向拼接,就会把带端口的地址暴露给用户,用户体验很差,而且有时候内网端口直接暴露出来还有安全隐患。
这种场景用$host最合适,它已经把端口剥离干净了。
location / { proxy_pass http://backend:8080; proxy_set_header Host $host; }再配合curl -H "Host: example.com:8000" http://127.0.0.1/测试,后端拿到的Host头是example.com,端口被Nginx去掉。仔细体会这个区别:$http_host保留的是用户原始输入,$host返回的是规范化后的主机名,两者处理的出发点完全不同。
3.4 场景三:动态upstream和SSL后端证书校验
理解$proxy_host的动态语义特别重要,尤其是当你配置了upstream集群或域名式后端时。先看一个用upstream的例子:
upstream backend_servers { server 192.168.1.10:8080 weight=3; server 192.168.1.11:8080 weight=1; } server { listen 443 ssl; server_name api.example.com; ssl_certificate /etc/nginx/certs/example.crt; ssl_certificate_key /etc/nginx/certs/example.key; location / { proxy_pass http://backend_servers; proxy_set_header Host $proxy_host; } }实际运行时,传给上游的Host头要么是192.168.1.10:8080,要么是192.168.1.11:8080,取决于负载均衡算法选中的节点。这种配置在需求上其实非常少见,但如果你不显式设置proxy_set_header Host,Nginx默认就是拿$proxy_host去填充的。这就是很多人在做反代时,后端拿到IP而不是域名的根本原因。
如果上游是HTTPS,还会涉及SNI证书校验的问题。当后端服务器上配了多个虚拟主机,用同一个IP和443端口对外提供服务时,Nginx握手时需要通过SNI携带Host名称,让后端选择正确的证书。这时候就不能把$proxy_host传给后端的SSL握手了,因为$proxy_host是upstream组名或者IP,后端根本无法根据它选择证书。你需要手动强制设置SNI:
location / { proxy_pass https://backend_servers; proxy_set_header Host $host; proxy_ssl_server_name on; proxy_ssl_name $host; }proxy_ssl_name指定SNI里携带的服务器名称,这里用$host把用户请求的域名传过去,后端才能正确匹配证书。这个配置细节,我见过不止一个团队在处理多域名HTTPS反代时踩坑。
3.5 场景四:日志记录里的变量差异
访问日志里的Host字段,很多人的第一反应是用$http_host。这么做有个隐患:如果客户端直接访问Nginx的IP地址,没有带合法的Host头,$http_host就是空的,日志里就会留下一个空格,排障的时候完全看不出用户访问了哪个站点。如果启用的是$host,Nginx会用默认server的server_name来兜底,日志至少是完整的。
log_format main '$remote_addr - $remote_user [$time_local] "$request" ' '$status $body_bytes_sent "$http_referer" ' '"$http_user_agent" "$http_x_forwarded_for" ' 'host=$host http_host=$http_host proxy_host=$proxy_host';这段日志模板把三个变量全部记录下来,排查问题的时候可以直接对比差异,非常直观。生产环境里我建议同时记录$host和$http_host,因为前者用于分析用户实际访问的站点,后者用于排查某些特殊客户端是否发送了非法或异常的Host头。这两种信息侧重点不同,组合使用能更快定位问题。
3.5 一个完整的综合配置参考
下面是一段生产环境级别的综合配置,覆盖了静态资源、动态接口和WebSocket三种常见代理场景。注意其中针对不同场景选择了不同的Host变量,不是所有location都千篇一律使用$host。
upstream api_servers { server 10.0.0.11:8080; server 10.0.0.12:8080; keepalive 32; } server { listen 80; server_name www.example.com example.com; return 301 https://$host$request_uri; } server { listen 443 ssl http2; server_name www.example.com example.com; ssl_certificate /etc/nginx/ssl/example.com.pem; ssl_certificate_key /etc/nginx/ssl/example.com.key; # 静态资源配置,直接用本地文件系统响应,不需要转发 location /static/ { alias /data/www/static/; expires 7d; access_log off; } # 动态接口代理 location /api/ { proxy_pass http://api_servers; 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_read_timeout 10s; proxy_send_timeout 10s; } # WebSocket代理 location /ws/ { proxy_pass http://api_servers; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; proxy_read_timeout 3600s; proxy_send_timeout 3600s; } }逻辑线很清晰:静态资源不经过代理,直接由Nginx本地处理;动态接口代理到后端Java服务,Host头用不带端口的$host;WebSocket代理需要额外放行Upgrade和Connection头,Host头同样用$host保证业务方拿到的域名干净统一。这套配置虽然简单,但基本覆盖了普通业务的绝大部分需求,你可以根据自己的实际拓扑做调整。
3.6 用curl验证所有变量的真实值
配置完成后,验证环节不能省。用curl直接构造不同Host头的请求,观察后端返回的头部信息,就能确实掌握三个变量的实际区别。下面是我本地测试的一组结果,你可以对照着看。
先验证默认转发行为,故意不对Host做任何设置:
curl -H "Host: www.example.com:8000" http://127.0.0.1/api/hello后端返回内容里的Host字段如果是backend:8080,说明使用的是默认的$proxy_host。再改成显式传递$host:
proxy_set_header Host $host;重新执行同样命令,后端收到的Host字段就是www.example.com,端口已经消失。最后试验$http_host,后端收到的是www.example.com:8000,端口原样保留。
4. 常见问题与排查技巧实录
4.1 后台上游收到的Host一直是内网IP
这个问题出现的频率极高,典型的反馈是“我明明配置了proxy_pass指向域名,为什么后端日志里看到的是内网IP?”如果你没有显式设置proxy_set_header Host,Nginx默认用$proxy_host填充上游请求的Host头,而$proxy_host的值是proxy_pass目标地址里的主机名。如果目标是IP或upstream组名,后端收到的自然就是IP或组名。
解决办法很简单,在location里显式声明一行就行:
proxy_set_header Host $host;这里我强烈建议:所有使用proxy_pass的场景,都必须显式设置proxy_set_header Host,哪怕你只是想保持默认行为,也写出来,方便后来人理解你的意图。不声明的配置,等于把默认行为藏在暗处,后期接手的人很容易误解。
4.2 HTTPS跳转后端口丢失
用户访问https://example.com:8443/login,Nginx做了301跳转到https://example.com/login,端口直接消失,用户点开就404。这类问题多半是Nginx配置了HTTP到HTTPS的强制跳转,或者后端应用根据Host头生成了重定向地址。排查的时候先看跳转发生在哪一层:是Nginx的return 301,还是后端应用返回的Location头。
如果是Nginx层跳转,配置里用了$host做域名拼接,端口自然没了。你需要的是$http_host,因为它保留客户端原始端口。改法如下:
return 301 https://$http_host$request_uri;修改之后,用户访问https://example.com:8443/login时,$http_host的值是example.com:8443,拼接出来的跳转地址就会带上8443端口。但这里还有一个隐藏坑:如果客户端访问的是80端口,$http_host就不带端口,这个方案依然正确。所以核心逻辑是:跳转时想保留什么,就选什么变量,端口是变量选择的分水岭。
4.3 WebSocket握手失败或频繁掉线
WebSocket场景下,握手阶段客户端会通过Upgrade: websocket和Connection: Upgrade两个请求头来发起协议升级。Nginx默认情况下会忽略客户端传入的Connection头,导致握手失败。如果你配置的WebSocket反代里把Host也设错了,后端可能直接拒绝连接。
标准代理配置长这样:
location /ws/ { proxy_pass http://backend; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; }proxy_http_version 1.1必须显式声明,因为HTTP/1.0不支持Upgrade机制。Connection "upgrade"覆盖了Nginx默认的Connection清理逻辑,让WebSocket帧能顺利透传。Host头用$host或$http_host都可以,根据后端需求决定,但不要漏掉,否则某些严格校验Host的应用会直接拒绝握手请求。
4.4 多域名共用一个Nginx,如何正确区分路由
如果一个Nginx通过server_name承载多个域名的转发,千万不要在location里写死proxy_set_header Host backend.example.com这种固定值。一旦两个域名共用同一个后端,后端就无法区分用户到底访问的是哪个站点,导致“所有域名都返回同一个主页”这类问题。
正确做法是让后端信任Nginx传递的请求域名,也就是用$host或者$http_host。如果后端是多租户架构,需要精确知道用户访问的原始域名和端口,用$http_host接收原始值;如果只需要域名本身做路由,用$host即可,还能顺带规避端口带来的麻烦:
proxy_set_header Host $http_host;这个配置尤其适用于需要按Host头区分租户的SaaS系统,比如用户访问tenant1.example.com和tenant2.example.com,后端拿到的Host头就是对应的租户域名,从而正确渲染各自的业务数据。
4.5 调试技巧速查
| 问题现象 | 可能原因 | 排查方向 |
|---|---|---|
| 后端收到的是IP而不是域名 | 未设置proxy_set_header Host,默认用了$proxy_host | 检查location里是否声明了Host变量 |
| 重定向后端口丢失 | 使用了$host拼跳转地址 | 改用$http_host |
| WebSocket握手失败 | 未配置Upgrade/Connection头 | 检查proxy_http_version和相关头设置 |
| 日志里Host字段是空的 | 客户端未携带Host头,$http_host为空 | 改用$host或$server_name兜底 |
| 多租户域名错乱 | Host头被写死或未透传 | 显式传给后端$http_host |
除了上述场景,我再推荐一个调试辅助手段。在Nginx的server块里临时加一个location,返回JSON格式的变量快照,排查时非常高效:
location /nginx_vars { default_type application/json; return 200 "{\"host\":\"$host\",\"http_host\":\"$http_host\",\"proxy_host\":\"$proxy_host\",\"server_name\":\"$server_name\",\"remote_addr\":\"$remote_addr\"}"; }上线前记得删除这个location,不然等于公开暴露了Nginx的转发细节,有一定安全隐患。我在本地测试环境经常保留它,生产环境从来不开,这是底线。
5. 按需选择,不要让默认行为替你做决定
最后再分享一个我在实际运维中的习惯。每次配置新的反向代理时,我都会先自问三个问题:用户访问的域名是什么?客户端原始端口是否需要保留?后端服务依赖于Host头的哪个部分做业务判断?回答完这三个问题,再决定用$host还是$http_host还是$proxy_host,基本不会出大乱子。
$host适合大多数透传场景,因为它剥离了端口,格式规范,对后端友好。$http_host适合需要完整保留客户端访问信息的场景,尤其是涉及端口和多租户路由时。$proxy_host则主要用于那些明确需要把上游节点信息暴露给后端的极少数场景,绝大多数业务用不到。排障的时候,把这三个变量的值同时打到日志里,上下游一对比,问题原因往往立刻浮出水面。默认行为只是Nginx替你做的兜底决策,不代表业务的最佳选择,关键位置一定要显式声明,这是反代配置里最重要的一条纪律。