关于 FullNat 模式的 Toa 实现原理

我们知道 FullNat 模式的特点,比如跨机房、可运维性强等优势。不过会存在一个问题,在后端服务器上,应用程序能够获取到的请求源 IP 是 lvs 的 LocalIP,并不是真实客户端的 ClientIP。而现在大多数业务都需要对用户信息进行分析画像,也有一些敏感业务需要对用户进行溯源,所以获取用户的真实客户端 IP 地址是非常重要和必要的。

几种获取源IP常见方法

有一定流量的业务基本上都要经过负载均衡设备,所以后端服务器要获取客户端真实IP地址,也是常见的问题和需求,这里先罗列几种常见的获取源 IP 的方式:

  • 通过 L3 转发时,源 IP 不变。比如 lvs 的 dr、nat、tunnel 模式,后端服务器可以直接获取到真实客户端 IP 地址。
  • 通过 proxy protocol 协议实现源 IP 传递。原理是在三次握手后,发送请求数据前,插入一个 proxy protocol 数据包,数据包中可以携带 src ipsrc port 等信息,该协议是由 haproxy 提出的,目前常见的 web 服务器都已经支持。
  • 通过 toa 模块获取源 IP。在三次握手最后一个 ack 数据包的 tcp option 中插入源 IP 和源 Port 等信息;后端服务器在调用 getpeername 获取源 IP 时读取 tcp option 数据即可获取真实客户端的 IP 地址。
  • 通过七层的 XFF 字段。HTTP 协议实现的字段,没什么可说的。
  • 业务层自己实现。

几种方案各有优缺点,以及自己适用的应用场景。本文重点要说的是 TOA ,TOA 工作在 L4 层,适用性更通用一些。

基本原理

TOA 名字全称是 tcp option address,是 FullNat 模式下能够让后端服务器获取 ClientIP 的一种实现方式,它的基本原理比较简单。

  1. 客户端用户请求数据包到达 LVS 时,LVS 在数据包的 tcp option 中插入 src ip 和 src port 信息
  2. 数据包到达后端服务器(装有 toa 模块)后,应用程序正常调用 getpeername 系统函数来获取连接的源端 IP 地址
  3. 由于在 toa 代码中 hook(修改)了 inet_getname 函数(getpeername 系统调用对应的内核处理函数),该函数会从 tcp option 中获取 lvs 填充的 src 信息
  4. 这样后端服务器应用程序就获取到了真实客户端的 ClientIP,而且对应用程序来说是透明的。

tcp option 字段

先看下 tcp header 格式
关于 FullNat 模式的 Toa 实现原理_第1张图片

其实 client ip 就是放在 tcp option 字段中。option 字段最长 40 字节,每个选项由三部分组成:op-kind、op-length、op-data,我们最常见的 MSS 字段就是在 option 里。

目前 option 使用的 op-kind 并不多,我们只需要构建一个不冲突的 op-kind 就可以把 clientIP 填充进去。

IPv4 地址占用 4 个字节,IPv6 占用 16 字节,填充到 option 中是没有问题的。

lvs 与 toa

首先要确定 toa 的具体数据格式:

(一)IPv4 toa 格式

+----------+----------+--------------------+
|  opcode  |  opsize  |         port       |
+----------+----------+--------------------+
|                clientIP                  |
+------------------------------------------+

各字段含义:

  • opcode: opcode = 254
  • opsize: toa 大小 8 字节
  • port: 客户端端口
  • clientIP: 客户端 IP(4 字节)

(二)IPv6 toa 格式

+----------+----------+--------------------+
|  opcode  |  opsize  |         port       |
+----------+----------+--------------------+
|                                          |
|               clientIPv6                 |
|               clientIPv6                 |
|                                          |
+------------------------------------------+

各字段含义:

  • opcode: opcode = 254
  • opsize: toa 大小 20 字节
  • port: 客户端端口
  • clientIP: 客户端 IP(16 字节)

(三)TOA 的插入

我们这时可以考虑个问题,lvs 需要对每个 tcp 数据包都要插入 toa 信息么?如果这样会影响到 lvs 整体性能的,而且后端服务器也没必要对每个 tcp 数据包进行解析,当然也很影响服务器性能。

其实只需要在第 3 次握手 ack 数据包中插入 toa 选项即可,后端服务器从 ack 数据包中解析并获取即可。

后端通过toa获取源IP

TCP 协议栈中处理三次握手的 ack 数据包的函数是 tcp_v4_syn_recv_sock,完成连接的建立,并创建 newsock。

toa 模块会将此函数通过 tcp_v4_syn_recv_sock_toa 函数进行劫持,也就是说第三次握手的 ack 到达协议栈后调用的是 tcp_v4_syn_recv_sock_toa 函数。

在 tcp_xx_toa 函数中首先会调用内核原有的处理函数 tcp_v4_syn_recv_sock 函数,这样兼容了那些不是通过 toa 的连接。然后解析 ack 数据包中 tcp option 内容,获取到 lvs 插入到 toa 的 src ip 和 src port 信息,将此信息挂在 newsock 结构变量中。

static struct sock *
tcp_v4_syn_recv_sock_toa(struct sock *sk, struct sk_buff *skb,
            struct request_sock *req, struct dst_entry *dst)
{
    newsock = tcp_v4_syn_recv_sock(sk, skb, req, dst);
    newsock->sk_user_data = get_toa_data_compatible(AF_INET, skb, &nat64);

    return newsock;
}

当应用程序,如 nginx 或 MySQL 调用 getpeername 系统函数时,正常情况会调用 inet_getname 函数来获取连接远端的 ClientIP。

toa 模块对 inet_getname 函数也用 inet_getname_toa 函数进行了劫持,也就是说应用程序调用 getpeername 时,内核对应的处理函数是 inet_getname_toa

static int
inet_getname_toa(struct socket *sock, struct sockaddr *uaddr,
        int *uaddr_len, int peer)
{
    int retval = 0;
    struct sock *sk = sock->sk;
    struct sockaddr_in *sin = (struct sockaddr_in *) uaddr;
    struct toa_ip4_data tdata;

    // 调用内核原来的函数,兼容那些不是toa的情况
    retval = inet_getname(sock, uaddr, uaddr_len, peer);

    // 如果是toa,则直接从sk->sk_user_data获取数据
    if (retval == 0 && NULL != sk->sk_user_data && peer) {
            memcpy(&tdata, &sk->sk_user_data, sizeof(tdata));
            sin->sin_port = tdata.port;
            sin->sin_addr.s_addr = tdata.ip;
    }

    return retval;
}

就这样后端服务器的应用程序就通过 toa 模块获取到了客户端的真实 ClientIP

你可能感兴趣的:(负载均衡)