关于ebpf:用eBPFXDP来替代LVS三

3次阅读

共计 7158 个字符,预计需要花费 18 分钟才能阅读完成。

随着 eBPF 的倒退,咱们曾经能够将 eBPF/XDP 程序间接部署在一般服务器上来实现负载平衡,从而去掉用于专门部署 LVS 的机器。本系列文章就是基于这个出发点,以演进的模式,剖析和探讨一些实现思路。

系列回顾

版本 0.1 分享了如何应用 xdp/ebpf 替换 lvs 来实现 slb,仍然采纳的是 slb 独立机器部署模式,并且采纳 bpftool 和硬编码配置的模式来进行加载 xdp 程序,代码在 https://github.com/MageekChiu/xdp4slb/tree/dev-0.1。

版本 0.2 在 0.1 根底上,批改为基于 bpf skeleton 的程序化加载模式,要想简略地体验下这种工作流而不改变版本 0.1 中整体部署模式的,能够去看看 https://github.com/MageekChiu/xdp4slb/tree/dev-0.2。

版本 0.3 在 0.2 根底上,反对以配置文件和命令行参数的模式动静加载 slb 配置。

版本 0.4 反对 slb 和 application 混布的模式,去除了专用的 slb 机器。混布模式使得一般机器也能间接做负载平衡,同时不影响利用(off load 模式下能够体现),有老本效益;另外,在路由到本地的场景下,缩小了路由跳数,整体性能更好。

本文属于 0.5,反对应用内核能力进行 mac 寻址、健康检查、conntrack 回收、向用户态透出统计数据等个性。
接下来别离进行介绍,如果你心愿本人试验一下,根本的环境搭建能够参考前文。

个性介绍

应用内核进行 mac 寻址

后面的版本中,咱们间接在配置文件中配置了 rs 的 mac 地址,这只是做 demo,事实中这是不太可行的,因为 ip 和 mac 的关系并不是变化无穷的。因而,咱们在每包路由的时候,须要动静填充 mac 地址。当然咱们不须要本人去实现 arp 性能,只须要应用 bpf_fib_lookup 便能够借助内核能力查问 mac 地址,这也是不采纳 kernel bypass 的益处之一,咱们在晋升了性能的同时,还能享受内核带来的红利。
次要代码如下,其中 ipv4_src 是本机 地址,ipv4_dst 是被选中的 rs ip:

static int gen_mac(struct xdp_md *ctx, struct ethhdr *eth ,struct iphdr *iph,
                    __u32 ipv4_src, __u32 ipv4_dst){
    struct bpf_fib_lookup fib_params;
    memset(&fib_params, 0, sizeof(fib_params));
    fib_params.family    = AF_INET;
    // ...

    int action = XDP_PASS;
    int rc = bpf_fib_lookup(ctx, &fib_params, sizeof(fib_params), 0);
    switch (rc) {
        case BPF_FIB_LKUP_RET_SUCCESS:         /* lookup successful */
            memcpy(eth->h_dest, fib_params.dmac, ETH_ALEN);
            memcpy(eth->h_source, fib_params.smac, ETH_ALEN);
            action = XDP_TX;
            break;
        case BPF_FIB_LKUP_RET_BLACKHOLE:    /* dest is blackholed; can be dropped */
             // ...
            action = XDP_DROP;
            break;
        case BPF_FIB_LKUP_RET_NOT_FWDED:    /* packet is not forwarded */
              // ...
            break;
    }
    return action;
}

这样咱们的配置文件中,就只须要填写 ip 而不须要 mac 地址了,如图:

然而程序间接这样跑的话,会发现寻址后果都是 not found,因为 mix 之间并没有建设起相应的 arp 表,所以接下来的健康检查就能排上用场了。

健康检查

在 slb 程序中,健康检查是一个比拟重要的的性能,可能及时剔除非衰弱节点,实现 rs 高可用。
咱们这里简略起见,让每个 mix(slb 和 app 的混布产物)在启动时,在用户态拜访一次其它所有 mix,这样 一方面能起到健康检查的作用,更重要的是,可能帮忙内核建设 arp 表,前面咱们在 xdp 中就能够间接查问,从而防止了在 xdp 本人做 arp

次要代码如下:

static int healthz_tcp(__u32 ip, __u16 port){int sockfd = socket(AF_INET, SOCK_STREAM, 0);

    struct sockaddr_in servaddr;
    servaddr.sin_family = AF_INET;
    servaddr.sin_port = port;
    servaddr.sin_addr.s_addr = ip;

    if (connect(sockfd, (struct sockaddr*)&servaddr, sizeof(servaddr)) < 0) {fprintf(stderr, "error socket conecting: \n");
        return 1;
    }
    
    close(sockfd);
    return 0; 
}

咱们能够用这些命令来察看 arp 表项

arp -a 
arp -d mix1.south 
# or
arp -d 172.19.0.9

Conntrack entry 回收

前文中,conntrack table 超过 max 后并不会导致路由产生问题(实践上),因为同一个“连贯”的哈希是统一的,采纳哈希负载平衡办法后,即便从新计算也会被调配到同一个后端。然而会导致反复计算“连贯”的哈希值,耗费 CPU。
为了防止这个问题,咱们能够把 max 调大一点(不要应用 prealloc),以反对更大的并发。然而这样就可能会导致内存的节约,因为 conntrack 的清理咱们依赖的是 LRU(被动清理),如果没有超过 max 就不会清理。所以,咱们须要加上被动清理的步骤,来回收内存。

回收大抵有两种计划:

  1. 采纳 LRU map,监听 socket 开释的事件,将事件进行 本地 / 组播 / 播送 三个级别的解决来清理对应的 conntrack entry
  2. 采纳 normal/ordered map + 工夫戳,定期遍历并清理 stale conntrack entry

内核的做法大抵是计划 2,本文采纳计划 1 来试试。

首先,取得 socket 开释事件有几种做法,依照 attach type 大抵能够分为

  1. SEC("tp_btf/inet_sock_set_state"):关注 tcp 状态转变
  2. SEC("kprobe/inet_release"):内核开释 socket 的时候会调用这个函数

因为 tracepoint 比 kprobe 更稳固,所以本文采纳计划 1,代码大抵为

SEC("tp_btf/inet_sock_set_state")
int BPF_PROG(trace_inet_sock_set_state, struct sock *sk, int oldstate,
         int newstate){
    // 只关注 tcp 敞开状态
    if (newstate != BPF_TCP_CLOSE)
        return 0;
    
    const int type = BPF_CORE_READ(sk,sk_type);
    if(type != SOCK_STREAM){//1
        return 0;
    }
    
    const struct sock_common skc = BPF_CORE_READ(sk,__sk_common);
    const __u32 dip = (BPF_CORE_READ(&skc,skc_daddr));  
    const __u16 dport = (BPF_CORE_READ(&skc,skc_dport));
    struct inet_sock *inet = (struct inet_sock *)(sk);
    const __u32 sip = (BPF_CORE_READ(inet,inet_saddr));
    const __u16 sport = (BPF_CORE_READ(inet,inet_sport));
    // 只关注跟 vip 相干的连贯
    if(sip == vip->ip_int && sport == vip->port){fire_sock_release_event(dip,dport);
    }
    return 0;
}

在 fire_sock_release_event 中真正处理事件。这里有一个点值得特地阐明,咱们的试验环境在容器中,所以以上 tp 相当于在同一个内核中挂在了 n 次(n = mix 数量),所以会被反复触发,但实际上,socket 开释只会产生在 被选中的 rs 中一次,为了防止这个缺点,咱们须要借助 bpf_get_current_cgroup_id() 来获取事件产生时的 cgroup,而后和本容器所在 cgroup_id 进行比拟,只有匹配了,才真正触发事件。

那么容器的 cgroup_id 如何获取呢?cgroup_id 实际上就是 cgroupfs 的 inode number,咱们只须要取得容器的 cgroupfs 而后获取 inode 即可,步骤能够是上面这样的(你可能须要根据你本人的发行版决定):

# 取得容器名称和 id
docker inspect -f "{{.Name}} {{.ID}}" $(docker ps -q)

# 将 id 填进去
find /sys/fs/cgroup -name "*46282095db3a*" -o -name "*33ed500a9fd9*" | \
        xargs -n1 stat --printf='\n%n %s %y %i\n'

这样咱们就能在 slb 启动时,将容器的 cgroup_id 作为参数传进去,而后处理事件时可用:

const volatile __u64 cur_cgp_id = 0;

__attribute__((always_inline)) 
static int sock_release_local(ce *nat_key){return bpf_map_delete_elem(&conntrack_map, nat_key); 
}

static void fire_sock_release_event(__u32 src_ip4,__u16 src_port){int cgrid = bpf_get_current_cgroup_id();
    if(cur_cgp_id && cur_cgp_id != cgrid){return;}
    
    ce nat_key = {
        .ip = src_ip4,
        .port = src_port
    };
    int err = sock_release_local(&nat_key);
    if(cur_clear_mode > just_local){ce *e = bpf_ringbuf_reserve(&rb, sizeof(*e), 0);
        e->ip = src_ip4;
        e->port = src_port;
        bpf_ringbuf_submit(e, 0);
    }
}

其实就是判断本实例是否应该解决这个事件,如果应该,则先清理本地(内核空间)的,如果配置了组播 / 播送,则发送到用户空间去做。用户空间代码:

static bool forge_header(const ce *e,__u32 ip, __u16 port){int fd = socket(AF_INET, SOCK_DGRAM, 0);
    struct sockaddr_in addr;
    memset(&addr, 0, sizeof(addr));
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = ip;
    addr.sin_port = port;
    int cnt = sendto(fd,e,sizeof(e),0,(struct sockaddr*) &addr,sizeof(addr));
    return true;
}

static bool do_multcast(const ce *e){return forge_header(e,env.gip.ip_int,env.gip.port);
}

static bool do_broadcast(const ce *e){
    // todo
    return true;
}

static int handle_event(void *ctx, void *data, size_t data_sz){
    const ce *e = data;
    bool sent = false;
    if(env.cur_clear_mode == group_cast){sent = do_multcast(e);
    }else if(env.cur_clear_mode == broad_cast){sent = do_broadcast(e);
    }
    return 0;
}

int main(int argc, char **argv){rb = ring_buffer__new(bpf_map__fd(skel->maps.rb), handle_event, NULL, NULL);
    while (!exiting) {err = ring_buffer__poll(rb, RING_BUFF_TIMEOUT);
        if (err < 0) {printf("Error polling perf buffer: %d\n", err);
            break;
        }
    }
}

其它 mix 收到后,间接在内核空间进行相应的清理,所以内核空间的整体架构如下:

if(iph->daddr == local_ip){// ... local handle}

int action = XDP_PASS;
if (iph->daddr == vip->ip_int){// ... vip handle}
if(gip->ip_int == iph->daddr && gip->port == dport){ce *payload  = data + sizeof(struct ethhdr) + sizeof(struct iphdr) + sizeof(struct udphdr);
    __u32 ip = payload->ip;
    __u16 port = payload->port;
    int err = sock_release_local(payload);
    return XDP_DROP;
}
return XDP_DROP;

这样整个流程就残缺了。代码实现后,咱们能够进行压测:

# 默认清理本机
./slb -i eth0 -c ./slb.conf 
# 组播清理,若在容器中应用要配置 cgroupid,按理论状况填入;非容器环境疏忽此参数
./slb -i eth0 -c ./slb.conf -g 13107 -k 3
# 不清理
./slb -i eth0 -c ./slb.conf -k 1

# client 中
ab -c 500 -n 8000 http://172.19.0.10:80/

查看 map 进行验证:

bpftool map help
bpftool map list
# 需填入理论 id
bpftool map dump id 660
bpftool map show id 660

统计数据透出

slb 中,通常还会加上一些统计数据,用于监控和计费等。
这里简略的应用全局变量,统计通过本 mix 的所有包大小,以及通过 slb 达到本 app 的包的大小。留神,间接达到 app 的包不属于 slb 性能,不在统计范畴内。外围代码如下:

volatile __u64 total_bits = 0;

volatile __u64 local_bits = 0;

if(iph->daddr == local_ip){
    return XDP_PASS;
    // this is a direct packet to rs, so doen't count for slb statics
}
if (iph->daddr == vip->ip_int){if(dport != vip->port){return XDP_DROP;}
    total_bits += pkt_sz;
    // Choose a backend server to send the request to; 
    if(rs->ip_int == local_ip){
        local_bits += pkt_sz;
        return XDP_PASS;
    }
    action = gen_mac(ctx,eth,iph,local_ip,rs->ip_int);
    return action;
}

用户态间接这样读取即可:skel->bss->total_bits,skel->bss->local_bits;
因为 0 相当于未初始化,被放在了 bss 段,对 elf 不相熟的同学,能够看看《程序员的自我涵养》这本书,对链接、装载与库介绍比拟齐备。

后记

通过几个版本的迭代,这个 slb 的外围能力曾经具备雏形了,接下来能够持续欠缺的有

  • 欠缺的边界查看,防止谬误配置
  • conntrack 清理这里被建模成一个分布式一致性问题,只是对一致性要求不高,如果你心愿更欠缺,齐全能够联合常见的分布式一致性协定来实现(如果做了集群间同步,就能够反对更多的负载平衡算法);或者重整旗鼓,比方依照内核或者 cilium 的思路来实现
  • 反对 udp 协定
  • 反对 full nat

参考

  • https://github.com/torvalds/linux/blob/master/net/netfilter/nf_conntrack_core.c
  • https://elixir.bootlin.com/linux/v6.1.11/source/kernel/bpf/ha…
  • https://elixir.bootlin.com/linux/v6.1.11/source/kernel/bpf/ha…
  • https://prototype-kernel.readthedocs.io/en/latest/bpf/ebpf_ma…
  • https://patchwork.ozlabs.org/project/netdev/patch/20180603225…
  • https://github.com/iovisor/bpftrace/issues/1500
正文完
 0