eBPF Loader
February 27, 2022
0. What is eBPF?
Extended Berkeley Packet Filter (eBPF) 是由 Linux 提供的内核技术,它是以安全沙盒 (Virtual Machine) 的形式运行用户定义的 ByteCode 来观测内核运行状态以及拓展内核的能力,开发者无需定制内核模块就可以高效地完成对现有模块的拓展。eBPF 安全沙盒是嵌入到 Linux 内核运行态的关键路径上,通过事件订阅的形式来触发 eBPF 程序,其运用场景有:
我过去主要使用 eBPF 在观测和排查一些节点的性能问题。由于底层基础设施能力以及业务运行模型存在差异,这将导致节点组件出现不在预期内的行为,而在本地又难以复现,大大增加了沟通和排查成本。而 eBPF 可以捕捉到程序在内核里的状态,甚至是短命的程序调用 (比如容器领域的 runC 命令) 都可以捕捉, 它可以最大程度地呈现程序的运行状态来提升问题的排查效率。
比如前段时间遇到的 containerD CRI 组件创建容器超时的问题,我通过 bcc stackcount 捕抓到 根因: umount 可写的文件系统时会调用底层文件系统的刷盘动作,它用来保证数据能及时落盘;但这同时也给磁盘带来压力,IOPS 弱的数据盘将会拖慢 umount 调用。对于一个不熟悉内核代码的开发者来说,eBPF 观测类工具暴露出的关键函数路径就和日志的错误信息一样,全局搜索相应的内核代码段,然后顺藤摸瓜,总会找到些蛛丝马迹。
eBPF 目前发展的比较快,其中一个重要的支线是 Compile-Once Run-Everwhere (CO-RE) ,这有点像 docker 镜像分发的 Build-Once Run-Anywhere, 下面将主要围绕兼容性去介绍如何加载 eBPF 程序。
1. Type Metadata
eBPF 允许读取和修改内核运行态的数据,常见数据结构有线程的 task_struct
和网络子系统的 __sk_buff
。这些常用的结构体在不同内核版本之间存在差异, 比如某一个字段位于 task_struct
结构体的第 16 字节处,然而升级到某一个内核版本后,这个字段被移到第 24 字节处,那么原先编译好的 eBPF 将无法正常工作。早期的 bcc 组件在使用过程中都是在目标节点上利用现有的内核相关头文件来编译。但即使如此,bcc 也没法解决字段名被更换的问题,字段名变化容易出现编译不通过。因此早期大部分情况下,开发者还是会选择根据不同内核版本出不同的二进制,这给测试验证带来了极大的成本。
为了解决类型匹配问题,eBPF 需要额外的类型系统的描述数据。eBPF 程序所使用的数据类型和函数接口都通过常用的调试信息 DWARF 描述, 如下图所示 bootstrap.bpf.c 编译完毕后的 task_struct
部分类型信息, 其中 thread_info
和 state
是 task_struct
的字段。
// use llvm-dwarfdump
...
0x00005f27: DW_TAG_structure_type
DW_AT_name ("task_struct")
DW_AT_byte_size (0x1ac0)
DW_AT_decl_file ("xxx")
DW_AT_decl_line (883)
0x00005f2f: DW_TAG_member
DW_AT_name ("thread_info")
DW_AT_type (0x00006775 "thread_info")
DW_AT_decl_file ("xxx")
DW_AT_decl_line (884)
DW_AT_data_member_location (0x00)
0x00005f3a: DW_TAG_member
DW_AT_name ("state")
DW_AT_type (0x00006793 "volatile long")
DW_AT_decl_file ("xxx")
DW_AT_decl_line (885)
DW_AT_data_member_location (0x10)
...
DWARF 对类型描述十分详细,包含类型字段的大小、字段的偏移量等等。当 eBPF 加载时,只需要在当前 Linux 内核的 DWARF 调试信息找到最匹配 eBPF 程序中的类型即可,即使字段的偏移量发生改变也没关系,只需 Relocation 成对应的偏移量即可。但 DWARF 调试信息为纯文本格式,内核的调试信息大概有 100+ MB,让内核启动带上这些信息成本太高了,这里需要可压缩的类型格式。
内核社区提出了 BPF Type Format (BTF), 它可以将 100+ MB DWARF 信息压缩到 1.5 MB,eBPF 以及 Linux 内核都方便携带,具体的压缩算法可以阅读 BTF deduplication and Linux kernel BTF, 在此就仅仅展示 BTF dump 信息。
// use pahole -JV
...
[624] STRUCT task_struct size=6848
thread_info type_id=625 bits_offset=0
state type_id=626 bits_offset=128
stack type_id=29 bits_offset=192
usage type_id=308 bits_offset=256
flags type_id=37 bits_offset=288
ptrace type_id=37 bits_offset=320
on_cpu type_id=10 bits_offset=352
wake_entry type_id=628 bits_offset=384
cpu type_id=37 bits_offset=512
...
当每个字段的偏移量以及类型信息可通过极低的成本携带时,eBPF 加载器可以使用类型匹配策略来将使用体验提升到新的高度。下面将介绍加载器的核心工作 - 重定向。
2. Relocation
eBPF 一般来说是由 clang/llvm 编译 C 文件得到的 ELF 二进制文件, 其中它采用十个通用的寄存器、只读的 Frame Pointer 寄存器以及使用长度为 64 bits 的 指令集。加载器需要解决重定向后才能进行 BPF Verifier,而重定向内容主要涉及到三部分 Map,CO-RE 以及函数调用。
2.1 Map
eBPF 程序和程序之间以及同用户态之间的交互都是通过 Map 来实现,而 Map 增删改查是通过文件句柄的形式来操作,而这依赖 BPF_MAP_CREATE bpf 系统调用。
// https://man7.org/linux/man-pages/man2/bpf.2.html
bpf(BPF_MAP_CREATE, &bpf_attr, sizeof(bpf_attr));
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; /* prealloc or not */
};
}
bpf_attr
定义可以从 ELF 二进制的 section .maps
或者 maps
中获取, 如下面的代码片段所示。bpf_map_def
定义数据模式已经被弃用了,如下图所示,加载器需要严格按照固定的偏移量来读取 bpf_attr
,相比 BTF 类型系统而言,编码和使用体验都差很多。
// Use BTF
struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, 8192);
__type(key, pid_t);
__type(value, u64);
} exec_start_btf SEC(".maps");
// Use symbol but it has been deprecated
struct bpf_map_def SEC("maps") exec_start_symbol = {
.type = BPF_MAP_TYPE_HASH,
.key_size = sizeof(pid_t),
.value_size = sizeof(u64),
.max_entries = 8192,
};
// https://github.com/fuweid/demos/tree/master/ebpf
➜ llvm-readelf -s -x maps ./.output/example-map-relo.bpf.o
Symbol table '.symtab' contains 6 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 0000000000000000 0 SECTION LOCAL DEFAULT 2 tp/sched/sched_process_exec
2: 0000000000000000 184 FUNC GLOBAL DEFAULT 2 handle_exec
3: 0000000000000000 32 OBJECT GLOBAL DEFAULT 5 exec_start_btf
4: 0000000000000000 20 OBJECT GLOBAL DEFAULT 4 exec_start_symbol
5: 0000000000000000 13 OBJECT GLOBAL DEFAULT 3 LICENSE
Hex dump of section 'maps':
.type=1 .key_size=4 .value_size=8 .max_entries = 8192
0x00000000 01000000 04000000 08000000 00200000 ............. ..
.flags=0
0x00000010 00000000
当加载器读取 eBPF ELF 时, 代码段 section, 比如 tp/sched/sched_process_exec
, 其对应的重定向符号的偏移量会在 .reltp/sched/sched_process_exec
描述,如下面的结果所示,其中 18 01 00 00 00 00 00 00 00 00 00 00 00 00 00 00
DWORD 指令将是我们要替换的指令。
➜ llvm-objdump -dr ./.output/example-map-relo-btf.bpf.o
./.output/example-map-relo-btf.bpf.o: file format elf64-bpf
Disassembly of section tp/sched/sched_process_exec:
0000000000000000 <handle_exec>:
0: 85 00 00 00 0e 00 00 00 call 14
1: 77 00 00 00 20 00 00 00 r0 >>= 32
2: 63 0a fc ff 00 00 00 00 *(u32 *)(r10 - 4) = r0
3: 85 00 00 00 05 00 00 00 call 5
4: 7b 0a f0 ff 00 00 00 00 *(u64 *)(r10 - 16) = r0
5: bf a2 00 00 00 00 00 00 r2 = r10
6: 07 02 00 00 fc ff ff ff r2 += -4
7: bf a3 00 00 00 00 00 00 r3 = r10
8: 07 03 00 00 f0 ff ff ff r3 += -16
9: 18 01 00 00 00 00 00 00 00 00 00 00 00 00 00 00 r1 = 0 ll
0000000000000048: R_BPF_64_64 exec_start_btf
11: b7 04 00 00 00 00 00 00 r4 = 0
12: 85 00 00 00 02 00 00 00 call 2
13: 95 00 00 00 00 00 00 00 exit
➜ llvm-readelf -r ./.output/example-map-relo-btf.bpf.o
Relocation section '.reltp/sched/sched_process_exec' at offset 0x6d8 contains 1 entries:
Offset Info Type Symbol's Value Symbol's Name
0000000000000048 0000000300000001 R_BPF_64_64 0000000000000000 exec_start_btf
当前阅读最新的内核文档 (v5.17.0-rc5) 没有说明如何替换 eBPF Map 符号指令,反倒是 libbpf 加载器中有说明:当指令类型为 LD_IMM64 且原寄存器编号不为 0 时,那么该指令可以被重写成 MAP 相关的操作,重写规则如下面的代码所示。回顾上头要被替换指令,其开头 18
为 LD_IMM64 且 01
是 r1
寄存器, 替换的 Map 符号符合该模式;因此通过 BPF 系统调用申请 Map 文件句柄之后,加载器可以把代码段中第 52 个 Byte 替换成对应的文件句柄即可。
需要说明的是,替换的并不是 .reltp/sched/sched_process_exec
中提到的第 48 Byte。BPF 并不采用 Elf64_Rela 来携带 Addend, 而是针对不同的重定向类型作了特殊的约定, 比如 R_BPF_64_64
类型的替换地址为 重定向声明的偏移量 + 4 = 48 + 4 = 52
。
// https://github.com/libbpf/libbpf/blob/v0.7.0/include/uapi/linux/bpf.h#L1121
/* When BPF ldimm64's insn[0].src_reg != 0 then this can have
* the following extensions:
*
* insn[0].src_reg: BPF_PSEUDO_MAP_[FD|IDX]
* insn[0].imm: map fd or fd_idx
* insn[1].imm: 0
* insn[0].off: 0
* insn[1].off: 0
* ldimm64 rewrite: address of map
* verifier type: CONST_PTR_TO_MAP
*/
#define BPF_PSEUDO_MAP_FD 1
#define BPF_PSEUDO_MAP_IDX 5
为了方便用户态修改程序中的只读常量,clang/llvm 会将 const volatile
声明的全局变量合并成一个结构体,并将为其声明成只有 [一个元素的 Map],数据按照顺序存储在第一个 Value 里。因为不同变量在结构体的偏移量不同,那么指令改写逻辑有点差异,如下面的代码所示。
// https://github.com/libbpf/libbpf/blob/v0.7.0/include/uapi/linux/bpf.h#L1135
/* insn[0].src_reg: BPF_PSEUDO_MAP_[IDX_]VALUE
* insn[0].imm: map fd or fd_idx
* insn[1].imm: offset into value
* insn[0].off: 0
* insn[1].off: 0
* ldimm64 rewrite: address of map[0]+offset
* verifier type: PTR_TO_MAP_VALUE
*/
#define BPF_PSEUDO_MAP_VALUE 2
#define BPF_PSEUDO_MAP_IDX_VALUE 6
不过使用的时候一定要注意用户态的类似是不是和 eBPF 程序中的一致,感兴趣的可以查看 iovisor/bcc#3777。
2.2 Field Offset Rewrite
前面提到了 Linux 内核社区设计可压缩的 BTF 格式,我们通过工具转化 DWARF 数据成 BTF,那么内核里所有的数据结构都可以通过 BTF 反推成 C 头文件。当我们有了这个 All-in-One 头文件,那我们就不再需要在安装 kernel-headers 了!没人喜欢安装一堆依赖,不是吗?!
➜ bpftool btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.h
我们来看一个具体的例子吧, example-field-offset-rewrite 读取当前 exec 进程 task_struct
结构体,并返回它的 real_parent
的 pid。如下图所示,我们可以看到没有加载前的访问指令是符合 vmlinux.c 描述的偏移量的:
real_parent
在task
变量的9472 bits = 1184 bytes
的位置;- 同时
real_parent
本身也是一个task_struct
类型的变量,它的pid
字段将在9344 bits = 1168 bytes
位置读取。
➜ llvm-objdump -dr ./.output/example-field-offset-rewrite.bpf.o
./.output/example-field-offset-rewrite.bpf.o: file format elf64-bpf
Disassembly of section tp/sched/sched_process_exec:
0000000000000000 <handle_exec>:
0: 85 00 00 00 23 00 00 00 call 35
// 使用 Ubuntu v5.8 内核 BTF dump 出来的 vmlinux.c
// [596] STRUCT task_struct size=6848
// ...
// real_parent type_id=594 bitfield_size=0 bits_offset=9472
1: b7 01 00 00 a0 04 00 00 r1 = 1184 <== 1184 Bytes
2: 0f 10 00 00 00 00 00 00 r0 += r1
3: bf a1 00 00 00 00 00 00 r1 = r10
4: 07 01 00 00 f0 ff ff ff r1 += -16
5: b7 02 00 00 08 00 00 00 r2 = 8
6: bf 03 00 00 00 00 00 00 r3 = r0
// bpf_probe_read 调用读出 real_parent task_struct == task->real_parent
7: 85 00 00 00 71 00 00 00 call 113
// [596] STRUCT task_struct size=6848
// ...
// pid type_id=1800 bitfield_size=0 bits_offset=9344
8: b7 01 00 00 90 04 00 00 r1 = 1168
9: 79 a3 f0 ff 00 00 00 00 r3 = *(u64 *)(r10 - 16)
10: 0f 13 00 00 00 00 00 00 r3 += r1
11: bf a1 00 00 00 00 00 00 r1 = r10
12: 07 01 00 00 fc ff ff ff r1 += -4
13: b7 02 00 00 04 00 00 00 r2 = 4
14: 85 00 00 00 71 00 00 00 call 113
15: 61 a0 fc ff 00 00 00 00 r0 = *(u32 *)(r10 - 4)
16: 95 00 00 00 00 00 00 00 exit
但上面的 eBPF 指令访问 task_struct
内部字段是基于 v5.8 内核 BTF 构建的,和我当前的 v5.13.0 内核版本存在较大的差异, real_parent
和 pid
偏移量都变化较多。而且 llvm-objdump -dr
并没有显示哪些 [读取字段的指令] 需要替换。由于这部分指令修改策略还没有更新到当前最新的 v5.17.0-rc5
版本上,只能通过阅读代码来获取。
➜ uname -a
Linux chaofan 5.13.0-30-generic #33~20.04.1-Ubuntu SMP Mon Feb 7 14:25:10 UTC 2022 x86_64 x86_64 x86_64 GNU/Linux
➜ bpftool btf dump file /sys/kernel/btf/vmlinux | less
...
[174] STRUCT 'task_struct' size=9472 vlen=232
...
'real_parent' type_id=175 bits_offset=18816
'pid' type_id=60 bits_offset=18688
通过查看代码,我发现主要由三个 Patch 来提供替换的编码信息:
- [BPF] Preserve debuginfo array/union/struct type/access index
- CO-RE offset relocations
- bpf: CO-RE support in the kernel
其中 llvm 的 Patch 做到了在 BTF 中保留对内核数据结构的访问路径, 如下面的代码片段所示, 目前 libbpf 加载器一次的访问路径的最大深度为 9。
// https://github.com/cilium/ebpf/blob/v0.8.1/internal/btf/core.go#L435
struct sample {
int a;
struct {
int b[10];
}
};
struct sample s = ...;
int x = &s->a; // encoded as "0:0" (a is field #0)
int y = &s->b[5]; // encoded as "0:1:0:5" (anon struct is field #1,
// b is field #0 inside anon struct, accessing elem #5)
int z = &s[10]->b; // encoded as "10:1" (ptr is used as an array)
// More info: https://llvm.org/docs/LangRef.html#getelementptr-instruction
而 0:1:0:5
这样的信息作为字符串保存在 .BTF
section 中,而重写指令的偏移量则保存在 .BTF.ext
的 sub-section 中,具体的数据结构如下:
// https://www.kernel.org/doc/html/latest/bpf/btf.html
struct btf_ext_info_sec {
__u32 sec_name_off; /* offset to section name */
__u32 num_info;
/* Followed by num_info * record_size number of bytes */
__u8 data[0];
};
// https://github.com/libbpf/libbpf/blob/v0.7.0/include/uapi/linux/bpf.h#L6560
struct bpf_core_relo {
__u32 insn_off; // 一开始是 section 偏移量,读出来一般都会修改成 [基于所在函数的偏移量],
// 方便后续不同 section 函数指令合并后的指令重写。
__u32 type_id; // 以上面 struct sample,那么 type_id 为 struct sample 的 BTF ID
__u32 access_str_off; // 0:1:0:5
enum bpf_core_relo_kind kind;
};
// sub-section layout about bpf_core_relo
btf_ext_info_sec for section #1 /* bpf_core_relo for section #1 */
btf_ext_info_sec for section #2 /* bpf_core_relo for section #2 */
...
有了这样访问路径信息后,加载器会读出本地 BTF 信息按照访问路径进行相应的结构体匹配,当然这里是 [尽力去找最匹配的结果],比如结构体的名字要一致,相应的结构体大小要一致等等。如果存在匹配结果,那么 bpf_core_relo.insn_off
对应的指令将会被修改; 否则,加载将会返回失败。
[一次编译,到处运行] 将 eBPF 使用体验都提升了好几个档次,想想看,OCI Artifacts 是不是可以 eBPF 程序作分发标准化呢?!
2.3 CALL INSN on pc-relative?
eBPF 是将每一个函数挂到相应的事件触发器上。一般来说,调用自定义的函数都会被 inlined 掉,即使代码段不在同一个 section 里。如下面的代码所示,这样程序是不需要重写指令的。
# https://github.com/fuweid/demos/blob/master/ebpf/example-func-inline.bpf.c
➜ llvm-objdump -dr ./.output/example-func-inline.bpf.o
./.output/example-func-inline.bpf.o: file format elf64-bpf
Disassembly of section .text:
0000000000000000 <double_ts_in_text>:
0: bf 10 00 00 00 00 00 00 r0 = r1
1: 67 00 00 00 01 00 00 00 r0 <<= 1
2: 95 00 00 00 00 00 00 00 exit
Disassembly of section tp/sched/sched_process_exec:
# 全部 inlined 到 handle_exec, 无需跳转
0000000000000000 <handle_exec>:
0: 85 00 00 00 05 00 00 00 call 5
1: 67 00 00 00 01 00 00 00 r0 <<= 1
2: 95 00 00 00 00 00 00 00 exit
如果 double_ts
函数不 inlined,那么编译后的结果如下。可以看到函数代码段在不同的 Section 中,加载器需要把所有相关的代码片段都 [追加] 到 handle_exec
的末尾,然后根据重定向的偏移量来修改指令。
需要注意的是,内核文档在描述 eBPF CALL 指令时并没有说 Immediate 是不是相对值。我是通过查看 libbpf 加载器中 BPF_PSEUDO_CALL 描述才知道这是一个相对值,偏移量的计算也是 约定值。函数调用重写相比 MAP/Field Offset 重写要简单些,一般这部分的指令修改都是放到最后做。
# https://github.com/fuweid/demos/blob/master/ebpf/example-func-noinline.bpf.c
➜ llvm-objdump -dr ./.output/example-func-noinline.bpf.o
./.output/example-func-noinline.bpf.o: file format elf64-bpf
Disassembly of section .text:
0000000000000000 <double_ts>:
0: bf 10 00 00 00 00 00 00 r0 = r1
1: 67 00 00 00 01 00 00 00 r0 <<= 1
2: 95 00 00 00 00 00 00 00 exit
Disassembly of section tp/sched/sched_process_exec:
0000000000000000 <handle_exec>:
0: 85 00 00 00 05 00 00 00 call 5
1: bf 01 00 00 00 00 00 00 r1 = r0
2: 85 10 00 00 ff ff ff ff call -1
0000000000000010: R_BPF_64_32 .text
3: 95 00 00 00 00 00 00 00 exit
# 修改后的结果 dump from kernel
int handle_exec(struct trace_event_raw_sched_process_exec * ctx):
; ts = bpf_ktime_get_ns();
0: (85) call bpf_ktime_get_ns#135136
; ts = double_ts(ts);
1: (bf) r1 = r0
2: (85) call pc+1#bpf_prog_6aadb6445c8badae_F
; return ts;
3: (95) exit
u64 double_ts(u64 ts):
; u64 double_ts(u64 ts) {
4: (bf) r0 = r1
; return ts + ts;
5: (67) r0 <<= 1
; return ts + ts;
6: (95) exit
3.0 Conclusion
本文只是简单介绍了 eBPF 加载器是怎么修改指令的,虽然还有很多细节没有提到,比如 CALL bpf_ktime_get_ns
指令是如何工作,以及 eBPF 尾调用如何拼接在一起等等,但大体的工作方式差不多,建议读一读 libbpf 加载器,或者 cilium/ebpf go 代码,还是比较有意思的。