一、背景
1. 业务上云带来性能收益
公司从去年全面推动业务上云,而以往 IDC 架构部署上,接入层采用典型的 4 层 LVS 多机房容灾架构,在业务高峰时期,扩容困难(受限于物理机资源和 LVS 内网网段的网络规划),且抵挡不住 HTTPS 卸载引发的高 CPU 占用。
而经过压力测试发现,使用腾讯云 7 层 CLB 负载均衡进行 HTTPS 卸载,性能得到极大提升。测试数据也表明,IDC 旧架构中,启用 HTTPS 会带来 90% 以上的性能损耗。
2. 架构调整引发多次故障
引入腾讯云 7 层 CLB 负载均衡产品,带了了巨大的性能提升,却也给业务带来了痛苦,主要核心问题是获取客户端的真实 IP 上。
当前现状是业务语言异构(PHP + Go),多数业务已经历服务化改造,但缺乏服务发现机制,服务与服务之间的调用依赖域名和 DNS 解析,大部分都是 HTTP 服务。
在架构调整后,由于未能 100% 覆盖测试,导致漏测的服务经常拿到错误的客户端 IP 地址,造成的后果是损失大量的用户。这些用户会因为短信验证码发送限制、IP 登录频次过高而无法登录、充值,给公司带来巨大损失。
3. 未来的路应该怎么走?
更进一步讲,当前业务如何抵挡外界的 DDoS 攻击、请求机器人、SQL 注入等等,最简单的是接入高防 IP、WAF 应用防火墙,而请求经过多轮转发,同样也有获取客户端真实 IP 的问题。
再者,业务也在逐步容器化,享受 Kubernetes 弹性扩容的便利,怎么平滑迁移也是非常值得深思的。
假设有一天某个同学,不小心配置有误——应用层拿到的,很有可能是高防 IP 或者 WAF 的 IP,业务绝对无法忍受。
显然,确定一个业务无感知的方案并成功落地迫在眉睫。
然而翻遍整个互联网,几乎没有文章能把这些看起来很简单的事情捋清楚、讲明白,更不用说最佳实践。
大多数人都是抄抄配置,潦潦草草上线,方案并没有普适性。
这篇文章也是我在这段时间的研究中总结出来的宝贵经验,希望对读者能有些许帮助。文章篇幅较长,难免有错误之处,还请各位看官斧正,感激不尽:)
二、名词释义
1. REMOTE-ADDR
- Nginx + PHP 模式下,REMOTE-ADDR 为远端的 IP 地址,可通过
$_SERVER['REMOTE-ADDR']
获取; - 它代表与上一层建立 TCP 连接的 IP 地址;
- 网站无代理时(客户端->服务端),WEB服务器(Nginx,Apache等)会设置该值为客户端 IP;
- 网站存在代理时(客户端->代理->服务端),该值为代理的 IP。
proxy_set_header REMOTE-ADDR $remote_addr;
2. X-Forwarded-For
X-Forwarded-For 是一个 HTTP 扩展头部。HTTP/1.1(RFC 2616)协议并没有对它的定义,它最开始是由 Squid 这个缓存代理软件引入,用来表示 HTTP 请求端真实 IP。如今它已经成为事实上的标准,被各大 HTTP 代理、负载均衡等转发服务广泛使用,并被写入 RFC 7239(Forwarded HTTP Extension)标准之中。
格式为英文逗号 + 空格隔开,例如:X-Forwarded-For: IP0(client), IP1(proxy), IP2(proxy);
中间经过的代理,会逐层追加至末尾;
IP0 离服务端最远,然后是每一级代理设备的 IP,IP2 直连服务端。
如果客户端伪造 IP 地址,格式为:X-Forwarded-For: 伪造的 IP 地址 1, [伪造的 IP 地址 2...], IP0(client), IP1(proxy), IP2(proxy)。
3. X-Real-IP
注:CLB <=> SLB,为腾讯云和阿里云不同产品的称呼,均为负载均衡。
典型的调用链路:
client --> ① [CLB-7]gateway --域名--> ② [CLB-7]server(③ nginx + ④ go/php)
- X-Real-IP 为建立 TCP 连接的上一跳的 IP 地址;
- 对于 ④ 而言,X-Real-IP 为 ① 网关的 NAT 公网出口 IP 地址,或 gateway 的内网 IP 地址,该结论通过生产环境 tcpdump 抓包验证得到;
- 公网调用下,① 网关 调用 ② 7 层 CLB,再到应用层 ③④,此时 ④ 拿到的 X-Real-IP 为 ① 的 NAT 公网出口地址(7 层 CLB 会重写 X-Real-IP 头部,并追加 X-Forwarded-For 头部);
- 内网环境中,原理相似,只不过拿到的是 gateway 的内网 IP 地址;
- 中间可能被 ③ nginx 重写,此时等同于 REMOTE-ADDR。
比如以下最常见的 nginx 配置:
proxy_set_header REMOTE-ADDR $remote_addr;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
REMOTE-ADDR 和 X-Real-IP 都是 nginx 的 $remote_addr 变量,再传递给下游。
三、面临困境
1. 运维侧
- 业务线配置五花八门,没有统一。具体表现在 nginx.conf 和 vhost 配置在不同的业务线有很大区别;
- vhost 成千上万,nginx 内部存在多重转发,外部也有网关转发过来的流量,且网关不止一套,捋不清链路容易导致线上故障;
- 缺乏完善的 QA 验证流程,变更没办法 100% 覆盖测试,最终结果就是尽可能少变更,但这不是长久之计;
- 存在开发自行维护信任 IP 的情况,所以运维不敢随便变更,因为变更前需要通知开发整改,开发有自己的时间排期,处理起来效率极其低下;
- 为了尽可能少修改原先的配置,部分机器组接入了腾讯云的 TOA 模块,用来获取客户端真实 IP 地址,而阿里云没有相似的产品,如果没有统一的方案,没办法上线阿里云,实现不了双云双活的目标等等。
2. 开发侧
各个业务线使用的技术栈不统一,存在多种获取客户端 IP 的方案,需要找到一种尽可能少修改代码,或者一点都不需要修改代码的方案。
PHP 以 Laravel 框架为例(底层是 Symfony 框架),发现内部取了 $_SERVER['REMOTE_ADDR'] 变量:
public function getClientIp()
{
{
mathJaxContainer[0]}this->getClientIps();
return $ipAddresses[0]; // 1. 取第一个 IP 地址。
}
public function getClientIps()
{
{
mathJaxContainer[1]}this->server->get('REMOTE_ADDR');
if (!$this->isFromTrustedProxy()) {
// 2. 程序在这里返回了 REMOTE_ADDR 头部的值。
return [$ip];
}
// 3. 永远到不了这个分支。
return {
mathJaxContainer[2]}ip) ?: [$ip];
}
public function isFromTrustedProxy()
{
// 4. 因为生产环境中,$trustedProxies 没有配置。
return self::{
mathJaxContainer[3]}this->server->get('REMOTE_ADDR', ''), self::$trustedProxies);
}
公司内部有些业务自己实现函数,依赖的是 X-Forwarded-For 头部。
Go 以 Gin 框架为例,准确的说是 Gin@v1.6.* 版本,它先取 X-Forwarded-For 的第一个 IP,取不到就取 X-Real-IP 头部:
func (c *Context) ClientIP() string {
// 1. ForwardedByClientIP 默认为 true
if c.engine.ForwardedByClientIP {
// 2. 优先获取 X-Forwarded-For 头部
clientIP := c.requestHeader("X-Forwarded-For")
// 3. 取 X-Forwarded-For 的第一个 IP 地址
clientIP = strings.TrimSpace(strings.Split(clientIP, ",")[0])
// 4. 取不到就取 X-Real-Ip 字段
if clientIP == "" {
clientIP = strings.TrimSpace(c.requestHeader("X-Real-Ip"))
}
// 5. 拿到了就直接返回(正常的逻辑)
if clientIP != "" {
return clientIP
}
}
// 6. 忽略,该值为 false,除非 build tags 包含 appengine 为 true
if c.engine.AppEngine {
if addr := c.requestHeader("X-Appengine-Remote-Addr"); addr != "" {
return addr
}
}
// 7. 以上都取不到的话,取 RemoteAddr 字段,走到这个逻辑,程序肯定不正常。
// 参考 Go 标准库,该值为 TCP 建立连接的远端 IP 地址
// go1.17.1/src/net/http/server.go:1003
// req.RemoteAddr = *conn.remoteAddr
if ip, _, err := net.SplitHostPort(strings.TrimSpace(c.Request.RemoteAddr)); err == nil {
return ip
}
return ""
}
经过调研发现,业务取的是 X-Real-IP 字段,具体原因就不展开了。
至于 Gin@1.7.* 版本,由于 Gin@1.6.* 的实现存在伪造客户端 IP 的问题,被爆 CVE-2020-28483 漏洞,官方为了修复这个问题,换了一种实现修复该漏洞:
func (c *Context) ClientIP() string {
// 1. 自定义 Header 的情况,可以忽略
if c.engine.TrustedPlatform != "" {
if addr := c.requestHeader(c.engine.TrustedPlatform); addr != "" {
return addr
}
}
if c.engine.AppEngine {
if addr := c.requestHeader("X-Appengine-Remote-Addr"); addr != "" {
return addr
}
}
// 2. 获取 IP 地址,并返回是否可以信任
remoteIP, trusted := c.RemoteIP()
if remoteIP == nil {
return ""
}
// 3. 如果信任,检查 IP 地址的合法性,合法就返回
// 默认值:ForwardedByClientIP=true,RemoteIPHeaders=[X-Forwarded-For(优先), X-Real-IP]
if trusted && c.engine.ForwardedByClientIP && c.engine.RemoteIPHeaders != nil {
for _, headerName := range c.engine.RemoteIPHeaders {
// c.requestHeader 在头部有效的情况下,也是返回第一个 IP 地址。
ip, valid := validateHeader(c.requestHeader(headerName))
if valid {
return ip
}
}
}
// 4. 不能信任,那就用 TCP 连接远端 IP 兜底。
return remoteIP.String()
}
func (c *Context) RemoteIP() (net.IP, bool) {
ip, _, err := net.SplitHostPort(strings.TrimSpace(c.Request.RemoteAddr))
if err != nil {
return nil, false
}
remoteIP := net.ParseIP(ip)
if remoteIP == nil {
return nil, false
}
// remoteIP = TCP 连接远端 IP 地址
// 由于业务没有配置 engine.TrustedProxies,所以是不可信任的。
return remoteIP, c.engine.isTrustedProxy(remoteIP)
}
func (e *Engine) isTrustedProxy(ip net.IP) bool {
if e.trustedCIDRs != nil {
for _, cidr := range e.trustedCIDRs {
if cidr.Contains(ip) {
return true
}
}
}
// 业务将会走到这里!
return false
}
func (e *Engine) validateHeader(header string) (clientIP string, valid bool) {
if header == "" {
return "", false
}
items := strings.Split(header, ",")
for i := len(items) - 1; i >= 0; i-- {
ipStr := strings.TrimSpace(items[i])
ip := net.ParseIP(ipStr)
if ip == nil {
return "", false
}
// X-Forwarded-For is appended by proxy
// Check IPs in reverse order and stop when find untrusted proxy
if (i == 0) || (!e.isTrustedProxy(ip)) {
return ipStr, true
}
}
return
}
官方的手法也是简单粗暴,以前是将错就错,这次一下子修复好了,搞得很多人翻车了(https://github.com/gin-gonic/gin/issues/2697)。
原因是新的实现没有兼容 1.6 版本,导致升级框架后获取不到客户端的真实 IP,1.7.7 才解决该问题。
四、三大原则
分析完整个事情的来龙去脉,想必读者们对现状有一定的了解。
我把这套方案,抽象为三大原则,只要理解它,获取客户端真实 IP 的问题,就跟喝水一样简单!
1. 代理必须向下传递客户端 IP 地址
原因:从入口流量开始,经过 N 层代理,如果代理中间不传递客户端的 IP 地址,底层业务必然获取不到客户端的真实 IP 地址。
2. 统一使用 nginx 的 realip 模块获取客户端 IP 地址
# nginx.conf
# ...
set_real_ip_from 腾讯云/阿里云 NAT 出口网段;
set_real_ip_from 腾讯云/阿里云高防 IP 网段;
set_real_ip_from 腾讯云/阿里云 WAF 网段;
set_real_ip_from CDN 网段;
set_real_ip_from 内网地址网段; # 按需配置,对于网关进来的请求通过内网到业务机器,需要配置上这个网段。
set_real_ip_from 127.0.0.1; # 按需配置,主要作用在 nginx 的内部转发。
real_ip_header X-Forwarded-For;
real_ip_recursive on; # 必须打开该选项,原因见下面分析。
access_by_lua '
ngx.req.set_header("X-REAL-IP", ngx.var.remote_addr)
ngx.req.set_header("X-FORWARDED-FOR", ngx.var.remote_addr)
';
# vhost/*.conf
location ^~ /foo {
access_log logs/api_foo.access.log main;
proxy_pass http://api_foo;
proxy_redirect off;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-NginX-Proxy true;
proxy_set_header Connection "";
}
此时,X-Real-IP、REMOTE-ADDR、X-Forwarded-For 均统一为 realip 模块重写后的 $remote_addr
变量,业务就可以取到真实的客户端 IP 地址,无需考虑 PHP、Go 等不同语言、同种语言不同框架下的差异。
那问题来了,客户端 IP 是否会被伪造?答案是不会的。
按照 X-Forwarded-For 的定义,该头部每经过一层就追加一个 IP 地址:
X-Forwarded-For: 客户端伪造 IP 地址, IP0(client), IP1(proxy), IP2(proxy)
那么,我们只需启用 realip 模块的 real_ip_recursive 递归模式,将从右往左逐步剔除 IP2,IP1 等信任代理,最后会获取到真实的客户端 IP 地址。
问题二:网上有一种边缘节点的方案,为什么不采用?
边缘节点,指的就是接入层,直接连接客户端的那一层。经过边缘节点转发到下游的,统称为非边缘节点。
按照这个思路,如果边缘节点拿到了客户端 IP,重置 X-FORWARDED-FOR 头部为客户端 IP 地址,并转发到下游,业务只获取第一个 IP 地址,理论上也不会被伪造,业务也简单,为什么不采用?
因为边缘节点方案最大的缺点在于失去了灵活性,譬如你想接入高防 IP 或者 WAF 防火墙,此时它已不再是边缘节点,而是接收高防服务器或 WAF 防火墙清洗的流量,将会拿到错误的 IP 地址。
3. 运维维护信任 IP 列表,开发代码不做处理
由 2 可知,三个头部均为统一的值,对开发可以保证最大的兼容性。原因是不同的语言,同个语言的不同开发框架,同个框架的不同版本,获取客户端 IP 的方式也就这几种。
对开发而言,确实没必要关心自己的代码需要引入 NAT 网关 IP 配置、高防 IP 配置等,并且每个工程可能都要修改,这是不现实的。
本质上,这也是运维的工作。举个例子,如果真的遇到 DDoS 攻击,切换高防 IP 抵御 DDoS 攻击的操作人是运维,开发这个时候去将所有工程配置上高防 IP 地址是一件极其痛苦的事情。一旦加漏、加错将直接引发故障。
五、最佳实践
(1) 虚拟机部署
- SRE 维护信任的 IP 池,X-Real-IP、REMOTE-ADDR、X-Forwarded-For 均统一为 realip 模块重写后的 $remote_addr 变量,开发不感知;
- 开发无需修改代码,因为上述三个变量读取出来的值是一致的,无任何风险。
(2) 容器化部署
a. PHP 无需改动,可以平滑切换上容器。因为 PHP 容器上层依然有 nginx.conf,平移该配置即可;
b. GO 容器化,有 2 种方案:
注:最终采用方案 2,去除了 Pod 内部的 nginx 转发,Pod 的上层使用了 nginx-ingress,做到了业务无感知容器上云。
如果保留虚拟机架构,即 Go 服务上层有 nginx,也是平移就可以了,跟 PHP 一样;
如果 Go 服务上游去除 nginx 转发:
流量入口使用 7 层腾讯云 CLB / 阿里云 SLB 进行 HTTPS 卸载后转发到容器集群的 nginx-ingress,业务代码无感知。实现原理和虚拟机方案相似,均为配置 realip 模块和统一 X-Real-IP、REMOTE-ADDR、X-Forwarded-For 头部,详情可以参考以下资料:
- https://kubernetes.github.io/ingress-nginx/user-guide/nginx-configuration/configmap/#use-proxy-protocol
- https://kubernetes.github.io/ingress-nginx/user-guide/nginx-configuration/configmap/#proxy-real-ip-cidr
- https://kubernetes.github.io/ingress-nginx/user-guide/nginx-configuration/configmap/#server-snippet
- https://help.aliyun.com/document_detail/86533.html
还有个容易忽略的点——ingress 选型。
如果使用 Pod 直连,也就是不使用 nginx-ingress:
PHP / Go 上层都需要有一层 nginx 并配置好 nginx.conf,配置 realip 模块和统一 X-Real-IP、REMOTE-ADDR、X-Forwarded-For 头部。
此时 PHP / Go 架构统一,但对 Go 容器来说多了一层 nginx,会造成资源浪费(每个 Pod 都需要部署一个 nginx,再转发到 Go)。
具体用哪个 ingress,就要看怎么取舍了。
nginx 存在的意义在于阻止业务直接感知到信任代理 IP 列表的存在,如果对于你的业务而言,各个业务线去维护这个配置列表成本极低,那 nginx 确实是没有存在的必要性。
总之,我个人认为:
- 业务完全不需要关心如何获取客户端的真实 IP,这是最好的选择;
- 千万不要封装各种函数去获取客户端真实 IP,这种问题最好交给上层 SRE 基础架构的同学负责,不然真的非常容易出问题;
- 理解好三大原则,获取客户端真实 IP 的问题,就跟喝水一样简单!
OK,文章终于写完了,花费了好多天的时间整理,憋出来了。感谢你读到这里,是时候吃晚饭了:)
文章来源于本人博客,发布于 2021-12-19,原文链接:https://imlht.com/archives/248/