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

栏目: 服务器 · 发布时间: 6年前

内容简介:译者按:本文翻译自2016年的一篇英文博客写给不想读长文的人(TL; DR):Linux如果你对eBPF或Linux tracing不是太熟悉,建议你阅读全文。本文循序渐进,并介绍了我在 上手bcc/eBPF时遇到的一些坑,这会节省你大量的时间。

译者按:本文翻译自2016年的一篇英文博客 How to turn any syscall into an event: Introducing eBPF Kernel probes如果能看懂英文,我强烈建议你阅读原文,或者和本文对照看。

写给不想读长文的人(TL; DR):Linux 4.4+ 支持 eBPF ,使用它可以将任何的 内核 函数调用 转换成 可带任何数据用户空间事件 ,而 bcc 使这个过程更加方便。 内核探测代码用C写,数据处理代码用Python。

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

1 消息系统:Push还是Pull

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

这种方式可以工作,但也有缺点:负载均衡器需要(分心)等待其他系统的结果,而它实际 上只应该负责负载均衡。

我们能做的更好吗?

当你希望一个程序能对系统变化做出反应时,通常有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防火墙开发的。它只有两个寄存器,只允许前向跳转,这意味着你不能用它 实现循环(如果非要说行也可以:如果你知道最大的循环次数,那可以手动做循环展开)。 这样设计是为了保证程序会结束,不会让操作系统卡住。你可能在考虑,我已经有iptables 做防火墙了,要这个有什么用?(作为一个例子,)它是CloudFlare的防DDOS攻击工具 AntiDDos 的基础。

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

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

下面让我们正式开始。

3 你好,世界

即使对大神级 程序员 来说,写汇编代码也并不太方便,因此我们这里使用 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 被调用,它 将会被显式地attach到 b.attach_kprobe("inet_listen", "my_probe")
  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 抓取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支持)码见 https://github.com/iovisor/bcc/blob/master/tools/solisten.py ,感谢bcc team的支持,现在它已经是一个正式工具。

如果想更深入的了解这个topic,建议阅读Brendan Gregg的博客,尤其是关于eBPF的maps和 statistics的 这一篇 。 Brendan Gregg是这个项目的主要贡献者之一。


以上就是本文的全部内容,希望本文的内容对大家的学习或者工作能带来一定的帮助,也希望大家多多支持 码农网

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

Code Reading

Code Reading

Diomidis Spinellis / Addison-Wesley Professional / 2003-06-06 / USD 64.99

This book is a unique and essential reference that focuses upon the reading and comprehension of existing software code. While code reading is an important task faced by the vast majority of students,......一起来看看 《Code Reading》 这本书的介绍吧!

随机密码生成器
随机密码生成器

多种字符组合密码

Base64 编码/解码
Base64 编码/解码

Base64 编码/解码

Markdown 在线编辑器
Markdown 在线编辑器

Markdown 在线编辑器