cBPF/eBPF如何处理reuseport的吐槽和示例

我想用eBPF或者至少cBPF实现一个功能:

  • 根据来源和目标IP地址来选择同一个reuseport组的socket。

比方说,我的服务器上有4个IP地址分别是10.0.0.1,10.0.0.2,10.0.0.3,10.0.0.4,我的服务侦听0.0.0.0:1234,分别有4个reuseport socket提供,我希望的select算法是:

  • 访问10.0.0.1的分派给sk1。
  • 访问10.0.0.2的分派给sk2。
  • 访问10.0.0.3的分派给sk3。
  • 访问10.0.0.4的分派给sk4。

不要问我这个需求哪来的,它是真实存在的,我的reuseport组三线连接三大运营商,每一个组内socket均有不同的策略,我受够了Netfilter,所以我必须用IP地址来进行socket查找。

然而这么简单的算法却无法实现!

对于cBPF而言,bpf程序携带的skb参数是pull过的,一直pull到TCP/UDP的payload位置,因此bpf程序连TCP/UDP头都无法访问,就更别提IP头了。

对于eBPF而言,结构体sk_reuseport_md是eBPF程序的参数:

struct sk_reuseport_md {
        /*
         * Start of directly accessible data. It begins from
         * the tcp/udp header.
         */
         // 注意上面的注释!!
        __bpf_md_ptr(void *, data); 
        /* End of directly accessible data */
        __bpf_md_ptr(void *, data_end);
        /*
         * Total length of packet (starting from the tcp/udp header).
         * Note that the directly accessible bytes (data_end - data)
         * could be less than this "len".  Those bytes could be
         * indirectly read by a helper "bpf_skb_load_bytes()".
         */
        __u32 len;
        /*
         * Eth protocol in the mac header (network byte order). e.g.
         * ETH_P_IP(0x0800) and ETH_P_IPV6(0x86DD)
         */
        __u32 eth_protocol;
        __u32 ip_protocol;      /* IP protocol. e.g. IPPROTO_TCP, IPPROTO_UDP */
        __u32 bind_inany;       /* Is sock bound to an INANY address? */
        __u32 hash;             /* A hash of the packet 4 tuples */
};

比cBPF强一点,至少可以访问TCP/UDP头了,然而还是无法访问IP头!

那么见招拆招,要想做到让bpf程序可以访问到IP头,办法很简单:

  • 对于cBPF:在bpf_prog_run之前,不要pull,而要push一个TCP/UDP的头。
  • 对于eBPF:在sk_reuseport_md结构体中加入五元组信息即可。

在实际修改生效之前,迄至5.3版本内核,目前 只能基于TCP/UDP不包括协议头的有效payload来选择socket了!

还要说一句,对于UDP而言,每一个数据包均可携带payload,自然可以根据payload的内容来选择socket,正如Quic协议经常用的那样,我自己曾经也用这个方法实现了基于SessionID的UDP隧道的构建。那么对于TCP呢?

TCP仅仅初始化连接时的SYN包受REUSEPORT的控制,然而这个SYN包是没有payload的!如此一来,你只能使用内置的字段来使用了:

//include/uapi/linux/filter.h
#define SKF_AD_OFF    (-0x1000)
#define SKF_AD_PROTOCOL 0
#define SKF_AD_PKTTYPE  4
#define SKF_AD_IFINDEX  8
#define SKF_AD_NLATTR   12
#define SKF_AD_NLATTR_NEST      16
#define SKF_AD_MARK     20
#define SKF_AD_QUEUE    24
#define SKF_AD_HATYPE   28
#define SKF_AD_RXHASH   32
#define SKF_AD_CPU      36
#define SKF_AD_ALU_XOR_X        40
#define SKF_AD_VLAN_TAG 44
#define SKF_AD_VLAN_TAG_PRESENT 48
#define SKF_AD_PAY_OFFSET       52
#define SKF_AD_RANDOM   56
#define SKF_AD_VLAN_TPID        60
#define SKF_AD_MAX      64

除此之外,和TCP SYN包本身相关的任何字段都无法使用,怎么办?这也许是一个败笔!

但是,仍然可以利用TCP的Fastopen特性。该特性让TCP的SYN包也可以携带payload,虽然目前无论在端到端还是中间链路都还没有完备的支持,但至少可以玩一玩。

来来来,现在让我来演示一个示例,表演一下 在使能TCP Fastopen的前提下,如何根据SYN包的paylaod来选择socket。

eBPF太麻烦了,所以我选择退回到cBPF。

下面是服务端的C代码:

// server.c
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 

int main(int argc, char **argv)
{
	int i = 0;
	int sd = -1;
	int optval = 1;
	struct sockaddr_in saddr;
	int len;
#define NUM		4
	// 超级简单的cBPF程序:
	// payload的第一个字节与socket数量取模,获得socket索引。
	struct sock_filter code[]={
		{ BPF_LD  | BPF_B | BPF_ABS, 0, 0, 0},	// load载荷的第一个字节到A
		{ BPF_ALU | BPF_MOD, 0, 0, NUM},			// 将A对4取模
		{ BPF_RET | BPF_A, 0, 0, 0 },			// 返回A
	};
	struct sock_fprog bpf = {
		.len = 3,
		.filter = code,
	};

	for (i = 0; i < NUM; i++) {
		if (fork() == 0) {
			sd = socket(AF_INET, SOCK_STREAM, 0);
			if (sd < 0) {
				exit(1);
			}

			saddr.sin_family = AF_INET;
			saddr.sin_port = htons(12345);
			saddr.sin_addr.s_addr = inet_addr("0.0.0.0"); 

			if (setsockopt(sd, SOL_SOCKET, SO_REUSEPORT, (const void *)&optval,sizeof(optval))) {
				exit(1);
			}
			if (setsockopt(sd, 6, 23, (const void *)&optval, sizeof(optval))) {
				exit(1);
			}
			if (bind(sd, (struct sockaddr *)&saddr, sizeof(struct sockaddr))) {
				exit(1);
        	}
        	if (listen(sd, 100)) {
				exit(1);
        	}
			if (setsockopt(sd, SOL_SOCKET, SO_ATTACH_REUSEPORT_CBPF, (const void *)&bpf, sizeof(bpf))) {
				exit(1);
			}

			while (1) {
				int cd;
				struct sockaddr_in caddr;
				len = sizeof(caddr);
				if ((cd = accept(sd, (struct sockaddr *)&caddr, &len)) == -1) {
                	continue;
            	}
				printf("client addr%s port:%d  porit %d\n",
						inet_ntoa(caddr.sin_addr),
						ntohs(caddr.sin_port),
						i);
				close(cd);
			}
		}
	}

	sleep(1000);
	return 0;
}

下面给出客户端的python代码:

#!/usr/bin/python
# cli.py
import socket
import sys

MSG_FASTOPEN = 0x20000000

data = int(sys.argv[1])
host = '127.0.0.1'

addr = (host, 12345)
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.sendto(str(data), MSG_FASTOPEN, addr)

OK,来试一下吧。

首先使能fastopen:

sysctl -w net.ipv4.tcp_fastopen=3

启动server,然后用不同的数字作为参数运行client:

root@zhaoya-VirtualBox:/usr/py# ./cli.py 1
root@zhaoya-VirtualBox:/usr/py# ./cli.py 0
root@zhaoya-VirtualBox:/usr/py# ./cli.py 2
root@zhaoya-VirtualBox:/usr/py# ./cli.py 3
root@zhaoya-VirtualBox:/usr/py# ./cli.py 0
root@zhaoya-VirtualBox:/usr/py# ./cli.py 1
root@zhaoya-VirtualBox:/usr/py# ./cli.py 4

观察server的输出:

root@zhaoya-VirtualBox:/usr/py# ./server
clent addr127.0.0.1 port:44778  porit 1
clent addr127.0.0.1 port:44780  porit 0
clent addr127.0.0.1 port:44782  porit 2
clent addr127.0.0.1 port:44784  porit 3
clent addr127.0.0.1 port:44786  porit 0
clent addr127.0.0.1 port:44788  porit 1
clent addr127.0.0.1 port:44790  porit 0

完全正确的选择!

基本就是这么个玩法了。如果没有Fastopen,那么对于TCP REUSEPORT的bpf程序选择socket,除了映射一下队列,CPU之外,基本没得玩。

OK,现在回过头来思考一个问题,到底有没有必要用bpf程序来选择socket,我认为代价有点大:

  • 对于TCP而言,由于SYN包没有有效payload,功能支持有限。
  • 对于UDP而言,每个包都要经过REUSEPORT的bpf程序,那么一堆字节码堵在数据路径,影响性能。除非为UDP设计一个REUSEPORT的cache。
  • 即便是支持了IP头字段,仍然得不偿失,完全有另外的方案可以完成此事。

在我看来,使用BPF程序选择socket弊大于利,而且引入了复杂性,意义不大,如果实在遇到无法直接支持的需求,还是直接修改代码来live patch比较妥当。

经理皮了鞋,空悲切!


浙江温州皮鞋湿,下雨进水不会胖。

你可能感兴趣的:(reuseport,cBPF,eBPF)