我们知道 FullNat 模式的特点,比如跨机房、可运维性强等优势。不过会存在一个问题,在后端服务器上,应用程序能够获取到的请求源 IP 是 lvs 的 LocalIP,并不是真实客户端的 ClientIP。而现在大多数业务都需要对用户信息进行分析画像,也有一些敏感业务需要对用户进行溯源,所以获取用户的真实客户端 IP 地址是非常重要和必要的。
有一定流量的业务基本上都要经过负载均衡设备,所以后端服务器要获取客户端真实IP地址,也是常见的问题和需求,这里先罗列几种常见的获取源 IP 的方式:
src ip
、src port
等信息,该协议是由 haproxy 提出的,目前常见的 web 服务器都已经支持。几种方案各有优缺点,以及自己适用的应用场景。本文重点要说的是 TOA ,TOA 工作在 L4 层,适用性更通用一些。
TOA 名字全称是 tcp option address,是 FullNat 模式下能够让后端服务器获取 ClientIP 的一种实现方式,它的基本原理比较简单。
其实 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 中是没有问题的。
首先要确定 toa 的具体数据格式:
(一)IPv4 toa 格式
+----------+----------+--------------------+
| opcode | opsize | port |
+----------+----------+--------------------+
| clientIP |
+------------------------------------------+
各字段含义:
opcode
: opcode = 254opsize
: toa 大小 8 字节port
: 客户端端口clientIP
: 客户端 IP(4 字节)(二)IPv6 toa 格式
+----------+----------+--------------------+
| opcode | opsize | port |
+----------+----------+--------------------+
| |
| clientIPv6 |
| clientIPv6 |
| |
+------------------------------------------+
各字段含义:
opcode
: opcode = 254opsize
: toa 大小 20 字节port
: 客户端端口clientIP
: 客户端 IP(16 字节)(三)TOA 的插入
我们这时可以考虑个问题,lvs 需要对每个 tcp 数据包都要插入 toa 信息么?如果这样会影响到 lvs 整体性能的,而且后端服务器也没必要对每个 tcp 数据包进行解析,当然也很影响服务器性能。
其实只需要在第 3 次握手 ack 数据包中插入 toa 选项即可,后端服务器从 ack 数据包中解析并获取即可。
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