Linux-BPF-可观测指南-全-
Linux BPF 可观测指南(全)
原文:
zh.annas-archive.org/md5/605aba759d9213eb2d7cdc7ced07903a译者:飞龙
序言
作为一个程序员(也是一个自称的怪胎),我喜欢跟踪各种内核的最新补充和计算研究。当我第一次在 Linux 中玩弄伯克利包过滤器(BPF)和 Express Data Path(XDP)时,我就爱上了它们。这些工具真是太棒了,我很高兴这本书把 BPF 和 XDP 放在中心舞台上,让更多的人能在他们的项目中开始使用它们。
让我详细介绍一下我的背景,以及为什么我对这些内核接口情有独钟。我曾担任 Docker 核心维护者,与 David 一起工作。如果你不熟悉,Docker 对于容器的过滤和路由逻辑大多是通过调用iptables实现的。我对 Docker 的第一个补丁是修复一个问题,即在 CentOS 上的一个版本中,iptables的命令行标志不同,因此写入iptables失败了。还有很多类似的奇怪问题,任何在软件中调用工具的人可能都能理解。此外,在主机上有成千上万的规则并不是iptables的初衷,这会导致性能副作用。
后来我听说了 BPF 和 XDP。这对我来说就像是天籁之音。我的iptables伤痕不再因另一个 bug 而流血了!甚至内核社区正在努力用 BPF 取代iptables!哈利路亚!Cilium,一个用于容器网络的工具,正在其项目的内部使用 BPF 和 XDP。
但这还不是全部!BPF 能做的远不止满足于iptables的使用场景。有了 BPF,你可以跟踪任何系统调用或内核函数,以及任何用户空间程序。bpftrace 让用户能在 Linux 命令行中拥有类似 DTrace 的能力。你可以追踪所有被打开的文件及调用这些打开操作的进程,统计调用它们的程序的系统调用次数,追踪 OOM killer 等等……世界就在你的掌握中!BPF 和 XDP 还被用于Cloudflare 和 Facebook 的负载均衡器 中,以防止分布式拒绝服务攻击。我不会剧透 XDP 为何如此擅长丢弃数据包,因为你将在本书的 XDP 和网络章节中了解到!
通过 Kubernetes 社区,我有幸认识了 Lorenzo。他的工具kubectl-trace 允许用户在其 Kubernetes 集群中轻松运行自定义跟踪程序。
个人而言,我对 BPF 的最喜爱用途是编写自定义跟踪器,以证明其他人的软件性能不达标或者系统调用次数过多的情况。永远不要低估用硬数据证明别人错误的力量。不要担心,本书将带领你编写你的第一个跟踪程序,让你也能做到这一点。BPF 的美妙之处在于在此之前,其他工具使用丢失队列将样本集发送到用户空间进行聚合,而 BPF 非常适合生产环境,因为它允许在事件源头直接构建直方图和过滤。
我职业生涯的一半时间都在为开发者工具工作。最好的工具允许开发者像你一样在其接口中自主使用,用于作者甚至未曾想象的事物。引用理查德·费曼的话,“我很早就学会了知道某事物的名字与真正了解它之间的区别。”直到现在,你可能只知道 BPF 这个名字,以及它可能对你有用。
我喜欢这本书的原因是它为你提供了创建所有新工具的知识,使用 BPF。阅读并完成练习后,你将有能力像使用超能力一样使用 BPF。你可以将其放在工具箱中,在最需要和最有用时使用。你不仅会学会 BPF;你会理解它。这本书是打开你头脑,看到用 BPF 可以构建的可能性的路径。
这个发展中的生态系统非常令人兴奋!我希望随着更多人开始发挥 BPF 的力量,它会变得更加庞大。我很期待了解本书读者最终构建的东西,无论是追踪疯狂软件错误的脚本,还是自定义防火墙,甚至是红外解码。请务必告诉我们你构建了什么!
Jessie Frazelle
前言
2015 年,David 作为 Docker 的核心开发者,这家公司使容器变得流行。他的日常工作分为帮助社区和推动项目增长。他的工作之一是审查社区成员发送的大量拉取请求;他还必须确保 Docker 适用于各种场景,包括在任何时候运行和配置数千个容器的高性能工作负载。
在 Docker 诊断性能问题时,我们使用了火焰图,这是高级可视化工具,可帮助您轻松地浏览数据。Go 编程语言通过嵌入式 HTTP 端点使得测量和提取应用性能数据变得非常容易,并基于这些数据生成图表。David 撰写了一篇关于 Go 分析器功能的文章,以及如何使用其数据生成火焰图。关于 Docker 收集性能数据的一个主要问题是分析器默认情况下是禁用的,因此如果您尝试调试性能问题,首要操作是重新启动 Docker。这种策略的主要问题在于通过重新启动服务,您可能会丢失正在尝试收集的相关数据,然后需要等待直到再次发生您要跟踪的事件。在 David 关于 Docker 火焰图的文章中,他提到这是测量 Docker 性能的一个必要步骤,但不一定需要这样做。这一认识使他开始研究不同的技术来收集和分析任何应用程序的性能,这导致他发现了 BPF。
与此同时,远离 David,Lorenzo 正在寻找一个理由来更好地研究 Linux 内核的内部机制,他发现通过学习 BPF 时能够轻松了解许多内核子系统。几年后,他能够在他在 InfluxData 的工作中应用 BPF,以了解如何使 InfluxCloud 中的数据摄取更快。现在 Lorenzo 参与了 BPF 社区和 IOVisor,并在 Sysdig 工作,负责 Falco,这是一个使用 BPF 进行容器和 Linux 运行时安全性的工具。
在过去的几年中,我们在多种场景中使用了 BPF,从收集 Kubernetes 集群的利用率数据到管理网络流量策略。通过使用它和阅读像 Brendan Gregg 和 Alexei Starovoitov 这样的技术领袖以及 Cilium 和 Facebook 等公司的许多博客文章,我们深入了解了它的方方面面。他们的文章和出版物在过去对我们帮助很大,并且它们也是本书开发的重要参考。
阅读了许多这些资源后,我们意识到,每次我们需要学习有关 BPF 的东西时,我们都需要在许多博客文章、手册页面和互联网的其他地方之间跳转。这本书是我们试图将分散在网络上的知识集中到一个中心位置,供下一代 BPF 爱好者学习这一美妙技术之用。
我们将我们的工作分为九个不同的章节,向您展示使用 BPF 可以实现的功能。您可以单独阅读一些章节作为参考指南,但如果您是 BPF 的新手,我们建议您按顺序阅读它们。这将使您了解 BPF 的核心概念,并指导您在前进的道路上的可能性。
无论您是已经是可观察性和性能分析的专家,还是正在研究新的可能性来回答您之前无法解决的生产系统问题,我们希望您能在本书中找到新的知识。
本书使用的约定
本书使用以下排版约定:
斜体
表示新术语、URL、电子邮件地址、文件名和文件扩展名。
Constant width
用于程序清单,以及在段落中用于引用程序元素,如变量或函数名称、数据库、数据类型、环境变量、语句和关键字。
Constant width bold
显示用户应该按照字面意义输入的命令或其他文本。
Constant width italic
显示应由用户提供值或由上下文确定值的文本。
提示
此元素表示提示或建议。
注意
此元素表示一般注释。
警告
此元素表示警告或注意事项。
使用代码示例
补充材料(代码示例、练习等)可在https://oreil.ly/lbpf-repo下载。
本书旨在帮助您完成工作。一般而言,如果本书提供示例代码,则可以在您的程序和文档中使用它。除非您重现了代码的重要部分,否则无需联系我们以获得许可。例如,编写一个使用本书中几个代码片段的程序不需要许可。销售或分发 O'Reilly 书籍的示例需要许可。通过引用本书并引用示例代码来回答问题不需要许可。将本书中大量示例代码合并到您产品的文档中需要许可。
我们感谢,但不要求署名。通常包括标题、作者、出版商和 ISBN 号的署名。例如:“Linux Observability with BPF by David Calavera and Lorenzo Fontana (O’Reilly). Copyright 2020 David Calavera and Lorenzo Fontana, 978-1-492-05020-9.”
如果您认为您使用的代码示例超出了公平使用范围或这里给出的许可,请随时通过permissions@oreilly.com与我们联系。
O’Reilly 在线学习
注意
40 多年来,O’Reilly Media提供技术和商业培训、知识和见解,帮助公司取得成功。
我们独特的专家和创新者网络通过书籍、文章、会议以及我们的在线学习平台分享他们的知识和专业知识。O’Reilly 的在线学习平台为您提供按需访问实时培训课程、深入学习路径、交互式编码环境以及来自 O’Reilly 和其他 200 多家出版商的大量文本和视频。欲了解更多信息,请访问http://oreilly.com。
如何联系我们
请将有关本书的评论和问题发送至出版商:
-
O’Reilly Media, Inc.
-
1005 Gravenstein Highway North
-
CA 95472,Sebastopol
-
800-998-9938(美国或加拿大)
-
707-829-0515(国际或本地)
-
707-829-0104(传真)
我们为本书创建了一个网页,列出勘误、示例和任何额外信息。您可以访问https://oreil.ly/linux-bpf查看这个页面。
通过电子邮件bookquestions@oreilly.com对本书提出评论或技术问题。
欲了解有关我们的图书、课程、会议和新闻的更多信息,请访问我们的网站:http://www.oreilly.com。
在 Facebook 上找到我们:http://facebook.com/oreilly
在 Twitter 上关注我们:http://twitter.com/oreillymedia
在 YouTube 上观看我们:http://www.youtube.com/oreillymedia
致谢
写一本书比我们想象的更困难,但这可能是我们生活中最有价值的活动之一。这本书耗费了许多日夜,如果没有我们的合作伙伴、家人、朋友和狗的帮助,这将是不可能完成的。我们要感谢 Debora Pace、Lorenzo 的女朋友以及他的儿子 Riccardo,在长时间的写作过程中对他们的耐心。同时也感谢 Lorenzo 的朋友 Leonardo Di Donato 提供的所有建议,特别是关于 XDP 和测试的部分。
我们对 Robin Means 和 David 的妻子在早期草稿和开始本书的初步概述的校对表示永远的感激,以及在多年来帮助他撰写许多文章并笑着听他编造的听起来比实际更可爱的英语单词。
我们都想向那些使 eBPF 和 BPF 变得可能的人表示衷心的感谢。感谢 David Miller 和 Alexei Starovoitov 不断贡献改进 Linux 内核,最终改进了 eBPF 及其周围的社区。感谢 Brendan Gregg 乐于分享、他的热情以及他在使 eBPF 更加易于访问的工具方面的工作。感谢 IOVisor 团队为 bpftrace、gobpf、kubectl-trace 和 BCC 所付出的努力、邮件以及所有工作。感谢 Daniel Borkmann 在 libbpf 和工具基础设施方面的所有激励性工作。感谢 Jessie Frazelle 撰写前言并对我们俩以及数千名开发者产生了启发作用。感谢 Jérôme Petazzoni 成为我们最想要的最佳技术评审者;他的问题让我们重新思考了本书的许多部分及其代码示例的处理方式。
感谢所有数千名 Linux 内核贡献者,特别是那些在 BPF 邮件列表中活跃的人,感谢他们的问题/回答、补丁和倡议。最后,感谢所有参与 O'Reilly 出版此书的人员,包括我们的编辑 John Devins 和 Melissa Potter,以及所有在幕后制作封面、审核页面的人员,使本书比我们职业生涯中的任何其他作品更加专业。
第一章:介绍
在过去几十年里,计算系统的复杂性不断增长。理解软件行为的推理已经创造了多个业务类别,所有这些类别都试图解决获得复杂系统洞察的挑战。获取这种可见性的一种方法是分析计算系统中所有应用程序生成的数据日志。日志是信息的重要来源。它们可以为您提供关于应用程序行为的精确数据。但是,它们也限制您,因为您只能获取应用程序构建工程师在这些日志中公开的信息。以日志格式收集任何额外信息可能与反编译程序并查看执行流程一样具有挑战性。另一种流行的方法是使用指标来推理程序行为的原因。指标在数据格式上与日志不同;日志提供显式数据,而指标聚合数据以衡量程序在特定时间点的行为方式。
可观察性 是一种从不同角度解决此问题的新兴实践。人们定义可观察性为我们能够从任何给定系统中提出任意问题并获得复杂答案的能力。可观察性、日志和指标聚合之间的一个关键区别在于您收集的数据。通过实践可观察性,您需要在任何时间点回答任意问题,因此推理数据的唯一方式是收集系统可以生成的所有数据,并在必要时进行聚合以回答您的问题。
纳西姆·尼古拉斯·塔勒布(Nassim Nicholas Taleb),畅销书作者,如《反脆弱:从混乱中获益的事物》(Penguin Random House),他为意外事件,具有重大后果,如果在其发生之前观察到它们则可以预料到的术语黑天鹅提出了流行。在他的书《黑天鹅》(Penguin Random House)中,他理性地阐述了如何通过拥有相关数据来帮助减少这些罕见事件的风险。黑天鹅事件在软件工程中比我们想象的更常见,并且是不可避免的。因为我们可以假设无法防止这些事件,我们唯一的选择就是尽可能多地获取关于它们的信息,以在不影响业务系统的情况下解决它们。可观察性帮助我们构建强大的系统并减少未来的黑天鹅事件,因为它基于这样一个前提:您正在收集可以回答任何未来问题的任何数据。研究黑天鹅事件和实践可观察性在一个中心点汇聚,即您从系统中收集的数据。
Linux 容器是 Linux 内核上一组功能的抽象,用于隔离和管理计算机进程。传统上负责资源管理的内核还提供了任务隔离和安全性。在 Linux 中,容器基于的主要功能是命名空间和控制组(cgroups)。命名空间是将任务彼此隔离的组件。从某种意义上说,当您在命名空间内部时,就像在计算机上没有其他任务在运行。控制组是提供资源管理的组件。从操作角度来看,它们为您提供了对 CPU、磁盘 I/O、网络等任何资源使用的精细控制。在过去的十年中,随着 Linux 容器的流行,软件工程师设计大型分布式系统和计算平台的方式发生了变化。多租户计算已完全依赖于内核中的这些特性。
通过如此依赖于 Linux 内核的低级能力,我们已经利用了一个新的复杂性和信息来源,这些都是我们在设计可观测系统时需要考虑的。内核是一个事件驱动系统,这意味着所有的工作都是基于事件描述和执行的。打开文件是一种事件,CPU 执行任意指令是一个事件,接收网络数据包也是一个事件,等等。伯克利数据包过滤器(BPF)是内核中可以检查这些新信息来源的子系统。BPF 允许你编写程序,在内核触发任何事件时安全执行。BPF 提供强大的安全保证,防止你在这些程序中引入系统崩溃和恶意行为。BPF 正在推动一批新工具的出现,帮助系统开发人员观察和处理这些新平台。
在本书中,我们展示了 BPF 为您提供的能力,使任何计算系统更易于观察。我们还展示了如何利用多种编程语言编写 BPF 程序。我们已经将程序代码放在 GitHub 上,所以您无需复制粘贴。您可以在 Git 仓库中找到它,本书的伴随资源。
但在我们开始专注于 BPF 的技术方面之前,让我们看看一切是如何开始的。
BPF 的历史
1992 年,Steven McCanne 和 Van Jacobson 撰写了论文《BSD 数据包过滤器:用于用户级数据包捕获的新架构》。在这篇论文中,作者描述了如何为 Unix 内核实现一个网络数据包过滤器,其速度比当时的最新技术快 20 倍。数据包过滤器有一个特定的目的:为监视系统网络的应用程序提供来自内核的直接信息。有了这些信息,应用程序可以决定如何处理这些数据包。BPF 在数据包过滤中引入了两个重要的创新:
-
设计用于与基于寄存器的 CPU 高效工作的新虚拟机(VM)。
-
使用每个应用程序缓冲区来过滤数据包,而无需复制所有数据包信息。这最小化了 BPF 需要进行决策的数据量。
这些显著改进使得所有 Unix 系统都采用了 BPF 作为网络数据包过滤的首选技术,放弃了消耗更多内存且性能较差的旧实现。这种实现仍然存在于包括 Linux 内核在内的许多 Unix 内核衍生系统中。
在 2014 年初,Alexei Starovoitov 引入了扩展 BPF 实现。这种新设计针对现代硬件进行了优化,使其生成的指令集比旧 BPF 解释器生成的机器码更快。扩展版本还将 BPF 虚拟机中的寄存器数量从两个 32 位寄存器增加到十个 64 位寄存器。寄存器数量的增加以及它们的宽度打开了编写更复杂程序的可能性,因为开发人员可以通过函数参数交换更多信息。这些变化及其他改进使扩展 BPF 版本比原始 BPF 实现快了多达四倍。
这种新实现的最初目标是优化处理网络过滤器的内部 BPF 指令集。在这一点上,BPF 仍然局限于内核空间,只有少数用户空间程序可以编写 BPF 过滤器供内核处理,例如 Tcpdump 和 Seccomp,我们将在后面的章节中讨论。如今,这些程序仍然为旧 BPF 解释器生成字节码,但内核将这些指令转换为大大改进的内部表示形式。
在 2014 年 6 月,扩展版 BPF 暴露给了用户空间。这是 BPF 未来的一个转折点。正如 Alexei 在引入这些更改的补丁中所写道:“这个补丁集展示了 eBPF 的潜力。”
BPF 成为顶级内核子系统,并不再局限于网络堆栈。BPF 程序开始看起来更像内核模块,非常注重安全性和稳定性。与内核模块不同,BPF 程序不需要重新编译内核,并且保证在不崩溃的情况下完成执行。
我们在下一章中将讨论的 BPF 验证器添加了这些必需的安全性保证。它确保任何 BPF 程序都能在不崩溃的情况下完成执行,并确保程序不会尝试访问超出范围的内存。然而,这些优势也带来了一些限制:程序有最大允许的大小,并且循环必须是有界的,以确保系统内存永远不会被糟糕的 BPF 程序耗尽。
随着使 BPF 从用户空间可访问的更改,内核开发人员还添加了一个新的系统调用(syscall),bpf。这个新的 syscall 将成为用户空间和内核之间通信的中心组成部分。我们将在本书的第 2 和 3 章中讨论如何使用这个 syscall 来处理 BPF 程序和映射。
BPF 映射将成为内核和用户空间之间交换数据的主要机制。第二章 展示了如何使用这些专门的结构从内核收集信息,以及向已在内核中运行的 BPF 程序发送信息。
扩展 BPF 版本是本书的起点。在过去的五年中,自从引入这个扩展版本以来,BPF 已经有了显著的进展,我们详细介绍了 BPF 程序、BPF 映射以及受这一进展影响的内核子系统的演变。
架构
BPF 内核中的架构是非常迷人的。我们会在整本书中深入探讨其具体细节,但在本章中,我们想为您快速概述其工作原理。
正如我们之前提到的,BPF 是一个高度先进的虚拟机,在隔离环境中运行代码指令。在某种意义上,您可以将 BPF 看作是 Java 虚拟机(JVM)的工作方式,一个专门运行从高级编程语言编译而成的机器码的程序。编译器如 LLVM 和 GNU Compiler Collection(GCC)在不久的将来将提供对 BPF 的支持,允许您将 C 代码编译为 BPF 指令。在编译完成后,BPF 使用验证器确保程序在内核中运行时安全。它通过阻止可能会导致内核崩溃的代码运行来保护您的系统。如果您的代码是安全的,BPF 程序将加载到内核中。Linux 内核还整合了用于 BPF 指令的即时编译器(JIT)。JIT 将在程序验证后直接将 BPF 字节码转换为机器码,避免执行时间上的额外开销。这种架构的一个有趣之处在于,您不需要重新启动系统来加载 BPF 程序;您可以按需加载它们,并且您还可以编写自己的 init 脚本,在系统启动时加载 BPF 程序。
在内核运行任何 BPF 程序之前,它需要知道程序连接到哪个执行点。内核中有多个连接点,并且该列表正在增长。执行点由 BPF 程序类型定义;我们将在下一章中讨论它们。当您选择一个执行点时,内核还会提供特定的函数助手,您可以使用这些助手来处理程序接收到的数据,从而使执行点和 BPF 程序紧密耦合。
BPF 架构中的最后一个组件负责在内核和用户空间之间共享数据。这个组件称为 BPF map,我们在第三章中讨论了关于地图的内容。BPF 地图是双向结构,用于共享数据。这意味着你可以从内核和用户空间两方面写入和读取它们。有几种类型的结构,从简单的数组和哈希映射到专门的映射,允许你在其中保存整个 BPF 程序。
随着本书的进展,我们将更详细地介绍 BPF 架构中的每个组件。您还将学习如何利用 BPF 的可扩展性和数据共享,具体示例涵盖从堆栈跟踪分析到网络过滤和运行时隔离的主题。
结论
我们编写这本书是为了帮助你熟悉与 Linux 子系统 BPF 日常工作中需要的基本概念。BPF 仍然是一项发展中的技术,随着我们写作本书,新的概念和范式也在不断增长。理想情况下,这本书将通过为你提供 BPF 基础组件的坚实基础来轻松扩展你的知识。
下一章直接深入探讨了 BPF 程序的结构以及内核如何运行它们。它还涵盖了你可以附加这些程序的内核中的各个点。这将帮助你熟悉你的程序可以消耗的所有数据及其使用方法。
第二章:运行您的第一个 BPF 程序
BPF 虚拟机能够响应内核触发的事件运行指令。然而,并非所有 BPF 程序都能访问内核触发的所有事件。当您将程序加载到 BPF 虚拟机时,您需要决定正在运行的程序类型。这告知内核您的程序将在何处被触发。它还告诉 BPF 验证器,您的程序中将允许哪些辅助功能。选择程序类型还意味着选择您的程序正在实现的接口。此接口确保您可以访问适当类型的数据,以及您的程序是否可以直接访问网络数据包。
在本章中,我们向您展示如何编写您的第一个 BPF 程序。我们还将指导您了解您可以创建的不同类型的 BPF 程序(截至本书编写时)。多年来,内核开发人员一直在添加可以附加 BPF 程序的不同入口点。这项工作尚未完成,他们正在每天发现利用 BPF 的新方法。在本章中,我们将专注于一些最有用的程序类型,并试图让您领略 BPF 的潜力。在未来的章节中,我们还会介绍许多其他的 BPF 程序示例。
本章还将介绍 BPF 验证器在运行您的程序中所起的作用。此组件验证您的代码是否安全执行,并帮助您编写不会导致意外结果的程序,例如内存耗尽或突然的内核崩溃。但让我们从头开始讲述编写您自己的 BPF 程序的基础知识。
编写 BPF 程序
编写 BPF 程序的最常见方法是使用 LLVM 编译的 C 语言子集。LLVM 是一个通用编译器,可以生成不同类型的字节码。在本例中,LLVM 将生成 BPF 汇编代码,稍后我们将加载到内核中。我们不会在本书中展示太多 BPF 汇编代码。经过长时间的讨论,我们决定更好地向您展示如何在特定情况下使用它的示例,但您可以轻松在网上或 BPF 手册中找到多个参考资料。在未来的章节中,我们会展示 BPF 汇编的简短示例,在这些章节中,使用汇编比 C 更合适,例如在内核中控制入站系统调用的 Seccomp 过滤器中。我们在第八章中更详细地讨论 Seccomp。
内核提供了bpf系统调用,用于在编译后将程序加载到 BPF 虚拟机中。此系统调用除了加载程序外,还用于其他操作,在后续章节中会有更多的使用示例。内核还提供了几种实用工具,用于为您抽象加载 BPF 程序的过程。在本书的第一个代码示例中,我们使用这些辅助工具向您展示了 BPF 的“Hello World”示例:
#include <linux/bpf.h>
#define SEC(NAME) __attribute__((section(NAME), used))
SEC("tracepoint/syscalls/sys_enter_execve")
int bpf_prog(void *ctx) {
char msg[] = "Hello, BPF World!";
bpf_trace_printk(msg, sizeof(msg));
return 0;
}
char _license[] SEC("license") = "GPL";
这个第一个程序中有一些有趣的概念。我们使用属性SEC来告知 BPF VM 何时运行这个程序。在这种情况下,当检测到execve系统调用的跟踪点时,我们将运行这个 BPF 程序。跟踪点是内核二进制代码中的静态标记,允许开发人员注入代码以检查内核的执行过程。我们在第四章中详细讨论了跟踪点,但现在你只需要知道execve是一个执行其他程序的指令。因此,每当内核检测到程序执行另一个程序时,我们将看到消息Hello, BPF World!。
在这个示例的结尾,我们还指定了这个程序的许可证。因为 Linux 内核使用 GPL 许可证,所以只能加载同样以 GPL 许可证发布的程序。如果我们将许可证设置为其他类型,内核将拒绝加载我们的程序。我们使用bpf_trace_printk在内核跟踪日志中打印消息;你可以在/sys/kernel/debug/tracing/trace_pipe中找到这个日志。
我们将使用clang将这个第一个程序编译成一个有效的 ELF 二进制文件。这是内核期望加载的格式。我们将把我们的第一个程序保存在一个叫做bpf_program.c的文件中以便编译它:
clang -O2 -target bpf -c bpf_program.c -o bpf_program.o
你会在书中代码示例的 GitHub 存储库中找到一些编译这些程序的脚本,因此你不需要记住这个clang命令。
现在我们已经编译出了我们的第一个 BPF 程序,接下来需要将其加载到内核中。正如我们提到的,我们使用内核提供的特殊助手来抽象编译和加载程序的样板代码。这个助手叫做load_bpf_file,它接受一个二进制文件并尝试将其加载到内核中。你可以在书中所有示例的 GitHub 存储库中找到这个助手,位于bpf_load.h文件中,如下所示:
#include <stdio.h>
#include <uapi/linux/bpf.h>
#include "bpf_load.h"
int main(int argc, char **argv) {
if (load_bpf_file("hello_world_kern.o") != 0) {
printf("The kernel didn't load the BPF program\n");
return -1;
}
read_trace_pipe();
return 0;
}
我们将使用一个脚本来编译这个程序,并将其链接为一个 ELF 二进制文件。在这种情况下,我们不需要指定一个目标,因为这个程序不会被加载到 BPF VM 中。我们需要使用一个外部库,并编写一个脚本可以很方便地将所有内容整合在一起:
TOOLS=../../../tools
INCLUDE=../../../libbpf/include
HEADERS=../../../libbpf/src
clang -o loader -l elf \
-I${INCLUDE} \
-I${HEADERS} \
-I${TOOLS} \
${TOOLS}/bpf_load.c \
loader.c
如果你想运行这个程序,你可以使用sudo来执行这个最终的二进制文件:sudo ./loader。sudo是一个 Linux 命令,它会让你在计算机上获得 root 权限。如果你不使用sudo来运行这个程序,你将会收到一个错误消息,因为大多数 BPF 程序只能由拥有 root 权限的用户加载到内核中。
当你运行这个程序时,即使你的电脑上什么也不做,几秒钟后你也会开始看到我们的Hello, BPF World!消息。这是因为在你的计算机后台运行的程序可能正在执行其他程序。
当你停止这个程序时,消息将不再显示在你的终端上。BPF 程序在加载它们的程序终止后立即从虚拟机中卸载。在接下来的章节中,我们将探讨如何使 BPF 程序持久化,即使它们的加载程序已经终止,但目前我们不打算引入太多的概念。这是一个重要的概念要记住,因为在许多情况下,你会希望你的 BPF 程序在后台运行,收集系统数据,而不管其他进程是否在运行。
现在你已经看到了 BPF 程序的基本结构,我们可以深入探讨你可以编写的各种类型的程序,这将使你能够访问 Linux 内核中的不同子系统。
BPF 程序类型
尽管在程序内没有明确的分类,你很快会意识到,本节中涵盖的所有类型都根据它们的主要目的分为两类。
第一个类别是跟踪。你可以编写的许多程序将帮助你更好地了解系统中正在发生的事情。它们直接提供有关系统行为和其运行的硬件的信息。它们可以访问与特定程序相关的内存区域,并从运行中的进程中提取执行跟踪。它们还直接提供访问分配给每个特定进程的资源,从文件描述符到 CPU 和内存使用情况。
第二类别是网络。这些类型的程序允许你检查和操作系统中的网络流量。它们允许你过滤从网络接口接收到的数据包,甚至完全拒绝这些数据包。不同类型的程序可以附加到内核中网络处理的不同阶段。这既有优势也有劣势。例如,你可以将 BPF 程序附加到网络事件,即在你的网络驱动程序接收到数据包时,但这个程序将只能访问关于数据包的较少信息,因为内核目前没有足够的信息提供给你。另一方面,你可以将 BPF 程序附加到网络事件,即将它们传递给用户空间之前的立即阶段。在这种情况下,你将获得关于数据包的更多信息,这将帮助你做出更为明智的决策,但你需要付出完全处理数据包的成本。
我们接下来展示的程序类型列表没有划分成类别;我们按照它们被添加到内核中的时间顺序介绍这些类型。我们将这一部分中最少使用的程序移至末尾,并且目前我们将重点放在对你更有用的程序上。如果你对我们在这里没有详细介绍的任何程序感兴趣,你可以在man 2 bpf中了解更多信息。
套接字过滤程序
BPF_PROG_TYPE_SOCKET_FILTER 是添加到 Linux 内核的第一个程序类型。当您将 BPF 程序附加到原始套接字时,您可以访问该套接字处理的所有数据包。套接字过滤程序不允许您修改这些数据包的内容或更改这些数据包的目的地;它们仅允许您观察这些数据包。您的程序接收到的元数据包含与网络堆栈相关的信息,例如用于传递数据包的协议类型。
我们将在第六章中更详细地介绍套接字过滤和其他网络程序。
Kprobe 程序
正如您将在第四章中看到的那样,在我们讨论跟踪时,kprobe 是您可以动态附加到内核中某些调用点的函数。BPF kprobe 程序类型允许您将 BPF 程序用作 kprobe 处理程序。它们使用类型 BPF_PROG_TYPE_KPROBE 定义。BPF VM 确保您的 kprobe 程序始终安全运行,这是传统 kprobe 模块的优势。您仍然需要记住,kprobe 不被视为内核的稳定入口点,因此您需要确保您的 kprobe BPF 程序与您使用的特定内核版本兼容。
当您编写附加到 kprobe 的 BPF 程序时,您需要决定它是在函数调用的第一条指令执行还是在调用完成时执行。您需要在 BPF 程序的节头中声明此行为。例如,如果您希望在内核调用 exec 系统调用时检查参数,则需要将程序附加到调用的开始位置。在这种情况下,您需要设置节头 SEC("kprobe/sys_exec")。如果您希望检查调用 exec 系统调用的返回值,则需要设置节头 SEC("kretprobe/sys_exec")。
关于 kprobe,我们在本书的后续章节中会详细讨论。它们是理解使用 BPF 进行跟踪的基本组成部分。
跟踪点程序
这种类型的程序允许您将 BPF 程序附加到内核提供的跟踪点处理程序上。跟踪点程序使用类型 BPF_PROG_TYPE_TRACEPOINT 定义。正如您将在第四章中看到的那样,跟踪点是内核代码库中的静态标记,允许您为跟踪和调试目的注入任意代码。它们比 kprobe 不太灵活,因为它们需要在内核中预先定义,但是在其引入内核后保证稳定。这在您想要调试系统时提供了更高的可预测性水平。
系统中的所有跟踪点都在目录 /sys/kernel/debug/tracing/events 中定义。在那里,您会找到包含任何跟踪点的每个子系统,您可以将 BPF 程序附加到这些跟踪点上。一个有趣的事实是,BPF 声明了自己的跟踪点,因此您可以编写检查其他 BPF 程序行为的 BPF 程序。BPF 跟踪点在 /sys/kernel/debug/tracing/events/bpf 中定义。例如,您可以在那里找到 bpf_prog_load 的跟踪点定义。这意味着您可以编写一个 BPF 程序,检查其他 BPF 程序何时被加载。
与 kprobes 类似,跟踪点是理解使用 BPF 进行跟踪的另一个基本组成部分。我们将在接下来的章节中更详细地讨论它们,并向您展示如何编写程序以利用它们。
XDP 程序
XDP 程序允许您编写在网络数据包到达内核时非常早期执行的代码。它们使用类型 BPF_PROG_TYPE_XDP 进行定义。它仅公开来自数据包的有限信息,因为内核尚未处理这些信息。由于数据包在早期被执行,您对如何处理该数据包具有更高的控制水平。
XDP 程序定义了几种您可以控制的操作,这些操作允许您决定如何处理数据包。您可以从您的 XDP 程序返回 XDP_PASS,这意味着数据包应该传递给内核中的下一个子系统。您也可以返回 XDP_DROP,这意味着内核应完全忽略这个数据包,不做任何其他操作。您还可以返回 XDP_TX,这意味着数据包应该转发回首次接收该数据包的网络接口卡(NIC)。
这种控制水平为网络层中许多有趣的程序打开了大门。XDP 已成为 BPF 的主要组成部分之一,这也是为什么我们在本书中专门讨论它的一个章节。在第七章中,我们讨论了 XDP 的许多强大用例,如实施程序以保护您的网络免受分布式拒绝服务(DDoS)攻击。
Perf 事件程序
这些类型的 BPF 程序允许您将您的 BPF 代码附加到 Perf 事件 上。它们使用类型 BPF_PROG_TYPE_PERF_EVENT 进行定义。Perf 是内核中的内部性能分析器,用于发出硬件和软件的性能数据事件。您可以使用它监视许多内容,从计算机的 CPU 到系统上运行的任何软件。当您将一个 BPF 程序附加到 Perf 事件时,每次 Perf 生成数据以供分析时,您的代码将被执行。
Cgroup Socket 程序
此类程序允许您将 BPF 逻辑附加到控制组(cgroups)上。它们使用类型BPF_PROG_TYPE_CGROUP_SKB进行定义。它们允许 cgroups 在其包含的进程中控制网络流量。通过这些程序,您可以在传递到 cgroup 中的进程之前决定对网络数据包采取何种操作。内核试图将任何数据包传递到同一 cgroup 中的任何进程时,都将通过这些过滤器。同时,您还可以决定当 cgroup 中的进程通过该接口发送网络数据包时采取何种操作。
正如您所见,它们的行为类似于BPF_PROG_TYPE_SOCKET_FILTER程序。主要区别在于BPF_PROG_TYPE_CGROUP_SKB程序附加到 cgroup 中的所有进程,而不是特定进程;这种行为适用于给定 cgroup 中创建的当前和未来套接字。附加到 cgroups 的 BPF 程序在容器环境中特别有用,其中进程组受 cgroups 约束,并且您可以在所有进程上应用相同的策略,而无需单独识别每个进程。Cillium是一个流行的开源项目,为 Kubernetes 提供负载均衡和安全功能,广泛使用 cgroup 套接字程序来应用其策略,而不是在孤立的容器中。
Cgroup Open Socket Programs
此类程序允许您在 cgroup 中的任何进程打开网络套接字时执行代码。这类行为与附加到 cgroup 套接字缓冲区的程序类似,但不是在网络数据包通过时提供访问权限,而是在进程打开新套接字时控制操作。它们使用类型BPF_PROG_TYPE_CGROUP_SOCK进行定义。这对于在不需要单独限制每个进程能力的情况下,提供安全性和访问控制非常有用。
Socket Option Programs
此类程序允许您在运行时修改套接字连接选项,而数据包通过内核网络堆栈的多个阶段。它们与 cgroups 附加,类似于BPF_PROG_TYPE_CGROUP_SOCK和BPF_PROG_TYPE_CGROUP_SKB,但与这些程序类型不同的是,在连接生命周期中可以多次调用它们。这些程序使用类型BPF_PROG_TYPE_SOCK_OPS进行定义。
当您使用此类型创建 BPF 程序时,您的函数调用将接收一个名为op的参数,该参数表示内核即将执行的与套接字连接相关的操作;因此,您可以知道程序在连接生命周期中的哪个点被调用。有了这些信息,您可以访问诸如网络 IP 地址和连接端口之类的数据,并可以修改连接选项以设置超时并改变给定数据包的往返延迟时间。
例如,Facebook 使用这种方式为同一数据中心内的连接设置短恢复时间目标(RTO)。RTO 是系统在失败后预计恢复的时间,或者在这种情况下,是网络连接。该目标还代表系统在遭受不可接受后果之前可以不可用的时间。在 Facebook 的情况下,它假设同一数据中心中的机器应具有较短的 RTO,并通过使用 BPF 程序修改此阈值。
套接字映射程序
BPF_PROG_TYPE_SK_SKB程序让你可以访问套接字映射和套接字重定向。正如你将在下一章中学到的那样,套接字映射允许你保持对多个套接字的引用。当你拥有这些引用时,你可以使用特殊的帮助程序将从一个套接字收到的数据包重定向到另一个套接字。当你希望使用 BPF 实现负载均衡能力时,这非常有趣。通过跟踪多个套接字,你可以在内核空间中转发网络数据包。像 Cillium 和 Facebook's Katran 这样的项目大量使用这些类型的程序来进行网络流量控制。
cgroup 设备程序
这种类型的程序允许你决定 cgroup 内的操作是否可以针对特定设备执行。这些程序的类型定义为BPF_PROG_TYPE_CGROUP_DEVICE。cgroups 的第一个实现(v1)具有一种机制,允许你为特定设备设置权限;然而,cgroups 的第二次迭代缺乏此功能。引入这种类型的程序是为了提供该功能。同时,编写 BPF 程序使你在需要时能够更灵活地设置这些权限。
套接字消息传递程序
这些类型的程序让你控制是否应将发送到套接字的消息传递。它们的类型定义为BPF_PROG_TYPE_SK_MSG。当内核创建套接字时,它将套接字存储在上述套接字映射中。该映射使内核能够快速访问特定组的套接字。当你将套接字消息 BPF 程序附加到套接字映射时,在将消息传递给套接字之前,所有发送到这些套接字的消息都将通过程序进行过滤。在过滤消息之前,内核会复制消息中的数据,以便你可以读取并决定如何处理它。这些程序有两种可能的返回值:SK_PASS和SK_DROP。如果你希望内核将消息发送到套接字,则使用第一个值;如果你希望内核忽略消息并且不将其传递到套接字,则使用后者。
原始跟踪点程序
我们之前谈到过一种程序类型,该程序访问内核中的跟踪点。内核开发人员添加了一个新的跟踪点程序,以满足访问内核保存的跟踪点参数的需求。此格式使您可以访问内核正在执行的任务的更详细信息;但是,它会带来小的性能开销。大多数情况下,您会希望在程序中使用常规的跟踪点以避免性能开销,但是需要时可以使用原始跟踪点访问原始参数是一个好主意。这些程序使用类型 BPF_PROG_TYPE_RAW_TRACEPOINT 进行定义。
Cgroup Socket Address 程序
这种类型的程序允许您在特定 cgroup 控制的情况下操作用户空间程序附加到的 IP 地址和端口号。当您的系统使用多个 IP 地址时,希望确保特定的用户空间程序使用相同的 IP 地址和端口时,存在使用案例。这些 BPF 程序使您能够在将这些用户空间程序放入同一 cgroup 时操作这些绑定。这确保了所有进入和离开这些应用程序的连接使用 BPF 程序提供的 IP 和端口。这些程序使用以下类型进行定义:BPF_PROG_TYPE_CGROUP_SOCK_ADDR。
Socket Reuseport 程序
SO_REUSEPORT 是内核中的一个选项,允许同一主机上的多个进程绑定到同一个端口。当您希望在多个线程之间分布负载时,此选项可以提高接受网络连接的性能。
BPF_PROG_TYPE_SK_REUSEPORT 程序类型允许您编写 BPF 程序,以钩入内核用于决定是否重新使用端口的逻辑。如果您的 BPF 程序返回 SK_DROP,则可以防止程序重新使用相同的端口;如果返回 SK_PASS,则可以通知内核继续按照其自身的重用例程操作。
流解剖程序
流解剖器是内核的一个组件,跟踪网络数据包需要通过的不同层次,从其抵达系统到交付给用户空间程序的过程。它允许您使用不同的分类方法控制数据包的流向。内核中的内置解剖器称为Flower 分类器,被防火墙和其他过滤设备用于决定如何处理特定的数据包。
BPF_PROG_TYPE_FLOW_DISSECTOR 程序旨在钩入流解剖路径中的逻辑。它们提供了内置解剖器无法提供的安全保证,例如始终保证程序终止,这在内置解剖器中可能无法保证。这些 BPF 程序可以修改网络数据包在内核内部遵循的流程。
其他 BPF 程序
我们已经讨论了在不同环境中使用的程序类型,但值得注意的是,还有一些其他额外的 BPF 程序类型我们尚未涵盖。以下是我们在这里仅简要提及的几种程序:
流量分类器程序
BPF_PROG_TYPE_SCHED_CLS和BPF_PROG_TYPE_SCHED_ACT是两种 BPF 程序类型,允许您对网络流量进行分类并修改套接字缓冲区中数据包的某些属性。
轻量级隧道程序
BPF_PROG_TYPE_LWT_IN、BPF_PROG_TYPE_LWT_OUT、BPF_PROG_TYPE_LWT_XMIT和BPF_PROG_TYPE_LWT_SEG6LOCAL是允许您将代码附加到内核轻量级隧道基础设施的 BPF 程序类型。
红外设备程序
BPF_PROG_TYPE_LIRC_MODE2程序允许您通过连接到红外设备(例如遥控器)来附加 BPF 程序,以增加乐趣。
这些程序是专业化的,它们的使用尚未被社区广泛采纳。
接下来,我们将讨论 BPF 如何确保在内核加载后您的程序不会导致系统灾难性故障。这是一个重要的主题,因为理解程序加载的方式也影响到如何编写这些程序。
BPF 验证程序
如果没有 BPF 验证程序,任何人都可以在 Linux 内核中执行任意代码,这听起来一开始就像一个糟糕的主意。如果不是因为 BPF 验证程序,运行 BPF 程序在生产系统中的风险将会太高。用内核网络维护者之一 Dave S. Miller 的话来说,“我们的 eBPF 程序与毁灭的深渊之间仅有的东西就是 eBPF 验证程序。”
很显然,BPF 验证程序也是在您的系统上运行的程序,并且它是受到高度审查的对象,以确保其正确执行其职能。在过去的几年中,安全研究人员已经发现了验证程序中的一些漏洞,这些漏洞允许攻击者在内核中访问随机内存,即使是作为非特权用户也可以。您可以在美国国土安全部赞助的公共漏洞和暴露目录(CVE)中了解更多类似的漏洞信息,这是已知安全威胁的列表。例如,CVE-2017-16995 描述了任何用户如何读取和写入内核内存以及绕过 BPF 验证器的详细信息。
在本节中,我们将指导您了解验证程序采取的措施,以防止类似刚刚描述的问题。
审核程序执行的第一个检查是对 VM 即将加载的代码的静态分析。这个首次检查的目标是确保程序有一个预期的结束。为了做到这一点,审核程序创建一个直接无环图(DAG)来表示代码。审核程序分析的每个指令都成为图中的一个节点,并且每个节点链接到下一个指令。审核程序生成此图后,执行深度优先搜索(DFS)以确保程序能够完成并且代码不包含危险路径。这意味着它将遍历图的每个分支,一直到分支的底部,以确保没有递归循环。
这些是审核程序在首次检查期间可能拒绝你的代码的条件:
-
程序不包含控制循环。为了确保程序不会陷入无限循环,审核程序拒绝任何类型的控制循环。已经有提案允许 BPF 程序中的循环,但截至目前尚未采纳。
-
程序不能尝试执行超过内核允许的最大指令数。目前,允许执行的最大指令数为 4,096。这个限制是为了防止 BPF 程序无限运行。在第三章中,我们讨论了如何嵌套不同的 BPF 程序以安全方式绕过这个限制。
-
程序不包含任何无法访问的指令,例如永远不会执行的条件或函数。这可以防止在虚拟机中加载死代码,这也会延迟 BPF 程序的终止。
-
程序不能尝试跳出其界限。
审核程序执行的第二个检查是对 BPF 程序的干行检查。这意味着审核程序将尝试分析程序将要执行的每个指令,以确保它不执行任何无效的指令。此外,此执行还检查所有内存指针是否被正确访问和解引用。最后,干行检查还通知审核程序有关程序控制流的信息,以确保无论程序采用哪种控制路径,它最终都会到达BPF_EXIT指令。为了做到这一点,审核程序在一个栈中跟踪所有访问的分支路径,在采用新路径之前评估这些路径,以确保不会多次访问特定路径。这两个检查通过后,审核程序认为程序可以安全执行。
bpf系统调用允许你调试审核程序的检查,如果你有兴趣查看程序的分析过程。使用这个系统调用加载程序时,你可以设置多个属性,这些属性将使审核程序打印其操作日志:
union bpf_attr attr = {
.prog_type = type,
.insns = ptr_to_u64(insns),
.insn_cnt = insn_cnt,
.license = ptr_to_u64(license),
.log_buf = ptr_to_u64(bpf_log_buf),
.log_size = LOG_BUF_SIZE,
.log_level = 1,
};
bpf(BPF_PROG_LOAD, &attr, sizeof(attr));
log_level 字段告诉验证器是否打印任何日志。当您将其设置为 1 时,它将打印其日志;如果设置为 0,则不打印任何内容。如果您想要打印验证器日志,则还需要提供日志缓冲区及其大小。此缓冲区是一个多行字符串,您可以打印以检查验证器所做的决策。
BPF 验证器在您在内核中运行任意程序时发挥着重要作用,以确保系统安全和可用性,尽管有时候它的某些决策可能难以理解。如果在尝试加载您的程序时遇到验证问题,请不要绝望。本书的其余部分将通过安全示例指导您,这将帮助您了解如何以安全的方式编写自己的程序。
下一节将介绍 BPF 如何在内存中结构化程序信息。程序结构化的方式将有助于清楚地访问 BPF 内部,帮助您调试和了解程序的行为方式。
BPF 类型格式
BPF 类型格式(BTF)是一组元数据结构,用于增强 BPF 程序、映射和函数的调试信息。BTF 包括源信息,因此像我们在第五章中讨论的 BPFTool 这样的工具可以为您展示更丰富的 BPF 数据解释。这些元数据存储在二进制程序的特殊“.BFT”元数据部分下。BTF 信息有助于使您的程序更易于调试,但会显著增加二进制文件的大小,因为它需要跟踪程序中声明的所有类型信息。BPF 验证器还使用此信息来确保程序定义的结构类型是正确的。
BTF 专门用于注释 C 类型。像 LLVM 这样的 BPF 编译器知道如何为您包含这些信息,因此您不需要通过耗时的任务向每个结构添加信息。然而,在某些情况下,工具链仍然需要一些注释来增强您的程序。在后续章节中,我们将描述这些注释的作用以及像 BPFTool 这样的工具如何显示这些信息。
BPF 尾调用
BPF 程序可以使用尾调用来调用其他 BPF 程序。这是一个强大的特性,因为它允许您通过组合较小的 BPF 函数来组装更复杂的程序。在 5.2 版本之前的内核版本中,BPF 程序可以生成的机器指令数有一个硬限制。为了确保程序能够在合理的时间内终止,这个限制被设置为 4,096 条。然而,随着人们构建更复杂的 BPF 程序,他们需要一种扩展内核所施加的指令限制的方法,这就是尾调用发挥作用的地方。从内核 5.2 版本开始,指令限制增加到一百万条指令。在这种情况下,尾调用嵌套也受到限制,最多可以组合 32 个程序形成一个链条,以生成更复杂的问题解决方案。
当您从另一个 BPF 程序调用 BPF 程序时,内核会完全重置程序上下文。记住这一点很重要,因为您可能需要一种在程序之间共享信息的方式。每个 BPF 程序作为其参数接收的上下文对象将无法帮助我们解决这个数据共享问题。在接下来的章节中,我们将讨论 BPF 映射作为在程序之间共享信息的一种方式。在那里,我们还会向您展示如何使用尾调用从一个 BPF 程序跳转到另一个程序。
结论
在本章中,我们引导您通过第一个代码示例来理解 BPF 程序。我们还描述了您可以使用 BPF 编写的所有类型的程序。如果这里介绍的一些概念还不清楚,不要担心;随着我们在本书中的进展,我们会向您展示更多这些程序的示例。我们还介绍了 BPF 所采取的重要验证步骤,以确保您的程序可以安全运行。
在接下来的章节中,我们将更深入地探讨这些程序并展示更多示例。我们还将讨论 BPF 程序如何与用户空间中的对应程序进行通信,以及它们如何共享信息。
第三章:BPF 地图
通过消息传递来调用程序中的行为是软件工程中广泛使用的技术。程序可以通过发送消息来修改另一个程序的行为;这也允许这些程序之间交换信息。关于 BPF 最迷人的一个方面是,运行在内核上的代码和加载了该代码的程序可以在运行时使用消息传递来彼此通信。
在本章中,我们介绍了 BPF 程序和用户空间程序如何进行交互。我们描述了内核与用户空间之间的不同通信渠道,以及它们如何存储信息。我们还展示了这些通道的用例以及如何使这些通道中的数据在程序初始化之间保持持久性。
BPF 地图是驻留在内核中的键/值存储。任何了解它们的 BPF 程序都可以访问这些地图。运行在用户空间的程序也可以使用文件描述符访问这些地图。您可以在地图中存储任何类型的数据,只要您事先正确指定数据大小。内核将键和值视为二进制数据块,并不关心您在地图中保存了什么。
BPF 验证器包含多个保障措施,以确保您创建和访问地图的方式是安全的。当我们解释如何访问这些地图中的数据时,我们将讨论这些保障措施。
创建 BPF 地图
创建 BPF 地图的最直接方法是使用 bpf 系统调用。当调用中的第一个参数是 BPF_MAP_CREATE 时,您告诉内核您要创建一个新的地图。此调用将返回与您刚刚创建的地图关联的文件描述符标识符。系统调用中的第二个参数是此地图的配置:
union bpf_attr {
struct {
__u32 map_type; /* one of the values from bpf_map_type */
__u32 key_size; /* size of the keys, in bytes */
__u32 value_size; /* size of the values, in bytes */
__u32 max_entries; /* maximum number of entries in the map */
__u32 map_flags; /* flags to modify how we create the map */
};
}
系统调用中的第三个参数是此配置属性的大小。
例如,您可以创建一个哈希表地图,以以下方式存储无符号整数作为键和值:
union bpf_attr my_map {
.map_type = BPF_MAP_TYPE_HASH,
.key_size = sizeof(int),
.value_size = sizeof(int),
.max_entries = 100,
.map_flags = BPF_F_NO_PREALLOC,
};
int fd = bpf(BPF_MAP_CREATE, &my_map, sizeof(my_map));
如果调用失败,内核将返回值 -1。它失败的原因可能有三种。如果其中一个属性无效,内核将将 errno 变量设置为 EINVAL。如果执行操作的用户权限不足,内核将将 errno 变量设置为 EPERM。最后,如果没有足够的内存来存储地图,内核将将 errno 变量设置为 ENOMEM。
在接下来的几节中,我们将引导您通过不同的示例,展示如何使用 BPF 地图执行更高级的操作;让我们从创建任何类型的地图的更直接方式开始。
ELF 约定创建 BPF 地图
内核包含了几个约定和帮助函数,用于生成和使用 BPF 地图。你可能会发现这些约定比直接的系统调用执行更常见,因为它们更易读、更易于跟随。请记住,即使在内核中直接运行时,这些约定仍然使用bpf系统调用来创建地图,如果你事先不知道需要哪种地图,直接使用系统调用会更有用。
辅助函数bpf_map_create包装了你刚才看到的代码,使得按需初始化地图变得更容易。我们可以用一行代码创建之前的地图:
int fd;
fd = bpf_create_map(BPF_MAP_TYPE_HASH, sizeof(int), sizeof(int), 100,
BPF_F_NO_PREALOC);
如果你知道程序中需要哪种地图,你也可以预定义它。这对于提前了解程序使用的地图更有帮助:
struct bpf_map_def SEC("maps") my_map = {
.type = BPF_MAP_TYPE_HASH,
.key_size = sizeof(int),
.value_size = sizeof(int),
.max_entries = 100,
.map_flags = BPF_F_NO_PREALLOC,
};
当你以这种方式定义地图时,你正在使用所谓的节属性,在这种情况下是SEC("maps")。这个宏告诉内核这个结构是一个 BPF 地图,并且应该相应地创建它。
你可能注意到,在这个新例子中我们没有与地图关联的文件描述符标识符。在这种情况下,内核使用一个叫做map_data的全局变量来存储程序中地图的信息。这个变量是一个结构体数组,按照你在代码中指定的每个地图的顺序排列。例如,如果前一个地图是你代码中指定的第一个地图,你可以从数组的第一个元素中获取文件描述符标识符:
fd = map_data[0].fd;
你也可以通过这个结构访问地图的名称及其定义;这些信息有时对调试和跟踪非常有用。
初始化地图后,你可以开始在内核和用户空间之间发送消息。现在让我们看看如何处理这些地图存储的数据。
与 BPF 地图的工作
内核和用户空间之间的通信将是你编写的每个 BPF 程序的一个基本组成部分。访问地图的 API 在编写内核代码和编写用户空间程序代码时有所不同。本节介绍了每种实现的语义和具体细节。
更新 BPF 地图中的元素
在创建任何映射之后,您可能希望用信息填充它。内核助手为此目的提供了函数bpf_map_update_elem。如果您从内核中运行的程序加载它,该函数的签名与如果您从用户空间中运行的程序加载它时稍有不同,因为当您在内核中工作时,您可以直接访问映射,但是当您在用户空间工作时,您将使用文件描述符引用它们。其行为也略有不同。在内核中运行的代码可以直接访问内存中的映射,并且可以原子地就地更新元素。然而,在用户空间运行的代码必须向内核发送消息,然后再更新映射之前复制提供的值,这使得更新操作不是原子的。当操作成功时,该函数返回0,当操作失败时返回负数。在失败的情况下,全局变量errno将填充失败的原因。我们稍后在本章中列出更多与上下文相关的失败案例。
内核中的bpf_map_update_elem函数接受四个参数。第一个是我们已经定义的映射的指针。第二个是指向我们想要更新的键的指针。因为内核不知道我们正在更新的键的类型,所以此方法被定义为对void的不透明指针,这意味着我们可以传递任何数据。第三个参数是我们想要插入的值。该参数使用与键参数相同的语义。我们在本书中展示了如何利用不透明指针的一些高级示例。您可以使用此函数的第四个参数来更改更新映射的方式。此参数可以取三个值:
-
如果你传递
0,你告诉内核你希望更新元素(如果存在),或者如果元素不存在则创建该元素。 -
如果你传递
1,你告诉内核仅在元素不存在时创建该元素。 -
如果你传递
2,内核将仅在元素存在时更新该元素。
这些值被定义为常量,你也可以使用它们,而不必记住整数语义。这些值分别是BPF_ANY代表0,BPF_NOEXIST代表1,以及BPF_EXIST代表2。
让我们使用在前一节中定义的映射来写一些示例。在我们的第一个示例中,我们向映射中添加一个新值。因为映射是空的,我们可以假设任何更新行为对我们都是有利的。
int key, value, result;
key = 1, value = 1234;
result = bpf_map_update_elem(&my_map, &key, &value, BPF_ANY);
if (result == 0)
printf("Map updated with new element\n");
else
printf("Failed to update map with new value: %d (%s)\n",
result, strerror(errno));
在这个例子中,我们使用strerror来描述errno变量中设置的错误。你可以在手册页面上使用man strerror了解更多关于这个函数的信息。
现在让我们看看当我们尝试使用相同的键创建一个元素时我们会得到什么结果。
int key, value, result;
key = 1, value = 5678;
result = bpf_map_update_elem(&my_map, &key, &value, BPF_NOEXIST);
if (result == 0)
printf("Map updated with new element\n");
else
printf("Failed to update map with new value: %d (%s)\n",
result, strerror(errno));
因为我们在映射中已经创建了键为1的元素,调用bpf_map_update_elem的结果将是-1,而errno值将是EEXIST。这个程序会在屏幕上打印以下内容:
Failed to update map with new value: -1 (File exists)
类似地,让我们修改这个程序,尝试更新一个尚不存在的元素:
int key, value, result;
key = 1234, value = 5678;
result = bpf_map_update_elem(&my_map, &key, &value, BPF_EXIST);
if (result == 0)
printf("Map updated with new element\n");
else
printf("Failed to update map with new value: %d (%s)\n",
result, strerror(errno));
使用BPF_EXIST标志,这个操作的结果将再次是-1。内核将会将errno变量设置为ENOENT,程序会打印以下内容:
Failed to update map with new value: -1 (No such file or directory)
这些示例展示了如何从内核程序内更新映射。您也可以从用户空间程序内更新映射。执行此操作的助手函数与我们刚刚看到的类似;唯一的区别在于它们使用文件描述符来访问映射,而不是直接使用指向映射的指针。正如您记得的那样,用户空间程序总是使用文件描述符访问映射。因此,在我们的示例中,我们将参数my_map替换为全局文件描述符标识符map_data[0].fd。在这种情况下,原始代码看起来是这样的:
int key, value, result;
key = 1, value = 1234;
result = bpf_map_update_elem(map_data[0].fd, &key, &value, BPF_ANY);
if (result == 0)
printf("Map updated with new element\n");
else
printf("Failed to update map with new value: %d (%s)\n",
result, strerror(errno));
尽管您可以在映射中存储的信息类型与您正在使用的映射类型直接相关,但用于填充信息的方法将保持不变,就像您在前面的示例中看到的那样。我们稍后将讨论每种映射类型接受的键和值类型;首先让我们看看如何操作存储数据。
从 BPF 映射中读取元素
现在我们已经使用新元素填充了我们的映射,我们可以从我们代码的其他点开始读取它们。在学习了bpf_map_update_element之后,阅读 API 会变得很熟悉。
BPF 还提供了两种不同的助手函数来从映射中读取,具体取决于您的代码运行在哪里。这两个助手函数都称为bpf_map_lookup_elem。与更新助手函数类似,它们在第一个参数上有所不同;内核方法接受映射的引用,而用户空间助手函数则以映射的文件描述符标识符作为其第一个参数。这两种方法都返回一个整数,表示操作成功或失败,就像更新助手函数一样。这些助手函数的第三个参数是指向您代码中将要存储从映射中读取的值的变量的指针。我们基于您在前一节中看到的代码提供了两个示例。
第一个例子读取了在 BPF 程序在内核上运行时插入映射中的值:
int key, value, result; // value is going to store the expected element's value
key = 1;
result = bpf_map_lookup_elem(&my_map, &key, &value);
if (result == 0)
printf("Value read from the map: '%d'\n", value);
else
printf("Failed to read value from the map: %d (%s)\n",
result, strerror(errno));
如果我们试图读取的键,bpf_map_lookup_elem,返回了一个负数,它将会在errno变量中设置错误。例如,如果我们在尝试读取之前没有插入该值,内核将会返回“未找到”错误ENOENT。
这个第二个例子与你刚刚看到的例子类似,但这次我们是从在用户空间运行的程序中读取映射:
int key, value, result; // value is going to store the expected element's value
key = 1;
result = bpf_map_lookup_elem(map_data[0].fd, &key, &value);
if (result == 0)
printf("Value read from the map: '%d'\n", value);
else
printf("Failed to read value from the map: %d (%s)\n",
result, strerror(errno));
如你所见,我们已将bpf_map_lookup_elem中的第一个参数替换为映射的文件描述符标识符。助手的行为与前面的示例相同。
这就是我们需要访问 BPF 映射中信息的全部内容。我们将在后面的章节中详细讨论不同工具包如何简化数据访问,使其更加简单。
从 BPF 映射中移除元素
我们可以在映射上执行的第三个操作是删除元素。与写入和读取元素一样,BPF 为我们提供了两个不同的帮助程序来删除元素,均称为bpf_map_delete_element。与之前的示例一样,当您在运行于内核的程序中使用它们时,这些助手使用映射的直接引用,而在运行于用户空间的程序中使用它们时,则使用映射的文件描述符标识符。
第一个示例在内核运行 BPF 程序时删除了映射中插入的值:
int key, result;
key = 1;
result = bpf_map_delete_element(&my_map, &key);
if (result == 0)
printf("Element deleted from the map\n");
else
printf("Failed to delete element from the map: %d (%s)\n",
result, strerror(errno));
如果您试图删除的元素不存在,内核将返回一个负数。在这种情况下,它还会将errno变量填充为“未找到”错误ENOENT。
这个第二个示例在用户空间运行 BPF 程序时删除了该值:
int key, result;
key = 1;
result = bpf_map_delete_element(map_data[0].fd, &key);
if (result == 0)
printf("Element deleted from the map\n");
else
printf("Failed to delete element from the map: %d (%s)\n",
result, strerror(errno));
您可以看到,我们再次更改了第一个参数,使用了文件描述符标识符。其行为将与内核的助手保持一致。
这结束了可以称为 BPF 映射的创建/读取/更新/删除(CRUD)操作的部分。内核公开了一些额外的函数来帮助您进行其他常见操作;我们将在接下来的两个部分中讨论其中的一些。
在 BPF 映射中遍历元素
在本节中我们查看的最后一个操作可以帮助您在 BPF 程序中找到任意元素。有时您可能不知道要查找的元素的确切键,或者只是想查看映射中的内容。BPF 为此提供了一个称为bpf_map_get_next_key的指令。与您目前看到的帮助程序不同,此指令仅适用于运行在用户空间的程序。
这个助手为您提供了一种确定性的方法来迭代映射中的元素,但其行为比大多数编程语言中的迭代器更不直观。它接受三个参数。第一个是映射的文件描述符标识符,就像您已经看到的其他用户空间助手一样。接下来的两个参数是它变得棘手的地方。根据官方文档,第二个参数 key 是您要查找的标识符,第三个参数 next_key 是映射中的下一个键。我们更喜欢将第一个参数称为 lookup_key —— 几秒钟之后,你就会明白为什么。当您调用此助手时,BPF 会尝试查找具有您传递的查找键的映射中的元素;然后,它将邻接的键设置为 next_key 参数的值。因此,如果您想知道在键 1 之后出现哪个键,您需要将 1 设置为您的查找键;如果映射有一个与此键相邻的键,BPF 将其设置为 next_key 参数的值。
在看例子中 bpf_map_get_next_key 如何工作之前,让我们向我们的映射中添加几个元素:
int new_key, new_value, it;
for (it = 2; it < 6 ; it++) {
new_key = it;
new_value = 1234 + it;
bpf_map_update_elem(map_data[0].fd, &new_key, &new_value, BPF_NOEXIST);
}
如果您想打印映射中的所有值,可以使用一个在映射中不存在的查找键调用 bpf_map_get_next_key。这会强制 BPF 从映射的开头开始:
int next_key, lookup_key;
lookup_key = -1;
while(bpf_map_get_next_key(map_data[0].fd, &lookup_key, &next_key) == 0) {
printf("The next key in the map is: '%d'\n", next_key);
lookup_key = next_key;
}
这段代码打印出像这样的东西:
The next key in the map is: '1'
The next key in the map is: '2'
The next key in the map is: '3'
The next key in the map is: '4'
The next key in the map is: '5'
您可以看到,我们将下一个键分配给 lookup_key,这样我们就可以继续迭代映射,直到达到结尾。当 bpf_map_get_next_key 到达映射的末尾时,返回的值是一个负数,并且设置了 errno 变量为 ENOENT。这将中止循环的执行。
正如您可以想象的那样,bpf_map_get_next_key 可以查找从映射的任意点开始的键;如果您只想获取另一个特定键的下一个键,您不需要从映射的开头开始。
bpf_map_get_next_key 可以对您施加的技巧并不止于此;还有另一种行为您需要注意。许多编程语言在迭代元素之前复制映射中的值。如果您的程序中的其他代码决定变更映射,这可以防止未知行为。如果该代码从映射中删除元素,这尤其危险。在使用 bpf_map_get_next_key 遍历值时,BPF 不会复制映射中的值。如果程序的其他部分在您循环遍历值时从映射中删除元素,则 bpf_map_get_next_key 在尝试查找已删除元素的键的下一个值时将重新开始。让我们通过一个例子来看看这个情况:
int next_key, lookup_key;
lookup_key = -1;
while(bpf_map_get_next_key(map_data[0].fd, &lookup_key, &next_key) == 0) {
printf("The next key in the map is: '%d'\n", next_key);
if (next_key == 2) {
printf("Deleting key '2'\n");
bpf_map_delete_element(map_data[0].fd &next_key);
}
lookup_key = next_key;
}
此程序打印出下一个输出:
The next key in the map is: '1'
The next key in the map is: '2'
Deleteing key '2'
The next key in the map is: '1'
The next key in the map is: '3'
The next key in the map is: '4'
The next key in the map is: '5'
当您使用 bpf_map_get_next_key 时,请记住这种行为并不是很直观。
因为我们在本章中涵盖的大多数映射类型表现得像数组一样,当您想访问它们存储的信息时,迭代它们将是一个关键操作。但是,在你下面将看到的时候,还有其他访问数据的函数。
查找并删除元素
内核提供的另一个有趣函数用于处理映射的是bpf_map_lookup_and_delete_elem。此函数在映射中查找给定键并删除该元素。同时,它将元素的值写入变量以供程序使用。当您使用队列和堆栈映射时,这个函数非常方便,我们将在下一节中描述这些映射。但是,并不限于仅在这些类型的映射中使用。让我们看一个如何在我们先前示例中使用该函数的例子:
int key, value, result, it;
key = 1;
for (it = 0; it < 2; it++) {
result = bpf_map_lookup_and_delete_element(map_data[0].fd, &key, &value);
if (result == 0)
printf("Value read from the map: '%d'\n", value);
else
printf("Failed to read value from the map: %d (%s)\n",
result, strerror(errno));
}
在这个例子中,我们尝试两次从映射中获取相同的元素。在第一次迭代中,此代码将打印映射中元素的值。然而,由于我们使用了bpf_map_lookup_and_delete_element,这第一次迭代还将从映射中删除该元素。当循环第二次尝试获取元素时,此代码将失败,并将“未找到”错误ENOENT填入errno变量中。
直到现在,我们并没有过多关注当并发操作尝试访问 BPF 映射中的同一信息时会发生什么。接下来我们来谈谈这个问题。
并发访问映射元素
处理 BPF 映射的一个挑战是许多程序可以并发访问同一映射。这可能会在我们的 BPF 程序中引入竞争条件,并使映射中资源的访问变得不可预测。为了防止竞争条件,BPF 引入了 BPF 自旋锁的概念,允许您在操作映射元素时锁定对其的访问。自旋锁仅适用于数组、哈希和 cgroup 存储映射。
有两个 BPF 辅助函数用于处理自旋锁:bpf_spin_lock锁定一个元素,bpf_spin_unlock解锁该元素。这些辅助函数与一个结构体一起工作,该结构体充当访问该元素的信号量。当信号量被锁定时,其他程序无法访问元素的值,并且它们会等待信号量被解锁。同时,BPF 自旋锁引入了一个新标志,用户空间程序可以用来改变该锁的状态;该标志称为BPF_F_LOCK。
处理自旋锁的第一步是创建我们要锁定访问的元素,然后添加我们的信号量:
struct concurrent_element {
struct bpf_spin_lock semaphore;
int count;
}
我们将在我们的 BPF 映射中存储此结构,并使用元素内的信号量来防止对其的不良访问。现在,我们可以声明将保存这些元素的映射。此映射必须使用 BPF 类型格式(BTF)进行注释,以便验证器知道如何解释结构。类型格式通过向二进制对象添加调试信息,使内核和其他工具对 BPF 数据结构有了更丰富的理解。因为这段代码将在内核中运行,我们可以使用libbpf提供的内核宏来注释此并发映射:
struct bpf_map_def SEC("maps") concurrent_map = {
.type = BPF_MAP_TYPE_HASH,
.key_size = sizeof(int),
.value_size = sizeof(struct concurrent_element),
.max_entries = 100,
};
BPF_ANNOTATE_KV_PAIR(concurrent_map, int, struct concurrent_element);
在 BPF 程序中,我们可以使用两个锁定辅助函数来保护这些元素,防止竞争条件。即使信号量被锁定,我们的程序也能够安全地修改元素的值:
int bpf_program(struct pt_regs *ctx) {
int key = 0;
struct concurrent_element init_value = {};
struct concurrent_element *read_value;
bpf_map_create_elem(&concurrent_map, &key, &init_value, BPF_NOEXIST);
read_value = bpf_map_lookup_elem(&concurrent_map, &key);
bpf_spin_lock(&read_value->semaphore);
read_value->count += 100;
bpf_spin_unlock(&read_value->semaphore);
}
此示例使用一个新条目初始化我们的并发映射,该条目可以锁定其值的访问。然后,它从映射中获取该值并锁定其信号量,以便它可以保持计数值,防止数据竞争。在使用完值后,它释放锁,以便其他映射可以安全地访问该元素。
从用户空间,我们可以通过使用标志 BPF_F_LOCK 在并发映射中持有元素的引用。您可以将此标志与 bpf_map_update_elem 和 bpf_map_lookup_elem_flags 辅助函数一起使用。这个标志允许您原地更新元素,而不必担心数据竞争。
注意
当更新散列映射和更新数组和 cgroup 存储映射时,BPF_F_LOCK 的行为稍有不同。对于后两者,更新发生在原地,并且在执行更新之前,要更新的元素必须已经存在于映射中。在散列映射的情况下,如果元素尚不存在,则程序会锁定映射中元素的桶,并插入一个新元素。
自旋锁并不总是必需的。如果您只是在映射中聚合值,那么您不需要它们。但是,如果您希望在执行多个操作时确保并发程序不会更改映射中的元素,从而保持原子性,它们将非常有用。
在本节中,您已经看到了可以使用 BPF 映射执行的可能操作;但是,到目前为止,我们只使用了一种类型的映射。BPF 包含许多其他映射类型,您可以在不同情况下使用它们。我们将解释 BPF 定义的所有映射类型,并向您展示如何在不同情况下使用它们的具体示例。
BPF 映射的类型
Linux 文档 将映射定义为通用数据结构,您可以在其中存储不同类型的数据。多年来,内核开发人员添加了许多专门的数据结构,这些数据结构在特定用例中更有效。本节探讨了每种映射类型及其用法。
散列表映射
散列表映射是添加到 BPF 的第一个通用映射。它们的定义是 BPF_MAP_TYPE_HASH 类型。它们的实现和使用与您可能熟悉的其他散列表类似。您可以使用任意大小的键和值;内核会根据需要为您分配和释放它们。当您在散列表映射上使用 bpf_map_update_elem 时,内核会原子地替换元素。
散列表映射在查找时被优化得非常快速;它们对于存储频繁读取的结构化数据非常有用。让我们看一个使用它们来跟踪网络 IP 地址及其速率限制的示例程序:
#define IPV4_FAMILY 1
struct ip_key {
union {
__u32 v4_addr;
__u8 v6_addr[16];
};
__u8 family;
};
struct bpf_map_def SEC("maps") counters = {
.type = BPF_MAP_TYPE_HASH,
.key_size = sizeof(struct ip_key),
.value_size = sizeof(uint64_t),
.max_entries = 100,
.map_flags = BPF_F_NO_PREALLOC
};
在这段代码中,我们声明了一个结构化键,并将其用于保存关于 IP 地址的信息。我们定义了我们的程序将用来跟踪速率限制的映射。你可以看到,我们在这个映射中使用 IP 地址作为键。值将是我们的 BPF 程序从特定 IP 地址接收网络数据包的次数。
让我们编写一个小代码片段,在内核中更新这些计数器:
uint64_t update_counter(uint32_t ipv4) {
uint64_t value;
struct ip_key key = {};
key.v4_addr = ip4;
key.family = IPV4_FAMILY;
bpf_map_lookup_elem(counters, &key, &value);
(*value) += 1;
}
这个函数接收从网络数据包中提取的 IP 地址,并使用我们声明的复合键进行映射查找。在这种情况下,我们假设之前已经用零值初始化了计数器;否则,bpf_map_lookup_elem 调用会返回一个负数。
数组映射
数组映射是内核添加的第二种 BPF 映射类型。它们使用类型 BPF_MAP_TYPE_ARRAY 来定义。当你初始化一个数组映射时,它的所有元素都预先分配在内存中,并设置为它们的零值。因为这些映射由元素切片支持,所以键是数组中的索引,其大小必须正好是四个字节。
使用数组映射的一个缺点是,映射中的元素不能被删除,也不能使数组比它的大小更小。如果尝试在数组映射上使用 map_delete_elem,调用将失败,并且你会得到一个 EINVAL 错误作为结果。
数组映射通常用于存储可以更改值的信息,但通常在行为上是固定的。人们使用它们来存储具有预定义分配规则的全局变量。由于你不能删除元素,可以假定特定位置的元素始终表示相同的元素。
另一件需要记住的事情是,map_update_elem 不像你在哈希表映射中看到的那样是原子的。如果有更新正在进行,同一个程序可以同时从相同位置读取不同的值。如果你在数组映射中存储计数器,可以使用内核的内置函数 __sync_fetch_and_add 对映射的值执行原子操作。
程序数组映射
程序数组映射是内核添加的第一个专门映射。它们使用类型 BPF_MAP_TYPE_PROG_ARRAY 来定义。你可以使用这种类型的映射来存储对 BPF 程序的引用,使用它们的文件描述符标识符。结合辅助函数 bpf_tail_call 使用这个映射,可以让你在程序之间跳转,绕过单个 BPF 程序的最大指令限制,并减少实现复杂性。
当你使用这种专用映射时,有几点需要考虑。首先要记住的是,键和值的大小都必须是四字节。第二点要记住的是,当你跳转到一个新程序时,新程序将重用同一内存堆栈,因此你的程序不会消耗所有可用内存。最后,如果尝试跳转到一个不存在于映射中的程序,尾调用将失败,当前程序将继续执行。
让我们深入一个详细的例子,以更好地理解如何使用这种类型的映射:
struct bpf_map_def SEC("maps") programs = {
.type = BPF_MAP_TYPE_PROG_ARRAY,
.key_size = 4,
.value_size = 4,
.max_entries = 1024,
};
首先,我们需要声明我们的新程序映射(正如我们前面提到的,键和值的大小始终为四字节)。
int key = 1;
struct bpf_insn prog[] = {
BPF_MOV64_IMM(BPF_REG_0, 0), // assign r0 = 0
BPF_EXIT_INSN(), // return r0
};
prog_fd = bpf_prog_load(BPF_PROG_TYPE_KPROBE, prog, sizeof(prog), "GPL");
bpf_map_update_elem(&programs, &key, &prog_fd, BPF_ANY);
我们需要声明要跳转到的程序。在本例中,我们编写了一个 BPF 程序,其唯一目的是返回 0。我们使用bpf_prog_load将其加载到内核中,然后将其文件描述符标识符添加到我们的程序映射中。
现在我们已经将该程序存储起来,我们可以编写另一个 BPF 程序来跳转到它。BPF 程序只能跳转到同类型的其他程序;在本例中,我们将程序附加到一个 kprobe 跟踪中,就像我们在第二章中看到的那样。
SEC("kprobe/seccomp_phase1")
int bpf_kprobe_program(struct pt_regs *ctx) {
int key = 1;
/* dispatch into next BPF program */
bpf_tail_call(ctx, &programs, &key);
/* fall through when the program descriptor is not in the map */
char fmt[] = "missing program in prog_array map\n";
bpf_trace_printk(fmt, sizeof(fmt));
return 0;
}
使用bpf_tail_call和BPF_MAP_TYPE_PROG_ARRAY,你可以链式调用高达 32 个嵌套调用。这是一个显式的限制,以防止无限循环和内存耗尽。
性能事件数组映射
这些类型的映射将perf_events数据存储在一个缓冲环中,实时地在 BPF 程序和用户空间程序之间进行通信。它们被定义为BPF_MAP_TYPE_PERF_EVENT_ARRAY类型。它们旨在将内核的跟踪工具发出的事件转发到用户空间程序进行进一步处理。这是最有趣的映射类型之一,也是许多可观察性工具的基础,我们将在接下来的章节中讨论。
让我们看一个例子,说明我们如何追踪计算机执行的所有程序。在跳入 BPF 程序代码之前,我们需要声明从内核发送到用户空间的事件结构:
struct data_t {
u32 pid;
char program_name[16];
};
现在,我们需要创建发送事件到用户空间的映射:
struct bpf_map_def SEC("maps") events = {
.type = BPF_MAP_TYPE_PERF_EVENT_ARRAY,
.key_size = sizeof(int),
.value_size = sizeof(u32),
.max_entries = 2,
};
在声明了数据类型和映射之后,我们可以创建捕获数据并将其发送到用户空间的 BPF 程序:
SEC("kprobe/sys_exec")
int bpf_capture_exec(struct pt_regs *ctx) {
data_t data;
// bpf_get_current_pid_tgid returns the current process identifier
data.pid = bpf_get_current_pid_tgid() >> 32;
// bpf_get_current_comm loads the current executable name
bpf_get_current_comm(&data.program_name, sizeof(data.program_name));
bpf_perf_event_output(ctx, &events, 0, &data, sizeof(data));
return 0;
}
在这个片段中,我们使用bpf_perf_event_output将数据追加到映射中。因为这是一个实时缓冲区,你不需要担心映射中元素的键;内核会负责将新元素添加到映射中,并在用户空间程序处理后刷新它。
在第四章中,我们讨论了这些类型映射的更高级用法,并展示了在用户空间处理程序的示例。
每 CPU 哈希地图
这种类型的地图是 BPF_MAP_TYPE_HASH 的优化版本。这些地图使用类型 BPF_MAP_TYPE_PERCPU_HASH 进行定义。当您分配其中一个地图时,每个 CPU 看到自己的地图的隔离版本,这使得高性能的查找和聚合更加高效。如果您的 BPF 程序收集指标并在哈希表地图中进行聚合,则此类型的地图非常有用。
每 CPU 数组地图
这种类型的地图也是 BPF_MAP_TYPE_ARRAY 的优化版本。它们使用类型 BPF_MAP_TYPE_PERCPU_ARRAY 进行定义。就像前面的地图一样,当您分配其中一个地图时,每个 CPU 看到自己的地图的隔离版本,这使得高性能的查找和聚合更加高效。
堆栈跟踪地图
这种类型的地图存储了运行过程中的堆栈跟踪。它们使用类型 BPF_MAP_TYPE_STACK_TRACE 进行定义。除了这个地图之外,内核开发者还添加了助手 bpf_get_stackid 来帮助您填充这个地图的堆栈跟踪。这个助手接受地图作为参数,以及一系列标志,这样您就可以指定是否只想要来自内核、用户空间或两者的跟踪。该助手返回与添加到地图的元素关联的键。
Cgroup 数组地图
这种类型的地图存储到 cgroup 的引用。Cgroup 数组地图使用类型 BPF_MAP_TYPE_CGROUP_ARRAY 进行定义。从本质上讲,它们的行为类似于 BPF_MAP_TYPE_PROG_ARRAY,但它们存储指向 cgroup 的文件描述符标识符。
当您希望在控制流量、调试和测试时在 BPF 地图之间共享 cgroup 引用时,这个地图非常有用。让我们看一个如何填充这个地图的示例。我们从地图的定义开始:
struct bpf_map_def SEC("maps") cgroups_map = {
.type = BPF_MAP_TYPE_CGROUP_ARRAY,
.key_size = sizeof(uint32_t),
.value_size = sizeof(uint32_t),
.max_entries = 1,
};
我们可以通过打开包含其信息的文件来检索 cgroup 的文件描述符。我们将打开控制 Docker 容器的基本 CPU 分享的 cgroup,并将该 cgroup 存储在我们的地图中:
int cgroup_fd, key = 0;
cgroup_fd = open("/sys/fs/cgroup/cpu/docker/cpu.shares", O_RDONLY);
bpf_update_elem(&cgroups_map, &key, &cgroup_fd, 0);
LRU 哈希和每 CPU 哈希地图
这两种类型的地图是哈希表地图,就像您之前看到的那些,但它们还实现了内部 LRU 缓存。LRU 是最近最少使用的缩写,这意味着如果地图已满,这些地图将删除不经常使用的元素,以便为地图中的新元素腾出空间。因此,只要您不介意丢失最近未使用的元素,您可以使用这些地图来插入超出最大限制的元素。它们的类型分别是 BPF_MAP_TYPE_LRU_HASH 和 BPF_MAP_TYPE_LRU_PERCPU_HASH。
这种地图的 per cpu 版本与您之前看到的其他 per cpu 地图略有不同。这个地图只保留一个哈希表来存储地图中的所有元素,并且每个 CPU 使用不同的 LRU 缓存,以确保每个 CPU 中使用最频繁的元素仍然保留在地图中。
LPM Trie 地图
LPM 前缀树地图是一种使用最长前缀匹配(LPM)查找地图中元素的地图类型。LPM 是一种算法,它从树中选择与任何其他匹配中最长查找键匹配的元素。此算法用于路由器和其他设备中,这些设备保持流量转发表以将 IP 地址与特定路由匹配。这些地图使用类型 BPF_MAP_TYPE_LPM_TRIE 定义。
这些地图要求其键大小为八的倍数,并且在 8 到 2048 的范围内。如果您不想实现自己的键,内核提供了一个名为 bpf_lpm_trie_key 的结构体,您可以用来创建这些键。
在下一个示例中,我们向地图添加两条转发路由,并尝试将 IP 地址与正确的路由匹配。首先,我们需要创建地图:
struct bpf_map_def SEC("maps") routing_map = {
.type = BPF_MAP_TYPE_LPM_TRIE,
.key_size = 8,
.value_size = sizeof(uint64_t),
.max_entries = 10000,
.map_flags = BPF_F_NO_PREALLOC,
};
我们将使用三条转发路由来填充这个地图:192.168.0.0/16、192.168.0.0/24 和 192.168.1.0/24:
uint64_t value_1 = 1;
struct bpf_lpm_trie_key route_1 = {.data = {192, 168, 0, 0}, .prefixlen = 16};
uint64_t value_2 = 2;
struct bpf_lpm_trie_key route_2 = {.data = {192, 168, 0, 0}, .prefixlen = 24};
uint64_t value_3 = 3;
struct bpf_lpm_trie_key route_3 = {.data = {192, 168, 1, 0}, .prefixlen = 24};
bpf_map_update_elem(&routing_map, &route_1, &value_1, BPF_ANY);
bpf_map_update_elem(&routing_map, &route_2, &value_2, BPF_ANY);
bpf_map_update_elem(&routing_map, &route_3, &value_3, BPF_ANY);
现在,我们使用相同的关键结构来查找 IP 192.168.1.1/32 的正确匹配:
uint64_t result;
struct bpf_lpm_trie_key lookup = {.data = {192, 168, 1, 1}, .prefixlen = 32};
int ret = bpf_map_lookup_elem(&routing_map, &lookup, &result);
if (ret == 0)
printf("Value read from the map: '%d'\n", result);
在本例中,192.168.0.0/24 和 192.168.1.0/24 都可以匹配查找 IP,因为它们都在这两个范围内。但是,由于此地图使用 LPM 算法,结果将填充键 192.168.1.0/24 的值。
地图数组和地图哈希
BPF_MAP_TYPE_ARRAY_OF_MAPS 和 BPF_MAP_TYPE_HASH_OF_MAPS 是两种存储对其他地图的引用的地图类型。它们仅支持一级间接,因此您不能使用它们来存储地图的地图,以此类推。这确保您不会通过意外存储无限链接地图而消耗所有内存。
当您希望能够在运行时替换整个地图时,这些地图类型非常有用。如果您的所有地图都是全局地图的子级,您可以创建完整状态的快照。内核确保在父地图的任何更新操作等待所有对旧子地图的引用被丢弃之前完成该操作。
设备地图映射
这种专门的地图类型存储对网络设备的引用。这些地图使用类型 BPF_MAP_TYPE_DEVMAP 定义。它们对于希望在内核级别操作流量的网络应用程序非常有用。您可以建立一个虚拟的端口地图,指向特定的网络设备,然后通过使用辅助 bpf_redirect_map 来重定向数据包。
CPU 地图映射
BPF_MAP_TYPE_CPUMAP 是另一种允许您转发网络流量的地图类型。在这种情况下,地图存储了主机中不同 CPU 的引用。与前一种地图类型类似,您可以使用 bpf_redirect_map 辅助程序来重定向数据包。然而,这种地图将数据包发送到不同的 CPU。这允许您为可伸缩性和隔离目的分配特定的 CPU 给网络堆栈。
打开套接字地图
BPF_MAP_TYPE_XSKMAP 是一种存储对打开套接字的引用的地图类型。与前述地图类似,这些地图对于在套接字之间转发数据包非常有用。
套接字数组和哈希地图
BPF_MAP_TYPE_SOCKMAP和BPF_MAP_TYPE_SOCKHASH是两种存储内核中打开套接字引用的专用地图。与之前的地图一样,这种类型的地图与助手bpf_redirect_map一起使用,将当前 XDP 程序的套接字缓冲区重定向到不同的套接字。
它们的主要区别在于其中一个使用数组来存储套接字,另一个使用哈希表。使用哈希表的优势在于你可以直接通过其键访问套接字,而无需遍历整个地图来查找。内核中的每个套接字由一个五元组键标识。这些五元组包括建立双向网络连接所需的必要信息。当你使用这种地图的哈希表版本时,可以将此键作为查找键在你的地图中使用。
Cgroup 存储和每 CPU 存储地图
这两种类型的地图被引入以帮助开发人员处理附加到 cgroup 的 BPF 程序。正如你在第二章中看到的,你可以附加和分离 BPF 程序到控制组,并通过BPF_PROG_TYPE_CGROUP_SKB将它们的运行时隔离到特定的 cgroup 中。这两个地图被定义为类型BPF_MAP_TYPE_CGROUP_STORAGE和BPF_MAP_TYPE_PERCPU_CGROUP_STORAGE。
这些类型的地图从开发者的角度来看类似于哈希表地图。内核提供了一个结构助手来为这个地图生成键,bpf_cgroup_storage_key,其中包含关于 cgroup 节点标识符和附加类型的信息。你可以向这个地图添加任何你想要的值;其访问将被限制在附加 cgroup 内部的 BPF 程序中。
这些地图存在两个限制。第一个是你不能从用户空间创建地图中的新元素。内核中的 BPF 程序可以使用bpf_map_update_elem创建元素,但如果从用户空间使用此方法且键不存在,bpf_map_update_elem将失败,并设置errno为ENOENT。第二个限制是你不能从此地图中删除元素。bpf_map_delete_elem始终失败,并将errno设置为EINVAL。
正如你之前看到的其他类似地图一样,这两种地图的主要区别在于BPF_MAP_TYPE_PERCPU_CGROUP_STORAGE为每个 CPU 保留了一个不同的哈希表。
重用端口套接字地图
这种特殊类型的地图存储可以被系统中打开端口的重用的套接字的引用。它们被定义为类型BPF_MAP_TYPE_REUSEPORT_SOCKARRAY。这些地图主要与BPF_PROG_TYPE_SK_REUSEPORT程序类型一起使用。结合使用,它们让你可以决定如何过滤和处理来自网络设备的传入数据包。例如,你可以决定哪些数据包发送到哪个套接字,即使这两个套接字都附加到同一个端口。
队列地图
队列映射使用先进先出(FIFO)的存储方式来保持映射中的元素。它们的类型定义为BPF_MAP_TYPE_QUEUE。FIFO 意味着当您从映射中获取元素时,结果将是在映射中存在时间最长的元素。
bpf映射助手在这种数据结构中也是以可预测的方式工作的。当您使用bpf_map_lookup_elem时,该映射总是查找映射中最旧的元素。当您使用bpf_map_update_elem时,该映射总是将元素追加到队列的末尾,因此您需要在获取此元素之前读取映射中的其余元素。您还可以使用助手bpf_map_lookup_and_delete以原子方式获取并从映射中删除较旧的元素。该映射不支持助手bpf_map_delete_elem和bpf_map_get_next_key。如果尝试使用它们,它们将失败并将errno变量设置为EINVAL。
您还需要记住关于这些类型映射的一些事情,它们不使用映射键进行查找,并且在初始化这些映射时,键大小必须始终为 0。当您将元素推送到这些映射时,键必须是空值。
让我们看一个如何使用这种类型映射的例子:
struct bpf_map_def SEC("maps") queue_map = {
.type = BPF_MAP_TYPE_QUEUE,
.key_size = 0,
.value_size = sizeof(int),
.max_entries = 100,
.map_flags = 0,
};
让我们在这个映射中插入几个元素,并以插入它们的相同顺序检索它们:
int i;
for (i = 0; i < 5; i++)
bpf_map_update_elem(&queue_map, NULL, &i, BPF_ANY);
int value;
for (i = 0; i < 5; i++) {
bpf_map_lookup_and_delete(&queue_map, NULL, &value);
printf("Value read from the map: '%d'\n", value);
}
该程序打印如下内容:
Value read from the map: '0'
Value read from the map: '1'
Value read from the map: '2'
Value read from the map: '3'
Value read from the map: '4'
如果我们尝试从映射中弹出一个新元素,bpf_map_lookup_and_delete将返回一个负数,并且errno变量将被设置为ENOENT。
栈映射
栈映射使用后进先出(LIFO)的存储方式来保持映射中的元素。它们的类型定义为BPF_MAP_TYPE_STACK。LIFO 意味着当您从映射中获取元素时,结果将是最近添加到映射中的元素。
bpf映射助手在这种数据结构中也是以可预测的方式工作的。当您使用bpf_map_lookup_elem时,该映射总是查找映射中最新的元素。当您使用bpf_map_update_elem时,该映射总是将元素追加到栈的顶部,因此它是第一个要获取的元素。您还可以使用助手bpf_map_lookup_and_delete以原子方式获取并从映射中删除最新的元素。该映射不支持助手bpf_map_delete_elem和bpf_map_get_next_key。如果尝试使用它们,它们将始终失败,并将errno变量设置为EINVAL。
让我们看一个如何使用这个映射的例子:
struct bpf_map_def SEC("maps") stack_map = {
.type = BPF_MAP_TYPE_STACK,
.key_size = 0,
.value_size = sizeof(int),
.max_entries = 100,
.map_flags = 0,
};
让我们在这个映射中插入几个元素,并以插入它们的相同顺序检索它们:
int i;
for (i = 0; i < 5; i++)
bpf_map_update_elem(&stack_map, NULL, &i, BPF_ANY);
int value;
for (i = 0; i < 5; i++) {
bpf_map_lookup_and_delete(&stack_map, NULL, &value);
printf("Value read from the map: '%d'\n", value);
}
该程序打印如下内容:
Value read from the map: '4'
Value read from the map: '3'
Value read from the map: '2'
Value read from the map: '1'
Value read from the map: '0'
如果我们尝试从映射中弹出一个新元素,bpf_map_lookup_and_delete将返回一个负数,并且errno变量将被设置为ENOENT。
这些都是您可以在 BPF 程序中使用的所有映射类型。您会发现其中一些比其他更有用;这取决于您正在编写的程序类型。在本书中,我们将看到更多的使用示例,这将帮助您巩固刚刚学到的基础知识。
正如我们之前提到的,BPF 映射作为操作系统中的常规文件存储。我们还没有讨论内核用于保存映射和程序的文件系统的具体特性。下一节将指导您了解 BPF 文件系统及其提供的持久性类型。
BPF 虚拟文件系统
BPF maps 的一个基本特征是,它们基于文件描述符,这意味着当描述符关闭时,该映射及其所保存的所有信息都会消失。最初的 BPF 映射实现专注于短暂的孤立程序,这些程序之间不共享任何信息。在这些情况下,关闭文件描述符时清除所有数据是有意义的。然而,随着内核中更复杂映射和集成的引入,其开发者意识到需要一种方法来保存映射所持有的信息,即使程序终止并关闭映射的文件描述符。Linux 内核版本 4.4 引入了两个新的系统调用,允许从虚拟文件系统中固定和获取映射和 BPF 程序。固定到该文件系统的映射和 BPF 程序将在创建它们的程序终止后仍保留在内存中。本节介绍如何使用这个虚拟文件系统。
BPF 期望找到这个虚拟文件系统的默认目录是/sys/fs/bpf。一些 Linux 发行版默认情况下不会挂载此文件系统,因为它们不假设内核支持 BPF。您可以使用mount命令自行挂载它:
# mount -t bpf /sys/fs/bpf /sys/fs/bpf
与任何其他文件层次结构一样,文件系统中的持久性 BPF 对象由路径标识。您可以以任何对程序有意义的方式组织这些路径。例如,如果您希望在程序之间共享包含 IP 信息的特定映射,则可能希望将其存储在/sys/fs/bpf/shared/ips中。正如我们之前提到的,您可以在此文件系统中保存两种类型的对象:BPF 映射和完整的 BPF 程序。这两者都由文件描述符标识,因此与它们交互的接口是相同的。这些对象只能通过bpf系统调用进行操作。尽管内核提供了高级别的辅助功能来帮助您与它们交互,但您不能像尝试使用open系统调用打开这些文件那样操作它们。
BPF_PIN_FD是将 BPF 对象保存在此文件系统中的命令。命令成功后,对象将在文件系统中以您指定的路径可见。如果命令失败,则返回一个负数,并设置全局的errno变量以表示错误代码。
BPF_OBJ_GET 是用于获取已固定到文件系统的 BPF 对象的命令。此命令使用您分配给对象的路径来加载它。当此命令成功时,它返回与对象关联的文件描述符标识符。如果失败,则返回负数,并且全局的 errno 变量设置为特定的错误代码。
让我们看一个示例,展示如何利用内核提供的辅助函数在不同的程序中使用这两个命令。
首先,我们将编写一个程序,创建一个映射,用多个元素填充它,并将其保存在文件系统中:
static const char * file_path = "/sys/fs/bpf/my_array";
int main(int argc, char **argv) {
int key, value, fd, added, pinned;
fd = bpf_create_map(BPF_MAP_TYPE_ARRAY, sizeof(int), sizeof(int), 100, 0); 
if (fd < 0) {
printf("Failed to create map: %d (%s)\n", fd, strerror(errno));
return -1;
}
key = 1, value = 1234;
added = bpf_map_update_elem(fd, &key, &value, BPF_ANY);
if (added < 0) {
printf("Failed to update map: %d (%s)\n", added, strerror(errno));
return -1;
}
pinned = bpf_obj_pin(fd, file_path);
if (pinned < 0) {
printf("Failed to pin map to the file system: %d (%s)\n",
pinned, strerror(errno));
return -1;
}
return 0;
}
这段代码的内容应该已经很熟悉了,因为它来自我们之前的示例。首先,我们创建了一个具有一个固定大小元素的哈希表映射。然后我们更新映射以添加该元素。如果尝试添加更多元素,bpf_map_update_elem 将会失败,因为这会导致映射溢出。
我们使用辅助函数 pbf_obj_pin 将地图保存在文件系统中。在程序终止后,您可以检查您的机器上该路径下是否有新文件:
ls -la /sys/fs/bpf
total 0
drwxrwxrwt 2 root root 0 Nov 24 13:56 .
drwxr-xr-x 9 root root 0 Nov 24 09:29 ..
-rw------- 1 david david 0 Nov 24 13:56 my_map
现在,我们可以编写一个类似的程序,从文件系统加载该映射并打印我们插入的元素。通过这种方式,我们可以验证我们正确保存了映射:
static const char * file_path = "/sys/fs/bpf/my_array";
int main(int argc, char **argv) {
int fd, key, value, result;
fd = bpf_obj_get(file_path);
if (fd < 0) {
printf("Failed to fetch the map: %d (%s)\n", fd, strerror(errno));
return -1;
}
key = 1;
result = bpf_map_lookup_elem(fd, &key, &value);
if (result < 0) {
printf("Failed to read value from the map: %d (%s)\n",
result, strerror(errno));
return -1;
}
printf("Value read from the map: '%d'\n", value);
return 0;
能够将 BPF 对象保存在文件系统中为更有趣的应用打开了大门。您的数据和程序不再绑定于单个执行线程。信息可以被不同的应用程序共享,并且 BPF 程序甚至可以在创建它们的应用程序终止后继续运行。这为它们提供了一种额外的可用性级别,这是在没有 BPF 文件系统的情况下无法实现的。
结论
在内核和用户空间之间建立通信渠道对于充分利用任何 BPF 程序至关重要。在本章中,您学习了如何创建 BPF 映射以建立通信,并学习了如何操作它们。我们还描述了您可以在程序中使用的映射类型。随着您在本书中的进展,您将看到更多具体的映射示例。最后,您学会了如何将整个映射固定到系统中,使它们及其包含的信息在崩溃和中断时依然可用。
BPF 映射是内核和用户空间之间通信的核心总线。在本章中,我们建立了您理解它们所需的基本概念。在下一章中,我们将更广泛地使用这些数据结构来共享数据。我们还将向您介绍更多使与 BPF 映射更高效的附加工具。
在接下来的章节中,你将看到 BPF 程序和映射如何共同工作,从内核的视角为你提供跟踪能力。我们探讨了将程序附加到内核不同入口点的不同方法。最后,我们讨论了如何以一种使应用程序更易于调试和观察的方式表示多个数据点。
第四章:使用 BPF 进行跟踪
在软件工程中,跟踪是一种收集数据以进行性能分析和调试的方法。其目的是在运行时提供有用的信息以供将来分析。使用 BPF 进行跟踪的主要优势在于,您可以访问来自 Linux 内核和您的应用程序几乎任何信息。与其他跟踪技术相比,BPF 对系统性能和延迟的开销最小,并且不要求开发人员为了从中收集数据而修改其应用程序。
Linux 内核提供了几种可与 BPF 结合使用的仪器化能力。在本章中,我们讨论这些不同的能力。我们向您展示内核如何在您的操作系统中公开这些能力,以便您知道如何找到您的 BPF 程序可用的信息。
跟踪的最终目标是通过获取所有可用数据并以有用的方式呈现给您,从而让您对任何系统有深入的理解。我们将讨论几种不同的数据表示以及您如何在不同的场景中使用它们。
从本章开始,我们将使用一个强大的工具包来编写 BPF 程序,即 BPF 编译器集合(BCC)。BCC 是一组组件,使构建 BPF 程序更加可预测。即使您精通 Clang 和 LLVM,您也可能不想花费比必要更多的时间来构建相同的实用程序,并确保 BPF 验证器不会拒绝您的程序。BCC 提供了用于常见结构的可重用组件,如 Perf 事件映射,并与 LLVM 后端集成,以提供更好的调试选项。此外,BCC 还包括对几种编程语言的绑定;我们将在示例中使用 Python。这些绑定允许您使用高级语言编写 BPF 程序的用户空间部分,从而产生更有用的程序。我们还将在接下来的章节中继续使用 BCC,以使我们的示例更加简洁。
能够在 Linux 内核中跟踪程序的第一步是识别它为您提供的用于附加 BPF 程序的扩展点。这些扩展点通常被称为探针。
探针
英语词典中“探针”一词的一个定义如下:
一个无人探测航天器,设计用于传输有关其环境的信息。
这个定义唤起了我们关于科幻电影和史诗般的 NASA 任务的回忆,也可能唤起了你的回忆。当我们谈论跟踪探针时,我们可以使用非常类似的定义。
跟踪探针是设计用于传输有关其执行环境信息的探索性程序。
它们在系统中收集数据,并使其可供您探索和分析。在 Linux 中传统上使用探测点涉及编写编译成内核模块的程序,这可能会在生产系统中造成严重问题。多年来,它们已经进化为更安全的执行方式,但仍然繁琐地编写和测试。像 SystemTap 这样的工具建立了新的协议来编写探测点,并为从 Linux 内核和所有运行在用户空间的程序中获取更丰富信息铺平了道路。
BPF 通过跟踪探测点来收集调试和分析信息。BPF 程序的安全性质使其比仍依赖于重新编译内核的工具更具吸引力。重新编译内核以包括外部模块可能会由于代码行为不端引入崩溃风险。BPF 验证器通过在加载到内核之前分析程序来消除此风险。BPF 开发者利用了探测点定义,并修改了内核以执行 BPF 程序,而不是在代码执行时加载内核模块。
了解您可以定义的不同类型的探测点对于探索系统内部发生的情况至关重要。在本节中,我们分类了不同的探测点定义,如何在您的系统中发现它们,以及如何将 BPF 程序附加到它们上。
在本章中,我们涵盖了四种不同类型的探测点:
内核探测点
这些点使您可以动态访问内核中的内部组件。
跟踪点
这些提供对内核中内部组件的静态访问。
用户空间探测点
这些点使您可以动态访问运行在用户空间程序中的内容。
用户静态定义的跟踪点
这些允许静态访问运行在用户空间的程序。
让我们从内核探测点开始。
内核探测点
内核探测点允许您在几乎任何内核指令上设置动态标志或断点,并且开销最小。当内核达到这些标志时,它会执行与探测点附加的代码,然后恢复其通常的例程。内核探测点可以为您提供关于系统中发生的任何事情的信息,例如在系统中打开的文件和正在执行的二进制文件。关于内核探测点需要记住的一件重要事情是它们没有稳定的应用程序二进制接口(ABI),这意味着它们可能会在内核版本之间发生变化。如果尝试将相同的探测点附加到具有两个不同内核版本的系统上,相同的代码可能会停止工作。
内核探测点分为两类:kprobes 和 kretprobes。它们的使用取决于您可以在执行周期中的何处插入您的 BPF 程序。本节指导您如何使用每一个来将 BPF 程序附加到这些探测点上,并从内核中提取信息。
Kprobes
Kprobes 允许你在执行任何内核指令之前插入 BPF 程序。你需要知道要中断的函数签名,并且正如前面提到的,这不是一个稳定的 ABI,因此如果要在不同的内核版本中运行相同的程序,设置这些探针时需要格外小心。当内核执行到设置探针的指令时,它会跳转到你的代码中,运行你的 BPF 程序,然后返回到原始指令的执行中。
展示如何使用 kprobes,我们将编写一个 BPF 程序,打印在系统中执行的任何二进制文件的名称。在这个示例中,我们将使用 BCC 工具的 Python 前端,但你也可以使用其他 BPF 工具来编写它:
from bcc import BPF
bpf_source = """
int do_sys_execve(struct pt_regs *ctx, void filename, void argv, void envp) { 
char comm[16];
bpf_get_current_comm(&comm, sizeof(comm));
bpf_trace_printk("executing program: %s", comm);
return 0;
}
"""
bpf = BPF(text = bpf_source) 
execve_function = bpf.get_syscall_fnname("execve") 
bpf.attach_kprobe(event = execve_function, fn_name = "do_sys_execve") 
bpf.trace_print()
我们的 BPF 程序开始运行。辅助函数 bpf_get_current_comm 将获取内核正在运行的当前命令名称,并将其存储在我们的 comm 变量中。我们将其定义为固定长度数组,因为内核对命令名称有一个 16 字符的限制。在获取命令名称后,我们将其打印在调试跟踪中,以便运行 Python 脚本的人可以看到 BPF 捕获的所有命令。
将 BPF 程序加载到内核中。
将程序与 execve 系统调用关联起来。这个系统调用的名称在不同的内核版本中可能会有所变化,而 BCC 提供了一个函数,可以获取这个名称,而无需记住你正在运行哪个内核版本。
该代码输出跟踪日志,因此你可以看到通过此程序跟踪的所有命令。
Kretprobes
Kretprobes 将在内核指令执行后返回一个值时插入你的 BPF 程序。通常,你会希望将 kprobes 和 kretprobes 结合到一个单独的 BPF 程序中,以便全面了解指令的行为。
我们将使用类似于前一节的示例来展示 kretprobes 的工作原理:
from bcc import BPF
bpf_source = """
int ret_sys_execve(struct pt_regs *ctx) { 
int return_value;
char comm[16];
bpf_get_current_comm(&comm, sizeof(comm));
return_value = PT_REGS_RC(ctx);
bpf_trace_printk("program: %s, return: %d", comm, return_value);
return 0;
}
"""
bpf = BPF(text = bpf_source) 
execve_function = bpf.get_syscall_fnname("execve")
bpf.attach_kretprobe(event = execve_function, fn_name = "ret_sys_execve") 
bpf.trace_print()
定义实现 BPF 程序的函数。内核将在 execve 系统调用完成后立即执行它。PT_REGS_RC 是一个宏,将从 BPF 寄存器中读取特定上下文的返回值。我们还使用 bpf_trace_printk 在调试日志中打印命令及其返回值。
初始化 BPF 程序并加载到内核中。
将附着功能更改为 attach_kretprobe。
内核探针是访问内核的强大方式。但正如我们之前提到的,它们可能不稳定,因为你要附加到内核源代码中的动态点,这些点可能会在不同版本之间改变或消失。现在你将看到一种更安全的方法来将程序附加到内核。
跟踪点
跟踪点是内核代码中的静态标记,你可以用来在运行的内核中附加代码。与 kprobes 的主要区别在于,当内核开发人员实现内核更改时,它们会为其编码;这就是我们称它们为静态的原因。由于它们是静态的,跟踪点的 ABI 更稳定;内核始终保证旧版本中的跟踪点会存在于新版本中。然而,由于开发人员需要将它们添加到内核中,它们可能无法覆盖构成内核的所有子系统。
正如我们在第二章中提到的,你可以通过列出/sys/kernel/debug/tracing/events中的所有文件来查看系统中所有可用的跟踪点。例如,你可以通过列出/sys/kernel/debug/tracing/events/bpf中定义的事件来找到 BPF 本身的所有跟踪点:
sudo ls -la /sys/kernel/debug/tracing/events/bpf
total 0
drwxr-xr-x 14 root root 0 Feb 4 16:13 .
drwxr-xr-x 106 root root 0 Feb 4 16:14 ..
drwxr-xr-x 2 root root 0 Feb 4 16:13 bpf_map_create
drwxr-xr-x 2 root root 0 Feb 4 16:13 bpf_map_delete_elem
drwxr-xr-x 2 root root 0 Feb 4 16:13 bpf_map_lookup_elem
drwxr-xr-x 2 root root 0 Feb 4 16:13 bpf_map_next_key
drwxr-xr-x 2 root root 0 Feb 4 16:13 bpf_map_update_elem
drwxr-xr-x 2 root root 0 Feb 4 16:13 bpf_obj_get_map
drwxr-xr-x 2 root root 0 Feb 4 16:13 bpf_obj_get_prog
drwxr-xr-x 2 root root 0 Feb 4 16:13 bpf_obj_pin_map
drwxr-xr-x 2 root root 0 Feb 4 16:13 bpf_obj_pin_prog
drwxr-xr-x 2 root root 0 Feb 4 16:13 bpf_prog_get_type
drwxr-xr-x 2 root root 0 Feb 4 16:13 bpf_prog_load
drwxr-xr-x 2 root root 0 Feb 4 16:13 bpf_prog_put_rcu
-rw-r--r-- 1 root root 0 Feb 4 16:13 enable
-rw-r--r-- 1 root root 0 Feb 4 16:13 filter
每个在该输出中列出的子目录对应一个我们可以附加 BPF 程序的跟踪点。但是这里还有两个额外的文件。第一个文件,enable,允许你启用和禁用 BPF 子系统的所有跟踪点。如果文件的内容为 0,则跟踪点被禁用;如果文件的内容为 1,则跟踪点被启用。filter 文件允许你编写表达式,内核中的 Trace 子系统将使用这些表达式来过滤事件。BPF 不使用这个文件;在内核的跟踪文档中可以了解更多信息。
编写 BPF 程序以利用跟踪点的方式与使用 kprobes 进行跟踪相似。下面是一个示例,使用 BPF 程序跟踪系统中加载其他 BPF 程序的所有应用程序:
from bcc import BPF
bpf_source = """
int trace_bpf_prog_load(void ctx) { 
char comm[16];
bpf_get_current_comm(&comm, sizeof(comm));
bpf_trace_printk("%s is loading a BPF program", comm);
return 0;
}
"""
bpf = BPF(text = bpf_source)
bpf.attach_tracepoint(tp = "bpf:bpf_prog_load",
fn_name = "trace_bpf_prog_load") 
bpf.trace_print()
声明定义 BPF 程序的函数。这段代码你应该已经很熟悉了;与我们在讨论 kprobes 时看到的第一个示例相比,只有几个语法上的变化。
这个程序的主要区别在于:我们不是将程序附加到 kprobe,而是附加到一个跟踪点。BCC 遵循一种命名跟踪点的约定;首先指定要跟踪的子系统——在这种情况下是bpf——然后是一个冒号,接着是子系统中的跟踪点,bpf_prog_load。这意味着每当内核执行函数bpf_prog_load时,该程序将接收该事件,并打印执行该bpf_prog_load指令的应用程序的名称。
内核探针和跟踪点将为您提供对内核的全面访问。我们建议您尽可能使用跟踪点,但不要因为它们更安全就感到必须遵循跟踪点。利用内核探针的动态特性。在下一节中,我们将讨论如何在运行在用户空间的程序中获得类似的可见性水平。
用户空间探针
用户空间探针允许您在运行在用户空间的程序中设置动态标志。它们相当于内核外运行的程序的内核探针。当您定义一个 uprobes 时,内核会在附加指令周围创建一个陷阱。当您的应用程序达到该指令时,内核会触发一个事件,该事件具有您的探针函数作为回调。Uprobes 还允许您访问任何您的程序链接到的库,并且如果您知道指令的正确名称,还可以跟踪这些调用。
与内核探针类似,用户空间探针也分为两类,即 uprobes 和 uretprobes,具体取决于您可以在执行周期中的哪个位置插入您的 BPF 程序。让我们直接通过一些示例来了解。
Uprobes
一般而言,uprobes 是内核在特定指令执行之前插入到程序指令集中的钩子。当您将 uprobes 附加到同一程序的不同版本时,需要小心,因为函数签名在这些版本之间可能会在内部更改。确保 BPF 程序能够在两个不同版本中运行的唯一方法是确保签名未更改。您可以在 Linux 中使用 nm 命令列出包含在 ELF 对象文件中的所有符号,这是检查您正在跟踪的指令是否仍然存在于您的程序中的好方法,例如:
package main
import "fmt"
func main() {
fmt.Println("Hello, BPF")
}
您可以通过使用 go build -o hello-bpf main.go 编译此 Go 程序。您可以使用 nm 命令获取关于二进制文件包含的所有指令点的信息。nm 是 GNU 开发工具中包含的程序,用于列出对象文件中的符号。如果您使用包含 main 在其名称中的符号进行过滤,您会得到类似以下列表:
nm hello-bpf | grep main
0000000004850b0 T main.init
00000000567f06 B main.initdone.
00000000485040 T main.main
000000004c84a0 R main.statictmp_0
00000000428660 T runtime.main
0000000044da30 T runtime.main.func1
00000000044da80 T runtime.main.func2
000000000054b928 B runtime.main_init_done
00000000004c8180 R runtime.mainPC
0000000000567f1a B runtime.mainStarted
现在您有了符号列表,您可以追踪它们何时被执行,甚至在执行相同二进制文件的不同进程之间。
要追踪我们之前的 Go 示例中的主函数何时执行,我们将编写一个 BPF 程序,并将其附加到一个 uprobes 上,在任何进程调用该指令之前都会触发:
from bcc import BPF
bpf_source = """
int trace_go_main(struct pt_regs *ctx) {
u64 pid = bpf_get_current_pid_tgid(); 
bpf_trace_printk("New hello-bpf process running with PID: %d", pid);
}
"""
bpf = BPF(text = bpf_source)
bpf.attach_uprobe(name = "hello-bpf",
sym = "main.main", fn_name = "trace_go_main") 
bpf.trace_print()
使用函数 bpf_get_current_pid_tgid 来获取运行我们的 hello-bpf 程序的进程标识符(PID)。
将该程序附加到一个 uprobe 上。此调用需要知道我们要跟踪的对象hello-bpf的绝对路径。它还需要我们在对象内部正在跟踪的符号,例如在这种情况下是main.main,以及我们想要运行的 BPF 程序。有了这些,每当有人在我们的系统中运行hello-bpf时,我们将在我们的跟踪管道中获得一个新的日志。
Uretprobes
Uretprobes 是用户空间程序的并行探针,类似于 kretprobes。它们将 BPF 程序附加到返回值的指令上,并通过从您的 BPF 代码访问寄存器来访问这些返回值。
结合 uprobes 和 uretprobes 允许您编写更复杂的 BPF 程序。它们可以为您提供系统中正在运行的应用程序的更全面视图。当您可以在函数运行之前和完成之后注入跟踪代码时,您可以开始收集更多数据并测量应用程序的行为。一个常见的用例是测量函数执行所需的时间,而无需更改应用程序中的任何代码。
我们将重用我们在“Uprobes”中编写的 Go 程序,以测量执行主函数所需的时间。这个 BPF 示例比您以前看到的示例更长,因此我们将其分成不同的代码块:
bpf_source = """
int trace_go_main(struct pt_regs *ctx) {
u64 pid = bpf_get_current_pid_tgid();
bpf_trace_printk("New hello-bpf process running with PID: %d", pid); 
}
"""
bpf = BPF(text = bpf_source)
bpf.attach_uprobe(name = "hello-bpf", 
sym = "main.main", fn_name = "trace_go_main") 
bpf.trace_print()
创建一个 BPF 哈希映射表。这个表允许我们在 uprobe 和 uretprobe 函数之间共享数据。在本例中,我们使用应用程序的 PID 作为表键,并将函数开始时间存储为值。我们的 uprobe 函数中最有趣的两个操作如下所述。
捕获内核中当前时间的纳秒级时间戳。
在我们的缓存中创建一个条目,其中包含程序的 PID 和当前时间。我们可以假设这个时间是应用程序函数的启动时间。现在让我们声明我们的 uretprobe 函数:
实现在指令完成时附加的函数。这个 uretprobe 函数与您在“Kretprobes”中看到的其他函数类似:
bpf_source += """
static int print_duration(struct pt_regs *ctx) {
u64 pid = bpf_get_current_pid_tgid(); 
u64 start_time_ns = cache.lookup(&pid);
if (start_time_ns == 0) {
return 0;
}
u64 duration_ns = bpf_ktime_get_ns() - start_time_ns;
bpf_trace_printk("Function call duration: %d", duration_ns); 
return 0; 
}
"""
获取我们应用程序的 PID;我们需要它来找到其启动时间。我们使用映射函数lookup从我们之前存储函数运行前时间的映射中获取该时间。
通过从当前时间减去该时间来计算函数持续时间。
将延迟打印到我们的跟踪日志中,以便在终端中显示。
现在,程序的其余部分需要将这两个 BPF 函数附加到正确的探针上:
bpf = BPF(text = bpf_source)
bpf.attach_uprobe(name = "hello-bpf", sym = "main.main",
fn_name = "trace_start_time")
bpf.attach_uretprobe(name = "hello-bpf", sym = "main.main",
fn_name = "print_duration")
bpf.trace_print()
我们已经在我们原始的 uprobe 示例中添加了一行,其中我们将我们的打印函数附加到我们应用程序的 uretprobe 上。
在本节中,您看到了如何使用 BPF 跟踪发生在用户空间中的操作。通过组合在应用程序生命周期的不同点执行的 BPF 函数,您可以开始从中提取更丰富的信息。但是,正如我们在本节开始时提到的,用户空间探针功能强大,但也不稳定。我们的 BPF 示例可能会因为有人决定重命名应用程序的函数而停止工作。现在让我们看一看跟踪用户空间程序的更稳定方法。
用户静态定义的跟踪点
用户静态定义的跟踪点(USDT)为用户空间的应用程序提供了静态跟踪点。这是一种方便的方法来为应用程序添加仪器,因为它们为您提供了 BPF 提供的跟踪能力的低开销入口点。您还可以将它们用作一种约定,用于在生产环境中跟踪应用程序,而不管这些应用程序使用哪种编程语言编写。
USDTs 被 DTrace 推广,DTrace 是最初由 Sun Microsystems 开发的用于动态仪器化 Unix 系统的工具。由于许可问题,DTrace 直到最近才在 Linux 中可用;但是 Linux 内核开发人员从 DTrace 的原始工作中汲取了许多灵感来实现 USDT。
就像您之前看到的静态内核跟踪点一样,USDTs 要求开发人员使用内核将用作陷阱以执行 BPF 程序的指令来仪器化其代码。USDTs 的 Hello World 版本只有几行代码:
#include <sys/sdt.h>
int main() {
DTRACE_PROBE("hello-usdt", "probe-main");
}
在这个例子中,我们使用 Linux 提供的宏来定义我们的第一个 USDT。您已经可以看到内核从哪里得到灵感了。DTRACE_PROBE 将注册内核将用于注入我们的 BPF 函数回调的跟踪点。此宏的第一个参数是报告跟踪的程序。第二个参数是我们正在报告的跟踪的名称。
许多您可能在系统中安装的应用程序使用此类探针,以可预测的方式向您提供运行时跟踪数据访问。例如,流行的数据库 MySQL 使用静态定义的跟踪点公开各种信息。您可以从服务器中执行的查询以及从许多其他用户操作中获取信息。基于 Chrome 的 V8 引擎构建的 JavaScript 运行时 Node.js 也提供了您可以使用的跟踪点,以提取运行时信息。
在向您展示如何将 BPF 程序附加到用户定义的跟踪点之前,我们需要讨论可发现性。因为这些跟踪点在可执行文件内以二进制格式定义,所以我们需要一种列出程序定义的探针的方法,而无需深入源代码。提取此信息的一种方法是直接读取 ELF 二进制文件。首先,我们将编译我们之前的 Hello World USDT 示例;我们可以使用 GCC 来完成:
gcc -o hello_usdt hello_usdt.c
此命令将生成一个名为hello_usdt的二进制文件,我们可以使用多种工具开始探索其定义的跟踪点。Linux 提供了一个名为readelf的实用程序,用于显示关于 ELF 文件的信息。您可以将其与我们编译的示例一起使用:
readelf -n ./hello_usdt
您可以看到我们在此命令的输出中定义的 USDT:
Displaying notes found in: .note.stapsdt
Owner Data size Description
stapsdt 0x00000033 NT_STAPSDT (SystemTap probe descriptors)
Provider: "hello-usdt"
Name: "probe-main"
readelf可以为二进制文件提供大量信息;在我们的小示例中,它仅显示了少量信息行,但对于更复杂的二进制文件,其输出变得难以解析。
发现二进制文件中定义的跟踪点的更好选择是使用 BCC 的tplist工具,它可以显示内核跟踪点和 USDT。该工具的优点是其输出的简单性;它只显示跟踪点定义,不提供有关可执行文件的任何其他信息。其使用方法类似于readelf:
tplist -l ./hello_usdt
它列出您在单独行中定义的每个跟踪点。在我们的示例中,它仅显示了一个包含我们的probe-main定义的单行:
./hello_usdt "hello-usdt":"probe-main"
在您了解二进制文件中支持的跟踪点之后,您可以以与之前示例中所见相似的方式将 BPF 程序附加到它们上:
from bcc import BPF, USDT
bpf_source = """
#include <uapi/linux/ptrace.h>
int trace_binary_exec(struct pt_regs *ctx) {
u64 pid = bpf_get_current_pid_tgid();
bpf_trace_printk("New hello_usdt process running with PID: %d", pid);
}
"""
usdt = USDT(path = "./hello_usdt") 
usdt.enable_probe(probe = "probe-main", fn_name = "trace_binary_exec") 
bpf = BPF(text = bpf_source, usdt = usdt) 
bpf.trace_print()
此示例中有一个重大更改,需要进行一些解释。
创建一个 USDT 对象;我们在之前的示例中没有这样做过。USDT 与 BPF 不同,您可以在不与 BPF 虚拟机交互的情况下使用它们。因为它们彼此独立,所以它们的使用与 BPF 代码的使用是独立的。
将 BPF 函数附加到我们应用程序中的探测点以跟踪程序执行。
初始化我们的 BPF 环境,使用刚刚创建的跟踪点定义。这将通知 BCC 需要生成代码,以连接我们的 BPF 程序与二进制文件中的探测点定义。当它们连接起来时,我们可以打印由我们的 BPF 程序生成的跟踪信息,以发现我们的二进制示例的新执行。
其他语言的 USDT 绑定
您还可以使用 USDT 来跟踪使用除了 C 语言之外的编程语言编写的应用程序。您可以在 GitHub 上找到 Python、Ruby、Go、Node.js 和许多其他语言的绑定。Ruby 绑定是我们喜欢的之一,因为它们与诸如 Rails 之类的框架的简单性和互操作性。目前在 Shopify 工作的 Dale Hamel 在他的博客中撰写了一篇关于 USDT 使用的优秀报告。他还维护着一个名为ruby-static-tracing的库,使得跟踪 Ruby 和 Rails 应用程序变得更加简单。
Hamel 的静态跟踪库允许您在类级别注入跟踪能力,而无需在该类的每个方法中添加跟踪逻辑。在复杂情景中,它还为您提供了方便的方法来注册专用的跟踪端点。
要在您的应用程序中使用ruby-static-tracing,首先需要配置何时启用跟踪点。您可以在应用程序启动时默认启用它们,但如果您想避免始终收集数据的开销,可以使用系统调用信号来激活它们。Hamel 建议使用PROF作为此信号:
require 'ruby-static-tracing'
StaticTracing.configure do |config|
config.mode = StaticTracing::Configuration::Modes::SIGNAL
config.signal = StaticTracing::Configuration::Modes::SIGNALS::SIGPROF
end
有了这个配置,您可以使用kill命令随需启用您应用程序的静态跟踪点。在下一个示例中,我们假设我们的机器上只有一个 Ruby 进程正在运行,并且我们可以使用pgrep获取其进程标识符:
kill -SIGPROF `pgrep -nx ruby`
除了配置跟踪点何时激活外,您可能还想使用ruby-static-tracing提供的一些内置跟踪机制。在撰写本文时,该库整合了跟踪点以测量延迟和收集堆栈跟踪。我们特别喜欢如何通过使用这个内置模块使得诸如测量函数延迟这样的繁琐任务几乎变得微不足道。首先,您需要将延迟跟踪器添加到您的初始配置中:
require 'ruby-static-tracing'
require 'ruby-static-tracing/tracer/concerns/latency_tracer'
StaticTracing.configure do |config|
config.add_tracer(StaticTracing::Tracer::Latency)
end
之后,每个包含延迟模块的类都会为其定义的每个公共方法生成静态跟踪点。启用跟踪时,您可以查询这些跟踪点以收集时间数据。在我们的下一个示例中,ruby-static-tracing生成一个名为usdt:/proc/X/fd/Y:user_model:find的静态跟踪点,遵循使用类名作为跟踪点的命名空间并使用方法名作为跟踪点名称的约定:
class UserModel
def find(id)
end
include StaticTracing::Tracer::Concerns::Latency
end
现在我们可以使用 BCC 提取每次调用我们的find方法时的延迟信息。为此,我们使用了 BCC 的内置函数bpf_usdt_readarg和bpf_usdt_readarg_p。这些函数每次我们应用程序的代码被执行时读取设置的参数。ruby-static-tracing始终将方法名设置为跟踪点的第一个参数,而将计算出的值设置为第二个参数。下一段代码片段实现了获取跟踪点信息并将其打印到跟踪日志中的 BPF 程序:
bpf_source = """
#include <uapi/linux/ptrace.h>
int trace_latency(struct pt_regs *ctx) {
char method[64];
u64 latency;
bpf_usdt_readarg_p(1, ctx, &method, sizeof(method));
bpf_usdt_readarg(2, ctx, &latency);
bpf_trace_printk("method %s took %d ms", method, latency);
}
"""
我们还需要将之前的 BPF 程序加载到内核中。因为我们正在跟踪一个已在本机上运行的特定应用程序,所以可以将程序附加到特定的进程标识符上:
parser = argparse.ArgumentParser()
parser.add_argument("-p", "--pid", type = int, help = "Process ID") 
args = parser.parse_args()
usdt = USDT(pid = int(args.pid))
usdt.enable_probe(probe = "latency", fn_name = "trace_latency") 
bpf = BPF(text = bpf_source, usdt = usdt)
bpf.trace_print()
指定该 PID。
启用探针,将程序加载到内核中,并打印跟踪日志。(本节与您之前看到的非常相似。)
在本节中,我们展示了如何检查静态定义跟踪点的应用程序。许多知名的库和编程语言都包含这些探针,以帮助您在生产环境中调试正在运行的应用程序,从而获得更多的可见性。这只是冰山一角;在获取数据之后,您需要理解这些数据。这是我们接下来要探讨的内容。
可视化跟踪数据
到目前为止,我们展示了打印数据的示例,这在生产环境中并不是非常有用。您希望理解这些数据,但没有人喜欢理解冗长且复杂的日志。如果我们想要监视延迟和 CPU 利用率的变化,通过查看一段时间内的图表比从文件流中聚合数字更容易。
本节探讨了展示 BPF 跟踪数据的不同方法。一方面,我们将展示 BPF 程序如何为您结构化信息聚合。另一方面,您将学习如何以便携的表示形式导出这些信息,并使用现成工具访问更丰富的表示,并与他人分享您的发现。
火焰图
火焰图 是帮助您可视化系统花费时间的图表。它们可以清晰地展示应用程序中哪些代码执行得更频繁。火焰图的创建者布兰登·格雷格在 GitHub 上维护了一组脚本,以便轻松生成这些可视化格式的工具 on GitHub。我们在本节的后面部分使用这些脚本从使用 BPF 收集的数据生成火焰图。您可以在 Figure 4-1 中看到这些图表的样式。

图 4-1. 一个 CPU 火焰图
关于火焰图展示的两个重要事项:
-
x 轴按字母顺序排序。每个堆栈的宽度表示在收集数据时它出现的频率,这可以与分析器启用时访问代码路径的频率相关联。
-
y 轴显示堆栈跟踪,按照分析器读取它们的顺序排列,保留了跟踪的层次结构。
最知名的火焰图代表了系统中消耗 CPU 最频繁的代码;这些称为on-CPU 图。另一种有趣的火焰图可视化是off-CPU 图;它们表示 CPU 在与您的应用程序无关的其他任务上花费的时间。通过结合 on-CPU 和 off-CPU 图,您可以完整地了解系统在 CPU 周期上的花费情况。
on-CPU 和 off-CPU 图都使用堆栈跟踪来指示系统花费时间的位置。某些编程语言(如 Go)始终在其二进制文件中包含跟踪信息,但其他一些(如 C++ 和 Java)需要额外的工作来使堆栈跟踪可读。在应用程序包含堆栈跟踪信息后,BPF 程序可以使用它来聚合内核看到的最频繁的代码路径。
注意
内核中的堆栈跟踪聚合有其优势和劣势。一方面,它是计算堆栈跟踪频率的高效方式,因为它发生在内核中,避免了将每个堆栈信息发送到用户空间,并减少了内核与用户空间之间的数据交换。另一方面,处理离 CPU 图表的事件数量可能会显著增加,因为你要跟踪在应用程序上下文切换期间发生的每个事件。如果试图对其进行长时间性能分析,这可能会给系统带来显著的开销。在使用火焰图时,请牢记这一点。
BCC 提供了几个实用工具,帮助你聚合和可视化堆栈跟踪,但主要工具是宏 BPF_STACK_TRACE。这个宏生成了一个类型为 BPF_MAP_TYPE_STACK_TRACE 的 BPF Map,用于存储你的 BPF 程序积累的堆栈。此外,这个 BPF Map 还增强了方法,用于从程序上下文中提取堆栈信息,并在聚合后想要使用时遍历积累的堆栈跟踪。
在下一个示例中,我们构建了一个简单的 BPF 分析器,用于打印从用户空间应用程序收集的堆栈跟踪。我们使用我们的分析器收集的跟踪生成了 on-CPU 火焰图。为了测试这个分析器,我们将编写一个生成 CPU 负载的最小化 Go 程序。以下是该最小应用程序的代码:
package main
import "time"
func main() {
j := 3
for time.Since(time.Now()) < time.Second {
for i := 1; i < 1000000; i++ {
j *= i
}
}
}
如果你将这段代码保存为 main.go 文件,并使用 go run main.go 运行它,你会看到系统的 CPU 利用率显著增加。你可以通过在键盘上按下 Ctrl-C 来停止执行,CPU 利用率将恢复正常。
我们 BPF 程序的第一部分将初始化分析器结构:
bpf_source = """
#include <uapi/linux/ptrace.h>
#include <uapi/linux/bpf_perf_event.h>
#include <linux/sched.h>
struct trace_t { 
int stack_id;
}
BPF_HASH(cache, struct trace_t); 
BPF_STACK_TRACE(traces, 10000); 
"""
初始化一个结构,用于存储我们的分析器接收到的每一个堆栈帧的参考标识符。稍后我们使用这些标识符来查找在该时间点执行的代码路径。
初始化一个 BPF 哈希 Map,用于聚合同一堆栈帧的频率。火焰图脚本使用这个聚合值来确定同一代码执行的频率。
初始化我们的 BPF 堆栈跟踪 Map。我们为这个 Map 设置了最大大小,但它可以根据你要处理的数据量而变化。最好将这个值作为变量,但我们知道我们的 Go 应用程序不是很大,所以 10,000 个元素已经足够。
接下来,我们实现了在我们的分析器中聚合堆栈跟踪的函数:
bpf_source += """
int collect_stack_traces(struct bpf_perf_event_data *ctx) {
u32 pid = bpf_get_current_pid_tgid() >> 32; 
if (pid != PROGRAM_PID)
return 0;
struct trace_t trace = { 
.stack_id = traces.get_stackid(&ctx->regs, BPF_F_USER_STACK)
};
cache.increment(trace); 
return 0;
}
"""
确认当前 BPF 上下文中程序的进程 ID 是否与我们 Go 应用程序的相符;否则,我们将忽略此事件。当前我们尚未定义 PROGRAM_PID 的值。在初始化 BPF 程序之前,让我们在分析器的 Python 部分替换这个字符串。这是 BCC 初始化 BPF 程序方式的一种限制;我们无法从用户空间传递任何变量,并且通常在初始化代码之前会替换这些字符串。
创建一个跟踪以聚合其使用情况。我们从程序上下文中使用内置函数 get_stackid 获取堆栈 ID。这是 BCC 添加到我们堆栈跟踪映射中的帮助之一。我们使用标志 BPF_F_USER_STACK 表示我们要获取用户空间应用程序的堆栈 ID,并且我们不关心内核内部发生的事情。
增加我们的跟踪计数器,以跟踪相同代码被执行的频率。
接下来,我们将把我们的堆栈跟踪收集器附加到内核中所有的 Perf 事件上:
program_pid = int(sys.argv[0]) 
bpf_source = bpf_source.replace('PROGRAM_PID', program_pid) 
bpf = BPF(text = bpf_source)
bpf.attach_perf_event(ev_type = PerfType.SOFTWARE, 
ev_config = PerfSWConfig.CPU_CLOCK,
fn_name = 'collect_stack_traces')
我们 Python 程序的第一个参数。这是我们正在分析的 Go 应用程序的进程标识符。
使用 Python 内置的 replace 函数,将字符串 PROGRAM_ID 在我们的 BPF 源码中替换为分析器提供的参数。
将 BPF 程序附加到所有软件 Perf 事件上,这将忽略其他事件,如硬件事件。我们还配置我们的 BPF 程序使用 CPU 时钟作为时间源,以便测量执行时间。
最后,在分析器被中断时,我们需要实现将堆栈跟踪转储到标准输出的代码。
try:
sleep(99999999)
except KeyboardInterrupt:
signal.signal(signal.SIGINT, signal_ignore)
for trace, acc in sorted(cache.items(), key=lambda cache: cache[1].value): 
line = []
if trace.stack_id < 0 and trace.stack_id == -errno.EFAULT 
line = ['Unknown stack']
else
stack_trace = list(traces.walk(trace.stack_id))
for stack_address in reversed(stack_trace) 
line.extend(bpf.sym(stack_address, program_pid)) 
frame = b";".join(line).decode('utf-8', 'replace') 
print("%s %d" % (frame, acc.value))
迭代我们收集的所有跟踪,以便按顺序打印它们。
验证我们得到的堆栈标识符,以便稍后可以将其与特定代码行相关联。如果得到无效值,我们将在火焰图中使用占位符。
逆序迭代堆栈跟踪中的所有条目。我们这样做是因为我们希望看到最近执行的代码路径位于顶部,就像在任何堆栈跟踪中所期望的那样。
使用 BCC 辅助工具 sym 将堆栈帧的内存地址转换为我们源代码中的函数名称。
将堆栈跟踪行格式化为分号分隔的格式。这是火焰图脚本后续期望能够生成我们可视化的格式。
完成了我们的 BPF 分析器后,我们可以以sudo权限运行它,收集我们繁忙的 Go 程序的堆栈跟踪。我们需要将 Go 程序的进程 ID 传递给我们的分析器,以确保我们仅收集该应用程序的跟踪;我们可以使用pgrep找到该 PID。如果将其保存在名为profiler.py的文件中,则以下是运行分析器的方法:
./profiler.py `pgrep -nx go` > /tmp/profile.out
pgrep将搜索在您的系统上运行名称匹配go的进程的 PID。我们将分析器的输出发送到临时文件中,以便我们可以生成火焰图可视化。
正如我们之前提到的,我们将使用 Brendan Gregg 的 FlameGraph 脚本来生成我们图表的 SVG 文件;您可以在他的GitHub 存储库中找到这些脚本。下载了该存储库后,您可以使用flamegraph.pl来生成图表。您可以使用您喜欢的浏览器打开图表;我们在这个示例中使用的是 Firefox 浏览器:
./flamegraph.pl /tmp/profile.out > /tmp/flamegraph.svg && \
firefox /tmp/flamefraph.svg
这种性能分析工具非常有用,可以用于追踪系统中的性能问题。BCC 已经包含了一个比我们示例中更高级的分析工具,您可以直接在生产环境中使用它。除了性能分析工具外,BCC 还包括帮助您生成 CPU 闲置火焰图和许多其他可视化工具来分析系统。
火焰图对性能分析非常有用。我们在日常工作中经常使用它们。在许多场景中,除了可视化热点代码路径外,您还希望测量系统中事件发生的频率。接下来我们将重点关注这一点。
直方图
直方图是显示多个数值范围发生频率的图表。用于表示数值数据的区间分为多个桶,每个桶内包含桶中任何数据点的出现次数。直方图测量的频率是每个桶的高度和宽度的组合。如果桶的范围是相等的,这个频率就匹配直方图的高度,但如果范围不是均匀的,则需要将每个高度乘以每个宽度以找到正确的频率。
直方图是进行系统性能分析的基本组件。它们是表示可测量事件(如指令延迟)分布的强大工具,因为它们向您展示的信息比其他测量方法(如平均值)更准确。
BPF 程序可以基于许多指标创建直方图。您可以使用 BPF 映射收集信息,将其分类到桶中,然后为数据生成直方图表示。实现此逻辑并不复杂,但如果您想要在每次分析程序输出时打印直方图,这将变得很乏味。BCC 已经包含了一个开箱即用的实现,您可以在每个程序中重复使用它,而无需手动计算分桶和频率。然而,内核源代码中也有一个出色的实现,我们建议您在 BPF 示例中查看它。
作为一个有趣的实验,我们将展示如何使用 BCC 的直方图来可视化在应用程序调用bpf_prog_load指令时加载 BPF 程序引入的延迟。我们使用 kprobes 来收集该指令完成所需的时间,并将结果累积在稍后将可视化的直方图中。我们已将此示例分成几个部分,以便更容易跟进。
第一部分包括我们的 BPF 程序的初始源代码:
bpf_source = """
#include <uapi/linux/ptrace.h>
BPF_HASH(cache, u64, u64);
BPF_HISTOGRAM(histogram);
int trace_bpf_prog_load_start(void ctx) { 
u64 pid = bpf_get_current_pid_tgid(); 
u64 start_time_ns = bpf_ktime_get_ns();
cache.update(&pid, &start_time_ns); 
return 0;
}
"""
使用宏创建一个 BPF 哈希映射,以存储bpf_prog_load指令触发时的初始时间。
使用新的宏创建一个 BPF 直方图映射。这不是一个原生 BPF 映射;BCC 包含这个宏是为了让您更容易创建这些可视化效果。在底层,这个 BPF 直方图使用数组映射来存储信息。它还有几个帮助函数来进行桶分配并创建最终的图表。
使用程序的 PID 来存储应用程序触发我们想要跟踪的指令的时间。(这个函数会让您感到熟悉——我们从先前的 Uprobes 示例中获取了它。)
让我们看看如何计算延迟的时间差并将其存储在我们的直方图中。这个新代码块的初始行也会看起来很熟悉,因为我们仍然在遵循我们在"Uprobes"中讨论的示例。
bpf_source += """
int trace_bpf_prog_load_return(void ctx) {
u64 *start_time_ns, delta;
u64 pid = bpf_get_current_pid_tgid();
start_time_ns = cache.lookup(&pid);
if (start_time_ns == 0)
return 0;
delta = bpf_ktime_get_ns() - *start_time_ns; 
histogram.increment(bpf_log2l(delta)); 
return 0;
}
"""
计算指令被调用时与程序到达此处所需的时间差;我们可以假设这也是指令完成时的时间。
将该时间差存储在我们的直方图中。在这行中,我们进行了两个操作。首先,我们使用内置函数bpf_log2l为时间差的值生成桶标识符。该函数会随着时间生成一个稳定的值分布。然后,我们使用increment函数向此桶添加一个新项。默认情况下,如果直方图中存在该桶,则increment将其值加 1;如果不存在,则创建一个初始值为 1 的新桶,因此您不必担心事先存在的值。
我们需要编写的最后一段代码是将这两个函数附加到有效的 kprobes 并将直方图打印到屏幕上,以便我们可以查看延迟分布。在这一部分,我们初始化我们的 BPF 程序并等待事件以生成直方图:
bpf = BPF(text = bpf_source) 
bpf.attach_kprobe(event = "bpf_prog_load",
fn_name = "trace_bpf_prog_load_start")
bpf.attach_kretprobe(event = "bpf_prog_load",
fn_name = "trace_bpf_prog_load_return")
try: 
sleep(99999999)
except KeyboardInterrupt:
print()
bpf["histogram"].print_log2_hist("msecs") 
初始化 BPF 并将我们的函数附加到 kprobes。
使我们的程序等待,以便我们可以从系统中收集到足够的事件。
在终端上打印带有跟踪事件分布的直方图映射——这是另一个允许我们获取直方图映射的 BCC 宏。
正如我们在本节开头提到的,直方图可以用来观察系统中的异常情况。BCC 工具包括许多使用直方图表示数据的脚本;当您需要启发以深入了解系统时,我们强烈推荐您查看它们。
Perf 事件
我们认为 Perf 事件可能是您需要掌握的最重要的通信方法,以成功使用 BPF 跟踪。我们在前一章讨论了 BPF Perf 事件数组映射。它们允许您将数据放入一个缓冲环中,实时与用户空间程序同步。当您在 BPF 程序中收集大量数据并希望将处理和可视化转移到用户空间程序时,这是理想的选择。这将使您能够更好地控制呈现层,因为您不受 BPF VM 在编程能力上的限制。您可以找到的大多数 BPF 跟踪程序仅出于此目的使用 Perf 事件。
这里,我们向您展示如何使用它们来提取有关二进制执行的信息,并将该信息分类,以打印出系统中最常执行的二进制文件。我们将此示例分为两个代码块,以便您可以轻松地跟随示例。在第一个代码块中,我们定义了我们的 BPF 程序,并将其附加到一个 kprobe,就像我们在“探针”中所做的那样:
bpf_source = """
#include <uapi/linux/ptrace.h>
BPF_PERF_OUTPUT(events); 
int do_sys_execve(struct pt_regs *ctx, void filename, void argv, void envp) {
char comm[16];
bpf_get_current_comm(&comm, sizeof(comm));
events.perf_submit(ctx, &comm, sizeof(comm)); 
return 0;
}
"""
bpf = BPF(text = bpf_source) 
execve_function = bpf.get_syscall_fnname("execve")
bpf.attach_kprobe(event = execve_function, fn_name = "do_sys_execve")
在此示例的第一行中,我们从 Python 标准库导入了一个库。我们将使用 Python 计数器来聚合我们从 BPF 程序接收到的事件。
使用BPF_PERF_OUTPUT来声明一个 Perf 事件映射。这是 BCC 提供的一个便利宏,用于声明这种类型的映射。我们将此映射命名为events。
在我们获得内核执行的程序名称后,将其发送到用户空间进行聚合。我们使用perf_submit来完成这个任务。此函数使用我们的新信息更新 Perf 事件映射。
初始化 BPF 程序并将其附加到 kprobe,以在系统中执行新程序时触发。
现在我们已经编写了收集系统中所有执行程序的代码,我们需要在用户空间中对它们进行聚合。在下一个代码片段中有很多信息,所以我们将带您浏览最重要的几行:
from collections import Counter
aggregates = Counter() 
def aggregate_programs(cpu, data, size): 
comm = bpf["events"].event(data) 
aggregates[comm] += 1
bpf["events"].open_perf_buffer(aggregate_programs) 
while True:
try:
bpf.perf_buffer_poll()
except KeyboardInterrupt: 
break
for (comm, times) in aggregates.most_common():
print("Program {} executed {} times".format(comm, times))
声明一个计数器来存储我们的程序信息。我们将程序的名称用作键,值将是计数器。我们使用aggregate_programs函数来从 Perf 事件映射中收集数据。在这个示例中,您可以看到我们如何使用 BCC 宏来访问映射,并从栈顶提取下一个传入数据事件。
增加我们接收到具有相同程序名称的事件的次数。
使用函数 open_perf_buffer 告诉 BCC 每次从 Perf 事件映射接收事件时都需要执行 aggregate_programs 函数。
在打开环形缓冲区后,BCC 会轮询事件,直到我们中断此 Python 程序。您等待的时间越长,处理的信息就越多。您可以看到我们如何使用 perf_buffer_poll 来实现这一目的。
使用 most_common 函数获取计数器中的元素列表,并循环打印系统中首先执行的顶级程序。
Perf 事件可以打开处理 BPF 提供的所有数据的大门,以新颖和意想不到的方式。我们展示了一个示例,启发您的想象力,当您需要从内核中收集某种任意数据时;您可以在 BCC 提供的跟踪工具中找到许多其他示例。
结论
在这一章中,我们只是初步涉及了使用 BPF 进行跟踪的内容。Linux 内核为您提供了一些其他工具难以获取的信息。BPF 通过提供一个通用接口来访问这些数据,使得这个过程更加可预测。在接下来的章节中,您将看到更多使用本章介绍的技术的示例,例如将函数附加到跟踪点。这些示例将帮助您巩固在这里学到的知识。
在本章中,我们使用了 BCC 框架编写了大部分示例。您可以像在之前的章节中所做的那样,在 C 语言中实现相同的示例,但 BCC 提供了几个内置功能,使编写跟踪程序比 C 语言更加容易。如果您愿意接受一个有趣的挑战,请尝试用 C 语言重写这些示例。
在下一章中,我们会展示一些构建在 BPF 之上的系统社区构建的工具,用于进行性能分析和跟踪。编写自己的程序是强大的,但这些专用工具使您可以以打包的方式访问我们在这里看到的大部分信息。这样,您就无需重写已经存在的工具。
第五章:BPF 实用工具
迄今为止,我们已经讨论了如何编写 BPF 程序来提高系统内的可见性。多年来,许多开发人员使用 BPF 构建了同样目的的工具。在本章中,我们讨论了几种您可以每天使用的现成工具。其中许多工具是您已经见过的某些 BPF 程序的高级版本。其他工具将帮助您直接查看自己的 BPF 程序。
本章涵盖了一些工具,这些工具将帮助您在日常工作中使用 BPF。我们首先介绍了 BPFTool,这是一个命令行实用程序,可提供有关您的 BPF 程序更多信息。我们还介绍了 BPFTrace 和kubectl-trace,这些工具将帮助您使用简洁的特定领域语言(DSL)更有效地编写 BPF 程序。最后,我们讨论了 eBPF Exporter,这是一个将 BPF 与 Prometheus 集成的开源项目。
BPFTool
BPFTool 是一个内核实用程序,用于检查 BPF 程序和映射。此工具不会默认安装在任何 Linux 发行版上,并且正在大力开发中,因此您需要编译最适合您的 Linux 内核的版本。我们涵盖了与 Linux 内核版本 5.1 分发的 BPFTool 版本。
在接下来的章节中,我们将讨论如何将 BPFTool 安装到您的系统上,并如何使用它从终端观察和更改您的 BPF 程序和映射的行为。
安装
要安装 BPFTool,您需要下载内核源代码的副本。您的特定 Linux 发行版可能有一些在线包,但我们将介绍如何从源代码安装,因为这并不太复杂。
-
使用
git clone https://github.com/torvalds/linux命令从 GitHub 克隆存储库。 -
使用
git checkout v5.1命令检出特定的内核版本标签。 -
在内核源代码中,使用
cd tools/bpf/bpftool命令导航到存储 BPFTool 源代码的目录。 -
使用
make && sudo make install命令编译并安装此工具。
检查 BPFTool 是否正确安装,可以通过检查其版本来确认:
# bpftool --version
bpftool v5.1.0
特性展示
使用 BPFTool 的基本操作之一是扫描系统,以了解您可以访问哪些 BPF 特性。当您不记得内核的哪个版本引入了哪种程序类型或 BPF JIT 编译器是否已启用时,这非常有用。要找出这些问题的答案及其他问题,请运行以下命令:
# bpftool feature
您将获得有关系统中所有支持的 BPF 特性的详细信息的长输出。为了简洁起见,我们在此展示了其剪裁版本:
Scanning system configuration...
bpf() syscall for unprivileged users is enabled
JIT compiler is enabled
...
Scanning eBPF program types...
eBPF program_type socket_filter is available
eBPF program_type kprobe is NOT available
...
Scanning eBPF map types...
eBPF map_type hash is available
eBPF map_type array is available
在此输出中,您可以看到我们的系统允许非特权用户执行bpf系统调用,此调用受限于某些操作。您还可以看到 JIT 已启用。内核的新版本默认启用此 JIT,在编译 BPF 程序时非常有用。如果您的系统未启用此功能,可以运行以下命令来启用它:
# echo 1 > /proc/sys/net/core/bpf_jit_enable
特性输出还显示了在你的系统中启用了哪些程序类型和映射类型。该命令公开的信息比我们这里展示的要多得多,例如,程序类型支持的 BPF 助手及许多其他配置指令。在探索系统时,可以随意深入了解它们。
知道你可以使用哪些功能非常有用,尤其是当你需要深入了解一个未知的系统时。接下来,我们准备介绍其他有趣的 BPFTool 功能,比如检查已加载的程序。
检查 BPF 程序
BPFTool 直接提供有关内核中 BPF 程序的信息。它允许你调查系统中已经运行的内容。它还允许你加载和固定之前从命令行编译的新 BPF 程序。
学习如何使用 BPFTool 与程序一起工作的最佳起点是检查系统中正在运行的内容。为此,你可以运行bpftool prog show命令。如果你使用 Systemd 作为启动系统,你可能已经加载了一些 BPF 程序并将其附加到一些 cgroups 上;我们稍后会详细讨论这些。运行该命令的输出如下所示:
52: cgroup_skb tag 7be49e3934a125ba
loaded_at 2019-03-28T16:46:04-0700 uid 0
xlated 296B jited 229B memlock 4096B map_ids 52,53
53: cgroup_skb tag 2a142ef67aaad174
loaded_at 2019-03-28T16:46:04-0700 uid 0
xlated 296B jited 229B memlock 4096B map_ids 52,53
54: cgroup_skb tag 7be49e3934a125ba
loaded_at 2019-03-28T16:46:04-0700 uid 0
xlated 296B jited 229B memlock 4096B map_ids 54,55
冒号前面的左侧数字是程序标识符;稍后我们会使用它们来调查这些程序的具体内容。从这个输出中,你还可以了解你的系统正在运行哪些类型的程序。在这种情况下,系统正在运行附加到 cgroup 套接字缓冲区的三个 BPF 程序。如果这些程序确实是由 Systemd 启动的,加载时间可能与你启动系统时相匹配。你还可以看到这些程序当前正在使用多少内存以及与它们相关联的映射的标识符。所有这些信息一目了然,并且因为我们有程序标识符,所以我们可以进一步深入了解。
你可以将程序标识符作为额外参数添加到前一个命令中:bpftool prog show id 52。这样,BPFTool 会显示与 ID 52 对应的相同信息,这样你就可以过滤掉不需要的信息。此命令还支持--json标志以生成一些 JSON 输出。如果你想操作输出,这个 JSON 输出非常方便。例如,像jq这样的工具可以为这些数据提供更结构化的格式化:
# bpftool prog show --json id 52 | jq
{
"id": 52,
"type": "cgroup_skb",
"tag": "7be49e3934a125ba",
"gpl_compatible": false,
"loaded_at": 1553816764,
"uid": 0,
"bytes_xlated": 296,
"jited": true,
"bytes_jited": 229,
"bytes_memlock": 4096,
"map_ids": [
52,
53
]
}
你还可以执行更高级的操作,并且仅筛选你感兴趣的信息。在下一个例子中,我们只关心知道 BPF 程序标识符、程序类型以及它何时加载到内核中:
# bpftool prog show --json id 52 | jq -c '[.id, .type, .loaded_at]'
[52,"cgroup_skb",1553816764]
当你知道一个程序标识符时,你还可以使用 BPFTool 获取整个程序的转储;当你需要调试编译器生成的 BPF 字节码时,这可能会很方便:
# bpftool prog dump xlated id 52
0: (bf) r6 = r1
1: (69) r7 = *(u16 *)(r6 +192)
2: (b4) w8 = 0
3: (55) if r7 != 0x8 goto pc+14
4: (bf) r1 = r6
5: (b4) w2 = 16
6: (bf) r3 = r10
7: (07) r3 += -4
8: (b4) w4 = 4
9: (85) call bpf_skb_load_bytes#7151872
...
我们的 Systemd 加载的这个程序正在使用bpf_skb_load_bytes助手检查数据包数据。
如果您希望以更直观的方式表示此程序,包括指令跳转,可以在此命令中使用visual关键字。这将以一种可以使用dotty等工具转换为图形表示的格式生成输出:
# bpftool prog dump xlated id 52 visual &> output.out
# dot -Tpng output.out -o visual-graph.png
您可以在图 5-1 中看到一个小型 Hello World 程序的可视表示。

图 5-1. BPF 程序的可视表示
如果您正在运行内核的 5.1 版本或更新版本,您还可以访问运行时统计信息。这些信息告诉您内核在运行您的 BPF 程序时花费了多少时间。此功能可能不会在您的系统中默认启用;您需要先运行此命令,让内核知道需要显示这些数据:
# sysctl -w kernel.bpf_stats_enabled=1
启用统计信息后,运行 BPFTool 时,您将获得两个额外的信息片段:内核运行该程序所花费的总时间(run_time_ns)以及运行次数(run_cnt):
52: cgroup_skb tag 7be49e3934a125ba run_time_ns 14397 run_cnt 39
loaded_at 2019-03-28T16:46:04-0700 uid 0
xlated 296B jited 229B memlock 4096B map_ids 52,53
但是 BPFTool 不仅允许您检查程序的执行情况;它还允许您将新程序加载到内核中,并将其中一些程序附加到套接字和 cgroups。例如,我们可以加载先前的程序之一并将其固定到 BPF 文件系统中,使用以下命令:
# bpftool prog load bpf_prog.o /sys/fs/bpf/bpf_prog
因为程序被固定到文件系统,运行后不会终止,我们可以看到它仍然加载在前面的show命令中:
# bpftool prog show
52: cgroup_skb tag 7be49e3934a125ba
loaded_at 2019-03-28T16:46:04-0700 uid 0
xlated 296B jited 229B memlock 4096B map_ids 52,53
53: cgroup_skb tag 2a142ef67aaad174
loaded_at 2019-03-28T16:46:04-0700 uid 0
xlated 296B jited 229B memlock 4096B map_ids 52,53
54: cgroup_skb tag 7be49e3934a125ba
loaded_at 2019-03-28T16:46:04-0700 uid 0
xlated 296B jited 229B memlock 4096B map_ids 54,55
60: perf_event name bpf_prog tag c6e8e35bea53af79
loaded_at 2019-03-28T20:46:32-0700 uid 0
xlated 112B jited 115B memlock 4096B
正如您所见,BPFTool 为您提供了有关加载在内核中的程序的大量信息,而无需编写和编译任何代码。接下来让我们看看如何使用 BPF 映射。
检查 BPF 映射
除了让您访问检查和操作 BPF 程序外,BPFTool 还可以让您访问这些程序正在使用的 BPF 映射。列出所有映射并按其标识符过滤的命令类似于您之前看到的show命令。而不是要求 BPFTool 显示prog的信息,让我们要求它显示map的信息:
# bpftool map show
52: lpm_trie flags 0x1
key 8B value 8B max_entries 1 memlock 4096B
53: lpm_trie flags 0x1
key 20B value 8B max_entries 1 memlock 4096B
54: lpm_trie flags 0x1
key 8B value 8B max_entries 1 memlock 4096B
55: lpm_trie flags 0x1
key 20B value 8B max_entries 1 memlock 4096B
这些映射与您早期在程序中看到的标识符匹配。您还可以按照它们的 ID 过滤映射,就像我们早期按照程序的 ID 过滤程序一样。
使用 BPFTool 可以创建和更新映射,并列出映射中的所有元素。创建新映射需要与初始化映射时提供的相同信息,以及您的程序之一。我们需要指定要创建的映射类型、键和值的大小以及其名称。因为我们没有与程序一起初始化映射,所以我们还需要将其固定到 BPF 文件系统,以便稍后使用:
# bpftool map create /sys/fs/bpf/counter
type array key 4 value 4 entries 5 name counter
在运行该命令后,如果列出系统中的映射,您将在列表底部看到新映射:
52: lpm_trie flags 0x1
key 8B value 8B max_entries 1 memlock 4096B
53: lpm_trie flags 0x1
key 20B value 8B max_entries 1 memlock 4096B
54: lpm_trie flags 0x1
key 8B value 8B max_entries 1 memlock 4096B
55: lpm_trie flags 0x1
key 20B value 8B max_entries 1 memlock 4096B
56: lpm_trie flags 0x1
key 8B value 8B max_entries 1 memlock 4096B
57: lpm_trie flags 0x1
key 20B value 8B max_entries 1 memlock 4096B
58: array name counter flags 0x0
key 4B value 4B max_entries 5 memlock 4096B
创建映射后,您可以更新和删除元素,就像我们在 BPF 程序内部所做的那样。
提示
请记住,你不能从固定大小的数组中删除元素;你只能更新它们。但是你可以完全删除其他地图中的元素,例如哈希地图。
如果你想向地图中添加新元素或更新现有元素,可以使用map update命令。你可以从前面的示例中获取地图标识符:
# bpftool map update id 58 key 1 0 0 0 value 1 0 0 0
如果尝试使用无效的键或值更新元素,BPFTool 将返回错误:
# bpftool map update id 58 key 1 0 0 0 value 1 0 0
Error: value expected 4 bytes got 3
如果需要检查其值,BPFTool 可以为你提供地图中所有元素的转储。当你创建固定大小数组地图时,你可以看到 BPF 如何将所有元素初始化为 null 值:
# bpftool map dump id 58
key: 00 00 00 00 value: 00 00 00 00
key: 01 00 00 00 value: 01 00 00 00
key: 02 00 00 00 value: 00 00 00 00
key: 03 00 00 00 value: 00 00 00 00
key: 04 00 00 00 value: 00 00 00 00
BPFTool 给你提供的最强大选项之一是你可以将预先创建的地图附加到新程序上,并用这些预分配的地图替换它们将初始化的地图。这样,即使你没有编写程序从 BPF 文件系统读取地图,也可以在程序加载时给程序访问保存的数据。为此,你需要在使用 BPFTool 加载程序时设置要初始化的地图。你可以通过地图的有序标识符指定地图,例如第一个地图为 0,第二个为 1,依此类推。你也可以通过地图的名称指定地图,这通常更方便:
# bpftool prog load bpf_prog.o /sys/fs/bpf/bpf_prog_2 \
map name counter /sys/fs/bpf/counter
在这个例子中,我们将刚创建的地图附加到一个新程序中。在这种情况下,我们通过地图名称替换地图,因为我们知道该程序初始化了一个名为counter的地图。如果你觉得更容易记忆,你也可以使用关键词idx来使用地图的索引位置,例如idx 0。
直接从命令行访问 BPF 地图在需要实时调试消息传递时非常有用。BPFTool 以方便的方式为你提供直接访问。除了检查程序和地图之外,你还可以使用 BPFTool 从内核中提取更多信息。接下来让我们看看如何访问特定接口。
检查连接到特定接口的程序
有时你会想知道哪些程序连接到特定接口。BPF 可以加载在 cgroups、Perf 事件和网络数据包之上运行的程序。子命令cgroup、perf和net可以帮助你跟踪这些接口上的附件。
perf子命令列出系统中跟踪点(如 kprobes、uprobes 和 tracepoints)附加的所有程序;你可以通过运行bpftool perf show查看此列表。
net子命令列出连接到 XDP 和 Traffic Control 的程序。像套接字过滤器和复用端口程序这样的其他附件只能通过使用iproute2访问。你可以像查看其他 BPF 对象一样,使用bpftool net show列出连接到 XDP 和 TC 的附件。
最后,cgroup 子命令列出附加到 cgroups 的所有程序。这个子命令与您看到的其他命令有点不同。bpftool cgroup show 需要指定要检查的 cgroup 的路径。如果要列出系统中所有 cgroups 中的所有附件,您需要使用 bpftool cgroup tree,如本例所示:
# bpftool cgroup tree
CgroupPath
ID AttachType AttachFlags Name
/sys/fs/cgroup/unified/system.slice/systemd-udevd.service
5 ingress
4 egress
/sys/fs/cgroup/unified/system.slice/systemd-journald.service
3 ingress
2 egress
/sys/fs/cgroup/unified/system.slice/systemd-logind.service
7 ingress
6 egress
有了 BPFTool,您可以验证程序是否正确附加到内核中的任何接口,从而快速访问 cgroups、Perf 和网络接口。
到目前为止,我们已经讨论了如何在终端中输入不同的命令来调试您的 BPF 程序的行为。然而,在您最需要它们时,记住所有这些命令可能会很麻烦。接下来我们描述如何从纯文本文件中加载多个命令,以便您可以建立一组脚本,而无需记住我们讨论过的每个选项。
批处理模式加载命令
在您试图分析一个或多个系统的行为时,运行多个命令多次是很常见的。您可能会最终得到一组您在工具链中经常使用的命令集合。如果您不想每次都输入这些命令,BPFTool 的批处理模式就是为您准备的。
使用批处理模式,您可以将想要执行的所有命令写入一个文件中,并一次性运行它们。您还可以通过在行首添加 # 来在此文件中编写注释。然而,这种执行模式不是原子性的。BPFTool 逐行执行命令,并且如果其中一个命令失败,它将中止执行,使系统保持在最新成功命令运行后的状态。
这是批处理模式可以处理的文件的一个简短示例:
# Create a new hash map
map create /sys/fs/bpf/hash_map type hash key 4 value 4 entries 5 name hash_map
# Now show all the maps in the system
map show
如果你将这些命令保存在名为 /tmp/batch_example.txt 的文件中,你可以使用 bpftool batch file /tmp/batch_example.txt 加载它。当你第一次运行此命令时,将会得到类似以下片段的输出,但如果你尝试再次运行它,由于系统中已经有一个名为 hash_map 的映射,命令将会退出而不产生输出,并且批处理执行将在第一行失败:
# bpftool batch file /tmp/batch_example.txt
2: lpm_trie flags 0x1
key 8B value 8B max_entries 1 memlock 4096B
3: lpm_trie flags 0x1
key 20B value 8B max_entries 1 memlock 4096B
18: hash name hash_map flags 0x0
key 4B value 4B max_entries 5 memlock 4096B
processed 2 commands
批处理模式是我们在 BPFTool 中最喜欢的选项之一。我们建议将这些批处理文件存储在版本控制系统中,以便您可以与团队共享,创建自己的一套实用工具。在跳转到我们下一个最喜欢的实用程序之前,让我们看看 BPFTool 如何帮助您更好地理解 BPF 类型格式。
显示 BTF 信息
当存在时,BPFTool 可以显示任何给定二进制对象的 BPF 类型格式(BTF)信息。正如我们在第二章中提到的,BTF 用元数据信息注释程序结构,以帮助您调试程序。
例如,当您向 prog dump 添加关键字 linum 时,它可以为 BPF 程序中的每条指令提供源文件和行号。
较新版本的 BPFTool 包括一个新的btf子命令,帮助您深入了解程序。该命令的初始重点是可视化结构类型。例如,bpftool btf dump id 54显示了加载 ID 为 54 的程序的所有 BFT 类型。
使用 BPFTool 的一些用途。这是一个低摩擦的入口点,尤其是如果你不是每天在该系统上工作的话。
BPFTrace
BPFTrace 是一种面向 BPF 的高级跟踪语言。它允许您使用简洁的 DSL 编写 BPF 程序,并将它们保存为脚本,无需手动编译和加载到内核中。这种语言受其他知名工具的启发,如 awk 和 DTrace。如果您熟悉 DTrace,并且一直想在 Linux 上使用它,那么在 BPFTrace 中您会找到一个很好的替代品。
使用 BPFTrace 而不是直接用 BCC 或其他 BPF 工具编写程序的一个优点是,BPFTrace 提供了许多不需要您自己实现的内置功能,例如聚合信息和创建直方图。另一方面,BPFTrace 使用的语言更加有限,如果尝试实现高级程序可能会受到限制。在本节中,我们展示了语言的最重要方面。我们建议访问Github 上的 BPFTrace 存储库来了解更多信息。
安装
您可以通过多种方式安装 BPFTrace,尽管其开发人员建议您使用适合您特定 Linux 发行版的预构建软件包之一。他们还在其存储库中维护了一份包含所有安装选项和系统前提条件的文档。在那里,您会找到关于安装文档的说明。
语言参考
BPFTrace 执行的程序具有简洁的语法。我们可以将它们分为三个部分:头部、动作块和尾部。头部是 BPFTrace 加载程序时执行的特殊块;通常用于在输出顶部打印一些信息,如序言。同样,尾部是 BPFTrace 在终止程序之前执行的特殊块。头部和尾部都是 BPFTrace 程序中可选的部分。BPFTrace 程序必须至少有一个动作块。动作块是我们指定要跟踪的探针及内核触发这些探针事件时执行的操作的地方。下一段代码片段展示了一个基本示例中的这三个部分:
BEGIN
{
printf("starting BPFTrace program\n")
}
kprobe:do_sys_open
{
printf("opening file descriptor: %s\n", str(arg1))
}
END
{
printf("exiting BPFTrace program\n")
}
头部区域始终用关键字BEGIN标记,尾部区域始终用关键字END标记。这些关键字是 BPFTrace 保留的。动作块标识符定义了要将 BPF 动作附加到的探针。在前面的示例中,我们每次内核打开文件时都会打印一条日志行。
除了识别程序段之外,我们已经在之前的示例中看到了有关语言语法的一些更多细节。当程序编译时,BPFTrace 提供一些帮助器,这些帮助器会被转换为 BPF 代码。帮助器printf是 C 函数printf的包装器,用于在需要时打印程序细节。str是一个内置帮助器,将 C 指针转换为其字符串表示。许多内核函数接收字符指针作为参数;此帮助器会将这些指针转换为字符串。
BPFTrace 在某种意义上可以被视为动态语言,因为它不知道内核在执行时可能接收到的参数数量。这就是为什么 BPFTrace 提供参数帮助器来访问内核处理的信息。BPFTrace 根据块接收的参数数量动态生成这些帮助器,并且您可以通过参数在参数列表中的位置访问信息。在上一个示例中,arg1是open系统调用中第二个参数的引用,该参数是文件路径的引用。
要执行此示例,您可以将其保存到文件中,并使用文件路径作为第一个参数运行 BPFTrace:
# bpftrace /tmp/example.bt
BPFTrace 的语言设计考虑到了脚本编写。在前面的示例中,您已经看到了该语言的简洁版本,因此您可以熟悉它。但是,您可以用 BPFTrace 编写的许多程序都可以放在一行中。您无需将这些单行程序存储在文件中以执行它们;在执行 BPFTrace 时,可以使用-e选项来运行它们。例如,前面的计数器示例可以通过将动作块折叠成一行来变成一行代码:
# bpftrace -e "kprobe:do_sys_open { @opens[str(arg1)] = count() }"
现在您对 BPFTrace 的语言有了更多了解,让我们看看如何在几种场景中使用它。
过滤
当您运行上一个示例时,您可能会得到一个流,显示系统正在不断打开的文件,直到您按 Ctrl-C 退出程序。这是因为我们告诉 BPF 打印内核打开的每个文件描述符。有些情况下,您只想针对特定条件执行动作块。BPFTrace 称之为过滤。
您可以将一个过滤器与每个动作块关联起来。它们像动作块一样进行评估,但如果过滤器返回 false 值,则动作不会执行。它们还可以访问语言的其余部分,包括探针参数和帮助器。这些过滤器在动作头部之后用两条斜线封装起来:
kprobe:do_sys_open /str(arg1) == "/tmp/example.bt"/
{
printf("opening file descriptor: %s\n", str(arg1))
}
在这个例子中,我们将我们的动作块细化为仅在内核打开的文件是我们用来存储此示例的文件时执行。如果你使用新的过滤器运行程序,你会看到它打印页眉,但在那里停止打印。这是因为以前触发我们动作的每个文件现在都被跳过了,多亏了我们的新过滤器。如果你在不同的终端中多次打开示例文件,你会看到内核在过滤器匹配我们文件路径时如何执行动作:
# bpftrace /tmp/example.bt
Attaching 3 probes...
starting BPFTrace program
opening file descriptor: /tmp/example.bt
opening file descriptor: /tmp/example.bt
opening file descriptor: /tmp/example.bt
^Cexiting BPFTrace program
BPFTrace 的过滤功能非常有助于隐藏不需要的信息,并将数据范围限定在真正关心的内容上。接下来我们将讨论 BPFTrace 如何实现与映射的无缝工作。
动态映射
BPFTrace 实现的一个方便功能是动态映射关联。它可以动态生成你可以用于本书中许多操作的 BPF 映射。所有映射关联都以字符 @ 开头,后跟你想要创建的映射的名称。你还可以通过为这些映射分配值来关联更新元素。
如果我们采用本节开始时的示例,我们可以聚合系统打开特定文件的频率。为此,我们需要计算内核在特定文件上运行 open 系统调用的次数,然后将这些计数器存储在映射中。为了识别这些聚合,我们可以使用文件路径作为映射的键。在这种情况下,我们的动作块将如何看起来:
kprobe:do_sys_open
{
@opens[str(arg1)] = count()
}
如果你再次运行你的程序,你会得到类似于这样的输出:
# bpftrace /tmp/example.bt
Attaching 3 probes...
starting BPFTrace program
^Cexiting BPFTrace program
@opens[/var/lib/snapd/lib/gl/haswell/libdl.so.2]: 1
@opens[/var/lib/snapd/lib/gl32/x86_64/libdl.so.2]: 1
...
@opens[/usr/lib/locale/en.utf8/LC_TIME]: 10
@opens[/usr/lib/locale/en_US/LC_TIME]: 10
@opens[/usr/share/locale/locale.alias]: 12
@opens[/proc/8483/cmdline]: 12
正如你所见,当 BPFTrace 停止程序执行时,它会打印映射的内容。正如我们预期的那样,它正在聚合内核在我们系统中打开文件的频率。默认情况下,当 BPFTrace 终止时,它总是会打印每个映射的内容。你不需要指定要打印映射;它总是假设你想要这样做。你可以通过在 END 块内使用内置函数 clear 来更改这种行为来清除映射。这有效是因为打印映射始终发生在页脚块执行之后。
BPFTrace 的动态映射非常方便。它消除了使用映射所需的大量样板,并专注于帮助你轻松收集数据。
BPFTrace 是你日常任务中强大的工具。其脚本语言提供了足够的灵活性,使你能够在不需要手动编译和加载 BPF 程序到内核的情况下访问系统的各个方面,从而帮助你从一开始就跟踪和调试系统中的问题。请查看其 GitHub 仓库中的参考指南,了解如何充分利用其内置功能,如自动直方图和堆栈跟踪聚合。
在下一节中,我们将探讨如何在 Kubernetes 中使用 BPFTrace。
kubectl-trace
kubectl-trace 是 Kubernetes 命令行工具 kubectl 的一个出色插件。它帮助你在 Kubernetes 集群中调度 BPFTrace 程序,而无需安装任何额外的包或模块。它通过调度一个 Kubernetes 任务来实现,任务使用已经安装了运行程序所需一切的容器镜像。这个镜像称为 trace-runner,并且也可以在公共 Docker 注册表中找到。
安装
你需要使用 Go 的工具链从其源代码仓库安装 kubectl-trace,因为它的开发者没有提供二进制包:
go get -u github.com/iovisor/kubectl-trace/cmd/kubectl-trace
在 Go 工具链编译程序并将其放入路径后,kubectl 的插件系统会自动检测到这个新插件。kubectl-trace 在第一次执行时会自动下载它在集群中运行所需的 Docker 镜像。
检查 Kubernetes 节点
你可以使用 kubectl-trace 来定位运行容器的节点和 Pod,也可以用它来定位运行在这些容器上的进程。在第一种情况下,你几乎可以运行任何你想要的 BPF 程序。但是,在第二种情况下,你只能运行将用户空间探针附加到这些进程的程序。
如果你想在特定节点上运行 BPF 程序,你需要一个适当的标识符,以便 Kubernetes 将任务调度到合适的地方。获取了标识符后,运行程序的方式与之前所见的程序类似。这是我们运行一个用于计算文件打开次数的单行程序的方法:
# kubectl trace run node/node_identifier -e \
"kprobe:do_sys_open { @opens[str(arg1)] = count() }"
如你所见,程序完全相同,但我们使用 kubectl trace run 命令将其调度到特定的集群节点上。我们使用 node/... 语法告诉 kubectl-trace 我们要定位集群中的一个节点。如果我们想要定位特定的 Pod,我们将 node/ 替换为 pod/。
在特定容器上运行程序需要更长的语法;让我们先看一个例子并逐步分解它:
# kubectl trace run pod/pod_identifier -n application_name -e <<PROGRAM
uretprobe:/proc/$container_pid/exe:"main.main" {
printf("exit: %d\n", retval)
}
PROGRAM
这个命令中有两个值得关注的地方。首先是我们需要应用程序在容器中运行的名称,以便找到其进程;在我们的示例中对应的是 application_name。你需要使用在容器中执行的二进制文件的名称,例如 nginx 或 memcached。通常情况下,容器只运行一个进程,但这样做可以确保我们将程序附加到正确的进程上。第二个需要强调的方面是在我们的 BPF 程序中包含的 $container_pid。这不是一个 BPFTrace 助手,而是 kubectl-trace 用作进程标识符替换的占位符。在运行 BPF 程序之前,跟踪运行器会用正确的标识符替换占位符,并将我们的程序附加到正确的进程上。
如果你在生产环境中运行 Kubernetes,kubectl-trace 在需要分析容器行为时将极大地简化你的工作。
在这一节和前面的章节中,我们专注于帮助您在容器环境中更有效地运行 BPF 程序的工具。在下一节中,我们将介绍一个很好的工具,用于将从 BPF 程序收集的数据集成到 Prometheus,这是一个知名的开源监控系统。
eBPF Exporter
eBPF Exporter 是一个工具,允许您将自定义的 BPF 跟踪指标导出到 Prometheus。Prometheus 是一个高度可扩展的监控和警报系统。使 Prometheus 不同于其他监控系统的一个关键因素是它使用拉取策略来获取指标,而不是期望客户端将指标推送给它。这使用户可以编写自定义导出器,从任何系统收集指标,并使用定义良好的 API 模式由 Prometheus 拉取它们。eBPF Exporter 实现了这个 API,从 BPF 程序中获取跟踪指标并将其导入 Prometheus。
安装
尽管 eBPF Exporter 提供二进制包,我们建议从源代码安装,因为通常没有新的发布版本。从源代码构建还可以让您使用基于现代版本的 BCC(BPF 编译器集合)构建的新功能。
要从源代码安装 eBPF Exporter,您需要在计算机上已经安装了 BCC 和 Go 的工具链。有了这些先决条件,您可以使用 Go 下载并构建二进制文件:
go get -u github.com/cloudflare/ebpf_exporter/...
从 BPF 导出指标
eBPF Exporter 使用 YAML 文件进行配置,您可以在其中指定要从系统收集的指标、生成这些指标的 BPF 程序以及它们如何转换为 Prometheus。当 Prometheus 发送请求给 eBPF Exporter 拉取指标时,这个工具将 BPF 程序正在收集的信息转换为指标值。幸运的是,eBPF Exporter 捆绑了许多程序,可以从您的系统收集非常有用的信息,如每周期指令数(IPC)和 CPU 缓存命中率。
eBPF Exporter 的简单配置文件包括三个主要部分。在第一部分中,您定义希望 Prometheus 从系统中拉取的指标。这里是将在 BPF 映射中收集的数据转换为 Prometheus 理解的指标的地方。以下是项目示例中这些转换的示例:
programs:
- name: timers
metrics:
counters:
- name: timer_start_total
help: Timers fired in the kernel
table: counts
labels:
- name: function
size: 8
decoders:
- name: ksym
我们正在定义一个名为 timer_start_total 的指标,它汇总内核启动定时器的频率。我们还指定,我们希望从名为 counts 的 BPF 映射收集此信息。最后,我们为映射键定义了一个转换函数。这是必要的,因为映射键通常是指向信息的指针,而我们希望将实际的函数名称发送给 Prometheus。
此示例的第二节描述了我们要将 BPF 程序附加到的探针。在这种情况下,我们想要跟踪定时器启动调用;我们使用追踪点 timer:timer_start 实现此目的:
tracepoints:
timer:timer_start: tracepoint__timer__timer_start
在这里,我们告诉 eBPF Exporter,我们希望将 BPF 函数tracepoint__timer__timer_start附加到这个特定的跟踪点。接下来让我们看看如何声明这个函数:
code: |
BPF_HASH(counts, u64);
// Generates function tracepoint__timer__timer_start
TRACEPOINT_PROBE(timer, timer_start) {
counts.increment((u64) args->function);
return 0;
}
BPF 程序是内联在 YAML 文件中的。这可能是我们这个工具中较不喜欢的部分之一,因为 YAML 对空白格特别敏感,但对于像这样的小程序来说,它是有效的。eBPF Exporter 使用 BCC 来编译程序,因此我们可以使用其所有宏和助手。前面的代码片段使用宏TRACEPOINT_PROBE生成我们将附加到跟踪点的最终函数,其名称为tracepoint__timer__timer_start。
Cloudflare 使用 eBPF Exporter 来监控其所有数据中心的指标。公司确保捆绑了您希望从系统导出的最常见指标。但正如您所见,扩展新指标相对容易。
结论
在本章中,我们讨论了一些我们喜欢的系统分析工具。这些工具足够通用,以便在您需要调试系统中任何异常情况时随时使用。正如您所见,所有这些工具都抽象了我们在前几章中看到的概念,以帮助您在环境尚未准备好使用 BPF 时使用它。这是 BPF 在其他分析工具之前的许多优势之一;因为任何现代 Linux 内核都包括 BPF 虚拟机,您可以在其上构建利用这些强大功能的新工具。
还有许多其他工具也使用 BPF 来实现类似的目的,如 Cilium 和 Sysdig,我们鼓励您去尝试它们。
本章和第四章主要涉及系统分析和跟踪,但是你可以利用 BPF 做更多事情。在接下来的章节中,我们将深入探讨其网络能力。我们将向您展示如何分析任何网络中的流量,以及如何使用 BPF 控制网络中的消息。
第六章:Linux 网络和 BPF
从网络角度来看,我们主要使用 BPF 程序的两个主要用例:数据包捕获和过滤。
这意味着用户空间程序可以将过滤器附加到任何套接字,并提取通过它流动的数据包的信息,并在检测到它们时允许/禁止/重定向某些类型的数据包。
本章的目标是解释 BPF 程序如何在 Linux 内核网络堆栈中的不同数据路径阶段与套接字缓冲结构进行交互。我们正在识别两种常见用例程序:
-
与套接字相关的程序类型
-
针对基于 BPF 的流量控制分类器编写的程序。
注意
套接字缓冲结构,也称为 SKB 或sk_buff,是内核中为每个发送或接收的数据包创建和使用的结构。通过读取 SKB,您可以传递或丢弃数据包,并填充 BPF 映射以创建有关当前流量的统计信息和流量度量。
此外,一些 BPF 程序允许您操作 SKB 及其扩展,以转换最终数据包的重定向或更改其基本结构。例如,在仅支持 IPv6 的系统上,您可以编写一个程序,将所有接收到的 IPv4 数据包转换为 IPv6,这可以通过对数据包的 SKB 进行操作来完成。
了解我们可以编写的不同类型程序之间的区别,以及不同程序如何达到相同目标是理解网络中的 BPF 和 eBPF 的关键;在下一节中,我们将看看在套接字级别进行过滤的前两种方法:通过使用经典 BPF 过滤器和通过附加到套接字的 eBPF 程序。
BPF 和数据包过滤
如所述,BPF 过滤器和 eBPF 程序是在网络上下文中使用 BPF 程序的主要用例;然而,最初,BPF 程序与数据包过滤是同义词。
数据包过滤仍然是最重要的用例之一,并且已从经典 BPF(cBPF)扩展到现代 eBPF,在 Linux 3.19 中增加了与过滤程序类型BPF_PROG_TYPE_SOCKET_FILTER相关的映射函数。
过滤器主要可以在三种高级场景中使用:
-
实时流量丢弃(例如,只允许用户数据报协议[UDP]流量,并丢弃其他任何内容)。
-
直接在实时系统中观察一组经过过滤的数据包的实时流动。
-
对在实时系统上捕获的网络流量进行回顾性分析,例如使用pcap 格式。
注意
术语pcap来自两个词的结合:数据包和捕获。 pcap 格式实现为用于在名为数据包捕获库(libpcap)中捕获数据包的特定领域 API。在调试场景中,当您想要保存在实时系统上捕获的一组数据包以便稍后使用能够读取以 pcap 格式导出的数据包流的工具进行分析时,这种格式非常有用。
在接下来的几节中,我们展示了两种使用 BPF 程序应用数据包过滤概念的不同方法。首先,我们展示了一个常见且广泛使用的工具tcpdump如何作为 BPF 程序的高级接口用于过滤。然后,我们编写并加载我们自己的程序,使用BPF_PROG_TYPE_SOCKET_FILTER BPF 程序类型。
tcpdump 和 BPF 表达式
谈论实时流量分析和观察时,几乎每个人都知道的一个命令行工具是tcpdump。本质上是libpcap的一个前端,它允许用户定义高级过滤表达式。tcpdump的作用是从你选择的网络接口(或任何接口)读取数据包,然后将接收到的数据包内容写入标准输出或文件中。可以使用 pcap 过滤语法来过滤数据包流。pcap 过滤语法是一种 DSL,用于使用一组原语来过滤数据包,这些原语通常比 BPF 汇编更易记。本章的范围不包括解释 pcap 过滤语法中所有可能的原语和表达式,因为完整的集合可以在man 7 pcap-filter中找到,但我们会通过一些示例让你了解其强大之处。
场景是我们位于一个 Linux 系统上,在端口 8080 上暴露了一个 Web 服务器;这个 Web 服务器没有记录它接收到的请求,而我们真的想知道它是否收到了任何请求,以及这些请求是如何流入的,因为应用服务的客户正在抱怨在浏览产品页面时无法得到任何响应。此时,我们只知道客户正在使用我们的 Web 应用程序连接到我们的产品页面之一,由该 Web 服务器提供服务,并且通常情况下,我们不知道问题的根本原因,因为最终用户通常不会为您调试服务,并且不幸的是,我们没有在这个系统中部署任何日志记录或错误报告策略,所以在调查问题时我们完全是盲目的。幸运的是,有一个工具可以帮助我们!它就是tcpdump,可以仅过滤在我们系统中使用传输控制协议(TCP)在端口 8080 上的 IPv4 数据包。因此,我们将能够分析 Web 服务器的流量并理解哪些是有问题的请求。
这是使用tcpdump进行过滤的命令:
# tcpdump -n 'ip and tcp port 8080'
让我们看看这条命令中发生了什么:
-
-n参数告诉tcpdump不要将地址转换为相应的名称,我们希望看到源地址和目标地址。 -
ip and tcp port 8080是tcpdump将用于过滤数据包的 pcap 过滤表达式。ip表示IPv4,and是一个连接词,用于表达更复杂的过滤条件以允许添加更多表达式以匹配,然后我们指定我们只对来自或到达端口8080的 TCP 数据包感兴趣。在这种特定情况下,更好的过滤器应该是tcp dst port 8080,因为我们只对目的端口为8080的数据包感兴趣,而不是来自该端口的数据包。
其输出将类似于这样(去除冗余部分,如完整的 TCP 握手):
tcpdump: verbose output suppressed, use -v or -vv for full protocol decode
listening on wlp4s0, link-type EN10MB (Ethernet), capture size 262144 bytes
12:04:29.593703 IP 192.168.1.249.44206 > 192.168.1.63.8080: Flags [P.],
seq 1:325, ack 1, win 343,
options [nop,nop,TS val 25580829 ecr 595195678],
length 324: HTTP: GET / HTTP/1.1
12:04:29.596073 IP 192.168.1.63.8080 > 192.168.1.249.44206: Flags [.],
seq 1:1449, ack 325, win 507,
options [nop,nop,TS val 595195731 ecr 25580829],
length 1448: HTTP: HTTP/1.1 200 OK
12:04:29.596139 IP 192.168.1.63.8080 > 192.168.1.249.44206: Flags [P.],
seq 1449:2390, ack 325, win 507,
options [nop,nop,TS val 595195731 ecr 25580829],
length 941: HTTP
12:04:46.242924 IP 192.168.1.249.44206 > 192.168.1.63.8080: Flags [P.],
seq 660:996, ack 4779, win 388,
options [nop,nop,TS val 25584934 ecr 595204802],
length 336: HTTP: GET /api/products HTTP/1.1
12:04:46.243594 IP 192.168.1.63.8080 > 192.168.1.249.44206: Flags [P.],
seq 4779:4873, ack 996, win 503,
options [nop,nop,TS val 595212378 ecr 25584934],
length 94: HTTP: HTTP/1.1 500 Internal Server Error
12:04:46.329245 IP 192.168.1.249.44234 > 192.168.1.63.8080: Flags [P.],
seq 471:706, ack 4779, win 388,
options [nop,nop,TS val 25585013 ecr 595205622],
length 235: HTTP: GET /favicon.ico HTTP/1.1
12:04:46.331659 IP 192.168.1.63.8080 > 192.168.1.249.44234: Flags [.],
seq 4779:6227, ack 706, win 506,
options [nop,nop,TS val 595212466 ecr 25585013],
length 1448: HTTP: HTTP/1.1 200 OK
12:04:46.331739 IP 192.168.1.63.8080 > 192.168.1.249.44234: Flags [P.],
seq 6227:7168, ack 706, win 506,
options [nop,nop,TS val 595212466 ecr 25585013],
length 941: HTTP
现在情况清楚了!我们有一堆请求成功进行,返回了 200 OK 状态码,但在 /api/products 终端点上还有一个返回了 500 Internal Server Error 状态码的请求。我们的客户是对的;我们在列出产品时遇到了问题!
在这一点上,你可能会问自己,这些 pcap 过滤和 tcpdump 的东西与 BPF 程序有什么关系,因为它们有自己的语法?在 Linux 上,Pcap 过滤器被编译成 BPF 程序!由于 tcpdump 使用 pcap 过滤器进行过滤,这意味着每次你使用带有过滤器的 tcpdump 时,实际上都在编译和加载一个 BPF 程序来过滤你的数据包。幸运的是,通过传递 -d 标志给 tcpdump,你可以转储它将在使用指定过滤器时加载的 BPF 指令:
tcpdump -d 'ip and tcp port 8080'
过滤器与之前示例中使用的相同,但现在的输出是一组 BPF 汇编指令,因为使用了 -d 标志。
这里是输出结果:
(000) ldh [12]
(001) jeq #0x800 jt 2 jf 12
(002) ldb [23]
(003) jeq #0x6 jt 4 jf 12
(004) ldh [20]
(005) jset #0x1fff jt 12 jf 6
(006) ldxb 4*([14]&0xf)
(007) ldh [x + 14]
(008) jeq #0x1f90 jt 11 jf 9
(009) ldh [x + 16]
(010) jeq #0x1f90 jt 11 jf 12
(011) ret #262144
(012) ret #0
让我们分析一下:
ldh [12]
(ld) 从累加器中偏移量为 12 的地方加载一个半字(h),该地方是以太网 II 帧的以太类型字段,如图 6-1 所示。
jeq #0x800 jt 2 jf 12
(j) 条件跳转相等; 检查前一条指令中的以太类型值是否等于 0x800—这是 IPv4 的标识符—如果是,使用条件为真时跳转到第 2 条指令 (jt),为假时跳转到第 12 条指令 (jf),所以如果是 Internet 协议为 IPv4,将继续执行下一条指令—否则将跳转到结尾并返回零。
ldb [23]
通过 (ldb) 加载一个字节,从 IP 帧中加载高层协议字段,该字段位于偏移量 23—偏移量 23 来自以太网第二层帧头部的 14 字节(参见图 6-1),加上协议在 IPv4 头部的位置,即第 9 个字节,因此为 14 + 9 = 23。
jeq #0x6 jt 4 jf 12
再次是条件相等的跳转。在这种情况下,我们检查之前提取的协议是否是 0x6,即 TCP。如果是,则跳转到下一条指令 (4),否则跳转到结尾 (12)—如果不是,则丢弃该数据包。
ldh [20]
这是另一条加载半字指令—在这种情况下,是加载 IPv4 头部中数据包偏移量 + 片偏移的值。
jset #0x1fff jt 12 6
如果片段偏移中找到的任何数据为真,则此jset指令将跳转到12——否则,前往6,即下一个指令。指令后的偏移量0x1fff告诉jset指令仅查看数据的最后 13 个字节。(扩展后变成0001 1111 1111 1111。)
ldxb 4*([14]&0xf)
(ld)加载到x中(x)的是(b)。此指令将 IP 头长度的值加载到x中。
ldh [x + 14]
另一个加载半字指令,将获取偏移量为(x + 14),即 IP 头长度加 14,这是数据包中源端口的位置。
jeq #0x1f90 jt 11 jf 9
如果(x + 14)处的值等于0x1f90(十进制为 8080),这意味着源端口将是8080,则继续到11或通过继续到9检查目的地是否位于端口8080。
ldh [x + 16]
这是另一条加载半字指令,将获取偏移量为(x + 16)的值,这是数据包中目标端口的位置。
jeq #0x1f90 jt 11 jf 12
这是另一个等于跳转,这次用于检查目的地是否是8080,前往11;如果不是,则前往12丢弃数据包。
ret #262144
当达到此指令时,找到匹配项——因此返回匹配的抓取长度。默认情况下,此值为 262,144 字节。可以使用tcpdump中的-s参数进行调整。

图 6-1. 第 2 层以太网帧结构
这是“正确”的示例,因为正如我们在我们的 Web 服务器的情况下所说的,我们只需要考虑数据包的目标是 8080,而不是源端口,所以tcpdump过滤器可以使用dst目标字段指定它:
tcpdump -d 'ip and tcp dst port 8080'
在这种情况下,所转储的指令集与前面的示例类似,但正如您所见,它缺少与源端口为 8080 匹配数据包的整个部分。事实上,没有ldh [x + 14]和相关的jeq #0x1f90 jt 11 jf 9。
(000) ldh [12]
(001) jeq #0x800 jt 2 jf 10
(002) ldb [23]
(003) jeq #0x6 jt 4 jf 10
(004) ldh [20]
(005) jset #0x1fff jt 10 jf 6
(006) ldxb 4*([14]&0xf)
(007) ldh [x + 16]
(008) jeq #0x1f90 jt 9 jf 10
(009) ret #262144
(010) ret #0
除了仅分析从tcpdump生成的汇编之外,正如我们所做的那样,您可能希望编写自己的代码来过滤网络数据包。事实证明,在这种情况下,实际上调试代码的执行以确保其符合我们的预期是最大的挑战;在内核源树中,有一个名为tools/bpf的工具,在其中有一个称为bpf_dbg.c的工具,它实质上是一个调试器,允许您逐步加载程序和 pcap 文件以测试执行步骤。
提示
tcpdump也可以直接从.pcap文件中读取,并对其应用 BPF 过滤器。
原始套接字的数据包过滤
BPF_PROG_TYPE_SOCKET_FILTER程序类型允许您将 BPF 程序附加到套接字上。所有由其接收的数据包将以sk_buff结构的形式传递给程序,然后程序可以决定是否丢弃或允许它们。这种程序还具有访问和操作映射的能力。
让我们看一个示例,看看这种类型的 BPF 程序如何使用。
我们示例程序的目的是计算接口下流动的 TCP、UDP 和 Internet 控制消息协议(ICMP)数据包的数量。为此,我们需要以下内容:
-
能够查看流动数据包的 BPF 程序
-
加载程序并将其附加到网络接口的代码
-
编译程序并启动加载程序的脚本
此时,我们可以用两种方式编写我们的 BPF 程序:一种是将 C 代码编译成ELF文件,另一种是直接作为 BPF 汇编。在本例中,我们选择使用 C 代码来展示更高层次的抽象和如何使用 Clang 编译程序。需要注意的是,为了编写这个程序,我们使用了仅在 Linux 内核源代码树中可用的头文件和帮助程序,因此首先要做的是使用 Git 获取一份副本。为了避免差异,您可以检出我们在此示例中使用的相同提交 SHA:
export KERNEL_SRCTREE=/tmp/linux-stable
git clone git://git.kernel.org/pub/scm/linux/kernel/git/stable/linux-stable.git
$KERNEL_SRCTREE
cd $KERNEL_SRCTREE
git checkout 4b3c31c8d4dda4d70f3f24a165f3be99499e0328
提示
要支持 BPF,您需要clang >= 3.4.0和llvm >= 3.7.1。要验证您的安装中是否支持 BPF,请使用命令llc -version并查看是否有 BPF 目标。
现在您了解了套接字过滤,我们可以着手编写一个类型为socket的 BPF 程序。
BPF 程序
这里 BPF 程序的主要任务是访问其接收到的数据包;检查其协议是否为 TCP、UDP 或 ICMP,然后在找到的协议的特定键上递增映射数组中的计数器。
对于这个程序,我们将利用使用位于内核源代码树中的samples/bpf/bpf_load.c中的帮助程序解析 ELF 文件的加载机制。load_bpf_file函数能够识别一些特定的 ELF 节头,并可以将它们关联到相应的程序类型。以下是代码的示例:
bool is_socket = strncmp(event, "socket", 6) == 0;
bool is_kprobe = strncmp(event, "kprobe/", 7) == 0;
bool is_kretprobe = strncmp(event, "kretprobe/", 10) == 0;
bool is_tracepoint = strncmp(event, "tracepoint/", 11) == 0;
bool is_raw_tracepoint = strncmp(event, "raw_tracepoint/", 15) == 0;
bool is_xdp = strncmp(event, "xdp", 3) == 0;
bool is_perf_event = strncmp(event, "perf_event", 10) == 0;
bool is_cgroup_skb = strncmp(event, "cgroup/skb", 10) == 0;
bool is_cgroup_sk = strncmp(event, "cgroup/sock", 11) == 0;
bool is_sockops = strncmp(event, "sockops", 7) == 0;
bool is_sk_skb = strncmp(event, "sk_skb", 6) == 0;
bool is_sk_msg = strncmp(event, "sk_msg", 6) == 0;
代码首先要做的是创建标头部分与内部变量之间的关联,例如对于SEC("socket"),我们将得到bool is_socket=true。
在同一文件的后面,我们看到一组if指令,它们创建了标头和实际prog_type之间的关联,因此对于is_socket,我们最终得到BPF_PROG_TYPE_SOCKET_FILTER:
if (is_socket) {
prog_type = BPF_PROG_TYPE_SOCKET_FILTER;
} else if (is_kprobe || is_kretprobe) {
prog_type = BPF_PROG_TYPE_KPROBE;
} else if (is_tracepoint) {
prog_type = BPF_PROG_TYPE_TRACEPOINT;
} else if (is_raw_tracepoint) {
prog_type = BPF_PROG_TYPE_RAW_TRACEPOINT;
} else if (is_xdp) {
prog_type = BPF_PROG_TYPE_XDP;
} else if (is_perf_event) {
prog_type = BPF_PROG_TYPE_PERF_EVENT;
} else if (is_cgroup_skb) {
prog_type = BPF_PROG_TYPE_CGROUP_SKB;
} else if (is_cgroup_sk) {
prog_type = BPF_PROG_TYPE_CGROUP_SOCK;
} else if (is_sockops) {
prog_type = BPF_PROG_TYPE_SOCK_OPS;
} else if (is_sk_skb) {
prog_type = BPF_PROG_TYPE_SK_SKB;
} else if (is_sk_msg) {
prog_type = BPF_PROG_TYPE_SK_MSG;
} else {
printf("Unknown event '%s'\n", event);
return -1;
}
很好,因此因为我们想编写一个BPF_PROG_TYPE_SOCKET_FILTER程序,我们需要在我们的函数中指定一个SEC("socket")作为 ELF 头。
如列表所示,与套接字和一般网络操作相关的各种程序类型。在本章中,我们展示了BPF_PROG_TYPE_SOCKET_FILTER的示例;然而,您可以在第二章找到所有其他程序类型的定义。此外,在第七章中,我们讨论了具有程序类型BPF_PROG_TYPE_XDP的 XDP 程序。
因为我们希望为遇到的每种协议存储数据包计数,所以需要创建一个键/值映射,其中协议是键,数据包计数是值。为此,我们可以使用BPF_MAP_TYPE_ARRAY:
struct bpf_map_def SEC("maps") countmap = {
.type = BPF_MAP_TYPE_ARRAY,
.key_size = sizeof(int),
.value_size = sizeof(int),
.max_entries = 256,
};
使用bpf_map_def结构定义映射,并将其命名为countmap以供程序引用。
在此时,我们可以编写一些代码来实际计算数据包的数量。我们知道BPF_PROG_TYPE_SOCKET_FILTER类型的程序是我们的一个选择,因为通过使用这样的程序,我们可以看到流经接口的所有数据包。因此,我们使用SEC("socket")将程序附加到正确的头部:
SEC("socket")
int socket_prog(struct __sk_buff *skb) {
int proto = load_byte(skb, ETH_HLEN + offsetof(struct iphdr, protocol));
int one = 1;
int *el = bpf_map_lookup_elem(&countmap, &proto);
if (el) {
(*el)++;
} else {
el = &one;
}
bpf_map_update_elem(&countmap, &proto, el, BPF_ANY);
return 0;
}
在 ELF 头附加之后,我们可以使用load_byte函数从sk_buff结构中提取协议部分。然后,我们使用协议 ID 作为键执行bpf_map_lookup_elem操作,从我们的countmap中提取当前计数器值,以便我们可以增加它或者如果是第一个数据包则设置为 1。现在我们可以使用bpf_map_update_elem更新映射的增加值。
要将程序编译成ELF文件,我们只需使用带有-target bpf的 Clang。此命令将创建一个bpf_program.o文件,我们将使用加载器加载它:
clang -O2 -target bpf -c bpf_program.c -o bpf_program.o
加载并附加到网络接口
加载器是打开我们编译的 BPF ELF 二进制文件bpf_program.o并将定义的 BPF 程序及其映射附加到一个套接字的程序。该套接字是针对监视接口创建的,本例中为lo,即环回接口。
加载器最重要的部分是实际加载ELF文件:
if (load_bpf_file(filename)) {
printf("%s", bpf_log_buf);
return 1;
}
sock = open_raw_sock("lo");
if (setsockopt(sock, SOL_SOCKET, SO_ATTACH_BPF, prog_fd,
sizeof(prog_fd[0]))) {
printf("setsockopt %s\n", strerror(errno));
return 0;
}
这将通过添加一个元素来填充prog_fd数组,该元素是我们加载的程序的文件描述符,现在我们可以将其附加到使用open_raw_sock打开的环回接口lo的套接字描述符上。
通过将选项SO_ATTACH_BPF设置为为接口打开的原始套接字完成附加。
此时,我们的用户空间加载器能够在内核发送它们时查找映射元素:
for (i = 0; i < 10; i++) {
key = IPPROTO_TCP;
assert(bpf_map_lookup_elem(map_fd[0], &key, &tcp_cnt) == 0);
key = IPPROTO_UDP;
assert(bpf_map_lookup_elem(map_fd[0], &key, &udp_cnt) == 0);
key = IPPROTO_ICMP;
assert(bpf_map_lookup_elem(map_fd[0], &key, &icmp_cnt) == 0);
printf("TCP %d UDP %d ICMP %d packets\n", tcp_cnt, udp_cnt, icmp_cnt);
sleep(1);
}
要进行查找,我们使用for循环和bpf_map_lookup_elem附加到数组映射,以便我们可以读取和打印 TCP、UDP 和 ICMP 数据包计数器的值。
唯一剩下的就是编译程序!
因为这个程序使用libbpf,所以我们需要从刚刚克隆的内核源代码树中编译它:
$ cd $KERNEL_SRCTREE/tools/lib/bpf
$ make
现在我们有了libbpf,我们可以使用这个脚本编译加载器:
KERNEL_SRCTREE=$1
LIBBPF=${KERNEL_SRCTREE}/tools/lib/bpf/libbpf.a
clang -o loader-bin -I${KERNEL_SRCTREE}/tools/lib/bpf/ \
-I${KERNEL_SRCTREE}/tools/lib -I${KERNEL_SRCTREE}/tools/include \
-I${KERNEL_SRCTREE}/tools/perf -I${KERNEL_SRCTREE}/samples \
${KERNEL_SRCTREE}/samples/bpf/bpf_load.c \
loader.c "${LIBBPF}" -lelf
如您所见,脚本包含了一堆标头和内核自带的libbpf库,因此必须知道在哪里找到内核源代码。为此,您可以在其中替换$KERNEL_SRCTREE,或者将该脚本写入文件并使用它:
$ ./build-loader.sh /tmp/linux-stable
此时,加载器将创建一个loader-bin文件,最终可以与 BPF 程序的ELF文件一起启动(需要 root 权限):
# ./loader-bin bpf_program.o
程序加载并启动后,将进行 10 次转储,每秒钟显示三个考虑的协议的数据包计数。由于程序附加到回环设备lo上,您可以同时运行ping并查看增加的 ICMP 计数。
因此运行ping以生成发往本地主机的 ICMP 流量:
$ ping -c 100 127.0.0.1
这将开始向本地主机发送 100 次 ping,并输出类似以下内容:
PING 127.0.0.1 (127.0.0.1) 56(84) bytes of data.
64 bytes from 127.0.0.1: icmp_seq=1 ttl=64 time=0.100 ms
64 bytes from 127.0.0.1: icmp_seq=2 ttl=64 time=0.107 ms
64 bytes from 127.0.0.1: icmp_seq=3 ttl=64 time=0.093 ms
64 bytes from 127.0.0.1: icmp_seq=4 ttl=64 time=0.102 ms
64 bytes from 127.0.0.1: icmp_seq=5 ttl=64 time=0.105 ms
64 bytes from 127.0.0.1: icmp_seq=6 ttl=64 time=0.093 ms
64 bytes from 127.0.0.1: icmp_seq=7 ttl=64 time=0.104 ms
64 bytes from 127.0.0.1: icmp_seq=8 ttl=64 time=0.142 ms
然后,在另一个终端中,我们最终可以运行我们的 BPF 程序:
# ./loader-bin bpf_program.o
它开始输出以下内容:
TCP 0 UDP 0 ICMP 0 packets
TCP 0 UDP 0 ICMP 4 packets
TCP 0 UDP 0 ICMP 8 packets
TCP 0 UDP 0 ICMP 12 packets
TCP 0 UDP 0 ICMP 16 packets
TCP 0 UDP 0 ICMP 20 packets
TCP 0 UDP 0 ICMP 24 packets
TCP 0 UDP 0 ICMP 28 packets
TCP 0 UDP 0 ICMP 32 packets
TCP 0 UDP 0 ICMP 36 packets
现在,您已经了解了使用基于套接字过滤器的 eBPF 程序在 Linux 上过滤数据包所需的大部分内容。这里有一个重要消息:这不是唯一的方法!您可能希望通过使用内核而不是直接在套接字上进行操作来在现有的数据包调度子系统中进行仪表化。只需阅读下一节,了解如何操作即可。
基于 BPF 的流量控制分类器
流量控制是内核包调度子系统的架构。它由机制和排队系统组成,可以决定数据包如何流动以及它们如何被接受。
流量控制的一些用例包括但不限于以下内容:
-
优先处理某些类型的数据包
-
丢弃特定类型的数据包
-
带宽分配
鉴于一般情况下,流量控制是在需要在系统中重新分配网络资源时的方法,为了从中获得最佳效果,应基于想要运行的应用程序类型部署特定的流量控制配置。流量控制提供了一个可编程分类器,称为cls_bpf,允许在调度操作的不同级别进行挂钩,从而可以读取和更新套接字缓冲区和数据包元数据,以执行诸如流量整形、跟踪、预处理等操作。
在内核 4.1 中实现了对cls_bpf中 eBPF 的支持,这意味着这种类型的程序可以访问 eBPF 映射、具有尾调用支持、可以访问 IPv4/IPv6 隧道元数据,并且通常使用 eBPF 附带的辅助工具和实用程序。
用于与与流量控制相关的网络配置交互的工具是iproute2套件的一部分,其中包含用于操作网络接口和流量控制配置的ip和tc。
此时,在没有适当的术语参考的情况下,学习流量控制可能会很困难。接下来的部分可以提供帮助。
术语
如前所述,在 Traffic Control 和 BPF 程序之间存在交互点,因此您需要了解一些 Traffic Control 的概念。如果您已经掌握了 Traffic Control,可以跳过术语部分,直接进入示例。
队列调度
队列调度(qdisc)定义了用于改变发送到接口的数据包的调度对象的方式;这些对象可以是无类的或有类的。
默认的 qdisc 是 pfifo_fast,它是无类的,并在三个 FIFO(先进先出)队列上排队数据包,根据它们的优先级出队;对于像回环(lo)或虚拟以太网设备(veth)这样的虚拟设备,此 qdisc 不适用,而是使用 noqueue。除了其调度算法的良好默认设置外,pfifo_fast 也不需要任何配置即可工作。
可以通过询问 /sys 伪文件系统来区分虚拟接口与物理接口(设备):
ls -la /sys/class/net
total 0
drwxr-xr-x 2 root root 0 Feb 13 21:52 .
drwxr-xr-x 64 root root 0 Feb 13 18:38 ..
lrwxrwxrwx 1 root root 0 Feb 13 23:26 docker0 ->
../../devices/virtual/net/docker0
lrwxrwxrwx 1 root root 0 Feb 13 23:26 enp0s31f6 ->
../../devices/pci0000:00/0000:00:1f.6/net/enp0s31f6
lrwxrwxrwx 1 root root 0 Feb 13 23:26 lo -> ../../devices/virtual/net/lo
此时,有些混淆是正常的。如果您从未听说过 qdiscs,您可以使用 ip a 命令来显示当前系统中配置的网络接口列表:
ip a
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue
state UNKNOWN group default qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
inet 127.0.0.1/8 scope host lo
valid_lft forever preferred_lft forever
inet6 ::1/128 scope host
valid_lft forever preferred_lft forever
2: enp0s31f6: <NO-CARRIER,BROADCAST,MULTICAST,UP> mtu 1500 qdisc
fq_codel stateDOWN group default
qlen 1000
link/ether 8c:16:45:00:a7:7e brd ff:ff:ff:ff:ff:ff
6: docker0: <NO-CARRIER,BROADCAST,MULTICAST,UP> mtu 1500 qdisc
noqueue state DOWN group default
link/ether 02:42:38:54:3c:98 brd ff:ff:ff:ff:ff:ff
inet 172.17.0.1/16 brd 172.17.255.255 scope global docker0
valid_lft forever preferred_lft forever
inet6 fe80::42:38ff:fe54:3c98/64 scope link
valid_lft forever preferred_lft forever
此列表已经告诉我们一些信息。你能在里面找到 qdisc 这个词吗?让我们分析一下情况:
-
在这个系统中,我们有三个网络接口:
lo,enp0s31f6和docker0。 -
lo接口是一个虚拟接口,因此它具有noqueueqdisc。 -
enp0s31f6是一个物理接口。等等,这里为什么是fq_codel(公平队列控制延迟)的 qdisc?难道不是默认的pfifo_fast吗?事实证明,我们正在测试命令的系统正在运行 Systemd,它使用内核参数net.core.default_qdisc设置默认的 qdisc。 -
docker0接口是一个桥接口,因此它使用了一个虚拟设备,并具有noqueueqdisc。
noqueue qdisc 没有类、调度程序或分类器。它的作用是尝试立即发送数据包。正如所述,虚拟设备默认使用 noqueue,但也是在删除当前关联的 qdisc 时变为有效的 qdisc。
fq_codel 是一个无类 qdisc,它使用随机模型对传入的数据包进行分类,以便能够以公平的方式排队流量。
现在情况应该更清楚了;我们使用 ip 命令查找关于 qdiscs 的信息,但事实证明在 iproute2 工具包中还有一个称为 tc 的工具,它有一个专门的子命令用于列出它们:
tc qdisc ls
qdisc noqueue 0: dev lo root refcnt 2
qdisc fq_codel 0: dev enp0s31f6 root refcnt 2 limit 10240p flows 1024 quantum 1514
target 5.0ms interval 100.0ms memory_limit 32Mb ecn
qdisc noqueue 0: dev docker0 root refcnt 2
在这里发生了更多的事情!对于 docker0 和 lo,我们基本上看到与 ip a 相同的信息,但对于 enp0s31f6,例如,它具有以下内容:
-
它可以处理的入站数据包的上限是 10,240。
-
如前所述,由
fq_codel使用的随机模型希望将流量排队到不同的流中,此输出包含了我们有多少个流的信息,即 1,024。
现在已经介绍了队列的关键概念,我们可以在接下来的部分更仔细地研究类型化和无类别队列,以了解它们的区别以及哪些适合 BPF 程序。
类型化队列(Classful qdiscs)、过滤器和类别
类型化队列允许为不同类型的流量定义类别,以便对它们应用不同的规则。对于一个队列,如果有类别存在,则它可以包含进一步的队列。有了这种层次结构,我们可以使用一个过滤器(分类器)通过确定下一个数据包应该入队的类别来对流量进行分类。
过滤器 用于根据其类型将数据包分配到特定的类别。在类型化队列内部使用过滤器来确定数据包应该入队的类别,而且两个或更多个过滤器可以映射到同一个类别,如图 6-2 所示。每个过滤器都使用分类器根据其信息对数据包进行分类。

图 6-2 类型化队列带有过滤器
如前所述,cls_bpf是我们想要使用来为 Traffic Control 编写 BPF 程序的分类器——在接下来的部分中,我们将展示如何使用具体示例。
类别 是只能存在于类型化队列中的对象;在 Traffic Control 中,类别用于创建层次结构。通过附加到类别的过滤器,复杂的层次结构成为可能,这些过滤器可以作为另一个类别或队列的入口点使用。
无类别队列(Classless qdiscs)
无类别队列是一种不能有子级的队列,因为它们不允许与任何类别相关联。这意味着无法向无类别队列添加过滤器。由于无类别队列不能有子级,因此无法向其添加过滤器和分类器,因此从 BPF 的角度来看,无类别队列并不具有吸引力,但对于简单的 Traffic Control 需求仍然有用。
在了解了一些关于队列、过滤器和类别的知识后,我们现在将向您展示如何为cls_bpf分类器编写 BPF 程序。
使用cls_bpf进行 Traffic Control 分类器程序
Traffic Control(流量控制)是一个强大的机制,得益于分类器(classifiers),其功能更加强大;然而,在所有的分类器中,有一种特殊的分类器能够编程网络数据路径——cls_bpf分类器。这个分类器之所以特殊,是因为它可以运行 BPF 程序,那么这意味着什么呢?这意味着cls_bpf允许你将你的 BPF 程序直接挂接在入口(ingress)和出口(egress)层,而运行挂接在这些层上的 BPF 程序意味着它们可以访问相应数据包的sk_buff结构。
要更好地理解 Traffic Control 和 BPF 程序之间的这种关系,请参见图 6-3,显示了如何加载针对cls_bpf分类器的 BPF 程序。您还会注意到这些程序被挂接到 ingress 和 egress qdisc 中。还描述了上下文中的所有其他交互。通过将网络接口作为网络流量的入口点,您将看到以下内容:
-
流量首先进入 Traffic Control 的 ingress 挂钩。
-
然后内核将对每个传入请求执行从用户空间加载到 ingress 的 BPF 程序。
-
在执行入站程序后,控制权交给了通知用户应用程序有关网络事件的网络堆栈。
-
应用程序给出响应后,使用另一个执行的 BPF 程序将控制权传递给 Traffic Control 的 egress,并在完成后将控制权交还给内核。
-
客户端收到了响应。
您可以使用 C 语言为 Traffic Control 编写 BPF 程序,并使用 LLVM/Clang 和 BPF 后端进行编译。

图 6-3. 使用 Traffic Control 加载 BPF 程序
提示
Ingress 和 egress qdisc 允许您将 Traffic Control 挂接到入站(ingress)和出站(egress)流量中。
要使此示例正常工作,您需要在已直接或作为模块编译了cls_bpf的内核上运行它。为了验证您拥有所需的一切,您可以执行以下操作:
cat /proc/config.gz| zcat | grep -i BPF
确保您至少使用y或m得到以下输出:
CONFIG_BPF=y
CONFIG_BPF_SYSCALL=y
CONFIG_NET_CLS_BPF=m
CONFIG_BPF_JIT=y
CONFIG_HAVE_EBPF_JIT=y
CONFIG_BPF_EVENTS=y
现在让我们看看如何编写分类器:
SEC("classifier")
static inline int classification(struct __sk_buff *skb) {
void *data_end = (void *)(long)skb->data_end;
void *data = (void *)(long)skb->data;
struct ethhdr *eth = data;
__u16 h_proto;
__u64 nh_off = 0;
nh_off = sizeof(*eth);
if (data + nh_off > data_end) {
return TC_ACT_OK;
}
我们分类器的“主要”部分是classification函数。此函数用classifier作为部分标头进行了注释,以便tc知道这是要使用的分类器。
此时,我们需要从skb中提取一些信息;data成员包含当前数据包及其协议详细信息的所有数据。为了让我们的程序知道其中的内容,我们需要将其强制转换为以太网帧(在我们的情况下是使用*eth变量)。为了让静态验证程序满意,我们需要检查数据与data_end之间的空间,不超过eth指针大小的和。之后,我们可以向内再进一步,并从*eth中的h_proto成员获取协议类型:
if (h_proto == bpf_htons(ETH_P_IP)) {
if (is_http(skb, nh_off) == 1) {
trace_printk("Yes! It is HTTP!\n");
}
}
return TC_ACT_OK;
}
有了协议后,我们需要将其从主机转换,以检查它是否等于 IPv4 协议,这是我们感兴趣的协议,如果是,则使用我们自己的is_http函数检查内部数据包是否为 HTTP。如果是,则打印调试消息表明我们发现了一个 HTTP 数据包:
void *data_end = (void *)(long)skb->data_end;
void *data = (void *)(long)skb->data;
struct iphdr *iph = data + nh_off;
if (iph + 1 > data_end) {
return 0;
}
if (iph->protocol != IPPROTO_TCP) {
return 0;
}
__u32 tcp_hlen = 0;
is_http 函数与我们的分类器函数类似,但它将从 skb 开始,已知 IPv4 协议数据的起始偏移量。与之前一样,在访问 IP 协议数据之前,我们需要通过 *iph 变量进行检查,以让静态验证器了解我们的意图。
当完成上述操作后,我们只需检查 IPv4 头部是否包含 TCP 数据包,以便进一步操作。如果数据包的协议类型为 IPPROTO_TCP,我们需要再次执行一些检查,以获取 *tcph 变量中的实际 TCP 头部:
plength = ip_total_length - ip_hlen - tcp_hlen;
if (plength >= 7) {
unsigned long p[7];
int i = 0;
for (i = 0; i < 7; i++) {
p[i] = load_byte(skb, poffset + i);
}
int *value;
if ((p[0] == 'H') && (p[1] == 'T') && (p[2] == 'T') && (p[3] == 'P')) {
return 1;
}
}
return 0;
}
现在我们拥有了 TCP 头部,我们可以继续从 skb 结构的 TCP 负载 poffset 偏移量加载前七个字节。此时,我们可以检查字节数组是否为表示 HTTP 的序列;然后我们知道第 7 层协议是 HTTP,我们可以返回 1 —— 否则,返回零。
正如您所见,我们的程序很简单。它基本上允许一切,当接收到 HTTP 数据包时,将通过调试消息通知我们。
您可以使用 Clang 编译该程序,使用 bpf 目标,就像我们在套接字过滤示例中所做的那样。我们无法以相同方式为流量控制编译此程序;这将生成一个 ELF 文件 classifier.o,这次将由 tc 而不是我们自己的自定义加载器加载:
clang -O2 -target bpf -c classifier.c -o classifier.o
现在,我们可以在希望程序操作的接口上安装程序;在我们的情况下,它是 eth0。
第一个命令将替换 eth0 设备的默认 qdisc,第二个命令实际上将我们的 cls_bpf 分类器加载到该 ingress 类别 qdisc 中。这意味着我们的程序将处理进入该接口的所有流量。如果我们想处理出站流量,我们需要使用 egress qdisc:
# tc qdisc add dev eth0 handle 0: ingress
# tc filter add dev eth0 ingress bpf obj classifier.o flowid 0:
现在我们的程序已加载完成 —— 我们需要做的是向该接口发送一些 HTTP 流量。
为此,您需要在该接口上拥有任何 HTTP 服务器。然后,您可以 curl 该接口 IP。
如果您没有一个,您可以使用 Python 3 的 http.server 模块获取一个测试 HTTP 服务器。它将使用当前工作目录的目录列表在端口 8000 打开:
python3 -m http.server
此时,您可以使用 curl 调用服务器:
$ curl http://192.168.1.63:8080
完成后,您应该能够看到来自 HTTP 服务器的 HTTP 响应。现在,您可以通过使用专用的 tc 命令获取调试消息(使用 trace_printk 创建)来确认:
# tc exec bpf dbg
输出将类似于以下内容:
Running! Hang up with ^C!
python3-18456 [000] ..s1 283544.114997: 0: Yes! It is HTTP!
python3-18754 [002] ..s1 283566.008163: 0: Yes! It is HTTP!
恭喜!您刚刚创建了您的第一个 BPF 流量控制分类器。
提示
与本示例中使用调试消息不同,您可以使用映射来向用户空间通信,指示接口刚刚接收到 HTTP 数据包。我们把这留给您作为练习。如果查看之前示例中的 classifier.c,您可以通过查看我们如何在那里使用映射 countmap 来了解如何做到这一点。
此时,你可能想要卸载分类器。您可以通过删除您刚刚附加到接口的入口 qdisc 来实现这一点:
# tc qdisc del dev eth0 ingress
关于 act_bpf 和 cls_bpf 之间的区别的注意事项
您可能已经注意到另一个称为 act_bpf 的 BPF 程序对象存在。事实证明,act_bpf 是一个操作,而不是一个分类器。这使得它在操作上有所不同,因为操作是附加到过滤器的对象,因此它无法直接执行过滤,而需要 Traffic Control 先考虑所有数据包。基于这个特性,通常更倾向于使用 cls_bpf 分类器而不是 act_bpf 操作。
但是,因为 act_bpf 可以附加到任何分类器,可能会有一些情况您发现只需重用您已经拥有的分类器并将 BPF 程序附加到它会很有用。
Differences Between Traffic Control and XDP
即使 Traffic Control 的 cls_bpf 和 XDP 程序看起来非常相似,它们实际上有很大的不同。XDP 程序在进入主内核网络堆栈之前在入口数据路径中更早执行,因此我们的程序不像 tc 那样可以访问套接字缓冲结构 sk_buff。相反,XDP 程序采用一种名为 xdp_buff 的不同结构,这是一个没有元数据的数据包的即时表示。所有这些都有优缺点。例如,由于在内核代码之前执行,XDP 程序可以高效地丢弃数据包。与 Traffic Control 程序相比,XDP 程序只能附加到系统入口流量。
此时,您可能会问自己何时使用其中一种而不是另一种的优势。答案是,由于它们的特性不包含所有内核增强的数据结构和元数据,因此 XDP 程序更适合覆盖 OSI 层直到第四层的用例。但让我们不要提前泄露下一章的所有内容!
结论
现在您应该很清楚 BPF 程序在获取网络数据路径不同级别的可见性和控制方面的用途。您已经看到如何利用它们来使用生成 BPF 汇编的高级工具来过滤数据包。然后我们将程序加载到网络套接字,并最终将我们的程序附加到 Traffic Control 入口 qdisc 以使用 BPF 程序进行流量分类。在本章中,我们还简要讨论了 XDP,但请准备好,因为在第七章中,我们将详细探讨该主题,扩展讨论 XDP 程序的构建方式、有哪些类型的 XDP 程序以及如何编写和测试它们。
第七章: Express Data Path
Express Data Path(XDP)是 Linux 网络数据路径中的安全、可编程、高性能、与内核集成的数据包处理器,当 NIC 驱动程序接收数据包时,它执行 BPF 程序。这使得 XDP 程序能够在最早可能的时间点决定接收到的数据包的处理方式(丢弃、修改或允许通过)。
执行点并不是使 XDP 程序快速的唯一因素;其他设计决策也起着重要作用:
-
在使用 XDP 进行数据包处理时不会进行内存分配。
-
XDP 程序仅适用于线性、非分段数据包,并具有数据包的起始和结束指针。
-
这种程序接收的输入上下文类型为
xdp_buff,而不是在 第六章 中遇到的sk_buff结构,因此无法访问完整的数据包元数据。 -
因为它们是 eBPF 程序,所以 XDP 程序有有限的执行时间,因此它们在网络管道中的使用具有固定的成本。
当谈论 XDP 时,重要的是记住它不是内核绕过机制;它被设计成与其他内核组件和内部 Linux 安全模型集成。
注意
xdp_buff 结构用于向使用 XDP 框架提供的直接数据包访问机制的 BPF 程序呈现数据包上下文。可以将其视为“轻量级”版本的 sk_buff。
sk_buff 与 xdp_buff 的区别在于,sk_buff 还包含并允许您处理数据包的元数据(协议、标记、类型),这些数据只在网络管道的更高级别中才可用。xdp_buff 之所以更快获取和处理数据包,一是因为它早期创建且不依赖于其他内核层;另一原因是 xdp_buff 不保留与路由、流量控制钩子或其他类型的数据包元数据相关的引用。
在本章中,我们探讨了 XDP 程序的特性、不同类型的 XDP 程序以及它们的编译和加载方式。此外,为了提供更多背景信息,我们还讨论了其实际用例。
XDP 程序概述
XDP 程序的基本功能是确定接收到的数据包,然后可以编辑接收到的数据包内容或仅返回结果代码。结果代码用于确定数据包在操作形式上的处理方式。可以丢弃数据包,可以通过同一接口传输它,也可以将其传递给网络堆栈的其他部分。此外,为了与网络堆栈合作,XDP 程序可以推送和拉取数据包的标头;例如,如果当前内核不支持封装格式或协议,XDP 程序可以对其进行解封装或转换协议,并将结果发送给内核进行处理。
但是,请等等,XDP 和 eBPF 之间有什么关联呢?
原来 XDP 程序是通过 bpf 系统调用进行控制,并使用程序类型 BPF_PROG_TYPE_XDP 进行加载。此外,执行驱动钩子执行 BPF 字节码。
在编写 XDP 程序时理解的一个重要概念是,它们将运行的上下文也称为操作模式。
操作模式
XDP 有三种操作模式,以便轻松测试功能、使用厂商定制硬件以及常见内核的构建而无需定制硬件。让我们分别介绍每种模式。
本机 XDP
这是默认模式。在此模式下,XDP BPF 程序直接在网络驱动程序的早期接收路径中运行。使用此模式时,重要的是要检查驱动程序是否支持。你可以通过对给定内核版本的源代码树执行以下命令来检查:
# Clone the linux-stable repository
git clone git://git.kernel.org/pub/scm/linux/kernel/git/stable/linux-stable.git\
linux-stable
# Checkout the tag for your current kernel version
cd linux-stable
git checkout tags/v4.18
# Check the available drivers
git grep -l XDP_SETUP_PROG drivers/
那会产生类似这样的输出:
drivers/net/ethernet/broadcom/bnxt/bnxt_xdp.c
drivers/net/ethernet/cavium/thunder/nicvf_main.c
drivers/net/ethernet/intel/i40e/i40e_main.c
drivers/net/ethernet/intel/ixgbe/ixgbe_main.c
drivers/net/ethernet/intel/ixgbevf/ixgbevf_main.c
drivers/net/ethernet/mellanox/mlx4/en_netdev.c
drivers/net/ethernet/mellanox/mlx5/core/en_main.c
drivers/net/ethernet/netronome/nfp/nfp_net_common.c
drivers/net/ethernet/qlogic/qede/qede_filter.c
drivers/net/netdevsim/bpf.c
drivers/net/tun.c
drivers/net/virtio_net.c
从我们所看到的,内核 4.18 支持以下内容:
-
Broadcom NetXtreme-C/E 网络驱动程序
bnxt -
Cavium
thunderx驱动程序 -
Intel
i40驱动程序 -
Intel
ixgbe和ixgvevf驱动程序 -
Mellanox
mlx4和mlx5驱动程序 -
Netronome Network Flow Processor
-
QLogic
qedeNIC 驱动程序 -
TUN/TAP
-
Virtio
现在我们对本机操作模式有了清晰的概念,我们可以继续看看如何通过使用离线 XDP,网络卡直接处理 XDP 程序职责。
离线 XDP
在此模式下,XDP BPF 程序直接被卸载到 NIC 中,而不是在主机 CPU 上执行。通过将执行任务从 CPU 转移到 NIC,该模式相比本机 XDP 具有高性能增益。
我们可以重用刚刚克隆的内核源代码树来检查 4.18 版本中哪些 NIC 驱动程序支持硬件卸载,方法是搜索 XDP_SETUP_PROG_HW:
git grep -l XDP_SETUP_PROG_HW drivers/
那应该输出类似这样的内容:
include/linux/netdevice.h
866: XDP_SETUP_PROG_HW,
net/core/dev.c
8001: xdp.command = XDP_SETUP_PROG_HW;
drivers/net/netdevsim/bpf.c
200: if (bpf->command == XDP_SETUP_PROG_HW && !ns->bpf_xdpoffload_accept) {
205: if (bpf->command == XDP_SETUP_PROG_HW) {
560: case XDP_SETUP_PROG_HW:
drivers/net/ethernet/netronome/nfp/nfp_net_common.c
3476: case XDP_SETUP_PROG_HW:
这只显示了 Netronome Network Flow Processor (nfp),这意味着它可以通过同时支持硬件卸载和本机 XDP 运行。
现在,对你自己来说一个很好的问题可能是,当我没有网络卡和驱动程序来尝试我的 XDP 程序时,我该怎么办呢?答案很简单,通用 XDP!
通用 XDP
这是为了那些想要编写和运行 XDP 程序但又没有本地或离线 XDP 能力的开发者提供的测试模式。通用 XDP 自内核版本 4.12 起已得到支持。例如,你可以在 veth 设备上使用这种模式——我们在随后的示例中使用这种模式来展示 XDP 的能力,而无需你购买特定的硬件设备以便跟进。
但是,谁是负责协调所有组件和操作模式之间关系的行动者呢?继续下一节以了解数据包处理器。
数据包处理器
使得在 XDP 数据包上执行 BPF 程序并协调它们与网络堆栈之间的交互成为可能的是 XDP 数据包处理器。数据包处理器是处理 XDP 程序的内核组件,它直接在由 NIC 呈现的接收(RX)队列上处理数据包。它确保数据包可读可写,并允许您附加后处理的决策作为数据包处理器的动作形式。可以在运行时执行原子程序更新和新程序加载到数据包处理器,而不会因网络和相关流量方面的服务中断。在操作时,XDP 可以在“忙碌轮询”模式下使用,允许您保留将处理每个 RX 队列的 CPU;这避免了上下文切换,并允许在数据包到达时立即做出反应,无论 IRQ 亲和性如何。XDP 还可以使用“中断驱动”模式,该模式不会保留 CPU,但会指示一个中断作为事件介质,通知 CPU 它必须处理一个新事件,同时继续进行正常处理。
在 图 7-1 中,您可以看到 RX/TX、应用程序、数据包处理器以及应用于其数据包的 BPF 程序之间的交互点。
注意到在 图 7-1 中有几个以 XDP_ 开头的字符串的方块。这些是 XDP 结果代码,我们接下来会介绍它们。

图 7-1. 数据包处理器
XDP 结果代码(包处理器的动作)
在数据包处理器中对数据包做出决策之后,可以使用五个返回代码之一来表达这一决策,然后这些代码可以指示网络驱动程序如何处理数据包。让我们深入了解数据包处理器执行的操作:
丢弃 (XDP_DROP)
丢弃数据包。这发生在驱动程序的最早 RX 阶段;丢弃数据包简单地意味着将其回收到刚“到达”的 RX 环队列中。在拒绝服务(DoS)缓解用例中,尽早丢弃数据包至关重要。这样一来,丢弃的数据包使用的 CPU 处理时间和功耗尽可能少。
转发 (XDP_TX)
转发数据包。这可以在数据包被修改之前或之后发生。转发数据包意味着将接收到的数据包页反弹回到它所在的同一个网卡上。
重定向 (XDP_REDIRECT)
与XDP_TX类似,它能够通过另一个网卡或者 BPF cpumap来传输 XDP 数据包。在 BPF cpumap的情况下,服务于网卡接收队列上的 CPU 可以继续执行此操作,并将数据包推送到远程 CPU 以进行上层内核栈的处理。这类似于XDP_PASS,但有一个特点是 XDP BPF 程序可以继续处理传入的高负载,而不是临时处理当前数据包以推送到上层。
传递(XDP_PASS)
将数据包传递到正常的网络栈进行处理。这相当于没有 XDP 时的默认数据包处理行为。可以通过以下两种方式之一实现:
-
正常接收分配元数据(
sk_buff),接收数据包到栈上,并将数据包传递到另一个 CPU 进行处理。它允许原始接口到用户空间的传输。这可以在数据包修改前或修改后发生。 -
通用接收卸载(GRO)可以接收大数据包并合并相同连接的数据包。在处理后,GRO 最终将数据包通过“正常接收”流程传递。
代码错误(XDP_ABORTED)
表示 eBPF 程序错误并导致数据包被丢弃。这不是功能程序应该使用的返回代码。例如,如果程序除以零,则会返回XDP_ABORTED。XDP_ABORTED的值将始终为零。它通过trace_xdp_exception跟踪点传递,可以额外监控以检测不当行为。
这些动作代码在linux/bpf.h头文件中表示如下:
enum xdp_action {
XDP_ABORTED = 0,
XDP_DROP,
XDP_PASS,
XDP_TX,
XDP_REDIRECT,
};
因为 XDP 动作决定了不同的行为,并且是数据包处理器的内部机制,你可以查看关于返回动作的简化版本的图 7-1(参见图 7-2)。

图 7-2. XDP 动作代码
关于 XDP 程序的一个有趣事实是,通常不需要编写加载器来加载它们。大多数 Linux 机器上都有一个良好的加载器,由ip命令实现。下一节描述如何使用它。
XDP 和 iproute2 作为加载器
可在iproute2中使用的ip命令具有作为加载 XDP 程序的前端的能力,该程序已编译为 ELF 文件,并且完全支持映射、映射重定位、尾调用和对象固定。
因为加载 XDP 程序可以表达为现有网络接口的配置,加载器实现为ip link命令的一部分,它是执行网络设备配置的命令。
加载 XDP 程序的语法很简单:
# ip link set dev eth0 xdp obj program.o sec mysection
让我们逐个分析这个命令的参数:
ip
这会调用ip命令。
link
配置网络接口。
set
更改设备属性。
dev eth0
指定我们要操作和加载 XDP 程序的网络设备。
xdp obj program.o
从名为 program.o 的 ELF 文件(对象)加载 XDP 程序。此命令中的 xdp 部分告诉系统在可用时使用本地驱动程序,否则回退到通用驱动程序。您可以通过使用更具体的选择器来强制使用某种模式:
-
xdpgeneric用于使用通用 XDP -
xdpdrv用于使用本地 XDP -
xdpoffload用于使用卸载的 XDP
sec mysection
指定包含要从 ELF 文件中使用的 BPF 程序的部分名称 mysection;如果未指定,则将使用名为 prog 的部分。如果程序中未指定部分,则必须在 ip 调用中指定 sec .text。
让我们看一个实际的例子。
情景是我们有一个带有 Web 服务器的系统,端口为 8000,我们希望通过禁止所有对其页面的访问来阻止服务器公共面向的 NIC 上的任何 TCP 连接。
我们首先需要的是相关的 Web 服务器;如果您还没有一个,您可以通过 python3 启动一个。
$ python3 -m http.server
在启动 Web 服务器之后,可以通过 ss 显示其开放端口的开放套接字。正如您所见,Web 服务器绑定到任何接口,*:8000,因此目前任何外部调用者都可以访问其内容!
$ ss -tulpn
Netid State Recv-Q Send-Q Local Address:Port Peer Address:Port
tcp LISTEN 0 5 *:8000 *:*
注意
套接字统计,终端中的 ss 是一个用于在 Linux 中调查网络套接字的命令行实用程序。它有效地是 netstat 的现代版本,其用户体验类似于 Netstat,这意味着您可以传递相同的参数并获得可比较的结果。
此时,我们可以检查运行我们的 HTTP 服务器的机器上的网络接口:
$ ip a
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group defau
lt qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
inet 127.0.0.1/8 scope host lo
valid_lft forever preferred_lft forever
inet6 ::1/128 scope host
valid_lft forever preferred_lft forever
2: enp0s3: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP g
roup default qlen 1000
link/ether 02:1e:30:9c:a3:c0 brd ff:ff:ff:ff:ff:ff
inet 10.0.2.15/24 brd 10.0.2.255 scope global dynamic enp0s3
valid_lft 84964sec preferred_lft 84964sec
inet6 fe80::1e:30ff:fe9c:a3c0/64 scope link
valid_lft forever preferred_lft forever
3: enp0s8: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP g
roup default qlen 1000
link/ether 08:00:27:0d:15:7d brd ff:ff:ff:ff:ff:ff
inet 192.168.33.11/24 brd 192.168.33.255 scope global enp0s8
valid_lft forever preferred_lft forever
inet6 fe80::a00:27ff:fe0d:157d/64 scope link
valid_lft forever preferred_lft forever
请注意,此机器有三个接口,网络拓扑很简单:
lo
这只是用于内部通信的环回接口。
enp0s3
这是管理网络层;管理员将使用此接口连接到 Web 服务器进行操作。
enp0s8
这是向公众开放的接口,我们的 Web 服务器需要隐藏在这个接口之外。
现在,在加载任何 XDP 程序之前,我们可以从能够访问其网络接口的另一台服务器上检查服务器的开放端口,在我们的情况下,使用 IPv4 192.168.33.11。
您可以通过以下方式使用 nmap 检查远程主机的开放端口:
# nmap -sS 192.168.33.11
Starting Nmap 7.70 ( https://nmap.org ) at 2019-04-06 23:57 CEST
Nmap scan report for 192.168.33.11
Host is up (0.0034s latency).
Not shown: 998 closed ports
PORT STATE SERVICE
22/tcp open ssh
8000/tcp open http-alt
很好!端口 8000 就在那里,此时我们需要阻止它!
注意
网络映射器 (nmap) 是一种网络扫描器,可以进行主机、服务、网络和端口的发现,以及操作系统的检测。其主要用途是安全审计和网络扫描。在扫描主机的开放端口时,nmap 会尝试指定(或全部)范围内的每个端口。
我们的程序将由一个名为 program.c 的单一源文件组成,让我们看看我们需要写什么。
需要使用 IPv4 的iphdr和以太网帧ethhdr头结构以及协议常量和其他结构体。让我们包含所需的头文件,如下所示:
#include <linux/bpf.h>
#include <linux/if_ether.h>
#include <linux/in.h>
#include <linux/ip.h>
头文件包含完毕后,我们可以声明之前章节中已经遇到的SEC宏,用于声明 ELF 属性。
#define SEC(NAME) __attribute__((section(NAME), used))
现在我们可以声明我们程序的主入口点,myprogram,以及它的 ELF 节名称,mysection。我们的程序以xdp_md结构体指针作为输入上下文,这是驱动中xdp_buff的 BPF 等效物。通过使用它作为上下文,接着我们定义接下来将要使用的变量,比如数据指针、以太网和 IP 层结构体:
SEC("mysection")
int myprogram(struct xdp_md *ctx) {
int ipsize = 0;
void *data = (void *)(long)ctx->data;
void *data_end = (void *)(long)ctx->data_end;
struct ethhdr *eth = data;
struct iphdr *ip;
因为data包含以太网帧,我们现在可以从中提取 IPv4 层。我们还要检查我们寻找 IPv4 层的偏移量是否超出了整个指针空间,以使静态验证器保持满意。当地址空间超出时,我们只需丢弃数据包:
ipsize = sizeof(*eth);
ip = data + ipsize;
ipsize += sizeof(struct iphdr);
if (data + ipsize > data_end) {
return XDP_DROP;
}
现在,在所有验证和设置之后,我们可以实现程序的实际逻辑,基本上是丢弃每个 TCP 数据包,同时允许其他所有数据包通过:
if (ip->protocol == IPPROTO_TCP) {
return XDP_DROP;
}
return XDP_PASS;
}
现在我们的程序完成了,可以将其保存为program.c。
下一步是使用 Clang 将 ELF 文件program.o编译出我们的程序。我们可以在目标机器之外执行此编译步骤,因为 BPF ELF 二进制文件不依赖于平台:
$ clang -O2 -target bpf -c program.c -o program.o
现在回到托管我们 Web 服务器的机器上,我们最终可以使用ip实用程序和set命令,如前所述,针对公共网络接口enp0s8加载program.o:
# ip link set dev enp0s8 xdp obj program.o sec mysection
如您所见,我们选择mysection节作为程序的入口点。
在此阶段,如果该命令以零作为退出代码返回且没有错误,则我们可以检查网络接口,看看程序是否已正确加载:
# ip a show enp0s8
3: enp0s8: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 xdpgeneric/id:32
qdisc fq_codel state UP group default qlen 1000
link/ether 08:00:27:0d:15:7d brd ff:ff:ff:ff:ff:ff
inet 192.168.33.11/24 brd 192.168.33.255 scope global enp0s8
valid_lft forever preferred_lft forever
inet6 fe80::a00:27ff:fe0d:157d/64 scope link
valid_lft forever preferred_lft forever
正如您所看到的,我们的ip a输出现在有一个新的细节;在 MTU 之后,它显示xdpgeneric/id:32,显示了两个有趣的信息:
-
使用的驱动程序是
xdpgeneric -
XDP 程序的 ID 是
32
最后一步是验证加载的程序确实在做它应该做的事情。我们可以通过在外部机器上再次执行nmap来观察端口 8000 是否不再可达:
# nmap -sS 192.168.33.11
Starting Nmap 7.70 ( https://nmap.org ) at 2019-04-07 01:07 CEST
Nmap scan report for 192.168.33.11
Host is up (0.00039s latency).
Not shown: 998 closed ports
PORT STATE SERVICE
22/tcp open ssh
另一个验证它是否工作的测试可以尝试通过浏览器访问程序或进行任何 HTTP 请求。任何类型的测试应该在将目标定为192.168.33.11时失败。干得好,祝贺您成功加载第一个 XDP 程序!
如果您在需要将机器恢复到原始状态的机器上完成了所有这些步骤,您可以随时分离程序并关闭设备的 XDP:
# ip link set dev enp0s8 xdp off
有趣!加载 XDP 程序看起来很容易,不是吗?
至少在使用iproute2作为加载器时,你可以跳过自己编写加载器的部分。在这个例子中,我们的重点是iproute2,它已经为 XDP 程序实现了一个加载器。然而,这些程序实际上是 BPF 程序,所以即使iproute2有时可能很方便,你应该始终记住,你可以像下一节中展示的那样使用 BCC 加载你的程序,或者你可以直接使用bpf系统调用。拥有自定义加载器的优势在于可以管理程序的生命周期及其与用户空间的交互。
XDP 和 BCC
与任何其他 BPF 程序一样,XDP 程序可以使用 BCC 进行编译、加载和运行。以下示例展示了一个类似于我们用于iproute2的 XDP 程序,但具有使用 BCC 制作的自定义用户空间加载器。在这种情况下,加载器是必需的,因为我们还想计数我们在丢弃 TCP 数据包时遇到的数据包数。
与之前一样,我们首先创建一个名为program.c的内核空间程序。
在iproute2的例子中,我们的程序需要导入与 BPF 和协议相关的结构体和函数定义所需的头文件。在这里我们做了相同的事情,但我们还使用BPF_TABLE宏声明了一个类型为BPF_MAP_TYPE_PERCPU_ARRAY的映射。这个映射将包含每个 IP 协议索引的数据包计数器,这也是大小为256的原因(IP 规范只包含 256 个值)。我们想使用BPF_MAP_TYPE_PERCPU_ARRAY类型,因为它保证了在 CPU 级别上计数器的原子性,无需锁定:
#define KBUILD_MODNAME "program"
#include <linux/bpf.h>
#include <linux/in.h>
#include <linux/ip.h>
BPF_TABLE("percpu_array", uint32_t, long, packetcnt, 256);
然后,我们声明我们的主函数myprogram,它以xdp_md结构体作为参数。这个结构体首先需要包含以太网 IPv4 帧的变量声明:
int myprogram(struct xdp_md *ctx) {
int ipsize = 0;
void *data = (void *)(long)ctx->data;
void *data_end = (void *)(long)ctx->data_end;
struct ethhdr *eth = data;
struct iphdr *ip;
long *cnt;
__u32 idx;
ipsize = sizeof(*eth);
ip = data + ipsize;
ipsize += sizeof(struct iphdr);
在我们完成所有变量声明并可以访问现在包含以太网帧的数据指针和 IPv4 数据包的ip指针后,我们可以检查内存空间是否越界。如果是,我们丢弃数据包。如果内存空间正常,则提取协议并查找packetcnt数组以获取当前协议的数据包计数器的先前值,存储在变量idx中。然后我们将计数器增加一。处理增量后,我们可以继续检查协议是否为 TCP。如果是,则无条件丢弃数据包;否则,允许其通过:
if (data + ipsize > data_end) {
return XDP_DROP;
}
idx = ip->protocol;
cnt = packetcnt.lookup(&idx);
if (cnt) {
*cnt += 1;
}
if (ip->protocol == IPPROTO_TCP) {
return XDP_DROP;
}
return XDP_PASS;
}
现在让我们写加载器:loader.py。
它由两部分组成:实际的加载逻辑和打印数据包计数的循环。
对于加载逻辑,我们通过读取文件program.c打开我们的程序。使用load_func,我们指示bpf系统调用将myprogram函数作为“main”使用程序类型BPF.XDP。这代表BPF_PROG_TYPE_XDP。
加载完成后,我们可以使用get_table访问名为packetcnt的 BPF 映射。
警告
确保将device变量从enp0s8更改为你要操作的接口。
#!/usr/bin/python
from bcc import BPF
import time
import sys
device = "enp0s8"
b = BPF(src_file="program.c")
fn = b.load_func("myprogram", BPF.XDP)
b.attach_xdp(device, fn, 0)
packetcnt = b.get_table("packetcnt")
我们还需要编写的剩余部分是实际循环以打印数据包计数。没有这个,我们的程序已经能够丢弃数据包,但我们想要看看那里发生了什么。我们有两个循环。外部循环获取键盘事件,并在有中断程序的信号时终止。当外部循环中断时,会调用 remove_xdp 函数,并且接口会从 XDP 程序中释放。
在外部循环内,内部循环的职责是从 packetcnt 映射中获取值,并以 *protocol*: *counter* pkt/s 的格式打印出来:
prev = [0] * 256
print("Printing packet counts per IP protocol-number, hit CTRL+C to stop")
while 1:
try:
for k in packetcnt.keys():
val = packetcnt.sum(k).value
i = k.value
if val:
delta = val - prev[i]
prev[i] = val
print("{}: {} pkt/s".format(i, delta))
time.sleep(1)
except KeyboardInterrupt:
print("Removing filter from device")
break
b.remove_xdp(device, 0)
很好!现在我们可以通过以 root 权限执行加载器来测试该程序:
# python program.py
这将每秒输出一行带有数据包计数器的信息:
Printing packet counts per IP protocol-number, hit CTRL+C to stop
6: 10 pkt/s
17: 3 pkt/s
^CRemoving filter from device
我们遇到的数据包类型只有两种:6 代表 TCP,17 代表 UDP。
在这一点上,你的大脑可能已经开始思考关于使用 XDP 的想法和项目,这非常好!但像在软件工程中一样,如果你想要写出一个好程序,写测试是非常重要的——或者至少要写测试!接下来的部分将介绍如何对 XDP 程序进行单元测试。
测试 XDP 程序
在开发 XDP 程序时,最困难的部分是为了测试实际的数据包流,需要重现一个所有组件都对齐以提供正确数据包的环境。虽然现在使用虚拟化技术可以很容易地创建工作环境,但复杂的设置也会限制测试环境的可重复性和可编程性。此外,当在虚拟化环境中分析高频率 XDP 程序的性能方面时,虚拟化的成本使得测试效果不佳,因为其比实际数据包处理成本更高。
幸运的是,内核开发者有一个解决方案。他们实现了一个命令,可以用来测试 XDP 程序,名为 BPF_PROG_TEST_RUN。
BPF_PROG_TEST_RUN 本质上是让一个 XDP 程序执行,还有一个输入数据包和一个输出数据包。当程序执行时,输出数据包变量被填充,返回的 XDP 代码也随之返回。这意味着你可以在你的测试断言中使用输出数据包和返回码!这种技术也可以用于 skb 程序。
为了完整起见,并且让这个例子简单化,我们使用 Python 和它的单元测试框架。
使用 Python 单元测试框架进行 XDP 测试
使用 BPF_PROG_TEST_RUN 编写 XDP 测试并将其集成到 Python 的单元测试框架 unittest 中是一个很好的主意,有几个原因:
-
你可以使用 Python 的 BCC 库来加载和执行 BPF 程序。
-
Python 拥有最好的数据包构造和内省库之一:
scapy。 -
Python 使用
ctypes与 C 结构体集成。
正如所说,我们需要导入所有必要的库;这是我们将在一个名为test_xdp.py的文件中做的第一件事:
from bcc import BPF, libbcc
from scapy.all import Ether, IP, raw, TCP, UDP
import ctypes
import unittest
class XDPExampleTestCase(unittest.TestCase):
SKB_OUT_SIZE = 1514 # mtu 1500 + 14 ethernet size
bpf_function = None
导入所有必要的库之后,我们可以继续创建一个名为XDPExampleTestCase的测试用例类。这个测试类将包含所有我们的测试用例和一个成员方法(_xdp_test_run),我们将在其中进行断言并调用bpf_prog_test_run。
在下面的代码中,您可以看到_xdp_test_run的样子:
def _xdp_test_run(self, given_packet, expected_packet, expected_return):
size = len(given_packet)
given_packet = ctypes.create_string_buffer(raw(given_packet), size)
packet_output = ctypes.create_string_buffer(self.SKB_OUT_SIZE)
packet_output_size = ctypes.c_uint32()
test_retval = ctypes.c_uint32()
duration = ctypes.c_uint32()
repeat = 1
ret = libbcc.lib.bpf_prog_test_run(self.bpf_function.fd,
repeat,
ctypes.byref(given_packet),
size,
ctypes.byref(packet_output),
ctypes.byref(packet_output_size),
ctypes.byref(test_retval),
ctypes.byref(duration))
self.assertEqual(ret, 0)
self.assertEqual(test_retval.value, expected_return)
if expected_packet:
self.assertEqual(
packet_output[:packet_output_size.value], raw(expected_packet))
它需要三个参数:
given_packet
这是我们针对我们的 XDP 程序进行测试的数据包;这是接口接收到的原始数据包。
expected_packet
这是我们期望在 XDP 程序处理后收到的数据包;当 XDP 程序返回XDP_DROP或XDP_ABORT时,我们期望这个数据包为None;在所有其他情况下,数据包保持与given_packet相同或可能被修改。
expected_return
这是在处理我们的given_packet后 XDP 程序的预期返回。
除了参数之外,这个方法的主体很简单。它使用ctypes库进行 C 类型转换,然后调用libbcc的BPF_PROG_TEST_RUN等效方法,libbcc.lib.bpf_prog_test_run,使用我们的数据包和它们的元数据作为测试参数。然后根据测试调用的结果以及给定的值进行所有断言。
有了那个函数之后,我们基本上只需通过制作不同的数据包来编写测试用例,以测试它们在通过我们的 XDP 程序时的行为,但在这样做之前,我们需要为我们的测试做一个setUp方法。
这部分非常关键,因为设置实际加载我们的名为myprogram的 BPF 程序,通过打开和编译一个名为program.c的源文件(这是我们的 XDP 代码将在其中的文件):
def setUp(self):
bpf_prog = BPF(src_file=b"program.c")
self.bpf_function = bpf_prog.load_func(b"myprogram", BPF.XDP)
设置完成后,下一步是编写我们想要观察的第一个行为。不要想得太过丰富,我们只想测试我们将丢弃所有 TCP 数据包。
因此,我们在given_packet中制作一个数据包,这只是一个 IPv4 上的 TCP 数据包。然后,使用我们的断言方法_xdp_test_run,我们只需验证,根据我们的数据包,我们将得到一个带有没有返回数据包的XDP_DROP:
def test_drop_tcp(self):
given_packet = Ether() / IP() / TCP()
self._xdp_test_run(given_packet, None, BPF.XDP_DROP)
因为这还不够,我们还想明确测试所有 UDP 数据包都是允许的。然后,我们制作两个 UDP 数据包,一个用于given_packet,一个用于expected_packet,它们本质上是相同的。这样,我们还在测试 UDP 数据包在被允许通过XDP_PASS时不被修改:
def test_pass_udp(self):
given_packet = Ether() / IP() / UDP()
expected_packet = Ether() / IP() / UDP()
self._xdp_test_run(given_packet, expected_packet, BPF.XDP_PASS)
为了让事情变得更加复杂,我们决定这个系统将允许 TCP 数据包,条件是它们要去端口 9090。当它们这样做时,它们还将被重写,将它们的目标 MAC 地址更改为重定向到具有地址08:00:27:dd:38:2a的特定网络接口。
这是执行此操作的测试用例。given_packet的目的端口为9090,我们要求expected_packet具有新的目标和端口9090:
def test_transform_dst(self):
given_packet = Ether() / IP() / TCP(dport=9090)
expected_packet = Ether(dst='08:00:27:dd:38:2a') / \
IP() / TCP(dport=9090)
self._xdp_test_run(given_packet, expected_packet, BPF.XDP_TX)
现在我们有了大量的测试用例,我们现在编写测试程序的入口点,它将只调用 unittest.main(),然后加载并执行我们的测试:
if __name__ == '__main__':
unittest.main()
现在我们已经为我们的 XDP 程序编写了测试!现在我们有了测试作为我们想要拥有的特定示例,我们可以编写实现它的 XDP 程序,方法是创建一个名为 program.c 的文件。
我们的程序很简单。它只包含了具有我们刚刚测试过的逻辑的 myprogram XDP 函数。与往常一样,我们需要做的第一件事是包含所需的头文件。这些头文件是自我解释的。我们有一个 BPF 程序,它将处理以太网上传输的 TCP/IP:
#define KBUILD_MODNAME "kmyprogram"
#include <linux/bpf.h>
#include <linux/if_ether.h>
#include <linux/tcp.h>
#include <linux/in.h>
#include <linux/ip.h>
正如本章中的其他程序一样,我们需要检查偏移量并为我们数据包的三个层(分别为以太网、IPv4 和 TCP 的 ethhdr、iphdr 和 tcphdr)填充变量:
int myprogram(struct xdp_md *ctx) {
int ipsize = 0;
void *data = (void *)(long)ctx->data;
void *data_end = (void *)(long)ctx->data_end;
struct ethhdr *eth = data;
struct iphdr *ip;
struct tcphdr *th;
ipsize = sizeof(*eth);
ip = data + ipsize;
ipsize += sizeof(struct iphdr);
if (data + ipsize > data_end) {
return XDP_DROP;
}
一旦我们有了这些值,我们就可以实现我们的逻辑。
我们做的第一件事是检查协议是否为 TCP ip->protocol == IPPROTO_TCP。当它是时,我们总是执行 XDP_DROP;否则,我们对其他所有情况执行 XDP_PASS。
在检查 TCP 协议时,我们还进行另一个控制以检查目标端口是否为 9090,th->dest == htons(9090);如果是,则在以太网层更改目标 MAC 地址并返回 XDP_TX 通过同一网卡反弹数据包:
if (ip->protocol == IPPROTO_TCP) {
th = (struct tcphdr *)(ip + 1);
if ((void *)(th + 1) > data_end) {
return XDP_DROP;
}
if (th->dest == htons(9090)) {
eth->h_dest[0] = 0x08;
eth->h_dest[1] = 0x00;
eth->h_dest[2] = 0x27;
eth->h_dest[3] = 0xdd;
eth->h_dest[4] = 0x38;
eth->h_dest[5] = 0x2a;
return XDP_TX;
}
return XDP_DROP;
}
return XDP_PASS;
}
太棒了!现在我们可以运行我们的测试:
sudo python test_xdp.py
它的输出将只报告这三个测试通过:
...
--------------------------------
Ran 3 tests in 4.676s
OK
此时,破坏事物变得更容易!我们可以只需在 program.c 中将最后一个 XDP_PASS 更改为 XDP_DROP 并观察发生的情况:
.F.
======================================================================
FAIL: test_pass_udp (__main__.XDPExampleTestCase)
----------------------------------------------------------------------
Traceback (most recent call last):
File "test_xdp.py", line 48, in test_pass_udp
self._xdp_test_run(given_packet, expected_packet, BPF.XDP_PASS)
File "test_xdp.py", line 31, in _xdp_test_run
self.assertEqual(test_retval.value, expected_return)
AssertionError: 1 != 2
----------------------------------------------------------------------
Ran 3 tests in 4.667s
FAILED (failures=1)
我们的测试失败了——状态码不匹配,测试框架报告了一个错误。这正是我们想要的!这是一个有效的测试框架,可以自信地编写 XDP 程序。现在,我们有能力对特定步骤进行断言并相应地更改它们以获得我们想要的行为。然后,我们编写匹配的代码来表达该行为,形成 XDP 程序。
注意
MAC 地址是介质访问控制地址的简称。它是一个由两组十六进制数字组成的唯一标识符,每个网络接口都有,并且用于数据链路层(OSI 模型中的第 2 层),用于连接以太网、蓝牙和 WiFi 等技术的设备。
XDP 使用案例
在接近 XDP 时,理解它被全球各地的各种组织采用的用例肯定是有用的。这可以帮助您想象为什么在某些情况下使用 XDP 比其他技术如套接字过滤或流量控制更好。
让我们从一个常见的例子开始:监控。
监控
如今,大多数网络监控系统要么通过编写内核模块,要么通过从用户空间访问 proc 文件来实现。编写、分发和编译内核模块并非人人能做的任务;这是一项危险的操作。它们也不容易维护和调试。然而,另一种选择可能更糟。要获取类似的信息,例如一秒钟内卡接收了多少个数据包,你需要打开和解析一个文件,比如 /sys/class/net/eth0/statistics/rx_packets。这可能看起来是个不错的主意,但这需要大量计算资源,因为在某些情况下使用 open 系统调用并不便宜。
因此,我们需要一种解决方案,可以在不损失性能的情况下实现类似内核模块功能的特性。XDP 就非常适合这种情况,因为我们可以使用 XDP 程序将要提取的数据发送到一个映射中。然后,这个映射被一个加载器消费,可以将指标存储到后端存储中,并对其应用算法或将结果绘制成图表。
DDoS 缓解
能够在网卡级别查看数据包确保在系统还没有花费足够的计算资源来判断数据包是否对系统有用之前拦截任何可能的数据包。在典型场景中,一个 bpf 映射可以指示 XDP 程序从特定来源的数据包执行 XDP_DROP 操作。该数据包列表可以在分析通过另一个映射接收的数据包后,在用户空间生成。一旦 XDP 程序接收到的数据包与列表中的某个元素匹配,就会执行这种缓解操作。数据包被丢弃,内核甚至不需要花费 CPU 周期来处理它。这使得攻击者的目标变得更难实现,因为在这种情况下,它无法浪费任何昂贵的计算资源。
负载均衡
XDP 程序的一个有趣用例是负载均衡;然而,XDP 只能在接收到数据包的同一网卡上重传数据包。这意味着 XDP 并不是实现经典负载均衡器(位于所有服务器前面并将流量转发到它们)的最佳选择。然而,这并不意味着 XDP 对这种用例不适用。如果我们将负载均衡从外部服务器移动到为应用服务的同一台机器上,你立即会看到它们的网卡可以用来完成这项任务。
通过这种方式,我们可以创建一个分布式负载均衡器,在托管应用程序的每台机器上帮助将流量分发到适当的服务器。
防火墙
当人们谈论 Linux 上的防火墙时,通常会想到iptables或netfilter。通过 XDP,您可以以完全可编程的方式直接在网卡或其驱动程序中获得相同的功能。通常,防火墙是昂贵的机器,位于网络堆栈的顶部或节点之间,以控制其通信的外观。然而,使用 XDP 时,很明显,因为 XDP 程序非常便宜和快速,我们可以将防火墙逻辑直接实现到节点的网卡中,而不是使用一组专用机器。一个常见的用例是有一个控制具有通过远程过程调用 API 更改的一组规则的映射的 XDP 加载器。然后,这些规则集动态传递给加载到每台特定机器上的 XDP 程序,以控制它可以接收什么,来自谁以及在哪种情况下。
这种替代方案不仅使防火墙成本更低,而且允许每个节点部署自己的防火墙级别,而无需依赖用户空间软件或内核来完成。当使用卸载的 XDP 作为操作模式部署时,我们可以获得最大的优势,因为处理甚至不是由主节点 CPU 完成的。
结论
现在你拥有了多么了不起的技能!我向你保证,XDP 将帮助你从现在开始以完全不同的方式思考网络流量。在处理网络数据包时,必须依赖诸如iptables或其他用户空间工具通常令人沮丧且缓慢。XDP 之所以有趣,是因为它由于其直接的数据包处理能力而更快,并且你可以编写自己的逻辑来处理网络数据包。因为所有这些任意代码都可以与映射交互,并与其他 BPF 程序互动,所以你有一个完整的可能用例世界可以为自己的架构发明和探索!
尽管这不涉及网络,下一章再次回顾了这里和第六章中涵盖的许多概念。再次强调,基于给定输入条件,BPF 用于过滤某些条件并过滤程序可以执行的操作。不要忘记 BPF 中的F代表过滤器!
第八章:Linux 内核安全性、权限和 Seccomp
BPF 是一种在不牺牲稳定性、安全性和速度的情况下扩展内核的强大方式。因此,内核开发人员认为,利用其多功能性来通过实现由 BPF 程序支持的 Seccomp 过滤器来改进 Seccomp 中的进程隔离是一个好主意,也被称为 Seccomp BPF。在本章中,我们将探讨 Seccomp 是什么以及如何使用它。然后,您将学习如何使用 BPF 程序编写 Seccomp 过滤器。之后,您将探索内核为 Linux 安全模块提供的内置 BPF 钩子。
Linux 安全模块(LSM)是一个提供一套函数的框架,可以用标准化的方式实现不同的安全模型。LSM 可以直接在内核源代码树中使用,比如 Apparmor、SELinux 和 Tomoyo。
我们从讨论 Linux 权限开始。
权限
Linux 权限的处理方式是,你需要为你的非特权进程提供执行特定任务的权限,但又不希望给二进制文件赋予SUID权限或以其他方式使进程具备特权,因此通过仅给予进程完成特定任务所需的特定能力来减少攻击面。例如,如果你的应用程序需要打开一个特权端口,比如 80 端口,你可以只赋予它CAP_NET_BIND_SERVICE能力,而不是以 root 身份启动进程。
考虑以下名为main.go的 Go 程序:
package main
import (
"net/http"
"log"
)
func main() {
log.Fatalf("%v", http.ListenAndServe(":80", nil))
}
该程序在端口 80 上提供 HTTP 服务器,这是一个特权端口。
我们通常在编译后直接运行该程序如下:
$ go build -o capabilities main.go
$ ./capabilities
然而,由于我们没有给予 root 权限,当绑定端口时,该代码将输出错误:
2019/04/25 23:17:06 listen tcp :80: bind: permission denied
exit status 1
提示
capsh(能力外壳包装器)是一个工具,它将以特定的一组能力启动一个 shell。
在这种情况下,正如所述,我们可以通过允许cap_net_bind_service权限及程序已有的其他权限,而不是给予完整的 root 权限,来允许绑定特权端口。为此,我们可以用capsh来包装我们的程序运行:
# capsh --caps='cap_net_bind_service+eip cap_setpcap,cap_setuid,cap_setgid+ep' \
--keep=1 --user="nobody" \
--addamb=cap_net_bind_service -- -c "./capabilities"
让我们稍微解析一下那个命令:
capsh
我们使用capsh作为包装器。
--caps='cap_net_bind_service+eip cap_setpcap,cap_setuid,cap_setgid+ep'
因为我们需要更改用户(我们不希望以 root 身份运行),所以我们需要指定cap_net_bind_service以及实际执行用户 ID 从root变为nobody所需的能力,即cap_setuid和cap_setgid:
--keep=1
我们希望在从 root 切换完成后保持已设置的能力。
--user="nobody"
运行我们程序的最终用户将是nobody。
--addamb=cap_net_bind_service
我们设置周围能力,因为这些能力在从 root 切换后会被清除。
-- -c "./capabilities"
最后,我们只需运行我们的程序。
注意
环境权限是当前程序使用execve()执行它们时由子程序继承的一种特定类型的权限。只有在环境中允许且可继承的权限才能成为环境权限。
此时,您可能会问自己--caps选项中的+eip是什么。这些标志用于确定:
-
需要激活该功能(p)。
-
该功能可用(e)。
-
可以通过子进程继承该功能(i)。
因为我们想要使用我们的cap_net_bind_service,我们需要使其为e;然后在我们的命令中,我们启动了一个 shell。然后启动了capabilities二进制文件,我们需要使其为i。最后,我们希望激活该能力(因为我们更改了 UID,它没有被激活),使用p。这最终变成了cap_net_bind_service+eip。
您可以使用ss来验证;我们将切断输出以使其适合本页,但它将显示绑定端口和用户 ID 与0不同,在本例中为65534:
# ss -tulpn -e -H | cut -d' ' -f17-
128 *:80 *:*
users:(("capabilities",pid=30040,fd=3)) uid:65534 ino:11311579 sk:2c v6only:0
我们在此示例中使用了capsh,但您可以通过使用libcap编写包装器来获取更多信息,请参阅man 3 libcap。
在编写程序时,开发人员通常不能预先知道程序在运行时所需的所有功能;而且,随着新版本的发布,这些功能可能会发生变化。
要更好地了解程序使用的能力,我们可以使用 BCC 中的capable工具在内核函数cap_capable上设置一个 kprobe:
/usr/share/bcc/tools/capable
TIME UID PID TID COMM CAP NAME AUDIT
10:12:53 0 424 424 systemd-udevd 12 CAP_NET_ADMIN 1
10:12:57 0 1103 1101 timesync 25 CAP_SYS_TIME 1
10:12:57 0 19545 19545 capabilities 10 CAP_NET_BIND_SERVICE 1
我们可以使用bpftrace来实现相同的功能,通过一行代码在cap_capable内核函数上设置一个 kprobe:
bpftrace -e \
'kprobe:cap_capable {
time("%H:%M:%S ");
printf("%-6d %-6d %-16s %-4d %d\n", uid, pid, comm, arg2, arg3);
}' \
| grep -i capabilities
如果我们的程序capabilities在 kprobe 之后启动,那么将输出类似以下内容:
12:01:56 1000 13524 capabilities 21 0
12:01:56 1000 13524 capabilities 21 0
12:01:56 1000 13524 capabilities 21 0
12:01:56 1000 13524 capabilities 12 0
12:01:56 1000 13524 capabilities 12 0
12:01:56 1000 13524 capabilities 12 0
12:01:56 1000 13524 capabilities 12 0
12:01:56 1000 13524 capabilities 10 1
第五列是进程所需的功能,因为此输出还包括非审计事件,我们可以看到所有的非审计检查,并最终看到具有审计标志(前一个输出的最后一个)设置为 1 的所需功能。我们感兴趣的功能是CAP_NET_BIND_SERVICE,在内核源代码的include/uapi/linux/capability.h中定义为常量,其 ID 为10:
/* Allows binding to TCP/UDP sockets below 1024 */
/* Allows binding to ATM VCIs below 32 */
#define CAP_NET_BIND_SERVICE 10
容器运行时(如 runC 或 Docker)经常使用权限来使容器无特权,并仅允许运行大多数应用程序所需的权限。当应用程序需要特定的权限时,在 Docker 中可以通过--cap-add完成:
docker run -it --rm --cap-add=NET_ADMIN ubuntu ip link add dummy0 type dummy
此命令将为该容器赋予CAP_NET_ADMIN能力,允许它设置一个 netlink 以添加dummy0接口。
下一节将展示如何使用另一种技术实现能力,例如过滤,这将使我们能够以编程方式实现自己的过滤器。
Seccomp
Seccomp 代表安全计算,是 Linux 内核中实现的安全层,允许开发人员过滤特定的系统调用。虽然 Seccomp 与 capabilities(能力)类似,但其能够控制特定系统调用的能力使其比 capabilities 更加灵活。
Seccomp 和 capabilities 并不互斥;它们通常一起使用,从两个世界中带来好处。例如,你可能想要给一个进程赋予CAP_NET_ADMIN权限,但通过阻止accept和accept4系统调用来防止其在套接字上接受连接。
Seccomp 过滤器的方式基于使用SECCOMP_MODE_FILTER模式的 BPF 过滤器,系统调用的过滤与包过滤一样进行。
Seccomp 过滤器使用prctl加载,通过PR_SET_SECCOMP操作表达为 BPF 程序形式,在每个使用seccomp_data结构表达的 Seccomp packet上执行。该结构包含参考架构,系统调用时 CPU 指令指针,以及最多六个系统调用参数作为uint64表达。
下面是来自内核源码linux/seccomp.h中seccomp_data结构的展示:
struct seccomp_data {
int nr;
__u32 arch;
__u64 instruction_pointer;
__u64 args[6];
};
通过阅读结构可以看出,我们可以基于系统调用本身、其参数或它们的组合来进行过滤。
在接收每个 Seccomp packet 后,过滤器负责进行处理以做出最终决策,告诉内核接下来应该做什么。最终决策通过其中一个返回值(状态码)表达,如以下所述:
SECCOMP_RET_KILL_PROCESS
在过滤系统调用后会立即终止整个进程,因此系统调用不会被执行。
SECCOMP_RET_KILL_THREAD
在过滤系统调用后会立即终止当前线程,因此系统调用不会被执行。
SECCOMP_RET_KILL
这是SECCOMP_RET_KILL_THREAD的别名,保留以保证兼容性。
SECCOMP_RET_TRAP
系统调用被禁止,并且会向调用该系统调用的任务发送SIGSYS(Bad System Call)信号。
SECCOMP_RET_ERRNO
系统调用不被执行,并且过滤器返回值的SECCOMP_RET_DATA部分作为errno值传递给用户空间。根据错误的原因不同,会返回不同的errno值。你可以在下面的部分找到错误号列表。
SECCOMP_RET_TRACE
这用于通过PTRACE_O_TRACESECCOMP通知ptrace跟踪器截获系统调用的调用,以便观察和控制系统调用的执行。如果没有附加跟踪器,则返回错误,设置errno为-ENOSYS,并且不执行系统调用。
SECCOMP_RET_LOG
允许系统调用并记录。
SECCOMP_RET_ALLOW
系统调用仅仅被允许。
注意
ptrace 是一个系统调用,用于在进程(称为 tracee)上实现跟踪机制,其效果是能够观察和控制进程的执行。追踪程序可以有效地影响执行并更改 tracee 的内存寄存器。在 Seccomp 的上下文中,当由 SECCOMP_RET_TRACE 状态码触发时,会使用 ptrace,因此追踪程序可以防止系统调用执行并实施自己的逻辑。
Seccomp 错误
在使用 Seccomp 时,您会不时遇到由 SECCOMP_RET_ERRNO 类型的返回值给出的不同错误。为了通知发生了错误,seccomp 系统调用将返回 -1 而不是 0。
可能的错误如下:
EACCESS
调用者不允许执行系统调用 —— 通常是因为它没有 CAP_SYS_ADMIN 特权或者没有使用 prctl 设置 no_new_privs,我们将在本章后面详细解释。
EFAULT
传递的参数(seccomp_data 结构中的 args)没有有效的地址。
EINVAL
它可以有四种含义:
-
请求的操作在当前配置的内核中不被知道或支持。
-
指定的标志对于请求的操作无效。
-
操作包括
BPF_ABS,但是指定的偏移量可能超出了seccomp_data结构的大小。 -
传递给过滤器的指令数超过了最大指令数。
ENOMEM
没有足够的内存来执行程序。
EOPNOTSUPP
操作指定了 SECCOMP_GET_ACTION_AVAIL,该操作是可用的,但实际上内核在参数返回中不支持该返回操作。
ESRCH
在另一个线程同步期间出现问题。
ENOSYS
SECCOMP_RET_TRACE 操作中没有追踪程序附加。
注意
prctl 是一个系统调用,允许用户空间程序控制(设置和获取)进程的特定方面,如端序、线程名称、安全计算(Seccomp)模式、权限、性能事件等。
对你来说,Seccomp 可能听起来像是一个沙盒机制,但这并不正确。Seccomp 是一个实用程序,允许其用户开发沙盒机制。现在这里是如何编写程序以直接调用 Seccomp 系统调用的过滤器来编写自定义交互。
Seccomp BPF 过滤器示例
在此示例中,我们展示如何组合前述的两个操作:
-
编写 Seccomp BPF 程序,用作根据其做出的决定返回不同返回码的过滤器。
-
使用
prctl加载过滤器。
首先,示例需要一些来自标准库和 Linux 内核的头文件:
#include <errno.h>
#include <linux/audit.h>
#include <linux/bpf.h>
#include <linux/filter.h>
#include <linux/seccomp.h>
#include <linux/unistd.h>
#include <stddef.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/prctl.h>
#include <unistd.h>
在尝试执行此示例之前,我们需要确保我们的内核已编译并设置为 CONFIG_SECCOMP 和 CONFIG_SECCOMP_FILTER 为 y。在实时机器上,可以通过以下方式检查:
cat /proc/config.gz| zcat | grep -i CONFIG_SECCOMP
代码的其余部分是install_filter函数,由两部分组成。第一部分包含我们的 BPF 过滤指令列表:
static int install_filter(int nr, int arch, int error) {
struct sock_filter filter[] = {
BPF_STMT(BPF_LD + BPF_W + BPF_ABS, (offsetof(struct seccomp_data, arch))),
BPF_JUMP(BPF_JMP + BPF_JEQ + BPF_K, arch, 0, 3),
BPF_STMT(BPF_LD + BPF_W + BPF_ABS, (offsetof(struct seccomp_data, nr))),
BPF_JUMP(BPF_JMP + BPF_JEQ + BPF_K, nr, 0, 1),
BPF_STMT(BPF_RET + BPF_K, SECCOMP_RET_ERRNO | (error & SECCOMP_RET_DATA)),
BPF_STMT(BPF_RET + BPF_K, SECCOMP_RET_ALLOW),
};
这些指令使用linux/filter.h中定义的BPF_STMT和BPF_JUMP宏设置。
让我们逐步执行这些指令:
BPF_STMT(BPF_LD + BPF_W + BPF_ABS (offsetof(struct seccomp_data, arch)))
这将加载并累积BPF_LD形式的字BPF_W,数据包数据包含在固定的BPF_ABS偏移量处。
BPF_JUMP(BPF_JMP + BPF_JEQ + BPF_K, arch, 0, 3)
这会使用BPF_JEQ检查累加器中的体系结构值是否等于arch。如果是,则跳转到下一条指令(偏移量为零);否则,跳转到偏移量为三的错误处理指令,因为体系结构不匹配。
BPF_STMT(BPF_LD + BPF_W + BPF_ABS (offsetof(struct seccomp_data, nr)))
这将加载并累积BPF_LD形式的字BPF_W,它是包含在固定BPF_ABS偏移量处的系统调用号数据。
BPF_JUMP(BPF_JMP + BPF_JEQ + BPF_K, nr, 0, 1)
这将比较系统调用号中的值与nr变量中的值。如果它们相等,将转到下一条指令并禁止系统调用;否则,将允许系统调用使用SECCOMP_RET_ALLOW。
BPF_STMT(BPF_RET + BPF_K, SECCOMP_RET_ERRNO | (error & SECCOMP_RET_DATA))
这会使用BPF_RET终止程序,并返回一个带有从err变量中指定的错误号的错误,SECCOMP_RET_ERRNO。
BPF_STMT(BPF_RET + BPF_K, SECCOMP_RET_ALLOW)
这将使用BPF_RET终止程序,并允许使用SECCOMP_RET_ALLOW进行系统调用执行。
如果您需要进一步理解该汇编内容,可能会发现一些做同样事情的伪代码很有用:
if (arch != AUDIT_ARCH_X86_64) {
return SECCOMP_RET_ALLOW;
}
if (nr == __NR_write) {
return SECCOMP_RET_ERRNO;
}
return SECCOMP_RET_ALLOW;
在socket_filter结构中定义过滤器代码后,我们需要定义一个sock_fprog,其中包含过滤器代码的长度计算结果。此数据结构需要作为声明后续过程操作的参数:
struct sock_fprog prog = {
.len = (unsigned short)(sizeof(filter) / sizeof(filter[0])),
.filter = filter,
};
现在,在install_filter函数中只剩下一件事要做:加载程序本身!为此,我们使用prctl并以PR_SET_SECCOMP为选项,因为我们想进入安全计算模式。然后,我们指示模式加载一个带有sock_fprog类型的prog变量中包含的SECCOMP_MODE_FILTER过滤器:
if (prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &prog)) {
perror("prctl(PR_SET_SECCOMP)");
return 1;
}
return 0;
}
最后,我们可以利用我们的install_filter函数,但在使用之前,我们需要使用prctl在当前执行中设置PR_SET_NO_NEW_PRIVS,以避免子进程具有比父进程更广泛的权限。这使得我们可以在没有根权限的情况下在install_filter函数中进行以下prctl调用。
现在我们可以调用 install_filter 函数。我们将阻止所有与 X86-64 架构相关的 write 系统调用,并只给予所有尝试的权限被拒绝。在过滤器安装后,我们通过使用第一个参数继续执行:
int main(int argc, char const *argv[]) {
if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0)) {
perror("prctl(NO_NEW_PRIVS)");
return 1;
}
install_filter(__NR_write, AUDIT_ARCH_X86_64, EPERM);
return system(argv[1]);
}
现在让我们来试试吧!
要编译我们的程序,我们可以使用 clang 或 gcc;无论哪种方式,只需编译 main.c 文件,没有特殊的选项:
clang main.c -o filter-write
我们说我们在我们的程序中阻止了所有的写入。为了测试它,我们需要一个进行写入操作的程序;ls 程序似乎是一个很好的选择,这是它正常行为的样子:
ls -la
total 36
drwxr-xr-x 2 fntlnz users 4096 Apr 28 21:09 .
drwxr-xr-x 4 fntlnz users 4096 Apr 26 13:01 ..
-rwxr-xr-x 1 fntlnz users 16800 Apr 28 21:09 filter-write
-rw-r--r-- 1 fntlnz users 19 Apr 28 21:09 .gitignore
-rw-r--r-- 1 fntlnz users 1282 Apr 28 21:08 main.c
太棒了!这是我们的包装程序用法的样子;我们只需将要测试的程序作为第一个参数传递:
./filter-write "ls -la"
执行后,该程序将完全空白输出,没有任何输出。然而,我们可以使用 strace 查看发生了什么:
strace -f ./filter-write "ls -la"
结果已经去除了许多噪音,相关部分显示写入操作因 EPERM 错误被阻止,这与我们设置的相同。这意味着程序是静默的,因为现在它无法访问该系统调用:
[pid 25099] write(2, "ls: ", 4) = -1 EPERM (Operation not permitted)
[pid 25099] write(2, "write error", 11) = -1 EPERM (Operation not permitted)
[pid 25099] write(2, "\n", 1) = -1 EPERM (Operation not permitted)
现在您已经了解了 Seccomp BPF 的运作方式,并且对您可以使用它做什么有了一个良好的认识。但如果有一种方法可以使用 eBPF 来实现相同的功能,而不是使用 cBPF 来利用它的强大能力,那不是更好吗?
在考虑 eBPF 程序时,大多数人认为您只需编写它们并使用 root 权限加载它们。虽然这种说法通常是正确的,但内核实现了一组机制来在各个层次上保护 eBPF 对象;这些机制称为 BPF LSM 钩子。
BPF LSM 钩子
为了在系统事件上提供与架构无关的控制,LSM 实现了钩子的概念。从技术上讲,钩子调用类似于系统调用;然而,由于钩子是系统独立的并且与 LSM 框架集成在一起,使得钩子变得非常有趣,因为提供的抽象层可以很方便地避免在不同架构上处理系统调用时可能出现的问题。
在撰写本文时,内核有七个与 BPF 程序相关的钩子,并且 SELinux 是唯一实现它们的内核 LSM。
您可以在此文件中查看内核源码树中的内容:include/linux/security.h:
extern int security_bpf(int cmd, union bpf_attr *attr, unsigned int size);
extern int security_bpf_map(struct bpf_map *map, fmode_t fmode);
extern int security_bpf_prog(struct bpf_prog *prog);
extern int security_bpf_map_alloc(struct bpf_map *map);
extern void security_bpf_map_free(struct bpf_map *map);
extern int security_bpf_prog_alloc(struct bpf_prog_aux *aux);
extern void security_bpf_prog_free(struct bpf_prog_aux *aux);
这些钩子中的每一个都会在执行的不同阶段被调用:
security_bpf
对执行的 BPF 系统调用进行初始检查
security_bpf_map
当内核为映射返回文件描述符时进行检查
security_bpf_prog
当内核为 eBPF 程序返回文件描述符时进行检查
security_bpf_map_alloc
初始化 BPF 映射内部的安全字段
security_bpf_map_free
清理 BPF 映射内部的安全字段
security_bpf_prog_alloc
初始化 BPF 程序内部的安全字段
security_bpf_prog_free
清理 BPF 程序内部的安全字段
现在我们已经看到它们,可以明显看出 LSM BPF 钩子背后的理念是,它们可以为 eBPF 对象提供逐个对象的保护,以确保只有具有适当权限的用户才能对映射和程序执行操作。
结论
安全并不是一种可以普遍应用于所有需要保护的东西的东西。能够在不同层面和不同方式上确保系统安全非常重要,不管你相不相信,确保系统安全的最佳方法是通过堆叠具有不同视角的不同层次,以便于被攻破的层次不会导致能够访问整个系统。内核开发人员在提供了一组不同层次和交互点的同时也做得非常出色;我们希望能够让您充分了解这些层次是什么以及如何使用 BPF 程序与它们交互。
第九章:实际应用案例
在实施新技术时,最重要的问题是:“在实际应用中这有什么用途?”这就是为什么我们决定采访一些最激动人心的 BPF 项目的创建者来分享他们的想法的原因。
Sysdig eBPF 的神模式
Sysdig,这家制造同名开源 Linux 故障排除工具的公司,从 2017 年开始在内核 4.11 下开始尝试 eBPF。
历史上它一直在使用一个内核模块来提取和执行所有内核端的工作,但随着用户基数的增加以及越来越多的公司开始进行实验,公司认识到这对于大多数外部参与者来说是一个限制,有很多方面:
-
越来越多的用户无法在其计算机上加载内核模块。云原生平台越来越严格限制运行时程序可以做什么。
-
新的贡献者(甚至老的)不理解内核模块的架构。这降低了贡献者的总体数量,也是项目自身增长的一个限制因素。
-
内核模块的维护很困难,不仅因为编写代码的原因,还因为需要保证其安全性和良好组织性所需的工作量。
出于这些动机,Sysdig 决定尝试采用使用 eBPF 程序而不是模块的方式来编写相同功能集的方法。从采用 eBPF 自动获取的另一个好处是 Sysdig 进一步利用其他出色的 eBPF 跟踪功能的可能性。例如,可以相对容易地使用用户探针将 eBPF 程序附加到用户空间应用程序中的特定执行点,如 “用户空间探针” 中所述。
此外,该项目现在可以利用 eBPF 程序中的本地辅助功能来捕获运行进程的堆栈跟踪,以增强典型的系统调用事件流。这为用户提供了更多的故障排除信息。
尽管现在一切都很顺利,但 Sysdig 最初在开始时面临了一些挑战,原因是 eBPF 虚拟机的一些限制,因此项目的首席架构师 Gianluca Borello 决定通过向内核贡献上游补丁来改进它,包括:
后者对处理系统调用参数特别重要,可能是工具中最重要的数据源。
图 9-1 展示了 Sysdig 中 eBPF 模式的架构。

图 9-1 Sysdig 的 eBPF 架构
实现的核心是一组定制的 eBPF 程序,负责仪器化。这些程序是用 C 语言的一个子集编写的。它们使用最近版本的 Clang 和 LLVM 进行编译,将高级 C 代码转换为 eBPF 字节码。
对于 Sysdig 仪器化内核的每个不同执行点,都有一个 eBPF 程序。目前,eBPF 程序附加到以下静态跟踪点:
-
系统调用进入路径
-
系统调用退出路径
-
进程上下文切换
-
进程终止
-
小页错误和大页错误
-
进程信号传递
每个程序接收执行点数据(例如对于系统调用,调用进程传递的参数),并开始处理它们。处理取决于系统调用的类型。对于简单的系统调用,参数仅直接复制到用于临时存储的 eBPF 映射中,直到整个事件帧形成。对于其他更复杂的调用,eBPF 程序包括转换或增强参数的逻辑。这使得用户空间中的 Sysdig 应用程序能够充分利用数据。
一些附加数据包括以下内容:
-
与网络连接相关的数据(TCP/UDP IPv4/IPv6 元组,UNIX 套接字名称等)
-
关于进程的高度粒度度量(内存计数器,页错误,套接字队列长度等)
-
特定于容器的数据,例如发出系统调用的进程所属的 cgroups,以及进程所在的命名空间
如图 9-1 所示,在 eBPF 程序捕获特定系统调用的所有所需数据后,它使用特殊的本机 BPF 函数将数据推送到一组每 CPU 环形缓冲区,用户空间应用程序可以以非常高的吞吐量读取。这是 Sysdig 中使用 eBPF 的使用方式与使用 eBPF 映射在内核空间与用户空间之间共享“小数据”的典型范例不同的地方。要了解有关映射及如何在用户空间和内核空间之间进行通信的更多信息,请参阅第 3 章。
从性能角度来看,结果很好!在图 9-2 中,您可以看到 Sysdig 的 eBPF 工具的仪器化开销仅比“经典”内核模块仪器化略高。

图 9-2. Sysdig eBPF 性能比较
您可以按照使用说明玩转 Sysdig 及其 eBPF 支持,但同时也要查看BPF 驱动程序的代码。
Flowmill
Flowmill 是一家可观察性初创公司,起源于创始人 Jonathan Perry 的学术研究项目 Flowtune。Flowtune 研究了如何在拥塞的数据中心网络中高效调度单个数据包。在这项工作中所需的核心技术之一是一种以极低开销收集网络遥测数据的方法。最终,Flowmill 将这项技术调整为观察、聚合和分析分布式应用程序中每个组件之间的连接。
-
提供一个准确的视图,展示分布式系统中服务之间的交互方式。
-
识别在流量速率、错误或延迟方面发生了统计显著变化的区域。
Flowmill 使用 eBPF 内核探针周期性地跟踪每个打开的套接字,并捕获它们的操作系统指标。由于多种原因,这是一项复杂的任务:
-
必须同时仪表化新连接和已经打开的现有连接,这些连接在 eBPF 探针建立时已经存在。此外,还必须考虑通过内核的 TCP 和 UDP 以及 IPv4 和 IPv6 代码路径。
-
对于基于容器的系统,每个套接字必须归属于适当的 cgroup,并且与来自平台(如 Kubernetes 或 Docker)的编排器元数据相结合。
-
通过 conntrack 执行的网络地址转换必须进行仪表化,以建立套接字与其外部可见 IP 地址之间的映射。例如,在 Docker 中,一种常见的网络模型使用源地址转换(Source NAT)来伪装位于主机 IP 地址后面的容器;而在 Kubernetes 中,服务虚拟 IP 地址用于表示一组容器。
-
eBPF 程序收集的数据必须进行后处理,以提供按服务聚合的数据,并且匹配连接两端收集的数据。
然而,添加 eBPF 内核探针提供了一种更加高效和健壮的方式来收集这些数据。它完全消除了漏掉连接的风险,并且可以在亚秒级间隔内对每个套接字进行低开销的操作。Flowmill 的方法依赖于一个代理,结合了一组 eBPF kprobes 和用户空间指标收集,以及离线聚合和后处理。该实现大量使用 Perf 环来将每个套接字收集的指标传递给用户空间进行进一步处理。此外,它使用哈希映射来跟踪打开的 TCP 和 UDP 套接字。
Flowmill 发现通常有两种设计 eBPF 仪器的策略。"简易" 方法找到每个仪器化事件上调用的一到两个内核函数,但要求 BPF 代码维护更多状态并在每次调用时执行更多工作,这个仪器化点经常被调用。为了缓解对仪器化对生产工作负载的影响的担忧,Flowmill 采用了另一种策略:仪器更具体的功能,这些功能调用较少,并且表示重要事件。这种方法的开销明显较低,但在覆盖所有重要代码路径方面需要更多的工作,特别是在内核版本演变时。
例如,tcp_v4_do_rcv 捕获所有已建立的 TCP RX 流量,并访问 struct sock,但调用量极高。用户可以改用处理 ACK、无序数据包处理、RTT 估算等功能的仪表化函数,以处理影响已知度量标准的特定事件。
通过跨 TCP、UDP、进程、容器、连接跟踪和其他子系统的这种方法,系统在几乎不可测量的低开销下实现了极好的性能。CPU 开销通常为每核心 0.1% 到 0.25%,包括 eBPF 和用户空间组件,主要取决于新套接字创建速率。
关于 Flowmill 和 Flowtune 的更多信息,请访问它们的网站。
Sysdig 和 Flowmill 是使用 BPF 构建监控和可观测性工具的先驱,但并非唯一。在本书中,我们提到了其他像 Cillium 和 Facebook 这样的公司,它们采用 BPF 作为首选框架来提供高度安全和高性能的网络基础设施。我们对 BPF 及其社区未来充满期待,并迫不及待地想看看你们用它建立了什么。


浙公网安备 33010602011771号