Contents

eBPF TC 程序实践

1. TC 和 eBPF

1.1 TC 简单介绍

TC(Traffic Control) 是 Linux 内核中负责流量控制的模块,早在 eBPF 出现之前,它就存在于内核中了。TC 自身的概念和设计比较复杂,这里不详细介绍。

值得关注的是,从 4.1 的内核版本开始,内核中增加了一些 TC 上的挂载点,支持让 eBPF 程序动态加载到对应位置,运行一些自定义的逻辑。

那么,TC 在内核网络栈中的位置如下(图片来自 这篇论文):

./pic_1.jpg

1.2 eBPF 中的 TC 类型

1.2.1 classifier 和 action

eBPF 的程序类型决定了程序可以调用的内核辅助函数(helper functions)和运行上下文(ctx)。eBPF 支持的类型可以查看 A thorough introduction to eBPF [LWN.net] 中 eBPF program types 小节。其中,和 TC 相关的类型如下:

BPF_PROG_TYPE_SCHED_CLS: a network traffic-control classifier
BPF_PROG_TYPE_SCHED_ACT: a network traffic-control action

BPF_PROG_TYPE_SCHED_CLS表示 eBPF 程序作为 TC 中的 classifier/filter 来运行,只能对数据包做分类,把流量划分为不同的类别,程序返回值表示是否命中当前分类。

BPF_PROG_TYPE_SCHED_ACT表示 eBPF 程序作为 TC 中的 action 来运行,程序返回值表示对这个数据包执行什么操作。在本文中会用到两个值:TC_ACT_UNSPECTC_ACT_SHOT。前者表示继续处理,后者表示将包丢弃。

所有的返回值定义可在linux/pkt_cls.h头文件中找到。

传统的 TC 需要使用 classifier 和 action 进行配合,先分类判断类别,然后再执行动作,所以要分别提供 classifier 和 action 。

1.2.2 direct action

参考:Understanding tc “direct action” mode for BPF

BPF_PROG_TYPE_SCHED_CLS类型的 eBPF 程序在实际使用时,其实是direct-action模式。简单来讲,程序在 TC 中作为 classifier,但是它的返回值可以是 action 的指令,相当于是将 classifier 和 action 的功能合二为一了,为开发提供了便利,而且在性能上也更优。

这似乎意味着BPF_PROG_TYPE_SCHED_ACT类型的程序很少会直接使用了。

1.2.3 SEC( ) 宏定义

在编写 eBPF 程序的代码时,通常都会使用SEC()宏定义来告知开发框架我们所写程序的类型,后面框架会根据识别的类型将程序 attach 到对应的挂载点上,简化开发流程。

以 libbpf 为例,早期的 TC 类型程序会使用SEC("classifier")来指明程序类型是BPF_PROG_TYPE_SCHED_CLS,在最新的 libbpf 版本中,增加了一种类型约定SEC("tc"),查看 源码 可以发现,它和SEC("classifier")的含义其实是一样的。

可能是因为 direct action 的原因,使用 classifier 类型的程序就已经可以满足完整的 TC 程序需求了,所以 libbpf 库会推荐直接使用SEC("tc")来代表 TC 场景下的程序类型,不用再区分 classifier 和 action 了(Libbpf 1.0 migration guide · libbpf/libbpf Wiki (github.com))。

2. TC 程序实践

本文想通过 TC eBPF 来对所有发往本机的 HTTP 报文进行丢包处理。

2.1 内核程序编写

前面介绍过,eBPF 的程序类型决定了程序可以调用的内核辅助函数(helper functions)和运行上下文(ctx)。

对于 TC 类型的程序,ctx 在这里表示类型为struct __sk_buff的结构体指针skb。可以在 这里 查看到该结构体的完整定义。

skb->dataskb->data_end就界定了数据包内容的范围,通过解包,可以判断各网络层采用的协议,读取到我们关注的内容。核心代码如下。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
SEC("tc")
int tc_ingress(struct __sk_buff *skb)
{
    void *data_end = (void*)(long)skb->data_end;
    struct ethhdr *eth = (struct ethhdr*)(void*)(long)skb->data;

    struct iphdr *iph = get_ipv4_hdr(eth, data_end);  // parse ip header
    if (!iph) {
        return TC_ACT_UNSPEC;  // pass, not ipv4
    }
    struct tcphdr *tcph = get_tcp_hdr(iph, data_end);  // parse tcp header
    if (!tcph) {
        return TC_ACT_UNSPEC;  // pass, not tcp
    }

    char p[PAYLOAD_PREFIX_LEN] = {0};
    u16 offset = sizeof(*eth) + sizeof(*iph) + (tcph->doff << 2);
    bpf_skb_load_bytes(skb, offset, p, PAYLOAD_PREFIX_LEN);
    
    // check whether it is a http response packet
    if (p[0] == 'H' && p[1] == 'T' && p[2] == 'T' && p[3] == 'P') {
        return TC_ACT_SHOT;  // drop the packet
    }
    return TC_ACT_UNSPEC;
}

主要逻辑就是解包读取 TCP 报文的 payload,判断是否为 HTTP 响应报文。这里用到了内核辅助函数bpf_skb_load_bytes(),从 skb 偏移为 offset 的地方,拷贝 PAYLOAD_PREFIX_LEN 个字节数据到 p。

2.2 通过 tc 命令进行加载

有了内核部分代码,可以编译生成.o文件。

1
clang -O2 -target bpf -D__TARGET_ARCH_x86_64 -c http_tc.bpf.c -o http_tc.bpf.o

随后直接用tc命令进行加载即可。

1
tc filter add dev eth1 ingress bpf da obj http_tc.bpf.o sec tc

指定网卡设备 eth1,加载到入口流量方向(ingress),da(direct-action) 模式,加载 ELF 文件中名为 tc 的 section。如果采用命令加载程序,代码里的SEC()内容是可以任意填写的,与命令中的名称对应即可。

./pic_2.jpg

查看、删除已经加载的程序:

1
2
tc filter show dev eth1 ingress
tc filter del dev eth1 ingress

./pic_3.png

2.3 通过 libbpf 库进行加载

如果使用 libbpf 库来进行加载,需要我们再额外编写一部分用户空间代码,这和开发其它类型的 eBPF 程序的流程是相同的。虽然tc命令加载比较简单,但是对于程序执行结果是无法观测的。

使用 libbpf 库来编写用户空间代码,可以利用 eBPF 的 MAP 数据结构在用户空间和内核空间进行数据传递,灵活性更高。

TC 类型程序的加载稍微复杂点,需要自己配置额外的一些信息。主要参考 这里的代码 来完成。

经本人尝试,只要指定网卡设备,流量方向似乎就可以了。除此之外,还有很多额外的控制信息,我自己也没完全弄明白,欢迎大佬们前来指点。

完整的用户空间代码可以去 这里 查看。

2.4 实现效果

程序会对所有通过eth1发往本机的 HTTP 报文做丢包处理,也就是说收不到 HTTP 的响应。

./pic_3.jpg

在未启动程序之前,curl 正常结束,输出 HTTP 响应报文。启动程序之后,curl 超时,因为响应数据包被丢掉了。程序退出后,HTTP 响应报文正常接收。

libbpf: Kernel error message: Exclusivity flag on, cannot modify

这个报错目前不知道原因,不过程序逻辑能正常执行。

3. 一些坑

3.1 SEC(“tc”) 的问题

把程序拷贝到其它机器上运行,如果出现下面的问题,说明目标机器的 libbpf 库没能正确识别到tc这个类型关键字。因为程序编译时使用的 libbpf.so,动态编译,而目标机器上共享库版本太低。 ./pic_4.jpg

./pic_5.jpg

三种解决办法。1.采用静态编译;2.升级目标机器上的库版本;3.改用老版本SEC("classifier")的写法。

3.2 iproute2 版本的问题

./pic_6.png

不是.o文件的问题,而是 iproute2 工具版本太低了。参考 这个 issue,升级即可。

1
2
3
4
5
git clone https://github.com/shemminger/iproute2.git
cd iproute2
./configure
make -j 4
make install

最新版的 iproute2 已经将 ELF support 选项开启了,直接编译安装即可。

./pic_6.jpg

安装完成后,即可正常使用tc命令进行程序加载和删除。

3.3 TC 程序卸载的问题

TC 程序特殊一点,使用 libbpf 库加载程序时,如果用户空间程序意外退出,没有手动将 eBPF 程序从挂载点卸掉,那么该程序会一直运行。从图中可以看到,Ctrl + C 终止程序后,依然可以看到内核部分所加载的程序。

./pic_7.png

所以,用户空间代码做了如下处理:

1
2
3
4
5
6
7
signal(SIGINT, sig_handler);
signal(SIGTERM, sig_handler);  // make sure the attached prog exit properly

// ...
cleanup:
    http_tc_bpf__destroy(skel);
    bpf_tc_hook_destroy(&hook);

这里处理了 SIGINT 和 SIGTERM 信号,确保程序意外退出时能主动卸掉挂载的程序。不过 SIGKILL 这个信号处理不了,如果程序被 kill -9 所杀,就只能使用tc命令去卸载程序了。

3.4 eBPF verifier 报错的问题

bpf_skb_load_bytes()是 TC 类型程序可以使用的 helper functions,用来从数据包中读取一些字节数据,但使用时并不愉快。

例如,我们想读取 TCP 报文的完整 payload,那么报文长度要提前计算好存放在变量 payload_len 中。

1
2
3
4
char payload[PAYLOAD_LEN] = {0};
u16 offset = sizeof(*eth) + sizeof(*iph) + (tcph->doff << 2);
u32 payload_len = skb->len - offset;
bpf_skb_load_bytes(skb, offset, payload, payload_len);

verifier 报错:

26: (85) call bpf_skb_load_bytes#26
R4 min value is negative, either use unsigned or 'var &= const'

这个错在使用bpf_probe_read()时也经常遇见,参考 这里 解决。

解决后,又有了新的错误:

1
2
85: (85) call bpf_skb_load_bytes#26
invalid stack type R3 off=-136 access_size=0

在这里找到一些相关资料:[PATCH net-next v2 3/3] bpf: avoid stack copy and use skb ctx for event output - Daniel Borkmann (kernel.org)

Since bpf_skb_load_bytes() currently needs to be used first, the helper needs to see a constant size on the passed stack buffer to make sure BPF verifier can do sanity checks on it during verification time.

上面验证器报的是 R3 的错,不过把 payload_len(R4) 换成一个字面值常量程序就正常了。可能,第四个参数就是没办法传一个变量吧。

4. 小结

本文记录了编写 eBPF TC 类型程序的方法,以及在实践过程中遇到的各种问题。

TC 类型程序相对 kprobe 和 tracepoint 类型程序来说较为特殊,在支持使用的 helper functions 和加载方式上都略有不同。

实践过程中,不断遇到 eBPF verifier 的报错。有大佬写了一篇 eBPF验证器原理 的文章,通过了解验证器原理,可以帮助我们快速定位错误类型,提高开发效率。