为什么你的 Nginx 日志里全是「-」:真实 IP 还原的三层链路拆解
打开 /var/log/nginx/access.log,第一列是 $remote_addr。如果你的网站上过 CDN、或者前面挂了负载均衡,这一列大概率清一色是 10.x、172.16.x 之类的内网地址,甚至干脆就是一串 -。日志分析、封禁恶意 IP、防盗链、WAF 规则——所有依赖真实客户端 IP 的功能,在这一刻全部失效。
这不是一个「加一行配置」就能解决的问题。真实 IP 还原是一条三层链路:CDN 是否透传 → Nginx 是否信任并解析 → 应用是否读到正确的值。三层里任何一层断了,你拿到的都是错的,而且错得很安静:不会报错,只会默默记下代理 IP。本文把这三层拆开讲清楚,并覆盖几个最常踩的坑。
第一层:CDN 到底把真实 IP 放在了哪
先把概念理清楚。客户端访问你的网站,实际链路是:
浏览器(1.2.3.4) → CDN 边缘节点(203.0.113.5) → 你的源站 Nginx源站看到的 TCP 对端永远是 203.0.113.5,也就是 CDN 节点。真实 IP 1.2.3.4 只能通过 HTTP 头「带」过来。主流厂商用的是这些头:
- X-Forwarded-For(XFF):事实标准,格式是
客户端IP, 代理1IP, 代理2IP,逗号分隔,最左边是原始客户端。但它可以被客户端伪造,不能无条件相信。 - X-Real-IP:Nginx 社区约定的单值头,只放一个 IP。很多 CDN 会同时发 XFF 和 X-Real-IP。
- CF-Connecting-IP:Cloudflare 专用,单值,且 Cloudflare 会覆盖客户端自带的同名头,相对可信。
- True-Client-IP:Akamai / Cloudflare Enterprise 使用。
- Forwarded:RFC 7239 标准头,格式
for=1.2.3.4;proto=https,但普及度远不如 XFF。
问题在于:这些头不是所有 CDN 都默认发。以部分国内 CDN 为例,默认只回源不带 XFF,需要在控制台手动开启「携带真实 IP」或「X-Forwarded-For」选项。你兴冲冲地在 Nginx 里配好了 real_ip_header X-Forwarded-For,结果发现日志还是 CDN 的 IP——先去 CDN 控制台确认透传开关开了没有。
用 curl 直接验证 CDN 发了什么头
别猜,直接问。构造一个请求,看源站收到了什么:
# 方法一:源码临时加一个 echo location,把请求头与变量直接返回给客户端
location = /whoami {
default_type text/plain;
return 200 "remote_addr: $remote_addr | XFF: $http_x_forwarded_for | XRealIP: $http_x_real_ip | CF: $http_cf_connecting_ip";
}
# 然后从外部发起请求查看返回
curl -s https://你的域名/whoami?_=$RANDOM
# 方法二:源站直接抓包看 HTTP 明文请求头(仅适用于 http 明文)
tcpdump -A -s 0 -n -i eth0 'tcp port 80' -c 5如果返回结果里 XFF 那一段是空的,说明 CDN 根本没透传,后面所有 Nginx 配置都是白写的。这一步是整个排查链条的起点,务必先做、先确认。另外记得给 whoami 这个 location 加上访问限制或事后删除,避免长期暴露服务器内部变量信息给外部探测。
第二层:Nginx ngx_http_realip_module 的正确用法
确认 CDN 有透传之后,才是 Nginx 的活。这块由 ngx_http_realip_module 负责,编译时默认包含。核心只有两个指令:
# /etc/nginx/conf.d/realip.conf
# 1) 告诉 Nginx 从哪个头里取真实 IP
set_real_ip_from 203.0.113.0/24; # CDN 回源 IP 段,必须精确列出
set_real_ip_from 198.51.100.0/24; # 可以多条
real_ip_header X-Forwarded-For;
# 2) 关键:从 XFF 的哪一端开始取
real_ip_recursive on; # on = 从右往左跳过所有可信代理,取第一个不可信 IP
real_ip_recursive 这个开关是整件事的分水岭,90% 的人配错在这里。
recursive off(默认)到底取哪个
假设 XFF 是:1.2.3.4, 203.0.113.5, 203.0.113.6(客户端伪造了 1.2.3.4,然后经过两个 CDN 节点)。
- recursive off:Nginx 直接取 XFF 最右边 的值,即
203.0.113.6。这通常还是 CDN 节点 IP,等于没还原。 - recursive on:Nginx 从最右边开始,逐个检查 IP 是否命中
set_real_ip_from的白名单。203.0.113.6 可信 → 跳过;203.0.113.5 可信 → 跳过;1.2.3.4 不在白名单 → 取它。
所以:前面的代理超过一跳,就必须开 recursive on,否则你取到的永远是最后一跳代理。单跳 CDN(比如 Cloudflare 直连源站)时两者结果相同,这也是很多人以为「配了就对了」的假象来源。
set_real_ip_from 必须精确,不能用 0.0.0.0/0
网上有种偷懒写法:set_real_ip_from 0.0.0.0/0;。这等于信任任何人发来的 XFF。攻击者可以随便伪造 X-Forwarded-For: 1.1.1.1,你的日志、封禁规则、限流全部被绕过。正确做法是把 CDN 官方公布的回源 IP 段全部列出来。
Cloudflare 的段可以从 API 自动拉取(因为会变,建议做成定时任务):
#!/bin/bash
# /etc/cron.daily/cf-realip.sh
CONF=/etc/nginx/conf.d/realip_cf.conf
{
echo "# auto-generated $(date -Is)"
echo "real_ip_header CF-Connecting-IP;"
curl -s https://api.cloudflare.com/client/v4/ips | \
python3 -c 'import sys,json;d=json.load(sys.stdin)["result"];[print("set_real_ip_from",ip+";") for ip in d["ipv4_cidrs"]+d["ipv6_cidrs"]]'
} > "$CONF.tmp" && mv "$CONF.tmp" "$CONF"
nginx -t && systemctl reload nginx注意 Cloudflare 场景下用 CF-Connecting-IP 比 XFF 更省心:它是单值头,Cloudflare 保证覆盖客户端伪造值,不需要开 recursive。但如果你的 CDN 不支持这个头,就老老实实用 XFF + recursive on。
第三层:应用层为什么还是读不到
Nginx 日志里的 IP 已经正确了,很多人的工作就到此为止。但如果你跑的是 PHP、Node、Go 应用,会发现后端拿到的仍然是代理 IP。原因是:realip 模块只改了 Nginx 自己的变量($remote_addr、$binary_remote_addr),不会自动改 FastCGI / 反向代理传给上游的头。换句话说,Nginx 自己「知道」真实 IP 了,但它不一定把这件事「告诉」后面的应用。
这里还有一个容易被忽略的细节:$binary_remote_addr 是二进制格式的客户端地址,体积比字符串形式小,常用于 limit_req、limit_conn 的 key。realip 生效时它同样会被改写,所以按 IP 限流时不用担心限到了 CDN 节点上——但前提依然是你的 set_real_ip_from 配置正确,否则所有用户的限流 key 都会变成同一个 CDN 节点 IP,等于给全站上了个全局限流,正常用户也会被误伤。
根据上游协议分别处理:
PHP-FPM(FastCGI)
PHP 里 $_SERVER['REMOTE_ADDR'] 来自 FastCGI 的 REMOTE_ADDR 参数,它由 fastcgi_params 的 fastcgi_param REMOTE_ADDR $remote_addr; 决定。因为 $remote_addr 已被 realip 改写,这一步通常是自动生效的——前提是你的配置里用的是 $remote_addr 而不是硬编码的 $http_x_real_ip。检查一下:
# 确认这两行存在且用 $remote_addr
# /etc/nginx/fastcgi_params 或站点配置内
fastcgi_param REMOTE_ADDR $remote_addr;
fastcgi_param HTTP_X_FORWARDED_FOR $http_x_forwarded_for; # 如需原样透传再传给 PHP
反向代理到 Node/Go
用 proxy_pass 时,Nginx 默认不重写 XFF,直接把客户端发来的头原样转发。这会导致两个问题:上游看到的 XFF 头是 CDN 节点那一层,且可能被污染。正确写法是显式设置:
location / {
proxy_pass http://127.0.0.1:3000;
# 追加真实 IP($remote_addr 已被 realip 修正)
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 Host $host;
}
$proxy_add_x_forwarded_for 的语义是「把 $remote_addr 追加到已有的 XFF 后面」。注意它不会清理客户端伪造的部分,所以上游如果盲目取 XFF 最左边的值仍有风险。稳妥做法是让上游只信任 X-Real-IP(单值、由 Nginx 写入)。
「改完没生效」的四步排查
配置全部写对了还是不生效?按这个顺序排:
- nginx -t 后是否 reload 了?
systemctl reload nginx(不是 restart 也不是 start)。realip 指令属于 http/server 层,reload 即可生效。 - realip 有没有被同名的其他 server 块覆盖?
real_ip_header的作用域是 http/server/location。如果某个location里又写了一次不同的值,会覆盖外层。用nginx -T打印合并后的完整配置,全文搜索real_ip看有没有冲突。 - CDN 回源段是不是变了? CDN 会新增/下线节点 IP。如果你把段写死在配置里又没更新,新节点来的请求就不被信任,取到的还是节点 IP。做成上面那种 cron 自动更新最省事。
- 日志格式用的是哪个变量? 自定义
log_format时,有人写了$http_x_forwarded_for而不是$remote_addr。前者是原始头(一整串逗号分隔的 IP),后者才是 realip 修正后的单一 IP。日志列显示一串 IP 或者空值,多半就是这个。
验证:拿一个真实请求跑一遍完整链路
配置完不要靠感觉,直接构造请求验证:
# 1) 伪造一个 XFF,从外部直接打你的源站(若源站没做 IP 白名单)
# 假设源站 IP 是 203.0.113.10
curl -s -H 'X-Forwarded-For: 9.9.9.9' http://203.0.113.10/whoami
# 期望:如果 9.9.9.9 不在 set_real_ip_from 里,且 recursive on,
# $remote_addr 应变成 9.9.9.9(前提是你自己也不在白名单)
# 若返回的还是你的真实出口 IP,说明 realip 没生效
# 2) 走 CDN 的真实访问
curl -s https://你的域名/whoami
# 期望:返回你本机的公网 IP
# 3) 看日志确认
tail -f /var/log/nginx/access.log
一个快速判断配置是否加载的技巧:故意把 set_real_ip_from 写一个不存在的段然后 nginx -t,如果语法报错说明这个文件真的被 include 了;确认无误后再改回来。比盲猜靠谱。
日志格式:让真实 IP 真正可读、可用
最后一件事:即使 realip 生效了,默认的 combined 日志格式也未必能满足你的分析需求。默认格式第一列是 $remote_addr(已被改写为真实 IP),这没问题,但很多深度分析需要同时知道「客户端真实 IP」和「回源经过的 CDN 节点」,用于区分「到底是用户直接访问还是经过 CDN」。可以在自定义 log_format 里同时记录两者:
log_format realip_log '$remote_addr - $remote_user [$time_local] '
'"$request" $status $body_bytes_sent '
'"$http_referer" "$http_user_agent" '
'xff="$http_x_forwarded_for" rt=$request_time';
access_log /var/log/nginx/access.log realip_log;
这里的 $remote_addr 是 realip 还原后的真实客户端 IP,而 $http_x_forwarded_for 保留原始头的完整内容(可能含多个 IP 和伪造值),二者对照,就能判断链路是否异常。再配合 rt=$request_time 记录响应耗时,后续做慢请求分析时不用再另外加字段。养成这个习惯,日志的价值会翻倍。
小结
真实 IP 还原是一条链,不是一行配置。记住三句话:先确认 CDN 透传了头,再让 Nginx 用 recursive on 从可信段里剥出客户端 IP,最后按上游协议把结果传下去。排查永远从「CDN 到底发了什么」这个最上游的问题开始,而不是先怀疑 Nginx。日志里那串 - 和代理 IP,本质上是在提醒你三层的某一层断了——定位到是哪一层,问题就解决一半了。
最后补充一个心态上的提醒:真实 IP 还原这类基础配置,配好之后往往「一劳永逸」好几年不动,但 CDN 厂商的 IP 段是会变的。今天配对了,不代表半年后还对。把它做成自动更新 + 定期人工复核的机制,才是长期可靠的做法。很多「突然某天限流失效了」「突然某天日志全是节点 IP 了」的怪事,根子都在这里。