我是 LEE,老李,一个在 IT 行业摸爬滚打 16 年的技术老兵。
事件背景
在完成了第一章的编写《ebpf 开发入门之 helloworld》后,继续往下写多少怎么写都是我最近思考的问题。跟周边的小伙伴一起沟通后,认为 epbf 还在继续发展,重点应该关注它的核心概念,而不是重点关注它底层的内部实现,毕竟我的环境是对 epbf 最大程度的使用,而不是深入开发。所以经过一段时间的思考,觉得为了使用 cilium 深入 ebpf 研究无可厚非,但是过分深入则可能不太合适。
有了上面的观点,那么我就再写一个文章输出 epbf 的核心概念,后续深入的小伙伴可以根据自己实际需要找资料深入。
世界观
在讲解具体概念之前,我们先科普下 epbf 的整体世界观。
Hook
中文名
钩子
大白话
在 epbf 的世界里看 Linux 内核所有核心调用都可以 Hook,可以理解成为万物皆可挂钩子做 Callback。
具体解释
eBPF 程序都是事件驱动的,它们会在内核或者应用程序经过某个确定的 Hook 点的时候运行,这些 Hook 点都是提前定义的,包括系统调用、函数进入/退出、内核 tracepoints、网络事件等。
如果针对某个特定需求的 Hook 点不存在,可以通过 kprobe 或者 uprobe 来在内核或者用户程序的几乎所有地方挂载 eBPF 程序。
Verifier
中文名
验证器
大白话
生成应用内核层的 bytescode 要想进入到内核中去运行,必然要有个“安全检查员”对这个 bytescode 的安全和合法性进行检测。
具体解释
每一个 eBPF 程序加载到内核都要经过 Verification,用来保证 eBPF 程序的安全性,主要包括:
-
要保证 加载 eBPF 程序的进程有必要的特权级,除非节点开启了 unpriviledged 特性,只有特权级的程序才能够加载 eBPF 程序
内核提供了一个配置项 /proc/sys/kernel/unprivileged_bpf_disabled 来禁止非 特权用户使用 bpf(2) 系统调用,可以通过 sysctl 命令修改
比较特殊的一点是,这个配置项特意设计为一次性开关(one-time kill switch), 这 意味着一旦将它设为 1,就没有办法再改为 0 了,除非重启内核
-
一旦设置为 1 之后,只有初始命名空间中有 CAP_SYS_ADMIN 特权的进程才可以调用 bpf(2) 系统调用 。Cilium 启动后也会将这个配置项设为 1
# echo 1 > /proc/sys/kernel/unprivileged_bpf_disabled
要保证 eBPF 程序不会崩溃或者使得系统出故障
要保证 eBPF 程序不能陷入死循环,能够 runs to completion
要保证 eBPF 程序必须满足系统要求的大小,过大的 eBPF 程序不允许被加载进内核
要保证 eBPF 程序的复杂度有限,Verifier 将会评估 eBPF 程序所有可能的执行路径,必须能够在有限时间内完成 eBPF 程序复杂度分析
JIT Compiler
中文名
JIT 编译器
大白话
跟 java 的 JVM 有点类似,就是把 bytescode 编译成本机能够运行的二进制代码。
具体解释
Just-In-Time(JIT) 编译用来将通用的 eBPF 字节码翻译成与机器相关的指令集,从而极大加速 BPF 程序的执行:
- 与解释器相比,它们可以降低每个指令的开销。通常,指令可以 1:1 映射到底层架构的原生指令
- 这也会减少生成的可执行镜像的大小,因此对 CPU 的指令缓存更友好
- 特别地,对于 CISC 指令集(例如 x86),JIT 做了很多特殊优化,目的是为给定的指令产生可能的最短操作码,以降低程序翻译过程所需的空间
概念讲解
有了世界观的上的认识,同时这篇文章是作为入门,核心概念不应该说的太细太深,这样容易劝退很多小伙伴。所以这里采用“一句话”说明白的方式解释和介绍 epbf 的核心概念。
Helper Functions
中文名
辅助函数
大白话
应用在用户层不能直接访问内核层的数据,那么就需要一个代理人帮忙去执行,等待执行完毕后获得返回结果。
具体解释
eBPF 程序不能够随意调用内核函数,如果这么做的话会导致 eBPF 程序与特定的内核版本绑定,相反它内核定义的一系列 Helper functions。Helper functions 使得 BPF 能够通过一组内核定义的稳定的函数调用来从内核中查询数据,或者将数据推送到内核。所有的 BPF 辅助函数都是核心内核的一部分,无法通过内核模块来扩展或添加。
不同类型的 BPF 程序能够使用的辅助函数可能是不同的,例如:
- 与 attach 到 tc 层的 BPF 程序相比,attach 到 socket 的 BPF 程序只能够调用前者可以调用的辅助函数的一个子集
- lightweight tunneling 使用的封装和解封装辅助函数,只能被更低的 tc 层使用;而推送通知到用户态所使用的事件输出辅助函数,既可以被 tc 程序使用也可以被 XDP 程序使用
Maps
中文名
映射存储
大白话
ebpf Map 是驻留在内核空间中的高效 Key/Value store,包含多种类型的 Map,由内核实现其功能。用来作为用户层和内核层之间数据交换的媒介,同时可以在不同程序之间共享数据。
具体解释
BPF Map 的交互场景有以下几种:
- BPF 程序和用户态程序的交互:BPF 程序运行完,得到的结果存储到 map 中,供用户态程序通过文件描述符访问
- BPF 程序和内核态程序的交互:和 BPF 程序以外的内核程序交互,也可以使用 map 作为中介
- BPF 程序间交互:如果 BPF 程序内部需要用全局变量来交互,但是由于安全原因 BPF 程序不允许访问全局变量,可以使用 map 来充当全局变量
- BPF Tail call:Tail call 是一个 BPF 程序跳转到另一 BPF 程序,BPF 程序首先通过 BPF_MAP_TYPE_PROG_ARRAY 类型的 map 来知道另一个 BPF 程序的指针,然后调用 tail_call() 的 helper function 来执行 Tail call
- 共享 map 的 BPF 程序不要求是相同的程序类型,例如 tracing 程序可以和网络程序共享 map,单个 BPF 程序目前最多可直接访问 64 个不同 map。
内核中的 通用 map 有:
- BPF_MAP_TYPE_HASH
- BPF_MAP_TYPE_ARRAY
- BPF_MAP_TYPE_PERCPU_HASH
- BPF_MAP_TYPE_PERCPU_ARRAY
- BPF_MAP_TYPE_LRU_HASH
- BPF_MAP_TYPE_LRU_PERCPU_HASH
- BPF_MAP_TYPE_LPM_TRIE
内核中的 非通用 map 有:
- BPF_MAP_TYPE_PROG_ARRAY:一个数组 map,用于 hold 其他的 BPF 程序
- BPF_MAP_TYPE_PERF_EVENT_ARRAY
- BPF_MAP_TYPE_CGROUP_ARRAY:用于检查 skb 中的 cgroup2 成员信息
- BPF_MAP_TYPE_STACK_TRACE:用于存储栈跟踪的 MAP
- BPF_MAP_TYPE_ARRAY_OF_MAPS:持有(hold) 其他 map 的指针,这样整个 map 就可以在运行时实现原子替换
- BPF_MAP_TYPE_HASH_OF_MAPS:持有(hold) 其他 map 的指针,这样整个 map 就可以在运行时实现原子替换
Object Pinning
中文名
钉住对象 (非常奇怪的翻译,但是看源代码,翻译 pin 为固定和被钉在那里还是满合适的)
大白话
ebpf map 和程序作为内核资源只能通过文件描述符访问(fd),这个映射实际就是 fd 到内存对象的属性路径的一个映射,这个映射过程叫 pin。
具体解释
(★)ebpf map 和程序作为内核资源只能通过文件描述符访问,其背后是内核中的匿名 inode。 这个观点很重要,因为 pin 这个行为都是依据这个概念来的。
这样做的优点:
- 用户空间应用程序能够使用大部分文件描述符相关的 API
- 传递给 Unix socket 的文件描述符是透明工作等等
这样做的缺点:
文件描述符受限于进程的生命周期,使得 map 共享之类的操作非常笨重,这给某些特定的场景带来了很多复杂性。
解法
为了解决这个问题,内核实现了一个最小内核空间 BPF 文件系统,BPF map 和 BPF 程序 都可以 pin 到这个文件系统内,这个过程称为 object pinning。BPF 相关的文件系统不是单例模式(singleton),它支持多挂载实例、硬链接、软连接等等。
相应的,BPF 系统调用扩展了两个新命令,如下图所示:
- BPF_OBJ_PIN:钉住一个对象
- BPF_OBJ_GET:获取一个被钉住的对象
Tail Calls
中文名
尾调用
大白话
一个 BPF 程序可以调用另一个 BPF 程序,并且调用完成后不用返回到原来的程序。
具体解释
尾调用的机制是指:一个 BPF 程序可以调用另一个 BPF 程序,并且调用完成后不用返回到原来的程序。
- 和普通函数调用相比,这种调用方式开销最小,因为它是用长跳转(long jump)实现的,复用了原来的栈帧 (stack frame)
- BPF 程序都是独立验证的,因此要传递状态,要么使用 per-CPU map 作为 scratch 缓冲区 ,要么如果是 tc 程序的话,还可以使用 skb 的某些字段(例如 cb[])
- 相同类型的程序才可以尾调用,而且它们还要与 JIT 编译器相匹配,因此要么是 JIT 编译执行,要么是解释器执行(invoke interpreted programs),但不能同时使用两种方式
Hardening
中文名
硬化 (明明说的就是安全,但是用 Hardening 这个单词,觉得有点奇怪)
大白话
硬化实际是对 epbf 运行状态的值和数据进行保护,防止以外被篡改和破坏,是一种暗转防护机制。
具体解释
在程序的生命周期内,BPF 将内核中的整个 BPF 解释器映像(struct bpf_prog)以及 JIT 编译映像(struct bpf_binary_header)锁定为只读,以防止代码被破坏。例如,由于某些内核 bug 而发生的任何损坏都会导致一般的保护故障,从而导致内核崩溃,而不是让损坏静静地发生。
对于 x86_64 JIT 编译器,如果 CONFIG_RETPOLINE 已经设置(大多数 Linux 发行版在编写时都是默认设置),则通过 retpoline 实现从使用尾部调用的间接跳转的 JIT。
在/proc/sys/net/core/bpf_jit_harden 设置为 1 的情况下,JIT 编译的额外加固步骤将对非特权用户生效。在不受信任的用户对系统进行操作的情况下,通过减少(潜在的)攻击面,可以有效地略微权衡它们的性能。与完全切换到解释器相比,程序执行时间的减少仍然会带来更好的性能。
通过将实际指令随机化,这意味着通过将值的实际负载分成两个步骤来重写指令,将操作从基于即时的源操作数转换为基于寄存器的操作数:
- 加载一个盲化后的(blinded)立即数 rnd ^ imm 到寄存器
- 将寄存器和 rnd 进行异或操作(xor)
Offloads
中文名
卸载
大白话
就是把 eBPF 的网络程序内核层 bytescode 从 CPU 运行改为由网卡的 MPU 来执行。
具体解释
eBPF 网络程序,尤其是 tc 和 XDP BPF 程序在内核中都有一个 offload 到硬件的接口,这样就可以直接在网卡上执行 BPF 程序。