改造LVS支持TOA

在四层负载均衡平台,我们使用LVS来实现核心功能。由于公司使用的linux大多数的内核是4.9.0.XXX,该版本只支持NAT,不能支持fullnat,fullnat是阿里在多年前的2.6.32版本的内核上开发支持了fullnat,并开源出来,阿里开源的这个该版本不仅支持了fullnat,而且还支持TOA功能,可以把客户端的IP和Port带到RealServer,但是该版本已经停止维护。

目的

现在微服务化已经应用非常广泛,微服务最常见的就是实现服务的高可用和负载均衡性,那么LVS就是其中一个选项,我们提供的四层负载均衡平台就是基于LVS来实现的一套负载均衡平台。

在很多业务场景下, 我们在对外提供服务时,需要检查服务请求方的IP地址,来针对IP地址做一些业务处理,最常见的一个例子就是:做白名单校验,只有在白名单列表中的IP地址,我们才允许它访问我们的服务;还有一种应用场景,那就是基于客户端的请求IP来进行调度,譬如CDN服务,那么就需要根据客户端的请求IP,来调度最近最适合的资源提供服务。

LVS支持的三种模式:Tunnel、DR和NAT,其中Tunnel,DR这两种模式都能够把客户端IP透传到RealServer上,但是由于在NAT模式下,由于我们的四层服务均衡平台是由于实现了FULLNAT,所以最终用户请求到业务服务器时,业务服务中拿到的是LVS的主机IP地址,而不是请求方的IP地址,这样,业务服务就无法针对请求IP来做业务逻辑处理了。

阿里提供的fullnat版本的内核中,实现了TOA的功能,可以在fullnat的场景下,把客户端ip地址传到RealServer中。本文旨在讲解,在LVS的NAT模式下,我们是如何实现TOA的功能,以及在实现的过程中,踩到的一些坑。

要实现TOA功能,设计到两块的改造:

    1. 改造LVS,在NAT模式下,实现TOA的功能;
    1. 在所有的RealServer中,安装TOA插件;

TOA的插件可以参考:https://github.com/Huawei/TCP_option_address

实现对TOA的支持

如果不做改造,基于LVS的NAT实现的包转发如下图所示:

image.png

假如我们配置了一个NAT的负载均衡,当图中一个请求达到LVS服务时,会进行如下几个步骤:

  • 进入时,源IP:10.1.1.5,目标IP:10.1.1.1
  • 进入LVS的DNAT逻辑中,做DNAT处理后,源IP:10.1.1.5,目标IP:192.168.1.2
  • 包出主机时,由iptables做snat,变成源IP:192.168.1.1,目标IP:192.168.1.2
  • RS收到包,源IP:192.168.1.1,目标IP:192.168.1.2
  • RS处理处理后回包,源IP:192.168.1.2,目标IP:192.168.1.1
  • 回包进入LVS服务,源IP:192.168.1.2,目标IP:192.168.1.1
  • 主机层面iptables,做dnat,变成源IP:192.168.1.2,目标IP:10.1.1.5
  • LVS服务做snat处理,变成源IP:10.1.1.1,目标IP:10.1.1.5

那么,可以看到在RS上拿到的源ip地址是192.168.1.1,是LVS的内网地址,不是客户端ip。

LVS实现对TOA的支持

阿里开源的LVS的版本内核是2.6.32,在该版本中,实现了TOA功能的支持,主要原理是在4层TCP协议的options字段名中增加源IP和PORT信息。TOA OPTION的OPCODE为254(0xfe), 长度为8字节,结构为:

struct toadata
{
    __u8   opcode;
    __u8   opsize;
    __u16  port;
    __u32  ip;
}

TOA实现的原理图为:


image.png

由于公司内部试用的内核是4.9以上,所以改造了一下LVS的代码,在NAT的基础上对toa实现了支持,笔者在dnat的处理逻辑代码上进行改造,按需把TOA options插入到tcp协议包中。

在RS上部署TOA插件解析客户端IP

为了让RS解析到真实客户端IP和PORT,还需要在RS服务器上安装TOA插件,

insmod toa.ko

在TOA插件插件中,HOOK内核的两个方法tcp_v4_sync_recv_sock_toa和inet_getname_toa。

其中tcp_v4_sync_recv_sock_toa的逻辑是判断TCP Options中是否存在TOA选项,如果有,就把TOA中的ip和端口的数据读取出来,并存放到sock结构体的sk_user_data指针中。

注:由于TOA选项占用8个字节,它利用了sk_user_data指针在64位主机,也是占用8个字节,在32位机器上,就不能正常工作了。

而inet_getname_toa在应用层调用getpeername或者getsocketname是调用,在该函数中,会判断sock结构体中的sk_user_data指针是否为不为空,如果不为空,就按照结构体TOA读取出真正的客户端ip和端口号。

用nginx部署RS验证

按照前文中nginx的部署,配置好lvs的环境。
在客户端访问lvs的服务:

curl http://10.1.1.1
hello world

当没安装TOA插件时,我们查看日志文件/data/weblog/nginx/_.access.log中的内容为:

192.168.1.1 - - - [09/Sep/2019:08:04:41 +0800] "GET / HTTP/1.1" 200 22 "-" "curl/7.47.0" "0.000" "-"

这里可以看到日志中记录的客户端ip地址为192.168.1.1,是LVS服务器的内网地址,在RS服务器(192.168.1.2)上安装TOA插件后,再测试,发现日志变成了

10.1.1.5 - - - [09/Sep/2019:08:04:41 +0800] "GET / HTTP/1.1" 200 22 "-" "curl/7.47.0" "0.000" "-"

其中的客户端IP地址已经换成了真是的请求服务器ip地址。

碰到的问题

通过用nginx验证通过了以后,笔者这里就在线上把环境部署好了,让业务部门的兄弟去测试,结果让人大失所望,对方反馈说仍然拿不到客户端ip。
首先我需要重现对方的问题,再次用nginx做测试后,也是能够正常工作的。

  • 我发现业务方的程序是用go语言编写的
    于是自己写了一个go语言的简单的服务器代码进行测试,一经测试果然不行,代码如下:
package main

import (
    "fmt"
    "net"
    "net/http"
)

func main() {
    http.HandleFunc("/", func (w http.ResponseWriter, r *http.Request) {
           fmt.Fprintf(w, "client address:%s,\n", r.RemoteAddr)
    })
    http.ListenAndServe("0.0.0.0:8080", nil)
}
  • 然后编写了一个c语言的服务,测试是能够拿到客户端ip

c语言的代码不是http协议的,是一个普通的tcp服务器,接受到客户端连接后,打印客户端ip地址,代码如下所示:

#include <arpa/inet.h>
#include <netinet/in.h>
#include <stdbool.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>

/**
 * TCP Uses 2 types of sockets, the connection socket and the listen socket.
 * The Goal is to separate the connection phase from the data exchange phase.
 * */

int main(int argc, char *argv[]) {
    // port to start the server on
    int SERVER_PORT = 8080;

    // socket address used for the server
    struct sockaddr_in server_address;
    memset(&server_address, 0, sizeof(server_address));
    server_address.sin_family = AF_INET;

    // htons: host to network short: transforms a value in host byte
    // ordering format to a short value in network byte ordering format
    server_address.sin_port = htons(SERVER_PORT);

    // htonl: host to network long: same as htons but to long
    server_address.sin_addr.s_addr = htonl(INADDR_ANY);

    // create a TCP socket, creation returns -1 on failure
    int listen_sock;
    if ((listen_sock = socket(PF_INET, SOCK_STREAM, 0)) < 0) {
        printf("could not create listen socket\n");
        return 1;
    }

    // bind it to listen to the incoming connections on the created server
    // address, will return -1 on error
    if ((bind(listen_sock, (struct sockaddr *)&server_address,
              sizeof(server_address))) < 0) {
        printf("could not bind socket\n");
        return 1;
    }

    int wait_size = 16;  // maximum number of waiting clients, after which
                         // dropping begins
    if (listen(listen_sock, wait_size) < 0) {
        printf("could not open socket for listening\n");
        return 1;
    }

    // socket address used to store client address
    struct sockaddr_in client_address;
    int client_address_len = 0;

    // run indefinitely
    while (true) {
        // open a new socket to transmit data per connection
        int sock;
        if ((sock =
                 accept(listen_sock, (struct sockaddr *)&client_address,
                        &client_address_len)) < 0) {
            printf("could not open a socket to accept data\n");
            return 1;
        }

        int n = 0;
        int len = 0, maxlen = 100;
        char buffer[maxlen];
        char *pbuffer = buffer;

        printf("client connected with ip address: %s\n", inet_ntoa(client_address.sin_addr));

        // keep running as long as the client keeps the connection open
        while ((n = recv(sock, pbuffer, maxlen, 0)) > 0) {
            pbuffer += n;
            maxlen -= n;
            len += n;

            printf("received: '%s'\n", buffer);

            // echo received content back
            send(sock, buffer, len, 0);
        }

        close(sock);
    }

    close(listen_sock);
    return 0;
}

分析和定位问题

从上面的测试可以看出c语言的后端,很明面,我们在toa模块中hook的钩子函数被调用了,而在与go语言的后端程序建立tcp连接的时候,钩子函数没有被调用。
笔者之前没有接触过内核,对tcp的内核里面的调用逻辑这块有点生疏,所以初步怀疑是不是go语言做了特殊处理,内核的处理逻辑跟c语言的调用路线不一致呢?答案显然是不可能的。

要解决问题,还是需要从内核入手,于是拿到一套公司用到的内核的源码,来查看tcp连接建立过程中的代码的调用关系,初步理清,一个连接建立三次握手最后的ACK消息处理的简单的调用关系为:tcp_v4_rcv->tcp_check_req->tcp_v4_syn_recv_sock

在TOA中的代码中,hook了tcp_v4_syn_recv_sock函数,在tcp_v4_syn_recv_sock_toa函数中去读取TOA选项中的数据来获取客户端的真实ip信息。

调试过程

    1. 在tcp_v4_sync_recv_sock函数中加了一行调试日志,重新编译内核后,再次发起tcp到go语言的lvs服务的调用,发现tcp_v4_sync_recv_sock_toa没被调用,但是tcp_v4_sync_recv_sock被调用了;
    1. 打开文件net/ipv4/tcp_minisocks.c文件,修改tcp_check_req方法,在其中加一行调试日志
printk("tcp_check_req call ops:%p, syn_recv_sock: %p\n", inet_csk(sk)->icsk_af_ops,  inet_csk(sk)->icsk_af_ops->syn_recv_sock) ;

通过打印发现,在tcp_check_req方法中,打印的sync_recv_sock的地址不是tcp_v4_sync_recv_sock_toa的地址,也不是tcp_v4_sync_recv_sock的地址。

    1. 怀疑是tcp_v6_syn_recv_sock被调用了,打开net/ipv6/tcp_ipv6.c,在tcp_v6_syn_recv_sock加上日志,发现果然在这里被调用了。

到这里基本上知道问题所在了,普通的c程序侦听时是基于tcp4协议,而go语言是基于tcp6的。
从go的Listen函数文档中截取出来的说明:

func Listen(network, address string) (Listener, error)
Listen announces on the local network address.
The network must be "tcp", "tcp4", "tcp6", "unix" or "unixpacket".
For TCP networks, if the host in the address parameter is empty or a literal unspecified IP address, Listen listens on all available unicast and anycast IP addresses of the local system. To only use IPv4, use network "tcp4". The address can use a host name, but this is not recommended, because it will create a listener for at most one of the host's IP addresses. If the port in the address parameter is empty or "0", as in "127.0.0.1:" or "[::1]:0", a port number is automatically chosen. The Addr method of Listener can be used to discover the chosen port.
See func Dial for a description of the network and address parameters.

在主机上查看

# netstat -tnpl | grep go-server
tcp6       0      0 :::8080                 :::*                    LISTEN      4391/go-server

果然,由于go语言程序缺省采用tcp6网络侦听,我们需要hook tcp6的函数才行。

问题的解决方案

第一种解决方案:hook tcp6的函数

TOA的代码中,有一个宏定义CONFIG_IP_VS_TOA_IPV6,来确定TOA模块是否会hook ipv6相关的函数,但是我打开宏之后,编译不过。

在内核代码中,打开net/ipv6/tcp_ipv6.c中查看代码发现:

static const struct inet_connection_sock_af_ops ipv6_specific;
......
static struct sock *tcp_v6_syn_recv_sock(const struct sock *sk, struct sk_buff *skb,
                     struct request_sock *req,
                     struct dst_entry *dst,
                     struct request_sock *req_unhash,
                     bool *own_req)

变量ipv6_specific和函数tcp_v6_syn_recv_sock都是static定义的,不会对外宣告,头文件中也没有相关信息,所以要解决这个问题,需要把这几个变量添加到头文件中,并且把static去掉,并且对外export变量。
重新编译内核和toa模块,即可。

第二种解决方案:改造go程序代码,只侦听tcp4网络

改造后的go语言代码如下所示:

package main

import (
    "fmt"
    "net"
    "net/http"
)

func main() {
    mux := http.NewServeMux()
    http.HandleFunc("/", func (w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "client address:%s,\n", r.RemoteAddr)
    })
    server := &http.Server{Handler: mux}
    l, _ := net.Listen("tcp4", "0.0.0.0:8080")
    server.Serve(l)
}

总结

上面提到的两种解决方案中,我们目前暂时采用的第二种,业务部门改写代码侦听tcp4网络后,能够正常工作了。第一种方案改造比较彻底,因为涉及到所有机器的内核的升级,工作量比较大。
工信部近期正在推ipv6,四层负载均衡面临着对ipv6提供支持,前面的TOA结构体中存储的数据需要做相应的修改。所以考虑在后续的版本开发中,统一对第一种方案进行支持改造。

推荐阅读更多精彩内容