EBPF-学习指南-全-
EBPF 学习指南(全)
原文:
zh.annas-archive.org/md5/38693e4ee9bbb7fe42a6b9672eb38674译者:飞龙
前言
在云原生社区及更广泛的领域中,eBPF 已成为近年来最热门的技术话题之一。使用 eBPF 作为平台,网络、安全、可观察性等领域中的一代强大工具和项目已经建立(并继续建立),相较于它们的前身,提供了更好的性能和准确性。eBPF 相关的会议如eBPF 峰会和云原生 eBPF 日吸引了数千名与会者和观众,在撰写本文时,eBPF Slack社区已拥有超过 14,000 名成员。
为什么选择 eBPF 作为许多基础设施工具的基础技术?它如何提升性能?eBPF 如何在如此广泛的技术领域中发挥作用,从性能跟踪到网络流量加密?
本书旨在通过让读者了解 eBPF 的工作原理,并介绍如何编写 eBPF 代码,来回答这些问题。
读者对象
本书适合开发人员、系统管理员、运维人员和对 eBPF 感兴趣并想了解其工作原理的学生。对于那些希望自己探索编写 eBPF 程序的人来说,本书将为他们提供一个基础。由于 eBPF 为新一代仪器和工具提供了一个良好的平台,未来几年内 eBPF 开发人员可能会有可观的就业机会。
但是,并不一定要计划自己编写 eBPF 代码才能从本书中获益。如果你从事运维、安全或任何涉及软件基础设施的角色,你可能会在现在或未来几年内接触到基于 eBPF 的工具。如果你了解这些工具的内部机制,你将更有能力有效地使用它们。例如,如果你知道事件如何触发 eBPF 程序,那么当 eBPF 基础工具展示性能指标时,你将对它们的真正测量有更清晰的心理模型。如果你是应用程序开发人员,你也可能会接触到一些基于 eBPF 的工具——例如,如果你在性能调优应用程序时,可以使用像Parca这样的工具生成展示哪些函数占用大量时间的火焰图。如果你正在评估安全工具,本书将帮助你了解 eBPF 的优势,并避免以不够有效抵御攻击的天真方式使用它。
即使今天你没有使用 eBPF 工具,我希望这本书能为你提供一些有趣的见解,让你对 Linux 的一些领域有所了解,这些领域可能在之前并没有考虑过。大多数开发者习惯于使用编程语言的高级抽象,这些抽象使他们可以专注于应用程序开发的工作——这本身已经足够难了!他们使用调试器和性能分析器等工具来帮助他们有效地完成工作。了解调试器或性能工具的内部工作原理可能很有趣,但并非必需。然而,对于我们许多人来说,深入探索并了解更多是一种乐趣和满足感。^(1) 同样,大多数人将使用 eBPF 工具,而不必担心它们是如何构建的。阿瑟·C·克拉克曾写道:“任何足够先进的技术都是不可区分于魔术的”,但就我个人而言,我喜欢深入挖掘并找出这种魔术如何运作。也许你和我一样,感觉有必要探索 eBPF 编程,以更好地了解这项技术的潜力。如果是这样,我相信你会喜欢这本书。
这本书的内容涵盖了什么。
eBPF 仍在快速发展,这使得撰写一本不需要经常更新的全面参考书变得相当困难。然而,有一些基本原理和基础原则不太可能发生显著变化,这正是本书讨论的内容。
第一章通过描述 eBPF 技术的强大之处,并解释在操作系统内核中运行自定义程序的能力如何带来如此多令人兴奋的功能,为书籍开篇。
在第二章中,事情变得更加具体,你将看到一些“Hello World”示例,介绍了 eBPF 程序和映射的概念。
第三章深入探讨了 eBPF 程序的详细内容及其在内核中的运行方式,而第四章则探讨了用户空间应用程序与 eBPF 程序之间的接口。
近年来 eBPF 的一大挑战之一是跨内核版本的兼容性问题。第五章讨论了“编译一次,到处运行”(CO-RE)方法,解决了这个问题。
验证过程可能是区分 eBPF 与内核模块的最重要特征。我将在第六章介绍 eBPF 验证器。
在第七章中,您将了解到多种不同类型的 eBPF 程序及其附着点。其中许多附着点位于网络堆栈内部,第八章更详细地探讨了 eBPF 在网络功能中的应用。第九章则探讨了 eBPF 用于构建安全工具的应用。
如果您希望编写一个与 eBPF 程序交互的用户空间应用程序,有许多可用的库和框架可以帮助您。第十章概述了各种编程语言的选项。
最后,在第十一章中,我将展望未来发展,介绍 eBPF 领域可能会出现的一些新进展。
先决知识
本书假设您已经熟悉在 Linux 上使用基本的 shell 命令,并且了解使用编译器将源代码转换成可执行程序的概念。书中提供了一些简单的 Makefile 示例片段,假定您至少对make如何使用这些文件有基本的理解。
本书中有很多 Python、C 和 Go 的代码示例。您不需要对这些语言有深入的了解才能从这些示例中获益,但如果您乐意阅读一些代码,将能更好地理解本书内容。我还假设您熟悉指针的概念,它们用于标识内存位置。
示例代码和练习
本书中有许多代码示例。如果您希望自己尝试一下,您可以在伴随的 GitHub 仓库找到相关代码及其安装和运行说明https://github.com/lizrice/learning-ebpf。
我还在大多数章节的末尾包含了练习,以帮助您通过扩展示例或编写自己的程序来探索 eBPF 编程。
由于 eBPF 技术不断发展,您可以使用的功能取决于您所运行的内核版本。许多早期版本中适用的限制在后来的版本中已经解除或放宽。Iovisor 项目详细介绍了不同 BPF 功能添加在哪些内核版本中,在本书中,我尽力记录了描述的特定功能是在哪个版本中添加的。示例代码是在内核版本 5.15 上测试的,截至本文撰写时,一些流行的 Linux 发行版尚不支持如此新的内核版本。如果您在本书出版后不久阅读,可能会发现某些功能在贵组织生产环境使用的 Linux 内核上无法运行。
eBPF 只适用于 Linux 吗?
eBPF 最初是为 Linux 开发的。同样的方法也可以用于其他操作系统——事实上,微软已经在为 Windows 开发eBPF 实现。我在第十一章中简要讨论了这一点,但在本书的其余部分中,我将专注于 Linux 的实现,并且所有示例都来自 Linux。
本书中使用的约定
本书使用以下印刷约定:
Italic
指示新术语,URL,电子邮件地址,文件名和文件扩展名。
Constant width
用于程序清单,以及段落内引用程序元素,如变量或函数名,数据库,数据类型,环境变量,语句和关键字。
Constant width bold
显示用户应直接输入的命令或其他文本。
Constant width italic
显示应用程序中应用用户提供的值或由上下文确定的值的文本。
提示
此元素表示提示或建议。
注意
此元素表示一般说明。
警告
此元素表示警告或注意事项。
使用代码示例
补充材料(代码示例,练习等)可在https://github.com/lizrice/learning-ebpf下载。
如果您有技术问题或在使用代码示例时遇到问题,请发送电子邮件至bookquestions@oreilly.com。
本书旨在帮助您完成工作。一般来说,如果本书提供了示例代码,您可以在您的程序和文档中使用它。除非您复制了代码的大部分内容,否则无需联系我们请求许可。例如,编写一个使用本书中多个代码片段的程序不需要许可。销售或分发 O’Reilly 书籍中的示例代码则需要许可。通过引用本书回答问题并引用示例代码不需要许可。将本书中大量示例代码整合到您产品的文档中需要许可。
我们感谢您的支持,但通常不需要署名。署名通常包括标题,作者,出版商和 ISBN。例如:“Learning eBPF by Liz Rice (O’Reilly). Copyright 2023 Vertical Shift Ltd., 978-1-098-13512-6.”
如果您觉得您使用的代码示例超出了公平使用范围或上述授权,请随时通过permissions@oreilly.com与我们联系。
O’Reilly 在线学习
注意
超过 40 年来,O’Reilly Media为企业的成功提供技术和商业培训,知识和见解。
我们独特的专家和创新者网络通过书籍、文章和我们的在线学习平台分享他们的知识和专长。O’Reilly 的在线学习平台为您提供按需访问的现场培训课程、深入学习路径、交互式编码环境以及来自 O’Reilly 和其他 200 多家出版商的广泛文本和视频资源。更多信息,请访问https://oreilly.com。
如何联系我们
请将对本书的评论和问题发送至出版商:
-
O’Reilly Media, Inc.
-
1005 Gravenstein Highway North
-
Sebastopol, CA 95472
-
800-998-9938(美国或加拿大)
-
707-829-0515(国际或本地)
-
707-829-0104(传真)
我们有一个专门为本书服务的网页,列出勘误、示例和任何额外信息。您可以访问此页面:https://oreil.ly/learning-eBPF。
电子邮件bookquestions@oreilly.com,用于对本书提出评论或技术问题。
关于我们的书籍和课程的新闻和信息,请访问https://oreilly.com。
在 LinkedIn 上找到我们:https://linkedin.com/company/oreilly-media。
在 Twitter 上关注我们:https://twitter.com/oreillymedia。
在 YouTube 上关注我们:https://youtube.com/oreillymedia。
致谢
我要感谢为这本书的撰写做出巨大贡献的众多人士:
-
我的技术审阅人员——Timo Beckers、Jess Males、Quentin Monnet、Kevin Sheldrake 和 Celeste Stinger——提供了详细的、可操作的反馈和改进示例的伟大想法,对此我深表感激。
-
我站在构建、推广和维护 eBPF 的巨匠们的肩膀上,包括 Daniel Borkmann、Thomas Graf、Brendan Gregg、Andrii Nakryiko、Alexei Starovoitov 等无数贡献者,他们不仅贡献了代码,还通过会议演讲和博客文章为社区做出了贡献。
-
感谢我的才华横溢且可爱的 Isovalent 同事们,其中许多人是 eBPF 和内核专家,我从他们身上继续学到很多。
-
同时感谢 O’Reilly 团队,特别是我的编辑 Rita Fernando,在写作过程中给予我无限的支持,并通过帮助计划保持书籍进度;以及 John Devins,他首先鼓励我写这本书。
-
Phil Pearl 不仅在内容上提供了有益的反馈,还确保我吃饱喝足。对于他的支持和鼓励,我将永远感激不尽。
多年来,我也想感谢所有那些花时间对我的工作做出鼓励性评论的美好人士,无论是在活动现场还是在社交媒体上。知道自己所写或录制的内容能帮助他人理解技术概念,或者激发他们想要创作或写作的愿望,这种感觉真是非常鼓舞人心。谢谢!
^(1) 在 2017 年的巴黎 dotGo 大会上,我做了一个演讲,展示了调试器的工作原理。
第一章:什么是 eBPF,为什么它重要?
eBPF 是一项革命性的内核技术,允许开发人员编写可以动态加载到内核中的自定义代码,改变内核行为的方式。(如果你对内核不是很自信,不用担心,我们稍后在本章将详细介绍。)
这使得新一代高性能的网络、可观察性和安全工具成为可能。正如你将看到的那样,如果你想用这些基于 eBPF 的工具来仪表化一个应用程序,你无需修改或重新配置该应用程序,这要归功于 eBPF 在内核中的优势位置。
你可以用 eBPF 做的一些事情包括:
-
几乎可以对系统的任何方面进行性能跟踪
-
高性能网络,具有内置的可见性
-
检测和(可选地)防止恶意活动
让我们简要回顾一下 eBPF 的历史,从伯克利数据包过滤器开始。
eBPF 的起源:伯克利数据包过滤器
我们今天称之为“eBPF”的东西,其根源于 1993 年由劳伦斯伯克利国家实验室的 Steven McCanne 和 Van Jacobson 编写的一篇论文^(1),该论文讨论了一个伪机器,可以运行 过滤器,这些程序编写用于确定是否接受或拒绝网络数据包。这些程序是用 BPF 指令集编写的,这是一组通用的 32 位指令,与汇编语言非常接近。以下是直接摘自该论文的一个例子:
ldh [12]
jeq #ETHERTYPE IP, L1, L2
L1: ret #TRUE
L2: ret #0
这段小小的代码过滤掉不是 Internet 协议(IP)数据包的数据。这个过滤器的输入是一个以太网数据包,第一条指令(ldh)加载从数据包的第 12 字节开始的一个 2 字节的值。在下一条指令(jeq)中,将这个值与表示 IP 数据包的值进行比较。如果匹配,执行跳转到标记为 L1 的指令,并且通过返回一个非零值(这里被标识为 #TRUE)接受数据包。如果不匹配,则数据包不是 IP 数据包,通过返回 0 被拒绝。
你可以想象(或者确实参考论文找到例子),更复杂的过滤程序,这些程序基于数据包的其他方面做出决策。重要的是,过滤器的作者可以编写自己的自定义程序在内核中执行,这正是 eBPF 能实现的核心。
BPF 的含义被改为“伯克利数据包过滤器”,最早在 1997 年被引入 Linux 内核版本 2.1.75 中,^(2) 它被用作 tcpdump 实用程序中高效捕获要跟踪的数据包的方式。
快进到 2012 年,seccomp-bpf 在内核版本 3.5 中引入。这使得可以使用 BPF 程序来决定是否允许或拒绝用户空间应用程序进行系统调用。我们将在第十章中详细探讨这一点。这是将 BPF 从仅限于数据包过滤演变为如今通用平台的第一步。从这一点开始,名称中的“数据包过滤器”一词开始变得不那么合理了!
从 BPF 到 eBPF
从 2014 年的内核版本 3.18 开始,BPF 演变为我们所称的“扩展 BPF”或“eBPF”。这涉及了几个重大改变:
-
BPF 指令集在 64 位机器上进行了彻底改进,使其更高效,并且解释器也进行了完全重写。
-
引入了 eBPF 映射,这是可以由 BPF 程序和用户空间应用程序访问的数据结构,允许它们之间共享信息。您将在第二章中了解映射。
-
添加了
bpf()系统调用,以便用户空间程序可以与内核中的 eBPF 程序进行交互。您将在第四章中了解此系统调用。 -
添加了几个 BPF 辅助函数。您将在第二章看到一些示例,并在第六章中看到更多细节。
-
添加了 eBPF 验证器,以确保 eBPF 程序的安全运行。这在第六章中讨论。
这为 eBPF 奠定了基础,但开发并未放缓!自那时以来,eBPF 已经显著发展。
eBPF 向生产系统的演变
自 2005 年以来,Linux 内核中存在名为kprobes(内核探测点)的特性,允许在内核代码中的几乎任何指令上设置陷阱。开发人员可以编写内核模块,将函数附加到 kprobes 以进行调试或性能测量目的。^(3)
2015 年添加了将 eBPF 程序附加到 kprobes 的能力,这是在 Linux 系统中进行跟踪革命的起点。同时,开始在内核的网络堆栈中添加钩子,允许 eBPF 程序处理更多的网络功能。我们将在第八章中看到更多内容。
到 2016 年,基于 eBPF 的工具已经开始在生产系统中使用。Brendan Gregg 在 Netflix 上关于跟踪的工作变得广为人知,正如他在基础设施和运维领域所说,eBPF“为 Linux 带来了超能力”。同年,宣布了 Cilium 项目,这是第一个在容器环境中使用 eBPF 来替换整个数据路径的网络项目。
2023 年,Facebook(现在的 Meta)将Katran开源。Katran 作为第四层负载均衡器,满足了 Facebook 对高可扩展性和快速解决方案的需求。自 2017 年以来,每个发送到Facebook.com的数据包都经过 eBPF/XDP 的处理。^(4)对我个人而言,这一年点燃了我对这项技术可能性的激情,特别是在德克萨斯州奥斯汀市 DockerCon 上看到Thomas Graf 的演讲,讲述了 eBPF 和Cilium 项目。
2018 年,eBPF 成为 Linux 内核中的一个独立子系统,由 Isovalent 的Daniel Borkmann和 Meta 的Alexei Starovoitov负责维护(后来还有来自 Meta 的Andrii Nakryiko加入)。同年还引入了 BPF 类型格式(BTF),极大地提升了 eBPF 程序的可移植性。我们将在第五章中探讨这一点。
2020 年,引入了 LSM BPF,允许将 eBPF 程序附加到 Linux 安全模块(LSM)内核接口上。这表明 eBPF 已经确定了第三个重要用例:除了网络和可观测性外,它还成为了一个出色的安全工具平台。
多年来,由于 300 多名内核开发人员的工作以及许多贡献者对关联用户空间工具(如我们将在第三章中介绍的bpftool)的贡献,eBPF 的能力显著增强。程序曾经限制在 4096 条指令,但现在已经增长到 100 万条经过验证的指令^(5),并且通过对尾调用和函数调用的支持,这一限制实际上已经变得不再重要(这些内容将在第二章和 3 章中介绍)。
注意
想要深入了解 eBPF 的历史,最好去咨询那些从一开始就在这项技术上工作的维护者们。
Alexei Starovoitov 对BPF 的历史进行了一次引人入胜的演讲,从软件定义网络(SDN)的根源开始。在这次演讲中,他讨论了早期 eBPF 补丁被接受进内核的策略,并透露了 eBPF 的官方生日是 2014 年 9 月 26 日,标志着第一批涵盖验证器、BPF 系统调用和映射的补丁被接受。
Daniel Borkmann 还讨论了 BPF 的历史以及其演变支持网络和跟踪功能。我强烈推荐他的演讲“eBPF 和 Kubernetes:扩展微服务的小助手”,其中充满了有趣的信息片段。
命名是困难的。
eBPF 的应用远远超出了数据包过滤的范围,以至于这个缩写现在基本上没有意义,它已经成为一个独立的术语。由于目前广泛使用的 Linux 内核都支持“扩展”部分,因此 eBPF 和 BPF 这两个术语基本上可以互换使用。在内核源代码和 eBPF 编程中,通用术语是 BPF。例如,正如我们将在 第四章 中看到的那样,与 eBPF 交互的系统调用是 bpf(),辅助函数以 bpf_ 开头,而不同类型的 (e)BPF 程序则以 BPF_PROG_TYPE 开头命名。在内核社区之外,“eBPF” 这个名字似乎已经被接受,例如在社区网站 ebpf.io 和 eBPF Foundation 的名称中。
Linux 内核
要理解 eBPF,你需要对 Linux 中内核和用户空间的区别有深入的了解。我在我的报告 “What Is eBPF?”^(6) 中已经涵盖了这一点,并且我已经为接下来的几段内容做了一些调整。
Linux 内核是应用程序和它们运行在的硬件之间的软件层。应用程序运行在一个称为 用户空间 的非特权层中,不能直接访问硬件。相反,应用程序使用系统调用(syscall)接口向内核请求代表其执行操作。硬件访问可以涉及读写文件、发送或接收网络流量,甚至只是访问内存。内核还负责协调并发进程,使许多应用程序可以同时运行。这在 图 1-1 中有所体现。
作为应用程序开发者,我们通常不直接使用系统调用接口,因为编程语言提供了高级抽象和标准库,这些更容易编程的接口。因此,很多人对内核在我们程序运行时所做的工作一无所知。如果你想了解内核被调用的频率,可以使用 strace 实用程序显示应用程序进行的所有系统调用。

图 1-1. 用户空间中的应用程序使用系统调用接口向内核发出请求。
这里有一个例子,使用 cat 将单词 hello 回显到屏幕上涉及超过 100 次系统调用:
$ strace -c echo "hello"
hello
% time seconds usecs/call calls errors syscall
------ ----------- ----------- --------- --------- ----------------
24.62 0.001693 56 30 12 openat
17.49 0.001203 60 20 mmap
15.92 0.001095 57 19 newfstatat
15.66 0.001077 53 20 close
10.35 0.000712 712 1 execve
3.04 0.000209 52 4 mprotect
2.52 0.000173 57 3 read
2.33 0.000160 53 3 brk
2.09 0.000144 48 3 munmap
1.11 0.000076 76 1 write
0.96 0.000066 66 1 1 faccessat
0.76 0.000052 52 1 getrandom
0.68 0.000047 47 1 rseq
0.65 0.000045 45 1 set_robust_list
0.63 0.000043 43 1 prlimit64
0.61 0.000042 42 1 set_tid_address
0.58 0.000040 40 1 futex
------ ----------- ----------- --------- --------- ----------------
100.00 0.006877 61 111 13 total
因为应用程序在很大程度上依赖于内核,这意味着如果我们能观察应用程序与内核的交互,我们就可以了解应用程序的行为方式。使用 eBPF,我们可以在内核中添加仪表,以获得这些见解。
例如,如果你能拦截打开文件的系统调用,你可以精确地看到任何应用程序访问的文件。但是你如何进行这种拦截?让我们考虑一下,如果我们想要修改内核,添加新代码以在调用该系统调用时创建某种输出,会涉及到什么。
向内核添加新功能
Linux 内核在撰写本文时非常复杂,大约有 3000 万行代码。^(7) 对任何代码库进行更改都需要对现有代码有一定的了解,因此,除非你已经是一个内核开发人员,否则这可能会是一个挑战。
此外,如果你想要向上游贡献你的变更,你将面临一个不仅仅是技术上的挑战。Linux 是一个通用操作系统,在各种环境和情况下使用。这意味着,如果你希望你的变更成为官方 Linux 发布的一部分,这不仅仅是编写有效代码的问题。代码必须被社区接受(更具体地说是被 Linux 的创始人和主要开发者 Linus Torvalds 接受),作为对所有人都有利的变更。这并非是理所当然的事情——只有三分之一的提交的内核补丁被接受。^(8)
假设你已经想出了一个良好的技术方法来拦截打开文件的系统调用。经过几个月的讨论和你的一些艰苦开发工作后,想象一下这个变更被内核接受了。太棒了!但是它会在所有人的机器上到达之前需要多长时间呢?
Linux 内核每两三个月就会有一个新版本发布,但即使某个变更已经包含在这些版本中,它仍需一段时间才能在大多数人的生产环境中可用。这是因为我们大多数人不直接使用 Linux 内核,而是使用像 Debian、Red Hat、Alpine 和 Ubuntu 这样的 Linux 发行版,这些发行版将 Linux 内核与各种其他组件打包在一起。你很可能会发现你喜爱的发行版使用的内核版本已经几年前的了。
例如,很多企业用户采用 Red Hat Enterprise Linux(RHEL)。截至撰写本文时,当前版本为 RHEL 8.5,发布日期为 2021 年 11 月,使用的 Linux 内核版本为 4.18。该内核发布于 2018 年 8 月。
正如在第 Figure 1-2 中的漫画所示,将新功能从想法阶段引入生产环境的 Linux 内核需要几乎数年时间。^(9)

图 1-2. 向内核添加功能(由 Isovalent 的 Vadim Shchekoldin 绘制的漫画)
内核模块
如果你不想等待数年才能让你的改动进入内核,还有另一种选择。Linux 内核设计成可以接受内核模块,这些模块可以按需加载和卸载。如果你想改变或扩展内核行为,编写一个模块当然是一种方法。内核模块可以分发给其他人使用,独立于官方 Linux 内核版本,因此不需要被合并到主要上游代码库中。
最大的挑战在于这仍然是完整的内核编程。用户在使用内核模块时历来非常谨慎,一个简单的原因是:如果内核代码崩溃,将导致整个机器和运行在上面的所有东西都崩溃。用户如何确信一个内核模块是安全可运行的?
“安全运行”不仅意味着不崩溃—用户还想知道一个内核模块从安全性角度是否安全。它是否包含攻击者可能利用的漏洞?我们是否信任模块的作者没有在其中放入恶意代码?因为内核是特权代码,它可以访问机器上的一切,包括所有数据,因此内核中的恶意代码将是一个严重的问题。这也适用于内核模块。
内核安全性是 Linux 发行版需要很长时间才能整合新版本的一个重要原因。如果其他人在各种情况下运行了一个内核版本数月甚至数年,这应该能够找出其中的问题。发行版维护者可以相信他们向用户/客户发布的内核已经经过强化—即,可以安全运行。
eBPF 提供了一种非常不同的安全方式:eBPF 验证器,它确保只有在安全的情况下才加载 eBPF 程序—这不会使机器崩溃或者陷入死循环,并且不会允许数据被泄漏。我们将在第六章中详细讨论验证过程。
eBPF 程序的动态加载
eBPF 程序可以动态加载到内核中,并且可以随时卸载。一旦它们附加到一个事件上,它们将被该事件触发,不论是什么导致了该事件的发生。例如,如果你将一个程序附加到打开文件的系统调用上,那么无论何时任何进程尝试打开文件,它都会被触发。这与升级内核然后必须重新启动机器以使用其新功能相比是一个巨大的优势。
这导致使用 eBPF 的可观测性或安全工具的一大优势—它立即获得对机器上发生的所有事情的可见性。在运行容器的环境中,这包括对所有运行在这些容器内及主机上的进程的可见性。我将在本章后面深入探讨这对云原生部署的影响。
此外,正如在图 1-3 中所示,人们可以通过 eBPF 快速创建新的内核功能,而无需让每个 Linux 用户都接受相同的更改。

图 1-3. 通过 eBPF 添加内核功能(Vadim Shchekoldin, Isovalent 提供的漫画)
eBPF 程序的高性能
eBPF 程序是增加仪表化的一种非常高效的方式。一旦加载并进行即时编译(您将在第三章中看到),程序将作为本机机器指令在 CPU 上运行。此外,无需承担每个事件处理时内核和用户空间之间转换的成本(这是一项昂贵的操作)。
描述 eXpress Data Path (XDP)的 2018 年论文^(10)包括一些插图,展示了 eBPF 在网络中带来的性能改进。例如,在 XDP 中实现路由“相比常规 Linux 内核实现提升了 2.5 倍性能”,并且“XDP 相比于 IPVS 提供了 4.3 倍的性能增益”用于负载平衡。
对于性能跟踪和安全可观察性,eBPF 的另一个优势是,在将相关事件发送到用户空间的成本之前,可以在内核中对其进行过滤。毕竟,最初的 BPF 实现的目的就是仅过滤特定的网络数据包。如今,eBPF 程序可以收集系统中各种事件的信息,并可以使用复杂的、定制的程序化过滤器,仅向用户空间发送相关的信息子集。
云原生环境中的 eBPF
如今,许多组织选择不直接在服务器上执行程序来运行应用程序。相反,许多使用云原生方法:容器、诸如 Kubernetes 或 ECS 的编排器,或者像 Lambda、云函数、Fargate 等的无服务器方法。这些方法都使用自动化来选择每个工作负载将运行的服务器;在无服务器中,我们甚至不知道每个工作负载在哪台服务器上运行。
尽管涉及到服务器,每个服务器(无论是虚拟机还是裸机)都运行着一个内核。在容器中运行应用程序时,如果它们在同一(虚拟)机器上运行,则共享同一个内核。在 Kubernetes 环境中,这意味着在给定节点上的所有 pod 中的所有容器都在使用同一个内核。当我们用 eBPF 程序进行内核仪表化时,那个节点上的所有容器化工作负载对这些 eBPF 程序都是可见的,正如图 1-4 所示。

图 1-4. eBPF 程序在内核中具有对运行在 Kubernetes 节点上所有应用程序的可见性
节点上所有进程的可见性,加上能够动态加载 eBPF 程序的能力,使我们在云原生计算中使用基于 eBPF 的工具具备真正的超能力:
-
我们不需要更改应用程序,甚至不需要更改它们的配置方式来使用 eBPF 工具。
-
一旦加载到内核并附加到事件,eBPF 程序就可以开始观察现有的应用程序进程。
与 旁车模型 相对比,该模型已被用于将日志记录、跟踪、安全性和服务网格功能等功能添加到 Kubernetes 应用程序中。在旁车方法中,工具化作为一个“注入”到每个应用程序 pod 中的容器运行。这个过程涉及修改定义应用程序 pod 的 YAML,增加旁车容器的定义。这种方法确实比将工具化添加到应用程序源代码中更为方便(这是我们在旁车方法之前必须做的事情;例如,在应用程序中包含日志记录库,并在代码中适当位置调用该库)。然而,旁车方法也有一些缺点:
-
应用程序 pod 必须重新启动以添加旁车。
-
必须有某种方式修改应用程序 YAML。通常这是一个自动化的过程,但如果出现问题,旁车将无法添加,这意味着 pod 不会被工具化。例如,部署可以被注释以指示准入控制器应该向该部署的 pod 规范中添加旁车 YAML。但如果部署没有正确标记,旁车就不会被添加,因此不会被工具化。
-
当一个 pod 中有多个容器时,它们可能在不同的时间到达就绪状态,其顺序可能是不可预测的。通过注入旁车,pod 的启动时间可能会显著延长,或者更糟糕的是,可能会导致竞争条件或其他不稳定性。例如,Open Service Mesh 文档描述了应用程序容器必须能够处理所有流量被丢弃,直到 Envoy 代理容器准备就绪的情况。
-
在像服务网格这样的网络功能实现为旁车时,这必然意味着所有与应用程序容器之间的流量都必须通过内核中的网络堆栈到达网络代理容器,从而增加了流量的延迟;这在图 1-5 中有所体现。我们将在第九章中讨论如何使用 eBPF 改善网络效率。

图 1-5. 网络包路径使用服务网格代理旁车容器
所有这些问题都是侧车模型固有的问题。幸运的是,现在 eBPF 作为一个平台可用,我们有了一个可以避免这些问题的新模型。此外,由于基于 eBPF 的工具可以查看(虚拟)机器上发生的一切,它们对于恶意行为者来说更难以规避。例如,如果攻击者成功在您的主机上部署了一个加密货币挖掘应用程序,他们可能不会在您的应用工作负载上使用的侧车工具中加入它。如果您依赖于基于侧车的安全工具来防止应用程序进行意外的网络连接,那么如果未注入侧车,该工具将无法发现挖掘应用连接到其挖掘池。相比之下,基于 eBPF 实施的网络安全可以监管主机上的所有流量,因此这种加密货币挖掘操作很容易被阻止。有关出于安全原因丢弃网络数据包的能力,我们将在第八章中回到这个问题。
总结
希望本章为您解释了 eBPF 作为平台如此强大的原因。它允许我们改变内核的行为,为我们提供构建定制工具或自定义策略的灵活性。基于 eBPF 的工具可以观察(虚拟)机器上发生的任何事件,因此可以观察到所有正在运行的应用程序,无论它们是否容器化。eBPF 程序还可以动态部署,允许在运行时更改行为。
到目前为止,我们在相对概念化的层次上讨论了 eBPF。在下一章中,我们将使其更加具体,并探索基于 eBPF 的应用程序的组成部分。
^(1) 由 Steven McCanne 和 Van Jacobson 撰写的“The BSD Packet Filter: A New Architecture for User-level Packet Capture”。
^(2) 这些以及其他细节来自于 Alexei Starovoitov 在 2015 年 NetDev 会议上的演示,“BPF – in-kernel virtual machine”。
^(3) 在内核文档中有关 kprobes 工作方式的良好描述。
^(4) 这个令人惊叹的事实来自 Daniel Borkmann 在 KubeCon 2020 上的演讲,题为“eBPF and Kubernetes: Little Helper Minions for Scaling Microservices”。
^(5) 关于指令限制和“复杂性限制”的更多细节,请参阅https://oreil.ly/0iVer。
^(6) 摘自 Liz Rice 的“What Is eBPF?”。版权所有 © 2022 O’Reilly Media,已授权使用。
^(7) “Linux 5.12 Coming In At Around 28.8 Million Lines”. Phoronix, March 2021.
^(8) 江宇,亚当斯,德国人 DM。2013 年。“我的补丁会通过吗?以及有多快?”(2013)。根据这篇研究论文,有 33%的补丁被接受,大多数需要三到六个月的时间。
^(9) 幸运的是,对现有功能的安全补丁更快地提供。
^(10) Høiland-Jørgensen T,Brouer JD,Borkmann D 等。“eXpress 数据路径:操作系统内核中快速可编程数据包处理”。第 14 届新兴网络实验与技术国际会议论文集(CoNEXT ’18)。计算机协会;2018:54–66。
第二章:eBPF 的“Hello World”
在前一章中,我讨论了为什么 eBPF 如此强大,但如果您还没有对运行 eBPF 程序的真正含义有一个具体的理解,这也是可以的。在本章中,我将使用一个简单的“Hello World”示例来让您更好地理解它。
正如您在阅读本书时将会了解的那样,有几种不同的库和框架可用于编写 eBPF 应用程序。作为一个热身,我将向您展示可能是从编程角度来看最容易的方法:BCC Python 框架。这提供了一个非常简单的方式来编写基本的 eBPF 程序。出于我将在第五章中讨论的原因,这不一定是我建议今天为打算分发给其他用户的生产应用程序选择的方法,但对于初学者来说非常适合。
注意
如果您想尝试这段代码,可以在https://github.com/lizrice/learning-ebpf的chapter2目录中找到它。
您将在https://github.com/iovisor/bcc找到 BCC 项目,并且安装 BCC 的说明在https://github.com/iovisor/bcc/blob/master/INSTALL.md。
BCC 的“Hello World”
下面是使用 BCC 的 Python 库编写的 eBPF“Hello World”应用程序^(1)的完整源代码hello.py:
#!/usr/bin/python
from bcc import BPF
program = r"""
int hello(void *ctx) {
bpf_trace_printk("Hello World!");
return 0;
}
"""
b = BPF(text=program)
syscall = b.get_syscall_fnname("execve")
b.attach_kprobe(event=syscall, fn_name="hello")
b.trace_print()
该代码包括两部分:将在内核中运行的 eBPF 程序本身,以及将 eBPF 程序加载到内核并读取其生成的跟踪的一些用户空间代码。正如您在图 2-1 中所看到的,hello.py是此应用程序的用户空间部分,hello()是运行在内核中的 eBPF 程序。

图 2-1. “Hello World”的用户空间和内核组件
让我们逐行分析源代码,以更好地理解它。
第一行告诉您这是 Python 代码,可以运行它的程序是 Python 解释器(/usr/bin/python)。
eBPF 程序本身是用 C 代码编写的,它就是这部分:
int hello(void *ctx) {
bpf_trace_printk("Hello World!");
return 0;
}
所有 eBPF 程序要做的就是使用一个辅助函数bpf_trace_printk()来写入消息。辅助函数是区分其“经典”前身的“扩展”BPF 的另一个特征。它们是 eBPF 程序可以调用以与系统交互的一组函数;我将在第五章进一步讨论它们。现在你可以简单地把它看作是打印一行文本。
整个 eBPF 程序在 Python 代码中定义为一个名为program的字符串。这个 C 程序在执行之前需要编译,但是 BCC 会为你处理这一切。(在下一章中,你将看到如何自己编译 eBPF 程序。)你只需要在创建 BPF 对象时将这个字符串作为参数传递即可,如下一行所示:
b = BPF(text=program)
eBPF 程序需要附加到一个事件上,例如我选择了附加到系统调用execve上,这是用于执行程序的系统调用。无论在这台机器上的任何事物或任何人启动新程序执行,都将调用execve(),这将触发 eBPF 程序。尽管execve()在 Linux 中是一个标准接口名称,但实现它的内核函数名称取决于芯片架构,但 BCC 为我们提供了一种便捷的方法来查找正在运行的机器的函数名称:
syscall = b.get_syscall_fnname("execve")
现在,syscall表示我要附加到的内核函数的名称,我将使用 kprobe(你在第一章中已经介绍过 kprobe 的概念)。^(2)你可以像这样将hello函数附加到该事件上:
b.attach_kprobe(event=syscall, fn_name="hello")
到此为止,eBPF 程序已加载到内核并附加到一个事件上,因此每当在机器上启动新的可执行程序时,程序就会被触发。在 Python 代码中,唯一剩下的就是读取内核输出的跟踪信息并将其显示在屏幕上:
b.trace_print()
此trace_print()函数将无限循环(直到你用 Ctrl+C 停止程序),显示任何跟踪信息。
图 2-2 展示了这段代码。Python 程序编译了 C 代码,将其加载到内核,并将其附加到execve系统调用的 kprobe 上。每当这台(虚拟)机器上的任何应用程序调用execve()时,它都会触发 eBPF 的hello()程序,后者将跟踪消息写入特定的伪文件中。(稍后在本章中我将介绍该伪文件的位置。)Python 程序从伪文件中读取跟踪消息并将其显示给用户。

图 2-2. “Hello World”的操作
运行“Hello World”
运行这个程序,根据你使用的(虚拟)机器上正在发生的情况,你可能会立即看到生成的跟踪,因为其他进程可能会使用execve系统调用执行程序(3)。如果你没有看到任何内容,请打开第二个终端并执行任何你喜欢的命令(4),你将看到由“Hello World”生成的相应跟踪:
$ hello.py
b' bash-5412 [001] .... 90432.904952: 0: bpf_trace_printk: Hello World'
注意
由于 eBPF 非常强大,使用它需要特殊权限。特权会自动分配给 root 用户,所以以 root 用户身份运行 eBPF 程序是最简单的方式,可以使用 sudo。为了清晰起见,在本书的示例命令中我不会包括 sudo,但是如果你看到“操作不允许”错误,首先要检查的是你是否试图以非特权用户身份运行 eBPF 程序。
CAP_BPF 是在内核版本 5.8 中引入的,它允许执行一些 eBPF 操作,如创建特定类型的映射。但是,你可能需要额外的特权:
-
CAP_PERFMON和CAP_BPF都是加载跟踪程序所需的权限。 -
CAP_NET_ADMIN和CAP_BPF都是加载网络程序所需的权限。
在 Milan Landaverde 的博客文章 “Introduction to CAP_BPF” 中有更详细的信息。
一旦 hello eBPF 程序被加载并附加到一个事件上,它就会被从已存在进程中生成的事件触发。这应该强化你在 第一章 中学到的一些要点:
-
eBPF 程序可以动态改变系统的行为。不需要重新启动机器或者重新启动现有进程。一旦 eBPF 代码附加到事件上,它就会立即开始生效。
-
不需要改变其他应用程序的任何内容,它们就能被 eBPF 看到。只要你在该机器上有终端访问权限,在其中运行可执行文件时,它将使用
execve()系统调用;如果你将 hello 程序附加到该系统调用上,它将被触发以生成跟踪输出。同样,如果你有一个运行可执行文件的脚本,那也会触发 hello eBPF 程序。你不需要改变终端的 shell、脚本或者你运行的可执行文件。
跟踪输出不仅显示了 "Hello World" 字符串,还显示了触发 hello eBPF 程序运行的事件的一些额外上下文信息。在本节开头显示的示例输出中,执行 execve 系统调用的进程的进程 ID 是 5412,并且正在运行 bash 命令。对于跟踪消息,这些上下文信息是作为内核跟踪基础设施的一部分添加的(这并不特定于 eBPF),但是后面你将看到,在 eBPF 程序内部也可以检索到类似的上下文信息。
也许你想知道 Python 代码是如何知道从哪里读取跟踪输出的。答案并不复杂——内核中的 bpf_trace_printk() 辅助函数始终将输出发送到同一预定义的伪文件位置:/sys/kernel/debug/tracing/trace_pipe。你可以使用 cat 命令查看其内容;需要 root 权限来访问它。
对于简单的“Hello World”示例或基本的调试目的,单个跟踪管道位置就足够了,但其功能非常有限。输出格式的灵活性非常小,仅支持字符串输出,因此对于传递结构化信息并不是非常有用。也许最重要的是,在(虚拟)机器上只有这一个位置。如果同时运行多个 eBPF 程序,它们都会将跟踪输出写入同一个跟踪管道,这对于人类操作员来说可能会非常混乱。
从 eBPF 程序中获取信息的更好方法是使用 eBPF 映射。
BPF 映射
映射是一种数据结构,可以从 eBPF 程序和用户空间访问。映射是区分扩展 BPF 和其经典前身的一项非常重要的特性之一。(你可能会认为这意味着它们通常被称为“eBPF 映射”,但你经常会看到“BPF 映射”的用法。通常情况下,这两个术语可以互换使用。)
映射可用于在多个 eBPF 程序之间共享数据,或在用户空间应用程序与运行在内核中的 eBPF 代码之间进行通信。典型用途包括以下内容:
-
用户空间编写配置信息以便由 eBPF 程序检索
-
一个 eBPF 程序存储状态,以便稍后由另一个 eBPF 程序检索(或同一个程序的未来运行)
-
一个 eBPF 程序将结果或指标写入一个映射,供用户空间应用程序检索并展示结果
在 Linux 的uapi/linux/bpf.h文件中定义了各种类型的 BPF 映射,并且内核文档中也提供了一些相关信息。总体而言,它们都是键-值存储,并且在本章中你将看到用于哈希表、perf 和环形缓冲区以及 eBPF 程序数组的映射示例。
一些映射类型被定义为数组,其键类型始终为 4 字节索引;而其他映射则是可以使用任意数据类型作为键的哈希表。
有一些映射类型经过优化,用于特定类型的操作,比如先进先出队列,后进先出栈,最近最少使用数据存储,最长前缀匹配,以及Bloom 过滤器(一种概率数据结构,旨在快速确定元素是否存在)。
一些 eBPF 映射类型保存特定类型对象的信息。例如,sockmaps 和 devmaps 保存套接字和网络设备的信息,被网络相关的 eBPF 程序用来重定向流量。程序数组映射存储一组索引化的 eBPF 程序,(后面你会看到)用于实现尾调用,其中一个程序可以调用另一个。甚至还有映射类型的映射支持存储关于映射的信息。
有些映射类型有每个 CPU 变体,也就是说内核为每个 CPU 核心的该映射版本使用不同的内存块。这可能会让你担心那些不是每个 CPU 的映射,多个 CPU 核心同时访问同一映射的并发问题。内核版本 5.1 添加了对(某些)映射的自旋锁支持,我们将在第五章中回到这个主题。
下一个示例(在GitHub 仓库中的chapter2/hello-map.py)展示了使用散列表映射的一些基本操作。它还展示了一些 BCC 提供的便捷抽象,使得使用映射变得非常容易。
散列表映射
与本章中前面的示例类似,这个 eBPF 程序将附加到 execve 系统调用的入口处。它将使用键-值对填充一个散列表,其中键是用户 ID,值是由该用户 ID 下的进程调用 execve 的次数计数器。在实际操作中,这个示例将展示每个不同用户运行程序的次数。
首先,让我们看看 eBPF 程序本身的 C 代码:
BPF_HASH(counter_table); 
int hello(void *ctx) {
u64 uid;
u64 counter = 0;
u64 *p;
uid = bpf_get_current_uid_gid() & 0xFFFFFFFF; 
p = counter_table.lookup(&uid); 
if (p != 0) { 
counter = *p;
}
counter++; 
counter_table.update(&uid, &counter); 
return 0;
}
BPF_HASH() 是一个 BCC 宏,用于定义散列表映射。
bpf_get_current_uid_gid() 是一个帮助函数,用于获取触发此 kprobe 事件的进程的用户 ID。用户 ID 存储在返回的 64 位值的最低 32 位中。(最高的 32 位存储组 ID,但这部分被掩码掉了。)
查找散列表中键与用户 ID 匹配的条目。它返回指向散列表中相应值的指针。
如果此用户 ID 的散列表中有条目,将 counter 变量设置为散列表中当前值(由 p 指向)。如果散列表中没有此用户 ID 的条目,指针将为 0,计数器值将保持为 0。
无论当前计数器值是多少,它都会增加一。
更新散列表,使用新的计数器值更新此用户 ID。
详细查看访问散列表的代码行:
p = counter_table.lookup(&uid);
稍后:
counter_table.update(&uid, &counter);
如果您认为“这不是正确的 C 代码!”您是完全正确的。C 语言不支持在结构体上定义方法的方式。^(5) 这是一个很好的例子,BCC 的 C 版本非常宽松地类似于 C 语言,BCC 在将代码发送到编译器之前对其进行了重写。BCC 提供了一些方便的快捷方式和宏,它们会转换为“正确”的 C 代码。
就像前面的示例一样,C 代码被定义为一个名为program的字符串。该程序被编译,加载到内核中,并附加到execve kprobe,与前面的“Hello World”示例完全相同:
b = BPF(text=program)
syscall = b.get_syscall_fnname("execve")
b.attach_kprobe(event=syscall, fn_name="hello")
这次在 Python 侧需要更多的工作来从哈希表中读取信息:
while True: 
sleep(2)
s = ""
for k,v in b["counter_table"].items(): 
s += f"ID {k.value}: {v.value}\t"
print(s)
此代码部分无限循环,每两秒查找一次输出。
BCC 自动创建一个 Python 对象来表示哈希表。此代码循环遍历任何值并将其打印到屏幕上。
当您运行此示例时,您会希望有第二个终端窗口,您可以在其中运行一些命令。右侧带有我在另一个终端中运行的命令的示例输出如下:
Terminal 1 Terminal 2
$ ./hello-map.py
[blank line(s) until I run something]
ID 501: 1 ls
ID 501: 1
ID 501: 2 ls
ID 501: 3 ID 0: 1 sudo ls
ID 501: 4 ID 0: 1 ls
ID 501: 4 ID 0: 1
ID 501: 5 ID 0: 2 sudo ls
此示例每两秒生成一行输出,无论是否发生任何事件。在此输出结束时,哈希表包含两个条目:
-
key=501, value=5 -
key=0, value=2
在第二个终端中,我有用户 ID 为 501。使用此用户 ID 运行ls命令会增加execve计数器。当我运行sudo ls时,会发生两次execve调用:一次是以用户 ID 501 执行sudo,另一次是以根用户 ID 0 执行ls。
在此示例中,我使用哈希表将数据从 eBPF 程序传输到用户空间。(我也可以在此处使用数组类型的映射,因为键是整数;哈希表允许您使用任意类型作为键。)当数据自然处于键值对时,哈希表非常方便,但用户空间代码必须定期轮询表格。Linux 内核已经支持从内核向用户空间发送数据的perf 子系统,而 eBPF 包括使用 perf 缓冲区及其后续 BPF 环形缓冲区的支持。让我们来看看。
Perf 和 Ring Buffer Maps
在本节中,我将描述一个稍微复杂的“Hello World”版本,它使用了 BCC 的BPF_PERF_OUTPUT功能,允许您将数据按您选择的结构写入到 perf 环形缓冲区映射中。
注意
现在有一种称为“BPF 环形缓冲区”的新构造,如果您的内核版本为 5.8 或以上,通常优先于 BPF perf 缓冲区。Andrii Nakryiko 在他的BPF 环形缓冲区博客文章中讨论了其中的区别。您将在第四章中看到 BCC 的BPF_RINGBUF_OUTPUT的示例。
你可以在 Learning eBPF GitHub 仓库 的 chapter2/hello-buffer.py 中找到此示例的源代码。就像在本章早期看到的第一个"Hello World"示例一样,此版本每次使用 execve() 系统调用时都会向屏幕输出字符串 "Hello World"。它还会查找每次调用 execve() 的进程 ID 和命令名称,以便你得到类似于第一个示例的输出。这使我有机会展示一些更多的 BPF 辅助函数示例。
下面是将加载到内核中的 eBPF 程序:
BPF_PERF_OUTPUT(output); 
struct data_t { 
int pid;
int uid;
char command[16];
char message[12];
};
int hello(void *ctx) {
struct data_t data = {}; 
char message[12] = "Hello World";
data.pid = bpf_get_current_pid_tgid() >> 32; 
data.uid = bpf_get_current_uid_gid() & 0xFFFFFFFF; 
bpf_get_current_comm(&data.command, sizeof(data.command)); 
bpf_probe_read_kernel(&data.message, sizeof(data.message), message); 
output.perf_submit(ctx, &data, sizeof(data)); 
return 0;
}
BCC 定义了宏 BPF_PERF_OUTPUT,用于创建一个映射,将从内核传递消息到用户空间。我将这个映射称为 output。
每次运行 hello() 时,该代码将写入一个数据结构的数据。以下是该结构的定义,其中包含进程 ID、当前运行命令的名称和文本消息。
data 是一个保存要提交的数据结构的本地变量,message 包含字符串 "Hello World"。
bpf_get_current_pid_tgid() 是一个辅助函数,用于获取触发此 eBPF 程序运行的进程 ID。它返回一个 64 位值,其中进程 ID 位于前 32 位中。^(6)
bpf_get_current_uid_gid() 是你在前面示例中看到的获取用户 ID 的辅助函数。
类似地,bpf_get_current_comm() 是一个辅助函数,用于获取进行 execve 系统调用的进程的可执行文件(或“命令”)名称。这是一个字符串,不像进程和用户 ID 那样是数值。在 C 语言中,你不能简单地使用 = 分配一个字符串。你必须将字符串应该写入的字段的地址 &data.command 作为辅助函数的参数传递。
对于这个例子,每次的消息都是 "Hello World"。bpf_probe_read_kernel() 将其复制到数据结构的正确位置。
此时数据结构已填充有进程 ID、命令名称和消息。调用 output.perf_submit() 将这些数据放入映射中。
正如在第一个“Hello World”示例中一样,此 C 程序被分配给 Python 代码中的一个名为 program 的字符串。接下来是 Python 代码的其余部分:
b = BPF(text=program) 
syscall = b.get_syscall_fnname("execve")
b.attach_kprobe(event=syscall, fn_name="hello")
def print_event(cpu, data, size): 
data = b["output"].event(data)
print(f"{data.pid} {data.uid} {data.command.decode()} " + \
f"{data.message.decode()}")
b["output"].open_perf_buffer(print_event) 
while True: 
b.perf_buffer_poll()
编译 C 代码、将其加载到内核并将其附加到系统调用事件的代码行与之前的“Hello World”版本相同。
print_event 是一个回调函数,将向屏幕输出一行数据。BCC 通过一些重活让我可以简单地将映射称为 b["output"] 并使用 b["output"].event() 从中获取数据。
b["output"].open_perf_buffer() 打开性能环形缓冲区。该函数将 print_event 作为参数,用于定义每当从缓冲区读取数据时要使用的回调函数。
程序现在将无限循环,^(7) 轮询性能环形缓冲区。如果有任何数据可用,print_event 将被调用。
运行此代码会给我们提供一个与原始“Hello World”相似的输出:
$ sudo ./hello-buffer.py
11654 node Hello World
11655 sh Hello World
...
与之前一样,您可能需要打开第二个终端到相同(虚拟)机器,并运行一些命令以触发一些输出。
这与原始的“Hello World”示例之间的主要区别在于,现在不再使用单一的中央跟踪管道,而是通过由此程序为自己使用创建的一个名为 output 的环形缓冲区映射来传递数据,如图 2-4 所示。

图 2-4. 使用性能环形缓冲区将数据从内核传递到用户空间
您可以通过使用 cat /sys/kernel/debug/tracing/trace_pipe 来验证信息是否不会发送到跟踪管道。
除了演示环形缓冲区映射的使用外,此示例还展示了一些用于检索触发 eBPF 程序运行事件相关信息的 eBPF 辅助函数。在这里,您已经看到了一些辅助函数,用于获取用户 ID、进程 ID 和当前命令的名称。正如您将在第七章中看到的那样,可用的上下文信息集和可用于检索它们的有效辅助函数集取决于程序类型及其触发事件。
eBPF 代码可以访问此类上下文信息的事实使其在可观察性方面非常有价值。每当事件发生时,eBPF 程序不仅可以报告事件发生的事实,还可以报告触发事件的相关信息。由于所有这些信息都可以在内核中收集,而无需进行任何同步的上下文切换到用户空间,因此它也具有高性能。
在本书的后续示例中,您将看到更多使用 eBPF 辅助函数来收集其他上下文数据的示例,以及使用 eBPF 程序更改上下文数据甚至阻止事件发生的示例。
函数调用
你已经看到 eBPF 程序可以调用内核提供的帮助函数,但如果你想将正在编写的代码拆分为函数,该怎么办?一般来说,在软件开发中,将常见代码提取为函数以供多处调用被视为良好实践^(8),而不是一遍又一遍地复制相同的代码行。但在早期,除了帮助函数外,eBPF 程序不允许调用其他函数。为了解决这个问题,程序员们已经指示编译器“始终内联”它们的函数,就像这样:
static __always_inline void my_function(void *ctx, int val)
通常,源代码中的函数导致编译器生成跳转指令,该指令导致执行跳转到组成被调用函数的一组指令(并在该函数完成后再次跳回)。你可以在 图 2-5 的左侧看到这一点。右侧显示了内联函数时发生的情况:没有跳转指令;相反,函数的指令副本直接嵌入到调用函数中。

图 2-5. 非内联和内联函数指令的布局
如果该函数被多处调用,那么编译后的可执行文件中将包含多个该函数的指令副本。(有时编译器可能会选择内联函数以进行优化,这也是你可能无法附加 kprobe 到某些内核函数的原因之一。我会在 第七章 中再次谈到这点。)
从 Linux 内核 4.16 和 LLVM 6.0 开始,解除了函数必须内联的限制,以便 eBPF 程序员可以更自然地编写函数调用。然而,这一特性称为“BPF 到 BPF 函数调用”或“BPF 子程序”,目前不受 BCC 框架支持,我们将在下一章中再回到这一点。(当然,如果函数被内联,你仍然可以继续在 BCC 中使用函数。)
eBPF 中还有另一种将复杂功能分解为较小部分的机制:尾调用。
尾调用
正如在 ebpf.io 中描述的,“尾调用可以调用并执行另一个 eBPF 程序,并替换执行上下文,类似于 execve() 系统调用在常规进程中的操作。” 换句话说,执行在尾调用完成后不会返回给调用者。
注意
尾调用并不是 eBPF 编程专有的。尾调用的一般动机是避免在函数递归调用时一遍又一遍地向栈中添加帧,这最终可能导致栈溢出错误。如果能够安排代码在调用递归函数后作为最后一件事做尾调用,那么与调用函数相关联的栈帧实际上并没有做任何有用的事情。尾调用允许调用一系列函数而不会增长栈。在 eBPF 中特别有用,因为栈限制为 512 字节。
使用bpf_tail_call()辅助函数进行尾调用,其签名如下:
long bpf_tail_call(void **`ctx`*, struct bpf_map **`prog_array_map`*, u32 *`index`*)
此函数的三个参数具有以下含义:
-
ctx允许从调用 eBPF 程序传递上下文到被调用程序。 -
prog_array_map是一个类型为BPF_MAP_TYPE_PROG_ARRAY的 eBPF 映射,其中包含用于标识 eBPF 程序的一组文件描述符。 -
index指示应调用那一组 eBPF 程序中的哪一个。
这个辅助程序有些不同寻常,如果成功,它永远不会返回。当前运行的 eBPF 程序在栈上被调用的程序替换。如果指定的程序在映射中不存在,例如,辅助程序可能会失败,在这种情况下,调用程序继续执行。
用户空间代码必须将所有 eBPF 程序加载到内核中(像往常一样),并设置程序数组映射。
让我们看一个简单的示例,使用 BCC 编写的 Python 代码;你可以在GitHub 存储库中找到这段代码,位于chapter2/hello-tail.py。主要的 eBPF 程序附加到了一个跟踪点,该跟踪点是所有系统调用的通用入口点。此程序使用尾调用来跟踪特定系统调用操作码的特定消息。如果对于给定的操作码没有尾调用,程序将跟踪一个通用消息。
如果你正在使用 BCC 框架,为了进行尾调用,可以使用略微简化的形式的代码行:
prog_array_map.call(ctx, index)
在将代码传递给编译步骤之前,BCC 将重写上述行为:
bpf_tail_call(ctx, prog_array_map, index)
下面是 eBPF 程序及其尾调用的源代码:
BPF_PROG_ARRAY(syscall, 300); 
int hello(struct bpf_raw_tracepoint_args *ctx) { 
int opcode = ctx->args[1]; 
syscall.call(ctx, opcode); 
bpf_trace_printk("Another syscall: %d", opcode); 
return 0;
}
int hello_execve(void *ctx) { 
bpf_trace_printk("Executing a program");
return 0;
}
int hello_timer(struct bpf_raw_tracepoint_args *ctx) { 
if (ctx->args[1] == 222) {
bpf_trace_printk("Creating a timer");
} else if (ctx->args[1] == 226) {
bpf_trace_printk("Deleting a timer");
} else {
bpf_trace_printk("Some other timer operation");
}
return 0;
}
int ignore_opcode(void *ctx) { 
return 0;
}
BCC 提供了一个BPF_PROG_ARRAY宏,用于轻松定义类型为BPF_MAP_TYPE_PROG_ARRAY的映射。我称这个映射为syscall,并允许 300 个条目,^(9) 这对于这个示例来说已经足够了。
在即将看到的用户空间代码中,我将把这个 eBPF 程序附加到sys_enter原始跟踪点上,每当进行任何系统调用时都会触发。传递给附加到原始跟踪点的 eBPF 程序的上下文采用bpf_raw_tracepoint_args结构的形式。
对于sys_enter,原始跟踪点参数包括标识正在进行的系统调用的操作码。
在这里,我们对与操作码匹配的程序数组条目进行了一个尾调用。在将源代码传递给编译器之前,BCC 将此行代码重写为对bpf_tail_call()辅助函数的调用。
如果尾调用成功,将不会执行此行跟踪操作码值的代码行。我已经用它来为映射中没有程序入口的操作码提供一个默认的追踪行。
hello_exec()是一个将加载到系统调用程序数组映射中的程序,在操作码指示为execve()系统调用时作为尾调用执行。它只会生成一行追踪,告诉用户正在执行一个新程序。
hello_timer()是另一个将加载到系统调用程序数组中的程序。在这种情况下,它将被多个程序数组条目引用。
ignore_opcode()是一个什么都不做的尾调用程序。我会将其用于那些我完全不想生成任何追踪的系统调用。
现在让我们看看加载和管理这组 eBPF 程序的用户空间代码:
b = BPF(text=program)
b.attach_raw_tracepoint(tp="sys_enter", fn_name="hello") 
ignore_fn = b.load_func("ignore_opcode", BPF.RAW_TRACEPOINT) 
exec_fn = b.load_func("hello_exec", BPF.RAW_TRACEPOINT)
timer_fn = b.load_func("hello_timer", BPF.RAW_TRACEPOINT)
prog_array = b.get_table("syscall") 
prog_array[ct.c_int(59)] = ct.c_int(exec_fn.fd)
prog_array[ct.c_int(222)] = ct.c_int(timer_fn.fd)
prog_array[ct.c_int(223)] = ct.c_int(timer_fn.fd)
prog_array[ct.c_int(224)] = ct.c_int(timer_fn.fd)
prog_array[ct.c_int(225)] = ct.c_int(timer_fn.fd)
prog_array[ct.c_int(226)] = ct.c_int(timer_fn.fd)
# Ignore some syscalls that come up a lot 
prog_array[ct.c_int(21)] = ct.c_int(ignore_fn.fd)
prog_array[ct.c_int(22)] = ct.c_int(ignore_fn.fd)
prog_array[ct.c_int(25)] = ct.c_int(ignore_fn.fd)
...
b.trace_print() 
与之前看到的连接到 kprobe 不同,这次用户空间代码将主 eBPF 程序连接到了sys_enter跟踪点。
这些对b.load_func()的调用每次都返回一个尾调用程序的文件描述符。请注意,尾调用需要与其父程序具有相同的程序类型——在本例中是BPF.RAW_TRACEPOINT。此外,需要指出的是,每个尾调用程序本身就是一个独立的 eBPF 程序。
用户空间代码会在syscall映射中创建条目。映射不必为每个可能的操作码完全填充;如果某个特定操作码没有条目,那就意味着不会执行任何尾调用。此外,有多个条目指向同一个 eBPF 程序也是完全合理的。在这种情况下,我希望hello_timer()尾调用能够对一组与定时器相关的系统调用中的任何一个执行。
某些系统调用由系统频繁运行,每次运行都生成一行追踪输出,使得输出难以阅读。我已经为几个系统调用使用了ignore_opcode()尾调用。
将追踪输出打印到屏幕上,直到用户终止程序运行。
运行此程序会为在(虚拟)机器上运行的每个系统调用生成追踪输出,除非操作码已与ignore_opcode()尾调用链接。以下是在另一个终端中运行ls时生成的部分追踪输出示例(为了可读性已省略部分细节):
./hello-tail.py
b' hello-tail.py-2767 ... Another syscall: 62'
b' hello-tail.py-2767 ... Another syscall: 62'
...
b' bash-2626 ... Executing a program'
b' bash-2626 ... Another syscall: 220'
...
b' <...>-2774 ... Creating a timer'
b' <...>-2774 ... Another syscall: 48'
b' <...>-2774 ... Deleting a timer'
...
b' ls-2774 ... Another syscall: 61'
b' ls-2774 ... Another syscall: 61'
...
正在执行的特定系统调用并不重要,但您可以看到不同的尾调用被调用并生成跟踪消息。您还可以看到对于没有在尾调用程序映射中具有条目的操作码,默认消息为Another syscall。
注意
查看保罗·夏尼翁在不同内核版本上关于BPF 尾调用成本的博客文章。
自内核版本 4.2 起,eBPF 支持尾调用,但长期以来它们与进行 BPF 到 BPF 函数调用不兼容。这一限制在内核 5.10 中被解除。^(10)
您可以将最多 33 个尾调用串联在一起,加上每个 eBPF 程序的指令复杂性限制为 100 万条指令,这意味着今天的 eBPF 程序员可以在内核中编写非常复杂的代码。
摘要
希望通过展示一些具体的 eBPF 程序示例,本章帮助您巩固对运行在内核中的 eBPF 代码的精神模型,这些代码由事件触发。您还看到了使用 BPF 映射将数据从内核传递到用户空间的示例。
使用 BCC 框架隐藏了构建程序、加载到内核并附加到事件的许多细节。在下一章中,我将向您展示编写“Hello World”的不同方法,并深入探讨这些隐藏的细节。
练习
如果您想进一步探索“Hello World”,可以尝试(或思考)一些可选的活动:
-
将hello-buffer.py eBPF 程序调整为对奇数和偶数进程 ID 输出不同的跟踪消息。
-
修改hello-map.py,以便 eBPF 代码可以被多个系统调用触发。例如,
openat()通常用于打开文件,而write()用于向文件写入数据。您可以从将hello eBPF 程序附加到多个系统调用 kprobes 开始。然后尝试为不同的系统调用准备修改后的hello eBPF 程序版本,展示您可以从多个不同的程序访问同一映射。 -
hello-tail.py eBPF 程序是一个示例程序,它附加到
sys_enter原始跟踪点,每次调用系统调用时都会触发。修改hello-map.py,以显示每个用户 ID 执行的系统调用总数,方法是将其附加到相同的sys_enter原始跟踪点。在我进行了这些更改后,这是我得到的一些示例输出:
$ ./hello-map.py ID 104: 6 ID 0: 225 ID 104: 6 ID 101: 34 ID 100: 45 ID 0: 332 ID 501: 19 ID 104: 6 ID 101: 34 ID 100: 45 ID 0: 368 ID 501: 38 ID 104: 6 ID 101: 34 ID 100: 45 ID 0: 533 ID 501: 57 -
由 BCC 提供的
RAW_TRACEPOINT_PROBE宏简化了附加到原始跟踪点的过程,告诉用户空间的 BCC 代码自动将其附加到指定的跟踪点。尝试在hello-tail.py中使用它,就像这样:-
将
hello()函数的定义替换为RAW_TRACEPOINT_PROBE(sys_enter)。 -
从 Python 代码中删除显式附加调用
b.attach_raw_tracepoint()。
您应该看到 BCC 自动创建附件,程序的运行完全相同。这是 BCC 提供的许多便利宏的一个示例。
-
-
您可以进一步调整hello_map.py,使哈希表中的键标识特定系统调用(而不是特定用户)。输出将显示该系统调用在整个系统中被调用的次数。
^(1) 最初我为一个名为“eBPF 编程入门指南”的演讲编写了这篇文章。您可以在https://github.com/lizrice/ebpf-beginners找到原始代码以及幻灯片和视频的链接。
^(2) 有一种更高效的方法可以将 eBPF 程序附加到函数上,从内核版本 5.5 开始可用,该方法使用 fentry(以及相应的 fexit,而不是 kretprobe 用于函数的退出)。我稍后会在书中讨论这个,但现在我使用 kprobe 使本章的示例尽可能简单。
^(3) 我经常使用 VScode 远程连接到云中的虚拟机。在虚拟机上运行许多节点脚本,这些节点脚本来自这个“Hello World”应用程序的跟踪。
^(4) 一些命令(例如echo)可能是作为 shell 内置运行的,而不是执行新程序。这些不会触发execve()事件,因此不会生成跟踪。
^(5) C++可以,但 C 语言不行。
^(6) 低 32 位是线程组 ID。对于单线程进程,这与进程 ID 相同,但进程的其他线程将获得不同的 ID。GNU C 库的文档对进程和线程组 ID的差异有很好的描述。
^(7) 这只是示例代码,所以我不会担心在键盘中断或其他方面的清理!
^(8) 这个原则通常被称为“DRY”(“不要重复自己”),由《务实程序员》所推广。
^(9) Linux 中有大约 300 个系统调用,由于我在这个示例中没有使用最近添加的任何系统调用,这已经足够了。
^(10) 从 BPF 子程序中进行尾调用需要 JIT 编译器的支持,您将在下一章中了解到。在我编写本书示例所用的内核版本中,只有 x86 上的 JIT 编译器支持此功能,尽管在内核 6.0 中已为 ARM 添加了支持。
第三章:eBPF 程序解剖
在前一章中,您看到了使用 BCC 框架编写的简单 eBPF “Hello World” 程序。本章还展示了一个完全用 C 编写的“Hello World” 程序版本,以便查看 BCC 在幕后处理的一些细节。
本章还展示了 eBPF 程序从源代码到执行过程中的各个阶段,如 图 3-1 所示。

图 3-1. C(或 Rust)源代码编译成 eBPF 字节码,然后 JIT 编译或解释成本地机器码指令
一个 eBPF 程序是一组 eBPF 字节码指令。可以直接在此字节码中编写 eBPF 代码,就像可以使用汇编语言编程一样。人类通常更喜欢使用高级编程语言来处理,至少在撰写本文时,我可以说绝大多数 eBPF 代码是用 C^(1) 编写并编译成 eBPF 字节码。
在概念上,这些字节码在内核中的 eBPF 虚拟机中运行。
eBPF 虚拟机
与任何虚拟机一样,eBPF 虚拟机是计算机的软件实现。它接收 eBPF 字节码指令形式的程序,并将其转换为在 CPU 上运行的本地机器指令。
在 eBPF 的早期实现中,内核内部解释了字节码指令——也就是说,每次运行 eBPF 程序时,内核都会检查指令并将其转换为机器码,然后执行。出于性能和避免 eBPF 解释器中一些与 Spectre 相关的漏洞的考虑,解释已大部分被 JIT(即时编译)替代。编译意味着程序加载到内核时,将字节码转换为本地机器指令,仅需进行一次。
eBPF 字节码包含一组指令,这些指令作用于(虚拟的)eBPF 寄存器。eBPF 指令集和寄存器模型设计得非常符合常见的 CPU 架构,因此从字节码到机器码的编译或解释步骤相对直接。
eBPF 寄存器
eBPF 虚拟机使用 10 个通用寄存器,编号从 0 到 9。此外,寄存器 10 用作堆栈帧指针(只能读取,不能写入)。在执行 BPF 程序时,这些寄存器中存储的值用于跟踪状态。
理解的重点是,eBPF 虚拟机中的这些 eBPF 寄存器是通过软件实现的。你可以在 Linux 内核源代码的 include/uapi/linux/bpf.h 头文件中看到它们从 BPF_REG_0 到 BPF_REG_10 的枚举。
在执行开始之前,eBPF 程序的上下文参数被加载到寄存器 1 中。函数的返回值存储在寄存器 0 中。
在从 eBPF 代码调用函数之前,该函数的参数被放置在寄存器 1 到寄存器 5 中(如果少于五个参数,则不使用所有寄存器)。
eBPF 指令
同样的 linux/bpf.h 头文件定义了一个称为 bpf_insn 的结构,表示一个 BPF 指令:
struct `bpf_insn` {
`__u8` `code`; /* opcode */ 
`__u8` `dst_reg`:4; /* dest register */ 
`__u8` `src_reg`:4; /* source register */
`__s16` `off`; /* signed offset */ 
`__s32` `imm`; /* signed immediate constant */
};
每个指令都有一个操作码,定义了指令要执行的操作,例如将一个值添加到寄存器的内容中,或者跳转到程序中的另一个指令。^(2) Iovisor 项目的“非官方 eBPF 规范”列出了有效指令的列表。
不同的操作可能涉及最多两个寄存器。
根据操作的不同,可能存在偏移值和/或“立即”整数值。
这个 bpf_insn 结构长达 64 位(或 8 字节)。然而,有时一条指令可能需要跨越超过 8 字节。如果要将寄存器设置为 64 位值,不可能将所有 64 位值与操作码和寄存器信息一起挤入结构中。在这些情况下,该指令使用了 宽指令编码,总长为 16 字节。你将在本章看到一个例子。
当加载到内核中时,eBPF 程序的字节码由一系列 bpf_insn 结构表示。验证器对此信息执行多项检查,以确保代码可以安全运行。你将在第六章了解更多关于验证过程的信息。
大多数不同的操作码属于以下类别之一:
-
将值加载到寄存器中(可以是立即值,也可以是从内存或另一个寄存器中读取的值)
-
将寄存器中的值存储到内存中
-
执行算术操作,例如将一个值添加到寄存器的内容中
-
如果满足特定条件,跳转到另一个指令
注意
关于 eBPF 架构的概述,我推荐阅读 BPF 和 XDP 参考指南,它作为 Cilium 项目文档的一部分包含在内。如果你想要更多细节,内核文档清楚地描述了 eBPF 指令和编码。
让我们以另一个简单的 eBPF 程序为例,从 C 源代码开始,跟随它的旅程,经过 eBPF 字节码,最终到达机器码指令。
注意
如果你想自行构建和运行这段代码,你可以在github.com/lizrice/learning-ebpf找到该代码以及设置环境的说明。本章的代码位于chapter3目录下。
本章的示例使用 C 语言编写,使用了名为libbpf的库。你将在第五章详细了解这个库。
eBPF“Hello World”适用于网络接口
上一章的示例通过系统调用的 kprobe 触发了跟踪“Hello World”;这次我将展示一个 eBPF 程序,它在接收到网络数据包时触发并写入一行跟踪信息。
数据包处理是 eBPF 的一个非常常见的应用。我将在第八章中详细讨论这一点,但现在知道一个 eBPF 程序的基本概念可能会对你有所帮助,该程序会在网络接口上到达的每个数据包上触发。程序可以检查甚至修改数据包的内容,并对内核对该数据包的处理做出决策(或评判)。评判可能告诉内核继续像往常一样处理,丢弃或重定向到其他位置。
在我这里展示的简单示例中,程序不会处理网络数据包;每次接收到网络数据包时,它只是将Hello World和一个计数器写入跟踪管道。
示例程序位于chapter3/hello.bpf.c。把 eBPF 程序放在以bpf.c结尾的文件名中是一个相当普遍的约定,以区分可能存放在同一源代码目录中的用户空间 C 代码。以下是整个程序:
#include <linux/bpf.h> 
#include <bpf/bpf_helpers.h>
int counter = 0; 
SEC("xdp") 
int hello(void *ctx) { 
bpf_printk("Hello World %d", counter);
counter++;
return XDP_PASS;
}
char LICENSE[] SEC("license") = "Dual BSD/GPL"; 
这个示例首先包含一些头文件。以防你不熟悉 C 编码,每个程序都必须包含定义程序要使用的任何结构或函数的头文件。从名称可以猜到,这些头文件与 BPF 相关。
这个示例展示了 eBPF 程序如何使用全局变量。每次程序运行时,这个计数器都会递增。
宏SEC()定义了一个名为xdp的段,在编译后的目标文件中可见。我将在第五章再回到段名是如何用于 eBPF 程序的讨论,但现在你可以简单地将其视为定义了一种称为 eXpress 数据路径(XDP)的 eBPF 程序。
这里你可以看到实际的 eBPF 程序。在 eBPF 中,程序名称即为函数名称,因此该程序称为hello。它使用一个辅助函数bpf_printk来写入文本字符串,增加全局变量counter的值,然后返回值XDP_PASS,这是告诉内核应像往常一样处理这个网络数据包的评判结果。
最后还有另一个SEC()宏定义了一个许可字符串,这是 eBPF 程序的一个关键要求。内核中的一些 BPF 辅助函数被定义为“仅限 GPL 使用”。如果你想使用这些函数中的任何一个,你的 BPF 代码必须声明为具有 GPL 兼容许可证。验证器(我们将在第六章讨论)会检查声明的许可证是否与程序使用的函数兼容。包括使用 BPF LSM 的某些 eBPF 程序类型,你将在第九章了解到,也需要兼容 GPL。
注意
你可能会想为什么上一章使用了 bpf_trace_printk(),而这个版本使用了 bpf_printk()。简短的答案是,BCC 的版本称为 bpf_trace_printk(),libbpf 的版本是 bpf_printk(),但这两者都是对内核函数 bpf_trace_printk() 的包装。Andrii Nakryiko 在他的博客上写了一篇很好的文章来解释这一点。
这是一个附加到网络接口上 XDP 钩点的 eBPF 程序示例。你可以将 XDP 事件看作是在网络接口上入站时触发的。
注意
一些网络卡支持将 XDP 程序卸载到网络卡本身以便执行。这意味着每个到达的网络数据包可以在卡上处理,而不需要接近计算机的 CPU。XDP 程序可以检查甚至修改每个网络数据包,因此这对于进行 DDoS 保护、防火墙或负载均衡是非常有用的。你将在第八章详细了解这些内容。
你已经看过了 C 源代码,下一步是将其编译成内核能理解的对象。
编译一个 eBPF 对象文件
我们的 eBPF 源代码需要编译成 eBPF 虚拟机可以理解的机器指令:eBPF 字节码。如果你指定了 -target bpf,LLVM 项目的 Clang 编译器将会完成这项工作。以下是一个 Makefile 的片段,用于执行编译:
hello.bpf.o: %.o: %.c
clang \
-target bpf \
-I/usr/include/$(shell uname -m)-linux-gnu \
-g \
-O2 -c $< -o $@
这从源代码 hello.bpf.c 生成了一个名为 hello.bpf.o 的对象文件。这里 -g 标志是可选的,^(3) 但它生成调试信息,这样你可以在检查对象文件时看到源代码和字节码。让我们检查一下这个对象文件,以更好地理解它包含的 eBPF 代码。
检查一个 eBPF 对象文件
文件工具通常用于确定文件的内容:
$ file hello.bpf.o
hello.bpf.o: ELF 64-bit LSB relocatable, eBPF, version 1 (SYSV), with debug_info,
not stripped
这显示了它是一个 ELF(可执行和可链接格式)文件,包含 eBPF 代码,适用于 LSB(最低有效位)架构的 64 位平台。如果在编译步骤中使用了 -g 标志,则包括调试信息。
你可以使用 llvm-objdump 进一步检查这个对象以查看 eBPF 指令:
$ llvm-objdump -S hello.bpf.o
即使你对反汇编不熟悉,这个命令的输出也不难理解:
hello.bpf.o: file format elf64-bpf 
Disassembly of section xdp: 
0000000000000000 <hello>: 
; bpf_printk("Hello World %d", counter"); 
0: 18 06 00 00 00 00 00 00 00 00 00 00 00 00 00 00 r6 = 0 ll
2: 61 63 00 00 00 00 00 00 r3 = *(u32 *)(r6 + 0)
3: 18 01 00 00 00 00 00 00 00 00 00 00 00 00 00 00 r1 = 0 ll
5: b7 02 00 00 0f 00 00 00 r2 = 15
6: 85 00 00 00 06 00 00 00 call 6
; counter++; 
7: 61 61 00 00 00 00 00 00 r1 = *(u32 *)(r6 + 0)
8: 07 01 00 00 01 00 00 00 r1 += 1
9: 63 16 00 00 00 00 00 00 *(u32 *)(r6 + 0) = r1
; return XDP_PASS; 
10: b7 00 00 00 02 00 00 00 r0 = 2
11: 95 00 00 00 00 00 00 00 exit
第一行进一步确认了 hello.bpf.o 是一个具有 eBPF 代码的 64 位 ELF 文件(某些工具使用术语 BPF 而另一些使用 eBPF 并无特定的差异;正如我之前所说,这些术语现在几乎是可互换的)。
接下来是标记为 xdp 的部分的反汇编,与 C 源代码中的 SEC() 定义相匹配。
这一节是一个名为hello的函数。
有五行 eBPF 字节码指令对应于源代码行bpf_printk("Hello World %d", counter");。
三行 eBPF 字节码指令增加了counter变量。
从源代码 return XDP_PASS; 生成了另外两行字节码。
除非你特别想这么做,没有必要完全理解每条字节码与源代码的关系。编译器负责生成字节码,你不需要去考虑它!但是让我们更详细地检查一下输出,这样你可以感受一下这个输出如何与本章前面学到的 eBPF 指令和寄存器相关联。
在每条字节码的左边,你可以看到该指令与内存中的hello位置的偏移量。如本章前所述,eBPF 指令通常是 8 字节长,由于在 64 位平台上每个内存位置可以容纳 8 字节,因此每条指令的偏移量通常递增一个。但是,该程序的第一条指令恰好是一个广泛的指令编码,需要 16 字节以设置寄存器 6 为0的 64 位值。这将该指令放在输出的第二行,偏移量为2。接着又有另一条 16 字节的指令,将寄存器 1 设置为0的 64 位值。然后,剩余的指令每个占用 8 字节,因此偏移量每行递增一。
每行的第一个字节是操作码,告诉内核执行什么操作,在每条指令行的右侧是指令的人类可读解释。在撰写本文时,Iovisor 项目有关 eBPF 操作码的文档是最全面的,但官方的 Linux 内核文档正在迎头赶上,而 eBPF 基金会正在制作不限于特定操作系统的标准文档。
例如,让我们看看偏移量为5的指令:
5: b7 02 00 00 0f 00 00 00 r2 = 15
操作码是 0xb7,文档告诉我们,对应此操作码的伪代码是 dst = imm,可以理解为“将目标设置为立即值”。目标由第二个字节定义,0x02 意味着“寄存器 2”。这里的“立即值”(或字面值)是 0x0f,即十进制的 15。因此,我们可以理解此指令告诉内核“将寄存器 2 设置为值 15”。这对应我们在指令右侧看到的输出:r2 = 15。
偏移量为 10 的指令类似:
10: b7 00 00 00 02 00 00 00 r0 = 2
此行的操作码也是 0xb7,这次它将寄存器 0 的值设置为 2。当 eBPF 程序运行结束时,寄存器 0 包含返回码,而 XDP_PASS 的值为 2。这与源代码匹配,源代码总是返回 XDP_PASS。
现在您知道 hello.bpf.o 包含一个字节码中的 eBPF 程序。下一步是将其加载到内核中。
将程序加载到内核中
对于本示例,我们将使用一个名为 bpftool 的实用程序。您还可以以编程方式加载程序,稍后在本书中将看到示例。
注意
一些 Linux 发行版提供了一个包,其中包含 bpftool,或者您可以从源代码编译它。您可以在Quentin Monnet 的博客上找到有关安装或构建此工具的更多详细信息,以及在Cilium 网站上的附加文档和用法。
以下是使用 bpftool 将程序加载到内核的示例。请注意,您可能需要 root 权限(或使用 sudo)以获取 bpftool 需要的 BPF 权限。
$ bpftool prog load hello.bpf.o /sys/fs/bpf/hello
这将从我们编译的对象文件中加载 eBPF 程序,并将其“pin”到位置 /sys/fs/bpf/hello。^(4) 此命令的无输出响应表示成功,但您可以使用 ls 确认该程序已经就位:
$ ls /sys/fs/bpf
hello
已成功加载 eBPF 程序。让我们使用 bpftool 实用程序了解有关程序及其在内核中的状态的更多信息。
检查已加载的程序
bpftool 实用程序可以列出加载到内核中的所有程序。如果您自己尝试,可能会在输出中看到几个预先存在的 eBPF 程序,但为了清晰起见,我只会显示与我们的“Hello World”示例相关的行:
$ bpftool prog list
...
540: xdp name hello tag d35b94b4c0c10efb gpl
loaded_at 2022-08-02T17:39:47+0000 uid 0
xlated 96B jited 148B memlock 4096B map_ids 165,166
btf_id 254
该程序已被分配 ID 540。此标识是在加载每个程序时分配的编号。知道了这个 ID,您可以要求 bpftool 显示关于此程序的更多信息。这次,让我们以格式化的 JSON 格式获取输出,以便可见字段名以及值:
$ bpftool prog show id 540 --pretty
{
"id": 540,
"type": "xdp",
"name": "hello",
"tag": "d35b94b4c0c10efb",
"gpl_compatible": true,
"loaded_at": 1659461987,
"uid": 0,
"bytes_xlated": 96,
"jited": true,
"bytes_jited": 148,
"bytes_memlock": 4096,
"map_ids": [165,166
],
"btf_id": 254
}
给定字段名,这些内容大部分都很容易理解:
-
程序的 ID 是 540。
-
type字段告诉我们,此程序可以使用 XDP 事件附加到网络接口。还有几种其他类型的 BPF 程序可以附加到不同类型的事件上,我们将在第七章中进一步讨论。 -
该程序的名称是
hello,这是源代码中的函数名称。 -
tag是该程序的另一个标识符,稍后我会详细描述它。 -
该程序以 GPL 兼容许可证定义。
-
显示程序加载的时间戳。
-
用户 ID 0(即 root 用户)加载了该程序。
-
该程序中有 96 字节的已翻译 eBPF 字节码,稍后我将向您展示。
-
该程序已经进行了 JIT 编译,并且编译结果是 148 字节的机器码。我稍后也会详细讨论这个。
-
bytes _memlock字段告诉我们,该程序保留了 4,096 字节的内存,不会被分页出去。 -
此程序引用了 ID 为 165 和 166 的 BPF 映射。这可能令人惊讶,因为在源代码中并没有明显的映射引用。稍后在本章中,您将看到如何使用映射语义来处理 eBPF 程序中的全局数据。
-
您将在第五章了解有关 BTF 的内容,但现在只需知道
btf_id指示此程序有一个 BTF 信息块。仅当您使用-g标志编译时,此信息才包含在对象文件中。
BPF 程序标签
tag是程序指令的 SHA(安全哈希算法)摘要,可以用作程序的另一个标识符。每次加载或卸载程序时,ID 可能会变化,但标签将保持不变。bpftool实用程序接受通过 ID、名称、标签或固定路径引用 BPF 程序,因此在这里的示例中,以下所有内容将产生相同的输出:
-
bpftool prog show id 540 -
bpftool prog show name hello -
bpftool prog show tag d35b94b4c0c10efb -
bpftool prog show pinned /sys/fs/bpf/hello
您可以具有相同名称的多个程序,甚至具有相同标签的多个程序实例,但 ID 和固定路径将始终是唯一的。
翻译后的字节码
bytes_xlated字段告诉我们经过验证器的 eBPF 字节码中有多少字节的“翻译”代码。这是 eBPF 字节码,在本书后面我将讨论内核可能因为我稍后会讨论的原因对其进行修改。
让我们使用bpftool来显示我们“Hello World”代码的翻译版本:
$ bpftool prog dump xlated name hello
int hello(struct xdp_md * ctx):
; bpf_printk("Hello World %d", counter);
0: (18) r6 = map[id:165][0]+0
2: (61) r3 = *(u32 *)(r6 +0)
3: (18) r1 = map[id:166][0]+0
5: (b7) r2 = 15
6: (85) call bpf_trace_printk#-78032
; counter++;
7: (61) r1 = *(u32 *)(r6 +0)
8: (07) r1 += 1
9: (63) *(u32 *)(r6 +0) = r1
; return XDP_PASS;
10: (b7) r0 = 2
11: (95) exit
这看起来与您之前从llvm-objdump的输出中看到的反汇编代码非常相似。偏移地址相同,指令看起来也很相似,例如我们可以看到偏移量为5的指令是r2=15。
JIT 编译的机器码
转换后的字节码相当低级,但还不是机器码。eBPF 使用 JIT 编译器将 eBPF 字节码转换为在目标 CPU 上本地运行的机器码。bytes_jited 字段显示,在此转换后程序长度为 108 字节。
注意
为了更高的性能,eBPF 程序通常是 JIT 编译的。另一种方法是在运行时解释 eBPF 字节码。eBPF 指令集和寄存器设计得相当接近本机机器指令,使得解释变得直观且相对快速,但编译后的程序将更快,并且大多数体系结构现在支持 JIT。^(5)
bpftool 实用程序可以生成这些 JITed 代码的汇编语言转储。如果你对汇编语言不熟悉,这可能看起来完全难以理解!我只是为了说明从源代码到可执行机器指令的所有转换过程。以下是命令及其输出:
$ bpftool prog dump jited name hello
int hello(struct xdp_md * ctx):
bpf_prog_d35b94b4c0c10efb_hello:
; bpf_printk("Hello World %d", counter);
0: hint #34
4: stp x29, x30, [sp, #-16]!
8: mov x29, sp
c: stp x19, x20, [sp, #-16]!
10: stp x21, x22, [sp, #-16]!
14: stp x25, x26, [sp, #-16]!
18: mov x25, sp
1c: mov x26, #0
20: hint #36
24: sub sp, sp, #0
28: mov x19, #-140733193388033
2c: movk x19, #2190, lsl #16
30: movk x19, #49152
34: mov x10, #0
38: ldr w2, [x19, x10]
3c: mov x0, #-205419695833089
40: movk x0, #709, lsl #16
44: movk x0, #5904
48: mov x1, #15
4c: mov x10, #-6992
50: movk x10, #29844, lsl #16
54: movk x10, #56832, lsl #32
58: blr x10
5c: add x7, x0, #0
; counter++;
60: mov x10, #0
64: ldr w0, [x19, x10]
68: add x0, x0, #1
6c: mov x10, #0
70: str w0, [x19, x10]
; return XDP_PASS;
74: mov x7, #2
78: mov sp, sp
7c: ldp x25, x26, [sp], #16
80: ldp x21, x22, [sp], #16
84: ldp x19, x20, [sp], #16
88: ldp x29, x30, [sp], #16
8c: add x0, x7, #0
90: ret
注意
一些打包的 bpftool 发行版尚未包含支持转储 JIT 输出的功能,如果是这种情况,你将看到“错误:没有 libbfd 支持。”你可以按照https://github.com/libbpf/bpftool上的说明自行构建 bpftool。
你已经看到“Hello World”程序已加载到内核中,但此时它尚未与事件关联,因此没有任何触发器来运行它。它需要附加到一个事件上。
附加到事件
程序类型必须与其附加的事件类型匹配;你将在第七章中了解更多。在这种情况下,它是一个 XDP 程序,你可以使用 bpftool 将示例 eBPF 程序附加到网络接口的 XDP 事件上,如下所示:
$ bpftool net attach xdp id 540 dev eth0
注意
此时,bpftool 实用程序不支持附加所有程序类型的能力,但已被最近扩展以自动附加 k(ret)probes、u(ret)probes 和 tracepoints。
在这里我使用了程序的 ID 540,但你也可以使用名称(假设它是唯一的)或标签来标识被附加的程序。在本例中,我已将程序附加到网络接口 eth0 上。
你可以使用 bpftool 查看所有已附加到网络的 eBPF 程序:
$ bpftool net list
xdp:
eth0(2) driver id 540
tc:
flow_dissector:
ID 为 540 的程序已附加到 eth0 接口的 XDP 事件上。此输出还提供了关于网络堆栈中其他潜在事件的一些线索,你可以将 eBPF 程序附加到其中:tc 和 flow_dissector。更多信息请参见第七章。
你还可以使用 ip link 检查网络接口,你将看到类似以下输出(为清晰起见,已删除了部分细节):
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN mode DEFAULT
group default qlen 1000
...
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 xdp qdisc fq_codel state UP
mode DEFAULT group default qlen 1000
...
prog/xdp id 540 tag 9d0e949f89f1a82c jited
...
在这个例子中有两个接口:回环接口 lo,用于向本机上的进程发送流量;以及 eth0 接口,连接本机与外部世界。该输出还显示 eth0 有一个 JIT 编译的 eBPF 程序,其标识为 540,标签为 9d0e949f89f1a82c,已附加到其 XDP 钩子上。
注意
你也可以使用 ip link 命令将 XDP 程序附加到或从网络接口中分离出来。我已经在本章末尾包含了这个作为练习,并且在 第七章 中有进一步的例子。
此时,hello eBPF 程序应该在每次接收到网络数据包时产生跟踪输出。你可以通过运行 cat /sys/kernel/debug/tracing/trace_pipe 来验证这一点。应该会显示类似于以下内容的大量输出:
<idle>-0 [003] d.s.. 655370.944105: bpf_trace_printk: Hello World 4531
<idle>-0 [003] d.s.. 655370.944587: bpf_trace_printk: Hello World 4532
<idle>-0 [003] d.s.. 655370.944896: bpf_trace_printk: Hello World 4533
如果你记不住跟踪管道的位置,你可以使用命令 bpftool prog tracelog 来获取相同的输出。
与你在 第二章 中看到的输出相比,这一次每个事件的开头都没有与其相关联的命令或进程 ID;相反,你会看到每行跟踪的开头是 <idle>-0。在 第二章 中,每个系统调用事件发生是因为在用户空间执行命令的进程调用了系统调用 API。该进程 ID 和命令是执行 eBPF 程序时的上下文的一部分。但在这个例子中,XDP 事件发生是由于网络数据包的到达。与该数据包相关联的没有用户空间进程——在触发 hello eBPF 程序时,系统仅仅是在内存中接收到该数据包,并且并不知道该数据包是什么,它要去往何处。
你可以看到追踪输出中的计数器值每次递增一个,这是预期的。在源代码中,counter 是一个全局变量。让我们看看如何在 eBPF 中使用映射来实现它。
全局变量
正如你在前一章学到的,eBPF 映射是一种数据结构,可以从 eBPF 程序或用户空间访问。由于同一个映射可以被同一程序的不同运行重复访问,它可以用来在不同执行之间保存状态。多个程序也可以访问同一个映射。由于这些特性,映射的语义可以被重新用作全局变量。
注意
在 2019 年添加全局变量支持之前,eBPF 程序员必须显式编写映射来执行相同的任务。
之前你看到 bpftool 显示了这个示例程序使用了两个映射,它们的标识分别是 165 和 166。(如果你自己尝试,可能会看到不同的标识,因为这些标识是在内核中创建映射时分配的。)让我们来探索一下这些映射中有什么。
bpftool实用程序可以显示加载到内核中的映射。为了清晰起见,我将仅显示与示例“Hello World”程序相关的条目 165 和 166:
$ bpftool map list
165: array name hello.bss flags 0x400
key 4B value 4B max_entries 1 memlock 4096B
btf_id 254
166: array name hello.rodata flags 0x80
key 4B value 15B max_entries 1 memlock 4096B
btf_id 254 frozen
从 C 程序编译的对象文件中的 bss^(6)部分通常保存全局变量,您可以使用bpftool检查其内容,如下所示:
$ bpftool map dump name hello.bss
[{
"value": {
".bss": [{
"counter": 11127
}
]
}
}
]
我也可以使用bpftool map dump id 165来检索相同的信息。如果再次运行任何一个命令,我会看到计数器增加了,因为程序在每次接收网络数据包时都会运行。
正如您将在第五章中了解到的那样,只有在可用 BTF 信息时,bpftool才能漂亮地打印映射(这里是变量名counter)的字段名,并且仅当您在编译时使用-g标志时才包含该信息。如果在编译步骤中省略了该标志,您将看到类似以下的输出:
$ bpftool map dump name hello.bss
key: 00 00 00 00 value: 19 01 00 00
Found 1 element
没有 BTF 信息,bpftool无法知道源代码中使用的变量名。你可以推断,由于此映射中只有一个项目,十六进制值19 01 00 00必须是counter的当前值(281 的十进制,因为字节按最低有效字节起始排序)。
您在这里看到 eBPF 程序使用映射的语义来读取和写入全局变量。映射还用于保存静态数据,如您可以通过检查其他映射看到的那样。
另一个映射命名为hello.rodata表明这可能是与我们的hello程序相关的只读数据。您可以转储此映射的内容以查看它包含的 eBPF 程序用于跟踪的字符串:
$ bpftool map dump name hello.rodata
[{
"value": {
".rodata": [{
"hello.____fmt": "Hello World %d"
}
]
}
}
]
如果您没有使用-g标志编译对象,则会看到类似以下的输出:
$ bpftool map dump id 166
key: 00 00 00 00 value: 48 65 6c 6c 6f 20 57 6f 72 6c 64 20 25 64 00
Found 1 element
此映射中有一个键值对,该值包含 12 个字节的数据,以 0 结尾。这些字节很可能是字符串"Hello World %d"的 ASCII 表示。
现在我们已经完成了检查此程序及其映射的工作,是时候清理它了。我们将从触发它的事件中开始分离它。
分离程序
您可以像这样将程序从网络接口分离:
$ bpftool net detach xdp dev eth0
如果此命令成功运行,则不会输出任何内容,但您可以通过bpftool net list输出中缺少 XDP 条目来确认程序已不再附加:
$ bpftool net list
xdp:
tc:
flow_dissector:
但是,程序仍加载在内核中:
$ bpftool prog show name hello
395: xdp name hello tag 9d0e949f89f1a82c gpl
loaded_at 2022-12-19T18:20:32+0000 uid 0
xlated 48B jited 108B memlock 4096B map_ids 4
卸载程序
在撰写本文时,bpftool prog load没有相反操作(至少没有),但您可以通过删除固定的伪文件从内核中删除程序:
$ rm /sys/fs/bpf/hello
$ bpftool prog show name hello
由于程序不再加载到内核中,因此此bpftool命令不会输出任何内容。
BPF 到 BPF 调用
在上一章中,您看到了尾调用的实际应用,并且我提到现在还可以从 eBPF 程序内调用函数的能力。让我们看一个简单的例子,就像尾调用示例一样,它可以附加到 sys_enter 跟踪点,但这次它将跟踪系统调用的操作码。您将在 chapter3/hello-func.bpf.c 中找到这段代码。
为了说明问题,我编写了一个非常简单的函数,用于从跟踪点参数中提取系统调用操作码:
static __attribute((noinline)) int get_opcode(struct bpf_raw_tracepoint_args
*ctx) {
return ctx->args[1];
}
如果可以选择,编译器可能会内联这个非常简单的函数,我只会从一个地方调用它。由于这样会破坏这个示例的目的,我添加了 __attribute((noinline)) 来强制编译器的行为。在正常情况下,您应该省略这一点,允许编译器根据需要进行优化。
调用此函数的 eBPF 函数如下所示:
SEC("raw_tp")
int hello(struct bpf_raw_tracepoint_args *ctx) {
int opcode = get_opcode(ctx);
bpf_printk("Syscall: %d", opcode);
return 0;
}
将其编译为 eBPF 对象文件后,您可以将其加载到内核中,并使用 bpftool 确认已加载:
$ bpftool prog load hello-func.bpf.o /sys/fs/bpf/hello
$ bpftool prog list name hello
893: raw_tracepoint name hello tag 3d9eb0c23d4ab186 gpl
loaded_at 2023-01-05T18:57:31+0000 uid 0
xlated 80B jited 208B memlock 4096B map_ids 204
btf_id 302
此练习的有趣部分是检查 eBPF 字节码以查看 get_opcode() 函数:
$ bpftool prog dump xlated name hello
int hello(struct bpf_raw_tracepoint_args * ctx):
; int opcode = get_opcode(ctx); 
0: (85) call pc+7#bpf_prog_cbacc90865b1b9a5_get_opcode
; bpf_printk("Syscall: %d", opcode);
1: (18) r1 = map[id:193][0]+0
3: (b7) r2 = 12
4: (bf) r3 = r0
5: (85) call bpf_trace_printk#-73584
; return 0;
6: (b7) r0 = 0
7: (95) exit
int get_opcode(struct bpf_raw_tracepoint_args * ctx): 
; return ctx->args[1];
8: (79) r0 = *(u64 *)(r1 +8)
; return ctx->args[1];
9: (95) exit
在这里,您可以看到 hello() eBPF 程序调用了 get_opcode()。偏移量为 0 的 eBPF 指令是 0x85,根据指令集文档,它对应于“函数调用”。而不是执行下一个指令(在偏移量 1 处),执行将跳过七个指令(pc+7),这意味着在偏移量 8 处的指令。
这是 get_opcode() 的字节码,正如您希望的那样,第一条指令位于偏移量 8 处。
函数调用指令要求将当前状态放入 eBPF 虚拟机的堆栈中,以便在调用的函数退出时,执行可以继续在调用函数中进行。由于堆栈大小限制为 512 字节,BPF 到 BPF 的调用不能太深嵌套。
注意
有关尾调用和 BPF 到 BPF 调用的更多详细信息,请参阅 Cloudflare 博客上 Jakub Sitnicki 的优秀文章:“在内!x86 和 ARM 上的 BPF 尾调用”。
摘要
在本章中,您看到了一些示例 C 源代码转换为 eBPF 字节码,然后编译为机器代码,以便在内核中执行。您还学习了如何使用 bpftool 检查加载到内核中的程序和映射,并附加到 XDP 事件。
此外,您还看到了由不同类型事件触发的不同类型的 eBPF 程序示例。XDP 事件是由数据包在网络接口上的到达触发的,而 kprobe 和 tracepoint 事件是由命中内核代码的某个特定点触发的。我将在第七章讨论一些其他 eBPF 程序类型。
您还学习了如何使用映射来实现 eBPF 程序的全局变量,并看到了 BPF 到 BPF 函数调用。
下一章将进一步详细地介绍当bpftool或任何其他用户空间代码加载程序并将其附加到事件时,系统调用级别发生了什么。
练习
如果你想进一步探索 BPF 程序,可以尝试以下几件事情:
-
尝试使用像下面这样的
ip link命令来附加和分离 XDP 程序:$ ip link set dev eth0 xdp obj hello.bpf.o sec xdp $ ip link set dev eth0 xdp off -
运行来自第二章的任何 BCC 示例。在程序运行时,使用第二个终端窗口使用
bpftool检查加载的程序。以下是我运行hello-map.py示例时看到的示例:$ bpftool prog show name hello 197: kprobe name hello tag ba73a317e9480a37 gpl loaded_at 2022-08-22T08:46:22+0000 uid 0 xlated 296B jited 328B memlock 4096B map_ids 65 btf_id 179 pids hello-map.py(2785)你也可以使用
bpftool prog dump命令来查看这些程序的字节码和机器码版本。 -
在chapter2目录中运行hello-tail.py,同时它在运行时查看加载的程序。你会看到每个尾调用程序都单独列出,就像这样:
$ bpftool prog list ... 120: raw_tracepoint name hello tag b6bfd0e76e7f9aac gpl loaded_at 2023-01-05T14:35:32+0000 uid 0 xlated 160B jited 272B memlock 4096B map_ids 29 btf_id 124 pids hello-tail.py(3590) 121: raw_tracepoint name ignore_opcode tag a04f5eef06a7f555 gpl loaded_at 2023-01-05T14:35:32+0000 uid 0 xlated 16B jited 72B memlock 4096B btf_id 124 pids hello-tail.py(3590) 122: raw_tracepoint name hello_exec tag 931f578bd09da154 gpl loaded_at 2023-01-05T14:35:32+0000 uid 0 xlated 112B jited 168B memlock 4096B btf_id 124 pids hello-tail.py(3590) 123: raw_tracepoint name hello_timer tag 6c3378ebb7d3a617 gpl loaded_at 2023-01-05T14:35:32+0000 uid 0 xlated 336B jited 356B memlock 4096B btf_id 124 pids hello-tail.py(3590)你也可以使用
bpftool prog dump xlated来查看字节码指令,并将其与“BPF to BPF Calls”中看到的内容进行比较。 -
要小心这个问题,最好的做法可能是简单地思考为什么会发生这种情况,而不是试图去做! 如果从一个 XDP 程序返回
0值,这对应于XDP_ABORTED,告诉内核放弃进一步处理这个数据包。这可能有点违反直觉,因为在 C 语言中,0值通常表示成功,但事实就是如此。所以,如果你尝试修改程序返回0并将其附加到虚拟机的eth0接口,所有网络数据包将会被丢弃。如果你正在使用 SSH 连接到这台机器,这将会有些不幸,你可能需要重新启动机器来恢复访问!你可以在容器内运行程序,这样 XDP 程序就会附加到仅影响该容器而不是整个虚拟机的虚拟以太网接口上。在https://github.com/lizrice/lb-from-scratch有一个实现这一点的示例。
^(1) 越来越多的 eBPF 程序也开始使用 Rust 编写,因为 Rust 编译器支持 eBPF 字节码作为目标。
^(2) 有几条指令的操作是通过指令中其他字段的值“修改”的。例如,在内核 5.12 中引入了一组原子指令,包括一个在`imm`字段中指定的算术操作(`ADD`、`AND`、`OR`、`XOR)。
^(3) 生成 BTF 信息需要使用-g标志,这对于 CO-RE eBPF 程序是必需的,我将在第五章中讨论。
^(4) 通常情况下,这是可选的——eBPF 程序可以加载到内核中而不被固定在文件位置上——但对于bpftool来说不是可选的,它加载的程序必须固定。这个原因在“BPF 程序和映射引用”中进一步讨论。
^(5) 要利用 JIT 编译,需要启用内核设置CONFIG_BPF_JIT,可以通过net.core.bpf_jit_enable sysctl设置在运行时启用或禁用。有关不同芯片架构上 JIT 支持的更多信息,请参阅文档。
^(6) 在这里,bss代表“block started by symbol”。
第四章:bpf()系统调用
正如您在第一章中看到的,当用户空间应用程序希望内核代表它们执行某些操作时,它们使用系统调用 API 发出请求。因此,如果用户空间应用程序希望将 eBPF 程序加载到内核中,必须涉及一些系统调用。实际上,有一个名为bpf()的系统调用,在本章中我将向您展示如何使用它来加载和与 eBPF 程序和映射交互。
值得注意的是,运行在内核中的 eBPF 代码不使用系统调用来访问映射。系统调用接口仅由用户空间应用程序使用。相反,eBPF 程序使用辅助函数来读取和写入映射;您已经在前两章中看到了这方面的示例。
如果您继续编写自己的 eBPF 程序,有很大的机会您不会直接调用这些bpf()系统调用。本书后面将讨论提供更高级抽象以简化操作的库。尽管如此,这些抽象通常相当直接地映射到您将在本章中看到的底层系统调用命令。无论使用哪种库,您都需要掌握底层操作——加载程序、创建和访问映射等——这些操作将在本章中看到。
在我向您展示bpf()系统调用示例之前,让我们考虑一下bpf()的 man 页面中写着,bpf()用于“在扩展 BPF 映射或程序上执行命令”。它还告诉我们,bpf()的签名如下:
int bpf(int cmd, union bpf_attr *attr, unsigned int size);
bpf()的第一个参数cmd指定要执行的命令。bpf()系统调用不只是做一件事情——有很多不同的命令可以用来操作 eBPF 程序和映射。图 4-1 展示了用户空间代码可能用来加载 eBPF 程序、创建映射、将程序附加到事件以及访问映射中键值对的一些常见命令概述。

图 4-1. 用户空间程序通过系统调用与内核中的 eBPF 程序和映射交互
bpf()系统调用的attr参数包含用于指定命令参数的任何数据,size指示attr中有多少字节的数据。
您已经在第一章中遇到了strace,我在那里使用它来展示用户空间代码如何通过系统调用 API 发出许多请求。在本章中,我将使用它来演示如何使用bpf()系统调用。strace的输出包括每个系统调用的参数,但为了保持本章中示例输出的简洁性,我将省略attr参数的许多细节,除非它们特别有趣。
注意
您将在github.com/lizrice/learning-ebpf找到代码,以及设置运行环境的说明。本章的代码位于 chapter4 目录中。
对于这个示例,我将使用一个名为 hello-buffer-config.py 的 BCC 程序,它构建在您在第 2 章看到的示例基础之上。与 hello-buffer.py 示例类似,此程序在每次运行时都将消息发送到性能缓冲区,从内核向用户空间传递有关 execve() 系统调用事件的信息。这个版本的新功能是允许为每个用户 ID 配置不同的消息。
这是 eBPF 源代码:
struct user_msg_t { 
char message[12];
};
BPF_HASH(config, u32, struct user_msg_t); 
BPF_PERF_OUTPUT(output); 
struct data_t { 
int pid;
int uid;
char command[16];
char message[12];
};
int hello(void *ctx) { 
struct data_t data = {};
struct user_msg_t *p;
char message[12] = "Hello World";
data.pid = bpf_get_current_pid_tgid() >> 32;
data.uid = bpf_get_current_uid_gid() & 0xFFFFFFFF;
bpf_get_current_comm(&data.command, sizeof(data.command));
p = config.lookup(&data.uid); 
if (p != 0) {
bpf_probe_read_kernel(&data.message, sizeof(data.message), p->message);
} else {
bpf_probe_read_kernel(&data.message, sizeof(data.message), message);
}
output.perf_submit(ctx, &data, sizeof(data));
return 0;
}
此行指示存在一个结构定义 user_msg_t,用于保存一个 12 字符消息。
BCC 宏 BPF_HASH 用于定义一个名为 config 的哈希表映射。它将以 u32 类型的键索引类型为 user_msg_t 的值,这对于用户 ID 来说是正确大小的。(如果您不指定键和值的类型,BCC 默认为 u64)
性能缓冲输出的定义方式与第 2 章完全相同。您可以向缓冲区提交任意数据,因此这里无需指定任何数据类型…
…尽管在实践中,在此示例中程序总是提交一个 data_t 结构。这与第 2 章的示例也没有变化。
大部分 eBPF 程序其余部分与您之前看到的 hello() 版本没有变化。
唯一的区别在于使用了一个帮助函数来获取用户 ID 后,代码查找 config 哈希映射中具有该用户 ID 作为键的条目。如果有匹配的条目,该值包含一个替代默认“Hello World”的消息。
Python 代码有两行额外内容:
b["config"][ct.c_int(0)] = ct.create_string_buffer(b"Hey root!")
b["config"][ct.c_int(501)] = ct.create_string_buffer(b"Hi user 501!")
这些为 config 哈希表中的用户 ID 0 和 501 定义了消息,分别对应于根用户和我在此虚拟机上的用户 ID。此代码使用 Python 的 ctypes 包确保键和值的类型与 user_msg_t 的 C 定义中使用的类型相同。
这是这个示例的一些说明性输出,以及我在第二个终端中运行的命令:
Terminal 1 Terminal 2
$ ./hello-buffer-config.py
37926 501 bash Hi user 501! ls
37927 501 bash Hi user 501! sudo ls
37929 0 sudo Hey root!
37931 501 bash Hi user 501! sudo -u daemon ls
37933 1 sudo Hello World
现在您已经了解了这个程序的功能,我想展示一下运行时使用的 bpf() 系统调用。我将再次使用 strace 运行它,并指定 -e bpf 表示我只关心看到 bpf() 系统调用:
$ strace -e bpf ./hello-buffer-config.py
如果你自己尝试,你将看到输出中显示了几次对此系统调用的调用。对于每次调用,你将看到指示bpf()系统调用应执行什么操作的命令。大致概述如下:
bpf(BPF_BTF_LOAD, ...) = 3
bpf(BPF_MAP_CREATE, {map_type=BPF_MAP_TYPE_PERF_EVENT_ARRAY…) = 4
bpf(BPF_MAP_CREATE, {map_type=BPF_MAP_TYPE_HASH...) = 5
bpf(BPF_PROG_LOAD, {prog_type=BPF_PROG_TYPE_KPROBE,...prog_name="hello",...) = 6
bpf(BPF_MAP_UPDATE_ELEM, ...}
...
让我们逐一检查它们。既然你和我都没有无限耐心,我不会讨论每个调用的每个参数!我将专注于那些我认为真正有助于讲述当用户空间程序与 eBPF 程序交互时发生了什么的部分。
加载 BTF 数据
我看到的第一个bpf()调用如下:
bpf(BPF_BTF_LOAD, {btf="\237\353\1\0...}, 128) = 3
在这种情况下,你可以在输出中看到的命令是BPF_BTF_LOAD。这只是一组有效命令中的一个(至少在撰写本文时是如此),这些命令在内核源代码中得到了最全面的文档记录。^(1)
如果你使用的是相对较旧的 Linux 内核,可能不会看到此命令的调用,因为它涉及到 BTF 或 BPF 类型格式。^(2) BTF 允许 eBPF 程序在不同的内核版本之间可移植,因此你可以在一台机器上编译程序,然后在另一台机器上使用,即使它使用的是不同的内核版本,因此具有不同的内核数据结构。我将在第五章中详细讨论这个问题。
此次bpf()调用正在将一块 BTF 数据加载到内核中,而bpf()系统调用的返回代码(在我的示例中为3)是一个文件描述符,指向该数据。
注意
文件描述符是打开文件(或类似文件对象)的标识符。如果你使用open()或openat()系统调用打开一个文件,返回的代码就是文件描述符,然后可以将其作为参数传递给其他系统调用,如read()或write(),以执行对该文件的操作。这里的数据块并不完全是文件,但它确实获得了文件描述符作为标识符,以便将来引用它时使用。
创建映射
接下来的bpf()创建了output性能缓冲映射:
bpf(BPF_MAP_CREATE, {map_type=BPF_MAP_TYPE_PERF_EVENT_ARRAY, , key_size=4,
value_size=4, max_entries=4, ... map_name="output", ...}, 128) = 4
你可以从命令名称BPF_MAP_CREATE推测出,此调用创建了一个 eBPF 映射。你可以看到,这个映射的类型是PERF_EVENT_ARRAY,名为output。在这个性能事件映射中,键和值都是 4 字节长。此映射最多可以容纳四对键-值,由max_entries字段定义;我稍后会解释为什么这个映射中有四个条目。返回值4是用户空间代码访问output映射的文件描述符。
输出中的下一个bpf()系统调用创建了config映射:
bpf(BPF_MAP_CREATE, {map_type=BPF_MAP_TYPE_HASH, key_size=4, value_size=12,
max_entries=10240... map_name="config", ...btf_fd=3,...}, 128) = 5
此映射被定义为哈希表映射,键长为 4 字节(对应于可以用于保存用户 ID 的 32 位整数),值长为 12 字节(与msg_t结构的长度相匹配)。我没有指定表的大小,因此它使用了 BCC 的默认大小,即 10,240 个条目。
这个 bpf() 系统调用也返回一个文件描述符,5,它将被用来在未来的系统调用中引用这个 config 映射。
你还可以看到 btf_fd=3 字段,它告诉内核要使用之前获取的 BTF 文件描述符 3。正如你将在第五章看到的那样,BTF 信息描述了数据结构的布局,并且将其包含在映射的定义中意味着关于在该映射中使用的键和值类型布局的信息。这些信息由像 bpftool 这样的工具用来打印映射转储,使其更易读——你在第三章中已经看到了一个示例。
加载程序
到目前为止,你已经看到了示例程序使用系统调用将 BTF 数据加载到内核并创建了一些 eBPF 映射。接下来要做的是使用以下 bpf() 系统调用加载正在加载到内核中的 eBPF 程序:
bpf(BPF_PROG_LOAD, {prog_type=BPF_PROG_TYPE_KPROBE, insn_cnt=44,
insns=0xffffa836abe8, license="GPL", ... prog_name="hello", ...
expected_attach_type=BPF_CGROUP_INET_INGRESS, prog_btf_fd=3,...}, 128) = 6
这里有一些字段非常有趣:
-
prog_type字段描述了程序类型,这里指示它是要附加到 kprobe。你将在第七章中进一步了解程序类型。 -
insn_cnt字段表示“指令计数”。这是程序中的字节码指令数。 -
组成这个 eBPF 程序的字节码指令保存在
insns字段指定的内存地址中。 -
此程序被指定为 GPL 许可,因此它可以使用 GPL 许可的 BPF 辅助函数。
-
程序名称是
hello。 -
expected_attach_type是BPF_CGROUP_INET_INGRESS,这可能听起来令人惊讶,因为它听起来与入口网络流量有关,但你知道这个 eBPF 程序将要附加到一个 kprobe 上。实际上,expected_attach_type字段仅用于某些程序类型,并且BPF_PROG_TYPE_KPROBE不在其中。BPF_CGROUP_INET_INGRESS恰好是 BPF 附加类型列表中的第一个,^(3) 因此它的值是0。 -
prog_btf_fd字段告诉内核要使用之前加载的 BTF 数据块来执行这个程序。这里的值3对应于从BPF_BTF_LOAD系统调用返回的文件描述符(而且这与config映射使用的同一个 BTF 数据块相同)。
如果程序验证失败(我将在第六章中讨论),此系统调用将返回一个负值,但在这里你可以看到它返回了文件描述符 6。总结一下,此时文件描述符的含义如表 4-1 所示。
表 4-1. 运行 hello-buffer-config.py 时的文件描述符
| 文件描述符 | 代表 |
|---|---|
3 |
BTF 数据 |
4 |
output 性能缓冲区映射 |
5 |
config 哈希表映射 |
6 |
hello eBPF 程序 |
修改用户空间中的映射
您已经在 Python 用户空间源代码中看到了一行,该行配置了将显示给根用户(用户 ID 0)和用户 ID 501 的特殊消息:
b["config"][ct.c_int(0)] = ct.create_string_buffer(b"Hey root!")
b["config"][ct.c_int(501)] = ct.create_string_buffer(b"Hi user 501!")
您可以通过这样的系统调用看到这些条目是如何在映射中定义的:
bpf(BPF_MAP_UPDATE_ELEM, {map_fd=5, key=0xffffa7842490, value=0xffffa7a2b410,
flags=BPF_ANY}, 128) = 0
BPF_MAP_UPDATE_ELEM 命令更新映射中的键值对。 BPF_ANY 标志表示,如果该映射中不存在该键,则应创建它。 这两个调用对应于为两个不同用户 ID 配置的两个条目。
map_fd 字段标识正在操作的映射。 您可以看到在这种情况下它是 5,这是早些时候创建 config 映射时返回的文件描述符值。
文件描述符由内核为特定进程分配,因此 5 的这个值仅对运行 Python 程序的特定用户空间进程有效。 但是,多个用户空间程序(以及内核中的多个 eBPF 程序)都可以访问相同的映射。 两个访问内核中同一映射结构的用户空间程序可能会被分配不同的文件描述符值;同样,两个用户空间程序可能会对完全不同的映射使用相同的文件描述符值。
键和值都是指针,因此无法从此 strace 输出中知道键或值的数值。 但是,您可以使用 bpftool 查看映射的内容,并看到类似以下的内容:
$ bpftool map dump name config
[{
"key": 0,
"value": {
"message": "Hey root!"
}
},{
"key": 501,
"value": {
"message": "Hi user 501!"
}
}
]
bpftool 如何知道如何格式化此输出呢? 例如,它如何知道值是一个结构,其中有一个名为 message 的字段,其中包含一个字符串? 答案是它使用 BPF_MAP_CREATE 系统调用中包含的 BTF 信息中定义的定义。 在下一章节中,您将看到有关 BTF 如何传达此信息的更多详细信息。
您现在已经看到用户空间如何与内核交互以加载程序和映射,并更新映射中的信息。 在您到目前为止看到的系统调用序列中,程序尚未附加到事件。 这一步必须发生;否则,程序将永远不会被触发。
公平警告:不同类型的 eBPF 程序以各种不同的方式附加到不同事件上! 在本例中,稍后我将向您展示用于附加到 kprobe 事件的示例中使用的系统调用,而在这种情况下它不涉及 bpf()。 相比之下,在本章末尾的练习中,我将向您展示另一个示例,在该示例中使用 bpf() 系统调用将程序附加到原始跟踪点事件。
在我们深入探讨这些细节之前,我想讨论一下当您停止运行程序时会发生什么。 您会发现程序和映射会自动卸载,这是因为内核使用 引用计数 来跟踪它们。
BPF 程序和映射引用
你知道使用 bpf() 系统调用将 BPF 程序加载到内核中会返回一个文件描述符。在内核中,这个文件描述符是对程序的 引用。进行此系统调用的用户空间进程拥有此文件描述符;当该进程退出时,文件描述符将被释放,程序的引用计数将减少。当没有引用指向 BPF 程序时,内核将删除该程序。
固定 程序到文件系统时会创建额外的引用。
固定
在 第三章 中,你已经看到了固定的示例,具体命令如下:
bpftool prog load hello.bpf.o /sys/fs/bpf/hello
注意
这些固定的对象并不是真实存储在磁盘上的文件。它们是在 伪文件系统 上创建的,其行为类似于具有目录和文件的常规基于磁盘的文件系统。但它们保存在内存中,这意味着它们在系统重启后不会保持原位。
如果 bpftool 允许你在不固定的情况下加载程序,那将毫无意义,因为当 bpftool 退出时文件描述符会释放,如果引用计数为零,程序会被删除,因此没有实现任何有用的功能。但是将其固定到文件系统意味着程序有了额外的引用,因此程序在命令完成后仍然加载。
当将 BPF 程序附加到触发它的钩子上时,引用计数器也会增加。这些引用计数的行为取决于 BPF 程序的类型。关于这些程序类型,你将在 第七章 中了解更多,但有些与跟踪相关(如 kprobes 和 tracepoints),总是与用户空间进程相关联;对于这些类型的 eBPF 程序,内核的引用计数在该进程退出时会减少。附加在网络堆栈或 cgroups(“控制组”)中的程序与任何用户空间进程无关联,因此即使加载它们的用户空间程序退出后,它们也会保持在原位。当使用 ip link 命令加载 XDP 程序时,你已经看到了这种情况的一个例子:
ip link set dev eth0 xdp obj hello.bpf.o sec xdp
ip 命令已完成,并没有固定位置的定义,但是 bpftool 将显示 XDP 程序已加载到内核中:
$ bpftool prog list
…
1255: xdp name hello tag 9d0e949f89f1a82c gpl
loaded_at 2022-11-01T19:21:14+0000 uid 0
xlated 48B jited 108B memlock 4096B map_ids 612
由于连接到 XDP 钩子后,此程序的引用计数不为零,即使 ip link 命令完成后也是如此。
eBPF 映射也有引用计数器,当其引用计数降至零时会进行清理。每个使用映射的 eBPF 程序都会增加计数器,用户空间程序持有的每个文件描述符也会增加计数器。
有可能 eBPF 程序的源代码定义了一个程序实际上不引用的映射。假设您想存储关于程序的一些元数据;您可以将其定义为全局变量,并且正如您在上一章中看到的那样,这些信息会存储在一个映射中。如果 eBPF 程序对该映射什么也不做,则程序到映射之间不会自动存在引用计数。有一个BPF(BPF_PROG_BIND_MAP)系统调用将一个映射与一个程序关联起来,以便在用户空间加载程序退出并且不再持有映射的文件描述符引用时,映射不会立即被清除。
映射也可以固定到文件系统,并且用户空间程序可以通过知道映射的路径来访问该映射。
注意
Alexei Starovoitov 在他的博客文章“BPF 对象的生命周期”中对 BPF 引用计数器和文件描述符进行了很好的描述。
另一种创建对 BPF 程序的引用的方式是使用 BPF 链接。
BPF 链接
BPF 链接为 eBPF 程序和其附加的事件之间提供了一层抽象。BPF 链接本身可以固定到文件系统上,这样会为程序创建一个额外的引用。这意味着加载程序将 eBPF 程序加载到内核中的用户空间进程可以终止,但是程序仍然保持加载状态。用户空间加载程序的文件描述符被释放,减少对程序的引用计数,但由于 BPF 链接的存在,引用计数将不为零。
如果您在本章结束后跟随练习,您将有机会看到 BPF 链接的实际操作。现在,让我们回到hello-buffer-config.py中使用的bpf()系统调用序列。
eBPF 涉及的其他系统调用
总结一下,到目前为止您已经看到了用于向内核添加 BTF 数据、程序和映射以及映射数据的bpf()系统调用。接下来,strace输出显示的下一步与设置性能缓冲区有关。
注意
本章的其余部分将相对深入地探讨在使用性能缓冲区、环形缓冲区、kprobes 和映射迭代时涉及的系统调用序列。并非所有的 eBPF 程序都需要执行这些操作,所以如果您赶时间或者感觉内容有点过于详细,可以直接跳到章节总结部分,我不会介意的!
初始化性能缓冲区
您已经看到了bpf(BPF_MAP_UPDATE_ELEM)调用,该调用将条目添加到config映射中。接下来,输出显示了一些类似以下的调用:
bpf(BPF_MAP_UPDATE_ELEM, {map_fd=4, key=0xffffa7842490, value=0xffffa7a2b410,
flags=BPF_ANY}, 128) = 0
这些看起来与定义config映射条目的调用非常相似,不同之处在于此时映射的文件描述符为4,代表了output性能缓冲区映射。
与之前一样,键和值都是指针,因此你无法从这个 strace 输出中得知键或值的数值。我看到这个系统调用重复了四次,所有参数的值都相同,尽管无法知道指针所持有的值在每次调用之间是否有变化。查看这些 BPF_MAP_UPDATE_ELEM bpf() 调用留下了一些关于如何设置和使用缓冲区的未解答问题:
-
为什么会有四次调用
BPF_MAP_UPDATE_ELEM?这是否与outputmap 创建时最多四个条目有关? -
在这四个
BPF_MAP_UPDATE_ELEM实例之后,strace输出中不再出现更多的bpf()系统调用。这可能看起来有点奇怪,因为这个映射存在的目的是使 eBPF 程序每次触发时能够写入数据,而你已经看到用户空间代码显示了数据。显然,这些数据并未通过bpf()系统调用从映射中检索,那么它是如何获取的?
你还没有看到任何证据表明 eBPF 程序是如何附加到触发它的 kprobe 事件上的。要解释所有这些问题,我需要 strace 在运行这个例子时显示更多的系统调用,就像这样:
$ strace -e bpf,perf_event_open,ioctl,ppoll ./hello-buffer-config.py
为简洁起见,我将忽略那些与这个例子的 eBPF 功能不相关的 ioctl() 调用。
附加到 Kprobe 事件上
你已经看到文件描述符 6 被分配来代表内核中加载的 eBPF 程序 hello。为了将 eBPF 程序附加到事件上,你还需要一个文件描述符来代表特定的事件。strace 输出中的以下行显示了为 execve() kprobe 创建文件描述符的过程:
perf_event_open({type=0x6 /* PERF_TYPE_??? */, ...},...) = 7
根据 manpage for the perf_event_open() syscall 所述,“创建一个文件描述符,允许测量性能信息。” 你可以从输出中看到 strace 不知道如何解释类型参数值为 6,但如果进一步查看该 manpage,它描述了 Linux 支持性能测量单元的动态类型:
…在 /sys/bus/event_source/devices 下每个 PMU 实例都有一个子目录。在每个子目录中,有一个类型文件,其内容是一个整数,可以在类型字段中使用。
如果你确实在该目录下查找,你会找到一个 kprobe/type 文件:
$ cat /sys/bus/event_source/devices/kprobe/type
6
从这里,你可以看到 perf_event_open() 的调用设置了类型值为 6,表示它是一种 kprobe 类型的性能事件。
strace 并没有输出详细信息来确切显示 kprobe 已经附加到 execve() 系统调用上,但我希望这里的证据足以说服你,这个文件描述符返回的是这个。
perf_event_open()的返回代码是7,这表示了 kprobe 的性能事件的文件描述符,你知道文件描述符6表示eBPF程序的hello。perf_event_open()的手册还解释了如何使用ioctl()来创建两者之间的附加关系:
PERF_EVENT_IOC_SET_BPF[...] 允许将一个伯克利数据包过滤(BPF)程序附加到一个已存在的 kprobe 跟踪点事件上。该参数是通过先前的bpf(2)系统调用创建的 BPF 程序文件描述符。
这解释了你会在strace输出中看到的以下ioctl()系统调用,其参数涉及两个文件描述符:
ioctl(7, PERF_EVENT_IOC_SET_BPF, 6) = 0
还有另一个ioctl()调用来启用 kprobe 事件:
ioctl(7, PERF_EVENT_IOC_ENABLE, 0) = 0
有了这个设置,每当在这台机器上运行execve()时,eBPF 程序就应该会被触发。
设置和读取性能事件
我已经提到了,我看到了四次调用bpf(BPF_MAP_UPDATE_ELEM)与输出性能缓冲相关联。随着额外的系统调用被跟踪,strace输出显示了四个序列,如下所示:
perf_event_open({type=PERF_TYPE_SOFTWARE, size=0 /* PERF_ATTR_SIZE_??? */,
config=PERF_COUNT_SW_BPF_OUTPUT, ...}, -1, X, -1, PERF_FLAG_FD_CLOEXEC) = Y
ioctl(Y, PERF_EVENT_IOC_ENABLE, 0) = 0
bpf(BPF_MAP_UPDATE_ELEM, {map_fd=4, key=0xffffa7842490, value=0xffffa7a2b410,
flags=BPF_ANY}, 128) = 0
我在这里使用了X来指示输出在四个此调用实例中显示值0、1、2和3的位置。参考perf_event_open()系统调用的手册页面,你会看到这是cpu,而它之前的字段是pid或进程 ID。从手册页面来看:
pid == -1 且 cpu >= 0
这会测量指定 CPU 上的所有进程/线程。
这个序列发生四次的事实对应于我笔记本电脑中存在四个 CPU 核心。这最终解释了为什么“output”性能缓冲区映射中有四个条目的原因:每个 CPU 核心都有一个。这也解释了映射类型名称BPF_MAP_TYPE_PERF_EVENT_ARRAY中“array”的部分,因为该映射不仅仅表示一个性能环缓冲区,而是一个缓冲区数组,每个核心一个。
如果你编写 eBPF 程序,你不需要担心如何处理核心数量等细节,因为在第十章讨论的任何 eBPF 库中,这些细节都会为你处理,但我认为这是在使用strace分析该程序时看到的系统调用的一个有趣方面。
每个perf_event_open()调用都返回一个文件描述符,我将它们表示为Y;它们的值分别是8、9、10和11。ioctl()系统调用使得每个 CPU 核心的性能输出启用了对应的性能环缓冲区的映射条目以指示可以提交数据的位置。
用户空间代码可以随后在这四个输出流文件描述符上使用ppoll(),以便在任何给定的execve() kprobe 事件触发eBPF程序hello时获取数据输出。这是对ppoll()的系统调用:
ppoll([{fd=8, events=POLLIN}, {fd=9, events=POLLIN}, {fd=10, events=POLLIN},
{fd=11, events=POLLIN}], 4, NULL, NULL, 0) = 1 ([{fd=8, revents=POLLIN}])
如果你自己尝试运行示例程序,你会发现这些ppoll()调用会阻塞,直到有文件描述符有数据可读。只有在触发execve()时,才会将返回码写入屏幕,这会导致 eBPF 程序使用这个ppoll()调用从用户空间检索数据。
在第二章中,我提到如果你的内核版本是 5.8 或以上,BPF 环形缓冲区现在优先于性能缓冲区。^(4) 让我们看一下使用环形缓冲区的同一示例代码的修改版本。
环形缓冲区
如内核文档中所述,环形缓冲区比性能缓冲区更受欢迎,部分原因是性能,但也确保数据的顺序性保持,即使数据由不同 CPU 核心提交。只有一个缓冲区,跨所有核心共享。
转换hello-buffer-config.py以使用环形缓冲区几乎不需要进行太多更改。在附带的 GitHub 仓库中,你会找到这个示例作为chapter4/hello-ring-buffer-config.py。表 4-2 展示了差异。
表 4-2. 使用性能缓冲区和环形缓冲区的示例 BCC 代码之间的差异
| hello-buffer-config.py | hello-ring-buffer-config.py |
|---|---|
BPF_PERF_OUTPUT(output); |
BPF_RINGBUF_OUTPUT(output, 1); |
output.perf_submit(ctx, &data, sizeof(data)); |
output.ringbuf_output(&data, sizeof(data), 0); |
b["output"]. open_perf_buffer(print_event) |
b["output"]. open_ring_buffer(print_event) |
b.perf_buffer_poll() |
b.ring_buffer_poll() |
正如你所预期的那样,由于这些变化仅涉及output缓冲区,加载程序和config映射以及将程序附加到 kprobe 事件相关的系统调用都保持不变。
创建output环形缓冲区映射的bpf()系统调用如下:
bpf(BPF_MAP_CREATE, {map_type=BPF_MAP_TYPE_RINGBUF, key_size=0, value_size=0,
max_entries=4096, ... map_name="output", ...}, 128) = 4
strace输出中的主要差异在于,在设置性能缓冲区时,你观察到的四个不同的perf_event_open()、ioctl()和bpf(BPF_MAP_UPDATE_ELEM)系统调用序列不再存在。对于环形缓冲区,只有一个文件描述符被所有 CPU 核心共享。
在撰写本文时,BCC 正在使用我之前展示的ppoll机制来进行性能缓冲区,但它使用更新的epoll机制来等待环形缓冲区的数据。让我们利用这个机会来理解ppoll和epoll之间的区别。
在性能缓冲区示例中,我展示了hello-buffer-config.py生成ppoll()系统调用,如下所示:
ppoll([{fd=8, events=POLLIN}, {fd=9, events=POLLIN}, {fd=10, events=POLLIN},
{fd=11, events=POLLIN}], 4, NULL, NULL, 0) = 1 ([{fd=8, revents=POLLIN}])
请注意,此处传入了文件描述符集 8、9、10 和 11,用户空间进程希望从中检索数据。每次此轮询事件返回数据时,必须再次调用 ppoll() 来设置相同的文件描述符集。在使用 epoll 时,文件描述符集由内核对象管理。
当 hello-ring-buffer-config.py 设置访问 output 环形缓冲区时,您可以在以下 epoll 相关系统调用序列中看到这一点。
首先,用户空间程序请求在内核中创建一个新的 epoll 实例:
epoll_create1(EPOLL_CLOEXEC) = 8
这返回文件描述符 8。然后调用 epoll_ctl(),告知内核将文件描述符 4(output 缓冲区)添加到该 epoll 实例的文件描述符集中:
epoll_ctl(8, EPOLL_CTL_ADD, 4, {events=EPOLLIN, data={u32=0, u64=0}}) = 0
用户空间程序使用 epoll_pwait() 等待环形缓冲区中有数据可用。此调用仅在数据可用时返回:
epoll_pwait(8, [{events=EPOLLIN, data={u32=0, u64=0}}], 1, -1, NULL, 8) = 1
当然,如果您使用像 BCC(或 libbpf 或本书后面将描述的任何其他库)这样的框架编写代码,您实际上不需要了解如何通过 perf 或环形缓冲区从内核获取信息的这些底层细节。我希望您发现了解这些工作原理的底层细节很有趣。
但是,您可能会发现自己编写访问用户空间映射的代码,看到如何实现这一点可能会很有帮助。在本章前面,我使用 bpftool 检查了 config 映射的内容。由于它是在用户空间运行的实用程序,让我们使用 strace 看看它调用内核以检索此信息的过程。
从映射中读取信息
以下命令显示 bpftool 在读取 config 映射内容时所做的 bpf() 系统调用的摘录:
$ strace -e bpf bpftool map dump name config
如您所见,该序列包括两个主要步骤:
-
迭代所有映射,寻找任何名称为
config的映射。 -
如果找到匹配的映射,请迭代该映射中的所有元素。
寻找映射
输出以类似调用的重复序列开始,因为 bpftool 浏览所有映射,寻找任何名称为 config 的映射:
bpf(BPF_MAP_GET_NEXT_ID, {start_id=0,...}, 12) = 0 
bpf(BPF_MAP_GET_FD_BY_ID, {map_id=48...}, 12) = 3 
bpf(BPF_OBJ_GET_INFO_BY_FD, {info={bpf_fd=3, ...}}, 16) = 0 
bpf(BPF_MAP_GET_NEXT_ID, {start_id=48, ...}, 12) = 0 
bpf(BPF_MAP_GET_FD_BY_ID, {map_id=116, ...}, 12) = 3
bpf(BPF_OBJ_GET_INFO_BY_FD, {info={bpf_fd=3...}}, 16) = 0
BPF_MAP_GET_NEXT_ID 获取指定 start_id 后下一个映射的 ID。
BPF_MAP_GET_FD_BY_ID 返回指定映射 ID 的文件描述符。
BPF_OBJ_GET_INFO_BY_FD 检索有关文件描述符引用的对象(在本例中是映射)的信息。此信息包括其名称,因此 bpftool 可以检查这是否是它正在寻找的映射。
序列重复,在步骤 1 中的下一个映射的 ID。
对于加载到内核中的每个映射,都有这三个系统调用的一组。您还应该看到 start_id 和 map_id 的值与这些映射的 ID 匹配。当没有更多映射可查看时,重复模式会结束,这导致 BPF_MAP_GET_NEXT_ID 返回 ENOENT,如下所示:
bpf(BPF_MAP_GET_NEXT_ID, {start_id=133,...}, 12) = -1 ENOENT (No such file or
directory)
如果找到了匹配的映射,bpftool 将持有其文件描述符,以便可以从该映射中读取元素。
读取映射元素
此时,bpftool 拥有映射的文件描述符引用,它将从中读取。让我们看看用于读取该信息的系统调用序列:
bpf(BPF_MAP_GET_NEXT_KEY, {map_fd=3, key=NULL, 
next_key=0xaaaaf7a63960}, 24) = 0
bpf(BPF_MAP_LOOKUP_ELEM, {map_fd=3, key=0xaaaaf7a63960, 
value=0xaaaaf7a63980, flags=BPF_ANY}, 32) = 0
{ 
next_key=0xaaaaf7a63960}, 24) = 0
bpf(BPF_MAP_LOOKUP_ELEM, {map_fd=3, key=0xaaaaf7a63960,
value=0xaaaaf7a63980, flags=BPF_ANY}, 32) = 0
},{
"key": 501,
"value": {
"message": "Hi user 501!"
}
bpf(BPF_MAP_GET_NEXT_KEY, {map_fd=3, key=0xaaaaf7a63960, 
next_key=0xaaaaf7a63960}, 24) = -1 ENOENT (No such file or directory)
} 
]
+++ exited with 0 +++
首先,应用程序需要找到映射中存在的有效键。它使用 bpf() 系统调用的 BPF_MAP_GET_NEXT_KEY 类型。key 参数是一个指向键的指针,系统调用将返回此键之后的下一个有效键。通过传递 NULL 指针,应用程序请求映射中的第一个有效键。内核将键写入由 next_key 指针指定的位置。
给定一个键,应用程序请求关联的值,并将其写入由 value 指定的内存位置。
此时,bpftool 拥有第一个键-值对的内容,并将此信息写入屏幕。
在这里,bpftool 继续移动到映射中的下一个键,检索其值,并将此键-值对写入屏幕。
对 BPF_MAP_GET_NEXT_KEY 的下一次调用返回 ENOENT,以指示映射中没有更多条目。
在这里,bpftool 完成了写入屏幕的最终输出并退出。
注意,在这里,bpftool 已被分配文件描述符 3,对应于 config 映射。这是 hello-buffer-config.py 使用文件描述符 4 引用的同一个映射。正如我之前提到的,文件描述符是进程特定的。
分析显示了 bpftool 的行为方式,展示了用户空间程序如何迭代可用映射及映射中存储的键-值对。
摘要
在本章中,您看到了用户空间代码如何使用 bpf() 系统调用加载 eBPF 程序和映射。您看到了使用 BPF_PROG_LOAD 和 BPF_MAP_CREATE 命令创建程序和映射。
您了解到内核跟踪对 eBPF 程序和映射的引用计数,当引用计数降至零时释放它们。还介绍了将 BPF 对象固定到文件系统并使用 BPF 链接创建附加引用的概念。
您看到了一个示例,展示了如何使用BPF_MAP_UPDATE_ELEM来从用户空间创建映射条目。类似的命令还有BPF_MAP_LOOKUP_ELEM和BPF_MAP_DELETE_ELEM,用于从映射中检索和删除值。还有一个命令BPF_MAP_GET_NEXT_KEY,用于查找映射中下一个存在的键。您可以使用它来遍历所有有效的条目。
您看到了用户空间程序示例,这些程序利用perf_event_open()和ioctl()来将 eBPF 程序附加到 kprobe 事件。对于其他类型的 eBPF 程序,附加方法可能大不相同,有些甚至使用bpf()系统调用。例如,有一个bpf(BPF_PROG_ATTACH)系统调用可用于附加 cgroup 程序,以及一个bpf(BPF_RAW_TRACEPOINT_OPEN)用于原始 tracepoint(见本章末尾的练习 5)。
我还展示了如何使用BPF_MAP_GET_NEXT_ID、BPF_MAP_GET_FD_BY_ID和BPF_OBJ_GET_INFO_BY_FD来定位内核持有的映射(和其他)对象。
本章中还有一些其他bpf()命令我没有涵盖,但您在这里看到的已经足以提供一个良好的概述。
您还看到了一些 BTF 数据被加载到内核中,我提到bpftool使用这些信息来理解数据结构的格式,以便能够漂亮地打印出它们。我还没有解释 BTF 数据的外观或者它如何用于使 eBPF 程序在内核版本之间可移植。这将在下一章中详细介绍。
练习
如果您想进一步探索bpf()系统调用,可以尝试以下几件事情:
-
确认通过
bpftool转储已加载程序的翻译后的 eBPF 字节码时,BPF_PROG_LOAD系统调用的insn_cnt字段是否对应于输出的指令数量(如bpf()系统调用的手册页所述)。 -
运行两个示例程序的实例,以便有两个名为
config的映射。如果运行bpftool map dump name config,输出将包括关于两个不同映射以及它们内容的信息。在strace下运行,并通过系统调用输出跟踪不同的文件描述符使用情况。您能看到它从哪里检索映射信息以及从中检索键-值对存储的地方吗? -
使用
bpftool map update来修改正在运行的示例程序之一中的config映射。使用sudo -u username来检查这些配置更改是否被 eBPF 程序接受。 -
当hello-buffer-config.py正在运行时,使用
bpftool将程序固定到 BPF 文件系统,方法如下:bpftool prog pin name hello /sys/fs/bpf/hi退出运行中的程序,并检查内核中是否仍加载了hello程序,使用
bpftool prog list。您可以通过rm /sys/fs/bpf/hi来清理链接。 -
在系统调用级别上,与挂载到 kprobe 相比,直接附加到原始 tracepoint 要简单得多,因为它只涉及一个
bpf()系统调用。尝试将 hello-buffer-config.py 转换为使用 BCC 的RAW_TRACEPOINT_PROBE宏来附加到sys_enter的原始 tracepoint (如果你在 第二章 中完成了练习,你已经有一个适合的程序可以使用)。你不需要在 Python 代码中显式附加程序,因为 BCC 会替你处理。在strace下运行,你应该会看到类似这样的系统调用:bpf(BPF_RAW_TRACEPOINT_OPEN, {raw_tracepoint={name="sys_enter", prog_fd=6}}, 128) = 7内核中的 tracepoint 名称为
sys_enter,文件描述符为6的 eBPF 程序被附加到它。从现在开始,每当内核中的执行达到该 tracepoint 时,它都会触发 eBPF 程序。 -
运行来自 BCC 的 libbpf 工具集 中的 opensnoop 应用程序。该工具会设置一些你可以用
bpftool看到的 BPF 链接,如下所示:$ bpftool link list 116: perf_event prog 1849 bpf_cookie 0 pids opensnoop(17711) 117: perf_event prog 1851 bpf_cookie 0 pids opensnoop(17711)确认程序 ID(1849 和 1851 在我这里的示例输出中)与列出的加载的 eBPF 程序的输出相匹配:
$ bpftool prog list ... 1849: tracepoint name tracepoint__syscalls__sys_enter_openat tag 8ee3432dcd98ffc3 gpl run_time_ns 95875 run_cnt 121 loaded_at 2023-01-08T15:49:54+0000 uid 0 xlated 240B jited 264B memlock 4096B map_ids 571,568 btf_id 710 pids opensnoop(17711) 1851: tracepoint name tracepoint__syscalls__sys_exit_openat tag 387291c2fb839ac6 gpl run_time_ns 8515669 run_cnt 120 loaded_at 2023-01-08T15:49:54+0000 uid 0 xlated 696B jited 744B memlock 4096B map_ids 568,571,569 btf_id 710 pids opensnoop(17711) -
当 opensnoop 在运行时,尝试使用
bpftool link pin id 116 /sys/fs/bpf/mylink将其中一个链接固定住(使用bpftool link list输出的链接 ID 之一)。你应该看到,即使终止 opensnoop,链接和相应的程序仍然加载在内核中。 -
如果你跳到 第五章 的示例代码,你会发现使用 libbpf 库编写的 hello-buffer-config.py 版本。这个库会自动为加载到内核中的程序设置一个 BPF 链接。使用
strace检查它所做的bpf()系统调用,以及查看bpf(BPF_LINK_CREATE)系统调用。
^(1) 如果你想查看完整的 BPF 命令集,它们在 linux/bpf.h 头文件中有文档。
^(2) BTF 在 5.1 内核中被引入,但已在一些 Linux 发行版上进行了回移,你可以从 这个讨论 中看到。
^(3) 这些在 linux/bpf.h. 中的 bpf_attach_type 枚举中定义。
^(4) 提醒你,如果想了解更多差异的信息,请阅读安德烈·纳克里科的 “BPF 环形缓冲区” 博客文章。
第五章:CO-RE、BTF 和 Libbpf
在上一章中,您首次遇到了 BTF(BPF 类型格式)。本章讨论了它存在的原因以及如何使用它使 eBPF 程序在不同内核版本间可移植。这是 BPF“编译一次,到处运行”(CO-RE)方法的关键部分,解决了在不同内核版本间实现 eBPF 程序可移植性的问题。
许多 eBPF 程序访问内核数据结构,eBPF 程序员需要包含相关的 Linux 头文件,以便他们的 eBPF 代码可以正确地定位这些数据结构中的字段。然而,Linux 内核在持续开发中,这意味着内部数据结构在不同内核版本间可能会发生变化。如果您将在一台机器上编译的 eBPF 对象文件加载到具有不同内核版本的机器上,不能保证数据结构会保持一致。
CO-RE 方法在有效解决这一可移植性问题方面迈出了重要的一步。它允许 eBPF 程序包含有关它们编译时所用数据结构布局的信息,并提供了一种机制,用于调整在目标机器上运行时数据结构布局不同情况下字段访问的方式。只要程序不需要访问目标机器内核中根本不存在的字段或数据结构,程序就能在不同内核版本间实现可移植性。
但在深入讨论 CO-RE 如何工作之前,让我们看看为什么它是如此令人向往的,通过观察最初在 BCC 项目中实现的内核可移植性的先前方法。
BCC 对可移植性的方法
在第二章中,我使用BCC展示了 eBPF 程序的基本“Hello World”示例。BCC 项目是第一个流行的用于实现 eBPF 程序的项目,为没有太多内核经验的程序员提供了一个相对易于使用的用户空间和内核方面的框架。为了解决跨内核的可移植性问题,BCC 采用了在目标机器上即时编译 eBPF 代码的方法。然而,这种方法存在一些问题:
-
编译工具链需要安装在每台希望运行代码的目标机器上,还需要内核头文件(这些文件通常不会默认存在)。
-
在工具启动之前,您必须等待编译完成,这可能意味着每次启动工具都会有几秒钟的延迟。
-
如果您在大量相同的机器群上运行工具,每台机器上都重复编译是一种计算资源的浪费。
-
一些基于 BCC 的项目将它们的 eBPF 源代码和工具链打包到一个容器映像中,这使得将其分发到每台机器更加容易。但这并不能解决确保内核头文件存在的问题,甚至可能意味着如果安装了多个这些 BCC 容器,则会有更多的重复。
-
嵌入式设备可能没有足够的内存资源来运行编译步骤。
因为这些问题,如果你计划开始开发一个重要的新 eBPF 项目,我不建议使用这种传统的 BCC 方法,尤其是如果你计划将其分发给其他人使用。在本书中,我给出了一些基于 BCC 的示例,因为它是了解 eBPF 基本概念的好方法,特别是因为 Python 用户空间代码如此紧凑且易于阅读。如果你更喜欢使用它,并且想要快速组装一些东西,那么它也是一个完全不错的选择。但是对于严肃的现代 eBPF 开发来说,它并不是最佳选择。
CO-RE 方法为 eBPF 程序的跨内核可移植性问题提供了一个更好的解决方案。
注意
BCC 项目位于github.com/iovisor/bcc,包含广泛的命令行工具,用于观察 Linux 机器行为的各种信息。位于tools目录中的原始版本大多使用 Python 实现,使用了我在本节中描述的传统可移植性方法。
在 BCC 的libbpf-tools目录中,你会找到使用 C 编写的更新版本的这些工具,利用了libbpf和 CO-RE,并且不会遇到我刚刚列出的问题。它们是一套非常有用的实用工具集!
CO-RE 概述
CO-RE 方法由几个元素组成:(2),^(3)
BTF
BTF 是用于表示数据结构布局和函数签名的格式。在 CO-RE 中,它用于确定编译时和运行时使用的结构之间的任何差异。像bpftool这样的工具也使用 BTF 来以人类可读的格式转储数据结构。从 Linux 内核 5.4 版本开始支持 BTF。
内核头文件
Linux 内核源代码包括描述其使用的数据结构的头文件,这些头文件在不同版本的 Linux 之间可能会发生变化。eBPF 程序员可以选择包含单独的头文件,或者如本章所示,可以使用bpftool从运行中的系统生成一个名为vmlinux.h的头文件,其中包含 BPF 程序可能需要的有关内核的所有数据结构信息。
编译器支持
当 Clang 编译器使用 -g 标志编译带有 CO-RE 重定位 的 eBPF 程序时,它包含了从描述内核数据结构的 BTF 信息中导出的内容。GCC 编译器也在版本 12 中为 BPF 目标添加了 CO-RE 支持。
用于数据结构重定位的库支持
在用户空间程序将 eBPF 程序加载到内核时,CO-RE 方法要求调整字节码以补偿编译时存在的数据结构与目标机器上实际运行时的差异,这是基于编译到对象中的 CO-RE 重定位信息。有几个库可以处理这个问题:libbpf 是最初的 C 库,包含这种重定位能力;Cilium 的 eBPF 库为 Go 程序员提供了同样的功能;Aya 则为 Rust 提供了支持。
可选地,BPF 骨架
骨架可以从编译后的 BPF 对象文件自动生成,其中包含了方便用户空间代码调用的实用函数,用于管理 BPF 程序的生命周期——将它们加载到内核中,将它们附加到事件上等。如果您使用 C 编写用户空间代码,可以使用 bpftool gen skeleton 生成这些骨架。这些函数是比直接使用底层库(libbpf、cilium/ebpf 等)更方便的高级抽象。
注意
Andrii Nakryiko 写了一篇关于 CO-RE 背景的卓越博客文章,并详细介绍了其工作原理及如何使用。他还撰写了权威的BPF CO-RE 参考指南,如果您准备自行编写代码,请务必阅读。他的 libbpf-bootstrap 指南则介绍了使用 CO-RE + libbpf + 骨架从头开始构建 eBPF 应用的方法,也是另一个必读资源。
现在您已经对 CO-RE 的元素有了概述,让我们深入了解它们的工作原理,从探索 BTF 开始。
BPF 类型格式
BTF 信息描述了数据结构和代码在内存中的布局方式。这些信息可以用于各种不同的用途。
BTF 使用案例
讨论 BTF 在本章关于 CO-RE 中的主要原因是,了解在编译 eBPF 程序的结构布局与将要运行的结构布局之间的差异,允许在程序加载到内核时进行适当的调整。我将在本章后面讨论重定位过程,但现在,让我们也考虑一些 BTF 信息可以用于的其他用途。
知道结构体的布局方式及其每个字段的类型,可以使结构体的内容以人类可读的形式进行漂亮打印。例如,从计算机的角度来看,字符串只是一系列字节,但将这些字节转换为字符可以使字符串更易于人类理解。您在前一章已经看到了一个例子,其中 bpftool 使用 BTF 信息来格式化映射转储的输出。
BTF 信息还包括行和函数信息,使 bpftool 能够在从翻译或 JIT 编译的程序转储的输出中插入源代码,正如您在 第三章 中看到的那样。当您阅读 第六章 时,您还将看到源代码信息与验证器日志输出交错,这同样来自于 BTF 信息。
BTF 信息还需要用于 BPF 自旋锁。自旋锁 用于阻止两个 CPU 核同时访问相同的映射值。锁必须是映射值结构体的一部分,如下所示:
struct my_value {
... <other fields>
struct bpf_spin_lock lock;
... <other fields>
};
在内核内部,eBPF 程序使用 bpf_spin_lock() 和 bpf_spin_unlock() 辅助函数来获取和释放锁。只有在有可用的 BTF 信息描述锁字段所在位置时,才能使用这些辅助函数。
注意
自旋锁支持是在内核版本 5.1 中添加的。对于自旋锁的使用有很多限制:它们只能用于哈希或数组映射类型,并且不能在跟踪或套接字过滤类型的 eBPF 程序中使用。更多关于自旋锁的信息,请参阅 lwn.net 上有关 BPF 并发管理的文章。
现在您知道 BTF 信息的用处,让我们通过查看一些示例来更具体化。
使用 bpftool 列出 BTF 信息
与程序和地图一样,您可以使用 bpftool 实用程序显示 BTF 信息。以下命令列出加载到内核中的所有 BTF 数据:
bpftool btf list
1: name [vmlinux] size 5843164B
2: name [aes_ce_cipher] size 407B
3: name [cryptd] size 3372B
...
149: name <anon> size 4372B prog_ids 319 map_ids 103
pids hello-buffer-co(7660)
155: name <anon> size 37100B
pids bpftool(7784)
(为了简洁起见,我省略了结果中的许多条目。)
列表中的第一项是 vmlinux,它对应我之前提到的 vmlinux 文件,其中包含有关当前运行内核的 BTF 信息。
注意
本章早期的一些示例重用了 第四章 的程序,然后在本章后期,您将找到新的示例,这些示例的源代码位于 github.com/lizrice/learning-ebpf 的 chapter5 目录中。
要获取此示例输出,我在运行 第四章 的 hello-buffer-config 示例时运行了此命令。您可以在以 149: 开头的行上看到描述此过程正在使用的 BTF 信息的条目:
149: name <anon> size 4372B prog_ids 319 map_ids 103
pids hello-buffer-co(7660)
这一行告诉我们以下内容:
-
此 BTF 信息块的 ID 是 149。
-
这是一个大约 4 KB 的匿名 BTF 信息块。
-
它被具有
prog_id 319的 BPF 程序和具有map_id 103的 BPF 映射使用。 -
它还被进程 ID 7660(括号内显示)运行的
hello-buffer-config可执行文件使用(其名称已截断为 15 个字符)。
这些程序、映射和 BTF 标识符与 bpftool 显示的有关 hello-buffer-config 的名为 hello 的程序的输出匹配:
bpftool prog show name hello
319: kprobe name hello tag a94092da317ac9ba gpl
loaded_at 2022-08-28T14:13:35+0000 uid 0
xlated 400B jited 428B memlock 4096B map_ids 103,104
btf_id 149
pids hello-buffer-co(7660)
唯一似乎完全不匹配的是程序引用了额外的 map_id,104。这是性能事件缓冲区映射,并且不使用 BTF 信息;因此,它不会出现在与 BTF 相关的输出中。
就像 bpftool 可以转储程序和映射的内容一样,它也可以用来查看数据块中包含的 BTF 类型信息。
BTF 类型
知道 BTF 信息的 ID 后,您可以使用命令 bpftool btf dump id <id> 检查其内容。当我使用之前获取的 ID 149 运行这个命令时,我得到了 69 行输出,每行都是一个类型定义。我将描述前几行,这应该能让您了解如何解释其余部分。这些首几行的 BTF 信息与在源代码中如此定义的 config 哈希映射相关:
struct user_msg_t {
char message[12];
};
BPF_HASH(config, u32, struct user_msg_t);
此哈希表的键类型为 u32,值类型为 struct user_msg_t。该结构包含一个 12 字节的 message 字段。让我们看看这些类型在对应的 BTF 信息中是如何定义的。
BTF 输出的前三行如下:
[1] TYPEDEF 'u32' type_id=2
[2] TYPEDEF '__u32' type_id=3
[3] INT 'unsigned int' size=4 bits_offset=0 nr_bits=32 encoding=(none)
每行开头的方括号中的数字是类型 ID(因此第一行,以 [1] 开头的行定义了 type_id 1,依此类推)。让我们更详细地探讨这三种类型:
-
类型 1 定义了名为
u32的类型及其类型,由以[2]开头的行定义,即哈希表中键的类型为u32。 -
类型 2 的名称为
__u32,类型由type_id 3定义。 -
类型 3 是一个名为
unsigned int的整数类型,长度为 4 字节。
这三种类型都是指 32 位无符号整数类型的同义词。在 C 中,整数的长度取决于平台,因此 Linux 定义了诸如 u32 这样的类型,明确地定义了特定长度的整数。在此机器上,u32 对应于无符号整数。引用这些的用户空间代码应该使用前缀带下划线的同义词,如 __u32。
BTF 输出的接下来几个类型如下:
[4] STRUCT 'user_msg_t' size=12 vlen=1
'message' type_id=6 bits_offset=0
[5] INT 'char' size=1 bits_offset=0 nr_bits=8 encoding=(none)
[6] ARRAY '(anon)' type_id=5 index_type_id=7 nr_elems=12
[7] INT '__ARRAY_SIZE_TYPE__' size=4 bits_offset=0 nr_bits=32 encoding=(none)
这些关联到 config 映射中值为 user_msg_t 结构的类型:
-
类型 4 是
user_msg_t结构本身,总长度为 12 字节。它包含一个名为message的字段,由类型 6 定义。vlen字段指示这个定义中有多少个字段。 -
类型 5 名为
char,是一个 1 字节的整数——这正是 C 程序员对名为char类型的定义期望的内容。 -
类型 6 将
message字段的类型定义为具有 12 个元素的数组。每个元素的类型为 5(它是一个char),并且数组由类型 7 索引。 -
类型 7 是一个 4 字节整数。
带着这些定义,你可以完整地了解user_msg_t结构在内存中的布局,如图 5-1 所示。

图 5-1. 一个user_msg_t结构占用 12 字节内存
到目前为止,所有的条目都将bits_offset设置为0,但是下一行输出的结构具有多个字段:
[8] STRUCT '____btf_map_config' size=16 vlen=2
'key' type_id=1 bits_offset=0
'value' type_id=4 bits_offset=32
这是存储在名为config的映射中的键值对的结构定义。我没有在源代码中定义这种____btf_map_config类型,但它是由 BCC 生成的。键的类型是u32,值是user_msg_t结构。这对应于您之前看到的类型 1 和类型 4。
关于这个结构的 BTF 信息的另一个重要部分是,value字段从结构的起始位置后 32 位开始。这完全是有道理的,因为前 32 位用于保存key字段。
注意
在 C 语言中,结构字段会自动对齐到边界,因此不能简单地假设一个字段总是紧跟在前一个字段的后面。例如,考虑这样一个结构:
struct something {
char letter;
u64 number;
}
在字段称为letter之后,在number字段之前有 7 字节的未使用内存,以便 64 位数字可以对齐到可以被 8 整除的内存位置。
在某些情况下,可以启用编译器的紧凑排列来避免这些未使用的空间,但通常会导致性能下降,并且——至少在我的经验中——很少这样做。更常见的是,C 程序员会手动设计结构以有效利用空间。
带有 BTF 信息的映射
您刚刚看到了与映射关联的 BTF 信息。现在让我们看看在创建映射时,这些 BTF 数据如何传递给内核。
您在第四章中看到,映射是使用bpf(BPF_MAP_CREATE)系统调用创建的。这需要一个bpf_attr结构作为参数,在内核中定义如下(省略了一些细节):
struct { /* anonymous struct used by BPF_MAP_CREATE command */
`__u32` `map_type`; /* one of enum bpf_map_type */
`__u32` `key_size`; /* size of key in bytes */
`__u32` `value_size`; /* size of value in bytes */
`__u32` `max_entries`; /* max number of entries in a map */
...
char `map_name`[`BPF_OBJ_NAME_LEN`];
...
`__u32` `btf_fd`; /* fd pointing to a BTF type data */
`__u32` `btf_key_type_id`; /* BTF type_id of the key */
`__u32` `btf_value_type_id`; /* BTF type_id of the value */
...
};
在引入 BTF 之前,btf_*字段不存在于bpf_attr结构中,内核不了解键或值的结构。key_size和value_size字段定义了它们所需的内存量,但它们只是被视为一些字节。通过额外传入定义键和值类型的 BTF 信息,内核可以内省它们,像bpftool这样的工具可以检索用于漂亮打印的类型信息,正如前面讨论的那样。但是有趣的是,为键和值分别传入了单独的 BTF type _id。刚才看到的____btf_map_config结构并不被内核用于映射定义;它只是由用户空间的 BCC 使用。
函数和函数原型的 BTF 数据
到目前为止,在这个示例输出中,BTF 数据与数据类型有关,但 BTF 数据还包含有关函数和函数原型的信息。以下是描述hello函数的同一 BTF 数据块中的信息:
[31] FUNC_PROTO '(anon)' ret_type_id=23 vlen=1
'ctx' type_id=10
[32] FUNC 'hello' type_id=31 linkage=static
在类型 32 中,您可以看到名为hello的函数被定义为具有前一行中定义的类型。这是一个函数原型,它返回类型 ID 23,并采用单个参数(vlen=1)称为ctx,其类型 ID 为10。为了完整起见,这里是前面输出中那些类型的定义:
[10] PTR '(anon)' type_id=0
[23] INT 'int' size=4 bits_offset=0 nr_bits=32 encoding=SIGNED
类型 10 是一个匿名指针,其默认类型为0,在 BTF 输出中没有明确包含,但定义为 void 指针。^(4)
返回值类型为 23,是一个 4 字节整数,encoding=SIGNED表示它是一个有符号整数;也就是说,它可以是正数或负数。这对应于源代码中hello-buffer-config.py中的函数定义,如下所示:
int hello(void *ctx)
到目前为止,我展示的示例 BTF 信息来自于列出 BTF 数据块内容。让我们看看如何获取与特定映射或程序相关的 BTF 信息。
检查映射和程序的 BTF 数据
如果您想检查与特定映射相关的 BTF 类型,bpftool使这变得容易。例如,这是config映射的输出:
bpftool btf dump map name config
[1] TYPEDEF 'u32' type_id=2
[4] STRUCT 'user_msg_t' size=12 vlen=1
'message' type_id=6 bits_offset=0
类似地,您可以使用bpftool btf dump prog <prog identity>检查与特定程序相关的 BTF 信息。我会让您查看manpage以获取更多详细信息。
注意
如果您想更好地理解如何生成和去重 BTF 类型数据,请参阅 Andrii Nakryiko 的另一篇优秀博客文章。
到此为止,您应该已经了解了 BTF 如何描述数据结构和函数的格式。一个用 C 编写的 eBPF 程序需要定义类型和结构的头文件。让我们看看为 eBPF 程序可能需要的任何内核数据类型生成头文件是多么简单。
生成内核头文件
如果您在启用了 BTF 的内核上运行bpftool btf list,您将看到许多类似以下的预存在 BTF 数据块:
$ bpftool btf list
1: name [vmlinux] size 5842973B
2: name [aes_ce_cipher] size 407B
3: name [cryptd] size 3372B
...
此列表中的第一项,ID 为 1,名称为vmlinux,是关于所有数据类型、结构和函数定义的 BTF 信息,这些定义由运行在此(虚拟)机器上的内核使用。^(5)
一个 eBPF 程序需要任何它将引用的内核数据结构和类型的定义。在 CO-RE 出现之前,您通常需要弄清楚 Linux 内核源代码中的许多单独头文件中哪些包含您感兴趣的结构的定义,但现在有了一个更简单的方法,因为启用 BTF 的工具可以从内核中包含的 BTF 信息生成一个适当的头文件。
这个头文件通常称为vmlinux.h,您可以像这样使用bpftool生成它:
bpftool btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.h
此文件定义了所有内核的数据类型,因此,在您的 eBPF 程序源代码中包含此生成的vmlinux.h文件将提供您可能需要的任何 Linux 数据结构的定义。当您将源代码编译为 eBPF 对象文件时,该对象将包含与此头文件中使用的定义匹配的 BTF 信息。稍后,在目标机器上运行程序时,将加载它到内核中的用户空间程序将调整以解决此构建时 BTF 信息与运行在目标机器上的内核的 BTF 信息之间的差异。
自 Linux 内核版本 5.4 以来,以/sys/kernel/btf/vmlinux文件形式的 BTF 信息已被包含在 Linux 内核中。^(6)但是,libbpf 可以利用的原始 BTF 数据也可以为旧内核生成。换句话说,如果您希望在目标机器上运行支持 CO-RE 的 eBPF 程序,而该目标机器没有 BTF 信息,您可能可以自己提供该目标机器的 BTF 数据。关于如何生成 BTF 文件以及各种 Linux 发行版的文件存档,请访问BTFHub获取更多信息。
注意
BTFHub 仓库还包括有关BTF internals的进一步阅读,如果您希望深入了解此主题。
接下来,让我们看看如何使用这些策略以及其他策略来编写可通过 CO-RE 在各种内核间移植的 eBPF 程序。
CO-RE eBPF 程序
您将回忆起 eBPF 程序在内核中运行。本章稍后将展示一些与内核中运行代码进行交互的用户空间代码,但本节集中在内核端。
正如你已经看到的那样,eBPF 程序被编译成 eBPF 字节码,(至少在撰写本文时)支持此功能的编译器有 Clang 或 gcc 用于编译 C 代码,以及 Rust 编译器。在第十章中,我将讨论一些使用 Rust 的选项,但在本章的目的上,我将假设你是在 C 中编写并使用 Clang,以及libbpf库。
在本章的其余部分,让我们考虑一个名为hello-buffer-config的示例应用程序。它与前一章中使用 BCC 框架的hello-buffer-config.py示例非常相似,但此版本是用 C 编写的,以使用libbpf和 CO-RE。
如果你有基于 BCC 的 eBPF 代码想要迁移到libbpf,请查看 Andrii Nakryiko 在他的网站上提供的出色且全面的指南。BCC 提供了一些便捷的快捷方式,而使用libbpf并不完全相同;反之,libbpf提供了一套宏和库函数,使 eBPF 程序员的生活更加轻松。当我讲解示例时,我将指出 BCC 和libbpf方法之间的一些区别。
注意
你可以在github.com/lizrice/learning-ebpf repo 的chapter5目录中找到本节的示例 C eBPF 程序。
首先让我们看看hello-buffer-config.bpf.c,它实现了在内核中运行的 eBPF 程序。本章后面我会展示用户空间代码hello-buffer-config.c,它加载程序并显示输出,类似于 Python 代码在第四章中 BCC 实现的例子。
就像任何 C 程序一样,eBPF 程序也需要包含一些头文件。
头文件
hello-buffer-config.bpf.c的前几行指定了它所需要的头文件:
#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>
#include <bpf/bpf_core_read.h>
#include "hello-buffer-config.h"
这些五个文件是vmlinux.h文件,来自libbpf的几个头文件,以及我自己编写的一个应用程序特定的头文件。让我们看看为libbpf程序所需的头文件为什么是一个典型模式。
内核头信息
如果你编写的 eBPF 程序涉及任何内核数据结构或类型,最简单的选择是在本章前面描述的vmliux.h文件中包含它。或者,可以从 Linux 源中包含单独的头文件,或者如果你真的愿意的话,可以在自己的代码中手动定义类型。如果你将使用libbpf中的任何 BPF 助手函数,你需要包含vmlinux.h或linux/types.h以获取 BPF 助手源引用的u32、u64等类型的定义。
vmlinux.h文件源自内核源码头文件,但不包括来自它们的#define值。例如,如果你的 eBPF 程序解析以太网数据包,你可能需要常量定义来确定数据包的协议(比如0x0800表示 IP 数据包,或者0x0806表示 ARP 数据包)。有一系列常量值需要在你自己的代码中复制,如果你没有包含定义这些值的if_ether.h文件。我在hello-buffer-config中不需要任何这些值定义,但在第八章中你会看到另一个例子,这里是相关的。
来自 libbpf 的头文件
要在你的 eBPF 代码中使用任何 BPF 辅助函数,你需要包含libbpf提供的头文件,这些头文件给出了它们的定义。
注意
libbpf可能会让人稍感困惑的一点是,它不仅仅是一个用户空间库。你会发现自己在用户空间和 eBPF C 代码中都包含libbpf的头文件。
在撰写本文时,看到将libbpf作为子模块包含并从源代码构建/安装是很常见的——这是我在本书示例库中所做的。如果你将其作为子模块包含,你只需从libbpf/src目录运行make install即可。我认为不久之后,libbpf将更普遍地作为常见 Linux 发行版上的一个包提供,特别是因为libbpf现在已经发布了 1.0 版本的里程碑。
特定于应用程序的头文件
拥有一个特定于应用程序的头文件是非常常见的,该头文件定义了用户空间和 eBPF 应用程序部分都使用的任何结构。在我的示例中,hello-buffer-config.h头文件定义了我用来从 eBPF 程序传递事件数据到用户空间的data_t结构。它几乎与这段代码的 BCC 版本中看到的结构相同,如下所示:
struct data_t {
int pid;
int uid;
char command[16];
char message[12];
char path[16];
};
与之前版本唯一的区别是我添加了一个名为path的字段。
将这个结构定义拉入一个单独的头文件的原因是,我将在hello-buffer-config.c用户空间代码中引用它。在 BCC 版本中,内核和用户空间代码都定义在一个文件中,BCC 在幕后做了一些工作,使得这个结构对 Python 用户空间代码可用。
定义映射
在包含头文件之后,源代码hello-buffer-config.bpf.c中的接下来几行定义了用于映射的结构,如下所示:
struct {
__uint(type, BPF_MAP_TYPE_PERF_EVENT_ARRAY);
__uint(key_size, sizeof(u32));
__uint(value_size, sizeof(u32));
} output SEC(".maps");
struct user_msg_t {
char message[12];
};
struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, 10240);
__type(key, u32);
__type(value, struct user_msg_t);
} my_config SEC(".maps");
这比我在等效的 BCC 示例中所需的代码行数更多!在 BCC 中,称为config的映射是用以下宏创建的:
BPF_HASH(config, u64, struct user_msg_t);
在不使用 BCC 时,此宏不可用,因此在 C 语言中,您必须手动编写它。您会看到我使用了__uint和__type。这些与bpf/bpf_helpers_def.h中定义的__array一起使用,如下所示:
#define `__uint`(name, val) int (*name)[val]
#define `__type`(name, val) `typeof`(val) *name
#define `__array`(name, val) `typeof`(val) *name[]
在libbpf基于程序中通常按惯例使用这些宏,我认为它们使地图定义变得更容易阅读。
注意
名称“config”与vmlinux.h中的定义冲突,因此我将地图重命名为“my_config”以供此示例使用。
eBPF 程序部分
使用libbpf需要每个 eBPF 程序都标记为使用SEC()宏定义程序类型,如下所示:
SEC("kprobe")
这将在编译后的 ELF 对象中生成一个名为kprobe的部分,因此libbpf知道将其加载为BPF_PROG_TYPE_KPROBE。我们将在第七章中进一步讨论不同的程序类型。
根据程序类型的不同,您还可以使用部分名称来指定程序将附加到的事件。libbpf库将使用此信息自动设置附加项,而不是让您在用户空间代码中显式执行。例如,在基于 ARM 的机器上自动附加到execve系统调用的 kprobe,您可以像这样指定部分名称:
SEC("kprobe/__arm64_sys_execve")
这要求您知道该体系结构上该系统调用的函数名(或通过查看目标机器上的/proc/kallsyms文件找出,该文件列出所有内核符号,包括其函数名)。但是libbpf可以通过k(ret)syscall部分名称让您的生活更轻松,该名称告诉加载器自动附加到体系结构特定函数中的 kprobe:
SEC("ksyscall/execve")
注意
libbpf文档列出了有效的部分名称和格式。过去,部分名称的要求要宽松得多,因此您可能会遇到在libbpf 1.0之前编写的部分名称不匹配有效集的 eBPF 程序。不要让它们让您困惑!
部分定义声明了 eBPF 程序应附加的位置,然后是程序本身。与以前一样,eBPF 程序本身被编写为 C 函数。在示例代码中,它称为hello(),与您在第四章中看到的hello()函数非常相似。让我们考虑前一个版本与这里版本之间的区别:
SEC("ksyscall/execve")
int BPF_KPROBE_SYSCALL(hello, const char *pathname) 
{
struct data_t data = {};
struct user_msg_t *p;
data.pid = bpf_get_current_pid_tgid() >> 32;
data.uid = bpf_get_current_uid_gid() & 0xFFFFFFFF;
bpf_get_current_comm(&data.command, sizeof(data.command));
bpf_probe_read_user_str(&data.path, sizeof(data.path), pathname); 
p = bpf_map_lookup_elem(&my_config, &data.uid); 
if (p != 0) {
bpf_probe_read_kernel(&data.message, sizeof(data.message), p->message);
} else {
bpf_probe_read_kernel(&data.message, sizeof(data.message), message);
}
bpf_perf_event_output(ctx, &output, BPF_F_CURRENT_CPU, 
&data, sizeof(data));
return 0;
}
我利用了在libbpf中定义的BPF_KPROBE_SYSCALL宏,通过名字轻松访问系统调用的参数。对于execve()来说,第一个参数是将要执行的程序的路径名。eBPF 程序的名称是hello。
由于宏使得访问execve()的路径名参数变得如此容易,我将其包含在发送到性能缓冲区输出的数据中。请注意,复制内存需要使用 BPF 辅助函数。
在这里,bpf_map_lookup_elem()是 BPF 地图中查找值的辅助函数,给定一个键。BCC 的等效操作将是p = my_config.lookup(&data.uid)。在将 C 代码传递给编译器之前,BCC 会重写此操作以使用底层的bpf_map_lookup_elem()函数。当您使用libbpf时,在编译之前不会对代码进行任何重写,^(7)因此您必须直接写入辅助函数。
这里是另一个类似的例子,我直接向辅助函数bpf_perf_event_output()写入了数据,在这里 BCC 给了我方便的等效操作output.perf_submit(ctx, &data, sizeof(data))。
唯一的另一个区别是,在 BCC 版本中,我将消息字符串定义为hello()函数内的局部变量。BCC 不支持(至少在撰写本文时不支持)全局变量。在此版本中,我将其定义为全局变量,如下所示:
char message[12] = "Hello World";
在chapter4/hello-buffer-config.py中,hello函数的定义方式略有不同,如下所示:
int hello(void *ctx)
BPF_KPROBE_SYSCALL宏是我提到的libbpf中的一个方便的新增功能之一。您不必使用该宏,但它会让生活变得更容易。它负责为传递给系统调用的所有参数提供命名参数。在这种情况下,它提供了一个pathname参数,该参数指向即将运行的可执行文件的路径字符串,这是传递给execve()系统调用的第一个参数。
如果您非常仔细地注意,您可能会注意到在我的hello-buffer-config.bpf.c源代码中,ctx变量在可见定义中并不存在,但尽管如此,我仍然能够在向输出性能缓冲区提交数据时使用它:
bpf_perf_event_output(ctx, &output, BPF_F_CURRENT_CPU, &data, sizeof(data));
ctx变量确实存在,隐藏在libbpf中bpf/bpf_tracing.h内的BPF_KPROBE_SYSCALL宏定义中,您也会在其中找到一些关于此的评论。使用一个不明确定义的变量可能会让人感到有些困惑,但它非常有用,因为它可以被访问。
CO-RE 下的内存访问
用于跟踪的 eBPF 程序对内存的访问受到限制,通过bpf_probe_read_*()家族的 BPF 辅助函数。^(8)(还有一个bpf_probe_write_user()辅助函数,但它仅用于“实验”)。问题在于,正如您将在下一章看到的,eBPF 验证器通常不会允许您像在 C 中通常可以那样简单地通过指针读取内存(例如,x = p->y)。^(9)
libbpf库提供了围绕bpf_probe_read_*()助手函数的 CO-RE 包装器,以利用 BTF 信息并使内存访问调用跨不同内核版本可移植。以下是其中一个包装器的示例,如bpf_core_read.h头文件中所定义:
#define bpf_core_read(dst, sz, src) \
bpf_probe_read_kernel(dst, sz, \
(const void *)__builtin_preserve_access_index(src))
如您所见,bpf_core_read()直接调用bpf_probe_read_kernel(),唯一的区别在于它用__builtin_preserve_access_index()包装了src字段。这告诉 Clang 在访问内存中这个地址时同时发出 CO-RE 重定位条目与 eBPF 指令。
注意
此__builtin_preserve_access_index()指令是对“常规”C 代码的扩展,在 eBPF 中添加它也需要 Clang 编译器的更改以支持并发出这些 CO-RE 重定位条目。这些扩展是为什么一些 C 编译器(至少目前是这样)不能生成 eBPF 字节码的示例。有关为 eBPF CO-RE 支持所需的 Clang 更改的更多信息,请阅读LLVM 邮件列表。
正如您稍后在本章中将看到的那样,CO-RE 重定位条目告诉libbpf在将 eBPF 程序加载到内核时重写地址,以考虑任何 BTF 差异。如果src在其包含结构中的偏移在目标内核上不同,重写的指令将考虑这一点。
libbpf库提供了BPF_CORE_READ()宏,这样您可以在单行中编写多个bpf_core_read()调用,而不需要为每个指针解引用编写单独的辅助函数调用。例如,如果您想要做类似d = a->b->c->d的操作,可以编写以下代码:
struct b_t *b;
struct c_t *c;
bpf_core_read(&b, 8, &a->b);
bpf_core_read(&c, 8, &b->c);
bpf_core_read(&d, 8, &c->d);
但使用以下方式要紧凑得多:
d = BPF_CORE_READ(a, b, c, d);
您可以使用bpf_probe_read_kernel()助手函数从d点读取。
关于这一点,Andrii 的指南有很好的描述。
许可证定义
正如您从第三章已经了解到的那样,eBPF 程序必须声明其许可证。示例代码如下所示:
char LICENSE[] SEC("license") = "Dual BSD/GPL";
您现在已经看到了hello-buffer-config.bpf.c示例中的所有代码。现在让我们将其编译成一个对象文件。
为 CO-RE 编译 eBPF 程序
在第三章中,您看到了一个从 Makefile 中提取出来的内容,用于将 C 编译为 eBPF 字节码。让我们深入研究所使用的选项,并了解它们对 CO-RE/libbpf程序的必要性。
调试信息
您必须向 Clang 传递-g标志,以便包含调试信息,这对于 BTF 是必要的。然而,-g标志也会向输出的对象文件添加 DWARF 调试信息,但 eBPF 程序不需要这些,因此您可以通过运行以下命令将其剥离以减少对象文件的大小:
llvm-strip -g <object file>
优化
-O2优化标志(2 级或更高)是 Clang 生成将通过验证器的 BPF 字节码所必需的。一个需要这样做的例子是,默认情况下,Clang 将输出callx <register>来调用辅助函数,但 eBPF 不支持从寄存器调用地址。
目标架构
如果您使用libbpf定义的某些宏,您需要在编译时指定目标架构。libbpf头文件bpf/bpf_tracing.h定义了几个特定于平台的宏,例如BPF_KPROBE和BPF_KPROBE_SYSCALL,我在本示例中使用了这些宏。BPF_KPROBE宏可用于将 eBPF 程序附加到 kprobes 上,而BPF_KPROBE_SYSCALL是专门用于系统调用 kprobes 的变体。
kprobe 的参数是一个pt_regs结构,其中保存了 CPU 寄存器内容的副本。由于寄存器是与架构相关的,pt_regs结构定义取决于您正在运行的架构。这意味着,如果您想要使用这些宏,您还需要告诉编译器目标架构是什么。您可以通过设置-D __TARGET_ARCH_($ARCH)来实现这一点,其中$ARCH是一个架构名称,如 arm64、amd64 等。
还要注意,如果您没有使用该宏,您仍然需要针对 kprobe 访问寄存器信息的特定于架构的代码。
或许“每个架构编译一次,到处运行”会有点啰嗦!
Makefile
以下是一个用于编译 CO-RE 对象的示例 Makefile 指令(取自本书 GitHub 存储库中chapter5目录中的 Makefile):
hello-buffer-config.bpf.o: %.o: %.c
clang \
-target bpf \
-D __TARGET_ARCH_$(ARCH) \
-I/usr/include/$(shell uname -m)-linux-gnu \
-Wall \
-O2 -g \
-c $< -o $@
llvm-strip -g $@
如果您正在使用示例代码,您应该能够通过在chapter5目录中运行make来构建 eBPF 目标文件hello-buffer-config.bpf.o(以及我将很快描述的其伴随的用户空间可执行文件)。让我们检查该目标文件,看看它是否包含 BTF 信息。
目标文件中的 BTF 信息
BTF 的内核文档描述了 BTF 数据如何在 ELF 目标文件中以两个部分进行编码:.BTF,其中包含数据和字符串信息,以及.BTF.ext,其中包含函数和行信息。您可以使用readelf查看这些部分已添加到目标文件中,如下所示:
$ readelf -S hello-buffer-config.bpf.o | grep BTF
[10] .BTF PROGBITS 0000000000000000 000002c0
[11] .rel.BTF REL 0000000000000000 00000e50
[12] .BTF.ext PROGBITS 0000000000000000 00000b18
[13] .rel.BTF.ext REL 0000000000000000 00000ea0
bpftool实用程序允许我们检查来自目标文件的 BTF 数据,如下所示:
bpftool btf dump file hello-buffer-config.bpf.o
输出看起来就像您在本章前面看到的从加载的程序和映射中转储 BTF 信息时获得的输出。
让我们看看如何使用这些 BTF 信息来使程序能够在具有不同内核版本和不同数据结构的另一台机器上运行。
BPF 重定位
libbpf库使 eBPF 程序适应它们运行的目标内核上的数据结构布局,即使此布局与编译代码的内核不同。为此,libbpf需要 Clang 在编译过程中生成的 BPF CO-RE 重定位信息。
您可以从linux/bpf.h头文件中struct bpf_core_relo的定义中了解重定位是如何工作的:
struct `bpf_core_relo` {
`__u32` `insn_off`;
`__u32` `type_id`;
`__u32` `access_str_off`;
enum `bpf_core_relo_kind` `kind`;
};
一个 eBPF 程序的 CO-RE 重定位数据由每个需要重定位的指令的一个结构体组成。假设指令将寄存器设置为结构体中字段的值,则该指令的bpf_core_relo结构体(由insn_off字段标识)编码该结构体的 BTF 类型(type_id字段)并指示如何相对于该结构体访问字段(access_str_off字段)。
正如您刚才看到的,Clang 会自动生成内核数据结构的重定位数据,并编码到 ELF 对象文件中。是以下代码,您会在vmlinux.h文件的开头附近找到,导致 Clang 执行此操作:
#pragma clang attribute push (__attribute__((preserve_access_index)), \
apply_to = record)
preserve_access_index属性告诉 Clang 为类型定义生成 BPF CO-RE 重定位。clang attribute push部分表示该属性应用于直到文件末尾的所有定义的情况下,会有clang attribute pop。这意味着 Clang 为vmlinux.h中定义的所有类型生成重定位信息。
当您加载 BPF 程序时,可以使用bpftool并通过-d标志打开调试信息,如下所示,查看重定位的发生:
bpftool -d prog load hello.bpf.o /sys/fs/bpf/hello
这会生成大量输出,但与重定位相关的部分如下所示:
libbpf: CO-RE relocating [24] struct user_pt_regs: found target candidate [205]
struct user_pt_regs in [vmlinux]
libbpf: prog 'hello': relo #0: <byte_off> [24] struct user_pt_regs.regs[0]
(0:0:0 @ offset 0)
libbpf: prog 'hello': relo #0: matching candidate #0 <byte_off> [205] struct
user_pt_regs.regs[0] (0:0:0 @ offset 0)
libbpf: prog 'hello': relo #0: patched insn #1 (LDX/ST/STX) off 0 -> 0
在这个例子中,您可以看到hello程序的 BTF 信息中类型 ID 为 24 的结构体称为user_pt_regs。libbpf库将其与内核结构体匹配,也称为user_pt_regs,该结构体在vmlinux BTF 数据集中的类型 ID 为 205。实际上,因为我在同一台机器上编译和加载了程序,类型定义是相同的,所以在这个例子中,从结构体开始的偏移量保持不变,并且对第 1 条指令的“补丁”也保持不变。
在许多应用程序中,您可能不希望要求用户运行bpftool来加载 eBPF 程序。相反,您希望将此功能构建到您提供的专用用户空间程序中作为可执行文件。让我们考虑如何编写这个用户空间代码。
CO-RE 用户空间代码
不同编程语言中有几种不同的框架支持 CO-RE,通过在加载 eBPF 程序到内核时实现重定位。在本章中,我将展示使用libbpf的 C 代码;其他选项包括 Go 包cilium/ebpf和libbpfgo,以及 Rust 的 Aya。我将在第十章进一步讨论这些选项。
用户空间的 Libbpf 库
libbpf库是一个用户空间库,如果你用 C 语言编写应用程序的用户空间部分,可以直接使用它。如果愿意,你可以在不使用 CO-RE 的情况下使用这个库。Andrii Nakryiko 在libbpf-bootstrap上有一个例子。
此库提供的函数封装了你在第四章中遇到的bpf()和相关系统调用,用于像将程序加载到内核中并将其附加到事件,或者从用户空间访问映射信息的操作。使用这些抽象的传统和最简单的方式是通过自动生成的 BPF 骨架代码。
BPF 骨架
你可以使用bpftool从现有的以 ELF 文件格式存在的 eBPF 对象中自动生成这些骨架代码,如下所示:
bpftool gen skeleton hello-buffer-config.bpf.o > hello-buffer-config.skel.h
查看这个骨架头文件,你会看到它包含了 eBPF 程序和映射的结构定义,以及几个以hello_buffer_config_bpf__开头的函数(根据对象文件的名称)。这些函数管理 eBPF 程序和映射的生命周期。如果你喜欢,你可以直接调用libbpf,而不必使用骨架代码,但通常自动生成的代码可以节省一些输入。
在生成的骨架文件末尾,你会看到一个名为hello_buffer_config_bpf__elf_bytes的函数,它返回 ELF 对象文件hello-buffer-config.bpf.o的字节内容。一旦生成了骨架,我们实际上不再需要该对象文件。你可以通过运行make生成hello-buffer-config可执行文件,并删除.o文件来测试这一点;可执行文件中包含 eBPF 字节码。
注意
如果你愿意,你可以使用libbpf函数bpf_object__open_file从 ELF 文件加载 eBPF 程序和映射,而不是使用骨架文件的字节。
下面是管理此示例中 eBPF 程序和映射生命周期的用户空间代码概述,使用生成的骨架代码。为了清晰起见,我省略了部分细节和错误处理,但你可以在chapter5/hello-buffer-config.c中找到完整的源代码。
... [other #includes]
#include "hello-buffer-config.h" 
#include "hello-buffer-config.skel.h"
... [some callback functions]
int main()
{
struct hello_buffer_config_bpf *skel;
struct perf_buffer *pb = NULL;
int err;
libbpf_set_print(libbpf_print_fn); 
skel = hello_buffer_config_bpf__open_and_load(); 
...
err = hello_buffer_config_bpf__attach(skel); 
...
pb = perf_buffer__new(bpf_map__fd(skel->maps.output), 8, handle_event,
lost_event, NULL, NULL);

...
while (true) { 
err = perf_buffer__poll(pb, 100);
...}
perf_buffer__free(pb); 
hello_buffer_config_bpf__destroy(skel);
return -err;
}
此文件包括自动生成的骨架头文件,以及我手动编写的用于用户空间和内核代码共享的头文件。
此代码设置了一个回调函数,用于打印由libbpf生成的任何日志消息。
在这里创建了一个 skel 结构,表示 ELF 字节中定义的所有映射和程序,并将它们加载到内核中。
程序会自动附加到适当的事件上。
此函数创建用于处理性能缓冲区输出的结构。
在此连续轮询性能缓冲区。
这是清理代码。
让我们更详细地探讨其中的一些步骤。
将程序和映射加载到内核中
对自动生成的第一个调用是这个函数:
skel = hello_buffer_config_bpf__open_and_load();
正如其名称所示,此函数涵盖了两个阶段:打开和加载。 “打开” 阶段涉及读取 ELF 数据并将其节转换为表示 eBPF 程序和映射的结构。 “加载” 阶段将这些映射和程序加载到内核中,并根据需要执行任何 CO-RE 修复。
这两个阶段可以轻松分开处理,因为骨架代码提供了分开的 name__open() 和 name__load() 函数。这使您可以在加载之前操作 eBPF 信息。例如,我可以将一个计数器全局变量 c 初始化为某个值,如下所示:
skel = hello_buffer_config_bpf__open();
if (!skel) {
// Error ...
}
skel->data->c = 10;
err = hello_buffer_config_bpf__load(skel);
hello_buffer_config_bpf__open() 返回的数据类型,以及 hello_buffer_config_bpf__load() 返回的数据类型,都是一个名为 hello_buffer_config_bpf 的结构体,在骨架头文件中定义了有关对象文件中定义的所有映射、程序和数据的信息。
注意
骨架对象(在本例中为 hello_buffer_config_bpf)只是来自 ELF 字节信息的用户空间表示。一旦加载到内核中,如果在加载后更改 skel->data->c 的值,它不会对内核端数据产生任何影响。
访问现有映射
默认情况下,libbpf 也会创建在 ELF 字节中定义的任何映射,但有时您可能希望编写一个 eBPF 程序,该程序重用现有的映射。在上一章中已经看到了一个例子,您看到 bpftool 遍历所有映射,查找与指定名称匹配的映射。使用映射的另一个常见原因是在两个不同的 eBPF 程序之间共享信息,因此只有一个程序应该创建映射。bpf_map__set_autocreate() 函数允许您覆盖 libbpf 的自动创建。
那么如何访问现有映射呢?映射可以被固定,如果您知道固定路径,可以使用 bpf_obj_get() 获取现有映射的文件描述符。以下是一个非常简单的示例(在 GitHub 存储库中作为 chapter5/find-map.c 可用):
struct bpf_map_info info = {};
unsigned int len = sizeof(info);
int findme = bpf_obj_get("/sys/fs/bpf/findme");
if (findme <= 0) {
printf("No FD\n");
} else {
bpf_obj_get_info_by_fd(findme, &info, &len);
printf("Name: %s\n", info.name);
}
您可以使用 bpftool 创建一个映射来尝试这一点,就像这样:
$ bpftool map create /sys/fs/bpf/findme type array key 4 value 32 entries 4
name findme
运行 find-map 可执行文件将输出:
Name: findme
让我们回到 hello-buffer-config 示例和骨架代码。
附加到事件
下面的示例中的下一个骨架函数将程序附加到execve系统调用函数:
err = hello_buffer_config_bpf__attach(skel);
libbpf库会自动从该程序的SEC()定义中获取附加点。如果您没有完全定义附加点,那么还有一系列libbpf函数,例如bpf_program__attach_kprobe、bpf_program__attach_xdp等,用于附加不同类型的程序。
管理事件缓冲区
设置 perf 缓冲区使用的函数是在libbpf库本身中定义的,而不是在骨架中:
pb = perf_buffer__new(bpf_map__fd(skel->maps.output), 8, handle_event,
lost_event, NULL, NULL);
您可以看到perf_buffer__new()函数将“输出”映射的文件描述符作为第一个参数。handle_event参数是一个回调函数,当 perf 缓冲区中有新数据到达时会调用它,如果内核没有足够的空间写入数据条目,则会调用lost_event。在我的示例中,这些函数只是将消息写入屏幕。
最后,程序必须重复轮询 perf 缓冲区:
while (true) {
err = perf_buffer__poll(pb, 100);
...
}
100 是毫秒级的超时时间。以前设置的回调函数将在数据到达或缓冲区已满时适时调用。
最后,为了清理,我释放 perf 缓冲区并销毁内核中的 eBPF 程序和映射,如下所示:
perf_buffer__free(pb);
hello_buffer_config_bpf__destroy(skel);
libbpf中有一整套与perf_buffer_*和ring_buffer_*相关的函数,帮助您管理事件缓冲区。
如果您制作并运行此示例hello-buffer-config程序,您将看到以下输出(与您在第四章中看到的非常相似):
23664 501 bash Hello World
23665 501 bash Hello World
23667 0 cron Hello World
23668 0 sh Hello World
Libbpf 代码示例
有许多基于libbpf的 eBPF 程序的优秀示例可供使用,可以作为编写自己程序的灵感和指导:
-
libbpf-bootstrap项目旨在帮助您通过一组示例程序入门。
-
BCC 项目已经将许多原始的基于 BCC 的工具迁移到libbpf版本。您可以在libbpf-tools目录中找到它们。
摘要
CO-RE 使得 eBPF 程序能够在与其构建时不同的内核版本上运行。这极大地提高了 eBPF 的可移植性,并且为希望向用户和客户交付生产就绪工具的工具开发人员带来了极大的便利。
在本章中,您看到了 CO-RE 是如何通过将类型信息编码到编译后的对象文件中,并使用重定位来在加载到内核时重写指令来实现的。您还介绍了如何在 C 中编写使用libbpf的代码:既在内核中运行的 eBPF 程序,又在用户空间中管理这些程序的生命周期,基于自动生成的 BPF 骨架代码。在下一章中,您将学习内核如何验证 eBPF 程序是否安全可运行。
练习
以下是您可以进一步探索 BTF、CO-RE 和libbpf的一些事项:
-
尝试使用
bpftool btf dump map和bpftool btf dump prog来查看与映射和程序相关的 BTF 信息。请记住,你可以用多种方式指定单独的映射和程序。 -
比较在 ELF 对象文件形式及加载到内核后的相同程序的
bpftool btf dump file和bpftool btf dump prog的输出。它们应该是相同的。 -
检查来自bpftool -d prog load hello-buffer-config.bpf.o /sys/fs/bpf/hello的调试输出。你将看到每个节被加载,对许可证的检查以及正在进行的重定位,以及描述每条 BPF 程序指令的输出。
-
尝试使用来自 BTFHub 的不同vmlinux头文件构建 BPF 程序,并查看
bpftool的调试输出,以查找更改偏移量的重定位。 -
修改hello-buffer-config.c程序,以便可以使用映射为不同用户 ID 配置不同的消息(类似于第四章中的hello-buffer-config.py示例)。
-
尝试更改
SEC();中的节名称,也许改成你自己的名字。当你加载程序到内核时,你应该会看到一个错误,因为libbpf不认识节名称。这说明了libbpf如何使用节名称来确定这是什么类型的 BPF 程序。你可以尝试编写自己的附加代码,显式地附加到你选择的事件,而不依赖于libbpf的自动附加。
^(1) 严格来说,数据结构定义来自内核头文件,你可以选择基于一组不同于用于构建运行在目标机器上的内核的头文件编译的这些头文件。要正确工作(没有本章描述的 CO-RE 机制),内核头文件必须与将运行 eBPF 程序的目标机器上的内核兼容。
^(2) 本节部分内容改编自 Liz Rice 的“What Is eBPF?”。版权所有 © 2022 O’Reilly Media。已获授权使用。
^(3) 根据一项小规模且非科学的调查,大多数人发音与单词core相同,而不是分成两个音节。
^(4) 请参阅内核文档https://docs.kernel.org/bpf/btf.html#type-encoding。
^(5) 内核需要启用CONFIG_DEBUG_INFO_BTF选项进行构建。
^(6) 哪个是支持 BTF 的最早的 Linux 内核版本?参见https://oreil.ly/HML9m。
^(7) 好吧,正常的 C 预处理适用于你可以做#define等事情。但是没有像使用 BCC 时那样的特殊重写。
^(8) 处理网络数据包的 eBPF 程序无法使用这个帮助函数,只能访问网络数据包的内存。
^(9) 在某些启用了 BTF 的程序类型中是允许的,比如 tp_btf、fentry 和 fexit。
第六章:eBPF 验证器
我已经多次提到验证步骤,所以你已经知道当你将 eBPF 程序加载到内核时,这个验证过程确保程序是安全的。在本章中,我们将深入探讨验证器如何工作以实现这一目标。
验证涉及检查程序的每个可能的执行路径,并确保每条指令都是安全的。验证器还对字节码进行一些更新,以准备执行。在本章中,我将展示一些验证失败的例子,从一个有效的示例开始,逐步进行修改,使该代码对验证器无效。
注意
本章的示例代码位于存储库的chapter6目录中,该存储库位于github.com/lizrice/learning-ebpf。
本章不试图涵盖验证器进行的每一种可能的检查。它旨在提供一个概述,具有说明性的例子,这些例子将帮助您处理编写自己的 eBPF 代码时可能遇到的验证错误。
需要记住的一件事是,验证器在 eBPF 字节码上工作,而不是直接在源代码上工作。该字节码依赖于编译器的输出。由于编译器优化等因素,源代码的更改可能不会始终产生您在字节码中期望的结果,因此相应地,它可能不会给您验证器的预期结果。例如,验证器将拒绝不可达的指令,但编译器在验证器看到它们之前可能会将它们优化掉。
验证过程
验证器分析程序以评估所有可能的执行路径。它按顺序逐步执行指令,评估而非实际执行它们。在执行过程中,它通过一种称为bpf_reg_state的结构来跟踪每个寄存器的状态。(这里提到的寄存器是指你在第三章中遇到的 eBPF 虚拟机的寄存器。)该结构包括一个称为bpf_reg_type的字段,描述该寄存器中保存的值的类型。有几种可能的类型,包括以下几种:
-
NOT_INIT,表示寄存器尚未被设置为值。 -
SCALAR_VALUE,表示寄存器被设置为不表示指针的值。 -
几种
PTR_TO_*类型,指示寄存器保存指向某物的指针。例如:-
PTR_TO_CTX:寄存器保存指向传递给 BPF 程序的上下文的指针。 -
PTR_TO_PACKET:寄存器指向网络数据包(在内核中作为skb->data保存)。 -
PTR_TO_MAP_KEY或PTR_TO_MAP_VALUE:我相信你可以猜到这些是什么意思。
-
这里有几种PTR_TO_*类型,你可以在linux/bpf.h头文件中找到完整的枚举集合。
bpf_reg_state结构还跟踪寄存器可能持有的可能值范围。验证器利用这些信息来确定是否正在尝试无效操作。
每当验证器遇到一个分支时,在这里需要决定是按顺序继续还是跳转到不同的指令时,验证器都会将当前所有寄存器的当前状态复制推送到堆栈上,并探索其中一条可能的路径。它继续评估指令,直到达到程序末尾的返回指令(或达到它将处理的指令数限制,当前为一百万条指令^(1)),然后弹出堆栈上的分支以评估下一个。如果找到可能导致无效操作的指令,则验证失败。
验证每一种可能性都可能导致计算成本过高,因此在实践中有称为状态修剪的优化方法,它避免重新评估程序中实质上等效的路径。当验证器通过程序时,在程序的某些指令处记录所有寄存器的状态。如果它后来再次到达相同的指令,并且寄存器处于匹配状态,那么就没有必要继续验证该路径的其余部分,因为已知其有效。
对验证器进行了大量的优化工作以及其修剪过程。验证器以前在每个跳转指令之前和之后存储修剪状态,但分析表明,这样做导致平均每四条指令左右存储一次状态,并且绝大多数这些修剪状态都不会被匹配。结果证明,无论分支情况如何,每 10 条指令存储一次修剪状态更有效率。
注意
您可以在内核文档中详细了解验证工作的更多细节。
验证器日志
当程序的验证失败时,验证器会生成一个日志,显示它是如何得出程序无效的结论的。如果您使用bpftool prog load,则验证器日志将输出到 stderr。当您使用libbpf编写程序时,可以使用函数libbpf_set_print()设置一个处理程序,它将显示(或执行其他有用的操作)任何错误。(您将在本章的hello-verifier.c源代码中看到此示例。)
注意
如果您真的想深入了解验证器的工作原理,您可以要求它在成功和失败时生成日志。在hello-verifier.c文件中也有一个基本的示例。它涉及将用于保存验证器日志内容的缓冲区传递到加载程序到内核的libbpf调用中,然后将该日志的内容写入屏幕。
验证器日志包含验证器执行的工作量摘要,类似于以下内容:
processed 61 insns (limit 1000000) max_states_per_insn 0 total_states 4
peak_states 4 mark_read 3
在这个示例中,验证器处理了 61 条指令,可能通过不同的路径多次处理了同一条指令。请注意,100 万的复杂性限制是程序中指令数量的上限;实际上,如果代码中有分支,验证器将多次处理某些指令。
存储的总状态数为四,对于这个简单的程序来说,这与存储状态的峰值数量匹配。如果某些状态被剪枝了,峰值数量可能会低于总数。
日志输出包括验证器分析的 BPF 指令,以及相应的 C 源代码行(如果目标文件使用了-g标志包含了调试信息),以及验证器状态信息的摘要。以下是与hello-verifier.bpf.c程序的前几行相关的验证器日志的示例摘录:
0: (bf) r6 = r1
; data.counter = c; 
1: (18) r1 = 0xffff800008178000
3: (61) r2 = *(u32 *)(r1 +0)
R1_w=map_value(id=0,off=0,ks=4,vs=16,imm=0) R6_w=ctx(id=0,off=0,imm=0)
R10=fp0 
; c++;
4: (bf) r3 = r2
5: (07) r3 += 1
6: (63) *(u32 *)(r1 +0) = r3
R1_w=map_value(id=0,off=0,ks=4,vs=16,imm=0) R2_w=inv(id=1,umax_value=4294967295,
var_off=(0x0; 0xffffffff)) R3_w=inv(id=0,umin_value=1,umax_value=4294967296,
var_off=(0x0; 0x1ffffffff)) R6_w=ctx(id=0,off=0,imm=0) R10=fp0 
日志包括源代码行,以便更容易理解输出与源代码的关系。这些源代码可用,因为在编译步骤中使用了-g标志以包含调试信息。
下面是日志中输出的一些寄存器状态信息的示例。它告诉我们,在这个阶段,寄存器 1 包含一个映射值,寄存器 6 保存上下文,寄存器 10 是帧(或堆栈)指针,用于保存局部变量。
这是寄存器状态信息的另一个例子。在这里,您不仅可以看到每个(初始化的)寄存器中保存的值的类型,还可以看到寄存器 2 和寄存器 3 可能值的范围。
让我们进一步探讨这一点的详细信息。我说寄存器 6 保存上下文,验证器日志通过R6_w=ctx(id=0,off=0,imm=0)表示了这一点。这是在字节码的第一行中设置的,其中寄存器 1 被复制到寄存器 6。当调用 eBPF 程序时,寄存器 1 始终保存传递给程序的上下文参数。为什么将其复制到寄存器 6?嗯,当调用 BPF 助手函数时,该调用的参数通过寄存器 1 到 5 传递。助手函数不会修改寄存器 6 到 9 的内容,因此将上下文保存到寄存器 6 意味着代码可以调用助手函数而不会失去对上下文的访问。
寄存器 0 用于助手函数的返回值,也用于 eBPF 程序的返回值。寄存器 10 始终保存指向 eBPF 堆栈帧的指针(eBPF 程序不能修改它)。
让我们看看第 6 条指令后寄存器 2 和寄存器 3 的寄存器状态信息:
R2_w=inv(id=1,umax_value=4294967295,var_off=(0x0; 0xffffffff))
R3_w=inv(id=0,umin_value=1,umax_value=4294967296,var_off=(0x0; 0x1ffffffff))
寄存器 2 没有最小值,这里显示的umax_value对应于十进制的 0xFFFFFFFF,这是可以存储在 8 字节寄存器中的最大值。换句话说,在这一点上,寄存器可以存储任何可能的值。
在指令 4 中,将寄存器 2 的内容复制到寄存器 3,然后指令 5 对该值加 1。因此,寄存器 3 的值可以是任何大于 1 的值。您可以在寄存器 3 的状态信息中看到这一点,其中umin_value设为1,而umax_value为0xFFFFFFFF。
验证器使用有关每个寄存器的状态以及每个寄存器可能包含的值范围的信息,以确定程序的可能路径。这也用于我之前提到的状态修剪:如果验证器在代码中的同一位置,具有相同类型和每个寄存器可能值范围的状态,则无需进一步评估此路径。此外,如果当前状态是稍早前看到的状态的子集,则也可以进行修剪。
可视化控制流
验证器探索 eBPF 程序的所有可能路径,如果您试图调试问题,查看这些路径对自己会有帮助。bpftool实用程序可以帮助您通过生成程序的 DOT 格式的控制流图,然后将其转换为图像格式,如下所示:
$ bpftool prog dump xlated name kprobe_exec visual > out.dot
$ dot -Tpng out.dot > out.png
这产生了一个类似图 6-1 所示的控制流的可视化表示。

图 6-1. 控制流图中的剪辑(完整图像可在该书的GitHub 仓库的 chapter6/kprobe_exec.png 找到)
验证助手函数的有效性
不允许从 eBPF 程序直接调用任何内核函数(除非已将其注册为 kfunc,您将在下一章中了解到),但是 eBPF 提供了许多助手函数,使程序能够从内核中访问信息。有一个bpf-helpers 手册页试图记录所有这些函数。
不同的助手函数适用于不同的 BPF 程序类型。例如,助手函数bpf_get_current_pid_tgid()用于获取当前用户空间的进程 ID 和线程 ID,但是从由网络接口接收数据包触发的 XDP 程序中调用此函数是没有意义的,因为这里没有涉及到用户空间进程。您可以通过将hello eBPF 程序中的SEC()定义从kprobe改为xdp来看到这个示例。尝试加载该程序时,验证器的输出会给出以下消息:
...
16: (85) call bpf_get_current_pid_tgid#14
unknown func bpf_get_current_pid_tgid#14
unknown func并不意味着该函数完全未知,而只是在此 BPF 程序类型中未知。 (BPF 程序类型是下一章的一个话题;目前,您可以将它们视为适合附加到不同类型事件的程序。)
助手函数参数
例如,如果您查看kernel/bpf/helpers.c^(2),您会发现每个助手函数都有类似于此示例中bpf_map_lookup_elem()助手函数的bpf_func_proto结构:
const struct `bpf_func_proto` `bpf_map_lookup_elem_proto` = {
.`func` = `bpf_map_lookup_elem`,
.`gpl_only` = `false`,
.`pkt_access` = `true`,
.`ret_type` = `RET_PTR_TO_MAP_VALUE_OR_NULL`,
.`arg1_type` = `ARG_CONST_MAP_PTR`,
.`arg2_type` = `ARG_PTR_TO_MAP_KEY`,
};
此结构定义了向助手函数传递的参数和返回值的约束条件。由于验证器跟踪每个寄存器中保存的值的类型,它可以发现您尝试向助手函数bpf_map_lookup_elem()调用中传递错误类型的参数。例如,尝试更改hello程序中对bpf_map_lookup_elem()调用的参数,如下所示:
p = bpf_map_lookup_elem(&data, &uid);
现在,不再传递指向映射的指针&my_config,而是传递了指向本地变量结构的指针&data。从编译器的角度来看,这是有效的,因此可以构建 BPF 对象文件hello-verifier.bpf.o,但当您尝试将程序加载到内核时,您会在验证器日志中看到如下错误:
27: (85) call bpf_map_lookup_elem#1
R1 type=fp expected=map_ptr
在这里,fp代表帧指针,它是存储本地变量的堆栈内存区域。寄存器 1 装载了名为data的本地变量的地址,但函数期望一个指向映射的指针(如前面bpf_func_proto结构的arg1_type字段所示)。通过跟踪每个寄存器中存储的值的类型,验证器能够发现这种差异。
检查许可证
验证器还检查,如果您使用了 GPL 许可的 BPF 助手函数,您的程序也必须具有 GPL 兼容的许可证。在hello-verifier.bpf.c的第六章的最后一行定义了一个“license”部分,其中包含字符串Dual BSD/GPL。如果您删除此行,则验证器的输出将以如下方式结束:
...
37: (85) call bpf_probe_read_kernel#113
cannot call GPL-restricted function from non-GPL compatible program
这是因为bpf_probe_read_kernel()助手函数的gpl_only字段设置为true。在此 eBPF 程序中早些时候调用了其他助手函数,但它们没有 GPL 许可证,因此验证器不会反对它们的使用。
BCC 项目维护着一个助手函数列表,指示它们是否具有 GPL 许可证。如果您对助手函数的实现细节更感兴趣,可以在BPF 和 XDP 参考指南中的相关部分找到更多详细信息。
检查内存访问
验证器执行多个检查,以确保 BPF 程序只能访问它们应该访问的内存。
例如,在处理网络数据包时,XDP 程序只允许访问构成该网络数据包的内存位置。大多数 XDP 程序从以下非常相似的内容开始:
SEC("xdp")
int xdp_load_balancer(struct xdp_md *ctx)
{
void *data = (void *)(long)ctx->data;
void *data_end = (void *)(long)ctx->data_end;
...
xdp_md 结构体作为上下文传递给程序,描述了接收到的网络数据包。该结构体内的 ctx->data 字段是数据包开始的内存位置,而 ctx->data_end 是数据包的最后位置。验证器将确保程序不会超出这些边界。
例如,在 hello_verifier.bpf.c 中的以下程序是有效的:
SEC("xdp")
int xdp_hello(struct xdp_md *ctx) {
void *data = (void *)(long)ctx->data;
void *data_end = (void *)(long)ctx->data_end;
bpf_printk("%x", data_end);
return XDP_PASS;
}
变量 data 和 data_end 非常相似,但验证器足够智能,能识别 data_end 关联到数据包的末端。你的程序需要检查从数据包读取的任何值是否超出该位置,并且它不允许通过修改 data_end 的值来“作弊”。尝试在 bpf_printk() 调用之前添加以下行:
data_end++;
验证器会报错,如下所示:
; data_end++;
1: (07) r3 += 1
R3 pointer arithmetic on pkt_end prohibited
另一个示例中,在访问数组时,你需要确保不会访问超出数组边界的索引。在示例代码中有一段代码从 message 数组中读取字符,如下所示:
if (c < sizeof(message)) {
char a = message[c];
bpf_printk("%c", a);
}
这是没问题的,因为有显式检查确保计数变量 c 不会超过消息数组的大小。而像下面这样的简单“差一”的错误会使其无效:
if (c <= sizeof(message)) {
char a = message[c];
bpf_printk("%c", a);
}
验证器将以类似以下的错误消息失败:
invalid access to map value, value_size=16 off=16 size=1
R2 max value is outside of the allowed memory range
从这条消息很明显可以看出,由于寄存器 2 可能保存一个对地图索引过大的值,导致对地图值的无效访问。如果你正在调试此错误,你需要深入日志,查看源代码中哪一行负责这个错误。日志在发出错误消息前如下结束(为了清晰起见,我已删除部分状态信息):
; if (c <= sizeof(message)) {
30: (25) if r1 > 0xc goto pc+10 
R0_w=map_value_or_null(id=2,off=0,ks=4,vs=12,imm=0) R1_w=inv(id=0,
umax_value=12,var_off=(0x0; 0xf)) R6=ctx(id=0,off=0,imm=0) ...
; char a = message[c];
31: (18) r2 = 0xffff800008e00004 
33: (0f) r2 += r1
last_idx 33 first_idx 19
regs=2 stack=0 before 31: (18) r2 = 0xffff800008e00004
regs=2 stack=0 before 30: (25) if r1 > 0xc goto pc+10
regs=2 stack=0 before 29: (61) r1 = *(u32 *)(r8 +0)
34: (71) r3 = *(u8 *)(r2 +0) 
R0_w=map_value_or_null(id=2,off=0,ks=4,vs=12,imm=0) R1_w=invP(id=0,
umax_value=12,var_off=(0x0; 0xf)) R2_w=map_value(id=0,off=4,ks=4,vs=16,
umax_value=12,var_off=(0x0; 0xf),s32_max_value=15,u32_max_value=15)
R6=ctx(id=0,off=0,imm=0) ...
从错误处回溯,最后的寄存器状态信息显示寄存器 2 的最大值可能是 12。
在第 31 指令处,寄存器 2 被设置为内存中的一个地址,然后按寄存器 1 的值递增。输出显示这对应于访问 message[c] 的代码行,因此合理推测寄存器 2 被设置为指向消息数组,然后按寄存器 1 中的 c 值递增。
进一步查找寄存器 1 的值,日志显示其最大值为 12(即十六进制的 0x0c)。然而,message 被定义为一个 12 字节的字符数组,因此只有索引 0 到 11 在其范围内。由此可见,错误来自于源代码中测试 c <= sizeof(message)。
在第 2 步,我已经从验证器包含在日志中的源代码行中推断了一些寄存器与它们表示的源代码变量之间的关系。如果代码是没有调试信息编译的,您可能需要通过验证器日志来检查这一点。鉴于存在调试信息,使用它是有意义的。
message 数组声明为全局变量,您可能还记得来自 第三章 的全局变量是使用映射实现的。这解释了为什么错误消息提到“无效访问映射值”。
在解引用指针之前检查指针
一个让 C 程序崩溃的简单方法是在指针的值为零(也称为null)时解引用指针。指针指示内存中值的位置,而零不是有效的内存位置。eBPF 验证器要求在解引用指针之前检查所有指针,以防止这种崩溃发生。
hello-verifier.bpf.c 中的示例代码寻找可能存在于 my_config 散列表映射中的自定义消息,代码如下:
p = bpf_map_lookup_elem(&my_config, &uid);
如果没有与 uid 对应的条目,则将 p(指向消息结构 msg_t 的指针)设置为零。这里有一小段额外的代码,试图解引用这个可能为空的指针:
char a = p->message[0];
bpf_printk("%c", a);
这段代码可以编译,但验证器会拒绝如下:
; p = bpf_map_lookup_elem(&my_config, &uid);
25: (18) r1 = 0xffff263ec2fe5000
27: (85) call bpf_map_lookup_elem#1
28: (bf) r7 = r0 
; char a = p->message[0];
29: (71) r3 = *(u8 *)(r7 +0) 
R7 invalid mem access 'map_value_or_null'
辅助函数调用的返回值存储在寄存器 0 中。在这里,该值被存储在寄存器 7 中。这意味着寄存器 7 现在保存了局部变量 p 的值。
此指令尝试解引用指针值 p。验证器一直跟踪寄存器 7 的状态,并知道它可能保存指向映射值的指针,或者可能为空。
验证器会拒绝尝试解引用空指针的尝试,但如果有显式检查,例如:
if (p != 0) {
char a = p->message[0];
bpf_printk("%d", cc);
}
一些辅助函数会为您集成指针检查。例如,如果您查看 bpf-helpers 的 man 页面,您将找到 bpf_probe_read_kernel() 的函数签名如下:
long bpf_probe_read_kernel(void **`dst`*, u32 *`size`*, const void **`unsafe_ptr`*)
此函数的第三个参数称为 unsafe_ptr。这是一个 BPF 辅助函数的示例,通过为您处理检查,帮助程序员编写安全代码。您可以传递一个潜在的空指针,但只能作为名为 unsafe_ptr 的第三个参数,并且在尝试解引用之前,辅助函数会检查它不为空。
访问上下文
每个 eBPF 程序作为参数传递一些上下文信息,但根据程序和附加类型的不同,可能只允许访问其中的一部分上下文信息。例如,跟踪点程序 接收一个指向某些跟踪点数据的指针。该数据的格式取决于特定的跟踪点,但它们都以一些共同字段开头——然而,这些共同字段对 eBPF 程序是不可访问的。只能访问后续的特定于跟踪点的字段。试图读取或写入错误的字段会导致 invalid bpf_context access 错误。本章末尾的练习中有一个例子。
运行至完成
验证器确保 eBPF 程序能够完成运行;否则,可能会无限消耗资源。为了达到这个目的,它限制了它将处理的总指令数,就像我之前提到的,在撰写本文时设定为一百万条指令。这个限制是 硬编码进内核 的,不是一个可配置的选项。如果验证器在处理这么多指令之前未到达 BPF 程序的末尾,则会拒绝该程序。
创建一个永不完成的程序的简单方法是编写一个永不结束的循环。让我们看看如何在 eBPF 程序中创建循环。
循环
为了保证完成,直到内核版本 5.3,对循环有一个限制。[³] 循环通过相同的指令需要向后跳转到较早的指令,过去验证器不允许这种情况发生。eBPF 程序员通过使用 #pragma unroll 编译器指令来绕过此问题,告诉编译器为每次循环写出一组相同(或非常相似)的字节码指令。这样节省了程序员重复输入相同代码的时间,但在生成的字节码中会看到重复的指令。
从 5.3 版本开始,验证器在检查所有可能的执行路径时向后跟随分支,而不仅仅是向前。这意味着它可以接受一些循环,只要执行路径保持在一百万条指令的限制内。
您可以在示例 xdp_hello 程序中看到一个循环的示例。通过验证的循环版本看起来像这样:
for (int i=0; i < 10; i++) {
bpf_printk("Looping %d", i);
}
(成功的)验证器日志将显示它已经围绕此循环执行路径 10 次。通过这样做,它不会达到一百万条指令的复杂性限制。在本章的练习中,还有另一个版本的循环将达到该限制并且无法通过验证。
在版本 5.17 中引入了一个新的助手函数bpf_loop(),它使得验证器不仅更容易接受循环,而且以更高效的方式执行。这个助手函数以其第一个参数作为最大迭代次数,并传递一个在每次迭代中调用的函数。验证器只需验证该函数中的 BPF 指令一次,无论它可能被调用多少次。该函数可以返回一个非零值,以指示无需再次调用它,这用于在达到所需结果后提前终止循环。
还有一个助手函数bpf_for_each_map_elem(),它调用映射中每个项目的提供的回调函数。
检查返回代码
eBPF 程序的返回代码存储在寄存器 0(R0)中。如果程序离开R0未初始化,验证器会失败,就像这样:
R0 !read_ok
你可以通过注释掉一个函数中的所有代码来尝试这个功能;例如,将xdp_hello示例修改如下:
SEC("xdp")
int xdp_hello(struct xdp_md *ctx) {
void *data = (void *)(long)ctx->data;
void *data_end = (void *)(long)ctx->data_end;
// bpf_printk("%x", data_end);
// return XDP_PASS;
}
这将导致验证器失败。然而,如果你把含有助手函数bpf_printf()的行放回去,验证器就不会抱怨,即使源代码中没有明确的返回值设置!
这是因为寄存器 0 还用于保存来自助手函数的返回代码。从 eBPF 程序的助手函数返回后,寄存器 0 不再是未初始化的。
无效指令
正如你从第三章中对 eBPF(虚拟)机器的讨论中了解到的,eBPF 程序由一组字节码指令组成。验证器检查程序中的指令是否是有效的字节码指令,例如,只使用已知的操作码。
如果编译器生成了无效的字节码,这将被视为编译器的一个错误,因此,除非你选择(出于某种你自己知道的原因)手动编写 eBPF 字节码,否则你不太可能看到这种类型的验证器错误。然而,最近添加了一些指令,如原子操作。如果你的编译字节码使用这些指令,它们将在较旧的内核上验证失败。
不可达指令
验证器还会拒绝具有不可达指令的程序。通常情况下,这些指令在编译器优化时会被剔除。
总结
当我第一次对 eBPF 产生兴趣时,通过验证器的代码看起来像是一门黑暗艺术,看似有效的代码会被拒绝,抛出看似随意的错误。随着时间的推移,验证器有了许多改进,在本章中你已经看到了几个示例,验证器日志提供了帮助,帮助你找出问题所在。
当你对 eBPF(虚拟)机器如何工作有一个心理模型时,这些提示会更有帮助,它使用一组寄存器作为临时值存储,在执行 eBPF 程序时步进。验证器跟踪每个寄存器的类型和可能的值范围,以确保 eBPF 程序可以安全运行。
如果你尝试编写自己的 eBPF 代码,可能会需要帮助来解决验证器错误。eBPF 社区 Slack 频道 是一个寻求帮助的好地方,许多人也在 StackOverflow 上找到了建议。
练习
这里有更多导致验证器错误的方式。看看你能否将验证器日志输出与你收到的错误相关联:
-
在 “检查内存访问” 中,你看到验证器拒绝超出全局
message数组末尾的访问。在示例代码中,有一个类似方式访问局部变量data.message的部分:if (c < sizeof(data.message)) { char a = data.message[c]; bpf_printk("%c", a); }尝试调整代码,通过用
<=替换<来制造相同的越界错误,你会看到关于invalid variable-offset read from stack R2的错误消息。 -
在示例代码的 xdp_hello 中找到被注释掉的循环。尝试添加第一个看起来像这样的循环:
for (int i=0; i < 10; i++) { bpf_printk("Looping %d", i); }你应该在验证器日志中看到一系列重复的类似以下内容的行:
42: (18) r1 = 0xffff800008e10009 44: (b7) r2 = 11 45: (b7) r3 = 8 46: (85) call bpf_trace_printk#6 R0=inv(id=0) R1_w=map_value(id=0,off=9,ks=4,vs=26,imm=0) R2_w=inv11 R3_w=inv8 R6=pkt_end(id=0,off=0,imm=0) R7=pkt(id=0,off=0,r=0,imm=0) R10=fp0 last_idx 46 first_idx 42 regs=4 stack=0 before 45: (b7) r3 = 8 regs=4 stack=0 before 44: (b7) r2 = 11从日志中找出跟踪循环变量
i的哪个寄存器。 -
现在尝试添加一个将失败的循环,看起来像这样:
for (int i=0; i < c; i++) { bpf_printk("Looping %d", i); }你应该看到验证器尝试探索这个循环直到结束,但在完成之前达到了指令复杂性限制(因为全局变量
c没有上限)。 -
编写一个附加到跟踪点的程序。(你可能已经在 第四章 中完成了练习。)预览 “跟踪点” 时,你可以看到一个从这些字段开始的上下文参数的结构定义:
unsigned short common_type; unsigned char common_flags; unsigned char common_preempt_count; int common_pid;创建一个类似这样开始的结构体的版本,并使程序中的上下文参数指向这个结构体的指针。在程序中,尝试访问任何这些字段,并看到验证器因为
invalid bpf_context access失败。
^(1) 长期以来,指令数限制为 4,096 条,这给 eBPF 程序的复杂性带来了显著限制。对于运行 BPF 程序的非特权用户,此限制仍然适用。
^(2) 辅助函数也在源代码的其他地方定义,例如,kernel/trace/bpf_trace.c 和 net/core/filter.c。
^(3) 该版本为 BPF 验证器带来了许多重要的优化和改进,这些内容在 LWN 文章 “BPF 在 5.3 内核中的有界循环” 中有详细总结。
第七章:eBPF 程序和附着类型
在前面的章节中,你看到了很多 eBPF 程序的例子,可能注意到它们附着在不同类型的事件上。我展示的一些例子附着在 kprobe 上,但在其他例子中,我展示了处理新到达的网络数据包的 XDP 程序。这些只是内核中许多附着点中的两个。在本章中,我们将深入探讨不同的程序类型以及它们如何附着到不同的事件上。
注意
你可以使用 github.com/lizrice/learning-ebpf 上的代码和指导来构建和运行本章的示例。本章的代码位于 chapter7 目录中。
在撰写本文时,一些示例在 ARM 处理器上不受支持。请查看 chapter7 目录下的 README 文件以获取更多详细信息和建议。
目前在 uapi/linux/bpf.h 中列出了大约 30 种程序类型,以及超过 40 种附着类型。附着类型更具体地定义了程序附着的位置;对于许多程序类型,附着类型可以从程序类型推断出来,但某些程序类型可以附着到内核中多个不同的点,因此还必须指定附着类型。
如你所知,本书不是一本参考手册,因此我不会详细介绍每一种 eBPF 程序类型。不过,在你阅读本书时,很可能已经新增了新的类型!
程序上下文参数
所有 eBPF 程序都需要一个指针类型的上下文参数,但它所指向的结构取决于触发它的事件类型。eBPF 程序员需要编写能够接受适当类型上下文的程序;如果事件是跟踪点,那么假装上下文参数指向网络数据包是没有意义的。定义不同类型的程序允许验证器确保上下文信息被适当处理,并强制执行关于哪些辅助函数是允许的规则。
注意
要深入了解传递给不同 BPF 程序类型的上下文数据的详细信息,请查看 Alan Maguire 在 Oracle 博客上的这篇文章。
辅助函数和返回代码
如前一章所示,验证器检查程序使用的所有辅助函数是否与其程序类型兼容。前一章的示例表明,在 XDP 程序中不允许使用 bpf_get_current_pid_tgid() 辅助函数。在接收数据包并触发 XDP 钩子时,并没有涉及用户空间的进程或线程,因此在这种情况下调用获取当前进程和线程 ID 的函数是没有意义的。
程序类型还确定了程序的返回代码的含义。再以 XDP 为例,返回代码告诉内核在 eBPF 程序完成处理后应该如何处理数据包——包括传递到网络堆栈、丢弃或重定向到另一个接口。当 eBPF 程序由于某种情况,比如命中特定的 tracepoint 时,这些返回代码就没有意义了,因为这时候没有涉及到网络数据包。
助手函数的 man 页面(带有合理的免责声明,由于 BPF 子系统的持续开发,该页面可能不完整)。
您可以使用bpftool feature命令获取您内核版本中每种程序类型可用的助手函数列表。该命令显示系统配置,并列出所有可用的程序类型和映射类型,甚至列出了每种程序类型支持的所有助手函数。
助手函数被视为UAPI的一部分,即 Linux 内核的外部稳定接口。因此,一旦在内核中定义了助手函数,即使内核的内部函数和数据结构可能会发生变化,助手函数也不应该在将来发生变化。
尽管内核版本之间可能发生变化,但 eBPF 程序员需要能够访问一些内部函数。这可以通过称为BPF 内核函数或kfuncs.的机制来实现。
Kfuncs
Kfuncs 允许将内部内核函数注册到 BPF 子系统中,以便验证器允许它们从 eBPF 程序中调用。对于每种允许调用特定 kfunc 的 eBPF 程序类型都有一个注册。
与助手函数不同,kfuncs 不提供兼容性保证,因此 eBPF 程序员必须考虑内核版本之间的变化可能性。
有一组“核心”BPF kfuncs,目前包括允许 eBPF 程序获取和释放任务及 cgroup 内核引用的函数。
总结一下,eBPF 程序的类型决定了它可以附加到哪些事件,从而定义了它接收的上下文信息的类型。程序类型还定义了它可以调用的助手函数和 kfuncs 集合。
程序类型通常分为两类:跟踪(或 perf)程序类型和与网络相关的程序类型。我们来看一些例子。
跟踪
附加到 kprobes、tracepoints、原始 tracepoints、fentry/fexit probes 和性能事件的程序,都旨在为内核中的 eBPF 程序提供一种有效的方式,将关于事件的跟踪信息报告到用户空间。这些与跟踪相关的类型并不预期会影响内核对它们附加的事件的响应方式(尽管正如你在 第九章 中看到的,这方面确实有一些创新!)。
这些有时被称为“与性能相关的”程序。例如,bpftool perf 子命令允许你查看附加到类似性能事件的程序:
$ sudo bpftool perf show
pid 232272 fd 16: prog_id 392 kprobe func __x64_sys_execve offset 0
pid 232272 fd 17: prog_id 394 kprobe func do_execve offset 0
pid 232272 fd 19: prog_id 396 tracepoint sys_enter_execve
pid 232272 fd 20: prog_id 397 raw_tracepoint sched_process_exec
pid 232272 fd 21: prog_id 398 raw_tracepoint sched_process_exec
上述输出是我在 chapter7 目录中运行 hello.bpf.c 文件中的示例代码时看到的。这些代码附加到与 execve() 相关的各种事件,我将在本节中讨论所有这些类型,但作为概述,这些程序包括:
-
一个附加到
execve()系统调用入口点的 kprobe。 -
一个附加到内核函数
do_execve()的 kprobe。 -
一个放置在
execve()系统调用入口处的 tracepoint。 -
在处理
execve()过程中调用的两个原始 tracepoint 的版本。你将在本节中看到,其中一个是启用了 BTF 的版本。
要使用与跟踪相关的 eBPF 程序类型,你需要 CAP_PERFMON 和 CAP_BPF 或 CAP_SYS_ADMIN 权限。
Kprobes 和 Kretprobes
我在 第一章 中讨论了 kprobes 的概念。你几乎可以将 kprobe 程序附加到内核的任何位置。^(1) 通常,它们使用 kprobes 附加到函数的入口点,使用 kretprobes 附加到函数的出口点,但你也可以使用 kprobes 附加到函数入口后指定偏移量处的指令。如果你选择这样做,^(2) 你需要确信你运行的内核版本确实有你想要附加到的指令!附加到内核函数的入口和出口点可能相对稳定,但任意代码行可能会在不同版本之间轻易修改。
注意
在 bpftool perf list 的示例输出中,你可以看到两个 kprobe 的偏移量都为 0。
当内核编译时,也有可能编译器选择“内联”任何给定的内核函数;也就是说,而不是从调用函数处跳转,编译器可能会生成机器代码来实现函数在调用函数内部的操作。如果一个函数被内联了,你的 eBPF 程序就无法附加到 kprobe 的入口点。
附加 kprobes 到系统调用入口点
本章的第一个示例 eBPF 程序称为 kprobe_sys_execve,它是附加到 execve() 系统调用的 kprobe。该函数及其段定义如下:
SEC("ksyscall/execve")
int BPF_KPROBE_SYSCALL(kprobe_sys_execve, char *pathname)
这与你在第五章中看到的内容相同。
附加到系统调用的一个原因是它们是稳定的接口,在内核版本之间不会改变(跟踪点也是如此,我们马上就会讲到)。然而,出于详细的安全工具原因,不应依赖于系统调用 kprobes,我将在第九章中详细讨论。
将 kprobes 附加到其他内核函数
您可以找到很多例子,eBPF 基础工具使用 kprobes 附加到系统调用,但正如前面提到的,kprobes 也可以附加到内核中任何非内联函数。我在hello.bpf.c中提供了一个示例,它将 kprobe 附加到函数do_execve(),并且定义如下:
SEC("kprobe/do_execve")
int BPF_KPROBE(kprobe_do_execve, struct filename *filename)
因为do_execve()不是一个系统调用,所以这与前面的例子有一些区别:
-
SEC 名称的格式与附加到系统调用入口点的先前版本相同,但无需定义特定于平台的变体,因为像大多数内核函数一样,
do_execve()对所有平台都是通用的。 -
我使用了
BPF_KPROBE宏而不是BPF_KPROBE_SYSCALL。意图完全相同,只是后者处理系统调用参数。 -
另一个重要的区别是系统调用的
pathname参数是一个指向字符串的指针(char *),但对于这个函数,该参数称为filename,它是指向内核中使用的数据结构struct filename的指针。
你可能想知道我是如何知道要为这个参数使用这种类型的。我会给你看。内核中的do_execve()函数具有以下签名:
int `do_execve`(struct `filename` *`filename`,
const char `__user` *const `__user` *__argv,
const char `__user` *const `__user` *__envp)
我选择忽略do_execve()的参数__argv和__envp,仅声明filename参数,使用类型struct filename *以匹配内核函数的定义。鉴于参数按顺序排列在内存中的方式,忽略最后的n个参数是可以的,但如果要使用后面的参数,则不能忽略列表中较早的参数。
这个filename结构在内核内部定义,这说明了 eBPF 编程是内核编程的一部分:我不得不查找do_execve()的定义以找到其参数的定义,以及struct filename的定义。即将运行的可执行文件的名称由filename->name指向。在示例代码中,我使用以下代码行检索此名称:
const char *name = BPF_CORE_READ(filename, name);
bpf_probe_read_kernel(&data.command, sizeof(data.command), name);
因此,总结一下:系统调用 kprobe 的上下文参数是一个表示用户空间传递给系统调用的值的结构体。“常规”(非系统调用)kprobe 的上下文参数是一个表示由调用它的内核代码传递给被调用函数的参数的结构体,因此结构体取决于函数定义。
Kretprobes 与 kprobes 非常相似,不同之处在于它们在函数返回时触发,并且可以访问返回值而不是参数。
如果您正在运行在最新内核上,那么 kprobes 和 kretprobes 是连接到内核函数的合理方式,但是如果您要考虑的是较新的选项,则有一个新的选项。
Fentry/Fexit
在内核版本 5.5 引入了一种更高效的机制,用于跟踪进入和退出内核函数的想法与x86 处理器上的 BPF 跳板一起引入(ARM 处理器上的 BPF 跳板支持直到 Linux 6.0 才到来)。如果您使用的是足够新的内核,fentry/fexit 现在是跟踪内核函数进入或退出的首选方法。您可以在 kprobe 或 fentry 类型程序中编写相同的代码。
在chapter7/hello.bpf.c中有一个名为fentry_execve()的示例 fentry 程序。我使用libbpf的宏BPF_PROG声明了这个 kprobe 的 eBPF 程序,这是另一个方便的包装器,提供了对类型化参数的访问,而不是通用的上下文指针,但是此版本用于 fentry、fexit 和 tracepoint 程序类型。定义如下:
SEC("fentry/do_execve")
int BPF_PROG(fentry_execve, struct filename *filename)
部分名称告诉libbpf在do_execve()内核函数的开始处附加 fentry 挂钩。就像 kprobe 示例中一样,上下文参数反映了您想要附加此 eBPF 程序的内核函数传递的参数。
Fentry 和 fexit 附加点的设计目的是比 kprobes 更高效,但是当您想在函数结束时生成事件时,还有另一个优势:fexit 挂钩可以访问函数的输入参数,而 kretprobe 则不能。您可以在libbpf-bootstrap的示例中看到一个例子。kprobe.bpf.c和fentry.bpf.c是钩入do_unlinkat()内核函数的等效示例。附加到 kretprobe 的 eBPF 程序具有以下签名:
SEC("kretprobe/do_unlinkat")
int BPF_KRETPROBE(do_unlinkat_exit, long ret)
BPF_KRETPROBE宏扩展以在从do_unlinkat()退出时创建一个 kretprobe 程序。eBPF 程序仅接收ret参数,该参数保存了从do_unlinkat()返回的值。与 fexit 版本进行比较:
SEC("fexit/do_unlinkat")
int BPF_PROG(do_unlinkat_exit, int dfd, struct filename *name, long ret)
在这个版本中,程序不仅可以访问返回值ret,还可以访问传递给do_unlinkat()的输入参数,这些参数是dfd和name。
Tracepoints
Tracepoints是内核代码中标记的位置(我们稍后在本章讨论用户空间 tracepoints)。它们并不专属于 eBPF,并且长期以来已被用于生成内核跟踪输出并被诸如SystemTap之类的工具使用。与使用 kprobes 连接到任意指令不同,tracepoints 在内核发布之间是稳定的(尽管旧内核可能没有新增到新内核中的完整 tracepoint 集)。
您可以通过查看/sys/kernel/tracing/available_events来查看内核上可用的跟踪子系统,如下所示:
$ cat /sys/kernel/tracing/available_events
tls:tls_device_offload_set
tls:tls_device_decrypted
...
syscalls:sys_exit_execveat
syscalls:sys_enter_execveat
syscalls:sys_exit_execve
syscalls:sys_enter_execve
...
我的 5.15 内核版本在此列表中定义了超过 1,400 个追踪点。追踪点 eBPF 程序的部分定义应该匹配其中的一个项目,以便libbpf可以自动将其附加到追踪点上。定义的形式为SEC("tp/tracing subsystem/tracepoint name")。
您可以在chapter7/hello.bpf.c文件中找到一个示例,匹配syscalls:sys_enter_execve追踪点,当内核开始处理execve()调用时就会触发。该部分定义告诉libbpf这是一个追踪点程序,并且它应该附加在哪里,就像这样:
SEC("tp/syscalls/sys_enter_execve")
针对追踪点的上下文参数怎么样呢?稍后我将来详细介绍,BTF 可以在这里帮助我们,但首先让我们考虑当 BTF 不可用时需要什么。每个追踪点都有一个格式,描述从中追踪出的字段。举个例子,这是execve()系统调用进入时的追踪点格式:
$ cat /sys/kernel/tracing/events/syscalls/sys_enter_execve/format
name: sys_enter_execve
ID: 622
format:
field:unsigned short common_type; offset:0; size:2; signed:0;
field:unsigned char common_flags; offset:2; size:1; signed:0;
field:unsigned char common_preempt_count; offset:3; size:1; signed:0;
field:int common_pid; offset:4; size:4; signed:1;
field:int __syscall_nr; offset:8; size:4; signed:1;
field:const char * filename; offset:16; size:8; signed:0;
field:const char *const * argv; offset:24; size:8; signed:0;
field:const char *const * envp; offset:32; size:8; signed:0;
print fmt: "filename: 0x%08lx, argv: 0x%08lx, envp: 0x%08lx",
((unsigned long)(REC->filename)), ((unsigned long)(REC->argv)),
((unsigned long)(REC->envp))
我使用这些信息在chapter7/hello.bpf.c中定义了一个匹配的结构,称为my_syscalls_enter_execve:
struct my_syscalls_enter_execve {
unsigned short common_type;
unsigned char common_flags;
unsigned char common_preempt_count;
int common_pid;
long syscall_nr;
long filename_ptr;
long argv_ptr;
long envp_ptr;
};
eBPF 程序不允许访问这四个字段中的前四个。如果尝试访问它们,程序将因为invalid bpf_context access错误而验证失败。
我的示例 eBPF 程序连接到此追踪点时,可以使用指向此类型的指针作为其上下文参数,如下所示:
int tp_sys_enter_execve(struct my_syscalls_enter_execve *ctx) {
然后,您可以访问此结构的内容。例如,您可以获取文件名指针如下:
bpf_probe_read_user_str(&data.command, sizeof(data.command), ctx->filename_ptr);
当您使用追踪点程序类型时,传递给 eBPF 程序的结构体已经从一组原始参数映射而来。为了提高性能,您可以直接访问这些原始参数,使用原始追踪点 eBPF 程序类型。该部分定义应该以raw_tp(或raw_tracepoint)开头,而不是tp。您需要将这些参数从__u64转换为追踪点结构体使用的任何类型(当追踪点是系统调用的入口时,这些参数依赖于芯片架构)。
启用了 BTF 的追踪点
在前面的示例中,我编写了一个名为my_syscalls_enter_execve的结构,来定义我的 eBPF 程序的上下文参数。但是,当您在您的 eBPF 代码中定义一个结构或解析原始参数时,存在代码可能与其运行的内核不匹配的风险。好消息是,您在第五章中遇到的 BTF 也解决了这个问题。
使用 BTF 支持时,在vmlinux.h中将定义一个与传递给追踪点 eBPF 程序的上下文结构匹配的结构。您的 eBPF 程序应该使用部分定义SEC("tp_btf/*tracepoint name*"),其中追踪点名称是sys/kernel/tracing/available_events中列出的可用事件之一。在chapter7/hello.bpf.c中的示例程序如下:
SEC("tp_btf/sched_process_exec")
int handle_exec(struct trace_event_raw_sched_process_exec *ctx)
正如您所见,结构名称与追踪点名称匹配,并且前缀为trace_event_raw_。
用户空间附件
到目前为止,我展示了 eBPF 程序附加到内核源代码中定义的事件的示例。在用户空间代码中也有类似的附加点:uprobes 和 uretprobes 用于附加到用户空间函数的入口和出口,以及用户静态定义的跟踪点(USDTs)用于附加到应用程序代码或用户空间库中指定的跟踪点。所有这些都使用 BPF_PROG_TYPE_KPROBE 程序类型。
注意
有许多公开的示例程序附加到用户空间事件。以下是来自 BCC 项目的一些示例:
-
bashreadline 和 funclatency 工具 附加到
uretprobe。
如果您使用 libbpf,SEC() 宏可以让您定义这些用户空间探针的自动附加点。您可以在 libbpf 文档 中找到所需的节名称格式。例如,要将 uprobe 附加到 OpenSSL 中 SSL_write() 函数的开头,您需要使用以下定义的 eBPF 程序节:
SEC("uprobe/usr/lib/aarch64-linux-gnu/libssl.so.3/SSL_write")
在工具化用户空间代码时,需要注意一些要点:
-
请注意,此示例中共享库的路径是特定于架构的,因此您可能需要相应的架构特定定义。
-
除非您控制运行代码的机器,否则无法确定安装了哪些用户空间库和应用程序。
-
一个应用程序可以作为一个独立的二进制文件构建,因此它不会触发你可能在共享库中附加的任何探针。
-
容器通常运行在自己的文件系统副本上,并安装了自己的依赖项集。容器使用的共享库路径与主机上的共享库路径不同。
-
您的 eBPF 程序可能需要了解应用程序的编程语言。例如,在 C 中,函数的参数通常使用寄存器传递,但在 Go 中则使用堆栈,^(3) 因此保存寄存器信息的
pt_args结构可能不太有用。
也就是说,有许多有用的工具可以使用 eBPF 为用户空间应用程序进行工具化。例如,您可以钩入 SSL 库以跟踪加密信息的解密版本 —— 我们将在下一章节详细探讨这一点。另一个例子是连续分析您的应用程序,使用诸如 Parca 等工具。
LSM
BPF_PROG_TYPE_LSM 程序附加到 Linux Security Module (LSM) API,这是内核中的一个稳定接口,最初用于内核模块以强制执行安全策略。正如您将在 第九章 中详细讨论的那样,现在 eBPF 安全工具也可以使用这个接口。
BPF_PROG_TYPE_LSM 程序通过 bpf(BPF_RAW_TRACEPOINT_OPEN) 进行挂载,并且在很多方面它们被视为跟踪程序。BPF_PROG_TYPE_LSM 程序的一个有趣特性是返回值会影响内核的行为方式。非零的返回码表示安全检查未通过,因此内核不会继续完成所请求的操作。这与与性能相关的程序类型有显著不同,后者会忽略返回码。
注意
Linux 内核文档涵盖LSM BPF 程序。
LSM 程序类型并非在安全性方面扮演的唯一角色。您将在下一节中看到许多与网络相关的程序类型,这些程序类型可以用于网络安全,允许或拒绝网络流量或与网络相关的操作。在第 9 章中,您还将看到更多关于 eBPF 用于安全目的的内容。
到目前为止,在本章中,您已经看到了一组内核和用户空间跟踪程序类型,它们能够实现对整个系统的可见性。接下来要考虑的一组 eBPF 程序类型是让我们能够钩入网络堆栈的程序类型,不仅可以观察数据的传输,还可以影响内核处理发送和接收数据的方式。
网络
有许多不同的 eBPF 程序类型,旨在处理网络消息,当它们通过网络堆栈中的各个点时。图 7-1 展示了一些常用程序类型的挂载点。所有这些程序类型都需要 CAP_NET_ADMIN 和 CAP_BPF,或者 CAP_SYS_ADMIN 权限才能允许。
传递给这些程序类型的上下文是相关网络消息,尽管结构类型取决于内核在网络堆栈相关点处的数据。在堆栈底部,数据以 Layer 2 网络数据包的形式保存,这些数据包基本上是一系列即将或已经准备好通过“电线”传输的字节。在堆栈顶部,应用程序使用套接字,并且内核创建套接字缓冲区来处理从这些套接字发送和接收的数据。

图 7-1. BPF 程序类型钩入网络堆栈中的各个点
注意
网络层模型超出了本书的范围,但它在许多其他书籍、文章和培训课程中有所涵盖。我在Container Security(O’Reilly)的第 10 章中讨论过它。对于本书的目的,知道 Layer 7 涵盖供应用程序使用的格式,如 HTTP、DNS 或 gRPC;TCP 位于 Layer 4;IP 位于 Layer 3;以太网和 WiFi 位于 Layer 2 是足够的。网络堆栈的一个作用是在这些不同格式之间转换消息。
网络程序类型与本章前面讨论的跟踪相关类型之间的一个重要区别在于,它们通常旨在允许定制网络行为。这涉及两个主要特点:
-
使用 eBPF 程序的返回代码告知内核如何处理网络数据包——这可能包括像往常一样处理、丢弃或重定向到不同的目的地。
-
允许 eBPF 程序修改网络数据包、套接字配置参数等等
你将在下一章看到这些特征如何用于构建强大的网络功能的一些示例,但现在,这里是 eBPF 程序类型的概述。
套接字
在栈的顶部,一部分与套接字及套接字操作相关的网络程序类型:
-
BPF_PROG_TYPE_SOCKET_FILTER是添加到内核中的第一个程序类型。你可能从名称中猜到了它用于套接字过滤,但不那么明显的是,这并不意味着过滤应用程序发送或接收的数据。它用于过滤可以发送到像 tcpdump 这样的观测工具的套接字数据的副本。 -
套接字是特定于第 4 层(TCP)连接的。
BPF_PROG_TYPE_SOCK_OPS允许 eBPF 程序拦截发生在套接字上的各种操作和动作,并为该套接字设置参数,如 TCP 超时值。套接字仅存在于连接的端点上,而不在它们可能经过的任何中间盒上。 -
BPF_PROG_TYPE_SK_SKB程序与一种特殊的映射类型结合使用,该映射类型保存了一组引用套接字,以提供所谓的sockmap操作:将流量重定向到套接字层的不同目的地。
流量控制
在网络栈的更深层次是“TC”或流量控制。Linux 内核中有一个与 TC 相关的完整子系统,查看tc命令的 man 页面将让你了解其复杂性以及在计算中拥有深层灵活性和配置方式的重要性。
eBPF 程序可以附加以为网络数据包提供自定义过滤器和分类器,用于入口和出口流量。这是 Cilium 项目的构建块之一,我将在下一章节中介绍一些示例。如果你等不及,可以在Quentin Monnet 的博客上找到一些很好的例子。这可以通过编程方式实现,但你也可以选择使用tc命令来操作这些类型的 eBPF 程序。
XDP
在第三章中简要介绍了 XDP(eXpress 数据路径)eBPF 程序。在那个例子中,我加载了 eBPF 程序,并使用以下命令将其附加到eth0接口:
bpftool prog load hello.bpf.o /sys/fs/bpf/hello
bpftool net attach xdp id 540 dev eth0
值得注意的是,XDP 程序附加到特定的接口(或虚拟接口),您很可能会在不同的接口上附加不同的 XDP 程序。在第八章中,您将更多地了解 XDP 程序如何被卸载到网络卡或由网络驱动程序执行的内容。
XDP 程序是可以使用 Linux 网络实用程序管理的另一个示例—在这种情况下,使用iproute2 的 ip 的 link 子命令。加载并附加程序到 eth0 的大致等效命令如下:
$ ip link set dev eth0 xdp obj hello.bpf.o sec xdp
此命令从 hello.bpf.o 对象读取标记为 xdp 部分的 eBPF 程序,并将其附加到 eth0 网络接口。此接口的 ip link show 命令现在包含有关附加到其上的 XDP 程序的一些信息:
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 xdpgeneric qdisc fq_codel
state UP mode DEFAULT group default qlen 1000
link/ether 52:55:55:3a:1b:a2 brd ff:ff:ff:ff:ff:ff
prog/xdp id 1255 tag 9d0e949f89f1a82c jited
使用 ip link 删除 XDP 程序可以像这样进行:
$ ip link set dev eth0 xdp off
在下一章节中,您将看到更多关于 XDP 程序及其应用的内容。
流分析器
流分析器在网络堆栈的各个点上用于从数据包头部提取详细信息。类型为 BPF_PROG_TYPE_FLOW_DISSECTOR 的 eBPF 程序可以实现自定义数据包解析。在这篇 LWN 文章中有一篇关于使用 BPF 编写网络流分析器的详细介绍。
轻量级隧道
BPF_PROG_TYPE_LWT_* 程序类型系列可用于在 eBPF 程序中实现网络封装。这些程序类型也可以使用 ip 命令进行操作,但这次是涉及到 route 子命令。在实践中,这些用法并不常见。
Cgroups
eBPF 程序可以附加到 cgroups(即“控制组”)。Cgroups 是 Linux 内核中的一个概念,它限制了给定进程或一组进程可以访问的资源集。Cgroups 是隔离一个容器(或一个 Kubernetes Pod)与另一个容器之间的机制之一。将 eBPF 程序附加到 cgroup 允许对该 cgroup 的进程应用仅适用于其自身的自定义行为。所有进程都与一个 cgroup 相关联,包括没有运行在容器内的进程。
还有几种与 cgroup 相关的程序类型,以及更多它们可以附加的钩子。至少在撰写本文时,它们几乎全部与网络相关,尽管也有一个 BPF_CGROUP_SYSCTL 程序类型,可以附加到影响特定 cgroup 的 sysctl 命令上。
例如,与 cgroups 特定的与套接字相关的程序类型有关 BPF_PROG_TYPE_CGROUP_SOCK 和 BPF_PROG_TYPE_CGROUP_SKB。eBPF 程序可以确定特定 cgroup 是否被允许执行请求的套接字操作或数据传输。这对于网络安全策略执行(将在下一章节中介绍)非常有用。套接字程序还可以欺骗调用进程,使其认为它们正在连接到特定的目标地址。
红外控制器
类型为 BPF_PROG_TYPE_LIRC_MODE2 的程序可以附加到红外控制器设备的文件描述符上,提供红外协议的解码。在撰写本文时,此程序类型需要 CAP_NET_ADMIN,但我认为这说明了将程序类型分为跟踪相关和网络相关并不能完全表达 eBPF 可以解决的不同应用范围。
BPF 附加类型
附加类型为程序在系统中附加位置提供了更精细的控制。对于某些程序类型,其与可以附加的挂接类型有一对一的关系,因此附加类型由程序类型隐含定义。例如,XDP 程序附加到网络堆栈中的 XDP 挂接点。对于少数程序类型,还必须指定附加类型。
附加类型涉及决定哪些辅助函数是有效的,它还在某些情况下限制对上下文信息的访问。本章早些时候有一个例子,在该例子中,验证器给出了 invalid bpf_context access 错误。
你还可以在内核函数 bpf_prog_load_check_attach(定义在 bpf/syscall.c 中)中看到需要指定附加类型的程序类型,以及哪些附加类型是有效的。
例如,这里是检查 CGROUP_SOCK 程序附加类型的代码:
case `BPF_PROG_TYPE_CGROUP_SOCK`:
`switch` (`expected_attach_type`) {
case `BPF_CGROUP_INET_SOCK_CREATE`:
case `BPF_CGROUP_INET_SOCK_RELEASE`:
case `BPF_CGROUP_INET4_POST_BIND`:
case `BPF_CGROUP_INET6_POST_BIND`:
return 0;
default:
return -`EINVAL`;
}
此程序类型可以附加在多个位置:在套接字创建时、在套接字释放时或在完成 IPv4 或 IPv6 中的绑定后。
另一个查找程序有效附加类型列表的地方是 libbpf 文档,你还将找到 libbpf 为每个程序和附加类型理解的章节名称。
总结
在本章中,你看到各种 eBPF 程序类型被用于附加到内核中的不同挂接点。如果你想编写响应特定事件的代码,你需要确定适合挂接到该事件的程序类型。传递到程序中的上下文取决于程序类型,而内核对程序的返回码也可能因其类型不同而有不同的响应。
本章的示例代码大多集中在与性能相关的(跟踪)事件上。在接下来的两章中,你将看到更多关于用于网络和安全应用的不同 eBPF 程序类型的详细信息。
练习
本章的示例代码包括 kprobe、fentry、tracepoint、raw tracepoint 和 BTF-enabled tracepoint 程序,它们都附加到相同系统调用的入口。正如你所知,eBPF 跟踪程序除了系统调用之外还可以附加到许多其他位置。
-
运行示例代码时,使用
strace来捕获bpf()系统调用,如下所示:strace -e bpf -o outfile ./hello这将记录每个
bpf()系统调用的信息到名为 outfile 的文件中。查找文件中的BPF_PROG_LOAD指令,并查看prog_type文件在不同程序中的变化。您可以通过跟踪中的prog_name字段识别每个程序,并将其与 chapter7/hello.bpf.c 中的源代码匹配。 -
hello.c 中的示例用户空间代码加载了
hello.bpf.o中定义的所有程序对象。作为编写 libbpf 用户空间代码的练习,修改示例代码以加载并附加一个 eBPF 程序(选择您喜欢的任何一个),而不从 hello.bpf.c 中删除这些程序。 -
编写一个 kprobe 和/或 fentry 程序,当调用其他内核函数时触发。您可以通过查看 /proc/kallsyms 来找到您内核版本中可用的函数。
-
编写一个常规、原始或启用了 BTF 的 tracepoint 程序,附加到其他内核 tracepoint 上。您可以在
/sys/kernel/tracing/available_events中找到可用的 tracepoint。 -
尝试将多个 XDP 程序附加到给定接口,并确认您无法这样做!您应该看到类似以下错误:
libbpf: Kernel error message: XDP program already attached Error: interface xdpgeneric attach failed: Device or resource busy
^(1) 除了出于安全原因,在某些内核部分 kprobe 不被允许。这些列在 /sys/kernel/debug/kprobes/blacklist 中。
^(2) 我迄今为止看到的唯一示例是在 cilium/ebpf 测试套件 中。
^(3) 直到 Go 版本 1.17,引入了新的基于寄存器的调用约定。尽管如此,我认为还将有使用旧版本构建的 Go 可执行文件在未来一段时间内继续流通。
第八章:网络中的 eBPF
正如您在第一章中看到的,eBPF 的动态特性使我们能够定制内核的行为。在网络世界中,有大量依赖于应用的理想行为。例如,电信运营商可能需要与 SRv6 等电信特定协议进行接口;Kubernetes 环境可能需要与旧有应用集成;专用硬件负载均衡器可以由在通用硬件上运行的 XDP 程序替代。eBPF 允许程序员构建满足特定需求的网络功能,而无需将它们强加于所有上游内核用户。
基于 eBPF 的网络工具现在被广泛应用,并且已被证明在大规模上非常有效。例如,CNCF 的Cilium 项目将 eBPF 作为 Kubernetes 网络、独立负载均衡等平台,并被云原生采纳者在各行业垂直领域广泛使用。^(1) 自 2017 年以来,Meta 一直在大规模使用 eBPF——Facebook 来往的每个数据包都经过了 XDP 程序。另一个公共且高扩展的例子是 Cloudflare 利用 eBPF 进行 DDoS(分布式拒绝服务)防护。
这些都是复杂的、可投入生产的解决方案,其详细内容远超出本书的范围,但通过阅读本章中的示例,您可以感受到像这样的 eBPF 网络解决方案是如何构建的。
注意
本章的代码示例位于github.com/lizrice/learning-ebpf的chapter8目录中。
数据包丢弃
有几个网络安全功能涉及丢弃某些传入数据包并允许其他数据包通过。这些功能包括防火墙、DDoS 防护和减轻“死亡数据包”漏洞:
-
防火墙涉及根据源 IP 地址和/或目标 IP 地址及端口号逐个数据包地决定是否允许数据包通过。
-
DDoS 防护增加了一些复杂性,也许需要跟踪从特定来源到达的数据包速率和/或检测数据包内容的某些特征,以确定攻击者或一组攻击者是否试图通过流量淹没接口。
-
“死亡数据包”漏洞是一类内核漏洞,其中内核未能安全处理以特定方式构造的数据包。发送这种特定格式的数据包的攻击者可以利用这一漏洞,这可能导致内核崩溃。传统上,当发现这样的内核漏洞时,需要安装带有修复程序的新内核,这又需要机器停机。但是,安装检测并丢弃这些恶意数据包的 eBPF 程序可以动态安装,立即保护主机,而不影响正在运行的任何应用程序。
这些功能的决策算法超出了本书的范围,但让我们探讨一下如何通过附加到网络接口 XDP 钩子上的 eBPF 程序来丢弃某些数据包,这是实现这些用例的基础。
XDP 程序返回码
当网络数据包到达时,XDP 程序会被触发。程序检查数据包,完成后,返回码给出了一个 决策,指示下一步该如何处理该数据包:
-
XDP_PASS表示应该将数据包以正常方式发送到网络堆栈(就像没有 XDP 程序时那样)。 -
XDP_DROP导致数据包立即被丢弃。 -
XDP_TX将数据包发送回它到达的同一接口。 -
XDP_REDIRECT用于将其发送到不同的网络接口。 -
XDP_ABORTED导致数据包被丢弃,但其使用暗示着错误情况或意外情况,而不是正常的丢弃数据包的决策。
对于某些用例(如防火墙),XDP 程序只需在传递数据包和丢弃数据包之间做出决策。决定是否丢弃数据包的 XDP 程序大纲看起来像这样:
SEC("xdp")
int hello(struct xdp_md *ctx) {
bool drop;
drop = <examine packet and decide whether to drop it>;
if (drop)
return XDP_DROP;
else
return XDP_PASS;
}
XDP 程序还可以操作数据包内容,但我稍后会讲到这一点。
每当入站网络数据包到达其附加的接口时,XDP 程序会被触发。ctx 参数是指向一个 xdp_md 结构体的指针,它保存了关于传入数据包的元数据。让我们看看如何使用这个结构体来检查数据包的内容以做出决策。
XDP 数据包解析
这里是 xdp_md 结构体的定义:
struct `xdp_md` {
`__u32` data;
`__u32` `data_end`;
`__u32` `data_meta`;
/* Below access go through struct xdp_rxq_info */
`__u32` `ingress_ifindex`; /* rxq->dev->ifindex */
`__u32` `rx_queue_index`; /* rxq->queue_index */
`__u32` `egress_ifindex`; /* txq->dev->ifindex */
};
不要被前三个字段的 __u32 类型所误导,它们实际上是指针。data 字段指示数据包开始的内存位置,data_end 显示其结束位置。正如您在 第六章 中看到的,为了通过 eBPF 验证器,您必须明确检查对数据包内容的任何读取或写入是否在 data 到 data_end 的范围内。
数据包之前的内存区域还有一个 data_meta 到 data 之间的区域,用于存储有关该数据包的元数据。这可以用于多个可能在数据包通过堆栈的不同位置处理相同数据包的 eBPF 程序之间的协调。
为了说明解析网络数据包的基础知识,在示例代码中有一个名为 ping() 的 XDP 程序,它只是在检测到 ping(ICMP)数据包时生成一行跟踪信息。以下是该程序的代码:
SEC("xdp")
int ping(struct xdp_md *ctx) {
long protocol = lookup_protocol(ctx);
if (protocol == 1) // ICMP
{
bpf_printk("Hello ping");
}
return XDP_PASS;
}
您可以按照以下步骤查看此程序的运行情况:
-
在 chapter8 目录中运行
make。这不仅会构建代码,还会将 XDP 程序附加到回环接口(称为lo)上。 -
在一个终端窗口中运行
ping localhost。 -
在另一个终端窗口中,通过运行
cat /sys/kernel/tracing/trace_pipe观察生成的跟踪管道输出。
每秒应生成大约两行跟踪信息,并且它们应该是这个样子:
ping-26622 [000] d.s11 276880.862408: bpf_trace_printk: Hello ping
ping-26622 [000] d.s11 276880.862459: bpf_trace_printk: Hello ping
ping-26622 [000] d.s11 276881.889575: bpf_trace_printk: Hello ping
ping-26622 [000] d.s11 276881.889676: bpf_trace_printk: Hello ping
ping-26622 [000] d.s11 276882.910777: bpf_trace_printk: Hello ping
ping-26622 [000] d.s11 276882.910930: bpf_trace_printk: Hello ping
每秒会有两行跟踪信息,因为环回接口既接收 ping 请求也接收 ping 响应。
你可以轻松地修改此代码,通过添加一行代码在协议匹配时返回XDP_DROP来丢弃 ping 数据包,如下所示:
if (protocol == 1) // ICMP
{
bpf_printk("Hello ping");
return XDP_DROP;
}
return XDP_PASS;
如果你尝试这样做,你会看到类似以下输出只在跟踪输出中每秒生成一次:
ping-26639 [002] d.s11 277050.589356: bpf_trace_printk: Hello ping
ping-26639 [002] d.s11 277051.615329: bpf_trace_printk: Hello ping
ping-26639 [002] d.s11 277052.637708: bpf_trace_printk: Hello ping
环回接口收到一个 ping 请求,并且 XDP 程序丢弃它,因此请求不会通过网络堆栈远到足以引发响应。
在这个 XDP 程序中,大部分工作都在一个名为lookup_protocol()的函数中完成,该函数确定第 4 层协议类型。这只是一个示例,不是一个高质量的网络数据包解析实现!但足以让你了解 eBPF 中解析的工作原理。
收到的网络数据包由一系列字节组成,布局如图 8-1 所示。

图 8-1. 以太网头部开始的 IP 网络数据包的布局,随后是 IP 头部,然后是第 4 层数据
lookup_protocol()函数接受包含有关此网络数据包在内存中位置的信息的ctx结构,并返回在 IP 头部中找到的协议类型。代码如下:
unsigned char lookup_protocol(struct xdp_md *ctx)
{
unsigned char protocol = 0;
void *data = (void *)(long)ctx->data; 
void *data_end = (void *)(long)ctx->data_end;
struct ethhdr *eth = data; 
if (data + sizeof(struct ethhdr) > data_end) 
return 0;
// Check that it's an IP packet
if (bpf_ntohs(eth->h_proto) == ETH_P_IP) 
{
// Return the protocol of this packet
// 1 = ICMP
// 6 = TCP
// 17 = UDP
struct iphdr *iph = data + sizeof(struct ethhdr); 
if (data + sizeof(struct ethhdr) + sizeof(struct iphdr) <= data_end) 
protocol = iph->protocol; 
}
return protocol;
}
局部变量data和data_end指向网络数据包的起始和结束位置。
网络数据包应该以以太网头部开始。
但是你不能简单地假设这个网络数据包足够大,可以容纳以太网头部!验证程序要求你明确检查这一点。
以太网头部包含一个 2 字节的字段,告诉我们第 3 层协议。
如果协议类型表明这是一个 IP 数据包,则紧随以太网头部的是 IP 头部。
你不能简单地假设网络数据包中有足够的空间来容纳那个 IP 头部。再次,验证程序要求你明确检查这一点。
IP 头部包含协议字节,该函数将返回给其调用者。
由该程序使用的bpf_ntohs()函数确保两个字节按此主机期望的顺序排序。网络协议是大端序的,但大多数处理器是小端序的,这意味着它们以不同的顺序保存多字节值。此函数将(如有必要)从网络顺序转换为主机顺序。当你从网络数据包的字段中提取一个超过一个字节长的值时,应使用此函数。
此简单示例显示了几行 eBPF 代码如何对网络功能产生巨大影响。不难想象,关于哪些数据包通过和哪些数据包丢弃的更复杂规则可能导致我在本节开头描述的功能:防火墙、DDoS 保护和包死亡漏洞的缓解。现在让我们考虑,在 eBPF 程序内部修改网络数据包的能力下,如何提供更多功能。
负载均衡和转发
XDP 程序不仅限于检查数据包内容,还可以修改数据包内容。我们来看看如果要构建一个简单的负载均衡器,将发送到特定 IP 地址的数据包转发到可以满足请求的多个后端时会涉及哪些内容。
GitHub 仓库中有一个示例。^(2) 这里的设置是在同一主机上运行的一组容器。有一个客户端、一个负载均衡器和两个后端,每个后端在自己的容器中运行。如图 8-2 所示,负载均衡器接收来自客户端的流量并将其转发到两个后端容器中的一个。

图 8-2. 示例负载均衡器设置
负载均衡功能实现为附加到负载均衡器的 eth0 网络接口的 XDP 程序。此程序的返回码为XDP_TX,表示数据包应通过原接口发送回去。但在此之前,程序必须更新数据包头中的地址信息。
虽然我认为这个例子作为学习练习很有用,但实际上这个代码离投入生产还有很大距离;例如,它使用硬编码的地址,假设 IP 地址的确切设置如图 8-2 所示。它假定它将接收的唯一 TCP 流量是来自客户端的请求或发送到客户端的响应。它还通过利用 Docker 设置虚拟 MAC 地址的方式作弊,使用每个容器的 IP 地址作为每个容器虚拟以太网接口的 MAC 地址的最后四个字节。从容器的角度来看,该虚拟以太网接口称为 eth0。
以下是示例负载均衡器代码中的 XDP 程序:
SEC("xdp_lb")
int xdp_load_balancer(struct xdp_md *ctx)
{
void *data = (void *)(long)ctx->data; 
void *data_end = (void *)(long)ctx->data_end;
struct ethhdr *eth = data;
if (data + sizeof(struct ethhdr) > data_end)
return XDP_ABORTED;
if (bpf_ntohs(eth->h_proto) != ETH_P_IP)
return XDP_PASS;
struct iphdr *iph = data + sizeof(struct ethhdr);
if (data + sizeof(struct ethhdr) + sizeof(struct iphdr) > data_end)
return XDP_ABORTED;
if (iph->protocol != IPPROTO_TCP) 
return XDP_PASS;
if (iph->saddr == IP_ADDRESS(CLIENT)) 
{
char be = BACKEND_A; 
if (bpf_get_prandom_u32() % 2)
be = BACKEND_B;
iph->daddr = IP_ADDRESS(be); 
eth->h_dest[5] = be;
}
else
{
iph->daddr = IP_ADDRESS(CLIENT); 
eth->h_dest[5] = CLIENT;
}
iph->saddr = IP_ADDRESS(LB); 
eth->h_source[5] = LB;
iph->check = iph_csum(iph); 
return XDP_TX;
}
此函数的前半部分与前面示例中的几乎相同:定位数据包中的以太网头部,然后是 IP 头部。
这次它将仅处理 TCP 数据包,将收到的其他任何内容原样上交给栈处理,就好像什么都没有发生一样。
此处检查源 IP 地址。如果该数据包不是来自客户端的,则假设它是发送到客户端的响应。
此代码生成在 A 和 B 后端之间的伪随机选择。
目标 IP 和 MAC 地址已更新,以匹配选择的后端…
…或者如果这是来自后端的响应(这是本文的假设,如果它不是来自客户端),则目标 IP 和 MAC 地址将更新以匹配客户端。
无论这个数据包去往何处,源地址都需要更新,以便看起来像数据包来自负载均衡器。
IP 头部包括计算其内容的校验和,由于源和目标 IP 地址都已更新,因此此数据包的校验和也需要重新计算和替换。
注意
由于这是一本关于 eBPF 而不是网络的书,我没有深入探讨 IP 和 MAC 地址为什么需要更新或如果它们没有更新会发生什么。如果你感兴趣,我在我的YouTube 视频的 eBPF 峰会演讲中更详细地介绍了这个例子代码。
就像前面的例子一样,Makefile 包括了不仅构建代码,还使用bpftool加载和附加 XDP 程序到接口的说明,就像这样:
xdp: $(BPF_OBJ)
bpftool net detach xdpgeneric dev eth0
rm -f /sys/fs/bpf/$(TARGET)
bpftool prog load $(BPF_OBJ) /sys/fs/bpf/$(TARGET)
bpftool net attach xdpgeneric pinned /sys/fs/bpf/$(TARGET) dev eth0
这个make指令需要在负载均衡器容器内部运行,以便 eth0 对应其虚拟以太网接口。这带来了一个有趣的观点:一个 eBPF 程序被加载到内核中,只有一个;然而附着点可能在特定的网络命名空间内,并且只在该网络命名空间内可见。^(3)
XDP 卸载
XDP 的想法源自一场关于如果你可以在网络卡上运行 eBPF 程序来在它们甚至到达内核网络堆栈之前对单个数据包做出决策会有多有用的对话。^(4) 有一些网络接口卡支持完整的XDP 卸载功能,在这些接口卡上确实可以在其自己的处理器上运行 eBPF 程序处理入站数据包。这在图 8-3 中有所说明。

图 8-3. 支持 XDP 卸载的网络接口卡可以处理、丢弃和重新传输数据包,而无需主机 CPU 执行任何工作
这意味着被丢弃或重定向回同一物理接口的数据包——就像本章前面的数据包丢弃和负载均衡示例一样——从未被主机内核看到,主机机器上的任何 CPU 周期也不会花费在处理它们上,因为所有工作都在网络卡上完成。
即使物理网络接口卡不支持完整的 XDP 卸载,许多网卡驱动程序支持 XDP 钩子,这样可以最小化 eBPF 程序处理数据包所需的内存复制。^(5)
这可以带来显著的性能优势,并且允许像负载均衡这样的功能在普通硬件上运行得非常高效。^(6)
您已经看到 XDP 如何用于处理入站网络数据包,尽快访问它们到达机器时。eBPF 也可以用于处理网络堆栈中其他点的流量,在流向如何流动的任何方向。让我们继续思考在 TC 子系统内附加的 eBPF 程序。
流量控制(TC)
我在上一章提到了流量控制。当一个网络数据包到达这一点时,它将以sk_buff的形式存在于内核内存中。这是内核网络堆栈中广泛使用的数据结构。附加在 TC 子系统内的 eBPF 程序将接收sk_buff结构作为上下文参数的指针。
注意
您可能想知道为什么 XDP 程序不使用相同的结构作为它们的上下文。答案是,XDP 钩子发生在网络数据到达网络堆栈之前,也在sk_buff结构设置完成之前。
TC 子系统旨在调度网络流量的方式。例如,您可能希望限制每个应用程序可用的带宽,以便它们都有公平的机会。但是,在调度单个数据包时,“带宽”并不是一个非常有意义的术语,因为它用于发送或接收的平均数据量。某个应用程序可能非常突发,或者另一个应用程序可能对网络延迟非常敏感,因此 TC 可以更精细地控制数据包的处理和优先级。^(7)
这里介绍了 eBPF 程序,以便对 TC 内使用的算法进行自定义控制。但是,通过操纵、丢弃或重定向数据包的能力,附加在 TC 内的 eBPF 程序也可以用作复杂网络行为的构建块。
堆栈中给定的网络数据在两个方向中流动:入口(从网络接口入站)或出口(朝向网络接口出站)。eBPF 程序可以附加在任一方向,并且只会影响该方向的流量。与 XDP 不同,可以附加多个按顺序处理的 eBPF 程序。
传统的流量控制分为分类器,根据某些规则对数据包进行分类,以及单独的操作,根据分类器的输出确定对数据包的处理方式。可以作为qdisc(排队策略)的一部分定义一系列分类器。
eBPF 程序作为分类器附加,但它们也可以在同一个程序中确定采取的操作。操作由程序的返回代码指示(其值在 linux/pkt_cls.h 中定义):
-
TC_ACT_SHOT指示内核丢弃该数据包。 -
TC_ACT_UNSPEC表现得好像 eBPF 程序并未在此数据包上运行过(因此将其传递给序列中的下一个分类器,如果有的话)。 -
TC_ACT_OK指示内核将数据包传递给堆栈中的下一层。 -
TC_ACT_REDIRECT将数据包发送到不同网络设备的 ingress 或 egress 路径。
让我们来看几个可以附加在 TC 内的简单程序示例。第一个简单地生成一行跟踪,并告诉内核丢弃该数据包:
int tc_drop(struct __sk_buff *skb) {
bpf_trace_printk("[tc] dropping packet\n");
return TC_ACT_SHOT;
}
现在让我们考虑如何仅丢弃数据包的子集。这个示例会丢弃 ICMP(ping)请求数据包,与本章早些时候介绍的 XDP 示例非常相似:
int tc(struct __sk_buff *skb) {
void *data = (void *)(long)skb->data;
void *data_end = (void *)(long)skb->data_end;
if (is_icmp_ping_request(data, data_end)) {
struct iphdr *iph = data + sizeof(struct ethhdr);
struct icmphdr *icmp = data + sizeof(struct ethhdr) + sizeof(struct iphdr);
bpf_trace_printk("[tc] ICMP request for %x type %x\n", iph->daddr,
icmp->type);
return TC_ACT_SHOT;
}
return TC_ACT_OK;
}
sk_buff 结构体具有指向数据包数据起始和结束的指针,非常类似于 xdp_md 结构体,数据包解析过程也非常相似。再次强调,为了通过验证,必须明确检查对数据的任何访问是否在 data 和 data_end 之间的范围内。
或许你会想知道为什么在已经看到 XDP 实现了类似功能的情况下,你还想在 TC 层实现类似的东西。一个很好的理由是,你可以在 egress 流量上使用 TC 程序,而 XDP 只能处理 ingress 流量。另一个理由是,因为 XDP 在数据包到达时立即触发,此时与数据包相关的 sk_buff 内核数据结构还不存在。如果 eBPF 程序对内核为该数据包创建的 sk_buff 感兴趣或希望操纵它,TC 附加点是合适的选择。
注意
要更好地理解 XDP 和 TC eBPF 程序之间的区别,请阅读 Cilium 项目的 BPF 和 XDP 参考指南 中的“程序类型”部分。
现在让我们考虑一个不仅仅丢弃某些数据包的示例。这个示例识别到收到了一个 ping 请求,并且会以 ping 响应进行响应:
int tc_pingpong(struct __sk_buff *skb) {
void *data = (void *)(long)skb->data;
void *data_end = (void *)(long)skb->data_end;
if (!is_icmp_ping_request(data, data_end)) { 
return TC_ACT_OK;
}
struct iphdr *iph = data + sizeof(struct ethhdr);
struct icmphdr *icmp = data + sizeof(struct ethhdr) + sizeof(struct iphdr);
swap_mac_addresses(skb); 
swap_ip_addresses(skb);
// Change the type of the ICMP packet to 0 (ICMP Echo Reply)
// (was 8 for ICMP Echo request)
update_icmp_type(skb, 8, 0); 
// Redirecting a clone of the modified skb back to the interface
// it arrived on
bpf_clone_redirect(skb, skb->ifindex, 0); 
return TC_ACT_SHOT; 
}
is_icmp_ping_request() 函数解析数据包,不仅检查它是否是 ICMP 消息,还检查它是否是回显(ping)请求。
由于这个函数将向发送方发送响应,所以需要交换源和目标地址。(如果你想看看这个例子的具体代码,包括更新 IP 标头校验和的细节,可以阅读示例代码。)
通过更改 ICMP 标头中的类型字段,将其转换为回显响应。
这个辅助函数通过接口(skb->ifindex)将数据包的克隆发送回去。
由于辅助函数在发送响应之前克隆了数据包,原始数据包应该被丢弃。
在正常情况下,ping 请求将由内核的网络堆栈稍后处理,但这个小例子演示了如何通过 eBPF 实现替换更广泛的网络功能。
今天许多网络功能由用户空间服务处理,但如果可以用 eBPF 程序替代,对性能可能会有很大帮助。在内核中处理的数据包无需完全经过堆栈的其余部分;不需要将其传输到用户空间进行处理,响应也无需再次返回内核。更重要的是,这两者可以并行运行——对于需要复杂处理但 eBPF 无法处理的任何数据包,eBPF 程序可以返回 TC_ACT_OK,以便正常地将其传递到用户空间服务。
对我来说,这是在 eBPF 中实现网络功能的一个重要方面。随着 eBPF 平台的发展(例如,最新内核允许一百万条指令的程序),可以在内核中实现越来越复杂的网络功能。目前尚未在 eBPF 中实现的部分仍可以由内核中的传统堆栈或用户空间来处理。随着时间的推移,越来越多的功能可以从用户空间移到内核中,eBPF 的灵活性和动态特性意味着你不必等待它们成为内核分发的一部分。你可以立即加载 eBPF 实现,就像我在 第一章 中讨论的那样。
我将在 “eBPF 和 Kubernetes 网络” 的实现中返回到网络功能的实现。但首先,让我们考虑 eBPF 另一个能够实现的用例:检查加密流量的解密内容。
数据包加密与解密
如果应用程序使用加密来保护发送或接收的数据,那么在加密之前或解密之后会有一个明文数据的点。回想一下,eBPF 可以几乎在机器的任何地方附加程序,因此如果可以钩入数据传递但尚未加密或刚刚解密的点,那么你的 eBPF 程序可以观察到这些明文数据。不需要提供任何证书来解密流量,就像在传统的 SSL 检查工具中那样。
在许多情况下,应用程序会使用像 OpenSSL 或 BoringSSL 这样的库来加密数据,这些库存在于用户空间。在这种情况下,数据到达套接字时已经是加密的,而这是网络流量的用户空间/内核边界。如果要以未加密形式跟踪这些数据,可以在用户空间代码中合适的位置使用 eBPF 程序。
用户空间 SSL 库
跟踪解密后的加密数据包的一种常见方法是挂接到用户空间库(如 OpenSSL 或 BoringSSL)调用中。使用 OpenSSL 的应用程序通过调用称为SSL_write()的函数发送要加密的数据,并使用SSL_read()从网络接收到的以加密形式接收的明文数据。使用 uprobes 将 eBPF 程序挂接到这些函数中,允许应用程序在加密或解密之前以及之后观察到从使用该共享库的任何应用程序中的数据。而且无需任何密钥,因为这些已经由应用程序提供。
在 Pixie 项目中有一个相当简单的示例叫做openssl-tracer^(8),其中 eBPF 程序位于名为openssl_tracer_bpf_funcs.c的文件中。以下是该代码将数据发送到用户空间的部分,使用了类似于本书早期示例的 perf 缓冲区:
static int process_SSL_data(struct pt_regs* ctx, uint64_t id, enum
ssl_data_event_type type, const char* buf) {
...
bpf_probe_read(event->data, event->data_len, buf);
tls_events.perf_submit(ctx, event, sizeof(struct ssl_data_event_t));
return 0;
}
您可以看到buf中的数据通过帮助函数bpf_probe_read()读入event结构,然后将该event结构提交到 perf 缓冲区。
如果此数据正在发送到用户空间,可以合理地假设这必须是未加密格式的数据。那么这个数据缓冲区从哪里获得?通过查看调用process_SSL_data()函数的位置可以找到答案。在读取到达此计算机的加密数据时,图 8-4 说明了正在发生的情况。
当您读取数据时,将指针指向SSL_read()的缓冲区,当函数返回时,该缓冲区将包含未加密数据。与 kprobes 类似,函数的输入参数(包括该缓冲区指针)仅在附加到入口点的 uprobe 中可用,因为它们所在的寄存器可能在函数执行期间被覆盖。但在函数退出时,直到通过 uretprobe 可以读取数据,该数据将不会在缓冲区中可用。

图 8-4. eBPF 程序在 SSL_read()的入口和出口处钩入 uprobes,以便从缓冲指针中读取未加密数据
所以这个示例遵循了 kprobes 和 uprobes 的常见模式,如图 8-4 所示,入口探针临时使用映射存储输入参数,退出探针可以从中检索。让我们看看执行此操作的代码,从SSL_read()的 eBPF 程序开始:
// Function signature being probed: // int SSL_read(SSL *s, void *buf, int num) int probe_entry_SSL_read(struct pt_regs* ctx) {
uint64_t current_pid_tgid = bpf_get_current_pid_tgid();
...
const char* buf = (const char*)PT_REGS_PARM2(ctx); 
active_ssl_read_args_map.update(¤t_pid_tgid, &buf); 
return 0;
}
正如此函数的注释所描述的,缓冲区指针是传递给SSL_read()函数的第二个参数,这个探测器将附加到这个参数上。PT_REGS_PARM2宏从上下文中获取此参数。
缓冲区指针存储在哈希映射中,其键是在函数开始时使用bpf_get_current_pid_tgid()辅助函数获取的当前进程和线程 ID。
这是退出探测器的相应程序:
int probe_ret_SSL_read(struct pt_regs* ctx) {
uint64_t current_pid_tgid = bpf_get_current_pid_tgid();
...
const char** buf = active_ssl_read_args_map.lookup(¤t_pid_tgid); 
if (buf != NULL) {
process_SSL_data(ctx, current_pid_tgid, kSSLRead, *buf); 
}
active_ssl_read_args_map.delete(¤t_pid_tgid); 
return 0;
}
已查找当前进程和线程 ID,将其用作从哈希映射中检索缓冲区指针的键。
如果这不是空指针,请调用process_SSL_data()函数,这个函数之前你看到过,它将数据从缓冲区发送到用户空间,使用 perf 缓冲区。
清理哈希映射中的条目,因为每个条目调用都应该与一个退出对应。
此示例展示了如何跟踪用户空间应用程序发送和接收的加密数据的明文版本。跟踪本身附加到用户空间库,并不能保证每个应用程序都使用给定的 SSL 库。BCC 项目包括一个名为sslsniff的实用工具,也支持 GnuTLS 和 NSS。但如果某人的应用程序使用其他加密库(甚至更糟糕的是,选择“自己实现加密”),uprobes 根本无法找到正确的挂接点,这些跟踪工具将无法正常工作。
这种基于 uprobes 的方法可能不成功的常见原因还有更多。与只有一个(虚拟)机器的内核不同,用户空间库代码可以存在多个副本。如果使用容器,每个容器很可能有自己的所有库依赖集合。您可以在这些库中挂接 uprobes,但必须确定要跟踪的特定容器的正确副本。另一种可能性是,应用程序可能不是使用共享的动态链接库,而是静态链接,因此它是一个单独的可执行文件。
eBPF 和 Kubernetes 网络
尽管本书不是关于 Kubernetes 的,但 eBPF 在 Kubernetes 网络中被广泛使用,这是使用该平台自定义网络堆栈的一个很好的例证。
在 Kubernetes 环境中,应用程序部署在pods中。每个 pod 是一个或多个容器的组合,它们共享内核命名空间和 cgroups,将 pod 与其他 pod 以及它们所在的主机机器隔离开来。
特别是(在本章节的目的上),一个 Pod 通常有自己的网络命名空间和 IP 地址。^(9)这意味着内核为该命名空间拥有一组网络堆栈结构,与主机及其他 Pod 分开。如图 8-5 所示,Pod 通过虚拟以太网连接与主机连接,并分配了自己的 IP 地址。

图 8-5. Kubernetes 中的网络路径
从图 8-5 可以看出,从机器外部发送给应用 Pod 的数据包必须经过主机的网络堆栈,跨越虚拟以太网连接进入 Pod 的网络命名空间,然后再次经过网络堆栈到达应用程序。
这两个网络堆栈在同一个内核中运行,因此数据包实际上会通过相同的处理两次。网络数据包经过的代码越多,延迟就越高,因此如果可能缩短网络路径,可能会带来性能改进。
基于 eBPF 的网络解决方案如 Cilium 可以钩入网络堆栈,覆盖内核的原生网络行为,如图 8-6 所示。

图 8-6. 使用 eBPF 绕过 iptables 和 conntrack 处理
特别是,eBPF 能够用更高效的解决方案替代 iptables 和 conntrack,用于管理网络规则和连接跟踪。让我们讨论为什么这在 Kubernetes 中会显著提高性能。
避免使用 iptables
Kubernetes 有一个名为 kube-proxy 的组件,实现负载均衡行为,允许多个 Pod 来处理对服务的请求。这是通过 iptables 规则来实现的。
Kubernetes 为用户提供了通过容器网络接口(CNI)选择网络解决方案的选项。一些 CNI 插件使用 iptables 规则来实现 Kubernetes 中的 L3/L4 网络策略,即 iptables 规则指示是否丢弃不符合网络策略的数据包。
虽然 iptables 在传统的(容器前)网络中很有效,但在 Kubernetes 中使用时存在一些弱点。在这个环境中,Pod 及其 IP 地址动态地出现和消失,每次添加或删除 Pod 时,iptables 规则必须完全重写,这会影响大规模的性能。(在 2017 年 KubeCon 上,Haibin Xie 和 Quinton Hoole 的演讲描述了为 20,000 个服务更新 iptables 规则需要五个小时。)
对 iptables 的更新并不是唯一的性能问题:查找规则需要通过表进行线性搜索,这是一个 O(n)的操作,随着规则数量线性增长。
Cilium 使用 eBPF 哈希表映射来存储网络策略规则、连接跟踪和负载均衡器查找表,这可以替代 kube-proxy 的 iptables。在哈希表中查找和插入条目都是大约 O(1) 的操作,这意味着它们具有更好的扩展性。
你可以在 Cilium 的 博客 中阅读关于其实现的性能改进的基准测试结果。在同一篇文章中,你会看到另一个 CNI —— Calico,它也有一个 eBPF 选项,选择其 eBPF 实现而不是 iptables 时性能更好。eBPF 为可扩展、动态的 Kubernetes 部署提供了最高性能的机制。
协调网络程序
像 Cilium 这样复杂的网络实现不能被写成单个 eBPF 程序。如图 8-7 所示,它提供了几个不同的 eBPF 程序,这些程序挂钩到内核及其网络堆栈的不同部分。

图 8-7. Cilium 由多个协调的 eBPF 程序组成,这些程序挂钩到内核的不同点
作为一个一般原则,Cilium 尽早拦截流量,以缩短每个数据包的处理路径。从应用程序 Pod 流出的消息在接近应用程序的套接字层被拦截。使用 XDP 拦截来自外部网络的入站数据包。但是附加点呢?
Cilium 支持适合不同环境的不同网络模式。本书不涵盖其全面描述(你可以在 Cilium.io 找到更多信息),但我会在这里简要概述一下,以便你了解为什么会有这么多不同的 eBPF 程序!
存在一种简单的扁平网络模式,在这种模式下,Cilium 为集群中所有的 Pod 分配相同的 CIDR IP 地址,并直接在它们之间路由流量。还有几种不同的隧道模式,其中用于不同节点上 Pod 的流量被封装在一个寻址到目标节点 IP 地址的消息中,并在目标节点上解封装以进行最终的 Pod 内部跳转。根据数据包的目的地,不同的 eBPF 程序被调用来处理流量,无论是本地容器、本地主机、本网络上的另一台主机还是隧道。
在 图 8-7 中,你可以看到处理来自不同设备流量的多个 TC 程序。这些设备代表了可能的不同真实和虚拟网络接口,数据包可能会流经这些接口:
-
Pod 网络的接口(连接 Pod 和主机之间的虚拟以太网连接的一端)
-
网络隧道的接口
-
主机上物理网络设备的接口
-
主机自身的网络接口
注意
如果您有兴趣了解更多关于数据包如何流经 Cilium 的信息,Arthur Chiao 写了这篇详细且有趣的博客文章:“Cilium 中数据包的生命周期:发现 Pod 到 Service 流量路径和 BPF 处理逻辑”。
附加在内核中的不同 eBPF 程序使用 eBPF map 进行通信,并使用可以附加到网络数据包的元数据来流经堆栈(在讨论访问 XDP 示例中提到时)。这些程序不仅仅将数据包路由到其目的地;它们还根据网络策略丢弃数据包,就像您在早期示例中看到的那样。
网络策略执行
在本章的开头,您看到 eBPF 程序如何丢弃数据包,这意味着它们根本不会到达其目的地。这是网络策略执行的基础,从概念上讲,无论我们是在考虑“传统”的防火墙还是云原生防火墙,它本质上都是相同的。策略根据数据包的源和/或目的地的信息决定是否丢弃数据包。
在传统环境中,IP 地址长期分配给特定服务器,但在 Kubernetes 中,IP 地址是动态分配的,今天分配给特定应用程序 Pod 的地址可能明天完全被重新分配给另一个应用程序使用。这就是为什么传统的防火墙在云原生环境中效果不佳的原因。每次 IP 地址变化时手动重新定义防火墙规则是不现实的。
相反,Kubernetes 支持 NetworkPolicy 资源的概念,该资源基于特定 Pod 上应用的标签定义防火墙规则,而不是基于它们的 IP 地址。尽管资源类型是 Kubernetes 本地支持的,但它并非由 Kubernetes 本身实现。相反,这个功能被委托给您正在使用的 CNI 插件。如果选择不支持 NetworkPolicy 资源的 CNI,则可能会忽略您配置的任何规则。另一方面,CNI 可以自由配置自定义资源,允许比本地 Kubernetes 定义更复杂的网络策略配置。例如,Cilium 支持基于 DNS 的网络策略规则,因此您可以根据 DNS 名称(例如,“example.com”)而不是 IP 地址定义是否允许流量。您还可以为各种第 7 层协议定义策略,例如允许或拒绝特定 URL 的 HTTP GET 调用的流量,但不允许 POST 调用。
注意
Isovalent 的免费实验室 “Cilium 入门” 会指导您在第 3/4 层和第 7 层定义网络策略。另一个非常有用的资源是 networkpolicy.io 上的 Network Policy 编辑器,它以可视化方式展示网络策略的影响。
正如我在本章早些时候讨论过的,可以使用 iptables 规则来丢弃流量,这是一些 CNIs 用来实现 Kubernetes NetworkPolicy 规则的方法。Cilium 使用 eBPF 程序来丢弃不符合当前规则集的流量。希望通过本章前面丢包示例的看法,你对此如何工作有一个初步的心理模型。
Cilium 使用 Kubernetes 身份来确定特定网络策略规则是否适用。就像标签定义 Kubernetes 中哪些 Pod 是服务的一部分一样,标签也定义了 Cilium 中 Pod 的安全标识。通过这些服务标识索引的 eBPF 哈希表,使规则查找非常高效。
加密连接
许多组织需要通过在应用程序中编写代码来加密应用程序之间的流量,以保护其部署和用户数据。通常情况下,这可以通过在每个应用程序中设置安全连接来实现,使用互为流量层安全 (mTLS) 作为 HTTP 或 gRPC 连接的基础。建立这些连接需要首先确认连接双方应用程序的身份(通常通过交换证书来实现),然后加密它们之间流动的数据。
在 Kubernetes 中,可以将应用程序的需求转移到服务网格层或底层网络本身。本书不涵盖完整的服务网格讨论,但你可能对我在新堆栈上写的一篇文章感兴趣:“如何通过 eBPF 简化服务网格”。让我们集中在网络层以及 eBPF 如何使将加密需求推入内核成为可能。
在 Kubernetes 集群内确保流量加密的最简单选项是使用透明加密。之所以称为“透明”,是因为它完全在网络层进行,从操作角度来看非常轻量级。应用程序本身完全不需要意识到加密的存在,也不需要建立 HTTPS 连接;此方法也不需要在 Kubernetes 下运行任何额外的基础设施组件。
在当前常见的内核中有两种加密协议,即 IPsec 和 WireGuard^((R)),它们都由 Cilium 和 Calico CNIs 支持在 Kubernetes 网络中使用。本书不讨论这两种协议之间的差异,但关键点在于它们建立了两台机器之间的安全隧道。CNI 可选择通过此安全隧道连接 Pod 的 eBPF 端点。
注意
Cilium 博客有一篇很好的文章,介绍了 Cilium 如何使用 WireGuard^((R)) 和 IPsec 在节点之间提供加密流量。文章还简要概述了两者的性能特征。Cilium 博客
使用节点末端的身份验证来建立安全隧道。这些身份验证由 Kubernetes 管理,因此操作员的管理负担很小。对于许多目的来说,这已经足够了,因为它确保集群中的所有网络流量都是加密的。透明加密也可以与使用 Kubernetes 身份验证来管理集群中不同端点之间的流量流动的 NetworkPolicy 无缝使用。
一些组织在多租户环境中运作,在这种环境中需要强大的租户边界,并且必须使用证书来标识每个应用端点。在每个应用程序内部处理这些工作是一个重大负担,因此最近已经将其转移到服务网格层,但这需要部署整套额外的组件,增加了资源消耗、延迟和操作复杂性。
现在 eBPF 正在启用一种 新方法,基于透明加密,但使用 TLS 进行初始证书交换和端点认证,以便身份可以表示个别应用程序,而不是它们运行的节点,如 图 8-8 所示。

图 8-8. 验证应用身份之间的透明加密
一旦完成身份验证步骤,内核中的 IPsec 或 WireGuard^(R) 用于加密应用程序之间流动的流量。这带来了许多优势。它允许第三方证书和身份管理工具(如 cert-manager 或 SPIFFE/SPIRE)处理身份部分,网络负责加密,因此对应用程序来说完全透明。Cilium 支持 NetworkPolicy 定义,可以通过其 SPIFFE ID 指定端点,而不仅仅是通过其 Kubernetes 标签。也许最重要的是,这种方法可以与在 IP 数据包中传输的任何协议一起使用。这比仅适用于基于 TCP 连接的 mTLS 更进了一步。
这本书没有足够的篇幅深入讨论 Cilium 的所有内部细节,但我希望本节帮助你理解 eBPF 如何成为构建复杂网络功能(如完全功能的 Kubernetes CNI)的强大平台。
总结
在本章中,您看到了 eBPF 程序附加到网络堆栈中的各种不同点。我展示了基本数据包处理的示例,希望这些示例能让您了解 eBPF 如何创建强大的网络功能。您还看到了一些这些网络功能的实际示例,包括负载平衡、防火墙、安全缓解和 Kubernetes 网络。
练习和进一步阅读
以下是了解 eBPF 的各种网络用例的一些方法:
-
修改示例 XDP 程序
ping(),使其为 ping 响应和 ping 请求生成不同的跟踪消息。ICMP 标头紧随网络数据包中的 IP 标头之后(就像 IP 标头紧随以太网标头之后一样)。你可能想使用linux/icmp.h中的struct icmphdr,并查看类型字段是否显示ICMP_ECHO或ICMP_ECHOREPLY。 -
如果你想进一步探索 XDP 编程,我推荐 xdp-project 的 xdp-tutorial。
-
使用 sslsniff 来查看 BCC 项目中加密流量的内容。
-
通过在 Cilium 网站 中链接的教程和实验来探索 Cilium。
-
使用 networkpolicy.io 上的编辑器来可视化 Kubernetes 部署中网络策略的影响。
^(1) 截至撰写本文时,约有 100 家组织公开宣布在其 USERS.md 文件 中使用 Cilium,尽管这一数字正在迅速增长。AWS、Google 和 Microsoft 也采用了 Cilium。
^(2) 此示例基于我在 eBPF Summit 2021 上的演讲 “A Load Balancer from scratch”。在 15 分钟内构建一个 eBPF 负载均衡器!
^(3) 如果你想探索这一点,请尝试来自 eBPF Summit 2022 的 CTF 挑战 3 “CTF Challenge 3 from eBPF Summit 2022”。我不会在这本书中透露剧透,但你可以在由 Duffie Cooley 和我提供的 这里的解决方案中 查看。
^(4) 查看丹尼尔·博克曼的演示 “Little Helper Minions for Scaling Microservices”,其中包括 eBPF 的历史,他在此时讲述了这个轶事。
^(5) Cilium 在 BPF 和 XDP 参考指南 中维护了一个支持 XDP 的驱动程序列表。
^(6) Ceznam 在 这篇博客文章中 分享了关于团队在使用基于 eBPF 的负载均衡器时看到的性能提升数据。
^(7) 要更全面地了解 TC 及其概念,我推荐 Quentin Monnet 的文章 “Understanding tc “direct action” mode for BPF”。
^(8) 这个示例还有一个博客文章,可以在 https://blog.px.dev/ebpf-openssl-tracing 中找到。
^(9) 可以将 Pod 运行在主机的网络命名空间中,以共享主机的 IP 地址,但除非应用程序在 Pod 中需要这样做,否则通常不会这样做。
第九章:eBPF 用于安全
您已经看到 eBPF 如何用于观察系统中的事件,并向用户空间工具报告有关这些事件的信息。在本章中,您将考虑如何在事件检测的概念基础上构建基于 eBPF 的安全工具,以检测甚至预防恶意活动。我将从帮助您理解安全性与其他类型可观察性的不同之处开始。
注意
本章的示例代码位于GitHub 仓库的 chapter9 目录中。
安全可观察性需要政策和上下文
安全工具与报告事件的可观察性工具之间的区别在于,安全工具需要能够区分正常情况下预期的事件和表明可能存在恶意活动的事件。例如,假设您有一个应用程序作为正常处理的一部分向本地文件写入数据。假设该应用程序预计会写入 /home/<用户名>/<文件名>,因此这种活动从安全角度来看并不重要。然而,如果该应用程序写入 Linux 中的许多敏感文件位置之一,则您希望收到通知。例如,它不太可能需要修改 /etc/passwd 中存储的密码信息。
政策必须考虑的不仅是系统完全正常运行时的正常行为,还包括预期的错误路径行为。例如,如果物理磁盘变满,应用程序可能会开始发送网络消息以警示此情况。这些网络消息不应被视为安全事件——尽管它们不寻常,但并不可疑。考虑错误路径可能会使创建有效政策变得具有挑战性,我们稍后在本章将回到这个挑战。
定义预期行为与非预期行为是政策的工作。安全工具将活动与政策进行比较,并在活动超出政策范围时采取某些行动,使其具有可疑性。这种行动通常涉及生成安全事件日志,通常会发送到安全信息事件管理(SIEM)平台。这可能还会导致向需要调查发生情况的人员发送警报。
调查员可用的上下文信息越多,他们就越有可能找出事件的根本原因,并确定它是否是攻击,哪些组件受到影响,攻击是如何进行的,以及谁负有责任。正如在图 9-1 中所说明的那样,能够回答这类问题需要一种工具从仅仅是日志记录升级为应当被称为“安全可观察性”的命名。

图 9-1. 安全可观察性的异常事件检测需要上下文信息
让我们探讨一些使用 eBPF 程序检测和执行安全事件的方法。如你所知,eBPF 程序可以附加到各种事件,多年来用于安全的一组事件之一是系统调用。我们将从系统调用开始讨论,但正如你将看到的,系统调用可能不是用 eBPF 实现安全工具的最有效方式。稍后在本章中我们将看到一些更新和更复杂的方法。
使用系统调用进行安全事件
系统调用(或 syscalls)是用户空间应用程序与内核之间的接口。如果可以限制应用程序可以进行的系统调用集合,那将限制其能力。例如,如果阻止应用程序进行 open*() 系列的系统调用,它将无法打开文件。如果有一个应用程序你从不希望打开文件,你可能希望创建这种限制,以便即使应用程序被入侵,它也无法恶意打开文件。如果你在过去几年中使用 Docker 或 Kubernetes,那么你很有可能已经接触过使用 BPF 来限制系统调用的安全工具:seccomp.
Seccomp
seccomp 的名称缩写自“SECure COMPuting”。在其原始或“严格”形式中,seccomp 用于将进程可以使用的系统调用集限制为非常小的子集:read()、write()、_exit() 和 sigreturn()。这种严格模式的目的是允许用户运行不受信任的代码(也许是从互联网下载的程序),而不可能使该代码执行恶意操作。
严格模式非常限制,许多应用程序需要使用更大的系统调用集,但这并不意味着它们需要全部 400 多个系统调用。允许更灵活的方法来限制任何给定应用程序可以使用的系统调用集是有道理的。这就是我们从容器世界大多数人遇到的 seccomp 变种背后的推理,更确切地说是称为 seccomp-bpf 的形式。与允许一组固定系统调用不同,这种 seccomp 模式使用 BPF 代码来过滤允许和禁止的系统调用。
在 seccomp-bpf 中,加载一组作为过滤器的 BPF 指令。每次调用系统调用时,都会触发过滤器。过滤器代码可以访问传递给系统调用的参数,以便根据系统调用本身和传递给它的参数做出决策。结果是可能的一组操作之一,包括:
-
允许系统调用继续执行
-
将错误代码返回给用户空间应用程序
-
终止线程
-
通知用户空间应用程序(seccomp-unotify)(截至内核版本 5.0)
注意
如果你想探索编写自己的 BPF 过滤器代码,Michael Kerrisk 在 https://oreil.ly/cJ6HL 上有一些很好的示例。
一些传递给系统调用的参数是指针,而 seccomp-bpf 中的 BPF 代码不能解引用这些指针。这限制了 seccomp 配置文件的灵活性,因为它只能在决策过程中使用值参数。此外,它必须在进程启动时应用——你不能修改正在应用于给定应用程序进程的配置文件。
你很可能已经在不编写 BPF 代码的情况下使用了 seccomp-bpf,因为该代码通常是从可读的 seccomp 配置文件派生出来的。Docker 的默认配置文件 就是一个很好的例子。这是一个通用的配置文件,旨在与几乎任何常规的容器化应用程序兼容。这意味着它允许大多数系统调用,并仅禁止一些在任何应用程序中不太可能合适的调用,reboot() 就是一个很好的例子。
根据 Aqua Security 的说法(https://oreil.ly/1xWmn),大多数容器化应用程序使用了大约 40 到 70 个系统调用。为了更好的安全性,最好使用一个更加限制性的配置文件,针对每个具体应用程序,只允许实际使用的系统调用。
生成 Seccomp 配置文件
如果你要求普通应用开发人员告诉你他们的某个程序调用了哪些系统调用,你可能会看到一脸茫然的表情。这并不是有意冒犯。只是因为大多数开发人员使用的编程语言为他们提供了远离系统调用细节的高级抽象。例如,他们可能知道他们的应用程序打开了哪些文件,但他们不太可能告诉你它们是使用 open() 还是 openat()。这使得如果你要求开发人员在他们的应用程序代码中手工制作一个合适的 seccomp 配置文件,你可能得不到积极的回应。
自动化是未来的趋势:使用工具记录应用程序调用的系统调用集合。在早期,seccomp 配置文件通常使用 strace 编译,以收集应用程序调用的系统调用集合。^(1) 在云原生时代,这不是一个理想的解决方案,因为没有简单的方法来指定 strace 对特定的容器或 Kubernetes Pod 进行跟踪。最好生成配置文件不仅作为系统调用列表,而是采用 Kubernetes 和 OCI 兼容容器运行时可以接受的 JSON 格式。有一些工具可以做到这一点,使用 eBPF 收集所有调用的系统调用信息:
-
Inspektor Gadget 包括一个 seccomp 分析器,允许你为 Kubernetes Pod 中的容器生成自定义 seccomp 配置文件。^(2)
-
Red Hat 创建了一个 seccomp 分析器,采用了 OCI 运行时钩子 的形式。
使用这些分析工具,您需要运行应用程序一段任意时间,以生成包括其可能合法调用的完整系统调用列表的性能分析。如本章前述,此列表需要包括错误路径。如果您的应用程序在错误条件下无法正确执行因为需要调用的系统调用被阻止,这可能会引起更大的问题。由于 seccomp 配置文件处理比大多数开发者熟悉的抽象层次更低,手动审查它们以确认是否覆盖了所有正确的情况是困难的。
以 OCI 运行时挂钩为例,eBPF 程序会附加到syscall_enter原始跟踪点,并维护一个 eBPF 映射,跟踪已见过的系统调用。此工具的用户空间部分使用 Go 编写,并使用iovisor/gobpf库。(我将在第十章中讨论此及其他用于 eBPF 的 Golang 库。)
下面是来自 OCI 运行时挂钩的代码行,加载 eBPF 程序到内核并将其附加到跟踪点(为了简洁起见,省略了几行):
src := strings.Replace(source, "$PARENT_PID", strconv.Itoa(pid), -1) 
m := bcc.NewModule(src, []string{})
defer m.Close()
...
enterTrace, err := m.LoadTracepoint("enter_trace") 
...
if err := m.AttachTracepoint("raw_syscalls:sys_enter", enterTrace); err != nil 
{
return fmt.Errorf("error attaching to tracepoint: %v", err)
}
这一行有非常有趣的作用:它用 eBPF 源代码中名为$PARENT_PID的变量替换了一个数值进程 ID。这是一个常见的模式,表明此工具将为每个被检测的进程加载单独的 eBPF 程序。
在这里,一个名为enter_trace的 eBPF 程序被加载到内核中。
enter_trace程序附加到raw_syscalls:sys_enter跟踪点。这是任何系统调用入口点的跟踪点,在先前的示例中已经遇到过。每当用户空间代码进行系统调用时,将触发此跟踪点。
这些分析工具使用 eBPF 代码附加到sys_enter以跟踪已使用的系统调用集,并生成一个 seccomp 配置文件,该配置文件将用于实施配置。接下来我们将考虑的 eBPF 工具类别也会附加到sys_enter,但它们使用系统调用来跟踪应用程序的行为并将其与安全策略进行比较。
系统调用跟踪安全工具
这类众所周知的工具中,属于系统调用跟踪安全工具的最佳项目是 CNCF 项目Falco,它提供安全警报。默认情况下,Falco 作为内核模块安装,但也有一个 eBPF 版本。用户可以定义规则来确定哪些事件与安全相关,并且当发生不符合这些规则定义的事件时,Falco 可以以各种格式生成警报。
内核模块驱动程序和基于 eBPF 的驱动程序都会附加到系统调用。如果您检查 GitHub 上的 Falco eBPF 程序,您会看到像以下这样的行,它们会附加探测器到原始系统调用的入口和退出点(以及一些其他事件,如页面错误):
BPF_PROBE("raw_syscalls/", sys_enter, sys_enter_args)
BPF_PROBE("raw_syscalls/", sys_exit, sys_exit_args)
由于 eBPF 程序可以动态加载并可以检测由预先存在的进程触发的事件,像 Falco 这样的工具可以对已运行的应用工作负载应用策略。用户可以修改正在应用的规则集,而无需修改应用程序或其配置。这与必须在应用程序启动时应用的 seccomp 配置文件形成对比。
不幸的是,使用系统调用入口点作为安全工具的方法存在问题:存在时间检查到使用时间(TOCTOU)问题。
当 eBPF 程序在系统调用入口点触发时,它可以访问用户空间传递给该系统调用的参数。如果这些参数是指针,则内核将需要将指向的数据复制到自己的数据结构中,然后再对数据进行操作。正如在 图 9-2 中所示,攻击者有机会在 eBPF 程序检查完数据后但内核复制之前修改这些数据。因此,正在处理的数据可能与 eBPF 程序捕获的数据不同。^(3)

图 9-2. 攻击者可以在内核访问系统调用参数之前更改它们。
对于 seccomp-bpf,同样的问题将适用,除非事实上在 seccomp-bpf 中不允许程序解引用用户空间指针,因此根本无法检查数据。
TOCTOU 问题确实适用于 seccomp_unotify,这是 seccomp 的最新添加模式,其中违规操作可以报告给用户空间。seccomp_unotify 手册 明确指出:“因此应该完全清楚,seccomp 用户空间通知机制 不能 用于实施安全策略!”
系统调用入口点可能非常方便用于可观察性目的,但对于严肃的安全工具来说确实不够。
Linux 的 Sysmon 工具通过附加到系统调用的入口和退出点来解决 TOCTOU 窗口问题。一旦调用完成,它会查看内核的数据结构以获取准确的视图。例如,如果系统调用返回一个文件描述符,附加到退出点的 eBPF 程序可以通过查看相关进程的文件描述符表获取关于该文件描述符表示的对象的正确信息。虽然这种方法可以生成安全相关活动的准确记录,但它无法阻止操作的执行,因为在进行检查时系统调用已经完成。
为确保检查的是内核将要操作的相同信息,eBPF 程序应附加到参数已复制到内核内存之后发生的事件上。不幸的是,在内核中没有单一通用的地方可以这样做,因为数据在特定于系统调用的代码中处理方式不同。然而,存在一个明确定义的接口,eBPF 程序可以安全地附加到其中:Linux 安全模块(LSM)API。这需要一个相对较新的 eBPF 特性:BPF LSM。
BPF LSM
LSM(Linux 安全模块)接口提供了一组钩子,每个钩子都在内核即将操作内核数据结构之前触发。由钩子调用的函数可以决定是否允许操作继续进行。最初提供此接口是为了允许以内核模块形式实现安全工具;BPF LSM将其扩展,使得 eBPF 程序可以附加到相同的钩子点上,如图 9-3 所示。

图 9-3. 使用 LSM BPF,eBPF 程序可以通过 LSM 钩子事件触发
有数百个 LSM 钩子,并且它们在内核源代码中有相当不错的文档记录。需要明确的是,系统调用与 LSM 钩子之间并没有一对一的映射,但如果系统调用从安全角度看有趣的潜力,则处理该系统调用将触发一个或多个钩子。
下面是一个 eBPF 程序附加到 LSM 钩子的简单示例。此示例在处理chmod命令(chmod代表“改变模式”,主要用于更改文件的访问权限)期间被调用:
SEC("lsm/path_chmod")
int BPF_PROG(path_chmod, const struct path *path, umode_t mode)
{
bpf_printk("Change mode of file name %s\n", path->dentry->d_iname);
return 0;
}
此示例简单地跟踪文件名并始终返回0,但您可以想象一个真实的实现会利用参数来决定是否允许此模式更改。返回非零值将拒绝此更改的权限,因此内核不会继续执行它。值得注意的是,完全在内核中进行此类策略检查非常高效。
BPF_PROG()函数的path参数是表示文件的内核数据结构,mode参数是所需的新模式值。你可以从path->dentry->d_iname字段看到正在访问的文件名称。
LSM BPF 是在内核版本 5.7 中添加的,这意味着(至少在撰写本文时)它尚未在许多支持的 Linux 发行版上可用,但我预计在接下来的几年里,许多供应商将开发利用此接口的安全工具。在 LSM BPF 广泛可用之前,还有另一种可能的方法,正如 Cilium Tetragon 的开发人员所使用的。
Cilium Tetragon
四边形是 Cilium 项目的一部分(也是 CNCF 的一部分)。与附加到 LSM API 挂钩不同,四边形的方法是构建一个框架,将 eBPF 程序附加到 Linux 内核中任意函数。
四边形专为在 Kubernetes 环境中使用设计,并且该项目定义了一个名为TracingPolicy的自定义 Kubernetes 资源类型。用于定义应将 eBPF 程序附加到的一组事件、由 eBPF 代码需要检查的条件以及条件满足时要执行的操作。以下是样本 TracingPolicy 的摘录:
spec:
kprobes:
- call: "fd_install"
...
matchArgs:
- index: 1
operator: "Prefix"
values:
- "/etc/"
...
此策略定义了一组 kprobes 以附加程序,其中第一个是内核函数fd_install。这是内核中的内部函数。让我们探讨为什么你可能选择附加到这样一个函数。
附加到内部内核函数
系统调用接口和 LSM 接口被定义为 Linux 内核中的稳定接口;也就是说,它们不会以不向后兼容的方式进行更改。如果您今天编写使用这些接口中的函数的代码,它们将继续在将来的内核版本中工作。这些接口仅代表构成 Linux 内核的 3000 万行代码的一小部分。即使它们尚未正式声明为稳定,这些代码库的部分长期没有更改,并且未来也不太可能更改。
完全可以编写 eBPF 程序,将其附加到官方未稳定的内核函数,预计它们将在相当长的时间内继续工作。此外,考虑到新内核版本通常需要几年时间才能被广泛部署,可以放心地认为将有足够的时间来解决可能出现的任何不兼容性问题。
Tetragon 的贡献者包括许多内核开发人员,他们利用对内核内部的知识,识别出一些适合用于有用安全目的的 eBPF 程序的安全位置。有几个示例 TracingPolicy 定义利用了这些知识。这些示例监控涵盖文件操作、网络活动、程序执行以及权限更改等安全事件,这些都是恶意行为者在攻击中可能会做的事情。
让我们回到附加到fd_install的示例策略定义。这里的“fd”代表“文件描述符”,而该函数源代码的注释告诉我们该函数“在 fd 数组中安装一个文件指针”。这发生在文件被打开时,并且在文件的数据结构在内核中被填充之后调用。这是一个安全检查文件名的地方——在早期的 TracingPolicy 示例中,只有当文件名以“/etc/”开头时才有兴趣。
就像 LSM BPF 程序一样,Tetragon 的 eBPF 程序可以访问上下文信息,使它们能够完全在内核内做出安全决策。与其将给定类型的所有事件报告给用户空间,安全相关事件可以在内核内被过滤,以便只有超出策略的事件才报告给用户空间。
预防安全措施
大多数基于 eBPF 的安全工具使用 eBPF 程序来检测恶意事件,然后通知一个用户空间应用程序来采取行动。正如你可以在图 9-4 中看到的,用户空间应用程序采取的任何操作都是异步进行的,可能会来不及——也许数据已经外泄,或者攻击者已经将恶意代码持久化到磁盘上。

图 9-4. 内核到用户空间的异步通知允许攻击继续一段时间
在内核版本 5.3 及更高版本中,有一个名为bpf_send_signal()的 BPF 辅助函数。Tetragon 使用此函数来实现预防安全措施。如果策略定义了 Sigkill 动作,任何匹配的事件都会导致 Tetragon 的 eBPF 代码生成一个 SIGKILL 信号,终止试图执行超出策略动作的进程。正如在图 9-5 中所示,这是同步进行的;也就是说,内核正在执行的活动被 eBPF 代码确定为超出策略的活动将被阻止完成。

图 9-5. Tetragon 通过内核发送 SIGKILL 信号同步终止恶意进程
Sigkill 策略需要谨慎使用,因为配置不正确的策略可能会不必要地终止应用程序,但这对于安全目的来说是非常强大的 eBPF 使用。您可以通过运行“审计”模式来开始,生成安全事件,但不应用 SIGKILL 执行,直到您确信该策略不会出问题。
如果您有兴趣了解如何使用 Cilium Tetragon 来检测安全事件的更多信息,有一份名为“使用 eBPF 进行安全可观察性”的报告,由 Natália Réka Ivánkó和 Jed Salazar 详细介绍。
网络安全
第八章讨论了如何利用 eBPF 非常有效地实现网络安全机制。总结一下:
-
防火墙和 DDoS 保护是早期附加在网络数据包入口路径的 eBPF 程序的自然选择。而且随着 XDP 程序被卸载到硬件的可能性,恶意数据包甚至可能根本不会到达 CPU!
-
对于实施更复杂的网络策略,例如 Kubernetes 策略确定哪些服务允许彼此通信,如果判断数据包违反策略,附加到网络堆栈中的 eBPF 程序可以丢弃这些数据包。
网络安全工具往往以预防模式使用,而不仅仅是审核恶意活动。这是因为对于恶意行为者来说很容易发动与网络相关的攻击;如果您给一个设备一个暴露在互联网上的公共 IP 地址,那么不久之后您就会开始看到可疑的流量,因此组织被迫采取预防措施。
相比之下,许多组织在审计模式下使用入侵检测工具,并依靠取证确定可疑事件是否真的恶意以及需要采取什么补救措施。如果某个安全工具过于粗糙且容易产生误报,那么它需要在审计模式而不是预防模式下运行并不奇怪。我相信 eBPF 正在实现更复杂的安全工具,具有更精细的、准确的控制。正如我们今天认为防火墙足够准确以在预防模式下使用一样,我们将看到更多使用预防性工具的情况,这些工具针对其他非网络事件采取行动。这甚至可能包括作为应用产品的一部分打包的基于 eBPF 的控制,以便提供自己的运行时安全性。
总结
在本章中,您看到 eBPF 在安全领域的使用已经从系统调用的低级检查发展到更复杂的用途,如安全策略检查、内核事件过滤和运行时强制执行。
在使用 eBPF 用于安全目的的领域仍然有很多积极的发展。我相信在未来几年内,我们将看到这一领域的工具不断发展,并被广泛采纳。
^(1) 例如,可以参考 Jess Frazelle 的这篇文章,她为 Docker 开发了默认的 seccomp 配置文件:“如何使用新的 Docker Seccomp 配置文件”。
^(2) Inspektor Gadget 的 seccomp 分析工具的文档相当枯燥,但Jose Blanquicet 的这段视频概述更易于理解。
^(3) 这个窗口的利用在 DEFCON 29 的讲座中有所讨论,题为“Phantom Attack: Evading System Call Monitoring”由 Rex Guo 和 Junyuan Zeng 主讲,以及在“LSM BPF Change Everything”由 Leo Di Donato 和 KP Singh 更详细地讨论了其对 Falco 的影响。
第十章:eBPF 编程
在本书中,你已经学到了很多关于 eBPF 的知识,并看到了许多如何将其用于各种应用程序的示例。但是如果你想基于 eBPF 实现自己的想法,该章节将讨论你在编写自己的 eBPF 代码时的选择。
正如你从本书中了解到的那样,eBPF 编程包括两个部分:
-
编写在内核中运行的 eBPF 程序
-
编写管理和与 eBPF 程序交互的用户空间代码
我将在本章讨论的大多数库和语言都要求你作为程序员处理这两部分,并意识到处理的具体位置。但是bpftrace,也许是最简单的 eBPF 编程语言,掩盖了程序员对这种区别的感知。
Bpftrace
正如项目的README页面所描述的,“bpftrace是 Linux eBPF 的高级跟踪语言…灵感来自 awk 和 C,以及前身跟踪器如 DTrace 和 SystemTap。”
bpftrace命令行工具将用高级语言编写的程序转换为 eBPF 内核代码,并为终端中的结果提供一些输出格式。作为用户,你不需要真正考虑内核与用户空间的分离。
在项目文档中,您会找到几个有用的单行示例,包括一个很好的教程,该教程将引导您从编写简单的“Hello World”脚本到编写更复杂的脚本,可以跟踪内核数据结构中读取的数据。
注意
从 Brendan Gregg 的bpftrace速查表中了解bpftrace提供的各种能力。或者,深入了解bpftrace和 BCC,参见他的书籍BPF 性能工具。
如其名所示,bpftrace可以附加到跟踪(也称为与性能相关的)事件,包括 kprobe、uprobe 和 tracepoint。例如,您可以使用-l选项列出机器上可用的 tracepoint 和 kprobe,就像这样:
$ bpftrace -l "*execve*"
tracepoint:syscalls:sys_enter_execve
tracepoint:syscalls:sys_exit_execve
...
kprobe:do_execve_file
kprobe:do_execve
kprobe:__ia32_sys_execve
kprobe:__x64_sys_execve
...
此示例查找所有包含“execve”的可用附加点。从此输出中,您可以看到可以附加到名为do_execve的 kprobe。以下是附加到该事件的bpftrace单行脚本:
bpftrace -e 'kprobe:do_execve { @[comm] = count(); }'
Attaching 1 probe...
^C
@[node]: 6
@[sh]: 6
@[cpuUsage.sh]: 18
{ @[comm] = count(); }部分是附加到该事件的脚本。此示例跟踪了由不同可执行文件触发该事件的次数。
bpftrace的脚本可以协调附加到不同事件的多个 eBPF 程序。例如,考虑报告被打开文件的opensnoop.bt脚本。以下是摘录:
tracepoint:syscalls:sys_enter_open,
tracepoint:syscalls:sys_enter_openat
{
@filename[tid] = args->filename;
}
tracepoint:syscalls:sys_exit_open,
tracepoint:syscalls:sys_exit_openat
/@filename[tid]/
{
$ret = args->ret;
$fd = $ret > 0 ? $ret : -1;
$errno = $ret > 0 ? 0 : - $ret;
printf("%-6d %-16s %4d %3d %s\n", pid, comm, $fd, $errno,
str(@filename[tid]));
delete(@filename[tid]);
}
此脚本定义了两个不同的 eBPF 程序,分别附加到两个不同的内核跟踪点,即进入和退出open()和openat()系统调用时。^(1)这两个系统调用都用于打开文件,并将文件名作为输入参数。由任一系统调用进入触发的程序会缓存该文件名,并将其存储在映射中,其中键是当前线程 ID。当触发退出跟踪点时,脚本中的/@filename[tid]/行将从该映射中检索缓存的文件名。
运行此脚本将生成如下输出:
./opensnoop.bt
Attaching 6 probes...
Tracing open syscalls... Hit Ctrl-C to end.
PID COMM FD ERR PATH
297388 node 30 0 /home/liz/.vscode-server/data/User/
workspaceStorage/73ace3ed015
297360 node 23 0 /proc/307224/cmdline
297360 node 23 0 /proc/305897/cmdline
297360 node 23 0 /proc/307224/cmdline
我刚刚告诉过你有四个 eBPF 程序附加到跟踪点,那么为什么这个输出中说有六个探针?答案是这里有两个“特殊探针”用于该程序的BEGIN和END子句,这类似于 awk 语言的完整版本。出于简洁起见,我在这里省略了这些子句,但你可以在GitHub 上的源代码中找到它们。
如果你正在使用bpftrace,你不需要了解底层的程序和映射,但如果你已经阅读了本书的前几章,这些概念对你来说应该已经很熟悉了。如果你有兴趣查看在运行bpftrace程序时加载到内核中的程序和映射,你可以轻松使用bpftool来完成(就像你在第三章中看到的那样)。这是我在运行opensnoop.bt时得到的输出:
$ bpftool prog list
...
494: tracepoint name sys_enter_open tag 6f08c3c150c4ce6e gpl
loaded_at 2022-11-18T12:44:05+0000 uid 0
xlated 128B jited 93B memlock 4096B map_ids 254
495: tracepoint name sys_enter_opena tag 26c093d1d907ce74 gpl
loaded_at 2022-11-18T12:44:05+0000 uid 0
xlated 128B jited 93B memlock 4096B map_ids 254
496: tracepoint name sys_exit_open tag 0484b911472301f7 gpl
loaded_at 2022-11-18T12:44:05+0000 uid 0
xlated 936B jited 565B memlock 4096B map_ids 254,255
497: tracepoint name sys_exit_openat tag 0484b911472301f7 gpl
loaded_at 2022-11-18T12:44:05+0000 uid 0
xlated 936B jited 565B memlock 4096B map_ids 254,255
$ bpftool map list
254: hash flags 0x0
key 8B value 8B max_entries 4096 memlock 331776B
255: perf_event_array name printf flags 0x0
key 4B value 4B max_entries 2 memlock 4096B
你可以清楚地看到四个跟踪点程序,以及用于缓存文件名的哈希映射和用于将内核输出数据传递到用户空间的perf_event_array。
注意
bpftrace实用程序是建立在 BCC 之上的,你在本书的其他地方也遇到过,并且我将在本章后面进行详细介绍。bpftrace脚本被转换为 BCC 程序,然后使用 LLVM/Clang 工具链在运行时编译。
如果你希望使用基于 eBPF 的性能测量的命令行工具,你可能会发现bpftrace能够满足你的需求。但尽管bpftrace可以成为利用 eBPF 进行跟踪的强大工具,它并未打开 eBPF 能够实现的所有可能性。
要充分发挥 eBPF 的潜力,你需要直接为内核编写 eBPF 程序,还需要处理用户空间部分。这两个方面通常可以用完全不同的语言编写。让我们从运行在内核中的 eBPF 代码的选择开始。
内核中的 eBPF 语言选择
eBPF 程序可以直接用 eBPF 字节码编写,^(2)但在实践中,大多数情况下是从 C 或 Rust 编译为字节码。这些语言的编译器支持将 eBPF 字节码作为目标输出。
注意
eBPF 字节码并不适合所有编译语言。如果语言涉及运行时组件(如 Go 或 Java 的虚拟机),它可能与 eBPF 的验证器不兼容。例如,很难想象内存垃圾回收如何与验证器对内存安全使用的检查协同工作。同样,eBPF 程序必须是单线程的,因此语言中的任何并发特性都无法使用。
尽管不完全是 eBPF,但有一个有趣的项目叫做XDPLua,提议使用 Lua 脚本在内核中直接运行 XDP 程序。然而,该项目的初步研究表明,eBPF 可能更具性能,随着每个内核版本发布,eBPF 变得越来越强大(例如现在能够实现循环),并不清楚除了个人偏好外是否有其他优势。
我敢猜测,大多数选择用 Rust 编写 eBPF 内核代码的人也会选择同样的语言编写用户空间代码,因为共享数据结构不需要重新编写。当然,这并非强制性的——你可以将 eBPF 代码与任何你选择的用户空间语言混合使用。
那些选择用 C 语言编写内核端代码的人也可以选择在用户空间用 C 语言编写代码(本书中已有多个例子)。但是 C 语言是一种相当底层的语言,需要程序员自己处理许多细节,特别是内存管理。虽然有些人能够轻松应对这些,但许多人更愿意用另一种更高级的语言编写用户空间代码。无论你偏爱哪种语言,你都希望有一个提供 eBPF 支持的库,这样你就不必直接写系统调用接口了(你在第三章看到过) 。在本章的其余部分,我们将讨论一些流行的 eBPF 库选项,涵盖多种语言。
BCC Python/Lua/C++
回到第二章,我给你的第一个“Hello World”示例是使用 BCC 库编写的 Python 程序。该项目包括许多有用的性能测量工具,使用了同一库(以及稍后我将介绍的基于libbpf的新实现)。
除了描述如何使用提供的 BCC 工具来测量性能的文档,BCC 还包括一个参考指南和一个Python 编程教程,帮助您在这个框架中开发自己的 eBPF 工具。
第五章 讨论了 BCC 在可移植性方面的方法,即在运行时编译 eBPF 代码,以确保其与目标机器的内核数据结构兼容。在 BCC 中,您将内核端 eBPF 程序代码定义为字符串(或 BCC 读入字符串的文件内容)。该字符串被传递给 Clang 进行编译,但在此之前,BCC 对字符串进行了一些预处理。这使其能够为程序员提供方便的快捷方式,其中一些已经在本书中看到了。例如,以下是来自 chapter2/hello_map.py 示例代码的相关行:
#!/usr/bin/python3 
from bcc import BPF
program = """ 
BPF_RINGBUF_OUTPUT(output, 1); 
...
int hello(void *ctx) {
...
output.ringbuf_output(&data, sizeof(data), 0); 
return 0;
}
"""
b = BPF(text=program) 
...
b["output"].open_ring_buffer(print_event) 
...
这是一个运行在用户空间的 Python 程序。
program 字符串包含要编译并加载到内核中的 eBPF 程序。
BPF_RINGBUF_OUTPUT 是一个 BCC 宏,用于定义名为 output 的环形缓冲区。这是 program 字符串的一部分,因此可以自然地认为它从内核的角度定义了缓冲区。在我们到达 callout 6 之前,请暂时保留这个想法。
此行看起来像是 object 对象上的 ringbuf_output() 方法。但等一下 —— 对象的方法甚至不是 C 语言的一部分!BCC 在这里进行了一些繁重的工作,像这样扩展方法到底层的 BPF 助手函数,例如在这种情况下是 bpf_ringbuf_output()。
这是程序字符串被重写为 Clang 可以编译的 BPF C 代码的地方。这行还将结果程序加载到内核中。
代码中没有定义名为output的环形缓冲区的其他位置,但在此处的 Python 用户空间代码中可以访问它。在调用 3 中,BCC 在预处理行时起到了双重作用,因为它为用户空间和内核部分都定义了环形缓冲区。
正如这个例子所示,BCC 实际上为 BPF 编程提供了自己的类似 C 的语言。它为程序员简化了生活,处理了诸如共享结构定义(供内核和用户空间使用)等事务,并提供了方便的快捷方式来包装 BPF 助手函数。这意味着如果您对 Python 已经感到满意,那么 BCC 是进入 eBPF 编程的一种可访问方式,尤其是如果您对该领域还不熟悉的话。
注意
如果您想探索 BCC 编程,这个面向 Python 程序员的教程是了解 BCC 的更多特性和能力的好方法,比本书所能包含的内容要多得多。
文档并没有非常清楚,但除了支持 Python 作为 eBPF 工具用户空间的语言外,BCC 还支持用 Lua 和 C++编写工具。如果你有兴趣尝试这种方法,提供的示例中有lua和cpp目录,你可以基于它们编写自己的代码。
BCC 对程序员来说可能很方便,但由于在你的实用程序旁边分发编译器工具链的低效率(在第五章中更深入讨论),如果你打算编写可分发的生产质量工具,我建议考虑本章讨论的其他一些库。
C 和 Libbpf
你在本书中已经看到了很多用 C 编写的 eBPF 程序的示例,使用 LLVM 工具链编译成 eBPF 字节码。你还看到了添加了支持 BTF 和 CO-RE 的扩展。许多 C 程序员也熟悉另一种主要的 C 编译器 GCC,并且将很高兴听到,从版本 10 开始,GCC 也支持编译为 eBPF 目标;然而,与 LLVM 提供的功能相比,仍然存在一些差距。
正如你在第五章中看到的那样,CO-RE 和libbpf实现了一种便携式 eBPF 编程的方法,不需要在每个 eBPF 工具旁边附带编译器工具链。BCC 项目利用了这一点,并且除了最初的一套 BCC 性能跟踪工具之外,现在还有这些工具的版本重写以利用libbpf。普遍的共识是,基于libbpf重写的 BCC 工具版本是更好的选择,因为它们具有显著更低的内存占用^(3),并且不涉及在编译步骤进行时的启动延迟。
如果你熟悉 C 编程,使用libbpf会非常合理。在本书的多个示例中已经看到了很多这样的例子。
注意
要用 C 编写你自己的libbpf程序,现在(现在你已经读完本书!)最好的起点是libbpf-bootstrap。阅读 Andrii Nakryiko 的关于该项目的博客文章,作为该项目背后动机的良好介绍。
也有一个名为libxdp的库,它建立在libbpf之上,以便更轻松地开发和管理 XDP 程序。这是 xdp-tools 的一部分,该工具集还包含我喜欢的 eBPF 编程学习资源之一:XDP 教程。^(4)
但是 C 是一门相当具有挑战性的低级语言。C 程序员必须对内存管理和缓冲区处理等事项负责,并且很容易因为处理指针不当而导致编写具有安全漏洞的代码,更不用说由于这些问题而导致崩溃。eBPF 验证器在内核端有所帮助,但对于用户空间代码没有类似的保护机制。
好消息是,还有其他编程语言的库与 libbpf 接口,或提供类似重定位功能,以便于可移植的 eBPF 程序。以下是一些最流行的库。
Go
Go 语言已被广泛应用于基础设施和云原生工具,因此在其中编写 eBPF 代码是很自然的选择。
注意
Michael Kashin 的这篇文章 提供了比较不同 Go eBPF 库的另一视角。
Gobpf
可能第一个严肃的 Golang 实现是 gobpf 项目,作为 Iovisor 的一部分与 BCC 并列。然而,它已经有一段时间没有得到积极维护,在我写作此文时,有一些 讨论计划弃用它,因此在选择库时请记住这一点。
Ebpf-go
Cilium 项目的一部分,包含的 eBPF Go 库 被广泛使用(我在 GitHub 上找到约 10,000 个引用,并且该项目接近 4,000 个星)。它提供了方便的函数来管理和加载 eBPF 程序和映射,包括 CO-RE 支持,全部纯 Go 实现。
使用此库,您可以选择将您的 eBPF 程序编译为字节码,并将该字节码嵌入到 Go 源代码中,使用名为 bpf2go 的提供的工具。在构建步骤中,您需要 LLVM/Clang 编译器来生成此字节码。一旦 Go 代码编译完成,您将得到一个单一的 Go 二进制文件,其中包含 eBPF 字节码,并且可以在不同内核之间进行移植,除了 Linux 内核本身之外没有任何依赖项。
cilium/ebpf 库还支持加载和管理构建为独立 ELF 文件的 eBPF 程序(例如您在本书中看到的 .bpf.o 示例)。
在撰写本文时,cilium/ebpf 库支持用于跟踪的 perf 事件,包括比较新的 fentry 事件,以及一系列广泛的网络程序类型,如 XDP 和 cgroup 套接字附加。
在本项目的 cilium/ebpf 目录下的 examples 目录中,您会看到内核程序的 C 代码与相应的 Go 用户空间代码位于相同的目录中:
-
C 文件以
// +build ignore开头,告诉 Go 编译器忽略它们。在撰写本文时,正在进行一个 更新,以改用更新的//go:build样式的构建标签。 -
用户空间文件包括如下一行,告诉 Go 编译器在 C 文件上调用 bpf2go 工具:
//go:generate go run github.com/cilium/ebpf/cmd/bpf2go -cc $BPF_CLANG -cflags $BPF_CFLAGS bpf <C filename> -- -I../headers运行
go:generate命令对包进行重建,以一步完成 eBPF 程序的重新生成和骨架的更新。
类似于bpftool gen skeleton,您在第五章中看到的,bpf2go为操作 eBPF 对象生成骨架代码,减少了您需要自己编写的用户空间代码(不过它生成的是 Go 代码而不是 C 代码)。输出文件还包括包含字节码的.o对象文件。
实际上,bpf2go生成了字节码的.o文件的两个版本,用于大端和小端架构。还有两个相应生成的.go文件,在编译时根据目标平台选择正确的版本。例如,在cilium/ebpf中的 kprobe 示例中自动生成的文件包括:
-
包含 eBPF 字节码的bpf_bpfeb.o和bpf_bpfel.o ELF 文件
-
bpf_bpfeb.go和bpf_bpfel.go文件定义了对应于字节码中定义的映射、程序和链接的 Go 结构和函数。
您可以将自动生成的 Go 代码中定义的对象与生成它的 C 代码相关联。以下是为该 kprobe 示例中的 C 代码定义的对象:
struct bpf_map_def SEC("maps") kprobe_map = {
...
};
SEC("kprobe/sys_execve")
int kprobe_execve() {
...
}
自动生成的 Go 代码包括表示所有映射和程序的结构(在本例中只有一个映射和一个程序):
type bpfMaps struct {
KprobeMap *ebpf.Map `ebpf:"kprobe_map"`
}
type bpfPrograms struct {
KprobeExecve *ebpf.Program `ebpf:"kprobe_execve"`
}
名称“KprobeMap”和“KprobeExecve”源自用于 C 代码中的映射和程序名称。这些对象被分组到一个bpfObjects结构中,代表着加载到内核中的所有内容:
type bpfObjects struct {
bpfPrograms
bpfMaps
}
然后,您可以在用户空间的 Go 代码中使用这些对象定义和相关的自动生成函数。为了让您了解可能涉及的内容,这里有一个基于相同kprobe 示例主函数的摘录(为简洁起见省略了错误处理):
objs := bpfObjects{}
loadBpfObjects(&objs, nil) 
defer objs.Close()
kp, _ := link.Kprobe("sys_execve",
objs.KprobeExecve, nil) 
defer kp.Close()
ticker := time.NewTicker(1 * time.Second) 
defer ticker.Stop()
for range ticker.C {
var value uint64
objs.KprobeMap.Lookup(mapKey, &value) 
log.Printf("%s called %d times\n", fn, value)
}
将所有以字节码形式嵌入的 BPF 对象加载到bpfObjects中,刚才我向您展示了由自动生成的代码定义的内容。
将程序附加到sys_execve kprobe 上。
设置一个定时器,以便代码每秒钟轮询映射。
从映射中读取一个项目。
在cilium/ebpf目录中还有其他几个示例,可以用作参考和灵感。
Libbpfgo
libbpfgo项目由 Aqua Security 实现了围绕libbpf的 C 代码的 Go 包装器,提供了加载和附加程序的实用工具,并使用 Go 本地特性(如通道)接收事件。因为它构建在libbpf上,所以支持 CO-RE。
这里是从libbpfgo的README中的示例摘录,它很好地高层次地展示了这个库的预期效果:
bpfModule := bpf.NewModuleFromFile(bpfObjectPath) 
bpfModule.BPFLoadObject() 
mymap, _ := bpfModule.GetMap("mymap") 
mymap.Update(key, value)
rb, _ := bpfModule.InitRingBuffer("events", eventsChannel, buffSize)
rb.Start()
e := <-eventsChannel 
从对象文件中读取 eBPF 字节码。
将这段字节码加载到内核中。
操纵 eBPF 映射中的条目。
Go 程序员将喜欢通过通道从环或性能缓冲区接收数据,这是一种处理异步事件的语言特性。
这个库是为 Aqua 的Tracee安全项目创建的,也被其他项目如 Polar Signals 的Parca所使用,它提供基于 eBPF 的 CPU 性能分析。对于这个项目的一个关注点是libbpf C 代码和 Go 之间的 CGo 边界可能会引起性能和其他问题^(5)。
虽然在过去十年中,Go 语言一直是许多基础设施编码的首选语言,但最近有越来越多的开发者更倾向于使用 Rust。
Rust
Rust 在构建基础设施工具方面的使用越来越广泛。它允许像 C 语言一样进行低级别访问,但又具有内存安全性的附加好处。确实,Linus Torvalds 在 2022 年确认,Linux 内核本身将开始整合 Rust 代码,最近的6.1 版本已经开始支持 Rust。
正如我在本章前面讨论的那样,Rust 可以编译成 eBPF 字节码,这意味着(通过正确的库支持)可以用 Rust 编写 eBPF 实用程序的用户空间和内核代码。
对于 Rust eBPF 开发,有几个选择:libbpf-rs,Redbpf和 Aya。
Libbpf-rs
Libbpf-rs是libbpf项目的一部分,提供了围绕libbpf C 代码的 Rust 包装器,使您可以用 Rust 编写 eBPF 代码的用户空间部分。正如您可以从该项目的示例中看到的那样,eBPF 程序本身是用 C 语言编写的。
注意
在libbpf-bootstrap项目中还有更多的 Rust 示例,旨在帮助您开始构建自己的代码。
这个 crate 对于将 eBPF 程序整合到基于 Rust 的项目中很有帮助,但它不能满足许多人希望在内核端用 Rust 编写代码的愿望。让我们看看其他一些支持这一功能的项目。
Redbpf
Redbpf是一组与libbpf接口的 Rust 包,作为eBPF安全监控代理的一部分进行开发。
Redbpf在 Rust 能够编译成 eBPF 字节码之前就已存在,因此它使用一个多步编译过程,包括从 Rust 编译到 LLVM 比特码,然后使用 LLVM 工具链生成 eBPF 字节码的 ELF 格式。Redbpf支持一系列程序类型,包括跟踪点、kprobes 和 uprobes、XDP 以及一些套接字事件。
随着 Rust 编译器 rustc 直接获得生成 eBPF 字节码的能力,这一能力被一个名为 Aya 的项目所利用。在撰写本文时,根据ebpf.io 社区网站,Aya 被认为是“新兴”项目,而Redbpf被列为一个主要项目,但我个人认为动力似乎正在向 Aya 方向发展。
Aya
Aya 直接在 Rust 中构建到系统调用级别,因此它不依赖于libbpf(或者 BCC 或 LLVM 工具链)。但它支持 BTF 格式,与libbpf相同的重定位(如第五章中描述的),因此它提供了相同的 CO-RE 能力,可以编译一次并在其他内核上运行。在撰写本文时,它支持比Redbpf更广泛的 eBPF 程序类型,包括跟踪/性能相关事件、XDP 和 TC、cgroups 以及 LSM 附件。
正如我提到的,Rust 编译器也支持编译为 eBPF 字节码,因此这种语言可以用于内核和用户空间的 eBPF 编程。
注意
能够在 Rust 中原生编写内核端和用户空间端,而无需中间依赖于 LLVM,吸引了 Rust 程序员选择这个选项。关于为什么lockc 项目的开发人员决定将他们的项目从 libbpf-rs 迁移到 Aya 的有趣讨论在 GitHub 上进行。
该项目包括 aya-tool,一个用于生成与内核数据结构匹配的 Rust 结构定义的实用程序,这样你就不必自己编写它们。
Aya 项目非常强调开发者体验,并且让新手很容易上手。考虑到这一点,“Aya 书”是一个非常易读的介绍,附有一些很好的示例代码,并附有有用的解释。
为了让你简要了解 Rust 中的 eBPF 代码是什么样子,这里是 Aya 基本 XDP 示例的一部分,允许所有流量通过:
#[xdp(name="myapp")] 
pub fn myapp(ctx: XdpContext) -> u32 {
match unsafe { try_myapp(ctx) } { 
Ok(ret) => ret,
Err(_) => xdp_action::XDP_ABORTED,
}
}
unsafe fn try_myapp(ctx: XdpContext) -> Result<u32, u32> { 
info!(&ctx, "received a packet");
Ok(xdp_action::XDP_PASS)
}
这一行定义了部分名称,相当于 C 中的 SEC("xdp/myapp")。
名为 myapp 的 eBPF 程序调用函数 try_myapp 来处理在 XDP 接收到的网络数据包。
try_myapp 函数记录了接收到数据包的事实,并始终返回XDP_PASS值,告诉内核继续按照通常方式处理数据包。
正如我们在本书中看到的基于 C 的示例一样,eBPF 程序被编译为 ELF 对象文件。不同之处在于 Aya 使用 Rust 编译器而不是 Clang 来创建该文件。
Aya 还为将 eBPF 程序加载到内核并将其附加到事件的用户空间活动生成代码。以下是该基本示例的用户空间关键行:
let mut bpf = Bpf::load(include_bytes_aligned!(
"../../target/bpfel-unknown-none/release/myapp"
))?; 
let program: &mut Xdp = bpf.program_mut("myapp").unwrap().try_into()?; 
program.load()?; 
program.attach(&opt.iface, XdpFlags::default()) 
从编译器生成的 ELF 对象文件中读取 eBPF 字节码。
查找名为 myapp 的程序的字节码。
将其加载到内核中。
将其附加到指定网络接口上的 XDP 事件。
如果你是 Rust 程序员,我强烈建议你更详细地探索“Aya 书籍”中的其他示例。Kong 的博客文章也很好地介绍了使用 Aya 编写 XDP 负载均衡器。
注意
Aya 的维护者 Dave Tucker 和 Alessandro Decina 在“eBPF 和 Cilium 办公室时间”直播第 25 集中加入了我,他们演示并介绍了使用 Aya 进行 eBPF 编程。
Rust-bcc
Rust-bcc 提供了模仿 BCC 项目 Python 绑定的 Rust 绑定,并附带一些 BCC 跟踪工具的 Rust 实现。
测试 BPF 程序
有一个 bpf() 命令,BPF_PROG_RUN,允许从用户空间运行 eBPF 程序进行测试。
BPF_PROG_RUN(目前)仅适用于大多数与网络相关的 BPF 程序类型。
您还可以通过一些内置统计信息获取有关 eBPF 程序性能的信息。运行以下命令以启用它:
$ sysctl -w kernel.bpf_stats_enabled=1
这将显示bpftool的输出中关于程序的额外信息,例如:
$ bpftool prog list
...
2179: raw_tracepoint name raw_tp_exec tag 7f6d182e48b7ed38 gpl
run_time_ns 316876 run_cnt 4
loaded_at 2023-01-09T11:07:31+0000 uid 0
xlated 216B jited 264B memlock 4096B map_ids 780,777
btf_id 953
pids hello(19173)
额外的统计信息显示为粗体,这里显示该程序已运行四次,总共大约耗时 300 微秒。
注意
从 Quentin Monnet 的 FOSDEM 2020 演讲“工具和机制来调试 BPF 程序”中了解更多信息。
多个 eBPF 程序
一个 eBPF 程序是附加到内核中事件的函数。许多应用程序需要跟踪多个事件以实现其目标。这方面的一个简单示例是 opensnoop。^(6) 本章早期我介绍了 bpftrace 版本,并展示了它如何将 BPF 程序附加到四个不同的系统调用跟踪点上:
-
syscall_enter_open -
syscall_exit_open -
syscall_enter_openat -
syscall_exit_openat
这些是内核处理 open() 和 openat() 系统调用的入口和出口点。这两个系统调用可用于打开文件,而 opensnoop 工具会跟踪这两个调用。
但是为什么需要同时跟踪这些系统调用的进入和退出呢?使用进入点是因为此时系统调用参数是可用的,这些参数包括要传递给open[at]系统调用的文件名和任何标志。但在这个阶段,还不知道文件是否会成功打开。这解释了为什么有必要在退出点也附加 eBPF 程序。
如果你查看libbpf-tools版本的 opensnoop,你会看到只有一个用户空间程序,它将所有四个 eBPF 程序加载到内核并将它们附加到它们的事件上。这些 eBPF 程序本身基本上是独立的,但它们使用 eBPF 映射来在彼此之间协调。
在一个复杂的应用程序中,甚至可能需要在很长一段时间内动态地添加和删除 eBPF 程序。对于任何给定的应用程序,甚至可能没有固定数量的 eBPF 程序。例如,Cilium 将 eBPF 程序附加到每个虚拟网络接口上,在 Kubernetes 环境中,这些接口的存在与否取决于运行的 Pod 数量。
本章中的大多数库都会自动处理这些 eBPF 程序的多样性。例如,libbpf和ebpf-go会生成加载所有程序和映射的骨架代码,这可以通过一个函数调用完成。它们还生成更细粒度的函数,以便你可以单独操作程序和映射。
摘要
大多数使用基于 eBPF 的工具的人不需要自己编写 eBPF 代码,但如果你确实希望自己实现一些东西,你有很多选择。这是一个不断变化的领域,所以很可能在你阅读这篇文章时,会有新的语言库和框架存在,或者已经在一些我在本章中强调的库周围形成了共识。你可以在ebpf.io 重要项目列表的基础设施页面找到关于 eBPF 主要语言项目的最新列表。
为了快速收集跟踪信息,bpftrace可以是一个非常有价值的选择。
对于更灵活和控制性更强的需求,如果你熟悉 Python,并且不介意运行时发生的编译步骤,BCC 是构建 eBPF 工具的快速方式。
如果你正在编写 eBPF 代码,希望在不同的内核版本间广泛分发和移植,你可能会想要利用 CO-RE。目前支持 CO-RE 的用户空间框架有 C 语言的libbpf,Go 语言的cilium/ebpf和libbpfgo,以及 Rust 语言的 Aya。
如果需要进一步的建议,我强烈建议加入eBPF Slack,在那里讨论你的问题。你很可能会在这个社区找到许多这些语言库的维护者。
练习
如果您想尝试本章讨论的一个或多个库,那么“Hello World”总是一个很好的开始:
-
使用您选择的一个或多个库,编写一个“Hello World”程序,输出一个简单的跟踪消息。
-
使用
llvm-objdump比较从第三章的“Hello World”示例生成的字节码。你会发现很多相似之处! -
正如您在第四章中看到的那样,您可以使用
strace -e bpf来查看何时进行bpf()系统调用。尝试在您的“Hello World”程序上执行此操作,看看它是否按预期行事。
^(1) 附加到系统调用入口点意味着这个脚本具有与上一章讨论的 TOCTOU(时间检查到使用时间)漏洞相同的漏洞。这并不能阻止它成为一个有用的工具;只是你不应该将其作为安全目的的唯一防线。
^(2) 例如,查看 Cloudflare 的博文“eBPF,Sockets,Hop Distance and manually writing eBPF assembly”。
^(3) 例如,Brendan Gregg 的观察显示,libbpf版本的 opensnoop 大约需要 9 MB,而基于 Python 的版本需要 80 MB。
^(4) 在第 13 集 eBPF 和 Cilium 办公时间的直播中,看我演示一些 XDP 教程示例。
^(5) 戴夫·陈尼在 2016 年的文章“cgo is not Go”仍然是对与 CGo 边界相关问题的很好概述。
^(6) 除了bpftrace版本的工具之外,在 BCC 和libbpf-tools中也有等效的工具。它们都做着相同的事情,每当一个进程打开文件时生成一行跟踪。在我的报告“什么是 eBPF?”中有 BCC 版本 opensnoop 的 eBPF 代码演示。
第十一章:eBPF 的未来演进
eBPF 还没有完全成熟!与大多数软件一样,它在 Linux 内核中不断发展,并且正在被添加到 Windows 操作系统中。在这一章中,我们将探讨该技术未来可能的发展路径。
自从在 Linux 内核中引入以来,BPF 已经发展成为具有自己的子系统、邮件列表和维护者。^(1) 随着 eBPF 的流行和兴趣超出 Linux 内核社区,创建一个中立机构来协调各方之间的合作是很有意义的。这个机构就是 eBPF 基金会。
eBPF 基金会
eBPF 基金会 由 Google、Isovalent、Meta(当时称为 Facebook)、Microsoft 和 Netflix 在 Linux 基金会的支持下于 2021 年成立。该基金会作为一个中立机构,可以持有资金和知识产权,各商业公司可以在此基础上进行合作。
这并不意味着要改变 Linux 内核社区和 Linux BPF 子系统的开发方式。基金会的活动由 BPF 领导委员会指导,该委员会完全由技术专家组成,包括 Linux 内核 BPF 维护者和其他核心 eBPF 项目的代表。
eBPF 基金会专注于 eBPF 作为技术平台及其生态系统中支持 eBPF 开发的工具。构建在 eBPF 之上的项目若寻求中立的管理,可能会在其他基金会中找到更合适的归属。例如,Cilium、Pixie 和 Falco 都是 CNCF 的一部分,这是有道理的,因为它们都旨在用于云原生环境。
在现有的 Linux 维护者之外,推动此次合作的关键驱动力是微软对在 Windows 操作系统中发展 eBPF 的兴趣。这带来了制定 eBPF 标准的需求,以便可以在不同操作系统上使用相同的程序。这项工作在 eBPF 基金会的支持下进行。
Windows 上的 eBPF
微软正在积极支持 Windows 上的 eBPF。在我写下这些文字的 2022 年末,已经有 功能演示 显示 Cilium 第四层负载均衡和基于 eBPF 的连接跟踪在 Windows 上的运行。
我之前说过,eBPF 编程就是内核编程,乍一看,似乎不太可能写一个程序在 Linux 内核中运行,并且访问 Linux 内核数据结构,然后它能在完全不同的操作系统中运行。但实际上,特别是在网络方面,所有操作系统都有很多共同之处。无论是在 Windows 还是 Linux 机器上创建的网络数据包,其结构都是相同的,网络堆栈的处理方式也是一样的。
你还记得 eBPF 程序由一组字节码指令组成,由内核中实现的虚拟机(VM)处理。在 Windows 中也可以实现这个 VM!
图 11-1 展示了 eBPF for Windows 的架构概述,来源于 项目的 GitHub 仓库。从这张图中可以看出,eBPF for Windows 重用了现有 eBPF 生态系统中的一些开源组件,比如 libbpf,以及 Clang 支持生成 eBPF 字节码。Linux 内核采用 GPL 许可,而 Windows 是专有的,因此 Windows 项目无法重用 Linux 内核的验证器实现。^(3) 反而,它使用了 PREVAIL 验证器 和 uBPF JIT 编译器(两者都采用宽松许可,以便更广泛地被项目和组织使用)。

图 11-1. eBPF for Windows 的架构概述,改编自 https://oreil.ly/HxKsu
一个有趣的区别是,在 Windows 安全环境中,eBPF 代码是在用户空间进行验证和 JIT 编译的,而不是在内核中(在 图 11-1 中显示的 uBPF 解释器仅用于调试构建,而非生产环境)。
期望每一个在 Linux 上运行的 eBPF 程序都能在 Windows 上工作是不现实的。但这与使 eBPF 程序在不同 Linux 内核版本上运行的挑战并没有太大不同:即使有了 CO-RE 支持,内部内核数据结构在版本之间也可能会被更改、添加或删除。处理这些可能性,是 eBPF 程序员的工作。
谈到 Linux 内核的变更,我们可以期待 eBPF 在未来几年的哪些变化?
Linux eBPF 演进
自 3.15 版以来,eBPF 的能力随着每个内核发布而演进。如果你想了解每个版本提供了哪些功能,请查看 BCC 项目维护的 有用列表。我确实期待未来会有更多的新增功能。
要预测未来的最佳方法就是简单地听取那些正在从事相关工作的人的意见。例如,在 2022 年 Linux 管道会议上,eBPF 维护者 Alexei Starovoitov 发表了一篇演讲,讨论了他对 eBPF 程序中使用的 C 语言未来演变的期望。^(4) 我们已经看到 eBPF 从支持几千条指令逐步演变为支持几乎无限复杂性,添加了对循环的支持和日益增加的 BPF 助手函数集。随着支持的 C 语言增加了额外的功能,以及验证器的支持,eBPF C 语言可能会演变成允许像开发内核模块一样灵活,但具备 eBPF 的安全性和动态加载特性。
正在讨论和开发的其他一些新 eBPF 特性和功能包括:
签名的 eBPF 程序
软件供应链安全是过去几年的热门话题,关键元素之一是能够检查您考虑运行的程序来自预期来源并且未被篡改。一种通用的方法是验证程序伴随的加密签名。你可能认为这是内核可以为 eBPF 程序做的事情之一,也许作为验证步骤的一部分,但不幸的是这并不简单!正如您在本书中看到的,用户空间加载器会动态调整程序,并提供有关地图位置以及从签名角度来看难以区分的恶意修改,这是一个问题,eBPF 社区正急于找到解决方案。
长期存活的内核指针
一个 eBPF 程序可以使用助手函数或 kfunc 检索指向内核对象的指针,但指针仅在程序的执行期间有效。不能将指针存储在地图中以供以后检索。支持类型化指针的想法将在这一领域提供更多的灵活性。
内存分配
对于 eBPF 程序来说,简单调用像kmalloc()这样的内存分配函数是不安全的,但有一个建议提议了 eBPF 特定的替代方案。
当新的 eBPF 功能出现时,您将能够利用它们吗?作为最终用户,您能够利用的功能取决于您在生产中运行的内核版本,在第一章中我讨论过,Linux 内核发布稳定版本可能需要数年时间才能到达。作为个人,您可能会选择一个最新的内核,但是大多数运行服务器部署的组织使用的是稳定且受支持的版本。eBPF 程序员必须考虑到,如果他们编写的代码利用了内核中添加的最新功能,这些功能在大多数生产环境中可能几年内还不能使用。一些组织可能会有足够紧急的需求,值得更快地推出新的内核版本,以便早日采用新的 eBPF 功能。
例如,在另一个展望未来的讲话中,Daniel Borkmann 讨论了一个名为 Big TCP 的功能,该功能在 Linux 5.19 版本中添加,通过批处理网络数据包以在内核中处理,实现了 100 GBit/s(甚至更快)的网络速度。大多数 Linux 发行版几年内不会支持这么新的内核,但对于处理大量网络流量的专业组织来说,可能更快升级是值得的。现在将 Big TCP 支持加入 eBPF 和 Cilium,这意味着对于那些大规模用户而言,即使大多数人暂时无法启用它,它也是可用的。
由于 eBPF 允许动态调整内核代码,因此合理地预期它将用于解决“现场”问题。在第九章中,您可以了解到使用 eBPF 缓解内核漏洞的情况;还在进行使用 eBPF 支持硬件设备,例如人机界面设备,如鼠标、键盘和游戏控制器。这是基于我在第七章中提到的支持解码红外控制器使用的协议的现有支持。
eBPF 是一个平台,而不是一个功能。
将近十年前,热门的新技术是容器,似乎每个人都在谈论它们及其带来的优势。今天,我们在 eBPF 领域也处于类似的阶段,有许多会议演讲和博客文章——其中本书中引用了几篇——赞扬 eBPF 的好处。今天,对于许多开发人员来说,容器已经成为日常生活的一部分,无论是使用 Docker 或其他容器运行时在本地运行代码,还是将代码部署到 Kubernetes 环境中。eBPF 是否也会成为每个人的常规工具包呢?
我相信答案是否定的,或者至少不是直接的。大多数用户不会直接编写 eBPF 程序,也不会使用类似bpftool的实用程序手动操作它们。但他们会定期与使用 eBPF 构建的工具进行交互,无论是性能测量、调试、网络、安全、跟踪,还是其他许多尚未使用 eBPF 实现的功能。用户可能并不知道他们在使用 eBPF,就像他们可能不知道使用容器时使用了像命名空间和控制组这样的内核特性一样。
如今,具备 eBPF 知识的项目和供应商因其强大性能和许多优势而突显其使用。随着基于 eBPF 的项目和产品在市场上占据越来越大的份额,eBPF 正在成为基础设施工具的事实标准技术平台。
eBPF 编程的知识是一种仍然很抢手但相对稀有的技能,就像今天内核开发比起开发业务应用或游戏来说要少得多一样。如果你喜欢深入系统的底层并且想要构建基础设施工具,eBPF 技能将对你大有裨益。我希望本书对你的 eBPF 之旅有所帮助!
结论
恭喜你完成了本书的阅读!
我希望通过阅读《学习 eBPF》使你深入了解 eBPF 的强大之处。也许它已经激发了你自己编写 eBPF 代码或尝试我讨论过的一些工具的兴趣。如果你已经决定进行一些 eBPF 编程,希望本书能给你一些开始的信心。如果你在阅读本书时完成了练习,那就太棒了!
如果你对 eBPF 感到兴奋,有很多参与社区的方式。最好的起点是访问网站ebpf.io。这将引导你了解最新的新闻、项目、活动,还有eBPF Slack频道,你很可能会在那里找到专家来回答你可能有的任何问题。
我欢迎你的反馈、评论以及对本文的任何更正。你可以通过附带本书的 GitHub 仓库提供你的意见:github.com/lizrice/learning-ebpf。我也很乐意直接听取你的意见。你可以在互联网的许多地方找到我,我的用户名是@lizrice。
^(1) 向 Meta 的 Alexei Starovoitov 和 Andrii Nakryiko,以及 Isovalent 的 Daniel Borkmann 致敬,他们在 Linux 内核中维护 BPF 子树。
^(2) Dave Thaler 在 Linux Plumbers Conference 上介绍了这项标准化工作的进展。
^(3) 嗯,的确可以,但这样做需要微软也以 GPL 许可证发布 Windows 源代码。
^(4) Alexei Starovoitov 讨论了 BPF 从受限 C 语言到扩展和安全 C 的发展过程,视频链接在此。


浙公网安备 33010602011771号