小红书如何让 eBPF 成为可观测性建设最强辅助?

小红书技术REDtech 2024-11-26 11:03:28

 

在当前的云原生时代,随着微服务架构的广泛应用,云原生可观测性概念被广泛讨论。可观测技术建设,将有助于跟踪、了解和诊断生产环境问题,辅助开发和运维人员快速发现、定位和解决问题,支撑风险追溯、经验沉淀、故障预警,提升系统可靠性。云原生和微服务技术的不断深入应用给可观测提出了新的需求,在 Metrics、Logging、Tracing 等传统可观测范畴外,我们需要探索新的技术和方案。

 

小红书可观测团队在过去一段时间内,对 eBPF 等新技术在可观测的应用进行了探索,在通用流量分析、持续 Profiling 等领域进行落地,解决了之前碰到的一些痛点问题。通过 eBPF 技术的应用,团队将可观测能力从应用程序扩展到了内核,实现了对可观测领域的进一步扩展。

 
 

 

一、背景

 

在过去一段时间里,我们在生产上遇到了一些实际问题,如中台服务被多上游服务访问、或者提供 OpenApi 供外部服务调用,有时候会碰到接收到的流量异常上涨,自身应用在流量异常上涨的情况下CPU、内存可能会跟着飙升,往往会影响应用自身的稳定性的情况。更麻烦的是,此时我们有时候并且不知道调用方在哪。甚至存在开发环境访问线上环境、跨机房访问等情况。如下图所示,可观测的主机监控存储集群,被未知的上游服务定期拉取数据,导致 CPU、内存异常上涨,严重的影响监控存储本身的稳定性。

 

 

想要解决类似的问题,需要对流量进行实时的分析,并且做到语言、架构无关。而传统的可观测领域,缺少解决这种实时流量分析的通用手段。

 

此外,业务对于 C++ 性能退化的识别是一个普遍的诉求,之前我们对 C++服务的持续 Profiling 和性能退化检测中,碰到了一些阻碍,其中主要的困难在于基于传统的 linux perf 的方式,使用frame pointer 方式的回溯可能会出现结果不准确;使用 dwarf 方式的回溯会出现性能开销比较大、耗时很长的问题。这些导致常态化 Profiling 无法实现,缺少低开销且通用的解决方案。

 

基于以上背景,我们注意到了近些年在各领域兴起并得到应用的 eBPF 技术,可以在 Linux 确定的内核函数 Hook 点运行,来执行用户设定好的逻辑,常见的如对网络数据包的监控、性能统计和安全审计等功能。我们初步判断,eBPF 的这些特性能够解决困扰我们的这些问题。

 

同时,eBPF 作为近些年来的 Linux 社区的新宠,受到了国内外互联网大厂的青睐,在多个领域都得到了应用。国内各大互联网公司的基础架构部门也都在落地 eBPF,如字节、阿里、腾讯百度等等,都有着eBPF的落地经历。基于 eBPF 的开源项目也像雨后春笋一样涌现出来。

 

所以我们尝试把 eBPF 在可观测所面临的问题场景中进行落地,来解决我们遇到的一些痛点问题,最终服务好业务的稳定性。

 

二、eBPF简介

 

eBPF(extended Berkeley Packet Filter),是对 BPF (Berkeley Packet Filter) 技术的扩展。通过在内核中运行沙盒程序,eBPF 允许程序在不修改内核源代码或加载内核模块的前提下,扩展内核的能力。随着 eBPF 技术的不断完善和加强,eBPF 已经不再局限于定义中的网络数据包的过滤,在可观测、安全、网络等方面得到了广泛的应用。

 

 

传统意义上的观测性,是指在外部洞悉应用程序运行状况的能力。基于 eBPF 可以无需侵入到应用程序内部、直接向内核添加代码来收集数据的特点,我们可以直接从内核中收集、聚合自定义的数据指标。通过这种方式,我们将可观测性扩展从应用程序扩展到了内核,实现对可观测领域的进一步扩展。

 

常见的内核 Event 如下图所示:

 

 

在这些 Hook 点上,都可以编写应用程序来实现可观测能力的覆盖,同时探索更多深度观测能力。

 

针对实际工作中遇到的痛点,我们基于 eBPF 技术,在流量分析和 Profiling 中进行了探索,下文分别对这两个方面进行详细的介绍。

 

三、在流量分析的应用

 

在流量分析场景下,目前我们主要聚焦在 L4、L7 层:L4 层得到流量包的大小,L7 层进一步得到 QPS、RPC Method 等信息。

 

整体架构如下所示:

 

 

我们的 eBPF Agent 以 DaemonSet 方式部署,在启动过程中,将 eBPF 程序加载到内核中,Hook 内核的 Tcp 数据收发等系统调用。主要流程:

 

  • Agent 通过接收下发的流量采集配置,在所在的 Node 上查找目标进程,在找到目标进程后,将目标进程号(Pid)传递到内核中。

 

  • 内核态的 eBPF 程序收到Pid信息后,开始采集流量并做轻量级的处理,并将数据发送到 eBPF Map 中。

 

  • 用户态的 eBPF Agent 读取 eBPF Map 中的数据,做聚合和处理,生成 Metrics 指标。

 

  • eBPF Collector 集中式的采集各个 eBPF Agent 生成的 Metrics 指标;在采集到指标后,根据指标中的上下游 IP 信息来查询 Meta 服务,获取到对应的应用信息,并补充到 Metrics 指标中;最终写入到 Vms 存储供查询。

 

下面分别从内核态、用户态、eBPF Collector 等几个方面来详细的阐述。

 

 

3.1. 内核态

 

3.1.1. L4层流量

 

一个典型的 Client-Server 之间的收发包流程,如下:

 

 

Client-Server之间,先建立连接:Server 通过 bind、listen 来监听端口,Client 通过 connect 来与 Server 创建连接;Server 在监听到这个请求之后,会调用 accept 函数取接收请求,这样就建立了连接。建立连接之后,Client 可以发出数据包,在L4层,关键函数是 tcp_sendmsg。

 

基于上面的流程,我们主要关注的 Hook 点如下:

 

 

其中,对于 Server 来说,我们没有 Hook tcp_recvmsg,而是 Hook tcp_cleanup_rbuf。这主要是因为一方面 tcp_recvmsg 可能存在统计上的遗漏和重复;另一方面,tcp_cleanup_rbuf 的执行次数低于 tcp_recvmsg,可以降低消耗。

 

在Hook tcp_sendmsg和tcp_cleanup_rbuf中,根据struct sock对象,拿到上下游的IP、Port、数据包大小等关键信息,并Output到用户态。

 

3.1.2. L7层流量

 

L4层面的流量提供了网络流量大小。有时候,我们还想要知道更多的信息,如QPS、延迟、消息协议、Rpc Method、Redis 命令等等。在这种情况下,我们需要进一步来实现 L7 层的流量分析功能。

 

我们通过Hook读写相关的系统调用,来获取到服务之间的流量数据,常见的读写相关的系统调用,如下:

 

 

通过Hook这些系统调用,我们最终需要拿到的是原始的报文buf数据、对端地址信息(socket address),并基于 buf 数据和 socket address 处理得到 QPS、协议、RPC method 等信息。

 

基本流程如下:

 

  • 通过 tracepoint/probe 追踪 socket syscall 相关的函数,Hook 并根据 Pid 进行过滤,保留 Pid 收发的流量 buf 数据;

  • 根据 buf 数据,提取 socket 元信息,获取 socket address;

  • 根据 buf 数据,进行相应的协议推断,判断是否是我们支持的协议,不是则设置为 unknown;

  • 将原始的流量 buf 数据 Output 到用户态,供进一步处理。

 

其中,两个关键的过程分别是获取 socket address、协议推断。

 

socket address获取:

 

一个方案是Hook 建立连接的系统调用,如 sys_enter_connect、inet_sock_set_state 等,并解析参数中的 skaddr 的信息来拿到 IP、Port。这种方案的优点是简单易实现,但是这种方案的问题是,对于在我们 Agent 部署之前就存在的长连接来说,我们无法捕获到相应的事件和相应的信息。

 

我们采用的方法是是通过 bpf_get_current_task 来拿到 task_struct 类型的 task,来获取 socket 对象,进而拿到 sockaddr:

 

  • task_struct 中的 files 字段,类型为 files_struct;

  • 根据 files 拿到 fdtable 字段,是当前进程的文件描述符表;

  • 再从 fdtable 中,根据 socket 的 fd,拿到 socket 的 file 结构;

  • socket 的 file 中,有个 sock 类型的 sk 对象,就是 socket 的内核对象指针;根据 sk 对象,就可以得到 IP、Port、UDP 还是 TCP、IPV4 还是 IPV6 等各种属性。

 

协议推断:

 

根据上述列出来的读写系统调用中,拿到的原始的字节流 buf 数据,可以来尝试解析对应的应用协议,直接遵照协议规范进行解析。当前常见的协议如 Http1、Thrift、Redis、Baidu-std 等,目前我们都已经支持了;后续会支持如 Mysql 等更多协议的解析推断。

 

此外,在协议的解析推断过程中,另外一个问题是业务消息的拆分和重组:在实际中业务进程的一次数据收发,在系统调用层面,可能会拆分成多次系统调用来进行读写,可能会导致后续的 buf 都无法正确的解析出协议来。

 

为了解决这种问题,当一个 socket 上的 buf 数据在协议推断成功后,将 socket 和协议信息保存在 socket info 中,并将 socket info 进行缓存;该 socket 上后续的 buf 数据在协议解析推断失败后,会默认使用该 socket info 中的协议信息;如果后续 buf 数据协议解析成功且多次不同时,对协议进行覆盖。这样,可以尽可能降低解析错误的概率。最后,Hook close 系统调用,在 socket close 的时候,把 socket info 清理掉。此外,利用数据包之间的关联来判断协议,即 request、response 之间的协议应该是一样的,来进一步降低解析错误的概率。

 

3.1.3. 内核适配

 

基于 eBPF,应用开发的模式主要有两种:

 

  • BPF 编译器集合 (BCC Tools) 工具包提供了许多有用的资源和示例来构建有效的内核跟踪和操作程序。

 

  • BPF CO-RE (Compile Once – Run Everywhere)是与 BCC 框架不同的开发部署模式,使用 BTF来解决编译依赖问题。

 

BCC的优点是提供了很多有用的示例,同时还有多种前端语言(主要是用户态用来处理加载到内核态BPF程序的输出和交互)来辅助进行编程,如Python、Golang。存在的问题是:

 

  • 使用 Clang 修改编写的 BPF 程序,当出现问题时,排查问题更加困难。

  • 类似一种动态语言的方式,BPF 程序是在运行时编译的,编译的时候需要工具链和内核文件。编译依赖是脆弱的、容易失败,所以总体不可控,兼容性不够好。

  • 应用在启动时,编译BPF程序会占用大量的CPU和内存资源,在大量的低规格的机器上,可能会影响业务进程。

 

这些问题,特别是兼容性问题和性能问题,对于我们想要在线上大规模部署的话,是很大的阻碍。

 

BPF CO-RE 依赖内核特性支持 BTF,将内核的数据结构类型构建在内核中。用户态的程序可以导出 BTF 成一个单独的大的.h 头文件(如vmlinux.h),这个头文件包含了所有的内核内部类型,BPF 程序只要依赖这个头文件就行,不需要安装内核头文件的包了。这样就可以减少依赖,进行提前编译。

 

因此,考虑到我们需要大规模部署并且长时间运行,我们需要尽可能降低资源占用、提高性能,我们选择了 CO-RE 方式。

 

使用 BTF 机制,需要内核开启了 CONFIG_DEBUG_INFO_BTF选项(CONFIG_DEBUG_INFO_BTF=y)。在我们上线覆盖过程中,遇到了部分机器的内核是5.4,同时没有开启 CONFIG_DEBUG_INFO_BTF 选项。对于这些没有开启的内核,我们生成并导入对应版本的 BTF 文件;我们的 eBPF Agent在启动时先检测内核版本和 CONFIG_DEBUG_INFO_BTF 选项;如果选项没有开启,则根据内核版本加载对应的 BTF 文件。当前我们对线上主要的5.4、5.10的多个内核版本做了适配。

 

 

3.2. 用户态

 

用户态的主要工作是通过接收采集配置来选择出目标 Pid,传递给内核 eBPF 程序来开启流量分析;从内核中读取流量数据并处理。基本的示意图如下:

 

 

3.2.1. 生效机制

 

在实际应用中,如果采集 Node 上所有的流量数据,消耗会很大;同时大量的未知流量信息会带来很大的干扰。因此,我们需要在 Node 上选择出我们实际关注的 Pid,同时将 Pid 信息传递到内核中,在内核流量采集分析的时候,根据Pid进行过滤。

 

当前流量分析功能是按需开启的。在 Node 上部署 eBPF Agent 后,通过配置中心下发配置来决定对哪些服务开启、开启的 K8S 集群,以及生效的比例等。Agent 在接收到配置后,根据 Pod 过滤规则,在所属的 Node 上查找匹配到的 Pod;在 Pod 的各个 Container 中,根据 Container name 查找匹配的 Container;最后根据 K8S 集群信息和 Pid name,在 Container 中匹配到 Pid。

 

匹配到 Pid 之后,将 Pid 传递给内核 eBPF 程序来开启采集。在需要关闭采集的时候,将 Pid 从内核中删除即可。

 

3.2.2 eBPF C 程序管理

 

在拿到 Pids 之后,我们将 eBPF C程序、相关的 eBPF Map 以及 Pids 加载到内核中,这里涉及到 eBPF C程序的管理和数据交互。

 

为了简化 eBPF C代码的开发和调试流程,我们支持了配置化的对 eBPF 程序的加载、卸载、数据读取等。整体结构如下:

 

 

编译&加载:

 

eBPF 的 C 代码,使用 Clang 和 LLVM 工具链来编译 eBPF 代码,生成可加载的字节码文件。将字节码文件作为 ELF 文件资源进行读取,并解析其中的 Maps、Program 等。在解析之后,通过 BPF 系统调用:对 Maps 进行 BPF_MAP_CREATE 创建 Maps;对 Program 进行BPF_PROG_LOAD。这样将字节码加载到内核中并进行安全验证。

 

Link:

 

根据配置文件中配置的 probe、tracepoint 信息,通过 BPF_LINK_CREATE BPF 系统调用,将 eBPF 程序挂载到对应的内核事件上,从而实现对这些事件的监听,当内核执行到对应的事件,会触发并执行对应的 eBPF 程序逻辑。

 

数据读取:

 

Map 是 eBPF 内核程序和用户态程序之间交互的桥梁。在用户态中,根据配置文件中配置的 Map,启动 Epoll 来读取 Map 中的数据。

 

3.2.3. 内核数据接收与处理

 

 

eBPF C程序被加载进内核后,代理程序(eBPF Agent)便开始通过 Epoll 机制读取 eBPF Map 中的数据。这些数据包含了业务模块间直接交换的原始流量。

 

采样流量开关:对于一些轻量级的 Proxy 服务,往往单个实例的流量很大;同时,单个 Node 上可能部署多个实例,这样一来 Node 上部署的eBPF Agent 采集流量并做处理的压力就很大。为了解决 eBPF Agent 流量处理压力大的问题,eBPF Agent 实现了流量采样机制,Agent 通过配置中心获取采样比例配置,通过 eBPF Map 将配置信息传给内核。eBPF Agent 也通过配置中心配置下发实现了更细粒度的流量开关,能精确控制 L4/L7 的进/出不同方向的流量采集,按需开启,来实现节约资源消耗的目的。

 

流量数据解析:当流量数据传到用户侧时 Agent 根据 L7 协议规范进一步解析并提供更多信息:在如网关场景下,通过精准解析 HTTP 消息,可以实时获取到请求的实际 IP;在 RPC 场景下,通过递归解析Thrift消息,可以识别 RPC 方法,任意 RPC 参数等信息(比如排序服务的模型信息);在 Redis 场景下,可以解析Redis命令。

 

指标数据生成:在解析补全 L7 流量信息后,Agent 将消息事件进行哈希后放入 Queue 中,保证后续构成相同指标的事件总是被缓存在同一个队列中。在消费 Queue 中缓存的消息事件时,消息事件流量 IP、方向、协议等信息被聚合为流量指标;同时将流量指标根据采样率进行流量还原,最终生成 Prometheus 格式的 Metrics 指标。此外,为了控制资源消耗、内存使用和监控指标的过度膨胀,Agent 会在实例IP变动后,需要及时进行数据过期清理。

 

 

3.3. 指标采集和处理

 

对于 L4、L7 层流量数据来说,我们在用户态拿到的数据中,包含了上下游服务的 IP、Port。实际生产环境中,上下游服务实例非常多,并且随着应用发布会不断变化,单纯提供IP对开发和运维同学的帮助不大。因此,我们需要将 IP、Port 关联出所属的应用、服务,并提供更多的相关信息,如 Region、K8S 集群等信息。

 

我们部署 eBPF-Collector 来统一采集部署的eBPF Agent的指标数据,处理后进行存储。

 

3.3.1 元数据关联

 

我们通过 CMDB 查询出 IP:Port 对应的应用名/区域等服务元信息。由于指标数据量巨大,不可能为每一个数据点请求一次 CMDB 来获取元信息,因此我们设计了元信息缓存来加速查询。

 

Cache 整体架构

 

我们最初将元信息缓存设计为指标采集服务(eBPF Collector)的本地内存缓存。但是由于相同的 IP:Port 查询请求会等概率地出现在所有采集分片中,采集分片的本地缓存会保存几乎全部被用到的数据。在水平扩容分片时,本地内存缓存数目也会成倍增加,这意味着当缓存更新时,缓存对 CMDB 的请求数目也会随服务分片数成倍增加,这会对 CMDB 服务造成巨大查询压力。为了解决这一问题,我们重新设计了如下图所示的新的 Cache Server 结构:

 

 

我们将缓存服务独立部署为单独的 Cache Server,与指标采集服务隔离。这解除了指标采集服务和元信息缓存的耦合,防止指标采集服务水平扩容带来的元信息重复请求问题。

 

Cache 内部结构

 

 

元信息缓存是基于 Working Set 的思路设计的,我们将查询到的元信息存储一段时间,同时使用 Singleflight 机制,合并同一时刻出现的相同的元信息查询请求,降低对 CMDB 的请求并发度。

 

为了降低查询延迟,缓存除了根据预先确定的 TTL 删除一段时间内未访问的元信息,还会对仍在缓存中的元信息每隔若干时间进行后台刷新来更新数据。

 

由于缓存的元信息都保存在内存中,Cache Server 服务重启/发布后会导致缓存的数据丢失。这意味着每次启动都需要几乎大量拉取 CMDB 元信息,我们为缓存服务添加了缓存持久化功能,缓存服务会将缓存持久化在硬盘中,重启后直接尝试读取旧缓存,防止冷启动问题。

 

3.3.2. 查询性能优化

 

eBPF 的网络流指标量非常大,一次采样周期内采集到的指标量超过1.1亿;并且高度集中在L4、L7的三四个指标中,这给指标查询带来了巨大压力,日常可查询的时间范围不超过一天,并且经常查询超时。

 

但是相对而言,eBPF 指标的查询方式比较固定,所以我们可以根据预先定义的 PromQL 查询对指标进行流式预聚合,将预聚合之后的指标写入存储。这相当于将指标链路中采集之后链路(比如存储/查询)的计算压力前置,大幅降低写入存储的指标量,进而减少查询的数据量,加快查询速度和可查询的时间范围。整体过程如下图:

 

 

就具体实现来说,我们通过配置中心下发预聚合配置,当配置有变更时,服务会原子地更新预聚合算子(Operator)并重置预聚合状态。

 

当指标数据到达预聚合服务时,数据会被复制一份,复制后的数据会经过预聚合 State Operator 来计算得到预聚合中间状态,并保存在内存中;根据配置不同,每隔若干时间(比如 30s)服务会将中间状态通过 Merge Operator 合并为聚合后的数据,并写入游数据源。

 

为了保证数据的完整性,预聚合服务起停时的最近聚合数据会被丢弃。对于单副本预聚合服务,服务起停时指标可能出现断点,我们使用双副本加上数据去重来避免这个问题。

 

经过对比验证,我们测试发现通过指标预聚合,指标查询速度提升能 10 倍以上;查询时间范围从一天延长至至少一周以上。

 

 

3.4. 产品化&实际落地的场景

 

当前的流量分析功能和“目标应用”的语言、框架无关,接入时不需要业务方做任何修改,对业务无感知、无侵入。我们在部署 Agent 并发布配置后,就会产生实时的、持续的流量数据,数据保存一个月。生效、取消生效的过程快速,秒级生效。

 

在性能上,在当前所有覆盖的场景下,eBPF Agent 日常平均CPU使用量在0.1 Core、内存在200MB;CPU Limit设置为0.5 Core,内存1GB,对业务基本无影响。

 

当前在小红书的Redis、KV存储、推荐、广告等场景规模落地,接入服务过一千。下面介绍流量分析的使用方式和一些实际 Case。

 

3.4.1. 产品

 

3.4.1.1. 流量大盘

 

L4层协议,当前支持展示"目标应用"的流量大小。

 

作为服务端(Server),接收到上游请求的流量(MB/s)、返回给上游的流量(MB/s);作为客户端(Client),请求下游的流量(MB/s)、接收到下游返回的流量(MB/s)。

 

 

上图中展示的一个排序服务的L4层详情:作为服务端(Server),接收到上游的请求流量(MB/s)、返回给上游的流量(MB/s);作为客户端(Client)请求下游的流量(MB/s)、接收到下游返回的流量(MB/s)。

 

L7层的流量分析,例子如下:

 

 

当前支持展示"目标应用":作为服务端(Server),接收到上游的请求QPS、返回给上游的QPS;作为客户端(Client)请求下游的QPS、接收到下游返回的QPS。此外,还展示对应的应用协议(当前支持Thrift、Redis、Http)、服务部署的Region(上海、南京、杭州)等信息。

 

此外,我们还提供了OpenApi接口,来查询服务的上下游流量指标情况。Redis、KV存储等存储服务的高可用架构规范治理过程中,通过这种方式来获取上游服务的来源和访问情况。

 

3.4.1.2. 服务拓扑

 

基于 eBPF 的服务流量指标,我们可以构造出服务之间拓扑关系,所有 A 服务发往 B 服务的流量都会聚合为服务 A 到服务 B 的一条边,由此构成拓扑图。我们定期拉取一天的的流量指标,聚合出服务拓扑边,并将边信息存储在 Clickhouse 中。当用户查询拓扑关系时,服务从 Clickhouse 中取出拓扑边信息构造拓扑图。下图展示了由 eBPF 流量指标获取的两层拓扑图:

 

 

3.4.2. 落地场景

 

在实际覆盖过程中,流量分析可以辅助定位流量上涨的来源确定、偶发的流量、服务下线过程中的流量排空等问题。

 

Case 1.  服务下线前,偶发流量的来源定位

 

问题背景:电商的研发同学向我们咨询,他们有个服务在准备下线的时候,遇到个问题:还有偶发的、非常零星的上游流量会访问他们的服务,访问的频率在每小时十几个请求。担心贸然的下线会影响稳定性,他们希望帮忙定位这些零星流量的来源。流量情况如下所示:

 

 

这种零星的流量,夹杂在日常的其他消息中,常规的抓包是很难定位的。我们的eBPF 流量来源分析,因为可以做到任意时刻、实时的流量采集,可以来解决这种问题。

 

我们在部署并开启了 eBPF 的 100% 全采样的流量分析。进一步了解到业务同学关心的零星请求是特定的 Thrift Method,所以想要定位的话,需要在采集 Thrift 流量后,进一步对 Thrift 消息进行解析和分析。经过解析实际的消息并进行 Thrift Method 聚合后,终于可以看到了小时级的偶发的流量来源,可以看到对应的上游服务 IP,如下所示:

 

 

根据 IP 很快就成功的定位到了上游服务,是一个很古老的前端 Node 服务。

 

Case2.  流量上涨的来源确定

 

问题背景:Redis 的一个集群,某晚上海区异常,流量大幅上涨导致集群被打挂,影响内流初排成功率。初步找到的流量来源看起来不是真正的大头,需要排查上游流量上涨的来源。

 

我们通过部署 eBPF Agent 并采集分析流量,在几分钟内,识别出真实的上游流量来源和流量大小,辅助业务同学进行止损。

 

 

四、在持续Profiling的应用

 

对于 C++服务的 Profiling 和性能退化检测来说,我们之前碰到了一些阻碍,其中主要的困难在于基于 linux perf 实现的常态化 Profiling 性能开销比较大、耗时很长。基于 linux perf 来 Profiling 的两个主要步骤:

 

perf record 按固定频率采集进程内各个线程栈信息, 生成性能事件

 

perf script 解析性能事件,转换为可读数据,将栈帧地址转换为对应的函数名称与所属文件和行号

 

在第一步 perf record 采集性能事件中,一般使用 -g 参数来获取完整的调用栈,默认使用 frame pointer。然而公司内 C++ 服务往往会开启编译优化选项,frame pointer 不可用,导致 profiling 的结果很大程度上失真。为了保证覆盖率,一般使用 -g dwarf 参数,指定使用 dwarf 方式来回溯获取调用栈。使用 dwarf 会遇到一个问题就是中间数据量大:为了后续回溯的需要,会将每个 CPU 的完整栈从内核拷贝出来;核数越多、采集时间越长,得到的栈数据就越大,以广告的一个服务为例,采样 10s 会生成将近 175MB 的数据。

 

第二步 perf script 解析性能事件,首先需要将第一步中的所有性能事件的栈进行回溯,拿到完整的调用链栈帧地址;再将地址通过 addr2line 工具转换为函数名称和文件信息。这时遇到了第二个问题,由于数据量大,整个转换过程耗费大量 CPU,耗时也很久。

 

以广告的一个服务为例,在服务的 Node 上部署 perf Agent,在 Agen t的 CPU Limits 为0.5 Core 的情况下,对服务进行 Profiling;采样 10s 的数据并处理,整体耗时将近 1 小时,并且全程 CPU 打满,如下图所示:

 

 

这种资源消耗和耗时情况,对于需要大面积部署、常态化的持续 Profiling并基于 Profiling 数据进行分析性能退化来说,是基本不可行的。

 

针对这个问题,我们基于 eBPF 来重新实现 C++ 的 Profiling,大幅降低 C++ 服务的 Profiling 资源消耗、整体耗时,来实现真正的持续 Profiling。核心的思路是:在 Node 上部署 Profiling Agent,在内核性能事件生成后,直接在内核继续完成栈回溯和聚合,大幅降低拷贝到用户态的栈数据量;通过 Collector 服务来集中式的采集各个 Profiling Agent 产生的栈数据并做处理。这种模式下,Agent 的计算压力很小,可以实现持续 Profiling;Collector 的处理逻辑对各个 Agent 可以复用,整体消耗低。总体架构如下:

 

 

简要的过程如下:

 

eBPF Agent 用户态程序根据下发的采集配置,获取目前服务的 Pid。根据 Pid,获取对应的内存分布信息以及使用的可执行文件内容,预处理后通过 eBPF Map 传递给内核态供栈回溯时查找;

 

eBPF Agent 内核态程序由 CPU Cycles 性能采样事件触发,从当前执行位置回溯得到完整调用栈,聚合并保存在 eBPF Map 中;

 

eBPF Agent 用户态按固定频率从 eBPF Map 获取每条调用栈的命中次数,转化为 pprof 格式数据;

 

eBPF Collector 按固定频率从 eBPF Agent 获取 pprof 格式数据;完成符号解析、生成火焰图,并写入存储;

 

写入的数据支持实时火焰图、性能对比分析、性能退化监测等功能。

 

下面分别从内核态、eBPF agent用户态、eBPF Collector 分别详细的介绍。

 

 

4.1 内核态

 

相比于 linux perf 将栈拷贝到用户态后再做回溯,我们选择借助 eBPF 提供的能力在内核态直接完成回溯。这样带来了很多好处:

 

  • 大幅减少内核态到用户态的数据拷贝。

  • 在内核态完成回溯后,重复命中采样的调用栈可以直接分组累积,数据量不随采集时间线型增长。

 

下面具体介绍我们在内核态做了哪些工作,以及如何在内核态完成基于 dwarf 的栈回溯的。

 

 

eBPF 原生支持 linux perf 的性能事件, 我们只需要编写对应的 eBPF 程序加载到对应的 perf event 就可以按固定的采样频率触发 eBPF 程序回调。

 

eBPF 程序会读取当前的三个寄存器(ip: 指向下一条指令, sp: 栈顶地址, bp: 栈帧基址),这三个寄存器的值是栈回溯过程的起点。回溯的过程就是将这三个寄存器的值反复地恢复到当前函数被调用前的值,直到没有函数调用为止。

 

回溯完成后更新获得的调用栈的命中次数到 eBPF Map 中,待用户侧采集使用。

 

eBPF 的verfier机制会限制程序复杂度和指令条数,但调用栈深度可能会很长,无法一次完成回溯。我们限制了 eBPF 程序中的循环次数,当循环完成仍未完成回溯时,我们用尾调用的方式重新调用自身继续回溯直到完成。

 

一般来说,内核函数与 jit 生成的函数会使用 framepointer 方式调用压栈,回溯也使用 framepointer。而大部分用户态的函数经过编译优化后 framepointer 不可用,需要使用 eh_frame 段中的 cfi 指令信息来辅助回溯。

 

下面具体解析函数的两种回溯方式。

 

4.1.1. framepointer 方式回溯

 

framepointer 的意思就是使用一个独立的寄存器保存栈基址,一般用来访问函数参数,也用来回溯函数调用, framepointer 一般就是指代 bp 寄存器。下图是包含了 framepointer 的函数调用压栈方式。

 

首先压栈返回地址,也就是调用函数返回后继续执行的下一条指令

然后压栈 bp 寄存器内容,把 bp 寄存器更新为新的栈帧基址

 

 

回溯其实就是调用函数的反向过程,由于 bp 寄存器的内容是栈基址,而栈基址所指的地方保存了 caller 函数的 bp。只要反复将 bp 寄存器的值作为指针读取值更新到 bp,就可以完成回溯。

 

我们关心的函数的 ip 指令地址,可以基于 bp 偏移得到。

 

如果只是使用 framepointer 方式回溯,我们只需要 bp 和 ip 的内容。但是实际程序运行场景中往往会出现带 framepointer 和不带 framepointer 函数互相调用的情况,而不带 framepointer 的函数需要使用 eh_frame 信息回溯,依赖 sp 寄存器。

 

所以我们也保留 sp 寄存器的值,也基于 bp 偏移得到。

 

4.1.2. eh_frame方式回溯

 

framepointer 方式占用了一个专用的寄存器,函数执行过程中很少使用,而且每次需要压栈。整体来说带来了额外的内存开销。现代编译器在开启编译优化的情况下不再使用 framepointer,这个时候我们的 bp 寄存器不再保存栈帧基址,而是作为通用寄存器使用,提高了内存效率。

 

不使用 framepointer 的函数在调用时,不再压栈 bp 寄存器了

 

 

但是函数调试/异常处理都需要用到回溯信息,没有 framepointer 的函数,它的回溯信息会在编译期间通过插入 cfi 指令的方式记录,cfi 指令最终会生成可执行 elf 文件中的 .eh_frame 段。

 

cfi 指令示例

每当发生栈变量分配和回收时,编译器生成一条 cfi 指令更新如何从栈顶找到栈基址的信息

每当寄存器压栈时,编译器生成一条 cfi 指令更新如何从栈基址恢复寄存器内容的信息

 

图片

 

回溯的思路类似 framepointer 方式,先拿到栈基址,通过栈基址偏移获取其他关心的寄存器内容。

 

cfi 指令一般记录栈基址到栈顶的距离,每次回溯时,我们读取 sp 寄存器的内容与对应的 cfi 指令信息找到栈基址。有了栈基址再通过偏移找到 下一轮回溯使用的 bp ip sp 寄存器。

 

4.1.2.1. 使用回溯表简化 cfi 指令使用

 

由于需要在内核侧 eBPF 程序中完成回溯,直接解析 cfi 指令过于复杂,我们将 cfi 指令生成 key 为指令地址的一张表,告诉回溯程序当执行到任意指令时如何找到栈基址,如何恢复寄存器内容,表内容如下图:

 

图片

 

4.1.2.2. 回溯表结构设计

 

可执行文件大小不一,指令数差异大,生成的回溯表大小不一,但 eBPF Map 的Key、Value 都是固定大小,为了高效存储回溯表,我们使用两个 Map 分别作为数据表、索引表,如下图所示:

 

 

数据表:用来保存具体回溯信息,由若干个shard组成,每个shard有数据量上限。对某个Pid开启Profiling时,对Pid的所有可执行文件进行遍历和解析,生成回溯表后,将回溯表数据append 写入数据表。写入数据表过程是按 shard 依次写满。

 

索引表:提供可执行文件到数据表之间的索引,定位可执行文件关联了数据表中分段。每次数据表写满一个shard 或当前可执行文件的回溯表写完,在索引表中记录一条数据表分段信息。

 

回溯表查找时,首先根据 pc (指令地址),在索引表找到可执行文件对应的所有分段,根据包含关系确定具体分段;最后根据分段对应的数据表信息,在数据表中二分查找。

 

 

4.2 用户态

 

 

4.2.1. 生成回溯表

 

读取 Profiling 进程的 Mapping (内存地址分布,/proc/$pid/maps 文件),将用到的可执行文件生成回溯表,写入 eBPF Map 传递到内核态。

 

进程的 Mapping 不是固定的,部分情况下会发生改变,比如动态链接库加载,jit 代码生成,这些都会在运行时改变进程 Mapping。

 

为了保证采样数据的完整性和正确性,每次采集 Profiling 数据时我们先检查 Mapping 可执行的部分有没有发生变化,如果变化就废弃这一次采集,更新 eBPF Map 的内容到 Mapping 的最新状态。

 

4.2.2. 获取性能采样数据

 

定时采集内核态暴露出来的调用栈和采样次数,按 Pid 聚合,为每个 Pid 生成 pprof 格式的性能采样数据, 通过 http 接口暴露给采集侧。

 

4.2.2.1. 内核函数符号解析

 

我们在 Agent 侧完成内核函数的符号解析(/proc/kallsyms 文件),因为不同节点的内核符号不同,内核函数必须在本地解析。另外内核函数的查找较为简单轻量。

 

用户态函数我们不在 Agent 侧解析,因为内联函数的符号解析依赖 dwarf, 是个比较重的查找过程,要解析 debug_info 段,占用大量内存和CPU, 对于 Agent 来说负载过重。而且对不同节点部署的相同服务来说,符号解析是个重复动作,放在采集侧完成能更有效利用缓存,避免重复计算。

 

4.2.2.2. 关联元数据标签

 

在发现 Profiling 进程的过程中,我们已经保留了进程的元数据,包括 Pod 名称、镜像版本、可用区等等,这些信息作为标签附加在性能采样数据中,方便后续实现过滤下钻查询。

 

为何使用 pprof 格式:有丰富工具类库可使用;序列化压缩效率高,所有字符串通过 id 引用;opentelemetry 规范中 profiling 数据模型是基于 pprof 格式设计的, 使用 pprof 方便后续对接业界规范。

 

4.2.3 精简可执行文件

 

精简可执行文件,抽取包含符号信息的分段,传递给采集侧供符号解析时使用。

 

符号信息有2种来源,一种是可执行文件的 .symtab 段,另一种是 dwarf。我们抽取这些分段合成一个精简过的 debuginfo 文件,通过 http 接口暴露给采集侧。debuginfo 文件可执行文件 buildid 缓存,避免相同的可执行文件被重复抽取。

 

 

4.3 采集侧

 

采集侧主要对各个Agent的性能采样数据进行集中采集和处理:

 

 

基本流程:

 

  • 服务发现并定时抓取所有eBPF Agent 的性能采样数据

 

  • 抓取性能采样数据后,对缺失名称的函数地址进行查找补足函数名

 

如果是第一次遇到的可执行文件,异步下载与构造符号索引,在索引 ready 之前始终忽略当前采样,直接返回;

 

如果索引可用,先在 dwarf 中查找当前地址关联的函数和内联函数,以及所属文件和行号;

 

如果当前地址不在 dwarf 的范围里,回退到 symtab 中查找函数名称

 

  • 符号关联完成后我们就拿到了完整的生成火焰图所需的所有数据,这份数据我们生成并上传火焰图供性能平台访问,并且写入 ck 存储支持性能对比与性能退化监测能力。

 

符号信息有2种来源,一种是可执行文件的 .symtab 段, 提供了函数地址到函数名的简单映射,但是不包含内联函数信息。symtab 形式的的符号查找非常简单,地址和函数名一一对应。

 

另一种是查找 dwarf 信息, .debug_xxx 段包含了每个函数覆盖的指令范围,函数名称,调用了哪些内联函数,属于哪个文件,行号等丰富信息。

 

下面重点介绍dwarf的结构和符号查找过程。

 

4.3.1. dwarf 结构&符号查找

 

如下图所示,结合一个例子,我们具体介绍下dwarf的结构和符合查找过程:

 

 

结构:

 

dwarf 是ELF文件的debug_info section,用来表示源码结构信息,整体是树状结构,由DIE(debug info entry) 构成,每个 DIE 有Tag 字段来区分类型,且各自带有不同属性信息。

 

最外层的 DIE 表示代码文件(DW_TAG_compile_unit,cu),如上图中的server.c。文件下层的DIE是各种数据结构、函数的声明,其中我们主要关心两种类型:函数(DW_TAG_subprogram) 与内联函数(DW_TAG_inlined_subroutine),内联函数处于调用它的函数下层,在上图中都有所展示。

 

此外,文件、函数、内联函数,都有属性来代表指令范围(如上图中的[0x40000,0x500000])。指令范围指的是,源代码编译生成的机器指令,在 .text 代码段中的偏移范围。函数编译生成的机器指令分布不一定是连续的一段,可能由多段范围构成,查找时每段都参与匹配。内联函数的生成的指令是调用它的函数的一部分,它的指令范围被它的 caller 函数覆盖。

 

我们会使用这个属性与待查找的指令地址做匹配。

 

查找过程:

 

查找函数的逻辑类似addr2line:

 

  • 定位文件DIE:对给定的 pc (指令地址),定位文件DIE (指令范围包含此地址的);

 

  • 文件DIE的子节点遍历:指令范围包含地址的原则,对给定的pc,在各个子节点中进行遍历和查找;得到所有匹配的结点,包括内联函数的结点;

 

  • 内联函数:函数 DIE 提供了属性可获取函数名称、所属文件与行号,内联函数不包含这些信息,需要通过 DW_AT_abstrct_origin 属性 (如上图中绿色序号)找到原始函数声明。

 

将所有函数信息按调用层级返回即完成查找,输出函数调用链信息。整个过程中,文件DIE的定位和子结点遍历如上图中pc和蓝色结点所示;内联函数的定位如图中绿色地址的对应关系所示。

 

4.3.2. 查找优化

 

4.3.2.1. dwarf 索引

 

dwarf 符号查找的整个查找过程需要加载整个 dwarf 结构,对于复杂项目来说,文件数量多且空间大。支持查找需要耗费很多内存,跳转过程也较为复杂。对于执行一次性命令的工具适合这种方法,对于持续常态化运行的服务来说就比较浪费。

 

为了保证查找的性能、节省内存,我们将 dwarf 的内容先做一次读取,取出我们关心的信息来构造成索引,后续的查找就可以基于索引来,这样 dwarf 结构就可以从内存中释放。索引构建的过程分两步:

 

  • 构建全量的函数信息集合

 

对 dwarf 进行深度优先遍历,取出每个遇到的函数和内联函数的信息,包括函数名、文件名、指令范围等属性,如下图蓝色部分所示;

 

遍历完成后,对内联函数查找函数定义,补足信息

 

  • 构建地址范围索引

 

获取所有函数的指令范围属性,每段指令范围关联到函数数组的下标;

 

对范围进行排序:首先比较每个范围的开始地址,这一步是为了支持函数地址的二分查找;如果开始地址相同(如一个函数的第一行就是执行一个内联函数),比较它们的树层级(子函数的层级更深),这一步是为了让函数和内联函数可以直接按调用顺序返回,得到正确的调用关系。如图中黄色部分(ranges)所示

 

排序后的范围数组和函数数组即可作为索引查找。

 

 

对于一个指令(上图中pc)来说,在上文4.3.1中常规的查找过程,需要遍历整个树,同时全量的dwarf都被加载到内存中并一直持有。我们优化后的过程,对于一个指令,首先在ranges中,使用二分查找找到匹配的指令范围;再根据关联关系,得到对应的函数信息;最后根据排序先后,得到函数调用链。优化之后的过程中,仅依赖少量的属性,大幅降低内存使用量;并且基于索引信息,加快查找速度。

 

由于有些函数会在多个文件声明,这一步完成后可能会匹配到多个名称一样的函数。我们将找到的函数按所属的 cu 分组,选择 cu offset最小的那组函数,这个行为对齐了 llvm-addr2line 中的选择入口 cu 的逻辑。

 

4.3.2.2. 符号缓存

 

长时间运行的服务采样得到的函数地址有固定范围,适合缓存,我们在符号索引前加了一道缓存后,稳定运行情况下缓存命中率达到了 99.9%,缓存后的索引变为按需查找,大幅降低了采集服务的 CPU 开销。我们使用了可持久化的缓存保证服务重启升级时的缓存命中率。

 

4.3.3. 存储

 

在地址关联后,我们得到了完整的Profiling Sample数据。我们对Profiling数据,以Pod粒度进行处理:如根据函数调用链统计 Sample 数、过滤占比过低的函数调用链等。处理后,我们将数据进行存储,用于后续的分析。我们的存储方案选择的是 Clickhouse,在存储 Profiling 的数据之外,同时会把相关的环境变量信息一起存储,如应用名、应用版本、机房等。

 

此外,根据Profiling Sample,当前会一起生成单 Pod 的火焰图,将火焰图压缩并保存在对象存储中。

 

 

4.4 产品化 & 落地场景

 

4.4.1 实时火焰图

 

提供 C++ 服务各个 Pod 、各历史版本的近实时火焰图,例子如下:

 

 

 

后续基于 Clickhouse 存储可实现实时的火焰图查询,实现任意范围的火焰图生成和展示。

 

4.4.2 性能对比分析

 

对于接入的服务,提供当前、历史版本之间的性能diff分析,无须人工对比火焰图,例子如下:

 

 

在性能diff火焰图中,展示潜在的性能退化点的调用链、对应的资源涨幅情况:

 

 

4.4.3.性能退化监测

 

当前支持对接入的服务,进行天级别的自动化性能退化巡检并推送。

 

 

当前已经接入推荐排序服务、以及广告业务线的C++服务。上线近一个月以来,发现多起疑似性能退化的Case,已经反馈给业务方并跟进排查中。

 

五、总结与展望

 

在过去的半年时间内,我们从零开始,尝试将eBPF技术与可观测的实际需求结合,来解决之前的一些疑难问题,比如流量来源分析、C++服务的Profiling等。这些能力在推荐、广告、Redis、Redkv等业务线的核心服务中得到了应用,接入服务过千,覆盖近五万个Node,实现日常常态化的运行。

 

在当前基础上,未来我们计划在以下方面继续演化:

 

  • 流量分析:支持绘制服务拓扑,补充 C++ 等多语言拓扑与链路数据;

 

  • Profiling的应用上:支持Off-CPU、内存泄露排查等更多的事件类型;支持实时的火焰图查询,实现任意范围的火焰图生成和展示。

 

 

作者介绍

韩柏,小红书可观测技术工程师,毕业于上海交通大学,从事推荐架构、基础架构工作,在可观测、云原生、推荐工程、中间件、性能优化等方面有较为丰富的经验。

 

布克,小红书可观测技术工程师,毕业于南京大学,从事基础架构可观测相关工作,熟悉监控基础组件、时序数据库相关的研发。

 

科米,小红书可观测技术工程师,毕业于浙江大学,从事基础架构可观测相关工作,熟悉可观测日志、指标相关工作。

 

来源丨公众号: 小红书技术REDtech(ID:gh_f510929429e3
dbaplus社群欢迎广大技术人员投稿,投稿邮箱:editor@dbaplus.cn
最新评论
访客 2024年04月08日

如果字段的最大可能长度超过255字节,那么长度值可能…

访客 2024年03月04日

只能说作者太用心了,优秀

访客 2024年02月23日

感谢详解

访客 2024年02月20日

一般干个7-8年(即30岁左右),能做到年入40w-50w;有…

访客 2023年08月20日

230721

活动预告