(转)《eBPF 核心技术与实战》

原文:https://fanlv.fun/2022/11/05/study-ebpf/

概览

eBPF 是什么呢? 从它的全称“扩展的伯克利数据包过滤器 (Extended Berkeley Packet Filter)” 来看,它是一种数据包过滤技术,是从 BPF (Berkeley Packet Filter) 技术扩展而来的。

BPF 提供了一种在内核事件和用户程序事件发生时安全注入代码的机制,这就让非内核开发人员也可以对内核进行控制。随着内核的发展,BPF 逐步从最初的数据包过滤扩展到了网络、内核、安全、跟踪等,而且它的功能特性还在快速发展中,这种扩展后的 BPF 被简称为 eBPF(相应的,早期的 BPF 被称为经典 BPF,简称 cBPF)。实际上,现代内核所运行的都是 eBPF,如果没有特殊说明,内核和开源社区中提到的 BPF 等同于 eBPF

tcpdump 和 BCC 之所以这么高效强大,都是得益于 BPF/eBPF 技术。

  • Katran: Facebook 开源的高性能网络负载均衡器。
  • cilium.ioIsovalent 开源的容器网络方案 。
  • bcc :BCC 是一个 BPF 编译器集合,包含了用于构建 BPF 程序的编程框架和库,并提供了大量可以直接使用的工具。使用 BCC 的好处是,它把上述的 eBPF 执行过程通过内置框架抽象了起来,并提供了 Python、C++ 等编程语言接口。这样,你就可以直接通过 Python 语言去跟 eBPF 的各种事件和数据进行交互。
  • bpftrace:动态工具。
  • libbpf 帮你避免了直接调用内核函数,提供了跨内核版本的兼容性(即一次编译到处执行,简称 CO-RE)

image.png

image.png

ebpf为什么性能这么好呢?这主要得益于 BPF 的两大设计:

  • 第一,内核态引入一个新的虚拟机,所有指令都在内核虚拟机中运行。
  • 第二,用户态使用 BPF 字节码来定义过滤表达式,然后传递给内核,由内核虚拟机解释执行。

eBPF 的诞生是 BPF 技术的一个转折点,使得 BPF 不再仅限于网络栈,而是成为内核的一个顶级子系统。

image.png

eBPF 程序执行过程:

image.png

如果 BPF 字节码中包含了不安全的操作,验证器会直接拒绝 BPF 程序的执行。比如,下面就是一些典型的验证过程:

  • 只有特权进程才可以执行 BPF 系统调用;
  • BPF 程序不能包含无限循环;
  • BPF 程序不能导致内核崩溃;
  • BPF 程序必须在有限时间内完成。

BPF 程序可以利用 BPF 映射(map)进行存储,而用户程序通常也需要通过 BPF 映射同运行在内核中的 BPF 程序进行交互

image.png

eBPF 限制:

  • eBPF 程序必须被验证器校验通过后才能执行,且不能包含无法到达的指令;
  • eBPF 程序不能随意调用内核函数,只能调用在 API 中定义的辅助函数;
  • eBPF 程序栈空间最多只有 512 字节,想要更大的存储,就必须要借助映射存储;
  • 在内核 5.2 之前,eBPF 字节码最多只支持 4096 条指令,而 5.2 内核把这个限制提高到了 100 万条;
  • 由于内核的快速变化,在不同版本内核中运行时,需要访问内核数据结构的 eBPF 程序很可能需要调整源码,并重新编译。

kernel-versions.md#main-features

基础入门

环境

推荐:

  • Ubuntu 20.10+
  • Fedora 31+
  • RHEL 8.2+
  • Debian 11+

工具:

# For Ubuntu20.10+
sudo apt-get install -y  make clang llvm libelf-dev libbpf-dev bpfcc-tools libbpfcc-dev linux-tools-$(uname -r) linux-headers-$(uname -r)

# For RHEL8.2+
sudo yum install libbpf-devel make clang llvm elfutils-libelf-devel bpftool bcc-tools bcc-devel

eBPF在内核中的运行时主要由5个模块组成:

image.png

  • 第一个模块是 eBPF 辅助函数。它提供了一系列用于 eBPF 程序与内核其他模块进行交互的函数。这些函数并不是任意一个 eBPF 程序都可以调用的,具体可用的函数集由 BPF 程序类型决定。关于 BPF 程序类型,我会在 06 讲 中进行讲解。
  • 第二个模块是 eBPF 验证器。它用于确保 eBPF 程序的安全。验证器会将待执行的指令创建为一个有向无环图(DAG),确保程序中不包含不可达指令;接着再模拟指令的执行过程,确保不会执行无效指令。
  • 第三个模块是由 11 个 64 位寄存器、一个程序计数器和一个 512 字节的栈组成的存储模块。这个模块用于控制 eBPF 程序的执行。其中,R0 寄存器用于存储函数调用和 eBPF 程序的返回值,这意味着函数调用最多只能有一个返回值;R1-R5 寄存器用于函数调用的参数,因此函数调用的参数最多不能超过 5 个;而 R10 则是一个只读寄存器,用于从栈中读取数据。
  • 第四个模块是即时编译器,它将 eBPF 字节码编译成本地机器指令,以便更高效地在内核中执行。
  • 第五个模块是 BPF 映射(map),它用于提供大块的存储。这些存储可被用户空间程序用来进行访问,进而控制 eBPF 程序的运行状态。

查询系统中正在运行的 eBPF 程序:

# sudo bpftool prog list
95: kprobe  name hello_world  tag c3d700bdee3931e4  gpl
    loaded_at 2022-10-30T20:00:44+0800  uid 0
    xlated 528B  jited 360B  memlock 4096B  map_ids 4
    btf_id 99

输出中,95 是这个 eBPF 程序的编号,kprobe 是程序的类型,而 hello_world 是程序的名字。

int hello_world(void * ctx):
; int hello_world(void *ctx)
   0: (b7) r1 = 33                  /* ! */
; ({ char _fmt[] = "Hello, World!"; bpf_trace_printk_(_fmt, sizeof(_fmt)); });
   1: (6b) *(u16 *)(r10 -4) = r1
   2: (b7) r1 = 1684828783          /* dlro */
   3: (63) *(u32 *)(r10 -8) = r1
   4: (18) r1 = 0x57202c6f6c6c6548  /* W ,olleH */
   6: (7b) *(u64 *)(r10 -16) = r1
   7: (bf) r1 = r10
;
   8: (07) r1 += -16
; ({ char _fmt[] = "Hello, World!"; bpf_trace_printk_(_fmt, sizeof(_fmt)); });
   9: (b7) r2 = 14
  10: (85) call bpf_trace_printk#-61616
; return 0;
  11: (b7) r0 = 0
  12: (95) exit

其中,分号开头的部分,正是我们前面写的C代码,而其他行则是具体的BPF指令。具体每一行的BPF指令又分为三部分:

  • 第一部分,冒号前面的数字 0-12 ,代表 BPF 指令行数;
  • 第二部分,括号中的 16 进制数值,表示 BPF 指令码。它的具体含义你可以参考 IOVisor BPF 文档,比如第 0 行的 0xb7 表示为 64 位寄存器赋值。
  • 第三部分,括号后面的部分,就是 BPF 指令的伪代码。

总结起来,这些指令先通过 R1 和 R2 寄存器设置了 bpf_trace_printk 的参数,然后调用 bpf_trace_printk 函数输出字符串,最后再通过 R0 寄存器返回成功。

跟踪bpf系统调用:

# -ebpf表示只跟踪bpf系统调用
sudo strace -v -f -ebpf ./hello.py


bpf(BPF_PROG_LOAD,
    {
        prog_type=BPF_PROG_TYPE_KPROBE,
        insn_cnt=13,
        insns=[
            {code=BPF_ALU64|BPF_K|BPF_MOV, dst_reg=BPF_REG_1, src_reg=BPF_REG_0, off=0, imm=0x21},
            {code=BPF_STX|BPF_H|BPF_MEM, dst_reg=BPF_REG_10, src_reg=BPF_REG_1, off=-4, imm=0},
            {code=BPF_ALU64|BPF_K|BPF_MOV, dst_reg=BPF_REG_1, src_reg=BPF_REG_0, off=0, imm=0x646c726f},
            {code=BPF_STX|BPF_W|BPF_MEM, dst_reg=BPF_REG_10, src_reg=BPF_REG_1, off=-8, imm=0},
            {code=BPF_LD|BPF_DW|BPF_IMM, dst_reg=BPF_REG_1, src_reg=BPF_REG_0, off=0, imm=0x6c6c6548},
            {code=BPF_LD|BPF_W|BPF_IMM, dst_reg=BPF_REG_0, src_reg=BPF_REG_0, off=0, imm=0x57202c6f},
            {code=BPF_STX|BPF_DW|BPF_MEM, dst_reg=BPF_REG_10, src_reg=BPF_REG_1, off=-16, imm=0},
            {code=BPF_ALU64|BPF_X|BPF_MOV, dst_reg=BPF_REG_1, src_reg=BPF_REG_10, off=0, imm=0},
            {code=BPF_ALU64|BPF_K|BPF_ADD, dst_reg=BPF_REG_1, src_reg=BPF_REG_0, off=0, imm=0xfffffff0},
            {code=BPF_ALU64|BPF_K|BPF_MOV, dst_reg=BPF_REG_2, src_reg=BPF_REG_0, off=0, imm=0xe},
            {code=BPF_JMP|BPF_K|BPF_CALL, dst_reg=BPF_REG_0, src_reg=BPF_REG_0, off=0, imm=0x6},
            {code=BPF_ALU64|BPF_K|BPF_MOV, dst_reg=BPF_REG_0, src_reg=BPF_REG_0, off=0, imm=0},
            {code=BPF_JMP|BPF_K|BPF_EXIT, dst_reg=BPF_REG_0, src_reg=BPF_REG_0, off=0, imm=0}
        ],
        prog_name="hello_world",
        ...
    },
    128) = 4

对应前面的 strace 输出结果,这三个参数的具体含义如下。

  • 第一个参数是 BPF_PROG_LOAD , 表示加载 BPF 程序。
  • 第二个参数是 bpf_attr 类型的结构体,表示 BPF 程序的属性。其中,有几个需要你留意的参数,比如:
    • prog_type 表示 BPF 程序的类型,这儿是 BPF_PROG_TYPE_KPROBE ,跟我们 Python 代码中的 attach_kprobe 一致;
    • insn_cnt (instructions count) 表示指令条数;
    • insns (instructions) 包含了具体的每一条指令,这儿的 13 条指令跟我们前面 bpftool prog dump 的结果是一致的(具体的指令格式,你可以参考内核中 bpf_insn 的定义);
  • 第三个参数 128 表示属性的大小。

在 eBPF 的实现中,诸如内核跟踪(kprobe)、用户跟踪(uprobe)等的事件绑定,都是通过 perf_event_open() 来完成的。

bpf程序加载过程:

sudo strace -v -f ./hello.py


...
/* 1) 加载BPF程序 */
bpf(BPF_PROG_LOAD,...) = 4
...

/* 2)查询事件类型 */
openat(AT_FDCWD, "/sys/bus/event_source/devices/kprobe/type", O_RDONLY) = 5
read(5, "6\n", 4096)                    = 2
close(5)                                = 0
...

/* 3)创建性能监控事件 */
perf_event_open(
    {
        type=0x6 /* PERF_TYPE_??? */,
        size=PERF_ATTR_SIZE_VER7,
        ...
        wakeup_events=1,
        config1=0x7f275d195c50,
        ...
    },
    -1,
    0,
    -1,
    PERF_FLAG_FD_CLOEXEC) = 5

/* 4)绑定BPF到kprobe事件 */
ioctl(5, PERF_EVENT_IOC_SET_BPF, 4)     = 0
...
  • 首先,借助 bpf 系统调用,加载 BPF 程序,并记住返回的文件描述符;
  • 然后,查询 kprobe 类型的事件编号。BCC 实际上是通过 /sys/bus/event_source/devices/kprobe/type 来查询的;
  • 接着,调用 perf_event_open 创建性能监控事件。比如,事件类型(type 是上一步查询到的 6)、事件的参数( config1 包含了内核函数 do_sys_openat2 )等;
  • 最后,再通过 ioctl 的 PERF_EVENT_IOC_SET_BPF 命令,将 BPF 程序绑定到性能监控事件。

可以参考内核源码 perf_event_set_bpf_prog 的实现;而最终性能监控调用 BPF 程序的实现,则可以参考内核源码 kprobe_perf_func 的实现。

BPF 系统调用

在命令行中输入 man bpf ,就可以查询到 BPF 系统调用的调用格式:

#include <linux/bpf.h>

// 第一个,cmd ,代表操作命令,比如上一讲中我们看到的 BPF_PROG_LOAD 就是加载 eBPF 程序;
// 第二个,attr,代表 bpf_attr 类型的 eBPF 属性指针,不同类型的操作命令需要传入不同的属性参数;
// 第三个,size ,代表属性的大小。
// 注意,不同版本的内核所支持的 BPF 命令是不同的,具体支持的命令列表可以参考内核头文件 include/uapi/linux/bpf.h 中 bpf_cmd 的定义。比如,v5.13 内核已经支持 36 个 BPF 命令:
int bpf(int cmd, union bpf_attr *attr, unsigned int size);

image.png

image.png

BPF 映射

image.png

BPF 辅助函数中并没有 BPF 映射的创建函数,BPF 映射只能通过用户态程序的系统调用来创建。比如,你可以通过下面的示例代码来创建一个 BPF 映射,并返回映射的文件描述符:

int bpf_create_map(enum bpf_map_type map_type,
       unsigned int key_size,
       unsigned int value_size, unsigned int max_entries)
{
  union bpf_attr attr = {
    .map_type = map_type,
    .key_size = key_size,
    .value_size = value_size,
    .max_entries = max_entries
  };
  return bpf(BPF_MAP_CREATE, &attr, sizeof(attr));
}

你可以使用如下的 bpftool 命令,来查询当前系统支持哪些映射类型:

$ bpftool feature probe | grep map_type
eBPF map_type hash is available
eBPF map_type array is available
eBPF map_type prog_array is available
eBPF map_type perf_event_array is available
eBPF map_type percpu_hash is available
eBPF map_type percpu_array is available
eBPF map_type stack_trace is available
...

image.png

如果你的 eBPF 程序使用了 BCC 库,你还可以使用预定义的宏来简化 BPF 映射的创建过程。

// 使用默认参数 key_type=u64, leaf_type=u64, size=10240
BPF_HASH(stats);

// 使用自定义key类型,保持默认 leaf_type=u64, size=10240
struct key_t {
  char c[80];
};
BPF_HASH(counts, struct key_t);

// 自定义所有参数
BPF_HASH(cpu_time, uint64_t, uint64_t, 4096);

除了创建之外,映射的删除也需要你特别注意。BPF 系统调用中并没有删除映射的命令,这是因为 BPF 映射会在用户态程序关闭文件描述符的时候自动删除(即close(fd))。 如果你想在程序退出后还保留映射,就需要调用 BPF_OBJ_PIN 命令,将映射挂载到 /sys/fs/bpf 中。

在调试 BPF 映射相关的问题时,你还可以通过 bpftool 来查看或操作映射的具体内容。比如,你可以通过下面这些命令创建、更新、输出以及删除映射:

//创建一个哈希表映射,并挂载到/sys/fs/bpf/stats_map(Key和Value的大小都是2字节)
bpftool map create /sys/fs/bpf/stats_map type hash key 2 value 2 entries 8 name stats_map

//查询系统中的所有映射
bpftool map
//示例输出
//340: hash  name stats_map  flags 0x0
//        key 2B  value 2B  max_entries 8  memlock 4096B

//向哈希表映射中插入数据
bpftool map update name stats_map key 0xc1 0xc2 value 0xa1 0xa2

//查询哈希表映射中的所有数据
 
bpftool map dump name stats_map
//示例输出
//key: c1 c2  value: a1 a2
//Found 1 element

//删除哈希表映射
rm /sys/fs/bpf/stats_map

BPF 类型格式 (BTF)

从内核 5.2 开始,只要开启了 CONFIG_DEBUG_INFO_BTF,在编译内核时,内核数据结构的定义就会自动内嵌在内核二进制文件 vmlinux 中。并且,你还可以借助下面的命令,把这些数据结构的定义导出到一个头文件中(通常命名为 vmlinux.h):

// 你在开发 eBPF 程序时只需要引入一个 vmlinux.h 即可
bpftool btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.h

image.png

# bpftool map dump id 386
[
  {
      "key": 0,
      "value": {
          "eth0": {
              "value": 0,
              "ifindex": 0,
              "mac": []
          }
      }
  }
]

https://github.com/aquasecurity/btfhub

eBPF 程序可以分成几类?

根据内核头文件 include/uapi/linux/bpf.h 中 bpf_prog_type 的定义,Linux 内核 v5.13 已经支持 30 种不同类型的 eBPF 程序(注意, BPF_PROG_TYPE_UNSPEC表示未定义):

enum bpf_prog_type {
  BPF_PROG_TYPE_UNSPEC, /* Reserve 0 as invalid program type */
  BPF_PROG_TYPE_SOCKET_FILTER,
  BPF_PROG_TYPE_KPROBE,
  BPF_PROG_TYPE_SCHED_CLS,
  BPF_PROG_TYPE_SCHED_ACT,
  BPF_PROG_TYPE_TRACEPOINT,
  BPF_PROG_TYPE_XDP,
  BPF_PROG_TYPE_PERF_EVENT,
  BPF_PROG_TYPE_CGROUP_SKB,
  BPF_PROG_TYPE_CGROUP_SOCK,
  BPF_PROG_TYPE_LWT_IN,
  BPF_PROG_TYPE_LWT_OUT,
  BPF_PROG_TYPE_LWT_XMIT,
  BPF_PROG_TYPE_SOCK_OPS,
  BPF_PROG_TYPE_SK_SKB,
  BPF_PROG_TYPE_CGROUP_DEVICE,
  BPF_PROG_TYPE_SK_MSG,
  BPF_PROG_TYPE_RAW_TRACEPOINT,
  BPF_PROG_TYPE_CGROUP_SOCK_ADDR,
  BPF_PROG_TYPE_LWT_SEG6LOCAL,
  BPF_PROG_TYPE_LIRC_MODE2,
  BPF_PROG_TYPE_SK_REUSEPORT,
  BPF_PROG_TYPE_FLOW_DISSECTOR,
  BPF_PROG_TYPE_CGROUP_SYSCTL,
  BPF_PROG_TYPE_RAW_TRACEPOINT_WRITABLE,
  BPF_PROG_TYPE_CGROUP_SOCKOPT,
  BPF_PROG_TYPE_TRACING,
  BPF_PROG_TYPE_STRUCT_OPS,
  BPF_PROG_TYPE_EXT,
  BPF_PROG_TYPE_LSM,
  BPF_PROG_TYPE_SK_LOOKUP,
};    

对于具体的内核来说,因为不同内核的版本和编译配置选项不同,一个内核并不会支持所有的程序类型。你可以在命令行中执行下面的命令,来查询当前系统支持的程序类型:

bpftool feature probe | grep program_type
  • 第一类是跟踪,即从内核和程序的运行状态中提取跟踪信息,来了解当前系统正在发生什么。
  • 第二类是网络,即对网络数据包进行过滤和处理,以便了解和控制网络数据包的收发过程。
  • 第三类是除跟踪和网络之外的其他类型,包括安全控制、BPF 扩展等等。

image.png

网络类 eBPF 程序

网络类 eBPF 程序主要用于对网络数据包进行过滤和处理,进而实现网络的观测、过滤、流量控制以及性能优化等各种丰富的功能。根据事件触发位置的不同,网络类 eBPF 程序又可以分为 XDPeXpress Data Path,高速数据路径)程序、TCTraffic Control,流量控制)程序、套接字程序以及 cgroup 程序,下面我们来分别看看。

XDP 程序

XDP 程序的类型定义为 BPF_PROG_TYPE_XDP,它在网络驱动程序刚刚收到数据包时触发执行。由于无需通过繁杂的内核网络协议栈,XDP 程序可用来实现高性能的网络处理方案,常用于 DDoS 防御、防火墙、4 层负载均衡等场景。

你需要注意,XDP 程序并不是绕过了内核协议栈,它只是在内核协议栈之前处理数据包,而处理过的数据包还可以正常通过内核协议栈继续处理。

image.png

根据网卡和网卡驱动是否原生支持 XDP 程序,XDP 运行模式可以分为下面这三种:

  • 通用模式。它不需要网卡和网卡驱动的支持,XDP 程序像常规的网络协议栈一样运行在内核中,性能相对较差,一般用于测试;
  • 原生模式。它需要网卡驱动程序的支持,XDP 程序在网卡驱动程序的早期路径运行;
  • 卸载模式。它需要网卡固件支持 XDP 卸载,XDP 程序直接运行在网卡上,而不再需要消耗主机的 CPU 资源,具有最好的性能。

无论哪种模式,XDP 程序在处理过网络包之后,都需要根据 eBPF 程序执行结果,决定数据包的去处。这些执行结果对应以下 5 种 XDP 程序结果码:

image.png

通常来说,XDP 程序通过 ip link 命令加载到具体的网卡上,加载格式为:

# eth1 为网卡名
# xdpgeneric 设置运行模式为通用模式
# xdp-example.o 为编译后的 XDP 字节码
sudo ip link set dev eth1 xdpgeneric object xdp-example.o

而卸载 XDP 程序也是通过 ip link 命令,具体参数如下:

sudo ip link set veth1 xdpgeneric off

除了 ip link之外, BCC 也提供了方便的库函数,让我们可以在同一个程序中管理 XDP 程序的生命周期:

from bcc import BPF

# 编译XDP程序
b = BPF(src_file="xdp-example.c")
fn = b.load_func("xdp-example", BPF.XDP)

# 加载XDP程序到eth0网卡
device = "eth0"
b.attach_xdp(device, fn, 0)

# 其他处理逻辑
...

# 卸载XDP程序
b.remove_xdp(device)

TC 程序

TC 程序的类型定义为 BPF_PROG_TYPE_SCHED_CLS 和 BPF_PROG_TYPE_SCHED_ACT,分别作为 Linux 流量控制 的分类器和执行器。Linux 流量控制通过网卡队列、排队规则、分类器、过滤器以及执行器等,实现了对网络流量的整形调度和带宽控制。

image.png

得益于内核 v4.4 引入的 direct-action 模式,TC 程序可以直接在一个程序内完成分类和执行的动作,而无需再调用其他的 TC 排队规则和分类器,具体如下图所示:

image.png

同 XDP 程序相比,TC 程序可以直接获取内核解析后的网络报文数据结构sk_buffXDP 则是 xdp_buff),并且可在网卡的接收和发送两个方向上执行(XDP 则只能用于接收)。下面我们来具体看看 TC 程序的执行位置:

  • 对于接收的网络包,TC 程序在网卡接收(GRO)之后、协议栈处理(包括 IP 层处理和 iptables 等)之前执行;
  • 对于发送的网络包,TC 程序在协议栈处理(包括 IP 层处理和 iptables 等)之后、数据包发送到网卡队列(GSO)之前执行。

除此之外,由于 TC 运行在内核协议栈中,不需要网卡驱动程序做任何改动,因而可以挂载到任意类型的网卡设备(包括容器等使用的虚拟网卡)上。

同 XDP 程序一样,TC eBPF 程序也可以通过 Linux 命令行工具来加载到网卡上,不过相应的工具要换成 tc。你可以通过下面的命令,分别加载接收和发送方向的 eBPF 程序:

# 创建 clsact 类型的排队规则
sudo tc qdisc add dev eth0 clsact

# 加载接收方向的 eBPF 程序
sudo tc filter add dev eth0 ingress bpf da obj tc-example.o sec ingress

# 加载发送方向的 eBPF 程序
sudo tc filter add dev eth0 egress bpf da obj tc-example.o sec egress

套接字程序

套接字程序用于过滤、观测或重定向套接字网络包,具体的种类也比较丰富。根据类型的不同,套接字 eBPF 程序可以挂载到套接字(socket)、控制组(cgroup)以及网络命名空间(netns)等各个位置。你可以根据具体的应用场景,选择一个或组合多个类型的 eBPF 程序,去控制套接字的网络包收发过程。

image.png

cgroup 程序

cgroup 程序用于对 cgroup 内所有进程的网络过滤、套接字选项以及转发等进行动态控制,它最典型的应用场景是对容器中运行的多个进程进行网络控制。

image.png

这些类型的 BPF 程序都可以通过 BPF 系统调用的 BPF_PROG_ATTACH 命令来进行挂载,并设置挂载类型为匹配的 BPF_CGROUP_xxx 类型。比如,在挂载 BPF_PROG_TYPE_CGROUP_DEVICE 类型的 BPF 程序时,需要设置 bpf_attach_type 为 BPF_CGROUP_DEVICE

union bpf_attr attr = {};
attr.target_fd = target_fd;            // cgroup文件描述符
attr.attach_bpf_fd = prog_fd;          // BPF程序文件描述符
attr.attach_type = BPF_CGROUP_DEVICE;  // 挂载类型为BPF_CGROUP_DEVICE

if (bpf(BPF_PROG_ATTACH, &attr, sizeof(attr)) < 0) {
  return -errno;
}

...

注意,这几类网络 eBPF 程序是在不同的事件触发时执行的,因此,在实际应用中我们通常可以把多个类型的 eBPF 程序结合起来,一起使用,来实现复杂的网络控制功能。比如,最流行的 Kubernetes 网络方案 Cilium 就大量使用了 XDPTC 和套接字 eBPF 程序,如下图(图片来自 Cilium 官方文档,图中黄色部分即为 Cilium eBPF 程序)所示:

image.png

其他类 eBPF 程序

image.png

进阶

内核跟踪

内核函数是一个非稳定 API,在新版本中可能会发生变化,并且内核函数的数量也在不断增长中。以 v5.13.0 为例,总的内核符号表数量已经超过了 16 万:

$ cat /proc/kallsyms | wc -l
165694

为了方便内核开发者获取所需的跟踪点信息,内核调试文件系统还向用户空间提供了内核调试所需的基本信息,如内核符号列表、跟踪点、函数跟踪(ftrace)状态以及参数格式等。你可以在终端中执行 sudo ls /sys/kernel/debug 来查询内核调试文件系统的具体信息。

比如,执行下面的命令,就可以查询 execve 系统调用的参数格式

sudo cat /sys/kernel/debug/tracing/events/syscalls/sys_enter_execve/format

sudo mount -t debugfs debugfs /sys/kernel/debug

利用 bpftrace 查询跟踪点

bpftrace 在 eBPF 和 BCC 之上构建了一个简化的跟踪语言,通过简单的几行脚本,就可以实现复杂的跟踪功能。并且,多行的跟踪指令也可以放到脚本文件中执行(脚本后缀通常为  .bt)。

# Ubuntu 19.04
sudo apt-get install -y bpftrace

# RHEL8/CentOS8
sudo dnf install -y bpftrace


# 查询所有内核插桩和跟踪点
sudo bpftrace -l

# 使用通配符查询所有的系统调用跟踪点
sudo bpftrace -l 'tracepoint:syscalls:*'

# 使用通配符查询所有名字包含"execve"的跟踪点
sudo bpftrace -l '*execve*'

image.png

比如,下面就是一个查询系统调用 execve 入口参数(对应系统调用sys_enter_execve)和返回值(对应系统调用sys_exit_execve)的示例:

# 查询execve入口参数格式
$ sudo bpftrace -lv tracepoint:syscalls:sys_enter_execve
tracepoint:syscalls:sys_enter_execve
    int __syscall_nr
    const char * filename
    const char *const * argv
    const char *const * envp

# 查询execve返回值格式
$ sudo bpftrace -lv tracepoint:syscalls:sys_exit_execve
tracepoint:syscalls:sys_exit_execve
    int __syscall_nr
    long ret
    
    

如何利用内核跟踪点排查短时进程问题?

image.png

借助刚才提到的 bpftrace 工具,你可以执行下面的命令,查询所有包含 execve 关键字的跟踪点:

sudo bpftrace -l '*execve*'


kprobe:__ia32_compat_sys_execve
kprobe:__ia32_compat_sys_execveat
kprobe:__ia32_sys_execve
kprobe:__ia32_sys_execveat
kprobe:__x32_compat_sys_execve
kprobe:__x32_compat_sys_execveat
kprobe:__x64_sys_execve
kprobe:__x64_sys_execveat
kprobe:audit_log_execve_info
kprobe:bprm_execve
kprobe:do_execveat_common.isra.0
kprobe:kernel_execve
tracepoint:syscalls:sys_enter_execve
tracepoint:syscalls:sys_enter_execveat
tracepoint:syscalls:sys_exit_execve
tracepoint:syscalls:sys_exit_execveat

从输出中,你可以发现这些函数可以分为内核插桩(kprobe)和跟踪点(tracepoint)两类。内核插桩属于不稳定接口,而跟踪点则是稳定接口。因而,在内核插桩和跟踪点两者都可用的情况下,应该选择更稳定的跟踪点,以保证 eBPF 程序的可移植性(即在不同版本的内核中都可以正常执行)。

bpftrace、BCC 、libbpf 对比

  • bpftrace 通常用在快速排查和定位系统上,它支持用单行脚本的方式来快速开发并执行一个 eBPF 程序。不过,bpftrace 的功能有限,不支持特别复杂的 eBPF 程序,也依赖于 BCC 和 LLVM 动态编译执行。
  • BCC 通常用在开发复杂的 eBPF 程序中,其内置的各种小工具也是目前应用最为广泛的 eBPF 小程序。不过,BCC 也不是完美的,它依赖于 LLVM 和内核头文件才可以动态编译和加载 eBPF 程序。
  • libbpf 是从内核中抽离出来的标准库,用它开发的 eBPF 程序可以直接分发执行,这样就不需要每台机器都安装 LLVM 和内核头文件了。不过,它要求内核开启 BTF 特性,需要非常新的发行版才会默认开启(如 RHEL 8.2+ 和 Ubuntu 20.10+ 等)。

如何使用 bpftrace 来跟踪短时进程?

    sudo bpftrace -e 'tracepoint:syscalls:sys_enter_execve,tracepoint:syscalls:sys_enter_execveat { printf("%-6d %-8s", pid, comm); join(args->argv);}'
    sudo bpftrace -e 'tracepoint:syscalls:sys_enter_execve { printf("%ld %s  ", nsecs, comm); join(args->argv);}'
    
  • bpftrace -e 表示直接从后面的字符串参数中读入 bpftrace 程序(除此之外,它还支持从文件中读入 bpftrace 程序);
  • tracepoint:syscalls:sys_enter_execve,tracepoint:syscalls:sys_enter_execveat 表示用逗号分隔的多个跟踪点,其后的中括号表示跟踪点的处理函数;
  • printf() 表示向终端中打印字符串,其用法类似于 C 语言中的 printf() 函数;
  • pid 和 comm 是 bpftrace 内置的变量,分别表示进程 PID 和进程名称(你可以在其官方文档中找到其他的内置变量);
  • join(args->argv) 表示把字符串数组格式的参数用空格拼接起来,再打印到终端中。对于跟踪点来说,你可以使用 args->参数名 的方式直接读取参数(比如这里的 args->argv 就是读取系统调用中的 argv 参数)。

BCC 内核跟踪

可以用下面的方式来创建这两个映射:

struct data_t {
    u32 pid;
    char comm[TASK_COMM_LEN];
    int retval;
    unsigned int args_size;
    char argv[FULL_MAX_ARGS_ARR];
};
BPF_PERF_OUTPUT(events);
BPF_HASH(tasks, u32, struct data_t);
  • struct data_t 定义了一个包含进程基本信息的数据结构,它将用在哈希映射的值中(其中的参数大小 args_size 会在读取参数内容的时候用到);
  • BPF_PERF_OUTPUT(events) 定义了一个性能事件映射;
  • BPF_HASH(tasks, u32, struct data_t) 定义了一个哈希映射,其键为 32 位的进程 PID,而值则是进程基本信息 data_t

两个映射定义好之后,接下来就是定义跟踪点的处理函数。在 BCC 中,你可以通过 TRACEPOINT_PROBE(category, event) 来定义一个跟踪点处理函数。BCC 会将所有的参数放入 args 这个变量中,这样使用 args-><参数名> 就可以访问跟踪点的参数值。

// 引入内核头文件
#include <uapi/linux/ptrace.h>
#include <linux/sched.h>
#include <linux/fs.h>

// 定义sys_enter_execve跟踪点处理函数.
TRACEPOINT_PROBE(syscalls, sys_enter_execve)
{
    // 变量定义
    unsigned int ret = 0;
    
    // BCC 把所有参数都放到了  args  中,你可以使用  args->argv  来访问参数列表:
    const char **argv = (const char **)(args->argv);

    // 调用  bpf_get_current_pid_tgid()  查询进程 PID,调用  bpf_get_current_comm()  读取进程名称:
    // 获取进程PID和进程名称
    struct data_t data = { };
    u32 pid = bpf_get_current_pid_tgid();
    data.pid = pid;
    bpf_get_current_comm(&data.comm, sizeof(data.comm));

    // 获取第一个参数(即可执行文件的名字)
    if (__bpf_read_arg_str(&data, (const char *)argv[0]) < 0) {
        goto out;
    }

    // 获取其他参数(限定最多5个)
    #pragma unrollfor (int i = 1; i < TOTAL_MAX_ARGS; i++) {
        if (__bpf_read_arg_str(&data, (const char *)argv[i]) < 0) {
            goto out;
        }
    }

 out:
    // 存储到哈希映射中
    tasks.update(&pid, &data);
    return 0;
}


// 从用户空间读取字符串
// 在调用  bpf_probe_read_user_str()  前后,需要对指针位置和返回值进行校验,这可以帮助 eBPF 验证器获取指针读写的边界(如果你感兴趣,可以参考这篇文章,了解更多的内存访问验证细节)。
static int __bpf_read_arg_str(struct data_t *data, const char *ptr)
{
    if (data->args_size > LAST_ARG) {
        return -1;
    }

    // bpf_probe_read_user_str()  返回的是包含字符串结束符  \0  的长度。为了拼接所有的字符串,在计算已读取参数长度的时候,需要把  \0  排除在外。
   // &data->argv[data->args_size]  用来获取要存放参数的位置指针,这是为了把多个参数拼接到一起。
   // 在调用  bpf_probe_read_user_str()  前后,需要对指针位置和返回值进行校验,这可以帮助 eBPF 验证器获取指针读写的边界
    int ret = bpf_probe_read_user_str(&data->argv[data->args_size], ARGSIZE, (void *)ptr);
    if (ret > ARGSIZE || ret < 0) {
        return -1;
    }

    // increase the args size. the first tailing '\0' is not counted and hence it
    // would be overwritten by the next call.
    data->args_size += (ret - 1);

    return 0;
}


// 定义sys_exit_execve跟踪点处理函数.
TRACEPOINT_PROBE(syscalls, sys_exit_execve)
{
    // 从哈希映射中查询进程基本信息
    u32 pid = bpf_get_current_pid_tgid();
    struct data_t *data = tasks.lookup(&pid);

    // 填充返回值并提交到性能事件映射中
    if (data != NULL) {
        data->retval = args->ret;
        events.perf_submit(args, data, sizeof(struct data_t));

        // 最后清理进程信息
        tasks.delete(&pid);
    }

    return 0;
}
    

Python 前端处理,Python 前端逻辑需要 eBPF 程序加载、挂载到内核函数和跟踪点,以及通过 BPF 映射获取和打印执行结果等几个步骤。其中,因为我们已经使用了 TRACEPOINT_PROBE 宏定义,来定义 eBPF 跟踪点处理函数,BCC 在加载字节码的时候,会帮你自动把它挂载到正确的跟踪点上,所以挂载的步骤就可以忽略。完整的 Python 程序如下所示:

# 引入库函数
from bcc import BPF
from bcc.utils import printb

# 1) 加载eBPF代码
b = BPF(src_file="execsnoop.c")

# 2) 输出头
print("%-6s %-16s %-3s %s" % ("PID", "COMM", "RET", "ARGS"))

# 3) 定义性能事件打印函数
def print_event(cpu, data, size):
    # BCC自动根据"struct data_t"生成数据结构s
    event = b["events"].event(data)
    printb(b"%-6d %-16s %-3d %-16s" % (event.pid, event.comm, event.retval, event.argv))

# 4) 绑定性能事件映射和输出函数,并从映射中循环读取数据
b["events"].open_perf_buffer(print_event)
while 1:
    try:
        b.perf_buffer_poll()
    except KeyboardInterrupt:
        exit()

libbpf 方法

用户态追踪

如何查询用户进程跟踪点?

在跟踪内核的状态之前,你需要利用内核提供的调试信息查询内核函数、内核跟踪点以及性能事件等。类似地,在跟踪应用进程之前,你也需要知道这个进程所对应的二进制文件中提供了哪些可用的跟踪点。那么,从哪里可以找到这些信息呢?如果你使用 GDB 之类的应用调试过程序,这时应该已经想到了,那就是应用程序二进制文件中的调试信息。

在静态语言的编译过程中,通常你可以加上 -g 选项保留调试信息。这样,源代码中的函数、变量以及它们对应的代码行号等信息,就以 DWARFDebugging With Attributed Record FormatsLinux 和类 Unix 平台最主流的调试信息格式)格式存储到了编译后的二进制文件中。

有了调试信息,你就可以通过 readelfobjdumpnm 等工具,查询可用于跟踪的函数、变量等符号列表。

# 查询符号表(RHEL8系统中请把动态库路径替换为/usr/lib64/libc.so.6)
readelf -Ws /usr/lib/x86_64-linux-gnu/libc.so.6

# 查询USDT信息(USDT信息位于ELF文件的notes段)
readelf -n /usr/lib/x86_64-linux-gnu/libc.so.6


# 查询uprobe(RHEL8系统中请把动态库路径替换为/usr/lib64/libc.so.6)
bpftrace -l 'uprobe:/usr/lib/x86_64-linux-gnu/libc.so.6:*'

# 查询USDT
bpftrace -l 'usdt:/usr/lib/x86_64-linux-gnu/libc.so.6:*'

同内核跟踪点类似,你也可以加上 -v 选项查询用户探针的参数格式。不过需要再次强调的是,想要通过二进制文件查询符号表和参数定义,必须在编译的时候保留 DWARF 调试信息。

ELF 符号表包含 .symtab(应用本地的符号)和 .dynsym(调用到外部的符号),strip 命令实际上只是删除了 .symtab 的内容。

除了符号表之外,理论上你可以把 uprobe 插桩到二进制文件的任意地址。不过这要求你对应用程序 ELF 格式的地址空间非常熟悉,并且具体的地址会随着应用的迭代更新而发生变化。所以,在需要跟踪地址的场景中,一定要记得去 ELF 二进制文件动态获取地址信息。

uprobe 是基于文件的。当文件中的某个函数被跟踪时,除非对进程 PID 进行了过滤,默认所有使用到这个文件的进程都会被插桩。

Bash 里面到底执行过什么命令

在跟踪 Bash 之前,首先执行下面的命令,安装它的调试信息:

# Ubuntu
sudo apt install bash-dbgsym

# RHEL
sudo debuginfo-install bash

有了 Bash 调试信息之后,再执行下面的几步,查询 Bash 的符号表:

# 第一步,查询 Build ID(用于关联调试信息)
readelf -n /usr/bin/bash | grep 'Build ID'
# 输出示例为:
#     Build ID: 7b140b33fd79d0861f831bae38a0cdfdf639d8bc

# 第二步,找到调试信息对应的文件(调试信息位于目录/usr/lib/debug/.build-id中,上一步中得到的Build ID前两个字母为目录名)
ls /usr/lib/debug/.build-id/7b/140b33fd79d0861f831bae38a0cdfdf639d8bc.debug

# 第三步,从调试信息中查询符号表
readelf -Ws /usr/lib/debug/.build-id/7b/140b33fd79d0861f831bae38a0cdfdf639d8bc.debug

参考 Bash 的源代码,每条 Bash 命令在运行前,都会调用 charreadline (const charprompt) 函数读取用户的输入,然后再去解析执行(Bash 自身是使用编译型语言 C 开发的,而 Bash 语言则是一种解释型语言)。

注意,readline 函数的参数是命令行提示符(通过环境变量 PS1、PS2 等设置),而返回值才是用户的输入。因而,我们只需要跟踪 readline 函数的返回值,也就是使用 uretprobe 跟踪。

bpftrace、BCC 以及 libbpf 等工具均支持 uretprobe,因而最简单的跟踪方法就是使用 bpftrace 的单行命令:

// uretprobe:/usr/bin/bash:readline  设置跟踪类型为  uretprobe,跟踪的二进制文件为  /usr/bin/bash,跟踪符号为  readline;
// 中括号里的内容为 uretprobe 的处理函数;
// 处理函数中,uid  和  retval  是两个内置变量,分别表示用户 UID 以及返回值;
// str 用于从指针中读取字符串, str(retval) 就是 Bash 中输入命令的字符串;
// printf 用于向终端中打印一个字符串。
sudo bpftrace -e 'uretprobe:/usr/bin/bash:readline { printf("User %d executed \"%s\" command\n", uid, str(retval)); }'

网络跟踪

image.png

eBPF 提供了大量专用于网络的 eBPF 程序类型,包括 XDP 程序、TC 程序、套接字程序以及 cgroup 程序等。这些类型的程序涵盖了从网卡(如卸载到硬件网卡中的 XDP 程序)到网卡队列(如 TC 程序)、封装路由(如轻量级隧道程序)、TCP 拥塞控制、套接字(如 sockops 程序)等内核协议栈,再到同属于一个 cgroup 的一组进程的网络过滤和控制,而这些都是内核协议栈的核心组成部分

容器安全

既然容器是共享内核的,这些安全问题的解决自然就可以从内核的角度进行考虑。除了容器自身所强依赖的命名空间、cgroupsLinux 权限控制 Capabilities 之外,可以动态跟踪和扩展内核的 eBPF 就成为了安全监控和安全控制的主要手段之一。 SysdigAqua SecurityDatadog 等业内知名的容器安全解决方案,都基于 eBPF 构建了丰富的安全特性。

比如,Aqua Security 开源的 Tracee 项目就利用 eBPF,动态跟踪系统和应用的可疑行为模式,再与不断丰富的特征检测库进行匹配,就可以分析出容器应用中的安全问题。

image.png

image.png

比如,Sysdig 贡献给 CNCF 基金会的 Falco 项目,就利用 eBPF 在运行时监测内核和应用中是否发生了诸如特权提升、SHELL 命令执行、系统文件(比如 /etc/passwd)修改、SSH 登录等异常行为,再通过告警系统实时将这些安全事件及时推送给你。

容器运行时安全系统 KubeArmor 就利用 LSM 和 eBPF,限制容器中进程执行、文件访问、网络连接等各种违反安全策略的操作。

image.png

如何使用 eBPF 优化负载均衡性能?

每个 eBPF 程序都属于特定的类型,不同类型 eBPF 程序的触发事件是不同的。既然是网络的性能优化,自然应该去考虑网络类的 eBPF 程序。根据触发事件的不同,网络类 eBPF 程序可以分为 XDP 程序、TC 程序、套接字程序以及 cgroup 程序。这几类程序的触发事件和常用场景分别为:

  • XDP 程序在网络驱动程序刚刚收到数据包的时候触发执行,支持卸载到网卡硬件,常用于防火墙和四层负载均衡;
  • TC 程序在网卡队列接收或发送的时候触发执行,运行在内核协议栈中,常用于流量控制;
  • 套接字程序在套接字发生创建、修改、收发数据等变化的时候触发执行,运行在内核协议栈中,常用于过滤、观测或重定向套接字网络包。其中,BPF_PROG_TYPE_SOCK_OPS、BPF_PROG_TYPE_SK_SKB、BPF_PROG_TYPE_SK_MSG 等都可以用于套接字重定向;
  • cgroup 程序在 cgroup 内所有进程的套接字创建、修改选项、连接等情况下触发执行,常用于过滤和控制 cgroup 内多个进程的套接字。

根据这些触发事件,你可以发现这几类网络程序都有可能用在网络性能优化上。其中,由于支持卸载到硬件,XDP 的性能应该是最好的;而由于直接作用在套接字上,套接字程序和 cgroup 程序是最接近应用的。

clang -g -O2 -target bpf -D__TARGET_ARCH_x86 -I/usr/include/x86_64-linux-gnu -I. -c sockops.bpf.c -o sockops.bpf.o

总结

image.png

BCClibbpf 以及内核源码,都主要使用 C 语言开发 eBPF 程序,而实际的应用程序可能会以多种多样的编程语言进行开发。所以,开源社区也开发和维护了很多不同语言的接口,方便这些高级语言跟 eBPF 系统进行交互。比如,我们课程多次使用的 BCC 就提供了 PythonC++ 等多种语言的接口,而使用 BCC 的 Python 接口去加载 eBPF 程序,要比 libbpf 和内核源码的方法简单得多。

在这些开发库的基础上,得益于 eBPF 在动态跟踪、网络、安全以及云原生等领域的广泛应用,开源社区中也诞生了各种编程语言的开发库,特别是 Go 和 Rust 这两种语言,其开发库尤为丰富。

image.png

在使用这些 Go 语言开发库时需要注意,Go 开发库只适用于用户态程序中,可以完成 eBPF 程序编译、加载、事件挂载,以及 BPF 映射交互等用户态的功能,而内核态的 eBPF 程序还是需要使用 C 语言来开发的。

而对于 Rust 来说,由于其出色的安全和性能,也诞生了很多不同的开发库。下面的表格列出了常见的 Rust 语言开发库,以及它们的使用场景:

image.png

从 Go 和 Rust 语言的开发库中你可以发现,纯编程语言实现的开发库(即不依赖于 libbpf 和 BCC 的库)由于很难及时通过内核进行同步,通常都有一定的功能限制;而 libbpf 和 libbcc 的开发语言绑定通常功能更为完善,但开发和运行环境就需要安装 libbpf 和 libbcc(部分开发库支持静态链接,在运行环境不再需要 libbpf 或 libbcc)。因为底层的实现是类似的,所以掌握了 libbpf 和 BCC 的使用方法,在学习其他语言的绑定时会更容易理解。

image.png

image.png

eBPF 应用类

image.png

 
posted @ 2024-08-14 11:06  liujiacai  阅读(5)  评论(0编辑  收藏  举报