一、背景

1. 业务上云带来性能收益

公司从去年全面推动业务上云,而以往 IDC 架构部署上,接入层采用典型的 4 层 LVS 多机房容灾架构,在业务高峰时期,扩容困难(受限于物理机资源和 LVS 内网网段的网络规划),且抵挡不住 HTTPS 卸载引发的高 CPU 占用。

而经过压力测试发现,使用腾讯云 7 层 CLB 负载均衡进行 HTTPS 卸载,性能得到极大提升。测试数据也表明,IDC 旧架构中,启用 HTTPS 会带来 90% 以上的性能损耗。

2. 架构调整引发多次故障

引入腾讯云 7 层 CLB 负载均衡产品,带了了巨大的性能提升,却也给业务带来了痛苦,主要核心问题是获取客户端的真实 IP 上。

当前现状是业务语言异构(PHP + Go),多数业务已经历服务化改造,但缺乏服务发现机制,服务与服务之间的调用依赖域名和 DNS 解析,大部分都是 HTTP 服务。

在架构调整后,QA 没能 100% 覆盖测试,导致漏测的服务经常拿到错误的客户端 IP 地址,造成的后果是损失大量的用户。这些用户会因为短信验证码发送限制、IP 登录频次过高而无法登录、充值,给公司带来巨大损失。

3. 未来的路应该怎么走?

更进一步讲,当前业务如何抵挡外界的 DDoS 攻击、请求机器人、SQL 注入等等,最简单的是接入高防 IP、WAF 应用防火墙,而请求经过多轮转发,同样也有获取客户端真实 IP 的问题。

再者,业务也在逐步容器化,享受 Kubernetes 弹性扩容的便利,怎么平滑迁移也是非常值得深思的。

假设 SRE 配置不当,应用层拿到的,很有可能是高防 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()
{
    $ipAddresses = $this->getClientIps();
    return $ipAddresses[0]; // 1. 取第一个 IP 地址。
}
public function getClientIps()
{
    $ip = $this->server->get('REMOTE_ADDR');
    if (!$this->isFromTrustedProxy()) {
        // 2. 程序在这里返回了 REMOTE_ADDR 头部的值。
        return [$ip];
    }
    // 3. 永远到不了这个分支。
    return $this->getTrustedValues(self::HEADER_X_FORWARDED_FOR, $ip) ?: [$ip];
}
public function isFromTrustedProxy()
{
    // 4. 因为生产环境中,$trustedProxies 没有配置。
    return self::$trustedProxies && IpUtils::checkIp($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 地址是一件极其痛苦的事情。一旦加漏、加错将直接引发故障。

对 PHP 来说,不同的开发框架获取客户端 IP 地址的函数实现并不一样,一般都是取 $_SERVER['REMOTE_ADDR'];

对 Go 来说,Gin 框架的 1.6 版本优先获取

五、最佳实践

(1) 虚拟机部署

  1. SRE 维护信任的 IP 池,X-Real-IP、REMOTE-ADDR、X-Forwarded-For 均统一为 realip 模块重写后的 $remote_addr 变量,开发不感知;
  2. 开发无需修改代码,因为上述三个变量读取出来的值是一致的,无任何风险。

(2) 容器化部署

a. PHP 无需改动,可以平滑切换上容器。因为 PHP 容器上层依然有 nginx.conf,平移该配置即可;

b. GO 容器化,有 2 种方案:

  1. 如果保留虚拟机架构,即 Go 服务上层有 nginx,也是平移就可以了,跟 PHP 一样;
  2. 如果 Go 服务上游去除 nginx 转发,有两种方案可以落地(未经过生产环境测试,理论上可行):

方案一:7 层腾讯云 CLB / 阿里云 SLB 进行 HTTPS 卸载后转发到容器集群的 nginx-ingress,业务代码无感知。实现原理和虚拟机方案相似,均为配置 realip 模块和统一 X-Real-IP、REMOTE-ADDR、X-Forwarded-For 头部,详情可以参考以下资料:

方案二:7 层腾讯云 CLB / 阿里云 SLB 进行 HTTPS 卸载后转发到容器集群的 nginx-ingress,仅需启用 realip 模块,无需配置 lua 代码:

还有个容易忽略的点——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 确实是没有存在的必要性。


OK,文章终于写完了,花费了好多天的时间整理,憋出来了。感谢你读到这里,是时候吃晚饭了:)

暂无评论