eBPF TC 程序实践
1. TC 和 eBPF
1.1 TC 简单介绍
TC(Traffic Control) 是 Linux 内核中负责流量控制的模块,早在 eBPF 出现之前,它就存在于内核中了。TC 自身的概念和设计比较复杂,这里不详细介绍。
值得关注的是,从 4.1 的内核版本开始,内核中增加了一些 TC 上的挂载点,支持让 eBPF 程序动态加载到对应位置,运行一些自定义的逻辑。
那么,TC 在内核网络栈中的位置如下(图片来自 这篇论文):
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_UNSPEC
和TC_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->data
和skb->data_end
就界定了数据包内容的范围,通过解包,可以判断各网络层采用的协议,读取到我们关注的内容。核心代码如下。
|
|
主要逻辑就是解包读取 TCP 报文的 payload,判断是否为 HTTP 响应报文。这里用到了内核辅助函数bpf_skb_load_bytes()
,从 skb 偏移为 offset 的地方,拷贝 PAYLOAD_PREFIX_LEN 个字节数据到 p。
2.2 通过 tc 命令进行加载
有了内核部分代码,可以编译生成.o
文件。
|
|
随后直接用tc
命令进行加载即可。
|
|
指定网卡设备 eth1,加载到入口流量方向(ingress),da(direct-action) 模式,加载 ELF 文件中名为 tc 的 section。如果采用命令加载程序,代码里的SEC()
内容是可以任意填写的,与命令中的名称对应即可。
查看、删除已经加载的程序:
|
|
2.3 通过 libbpf 库进行加载
如果使用 libbpf 库来进行加载,需要我们再额外编写一部分用户空间代码,这和开发其它类型的 eBPF 程序的流程是相同的。虽然tc
命令加载比较简单,但是对于程序执行结果是无法观测的。
使用 libbpf 库来编写用户空间代码,可以利用 eBPF 的 MAP 数据结构在用户空间和内核空间进行数据传递,灵活性更高。
TC 类型程序的加载稍微复杂点,需要自己配置额外的一些信息。主要参考 这里的代码 来完成。
经本人尝试,只要指定网卡设备,流量方向似乎就可以了。除此之外,还有很多额外的控制信息,我自己也没完全弄明白,欢迎大佬们前来指点。
完整的用户空间代码可以去 这里 查看。
2.4 实现效果
程序会对所有通过eth1
发往本机的 HTTP 报文做丢包处理,也就是说收不到 HTTP 的响应。
在未启动程序之前,curl 正常结束,输出 HTTP 响应报文。启动程序之后,curl 超时,因为响应数据包被丢掉了。程序退出后,HTTP 响应报文正常接收。
libbpf: Kernel error message: Exclusivity flag on, cannot modify
这个报错目前不知道原因,不过程序逻辑能正常执行。
3. 一些坑
3.1 SEC(“tc”) 的问题
把程序拷贝到其它机器上运行,如果出现下面的问题,说明目标机器的 libbpf 库没能正确识别到tc
这个类型关键字。因为程序编译时使用的 libbpf.so,动态编译,而目标机器上共享库版本太低。
三种解决办法。1.采用静态编译;2.升级目标机器上的库版本;3.改用老版本SEC("classifier")
的写法。
3.2 iproute2 版本的问题
不是.o
文件的问题,而是 iproute2 工具版本太低了。参考 这个 issue,升级即可。
|
|
最新版的 iproute2 已经将 ELF support 选项开启了,直接编译安装即可。
安装完成后,即可正常使用tc
命令进行程序加载和删除。
3.3 TC 程序卸载的问题
TC 程序特殊一点,使用 libbpf 库加载程序时,如果用户空间程序意外退出,没有手动将 eBPF 程序从挂载点卸掉,那么该程序会一直运行。从图中可以看到,Ctrl + C 终止程序后,依然可以看到内核部分所加载的程序。
所以,用户空间代码做了如下处理:
|
|
这里处理了 SIGINT 和 SIGTERM 信号,确保程序意外退出时能主动卸掉挂载的程序。不过 SIGKILL 这个信号处理不了,如果程序被 kill -9 所杀,就只能使用tc
命令去卸载程序了。
3.4 eBPF verifier 报错的问题
bpf_skb_load_bytes()
是 TC 类型程序可以使用的 helper functions,用来从数据包中读取一些字节数据,但使用时并不愉快。
例如,我们想读取 TCP 报文的完整 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()
时也经常遇见,参考 这里 解决。
解决后,又有了新的错误:
|
|
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验证器原理 的文章,通过了解验证器原理,可以帮助我们快速定位错误类型,提高开发效率。