Traffic Control (TC,流量控制) 允许我们在网络数据包进入或离开网络接口时,运行可编程的逻辑。eBPF程序可以附加到TC的钩子点上,实现对网络流量的深度监控与处理。
本文将以一个简单的示例程序入手,解析其工作原理与挂载流程。
eBPF 程序解析
参考示例:https://github.com/libbpf/libbpf-bootstrap/blob/master/examples/c/tc.bpf.c
SEC(“tc”)
int tc_ingress(struct __sk_buff *ctx) {
void *data_end = (void *)(__u64)ctx->data_end;
void *data = (void *)(__u64)ctx->data;
struct ethhdr *l2;
struct iphdr *l3;
if (ctx->protocol != bpf_htons(ETH_P_IP))
return TC_ACT_OK;
l2 = data;
if ((void *)(l2 + 1) > data_end)
return TC_ACT_OK;
l3 = (struct iphdr *)(l2 + 1);
if ((void *)(l3 + 1) > data_end)
return TC_ACT_OK;
bpf_printk(“Got IP packet: tot_len: %d, ttl: %d”, bpf_ntohs(l3->tot_len), l3->ttl);
return TC_ACT_OK;
}
示例程序中,section 被定义为 SEC(“tc”)。根据 libbpf 的源码定义,tc 和 classifier 都是历史遗留的等价定义,现代写法更推荐使用 tcx。
程序逻辑本身并不复杂,但必须重视对数据包长度(如 data 与 data_end)的边界检查。因为 eBPF 校验器在编译和加载时会强制验证这些安全检查逻辑,缺少它们将导致程序加载失败。
此外,细心的读者可能会发现,与 socketfilter 类型的程序不同,这里的程序直接通过结构体指针(如 l3->tot_len)访问数据包字段。这背后的原因与程序类型的安全模型有关:
- Socket Filter (套接字过滤器):这类程序最初设计允许非特权用户加载过滤规则。直接解引用复杂的内核结构体指针存在安全风险,例如可能访问到未初始化或内核内部的敏感字段。因此,访问数据包内容必须通过 Helper Functions 进行。
- TC (流量控制):它运行在
struct __sk_buff 上下文(在 Linux 内核源码中定义)中。内核为 TC 程序显式暴露了该结构体中部分被认为是安全且稳定的字段,允许直接访问(例如 ifindex、priority 等)。对于修改数据包或访问非线性数据等更复杂的操作,则仍需调用特定的 Helper Functions。
用户态挂载逻辑分析
用户态程序负责将编译好的 eBPF 程序加载并附加到目标网络接口上。核心步骤如下:
首先,定义一个 bpf_tc_hook,指定要附加的网络接口和方向。
DECLARE_LIBBPF_OPTS(bpf_tc_hook, tc_hook,
.ifindex = LO_IFINDEX,
.attach_point = BPF_TC_INGRESS);
.ifindex = LO_IFINDEX: 指定要挂载 eBPF 程序的网络接口索引。LO_IFINDEX 通常指回环接口 lo。
.attach_point = BPF_TC_INGRESS: 指定挂载方向为入口(Ingress),即在网卡接收数据包时触发程序执行。
接着,配置 bpf_tc_opts,设置优先级和句柄。
DECLARE_LIBBPF_OPTS(bpf_tc_opts, tc_opts,
.handle = 1,
.priority = 1);
.priority (优先级):决定多个过滤器(Filter)的执行顺序。数值越小,优先级越高。
- 内核会先执行
priority = 1 的程序。
- 如果该程序返回
TC_ACT_OK 或未匹配,内核才会继续执行 priority = 2 的程序,依此类推。
- 若前面的程序决定丢弃 (
TC_ACT_SHOT),后续程序将无法看到该数据包。
.handle (句柄):在同一优先级下,用于唯一标识一个具体的规则实例。如果设为 0,内核会分配一个随机 ID。若需更新正在运行的程序(例如修复 Bug),可以用相同的 .priority 和 .handle 配合 BPF_TC_F_REPLACE 标志重新加载,内核会以原子方式替换旧程序,避免流量中断或规则重复。
然后,调用 bpf_tc_hook_create 创建 TC 钩子。
err = bpf_tc_hook_create(&tc_hook);
这个函数是理解TC挂载底层原理的关键。它本质上是对 Netlink 协议的封装,用于配置 Linux 内核的流量控制子系统。Netlink 是Linux内核与用户空间进行通信的专用 IPC 机制。
bpf_tc_hook_create 的实现(通常在 libbpf 的 src/net.c 中)会构建一个 Netlink 消息,其核心是填充 struct tcmsg 结构,并通过 TLV (Type-Length-Value) 格式附加属性。关键步骤包括:
- 设置消息类型
RTM_NEWQDISC,表示要创建一个新的队列规则 (qdisc)。
- 在
tcmsg 中,将 tcm_parent 设置为 TC_H_CLSACT。这告知内核要创建的是一个专为 eBPF 设计的、高效的 clsact qdisc。
- 添加
TCA_KIND 属性,值为 ”clsact”,明确指定 qdisc 的类型。
整个函数的目的,就是将用户态的配置请求“翻译”成内核能理解的 Netlink 数据结构,并通过 socket 发送给内核,从而完成对网络配置的修改。
最后,将 eBPF 程序附加到创建好的钩子上。
tc_opts.prog_fd = bpf_program__fd(skel->progs.tc_ingress);
err = bpf_tc_attach(&tc_hook, &tc_opts);
// … 程序运行 …
err = bpf_tc_detach(&tc_hook, &tc_opts); // 卸载程序
bpf_program__fd 从 eBPF 骨架对象中获取编译好的程序文件描述符(FD)。bpf_tc_attach 最终将这个 FD 与之前配置好的 TC 钩子关联起来。对应的 bpf_tc_detach 则用于卸载程序。