Service Mesh架构新技能之eBPF入门与实践

在分享这篇文章之前,先简单和大家说下背景。在之前的文章中作者分享了一些关于service mesh微服务架构的文章,在service mesh架构中需要通过sidecar代理的方式对应用容器流量进行劫持,并以此实现微服务治理相关的各种能力。但这种sidecar方式在微服务数量过多时会造成系统性能的降低,因为sidecar本质上来说,也是通过用户代码实现的网络代理来进行流量管控的。而ebpf则是一种替代sidecar的新式解决方案,它存在于操作系统的内核层级,在性能上表现更优。因此目前关于service mesh微服务架构的技术方案开始逐步趋向于使用ebpf来替代原先的像envoy这样的sidecar代理。本文的内容将详细介绍ebpf的前世今生,具体如下:

1

技术背景

eBPF 源于 BPF,本质上是处于内核中的一个高效与灵活的虚类虚拟机组件,以一种安全的方式在许多内核 hook 点执行字节码。BPF 最初的目的是用于高效网络报文过滤,经过重新设计,eBPF 不再局限于网络协议,已经成为内核顶级的子系统,演进为一个通用执行引擎。

开发者可基于 eBPF 开发性能分析工具、软件定义网络、安全等诸多场景。本文将介绍 eBPF 的前世今生,并构建一个 eBPF 环境进行开发实践,文中所有的代码可以在我的 gitHub[1] 中找到。

发展历史

BPF,是类 unix 系统上数据链路层的一种原始接口,提供原始链路层封包的收发。1992 年,Steven McCanne 和 Van Jacobson 写了一篇名为《The BSD Packet Filter: A New Architecture for User-level Packet Capture[2]》的论文。在文中,作者描述了他们如何在 Unix 内核实现网络数据包过滤,这种新的技术比当时最先进的数据包过滤技术快 20 倍。

Service Mesh架构新技能之eBPF入门与实践

BPF 在数据包过滤上引入了两大革新:

一个新的虚拟机 (VM) 设计,可以有效地工作在基于寄存器结构的 CPU 之上应用程序使用缓存只复制与过滤数据包相关的数据,不会复制数据包的所有信息,这样可以最大程度地减少BPF 处理的数据

由于这些巨大的改进,所有的 Unix 系统都选择采用 BPF 作为网络数据包过滤技术,直到今天,许多 Unix 内核的派生系统中(包括 linux 内核)仍使用该实现。tcpdump 的底层采用 BPF 作为底层包过滤技术,我们可以在命令后面增加 -d 来查看 tcpdump 过滤条件的底层汇编指令。

代码语言:JavaScript代码运行次数:0运行复制

$ 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

2014 年初,Alexei Starovoitov 实现了 eBPF(extended Berkeley Packet Filter)。经过重新设计,eBPF 演进为一个通用执行引擎,可基于此开发性能分析工具、软件定义网络等诸多场景。

eBPF 最早出现在 3.18 内核中,此后原来的 BPF 就被称为经典 BPF,缩写 cBPF(classic BPF),cBPF 现在已经基本废弃。现在,Linux 内核只运行 eBPF,内核会将加载的 cBPF 字节码透明地转换成 eBPF 再执行。

eBPF 与 cBPF

eBPF 新的设计针对现代硬件进行了优化,所以 eBPF 生成的指令集比旧的 BPF 解释器生成的机器码执行得更快。扩展版本也增加了虚拟机中的寄存器数量,将原有的 2 个 32 位寄存器增加到 10 个 64 位寄存器。

由于寄存器数量和宽度的增加,开发人员可以使用函数参数自由交换更多的信息,编写更复杂的程序。总之,这些改进使 eBPF 版本的速度比原来的 BPF 提高了 4 倍。

维度

cBPF

eBPF

内核版本

Linux 2.1.75(1997 年)

Linux 3.18(2014 年)[4.x for kprobe/uprobe/tracepoint/perf-Event]

寄存器数目

2 个:A,X

10个:R0–R9,另外 R10 是一个只读的帧指针R0:eBPF 中内核函数的返回值和退出值R1 – R5:eBF 程序在内核中的参数值R6 – R9:内核函数将保存的被调用者callee保存的寄存器R10:一个只读的栈帧指针

寄存器宽度

32 位

64 位

存储

16 个内存位: M[0–15]

512 字节堆栈,无限制大小的 map 存储

限制的内核调用

非常有限,仅限于 JIT 特定

有限,通过 bpf_call 指令调用

目标事件

数据包、 seccomp-BPF

数据包、内核函数、用户函数、跟踪点 PMCs 等

R0:eBPF 中内核函数的返回值和退出值R1 – R5:eBF 程序在内核中的参数值R6 – R9:内核函数将保存的被调用者callee保存的寄存器R10:一个只读的堆栈帧指针

寄存器宽度32 位64 位存储16 个内存位: M[0–15]512 字节堆栈,无限制大小的 map 存储限制的内核调用非常有限,仅限于 JIT 特定有限,通过 bpf_call 指令调用目标事件数据包、 seccomp-BPF数据包、内核函数、用户函数、跟踪点 PMCs 等

2014 年 6 月,eBPF 扩展到用户空间,这也成为了 BPF 技术的转折点。正如 Alexei 在提交补丁的注释中写到:「这个补丁展示了 eBPF 的潜力」。当前,eBPF 不再局限于网络栈,已经成为内核顶级的子系统。

eBPF 与内核模块

对比 Web 的发展,eBPF 与内核的关系有点类似于 JavaScript 与浏览器内核的关系,eBPF 相比于直接修改内核和编写内核模块提供了一种新的内核可编程的选项。eBPF 程序架构强调安全性和稳定性,看上去更像内核模块,但与内核模块不同,eBPF 程序不需要重新编译内核,并且可以确保 eBPF 程序运行完成,而不会造成系统的崩溃。

维度

Linux 内核模块

eBPF

kprobes/tracepoints

支持

支持

安全性

可能引入安全漏洞或导致内核 Panic

通过验证器进行检查,可以保障内核安全

内核函数

可以调用内核函数

只能通过 BPF Helper 函数调用

编译性

需要编译内核

不需要编译内核,引入头文件即可

运行

基于相同内核运行

基于稳定 ABI 的 BPF 程序可以编译一次,各处运行

与应用程序交互

打印日志或文件

通过 perf_event 或 map 结构

数据结构

丰富性

一般丰富

入门门槛

升级

需要卸载和加载,可能导致处理流程中断

原子替换升级,不会造成处理流程中断

内核内置

视情况而定

内核内置支持

eBPF 架构

eBPF 分为用户空间程序和内核程序两部分:

用户空间程序负责加载 BPF 字节码至内核,如需要也会负责读取内核回传的统计信息或者事件详情内核中的 BPF 字节码负责在内核中执行特定事件,如需要也会将执行的结果通过 maps 或者 perf-event 事件发送至用户空间其中用户空间程序与内核 BPF 字节码程序可以使用 map 结构实现双向通信,这为内核中运行的 BPF 字节码程序提供了更加灵活的控制

eBPF 整体结构图如下:

Service Mesh架构新技能之eBPF入门与实践

用户空间程序与内核中的 BPF 字节码交互的流程主要如下:

1、使用 LLVM 或者 GCC 工具将编写的 BPF 代码程序编译成 BPF 字节码

2、使用加载程序 Loader 将字节码加载至内核

3、内核使用验证器(Verfier) 组件保证执行字节码的安全性,以避免对内核造成灾难,在确认字节码安全后将其加载对应的内核模块执行

4、内核中运行的 BPF 字节码程序可以使用两种方式将数据回传至用户空间:

maps 方式可用于将内核中实现的统计摘要信息(比如测量延迟、堆栈信息)等回传至用户空间;perf-event 用于将内核采集的事件实时发送至用户空间,用户空间程序实时读取分析。

eBPF 限制

eBPF 技术虽然强大,但是为了保证内核的处理安全和及时响应,内核中的 eBPF 技术也给予了诸多限制,当然随着技术的发展和演进,限制也在逐步放宽或者提供了对应的解决方案。

eBPF 程序不能调用任意的内核参数,只限于内核模块中列出的 BPF Helper 函数,函数支持列表也随着内核的演进在不断增加。

eBPF 程序不允许包含无法到达的指令,防止加载无效代码,延迟程序的终止。

eBPF 程序中循环次数限制且必须在有限时间内结束,这主要是用来防止在 kprobes 中插入任意的循环,导致锁住整个系统;解决办法包括展开循环,并为需要循环的常见用途添加辅助函数。Linux 5.3 在 BPF 中包含了对有界循环的支持,它有一个可验证的运行时间上限。

eBPF 堆栈大小被限制在 MAX_BPF_STACK,截止到内核 Linux 5.8 版本,被设置为 512;参见 include/linux/filter.h[3],这个限制特别是在栈上存储多个字符串缓冲区时:一个char[256]缓冲区会消耗这个栈的一半。目前没有计划增加这个限制,解决方法是改用 bpf 映射存储,它实际上是无限的。

代码语言:javascript代码运行次数:0运行复制

/* BPF program can Access up to 512 bytes of stack space. */#define MAX_BPF_STACK 512

eBPF 字节码大小最初被限制为 4096 条指令,截止到内核 Linux 5.8 版本, 当前已将放宽至 100 万指令( BPF_COMPLEXITY_LIMIT_INSNS),参见:include/linux/bpf.h[4],对于无权限的BPF程序,仍然保留4096条限制 ( BPF_MAXINSNS );新版本的 eBPF 也支持了多个 eBPF 程序级联调用,虽然传递信息存在某些限制,但是可以通过组合实现更加强大的功能。

代码语言:javascript代码运行次数:0运行复制

#define BPF_COMPLEXITY_LIMIT_INSNS      1000000 /* yes. 1M insns */

2

eBPF 实战

在深入介绍 eBPF 特性之前,让我们 Get Hands Dirty,切切实实的感受 eBPF 程序到底是什么,我们该如何开发 eBPF 程序。随着 eBPF 生态的演进,现在已经有越来越多的工具链用于开发 eBPF 程序,在后文也会详细介绍:

基于 bcc 开发:bcc 提供了对 eBPF 开发,前段提供 python API,后端 eBPF 程序通过 C 实现。特点是简单易用,但是性能较差。基于 libebpf-bootstrap 开发:libebpf-bootstrap 提供了一个方便的脚手架。基于内核源码开发:内核源码开发门槛较高,但是也更加切合 eBPF 底层原理,所以这里以这个方法作为示例。

内核源码编译

系统环境如下,采用php中文网 CVM,ubuntu 20.04,内核版本 5.4.0。

代码语言:javascript代码运行次数:0运行复制

$ uname -aLinux VM-1-3-ubuntu 5.4.0-42-generic #46-Ubuntu SMP Fri Jul 10 00:24:02 UTC 2020 x86_64 x86_64 x86_64 GNU/Linux

首先安装必要依赖:

代码语言:javascript代码运行次数:0运行复制

sudo apt install -y bison build-essential cmake flex git libedit-dev pkg-config libmnl-dev    python zlib1g-dev libssl-dev libelf-dev libcap-dev libfl-dev llvm clang pkg-config    gcc-multilib luajit libluajit-5.1-dev libncurses5-dev libclang-dev clang-tools

一般情况下推荐采用 apt 方式的安装源码,安装简单而且只安装当前内核的源码,源码的大小在 200M 左右。

代码语言:javascript代码运行次数:0运行复制

# apt-cache search linux-source# apt install linux-source-5.4.0

源码安装至 /usr/src/ 目录下。

代码语言:javascript代码运行次数:0运行复制

$ ls -hltotal 4.0Kdrwxr-xr-x 4 root root 4.0K Nov  9 13:22 linux-source-5.4.0lrwxrwxrwx 1 root root   45 Oct 15 10:28 linux-source-5.4.0.tar.bz2 -> linux-source-5.4.0/linux-source-5.4.0.tar.bz2$ tar -jxvf linux-source-5.4.0.tar.bz2$ cd linux-source-5.4.0$ cp -v /boot/config-$(uname -r) .config # make defconfig 或者 make menuconfig$ make headers_install$ make modules_prepare$ make scripts     # 可选$ make M=samples/bpf  # 如果配置出错,可以使用 make oldconfig && make prepare 修复

编译成功后,可以在 samples/bpf 目录下看到一系列的目标文件和二进制文件。

Hello World

前面说到 eBPF 通常由内核空间程序和用户空间程序两部分组成,现在 samples/bpf 目录下有很多这种程序,内核空间程序以 _kern.c 结尾,用户空间程序以 _user.c 结尾。先不看这些复杂的程序,我们手动写一个 eBPF 程序的 Hello World。

内核中的程序 hello_kern.c:

代码语言:javascript代码运行次数:0运行复制

#include <linux/bpf.h>#include "bpf_helpers.h"#define SEC(NAME) __attribute__((section(NAME), used))SEC("tracepoint/syscalls/sys_enter_execve")int bpf_prog(void *ctx){    char msg[] = "Hello BPF from houmin!n";    bpf_trace_printk(msg, sizeof(msg));    return 0;}char _license[] SEC("license") = "GPL";

函数入口:

上述代码和普通的c语言编程有一些区别。

程序的入口通过编译器的 pragama __section(“tracepoint/syscalls/sys_enter_execve”) 指定的。入口的参数不再是 argc, argv, 它根据不同的 prog type 而有所差别。我们的例子中,prog type 是 BPF_PROG_TYPE_TRACEPOINT, 它的入口参数就是 void *ctx。

头文件:

代码语言:javascript代码运行次数:0运行复制

#include <linux/bpf.h>

这个头文件的来源是kernel source header file 。它安装在 /usr/include/linux/bpf.h中。

它提供了bpf 编程需要的很多symbol。例如:

enum bpf_func_id 定义了所有的kerne helper function 的idenum bpf_prog_type 定义了内核支持的所有的prog 的类型。Struct __sk_buff 是bpf 代码中访问内核struct sk_buff的接口。

等等

代码语言:javascript代码运行次数:0运行复制

#include “bpf_helpers.h”

来自libbpf ,需要自行安装。我们引用这个头文件是因为调用了bpf_printk()。这是一个kernel helper function。

程序解释:

这里我们简单解读下内核态的 ebpf 程序,非常简单:

bpf_trace_printk 是一个 eBPF helper 函数,用于打印信息到 trace_pipe (/sys/kernel/debug/tracing/trace_pipe),详见这里[5]代码声明了 SEC 宏,并且定义了 GPL 的 License,这是因为加载进内核的 eBPF 程序需要有 License 检查,类似于内核模块

加载 BPF 代码:

用户态程序 hello_user.c:

代码语言:javascript代码运行次数:0运行复制

#include <stdio.h>#include "bpf_load.h"int main(int argc, char **argv){    if(load_bpf_file("hello_kern.o") != 0)    {        printf("The kernel didn't load BPF programn");        return -1;    }    read_trace_pipe();    return 0;}

在用户态 ebpf 程序中,解读如下:

通过 load_bpf_file 将编译出的内核态 ebpf 目标文件加载到内核通过 read_trace_pipe 从 trace_pipe 读取 trace 信息,打印到控制台中

修改 samples/bpf 目录下的 Makefile 文件,在对应的位置添加以下三行:

代码语言:javascript代码运行次数:0运行复制

hostprogs-y += hellohello-objs := bpf_load.o hello_user.oalways += hello_kern.o

重新编译,可以看到编译成功的文件:

代码语言:javascript代码运行次数:0运行复制

$ make M=samples/bpf$ ls -hl samples/bpf/hello*-rwxrwxr-x 1 ubuntu ubuntu 404K Mar 30 17:48 samples/bpf/hello-rw-rw-r-- 1 ubuntu ubuntu  317 Mar 30 17:47 samples/bpf/hello_kern.c-rw-rw-r-- 1 ubuntu ubuntu 3.8K Mar 30 17:48 samples/bpf/hello_kern.o-rw-rw-r-- 1 ubuntu ubuntu  246 Mar 30 17:47 samples/bpf/hello_user.c-rw-rw-r-- 1 ubuntu ubuntu 2.2K Mar 30 17:48 samples/bpf/hello_user.o

进入到对应的目录运行 hello 程序,可以看到输出结果如下:

代码语言:javascript代码运行次数:0运行复制

$ sudo ./hello           <...>-102735 [001] ....  6733.481740: 0: Hello BPF from houmin!           <...>-102736 [000] ....  6733.482884: 0: Hello BPF from houmin!           <...>-102737 [002] ....  6733.483074: 0: Hello BPF from houmin!

代码解读

前面提到 load_bpf_file 函数将 LLVM 编译出来的 eBPF 字节码加载进内核,这到底是如何实现的呢?

经过搜查,可以看到 load_bpf_file 也是在 samples/bpf 目录下实现的,具体的参见 bpf_load.c[6]。

阅读 load_bpf_file 代码可以看到,它主要是解析 ELF 格式的 eBPF 字节码,然后调用 load_and_attach[7] 函数。

在 load_and_attach 函数中,我们可以看到其调用了 bpf_load_program 函数,这是 libbpf 提供的函数。

调用的 bpf_load_program 中的 license、kern_version 等参数来自于解析 eBPF ELF 文件,prog_type 来自于 bpf 代码里面 SEC 字段指定的类型。

代码语言:javascript代码运行次数:0运行复制

static int load_and_attach(const char *event, struct bpf_insn *prog, int size){  bool is_socket = strncmp(event, "socket", 6) == 0; bool is_kprobe = strncmp(event, "kprobe/", 7) == 0; bool is_kretprobe = strncmp(event, "kretprobe/", 10) == 0; bool is_tracepoint = strncmp(event, "tracepoint/", 11) == 0; bool is_raw_tracepoint = strncmp(event, "raw_tracepoint/", 15) == 0; bool is_xdp = strncmp(event, "xdp", 3) == 0; bool is_perf_event = strncmp(event, "perf_event", 10) == 0; bool is_cgroup_skb = strncmp(event, "cgroup/skb", 10) == 0; bool is_cgroup_sk = strncmp(event, "cgroup/sock", 11) == 0; bool is_sockops = strncmp(event, "sockops", 7) == 0; bool is_sk_skb = strncmp(event, "sk_skb", 6) == 0; bool is_sk_msg = strncmp(event, "sk_msg", 6) == 0;    //...   fd = bpf_load_program(prog_type, prog, insns_cnt, license, kern_version,         bpf_log_buf, BPF_LOG_BUF_SIZE); if (fd < 0) {  printf("bpf_load_program() err=%dn%s", errno, bpf_log_buf);  return -1; }  //...}

3

eBPF 特性

Hook Overview

eBPF 程序都是事件驱动的,它们会在内核或者应用程序经过某个确定的 Hook 点的时候运行,这些 Hook 点都是提前定义的,包括系统调用、函数进入/退出、内核 tracepoints、网络事件等。

Service Mesh架构新技能之eBPF入门与实践

如果针对某个特定需求的 Hook 点不存在,可以通过 kprobe 或者 uprobe 来在内核或者用户程序的几乎所有地方挂载 eBPF 程序。

Service Mesh架构新技能之eBPF入门与实践

Verification

With great power there must also come great responsibility.

每一个 eBPF 程序加载到内核都要经过 Verification,用来保证 eBPF 程序的安全性,主要包括:

要保证加载 eBPF 程序的进程有必要的特权级,除非节点开启了 unpriviledged 特性,只有特权级的程序才能够加载 eBPF 程序。

1、内核提供了一个配置项 /proc/sys/kernel/unprivileged_bpf_disabled 来禁止非特权用户使用 bpf(2) 系统调用,可以通过 sysctl 命令修改

2、比较特殊的一点是,这个配置项特意设计为一次性开关(one-time kill switch), 这意味着一旦将它设为 1,就没有办法再改为 0 了,除非重启内核

3、一旦设置为 1 之后,只有初始命名空间中有 CAP_SYS_ADMIN 特权的进程才可以调用 bpf(2) 系统调用 。Cilium 启动后也会将这个配置项设为 1:

代码语言:javascript代码运行次数:0运行复制

$ echo 1 > /proc/sys/kernel/unprivileged_bpf_disabled

要保证 eBPF 程序不会崩溃或者使得系统出故障。

要保证 eBPF 程序不能陷入死循环,能够 runs to completion。

要保证 eBPF 程序必须满足系统要求的大小,过大的 eBPF 程序不允许被加载进内核。

要保证 eBPF 程序的复杂度有限,Verifier 将会评估 eBPF 程序所有可能的执行路径,必须能够在有限时间内完成 eBPF 程序复杂度分析。

JIT Compilation

Just-In-Time(JIT)编译用来将通用的 eBPF 字节码翻译成与机器相关的指令集,从而极大加速 BPF 程序的执行:

与解释器相比,它们可以降低每个指令的开销。通常,指令可以 1:1 映射到底层架构的原生指令这也会减少生成的可执行镜像的大小,因此对 CPU 的指令缓存更友好特别地,对于 CISC 指令集(例如 x86),JIT 做了很多特殊优化,目的是为给定的指令产生可能的最短操作码,以降低程序翻译过程所需的空间

64 位的 x86_64、arm64、ppc64、s390x、mips64、sparc64 和 32 位的 arm 、x86_32 架构都内置了 in-kernel eBPF JIT 编译器,它们的功能都是一样的,可以用如下方式打开:

代码语言:javascript代码运行次数:0运行复制

$ echo 1 > /proc/sys/net/core/bpf_jit_enable

32 位的 mips、ppc 和 sparc 架构目前内置的是一个 cBPF JIT 编译器。这些只有 cBPF JIT 编译器的架构,以及那些甚至完全没有 BPF JIT 编译器的架构,需要通过内核中的解释器(in-kernel interpreter)执行 eBPF 程序。

要判断哪些平台支持 eBPF JIT,可以在内核源文件中 grep HAVE_EBPF_JIT:

代码语言:javascript代码运行次数:0运行复制

$ git grep HAVE_EBPF_JIT arch/arch/arm/Kconfig:       select HAVE_EBPF_JIT   if !CPU_ENDIAN_BE32arch/arm64/Kconfig:     select HAVE_EBPF_JITarch/powerpc/Kconfig:   select HAVE_EBPF_JIT   if PPC64arch/mips/Kconfig:      select HAVE_EBPF_JIT   if (64BIT && !CPU_MICROMIPS)arch/s390/Kconfig:      select HAVE_EBPF_JIT   if PACK_STACK && HAVE_MARCH_Z196_FEATURESarch/sparc/Kconfig:     select HAVE_EBPF_JIT   if SPARC64arch/x86/Kconfig:       select HAVE_EBPF_JIT   if X86_64
Service Mesh架构新技能之eBPF入门与实践

Maps

BPF Map 是驻留在内核空间中的高效 Key/Value store,包含多种类型的 Map,由内核实现其功能,具体实现可以参考我的这篇博文[8]。

Service Mesh架构新技能之eBPF入门与实践

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。

Service Mesh架构新技能之eBPF入门与实践

当前可用的通用 map 有:

BPF_MAP_TYPE_HASHBPF_MAP_TYPE_ARRAYBPF_MAP_TYPE_PERCPU_HASHBPF_MAP_TYPE_PERCPU_ARRAYBPF_MAP_TYPE_LRU_HASHBPF_MAP_TYPE_LRU_PERCPU_HASHBPF_MAP_TYPE_LPM_TRIE

© 版权声明
THE END
喜欢就支持一下吧
点赞11 分享