https://cloud.tencent.com/developer/inventory/600/article/1644458
这是一个实战系列文章,它是eBPF学习计划里面的应用场景之网络部分,终极目标是源码级别学习云原生环境下使用eBPF的场景,比如Cilium、Falco等(声明:下文提到的BPF字样是泛指,包括cBPF和eBPF)。 本篇文章从源码级别学习BPF Map使用场景和工作原理,祝大家阅读愉快。
目录
文章涉及的实验环境和代码可以到这个git repo获取:
https://github.com/nevermosby/linux-bpf-learning/tree/master/bpf/bpf-maps
通过消息传递来触发程序中的行为是软件工程中广泛使用的技术。一个程序可以通过发送消息来修改另一个程序的行为,这也允许这些程序之间通过这个方式来传递信息。
关于BPF最吸引人的一个方面,就是运行在内核上的程序可以在运行时使用消息传递相互通信,我称之为「communication on air」。
而BPF Map就是用户空间和内核空间之间的数据交换、信息传递的桥梁。
BPF Map本质上是以「键/值」方式存储在内核中的数据结构,它们可以被任何知道它们的BPF程序访问。在内核空间的程序创建BPF Map并返回对应的文件描述符,在用户空间运行的程序就可以通过这个文件描述符来访问并操作BPF Map,这就是为什么BPF Map在BPF世界中是桥梁的存在了。
根据申请内存方式的不同,BPF Map有很多种类型(具体的列表可以看之前的博客文章),常用的类型是BPF_MAP_TYPE_HASH和BPF_MAP_TYPE_ARRAY,它们背后的内存管理方式跟我们熟悉的哈希表和数组基本一致,此外还有包括BPF_MAP_TYPE_PROG_ARRAY、BPF_MAP_TYPE_PERF_EVENT_ARRAY等10余种Map类型,具体可以查看之前的博文。随着多CPU架构的成熟发展,BPF Map也引入了per-cpu类型,如BPF_MAP_TYPE_PERCPU_HASH、BPF_MAP_TYPE_PERCPU_ARRAY等,当你使用这种类型的BPF Map时,每个CPU都会存储并看到它自己的Map数据,从属于不同CPU之间的数据是互相隔离的,这样做的好处是,在进行查找和聚合操作时更加高效,性能更好,尤其是你的BPF程序主要是在做收集时间序列型数据,如流量数据或指标等。
union bpf_attr my_map_attr {
.map_type = BPF_MAP_TYPE_ARRAY,
.key_size = sizeof(int),
.value_size = sizeof(int),
.max_entries = 1024,
.map_flags = BPF_F_NO_PREALLOC,
};
int fd = bpf(BPF_MAP_CREATE, &my_map_attr, sizeof(my_map_attr));
上面是原生创建BPF Map的代码片段,最初创建BPF Map的方式都是通过bpf系统调用函数(上述代码第9行),传入的第一个参数是BPF_MAP_CREATE
,它是创建BPF Map系统调用的代号,第二参数是指定将要创建Map的属性,第三个参数是这个Map配置的大小。因此创建Map之前首先要声明一个BPF Map(上述代码的第1-7行),其中有四大要素:
map_type
),就是上文提到的各种Map类型key_size
),以字节为单位value_size
),以字节为单位max_entries
),个数为单位可以去到内核代码里看到相关定义:
union bpf_attr {
struct { /* anonymous struct used by BPF_MAP_CREATE command */
__u32 map_type; /* one of enum bpf_map_type */
__u32 key_size; /* size of key in bytes */
__u32 value_size; /* size of value in bytes */
__u32 max_entries; /* max number of entries in a map */
__u32 map_flags; /* BPF_MAP_CREATE related
* flags defined above.
*/
__u32 inner_map_fd; /* fd pointing to the inner map */
__u32 numa_node; /* numa node (effective only if
* BPF_F_NUMA_NODE is set).
*/
char map_name[BPF_OBJ_NAME_LEN];
};
除了四大要素之后,还有一些高级选项,其中map_flags
作为可以调整Map创建行为的参数,使用频率也是越来越高。
上文示例代码的第9行中变量fd
是创建BPF Map系统调用的返回值。正常情况下它的值是被成功创建出来的BPF Map的文件描述符(file descriptor)。非正常情况下,也就是系统调用失败,BPF Map没有被成功创建出来,返回值为-1,可以通过系统全局错误变量errno
获取具体的错误信息,一般创建失败有3种原因:
相对于直接使用bpf系统调用函数来创建BPF Map,在实际场景中常用的是一个简化版:
struct bpf_map_def SEC("maps") my_bpf_map = {
.type = BPF_MAP_TYPE_HASH,
.key_size = sizeof(int),
.value_size = sizeof(int),
.max_entries = 100,
.map_flags = BPF_F_NO_PREALLOC,
};
这个简化版看起来就是一个BPF Map声明,它是如何做到声明即创建的呢?关键点就是SEC("maps")
,学名ELF惯例格式(ELF convention),它的工作原理是这样的:
声明ELF Section属性 SEC("maps") (之前的博文里有对Section作用的描述)
内核代码bpf_load.crespect目标文件中所有Section信息,它会扫描目标文件里定义的Section,其中就有用来创建BPF Map的SEC("maps"),我们可以到相关代码里看到说明:
// https://elixir.bootlin.com/linux/v4.15/source/samples/bpf/bpf_load.h#L41
/* parses elf file compiled by llvm .c->.o
* . parses 'maps' section and creates maps via BPF syscall // 就是这里
* . parses 'license' section and passes it to syscall
* . parses elf relocations for BPF maps and adjusts BPF_LD_IMM64 insns by
* storing map_fd into insn->imm and marking such insns as BPF_PSEUDO_MAP_FD
* . loads eBPF programs via BPF syscall
*
* One ELF file can contain multiple BPF programs which will be loaded
* and their FDs stored stored in prog_fd array
*
* returns zero on success
*/
int load_bpf_file(char *path);
bpf_load.o
作为依赖库,并合并为最终的可执行文件中,这样在程序运行起来时,就可以通过声明SEC("maps")
即可完成创建BPF Map的行为了。从上面梳理的过程可以看到,这个简化版虽然使用了“语法糖”,但最后还是会去使用bpf()函数完成系统调用。
BPF Map也有自己的CRUD,除了bpf_map_create
是创建BPF Map操作之外,下面列出了其他主要操作,
bpf_map_lookup_elem(map, key)
函数,通过key查询BPF Map,得到对应valuebpf_map_update_elem(map, key, value, options)
函数,通过key-value更新BPF Map,如果这个key不存在,也可以作为新的元素插入到BPF Map中去bpf_map_get_next_key(map, lookup_key, next_key)
函数,这个函数可以用来遍历BPF Map,下文有具体的介绍。
Linux内核观测技术BPF
还记得上一篇如何调试BPF程序的博文么,我们是通过bpf_trace_printk函数打印日志来调试程序,但是这个只能在内核空间使用的helper函数,其本身有很多限制,包括输出参数的个数有限、类型有限等。现在我们就可以借助BPF Map来完成相同的功能,即在内核空间收集网络包信息(源地址和目标地址),在用户空间展示这些信息,完成期待的程序「调试」工作。
代码主要分两个部分:
请注意,该程序的编译运行是基于Linux内核代码中BPF示例环境,如果你还不熟悉,可以参考这篇博文
下面首先介绍运行在内核空间的示例代码:
#define KBUILD_MODNAME "foo"
#include <uapi/linux/bpf.h>
#include <uapi/linux/if_ether.h>
#include <uapi/linux/if_packet.h>
#include <uapi/linux/if_vlan.h>
#include <uapi/linux/ip.h>
#include <uapi/linux/in.h>
#include <uapi/linux/tcp.h>
#include <uapi/linux/udp.h>
#include "bpf_helpers.h"
#include "bpf_endian.h"
#include "xdp_ip_tracker_common.h"
#define bpf_printk(fmt, ...) \
({ \
char ____fmt[] = fmt; \
bpf_trace_printk(____fmt, sizeof(____fmt), \
##__VA_ARGS__); \
})
struct bpf_map_def SEC("maps") tracker_map = {
.type = BPF_MAP_TYPE_HASH,
.key_size = sizeof(struct pair),
.value_size = sizeof(struct stats),
.max_entries = 2048,
};
static __always_inline bool parse_and_track(bool is_rx, void *data_begin, void *data_end, struct pair *pair)
{
struct ethhdr *eth = data_begin;
if ((void *)(eth + 1) > data_end)
return false;
if (eth->h_proto == bpf_htons(ETH_P_IP))
{
struct iphdr *iph = (struct iphdr *)(eth + 1);
if ((void *)(iph + 1) > data_end)
return false;
pair->src_ip = is_rx ? iph->daddr : iph->saddr;
pair->dest_ip = is_rx ? iph->saddr : iph->daddr;
// update the map for track
struct stats *stats, newstats = {0, 0, 0, 0};
long long bytes = data_end - data_begin;
stats = bpf_map_lookup_elem(&tracker_map, pair);
if (stats)
{
if (is_rx)
{
stats->rx_cnt++;
stats->rx_bytes += bytes;
}
else
{
stats->tx_cnt++;
stats->tx_bytes += bytes;
}
}
else
{
if (is_rx)
{
newstats