eBPF (扩展伯克利包过滤器)起源于Linux内核,可以在操作系统内核中运行的沙盒程序。其技术安全有效地扩展内核功能。而无需更改内核源代码或者加载内核模块。
eBPF 被广泛用于:
内核性能追踪
网络安全和可观测性
应用程序和容器运行时安全
……
1.eBPF程序执行一般性流程
eBPF 程序的首先使用 C 或 Rust 编写 eBPF 程序,LLVM编译为字节码,用户态程序通过 eBPF 库,使用 bpf 系统调用将 eBPF 字节码加载到Linux 内核。
内核 ebpf 验证器,校验 BPF 字节码:
发起bpf系统调用的进程是否具有相应权限,要求进程具有相关的Linux Capabilities(CAP_BPF)或root权限;
检查程序是否会导致内核崩溃,例如是否有未初始化的变量,是否有可能导致数组越界、空指针访问的语句;
检查程序是否有限时间执行完,eBPF 只允许有限的循环和跳转,且只允许执行有限的指令条数。
eBPF 程序在完成构建后,挂载到内核上的对应事件上如系统调用,当某个系统调用产生时,触发内核调用对应的 eBPF 程序。内核 ebpf 程序通过map 数据结构与用户态程序进行交互,完成相应功能。
2.eBPF跟踪
2.1 探针类型
内核探针:提供对内核中内部组件的动态访问
跟踪点:提供对用户空间运行的程序的动态访问
用户空间探针:提供对用户空间运行的程序的动态访问
用户静态定义跟踪点:提供对用户空间运行的程序的静态访问
2.2 内核探针
内核探针可以在任何内核指令上设置动态标志或中断,当内核到达这些标志时,附加到探针的代码将被执行,之后内核将恢复正常模式。
内核探针分为两类:kprobes 和 kretprobes。
kprobes允许在执行任何内核指令之前插入BPF程序,需要知道插入点的函数签名,内核探针不是稳定的ABI(程序二进制接口),所以需要谨慎在不同的内核版本中运行设置探针的程序。当内核执行到设置探针的指令时,它将从代码执行处开始运行BPF程序,在BPF程序执行完成后将返回至插入BPF程序处继续执行。
kretprobes是在内核指令有返回值时插入BPF程序。通常,我们会在一个BPF程序中同时使用kprobes和kretprobes,以便获得对内核指令的全面了解。
跟踪点是内核代码的静态标记,可用于将代码附加在运行的内核中。跟踪点与kprobes的主要区别在于跟踪点由内核开发人员在内核中编写和修改。由于跟踪点是静态存在的,所以跟踪点的ABI是最稳定的。
跟踪点是内核开发人员添加的,所以跟踪点可能并不会涵盖到内核的所有子系统。
/sys/kernel/debug/tracing/events目录下的内容可以查看系统中所有可用的跟踪点。
上面输出中的每个子目录对应一个BPF程序可附加的跟踪点。还有两个额外文件:第一个文件为enable,允许启用和禁用BPF子系统的所有跟踪点。如果该文件内容为0,表示禁用跟踪点。如果该文件内容为1,表示跟踪点已启用。
内核探针与跟踪点提供了对内核的完全访问。由于跟踪点更加安全,尽可能使用跟踪点。
2.4 用户空间探针
用户空间探针允许在用户空间运行的程序中设置动态标志。它们等同于内核探针,用户空间探针是运行在用户空间的监测系统。当我们定义uprobe时,内核会在附加的指令上创建trap,当程序执行到该指令时,内核将触发事件已回调函数的方式调用探针函数。uprobes也可以访问程序链接到的任何库,只要知道指令的名称,就可以跟踪对应的调用。
与内核探针非常相似,用户空间探针也分为两类:uprobes和uretporbes,依赖于插入BPF程序在指令执行周期的哪个阶段。
uprobes是内核在程序特定指令执行之前插入该指令集的钩子。不同内核版本的函数签名可能有所变化。Linux中可以使用nm命令列出ELF对象文件中包括的所有符号,并检查跟踪指令在程序中是否存在。
uretprobes是kretprobes并行探针,适用于用户空间程序使用。它将BPF程序附加到指令返回值之上,允许通过BPF代码从寄存器中访问返回值。
uprobes和uretprobes的结合使用可以编写更复杂的BPF程序。
● eBPF 允许在以下位置创建内核中的跟踪点(tracepoint)
○ 系统调用
○ 网络接口(socket/xdp)
○ 函数入口/退出
○ 内核跟踪点
○ 容器(cgroup)
○ 用户模式功能
……
● eBPF 允许创建探针(probe):
○ 内核探针(kprobe)
○ 用户探针(uprobe)
3.eBPF程序组成部分
4.eBPF映射
BPF映射以键/值保存在内核中,可以被任何BPF程序访问。用户空间的程序也可以通过文件描述符访问BPF映射。BPF映射中可以保存实现指定大小的任何类型的数据。内核将键和值作为二进制块,这意味着内核并不关心BPF映射保存的具体内容。
BPF验证器使用多种保护措施确保创建和访问BPF映射的方式是安全的。
创建BPF映射的最直接方式是使用bpf系统调用。如果该系统调用的第一个参数设置为BPF_MAP_CREATE,则表示创建一个新映射。改调用将返回与创建映射相关的文件描述符。bpf系统调用的第二个参数是BPF映射的设置。
union bpf_attr(){
struct {
__u32 map_type; /*bpf_map_type*/
__u32 key_size;
__u32 value_size;
__u32 max_entries;
__u32 map_flags;
};
}
bpf系统调用的第三个参数是设置属性的大小,创建一个键和值为无符号整数的哈希表映射:
union bpf_attr_my_map {
.map_type = BPF_MAP_TYPE_HASH,
.key_size = sizeof(int),
.value_size = sizeof(int),
.max_entries = 100,
.map_flags = BPF_F_NO_PREALLOC,
};
int fd = bpf(BPF_MAP_CREATE, &my_map, sizeof(my_map));
如果系统调用失败,内核返回-1,失败原因有三种,通过errno来进行区分。
如果属性无效,内核返回EINVAL。
如果没有足够的权限执行操作,内核返回EPERM。
如果没有足够的内存保存映射,内核将返回ENOMEM。
4.1 使用ELF约定创建BPF映射
内核存在一些约定和帮助函数,用于生成和使用BPF映射。这些约定即使运行在内核中,底层仍然是通过bpf系统调用来创建映射。
帮助函数bpf_map_create封装了我们上面使用的代码,可以容易地按需初始化映射。
int fd;
fd = bpf_map_create(BPF_MAP_TYPE_HASH, sizeof(int), sizeof(int), 100, BPF_F_NO_PREALOC);
4.2 使用BPF映射
内核和用户空间之间的通信是编写BPF程序的基础。内核程序和用户空间程序代码都可访问映射,但它们使用的API签名不同。
内核程序可以直接访问映射,而用户程序需要使用文件描述符来引用映射。
int key, value, result;
key = 1, value = 1234;
result = bpf_map_update_elem(map_data[0].fd, &key, &value, BPF_ANY);
if(result == 0)
printf("Map updated with new element\n");
else
printf("Failed to update map with new value: %d (%s)\n", result, strerror(errno));
4.2.2 读取BPF映射元素
BPF根据程序执行位置提供了两个不同的帮助函数用来读取映射元素。这两个函数名都为bpf_map_lookup_elem。
从内核空间读取映射:
int key, value, result;
key = 1;
result = bpf_map_lookup_elem(&my_map, &key, &value);
if(result == 0)
printf("Value to read from the map: '%d'\n", value);
else
printf("Failed to read value from the map: %d (%s)\n", result, strerror(errno));
从用户空间读取映射:
int key, value, result;
key = 1;
result = bpf_map_lookup_elem(map_data[0].fd, &key, &value);
if(result == 0)
printf("Value to read from the map: '%d'\n", value);
else
printf("Failed to read value from the map: %d (%s)\n", result, strerror(errno));
bpf_map_lookup_elem中的第一个参数将替换为映射的文件描述符。帮助函数的行为与上面示例的行为相同。
4.2.3 删除BPF映射元素
BPF根据程序执行位置提供了两个不同的帮助函数用来删除映射元素。这两个函数名都为bpf_map_delete_element。
从内核空间删除插入映射中的值:
int key, value, result;
key = 1;
result = bpf_map_delete_element(&my_map, &key);
if(result == 0)
printf("Element deleted from the map\n");
else
printf("Failed to delete element from the map: %d (%s)\n", result, strerror(errno));
从用户空间读取映射:
int key, value, result;
key = 1;
result = bpf_map_delete_element(map_data[0].fd, &key);
if(result == 0)
printf("Element deleted from the map\n");
else
printf("Failed to delete element from the map: %d (%s)\n", result, strerror(errno));
4.2.4 迭代BPF映射元素
BPF中查找任意元素。BPF提供bpf_map_get_next_key指令,该指令仅仅适用于用户空间上运行的程序。
int next_key, lookup_key;
lookup_key = -1;
while(bpf_map_get_next_key(map_data[0].fd, &lookup_key, &next_key) == 0){
printf("The next key in the map is: '%d'\n", next_key);
lookup_key = next_key;
}
4.2.5 查找和删除映射元素
bpf_map_lookup_and_delete_elem。此功能是在映射中查找指定的键并删除元素。同时,程序将该元素的值赋予一个变量。
int key, value, result, it;
key = 1;
for (it =0; it < 2; it++){
result = bpf_map_lookup_and_delete_element(map_data[0].fd, &key, &value);
if(result == 0)
printf("Value read from the map: '%d'\n", value);
else
printf("Failed to read value from the map: %d (%s)\n", result, strerror(errno));
}
4.2.6 并发访问映射元素
并发访问相同的映射元素,可能会在BPF程序中产生竞争条件。BPF增加了BPF自旋锁的概念,可以在操作映射元素时对访问的映射元素进行锁定。自旋锁仅适用于数组、哈希、cgroup存储映射。
内核中有两个帮助函数与自旋锁一起使用:bpf_spin_lock锁定、bpf_spin_unlock解锁。用户程序可以使用BPF_F_LOCK标志。
使用自旋锁首先需要创建要锁定访问的元素,然后为该元素添加信号。
struct concurrent_element{
struct bpf_spin_lock semaphore;
int count;
}
我们可以声明持有这些元素的映射。该映射必须使用BPF类型格式(BTF)进行注释,以便验证器知道如何解释BTF。BTF可以通过给二进制对象添加调试信息,为内核和其他工具提供更丰富的信息。在内核中,我们可以使用libbpf的内核宏来注释这个并发映射。
struct bpf_map_def SEC("maps") concurrent_map = {
.type = BPF_MAP_TYPE_HASH,
.key_size = sizeof(int),
.value_size = sizeof(struct concurrent_element),
.max_entries = 100,
};
BPF_ANNOTATE_KV_PAIR(concurrent_map, int, struct concurrent_element);
使用这两个帮助函数保护这些元素防止竞争条件。
5. BPF映射类型
5.1 哈希表映射
哈希表映射是添加到BPF中的第一个通用映射。映射类型定义为BPF_MAP_TYPE_HASH。
数组映射是添加到内核的第二个BPF映射。映射类型定义为BPF_MAP_TYPE_ARRAY。对数组映射初始化时,所有元素在内存中将预分配空间并设置为零。键是数组中的索引,大小必须恰好为四个字节。数组映射中的元素不能删除。
程序数组映射添加到内核的第一个专用映射。映射类型定义为BPF_MAP_TYPE_PROC_ARRAY。这种类型保存对BPF程序的引用,即BPF程序的文件描述符。程序数组映射类型可以与帮助函数bpf_tail_call结合使用,实现在程序之间跳转,突破单个BPF程序最大指令的限制,并且降低实现的复杂度。键和值的大小必须为四个字节。跳转到新程序时,新程序将使用相同的内存栈,因此程序不会耗尽所有有效的内存。如果跳转到不存在的程序时,尾部调用将失败,返回继续执行当前程序。
这种类型映射将perf_events数据存储在环形缓存区中,用于BPF程序和用户空间程序进行实时通信。
映射类型定义为BPF_MAP_TYPE_PERF_EVENT_ARRAY。它可以将内核跟踪工具发出的事件转发给用户空间程序,做进一步处理。
声明event结构体:
struct data_t{
u32 pid;
char program_name[16];
}
创建映射用来发送event到用户空间:
struct bpf_map_def SEC("maps") events = {
.type = BPF_MAP_TYPE_PERF_EVENT_ARRAY,
.key_size = sizeof(int),
.value_size = sizeof(u32),
.max_entries = 2,
}
声明数据类型和映射后,我们可以创建BPF程序用来捕获数据并发送到用户空间:
SEC("kprobe/sys_exec")
int bpf_capture_exec(struct pt_regs *ctx){
data_t data;
data.pid = bpf_get_current_pid_tgid() >> 32;
bpf_get_current_comm(&data.program_name, sizeof(data.program_name));
bpf_perf_event_output(ctx, &events, 0, &data, sizeof(data));
return 0;
}
reference
《Linux内核观测技术BPF》
https://ebpf.io/zh-cn/