eBPF入门指南

yuan 1年前 ⋅ 907 阅读
ad

本文旨在提供一个全面的eBPF入门指南,介绍其基本原理、关键特性以及如何在现代Linux系统中应用eBPF来优化性能、增强安全性和改善监控能力。

BPF、cBPF 和 eBPF

本节将介绍 BPF 的发展历程,以及相应名词的解释。

eBPF的诞生和发展

BPF 是 Berkeley Packet Filter (伯克利数据包过滤器) 的缩写, 这是一项诞生于 1992 年的冷门技术,允许用户态程序定义一个过滤规则集,这些规则集在内核空间执行,从而决定哪些包应该被抓取和分析。BPF的这种设计大幅度提升了网络监控和分析工具的性能,因为它减少了不必要数据的用户空间复制。直到 2013 年, Linux 社区收到了重新实现 BPF 的内核补丁。该项补丁最终在 2014 年正式并入 Linux 内核主线。 至此BPF演化成了一种更通用的执行引擎,能够运行在内核空间的小程序,不仅仅局限于网络包过滤。这种演化版本被称为eBPF(extended Berkeley Packet Filter)。

cBPF到eBPF

在eBPF出现之前,BPF通常指的是经典的BPF(cBPF, classic BPF)。eBPF的引入不是替代cBPF,而是一种扩展和增强。为了向后兼容,现代Linux系统通常会将cBPF程序透明地转换成eBPF程序来执行。这种转换保持了对旧有BPF程序的支持,同时让这些程序能够利用eBPF提供的性能优化和功能扩展。注意,虽然扩展后的 BPF 常常被称为 eBPF,但官方的缩写依然是 BPF,不带有前缀‘e’。所以本文仍然以 BPF代表扩展后的 BPF。事实上,正如前文所说,内核中只有一个执行引擎,它同时支持经典的 BPF程序以及扩展的 BPF 程序。

BPF 的作用

要精准介绍 BPF 的作用并不容易,因为它的功能过于强大。简单来说,BPF = 事件 + BPF 程序。 BPF 提供了一种在各种内核事件和应用程序事件发生时运行一段小程序的机制。如果大家熟悉监听器设计模式,会发现这有一定的相似之处:BPF 在发生对应事件的情况下,会将部分上下文提供给 BPF 程序,通过BPF程序的运行,从而实现很多强大的功能。因此,该技术使得内核完全可编程,允许用户(包括非专业内核开发人员),也可以较为轻松地定制系统,已解决现实问题。

一个简单的例子

前文的描述比较晦涩,本节用一个简单的例子来解释 BPF的具体功能及强大能力。首先,我们简单的介绍一下bpftrace。

bpftrace 是一款基于 BPF 的开源跟踪器,它提供了一个高级编程语言环境,用来创建强大的单行程序和小工具。简单来说,它善于编写一些简单的"单行程序",即很短的逻辑就可以实现的程序。bpftrace 的安装非常简单,我们可以简单使用包管理器进行一键安装,如:

sudo apt install bpftrace

本节中要展示的例子如下:

sudo bpftrace -e 'tracepoint:syscalls:sys_enter_open { printf("%s opened %s\n", comm, str(args->filename)); }'

运行结果

解析: sudo: 绝大部分的 BPF 程序由于涉及内核,所以一定要使用 root 用户,或者具有管理员权限的其他用户通过sudo命令执行。 bpftrace -e: 通过 bpftrace 框架,执行后面指定的BPF 程序。 tracepoint:syscalls:sys_enter_open:这是一个由 Linux 内核提供的事件,在执行到系统调用 open 的时候,会发出事件,这时如果有 BPF 程序挂载到此事件时,就会执行。系统调用open是用于打开文件的基本操作之一,本节中将程序挂载在此事件来监听系统的文件打开操作。 { printf("%s opened %s\n", comm, str(args->filename)); }: 这是BPF 程序本身,我们使用简单的 printf 函数来打印从 sys_enter_open 事件中传递过来的上下文。本案例中,我们打印了comm(进程名),args->filename(需要打开的文件路径)。

可以看到,我们通过短短一行程序,就简单的实现了对文件系统 IO的部分监控,这就是 BPF 给我们带来的强大内核编程能力的直观展示。

BPF 跟踪的能见度

前文中介绍了 BPF 的基本功能,接下来我们来探讨一下BPF 的深度和广度。我们先来看一张图片,图片来自BCC, BCC 是一个基于 BPF 的快速开发工具包,提供了众多的可观测性相关 BPF 程序。

从图中可以看到,BPF 可以在整个软件栈范围内达到可观测性的全覆盖:无论是操作系统内核态,还是用户态。甚至对于硬件级别,都有对应的可观测性能力。

为什么我们要使用 BPF 技术

本节将介绍BPF 技术相较于传统工具的优势。

在现代计算环境中,系统和应用程序的复杂性日益增加,这给性能监控、安全保障、以及网络功能的实现带来了前所未有的挑战。传统的工具和方法在处理这些问题时,往往要么无法提供足够的灵活性和精细度,要么会引入过多的性能开销。在这样的背景下,BPF技术不仅解决了这些问题,还开辟了系统设计和网络架构的新领域。以下是使用eBPF技术的几个关键原因:

高性能和低开销

eBPF程序运行在Linux内核空间,能够在数据包到达或离开网络接口、系统调用、或者其他关键事件发生时,直接处理数据,避免了传统方法中的上下文切换开销。这使得eBPF非常适合构建高性能的监控和网络功能,而不会对系统性能产生显著影响。如,在系统调用追踪方面:

  • 传统工具:传统上,系统调用追踪可能依赖于如straceptrace这样的工具。这些工具通过在用户空间和内核空间之间进行频繁的上下文切换来追踪进程的系统调用,这会导致相对较高的性能开销。特别是在追踪大量进程或需要长时间追踪时,性能损耗更为明显。

  • BPF:使用BPF进行系统调用追踪,可以直接在内核中执行追踪逻辑,减少了上下文切换的需要。eBPF程序可以在系统调用发生时立即执行,收集必要的数据,而对被监控的应用和整体系统性能的影响微乎其微。

安全性

eBPF程序在被加载和执行前,需要通过内核的一系列严格验证,确保它们不会访问非法内存区域、进入无限循环、或执行其他可能危害系统稳定性和安全的操作。这种设计为在内核空间执行自定义逻辑提供了安全保障。关于安全性的实现架构,我们会在后文讨论。

灵活性和动态性

与需要修改内核源代码或加载内核模块的传统方法不同,eBPF程序可以在运行时动态加载和卸载,提供了极高的灵活性。

我们在生产环境中可以立刻部署 BPF 跟踪程序,不需要重启系统,也不需要以特殊方式重启应用软件。正如 Brendan Gregg 所说,这感觉类似于医学检查中的 X 光影像:当需要对某些内核组件、设备、应用库进行检查时,我们能够以一种前所未有的方式看到它们的内部运作———直接在生产环境中进行现场直播。

丰富的应用场景

eBPF技术在性能监控、安全增强、网络功能扩展等多个领域都有广泛的应用。例如,它可以用于实现高级网络包过滤、负载均衡、流量监控、系统调用审计、以及基于行为的安全策略等。

生态系统和社区支持

随着eBPF技术的成熟,围绕它形成了一个活跃的开发和用户社区,提供了大量的工具、库和文档,如BCC(BPF Compiler Collection)、bpftrace 以及 cilium/ebpf 等,这些资源极大地降低了eBPF的学习和使用门槛,加速了其在实际环境中的应用。

总而言之,eBPF技术提供了一种高效、安全、灵活的方式,使得开发者和系统管理员能够实现之前难以想象的功能,同时最小化对系统性能的影响。正因如此,eBPF不仅是解决当下挑战的有力工具,也是未来系统和网络创新的基石。

BPF 跟踪的事件源

本节将介绍事件源部分。

上文中,我们曾提到,BPF = 事件 + BPF 程序。通过理解事件的产生方式,我们可以了解BPF 是如何做到全栈监控的。

BPF 支持多种事件源,其中可以简单分为几类,系统调用(Syscalls),网络事件,追踪点(Tracepoints),kprobes和uprobes,性能计数器(Perf Events),cgroup事件,XDP(eXpress Data Path),LSM(Linux Security Modules)钩子,用户自定义追踪点(USDT - User Statically Defined Tracing)。由于篇幅关系,我们不能介绍每一个事件源的具体情况,我们只对动态插桩以及静态插桩进行简单讲解。注意,这并不意味着其他的事件源并不重要。比如 XDP 与 TC 对于网络的监控尤为重要,它们会在其他文章中详细介绍。

需要注意,这些事件源并非专为 BPF 提供,它们还有其他的使用方式,不过具体使用方式不在本文章的涵盖范围内,不做过多讨论。

动态插桩

在Linux中,kprobesuprobes提供了一种强大的动态插桩(Dynamic Instrumentation)机制,允许开发者和系统管理员在运行时监控和修改内核空间(kprobes)和用户空间(uprobes)中程序的行为,而无需修改代码或重新编译程序。这种机制极大地增加了Linux系统的可观测性和灵活性。

kprobes

kprobes 提供了对内核的动态插桩支持,它从 2004 年正式加入 Linux 内核,从 2.6.9 版本开始可用。 kprobes 函数可以对任何内核函数进行插桩( 除非该函数被特殊标记为不能被插桩,如 kprobe 函数本身用来防止一些安全问题),它可以对函数内部的指令进行插桩。可以实时在生产环境系统中启用,不需要重启系统,也不需要通过特殊方式重启内核。性能方面,可以达到:未启用时对软件无任何影响,开销为 0,启用时性能影响微乎其微。 这是一项特别强大的能力,这可以让我们对 Linux 内核数不胜数的内核函数进行任意插桩,获取各种层面的指标。 kprobes 技术还有另一个接口叫做 kretprobe,用于在内核函数返回时插桩以获取返回值。

问题

正如前文所说,Linux 内核已经通过黑名单的方式确保了危险的函数不会被成功注入,所以安全性并不是最大的问题,但是,对于部分调用频率非常高的函数,如上文提到过的 open 系统调用。那么,相应的 BPF 程序的执行频率也会非常高,进而可能导致对性能产生一定的影响,需要开发者着重考虑。

例子

我们仍然使用 bpftrace 进行验证,这次,我们尝试对虚拟文件系统进行监控:

 sudo bpftrace -e 'kprobe:vfs_* { @[probe] = count() }'

结果:

解析: kprobe:vfs_*:这指定了要附加的kprobe类型和目标。vfs_*是一个通配符模式,匹配所有以vfs_开头的内核函数。也就是对所有的 vfs_开头的函数进行插桩。VFS(Virtual File System)是Linux内核的一部分,负责文件系统的抽象和管理。因此,这条命令将监控所有VFS层的函数调用次数。 { @[probe] = count() }:对应的 BPF 函数,简单来说,就是创建一个 map(map 的相关知识会在下文详细讲解,这里可简单理解为一种 HashMap),每次执行对应函数,对应函数名的 key 的 value 就会进行累加操作。最终打印结果。

由此,我们就可以通过 kprobes 的强大能力监控整个内核函数栈。

扩展阅读

kprobes 是一个很重要的内核技术,由于篇幅关系,很难详细介绍其中的原理。所以在这里留下几篇文章,供读者参考:

  1. Linux 内核kprobes官方介绍:[[https://www.kernel.org/doc/Documentation/kprobes.txt]]

  2. An introduce to kprobes : [[https://lwn.net/Articles/132196/]]

uprobes

uprobes 技术类似于 kprobes 技术,只不过它们作用于用户态。具体使用方法与 kprobe 十分相似,不作过多赘述,我们通过一个简单的案例来展现它的应用场景:

sudo bpftrace -e 'uretprobe:/bin/bash:readline { printf("%s\n", str(retval)); }'

结果:

相信由上两个案例的经历,读者们应该都对 bpftrace 有了一定的了解,所以逐步分析部分简单略过。在本案例中,尝试对/bin/bash 的 readline 函数进行插桩,以获取返回值(即用户的命令行输入),从而达到对其他用户的命令行进行输入检测的目的。

性能

虽然 uprobes 和 kprobes 的效果类似,只是作用与不同,但是不得不提的是,uprobes 的性能仍有待提高。对于部分情景,uprobes 的时间消耗会是其他插桩方式的 14-20 倍。因此,应尽量避免将uprobe 挂载到高频率的方法中。不过,uprobe 的性能优化仍然是 Linux 的重要课题。也许在不久的将来,它的性能问题将彻底消失。

实际案例

在生产中,我们可能并不会用太多的 uprobes 相关技术,因为 BPF 的适用场景可能更偏向于系统级别而不是用户级别。所以对 kprobes 的使用会更多。然而,还是有很多不得不使用uprobes 的情景,如:如何分析 SSL/TLS 加密后的数据包。如果我们使用常规插桩分析的话,只会得到加密后的数据。因此,我们可以对 OpenSSL 等框架逻辑进行插桩,从而直接获得解密后的数据包。具体实现逻辑稍微有些复杂,不在本文中详细解释。

静态插桩

动态插桩对应的,自然就是静态插桩。所谓静态插桩,就是必须在代码作者提供插装点的情况下才能插桩。伪代码实例如下:

int funcA () {
...
PROBE_HERE(ctx)
...
}

也就是代码编写者必须要在代码中指定插装点,并且提供合适的上下文,我们才能够进行插桩。这样看起来灵活性会很差,实际上确实如此,不过也因此提供了更高的稳定性。我们可以设想以下场景:比如我们通过 kprobe 对某函数进行插桩,但是由于内核版本的升级,原有的函数被删除了,那么插装自然就会失败,所以,很难保证它的可维护性。这也正是静态插桩的意义所在:通过让代码编写者维护插装点的方式,让插装点稳定下来。

tracepoint

tracepoint 是内核态的静态插桩技术,在 Linux 内核中,开发者有意的放置了特别多的 tracepoint 在特定逻辑处。Linux 社区承诺,将尽最大努力保证所有 tracepoint 的稳定性:他们不会轻易的改变 tracepoint 名称,位置,定义等状态。细心的读者可能已经发现了,本文章开篇示例使用的事件源就是 tracepoint,让我们重新回顾一下:

sudo bpftrace -e 'tracepoint:syscalls:sys_enter_open { printf("%s opened %s\n", comm, str(args->filename)); }'

这里的事件源的定义为 tracepoint:syscalls:sys_enter_open,这里包含了 tracepoint 跟踪点格式的说明,即子系统:事件名。对于本案例来说,就是对系统调用这个子系统的 sys_enter_open 这个事件进行插桩。我们可以通过如下命令获取系统支持的tracepoint 列表:

cat /sys/kernel/debug/tracing/available_events

同时,tracepoint也是性能监控一个不错的学习入口:如果你不清楚什么地方的性能需要监控,那么就可以通过查看 Linux 给我们提供了什么插装点,进而了解到自己的性能监控程序薄弱的地方。

真实情景

在真实案例中,tracepoint 是使用最多的事件源,因为它使用稳定的同时,也拥有最高的性能。尤其对于一些需要全天全时段进行插桩的情景来说,使用它造成的性能消耗最小,也最可控。所以说,实际开发中,我们要尽可能优先选择 tracepoint,如果功能不能被支持,才会尝试使用 kprobes 等事件源实现。

扩展阅读

tracepoint 会是很多 BPF 开发者最常使用的事件源,下面提供内核源码中的一篇文章,供读者参考。 [[https://www.kernel.org/doc/Documentation/trace/tracepoints.rst]]

USDT

USDT(Userland Statically Defined Tracing,用户自定义追踪点)是一种在用户空间应用程序中定义静态追踪点的技术。这允许开发者在软件应用中插入追踪点,从而在运行时无需修改代码或重新编译程序即可进行动态追踪。USDT 主要用于性能分析和调试,使得开发者可以深入理解软件在生产环境中的行为。 在实际生产中,这类事件源的使用较为少见,在作者的实际业务开发中,从未使用过 USDT 进行 BPF 程序编写。因为很少有用户愿意在自己的代码中添加对应标记来支持 USDT。同时,USDT 对非编译类语言支持很差。因为 USDT 需要一个有特定标记的 ELF 文件,而如 Java 这种基于 JIT 的语言是不存在这种编译产物的。虽然,有一类相关技术,称为动态 USDT 对这类情况有一定支持,不过使用人数仍然不多。所以,本文中不会对此事件源过多讨论,对于感兴趣的读者,这里有两篇优秀的文章,可以供大家了解,学习:

  1. Hacking Linux USDT with Ftrace

  2. USDT Tracing Report

BPF 架构

本节将介绍BPF的运行时架构。

虽然在编写BPF程序时,深入了解其架构不是必要的,但对其有一定的理解却极为重要。事实上,如果缺乏对BPF架构的了解,开发过程中可能会遇到一些看似莫名其妙的问题。例如,在我的实际开发经历中,有时会遇到这样的情况:一些在C语言中毫无语法错误的基础逻辑和比较语句,一旦放到BPF程序中执行就会出现错误。通过深入理解BPF的指令集和架构,我们可以更清楚地知道这些错误的具体原因。

本节将以验证器、寄存器、指令集与指令格式、帮助函数以及BPF Map几个方面进行介绍。

从设计规范上看,BPF 类似于一种虚拟机,不过它与传统的虚拟机有所不同。它的指令仍然在物理机处理器上运行,寄存器也是简单的映射到物理机寄存器。只不过实现规范类似,所以被称为虚拟机形式的架构。具体运行时架构图如下: 

验证器

前文中我们提到过,BPF 是一项安全的技术,它几乎不可能影响到内核的正常运行。这便是验证器所提供的功能,它对开发者编写的 BPF 程序进行校验,以达到拒绝危险代码的目的。验证器主要通过以下几个方面判定一个程序是否危险:

  1. 首先,它检查程序是否只访问允许的内存区域,包括eBPF Map的访问、对内核数据结构的引用,以及确保程序不会访问未经授权的内存地址。同时,验证器确保eBPF程序只调用被允许的辅助函数,提供了一种安全的方式来与内核交互。

  2. 其次,验证器关注程序中是否存在无限循环,以及程序是否能够在有限的指令内到达终止点,从而确保CPU资源不会被无限循环耗尽,影响系统稳定性。此外,所有使用的变量都必须在使用前被正确初始化,以防止未定义行为的发生,同时保证程序中的操作和比较等都符合类型安全,避免因类型不匹配导致的错误。

  3. 验证器还检查eBPF程序的堆栈使用是否在允许的范围内,以及程序的指令数是否超过了内核定义的限制,确保系统资源被合理使用。通过这些检查,eBPF验证器确保程序运行在一个受限的环境中,即使程序行为异常,也不会对系统整体安全造成威胁。对于特定的安全策略,如SELinux或AppArmor,验证器还会检查eBPF程序是否符合这些策略的规定。

这一系列综合性的检查是保证eBPF技术能够安全应用于Linux内核的关键,帮助过滤掉可能对系统安全和稳定性构成威胁的程序。

验证器虽然确实从很多方面保证了系统的安全和稳定性,不过我们也能从中看出 BPF 的很多限制。如:

  1. 我们并不能直接调用内核函数,只能通过帮助函数调用其中的一部分。

  2. 我们不能直接访问内核数据,通过帮助函数可以访问固定的安全数据段。

  3. 我们不能使用无限循环,事实上,在早期的版本中根本不支持循环(因为验证无限循环的存在是一个非平凡的计算问题,早期的eBPF设计选择禁止循环以简化验证过程和提高安全性),4.18版本开始,引入了对有限循环的支持。然而,仍有很多限制,比如,必须使用帮助函数进行循环,必须有一个静态可预测的上限,这意味着循环次数在编译时就应该是可确定的,循环体中不能包含可能改变控制流的复杂操作,例如调用辅助函数。

  4. 指令总数是有限制的,每个程序的最大指令限制为 4096 BPF 指令,根据设计,这意味着任何程序都会快速终止。对于 5.1 以上的内核,此限制已提升至 100 万条 BPF 指令。

  5. 栈空间是有限制的,只有 512KB。这意味着我们几乎不可能尝试在内核态去解码 HTTP 请求。只能进行一些简单有限的操作。不过,BPF 也有相应的解决方案,我们可以通过BPF map,简单的存入数据,在用户态程序中取出,进行更复杂的操作,具体内容在后文详解。

综合来看,BPF 函数的安全性是一把双刃剑,虽然保证了内核的安全,但也有诸多限制,需要开发者注意。

寄存器

具体来说,BPF包含十一个64位寄存器,每个寄存器具有32位的子寄存器,一个程序计数器和一个 512 字节大小的BPF 堆栈空间组成。寄存器被命名为r0r10。操作模式默认为64位,32位子寄存器只能通过特殊的ALU(算术逻辑单元)运算来访问。写入时,32 位低位子寄存器将零扩展到 64 位。 寄存器 r10 是唯一一个只读寄存器,包含用于访问 BPF 堆栈空间的帧指针地址。其余的 r0 - r9 寄存器是通用寄存器,具有读/写性质。寄存器 r10 是唯一一个只读寄存器,包含用于访问 BPF 堆栈空间的帧指针地址。BPF程序会调用一些辅助函数,这些辅助函数由系统内核定义,调用过程的寄存器约定如下: ① r0 包含辅助函数调用的返回值。 ② r1 - r5 保存从 BPF 程序到内核帮助函数的参数。 ③ r6 - r9 是被调用者保存的寄存器,将在辅助函数调用时保留。 从此,我们也可以看出,bpf 并不支持五个以上参数的帮助函数的调用。

指令集与指令格式

BPF 的指令格式如下:

简单来说,BPF 是一种精简指令集设计。其中指令格式包含 8 位的操作码,8 位的目标寄存器号,8 位的源寄存器号,16 位的 有符号偏移量和 32 位的有符号立即数。一共 64 位,即 8 字节。 下面对 8 位的操作码进行了简单的分析: 首先,8 位的操作码中低 3 位的字节,有下面八种排列方式,表示指令类型。

虽然 BPF 指令类型不多,不过对于每一条指令及其作用的讲解并不在本篇文章的涵盖范围内,读者如果感兴趣,可以通过此链接进行浏览。了解大部分指令的作用是重要的,因为在 BPF程序加载发生错误时,只会将错误位置以汇编指令的形式进行报告。

到这里,读者可能会有一些疑惑,为什么 BPF 的寄存器数量很少,并且指令集也比较小?这实际上有以下几个原因:

  1. BPF程序在加载到内核执行前,必须通过一个严格的验证过程,确保它们不会对系统稳定性和安全构成威胁。一个较小的指令集和有限的寄存器数量简化了验证过程,使得静态分析和安全审计更为可行和高效。

  2. BPF设计为能够快速执行,同时对系统性能影响最小。有限的寄存器数量和简化的指令集有助于实现这一点,因为它减少了执行时的复杂性,提高了指令的执行速度。同时这种设计也能直接作为比如 X86, ARM 等指令集的子集,从而直接映射到物理寄存器和处理器。进而减少了虚拟机设计的复杂度。这也一定程度的实现了它的可移植性。

  3. 在之前bpftrace的实例中,我们可以使用一种较为高级的语言去进行 BPF 程序编写,而实际开发中,我们更有可能使用 C 语言去编写 BPF 程序。从此看出,BPF指令集设计的如此精简也是为了支持从各种编程语言转化为 BPF 的汇编语言,这也同样展示了 BPF的灵活性。

帮助函数

前文中,我们反复提及帮助函数这个概念,事实上,它并不难理解,就像它的名字一样。它是由内核提供用来方便我们进行一些操作的 API。它使 BPF 程序能够查阅内核定义的函数调用集,以便从内核检索数据/将数据推送到内核。对于每个 BPF 程序类型,可用的辅助函数可能有所不同,例如:

该函数返回一个 64 位的数字,其中前32位是线程 id,后32 位是线程组 id。这个函数可以方便的让我们获取当前执行任务的线程号,用来进行一些关联操作。然而,在 XDP事件源的 BPF程序中,就不能使用这个方法:因为 XDP 的触发点在网络接收包的位置,而那个时间还尚未由线程接管,自然就获取不了线程 ID。

内核提供的帮助函数有很多,并且这个列表在不断增加,详细的帮助函数信息,可以在这里找到: https://man7.org/linux/man-pages/man7/bpf-helpers.7.html

BPF map

上文提到,BPF 的栈空间只要 512KB,复杂的数据无法在内核态中处理。所以,BPF 提供了 map 数据结构来进行内核态 BPF 程序和用户态程序之间的数据交互。我们尚未解释什么是用户态和内核态程序,在这里可以简单理解为,由于栈空间过小,所以大型的数据都要由 BPF 程序之外的程序进行处理,这个程序被称之为用户态程序。而真正的BPF程序是在操作内核态运行的,所以被称之为内核态程序。而 map 就是他们之间数据交互的桥梁。

概念上,map是驻留在内核空间中的高效键/值存储。可以从 BPF 程序访问它们,以便在多个 BPF 程序调用之间保持状态。它们还可以通过用户空间的文件描述符进行访问,并且可以与其他 BPF 程序或用户空间应用程序任意共享。 请注意,map 可以通过文件描述符进行访问,这并不代表它是一个运行在内核态的文件,它只是一种特殊的数据结构,可以同时让用户态和内核态程序进行访问和修改。

map 的实现类型大致如下:

 它们的其中一部分属于通用 map,如BPF_MAP_TYPE_HASHBPF_MAP_TYPE_ARRAY、 BPF_MAP_TYPE_PERCPU_HASHBPF_MAP_TYPE_PERCPU_ARRAYBPF_MAP_TYPE_LRU_HASH。它们都使用相同的一组通用的 BPF 帮助器函数来执行查找、更新或删除操作,只是在不同的情况下有不同的性能表现,或者不同的限制问题。 剩下的一些属于是非通用 map,他们需要特别的帮助函数进行操作,例如,BPF_MAP_TYPE_PROG_ARRAY是一个数组映射,它保存其他 BPF 程序,BPF_MAP_TYPE_ARRAY_OF_MAPS和 BPF_MAP_TYPE_HASH_OF_MAPS都保存指向其他map的指针,以便可以在运行时自动替换整个 BPF map。这些类型的map解决了一个特定问题, 用于一些特殊情况的处理。 听到这里,读者们可能还不能对 map 有个直观的印象。我们会在后文中举例说明。

总之,BPF架构的介绍揭示了其作为现代Linux系统中不可或缺的一部分的重要性。对BPF架构的理解不仅能帮助开发者避免在编程时遇到的意外情况,更能激发出利用这一强大工具解决实际问题的创新思路。

实际开发

本节将通过三种常见的 BPF 程序开发工具,来介绍实际开发中的项目结构。

我们之前提到过,BPF程序的编写,需要内核态 BPF 程序和用户态 BPF 程序的配合。那么自然,用户态程序就有很多选择。事实上,BPF 程序对用户态语言是没有要求的。这自然也就产生了很多 BPF 的用户态框架,我们挑选几个进行介绍:

  1. bpftrace:bpftrace 是我们之前实例使用的开发框架,我们可以注意到,我们好像并没有编写用户态编码。实际上还是有的,只不过框架本身为我们写好了。这也同样表示:bpftrace 框架的自由度是不够的。在实际的开发过程中,我们一般并不会使用 bpftrace 这种高级环境进行开发,因为它并不适合大型 BPF程序的编写。功能也不足够齐全。比如并不支持 kprobe 的地址级别的插桩。不过,毫无疑问,如果编写功能简单的 BPF工具,它将是首选。它将大大的减少简单工具的开发难度,减少开发负担。

  2. BCC:BCC 是一个老牌的 BPF开发工具,它使用 python 作为用户态开发,C语言作为内核态开发。具体代码类似于如下示例:

#!/usr/bin/python3
# 该实例来源于 https://github.com/iovisor/bcc/blob/master/examples/ringbuf/ringbuf_submit.py
import sys
import time

from bcc import BPF

src = r"""
BPF_RINGBUF_OUTPUT(buffer, 1 << 4);

struct event {
  char filename[64];
  int dfd;
  int flags;
  int mode;
};

TRACEPOINT_PROBE(syscalls, sys_enter_openat) {
  int zero = 0;

  struct event *event = buffer.ringbuf_reserve(sizeof(struct event));
  if (!event) {
      return 1;
  }

  bpf_probe_read_user_str(event->filename, sizeof(event->filename), args->filename);

  event->dfd = args->dfd;
  event->flags = args->flags;
  event->mode = args->mode;

  buffer.ringbuf_submit(event, 0);
  // or, to discard: buffer.ringbuf_discard(event, 0);

  return 0;
}
"""

b = BPF(text=src)

def callback(ctx, data, size):
   event = b['buffer'].event(data)
   print("%-64s %10d %10d %10d" % (event.filename.decode('utf-8'), event.dfd, event.flags, event.mode))

b['buffer'].open_ring_buffer(callback)

print("Printing openat() calls, ctrl-c to exit.")

print("%-64s %10s %10s %10s" % ("FILENAME", "DIR_FD", "FLAGS", "MODE"))

try:
   while 1:
       b.ring_buffer_consume()
       time.sleep(0.5)
except KeyboardInterrupt:
   sys.exit()

我们从中可以看出,它们的开发模式为:以字符串的形式存入 BPF 内核态程序,并且由用户态 python 代码进行 BPF 程序的加载。同时,使用ring_buffer 类型的 map 进行内核态与用户态之间的操作。BCC 是一个优秀的框架,它有丰富的示例程序可供参考。对于善于 python 的读者来说,它将是不二选择。

  1. cilium/ebpf: cilium 本身是一个极其优秀的开源框架,它基于 BPF 等技术,实现了强大的网络扩展与观测能力。同时,cilium 社区也提供了 cilium/ebpf 项目来简化大家的 BPF 开发。ebpf-go 是一个纯 Go 库,提供用于加载、编译和调试 eBPF 程序的实用程序。它具有最小的外部依赖性,旨在用于长时间运行的进程。Go 语言一直是云原生领域中的首选编程语言之一,得益于其简洁的语法、强大的并发支持以及广泛的社区生态。这使得Go语言成为开发基于eBPF的云原生网络和安全解决方案的理想选择。这也是笔者在工作开发中最常使用的框架,主要也是因为Go 语言在云原生方面的优秀支持。后面的进阶示例中,我们也将使用此框架,将我们之前学到的知识连接起来。

在介绍了bpftrace、BCC以及cilium/ebpf这几个BPF用户态框架后,我们可以看到每个框架都有其独特的优势和应用场景。bpftrace以其简洁性适合快速开发简单的BPF工具,而BCC则提供了更为丰富的功能和灵活性,适合进行复杂BPF程序的开发。cilium/ebpf,作为一个纯Go库,极大地降低了使用Go语言进行BPF开发的门槛,特别适合云原生环境。这些框架共同丰富了BPF生态,让开发者根据自己的需求和偏好选择最合适的工具,无论是进行系统观测、性能监控还是安全增强等领域的开发。理解它们的特点和局限,有助于在实际项目中做出更合适的技术选型,更高效地利用BPF带来的强大能力。

进阶示例

本节通过一个进阶实例,来了解基本的 BPF 程序的编写方式。

通过之前的学习,我们已经能够写出一些基础的 BPF程序了,让我们实战尝试一下。在这次的情景中,我们需要插桩系统调用 execve 方法,获取总体的调用次数。

内核态代码

//go:build ignore

#include "common.h"

char __license[] SEC("license") = "Dual MIT/GPL";

struct bpf_map_def SEC("maps") kprobe_map = {
.type        = BPF_MAP_TYPE_ARRAY,
.key_size    = sizeof(u32),
.value_size  = sizeof(u64),
.max_entries = 1,
};

SEC("kprobe/sys_execve")
int kprobe_execve() {
u32 key     = 0;
u64 initval = 1, *valp;

valp = bpf_map_lookup_elem(&kprobe_map, &key);
if (!valp) {
bpf_map_update_elem(&kprobe_map, &key, &initval, BPF_ANY);
return 0;
}
__sync_fetch_and_add(valp, 1);

return 0;
}

解析:

  1. 头文件及注释

//go:build ignore

#include "common.h"

由于 cilium/ebpf 是一个纯 go 的库,所以对于 c 语言文件的保存,我们需要标注 go:build ignore让 go build 指令对它进行忽略。

include 指令:引入 cilium 提供的头文件,来提供对 BPF 的支持。

  1. 协议段

char __license[] SEC("license") = "Dual MIT/GPL";

这段代码是 BPF 程序的常用代码,由于我们要对 Linux 内核的源代码进行插桩,所以,我们的程序本身,也要去同意相应的协议规范,否则将禁止一些操作的执行。

  1. map 定义

struct bpf_map_def SEC("maps") kprobe_map = {
.type       = BPF_MAP_TYPE_ARRAY,
.key_size   = sizeof(u32),
.value_size = sizeof(u64),
.max_entries = 1,
};

这是一个标准的 BPF map 定义,首先,它定义了一个结构体,名为 kprobe_map,类型为bpf_map_def。然后,我们使用 SEC()宏,要求在最终编译成果中,将这段加入到 maps 段。从而BPF 加载程序才能认识到这是一个 map。

然后,在结构体内,使用固定的配置列表,进行对 map 的配置: ① type:即 map 的类型,在前文中已经解释,不再详细赘述。简单来说,这是一个数组类型的 map,key 会是一个数字,value 没有限制。 ②key_size / value_size:即 key / value 的大小。map 并不关心配置的具体类型,只要指定尺寸即可。 ③ max_entries:即最大的键值对的数量,由于本案例只希望向用户态传递一个数值,所以为 1 即可。

  1. BPF 程序

SEC("kprobe/sys_execve")
int kprobe_execve() {
u32 key     = 0;
u64 initval = 1, *valp;

valp = bpf_map_lookup_elem(&kprobe_map, &key);
if (!valp) {
bpf_map_update_elem(&kprobe_map, &key, &initval, BPF_ANY);
return 0;
}
__sync_fetch_and_add(valp, 1);

return 0;
}

BPF 程序需要在 SEC 部分指定自己的事件源,本案例中,事件源为 kprobes,用来插桩 sys_execve函数。通常,函数会有一个上下文的参数供我们使用,在本案例中由于没有使用到,因此可以省略。

具体逻辑中,我们强制定义 key 为 0(因为我们只需要传递函数调用次数这一个数据给用户态,所以 key 会一直为 0)。 接下来,我们使用 0,调用帮助函数bpf_map_lookup_elem,该函数顾名思义,即通过 key 到对应的 map 中查找 value 并返回。如果未查到,则通过帮助函数bpf_map_update_elem将 value 设置为 1,如果查到了,则在加锁来避免多线程影响的情况下,对其进行自增操作。这样,map 中 key 为 0 的 value,即为函数的调用次数。

用户态代码

package main

import (
"log"
"time"

"github.com/cilium/ebpf/link"
"github.com/cilium/ebpf/rlimit"
)

//go:generate go run github.com/cilium/ebpf/cmd/bpf2go bpf kprobe.c -- -I../headers

const mapKey uint32 = 0

func main() {

// Name of the kernel function to trace.
fn := "sys_execve"

// Allow the current process to lock memory for eBPF resources.
if err := rlimit.RemoveMemlock(); err != nil {
log.Fatal(err)
}

// Load pre-compiled programs and maps into the kernel.
objs := bpfObjects{}
if err := loadBpfObjects(&objs, nil); err != nil {
log.Fatalf("loading objects: %v", err)
}
defer objs.Close()

// Open a Kprobe at the entry point of the kernel function and attach the
// pre-compiled program. Each time the kernel function enters, the program
// will increment the execution counter by 1. The read loop below polls this
// map value once per second.
kp, err := link.Kprobe(fn, objs.KprobeExecve, nil)
if err != nil {
log.Fatalf("opening kprobe: %s", err)
}
defer kp.Close()

// Read loop reporting the total amount of times the kernel
// function was entered, once per second.
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()

log.Println("Waiting for events..")

for range ticker.C {
var value uint64
if err := objs.KprobeMap.Lookup(mapKey, &value); err != nil {
log.Fatalf("reading map: %v", err)
}
log.Printf("%s called %d times\n", fn, value)
}
}

go 代码的编写更为简单,仅有约60 行。 解析:

  1. go generate

//go:generate go run github.com/cilium/ebpf/cmd/bpf2go bpf kprobe.c -- -I../headers

首先,使用 go generate调用官网的 bpf2go 方法生成go 文件,该方法抽象了 c 语言中编写的 map 定义,以及 BPF 程序本身,方便用户态使用 Go 调用。同时,在执行 go build 方法时,我们也会对之前编写的 BPF 程序使用 clang 进行编译,以得到最终的编译结果,方便用户态加载。

  1. 加载 BPF程序


// Name of the kernel function to trace.
fn := "sys_execve"

// Allow the current process to lock memory for eBPF resources.
if err := rlimit.RemoveMemlock(); err != nil {
log.Fatal(err)
}

// Load pre-compiled programs and maps into the kernel.
objs := bpfObjects{}
if err := loadBpfObjects(&objs, nil); err != nil {
log.Fatalf("loading objects: %v", err)
}
defer objs.Close()

首先,在内核 5.11 版本前,系统对程序有一些内存方面的限制,所以我们使用rlimit.RemoveMemlock() 对限制进行解除,方便后续操作。 接下来,我们使用了cilium/ebpf 提供的 loadBpfObjects() api,轻松的将编译好的 BPF 程序加载进内存。

  1. 挂载 BPF 程序

  kp, err := link.Kprobe(fn, objs.KprobeExecve, nil)
if err != nil {
log.Fatalf("opening kprobe: %s", err)
}
defer kp.Close()

BPF程序有很多种挂载方法,但是毫无疑问,使用用户态程序进行挂载是最简单的,而且便于管理。由于内核态使用 Kprobes 作为事件源,所以我们这里要通过 link.Kprobe 进行挂载。参数为需要挂载的函数名,以及上一步中加载到的 BPF 程序。

  1. 打印结果

  ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()

log.Println("Waiting for events..")

for range ticker.C {
var value uint64
if err := objs.KprobeMap.Lookup(mapKey, &value); err != nil {
log.Fatalf("reading map: %v", err)
}
log.Printf("%s called %d times\n", fn, value)
}

最终,我们使用定时查询打印的方式,将结果显示在终端上,这里可以看到。无论是用户态还是内核态,对于 map 的访问都是简单而轻松的。这便是 BPF map 的强大之处。

最终成果

编写完成后,我们在根目录使用 make build。编译成功后。我们会在 bin 目录下看到对应的可执行文件,使用 root 权限执行后,可得到类似于如下的结果: 由此,我们的程序编写完成。通过上述示例,我们展现了使用cilium/ebpf库编写BPF程序的过程,从而揭示了在实际开发中如何结合Go语言的便捷性和BPF的高效能力来解决具体问题。

限制

本节将介绍 BPF 程序的诸多限制。

通过前文的描述,我们已经了解了BPF 作为性能监控工具的诸多优点。不过众所周知,性能监测是没有银弹的。尽管BPF作为性能监控工具拥有众多优点,它仍然受到一些限制和挑战。理解这些限制有助于更合理地应用BPF技术,避免在开发和部署过程中遇到预期之外的问题。

1. 学习曲线

BPF的强大功能伴随着较高的学习门槛。开发者需要对Linux内核的工作方式有深入理解,同时也需要掌握BPF的API和工具链。对于新手来说,这可能是一个不小的挑战。

2. 环境和版本依赖

BPF的一些高级特性依赖于较新版本的Linux内核。在老旧系统上,这些特性可能不可用,限制了BPF程序的兼容性和可移植性。在实际生产中,由于客户的系统老旧从而不得不放弃 BPF 实现方式的情况并不少见,所以,BPF 程序普适性的问题需要开发者着重考虑 。不过,随着云原生时代的到来以及 K8S 的普及,这些情况的发生概率会越来越小。

3. 安全和稳定性考虑

虽然BPF经过设计确保其运行安全,不会妨碍系统稳定性,但不当的使用仍有可能导致资源泄露、性能下降甚至系统崩溃。比如,对于一个调用频率极高的内核函数进行插桩,即时 BPF 程序的耗时很短,也可能因为极高的执行频率导致整体性能的下降。因此,开发和部署BPF程序需要谨慎,尤其是在生产环境中。

4. 资源限制

正如之前提到,BPF程序在执行时受到一系列资源限制,如程序大小、栈大小和运行时间等。这些限制保证了BPF程序不会过度消耗系统资源,但同时也限制了程序的复杂度。

5. 调试难度

由于BPF程序运行在内核空间,其调试比用户空间程序更为复杂。虽然社区提供了一些调试工具和技术,但调试BPF程序仍然是一个较为困难的任务,往往需要开发者了解 BPF 汇编指令,从而找出问题的所在位置。

尽管存在上述限制,BPF的优势在于其提供了前所未有的性能监控和系统观测能力。通过合理应用和遵循最佳实践,开发者可以克服这些挑战,充分发挥BPF技术的潜力,为系统性能优化、安全监控和故障诊断等领域带来革命性的改进。

总结

在本文中,我们深入探讨了BPF(Berkeley Packet Filter)的核心概念、架构、以及在性能监控和系统观测领域的应用。通过实战示例,我们展示了如何利用BPF技术和不同的用户态框架,如bpftrace、BCC和cilium/ebpf,来开发强大而灵活的性能监控工具。

我们讨论了BPF的主要优点,包括其高性能的数据采集能力、对系统的低侵入性、以及在现代Linux系统中提供的广泛应用场景。这些优点使得BPF成为了性能分析、网络监控和安全策略实施等领域的有力工具。

同时,我们也没有忽视BPF技术面临的挑战和限制,包括学习曲线、环境和版本依赖、资源限制和调试难度等。尽管如此,通过社区的持续努力和技术的不断进化,BPF生态系统正在变得越来越成熟,为开发者提供了更多的资源和工具,帮助他们克服这些挑战。

最后,BPF技术的探索和应用展现了Linux内核的强大能力和灵活性。随着云计算、微服务架构和容器技术的广泛应用,BPF的重要性日益凸显,它不仅为系统管理员和性能工程师提供了强大的工具,也为开发云原生应用的开发者打开了新的可能性。

总结而言,BPF技术是现代Linux系统的宝贵财富,为性能监控、系统观测和安全增强等领域带来了新的视角和方法。随着技术的不断发展和社区的成长,BPF将继续在系统设计和应用开发中发挥其独特且不可替代的作用。

关于纵目

江苏纵目信息科技有限公司是一家专注于运维监控软件产品研发与销售的高科技企业。覆盖全链路应用性能监控、IT基础设施监控、物联网数据采集数据观测等场景,基于Skywalking、Zabbix、ThingsBoard等开源体系构建了ArgusAPM、ArgusOMS、ZeusIoT等产品,致力于帮助各行业客户构建集聚可观测性的统一运维平台、物联网大数据平台。

  点赞 0   收藏 0
  • yuan
    共发布32篇文章 获得1个收藏
全部评论: 0