目录
cbpf 介绍
BPF(Berkeley Packet Filter ),中文翻译为伯克利包过滤器,是类 Unix 系统上数据链路层的一种原始接口,提供原始链路层封包的收发。
BPF 在数据包过滤上引入了两大革新:
- 一个新的虚拟机 (VM) 设计,可以有效地工作在基于寄存器结构的 CPU 之上;
- 应用程序使用缓存只复制与过滤数据包相关的数据,不会复制数据包的所有信息。这样可以最大程度地减少BPF 处理的数据;
tcpdump 可以根据用户指定的自定义过滤规则,在报文出入协议栈时获取报文的元信息。tcpdump 之所以可以灵活的过滤用户报文,本质是将过滤规则转化为一种特殊的指令。tcpdump 的底层采用 BPF 作为底层包过滤技术,我们可以在命令后面增加 ”-d“ 来查看 tcpdump 过滤条件的底层汇编指令。
$ tcpdump -d 'ip and tcp port 8080'
(000) ldh [12]
(001) jeq #0x800 jt 2 jf 12
(002) ldb [23]
(003) jeq #0x6 jt 4 jf 12
(004) ldh [20]
(005) jset #0x1fff jt 12 jf 6
(006) ldxb 4*([14]&0xf)
(007) ldh [x + 14]
(008) jeq #0x1f90 jt 11 jf 9
(009) ldh [x + 16]
(010) jeq #0x1f90 jt 11 jf 12
(011) ret #262144
(012) ret #0
BPF 工作在内核层,BPF 的架构图如下 [来自于bpf-usenix93]:
ebpf 介绍
2014 年初,Alexei Starovoitov 实现了 eBPF(extended Berkeley Packet Filter)。经过重新设计,eBPF 演进为一个通用执行引擎,可基于此开发性能分析工具、软件定义网络等诸多场景。eBPF 最早出现在 3.18 内核中,此后原来的 BPF 就被称为经典 BPF,缩写 cBPF(classic BPF),cBPF 现在已经基本废弃。现在,Linux 内核只运行 eBPF,内核会将加载的 cBPF 字节码透明地转换成 eBPF 再执行。
注:eBPF 在 Linux 3.18 版本以后引入,并不代表只能在内核 3.18+ 版本上运行,低版本的内核升级到最新也可以使用 eBPF 能力,只是可能部分功能受限,比如我就是在 Linux 发行版本 CentOS Linux release 7.7.1908 内核版本 3.10.0-1062.9.1.el7.x86_64 上运行 eBPF 在生产环境上搜集和排查网络问题。
ebpf 和 cbpf对比
eBPF 新的设计针对现代硬件进行了优化,所以 eBPF 生成的指令集比旧的 BPF 解释器生成的机器码执行得更快。扩展版本也增加了虚拟机中的寄存器数量,将原有的 2 个 32 位寄存器增加到 10 个 64 位寄存器。由于寄存器数量和宽度的增加,开发人员可以使用函数参数自由交换更多的信息,编写更复杂的程序。总之,这些改进使 eBPF 版本的速度比原来的 BPF 提高了 4 倍。
ebpf和内核模块的对比
2014 年 6 月,eBPF 扩展到用户空间,这也成为了 BPF 技术的转折点。
当前,eBPF 不再局限于网络栈,已经成为内核顶级的子系统。eBPF 程序架构强调安全性和稳定性,看上去更像内核模块,但与内核模块不同,eBPF 程序不需要重新编译内核,并且可以确保 eBPF 程序运行完成,而不会造成系统的崩溃。
ebpf应用
BPF 技术目前主要用于以下场景:
- 追踪和性能分析(Tracing & Profiling)
将 eBPF 程序附加到跟踪点以及内核和用户应用探针点的能力,使得应用程序和系统本身的运行时行为具有前所未有的可见性。通过赋予应用程序和系统两方面的检测能力,可以将两种视图结合起来,从而获得强大而独特的洞察力来排除系统性能问题。先进的统计数据结构允许以高效的方式提取有意义的可见性数据,而不需要像类似系统那样,通常需要导出大量的采样数据。
比如:BCC 工具集,bpftrace 和 systemtap-bpf,丰富并增强了内核调试和跟踪的能力;
IO Visor 项目开源的 BCC、 BPFTrace 和 Kubectl-Trace: BCC 提供了更高阶的抽象,可以让用户采用 Python、C++ 和 Lua 等高级语言快速开发 BPF 程序;BPFTrace 采用类似于 awk 语言快速编写 eBPF 程序;Kubectl-Trace 则提供了在 kubernetes 集群中使用 BPF 程序调试的方便操作;
- 网络(Network)
可编程性和效率的结合使得 eBPF 自然而然地满足了网络解决方案的所有数据包处理要求。eBPF 的可编程性使其能够在不离开 Linux内核的包处理上下文的情况下,添加额外的协议解析器,并轻松编程任何转发逻辑以满足不断变化的需求。JIT 编译器提供的效率使其执行性能接近于本地编译的内核代码。
比如: 负载均衡产品;Katran,Facebook 开源的 4 层负载均衡,基于 XDP;
- 安全(Security)
在看到和理解所有系统调用的基础上,将其与所有网络操作的数据包和套接字级视图相结合,可以采用革命性的新方法来确保系统的安全。虽然系统调用过滤、网络级过滤和进程上下文跟踪等方面通常由完全独立的系统处理,但 eBPF 允许将所有方面的可视性和控制结合起来,以创建在更多上下文上运行的、具有更好控制水平的安全系统。
比如: 比如ddos攻击的产品;比如,secomp
ebpf架构
简述概括, eBPF 是一套通用执行引擎,提供了可基于系统或程序事件高效安全执行特定代码的通用能力,通用能力的使用者不再局限于内核开发者;
eBPF 可由执行字节码指令、存储对象和 Helper 辅助函数组成。
字节码指令在内核执行前必须通过 BPF 验证器 Verfier 的验证,同时在启用 BPF JIT 模式的内核中,会直接将字节码指令转成内核可执行的本地指令运行。
基于 Linux 系统的观测工具中,eBPF 有着得天独厚的优势,高效、生产安全且内核中内置,特别的可以在内核中完成数据分析聚合比如直方图,与将数据发送到用户空间分析聚合相比,能够节省大量的数据复制传递带来的 CPU 消耗。
eBPF 分为用户空间程序和内核程序两部分:
- 用户空间程序负责加载 BPF 字节码至内核,如需要也会负责读取内核回传的统计信息或者事件详情;
- 内核中的 BPF 字节码负责在内核中执行特定事件,如需要也会将执行的结果通过 maps 或者 perf-event 事件发送至用户空间;
其中用户空间程序与内核 BPF 字节码程序可以使用 map 结构实现双向通信,这为内核中运行的 BPF 字节码程序提供了更加灵活的控制。
用户空间程序与内核中的 BPF 字节码交互的流程主要如下:
- 我们可以使用 LLVM 或者 GCC 工具将编写的 BPF 代码程序编译成 BPF 字节码;
- 然后使用加载程序 Loader 将字节码加载至内核;内核使用验证器(verfier) 组件保证执行字节码的安全性,以避免对内核造成灾难,在确认字节码安全后将其加载对应的内核模块执行;
- BPF 观测技术相关的程序程序类型可能是 kprobes/uprobes/tracepoint/perf_events 中的一个或多个,其中:
- kprobes:实现内核中动态跟踪。 kprobes 可以跟踪到 Linux 内核中的函数入口或返回点,但是不是稳定 ABI 接口,可能会因为内核版本变化导致,导致跟踪失效。
- uprobes:用户级别的动态跟踪。与 kprobes 类似,只是跟踪的函数为用户程序中的函数。
- tracepoints:内核中静态跟踪。tracepoints 是内核开发人员维护的跟踪点,能够提供稳定的 ABI 接口,但是由于是研发人员维护,数量和场景可能受限。
- perf_events:定时采样和 PMC。
- 内核中运行的 BPF 字节码程序可以使用两种方式将测量数据回传至用户空间
- maps 方式可用于将内核中实现的统计摘要信息(比如测量延迟、堆栈信息)等回传至用户空间;
- perf-event 用于将内核采集的事件实时发送至用户空间,用户空间程序实时读取分析;
Why BPF is FAST
BPF 在内核中的运行流程如下:
假设一种场景,我们将 BPF attach 到了某个热点的 tracepoint 之上,例如收发包,每次收发包时,tracepoint attached 的 BPF 程序都会被执行一遍。在比较繁忙的机器上,收发包可能每秒钟百万次,执行效率至关重要,如果 BPF 程序被 attach 在热点中,性能问题很可能会成千上万倍的放大。在我们探讨 BPF 程序为什么会执行的如此之快之前,我们有必要先了解下 BPF 指令和解释器。
指令
BPF 当前拥有 102 个指令,主要包括三大类:ALU (64bit and 32bit)、内存操作和分支操作。其中指令的格式主要由下面这几部分组成:
8bit opcode
4bit destination register (dst)
4bit source register (src)
16bit 偏移
32bit 立即数
与我们常见的 x86 或 ARM 的指令非常接近。在定义了指令后,每一条的指令执行,是通过内核中的解释器运行,流程可以抽象为一个 loop 循环,也被称为指令分发,循环内会不断的载入指令、执行指令,直至退出。
虚拟机
可以认为是 BPF 字节码是运行在内核中的 BPF 虚拟机中,BPF 字节码也是我们通常提到的 p-code (portable code),主要目的是为了软件解释器的高效运行。
提到了虚拟机,不得不提到我们常见的几种解释运行的语言,例如 Python 和 Lua。
根据虚拟机的实现,可以分为两类,基于栈的虚拟机和基于寄存器的虚拟机,其中基于栈的虚拟机的思想,最早是来自于 Pascal,CPython 和 Lua 4 同样是基于栈的虚拟机。Lua 5 和 Dalvik JVM 则是基于寄存器的虚拟机,BPF 同样是基于寄存器的虚拟机。
那么栈和寄存器的实现有何不同,性能是否有所差异,接下来我们继续分析。
-
基于栈的虚拟机
基于栈的虚拟机,顾名思义指令是以栈的数据结构组织的。下面的图可以比较清晰的展示这一流程:
当我们需要获得 20+7 结果时,需要生成 4 条指令,LIFO 执行。这样会生成更多的指令,同时需要移动多次内存,但是由于没有众多的寄存器,虚拟机的实现会相对简单。 -
基于寄存器的虚拟机
基于寄存器的虚拟机,不同于频繁操作栈,它可以直接操作寄存器。同样的需要获得 20+7 的结果,在寄存器足够的情况下,我们只需要生成并执行一条指令即可。指令行数相对于栈的实现有显著减少,效率也会提高。但是基于寄存器的虚拟机实现会更加复杂,同时每次指令需要访问更多的内存,并且指令也会更复杂,因为需要提供 2,3,4 地址指令的支持。 -
基于栈和基于寄存器的进行一个简单的对比
基于寄存器的虚拟机性能在总的时间上比基于栈的虚拟机快 20.39%:
指令分发执行,基于寄存器的虚拟机快 66.42%;
数据获取,基于栈的虚拟机快 23.5%;
在通用场景下,基于寄存器比基于栈的虚拟机实现,性能更好。
JIT
JIT (Just-in-time) 在 2011 年引入到 cBPF。与 JIT 相对应的为 AOT (ahead-of-time)。JIT 不需要解释器,或者说扩展了解释器,JIT 在运行时会将指令编译为原生指令在本机执行。
BPF 虚拟机会将所有的字节码翻译到本地原生代码再执行,具体的是翻译 BPF 字节码到本地原生代码,保存到内存中的特定区域并执行。BPF 程序通常比较简洁和轻量,引入 JIT 不会显著影响冷启动性能。
- 启用 JIT 究竟会带来多大的性能提升?
PyPy 是 CPython 基于 JIT 的实现,我们看到 PyPy 比 CPython 快 2-10 倍。
对于 BPF 而言,JIT 究竟会带来多大的性能?uBPF 是一个很好的测试程序,uBPF 是 BPF 虚拟机在用户态的实现,它提供了可选的 JIT,我们可以使用 clang 将测试程序编译为 elf 文件,分别测试开启和关闭 JIT 情况下,执行同一个 BPF 程序的性能。从下面的测试数据可以看到,开启 JIT 后性能同样也有数倍的提升。
How BPF extends Kernel
在前面的内容中,提到了编译、指令集和虚拟机。那么 BPF 是如何编译成一个可执行文件,在内核中运行的?
LLVM
当前 BPF 的编译离不开 LLVM,LLVM 分为前端和后端,我们可以将任何语言编译为 LLVM IR,这是一种中间文件。LLVM 可以将 LLVM IR 编译为目标文件,也就是我们提到的二进制文件。
对于 BPF 而言,我们可以使用 clang 将 BPF 编译为 LLVM IR 文件,LLVM 当前已经支持 BPF 作为目标文件,因此我们可以将任何的 LLVM IR 编译为 BPF 目标文件。大体的流程可以参考下图:
我们当前在使用 C 编写,并编译成 BPF 程序。从上面的流程中,我们可以了解到,我们可以将任何语言翻译为 LLVM IR,只需要这门语言提供 LLVM 的前端,我们就可以将这门语言编译为 BPF 目标文件。幸运的是,当前很多主流语言都提供了 LLVM 的前端,例如 C, C++, Go Haskell 等等。
我们将各种语言编译为 BPF 目标文件后,我们不仅可以使用这些语言来开发 BPF 程序,我们还可以将 BPF 作为一种通用的指令集,使用用户态的虚拟机来运行 BPF 执行,作为一种平台无关、CO-RE 的指令架构。
编写ebpf程序
对于大多数开发者而言,更多的是基于 BPF 技术之上编写解决我们日常遇到的各种问题,当前 BCC 和 BPFTrace 两个项目在观测和性能分析上已经有了诸多灵活且功能强大的工具箱,完全可以满足我们日常使用。
- BCC 提供了更高阶的抽象,可以让用户采用 Python、C++ 和 Lua 等高级语言快速开发 BPF 程序;
- BPFTrace 采用类似于 awk 语言快速编写 eBPF 程序;
BCC
bcc的整体架构如下所示:
使用 BCC 前端绑定语言 Python 编写的 Hello World 版本:
#!/usr/bin/python3
from bcc import BPF
# This may not work for 4.17 on x64, you need replace kprobe__sys_clone with kprobe____x64_sys_clone
prog = """
int kprobe__sys_clone(void *ctx) {
bpf_trace_printk("Hello, World!\\n");
return 0;
}
"""
b = BPF(text=prog, debug=0x04)
b.trace_print()
运行程序前需要安装过 bcc 相关工具包,当运行正常的时候我们发现每当 sys_clone 系统调用时,运行的控制台上就会打印 “Hello, World!”,在打印文字前面还包含了调用程序的进程名称,进程 ID 等信息;
# python ./hello.py
kubelet-8349 [006] d... 33637334.829981: : Hello, World!
kubelet-8349 [006] d... 33637334.838594: : Hello, World!
kubelet-8349 [006] d... 33637334.843788: : Hello, World!
BPFTrace
BPFTrace 是基于 BPF 和 BCC 的开源项目,与 BCC 不同的是其提供了更高层次的抽象,可以使用类似 AWK 脚本语言来编写基于 BPF 的跟踪或者性能排查工具,更加易于入门和编写,该工具的主要灵感来自于 Solaris 的 D 语言。
BPFTrace 使用 LLVM 将脚本编译成 BPF 二进制码,后续使用 BCC 与 Linux 内核进行交互。从功能层面上讲,BPFTrace 的定制性和灵活性不如 BCC,但是比 BCC 工具更加易于理解和使用,降低了 BPF 技术的使用门槛。
# 统计进程调用 sys_enter 的次数
#bpftrace -e 'tracepoint:raw_syscalls:sys_enter { @[comm] = count(); }'
Attaching 1 probe...
^C
@[bpftrace]: 6
@[systemd]: 24
@[snmp-pass]: 96
@[sshd]: 125
# 统计内核中函数堆栈的次数
# bpftrace -e 'profile:hz:99 { @[kstack] = count(); }'
Attaching 1 probe...
^C
[...]
@[
filemap_map_pages+181
__handle_mm_fault+2905
handle_mm_fault+250
__do_page_fault+599
async_page_fault+69
]: 12
[...]
@[
cpuidle_enter_state+164
do_idle+390
cpu_startup_entry+111
start_secondary+423
secondary_startup_64+165
]: 22122
C 语言原生方式
采用 LLVM Clang 的方式编译会涉及到内核编译环境搭建,而且还需要自己编译 Makefile 等操作,属于高级用户使用:
国内大厂使用ebpf的实践经验
字节跳动:eBPF 技术实践:高性能 ACL
使用 ebpf 深入分析容器网络 dup 包问题
eBay 云计算“网”事:网络超时篇
eBay云计算“网”事|网络丢包篇
参考
https://www.ebpf.top/post/ebpf_intro/