BPF可移植性和CO-Re(随处运行一次编译)

2020-08-25 21:05:13

可移植性在BPF上下文中意味着什么?编写开发人员需要处理的可移植BPF程序的挑战是什么?这篇文章将描述BPF的可移植性问题,以及BPF CO-RE(编译一次,随处运行)是如何帮助解决这个问题的。

自从(E)BPF诞生以来,对BPF社区来说,尽可能简化BPF应用程序的开发,使其体验像用户空间应用程序一样简单和熟悉,一直是BPF社区的优先事项。随着围绕BPF可编程性的稳步发展,编写BPF程序变得前所未有的容易。

尽管有这些可用性方面的改进,但是BPF应用程序开发的一个方面被忽略了(主要是由于技术原因):可移植性。不过,bpf可移植性意味着什么呢?我们将BPF可移植性定义为编写BPF程序的能力,该程序将成功编译并通过内核验证,并且将在不同的内核版本之间正确工作,而无需为每个特定内核重新编译它。

本文描述了BPF可移植性问题和我们的解决方案:BPF CO-RE(只编译一次,随处运行)。首先,我们将看看BPF可移植性问题本身,描述为什么它是一个问题,以及为什么解决它很重要。然后,我们将概述我们的解决方案BPF CO-RE的高级组件,并简要介绍为实现该解决方案所需拼凑的各个部分。我们将以某种形式的教程结束,描述BPF CO-RE方法的用户可见API,并用示例演示其应用。

BPF程序是一段用户提供的代码,它直接注入到内核中。加载并验证后,BPF程序将在内核上下文中执行。这些程序在内核内存空间内运行,可以访问它可用的所有内部内核状态。这是非常强大的,也是BPF技术成功应用于如此多不同应用的原因之一。然而,这种强大的功能也带来了我们今天的BPF可移植性问题:BPF程序不控制周围内核环境的内存布局。他们必须使用从独立开发、编译和部署的内核中获得的内容。

此外,内核类型和数据结构也在不断变化。不同的内核版本将使struct字段在结构中随意移动,甚至移到新的内部结构中。字段可以重命名或删除,其类型可以更改为一些微不足道的兼容字段或完全不同的字段。结构和其他类型可以重命名,也可以有条件地编译出来(取决于内核配置),或者在不同的内核版本之间直接删除。

换句话说,在不同的内核版本之间,事情总是在变化,但是BPF应用程序开发人员应该以某种方式来解决这个问题。考虑到这个不断变化的内核环境,今天用BPF怎么可能做任何有用的事情呢?这有几个原因。

首先,并不是所有的BPF程序都需要查看内部内核数据结构。Opensnoop工具就是一个例子,它依赖kprobe/tracepoint来跟踪哪些进程打开了哪些文件,并且只需要捕获几个syscall参数就可以工作。由于syscall参数提供了稳定的ABI,因此这些参数在不同内核版本之间不会改变,因此可移植性从一开始就不是问题。不幸的是,这样的应用程序非常少见。这些类型的应用程序通常在功能上也相当有限。

因此,另外,内核中的BPF机制提供了一组有限的稳定接口,BPF程序可以依赖这些接口在内核之间保持稳定。实际上,底层结构和机制确实会发生变化,但这些由BPF提供的稳定接口将这些细节从用户程序中抽象出来。

举个例子,对于网络应用程序来说,通常只需查看SK_BUFF的一组有限的属性(当然还有分组数据)就可以非常有用和通用。为此,BPF验证器提供了一个稳定的__SK_BUFF";视图(注意前面有下划线),它保护BPF程序不会更改struct SK_BUFF布局。所有的__sk_buff字段访问都被透明地重写为实际的sk_buff访问(有时非常复杂-在最终获取请求的字段之前执行一系列内部指针跟踪)。许多不同的BPF程序类型都有类似的机制可用。它们作为BPF验证器理解的特定于程序类型的BPF上下文来完成。因此,如果您正在开发具有这样上下文的BPF程序,请认为您是幸运的,您可以幸福地生活在稳定的美好幻觉中。

但是,一旦您需要了解任何原始的内部内核数据(例如,非常常见的struct task_struct,它表示进程/线程并包含进程信息的宝库),您就只能靠自己了。跟踪、监视和分析应用程序通常都是这种情况,它们是一大类非常有用的BPF程序。

在这种情况下,当某个内核在您认为的字段之前添加了一个额外的字段(比方说,位于struct task_struct开头的偏移量16处)时,如何确保您没有读取垃圾数据呢?突然之间,对于该内核,您将需要从例如偏移量24读取数据。而且问题还不止于此:如果字段被重命名了怎么办,就像threadstruct的fs字段(用于访问线程本地存储)一样,它在4.6到4.7内核之间被重命名为fsbase会怎样呢?(=。或者,如果您必须在内核的两种不同配置上运行,其中一种配置禁用了某些特定功能并完全编译出结构的某些部分(这是附加记帐字段的常见情况,这些字段是可选的,但如果存在则非常有用),该怎么办呢?所有这些都意味着,您不能再使用开发服务器的内核标头在本地编译BPF程序,并将其以编译后的形式分发到其他系统,同时期望它能够工作并产生正确的结果。这是因为不同内核版本的内核头将为您的程序所依赖的数据指定不同的内存布局。

到目前为止,人们一直依靠BCC(BPF Compiler Collection,BPF编译器集合)来解决这一问题。使用BCC,您可以将BPF程序C源代码作为纯字符串嵌入到用户空间程序(控制应用程序)中。当控制应用程序最终在目标主机上部署和执行时,BCC会调用其嵌入式Clang/LLVM,拉入本地内核头(您必须确保从正确的kernel-devel包将其安装在系统上),并动态执行编译。这将确保BPF程序期望的内存布局与目标主机正在运行的内核中的内存布局完全相同。如果您必须在内核中处理一些可选的和可能编译出来的内容,您只需在源代码中执行#ifdef/#Else保护,以适应诸如重命名字段、不同的值语义或当前配置中不可用的任何可选内容之类的危险。Embedded Clang将愉快地删除代码中不相关的部分,并针对特定内核定制BPF程序代码。

这听起来不错,不是吗?不幸的是,情况并非如此。虽然这个工作流程有效,但它也不是没有重大缺陷。

Clang/LLVM组合是一个很大的库,导致需要随应用程序一起分发的大型胖二进制文件。

Clang/LLVM组合占用大量资源,因此当您在启动时编译BPF代码时,您将使用大量资源,这可能会破坏仔细平衡的生产工作流程。反之亦然,在繁忙的主机上,编译一个小的BPF程序在某些情况下可能需要几分钟的时间。

您下了很大的赌注,认为目标系统将提供内核标头,这在大多数情况下不是问题,但有时可能会导致很多令人头疼的问题。对于内核开发人员来说,这也是一个特别恼人的要求,因为作为开发过程的一部分,他们经常必须构建和部署定制的一次性内核。如果没有定制的内核头包,任何基于BCC的应用程序都不能在这样的内核上工作,从而剥夺了开发人员调试和监控的一套有用的工具。

BPF程序测试和开发迭代也非常痛苦,因为一旦重新编译并重新启动用户空间控制应用程序,您甚至只会在运行时出现最微不足道的编译错误。这肯定会增加摩擦,无助于快速迭代。

总体而言,虽然BCC是一个很棒的工具,特别是对于快速原型、实验和小型工具,但当它用于广泛部署的生产BPF应用程序时,它肯定有很多缺点。

我们正在与BPF CO-RE加强BPF可移植性的游戏,并相信这是BPF程序开发的未来,特别是对于复杂的现实世界BPF应用程序。

BPF CO-RE将所有级别的软件堆栈(内核、用户空间BPF加载器库(Libbpf)和编译器(Clang))中的必要功能和数据集中在一起,以便于以可移植的方式编写BPF程序,从而处理同一预编译的BPF程序中不同内核之间的差异。BPF CO-RE需要仔细集成和协作以下组件:

BTF类型信息,它允许捕获关于内核和BPF程序类型和代码的关键信息,从而启用BPF CO-RE难题的所有其他部分;

编译器(Clang)为BPF程序C代码提供了表达意图和记录重定位信息的手段;

BPF加载器(Libbpf)将来自内核的BTF和BPF程序绑定在一起,将编译后的BPF代码调整到目标主机上的特定内核;

内核在保持完全与BPF CO-RE无关的同时,提供了高级BPF特性来支持一些更高级的场景。

这些组件协同工作,使得开发可移植的BPF程序具有前所未有的能力,具有简单性、适应性和表现力,以前只能通过BCC在运行时编译BPF程序的C代码来实现,但不需要付出BCC方式的高昂代价。

整个BPF CO-RE方法的关键推动因素之一是BTF。创建BTF(BPF类型格式)是为了替代更通用、更详细的侏儒调试信息。BTF是一种节省空间、紧凑但仍具有足够表现力的格式,可以描述C程序的所有类型信息。由于其简单性和BTF重复数据删除算法,与DWARF相比,BTF最多可实现100倍的尺寸缩减。现在,让Linux内核在运行时始终显示嵌入的BTF类型信息是可行的:只需使用CONFIG_DEBUG_INFO_BTF=y选项构建内核即可。内核的BTF可用于内核本身,并且现在用于增强BPF验证器自身的功能,超出我们一年前的想象(例如,不使用bpf_probe_read()直接读取内核内存现在已经成为一件事)。

对于BPF CO-RE来说,更重要的是,内核还通过/sys/kernel/btf/vmlinux上的sysfs公开了这种自我描述的权威BTF信息(其中包括定义确切的结构布局)。您可以通过以下方式亲自尝试:

您将获得一个包含所有内核类型的可编译C头文件(我们通常将其称为";vmlinux.h&34;)。我们所说的“所有”指的是所有:包括那些从未通过kernel-devel包提供的头公开的内容!

为了启用BPF CO-RE并让BPF装载器(即libbpf)将BPF程序调整到目标主机上运行的特定内核,对Clang进行了少量内置扩展。它们发出BTF重定位,捕捉BPF程序代码要读取的信息片段的高级描述。如果您要访问task_struct->;pid字段,Clang会记录它正是一个驻留在struct task_struct中的类型为“pid_t”的名为";pid";的字段。这样做的目的是,即使目标内核有一个TASK_STRUT布局,其中“PID”字段被移动到TASK_STRUCT结构中的不同偏移量(例如,由于在“PID”字段之前添加了额外的字段),或者即使它被移动到某个嵌套的匿名结构或联合中(这在C代码中是完全透明的,所以从来没有人关注过这样的细节),我们仍然能够仅仅通过它的名称和类型信息来找到它。这称为字段偏移重新定位。

不仅可以捕获(并随后重新定位)字段偏移量,还可以捕获其他字段方面,如字段存在或大小。即使对于位域(众所周知,C语言中的数据是不合作的,抵制使其可重定位的努力),仍有可能捕获足够的信息使其可重定位,所有这些信息对BPF程序开发人员都是透明的。

所有前面的数据片段(内核btf和Clang重定位)都汇集在一起,并由充当BPF程序加载器的libbpf处理。它接受编译后的BPF ELF目标文件,根据需要对其进行后处理,设置各种内核对象(映射、程序等),并触发BPF程序加载和验证。

Libbpf知道如何根据主机上特定的运行内核定制BPF程序代码。它查看BPF程序记录的BTF类型和重定位信息,并将它们与运行内核提供的BTF信息进行匹配。Libbpf解析并匹配所有类型和字段,根据需要更新必要的偏移量和其他可重定位的数据,以确保BPF程序的逻辑针对主机上的特定内核正确运行。如果一切正常,您(BPF应用程序开发人员)将获得一个针对目标主机上的内核定制的BPF程序,就像您的程序是专门为它编译的一样。但是,所有这些都是在不支付随应用程序分发Clang和在目标主机上运行时执行编译的开销的情况下实现的。

令人惊讶的是,内核不需要太多修改就可以支持BPF CO-RE。由于很好地分离了关注点,在libbpf处理了BPF程序代码之后,对于内核来说,它看起来像任何其他有效的BPF程序代码。它与在主机上使用最新内核标头编译的BPF程序没有什么区别。这意味着BPF CO-RE的许多功能不需要尖端内核功能,因此可以更广泛和更快地适应。

将会有一些高级场景可能需要较新的内核,但这些应该是很少见的。我们将在下一部分解释BPF CO-RE的面向用户的机制时讨论这些场景,详细介绍BPF CO-RE的面向用户的API。

我们将经历现实世界中的BPF应用程序必须处理的典型场景,看看如何使用BPF CO-RE解决这些问题。正如您将在下面看到的,一些可移植性问题(例如,兼容的结构布局差异)被相当透明和自然地处理,而其他问题则被更显式地处理,例如,通过if/Else条件(与BCC程序中的编译时#ifdef/#Else构造相反)和BPF CO-RE提供的额外机制。

除了使用内核的BTF信息进行字段重新定位之外,还可以使用它生成包含所有内部内核类型的大标头(";vmlinux.h";),并完全避免对系统范围内核头的依赖。可以通过bpftool获取:

有了vmlinux.h,就不需要所有通常在BPF程序中使用的#include<;linux/Sched.h>;、#include<;linux/fs.h>;等。您现在只需#include";vmlinux.h";就可以忘记kernel-devel包了。此标头包含所有内核类型:作为UAPI的一部分公开的内核类型、可通过kernel-devel获得的内部类型,以及在其他地方不可用的更多内部内核类型。

不幸的是,BTF(以及DWARF)不记录#DEFINE宏,因此vmlinux.h中可能缺少一些常用宏。最常见的缺失可能是作为libbpf的bpf_helpers.h(内核侧库,由libbpf提供)的一部分提供的。

最常见、最典型的情况是从许多内核结构中读取一个字段。假设我们想要读取task_struct的PID。使用密件抄送可以轻松简单地实现这一点:

Bcc将方便地将task->;pid重写为对bpf_probe_read()的调用,这很棒(尽管有时可能不起作用,这取决于使用的表达式的复杂程度)。使用libbpf,因为它不具备BCC的代码重写魔力,所以有几种方法可以实现相同的结果。

如果您使用的是最近添加的BTF_PROG_TYPE_TRACKING BPF程序,那么您这边就有了BPF验证器的智能,它现在可以本机理解和跟踪BTF类型,并允许您直接(安全地)跟随指针和读取内核内存,从而避免调用BPF_PROG_READ(),因此您不需要编译器重写魔术就可以获得同样好的、熟悉的语法。

将此功能与BPF CO-RE配合使用以支持可移植(即可重定位)字段读取,您必须将此代码包含在内置的__builtin_preserve_access_index编译器中:

就这样。它可以像您预期的那样工作,但是可以在不同的内核版本之间移植。但是考虑到BPF_PROG_TYPE_TRACKING的前沿性,您可能还没有奢侈地使用它,所以您必须显式地使用BPF_PROBE_READ()。

现在,使用CO-RE+libbpf,我们有两种方法可以做到这一点。其一,将bpf_probe_read()直接替换为bpf_core_read():

Bpf_core_read()是一个简单的宏,它将所有参数直接传递给bpf_probe_read(),但它还通过传递__builtin_preserve_access_index()来为第三个参数(&;task->;pid)重新定位Clang记录字段偏移量。所以最后一个例子实际上就是这个,在引擎盖下面:

唉,这些bpf_probe_read()/bpf_core_read()调用可能很快就过时了,特别是当您处理一堆通过指针链接在一起的结构时。例如,要获取当前进程的可执行二进制文件的inode编号,您必须使用bcc执行如下操作:

使用普通的bpf_probe_read()/bpf_core_read(),这将变成4个调用,带有一个额外的临时变量来存储所有这些中间指针的值,以便我们最终到达i_ino字段。幸运的是,有了BPF CO-RE,我们有了一个帮助器宏,它将允许我们获得几乎类似于BCC的可用性,而且根本不需要代码重写魔术:

或者,如果您已经有一个要读入的变量,则可以执行以下操作并避免额外的中间变量(隐藏在bpf_core_read中):

有一个对应的bpf_core_read_str(),它替代了bpf_probe_read_str()。还有一个BPF_CORE_READ_STR_INTO()宏,其工作方式与BPF_CORE_READ_INTO()类似,但将对最后一个字段执行BPF_PROBE_READ_STR()调用。

还可以使用适当命名的bpf_core_field_Existes()宏来检查目标内核中是否存在字段,并根据字段是否存在执行不同的操作:

此外,在不能保证正在使用的数据大小在不同内核版本之间不变的情况下,可以使用BPF_CORE_FIELD_SIZE()宏捕获任何字段的大小:

最重要的是,对于必须从内核结构中读取位字段的极少数情况(但跨内核支持非常痛苦),有特殊的BPF_CORE_READ_BITFIELD()(使用直接内存读取)和BPF_CORE_READ_BITFIELD_PROBED()(依赖于BPF_PROBE_READ()调用)。它们抽象出提取位字段的其他血腥而痛苦的细节,同时保留内核版本之间的可移植性:

Struct*=...;/*使用直接读取*/bool is_cwnd_Limited=BPF_CORE_READ_BITFIELD(s,IS_cwnd_LIMITED);/*使用BPF_PROBE_READ()-BASE。

.