1. 私有云K8s里的“IP谜局”:为什么Nginx看到的总是内网地址
先描述一个我常遇到的场景:私有云K8s集群部署好之后,前面架了一台HAProxy做统一公网入口,业务跑在Nginx Ingress和一堆后端Pod里。上线前功能全通,上线后业务方跑来提了个很微妙的问题——访问日志里看到的客户端IP全是内网地址,要么是某个Node节点IP,要么是Pod网段的地址,审计没法做,限流也全乱了。
这个问题为什么会存在,得从流量路径说起。通常一个私有云K8s集群的访问链路是:客户端 → 边界HAProxy → K8s任意Node的NodePort → 调度到某个Ingress Controller Pod → 再转发给业务Pod。链路里有两个“断点”。
第一个断点在HAProxy到NodePort这一段。如果HAProxy用的是mode tcp四层转发,它只负责把TCP流原封不动地搬到后端端口,既不解析HTTP头,也不往里面塞任何客户端信息。TCP流到了NodePort,Kube-proxy还要做一次DNAT/SNAT,把数据包转发给Ingress Controller Pod。于是Ingress收到的TCP连接,源地址已经是Node节点的内网IP了。更麻烦的是,如果集群用了多网卡、多个Node做NodePort,你甚至可能看到源地址是某一台机器上的VIP或网桥地址。
第二个断点在Ingress Controller到业务Pod这一段。Nginx Ingress本身是个反向代理,它接收到请求后,会用一个新的TCP连接把请求转发给后端业务Pod。这个新连接的源地址是Ingress Pod自己的IP。业务应用如果不做任何处理,直接读Nginx变量里的$remote_addr,拿到的就是Ingress Pod网段里的某个地址,和真实客户端毫无关系。
有不少人第一反应是“那我去读HTTP头里的X-Forwarded-For不就行了”。这个想法方向对,但实操里坑很多。X-Forwarded-For是标准HTTP头,可它有两个致命问题:第一,它能不能出现、值是什么,完全取决于链路上的代理有没有主动写;如果整个链路都是四层转发,这个头可能压根不存在。第二,它是可以被客户端伪造的。随便一个懂HTTP的人,都能在请求里预先塞一个假的X-Forwarded-For: 1.2.3.4,如果你没有任何信任链校验就信了这个值,等于把访问控制和安全审计直接暴露给攻击者。
所以解决这件事的完整思路不是“在应用里读某个头”,而是:从链路入口开始,用协议机制把真实客户端IP一级一级传递下来,每一级都只信任自己上游那个代理,最终形成一条完整的信任链。有一个专门干这事的协议,叫PROXY Protocol,最早是负载均衡软件社区搞出来的,现在HAProxy和Nginx都原生支持。它的原理是在TCP连接建立之后、正式业务数据发送之前,先插一段特殊格式的头部,里面写明这条连接的真实源IP、源端口、目标IP、目标端口。四层转发因此也能携带原始客户端地址了。
整个链路再加上IP透传之后,大致长这样:
客户端浏览器 │ ▼ 公司边界HAProxy(监听80/443) │ L4 TCP流 + PROXY Protocol帧 ▼ K8s任意Node的NodePort(如30080/30443) │ Kube-proxy调度转发 ▼ Nginx Ingress Controller Pod(解析PROXY帧,重写remote_addr) │ HTTP请求 + X-Forwarded-For ▼ 业务应用Pod(读取remote_addr或XFF)下面每一层要做什么、怎么配、怎么验证,我按实战顺序拆开讲。
2. 先从HAProxy动手:四层透传和七层改写怎么选
2.1 方案A:四层TCP + PROXY Protocol,推荐的大头方案
大部分私有云场景,我建议把HAProxy作为纯粹的TCP负载均衡器来用,业务侧的七层能力全交给Nginx Ingress。这样HAProxy不解析HTTP、不终止TLS、不做任何应用层判断,转发性能最高,也最稳定。
在HAProxy上的配置大概是这样的。假设后端两个Node节点,NodePort选了30080和30443:
frontend fe_k8s_http bind *:80 mode tcp option tcplog default_backend be_k8s_ingress_http backend be_k8s_ingress_http mode tcp balance roundrobin server k8s-node1 192.168.1.11:30080 send-proxy-v2 check inter 3s rise 2 fall 3 server k8s-node2 192.168.1.12:30080 send-proxy-v2 check inter 3s rise 2 fall 3frontend fe_k8s_https bind *:443 mode tcp option tcplog default_backend be_k8s_ingress_https backend be_k8s_ingress_https mode tcp balance roundrobin server k8s-node1 192.168.1.11:30443 send-proxy-v2 check inter 3s rise 2 fall 3 server k8s-node2 192.168.1.12:30443 send-proxy-v2 check inter 3s rise 2 fall 3关键就在send-proxy-v2这个参数上。它表示HAProxy向本后端转发TCP流时,要在业务数据前插入一个PROXY Protocol v2二进制帧,里面带这条连接的真实源IP和端口。
2.2 send-proxy和send-proxy-v2到底差在哪
这里插一个容易踩坑的细节。HAProxy里有两个非常像的参数:send-proxy和send-proxy-v2。前者发送PROXY v1文本帧,后者发送PROXY v2二进制帧。
v1长什么样?是一条ASCII文本,开头是PROXY TCP4 源IP 目标IP 源端口 目标端口\r\n这样的可读格式。v2则是二进制,专门设计成机器可读,开头有固定的魔数标记,解析效率更高,也能携带更多TLV扩展信息。Nginx 1.9.2以后两者都支持,所以大多数情况下用哪个都不会出错。
我的习惯是统一用v2。原因很实际:私有云里这套链路已经够多了,每次抓包排查都靠肉眼去看文本帧太累,v2结构规范、解析明确,两侧版本朝一个方向对齐,以后升级维护少一事。
2.3 方案B:七层HTTP + X-Forwarded-For的适用场景
也有一些情况需要让HAProxy直接做七层。比如边界就要按域名分流、要做简单的ACL放行,或者集群里不用Nginx Ingress,而是一组独立Nginx实例直接对外。这种时候四层的PROXY Protocol链路就没法直接用,因为HAProxy自己已经消费了HTTP流,你需要在转发时主动注入客户端信息。
HAProxy七层配置可以这样:
frontend fe_k8s_http bind *:80 mode http option httplog http-request set-header X-Real-IP %[src] http-request set-header X-Forwarded-For %[src] default_backend be_k8s_ingress_http backend be_k8s_ingress_http mode http server k8s-node1 192.168.1.11:30080 check inter 3s rise 2 fall 3 server k8s-node2 192.168.1.12:30080 check inter 3s rise 2 fall 3这里用%[src]直接把HAProxy看到的客户端源IP写进X-Real-IP和X-Forwarded-For。但注意,X-Forwarded-For的值会被Nginx后面用$proxy_add_x_forwarded_for继续追加。如果你不加处理,TLS终止时、反复代理时,这个头里的地址会越来越长、越叠越乱,解析时就得多做一层清洗。
还有另一个细节:如果你在HAProxy七层改写里只是简单set-header,会直接覆盖客户端原本可能带的XFF头。这有时是好事(防伪造),但如果你还需要原始XFF做链路分析,就得换写法。我不能给出一个普适结论,只能说,生产环境一般按“只相信边界代理注入的那一个值”来设计最稳妥。
2.4 两种方案怎么选,给个参考表
| 维度 | 四层TCP + PROXY Protocol | 七层HTTP + XFF改写 |
|---|---|---|
| HAProxy转发性能 | 高,不解析HTTP | 低一些,每个连接都要解析应用层 |
| TLS终止位置 | 在Nginx Ingress | 可放在HAProxy,也可放Ingress |
| 真实IP准确性 | 很高,帧内明确携带源IP | 高,但依赖链路一致且信任边界清晰 |
| 配置复杂度 | 中,HAProxy和Ingress都要开 | 中低,但XFF清洗麻烦 |
| 典型适用场景 | 私有云K8s + Nginx Ingress,大部分生产 | 边界已有L7需求、非Ingress的Nginx集群 |
我自己的经验是:一旦决定未来由Nginx Ingress统一负责七层逻辑,就直奔方案A。一次把HAProxy改成mode tcp + send-proxy-v2,后面所有服务都省心。
3. Nginx Ingress层配置:listen proxy_protocol与信任边界
3.1 让Ingress Controller接收PROXY帧
HAProxy改完只是发出去,接收侧不认账等于白干。Nginx Ingress Controller默认监听80端口时不会解析PROXY帧,你不改,它会把这帧当作普通HTTP请求来解析,直接报400或者挂断连接。这就是为什么不少人改完HAProxy后马上看到端口全线Down。
需要改的是Ingress Controller的启动ConfigMap。如果通过Helm安装,最方便的是在values里写入:
controller: config: proxy-protocol: "true" set-real-ip-from: "192.168.0.0/16,10.0.0.0/8,172.16.0.0/12"如果你已经用kubectl裸部署,就编辑对应的ConfigMap,名字一般是ingress-nginx-controller,位于ingress-nginx命名空间:
kubectl -n ingress-nginx edit configmap ingress-nginx-controller在那里的data段加上:
data: proxy-protocol: "true" set-real-ip-from: "192.168.0.0/16,10.0.0.0/8,172.16.0.0/12"数据里的proxy-protocol: "true"会让Nginx在listen指令里加上proxy_protocol参数。set-real-ip-from则声明:只有当连接源地址处于这些内网网段时,才解析PROXY帧并把其中携带的客户端IP视为真实IP。这两个参数必须在一起,否则你等于无条件信任所有连接上来的客户端都能随便自报IP。
改完之后验证一下是否真的生效。执行:
kubectl -n ingress-nginx exec deploy/ingress-nginx-controller -- cat /etc/nginx/nginx.conf | grep -A 10 "listen 80"如果看到类似:
server { listen 80 proxy_protocol;那就说明listen层已经挂上了。
3.2 信任链的坑:use-forwarded-headers到底该不该开
这里必须展开讲一个很多人搞混的配置项:use-forwarded-headers。
Nginx Ingress Controller的ConfigMap里经常被建议加use-forwarded-headers: "true",它的作用是让Nginx把请求头里的X-Forwarded-For当作客户端真实IP来源,并更新$remote_addr。在“前端是一台七层云负载均衡,且那台设备已经重写了XFF”的场景下,这是合理的。
但在HAProxy四层+PROXY Protocol这套链路里,我非常不建议随便开这个开关。原因很直接:如果开启,Nginx会优先根据传入请求里的XFF来重置remote_addr,而XFF是可以由客户端自己预埋伪造值的。虽然PROXY帧里带了真实IP,但XFF优先参与解析之后,真实IP反而容易被假值盖掉。你辛辛苦苦把信任链建好,最后却主动把最不可信的一个头当成依据,等于安全防线白做。
所以我的生产推荐组合是:
- HAProxy侧:四层TCP +
send-proxy-v2 - Ingress侧:
proxy-protocol: "true"+set-real-ip-from: "内网网段" use-forwarded-headers:不开启,或仅在你能确认边界HAProxy已经用七层方式重写XFF时才开启
这套组合下,真实来源信息由PROXY帧承担,Nginx的$remote_addr会被重置为客户端公网IP。而在继续向后端转发时,Nginx默认会为上游连接生成或追加X-Forwarded-For头,里面带上这个真实IP,所以业务Pod依然有标准头可读。
3.3 业务Pod侧应该读什么
到了业务应用这一层,很多人又会纠结:到底读$remote_addr还是读X-Forwarded-For?
我一般建议业务统一读标准头X-Forwarded-For,并且按“从右往左取第一个非可信内网地址”的规则解析。因为Nginx Ingress在向业务转发时,会在已有的XFF基础上追加它看到的remote_addr,而这个remote_addr经PROXY帧重置后就是客户端真实IP。规整的链路里,XFF最后一段通常就是客户端。直接读$remote_addr在以后链路变动、插一层代理时又会拿到内网地址,编码层面要尽量避免这种“源地址硬绑定”。
4. 端到端验证:让IP从HAProxy一路亮到业务日志
配置改完,别急着上线。我建议先做一轮端到端验证,确认每一级拿到的IP确实对。验证方法很多,我按成本从低到高说。
4.1 第一种:从业务Pod日志验证
先找一个后端业务,比如典型的Nginx应用,确认它的访问日志格式里有$remote_addr。然后从公网侧发起一个请求,比如在外部机器上执行:
curl -k https://svc.example.com/ --resolve svc.example.com:443:你的HAProxy公网VIP发起后立刻去看业务Pod日志:
kubectl logs -n <命名空间> deploy/<业务Deployment> --tail=20如果链路配置正确,你会看到类似:
192.0.2.100 - - [17/Feb/2025:10:20:33 +0800] "GET / HTTP/1.1" 200 612 "-" "curl/8.0"这里的192.0.2.100就是你测试客户端的公网IP。在透传之前,这个位置大概率是一段10.244.x.x或192.168.x.x的内网地址。看到公网IP出现,说明链路已经通了。
4.2 第二种:查看Nginx Ingress Controller日志
Ingress Controller自己的访问日志也可以验证。执行:
kubectl -n ingress-nginx logs -l app.kubernetes.io/name=ingress-nginx --tail=50里面同样会把remote_addr打进日志。注意Ingress Controller的日志默认包含一堆健康检查、404探测,建议用请求路径过滤一下,或者加一个带唯一参数的路径来定位。
4.3 第三种:抓包确认PROXY帧
如果前面两种发现IP没透传成功,抓包是最快的排障方式。在Ingress Controller Pod里抓NodePort对应的端口,比如:
kubectl -n ingress-nginx exec deploy/ingress-nginx-controller -- tcpdump -ni eth0 'tcp port 80' -c 5如果HAProxy正常发出PROXY帧,你会看到TCP流里第一条数据不是GET / HTTP/1.1,而是PROXY协议特有的帧头。v1文本帧会以PROXY开头,v2二进制帧开头有\x0D\x0A加QUIT之类的魔数。看到这些,说明HAProxy确实发出、Ingress也确实收到了。
如果只看到GET / HTTP/1.1而前面没有PROXY帧,基本可以断定HAProxy那侧配置没生效,或者后端端口指错了。
4.4 用回显服务做一次干净验证
我习惯在集群里临时起一个极简回显服务,直接返回HTTP头里传入的源信息,这样能快速排除业务代码干扰。思路是部署一个小应用,在响应里输出Node环境变量和请求头。也可以用现成的httpbin镜像,起一个Deployment、Service、Ingress,然后从公网访问并观察返回体里的头信息。
步骤是:部署回显服务 → 从公网curl访问 → 在回显结果里检查X-Forwarded-For的最后一个值。如果它不是内网地址而是你的公网IP,整个链路已经验证通过。这种一次性的验证服务很值得维护成标准做法,以后每次改负载均衡配置,都能先拿它探路。
5. 线上踩过的坑与生产配置建议
5.1 只改一头,另一头还在等普通HTTP
最常见的事故是:HAProxy侧已经加上了send-proxy-v2,但Ingress Controller的ConfigMap没改,或者改完没有生效。结果就是Nginx本来在等一个HTTP请求头,结果先收到了一段PROXY开头的帧,直接判定为非法请求,连HTTP协议解析都过不去,日志里时不时出现端口Down、502/400。
排查方法很简单,第一件事永远是看Ingress Controller的listen配置里有没有proxy_protocol。有就往HAProxy方向确认,没有就先改Ingress侧。
5.2 健康检查通道被PROXY帧冲垮
HAProxy后端默认会做健康检查,普通的TCP check是建立连接就断开,不会发PROXY帧。但Nginx Ingress既然开启了proxy_protocol,它会在每次TCP连接建立后等待PROXY帧,如果等不到就判定异常,健康检查自然全军覆没。表现是HAProxy面板里后端全红,但用浏览器访问有时又能通。
我的做法是:健康检查不直接探测NodePort的80/443,而是探测Nginx Ingress Controller自带的管理端口10254。在HAProxy里给后端单独定义一条健康检查,比如:
server k8s-node1 192.168.1.11:30080 send-proxy-v2 check port 10254 inter 3s rise 2 fall 3这样检查端口与业务转发端口分离,业务流走80换80,健康探针走10254,两不干扰。
5.3 多层代理时信任链被折叠
有些环境里,HAProxy前面还有一台云平台自带的负载均衡,或者是机房硬件负载均衡。这种多层代理下,如果你只在HAProxy上配了PROXY协议,上层设备不认识,根本没把客户端IP传下来,你在HAProxy里看到的源地址就是上层设备的内网地址,后面的一切都建立在错误源头上。
要先确认:最外层设备支不支持转发PROXY协议。支持,就逐层配置,每层都要声明信任和启用协议;不支持,就只能退到七层XFF方案,在HAProxy里强制set-header注入XFF,同时保证最外层设备也覆盖XFF。最怕的是每层都在转发,但谁都没真正把真实客户端IP写进去,最后一层只是在给自己编故事。
5.4 禁止公网直接访问NodePort
这套链路成立的前提是“只有你信任的HAProxy能连到NodePort”。如果NodePort直接暴露在公网上,任何绕过HAProxy的请求都能直连Ingress,而且你还开启了proxy-protocol,那一个聪明的攻击者完全可以伪造PROXY帧来伪装任意IP。所以云主机安全组、机房防火墙里一定要限制:NodePort只允许HAProxy所在网段访问,绝不暴露公网。这是整条链路的底线。
5.5 配置变更顺序与回滚预案
改这种跨节点的链路配置,最忌讳一下子全量上。我踩过一次最疼的坑是在业务高峰期同时刷新HAProxy和Ingress配置,结果流量涌入的瞬间后端大量5xx。后来形成习惯:先改Ingress侧让它可以接收PROXY帧,再用HAProxy的disable server逐个摘掉后端,验证一轮没问题再加回去。每一步控制在分钟级,出问题随时回滚。
说到底,IP透传这件事并不难,难的是链路里每一层都对“自己是第几个可信代理、下一层该相信什么”有共识。PROXY Protocol解决的是传输层携带源IP的问题,XFF解决的是应用层携带源IP的问题,两者配合起来,再从HAProxy到Ingress到业务Pod逐级验证一次,后面就不用再为“为什么日志全是内网IP”头疼了。