WEBKT

我是内核开发者,用 eBPF 优化网络协议栈性能的实践记录

87 0 0 0

为什么选择 eBPF?

eBPF 的基本原理

实战:利用 eBPF 分析网络协议栈瓶颈

1. 确定监控目标

2. 编写 eBPF 程序

3. 编译和加载 eBPF 程序

4. 分析数据

5. 优化性能

进阶:使用 BCC 工具集

eBPF 的局限性

总结

作为一名内核开发者,优化网络协议栈性能是我的日常工作之一。面对日益增长的网络流量和对低延迟的极致追求,传统的性能分析工具往往显得力不从心。直到我遇到了 eBPF(Extended Berkeley Packet Filter),这个强大的内核技术彻底改变了我的工作方式。本文将分享我如何利用 eBPF 来分析网络协议栈瓶颈,并优化其性能的实践经验,希望能帮助你更好地理解 eBPF 在内核性能优化中的应用。

为什么选择 eBPF?

在深入细节之前,我想先谈谈为什么选择 eBPF。传统的内核性能分析方法,比如使用 perf 工具,虽然强大,但也存在一些局限性:

  • 侵入性: 某些分析操作可能会引入额外的开销,影响系统性能,尤其是在高负载环境下。
  • 数据量大: perf 生成的数据量非常庞大,分析起来费时费力。
  • 灵活性不足: perf 提供的分析角度相对固定,难以满足定制化的需求。

eBPF 则不同,它具有以下优势:

  • 安全性: eBPF 程序在内核中运行,但受到严格的验证器的检查,确保不会崩溃内核。
  • 高性能: eBPF 程序可以被 JIT 编译成本地机器码,执行效率非常高。
  • 灵活性: eBPF 允许用户自定义程序逻辑,可以从各种内核事件中提取所需的数据。

总而言之,eBPF 提供了一种安全、高效、灵活的方式来观察和分析内核行为,是网络协议栈性能优化的理想工具。

eBPF 的基本原理

为了更好地理解 eBPF 的应用,我们先简单了解一下它的基本原理。eBPF 的核心思想是:在内核中安全地运行用户提供的程序。具体来说,一个典型的 eBPF 工作流程如下:

  1. 编写 eBPF 程序: 使用类 C 语言编写 eBPF 程序,定义需要监控的内核事件和处理逻辑。
  2. 编译 eBPF 程序: 使用 LLVM 等工具将 eBPF 程序编译成字节码。
  3. 加载 eBPF 程序: 将编译后的字节码加载到内核中。
  4. 验证 eBPF 程序: 内核中的验证器会对 eBPF 程序进行安全检查,确保其不会崩溃内核。
  5. JIT 编译: 通过验证的 eBPF 程序会被 JIT 编译成本地机器码,以提高执行效率。
  6. 挂载 eBPF 程序: 将 eBPF 程序挂载到指定的内核事件上,例如函数调用、网络包接收等。
  7. 触发 eBPF 程序: 当内核事件发生时,eBPF 程序会被自动触发执行。
  8. 收集数据: eBPF 程序可以从内核事件中提取数据,并将数据存储到 eBPF map 中。
  9. 用户空间访问数据: 用户空间的应用程序可以通过 eBPF map 读取 eBPF 程序收集到的数据。

实战:利用 eBPF 分析网络协议栈瓶颈

现在,让我们通过一个实际的案例来演示如何利用 eBPF 分析网络协议栈瓶颈。假设我们发现服务器的网络吞吐量上不去,怀疑是 TCP 协议栈存在性能问题。我们可以使用 eBPF 来分析 TCP 协议栈的关键函数,找出潜在的瓶颈。

1. 确定监控目标

首先,我们需要确定需要监控的 TCP 协议栈函数。一般来说,以下函数是性能分析的重点:

  • tcp_recvmsg:接收 TCP 数据的函数。
  • tcp_sendmsg:发送 TCP 数据的函数。
  • tcp_v4_rcv:处理 IPv4 TCP 包的函数。
  • tcp_v6_rcv:处理 IPv6 TCP 包的函数。
  • tcp_transmit_skb:实际发送 TCP 包的函数。

2. 编写 eBPF 程序

接下来,我们需要编写 eBPF 程序来监控这些函数。下面是一个简单的 eBPF 程序示例,用于统计 tcp_recvmsg 函数的调用次数和执行时间:

#include <linux/kconfig.h>
#include <linux/ptrace.h>
#include <linux/version.h>
struct data_t {
u64 ts;
u32 pid;
u32 len;
};
BPF_HASH(counts, u32, struct data_t);
int kprobe__tcp_recvmsg(struct pt_regs *ctx, struct sock *sk, struct msghdr *msg, size_t len, int nonblock, int flags, int addr_copied) {
u32 pid = bpf_get_current_pid_tgid();
struct data_t data = {};
data.ts = bpf_ktime_get_ns();
data.pid = pid;
data.len = len;
counts.update(&pid, &data);
return 0;
}
int kretprobe__tcp_recvmsg(struct pt_regs *ctx) {
u32 pid = bpf_get_current_pid_tgid();
struct data_t *data = counts.lookup(&pid);
if (!data) {
return 0; // Missed entry
}
u64 delta = bpf_ktime_get_ns() - data->ts;
bpf_trace_message("tcp_recvmsg pid = %d, len = %d, time = %llu ns\n", data->pid, data->len, delta);
counts.delete(&pid);
return 0;
}

这个程序使用了 kprobe 和 kretprobe 两种探针:

  • kprobe: 在函数入口处插入探针,记录函数被调用的时间和一些关键参数。
  • kretprobe: 在函数返回时插入探针,计算函数的执行时间。

程序首先定义了一个 data_t 结构体,用于存储函数被调用时的时间戳、进程 ID 和数据长度。然后,使用 BPF_HASH 宏定义了一个哈希表 counts,用于存储每个进程的 data_t 结构体。kprobe__tcp_recvmsg 函数在 tcp_recvmsg 函数入口处被调用,它会获取当前进程 ID、时间戳和数据长度,并将这些信息存储到 counts 哈希表中。kretprobe__tcp_recvmsg 函数在 tcp_recvmsg 函数返回时被调用,它会从 counts 哈希表中读取之前存储的信息,计算函数的执行时间,并通过 bpf_trace_message 函数将结果打印到内核跟踪日志中。

3. 编译和加载 eBPF 程序

将上述代码保存为 tcp_recvmsg.c 文件,然后使用以下命令编译 eBPF 程序:

clang -O2 -target bpf -c tcp_recvmsg.c -o tcp_recvmsg.o

接下来,我们需要使用一个工具来加载 eBPF 程序到内核中。这里我推荐使用 bpftool,它是 Linux 内核自带的 eBPF 管理工具。首先,我们需要创建一个 bpftool prog load 命令,将编译后的 tcp_recvmsg.o 文件加载到内核中:

bpftool prog load tcp_recvmsg.o /sys/fs/bpf/tcp_recvmsg

然后,我们需要将 eBPF 程序挂载到 tcp_recvmsg 函数上。这可以通过 bpftool prog attach 命令来实现:

bpftool prog attach kprobe tcp_recvmsg /sys/fs/bpf/tcp_recvmsg -F
bpftool prog attach kretprobe tcp_recvmsg /sys/fs/bpf/tcp_recvmsg -F

-F 参数表示强制挂载,即使该函数已经被其他 eBPF 程序监控。执行完这些命令后,eBPF 程序就开始工作了。每当 tcp_recvmsg 函数被调用时,eBPF 程序都会被触发执行,并将结果打印到内核跟踪日志中。

4. 分析数据

我们可以使用 dmesg 命令查看内核跟踪日志:

dmesg | grep tcp_recvmsg

日志中会包含类似以下的信息:

[12345.678901] tcp_recvmsg pid = 1234, len = 1024, time = 123456 ns

这些信息告诉我们 tcp_recvmsg 函数被进程 ID 为 1234 的进程调用,接收了 1024 字节的数据,执行时间为 123456 纳秒。通过分析这些数据,我们可以了解 tcp_recvmsg 函数的性能瓶颈。例如,如果发现 tcp_recvmsg 函数的执行时间过长,或者频繁被调用,就可能存在性能问题。

5. 优化性能

找到性能瓶颈后,我们就可以采取相应的优化措施。例如,如果发现 tcp_recvmsg 函数频繁被调用,可以考虑增加 TCP 窗口大小,减少数据包的数量。如果发现 tcp_recvmsg 函数的执行时间过长,可以考虑优化 TCP 协议栈的代码,提高其执行效率。

进阶:使用 BCC 工具集

上面的例子只是一个简单的演示,实际的性能分析工作往往更加复杂。为了简化 eBPF 程序的开发和管理,我强烈推荐使用 BCC(BPF Compiler Collection)工具集。BCC 提供了一系列 Python 脚本和库,可以方便地编写、编译、加载和运行 eBPF 程序。使用 BCC,我们可以用更少的代码实现更强大的功能。

例如,使用 BCC 提供的 tcpaccept 工具,我们可以轻松地监控 TCP 连接的建立过程:

#!/usr/bin/env python
from bcc import BPF
# 加载 eBPF 程序
program = BPF(src_file="tcpaccept.c")
# 挂载 kprobe 到 tcp_v4_syn_recv 函数
program.attach_kprobe(event="tcp_v4_syn_recv", fn_name="trace_tcp_v4_syn_recv")
# 挂载 kprobe 到 tcp_v6_syn_recv 函数
program.attach_kprobe(event="tcp_v6_syn_recv", fn_name="trace_tcp_v6_syn_recv")
# 定义回调函数
def print_event(cpu, data, size):
event = program["events"].event(data)
print("%s %-6d %-16s %s:%d -> %s:%d" % (event.timestamp.decode('utf-8'), event.pid, event.task.decode('utf-8'), event.saddr.decode('utf-8'), event.sport, event.daddr.decode('utf-8'), event.dport))
# 注册回调函数
program["events"].open_perf_buffer(print_event)
# 循环读取数据
while True:
try:
program.perf_buffer_poll()
except KeyboardInterrupt:
exit()

这个 Python 脚本首先加载了一个名为 tcpaccept.c 的 eBPF 程序,然后将 kprobe 挂载到 tcp_v4_syn_recvtcp_v6_syn_recv 函数上。当有新的 TCP 连接建立时,eBPF 程序会被触发执行,并将连接信息存储到 events 环形缓冲区中。Python 脚本会循环读取 events 环形缓冲区中的数据,并将连接信息打印到屏幕上。

tcpaccept.c 文件的内容如下:

#include <uapi/linux/ptrace.h>
#include <net/sock.h>
#include <net/inet_sock.h>
#include <bcc/proto.h>
struct event_t {
u64 timestamp;
u32 pid;
char task[TASK_COMM_LEN];
char saddr[INET6_ADDRSTRLEN];
char daddr[INET6_ADDRSTRLEN];
u16 sport;
u16 dport;
};
BPF_PERF_OUTPUT(events);
int trace_tcp_v4_syn_recv(struct pt_regs *ctx, struct sock *sk) {
struct event_t event = {};
struct inet_sock *inet = inet_sk(sk);
event.timestamp = bpf_ktime_get_ns();
event.pid = bpf_get_current_pid_tgid() >> 32;
bpf_get_current_comm(event.task, sizeof(event.task));
bpf_probe_read_str(event.saddr, sizeof(event.saddr), inet_ntoa(&inet->inet_saddr));
bpf_probe_read_str(event.daddr, sizeof(event.daddr), inet_ntoa(&inet->inet_daddr));
event.sport = ntohs(inet->inet_sport);
event.dport = ntohs(inet->inet_daddr);
events.perf_submit(ctx, &event, sizeof(event));
return 0;
}
int trace_tcp_v6_syn_recv(struct pt_regs *ctx, struct sock *sk) {
struct event_t event = {};
struct inet_sock *inet = inet_sk(sk);
struct in6_addr *saddr = &inet->in6_addr;
struct in6_addr *daddr = &inet->in6_addr;
event.timestamp = bpf_ktime_get_ns();
event.pid = bpf_get_current_pid_tgid() >> 32;
bpf_get_current_comm(event.task, sizeof(event.task));
bpf_probe_read_str(event.saddr, sizeof(event.saddr), inet6_ntoa(saddr));
bpf_probe_read_str(event.daddr, sizeof(event.daddr), inet6_ntoa(daddr));
event.sport = ntohs(inet->inet_sport);
event.dport = ntohs(inet->inet_daddr);
events.perf_submit(ctx, &event, sizeof(event));
return 0;
}

这个 eBPF 程序定义了一个 event_t 结构体,用于存储连接信息,包括时间戳、进程 ID、进程名、源 IP 地址、目标 IP 地址、源端口和目标端口。trace_tcp_v4_syn_recvtrace_tcp_v6_syn_recv 函数分别在 tcp_v4_syn_recvtcp_v6_syn_recv 函数入口处被调用,它们会获取连接信息,并将信息存储到 events 环形缓冲区中。

通过这个例子,我们可以看到 BCC 的强大之处。使用 BCC,我们可以用更少的代码实现更复杂的功能,大大提高了 eBPF 程序的开发效率。

eBPF 的局限性

虽然 eBPF 非常强大,但也存在一些局限性:

  • 学习曲线陡峭: eBPF 的概念和技术比较复杂,需要一定的学习成本。
  • 内核版本依赖: 不同的内核版本对 eBPF 的支持程度不同,需要根据实际情况选择合适的 eBPF 程序。
  • 安全风险: 虽然 eBPF 程序受到验证器的检查,但仍然存在一定的安全风险,需要谨慎使用。

总结

eBPF 是一种强大的内核技术,可以用于分析和优化网络协议栈性能。通过本文的介绍,相信你已经对 eBPF 的基本原理和应用有了一定的了解。希望你能将 eBPF 应用到实际工作中,解决网络性能问题,提高系统性能。

记住,eBPF 是一把双刃剑,使用时需要谨慎,确保安全可靠。不断学习和实践,才能真正掌握 eBPF 的精髓,发挥其强大的威力。

希望这篇文章对你有所帮助!祝你在 eBPF 的学习和实践中取得更大的成功!

内核老司机 eBPF内核优化网络协议栈

评论点评

打赏赞助
sponsor

感谢您的支持让我们更好的前行

分享

QRcode

https://www.webkt.com/article/9196