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

上篇文章我们讲到可观测性在滴滴的实践与落地,更多关注的是不同观测信号之间的关联关系。那服务与服务之间的关系又如何串联,业界当前爆火的 ebpf 又在滴滴有着怎样的应用,本文为你揭晓。

背景

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

滴滴可观测平台除了负责滴滴 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 命令的示例。

<span><section class="code-snippet__fix code-snippet__js"><ul class="code-snippet__line-index code-snippet__js"></ul><code class="language-perl">
#!/usr/bin/bpftrace 

BEGIN 
{ 
  printf("开始观测bash...n使用Ctrl-C停止n"); 
} 

uretprobe:/bin/bash:readline 
{ 
  printf("cmd: %sn", str(retval)); 
} 

</code><span></span></section></span><p><br></br></p>

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


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

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

```properties

$ sudo bpftrace ./bashreadline.bt 
Attaching 2 probes... 
开始观测bash... 
使用Ctrl-C停止 
cmd: ls -l 
cmd: pwd 
cmd: crontab -e 
cmd: 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, 
    "http://0.0.0.0:9932/echo", 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: %sn", 
  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: %sn", str($addr, $len)); 

  $url_addr = *(uint64*)($req_addr + 16); 
  $addr = *(uint64*)($url_addr + 40); 
  $len = *(uint64*)($url_addr + 48); 
  printf(" host: %sn", str($addr, $len)); 

  $addr = *(uint64*)($url_addr + 56); 
  $len = *(uint64*)($url_addr + 64); 
  printf(" url: %snn", str($addr, $len)); 

  @callee_set[pid] = 1 
} 

uprobe:./http_demo:"net/http.(*response).finishRequest" 
{ 
  // 如果没有下游请求,单独输出 
  if (@callee_set[pid] == 0){ 
    printf("caller: n caller_path: %sn", 
    str(@caller_path_addr[pid], @caller_path_len[pid])); 
    printf("callee: nonenn"); 
    @callee_set[pid] = 1; 
  } 
} 

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


# 启动采集 
$ bpftrace ./http.bt 
Attaching 2 probes... # 未触发请求前,停止在这里 
caller: # 触发请求后,输出 
caller_path: /handle 
callee: 
  method: GET 
  host: 0.0.0.0:9932 
  url: /echo 
caller: 
  caller_path: /echo 
  callee: none 

# 开始服务 
$ ./http_demo & 
# 触发请求 
$ curl http://0.0.0.0:9932/handle 

可以看到,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, Worldn") 
  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 GoroutinePool 

func handle(w http.ResponseWriter, req *http.Request) { 
  io.WriteString(w, "Hello, Worldn") 

  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) 

Description 
Attempt 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?

下篇文章将为您呈现滴滴的可观测团队是如何实现数据计算的,敬请期待。


云原生夜话

你期待eBPF技术能够解决可观测的哪些问题?欢迎在评论区留言,如需与我们进一步交流探讨,也可直接私信后台。

作者将选取1则最有意义的留言,送出滴滴元气牛仔托特包,9月21日晚9点开奖。