龙空技术网

服务拓扑串联难?eBPF为滴滴可观测带来解题新思路

闪念基因 159

前言:

此刻姐妹们对“nginx排查接口量级”大约比较关怀,大家都想要了解一些“nginx排查接口量级”的相关知识。那么小编也在网上收集了一些对于“nginx排查接口量级””的相关文章,希望各位老铁们能喜欢,你们一起来了解一下吧!

背景

业务介绍:业务接口调用观测

滴滴可观测平台除了负责滴滴 MTL 能力的建设,还涉及更偏向业务侧的数据及服务接口调用观测。

关于接口调用拓扑观测,这里先解释下以免引起歧义。如下图描述了一个调用关系:

一次请求、响应过程

这里用[caller=A, caller-func=/a, callee=B, callee-func=/b],简写成[A, /a, B, /b],以及 [A, /a, C, /c]来描述A服务的/a触发后调用B:/b以及C:/c的动作。在获取到足够多的接口调用数据时,通过给定某个业务的若干个调用入口(如上述示例中的[A, /a]),通过对接口调用链路的不断串联,可以梳理出该业务若干个重要的调用链路。

调用链路的构建对于服务稳定性保障有重要意义,无论是容灾放火、业务按需扩容、高峰期业务状态巡检护堤等均依赖于核心调用链路的构建。从经验上来看,在实际故障处理以及容量评估时,接口级的调用拓扑比服务级或者容器/物理机级的调用拓扑要有效很多。

一般来说,接口粒度的服务拓扑可以通过调用日志或者调用 metric 来进行串联。滴滴可观测早些时候采用调用日志+调用 metric 相结合的方式生成服务接口调用拓扑。后来随着统一服务治理的推进,业务上报 metric 完全可以覆盖调用日志里的调用关系,且生成接口拓扑的成本大幅降低,因此就接口拓扑生成这一场景而言,已经调整为基于服务调用的 metric 数据来生成。

通过metric串联接口拓扑的示意图

业务问题:服务接口拓扑的校验

看起来,通过接口调用 metric 来串联调用链路是一种通用的方式,但是其生成结果显然存在如下的问题:

已生成的数据缺少校验方式。由于数据是业务方代码上报的,即使引入了通用的SDK,caller-func 信息也只能依赖于代码调用时主动传入。从实践经验来看,caller-func 的漏传错传问题比较明显。调用关系校验、生成成本高昂。依赖业务代码上报,意味着代码需要遵循相当的规范。较为核心的调用链路,推动代码的变更相对容易,业务配合度较高。但非核心的调用链路或已经稳定运行许久的遗留项目,代码的规范化变更是较难推动的。而手动添加则需要对项目进行人工梳理,对于存在近千个调用的链路而言,没有实际操作空间。

上述两个问题是使用 metric 串联业务接口拓扑时常见的问题。

以滴滴可观测的实践来看,当核心链路的复杂度达到以千计的量级,即使有专门的团队推动业务调用链路的 metric 接入治理,也会有相当比例的调用关系缺失或者错误。

理想情况下的正常结果

metric 信息错误时可能的结果

针对服务接口拓扑校验的问题,滴滴可观测通过探索,形成了基于eBPF(后文如无其他说明,简称BPF)技术进行服务接口拓扑无侵入采集的方案。通过 metric+BPF 采集相结合的方式,实现了接口拓扑数据的准确性验证、缺失数据补充。同时,进一步探索了可观测更深层次使用 BPF,如 MTL 的融合。

方案

BPF介绍BPF 最早是伯克利包过滤器(Berkely Packet Filter)的简称,内核自3.15开始对 BPF 进行扩展,通过增加 BPF 程序寄存器个数、扩充 BPF 程序可使用内存以及增加多个BPF事件使得 BPF 具备高可定制性。为了和扩展前的 BPF进行区分,将3.15之前的BPF称为 cBPF(classic BPF),扩展后的 BPF 称为 eBPF (extended BPF),而 BPF 也从一种缩写更多的成为了一种技术的代称。

截至4.18版本的内核,BPF支持的部分事件类型及其简要介绍如下:

本文涉及的内容有 uprobe 以及 kprobe,大多数的内核函数都可以通过 kprobe 来进行 hook。而在用户自定义程序中,符号表中存在的函数也均可通过 uprobe 进行 hook。

kprobe 和 uprobe 触发时,只能获取目标函数的参数或者堆栈信息。如下面一段代码是通过 bpftrace 来观测 /bin/bash 并通过获取 readline 返回值来观测用户 bash 命令的示例。

#!/usr/bin/bpftraceBEGIN{  printf("开始观测bash...\n使用Ctrl-C停止\n");}uretprobe:/bin/bash:readline{  printf("cmd: %s\n", str(retval));}

其中,bash 源码对 readline 的定义如下,参照目标函数的源码可以更好理解BPF 的逻辑。

/* Read a line of input. Prompt with PROMPT. A NULL PROMPT means none. */extern char *readline (const char *);

执行后,当出现目标内核函数执行时,触发如下:

$ sudo bpftrace ./bashreadline.btAttaching 2 probes...开始观测bash...使用Ctrl-C停止cmd: ls -lcmd: pwdcmd: crontab -ecmd: clear

eBPF 在3.15内核引入后,其功能不断扩展。比较重大的一个扩展是在4.18内核中引入了BTF(BPF Type Format),BTF 技术使得 BPF 字节码的加载、使用变得更加简单。

BPF的开发

原生的 BPF 实现各种功能一般是使用受限的C语言调用 bpf-helpers 函数,而后使用 LLVM 将其编译成 BPF-code 字节码,通过系统调用进行加载。原生的C语言编写方式较为繁琐,iovisor 项目推出了 bcc 库来增强 BPF 的开发便捷度,同时维护了支持 one-liner风格、极具易用性的 bpftrace 工具。业内知名的 cilium 也维护了一个 cilium-ebpf。除了bcc、bpftrace、cilium-ebpf,亦有 长于全生产周期支持的 coolbpf、在 libc 基础上使用 rust 提供 BPF 支持的 aya 等工具。

BPF生态,图源自ebpf.io

使用BPF解决服务接口拓扑问题

上一章节提到服务接口拓扑中无法对生成的拓扑数据进行校验,这样的问题目前在滴滴可观测是通过 BPF 来解决。这里通过一个简单的示例以及使用 bpftrace 脚本构建的解决方案来展示下效果。

示例:简单的golang服务

这里给出一个基于go1.16的简单的golang服务。从处理代码中可知,这里的四元组是 [local, /handle, local, /echo]。为了方便示例说明,这里的"handle"的逻辑和请求下游的逻辑是串行的,没有使用"goroutine"。这一点很重要,后面会进行说明。

func echo(c *gin.Context) {  c.JSON(http.StatusOK, &Resp{    Errno: 0,    Errmsg: "ok",  })  return}/* s := http.Server{  Addr: "0.0.0.0:9932",}r := gin.Default()r.GET("/echo", echo)r.GET("/handle", handle)s.Handler = r*/func handle(c *gin.Context) {  client := http.Client{}  req, _ := http.NewRequest(http.MethodGet,    ";, nil)  resp, err := client.Do(req)  if err != nil {    fmt.Println("failed to request", err.Error())    c.JSON(http.StatusOK, &Resp{    Errno: 1,    Errmsg: "failed to request",  })    return  }  respB, err := ioutil.ReadAll(resp.Body)  if err != nil {    fmt.Println("read resp failed")    c.JSON(http.StatusOK, &Resp{      Errno: 2,      Errmsg: "failed to read request",    })    return  }  defer resp.Body.Close()  fmt.Println("resp: ", string(respB))  c.JSON(http.StatusOK, &Resp{    Errno: 0,    Errmsg: "request okay",  })    return}

采集的逻辑及执行效果:

uprobe:./http_demo:net/http.serverHandler.ServeHTTP{  $req_addr = sarg3;  $url_addr = *(uint64*)($req_addr+16);  $path_addr = *(uint64*)($url_addr+56);  $path_len = *(uint64*)($url_addr+64);  // 在http请求触发处,依据pid将caller_func存储起来  @caller_path_addr[pid] = $path_addr;  @caller_path_len[pid] = $path_len;  @callee_set[pid] = 0;}uprobe:./http_demo:"net/http.(*Client).do"{  // 依据 pid 获取 caller 信息  printf("caller: \n caller_path: %s\n",  str(@caller_path_addr[pid], @caller_path_len[pid]));  $req_addr = sarg1;  // 获取 callee 信息  $addr = *(uint64*)($req_addr);  $len = *(uint64*)($req_addr + 8);  printf("callee: \n method: %s\n", str($addr, $len));  $url_addr = *(uint64*)($req_addr + 16);  $addr = *(uint64*)($url_addr + 40);  $len = *(uint64*)($url_addr + 48);  printf(" host: %s\n", str($addr, $len));  $addr = *(uint64*)($url_addr + 56);  $len = *(uint64*)($url_addr + 64);  printf(" url: %s\n\n", str($addr, $len));  @callee_set[pid] = 1}uprobe:./http_demo:"net/http.(*response).finishRequest"{  // 如果没有下游请求,单独输出  if (@callee_set[pid] == 0){    printf("caller: \n caller_path: %s\n",    str(@caller_path_addr[pid], @caller_path_len[pid]));    printf("callee: none\n\n");    @callee_set[pid] = 1;  }}

使用采集脚本进行采集,结果如下:

# 启动采集$ bpftrace ./http.btAttaching 2 probes... # 未触发请求前,停止在这里caller: # 触发请求后,输出caller_path: /handlecallee:  method: GET  host: 0.0.0.0:9932  url: /echocaller:  caller_path: /echo  callee: none# 开始服务$ ./http_demo &# 触发请求$ curl 

可以看到,bpftrace 脚本实现了对目标服务接口调用四元组的采集,而这是在目标服务未进行任何代码变更的情况下进行的,BPF 展示了其在可观测领域的魅力。

实际的方案覆盖及效果

通过上面的示例,展示了使用 BPF 进行接口拓扑观测的主要思路。需要说明的是,示例里使用的是 pid 作为 caller_map 里的 key,但在实际的工程中,由于 golang goroutine 与 pid 并非一一对应的,需要使用 goid 来作为 key。

同时,由于 handleFunc 里会使用新的 goroutine 来发起下游的请求,BPF 也需要对 goid 的派生关系进行维护,以避免某个 goid 关联的 caller 信息丢失。这样一来, 对于 golang 服务而言,实际的处理思路就很明确了。

BPF观测服务拓扑的方案示意

上图是滴滴可观测现行的 golang 接口调用观测 BPF 方案,对方案进行总结,其核心在于:

信息采集。包括 caller-func,callee,callee-func 等信息,均需要通过合适的 hook 点选择来获取。信息关联。基于 golang 服务的特性,使用 goid 进行关联。这就使得 caller 信息能够和 callee 信息相关联,以获取四元组。

目前滴滴可观测基于这样的思路,完成了对 golang 和 PHP 服务的覆盖。从实践结果来看,该方案对目标服务有效覆盖率约 80%。目标监控核心调用链路,经对 BPF 新增四元组的人工确认,无异常四元组。与基于 metric 的数据相对比,在部分核心调用链路,新增四元组调用可达20%。

问题

丢掉的关联性

上述方案确实是目前能够想到较为直观的方案。其中信息采集部分问题不大,虽使用了 uprobe,引入了对目标函数参数的依赖,但是就实际生产环境上使用的go1.10~go1.20而言,除了 go1.17 引入的函数调用规约需要适配外,其他必要的信息基本上没有变化。

信息关联部分比较麻烦,现有方案里是通过维护 goroutine 的派生关系来实现 caller 信息和 callee信息的关联,但现实往往不尽如意。比如,从实际的工程来看,下面的代码是会出现的:

/*用法1:通过channel来传递request。这种场景下,事件间的关联性丢失,无法形成四元组*/var reqChan = make(chan *http.Request, 10)func handle(w http.ResponseWriter, req *http.Request) {  io.WriteString(w, "Hello, World\n")  reqChan <- req // 这里通过channel来传递请求  return}func handleReq() {  for {    select {    case req, ok := <-reqChan:      if !ok {        log.Println("channel closed")        return      }      log.Println("received, ", req.Host, req.Method)      // do some stuff      // 即使这里存在下游请求,也无法和caller关联起来。    }  }}func main() {  go handleReq()  http.HandleFunc("/hello", handle)  http.ListenAndServe("0.0.0.0:9999", nil)  return}type GoroutinePool interface {  Start() (error, bool)  AddTask(func())  Stop() (error, bool)}var pool GoroutinePoolfunc handle(w http.ResponseWriter, req *http.Request) {  io.WriteString(w, "Hello, World\n")  pool.AddTask(func() {    // 这里由于采用了goroutine池,goroutine间的派生关系  会丢失,事件无法有效串联    handleReq(req)  })  return}func handleReq(req *http.Request) {  log.Println("received, ", req.Host, req.Method)  // do some stuff}func main() {  // init pool  // pool = New()  http.HandleFunc("/hello", handle)  http.ListenAndServe("0.0.0.0:9999", nil) return}

上述的两个场景由于无法获取 goroutine 的派生关系,现有的方案将无法获取四元组,类似的问题会影响 BPF 的采集效果。从现有经验来看,golang 工程中受类似代码影响的四元组占比在20%以内。

uprobe:适配的复杂性

经过上节的介绍,可知滴滴可观测是基于 uprobe 构建的服务接口拓扑观测方案。

BPF uprobe 的使用具有处理数据高效、整体方案直观的特点。由于 uprobe 更接近于用户的代码,因此对于用户感知较强的问题更加得心应手,如框架中慢函数调用等。

但大多数的项目使用更多的是 kprobe,比如 bpftrace 中的很多实用工具。deepflow 的观测能力大都是在 kprobe 的基础上构建的,kindling 涉及网络数据处理的内容也是基于 kprobe 进行处理的。

目前在实际使用中,完全按照 uprobe 构建方案的项目仍属少数。究其原因, uprobe 的使用存在如下两个缺点:

通用性较差。通过方案介绍可知,基于 uprobe 的方案和语言(甚至是框架)是强相关的。且在目标程序符号表不存在的情况下,uprobe 无法进行工作。这意味着如果目标使用场景不明确,使用 uprobe 就需要对每个具体的场景进行适配,整体的投入、产出将会很低。性能问题。uprobe 触发时,会涉及到用户态和内核态的两次切换,这意味着单次执行 uprob 时,其性能开销很高(单个 uprobe 的触发耗时在1us左右,而单个 kprobe 的触发耗时则在100ns左右)。当被 hook 的函数频繁触发时,目标进程的性能将会很差。

尽管 uprobe 存在上述所说的缺陷,滴滴可观测仍然选择了基于 uprobe 来构建方案,主要因为 uprobe 的开发效率更快,成本更低。

使用 uprobe 来开发,所见即所得。数据不存在退化,关键信息无须从传输层报文中获取。不仅节省了开发时间,处理的复杂性也大大降低:考虑一个长 http 报文,uprobe 可以直接从目标函数获取需要的数据,比如 URL 信息,而 kprobe 则会触发多次,且需要对报文进行解析以获取所需要的信息。就目前来看,滴滴可观测的 ebpf-agent 线上实际 CPU 开销常态在单核的10%以下(一般的业务进程,含 PHP 进程,路由 nginx 服务CPU会高些),对目标进程的性能影响几乎不会被感知。

展望

用户态VM的需求

滴滴可观测使用了大量的 uprobe ,在离线环境上,单个物理机常态运行1500多个 uprobe 的 hook 点。将来随着 BPF 功能的延伸,uprobe hook 点的数量还会增加。大量的 uprobe 放到内核中,不仅对内核造成稳定性压力,而且由于BPF VM 运行在内核态,使得 uprobe 触发时会导致程序触发内核态和用户态的2次切换,对目标进程的函数执行造成延迟。

这两点都让用户态的 VM 使用无法避免。只有将 uprobe 切换到用户态的 VM 执行,uprobe 的耗时才能降下来,大规模使用 uprobe 才不会对目标服务造成太大的影响。

基于BPF的MTL融合方案

当我们重新审视 bpf-helpers 时可以看到这样一个有意思的函数:

long bpf_probe_write_user(void *dst, const void *src, u32 len) DescriptionAttempt in a safe way to write len bytes from the buffer src to dst in memory. It only works for threads that are in user context, and dst must be a valid user space address. This helper should not be used to implement any kind of security mechanism because of TOC-TOU attacks, but rather to debug, divert, and manipulate execution of semi-cooperative processes. Keep in mind that this feature is meant for experiments, and it has a risk of crashing the system and running programs.  Therefore, when an eBPF program using this helper is attached, a warning including PID and process name is printed to kernel logs. Return 0 on success, or a negative error in case of failure.

这个函数的功能就强大了,意味着 BPF 的数据可以直接写入目标进程的空间,扩充了 BPF 的使用范围。而在 MTL 融合的过程中, 比较棘手的问题是 trace 信息无法有效关联到 metric 以及 log 中。

原始的MTL融合方案

如上图所示,当 metric 或者 log 上报时没有上报正确的 trace 信息,则 metric 及 log 将无法关联到 trace 中。

而如果每个请求的处理链路被 BPF 正常维护,且 BPF 维护了该请求的 trace 信息,metric 和日志在生成时,自然就可以和trace关联起来。下图分别展示了 BPF 增强的三种方案:

BPF增强的MTL融合方案

BPF+SDK的MTL融合方案

BPF为主的MTL融合方案

总结

有了各种观测采集手段,收集了大量的观测数据。这些数据是直接事无巨细地交付给用户,还是按指定维度聚合后展示,聚合使用什么样的计算引擎,spark 还是 flink?

作者:李岩

来源:微信公众号:滴滴技术

出处:

标签: #nginx排查接口量级