我想用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 <stdlib.h>
#include <stdio.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <strings.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <linux/filter.h>
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比较妥当。
…
经理皮了鞋,空悲切!
浙江温州皮鞋湿,下雨进水不会胖。
来源:oschina
链接:https://my.oschina.net/u/4402583/blog/4341658