eBPF对TCP listen socket lookup的逻辑进行重定义

eBPF让Linux内核(其它OS内核对eBPF的支持,我不清楚,仅谈Linux)本身变得可编程,前面我已经展示了eBPF很多的trick用法,本文我来展示如何让eBPF干涉socket的查找。

我们知道,数据包到达四层后,会进行socket查找,以TCP为例,这个过程在tcp_v4_rcv函数中执行,所谓的查找过程就是一个简单的hash查找,然而hash查找算法的执行过程会随着输入数据的不同而产生畸变,由于代码是写死的,我们对这种畸变束手无策,只能寄希望于实现更好的hash算法。

eBPF改变了这一切,它可以让socket的查找过程变得可编程,你可以自定义自己的查找算法,绕开内核提供的标准hash查找过程。

先从一个概念,Anycast,开始。

Anycast即多个节点对外通告相同的地址,通过就近路由来寻址最合适的节点。采用Anycast有两个明显的益处:

  1. 实现广域范围的负载均衡。
  2. 所有节点配置相同,可以透明无缝迁移。

由于任何运营商都不会通告/32的前缀,甚至/24的很少通告,因此可以将IP地址的最后几位用于负载均衡,因此,一个服务器节点一般通告一个Anycast网段而不是一个/32地址,比如 192.168.40.0/24

这有几个益处:

  • 广域范围,DNS调度系统可以为用户吐192.168.40.0/24段的任意地址,实现地址隐藏。
  • 局域网范围,前端LB(比如LVS)可以target 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);
}

我们在两个用户态的服务里所要做的,仅仅是:

  1. 将上述eBPF程序载入内核。
  2. 找到redir_map这个map。
  3. 将通告192.168.40.0/24的服务socket以index=0插入map。
  4. 将通告172.16.40.0/24的服务socket以index=1插入map。

是不是非常有意义!


事实上,socket查找这件事是非常灵活的,TPROXY就是其简单的case之一。我们考虑以下场景:

  • 服务器侦听192.168.56.110:80

此时如果从另一台机器发起一个到 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);
}

有点意思。虽然经理可能并不认同。


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

你可能感兴趣的:(eBPF对TCP listen socket lookup的逻辑进行重定义)