eBPF冒险记(二)

eBPF冒险记(二)

1.内核跟踪实战

跟踪类eBPF程序主要包含内核插桩(BPF_PROG_TYPE_KPROBE)跟踪点(BPF_PROG_TYPE_TRACEPOINT)以及性能事件(BPF_PROG_TYPE_PERF_EVENT)等程序类型,而每类eBPF程序类型又可以挂载到不同的内核函数、内核跟踪点或性能事件上。当这些内核函数、内核跟踪点或性能事件被调用的时候,挂载到其上的eBPF程序就会自动执行。

那么,你可能想问了:当我不知道内核中都有哪些内核函数、内核跟踪点或性能事件的时候,可以在哪里查询到它们的列表呢?对于内核函数和内核跟踪点,在需要跟踪它们的传入参数和返回值的时候,又该如何查询这些数据结构的定义格式呢?别担心,接下来就跟我一起去探索下吧。

1.1利用调试信息查询跟踪点

为了方便调试,内核把所有函数以及非栈变量的地址都抽取到了/proc/kallsyms中,这样调试器就可以根据地址找出对应的函数和变量名称。很显然,具有实际含义的名称要比16进制的地址易读得多。对内核插桩类的eBPF程序来说,它们要挂载的内核函数就可以从/proc/kallsyms这个文件中查到。注意,内核函数是一个非稳定API,在新版本中可能会发生变化,并且内核函数的数量也在不断增长中。

不过需要提醒你的是,这些符号表不仅包含了内核函数,还包含了非栈数据变量。而且,并不是所有的内核函数都是可跟踪的,只有显式导出的内核函数才可以被eBPF进行动态跟踪。因而,通常我们并不直接从内核符号表查询可跟踪点,而是使用我接下来介绍的方法。

为了方便内核开发者获取所需的跟踪点信息,内核调试文件系统还向用户空间提供了内核调试所需的基本信息,如内核符号列表、跟踪点、函数跟踪(ftrace)状态以及参数格式等。你可以在终端中执行sudo ls /sys/kernel/debug来查询内核调试文件系统的具体信息。比如,执行下面的命令,就可以查询execve系统调用的参数格式:

image-20240916155815609

有了调试文件系统,你就可以从/sys/kernel/debug/tracing中找到所有内核预定义的跟踪点,进而可以在需要时把eBPF程序挂载到对应的跟踪点。除了内核函数和跟踪点之外,性能事件又该如何查询呢?你可以使用Linux性能工具perf来查询性能事件的列表。如下面的命令所示,你可以不带参数查询所有的性能事件,也可以加入可选的事件类型参数进行过滤:

1
sudo perf list [hw|sw|cache|tracepoint|pmu|sdt|metric|metricgroup]

1.2利用bpftrace查询跟踪点

bpftrace在eBPF和BCC之上构建了一个简化的跟踪语言,通过简单的几行脚本,就可以实现复杂的跟踪功能。并且,多行的跟踪指令也可以放到脚本文件中执行(脚本后缀通常为.bt)。bpftrace会把你开发的脚本借助BCC编译加载到内核中执行,再通过BPF映射获取执行的结果:

img

因此,在编写简单的eBPF程序,特别是编写的eBPF程序用于临时的调试和排错时,你可以考虑直接使用bpftrace,而不需要用C或Python去开发一个复杂的程序。可以执行bpftrace -l来查询内核插桩和跟踪点了。比如你可以通过以下几种方式来查询:

1
2
3
4
5
6
7
8
# 查询所有内核插桩和跟踪点
sudo bpftrace -l

# 使用通配符查询所有的系统调用跟踪点
sudo bpftrace -l 'tracepoint:syscalls:*'

# 使用通配符查询所有名字包含"execve"的跟踪点
sudo bpftrace -l '*execve*'

对于跟踪点来说,你还可以加上-v参数查询函数的入口参数或返回值。而由于内核函数属于不稳定的API,在bpftrace中只能通过arg0、arg1这样的参数来访问,具体的参数格式还需要参考内核源代码。

比如,下面就是一个查询系统调用execve入口参数(对应系统调用sys_enter_execve)和返回值(对应系统调用sys_exit_execve)的示例:

image-20240916160523099

在这两种方法中,我更推荐使用更简单的bpftrace进行查询。这是因为,我们通常只需要在开发环境查询这些列表,以便去准备eBPF程序的挂载点。也就是说,虽然bpftrace依赖BCC和LLVM开发工具,但开发环境本来就需要这些库和开发工具。综合来看,用bpftrace工具来查询的方法显然更简单快捷。

1.3利用内核跟踪点排查短时进程问题

在排查系统CPU使用率高的问题时,我想你很可能遇到过这样的困惑:明明通过top命令发现系统的CPU使用率(特别是用户CPU使用率)特别高,但通过pspidstat等工具都找不出CPU使用率高的进程。这是什么原因导致的呢?在我看来,一般情况下,这类问题很可能是以下两个原因导致的:

  • 第一,应用程序里面直接调用其他二进制程序,并且这些程序的运行时间很短,通过top工具不容易发现;
  • 第二,应用程序自身在不停地崩溃重启中,且重启间隔较短,启动过程中资源的初始化导致了高CPU使用率。

使用top、ps等性能工具很难发现这类短时进程,这是因为它们都只会按照给定的时间间隔采样,而不会实时采集到所有新创建的进程。那要如何才能采集到所有的短时进程呢?你肯定已经想到了,那就是利用eBPF的事件触发机制,跟踪内核每次新创建的进程,这样就可以揪出这些短时进程。

img

因为我们要关心的主要是新创建进程的基本信息,而像进程名称和参数等信息都在execve()的参数里,所以我们就要找出execve()所对应的内核函数或跟踪点。借助刚才提到的bpftrace工具,你可以执行下面的命令,查询所有包含execve关键字的跟踪点:

image-20240916161253091

从输出中可以发现这些函数可以分为内核插桩(kprobe)和跟踪点(tracepoint)两类。内核插桩属于不稳定接口,而跟踪点则是稳定接口。因而,在内核插桩和跟踪点两者都可用的情况下,应该选择更稳定的跟踪点,以保证eBPF程序的可移植性(即在不同版本的内核中都可以正常执行)。

只有跟踪点的列表还不够,因为我们还想知道具体启动的进程名称、命令行选项以及返回值,而这些也都可以通过bpftrace来查询。在命令行中执行下面的命令,即可查询:

image-20240916161452314

从输出中可以看到,sys_enter_execveat()比sys_enter_execve()多了两个参数,而文件名filename命令行选项argv以及返回值ret的定义都是一样的。

为了帮你全方位掌握eBPF程序的开发过程,下面会以bpftrace、BCC和libbpf这三种方式为例,带你开发一个跟踪短时进程的eBPF程序。这三种方式各有优缺点,在实际的生产环境中都有大量的应用:

  • bpftrace通常用在快速排查和定位系统上,它支持用单行脚本的方式来快速开发并执行一个eBPF程序。不过,bpftrace的功能有限,不支持特别复杂的eBPF程序,也依赖于BCC和LLVM动态编译执行。
  • BCC通常用在开发复杂的eBPF程序中,其内置的各种小工具也是目前应用最为广泛的eBPF小程序。不过,BCC也不是完美的,它依赖于LLVM和内核头文件才可以动态编译和加载eBPF程序。
  • libbpf是从内核中抽离出来的标准库,用它开发的eBPF程序可以直接分发执行,这样就不需要每台机器都安装LLVM和内核头文件了。不过,它要求内核开启BTF特性,需要非常新的发行版才会默认开启(如RHEL8.2+和Ubuntu20.10+等)。

在实际应用中,你可以根据你的内核版本、内核配置、eBPF程序复杂度,以及是否允许安装内核头文件和LLVM等编译工具等,来选择最合适的方案。

1.4bpftrace方法

由于execve()和execveat()这两个系统调用的入口参数文件名filename和命令行选项argv,以及返回值ret的定义都是一样的,因而我们可以把这两个跟踪点放到一起来处理。

image-20240916162446290

不过,这个跟踪程序还是有一些比较大的限制,比如:

  • 没有输出时间戳,这样去大量日志里面定位问题就比较困难;
  • 没有父进程PID,还需要一些额外的工具或经验,才可以找出父进程。

image-20240916164717004

1.5BCC方法

为了在系统调用入口跟踪点和出口跟踪点间共享进程信息等数据,我们可以定义一个哈希映射(比如命名为tasks);同样地,因为我们想要在用户空间实时获取跟踪信息,这就需要一个性能事件映射。对于这两种映射的创建步骤,BCC已经提供了非常方便的宏定义,你可以直接使用。

1
2
3
4
5
6
7
8
9
struct data_t {
u32 pid;
char comm[TASK_COMM_LEN];
int retval;
unsigned int args_size;
char argv[FULL_MAX_ARGS_ARR];
};
BPF_PERF_OUTPUT(events);
BPF_HASH(tasks, u32, struct data_t);
  • struct data_t定义了一个包含进程基本信息的数据结构,它将用在哈希映射的值中(其中的参数大小args_size会在读取参数内容的时候用到);
  • BPF_PERF_OUTPUT(events)定义了一个性能事件映射;
  • BPF_HASH(tasks, u32, struct data_t)定义了一个哈希映射,其键为32位的进程PID,而值则是进程基本信息data_t。

两个映射定义好之后,接下来就是定义跟踪点的处理函数。在BCC中,你可以通过TRACEPOINT_PROBE(category, event)来定义一个跟踪点处理函数。BCC会将所有的参数放入args这个变量中,这样使用args-><参数名>就可以访问跟踪点的参数值。对我们要跟踪的短时进程问题来说,也就是下面这两个跟踪点:

1
2
3
4
5
6
7
8
9
10
11
// 定义sys_enter_execve跟踪点处理函数.
TRACEPOINT_PROBE(syscalls, sys_enter_execve)
{
//待添加处理逻辑
}

// 定义sys_exit_execve跟踪点处理函数.
TRACEPOINT_PROBE(syscalls, sys_exit_execve)
{
//待添加处理逻辑
}

对于入口跟踪点sys_enter_execve的处理,还是先获取进程的PID、进程名称和参数列表之后,再存入刚刚定义的哈希映射中。

1
2
3
4
5
struct data_t data = { };
u32 pid = bpf_get_current_pid_tgid(); // 取低32位为进程PID
data.pid = pid;
bpf_get_current_comm(&data.comm, sizeof(data.comm));
const char **argv = (const char **)(args->argv);

注意,argv是一个用户空间的字符串数组(指针数组),这就需要调用bpf_probe_read系列的辅助函数,去这些指针中读取数据。并且,字符串的数量(即参数的个数)和每个字符串的长度(即每个参数的长度)都是未知的,由于eBPF栈大小只有512字节,如果想要把它们读入一个临时的字符数组中,必须要保证每次读取的内容不超过栈的大小。这类问题有很多种不同的处理方法,其中一个比较简单的方式就是把多余的参数截断,使用...代替过长的参数。一般来说,知道了进程的名称和前几个参数,对调试和排错来说就足够了。

1
2
3
4
5
6
7
8
9
10
// 定义参数长度和参数个数常量
#define ARGSIZE 64
#define TOTAL_MAX_ARGS 5
#define FULL_MAX_ARGS_ARR (TOTAL_MAX_ARGS * ARGSIZE)
#define LAST_ARG (FULL_MAX_ARGS_ARR - ARGSIZE)

struct data_t {
...
char argv[FULL_MAX_ARGS_ARR];
};

有了字符数组,接下来再定义一个辅助函数,从参数数组中读取字符串参数(限定最长ARGSIZE):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 从用户空间读取字符串
static int __bpf_read_arg_str(struct data_t *data, const char *ptr)
{
if (data->args_size > LAST_ARG) {
return -1;
}

int ret = bpf_probe_read_user_str(&data->argv[data->args_size], ARGSIZE, (void *)ptr);
if (ret > ARGSIZE || ret < 0) {
return -1;
}

// increase the args size. the first tailing '\0' is not counted and hence it
// would be overwritten by the next call.
data->args_size += (ret - 1);

return 0;
}

在这个函数中,有几点需要你注意:

  • bpf_probe_read_user_str()返回的是包含字符串结束符\0的长度。为了拼接所有的字符串,在计算已读取参数长度的时候,需要把 \0排除在外。
  • &data->argv[data->args_size]用来获取要存放参数的位置指针,这是为了把多个参数拼接到一起。
  • 在调用bpf_probe_read_user_str()前后,需要对指针位置和返回值进行校验,这可以帮助eBPF验证器获取指针读写的边界。

有了这个辅助函数之后,因为eBPF在老版本内核中并不支持循环(有界循环在5.3之后才支持),要访问字符串数组,还需要一个小技巧:使用 #pragma unroll告诉编译器,把源码中的循环自动展开。这就避免了最终的字节码中包含循环。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// 引入内核头文件
#include <uapi/linux/ptrace.h>
#include <linux/sched.h>
#include <linux/fs.h>

// 定义sys_enter_execve跟踪点处理函数.
TRACEPOINT_PROBE(syscalls, sys_enter_execve)
{
// 变量定义
unsigned int ret = 0;
const char **argv = (const char **)(args->argv);

// 获取进程PID和进程名称
struct data_t data = { };
u32 pid = bpf_get_current_pid_tgid();
data.pid = pid;
bpf_get_current_comm(&data.comm, sizeof(data.comm));

// 获取第一个参数(即可执行文件的名字)
if (__bpf_read_arg_str(&data, (const char *)argv[0]) < 0) {
goto out;
}

// 获取其他参数(限定最多5个)
#pragma unroll
for (int i = 1; i < TOTAL_MAX_ARGS; i++) {
if (__bpf_read_arg_str(&data, (const char *)argv[i]) < 0) {
goto out;
}
}

out:
// 存储到哈希映射中
tasks.update(&pid, &data);
return 0;
}

注意,为了获取内核数据结构的定义,在文件的开头需要引入相关的内核头文件。此外,读取参数完成之后,不要忘记调用 tasks.update()把进程的基本信息存储到哈希映射中。因为返回值需要等到出口跟踪点时才可以获取,这儿只需要更新哈希映射就可以了,不需要把进程信息提交到性能事件映射中去。

入口跟踪点sys_enter_execve处理好之后,我们再来看看出口跟踪点sys_exit_execve该如何处理。

由于进程的基本信息已经保存在了哈希映射中,所以出口事件的处理可以分为查询进程基本信息填充返回值最后再提交到性能事件映射这三个步骤。具体代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 定义sys_exit_execve跟踪点处理函数.
TRACEPOINT_PROBE(syscalls, sys_exit_execve)
{
// 从哈希映射中查询进程基本信息
u32 pid = bpf_get_current_pid_tgid();
struct data_t *data = tasks.lookup(&pid);

// 填充返回值并提交到性能事件映射中
if (data != NULL) {
data->retval = args->ret;
events.perf_submit(args, data, sizeof(struct data_t));

// 最后清理进程信息
tasks.delete(&pid);
}

return 0;
}

到这里,完整的eBPF程序就开发好了,你可以把上述的代码保存到一个本地文件中,并命名为execsnoop.c。eBPF程序开发完成后,最后一步就是为它增加一个Python前端。Python前端逻辑需要eBPF程序加载挂载到内核函数和跟踪点,以及通过BPF映射获取和打印执行结果等几个步骤。其中,因为我们已经使用了TRACEPOINT_PROBE宏定义,来定义eBPF跟踪点处理函数,BCC在加载字节码的时候,会帮你自动把它挂载到正确的跟踪点上,所以挂载的步骤就可以忽略。完整的Python程序如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 引入库函数
from bcc import BPF
from bcc.utils import printb

# 1) 加载eBPF代码
b = BPF(src_file="execsnoop.c")

# 2) 输出头
print("%-6s %-16s %-3s %s" % ("PID", "COMM", "RET", "ARGS"))

# 3) 定义性能事件打印函数
def print_event(cpu, data, size):
# BCC自动根据"struct data_t"生成数据结构
event = b["events"].event(data)
printb(b"%-6d %-16s %-3d %-16s" % (event.pid, event.comm, event.retval, event.argv))

# 4) 绑定性能事件映射和输出函数,并从映射中循环读取数据
b["events"].open_perf_buffer(print_event)
while 1:
try:
b.perf_buffer_poll()
except KeyboardInterrupt:
exit()

把上述的代码保存到execsnoop.py中,然后通过Python运行,并在另一个终端中执行ls命令,你就可以得到如下的输出:

image-20240916172901436

不过,在你想要分发这个程序到生产环境时,又会碰到一个新的难题:BCC依赖于LLVM和内核头文件才可以动态编译和加载eBPF程序,而出于安全策略的需要,在生产环境中通常又不允许安装这些开发工具。这个难题应该怎么克服呢?一种很容易想到的方法是把BCC和开发工具都安装到容器中,容器本身不提供对外服务,这样可以降低安全风险。另外一种方法就是参考内核中的eBPF示例,开发一个匹配当前内核版本的eBPF程序,并编译为字节码,再分发到生产环境中。

除此之外,如果内核已经支持了BPF类型格式 (BTF),推荐使用从内核源码中抽离出来的libbpf进行开发,这样可以借助BTF和CO-RE获得更好的移植性。实际上,BCC的很多工具都在向BTF迁移中,相信未来libbpf会成为最受欢迎的eBPF程序开发基础库,甚至Windows eBPF也会支持libbpf。

2.网络跟踪实战

既然想要使用eBPF排查网络问题,我想进入你头脑的第一个问题就是:eBPF到底提供了哪些网络相关的功能框架呢?要回答这个问题,首先要理解Linux网络协议栈的基本原理。下面是一个简化版的内核协议栈示意图,如下图所示,eBPF 实际上提供了贯穿整个网络协议栈的过滤、捕获以及重定向等丰富的网络功能:

img

一方面,网络协议栈也是内核的一部分,因而网络相关的内核函数、跟踪点以及用户程序的函数等,也都可以使用kprobe、uprobe、USDT等跟踪类eBPF程序进行跟踪(如上图中紫色部分所示)。另一方面,eBPF提供了大量专用于网络的eBPF程序类型,包括XDP程序、TC程序、套接字程序以及cgroup程序等。这些类型的程序涵盖了从网卡(如卸载到硬件网卡中的XDP程序)到网卡队列(如TC程序)、封装路由(如轻量级隧道程序)、TCP拥塞控制、套接字(如sockops程序)等内核协议栈,再到同属于一个cgroup的一组进程的网络过滤和控制,而这些都是内核协议栈的核心组成部分(如上图中绿色部分所示)。

接下来,我就以最常见的网络丢包为例,带你看看如何使用eBPF来排查网络问题。

2.1跟踪内核网络协议栈

即使理解了内核协议栈的基本原理,以及各种类型eBPF程序的基本功能,在想要跟踪网络相关的问题时,你可能还是觉得无从下手,这是为什么呢?究其原因,我认为最主要是因为不清楚内核中都有哪些函数和跟踪点可以拿来跟踪。而即使通过源码查询到了一系列的内核函数,还是没有一个清晰的思路把这些内核函数与所碰到的网络问题关联起来。

如何把内核函数跟相关的网络问题关联起来呢?看到本小节的标题,你应该已经想到了:跟踪调用栈,根据调用栈回溯路径,找出导致某个网络事件发生的整个流程,进而就可以再根据这些流程中的内核函数进一步跟踪

既然是调用栈的回溯,只有我们知道了最接近整个执行逻辑结尾的函数,才有可能开始这个回溯过程。对Linux网络丢包问题来说,内核协议栈执行的结尾,当然就是释放最核心的SKB(Socket Buffer)数据结构。查询内核SKB文档,你可以发现,内核中释放SKB相关的函数有两个:

  • 第一个,kfree_skb,它经常在网络异常丢包时调用;
  • 第二个,consume_skb,它在正常网络连接完成时调用。

这两个函数除了使用场景的不同,其功能和实现流程都是一样的,即都是检查SKB的引用计数,当引用计数为0时释放其内核内存。所以,要跟踪网络丢包的执行过程,也就可以跟踪kfree_skb的内核调用栈。

接下来,我就以访问极客时间的网站time.geekbang.org为例,来带你一起看看,如何使用bpftrace来进行调用栈的跟踪。

为了方便调用栈的跟踪,bpftrace提供了kstackustack这两个内置变量,分别用于获取内核和进程的调用栈。打开一个终端,执行下面的命令就可以跟踪kfree_skb的内核调用栈了:

1
sudo bpftrace -e 'kprobe:kfree_skb /comm=="curl"/ {printf("kstack: %s\n", kstack);}'
  • kprobe:kfree_skb指定跟踪的内核函数为kfree_skb;
  • 紧随其后的/comm==”curl”/,表示只跟踪curl进程,这是为了过滤掉其他不相关的进程操作;
  • 最后的printf()函数就是把内核协议栈打印到终端中。

打开一个新终端,并在终端中执行curl time.geekbang.org命令,然后回到第一个终端,就可以看到如下的输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
kstack:
kfree_skb+1
udpv6_destroy_sock+66
sk_common_release+34
udp_lib_close+9
inet_release+75
inet6_release+49
__sock_release+66
sock_close+21
__fput+159
____fput+14
task_work_run+103
exit_to_user_mode_loop+411
exit_to_user_mode_prepare+187
syscall_exit_to_user_mode+23
do_syscall_64+110
entry_SYSCALL_64_after_hwframe+68

kstack:
kfree_skb+1
udpv6_destroy_sock+66
sk_common_release+34
udp_lib_close+9
inet_release+75
inet6_release+49
__sock_release+66
sock_close+21
__fput+159
____fput+14
task_work_run+103
exit_to_user_mode_loop+411
exit_to_user_mode_prepare+187
syscall_exit_to_user_mode+23
do_syscall_64+110
entry_SYSCALL_64_after_hwframe+68

kstack:
kfree_skb+1
unix_release+29
__sock_release+66
sock_close+21
__fput+159
____fput+14
task_work_run+103
exit_to_user_mode_loop+411
exit_to_user_mode_prepare+187
syscall_exit_to_user_mode+23
do_syscall_64+110
entry_SYSCALL_64_after_hwframe+68

kstack:
kfree_skb+1
__sys_connect_file+95
__sys_connect+162
__x64_sys_connect+24
do_syscall_64+97
entry_SYSCALL_64_after_hwframe+68

kstack:
kfree_skb+1
__sys_connect_file+95
__sys_connect+162
__x64_sys_connect+24
do_syscall_64+97
entry_SYSCALL_64_after_hwframe+68

这个输出包含了多个调用栈,每个调用栈从下往上就是kfree_skb被调用过程中的各个函数(函数名后的数字表示调用点相对函数地址的偏移),它们都是从系统调用(entry_SYSCALL_64)开始,通过一系列的内核函数之后,最终调用到了跟踪函数。

输出中包含多个调用栈,是因为同一个内核函数是有可能在多个地方调用的。因此,我们需要对它进一步改进,加上网络信息的过滤,并把源IP 和目的IP等基本信息也打印出来。比如,我们访问一个网址,只需要关心TCP协议,而其他协议相关的内核栈就可以忽略掉。

kfree_skb函数的定义格式如下所示,它包含一个struct sk_buff类型的参数,这样我们就可以从中获取协议、源IP和目的IP等基本信息:

1
void kfree_skb(struct sk_buff * skb);

由于我们需要添加数据结构读取的过程,为了更好的可读性,你可以把这些过程放入一个脚本文件中,通常后缀为.bt。下面就是一个改进了的跟踪程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
kprobe:kfree_skb /comm=="curl"/
{
// 1. 第一个参数是 struct sk_buff
$skb = (struct sk_buff *)arg0;

// 2. 从网络头中获取源IP和目的IP
$iph = (struct iphdr *)($skb->head + $skb->network_header);
$sip = ntop(AF_INET, $iph->saddr);
$dip = ntop(AF_INET, $iph->daddr);

// 3. 只处理TCP协议
if ($iph->protocol == IPPROTO_TCP)
{
// 4. 打印源IP、目的IP和内核调用栈
printf("SKB dropped: %s->%s, kstack: %s\n", $sip, $dip, kstack);
}
}
  • 第1处是把bpftrace的内置参数arg0转换成SKB数据结构struct sk_buff *(注意使用指针)。
  • 第2处是从SKB数据结构中获取网络头之后,再从中拿到源IP和目的IP,最后再调用内置函数ntop(),把整数型的IP数据结构转换为可读性更好的字符串格式。
  • 第3处是对网络协议进行了过滤,只保留TCP协议。
  • 第4处是向终端中打印刚才获取的源IP和目的IP,同时也打印内核调用栈。

我们在脚本文件中加入这些类型定义的头文件:

1
2
3
4
#include <linux/skbuff.h>
#include <linux/ip.h>
#include <linux/socket.h>
#include <linux/netdevice.h>

然后,保存到文件dropwatch.bt中,就可以通过sudo bpftrace dropwatch.bt来运行了。

2.2排查网络丢包问题

最常见的丢包是由系统防火墙阻止了相应的IP或端口导致的,你可以执行下面的nslookup命令,查询到极客时间的IP地址,然后再执iptables命令,禁止访问极客时间的80端口:

1
2
3
4
5
6
7
8
9
10
11
# 首先查询极客时间的IP
$ nslookup time.geekbang.org
Server: 127.0.0.53
Address: 127.0.0.53#53

Non-authoritative answer:
Name: time.geekbang.org
Address: 39.106.233.176

# 然后增加防火墙规则阻止80端口
$ sudo iptables -I OUTPUT -d 39.106.233.176/32 -p tcp -m tcp --dport 80 -j DROP

防火墙规则加好之后,在终端一中启动跟踪脚本:

1
sudo bpftrace dropwatch.bt

然后,新建一个终端,访问极客时间,你应该会看到超时的错误:

1
2
$ curl --connect-timeout 1 39.106.233.176
curl: (28) Connection timed out after 1000 milliseconds

返回第一个终端,你就可以看到eBPF程序已经成功跟踪到了内核丢包的调用栈信息,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
SKB dropped: 192.168.1.129->39.106.233.176, kstack:
kfree_skb+1
__ip_local_out+219
ip_local_out+29
__ip_queue_xmit+367
ip_queue_xmit+21
__tcp_transmit_skb+2237
tcp_connect+1009
tcp_v4_connect+951
__inet_stream_connect+206
inet_stream_connect+59
__sys_connect_file+95
__sys_connect+162
__x64_sys_connect+24
do_syscall_64+97
entry_SYSCALL_64_after_hwframe+68

从这个输出中,我们可以看到,第一行输出中我们成功拿到了源IP和目的IP,而接下来的每一行中都包含了指令地址、函数名以及函数地址偏移。从下往上看这个调用栈,最后调用kfree_skb函数的是__ip_local_out,那么__ip_local_out这个函数又是干什么的呢?根据函数名,你可以大致猜测出,它是用于向外发送网络包的,但具体的步骤我们就不太确定了。所以,这时候就需要去参考一下内核源代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
int __ip_local_out(struct net *net, struct sock *sk, struct sk_buff *skb)
{
struct iphdr *iph = ip_hdr(skb);

/* 计算总长度 */
iph->tot_len = htons(skb->len);
/* 计算校验和 */
ip_send_check(iph);

/* L3主设备处理 */
skb = l3mdev_ip_out(sk, skb);
if (unlikely(!skb))
return 0;

/* 设置IP协议 */
skb->protocol = htons(ETH_P_IP);

/* 调用NF_INET_LOCAL_OUT钩子 */
return nf_hook(NFPROTO_IPV4, NF_INET_LOCAL_OUT,
net, sk, skb, NULL, skb_dst(skb)->dev,
dst_output);
}

从这个代码来看,__ip_local_out函数的主要流程就是计算总长度和校验和,再设置L3主设备和协议等属性后,最终调用nf_hook。而nf就是netfilter的缩写,所以你就可以将其理解为调用iptables规则。再根据NF_INET_LOCAL_OUT参数,你就可以知道接下来调用了OUTPUT链(chain)的钩子。知道了发生丢包的问题来源,接下来再去定位iptables就比较容易了。在终端中执行下面的iptables命令,就可以查询OUTPUT链的过滤规则:

1
sudo iptables -nvL OUTPUT

命令执行后,你应该可以看到类似下面的输出。可以看到,正是我们之前加入的iptables规则导致了丢包:

1
2
3
Chain OUTPUT (policy ACCEPT 0 packets, 0 bytes)
pkts bytes target prot opt in out source destination
1 180 DROP tcp -- * * 0.0.0.0/0 39.106.233.176 tcp dpt:80

清楚了问题的根源,要解决它当然就很简单了。只要执行下面的命令,把导致丢包的iptables规则删除即可:

1
sudo iptables -D OUTPUT -d 39.106.233.176/32 -p tcp -m tcp --dport 80 -j DROP

3.网络性能优化

以最常用的负载均衡器为例,带你一起来看看如何借助eBPF来优化网络的性能。既然要优化负载均衡器的网络性能,那么首先就需要有一个优化的目标,即初始版的负载均衡器。在今天的案例中,我们使用最常用的反向代理和Web服务器Nginx作为初始版的负载均衡器,同时也使用自定义的Nginx作为后端的Web服务器。

3.1Nginx负载均衡

为了方便环境的重现,负载均衡器、Web服务器以及客户端都运行在容器中,它们的IP和MAC等基本信息如下图所示:

img

参考Nginx官方文档中HTTP负载均衡的配置方法,你可以通过以下几步来搭建上述的案例环境。

1)执行下面的命令,创建上图中的4个容器:

1
2
3
4
5
6
7
8
9
# Webserver (响应是hostname,如 http1 或 http2)
docker run -itd --name=http1 --hostname=http1 feisky/webserver
docker run -itd --name=http2 --hostname=http2 feisky/webserver

# Client
docker run -itd --name=client alpine

# Nginx
docker run -itd --name=nginx nginx

注意,这儿启动的Nginx容器使用的还是官方镜像,还需要额外的步骤更新它的负载均衡配置。

2)执行下面的命令,查询两个Web服务器的IP地址:

1
2
3
4
IP1=$(docker inspect http1 -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}')
IP2=$(docker inspect http2 -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}')
echo "Webserver1's IP: $IP1"
echo "Webserver2's IP: $IP2"

命令执行后,你将会看到如下的输出:

1
2
Webserver1's IP: 172.17.0.2
Webserver2's IP: 172.17.0.3

3)执行下面的命令,生成并更新Nginx配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
# 生成nginx.conf文件
cat>nginx.conf <<EOF
user nginx;
worker_processes auto;

error_log /var/log/nginx/error.log notice;
pid /var/run/nginx.pid;

events {
worker_connections 1024;
}

http {
include /etc/nginx/mime.types;
default_type application/octet-stream;

upstream webservers {
server $IP1;
server $IP2;
}

server {
listen 80;

location / {
proxy_pass http://webservers;
}
}
}
EOF

# 更新Nginx配置
docker cp nginx.conf nginx:/etc/nginx/nginx.conf
docker exec nginx nginx -s reload

配置完成后,再执行下面的命令,验证负载均衡器是不是生效了(/ # 表示在容器终端中执行命令):

1
2
3
4
5
6
7
8
9
# 查询Nginx容器IP(输出为172.17.0.5)
docker inspect nginx -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}'

# 进入client容器终端,安装curl之后访问Nginx
docker exec -it client sh

# (以下命令运行在client容器中)
/ # apk add curl wrk --update
/ # curl "http://172.17.0.5"

如果一切正常,多次执行curl命令后,你会看到如下的输出,即通过Nginx成功获得了两个Web服务器的输出,说明负载均衡器配置成功了:

1
2
3
4
5
/ # curl "http://172.17.0.5"
Hostname: http1

/ # curl "http://172.17.0.5"
Hostname: http2

负载均衡器配置成功后,它的性能怎么样呢?进入client容器终端中,执行下面的命令,就可以使用wrk给它做个性能测试:

1
2
/ # apk add wrk --update
/ # wrk -c100 "http://172.17.0.5"

稍等一会,你可以看到如下的性能测试报告:

1
2
3
4
5
6
7
8
Running 10s test @ http://172.17.0.5
2 threads and 100 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 7.53ms 4.96ms 39.33ms 70.78%
Req/Sec 6.96k 514.59 8.88k 74.00%
138711 requests in 10.05s, 21.83MB read
Requests/sec: 13798.11
Transfer/sec: 2.17MB

从报告中你可以发现,默认情况下,总的平均每秒请求数是13798,而每个线程的平均请求数和请求延迟是6.96k和7.53毫秒(在你的环境下可能看到不同数值,具体的性能指标取决于运行环境和配置)。你可以记录一下这些数值,以便后面跟eBPF进行比较。

3.2套接字eBPF程序优化负载均衡性能

根据原理的不同,套接字eBPF程序又分为很多不同的类型。其中,BPF_PROG_TYPE_SOCK_OPS、BPF_PROG_TYPE_SK_SKB、BPF_PROG_TYPE_SK_MSG等类型的eBPF程序可以与套接字映射(如BPF_MAP_TYPE_SOCKMAP或BPF_MAP_TYPE_SOCKHASH)配合,实现套接字的转发。套接字eBPF程序工作在内核空间中,无需把网络数据发送到用户空间就能完成转发。因此,我们可以先猜测,它应该是可以提升网络转发的性能(当然,具体能不能提升,还需要接下来的测试验证)。

3.2.1创建套接字映射

首先,第一步是创建一个套接字类型的映射。以BPF_MAP_TYPE_SOCKHASH类型的套接字映射为例,它的值总是套接字文件描述符,而键则需要我们去定义。比如,可以定义一个包含IP协议五元组的结构体,作为套接字映射的键类型:

1
2
3
4
5
6
7
8
struct sock_key
{
__u32 sip; //源IP
__u32 dip; //目的IP
__u32 sport; //源端口
__u32 dport; //目的端口
__u32 family; //协议
};

有了键类型之后,就可以使用SEC关键字来定义套接字映射了,如下所示:

1
2
3
4
5
6
7
8
9
#include <linux/bpf.h>

struct bpf_map_def SEC("maps") sock_ops_map = {
.type = BPF_MAP_TYPE_SOCKHASH,
.key_size = sizeof(struct sock_key),
.value_size = sizeof(int),
.max_entries = 65535,
.map_flags = 0,
};

为了方便后续在eBPF程序中引用这两个数据结构,可以把它们保存到一个头文件sockops.h中。

3.2.2更新套接字映射

套接字映射准备好之后,第二步就是在BPF_PROG_TYPE_SOCK_OPS类型的eBPF程序中跟踪套接字事件,并把套接字信息保存到SOCKHASH映射中。

1
2
3
4
#define BPF_PROG_TYPE(_id, _name, prog_ctx_type, kern_ctx_type)

BPF_PROG_TYPE(BPF_PROG_TYPE_SOCK_OPS, sock_ops,
struct bpf_sock_ops, struct bpf_sock_ops_kern)

因此,你就可以使用如下的格式来定义这个eBPF程序:

1
2
3
4
5
SEC("sockops")
int bpf_sockmap(struct bpf_sock_ops *skops)
{
// TODO: 添加套接字映射更新操作
}

在添加具体的套接字映射更新逻辑之前,还需要你先从struct bpf_sock_ops中获取作为键类型的五元组。参考内核中struct bpf_sock_ops的定义,如下的几个字段刚好可以满足我们的需要:

1
2
3
4
5
6
7
8
struct bpf_sock_ops {
__u32 family;
__u32 remote_ip4; /* Stored in network byte order */
__u32 local_ip4; /* Stored in network byte order */
__u32 remote_port;/* Stored in network byte order */
__u32 local_port; /* stored in host byte order */
...
}

因此,你就可以直接使用它们来定义映射中所需要的键。下面就是sock_key的定义方法,注意这里把local_port转换为了同其他字段一样的网络字节序:

1
2
3
4
5
6
7
struct sock_key key = {
.dip = skops->remote_ip4,
.sip = skops->local_ip4,
.sport = bpf_htonl(skops->local_port),
.dport = skops->remote_port,
.family = skops->family,
};

有了键之后,还不能立刻就去更新套接字映射。这是因为BPF_PROG_TYPE_SOCK_OPS程序跟踪了所有类型的套接字操作,而我们只需要把新创建的套接字更新到映射中。

struct bpf_sock_ops中包含的op字段可用于判断套接字操作类型,内核头文件中已经为每种操作的具体含义加了详细的注释,对于新创建的连接,我们就可以使用以下两个状态(即主动连接和被动连接)作为判断条件:

1
2
3
4
/* skip if it is not established op */
if (skops->op != BPF_SOCK_OPS_PASSIVE_ESTABLISHED_CB && skops->op != BPF_SOCK_OPS_ACTIVE_ESTABLISHED_CB) {
return BPF_OK;
}

到这里,说明套接字已经属于新创建的连接了,所以接下来就是调用BPF辅助函数去更新套接字映射,如下所示:

1
bpf_sock_hash_update(skops, &sock_ops_map, &key, BPF_NOEXIST);

其中,BPF_NOEXIST表示键不存在的时候才添加新元素。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#include <linux/bpf.h>
#include <bpf/bpf_endian.h>
#include <bpf/bpf_helpers.h>
#include <sys/socket.h>
#include "sockops.h"

SEC("sockops")
int bpf_sockmap(struct bpf_sock_ops *skops)
{
/* skip if the packet is not ipv4 */
if (skops->family != AF_INET)
{
return BPF_OK;
}

/* skip if it is not established op */
if (skops->op != BPF_SOCK_OPS_PASSIVE_ESTABLISHED_CB && skops->op != BPF_SOCK_OPS_ACTIVE_ESTABLISHED_CB) {
return BPF_OK;
}

struct sock_key key = {
.dip = skops->remote_ip4,
.sip = skops->local_ip4,
/* convert to network byte order */
.sport = (bpf_htonl(skops->local_port)),
.dport = skops->remote_port,
.family = skops->family,
};

bpf_sock_hash_update(skops, &sock_ops_map, &key, BPF_NOEXIST);
return BPF_OK;
}

char LICENSE[] SEC("license") = "Dual BSD/GPL";

把上述代码保存到sockops.bpf.c文件中,然后执行下面的命令,将其编译为BPF字节码:

1
clang -g -O2 -target bpf -D__TARGET_ARCH_x86 -I/usr/include/x86_64-linux-gnu -I. -c sockops.bpf.c -o sockops.bpf.o

到这里,套接字更新的eBPF程序就准备好了,接下来我们来看看如何转发套接字。

3.2.3套接字转发

第三步的套接字转发可以使用BPF_PROG_TYPE_SK_MSG类型的eBPF程序,捕获套接字中的发送数据包,并根据上述的套接字映射进行转发。根据内核头文件中的定义格式,它的参数格式为struct sk_msg_md。struct sk_msg_md的定义格式如下所示,也已经包含了套接字映射所需的五元组信息:

1
2
3
4
5
6
7
8
9
struct sk_msg_md {
...
__u32 family;
__u32 remote_ip4; /* Stored in network byte order */
__u32 local_ip4; /* Stored in network byte order */
__u32 remote_port; /* Stored in network byte order */
__u32 local_port; /* stored in host byte order */
...
};

了解清楚数据结构的定义格式之后,还需要你注意一点:BPF_PROG_TYPE_SK_MSG跟BPF_PROG_TYPE_SOCK_OPS属于不同的eBPF程序。虽然你可以把多个eBPF程序放入同一个源码文件,并编译到同一个字节码文件(即文件名.o)中,但由于它们的加载和挂载格式都是不同的,我推荐你把不同的eBPF程序放入不同的文件中,这样管理起来更为方便。

因此,接下来创建一个新的文件(如sockredir.bpf.c),用于保存BPF_PROG_TYPE_SK_MSG程序。添加如下的代码,就定义了一个名为bpf_redir的eBPF程序:

1
2
3
4
5
SEC("sk_msg")
int bpf_redir(struct sk_msg_md *msg)
{
//TODO: 添加套接字转发逻辑
}

在这个eBPF程序中,既然还要访问相同的套接字映射,也就需要从参数struct sk_msg_md中提取五元组信息,并存入套接字映射所需要的键struct sock_key中。如下所示,我们就定义了一个新的struct sock_key(注意,这里同样需要把local_port转换为网络字节序):

1
2
3
4
5
6
7
struct sock_key key = {
.sip = msg->remote_ip4,
.dip = msg->local_ip4,
.dport = bpf_htonl(msg->local_port),
.sport = msg->remote_port,
.family = msg->family,
};

需要你注意的是,这儿的源IP和源端口对应上述eBPF程序的目的IP和目的端口,也就是说,发送方向刚好是相反的。为什么是相反的呢?来看看下面这张图,原因就很清楚了:

img

图中,灰色箭头是套接字转发之前的网络流向,而绿色箭头则是套接字转发后的网络流向。从这张图中你可以发现:

  • 在套接字转发之前,即便是在同一台机器的两个容器中,负载均衡器和Web服务器的两个套接字通信还是需要通过完整的内核协议栈进行处理的;
  • 而在套接字转发之后,来自发送端套接字1的网络包在套接字层就交给了接收端的套接字2,从而避免了额外的内核协议栈处理过程。

由于这两个套接字一个是发送,一个是接收,因而它们的方向是相反的,所以在构造转发套接字的键时,就需要把源和目的交换。

有了套接字映射所需要的键之后,最后还剩下添加套接字转发逻辑的步骤。参考BPF辅助函数文档(你可以执行man bpf-helpers查询)bpf_msg_redirect_hash()正好跟我们的需求完全匹配。为了方便你理解,我把它的使用文档也贴一下:

1
2
3
4
5
6
7
8
9
10
11
12
long bpf_msg_redirect_hash(struct sk_msg_buff *msg, struct bpf_map *map, void *key, u64 flags)

Description
This helper is used in programs implementing policies at the socket level. If the
message msg is allowed to pass (i.e. if the verdict eBPF program returns SK_PASS),
redirect it to the socket referenced by map (of type BPF_MAP_TYPE_SOCKHASH) using
hash key. Both ingress and egress interfaces can be used for redirection. The
BPF_F_INGRESS value in flags is used to make the distinction (ingress path is se‐
lected if the flag is present, egress path otherwise). This is the only flag sup‐
ported for now.

Return SK_PASS on success, or SK_DROP on error.

概括来说,bpf_msg_redirect_hash()的作用就是把当前套接字转发给套接字映射中的套接字。而参数key用于从套接字映射中查询待转发的套接字,flags用于区分入口或出口路径。根据每个参数的具体格式,你就可以通过下面的方式进行套接字转发。注意,对于负载均衡的场景来说,只需要对入口路径进行处理,因而这儿设置了BPF_F_INGRESS

1
bpf_msg_redirect_hash(msg, &sock_ops_map, &key, BPF_F_INGRESS);

再加上必要的头文件之后,完整的eBPF程序如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <linux/bpf.h>
#include <bpf/bpf_endian.h>
#include <bpf/bpf_helpers.h>
#include <sys/socket.h>
#include "sockops.h"



SEC("sk_msg")
int bpf_redir(struct sk_msg_md *msg)
{
struct sock_key key = {
.sip = msg->remote_ip4,
.dip = msg->local_ip4,
.dport = bpf_htonl(msg->local_port),
.sport = msg->remote_port,
.family = msg->family,
};

bpf_msg_redirect_hash(msg, &sock_ops_map, &key, BPF_F_INGRESS);
return SK_PASS;
}

char LICENSE[] SEC("license") = "Dual BSD/GPL";

3.2.4加载eBPF程序

得到套接字映射更新和转发这两个BPF字节码之后,还需要把它们加载到内核之中,再挂载到特定的内核事件之后才会生效。在之前的案例中,介绍的方法是利用BCC、libbpf等提供的库函数。今天介绍另外一种方法,即通过命令行工具bpftool加载和挂载eBPF程序。

首先,对于sockops程序sockops.bpf.o来说,你可以执行下面的命令,将其加载到内核中:

1
sudo bpftool prog load sockops.bpf.o /sys/fs/bpf/sockops type sockops pinmaps /sys/fs/bpf

这条命令将sockops.bpf.o中的eBPF程序和映射加载到内核中,并固定到BPF文件系统中。固定到BPF文件系统的好处是,即便bpftool命令已经执行结束,eBPF程序还会继续在内核中运行,并且eBPF映射也会继续存在内核内存中。

加载成功后,你还可以执行bpftool prog showbpftool map show命令确认它们的加载结果。执行成功后,你会看到类似下面的输出:

1
2
3
4
5
6
7
8
9
$ sudo bpftool prog show name bpf_sockmap
1062: sock_ops name bpf_sockmap tag e37ef726a3a85a2e gpl
loaded_at 2022-02-04T13:07:28+0000 uid 0
xlated 256B jited 140B memlock 4096B map_ids 90
btf_id 234

$ sudo bpftool map show name sock_ops_map
90: sockhash name sock_ops_map flags 0x0
key 20B value 4B max_entries 65535 memlock 1572864B

BPF字节码加载成功之后,其中的eBPF程序还不会自动运行,因为这时候它还没有与内核事件挂载。对sockops程序来说,它支持挂载到cgroups,从而对cgroups所拥有的所有进程生效,这跟我们案例的容器场景也是匹配的。

通常情况下,主流的发行版都会把cgroups挂载到/sys/fs/cgroup路径下。接着,再执行下面的bpftool cgroup attach命令,把sockops程序挂载到cgroups路径中:

1
sudo bpftool cgroup attach /sys/fs/cgroup/ sock_ops pinned /sys/fs/bpf/sockops

接下来,再执行下面的命令,加载并挂载sk_msg程序sockredir.bpf.o:

1
2
sudo bpftool prog load sockredir.bpf.o /sys/fs/bpf/sockredir type sk_msg map name sock_ops_map pinned /sys/fs/bpf/sock_ops_map
sudo bpftool prog attach pinned /sys/fs/bpf/sockredir msg_verdict pinned /sys/fs/bpf/sock_ops_map

3.2.5性能测试

执行下面的命令进入client容器终端,并在容器终端中执行wrk命令:

1
2
docker exec -it client sh
/ # wrk -c100 "http://172.17.0.5"

稍等一会,你会看到如下的输出:

1
2
3
4
5
6
7
8
Running 10s test @ http://172.17.0.5
2 threads and 100 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 6.88ms 4.71ms 46.08ms 70.77%
Req/Sec 7.70k 548.11 9.10k 66.50%
153466 requests in 10.03s, 24.15MB read
Requests/sec: 15300.71
Transfer/sec: 2.41MB

你可以看到,新的平均每秒请求数是15300,相比优化之前的13798提升了10.8%;而每个线程的平均延迟6.88ms也比之前的7.53ms降低了8.6%。这说明,eBPF真的优化了负载均衡器的转发性能,这跟我们一开始的猜想是一致的。