ArthurChiao's Blog

[译] eBPF 内核探测:如何将任意系统调用转换成事件(2016)

Published at 2018-12-03 | Last Update 2022-05-04

译者序

本文翻译自 2016 年的一篇英文博客 How to turn any syscall into an event: Introducing eBPF Kernel probes

由于译者水平有限,本文不免存在遗漏或错误之处。如有疑问,请查阅原文。

以下是译文。



太长不读(TL; DR)

Linux 4.4+ 支持 eBPF。基于 eBPF 可以将任何内核函数调用转换成可带任何 数据的用户空间事件bcc 作为一个更上层的工具使这个过程更加方便。内核探测 代码用 C 写,数据处理代码用 Python。

如果对 eBPF 或 Linux tracing 不是太熟悉,建议阅读全文。本文循序渐进,并介绍 了我在上手 bcc/eBPF 时遇到的一些坑,这会节省你大量时间。

1 消息系统:Push 还是 Pull

刚接触容器时,我曾思考如何根据系统的真实状态动态更新负载均衡器的配置。一 个可行的方案是,每次容器编排服务(orchestrator)启动一个容器,就由它去负责轮 询这个容器,然后根据健康检查的结果触发一次负载均衡器的配置更新。这属于简单 的 “SYN” test(探测新启动的服务是否正常)类型。

这种方式显然是可行的,但也有缺点:负载均衡器需要(分心)等待其他系统的结果,而 它实际上只应该负责负载均衡。我们能做的更好吗?

当希望一个程序能对系统变化做出反应时,通常有 2 种可能的方式:

  • 一种是程序主动去轮询,检查系统变化;
  • 另一种,如果系统支持事件通知的话,让它主动通知程序。

使用 push 还是 pull 取决于具体的问题。通常的经验是,

  • 如果事件频率相对于事件处理时间来说比较低,那 push 模型比较合适;
  • 如果事件频率很高,就采用 pull 模型,否则系统变得不稳定。

例如,通常的网络驱动会等待网卡事件,而 dpdk 这样的框架会主动 poll 网卡, 以获得最高的吞吐性能和最低的延迟。

在一个理想的世界中,我们有如下事件机制:

  • 操作系统 –> 容器管理服务:“我刚给一个容器创建了一个 socket,你需要更新你的状态吗?”
  • 容器管理服务 –> 操作系统:“谢谢通知,我需要更新。”

虽然 Linux 有大量的函数接口用于事件处理,其中包括 3 个用于文件事件的,但并没有专 门用于 socket 事件的。你能获取路由表事件、邻居表(2 层转发表)事件,conntrack 事件,接口(网络设备)变动事件,但就是没有 socket 事件。非要说有的话也行,但它深 深地隐藏在一个 Netlink 接口中。

理想情况下,我们需要一个通用的方式处理事件。怎么做呢?

2 内核跟踪和 eBPF 简史

直到最近,唯一的通用方式是给内核打补丁,或者使用 SystemTap。SystemTap 是一个 tracing 系统,简单来说,它提供了一种领域特定语言(DSL),代码编译成内核模块, 然后热加载到运行中的内核。但出于安全考虑,一些生产系统禁止动态模块加载, 例如我研究 eBPF 时所用的系统就不允许。

另一种方式是给内核打补丁来触发事件,可能会基于 Netlink。这种方式不太方便,内 核 hacking 有副作用,例如新引入的特性也许有毒,而且会增加维护成本。

从 Linux 3.15 开始,将任何可跟踪的内核函数安全地转换成事件, 很可能将成为现实。在计算机科学的表述中,“安全地”经常是指通过“某种类型的虚拟机” 来执行代码,这里也不例外。事实上,Linux 内部的这个“虚拟机”已经存 在几年了,从 1997 年的 2.1.75 版本有了,称作伯克利包过滤器(Berkeley Packet Filter),缩写 BPF。从名字就可以看出,它最开始是为 BSD 防火墙开发的。它只有两 个寄存器,只允许前向跳转,这意味着无法用它实现循环(如果非要说行也可以:如果 你知道最大的循环次数,那可以手动做循环展开)。这样设计是为了保证程序会在有限步骤 内结束,而不会让操作系统卡住。

更多信息见 (译) Linux Socket Filtering (LSF, aka BPF)(Kernel,2021)

你可能在考虑,我已经有 iptables 做防火墙了,要这个有 什么用?(作为一个例子,)它是 CloudFlare 的防 DDOS 攻击工具 AntiDDos的基础。

从 Linux 3.15 开始,BPF 被扩展成了 eBPF,extended BPF 的缩写。它从 2 个 32bit 寄存器扩展到了 10 个 64bit 寄存器,并增加了后向跳转。Linux 3.18 中又进行了进一 步扩展,将它从网络子系统中移出来,并添加了 maps 等工具。为了保证安全性又引入了一 个校验器,用于验证内存访问的合法性和可能的代码路径。如果校验器不能推断出程序会在 有限的步骤内结束,就会拒绝程序的注入(内核)。

更多关于 eBPF 的历史,可以参考 Oracle 的一篇精彩分享

下面让我们正式开始。

3 Hello, World!

即使对大神级程序员来说写汇编代码也并不是一件方便的事,因此我们这里使用 bccbcc 是基于 LLVM 的工具集,用 Python 封装了底层机器相关的细节。探测代码用 C 写, 数据用 Python 分析,可以比较容易地开发一些实用工具。

我们从安装 bcc 开始。本文的一些例子需要 4.4 以上内核。如果你想运行这些例子,我强 烈建议你启动一个虚拟机。注意是虚拟机,而不是docker 容器。容器使用的是宿 主机内核,因此无法单独更改容器内核。安装参考 GitHub

我们的目标是:每当有程序监听 TCP socket,就得到一个事件通知。当在 AF_INET + SOCK_STREAM 类型 socket 上调用系统调用 listen() 时,底层负责处理的内核函数就 是 inet_listen()。我们从用 kprobe 在它的入口做 hook,打印一个 “Hello, World” 开始。

from bcc import BPF

# Hello BPF Program
bpf_text = """
#include <net/inet_sock.h>
#include <bcc/proto.h>

// 1. Attach kprobe to "inet_listen"
int kprobe__inet_listen(struct pt_regs *ctx, struct socket *sock, int backlog)
{
    bpf_trace_printk("Hello World!\\n");
    return 0;
};
"""

# 2. Build and Inject program
b = BPF(text=bpf_text)

# 3. Print debug output
while True:
    print b.trace_readline()

这个程序做了 3 件事情:

  1. 依据命名规则,将探测点 attach 到 inet_listen 函数。例如按照这种规则,如果 my_probe 被调用,它 将会通过 b.attach_kprobe("inet_listen", "my_probe") 显式 attach
  2. 使用 LLM eBPF 编译,将生成的字节码用 bpf() 系统调用注入(inject)内核,并自动根据命名规则 attach 到 probe 点
  3. 从内核管道读取原始格式的输出

bpf_trace_printk() 是内核函数 printk() 的简单版,用于 debug。它可以将 tracing 信息打印到 /sys/kernel/debug/tracing/trace_pipe 下面的一个特殊管道,从 名字就可以看出这是一个管道。注意如果有多个程序读,只有一个会读到,因此对生产环境 并不适用。

幸运的是,Linux 3.19 为消息传递引入了 maps,4.4 引入了任意 perf 事件的支持。本文 后面会展示基于 perf 事件的例子。

# From a first console
ubuntu@bcc:~/dev/listen-evts$ sudo /python tcv4listen.py
nc-4940  [000] d... 22666.991714: : Hello World!

# From a second console
ubuntu@bcc:~$ nc -l 0 4242
^C

成功!

4 改进

接下来让我们通过事件发一些有用的信息出来。

4.1 抓取 TCP backlog 信息

“backlog” 是 TCP socket 允许建立的最大连接数(等待被 accept() 的连接数量)。

bpf_trace_printk 稍作调整:

bpf_trace_printk("Listening with with up to %d pending connections!\\n", backlog);

重新运行:

(bcc)ubuntu@bcc:~/dev/listen-evts$ sudo python tcv4listen.py
nc-5020  [000] d... 25497.154070: : Listening with with up to 1 pending connections!

nc 是个单连接的小工具,因此 backlog 是 1。如果 Nginx 或 Redis,这里将会是 128,后面会看到。

是不是很简单?接下来再获取端口和 IP 信息。

4.2 抓取 Port 和 IP 信息

浏览内核 inet_listen 代码发现,我们需要从 socket 对象中拿到 inet_sock 字段 。从内核直接拷贝这两行代码,放到我们 tracing 程序的开始处:

// cast types. Intermediate cast not needed, kept for readability
struct sock *sk = sock->sk;
struct inet_sock *inet = inet_sk(sk);

现在 Port 可以从 inet->inet_sport 中获得,注意是网络序(大端)。

如此简单!再更新下打印:

bpf_trace_printk("Listening on port %d!\\n", inet->inet_sport);

运行:

ubuntu@bcc:~/dev/listen-evts$ sudo /python tcv4listen.py
...
R1 invalid mem access 'inv'
...
Exception: Failed to load BPF program kprobe__inet_listen

从出错信息看,内核校验器无法验证这个程序的内存访问是合法的。解决办法是让内存访问 变得更加显式:使用受信任的 bpf_probe_read 函数,只要有必要的安全检测,可以用它 读取任何内存地址。

// Explicit initialization. The "=0" part is needed to "give life" to the variable on the stack
u16 lport = 0;

// Explicit arbitrary memory access. Read it:
// Read into 'lport', 'sizeof(lport)' bytes from 'inet->inet_sport' memory location
bpf_probe_read(&lport, sizeof(lport), &(inet->inet_sport));

获取 IP 与此类似,从 inet->inet_rcv_saddr 读取。综上,现在我们可以读取 backlog ,port 和绑定的 IP:

from bcc import BPF

# BPF Program
bpf_text = """
#include <net/sock.h>
#include <net/inet_sock.h>
#include <bcc/proto.h>

// Send an event for each IPv4 listen with PID, bound address and port
int kprobe__inet_listen(struct pt_regs *ctx, struct socket *sock, int backlog)
{
    // Cast types. Intermediate cast not needed, kept for readability
    struct sock *sk = sock->sk;
    struct inet_sock *inet = inet_sk(sk);

    // Working values. You *need* to initialize them to give them "life" on the stack and use them afterward
    u32 laddr = 0;
    u16 lport = 0;

    // Pull in details. As 'inet_sk' is internally a type cast, we need to use 'bpf_probe_read'
    // read: load into 'laddr' 'sizeof(laddr)' bytes from address 'inet->inet_rcv_saddr'
    bpf_probe_read(&laddr, sizeof(laddr), &(inet->inet_rcv_saddr));
    bpf_probe_read(&lport, sizeof(lport), &(inet->inet_sport));

    // Push event
    bpf_trace_printk("Listening on %x %d with %d pending connections\\n", ntohl(laddr), ntohs(lport), backlog);
    return 0;
};
"""

# Build and Inject BPF
b = BPF(text=bpf_text)

# Print debug output
while True:
  print b.trace_readline()

输出信息:

(bcc)ubuntu@bcc:~/dev/listen-evts$ sudo python tcv4listen.py
nc-5024  [000] d... 25821.166286: : Listening on 7f000001 4242 with 1 pending connections

这里 IP 是用 16 进制打印的,没有转换成适合人读的格式。

注:你可能会有疑问,为什么 ntohsntohl 并不是受信任的,却可以在 BPF 里 被调用。这是因为他们是定义在.h 文件中的内联函数,在写作本文期间,修了一个与此相 关的小bug

接下来,我们想获取相关的容器(container)。对于网络,这意味着我们要获得网络命名 空间。网络命名空间是容器的基石之一,使得(docker 等)容器拥有隔离的网络。

4.3 抓取网络命名空间信息

在用户空间,可以在 /proc/PID/ns/net 查看网络命名空间,格式类似于 net:[4026531957]。中括号中的数字是网络命名空间的 inode。这意味着,想获取命 名空间,我们直接去读 /proc 就行了。但是,这种方式太粗暴,只适用于运行时间比较 短的进程;而且还存在竞争。我们接下来从 kernel 直接读取 inode 值,幸运的是,这很 容易:

// Create an populate the variable
u32 netns = 0;

// Read the netns inode number, like /proc does
netns = sk->__sk_common.skc_net.net->ns.inum;

更新打印格式:

bpf_trace_printk("Listening on %x %d with %d pending connections in container %d\\n", ntohl(laddr), ntohs(lport), backlog, netns);

执行的时候,遇到如下错误:

(bcc)ubuntu@bcc:~/dev/listen-evts$ sudo python tcv4listen.py
error: in function kprobe__inet_listen i32 (%struct.pt_regs*, %struct.socket*, i32)
too many args to 0x1ba9108: i64 = Constant<6>

Clang 想告诉你的是:bpf_trace_printk 只能带 4 个参数,而你传了 5 个给它。这里 我不展开,只告诉你结论:这是 BPF 的限制。想深入了解,这里 是不错的入门 点。

唯一解决这个问题的办法就是。。把 eBPF 做到生产 ready(写作本文时还没,因此 eBPF 的探索就都这里了,译者注)。所以接下来我们换到 perf,它支持传递任意大小的结构体 到用户空间。注意需要 Linux 4.4 以上内核。

要使用 perf,我们需要:

  1. 定义一个结构体
  2. 声明一个事件
  3. 推送(push)事件
  4. Python 端再定义一遍这个事件(将来这一步就不需要了)
  5. 消费并格式化输出事件

看起来要做的事情很多,其实不是。

C 端:

// At the begining of the C program, declare our event
struct listen_evt_t {
    u64 laddr;
    u64 lport;
    u64 netns;
    u64 backlog;
};
BPF_PERF_OUTPUT(listen_evt);

// In kprobe__inet_listen, replace the printk with
struct listen_evt_t evt = {
    .laddr = ntohl(laddr),
    .lport = ntohs(lport),
    .netns = netns,
    .backlog = backlog,
};
listen_evt.perf_submit(ctx, &evt, sizeof(evt));

Python 端事情稍微多一点:

# We need ctypes to parse the event structure
import ctypes

# Declare data format
class ListenEvt(ctypes.Structure):
    _fields_ = [
        ("laddr",   ctypes.c_ulonglong),
        ("lport",   ctypes.c_ulonglong),
        ("netns",   ctypes.c_ulonglong),
        ("backlog", ctypes.c_ulonglong),
    ]

# Declare event printer
def print_event(cpu, data, size):
    event = ctypes.cast(data, ctypes.POINTER(ListenEvt)).contents
    print("Listening on %x %d with %d pending connections in container %d" % (
        event.laddr,
        event.lport,
        event.backlog,
        event.netns,
    ))

# Replace the event loop
b["listen_evt"].open_perf_buffer(print_event)
while True:
    b.kprobe_poll()

测试一下,这里我用一个跑在容器里的 redis,在宿主机上用 nc 命令:

(bcc)ubuntu@bcc:~/dev/listen-evts$ sudo python tcv4listen.py
Listening on 0 6379 with 128 pending connections in container 4026532165
Listening on 0 6379 with 128 pending connections in container 4026532165
Listening on 7f000001 6588 with 1 pending connections in container 4026531957

5 结束语

使用 eBPF,任何内核的函数调用都可以转换成事件触发的方式。本文也展示了笔者过程中 遇到的一些常见的坑。完整代码(包括 IPv6 支持)见 这里, 感谢 bcc team 的支持,现在它已经是一个正式工具。

想更深入了解这个 topic,建议阅读 Brendan Gregg 的博客。Brendan Gregg 是这个项目的主要贡献者之一。