jaeger调用链追踪实践
Contents
解决的问题
在 piggygo 后台项目中处理一个请求可能会进行多次 pika 拉取数据或保存数据,同时可能存在同步或异步调用其他节点的情况。目前我们发现接口延时的主要依据是 prometheus 中的接口延时统计,存在的问题是 prometheus 统计的 30s 内各个请求的均值,并且根本无法知道高延时的请求时间花费在哪里。去年我尝试过给 piggygo 后台项目优化过几个高延时的接口,当时的依据是在开发测试环境针对 pika 和远程调用打印耗时,并收集和识别出问题,从而解决了排行榜无缓存从而拉取 pika 多次,以及没有使用 goroutine 进行异步发送远程调用的问题。
以下是当时排查输出的日志:
|
|
但这种方式使用起来不方便,最大的问题是环境是收集和分析不容易,日志分布在各个进程自己的目录下,日志跟其他业务日志混合在一起,耗时日志无法反映各函数之间的调用关系,需要开发人员对代码足够熟悉才能把日志串起来进行分析。
为了更加方便的对线上的性能问题进行定位,对分布式链路追踪进行了调研、运用到 piggygo 后台项目。我们选择了 JAEGER 开源解决方案,这个项目目前已经是 CNCF 的 graduated 项目,被广泛运用于微服务领域。
Jaeger 是由 Uber Technologies 作为开源发布的分布式跟踪系统。 它用于监视和诊断基于微服务的分布式系统,包括:
- 分布式上下文传播
- 服务依赖性分析
- 性能/延迟优化
Jaeger 介绍
Open Trace 数据模型
分布式链路追踪依赖 OpenTracing 数据模型,其起源是 Google Dapper 的论文。这些概念也是在接入 Jaeger 分布式链路追踪所必须了解的。下图包含了需要用到的所有的概念名词:
Trace
:表示在分布式系统中传播的事务,这是一个隐式的概念,实际上在代码中不会出现此对象;Span
:表示分布式事务的一环,具有自己名字,并且可以通过调用Finish
进行从生成到调用点的耗时统计,以及利用 Tag 装载其它有用信息。Span 之间是存在父子关系的,Root Span于 Trace 的概念一致,表示一次事务,Child Span 则表示为事务中的一环,一个事务内的 Span 构成一个有向无环图,从而知道整个事务的完整调用关系和耗时;
Span contxt
:用于在分布式系统中传播的载体,其中包含 TraceId, SpanId 等信息,通过Inject
函数注入到一个载体中,比如:bp 对象,以及使用Extract
函数从载体中提取出来用于构建新的 Span 对象;
- 信息携带:
Tag
描述当前 span 的属性数据;Log
结构化日志数据,是一种键值对,用于描述 span 中发生的特定事件,包含时间戳,比如:出错信息;baggage
也是键值对,用于在整个事务中进行传播,会被 Child Span 继承,可以传播至远程节点;
完整的规格文档参考:https://github.com/opentracing/specification/blob/master/specification.md
Jaeger 架构
- jaeger-client:为不同语言实现了符合 OpenTracing 标准的SDK,项目代码需要导入这个包来进行 span 数据上报;
- jaeger-agent:它是一个监听在UDP端口上接收 span 数据的网络守护进程,它会将数据批量发送给 jaeger-collector。它被设计成一个基础组件,部署到业务进程所在宿主机上;
- jaeger-collector:接收 jaeger-agent 发送来的数据,然后将数据写入后端存储。jaeger-collector 被设计成无状态的组件,因此您可以同时运行任意数量的 jaeger-collector;
- DB:后端存储被设计成一个可插拔的组件,支持将数据写入cassandra、elastic search。
- jaeger-query:接收查询请求,然后从后端存储系统中检索 Trace 并通过 UI 进行展示。jaeger-query是无状态的,您可以启动多个实例,把它们部署在Nginx负载均衡器后面;
在开发测试阶段,可通过 docker 快速部署一个 all-in-one 可执行文件:
|
|
在 Jaeger中文文档 有一个比较好的入门教程来了解 jaeger 相关的知识。
实现过程
实现过程包含如下几个过程:
- 前期调研快速进行 demo 实验,当时想使用 opentelemetry 这个官方推荐库作为依赖库,在尝试引入时发现 opentelmetry 依赖更高版本的 go 以及引入过多包,不便于放到 vendor 库中,遂改为使用 jaeger-client-go 作为依赖库,降低对外部的依赖;
- demo 实现只能对最外层进行耗时统计,想要获得完整的调用链必须利用 go 语言中的 context 将 span 传递到任何想要生成 child span 的地方,以及跨节点调用的地方。这就必须对项目进行改进从而将 ctx 传递到需要的地方去,目前的做法是在
handleRequest
中生成一个 ctx 并作为函数的第一个参数传递到 pika 取数据,远程调用的地方去; - 在进入
handleRequest
,pika 获取、保存数据以及远程调用时进行埋点,在跨节点的地方将SpanContext
数据注入到ppb.PktOpt
中去,并在handleRequst
函数解包后立即用于生成 child span; - 在任何需要的业务点调用
NewSpanChildOf
并在结束时调用Finish
函数即可;
以下是实现时具体遇到的问题
ctx 问题
pgcontext 的问题
piggygo 中的 ctx 是自己实现的,其底层是一个 map,这样做的好处是 WithValue
是更轻量,且可以将数据从被调用者传递到调用者。但问题是 Open Tracing 利用了标准库中的 ctx 特性,这样被调用的函数不会影响到调用者。我们需要在 pgcontext 中模拟这个特性,所以提供了两个函数:
SetGoCtx
设置一个标准库中的 ctx 进去并生成一个新的 pgcontext 对象;GetGoCtx
从 pgcontext 中取出一个标准库的 ctx 对象,如果没有的话则返回 nil ;
这样要求我们每次调用 SetGoCtx
后需要将新的 ctx 替换掉作为参数传递的 ctx:
|
|
ctx 传递的问题
piggygo 后端项目中原本仅仅将 context 用于给发奖函数等单次请求玩家相关的数据传递的,相对用的地方是比较少的。而分布式调用链追踪却要求加 context 传递到几乎整个项目所有的地方。改动起来倒也不难就是给每个调用 SetSliceData
和 GetSliceData
的地方都在最开始加一个 ctx pgcontext.Context
参数即可,难在于对整个项目进行全部修改。目前统计总共修改了 ~5202 处调用点和函数签名,整个过程 90% 以上是自动化修改,且可用于调用链增加其他参数。
为了做到自动化修改,利用了Go Tools 提供的静态程序分析工具,其中用到的核心包是 cmd/callgraph
对 go 程序中的调用图进行分析。
以下是程序分析的代码:
|
|
核心算法是利用调用图生成调用点 CallLocation
、调用者 Caller
、被调用者 Callee
对象,并生成一个有向无环图。
|
|
随后从被调用的最底层往上回溯用 appendCallCtxParam
对没有 ctx 的调用点加上 ctx 参数,用 appendFuncDefCtxParam
对调用者函数签名没有 ctx 的加上 pgcontext.Context
参数声明。
利用 Go Tools 进行程序静态分析存在无法识别 interface 中参数的问题,所有 interface 中的参数都是手动加上的。另外存在对函数内部定义闭包无法利用自由变量,这一块也是手动改的。
另外一个我觉得比较好的地方是 Go Tools 静态分析不仅可用于正常程序还可用于单元测试,从而节省了不少修改单测的时间。
跨节点
通过给所有调用 GetSliceData
和 SetSliceData
加上 ctx 参数,且在处理客户端消息的入口函数 handleRequest
处生成一个 trace 即可形成单进程内调用图的有向无环图。对于一条信息可能会执行 SendTo
和 SendToAsync
需要一种方式串联起来。 Open Traceing 提供了 Inject
接口将 span 父子关系数据 baggage 序列化,并提供 Extract
反序列化出来,并且提供了 carrier
抽象,我实现了利用 Opt 作为载体的 carrier:
|
|
在消息处理入口如果能够从 Opt 中继承父 span 则进行继承,如果不能的话则启动一个 root span:
|
|
进行内部调用时将 span 关系数据注入到 Opt 中,这里区分了异步和同步调用:
|
|
至此项目中的所有进程就都串联起来了。
如何埋点
上面实现的是一个基本框架,可以对单次请求中的 pika 访问和节点之间的调用进行分析。使用时也可以在任何想要进行分析的函数中加上 NewSpanChildOf
调用:
|
|
如果这是一条进行了分析的请求,sp
必不为 nil,调用 ps.Finish()
进行耗时上报。注意这里返回的 ctx 需要替换掉作为参数传递进来的 ctx,这样才能形成有效的父子关系。
当需要加入其他信息到分析中去的时候,调用 SetTag
LogFields
LogError
:
|
|
效果
登录接口
以下是一个数据库为 redis 的开发机上游客登录的调用链图形,可以看出登录过程中分别进入了 loginsrv, statsrv, game, boardsrv 节点,并进行了多次pika 拉取和保存数据。
从这里可以看出登录接口本身消耗了 3.89 ms 进行登录处理,并对 game 节点进行了一次异步调用 ASYNC_EVENT_TYPE_LOGIN_SERVER
这个异步事件中 9 次 pika get 操作,还执行了一次异步添加 mail 的操作以及 statsrv 的在线上报。如果是 fb 登录或者数据库为 pika 的话数据耗时上还会有所不一样。
即便是从这个图形中依然可以看出 ASYNC_EVENT_TYPE_LOGIN_SERVER
执行了过多的 pika get 操作,是否可以合并成一个,或者其中一些消除掉。
投骰子接口
以下图形为一次没有阶梯的投骰子过程,耗时4.63 ms,其中1.06 ms 消耗在 boardsrv 实际处理投骰子算法过程中,后面还有多次异步调用分别是修改 CK, 修改数据, 投骰子低速率异步触发事件。
从上图可以看出在远程调用过程中不是没有网络时间损耗的,实际boardsrv处理消耗1.06ms,调用花费了 1.82ms, 其中 0.76ms 划分在网络中。而且这里 3.0ms后到4.63ms的时间去缺失的,这里具体时间消耗在做什么需要进一步埋点去看问题。
后续
以上的实践基本搭建好了分布式链路追踪的架子,但依然有不完善的地方,比如:目前上报的信息只上报了 UID、命令字、数据key等少量信息,没有对请求中的 error 进行区分上报,对于 HTTP 请求没有进行上报。后面都需要在使用过程中继续完善以发挥作用。
Author zoro.wang
LastMod 2022-09-25