天天看点

全面介绍eBPF-概念

全面介绍eBPF-概念

前面介绍了BCC可观测性和BCC网络,但对底层使用的eBPF的介绍相对较少,且官方欠缺对网络方面的介绍。下面对eBPF进行全面介绍。

目录

    • BPF概述
      • eBPF的演进
      • 使用eBPF可以做什么?
      • 内核的eBPF校验器
      • bpf()系统调用
      • eBPF 程序类型
        • eBPF 数据结构
      • eBPF辅助函数
      • 如何编写eBPF程序
    • seccomp 概述
      • 历史
      • BPF
      • 编写过滤器
    • XDP
      • XDP模式
        • 模式介绍
        • 模式校验
      • XDP Action
        • AF_XDP
          • 术语
            • UMEM
            • Rings
            • UMEM Fill Ring
            • UMEM Completion Ring
            • RX Ring
            • TX Ring
          • XSKMAP / BPF_MAP_TYPE_XSKMAP
          • 配置标志位和socket选项
            • XDP_COPY 和XDP_ZERO_COPY bind标志
            • XDP_SHARED_UMEM bind 标志
            • XDP_USE_NEED_WAKEUP bind标志
            • XDP_{RX|TX|UMEM_FILL|UMEM_COMPLETION}_RING setsockopts
            • XDP_UMEM_REG setsockopt
            • XDP_STATISTICS getsockopt
            • XDP_OPTIONS getsockopt
    • TC
      • 返回码
      • 加载tc BPF程序

下面内容来自Linux官方文档:

最初的[Berkeley Packet Filter (BPF) PDF]是为捕捉和过滤符合特定规则的网络包而设计的,过滤器为运行在基于寄存器的虚拟机上的程序。

在内核中运行用户指定的程序被证明是一种有用的设计,但最初BPF设计中的一些特性却并没有得到很好的支持。例如,虚拟机的指令集架构(ISA)相对落后,现在处理器已经使用64位的寄存器,并为多核系统引入了新的指令,如原子指令XADD。BPF提供的一小部分RISC指令已经无法在现有的处理器上使用。

因此Alexei Starovoitov在eBPF的设计中介绍了如何利用现代硬件,使eBPF虚拟机更接近当代处理器,eBPF指令更接近硬件的ISA,便于提升性能。其中最大的变动之一是使用了64位的寄存器,并将寄存器的数量从2提升到了10个。由于现代架构使用的寄存器远远大于10个,这样就可以像本机硬件一样将参数通过eBPF虚拟机寄存器传递给对应的函数。另外,新增的

BPF_CALL

指令使得调用内核函数更加便利。

将eBPF映射到本机指令有助于实时编译,提升性能。3.15内核中新增的eBPF补丁使得x86-64上运行的eBPF相比老的BPF(cBPF)在网络过滤上的性能提升了4倍,大部分情况下会保持1.5倍的性能提升。很多架构 (x86-64, SPARC, PowerPC, ARM, arm64, MIPS, and s390)已经支持即时(JIT)编译。

一个eBPF程序会附加到指定的内核代码路径中,当执行该代码路径时,会执行对应的eBPF程序。鉴于它的起源,eBPF特别适合编写网络程序,将该网络程序附加到网络socket,进行流量过滤,流量分类以及执行网络分类器的动作。eBPF程序甚至可以修改一个已建链的网络socket的配置。XDP工程会在网络栈的底层运行eBPF程序,高性能地进行处理接收到的报文。从下图可以看到eBPF支持的功能:

全面介绍eBPF-概念
BPF对网络的处理可以分为tc/BPF和XDP/BPF,它们的主要区别如下(参考该文档):
  • XDP的钩子要早于tc,因此性能更高:tc钩子使用

    sk_buff

    结构体作为参数,而XDP使用

    xdp_md

    结构体作为参数,

    sk_buff

    中的数据要远多于

    xdp_md

    ,但也会对性能造成一定影响,且报文需要上送到tc钩子才会触发处理程序。由于XDP钩子位于网络栈之前,因此XDP使用的

    xdp_buff

    (即

    xdp_md

    )无法访问sk_buff元数据。
struct xdp_buff {  /* Linux 5.8*/
	void *data;
	void *data_end;
	void *data_meta;
	void *data_hard_start;
	struct xdp_rxq_info *rxq;
	struct xdp_txq_info *txq;
	u32 frame_sz; /* frame size to deduce data_hard_end/reserved tailroom*/
};
struct xdp_rxq_info {
	struct net_device *dev;
	u32 queue_index;
	u32 reg_state;
	struct xdp_mem_info mem;
} ____cacheline_aligned; /* perf critical, avoid false-sharing */

struct xdp_txq_info {
	struct net_device *dev;
};
           

data

指向page中的数据包的起始位置,

data_end

指向数据包的结尾。由于XDP允许

headroom

(见下文),

data_hard_start

指向page中

headroom

的起始位置,即,当对报文进行封装时,

data

bpf_xdp_adjust_head()

通过向

data_hard_start

移动。相同的BPF辅助函数也可以用以解封转,此时

data

会远离

data_hard_start

data_meta

一开始指向与

data

相同的位置,但

bpf_xdp_adjust_meta()

能够将其朝着

data_hard_start

移动,进而给用户元数据提供空间,这部分空间对内核网络栈是不可见的,但可以被tc BPF程序读取( tc 需要将它从 XDP 转移到

skb

)。反之,可以通过相同的BPF程序将

data_meta

远离

data_hard_start

来移除或减少用户元数据大小。

data_meta

还可以地单纯用于在尾调用间传递状态,与tc BPF程序访问的skb->cb[]控制块类似。

对于

struct xdp_buff

中的报文指针,有如下关系 :

data_hard_start

<=

data_meta

data

<

data_end

rxq

字段指向在ring启动期间填充的额外的与每个接受队列相关的元数据。

BPF程序可以检索

queue_index

,以及网络设备上的其他数据(如

ifindex

等)。
  • tc能够更好地管理报文:tc的BPF输入上下文是一个

    sk_buff

    ,不同于XDP使用的

    xdp_buff

    ,二者各有利弊。当内核的网络栈在XDP层之后接收到一个报文时,会分配一个buffer,解析并保存报文的元数据,这些元数据即

    sk_buff

    。该结构体会暴露给BPF的输入上下文,这样tc ingress层的tc BPF程序就能够使用网络栈从报文解析到的元数据。使用

    sk_buff

    ,tc可以更直接地使用这些元数据,因此附加到tc BPF钩子的BPF程序可以读取或写入skb的mark,pkt_type, protocol, priority, queue_mapping, napi_id, cb[] array, hash, tc_classid 或 tc_index, vlan metadata等,而XDP能够传输用户的元数据以及其他信息。tc BPF使用的

    struct __sk_buff

    定义在linux/bpf.h头文件中。xdp_buff 的弊端在于,其无法使用sk_buff中的数据,XDP只能使用原始的报文数据,并传输用户元数据。
  • XDP的能够更快地修改报文:sk_buff包含很多协议相关的信息(如GSO阶段的信息),因此其很难通过简单地修改报文数据达到切换协议的目的,原因是网络栈对报文的处理主要基于报文的元数据,而非每次访问数据包内容的开销。因此,BPF辅助函数需要正确处理内部

    sk_buff

    的转换。而

    xdp_buff

    则不会有这种问题,因为XDP的处理时间早于内核分配sk_buff的时间,因此可以简单地实现对任何报文的修改(但管理起来要更加困难)。
  • tc/ebpf和xdp可以互补:如果用户需要修改报文,同时对数据进行比较复杂的管理,那么,可以通过运行两种类型的程序来弥补每种程序类型的局限性。XDP程序位于ingress,可以修改完整的报文,并将用户元数据从XDP BPF传递给tc BPF,然后tc可以使用XDP的元数据和

    sk_buff

    字段管理报文。
  • tc/eBPF可以作用于ingress和egress,但XDP只能作用于ingress:与XDP相比,tc BPF程序可以在ingress和egress的网络数据路径上触发,而XDP只能作用于ingress。
  • tc/BPF不需要改变硬件驱动,而XDP通常会使用native驱动模式来获得更高的性能。但tc BPF程序的处理仍作用于早期的内核网络数据路径上(GRO处理之后,协议处理和传统的iptables防火墙的处理之前,如iptables PREROUTING或nftables ingress钩子等)。而在egress上,tc BPF程序在将报文传递给驱动之前进行处理,即在传统的iptables防火墙(如iptables POSTROUTING)之后,但在内核的GSO引擎之前进行处理。一个特殊情况是,如果使用了offloaded的tc BPF程序(通常通过SmartNIC提供),此时Offloaded tc/eBPF接近于Offloaded XDP的性能。
从下图可以看到TC和XDP的工作位置,可以看到XDP对报文的处理要先于TC:
全面介绍eBPF-概念

内核执行的另一种过滤类型是限制进程可以使用的系统调用。通过seccomp BPF实现。

eBPF也可以用于通过将程序附加到

tracepoints

,

kprobes

,和

perf events

的方式定位内核问题,以及进行性能分析。因为eBPF可以访问内核数据结构,开发者可以在不编译内核的前提下编写并测试代码。对于工作繁忙的工程师,通过该方式可以方便地调试一个在线运行的系统。此外,还可以通过静态定义的追踪点调试用户空间的程序(即BCC调试用户程序,如Mysql)。

使用eBPF有两大优势:快速,安全。为了更好地使用eBPF,需要了解它是如何工作的。

在内核中运行用户空间的代码可能会存在安全和稳定性风险。因此,在加载eBPF程序前需要进行大量校验。首先通过对程序控制流的深度优先搜索保证eBPF能够正常结束,不会因为任何循环导致内核锁定。严禁使用无法到达的指令;任何包含无法到达的指令的程序都会导致加载失败。

第二个阶段涉及使用校验器模拟执行eBPF程序(每次执行一个指令)。在每次指令执行前后都需要校验虚拟机的状态,保证寄存器和栈的状态都是有效的。严禁越界(代码)跳跃,以及访问越界数据。

校验器不会检查程序的每条路径,它能够知道程序的当前状态是否是已经检查过的程序的子集。由于前面的所有路径都必须是有效的(否则程序会加载失败),当前的路径也必须是有效的,因此允许验证器“修剪”当前分支并跳过其模拟阶段。

校验器有一个"安全模式",禁止指针运算。当一个没有

CAP_SYS_ADMIN

特权的用户加载eBPF程序时会启用安全模式,确保不会将内核地址泄露给非特权用户,且不会将指针写入内存。如果没有启用安全模式,则仅允许在执行检查之后进行指针运算。例如,所有的指针访问时都会检查类型,对齐和边界冲突。

无法读取包含未初始化内容的寄存器,尝试读取这类寄存器中的内容将导致加载失败。R0-R5的寄存器内容在函数调用期间被标记未不可读状态,可以通过存储一个特殊值来测试任何对未初始化寄存器的读取行为;对于读取堆栈上的变量的行为也进行了类似的检查,确保没有指令会写入只读的帧指针寄存器。

最后,校验器会使用eBPF程序类型(见下)来限制可以从eBPF程序调用哪些内核函数,以及访问哪些数据结构。例如,一些程序类型可以直接访问网络报文。

使用

bpf()

系统调用和

BPF_PROG_LOAD

命令加载程序。该系统调用的原型为:

int bpf(int cmd, union bpf_attr *attr, unsigned int size);
           

bpf_attr

允许数据在内核和用户空间传递,具体类型取决于

cmd

参数。

cmd可以是如下内容:

BPF_MAP_CREATE
              Create a map and return a file descriptor that refers to the
              map.  The close-on-exec file descriptor flag (see fcntl(2)) is
              automatically enabled for the new file descriptor.

       BPF_MAP_LOOKUP_ELEM
              Look up an element by key in a specified map and return its
              value.

       BPF_MAP_UPDATE_ELEM
              Create or update an element (key/value pair) in a specified
              map.

       BPF_MAP_DELETE_ELEM
              Look up and delete an element by key in a specified map.

       BPF_MAP_GET_NEXT_KEY
              Look up an element by key in a specified map and return the
              key of the next element.

       BPF_PROG_LOAD
              Verify and load an eBPF program, returning a new file descrip‐
              tor associated with the program.  The close-on-exec file
              descriptor flag (see fcntl(2)) is automatically enabled for
              the new file descriptor.
           

size

参数给出了

bpf_attr

联合体对象的字节长度。

BPF_PROG_LOAD

加载的命令可以用于创建和修改eBPF maps,maps是普通的key/value数据结构,用于在eBPF程序和内核空间或用户空间之间通信。其他命令允许将eBPF程序附加到一个控制组目录或socket文件描述符上,迭代所有的maps和程序,以及将eBPF对象固定到文件,这样在加载eBPF程序的进程结束后不会被销毁(后者由tc分类器/操作代码使用,因此可以将eBPF程序持久化,而不需要加载的进程保持活动状态)。完整的命令可以参考bpf()帮助文档。

虽然可能存在很多不同的命令,但大体可以分为两类:与eBPF程序交互的命令,与eBPF maps交互的命令,或同时与程序和maps交互的命令(统称为对象)。

BPF_PROG_LOAD

加载的程序类型确定了四件事:附加的程序的位置,验证器允许调用的内核辅助函数,是否可以直接访问网络数据报文,以及传递给程序的第一个参数对象的类型。实际上,程序类型本质上定义了一个API。创建新的程序类型甚至纯粹是为了区分不同的可调用函数列表(例如,

BPF_PROG_TYPE_CGROUP_SKB

BPF_PROG_TYPE_SOCKET_FILTER

)。

当前内核支持的eBPF程序类型为:

  • BPF_PROG_TYPE_SOCKET_FILTER

    : a network packet filter
  • BPF_PROG_TYPE_KPROBE

    : determine whether a kprobe should fire or not
  • BPF_PROG_TYPE_SCHED_CLS

    : a network traffic-control classifier
  • BPF_PROG_TYPE_SCHED_ACT

    : a network traffic-control action
  • BPF_PROG_TYPE_TRACEPOINT

    : determine whether a tracepoint should fire or not
  • BPF_PROG_TYPE_XDP

    : a network packet filter run from the device-driver receive path
  • BPF_PROG_TYPE_PERF_EVENT

    : determine whether a perf event handler should fire or not
  • BPF_PROG_TYPE_CGROUP_SKB

    : a network packet filter for control groups
  • BPF_PROG_TYPE_CGROUP_SOCK

    : a network packet filter for control groups that is allowed to modify socket options
  • BPF_PROG_TYPE_LWT_*

    : a network packet filter for lightweight tunnels
  • BPF_PROG_TYPE_SOCK_OPS

    : a program for setting socket parameters
  • BPF_PROG_TYPE_SK_SKB

    : a network packet filter for forwarding packets between sockets
  • BPF_PROG_CGROUP_DEVICE

    : determine if a device operation should be permitted or not

随着新程序类型的增加,内核开发人员也会发现需要添加新的数据结构。

eBPF使用的主要的数据结构是eBPF map,这是一个通用的数据结构,用于在内核或内核和用户空间传递数据。其名称"map"也意味着数据的存储和检索需要用到key。

bpf()

系统调用创建和管理map。当成功创建一个map后,会返回与该map关联的文件描述符。关闭相应的文件描述符的同时会销毁map。每个map定义了4个值:类型,元素最大数目,数值的字节大小,以及key的字节大小。eBPF提供了不同的map类型,不同类型的map提供了不同的特性。

  • BPF_MAP_TYPE_HASH

    : a hash table
  • BPF_MAP_TYPE_ARRAY

    : an array map, optimized for fast lookup speeds, often used for counters
  • BPF_MAP_TYPE_PROG_ARRAY

    : an array of file descriptors corresponding to eBPF programs; used to implement jump tables and sub-programs to handle specific packet protocols
  • BPF_MAP_TYPE_PERCPU_ARRAY

    : a per-CPU array, used to implement histograms of latency
  • BPF_MAP_TYPE_PERF_EVENT_ARRAY

    : stores pointers to

    struct perf_event

    , used to read and store perf event counters
  • BPF_MAP_TYPE_CGROUP_ARRAY

    : stores pointers to control groups
  • BPF_MAP_TYPE_PERCPU_HASH

    : a per-CPU hash table
  • BPF_MAP_TYPE_LRU_HASH

    : a hash table that only retains the most recently used items
  • BPF_MAP_TYPE_LRU_PERCPU_HASH

    : a per-CPU hash table that only retains the most recently used items
  • BPF_MAP_TYPE_LPM_TRIE

    : a longest-prefix match trie, good for matching IP addresses to a range
  • BPF_MAP_TYPE_STACK_TRACE

    : stores stack traces
  • BPF_MAP_TYPE_ARRAY_OF_MAPS

    : a map-in-map data structure
  • BPF_MAP_TYPE_HASH_OF_MAPS

  • BPF_MAP_TYPE_DEVICE_MAP

    : for storing and looking up network device references
  • BPF_MAP_TYPE_SOCKET_MAP

    : stores and looks up sockets and allows socket redirection with BPF helper functions

所有的map都可以通过eBPF或在用户空间的程序中使用

bpf_map_lookup_elem()

bpf_map_update_elem()

函数进行访问。某些map类型,如socket map,会使用其他执行特殊任务的eBPF辅助函数。

eBPF的更多细节可以参见官方帮助文档。

注:

在Linux4.4之前,

bpf()

要求调用者具有

CAP_SYS_ADMIN

capability权限,从Linux 4.4.开始,非特权用户可以使用

BPF_PROG_TYPE_SOCKET_FILTER

类型和相应的map创建受限的程序,然而这类程序无法将内核指针保存到map中,仅限于使用如下辅助函数:
*  get_random
*  get_smp_processor_id
*  tail_call
*  ktime_get_ns
           
可以通过sysctl禁用非特权访问:
/proc/sys/kernel/unprivileged_bpf_disabled
           
eBPF对象(maps和程序)可以在不同的进程间共享。例如,在fork之后,子进程会继承引用eBPF对象的文件描述符。此外,引用eBPF对象的文件描述符可以通过UNIX域socket传输。引用eBPF对象的文件描述符可以通过

dup(2)

和类似的调用进行复制。当所有引用对象的文件描述符关闭后,才会释放eBPF对象。

eBPF程序可以使用受限的C语言进行编写,并使用clang编译器编译为eBPF字节码。受限的C语言会禁用很多特性,如循环,全局变量,浮点数以及使用结构体作为函数参数。可以在内核源码的samples/bpf/*_kern.c 文件中查看例子。

内核中的just-in-time (JIT)可以将eBPF字节码转换为机器码,提升性能。在Linux 4.15之前,默认会禁用JIT,可以通过修改

/proc/sys/net/core/bpf_jit_enable

启用JIT。
  • 0 禁用JIT
  • 1 正常编译
  • 2 dehub模式。
从Linux 4.15开始,内核可能会配置

CONFIG_BPF_JIT_ALWAYS_ON

选项,这种情况下,会启用JIT编译器,

bpf_jit_enable

会被设置为1。如下架构支持eBPF的JIT编译器:
*  x86-64 (since Linux 3.18; cBPF since Linux 3.0);
*  ARM32 (since Linux 3.18; cBPF since Linux 3.4);
*  SPARC 32 (since Linux 3.18; cBPF since Linux 3.5);
*  ARM-64 (since Linux 3.18);
*  s390 (since Linux 4.1; cBPF since Linux 3.7);
*  PowerPC 64 (since Linux 4.8; cBPF since Linux 3.1);
*  SPARC 64 (since Linux 4.12);
*  x86-32 (since Linux 4.18);
*  MIPS 64 (since Linux 4.18; cBPF since Linux 3.16);
*  riscv (since Linux 5.1).
           

可以参考官方帮助文档查看libbpf库提供的辅助函数。

官方文档给出了现有的eBPF辅助函数。更多的实例可以参见内核源码的

samples/bpf/

tools/testing/selftests/bpf/

目录。

在官方帮助文档中有如下补充:

由于在编写帮助文档的同时,也同时在进行eBPF开发,因此新引入的eBPF程序或map类型可能没有及时添加到帮助文档中,可以在内核源码树中找到最准确的描述:

include/uapi/linux/bpf.h:主要的BPF头文件。包含完整的辅助函数列表,以及对辅助函数使用的标记,结构体和常量的描述

net/core/filter.c:包含大部分与网络有关的辅助函数,以及使用的程序类型列表

kernel/trace/bpf_trace.c:包含大部分与程序跟踪有关的辅助函数

kernel/bpf/verifier.c:包含特定辅助函数使用的用于校验eBPF map有效性的函数

kernel/bpf/:该目录中的文件包含了其他辅助函数(如cgroups,sockmaps等)

历史上,需要使用内核的bpf_asm汇编器将eBPF程序转换为BPF字节码。幸运的是,LLVM Clang编译器支持将C语言编写的eBPF后端编译为字节码。

bpf()

BPF_PROG_LOAD

命令可以直接加载包含这些字节码的对象文件。

可以使用C编写eBPF程序,并使用Clang的

-march=bpf

参数进行编译。在内核的

samples/bpf/

目录下有很多eBPF程序的例子。大多数文件名中都有一个

_kern.c

后缀。Clang编译出的目标文件(eBPF字节码)需要由一个本机运行的程序进行加载(通常为使用

_user.c

开头的文件)。为了简化eBPF程序的编写,内核提供了

libbpf

库,可以使用辅助函数来加载,创建和管理eBPF对象。例如,一个eBPF程序和使用

libbpf

的用户程序的大体流程为:

  • 在用户程序中读取eBPF字节流,并将其传递给

    bpf_load_program()

  • 当在内核中运行eBPF程序时,将会调用

    bpf_map_lookup_elem()

    在一个map中查找元素,并保存一个新的值。
  • 用户程序会调用

    bpf_map_lookup_elem()

    读取由eBPF程序保存的内核数据。

然而,大部分的实例代码都有一个主要的缺点:需要在内核源码树中编译自己的eBPF程序。幸运的是,BCC项目解决了这类问题。它包含了一个完整的工具链来编写并加载eBPF程序,而不需要链接到内核源码树。

seccomp首个版本在2005年合入Linux 2.6.12版本。通过在

/proc/PID/seccomp

中写入

1

启用该功能。一旦启用,进程只能使用4个系统调用

read()

write()

exit()

sigreturn()

,如果进程调用其他系统调用将会导致

SIGKILL

。该想法和补丁来自andreaarcangeli,作为一种安全运行他人代码的方法。然而,这个想法一直没有实现。

在2007年,内核2.6.23中改变了启用seccomp的方式。添加了

prctl()

操作方式(

PR_SET_SECCOMP

SECCOMP_MODE_STRICT

参数),并移除了

/proc

接口。

PR_GET_SECCOMP

操作的行为比较有趣:如果进程不处于seccomp模式,则会返回0,否则会发出

SIGKILL

信号(原因是

prctl()

不是一个允许的系统调用)。Kerrisk说,这证明了内核开发人员确实有幽默感。

在接下来的五年左右,seccomp领域的情况一直很平静,直到2012年linux3.5中加入了

seccomp模式2

(或“seccomp过滤模式”)。为seccomp添加了第二个模式:

SECCOMP_MODE_FILTER

。使用该模式,进程可以指定允许哪些系统调用。通过mini的BPF程序,进程可以限制整个系统调用或特定的参数值。现在已经有很多工具使用了seccomp过滤,包括 Chrome/Chromium浏览器, OpenSSH, vsftpd, 和Firefox OS。此外,容器中也大量使用了seccomp。

2013年的3.8内核版主中,在

/proc/PID/status

中添加了一个“Seccomp”字段。通过读取该字段,进程可以确定其seccomp模式(0为禁用,1为严格,2为过滤)。Kerrisk指出,进程可能需要从其他地方获取一个文件的文件描述符,以确保不会收到SIGKILL。

2014 年3.17版本中加入了

seccomp()

系统调用(不会再使得

prctl()

系统调用变得更加复杂)。

seccomp()

系统调用提供了现有功能的超集。它还增加了将一个进程的所有线程同步到同一组过滤器的能力,有助于确保即使是在安装过滤器之前创建的线程也仍然受其影响。

seccomp的过滤模式允许开发者编写BPF程序来根据传入的参数数目和参数值来决定是否可以运行某个给定的系统调用。只有值传递有效(BPF虚拟机不会取消对指针参数的引用)。

可以使用

seccomp()

prctl()

安装过滤器。首先必须构造BPF程序,然后将其安装到内核。之后每次执行系统调用时都会触发过滤代码。也可以移除已经安装的过滤器(因为安装过滤器实际上是一种声明,表明任何后续执行的代码都是不可信的)。

BPF语言几乎早于Linux(Kerrisk)。首次出现在1992年,被用于tcpdump程序,用于监听网络报文。但由于报文数目比较大,因此将所有的报文传递到用于空间再进行过滤的代价相当大。BPF提供了一种内核层面的过滤,这样用户空间只需要处理其感兴趣的报文。

seccomp过滤器开发人员发现可以使用BPF实现其他类型的功能,后来BPF演化为允许过滤系统调用。内核中的小型内核内虚拟机用于解释一组简单的BPF指令。

BPF允许分支,但仅允许向前的分支,因此不能出现循环,通过这种方式保证出现能够结束。BPF程序的指令限制为4096个,且在加载期间完成有效性校验。此外,校验器可以保证程序能够正常退出,并返回一条指令,告诉内核针对该系统调用应该采取何种动作。

BPF的推广正在进行中,其中eBPF已经添加到了内核中,可以针对tracepoint(Linux 3.18)和raw socket(3.19)进行过滤,同时在4.1版本中合入了针对perf event的eBPF代码。

BPF有一个累加器寄存器,一个数据区(用于seccomp,包含系统调用的信息),以及一个隐式程序计数器。所有的指令都是64位长度,其中16比特用于操作码,两个8bit字段用于跳转目的地,以及一个32位的字段保存依赖操作码解析出的值。

BPF使用的基本的指令有:load,stora,jump,算术和逻辑运算,以及return。BPF支持条件和非条件跳转指令,后者使用32位字段作为其偏移量。条件跳转会在指令中使用两个跳转目的字段,每个字段都包含一个跳转偏移量(具体取决于跳转为true还是false)。

由于具有两个跳转目的,BPF可以简化条件跳转指令(例如,可以使用"等于时跳转",但不能使用"不等于时跳转"),如果需要另一种意义上的比较,可以将这两种偏移互换。目的地即是偏移量,0表示"不跳转"(执行下一跳指令),由于它们是8比特的值,最大支持跳转255条指令。正如前面所述,不允许负偏移量,避免循环。

给seccomp使用的BPF数据区(

struct seccomp_data

)有几个不同的字段来描述正在进行的系统调用:系统调用号,架构,指令指针,以及系统调用参数。它是一个只读buffer,程序无法修改。

可以使用常数和宏编写BPF程序,例如:

BPF_STMT(BPF_LD | BPF_W | BPF_ABS, (offsetof(struct seccomp_data, arch)))
           

上述命令将会创建一个加载(BPF_LD)字(BPF_W)的操作,使用指令中的值作为数据区的偏移量(BPF_ABS)。该值是architecture字段与数据区域的偏移量,因此最终结果是一条指令,该指令会根据架构加载累加器(来自

AUDIT.h

中的

AUDIT_ARCH_*

值)。下一条指令为:

BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K ,AUDIT_ARCH_X86_64 , 1, 0)
           

上述命令会创建一个jump-if-equal指令(BPF_JMP | BPF JEQ),将指令中的值(BPF_K)与累加器中的值进行比较。如果架构为x86-64,该跳转会忽略吓一跳指令(跳转的指令数为"1"),否则会继续执行(跳转为false,"0")。

BPF程序应该首先对其架构进行校验,确保系统调用与程序所期望的一致。BPF程序可能是在与它允许的架构不同的架构上创建的。

一旦创建了过滤器,在每次系统调用时都会允许该程序,同时也会对性能造成一定影响。每个程序在退出时必须返回一条指令,否则,校验器会返回

EINVAL

。返回的内容为一个32位的数值。高16比特指定了内核的动作,其他比特返回与动作相关的数据。

程序可以返回5个动作:

SECCOMP_RET_ALLOW

表示允许运行系统调用;

SECCOMP_RET_KILL

表示终止进程,就像该进程由于

SIGSYS

(进程不会捕获到该信号)被杀死一样;

SECCOMP_RET_ERRNO

会告诉内核尝试通知一个

ptrace()

跟踪器,使其有机会获得控制权;

SECCOMP_RET_TRAP

告诉内核立即发送一个真实的

SIGSYS

信号,进程会在期望时捕获到该信号。

seccomp()

(since Linux 3.17) 或

prctl()

安装BPF程序,这两种情况下都会传递一个

struct sock_fprog

指针,包含指令数目和一个指向程序的指针。为了成功执行指令,调用者要么需要具有

CAP_SYS_ADMIN

权限,要么给进程设置

PR_SET_NO_NEW_PRIVS

属性(使用

execve()

执行新的程序时会忽略set-UID, set-GID, 和文件capabilities)。

如果过滤器运行程序调用

prctl()

seccomp()

,那么就可以安装更多的过滤器,它们将以与添加顺序相反的顺序运行,最终返回过滤器中具有最高优先级的值(

KILL

的优先级最高,

ALLOW

的优先级最低)。如果筛选器允许调用fork()、clone()和execve(),则会在调用这些命令时保留筛选器。

seccomp过滤器的两个主要用途是沙盒和故障模式测试。前者用于限制程序,特别是需要处理不可信输入的系统调用,通常会用到白名单。对于故障模式测试,可以使用seccomp给程序注入各种不可预期的错误来帮助查找bugs。

目前有很多工具和资源可以简化seccomp过滤器和BPF的开发。Libseccomp提供了一组高级API来创建过滤器。libseccomp项目给出了很多帮助文档,如

seccomp_init()

最后,内核有一个just-in-time (JIT)编译器,用于将BPF字节码转化为机器码,通过这种方式可以提升2-3倍的性能。JIT编译器默认是禁用的,可以通过在下面文件中写入1启用。

/proc/sys/net/core/bpf_jit_enable
           

XDP是一个基于eBPF的高性能数据链路,在Linux 4.8内核版本合入。

XDP支持三种操作模式,默认会使用

native

模式。

  • Native XDP(XDP_FLAGS_DRV_MODE)

    :默认的工作模式,XDP BPF程序运行在网络驱动的早期接收路径(RX队列)上。大多数10G或更高级别的NIC都已经支持了

    native

    XDP。
  • Offloaded XDP(XDP_FLAGS_HW_MODE)

    offloaded

    XDP模式中,XDP BPF程序直接在NIC中处理报文,而不会使用主机的CPU。因此,处理报文的成本非常低,性能要远远高于

    native

    XDP。该模式通常由智能网卡实现,包含多线程,多核流量处理器(以及一个内核的JIT编译器,将BPF转变为该处理器可以执行的指令)。支持

    offloaded

    XDP的驱动通常也支持

    native

    XDP(某些BPF辅助函数通常仅支持native 模式)。
  • Generic XDP(XDP_FLAGS_SKB_MODE)

    :对于没有实现native或offloaded模式的XDP,内核提供了一种处理XDP的通用方案。由于该模式运行在网络栈中,因此不需要对驱动进行修改。该模式主要用于给开发者测试使用XDP API编写的程序,其性能要远低于native或offloaded模式。在生产环境中,建议使用native或offloaded模式。

支持

native

XDP的驱动如下:

  • Broadcom
    • bnxt
  • Cavium
    • thunderx
  • Intel
    • ixgbe
    • ixgbevf
    • i40e
  • Mellanox
    • mlx4
    • mlx5
  • Netronome
    • nfp
  • Others
    • tun
    • virtio_net
  • Qlogic
    • qede
  • Solarflare
    • sfc [1]

offloaded

    • nfp [2]

可以通过

ip link

命令查看已经安装的XDP模式,generic/SKB (

xdpgeneric

), native/driver (

xdp

), hardware offload (

xdpoffload

),如下xdpgeneric即generic模式。

# ip link
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 xdpgeneric qdisc pfifo_fast state UP mode DEFAULT group default qlen 1000
    link/ether 00:16:3e:00:2d:67 brd ff:ff:ff:ff:ff:ff
    prog/xdp id 101 tag 3b185187f1855c4c jited
           
虚拟机上的设备可能无法支持native模式。在阿里云ecs上运行下文的例子时出现了错误:

libbpf: Kernel error message: virtio_net: Too few free TX rings available

,且无权限使用

ethtool -G eth0 tx 4080

修改tx buffer的大小。建议使用物理机。

可以使用ethtool查看经XDP处理的报文统计:

# ethtool -S eth0
NIC statistics:
  rx_queue_0_packets: 547115
  rx_queue_0_bytes: 719558449
  rx_queue_0_drops: 0
  rx_queue_0_xdp_packets: 0
  rx_queue_0_xdp_tx: 0
  rx_queue_0_xdp_redirects: 0
  rx_queue_0_xdp_drops: 0
  rx_queue_0_kicks: 20
  tx_queue_0_packets: 134668
  tx_queue_0_bytes: 30534028
  tx_queue_0_xdp_tx: 0
  tx_queue_0_xdp_tx_drops: 0
  tx_queue_0_kicks: 127973
           

XDP用于报文的处理,支持如下action:

enum xdp_action {
    XDP_ABORTED = 0,
    XDP_DROP,
    XDP_PASS,
    XDP_TX,
    XDP_REDIRECT,
};
           
  • XDP_DROP:在驱动层丢弃报文,通常用于实现DDos或防火墙
  • XDP_PASS:允许报文上送到内核网络栈,同时处理该报文的CPU会分配并填充一个

    skb

    ,将其传递到GRO引擎。之后的处理与没有XDP程序的过程相同。
  • XDP_TX:BPF程序通过该选项可以将网络报文从接收到该报文的NIC上发送出去。例如当集群中的部分机器实现了防火墙和负载均衡时,这些机器就可以作为hairpinned模式的负载均衡,在接收到报文,经过XDP BPF修改后将该报文原路发送出去。
  • XDP_REDIRECT:与XDP_TX类似,但是通过另一个网卡将包发出去。另外,

    XDP_REDIRECT

    还可以将包重定向到一个 BPF cpumap,即,当前执行 XDP 程序的 CPU 可以将这个包交给某个远端 CPU,由后者将这个包送到更上层的内核栈,当前 CPU 则继续在这个网卡执行接收和处理包的任务。这和

    XDP_PASS

    类似,但当前 CPU 不用去做将包送到内核协议栈的准备工作(分配

    skb

    ,初始化等等),这部分开销还是很大的。
  • XDP_ABORTED:表示程序产生了异常,其行为和

    XDP_DROP

    相同,但

    XDP_ABORTED

    会经过

    trace_xdp_exception

    tracepoint,因此可以通过 tracing 工具来监控这种非正常行为。

XDP_REDIRECT

action的XDP程序可以通过

bpf_redirect_map()

函数将接收到的帧传递到其他启用XDP的netdevs上,AF_XDP socket使得XDP程序可以将帧重定向到用户空间的程序的内存buffer中。

socket()

系统调用创建AF_XDP socket (XSK)。每个XSK涉及两个ring:RX ring和TX ring。一个socket可以从RX ring上接收报文,并发送到TX ring。这两个rings分别通过socket选项

XDP_RX_RING

XDP_TX_RING

进行注册。每个socket必须至少具有其中一个ring。RX或TX ring描述符指向内存域中的data buffer,称为UMEM。RX和TX可以共享相同的UMEM,这样一个报文无需在RX和TX之间进行拷贝。此外,如果一个报文由于重传需要保留一段时间,则指向该报文的描述符可以指向另外一个报文,这样就避免了数据的拷贝。基本流程如下:

全面介绍eBPF-概念

UMEM包含一系列大小相同的chunks,ring中的描述符通过引用帧的地址来引用该帧,该地址为整个UMEM域的偏移量。用户空间会使用合适的方式(malloc,mmap,大页内存等)为UMEM分配内存,然后使用使用新的socket选项

XDP_UMEM_REG

将内存域注册到内核中。UMEM也包含两个ring:FILL ring和COMPLETION ring。应用会使用FILL ring下发addr,让内核填写RX包数据。一旦接收到报文,RX ring会引用这些帧。COMPLETION ring包含内核传输完的帧地址,且可以被用户空间使用,用于TX或RX。因此COMPLETION ring中的帧地址为先前使用TX ring传输的地址。总之,RX和FILL ring用于RX路径,TX和COMPLETION ring用于TX路径。

最后会使用bind()调用将socket绑定到一个设备以及该设备指定的队列id上,绑定没有完成前无法传输流量。

可以在多个进程间共享UMEM 。如果一个进程需要更新UMEM,则会跳过注册UMEM和其对应的两个ring的过程。在bind调用中设置

XDP_SHARED_UMEM

标志,并提交该进程期望共享UMEM的XSK,以及新创建的XSK socket。新进程会在其共享UMEM的RX ring中接收到帧地址引用。注意,由于ring的结构是单生产者/单消费者的,新的进程的socket必须创建独立的RX和TX ring。同样的原因,每个UMEM也只能有一个FILL和COMPLETION ring。每个进程都需要正确地处理好UMEM。

那么报文是怎么从XDP程序分发到XSKs的呢?通过名为

XSKMAP

(完整名为BPF_MAP_TYPE_XSKMAP`) BPF map。用户空间的应用可以将一个XSK放到该map的任意位置,然后XDP程序就可以将一个报文重定向到该map中指定的索引中,此时XDP会校验map中的XSK确实绑定到该设备和ring号。如果没有,则会丢弃该报文。如果map中的索引为空,也会丢弃该报文。因此,当前的实现中强制要求必须加载一个XDP程序(以及保证XSKMAP存在一个XSK),这样才能通过XSK将流量传送到用户空间。

AF_XDP可以运行在两种模式上:

XDP_SKB

XDP_DRV

。如果驱动不支持XDP,则在加载XDP程序是需要明确指定使用XDP_SKB,

XDP_SKB

模式使用SKB和通用的XDP功能,并将数据复制到用户空间,是一种适用于任何网络设备的回退模式。 如果驱动支持XDP,将使用AF_XDP代码提供更好的性能,但仍然会将数据拷贝到用户空间的操作。

UMEM是一个虚拟的连续内存域,分割为相同大小的帧。一个UMEM会关联一个netdev以及该netdev的队列id。通过

XDP_UMEM_REG

socket选项进行创建和配置(chunk大小,headroom,开始地址和大小)。通过

bind()

系统调用将一个UMEM绑定到一个netdev和队列id。umem的基本结构如下:

全面介绍eBPF-概念

一个AF_XDP为一个链接到一个独立的UMEM的socket,但一个UMEM可以有多个AF_XDP socket。为了共享一个通过socket A创建的UMEM,socket B可以将结构体

sockaddr_xdp

中的成员sxdp_flags设置为

XDP_SHARED_UMEM

,并将A的文件描述符传递给结构体

sockaddr_xdp

的成员

sxdp_shared_umem_fd

UMEM有两个单生产者/单消费者ring,用于在内核和用户空间应用程序之间转移UMEM帧。

有4类不同类型的ring:FILL, COMPLETION, RX 和TX,所有的ring都是单生产者/单消费者,因此用户空间的程序需要显示地同步对这些rings进行读/写的多进程/线程。

UMEM使用2个ring:FILL和COMPLETION。每个关联到UMEM的socket必须有1个RX队列,1个TX队列或同时拥有2个队列。如果配置了4个socket(同时使用TX和RX),那么此时会有1个FILL ring,1个COMPLETION ring,4个TX ring和4个RX ring。

ring是基于首(生产者)尾(消费者)的结构。一个生产者会在结构体xdp_ring的producer成员指出的ring索引处写入数据,并增加生产者索引;一个消费者会结构体xdp_ring的consumer成员指出的ring索引处读取数据,并增加消费者索引。

可以通过_RING setsockopt系统调用配置和创建ring,使用mmap(),并结合合适的偏移量,将其映射到用户空间

ring的大小需要是2次幂。

FILL ring用于将UMEM帧从用户空间传递到内核空间,同时将UMEM地址传递给ring。例如,如果UMEM的大小为64k,且每个chunk的大小为4k,那么UMEM包含16个chunk,可以传递的地址为0到64k。

传递给内核的帧用于ingress路径(RX rings)。

用户应用也会在该ring中生成UMEM地址。注意,如果以对齐的chunk模式运行应用,则内核会屏蔽传入的地址。即,如果一个chunk大小为2k,则会屏蔽掉log2(2048) LSB的地址,意味着2048, 2050 和3000都将引用相同的chunk。如果用户应用使用非对其的chunk模式运行,那么传入的地址将保持不变。

COMPLETION Ring用于将UMEM帧从内核空间传递到用户空间,与FILL ring相同,使用了UMEM索引。

已经发送的从内核空间传递到用户空间的帧还可以被用户空间使用。

用户应用会消费该ring种的UMEM地址。

RX ring位于socket的接收侧,ring中的每个表项都是一个

xdp_desc

结构的描述符。该描述符包含UMEM偏移量(地址)以及数据的长度。

如果没有帧从FILL ring传递给内核,则RX ring中不会出现任何描述符。

用户程序会消费该ring中的

xdp_desc

描述符。

TX Ring用于发送帧。在填充

xdp_desc

(索引,长度和偏移量)描述符后传递给该ring。

如果要启动数据传输,则必须调用

sendmsg()

,未来可能会放宽这种限制。

用户程序会给TX ring生成

xdp_desc

在XDP侧会用到类型为

BPF_MAP_TYPE_XSKMAP

的BPF map,并结合

bpf_redirect_map()

将ingress帧传递给socket。

用户应用会通过

bpf()

系统调用将socket插入该map。

注意,如果一个XDP程序尝试将帧重定向到一个与队列配置和netdev不匹配的socket时,会丢弃该帧。即,如果一个AF_XDP socket绑定到一个名为eth0,队列为17的netdev上时,只有当XDP程序指定到eth0且队列为17时,才会将数据传递给该socket。参见

samples/bpf/

获取例子

当绑定到一个socket时,内核会首先尝试使用零拷贝进行拷贝。如果不支持零拷贝,则会回退为使用拷贝模式。即,将所有的报文拷贝到用户空间。但如果想强制指定一种特定的模式,则可以使用如下标志:如果给bind调用传递了

XDP_COPY

,则内核将强制进入拷贝模式;如果没有使用拷贝模式,则bind调用会失败,并返回错误。相反地,

XDP_ZERO_COPY

将强制socket使用零拷贝或调用失败。

该表示可以使多个socket绑定到系统的UMEM,但仅能使用系统的队列id。这种模式下,每个socket都有其各自的RX和TX ring,但UMEM只能有一个FILL ring和一个COMPLETION ring。为了使用这种模式,需要创建第一个socket,并使用正常模式进行绑定。然后创建第二个socket,含一个RX和一个TX(或二者之一),但不会创建FILL 或COMPLETION ring(与第一个socket共享)。在bind调用中,设置

XDP_SHARED_UMEM

选项,并在sxdp_shared_umem_fd中提供初始socket的fd。以此类推。

那么当接收到一个报文后,应该上送到那个socket呢?答案是由XDP程序来决定。将所有的socket放到XDP_MAP中,然后将报文发送给数组中索引对应的socket。下面展示了一个简单的以轮询方式分发报文的例子:

#include <linux/bpf.h>
#include "bpf_helpers.h"

#define MAX_SOCKS 16

struct {
     __uint(type, BPF_MAP_TYPE_XSKMAP);
     __uint(max_entries, MAX_SOCKS);
     __uint(key_size, sizeof(int));
     __uint(value_size, sizeof(int));
} xsks_map SEC(".maps");

static unsigned int rr;

SEC("xdp_sock") int xdp_sock_prog(struct xdp_md *ctx)
{
     rr = (rr + 1) & (MAX_SOCKS - 1);

     return bpf_redirect_map(&xsks_map, rr, XDP_DROP);
}
           

注意,由于只有一个FILL和一个COMPLETION ring,且是单生产者单消费者的ring,需要确保多处理器或多线程不会同时使用这些ring。libbpf没有提供原子同步功能。

当多个socket绑定到相同的umem时,libbpf会使用这种模式。然而,需要注意的是,需要在

xsk_socket__create

调用中提供

XSK_LIBBPF_FLAGS__INHIBIT_PROG_LOAD

libbpf_flag,然后将其加载到自己的XDP程序中(因为libbpf没有内置路由流量功能)。

该选择支持在FILL ring和TX ring中设置一个名为

need_wakeup

的标志,用户空间作为这些ring的生产者。当在bind调用中设置了该选项,如果需要明确地通过系统调用唤醒内核来继续处理报文时,会设置

need_wakeup

标志。

如果将该标志设置给FILL ring,则应用需要调用

poll()

,以便在RX ring上继续接收报文。如,当内核检测到FILL ring中没有足够的buff,且NIC的RX HW RING中也没有足够的buffer时会发生这种情况。此时会关中断,这样NIC就无法接收到任何报文(由于没有足够的buffer),由于设置了need_wakeup,这样用户空间就可以在FILL ring上增加buffer,然后调用

poll()

,这样内核驱动就可以将这些buffer添加到HW ring上继续接收报文。

如果将该标志设置给TX ring,意味着应用需要明确地通知内核发送位于TX ring上的报文。可以通过调用

poll()

,或调用

sendto()

完成。

可以在samples/bpf/xdpsock_user.c中找到例子。在TX路径上使用libbpf辅助函数的例子如下:

if (xsk_ring_prod__needs_wakeup(&my_tx_ring))
   sendto(xsk_socket__fd(xsk_handle), NULL, 0, MSG_DONTWAIT, NULL, 0);
           

建议启用该模式,由于减少了TX路径上的系统调用的数目,因此可以在应用和驱动运行在同一个(或不同)core的情况下提升性能。

这些socket选项分别设置RX, TX, FILL和COMPLETION ring的描述符数量(必须至少设置RX或TX ring的描述符大小)。如果同时设置了RX和TX,就可以同时接收和发送来自应用的流量;如果仅设置了其中一个,就可以节省相应的资源。如果需要将一个UMEM绑定到socket,需要同时设置FILL ring和COMPLETION ring。如果使用了

XDP_SHARED_UMEM

标志,无需为除第一个socket之外的socket创建单独的UMEM,所有的socket将使用共享的UMEM。注意ring为单生产者单消费者结构,因此多进程无法同时访问同一个ring。参见

XDP_SHARED_UMEM

章节。

使用libbpf时,可以通过给

xsk_socket__create

函数的rx和tx参数设置NULL来创建Rx-only和Tx-only的socket。

如果创建了一个Tx-only的socket,建议不要在FILL ring中放入任何报文,否则,驱动可能会认为需要接收数据(但实际上并不是这样的),进而影响性能。

该socket选项会给一个socket注册一个UMEM,其对应的区域包含了可以容纳报文的buffer。该调用会使用一个指向该区域开始处的指针,以及该区域的大小。此外,还有一个UMEM可以切分的chunk大小参数(目前仅支持2K或4K)。如果一个UMEM区域的大小为128K,且chunk大小为2K,意味着该UMEM域最大可以有128K / 2K = 64个报文,且最大的报文大小为2K。

还有一个选项可以在UMEM中设置每个buffer的headroom。如果设置为N字节,意味着报文会从buffer的第N个字节开始,为应用保留前N个字节。最后一个选项为标志位字段,会在每个UMEM标志中单独处理。

获取一个socket丢弃信息,用于调试。支持的信息为:

struct xdp_statistics {
       __u64 rx_dropped; /* Dropped for reasons other than invalid desc */
       __u64 rx_invalid_descs; /* Dropped due to invalid descriptor */
       __u64 tx_invalid_descs; /* Dropped due to invalid descriptor */
};
           

获取一个XDP socket的选项。目前仅支持

XDP_OPTIONS_ZEROCOPY

,用于检查是否使用了零拷贝。

从AF_XDP的特性上可以看到其局限性:不能使用XDP将不同的流量重定向的多个AF_XDP socket上,原因是每个AF_XDP socket必须绑定到物理接口的TX队列上。大多数的物理和仿真HW的每个接口仅支持一个RX/TX队列,因此当该接口上绑定了一个AF_XDP后,后续的绑定操作都将失败。仅有少数HW支持多RX/TX队列,且通常仅有2/4/8个队列,无法扩展给cloud中的上百个容器使用。

更多细节参见AF_XDP官方文档以及这篇论文。

除了XDP,BPF还可以在网络数据路径的内核tc(traffic control)层之外使用。上文已经给出了XDP和TC的区别。

  • ingress

    hook:

    __netif_receive_skb_core() -> sch_handle_ingress()

  • egress

    __dev_queue_xmit() -> sch_handle_egress()

全面介绍eBPF-概念

运行在tc层的BPF程序使用的是

cls_bpf

(cls即Classifiers的简称)分类器。在tc中,将BPF的附着点描述为一个"分类器",这个词有点误导,因此它少描述了

cls_bpf

的所支持的功能。即一个完整的可编程的报文处理器不仅可以读取

skb

的元数据和报文数据,还可以对其进行任意修改,最后终止tc的处理,并返回裁定的action(见下)。

cls_bpf

可以认为是一个自包含的,可以管理和执行tc BPF程序的实体。

cls_bpf

可以包含一个或多个tc BPF程序。通常,在传统的tc方案中,分类器和action模块是分开的,每个分类器可以附加一个或多个action,一旦匹配到分类器时就会执行action。但在现代软件数据路径中使用这种模式的tc处理复杂的报文时会遇到扩展性问题。由于附加到cls_bpf的tc BPF程序是完全自包含的,因此可以有效地将解析和操作过程融合到一个单元中。幸好有了

cls_bpf

direct-action

模式,该模式下,仅需要返回tc action裁定结果并立即结束处理流即可,可以在网络数据流中实现可扩展的可编程报文处理流程,同时避免了action的线性迭代。

cls_bpf

是tc层中唯一能够实现这种快速路径的“分类器”模块。

与XDP BPF程序类似,tc BPF程序可以在运行时通过cls_bpf自动更新,而不会中断任何网络流或重启服务。

cls_bpf

可以附加的tc ingress和egree钩子都通过一个名为

sch_clsact

的伪qdisc进行管理。由于该伪qdisc可以同时管理ingress和egress的tc钩子,因此它是ingress qdisc的超集(也可直接替换)。对于

__dev_queue_xmit()

中的tc的egress钩子,需要注意的是,它不是在内核的qdisc root锁下运行的。因此,tc ingress和egress钩子都以无锁的方式运行在快速路径中,且这两个钩子都禁用了抢占,并运行在RCU读取侧。

通常在egress上会存在附着到网络设备上的qdisc,如

sch_mq

sch_fq

sch_fq_codel

sch_htb

,其中有些是可分类的qdisc(包含子类),因此会要求一个报文分类机制来决定在哪里解复用数据包。该过程通过调用

tcf_classify()

进行处理,进而调用tc分类器(如果存在)。

cls_bpf

也可以附加并用于如下场景:一些在qdisc root锁下的操作可能会收到锁竞争的影响。

sch_clsact

qdisc的egress钩子出现在更早的时间点,但它不属于这个锁的范围,因此作完全独立于常规的egress qdiscs。因此,对于

sch_htb

这样的情况,

sch_clsact

qdisc可以通过qdisc root锁之外的tc BPF执行繁重的包分类工作,通过在这些 tc BPF 程序中设置

skb->mark

skb->priority

,这样

sch_htb

只需要一个简单的映射即可,不需要在root锁下执行代价高昂的报文分类工作,通过这种方式可以减少锁竞争。

在sch_clsact结合cls_bpf的场景下支持offloaded tc BPF程序,这种情况下,先前加载的BPF程序是从SmartNIC驱动程序jit生成的,以便在NIC上以本机方式运行。只有在

direct-action

模式下运行的

cls_bpf

程序才支持offloaded。

cls_bpf

仅支持offload一个单独的程序(无法offload多个程序),且只有ingress支持offload BPF程序。

一个

cls_bpf

实例可以包含多个tc BPF程序,如果是这种情况,那么

TC_ACT_UNSPEC

程序返回码可以继续执行列表中的下一个tc BPF程序。然而,这样做的缺点是,多个程序需要多次解析相同的报文,导致性能下降。

tc的ingress和egress钩子共享相同的action来返回tc BPF程序使用的裁定结果,定义在

linux/pkt_cls.h

系统头文件中:

#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_*

开头的action变量,可以被两个钩子使用。但它们与上面的语义相同。即,从tc BPF的角度来看

TC_ACT_OK

TC_ACT_RECLASSIFY

的语义相同,三个

TC_ACT_stelled

TC_ACT_QUEUED

TC_ACT_TRAP

操作码的语义也是相同的。因此,对于这些情况,我们只描述

TC_ACT_OK

TC_ACT_STOLEN

操作码。

TC_ACT_UNSPEC

开始,表示"未指定的action",用于以下三种场景:i)当一个offloaded tc程序的tc ingress钩子运行在

cls_bpf

的位置,则该offloaded程序将返回

TC_ACT_UNSPEC

;ii)为了在多程序场景下继续执行

cls_bpf

中的下一个BPF程序,后续的程序需要与步骤i中的offloaded tc BPF程序配合使用,但出现了一个非offloaded场景下运行的tc BPF程序;iii)

TC_ACT_UNSPEC

还可以用于单个程序场景,用于告诉内核继续使用skb,不会产生其他副作用。

TC_ACT_UNSPEC

TC_ACT_OK

类似,两者都会将skb通过ingress向上传递到网络栈的上层,或者通过egress向下传递到网络设备驱动程序,以便在egress进行传输。与

TC_ACT_OK

的唯一不同之处是,

TC_ACT_OK

基于tc BPF程序设定的classid来设置

skb->tc_index

,而

TC_ACT_UNSPEC

是通过 tc BPF 程序之外的 BPF上下文中的

skb->tc_classid

进行设置。

TC_ACT_SHOT

通知内核丢弃报文,即网络栈上层将不会在ingress的skb中看到该报文,类似地,这类报文也不会在egress中发送。

TC_ACT_SHOT

TC_ACT_STOLEN

本质上是相似的,仅存在部分差异:

TC_ACT_SHOT

会通知内核已经通过

kfree_skb()

释放skb,且会立即给调用者返回

NET_XMIT_DROP

;而TC_ACT_STOLEN会通过

consume_skb()

释放skb,并给上层返回

NET_XMIT_SUCCESS

,假装传输成功。perf的报文丢弃监控会记录

kfree_skb()

的操作,因此不会记录任何因为

TC_ACT_STOLEN

丢弃的报文,因为从语义上说,这些

skb

是被消费或排队的而不是被丢弃的。

最后

TC_ACT_REDIRECT

action允许tc BPF程序通过

bpf_redirect()

辅助函数将skb重定向到相同或不同的设备ingress或egress路径上。通过将报文导入其他设备的ingress或egress方向,可以最大化地实现BPF的报文转发功能。使用该方式不需要对目标网络设备做任何更改,也不需要在目标设备上运行另外一个

cls_bpf

实例。

假设有一个名为

prog.o

的tc BPF程序,可以通过tc命令将该程序加载到网络设备山。与XDP不同,它不需要依赖驱动将BPF程序附加到设备上,下面会用到一个名为

em1

的网络设备,并将程序附加到

em1

ingress

报文路径上。

# tc qdisc add dev em1 clsact
# tc filter add dev em1 ingress bpf da obj prog.o
           

第一步首先配置一个

clsact

qdisc。如上文所述,clsact是一个伪造的qdisc,与

ingress

qdisc类似,仅包含分类器和action,但不会提供实际的队列功能,它是附加bpf分类器所必需的。

clsact

提供了两个特殊的钩子,称为

ingress

egress

,分类器可以附加到这两个钩子上。

ingress

egress

钩子都位于网络数据路径的中央接收和发送位置,每个经过设备的报文都会经过此处。

ingees

钩子通过内核的

__netif_receive_skb_core() -> sch_handle_ingress()

进行调用,

egress

钩子通过

__dev_queue_xmit() -> sch_handle_egress()

进行调用。

将程序附加到

egress

钩子上的操作为:

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

clsact

qdisc以无锁的方式处理来自

ingress

egress

方向的报文,且可以附加到一个无队列虚拟设备上,如连接到容器的

veth

设备。

在钩子之后,

tc filter

命令选择使用

bpf

da

(direct-action)模式。推荐使用并指定da

模式

,基本上意味着bpf分类器不再需要调用外部tc action模块,所有报文的修改,转发或其他action都可以通过附加的BPF程序来实现,因此处理速度更快。

到此位置,已经附加bpf程序,一旦有报文传输到该设备后就会执行该程序。与XDP相同,如果不使用默认的section名称,则可以在加载期间进行指定,例如,下面指定的section名为

foobar

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

iptables2的BPF加载器允许跨程序类型使用相同的命令行语法。

附加的程序可以使用如下命令列出:

# 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]

的输出说明程序段

ingress

通过文件

prog.o

进行加载,且

bpf

运行在

direct-action

模式下。上面两种情况附加了程序

id

tag

,其中后者表示对指令流的hash,该hash可以与目标文件或带有堆栈跟踪的perf report等相关。最后,

id

表示系统范围内的BPF程序的唯一标识符,可以使用

bpftool

来查看或dump附加的BPF程序。

tc可以附加多个BPF程序,它提供了其他可以链接在一起的分类器。但附加一个BPF程序已经可以完全满足需求,因为通过

da

(

direct-action

)模式可以在一个程序中实现所有的报文操作,意味着BPF程序将返回tc action裁定结果,如

TC_ACT_OK

TC_ACT_SHOT

等。为了获得最佳性能和灵活性,推荐使用这种方式。

在上述

show

命令中,在BPF的相关输出旁显示了

pref 49152

handle 0x1

。如果没有通过命令行显式地提供,会自动生成的这两个输出。

perf

表明了一个优先级数字,即当附加了多个分类器时,将会按照优先级上升的顺序执行这些分类器。

handle

表示一个标识符,当一个

perf

加载了系统分类器的多个实例时起作用。由于在BPF场景下,一个程序足矣,

perf

handle

通常可以忽略。

只有在需要自动替换附加的BPF程序的情况下,才会推荐在初始化加载前指定

pref

handle

,这样在以后执行

replace

操作时就不必在进行查询。创建方式如下:

# 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
           

对于原子替换,可以使用(来自文件

prog.o

foobar

section的BPF程序)如下命令来更新现有的

ingress

钩子上的程序

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

最后,为了移除所有ingress和egress上附加的程序,可以使用如下命令:

# tc filter del dev em1 ingress
# tc filter del dev em1 egress
           

为了移除网络设备上的整个

clsact

qdisc,即移除掉ingress和egress钩子上附加的所有程序,可以使用如下命令:

# tc qdisc del dev em1 clsact
           

如果NIC和驱动也像XDP BPF程序一样支持offloaded,则tc BPF程序也可以是offloaded的。Netronome的nfp同时支持两种类型的BPF offload。

# 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
           

如果出现了如上错误,则表示首先需要通过ethtool的

hw-tc-offload

来启动tc硬件offload:

# 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到了NIC中。

注意不能同时offload tc和XDP BPF,必须且只能选择其中之一。

下一篇将给出XDP和TC的使用例子。