eBPF让Linux内核(其它OS内核对eBPF的支持,我不清楚,仅谈Linux)本身变得可编程,前面我已经展示了eBPF很多的trick用法,本文我来展示如何让eBPF干涉socket的查找。
我们知道,数据包到达四层后,会进行socket查找,以TCP为例,这个过程在tcp_v4_rcv函数中执行,所谓的查找过程就是一个简单的hash查找,然而hash查找算法的执行过程会随着输入数据的不同而产生畸变,由于代码是写死的,我们对这种畸变束手无策,只能寄希望于实现更好的hash算法。
eBPF改变了这一切,它可以让socket的查找过程变得可编程,你可以自定义自己的查找算法,绕开内核提供的标准hash查找过程。
先从一个概念,Anycast,开始。
Anycast即多个节点对外通告相同的地址,通过就近路由来寻址最合适的节点。采用Anycast有两个明显的益处:
由于任何运营商都不会通告/32的前缀,甚至/24的很少通告,因此可以将IP地址的最后几位用于负载均衡,因此,一个服务器节点一般通告一个Anycast网段而不是一个/32地址,比如 192.168.40.0/24 。
这有几个益处:
类似的用法,参见iptables的 CLUSTERIP ,我之前写过一篇分析文章:
https://blog.csdn.net/dog250/article/details/77993563
现在言归正传,虽然可以通告一个网段,但是一个TCP服务却无法侦听一个网段,一个TCP服务要么侦听特定的/32地址,要么侦听0.0.0.0,别无它选。
假设一个服务器上驻留了两个服务,服务A通告192.168.40.0/24网段的80端口,服务B通告172.16.40.0/24网段的80端口,如何?
显然,侦听0.0.0.0:80是不可以的,这样会混杂两个服务,所以,你必须显示侦听所有这2段/24的地址,一共512个,一个socket只能侦听1个IP/Port元组,这意味着你必须显示创建512个socket,然而你的本意可能只需要2个就够了,一个侦听192.168.40.0/24:80,另一个侦听172.16.40.0/24:80,问题又回到了原点,怎么办?
一个见招拆招的方案就是 允许一个socket侦听一个非/32段。 这意味着要修改内核。我们可以找到这个patch:
https://www.spinics.net/lists/netdev/msg370789.html
我很赞同这种风格,事实上我出过很多如此捣鼓的方案。
然而,eBPF是更好的选择:
Programming socket lookup with BPF: https://lwn.net/Articles/797596/
这个topic事实上新增了一种eBPF类型(这种新增类型的事情一直在持续发生),我们只需要看一下代码就知道发生了什么。
遗憾的是,在Linux 5.5的合并窗口,这个patch目前依然没有进入主线,我们只能从支线来拉取代码。不管怎样,我觉得它是有意义的,甚至可以和eBPF的REUSEPORT作用点进行合并。
首先,我们把patch该功能的代码拉下来:
git clone https://github.com/jsitnicki/linux.git
然后翻阅 net/ipv4/inet_hashtables.c 文件的 __inet_lookup_listener函数:
struct sock *__inet_lookup_listener(struct net *net,
struct inet_hashinfo *hashinfo,
struct sk_buff *skb, int doff,
const __be32 saddr, __be16 sport,
const __be32 daddr, const unsigned short hnum,
const int dif, const int sdif)
{
struct inet_listen_hashbucket *ilb2;
struct sock *result = NULL;
unsigned int hash2;
// 新增逻辑!增加了对eBPF程序的调用支持
result = inet_lookup_run_bpf(net, hashinfo->protocol,
saddr, sport, daddr, hnum);
if (result)
goto done;
// 即便这里采用了2元组的hash2替代了仅仅端口hash的listen_hash,
// 在海量listener情况下依然存在冲突链表过长的问题,毕竟hash桶是宏定义写死的。
hash2 = ipv4_portaddr_hash(net, daddr, hnum);
ilb2 = inet_lhash2_bucket(hashinfo, hash2);
result = inet_lhash2_lookup(net, ilb2, skb, doff,
saddr, sport, daddr, hnum,
dif, sdif);
if (result)
goto done;
/* Lookup lhash2 with INADDR_ANY */
hash2 = ipv4_portaddr_hash(net, htonl(INADDR_ANY), hnum);
ilb2 = inet_lhash2_bucket(hashinfo, hash2);
result = inet_lhash2_lookup(net, ilb2, skb, doff,
saddr, sport, htonl(INADDR_ANY), hnum,
dif, sdif);
done:
if (IS_ERR(result))
return NULL;
return result;
}
我们可以在eBPF程序中大有所为:
static inline struct sock *__inet_lookup_run_bpf(const struct net *net,
struct bpf_inet_lookup_kern *ctx)
{
struct bpf_prog *prog;
int ret = BPF_OK;
rcu_read_lock();
prog = rcu_dereference(net->inet_lookup_prog);
if (prog) // 这里定义我们自己的lookup逻辑就是了。
ret = BPF_PROG_RUN(prog, ctx);
rcu_read_unlock();
return ret == BPF_REDIRECT ? ctx->redir_sk : NULL;
}
现在有解了,还是上面的需求,我们可以让两个服务均侦听0.0.0.0:80,然后用eBPF程序进行分流:
#define NET1 (IP4(192, 168, 40, 0) >> 8)
#define NET2 (IP4(172, 16, 40, 0) >> 8)
struct {
__uint(type, BPF_MAP_TYPE_REUSEPORT_SOCKARRAY);
__uint(max_entries, MAX_SERVERS);
__type(key, __u32);
__type(value, __u64);
} redir_map SEC(".maps");
SEC("inet_lookup/demo_two_servers")
int demo_two_http_servers(struct bpf_inet_lookup *ctx)
{
__u32 index = 0;
__u64 flags = 0;
if (ctx->family != AF_INET)
return BPF_OK;
if (ctx->protocol != IPPROTO_TCP)
return BPF_OK;
if (ctx->local_port != 80)
return BPF_OK;
switch (bpf_ntohl(ctx->local_ip4) >> 8) {
case NET1:
index = 0;
break;
case NET2:
index = 1;
break;
default:
return BPF_OK;
}
return bpf_redirect_lookup(ctx, &redir_map, &index, flags);
}
我们在两个用户态的服务里所要做的,仅仅是:
是不是非常有意义!
事实上,socket查找这件事是非常灵活的,TPROXY就是其简单的case之一。我们考虑以下场景:
此时如果从另一台机器发起一个到 192.168.56.110:1234 的TCP连接,很明显是不通的,理由就是用192.168.56.110:1234作为key查找socket表的时候,没有发现有任何socket侦听1234端口。
然而这只是常规的hash元组匹配算法的结果,如果你手工指定 就是让这个访问到达192.168.56.110:80这个socket ,那么事实上也是可以建连成功的,TPROXY就是干这个的,这只需要在192.168.56.110上配置下面的iptables规则:
iptables -t mangle -A PREROUTING -p tcp --dport 1234 -j TPROXY --on-port 80
这个时候,你再试试?
实际上,TPROXY还是有所限制的,比如其侦听socket必须携带TRANSPARENT选项,毕竟从TPROXY顾名思义,它截获的包一般目的地并不是它自己,它只是一个代理。
用eBPF实现上面的从1234端口到80端口的重定向就简单多了:
SEC("inet_lookup/demo_two_servers")
int redirect_to_port_80(struct bpf_inet_lookup *ctx)
{
__u32 index = 0;
if (ctx->family != AF_INET)
return BPF_OK;
if (ctx->protocol != IPPROTO_TCP)
return BPF_OK;
if (ctx->local_port != 1234)
return BPF_OK;
// 80服务socket在服务进程中以index=0插入到map中即可!
return bpf_redirect_lookup(ctx, &sk_map, &index, 0);
}
有点意思。虽然经理可能并不认同。
浙江温州皮鞋湿,下雨进水不会胖。