0%

eBPF tc 子系统

使用 BPF 的内核子系统也是 BPF 基础设施的一部分,像网络子系统、追踪子系统都可以 attch BPF 程序,这篇博客 中讲述了 tc 和 XDP 在 Cilium 整个数据链路的位置。在 eBPF XDP 子系统 中介绍了 XDP 子系统,网络包在还未进入网络协议栈之前就处理,带来巨大的性能提升。也正是因为协议栈此时还没有从包中提取出元数据,因此 XDP BPF 程序无法利用这些元数据。与 XDP 相比,tc BPF 程序在内核栈中稍后面的一些地方执行,因此它们可以访问更多的元数据和一 些核心的内核功能。

TC 概念

TC全称「Traffic Control」,直译过来是「流量控制」,在这个领域,你可能更熟悉的是 Linux iptables 或者 netfilter,它们都能做 packet mangling,而TC更专注于packet scheduler,所谓的网络包调度器,调度网络包的延迟、丢失、传输顺序和速度控制。

TC优势

使用并配置TC,为用户带来了对于网络包的可预测性,减少对于网络资源的争夺,实现对不同优先等级的网络服务分配网络资源(如带宽),达到互不干扰的目的,因此服务质量(QoS)一词经常被用作 TC 的代名词。

TC劣势

配置复杂性成为使用TC最显著的缺点,如果配置TC得当,可以使网络资源分配更加公平。但一旦它以不恰当的方式配置使用,可能会导致资源的进一步争夺。因此相比学习如何正确配置TC,很多IT企业可能会倾向购买更高的带宽资源。

从内核 4.1 版本起,引入了一个特殊的 qdisc,叫做 clsact,它为TC提供了一个可以加载BPF程序的入口,使TC和XDP一样,成为一个可以加载BPF程序的网络钩子。

TC vs XDP

从高层看,tc BPF 程序和 XDP BPF 程序有三点主要不同:

输入上下文

BPF 的输入上下文(input context)是一个 sk_buff 而不是 xdp_buff。当内核协议栈收到一个包时(说明包通过了 XDP 层),它会分配一个缓冲区,解析包,并存储包的元数据。表示这个包的结构体就是 sk_buff。这个结构体会暴露给 BPF 输入上下文, 因此 tc ingress 层的 BPF 程序就可以利用这些(由协议栈提取的)包的元数据。这些元数据很有用,但在包达到 tc 的 hook 点之前,协议栈执行的缓冲区分配、元数据提取和其他处理等过程也是有开销的。从定义来看,xdp_buff 不需要访问这些元数据,因为 XDP hook 在协议栈之前就会被调用。这是 XDP 和 tc hook 性能差距的重要原因之一

因此,attach 到 tc BPF hook 的 BPF 程序可以读取 skb 的 markpkt_typeprotocolpriorityqueue_mappingnapi_idcb[]hashtc_classidtc_index、vlan 元数据、XDP 层传过来的自定义元数据以及其他信息。 tc BPF 的 BPF 上下文中使用了 struct __sk_buff,这个结构体中的所有成员字段都定 义在 linux/bpf.h 系统头文件。

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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
* user accessible mirror of in-kernel sk_buff.
* new fields can only be added to the end of this structure
*/
struct __sk_buff {
__u32 len;
__u32 pkt_type;
__u32 mark;
__u32 queue_mapping;
__u32 protocol;
__u32 vlan_present;
__u32 vlan_tci;
__u32 vlan_proto;
__u32 priority;
__u32 ingress_ifindex;
__u32 ifindex;
__u32 tc_index;
__u32 cb[5];
__u32 hash;
__u32 tc_classid;
__u32 data;
__u32 data_end;
__u32 napi_id;

/* Accessed by BPF_PROG_TYPE_sk_skb types from here to ... */
__u32 family;
__u32 remote_ip4; /* Stored in network byte order */
__u32 local_ip4; /* Stored in network byte order */
__u32 remote_ip6[4]; /* Stored in network byte order */
__u32 local_ip6[4]; /* Stored in network byte order */
__u32 remote_port; /* Stored in network byte order */
__u32 local_port; /* stored in host byte order */
/* ... here. */

__u32 data_meta;
__bpf_md_ptr(struct bpf_flow_keys *, flow_keys);
__u64 tstamp;
__u32 wire_len;
__u32 gso_segs;
__bpf_md_ptr(struct bpf_sock *, sk);
};

通常来说,sk_buffxdp_buff 完全不同,二者各有优劣。

  • sk_buff 修改与其关联的元数据非常方便,但它包含了大量协议相关的信息(例如 GSO 相关的状态),这使得无法仅仅通过重写包数据来切换协议。
  • 这是因为协议栈是基于元数据处理包的,而不是每次都去读包的内容。因此,BPF 辅助函数需要额外的转换,并且还要正确处理 sk_buff 内部信息。
  • xdp_buff 没有这些问题,因为它所处的阶段非常早,此时内核还没有分配 sk_buff,因此很容易实现各种类型的数据包重写。
  • 但是,xdp_buff 的缺点是在它这个阶段进行 mangling 的时候,无法利用到 sk_buff 元数据。
  • 解决这个问题的方式是从 XDP BPF 传递自定义的元数据到 tc BPF。这样,根据使用场景的不同,可以同时利用这两者 BPF 程序,以达到互补的效果。

hook 触发点

tc BPF 程序在数据路径上的 ingress 和 egress 点都可以触发;而 XDP BPF 程序只能在 ingress 点触发。内核两个 hook 点:

  • ingress hook sch_handle_ingress():由 __netif_receive_skb_core() 触发
  • egress hook sch_handle_egress():由 __dev_queue_xmit() 触发

__netif_receive_skb_core()__dev_queue_xmit()data path 的主要接收和发送函数,不考虑 XDP 的话(XDP 可能会拦截或修改,导致不经过这两个 hook 点), 每个网络进入或离开系统的网络包都会经过这两个点,从而使得 tc BPF 程序具备完全可观测性

是否依赖驱动支持

tc BPF 程序不需要驱动做任何改动,因为它们运行在网络栈通用层中的 hook 点。因此,它们可以 attach 到任何类型的网络设备上

Ingress

这提供了很好的灵活性,但跟运行在原生 XDP 层的程序相比,性能要差一些。然而,tc BPF 程序仍然是内核的通用 data path 做完 GRO 之后、且处理任何协议之前 最早的处理点。传统的 iptables 防火墙也是在这里处理的,例如 iptables PREROUTING 或 nftables ingress hook 或其他数据包包处理过程。

Egress

类似的,对于 egress,tc BPF 程序在将包交给驱动之前的最晚的地方执行,这个地方在传统 iptables 防火墙 hook 之后(例如 iptables POSTROUTING), 但在内核 GSO 引擎之前

唯一需要驱动做改动的场景是:将 tc BPF 程序 offload 到网卡。形式通常和 XDP offload 类似,只是特性列表不同,因为二者的 BPF 输入上下文、辅助函数和返回码( verdict)不同。支持 offload tc BPF 程序的驱动 Netronome/nfp。

TC BPF

cls_bpf 分类器

运行在 tc 层的 BPF 程序使用的是 cls_bpf 分类器。在 tc 术语中 “BPF 附着点”被称为“分类器”,但这个词其实有点误导,因为它少描述了前者可以做的事情。attachment point 是一个完全可编程的包处理器,不仅能够读取 skb 元数据 和包数据,还可以任意 mangle 这两者,最后结束 tc 处理过程,返回一个裁定结果( verdict)。因此,cls_bpf 可以认为是一个管理和执行 tc BPF 程序的自包含实体( self-contained entity)。

cls_bpf 可以持有(hold)一个或多个 tc BPF 程序。Cilium 在部署 cls_bpf 程序时 ,对于一个给定的 hook 点只会附着一个程序,并且用的是 direct-action 模式。 典型情况下,在传统 tc 方案中,分类器(classifier )和动作模块(action modules) 之间是分开的,每个分类器可以 attach 多个 action,当匹配到这个分类器时这些 action 就会执行。在现代世界,在软件 data path 中使用 tc 做复杂包处理时这种模型扩展性不好。 考虑到附着到 cls_bpf 的 tc BPF 程序 是完全自包含的,因此它们有效地将解析和 action 过程融合到了单个单元(unit)中。得益于 cls_bpfdirect-action 模式,它只需要返回 tc action 判决结果,然后立即终止处理流水线。这使得能够在网络 data path 中实现可扩展可编程的包处理,避免动作的线性迭代。cls_bpf 是 tc 层中唯一支持这种快速路径(fast-path)的一个分类器模块。

和 XDP BPF 程序类似,tc BPF 程序能在运行时通过 cls_bpf 原子地更新, 而不会导致任何网络流量中断,也不用重启服务。

cls_bpf 可以附着的 tc ingress 和 egress hook 点都是由一个名为 sch_clsact 的 伪 qdisc 管理的,它是 ingress qdisc 的一个超集,可以无缝替换后者,因为它既可以管理 ingress tc hook 又可以管理 egress tc hook。对于 __dev_queue_xmit() 内的 tc egress hook,需要注意的是这个 hook 并不是在内核的 qdisc root lock 下执行的。因此,ingress 和 egress hook 都是在快速路径中以无锁( lockless)方式执行的。不管是 ingress 还是 egress,抢占(preemption )都被关闭, 执行发生在 RCU 读侧(execution happens under RCU read side)。

通常在 egress 的场景下,有很多类型的 qdisc 会 attach 到 netdevice,例如 sch_mq, sch_fq, sch_fq_codel or sch_htb,其中某些是 classful qdiscs,这些 qdisc 包 含 subclasses 因此需要一个对包进行分类的机制,决定将包 demux 到哪里。这个机制是 由调用 tcf_classify() 实现的,这个函数会进一步调用 tc 分类器(如果提供了)。在 这种场景下, cls_bpf 也可以被 attach 和使用。这种操作通常发生在 qdisc root lock 下面,因此会面临锁竞争的问题。sch_clsact qdisc 的 egress hook 点位于更前 面,没有落入这个锁的范围内,因此完全独立于常规 egress qdisc 而执行。 因此对于 sch_htb 这种场景,sch_clsact qdisc 可以将繁重的包分类工作放到 tc BPF 程序,在 qdisc root lock 之外执行,在这些 tc BPF 程序中设置 skb->markskb->priority ,因此随后 sch_htb 只需要一个简单的映射,没有原来在 root lock 下面昂贵的包分类开销,还减少了锁竞争。

sch_clsact in combination with cls_bpf 场景下支持 Offloaded tc BPF 程序, 在这种场景下,原来加载到智能网卡驱动的 BPF 程序被 JIT,在网卡原生执行。 只有工作在 direct-action 模式的 cls_bpf 程序支持 offload。 cls_bpf 只支持 offload 单个程序,不支持同时 offload 多个程序。另外,只有 ingress hook 支持 offloading BPF 程序。

一个 cls_bpf 实例内部可以 hold 多个 tc BPF 程序。如果由多个程序, TC_ACT_UNSPEC 程序返回码就是让继续执行列表中的下一个程序。但这种方式的缺点是: 每个程序都需要解析一遍数据包,性能会下降。

tc BPF 程序返回码

tc ingress 和 egress hook 共享相同的返回码(动作判决),定义在 linux/pkt_cls.h 系统头文件:

1
2
3
4
5
#define TC_ACT_UNSPEC         (-1)
#define TC_ACT_OK 0
#define TC_ACT_SHOT 2
#define TC_ACT_STOLEN 4
#define TC_ACT_REDIRECT 7

系统头文件中还有一些 TC_ACT_* 动作判决,也用在了这两个 hook 中。但是,这些判决和上面列出的那几个共享相同的语义。这意味着,从 tc BPF 的角度看, TC_ACT_OKTC_ACT_RECLASSIFY 有相同的语义, TC_ACT_STOLEN, TC_ACT_QUEUED and TC_ACT_TRAP 返回码也是类似的情况。因此, 对于这些情况,我们只描述 TC_ACT_OKTC_ACT_STOLEN 操作码。

TC_ACT_UNSPECTC_ACT_OK

TC_ACT_UNSPEC 表示“未指定的动作”(unspecified action),在三种情况下会用到:

  1. attach 了一个 offloaded tc BPF 程序,tc ingress hook 正在运行,被 offload 的 程序的 cls_bpf 表示会返回 TC_ACT_UNSPEC
  2. 为了在 cls_bpf 多程序的情况下,继续下一个 tc BPF 程序。这种情况可以和 第一种情况中提到的 offloaded tc BPF 程序一起使用,此时第一种情况返回的 TC_ACT_UNSPEC 继续执行下一个没有被 offloaded BPF 程序?
  3. TC_ACT_UNSPEC 还用于单个程序从场景,只是通知内核继续执行 skb 处理,但不要带来任何副作用。

TC_ACT_UNSPEC 在某些方面和 TC_ACT_OK 非常类似,因为二者都是将 skb 向下一个 处理阶段传递,在 ingress 的情况下是传递给内核协议栈的更上层,在 egress 的情况下 是传递给网络设备驱动。唯一的不同是 TC_ACT_OK 基于 tc BPF 程序设置的 classid 来 设置 skb->tc_index,而 TC_ACT_UNSPEC 是通过 tc BPF 程序之外的 BPF 上下文中的 skb->tc_classid 设置。

TC_ACT_SHOTTC_ACT_STOLEN

这两个返回码指示内核将包丢弃。这两个返回码很相似,只有少数几个区别:

  • TC_ACT_SHOT 提示内核 skb 是通过 kfree_skb() 释放的,并返回 NET_XMIT_DROP 给调用方,作为立即反馈
  • TC_ACT_STOLEN 通过 consume_skb() 释放 skb,返回 NET_XMIT_SUCCESS 给上 层假装这个包已经被正确发送了

perf 的丢包监控是跟踪的 kfree_skb(),因此在 TC_ACT_STOLEN 的场景下它无法看到任何丢包统计,因为从语义上说,此时这些 skb 是被”consumed” 或 queued 而不是被 dropped。

TC_ACT_REDIRECT

这个返回码加上 bpf_redirect() 辅助函数,允许重定向一个 skb 到同一个或另一个 设备的 ingress 或 egress 路径。能够将包注入另一个设备的 ingress 或 egress 路径使 得基于 BPF 的包转发具备了完全的灵活性。对目标网络设备没有额外的要求,只要本身是 一个网络设备就行了,在目标设备上不需要运行 cls_bpf 实例或其他限制。

tc BPF FAQ

本节列出一些经常被问的、与 tc BPF 程序有关的问题。

  • act_bpf 作为 tc action module 怎么样,现在用的还多吗?

    不多。虽然对于 tc BPF 程序来说 cls_bpfact_bpf 有相同的功能 ,但前者更加灵活,因为它是后者的一个超集(superset)。tc 的工作原理是将 tc actions attach 到 tc 分类器。要想实现与 cls_bpf 一样的灵活性,act_bpf 需要 被 attach 到 cls_matchall 分类器。如名字所示,为了将包传递给 attached tc action 去处理,这个分类器会匹配每一个包。相比于工作在 direct-action 模式的 cls_bpfact_bpf 这种方式会导致较低的包处理性能。如果 act_bpf 用在 cls_bpf or cls_matchall 之外的其他分类器,那性能会更差,这是由 tc 分类器的 操作特性(nature of operation of tc classifiers)决定的。同时,如果分类器 A 未 匹配,那包会传给分类器 B,B 会重新解析这个包以及重复后面的流量,因此这是一个线 性过程,在最坏的情况下需要遍历 N 个分类器才能匹配和(在匹配的分类器上)执行 act_bpf。因此,act_bpf 从未大规模使用过。另外,和 cls_bpf 相比, act_bpf 也没有提供 tc offload 接口。

  • 是否推荐在使用 cls_bpf 时选择 direct-action 之外的其他模式?

    不推荐。原因和上面的问题类似,选择其他模式无法应对更加复杂的处理情况。tc BPF 程序本身已经能以一种高效的方式做任何处理,因此除了 direct-action 这个模式 之外,不需要其他的任何东西了。

  • offloaded cls_bpf 和 offloaded XDP 有性能差异吗?

    没有。二者都是由内核内的同一个编译器 JIT 的,这个编译器负责 offload 到智能网卡,并且对二者的加载机制是非常相似的。因此,要在 NIC 上原生执行,BPF 程序会被翻译成相同的目标指令。

    tc BPF 和 XDP BPF 这两种程序类型有不同的特性集合,因此根据使用场景的不同,你可以选择 tc BPF 或者是 XDP BPF,例如,二者的在 offload 场景下的辅助函数可能会有差异。

tc BPF 使用案例

本节列出了 tc BPF 程序的主要使用案例。但要注意,这里列出的并不是全部案例,而且考虑到 tc BPF 的可编程性和效率,人们很容易对它进行定制化然后集成到编排系统,用来解决特定的问题。XDP 的一些案例可能有重叠,但 tc BPF 和 XDP BPF 大部分情况下都是互补的,可以单独使用,也可以同时使用,就看哪种情况更适合解决给定的问题了 。

  • 为容器落实策略(Policy enforcement)

    tc BPF 程序适合用来给容器实现安全策略、自定义防火墙或类似的安全工具。在传统方式中,容器隔离是通过网络命名空间时实现的,veth pair 的一端连接到宿主机的初始命名空间,另一端连接到容器的命名空间。因为 veth pair 的 一端移动到了容器的命名空间,而另一端还留在宿主机上(默认命名空间),容器所有的网络流量都需要经过主机端的 veth 设备,因此可以在这个 veth 设备的 tc ingress 和 egress hook 点 attach tc BPF 程序。目标地址是容器的网络流量会经过主机端的 veth 的 tc egress hook,而从容器出来的网络流量会经过主机端的 veth 的 tc ingress hook。

    对于像 veth 这样的虚拟设备,XDP 在这种场景下是不合适的,因为内核在这里只操作 skb,而通用 XDP 有几个限制,导致无法操作克隆的 skb。而克隆 skb 在 TCP/IP 协议栈中用的非常多,目的是持有(hold)准备重传的数据片(data segments),而通 用 XDP hook 在这种情况下回被直接绕过。另外,generic XDP 需要顺序化(linearize )整个 skb 导致严重的性能下降。相比之下, tc BPF 非常灵活,因为设计中它就是工作在接 收 skb 格式的输入上下文中,因此没有 generic XDP 遇到的那些问题。

  • 转发和负载均衡

    转发和负载均衡的使用场景和 XDP 很类似,只是目标更多的是在东西向容器流量而不是南北向(虽然两者都可以用于东西向或南北向场景)。XDP 只能在 ingress 方向使用, tc BPF 程序还可以在 egress 方向使用,例如,可以在初始命名空间内(宿主机上的 veth 设备上),通过 BPF 对容器的 egress 流量同时做地址转化(NAT)和负载均衡, 整个过程对容器是透明的。由于在内核网络栈的实现中,egress 流量已经是 sk_buff 形式的了,因此很适合 tc BPF 对其进行重写(rewrite)和重定向(redirect)。 使用 bpf_redirect() 辅助函数,BPF 就可以接管转发逻辑,将包推送到另一个网络设备的 ingress 或 egress 路径上。因此,有了 tc BPF 程序实现的转发网格( forwarding fabric),网桥设备都可以不用了。

  • 流抽样与监控

    和 XDP 类似,可以通过高性能无锁 per-CPU 内存映射 perf 环形缓冲区(ring buffer )实现流抽样(flow sampling)和监控,在这种场景下,BPF 程序能够将自定义数据、 全部或截断的包内容或者二者同时推送到一个用户空间应用。在 tc BPF 程序中这是通过 bpf_skb_event_output() BPF 辅助函数实现的,它和 bpf_xdp_event_output() 有相 同的函数签名和语义。

    考虑到 tc BPF 程序可以同时 attach 到 ingress 和 egress,而 XDP 只能 attach 到 ingress,另外,这两个 hook 都在(通用)网络栈的更低层,这使得可以监控每台节点 的所有双向网络流量。这和 tcpdump 和 Wireshark 使用的 cBPF 比较相关,但是,不需要克隆 skb,而且因为其可编程性而更加灵活,例如。BPF 能够在内核中完成聚合 ,而不用将所有数据推送到用户空间;也可以对每个放到 ring buffer 的包添加自定义 的 annotations。Cilium 大量使用了后者,对被 drop 的包进一步 annotate,关联到 容器标签以及 drop 的原因(例如因为违反了安全策略),提供了更丰富的信息。

  • 包调度器预处理(Packet scheduler pre-processing)

    sch_clsact’s egress hook 被 sch_handle_egress() 调用,在获得内核的 qdisc root lock 之前执行,因此 tc BPF 程序可以在包被发送到一个真实的 full blown qdis (例如 sch_htb)之前,用来执行包分类和 mangling 等所有这些高开销工作。 这种 sch_clsact 和后面的发送阶段的真实 qdisc(例如 sch_htb) 之间的交互, 能够减少发送时的锁竞争,因为 sch_clsact 的 egress hook 是在无锁的上下文中执行的。

同时使用 tc BPF 和 XDP BPF 程序的一个具体例子是 Cilium。Cilium 是一个开源软件, 透明地对(K8S 这样的容器编排平台中的)容器之间的网络连接进行安全保护,工作在 L3/L4/L7。Cilium 的核心基于 BPF,用来实现安全策略、负载均衡和监控。

加载 tc BPF 对象文件

用 tc 加载 BPF 程序

给定一个为 tc 编译的 BPF 对象文件 prog.o, 可以通过 tc 命令将其加载到一个网络设备。但与 XDP 不同,设备是否支持 attach BPF 程序并不依赖驱动 (即任何网络设备都支持 tc BPF)。下面的命令可以将程序 attach 到 em1ingress 网络:

1
2
$ tc qdisc add dev em1 clsact
$ tc filter add dev em1 ingress bpf da obj prog.o

第一步创建了一个 clsact qdisc (Linux 排队规则,Linux queueing discipline)。

clsact 是一个 dummy qdisc,和 ingress qdisc 类似,可以持有(hold)分类器和动作(classifier and actions),但不执行真正的排队(queueing)。后面 attach bpf 分类器需要用到它。clsact qdisc 提供了两个特殊的 hook:ingress and egress,分类器可以 attach 到这两个 hook 点。这两个 hook 都位于 datapath 的 关键收发路径上,设备 em1 的每个包都会经过这两个点。这两个 hook 分别会被下面的内 核函数调用:

  • ingress hook:__netif_receive_skb_core() -> sch_handle_ingress()
  • egress hook:__dev_queue_xmit() -> sch_handle_egress()

类似地,将程序 attach 到 egress hook:

1
$ tc filter add dev em1 egress bpf da obj prog.o

clsact qdisc ingressegress 方向以无锁(lockless)方式执行,而且 可以 attach 到虚拟的、无队列的设备(virtual, queue-less devices),例如连接容器和 宿主机的 veth 设备。

第二条命令,tc filter 选择了在 da(direct-action)模式中使用 bpfda 是 推荐的模式,并且应该永远指定这个参数。粗略地说,da 模式表示 bpf 分类器不需 要调用外部的 tc action 模块。事实上 bpf 分类器也完全不需要调用外部模块,因 为所有的 packet mangling、转发或其他类型的 action 都可以在这单个 BPF 程序内完成 ,因此执行会明显更快。

配置了这两条命令之后,程序就 attach 完成了,接下来只要有包经过这个设备,就会触发 这个程序执行。和 XDP 类似,如果没有使用默认 section 名字,那可以在加载时指定,例 如指定 section 为 foobar

1
$ tc filter add dev em1 egress bpf da obj prog.o sec foobar

iproute2 BPF 加载器的命令行语法对不同的程序类型都是一样的,因此 obj prog.o sec foobar 命令行格式和前面看到的 XDP 的加载是类似的。

查看已经 attach 的程序:

1
2
3
4
5
6
7
$ tc filter show dev em1 ingress
filter protocol all pref 49152 bpf
filter protocol all pref 49152 bpf handle 0x1 prog.o:[ingress] direct-action id 1 tag c5f7825e5dac396f

$ tc filter show dev em1 egress
filter protocol all pref 49152 bpf
filter protocol all pref 49152 bpf handle 0x1 prog.o:[egress] direct-action id 2 tag b2fd5adc0f262714

输出中的 prog.o:[ingress] 表示 section ingress 中的程序是从 文件 prog.o 加 载的,而且 bpf 工作在 direct-action 模式。上面还打印了程序的 idtag, 其中 tag 是指令流(instruction stream)的哈希,可以关联到对应的对象文件或用 perf 查看调用栈信息id 是一个操作系统层唯一的 BPF 程序标识符,可以bpftool 进一步查看或 dump 相关的程序信息

tc 可以 attach 多个 BPF 程序,并提供了其他的一些分类器,这些分类器可以 chain 到 一起使用。但是,attach 单个 BPF 程序已经完全足够了,因为有了 da 模式,所有的包 操作都可以放到同一个程序中,这意味着 BPF 程序自身将会返回 tc action verdict,例 如 TC_ACT_OKTC_ACT_SHOT 等等。出于最佳性能和灵活性考虑,这(da 模式)是推 荐的使用方式。

程序优先级和句柄

在上面的 show 命令中,tc 还打印出了 pref 49152handle 0x1。如果之前没有 通过命令行显式指定,这两个数据就会自动生成。pref 表示优先级,如果指定了多个分 类器,它们会按照优先级从高到低依次执行;handle 是一个标识符,在加载了同一分类器的多 个实例并且它们的优先级(pref)都一样的情况下会用到这个标识符。因为 在 BPF 的场景下,单个程序就足够了,因此 prefhandle 通常情况下都可以忽略

除非打算后面原子地替换 attached BPF 程序,否则不建议在加载时显式指定 prefhandle。显式指定这两个参数的好处是,后面执行 replace 操作时,就不需要再去动 态地查询这两个值。显式指定 prefhandle 时的加载命令:

1
2
3
4
5
$ tc filter add dev em1 ingress pref 1 handle 1 bpf da obj prog.o sec foobar

$ tc filter show dev em1 ingress
filter protocol all pref 1 bpf
filter protocol all pref 1 bpf handle 0x1 prog.o:[foobar] direct-action id 1 tag c5f7825e5dac396f

对应的原子 replace 命令:将 ingress hook 处的已有程序替换为 prog.o 文件中 foobar section 中的新 BPF 程序,

1
$ tc filter replace dev em1 ingress pref 1 handle 1 bpf da obj prog.o sec foobar

用 tc 删除 BPF 程序

最后,要分别从 ingressegress 删除所有 attach 的程序,执行:

1
2
$ tc filter del dev em1 ingress
$ tc filter del dev em1 egress

要从 netdevice 删除整个 clsact qdisc(会隐式地删除 attach 到 ingressegress hook 上面的所有程序),执行:

1
$ tc qdisc del dev em1 clsact

offload 到网卡

和 XDP BPF 程序类似,如果网卡驱动支持 tc BPF 程序,那也可以将它们 offload 到网卡 。Netronome 的 nfp 网卡对 XDP 和 tc BPF 程序都支持 offload。

1
2
3
4
$ tc qdisc add dev em1 clsact
$ tc filter replace dev em1 ingress pref 1 handle 1 bpf skip_sw da obj prog.o
Error: TC offload is disabled on net device.
We have an error talking to the kernel

如果显式以上错误,那需要先启用网卡的 hw-tc-offload 功能:

1
2
3
4
5
6
7
$ ethtool -K em1 hw-tc-offload on

$ tc qdisc add dev em1 clsact
$ tc filter replace dev em1 ingress pref 1 handle 1 bpf skip_sw da obj prog.o
$ tc filter show dev em1 ingress
filter protocol all pref 1 bpf
filter protocol all pref 1 bpf handle 0x1 prog.o:[classifier] direct-action skip_sw in_hw id 19 tag 57cd311f2e27366b

其中的 in_hw 标志表示这个程序已经被 offload 到网卡了。

注意,tc 和 XDP offload 无法同时加载,因此必须要指明是 tc 还是 XDP offload 选项 。

通过 netdevsim 驱动测试 BPF offload

netdevsim 驱动是 Linux 内核的一部分,它是一个 dummy driver,实现了 XDP BPF 和 tc BPF 程序的 offload 接口,以及其他一些设施,这些设施可以用来测试内核的改动,或者 某些利用内核的 UAPI 实现了一个控制平面功能的底层用户空间程序。

可以用如下命令创建一个 netdevsim 设备:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ modprobe netdevsim
// [ID] [PORT_COUNT]
$ echo "1 1" > /sys/bus/netdevsim/new_device

$ devlink dev
netdevsim/netdevsim1

$ devlink port
netdevsim/netdevsim1/0: type eth netdev eth0 flavour physical

$ ip l
[...]
4: eth0: <BROADCAST,NOARP,UP,LOWER_UP> mtu 1500 qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000
link/ether 2a:d5:cd:08:d1:3f brd ff:ff:ff:ff:ff:ff

然后就可以加载 XDP 或 tc BPF 程序,命令和前面的一些例子一样:

1
2
3
4
5
6
$ ip -force link set dev eth0 xdpoffload obj prog.o
$ ip l
[...]
4: eth0: <BROADCAST,NOARP,UP,LOWER_UP> mtu 1500 xdpoffload qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000
link/ether 2a:d5:cd:08:d1:3f brd ff:ff:ff:ff:ff:ff
prog/xdp id 16 tag a04f5eef06a7f555

这是用 iproute2 加载 XDP/tc BPF 程序的两个标准步骤。

还有很多对 XDP 和 tc 都适用的 BPF 加载器高级选项,下面列出其中一些。为简单 起见,这里只列出了 XDP 的例子。

  1. 打印更多 log(Verbose),即使命令执行成功

    在命令最后加上 verb 选项可以打印校验器的日志:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    $ ip link set dev em1 xdp obj xdp-example.o verb

    Prog section 'prog' loaded (5)!
    - Type: 6
    - Instructions: 2 (0 over limit)
    - License: GPL

    Verifier analysis:

    0: (b7) r0 = 1
    1: (95) exit
    processed 2 insns
  2. 加载已经 pin 在 BPF 文件系统中的程序

    除了从对象文件加载程序之外,iproute2 还可以从 BPF 文件系统加载程序。在某些场 景下,一些外部实体会将 BPF 程序 pin 在 BPF 文件系统并 attach 到设备。加载命 令:

    1
    $ ip link set dev em1 xdp pinned /sys/fs/bpf/prog

    iproute2 还可以使用更简短的相对路径方式(相对于 BPF 文件系统的挂载点):

    1
    $ ip link set dev em1 xdp pinned m:prog

在加载 BPF 程序时,iproute2 会自动检测挂载的文件系统实例。如果发现还没有挂载,tc 就会自动将其挂载到默认位置 /sys/fs/bpf/

如果发现已经挂载了一个 BPF 文件系统实例,接下来就会使用这个实例,不会再挂载新的 了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ mkdir /var/run/bpf
$ mount --bind /var/run/bpf /var/run/bpf
$ mount -t bpf bpf /var/run/bpf

$ tc filter add dev em1 ingress bpf da obj tc-example.o sec prog

$ tree /var/run/bpf
/var/run/bpf
+-- ip -> /run/bpf/tc/
+-- tc
| +-- globals
| +-- jmp_map
+-- xdp -> /run/bpf/tc/

4 directories, 1 file

默认情况下,tc 会创建一个如上面所示的初始目录,所有子系统的用户都会通过符号 链接(symbolic links)指向相同的位置,也是就是 globals 命名空间,因此 pinned BPF maps 可以被 iproute2 中不同类型的 BPF 程序使用。如果文件系统实例已经挂载、 目录已经存在,那 tc 是不会覆盖这个目录的。因此对于 lwt, tcxdp 这几种类 型的 BPF maps,可以从 globals 中分离出来,放到各自的目录存放。

在前面的 LLVM 小节中简要介绍过,安装 iproute2 时会向系统中安装一个头文件,BPF 程 序可以直接以标准路(standard include path)径来 include 这个头文件:

1
#include <iproute2/bpf_elf.h>

这个头文件中提供的 API 可以让程序使用 maps 和默认 section 名字。它是 iproute2 和 BPF 程序之间的一份稳定契约(contract )。

iproute2 中 map 的定义是 struct bpf_elf_map。这个结构体内的成员变量已经在 LLVM 小节中介绍过了。

When parsing the BPF object file, the iproute2 loader will walk through all ELF sections. It initially fetches ancillary sections like maps and license. For maps, the struct bpf_elf_map array will be checked for validity and whenever needed, compatibility workarounds are performed. Subsequently all maps are created with the user provided information, either retrieved as a pinned object, or newly created and then pinned into the BPF file system. Next the loader will handle all program sections that contain ELF relocation entries for maps, meaning that BPF instructions loading map file descriptors into registers are rewritten so that the corresponding map file descriptors are encoded into the instructions immediate value, in order for the kernel to be able to convert them later on into map kernel pointers. After that all the programs themselves are created through the BPF system call, and tail called maps, if present, updated with the program’s file descriptors.

参考资料