Nahida: In-Band Distributed Tracing with eBPF

文章主要是在HTTP的header里面插入context。 同时,在线程创建时记录父子关系,并利用这个父子关系处理多线程的context propagation。 不同命名空间的线程ID追踪是一个技术点。

在introduction部分,文章首先排除了intrusive的解决方案(相关的论据和DeepFlow比较类似)。 主打的是,在high-level language和多线程的情况下,non-invasive implementations fail to reconstruct request causality and track end-to-end requests。 因此这些现有的非侵入方式都有一些精度问题(虽然明显是DeepFlow的bug)。

文章在introduction部分提出了challenge:

  1. Tracking complex end-to-end requests
  2. Compatible with heterogeneous high-level languages
  3. Supporting single-threaded and multi-threaded applications
  4. Maintaining high performance

Motivation

里面比较有意思的是,作者提到了“we observed that existing invasive tracing systems with third-party library instrumentation are unable to track accurate end-to-end requests as implemented in application source codes in such complex application implementation”。

同时,作者认为DeepFlow的accuracy问题一是受限于metadata、二是受限于iterative times和policy。

Goals

文章的主要目标是

  1. Zero instrumentation
  2. Support for high-level languages
  3. High Accuracy
  4. Low Overhead

System Design

Overview

系统最终的产出被设计为符合OTel的标准。 同时,系统主要把BPF钩子挂在sendfile、sendmsg、recvmsg上。 整体流程能够想象,大概是context随着包传输,recv的时候查询/extract出来,send的时候再inject进去。

Trace Generation

分成三部分:生成unique trace context、context transmission和context propagation。

Context Generation

好像是常规的span定义。 从第一次发送请求到第一次接收响应的时间被视为客户端的时间成本,而从第一次接收请求到第一次发送响应的时间被记录为服务器的时间成本。 例如,当客户端向服务 A 发送请求时,服务 A 将从第一次接收请求到第一次发送响应的时间作为其服务器时间,将从第一次向服务 B 发送请求到第一次接收 B 的响应的时间作为客户端到请求 B 的时间。

跟踪 ID 是一个 16 字节的值,包括

  • 主机 IP 地址
  • 端到端请求条目的时间戳
  • 顺序
  • 调试标签
  • 运行进程 ID。

跨度 ID 为 8 字节,记录

  • 通信客户端和服务器的 IP 地址
  • 整个跟踪中的跨度顺序

Nahida 生成跟踪上下文的时间是服务向其上游发送请求和服务接收请求的时间。 通过解析消息内容,Nahida 可以通过匹配 HTTP 格式识别 HTTP 消息,然后生成跟踪上下文,而不会影响其他非 HTTP 消息。 此外,由于 Kubernetes提供的服务治理功能,每个服务及其实例都有自己的实例 IP 地址。 通过过滤,Nahida 可以进一步减少非服务请求的消息解析和请求跟踪时间开销,从而更有效地服务于微服务系统的端到端请求跟踪。(背景噪声消除)

Context Transmission

论文将trace context作为自定义的key-value对放进HTTP header里面。

利用 eBPF 的辅助函数 bpf_msg_push_data,Nahida 可以拦截发送信息,并在用户不知情的情况下在特定位置注入定制的上下文。 Nahida 利用 sk_msg 程序将信息以散点图的形式分割成两个片段,然后在请求行之后的第一个片段中注入我们定制的上下文,HTTP 头中的第一个键值数据就放置在这个片段中。(这句话什么意思?) 请求行可以通过 HTTP 报文中每一行的十六进制结尾符号 0x0d0a 来识别。通过这种方法,Nahida 可以减少本地化查找所需的操作指令数量,因为出于安全考虑,eBPF 程序对指令数量有上限。

在注入上下文后,这两个片段会合并为一条新信息发送。当收到请求和唯一文本时,内核函数 sock_recvmsg 上的 kprobe 和 kretprobe 程序会帮助我们获取接收到的信息内容,并提取注入的跟踪上下文。

Context Propagation

中心思想是capture the user thread creation,利用这个信息来取理解user thread的执行和父子关系。 具体来说是这样么做的

  1. 对于单线程应用,用threadID作为key,将trace context存在eBPF maps里面,发包的时候根据当前发包线程的threadID在maps里面找trace context、插入到包里
  2. 对于多线程应用,用parent threadID作为key,将trace context存在eBPF maps里面,发包的时候,先根据当前发包线程的threadID找parent,然后在maps里面找parent threadID的trace context、插入到包里

在微服务中,应用程序部署在容器中以实现快速部署。 然而,命名空间中容器之间的资源隔离使得在内核中建立线程的父子关系成为一项挑战。 容器化应用程序的线程在其容器的命名空间中执行,并在该命名空间中分配唯一的 ID。 此外,子命名空间中的线程也应在其父命名空间中拥有唯一的 ID。

线程的 ID 随着命名空间不同会改变,Nahida 在【根命名空间】中执行,而应用程序则在【子命名空间】中运行。 在监控系统函数 fork 和 clone 时,Nahida 会获取子命名空间中子线程的 ID 和根命名空间中父线程的 ID。 这会导致线程 ID 不一致,线程间的父子关系不准确。

为了应对这一挑战,我们尝试研究 Nahida 如何获取不同命名空间中的线程 ID。 图 5 显示了 Linux 中线程的数据结构,其中线程被视为一个执行进程。线程将其命名空间记录在名为 level 的变量中,并将每个命名空间的 ID 记录在名为 number 的数组中。 当一个新线程被fork时,它将通过调用系统函数 alloc_pid 来分配其所有命名空间的新 ID。 该函数上的 kprobe 程序可以帮助 Nahida 获得线程的结构,并解析其在所有命名空间中的 ID,因此 Nahida 能够在建立线程的父子关系时保持一致性。

利用收集到的信息,Nahida 以线程 ID 为键存储提取的上下文,实现上下文预处理。向上游发送请求时,Nahida 会找到要注入的上下文。当线程向上游发送消息时,Nahida 会首先查找带有运行线程 ID 的跟踪上下文。如果运行线程没有自己的跟踪上下文,Nahida 会继续查找其父线程的跟踪上下文。找到跟踪上下文后,Nahida 会将该跟踪上下文的子上下文注入向上游发送的信息中,从而在应用程序中传播跟踪上下文。

Implementation

这边有用的部分我整合到前面去了。