简介
本仓库通过 eBPF 实现了一个针对 Linux 的全系统、跨语言分析器。
核心特性和优势
- 实现了 OTel 性能分析信号的实验性规范
- 非常低的 CPU 和内存开销(在测试中我们的上限是 1% 的 CPU 和 250MB 内存,代理通常能远低于此)
- 支持原生 C/C++ 可执行文件,无需 DWARF 调试信息(通过利用
.eh_frame
数据,如 US11604718B1 所述) - 支持对没有帧指针和主机上没有调试符号的系统库进行性能分析
- 支持运行时之间的混合堆栈跟踪 - 堆栈跟踪从内核空间通过未修改的系统库一直延伸到高级语言
- 支持原生代码(C/C++、Rust、Zig、Go 等,主机上无需调试符号)
- 支持广泛的高级语言(Hotspot JVM、Python、Ruby、PHP、Node.JS、V8、Perl),.NET 正在准备中
- 100% 非侵入性:无需在被分析的进程中加载代理或库
- 无需对高级语言解释器和虚拟机进行任何重新配置、插桩或重启:代理在默认配置下支持展开每种受支持的语言
- 除 NodeJS 外,所有展开器都支持 ARM64
- 支持原生
内联帧
,提供对编译器优化的洞察,并提供更精确的函数调用链
构建
[!注意]
如果你只是想以最小的努力试用一下代理,你也可以直接跳到"本地可视化数据"部分,启动 devfiler 并在其"添加数据"对话框中按照代理二进制文件的下载链接操作。
可以使用提供的 make
目标构建代理,而不会影响你的环境。不过,你需要安装 docker
。
支持在 amd64 和 arm64 架构上构建。
第一步是构建包含构建环境的 Docker 镜像:
make docker-image
然后,你可以构建代理:
# 为当前机器的架构构建:
make agent
# 或者,为另一个架构交叉编译代理:
make agent TARGET_ARCH=arm64 # 接受: amd64, arm64
生成的二进制文件将在当前目录中,名为 otel-profiling-agent
。
或者,你可以不使用 Docker 进行构建。请查看 Dockerfile
以了解所需的依赖项。
安装依赖项后,只需运行 make
即可构建。
运行
你可以使用以下命令启动代理:
sudo ./otel-profiling-agent -collection-agent=127.0.0.1:11000 -disable-tls
代理附带了一个功能性但仍在进行中/不断发展的实现,用于最近发布的 OTel 性能分析信号。
代理加载 eBPF 程序及其映射,开始展开并将捕获的跟踪报告给后端。
本地可视化数据
我们创建了一个名为"devfiler"的桌面应用程序,可以在本地可视化性能分析代理的输出,使其非常便于开发使用。devfiler 启动一个本地服务器,监听 0.0.0.0:11000
。
要运行它,只需从以下 URL 下载并解压缩存档:
https://upload.elastic.co/d/783f2fc7bcf34bd4ba5aa85676710d171ac574f8e6e99c85addabe9202673fdc
认证令牌: 801c759135b8bdb2
存档包含以下平台的构建:
- macOS (Intel)
- macOS (Apple Silicon)
- Linux AppImage (x86_64)
- Linux AppImage (aarch64)
[!注意] devfiler 目前处于实验性预览阶段。
macOS
此 devfiler 构建目前没有使用全球受信任的 Apple 开发者 ID 签名,而是使用开发者证书签名。如果你只是双击应用程序,会遇到错误。不要双击打开,而是在 devfiler.app
上右键单击,然后选择"打开"。如果你采用这种方式,会看到运行它的选项。
Linux
存档中的 AppImages 应该可以在任何具有相对现代的 glibc 和 libgl 安装的 Linux 发行版上运行。要运行应用程序,只需解压存档,然后执行:
./devfiler-appimage-$(uname -m).AppImage
代理内部结构
主机代理是一个 Go 应用程序,部署在客户希望进行性能分析的所有机器上。它收集、处理并将观察到的堆栈跟踪和相关元信息推送到后端收集器。
概念
文件 ID
文件 ID 唯一标识可执行文件、内核或脚本语言源文件。
原生应用程序的文件 ID 是通过取文件头、尾和大小的 SHA256 校验和创建的,然后将哈希摘要截断为 16 字节(128 位):
输入 ← 连接(文件[:4096], 文件[-4096:], BigEndianUInt64(Len(文件)))
摘要 ← SHA256(输入)
文件ID ← 摘要[:16]
脚本和 JIT 语言的文件 ID 以解释器特定的方式创建。
Linux 内核的文件 ID 通过对其 GNU 构建 ID 取 FNV128 哈希来计算。
堆栈展开
堆栈展开是恢复导致程序执行到分析器中断点的函数调用列表的过程。
堆栈展开的方式因线程是运行原生代码、JIT 代码还是解释代码而异,但基本思想始终相同:每种支持任意嵌套函数调用的语言都需要一种方法来跟踪当前函数完成后需要返回到哪个函数。我们的展开器使用相同的信息反复确定调用者,直到达到线程的入口点。
简化的伪代码如下:
pc ← 中断进程.cpu.pc
sp ← 中断进程.cpu.sp
while !is_entry_point(pc):
file_id, start_addr, interp_type ← file_id_at_pc(pc)
push_frame(interp_type, file_id, pc - start_addr)
unwinder ← unwinder_for_interp(interp_type)
pc, sp ← unwinder.next_frame(pc, sp)
符号化
符号化是将源代码行信息分配给堆栈展开期间提取的原始地址的过程。
对于在客户机器上始终有符号信息可用的脚本和 JIT 语言,主机代理负责对帧进行符号化。
对于原生代码,符号化发生在后端。堆栈帧作为文件 ID 和文件内偏移量发送,然后符号化服务负责在后台分配正确的函数名称、源文件和行。从操作系统软件包仓库安装的开源软件的符号从我们的全局符号化基础设施中提取,客户可以手动上传私有可执行文件的符号。
在后端进行原生符号化的主要原因是生产中的原生可执行文件通常会被剥离。要求客户将符号部署到生产环境既会浪费磁盘使用,也会成为初始采用的一个主要障碍点。
堆栈跟踪表示
我们有两种主要的堆栈跟踪表示方式。
我们的 BPF 展开器生成的原始跟踪格式:
用户空间额外处理后生成的最终格式:
乍看之下,这两种格式可能看起来非常相似,但有一些重要的区别:
- BPF 变体使用截断的 64 位文件 ID 以节省宝贵的内核内存
- 对于解释器帧,BPF 变体使用文件 ID 和行号字段存储或多或少任意的解释器特定数据,用户模式代码需要这些数据来进行符号化
我们的网络协议中存在第三种跟踪表示,但它本质上只是用户空间跟踪格式的去重压缩表示。
跟踪哈希
在性能分析中,经常会多次看到相同的跟踪。跟踪最多可达 128 个条目,重复对相同的跟踪进行符号化并通过网络发送会非常浪费。我们使用跟踪哈希来避免这种情况。BPF 和用户模式跟踪表示使用不同的哈希方案。多个 64 位哈希可能最终映射到相同的 128 位哈希,但反之则不然。
BPF 跟踪哈希(64 位):
H(kernel_stack_id, frames_user, PID)
用户空间跟踪哈希(128 位)
H(frames_user_kernel)
用户空间子组件
跟踪器
跟踪器是一个中央用户空间组件,在启动期间加载并将我们的 BPF 程序附加到相应的 BPF 探针上,然后继续作为 BPF <-> 用户空间通信的主要事件泵。它还实例化并拥有其他重要的子组件,如进程管理器。
跟踪处理程序
跟踪处理程序负责将BPF格式的跟踪转换为用户空间格式。它接收来自tracer的原始跟踪,将其转换为用户空间格式,然后发送给reporter。大部分转换逻辑通过调用进程管理器的ConvertTrace
函数完成。
由于转换和丰富BPF格式的跟踪不是一个低成本的操作,跟踪处理程序还负责维护一个跟踪哈希的缓存(映射):从64位BPF哈希到用户空间128位哈希。
报告器
报告器从跟踪处理程序接收用户模式格式的跟踪和跟踪计数,将它们转换为gRPC表示,然后发送到后端收集器。
它还接收额外的元信息(如指标和主机元数据),也将其转换并通过gRPC发送到后端收集器。
报告器不对网络操作的可靠性提供强保证,可能在任何时候丢弃数据,采用"最终一致性"模型。
进程管理器
进程管理器从tracer接收进程创建/终止事件,负责为BPF代码提供进行堆栈展开所需的任何信息。它维护每个进程映射的可执行文件映射,为原生模块加载堆栈展开增量,并为每个属于支持的语言解释器的内存映射创建解释器处理程序。
在跟踪转换过程中,进程管理器还负责将符号化请求路由到正确的解释器处理程序。
解释器处理程序
我们支持的每种解释或JIT编译语言都有一个对应的类型,实现了解释器处理程序接口。它负责:
- 检测解释器的版本和结构布局
- 将相应BPF解释器展开器需要的信息放入BPF映射
- 通过符号化将解释器帧从BPF格式转换为用户态格式
堆栈增量提供器
展开没有帧指针编译的原生可执行文件的堆栈需要堆栈增量。这些增量本质上是可执行文件中每个PC到描述如何找到调用者以及如何调整展开器机器状态以准备定位下一帧的指令的映射。通常,这些指令包括用作基地址的寄存器和需要添加到其中的偏移量(增量)——因此得名。堆栈增量提供器负责分析可执行文件并为它们创建堆栈增量。
对于大多数原生可执行文件,我们依赖.eh_frame
中存在的信息。.eh_frame
最初只用于C++异常展开,但后来被重新用于一般的堆栈展开。即使是用许多其他原生语言如C、Zig或Rust编写的应用程序通常也会带有.eh_frame
。
Go是这种通用模式的一个重要例外。截至撰写本文时,除非启用CGo构建,否则Go可执行文件不会带有.eh_frame
部分。即使使用CGo,.eh_frame
部分也只会包含一小部分函数的信息,这些函数要么是用C/C++编写的,要么是CGo运行时的一部分。对于Go可执行文件,我们从名为.gopclntab
的Go特定部分提取堆栈增量信息。关于该格式的深入文档可在单独的文档中找到。
BPF组件
主机代理的BPF部分实现了实际的堆栈展开。它使用eBPF虚拟机直接在Linux内核中执行我们的代码。这些组件是用BPF C实现的,位于otel-profiling-agent/support/ebpf
目录中。
限制
BPF程序必须遵守验证器施加的各种限制。在较新的内核版本中,许多这些限制已经大大放宽,但我们仍然必须坚持旧的限制,因为我们希望继续支持older内核。
支持的最低Linux内核版本是:
- amd64/x86_64为4.19
- arm64/aarch64为5.5
最显著的限制是以下两个:
- 每个程序4096条指令 单个BPF程序最多可以包含4096条指令,否则旧内核将拒绝加载它。由于BPF不允许循环,因此需要将它们展开。
- 32次尾调用
Linux允许BPF程序对另一个BPF程序进行尾调用。尾调用本质上是一个
jmp
到另一个BPF程序,结束当前处理程序的执行并开始一个新的处理程序。这允许我们通过在达到限制之前进行尾调用来稍微绕过4096条指令的限制。BPF程序最多可以进行32次尾调用。
这些限制意味着我们通常尽量在用户态准备尽可能多的工作,然后只在BPF内进行必要的最小工作。我们最多只能使用$O(\log{n})$算法,大多数情况下尽量坚持$O(1)$。所有无法按这种方式实现的处理都必须委托给用户态。作为一般经验法则,任何需要在循环中迭代超过32次的操作都不适合在BPF中进行。
展开器
展开总是从native_tracer_entry
开始。我们的跟踪器的这个入口点首先读取我们刚刚中断的线程的寄存器状态,并初始化PerCPURecord
结构。每CPU记录在同一展开器调用的尾调用之间保持数据。展开器的当前PC
、SP
等值从寄存器值初始化。
在初始设置之后,入口点查询由代理的用户态部分维护的BPF映射,以确定哪个解释器展开器负责展开PC
处的代码。如果找到该内存区域的记录,我们就尾调用到相应的解释器展开器。
每个解释器展开器都有自己的BPF程序。解释器展开器通常有一个展开的主循环,在这里它们尝试展开尽可能多的该解释器的帧,而不超过指令限制。每次迭代后,展开器通常会检查当前PC值是否仍然属于当前展开器,否则就尾调用到正确的展开器。
当展开器检测到我们已到达跟踪中的最后一帧时,展开通过尾调用unwind_stop
终止。对于大多数跟踪,这个调用将发生在原生展开器中,因为即使是JIT编译的语言通常也会通过几层原生C/C++代码才进入虚拟机。我们通过在用户态准备的BPF映射中启发式地用PROG_UNWIND_STOP
标记某些函数来检测跟踪的结束。然后unwind_stop
将完成的BPF跟踪发送到用户态。
如果跟踪中的任何帧需要在用户模式下进行符号化,我们还会发送一个BPF事件来请求从用户态加速读取。对于所有其他跟踪,用户态将简单地按定时器读取然后清除这个映射。
PID事件
BPF组件负责通知用户态有关新进程和退出进程的信息。当我们首次用展开器中断一个进程时,会产生关于新进程的事件。关于退出进程的事件是通过sched_process_exit
探针创建的。在这两种情况下,BPF代码都会发送一个perf事件来通知用户态。如果我们检测到在以前未知的内存区域执行,我们也会重新报告PID,以提示重新扫描映射。
网络协议
所有收集的信息都通过基于推送的、无状态的、单向gRPC协议报告给后端收集器。
所有要传输的数据都存储在有界的FIFO队列(环形缓冲区)中。当队列填满时(例如由于后端收集器滞后或离线),旧数据会被覆盖。没有明确的可靠性或冗余(除了gRPC内部的重试),假设数据将被重新发送(最终一致性)。
跟踪处理流水线
主机代理包含一个内部流水线,逐步处理BPF展开器产生的原始跟踪,用额外信息(如解释器帧的符号和容器信息)丰富它们,对已知跟踪进行去重,并合并在同一更新周期内发生的跟踪计数。
BPF中产生的跟踪最初包含以下图表中显示的信息。
注意:如果您想更新图表,请阅读此内容
本节中的图表是通过draw.io创建的。可以将SVG文件加载到draw.io中进行编辑。完成后,请务必通过文件 -> 导出为 -> SVG来导出,然后选择200%的缩放级别。如果只是通过CTRL+S保存图表,它将无法填满文档页面的整个宽度。另外,请确保"包含我的图表副本"保持勾选状态,以保持图表的可编辑性。我们的后端收集器期望接收规范化和丰富化格式的跟踪信息。下面的图表与实际通过网络发送的数据结构非常接近,只是省略了在发送前应用的批处理和特定领域的去重。
下面的图表详细概述了主机代理的各个组件如何交互,将原始跟踪转换为网络格式。它主要关注我们的数据结构以及数据如何在它们之间流动。虚线表示与数据结构的间接交互,实线对应代码流程。"UM"是"用户模式"的缩写。
测试策略
主机代理代码通过三个测试套件进行测试:
- Go单元测试
使用常规的Go单元测试来测试单个函数和类型的功能。这对于代理的用户空间部分效果很好,但无法测试任何展开逻辑和BPF交互。 - coredump测试套件
在coredump测试套件(utils/coredump
)中,我们将整个BPF展开器代码编译成用户模式可执行文件,然后使用coredump中的信息来模拟一个真实的环境,以测试展开器代码。coredump套件本质上是在用户空间实现所有必需的BPF辅助函数,从coredump中读取内存和线程上下文。然后将结果跟踪与JSON文件中的帧列表进行比较,作为回归测试。 - BPF集成测试
创建一个带有integration
标记的主机代理特殊构建,启用专门的测试用例,这些用例实际上将BPF跟踪器加载到内核中。这些测试用例需要root权限,因此不能成为常规单元测试套件的一部分。测试用例专注于覆盖BPF与用户模式代码的交互和通信,以及测试我们的BPF代码是否通过BPF验证器。我们的CI构建一次集成测试可执行文件,然后通过qemu在多种不同的Linux内核版本上执行它。
概率性分析
概率性分析允许您通过收集具有代表性的分析数据样本来降低存储成本。这种方法以可见性为代价来降低存储成本,因为并非所有分析主机代理都会始终启用分析数据收集。
分析事件与概率性分析值呈线性相关。值越低,收集的事件越少。
配置概率性分析
要配置概率性分析,请设置-probabilistic-threshold
和-probabilistic-interval
选项。
将-probabilistic-threshold
选项设置为1到99之间的无符号整数以启用概率性分析。在每个概率性间隔,系统会选择一个0到99之间的随机数。如果您设置的概率阈值大于这个随机数,代理将在该间隔的持续时间内从这个系统收集分析数据。默认值为100。
将-probabilistic-interval
选项设置为时间持续值,以定义概率性分析启用或禁用的时间间隔。默认值为1分钟。
示例
以下示例展示了如何配置分析代理,阈值为50,间隔为2分30秒:
sudo ./otel-profiling-agent -probabilistic-threshold=50 -probabilistic-interval=2m30s
法律
许可信息
本项目根据Apache许可证2.0(Apache-2.0)授权。 Apache许可证2.0
eBPF源代码根据GPL 2.0许可证授权。 GPL 2.0
依赖项的许可证
要显示依赖项许可证的摘要:
make legal
详细信息可在生成的deps.profiling-agent.csv
文件中找到。
在撰写本文时,摘要如下:
数量 许可证
52 Apache-2.0
17 BSD-3-Clause
17 MIT
3 BSD-2-Clause
1 ISC