服务拓扑串联难?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点开奖。