Nginx反向代理中$http_host、$host、$proxy_host三个变量详解与实战
先说一个结论$http_host、$host、$proxy_host这三个变量不是“长得像”的关系而是“来源完全不一样”的关系。很多人配置反向代理时被这三个变量搞晕最常见的问题就是明明我把proxy_set_header Host $host写上了为什么上游服务拿到的还是 IP为什么日志里记录的域名没有端口为什么用变量玩动态转发时$proxy_host的值跟我预期完全不同这篇文章就把这三个变量从头到尾拆一遍各自的取值来源、在反向代理链路里谁传给谁、真实项目里该怎么选以及我实际踩过的一些坑。内容偏实战适合刚接触 Nginx 的开发者也适合配置过不少反代但一直靠“试”来解决问题的老手。1. 先把变量来源理清楚1.1$http_host客户端说了什么Nginx 就转述什么Nginx 里所有以$http_开头的东西本质上都是请求头Header的映射。比如$http_user_agent对应User-Agent$http_referer对应Referer而$http_host对应的就是客户端请求里的Host请求头。这个值最大的特点是“不加工”。客户端发过来是什么Nginx 就原样抛给你。举个例子curl http://192.168.1.10/ -H Host: www.example.com:8080在这个请求里$http_host的值就是www.example.com:8080注意它保留了端口。如果客户端故意发一个乱七八糟的Host头比如Host: something/evilNginx 也不会帮你清洗$http_host就直接变成something/evil。理解这一点很重要$http_host是“客户端视角的主机名”它的值完全由客户端控制不代表任何服务端的判断结果。1.2$hostNginx 帮你整理过的“有效主机名”$host的取值顺序和$http_host完全不一样。按照 Nginx 官方文档的定义它会按照下面的优先级获取请求行中的 host也就是 HTTP 请求行里的主机名极少见请求头中的Host与请求匹配的server_name配置值。取到之后Nginx 还会做两件事转成小写去掉端口。所以即使客户端发了Host: WWW.Example.COM:8080$host最后也只会是www.example.com。如果客户端没发Host头$host还能兜底用上server_name这一点在排查“为什么有的请求拿不到域名”时非常关键。一句话总结$host是“Nginx 视角的主机名”它更适合做逻辑判断、路由分发但并不适合用来还原客户端最原始的请求头。1.3$proxy_host上游服务的主机名$proxy_host只有在使用proxy_pass时才有意义。它的值是proxy_pass后面指定的主机名和端口也就是 Nginx 作为代理转发时要连接的那个上游地址。比如下面这个配置location /api/ { proxy_pass http://internal-backend:8080; }此时$proxy_host的内容就是internal-backend:8080。它跟客户端发什么Host完全没有关系只跟proxy_pass里写的是什么有关。如果你用变量来写proxy_pass那$proxy_host就是变量计算后的最终上游地址。这个后面讲动态转发的时候会细说。所以一句话$proxy_host是“上游视角的主机名”它反映的是“Nginx 要去连接谁”而不是“客户端当初想访问谁”。2. 差异最明显的场景一次反向代理请求的完整生命周期2.1 模拟一次请求看看三个变量分别是什么我们搭一个最简单的反向代理场景客户端访问http://nginx.local/apiNginx 监听80端口server_name配置为nginx.localproxy_pass指向http://127.0.0.1:3000然后发一个不那么“标准”的请求curl http://nginx.local/api -H Host: external.example.com:8090这时三个变量的值是变量值来源$http_hostexternal.example.com:8090请求头 Host 原样保留$hostexternal.example.com从 Host 头取到后去掉端口、转小写$proxy_host127.0.0.1:3000来自 proxy_pass 配置看到了吧三个值三个意思。如果不明白来源看到$host和自己的server_name不一样很容易误以为 Nginx 配置出错了。2.2 默认情况下上游服务收到的 Host 是谁这是最容易让人迷糊的地方。在没有显式写proxy_set_header Host的情况下Nginx 转发给上游服务时默认会带上$proxy_host作为Host请求头。也就是说上面的请求转发到127.0.0.1:3000的时候上游服务收到的Host是127.0.0.1:3000而不是客户端发来的external.example.com。这就是很多后端服务报错“Invalid Host header”或者“域名不对”的根本原因。所以绝大多数反代场景都要显式加一行proxy_set_header Host $host;加了这行之后上游服务收到的就是去掉端口并转小写后的域名了。但要注意如果你希望上游拿到完整端口比如业务上需要识别8090端口那用$host是不够的要么用$http_host要么用X-Forwarded-Host头传过去。2.3 端口被“吃掉”之后引发重定向死循环$host不带端口这个特性在默认端口场景下是优点在非标准端口场景下就是个坑。假设你的 Nginx 监听在8080业务域名是www.example.com:8080。你用$host去拼重定向地址return 301 http://$host$request_uri;那么用户访问http://www.example.com:8080/oldNginx 返回的却是http://www.example.com/old。端口丢了浏览器跳到 80 端口如果 80 端口上没有对应服务直接打不开如果 80 端口也存在同一个 Nginx还可能造成反复重定向。这种情况用$http_host更稳妥return 301 http://$http_host$request_uri;但$http_host是拿客户端原始值如果客户端发的是www.example.com没带端口拼出来的 URL 又不带端口。所以最稳妥的做法是分情况处理if ($http_host ) { return 400; }或者直接用$http_host做重定向同时保证 Nginx 收到的请求头里一定带端口。3. 实战选型每个场景到底该用哪个变量3.1 域名校验和裸 IP 拦截优先用$host如果你只想允许特定域名访问其他域名直接拒绝用$host做判断是合理的因为$host经过了 Nginx 规范化不会出现大小写干扰也不带端口干扰。一个常见的防止裸 IP 访问的配置server { listen 80 default_server; server_name _; if ($host !~* ^(www\.)?example\.com$) { return 444; } # 其他正常反代逻辑... }这里return 444是 Nginx 特有的“直接断开连接”的方式比return 403更省资源也更能挡住扫描器。但要提醒一句$host虽然经过规范化可它仍然受客户端Host头控制。如果客户端直接伪造Host: www.example.com这个判断照样会被绕过。所以域名校验可以作为一种“防误配置”的手段但不要当成真正的安全防线。真正的访问控制还是要靠鉴权、签名或 WAF。3.2 日志分析$http_host保留原始$host用于归类我在实际项目里通常会把这两个变量同时记到日志里因为它们俩记录的“视角”不一样。log_format main $remote_addr - $remote_user [$time_local] $request $status $body_bytes_sent $http_referer $http_user_agent $host $http_host $proxy_host;我见过不少明明服务器上已经配置了域名但 access log 里域名那栏却是空的情况。原因就是用错了变量有些场景下$server_name只在没有 Host 头时才生效而你用的又是$http_host客户端如果走的是健康检查、IP 直连它可能根本没发Host头日志里当然没有。我的习惯是记录“客户端真实意图”用$http_host但它可能为空或异常记录“应该匹配到哪个虚拟主机”用$host即使 Host 头缺失也能从server_name兜底排查反代连接问题时把$proxy_host也打出来一眼就能看到 Nginx 实际连接的是谁。3.3 动态反向代理$proxy_host等于计算后的上游地址很多人知道proxy_pass后面可以跟变量但不知道$proxy_host在该场景下会怎么变化。看一个例子map $host $backend_addr { default 127.0.0.1:3000; api.example.com 127.0.0.1:3001; static.example.com 127.0.0.1:3002; } server { listen 80; server_name _; location / { proxy_pass http://$backend_addr; } }这里proxy_pass http://$backend_addr;使用了变量Nginx 会先把$backend_addr计算出来比如请求api.example.com时$backend_addr等于127.0.0.1:3001那么此时$proxy_host也等于127.0.0.1:3001。这里要注意proxy_pass直接带上游端口转发后的Host仍然是$proxy_host除非你有proxy_set_header Host $host。很多人以为写了动态proxy_pass就万事大吉结果后端收到的是127.0.0.1:3001然后一脸懵。另外一旦proxy_pass里出现变量Nginx 不会在加载配置时解析上游域名而是在请求时动态解析。如果你写的是域名而不是 IP还会遇到 DNS 解析报错的问题。这个放到下面踩坑部分讲。3.4 重定向和 rewrite按“是否需要端口”来选变量做rewrite或return时到底用$host还是$http_host取决于你最终 URL 里是否要保留原始端口。location /old/ { return 301 http://$host/new/; }这个写法适合标准 80/443 端口。如果线上存在多种访问入口比如http://example.com和http://example.com:8080都能访问那建议保留端口location /old/ { return 301 http://$http_host/new/; }但$http_host本身可能为空所以更健壮的做法是先把缺失情况处理掉if ($http_host ) { return 400; } return 301 http://$http_host/new/;4. 高频踩坑与排查技巧4.1 现象一为什么我拿到的$host是 IP 而不是域名如果你访问 Nginx 时用的是 IP比如http://192.168.1.10/那客户端发的Host头自然就是192.168.1.10$host也就是192.168.1.10。这不是 Nginx 的问题而是你请求的“目标域名”本身就是 IP。还有一种情况你配置了server_name api.example.com但是用curl -H Host: whatever http://127.0.0.1/去测试此时$host会跟着Host头变成whatever依旧不是api.example.com。因为没有走域名解析Nginx 能看到的就只有 Host 头。排查建议先用curl -v查看实际发出的Host头再看 Nginx 日志里$host的值这样能快速判断到底哪一层出了问题。4.2 现象二上游服务一直说收到的域名不对如果你配置了反代但后端程序里拿到的$_SERVER[HTTP_HOST]或者流量分析工具里看到的域名不是预期值多半就是proxy_set_header Host没有写对。常见的有两种错误不写proxy_set_header Host上游收到了$proxy_host写了proxy_set_header Host $http_host但客户端没带端口上游拿到没有端口的域名导致非默认端口场景下丢失信息。我的建议是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-Host $http_host; proxy_set_header X-Forwarded-Proto $scheme;这样上游拿到的“基础域名”是$host完整原始 Host 通过X-Forwarded-Host透传。既保证了通用性又不丢原始信息。4.3 现象三proxy_pass用了变量后报no resolver definedproxy_pass一旦用了变量Nginx 就无法在配置加载阶段解析上游地址必须依赖运行时 DNS 解析。如果proxy_pass后面写的是域名而配置里没有resolver就会报类似no resolver defined to resolve xxx.example.com解决办法有两个第一种在 Nginx 配置里加上 DNS 解析器resolver 127.0.0.53 valid10s;第二种尽量在map或upstream里把域名转成 IP。但要注意如果上游域名经常变化用 IP 就不灵活了所以实际项目里加resolver更常见。另外用变量时proxy_pass后面不能带 URI路径部分比如写成proxy_pass http://$backend/;这种会被 Nginx 拒绝或产生其他问题。建议要么不加路径要么在location外用 rewrite 处理路径。4.4 现象四$http_host被“清洗”了吗为什么和请求头不一样$http_host按理说应该等于原始的Host请求头但有一种情况会不一致Nginx 对请求头有自己的解析规范如果Host头包含非法字符Nginx 可能会直接拒绝请求根本到不了变量取值的阶段。如果你是通过curl -H Host: example.com:abc测试Nginx 很可能会报 400 错误。这不算变量问题而是 HTTP 协议本身不允许非法端口。排查时只要记住$http_host是“合法请求里的 Host 头”非法请求在更早阶段就被拦掉了。5. 一份可以直接参考的 server 模板5.1 基础反向代理模板下面是我在实际环境里经常用的一个模板兼顾了通用性、日志可排查性和端口传递问题。log_format proxy $remote_addr [$time_local] $http_host $host $proxy_host $request $status $body_bytes_sent $http_referer $http_user_agent; server { listen 80; server_name api.example.com; # 如果不希望被裸 IP 直接访问可以用 default_server 单独拦 if ($host ! api.example.com) { return 444; } location / { proxy_pass http://127.0.0.1:3000; 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-Host $http_host; proxy_set_header X-Forwarded-Proto $scheme; proxy_connect_timeout 5s; proxy_read_timeout 30s; proxy_send_timeout 30s; } access_log /var/log/nginx/api-access.log proxy; }这个模板里最值得注意的就是X-Forwarded-Host $http_host如果你需要让后端知道客户端当初访问的完整 Host含端口就靠它了。5.2 验证配置和观察变量写完配置后先别急着重载用下面的步骤检查nginx -t确认没问题后nginx -s reload然后发一个带端口的测试请求curl -H Host: api.example.com:8080 http://127.0.0.1/test再看日志tail -f /var/log/nginx/api-access.log如果日志里$http_host输出api.example.com:8080$host输出api.example.com$proxy_host输出127.0.0.1:3000说明三个变量的区分你已经完全掌握了。5.3 我个人习惯总结使用这三个变量我在项目里定了几条规矩做域名判断、路由分发、虚拟主机匹配一律用$host需要还原用户的原始访问地址用$http_host同时配合X-Forwarded-Host传给后端排查反代连接问题、确认上游地址时看$proxy_host只要配置了proxy_pass就显式写proxy_set_header Host $host绝不依赖默认行为遇到端口相关重定向死循环回头检查是不是用了$host拼 URL。踩过几次坑之后我现在写 Nginx 配置时会把日志里的三个变量全部打出来。刚开始觉得冗余后来发现排查问题的时候比任何文档都直观。你可以先照这个方式用一段时间等熟悉了每个变量的“性格”再按业务需要精简。