概览
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.io:
Isovalent
开源的容器网络方案 。 - bcc :
BCC
是一个BPF
编译器集合,包含了用于构建BPF
程序的编程框架和库,并提供了大量可以直接使用的工具。使用BCC
的好处是,它把上述的eBPF
执行过程通过内置框架抽象了起来,并提供了Python、C++
等编程语言接口。这样,你就可以直接通过Python
语言去跟eBPF
的各种事件和数据进行交互。 - bpftrace:动态工具。
- libbpf 帮你避免了直接调用内核函数,提供了跨内核版本的兼容性(即一次编译到处执行,简称 CO-RE)
ebpf
为什么性能这么好呢?这主要得益于 BPF
的两大设计:
- 第一,内核态引入一个新的虚拟机,所有指令都在内核虚拟机中运行。
- 第二,用户态使用
BPF
字节码来定义过滤表达式,然后传递给内核,由内核虚拟机解释执行。
eBPF 的诞生是 BPF 技术的一个转折点,使得 BPF 不再仅限于网络栈,而是成为内核的一个顶级子系统。
eBPF
程序执行过程:
如果 BPF
字节码中包含了不安全的操作,验证器会直接拒绝 BPF 程序的执行。比如,下面就是一些典型的验证过程:
- 只有特权进程才可以执行
BPF
系统调用; BPF
程序不能包含无限循环;BPF
程序不能导致内核崩溃;BPF
程序必须在有限时间内完成。
BPF
程序可以利用 BPF
映射(map
)进行存储,而用户程序通常也需要通过 BPF
映射同运行在内核中的 BPF
程序进行交互
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
个模块组成:
- 第一个模块是
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);
BPF 映射
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
...
如果你的 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
# 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 扩展等等。
网络类 eBPF 程序
网络类 eBPF
程序主要用于对网络数据包进行过滤和处理,进而实现网络的观测、过滤、流量控制以及性能优化等各种丰富的功能。根据事件触发位置的不同,网络类 eBPF
程序又可以分为 XDP
(eXpress Data Path
,高速数据路径)程序、TC
(Traffic Control
,流量控制)程序、套接字程序以及 cgroup
程序,下面我们来分别看看。
XDP 程序
XDP
程序的类型定义为 BPF_PROG_TYPE_XDP
,它在网络驱动程序刚刚收到数据包时触发执行。由于无需通过繁杂的内核网络协议栈,XDP
程序可用来实现高性能的网络处理方案,常用于 DDoS
防御、防火墙、4
层负载均衡等场景。
你需要注意,XDP 程序并不是绕过了内核协议栈,它只是在内核协议栈之前处理数据包,而处理过的数据包还可以正常通过内核协议栈继续处理。
根据网卡和网卡驱动是否原生支持 XDP
程序,XDP
运行模式可以分为下面这三种:
- 通用模式。它不需要网卡和网卡驱动的支持,
XDP
程序像常规的网络协议栈一样运行在内核中,性能相对较差,一般用于测试; - 原生模式。它需要网卡驱动程序的支持,
XDP
程序在网卡驱动程序的早期路径运行; - 卸载模式。它需要网卡固件支持
XDP
卸载,XDP
程序直接运行在网卡上,而不再需要消耗主机的CPU
资源,具有最好的性能。
无论哪种模式,XDP
程序在处理过网络包之后,都需要根据 eBPF
程序执行结果,决定数据包的去处。这些执行结果对应以下 5
种 XDP
程序结果码:
通常来说,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
流量控制通过网卡队列、排队规则、分类器、过滤器以及执行器等,实现了对网络流量的整形调度和带宽控制。
得益于内核 v4.4
引入的 direct-action
模式,TC
程序可以直接在一个程序内完成分类和执行的动作,而无需再调用其他的 TC
排队规则和分类器,具体如下图所示:
同 XDP
程序相比,TC
程序可以直接获取内核解析后的网络报文数据结构sk_buff
(XDP
则是 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
程序,去控制套接字的网络包收发过程。
cgroup 程序
cgroup
程序用于对 cgroup
内所有进程的网络过滤、套接字选项以及转发等进行动态控制,它最典型的应用场景是对容器中运行的多个进程进行网络控制。
这些类型的 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
就大量使用了 XDP
、TC
和套接字 eBPF
程序,如下图(图片来自 Cilium
官方文档,图中黄色部分即为 Cilium eBPF
程序)所示:
其他类 eBPF 程序
进阶
内核跟踪
内核函数是一个非稳定 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*'
比如,下面就是一个查询系统调用 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
如何利用内核跟踪点排查短时进程问题?
借助刚才提到的 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
选项保留调试信息。这样,源代码中的函数、变量以及它们对应的代码行号等信息,就以 DWARF(Debugging With Attributed Record Formats
,Linux
和类 Unix
平台最主流的调试信息格式)格式存储到了编译后的二进制文件中。
有了调试信息,你就可以通过 readelf
、objdump
、nm
等工具,查询可用于跟踪的函数、变量等符号列表。
# 查询符号表(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)); }'
网络跟踪
eBPF
提供了大量专用于网络的 eBPF
程序类型,包括 XDP
程序、TC
程序、套接字程序以及 cgroup
程序等。这些类型的程序涵盖了从网卡(如卸载到硬件网卡中的 XDP
程序)到网卡队列(如 TC
程序)、封装路由(如轻量级隧道程序)、TCP
拥塞控制、套接字(如 sockops
程序)等内核协议栈,再到同属于一个 cgroup
的一组进程的网络过滤和控制,而这些都是内核协议栈的核心组成部分
容器安全
既然容器是共享内核的,这些安全问题的解决自然就可以从内核的角度进行考虑。除了容器自身所强依赖的命名空间、cgroups
、Linux
权限控制 Capabilities
之外,可以动态跟踪和扩展内核的 eBPF
就成为了安全监控和安全控制的主要手段之一。 Sysdig
、Aqua
Security
、Datadog
等业内知名的容器安全解决方案,都基于 eBPF
构建了丰富的安全特性。
比如,Aqua Security
开源的 Tracee 项目就利用 eBPF
,动态跟踪系统和应用的可疑行为模式,再与不断丰富的特征检测库进行匹配,就可以分析出容器应用中的安全问题。
比如,Sysdig
贡献给 CNCF
基金会的 Falco 项目,就利用 eBPF
在运行时监测内核和应用中是否发生了诸如特权提升、SHELL
命令执行、系统文件(比如 /etc/passwd
)修改、SSH
登录等异常行为,再通过告警系统实时将这些安全事件及时推送给你。
容器运行时安全系统 KubeArmor
就利用 LSM
和 eBPF
,限制容器中进程执行、文件访问、网络连接等各种违反安全策略的操作。
如何使用 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
总结
BCC
、libbpf
以及内核源码,都主要使用 C
语言开发 eBPF
程序,而实际的应用程序可能会以多种多样的编程语言进行开发。所以,开源社区也开发和维护了很多不同语言的接口,方便这些高级语言跟 eBPF
系统进行交互。比如,我们课程多次使用的 BCC
就提供了 Python
、C++
等多种语言的接口,而使用 BCC
的 Python
接口去加载 eBPF
程序,要比 libbpf
和内核源码的方法简单得多。
在这些开发库的基础上,得益于 eBPF
在动态跟踪、网络、安全以及云原生等领域的广泛应用,开源社区中也诞生了各种编程语言的开发库,特别是 Go
和 Rust
这两种语言,其开发库尤为丰富。
在使用这些 Go
语言开发库时需要注意,Go
开发库只适用于用户态程序中,可以完成 eBPF
程序编译、加载、事件挂载,以及 BPF
映射交互等用户态的功能,而内核态的 eBPF
程序还是需要使用 C
语言来开发的。
而对于 Rust
来说,由于其出色的安全和性能,也诞生了很多不同的开发库。下面的表格列出了常见的 Rust
语言开发库,以及它们的使用场景:
从 Go
和 Rust
语言的开发库中你可以发现,纯编程语言实现的开发库(即不依赖于 libbpf
和 BCC
的库)由于很难及时通过内核进行同步,通常都有一定的功能限制;而 libbpf
和 libbcc
的开发语言绑定通常功能更为完善,但开发和运行环境就需要安装 libbpf
和 libbcc
(部分开发库支持静态链接,在运行环境不再需要 libbpf
或 libbcc
)。因为底层的实现是类似的,所以掌握了 libbpf
和 BCC
的使用方法,在学习其他语言的绑定时会更容易理解。
eBPF 应用类