阅读 425

漫谈分布式链路追踪

链路跟踪

链路跟踪归根到底只是一种理念和策略,简单的说就是在2次关联调用之间传递特定透传信息的能力。从组件设计的角度说其实关心的是是下面的几个特性:

  • 泛用性:在多大范围的作用域上可用,有没有不可用的情况
  • 完备性:数据模型的设计上是否考虑的足够全面,该有的都有,不该有的可以扔
  • 成本:实现的成本和风险、接入的复杂度。 落地到实现方案上还是有很多不同的策略,但总的来说其实有三种策略。

基于特定语言实现的方案

典型的例子就是Java系的方案,总的来说java是一种编译语言,但是得意于虚拟机和字节码的实现方式,Java实际上是具有动态语言的特性的。

这类实现的基本思路就是在利用java-agent拦截具体类加载过程,在特定的类加载过程加入自定义的代码来实现trace的能力。

这类方案的主要缺点是的只能用于java,但是只要是java技术栈的实现就几乎可以无任何限制的接入,对java技术栈的公司来说是非常有效。接入成本也非常低,只要在启动命令中指定参数就可以了,无论是部署脚本还是构建镜像都很方便。剩下另一个一个缺点就是改字节码本身还是有一定风险的。不过总体来说稳定性还是有保障的。 skywalking

基于组织内编码规范的实现

并不是所有公司内部都是java的,其他语言并没有改字节码这种骚操作,或者认为这种方式太过粗暴该怎么办呢?

这种情况下基本的思路就是抽象出协议层面的概念,让各个组件的实现内部支持链路跟踪的实现,trace日志组件的信息汇集也由组件完成。如果有业务方有特殊需要接入链路跟踪系统也需要可以依照相同的约定与trace进行交互。此外还考虑需要和各类开源的组件相适配。

这种情况下,协议层面的设计就显得很重要,是需要各方都认同和理解的方案,协议本身的完备性就是非常重要的。就目前来说最为著名的就是opentracing的规范,基本上可以视为链路跟踪领域的事实上的标准。

从个人来看我这种方式是更优的策略,而且在大公司的内部,推行标准化的编程规范也是必要的,但是这也是双刃剑,trace的实现依赖于标准化的程度,因为链路这种东西只要中间断过一次就无法达到链路跟踪的效果了。

另一个问题是即使标准化也是有限度的,比如跨线程的信息传递绝大多少公司内部的标准化就很难做。这样做出来的功能其实还是不如字节码增强来的简单有效。 jaegerCATSOFATracerzipkindapper

基于mesh的方案

当然随着近几年容器化和service mesh的推进,基于servicemesh的方案也是可以做链路跟踪的。通过sidecare劫持流量,可以构建出不依赖具体语言或者rpc的链路跟踪系统,从模型上看确实是更为理想的模型,不过如何让运行时的程序内部也感知到链路跟踪也是一个问题,同时mesh的各种方案截止现阶段其实还是处在探索和实践阶段并没有完美的解决方案。 jaeger

模型和协议设计

数据模型

现有的链路跟踪的模型大多参考了dapper的实现,opentracing的规范也对模型设计有很大的影响。opentracing的语义。下面大部分内容都摘取自这两部分内容

简单的说一次外部调用可以被多个内部请求组合完成,这一过程可以被描述成一个树的形式,而每次调用被定义为一个span。整个调用可以被称为一个trace,可以用一个唯一id标识。

span是构成调用树的最小单元。通常来说包括下面几个部分,通常也会被一个唯一id标识:

  • 操作名,通常是一个pattern,比如调用方法名,比如URL等等。
  • 起止时间。
  • 0个或多个tags,key为string,value,比如ip,app名,数据名、url之类
  • 0个或多个log,比如错误码,调用栈、时间消息。
  • 0个或多个span的引用,(ChildOf、FollowsFrom)
  • SpanContext,(traceid、spanid、sampleFlag、Baggage)是一种概念或者说接口层面的东西。可以用于序列化和反序列化的对象,或是为中间件和业务程序提供瘦api,本身是不可变的。Baggage可以透传用户的kv。
Causal relationships between Spans in a single Trace

        [Span A]  ←←←(the root span)
            |
     +------+------+
     |             |
 [Span B]      [Span C] ←←←(Span C is a `ChildOf` Span A)
     |             |
 [Span D]      +---+-------+
               |           |
           [Span E]    [Span F] >>> [Span G] >>> [Span H]
                                       ↑
                                       ↑
                         (Span G `FollowsFrom` Span F)
复制代码

在如上的链路跟踪调用树比较直观了解,只有2个地方需要解释下:

span之间的关系

一个是span中的引用,其实我觉得这应用更理解不同,在实现上大部分不太可能找到所有的子span的引用,或者没有必要。大部分情况下其实使用parent-span-id的概念来构造。一个引用的类型,

  • ChildOf主要是在同步的场景下,这种场景下子span一定在父span的结束之前返回。
  • FollowsFrom主要是指一些异步的场景,这种情况下子span和父span只有逻辑上的关联性,但是时间上并没包含关系,在链路的构造上并没有什么问题,但是在某些数据处理的场景下就会比较麻烦。比如无法确定链路何时结束?

其实这里我其实更想讨论的是如何界定span的范围,但以我个人的来看并不太赞成将异步场景都串联起来。主要基于2点考虑

  • 一是trace很多时候用于性能分析或者依赖分析之类的场景,在这2种比较典型的场景下其实将异步场景串联起来并没有太大的意义,反而不利于后续的数据分析工作

  • 另一原因是即使不使用spanid本身的结构串联也并不意味着丢失了关联信息,因为trace本身是有信息透传能力的,我们完全可以构造一个类似logid的概念或者业务上有意义的数据,进行透传即使链路本身不再同一个trace下,但是信息依然是可以透传的。

透传实现原理

透传的实质上就是在两个上下文之间完成spancontext的构造。总的来说需要做2件事情,一件事情用尽可能无侵入的方式传递spanContext用于重建span。另一件就是在当前的context构造的spanwapper中构造一个新的span并推入栈顶,当然也可以根据情况来选择是否构造新的span。下图展示了一个跨线程传递的例子。

在透传的场景下其实参数是可选的,大部分场景下,trace只针对跨runtime的请求处理,内部跨线程不会创建信息的span,这种情况下不会构造新的span,如果是rpc调用则会生成新的span。

span日志数据构成、透传与搜集

另一个需要讨论的是SpanContext:SpanContext其实在不同场景下都有不同的概念。这里更多的指的是用于下游恢复链路的构造部分和需要透传的参数。 根据上述内容,很容易理解一个span其实是需要对端构造的2条日志才能完整的构造。这2条日志会被日志被各自实例收集起来各自上传,仅仅有少量的参数会随rpc之类的介质透传给下游用于恢复链路或者参数透传,总的来说参数透传成3个部分:

  • 需要透传的业务参数:比如说压测标识、logId之类的。这些参数无论何种场景下都是需要传递下去的。
  • 用于构造span的参数:其实只需要三个:traceId、spanId、sample,这些参数根据不同场景会决定传递或者不传递,通常来说是同步场景传递异步场景下不传递。如果不传递,则通常下游会构造新的spanId和traceid,这时可以构造span的日志只有单端的日志。
  • 用于信息分享的参数:除了上述的参数其他参数都可以通过带外的通道进行传递,大部分情况下都是利用类似ELK的技术栈传递。 具体的说其实透传的方式其实也有不同的实现策略,如下图所示

  • 一种方案是将业务信息和span构造信息都request传递,下游返回结果也将同样的信息传递回来。这种模型的优势是概念和模型比较清晰。但是问题在于上游的信息是否还需要需要在请求发出的时候打印出来?但是这时截止时间内并没有打印,待请求信息回复之后需要做一次merge。或者打印两次日志,在存储层做1次数据的merge。
  • 另一种方案是将暂时的数据存储当前thread变量的栈中,待请求返回之后再栈中pop出当前的对象。将请求参数添加到对象中。不过这种方式无法处理异步回调的场景。
  • 最后一种就是异步的重新构造span的场景了。这里的sample参数特殊一些,大部分链路跟踪系统都是需要采样的,采样模式都是head-based,就是跟节点采样后续都采样。这导致在某些异步场景下即使traceI和spanid都不传递,但是sample也需要传递。

模块拆分和设计边界

系统模块拆分

这里面描述的是通用的分布式链路追踪的模块设计类型,不同的系统可能在不同的地方有所取舍。但总体来说遵循

客户端功能拆分和实现

  • agent-转码打包的代码模块用于javaagent
    • transform代码增强相关
    • classloader合理agent代码,防止类污染
    • static-config,静态配置用于拦截逻辑和静态配置
  • plugin-特定业务层面的逻辑
  • client-组件pom依赖
    • opentracing接口构造span
    • processor,通用处理逻辑
    • weavepoint,增强点类型定义用于界定作用域
    • dynamic-config。用于获取动态配置
    • log,打印日志
    • util,工具包
  • core-数据模型依赖

类与接口设计

链路跟踪有很多实现形式,从我个人的理解来看需要做2层抽象,一层是增强点层面的。 具体实现上代码模块还是分了很多有意思的部分代码模块拆分成了多个部分

代码层面其实要做2层抽象,

  • 一层抽象是针对增强点或者植入点的。因为不同介质的入口都是不一样的,wapper是统一的针对增强点的处理,用来统一的代码收口,wapper层面会判断当前代码的入口类型,并选取不同的AbsctrctWeaveProcess来进行处理,代码可以设计抽象的代码模板(client和service端基础类)。不同植入点在基础代码暴露出来的接口上进行拓展。
  • 一般来说上述代码可以保证链路跟 踪的基本功能。但是在公司内部往往需要服务很多业务功能。比如压测、自定义参数透传、环境染色、logId之类的。这些功能往往有一层统一的业务抽象,需要在各种增强点复用,因此抽象出来插件层更为合理。

有趣的设计细节

Trace-id vs log-id

logid是不是traceid?两种有什么区别? 严格来说log-id不是trace-id,但是也可以是。trace本质上是提供透传信息的能力,logid常用于串联日志信息,所以大部分场景下logid都是trace的透传能力在系统间透传的,在系统内部往往是threadlocal或者context的概念保存。 初次之外,之前我们还讨论过一种有意思的问题就是异步场景下的串联。根据之前的内容我们其实讨论过,trace本身由于起止时间的限制虽然可以用于异步场景,但是这样会给信息分析带来很多麻烦,在实际中我其实更倾向于将traceid定义在同步调用的scope内,在异步场景下,比如异步rpc,或者消息队列场景下,重新构造logId。

Java-agent与字节码增强

这里还有一个问题没有解释就是如何实现字节码增强的,基本原理是用的java-agent。java虽然是编译性的语言但是由于jvm和classloader的存在,java具有一定的动态特性。java的实际运行逻辑实际上是取决于jvm中的字节码,比如大多数javaer怀念的事务管理的注解,本质上上就给某些方法或者成员变量打上标记,运行过程中生成一个代理类同时在原有方法的基础上添加一些事务管理的模板。不论是生成新的代理类或者改变原有的类的字节码,从而实现动态代理。 我们回到trace的使用场景下,其实我们也是希望对字节码实现增强,理论上说也是可以基于自定义的类加载去制作动态代理实现的,但是一个主要的问题是没有办法控制所有的类加载器,其实trace希望的是在某个方法上实现wapper而并不关心具体的类加载是哪个。java本身提供了一种java-agent机制可以实现拦截所有的类加载过程,或者在运行过程中重载某个类的后门,显然更适合我们的场景。使用中只要是实现了对应接口,并打包成jar就可以。java-agent提供了2种方式一种是作为启动参数与jvm-runtime同时启动。或者在jvm实例启动之后,作为队列进程启动并attchment到jvm进程上。 这里不详细讨论代码实现的方式,因为网上例子很多,这里想说的其实是一套常用的java-agent使用的设计方式,这样对理解其他开源设计也有很多帮助。

一个典型的java-agent相关的模块很多情况下包括上面几个部分,

  • 首先是agent-entrace。这里是是实现java-agent的接口也就类似于main方法,是主逻辑展开的地方。
  • 其实是一个与外部接口对接的部分,可以提供一个可观测和可操作的入口,比如一个httpserver或者一个etcd/zk-client。
  • 很多java-agent最主要的功能是为了增强字节码,这其实是一个很危险的操作,相当于不停机的情况下该代码逻辑,因此通常会定义一些spi接口出来,把增强点限定在一些范围内,通过独立编译一些jar来修改制定的接口的方式加载,除了安全性的考虑之外也是希望考虑代码的解耦。这个过程有点类似于我们写一个api服务,会先顶一个url,在去写这个url实现一样。
  • 另一个场景的模块是独立的classloader。因为javagent很多时候是以premian的方式运行,这时main方法还没有运行,如果由于简介依赖引用了很多类可能会导致后续应用正常启动的时候加载了被污染的类。因此大部分会写一个classloader来独立加载agent用到的类,避免污染问题。这个类加载通常都不是双亲委派模型的。

java-agent应用其实非常广,这里可以举几个列子;

  • 开源的trace实现:skywalking.apache.org/
  • 混沌工程:github.com/alibaba/jvm…github.com/chaosblade-…
  • java分析诊断工具:github.com/alibaba/art… Head-base sample vs tail-base sample 每次rpc会生成2个日志,不采样的话日志的量会非常大,大多数trace日志都是采样的,而且采样率非常低,基本在1-5%之间。但是这对trace的功能并不影响,如之前所说,采样只涉及到的日志的落盘和上传,并不影响span的生成和参数的透传,只是生成的span是否上传而已。 采样本身也是有一个有意思的话,大部分trace都是head-base的,就是只有根节点有权利决定采样不采用,后续节点都是根据上游传过来的sample标志位来确定是否采样的。

典型的应用场景

请求染色(环境路由)

首先介绍下环境隔离的概念:大部分的开发模式都是基于giflow的。如果只有一套环境的话就会有多个代码合并的蛋疼问题,如果建多套环境的话成本有很高(比如独立的数据库,Nginx、注册中心、redis,以及大量的依赖服务),那有没有一种方式既可以创建创建多套环境又避免蛋疼的产品问题呢?

环境染色的方案就是trace在各个中间件请求下解析出入口环境信息,并且将其作为参数透传,各个中间件组件配合该信息将trace路由到对应集群上的方案:举一个简单的例子如下所示:

基本的思路就是这样,所有集群都有一套基准环境,通常部署master分支,而本次分支涉及到的变革部署一套feature集群。。所有的公共组件都用同一套,比如Nginx、注册中心、数据库、kafka集群等等。但是应用用到的资源会略有区别,比如说注册中心上带有集群的环境信息,rds可以建一个带有环境名后缀的影子表。kafka建有对应环境名后缀的topic。 用户请求的时候使用相同的域名但是附带上具有环境名的header,app在接受请求的时候会解析header并将入口环境信息放入baaage中,该信息会随链路下传。

对于rpc,客户端做路由的时候会根据环境信息优先选取特定子环境的集群,如果没有则调用基准环境,基准环境中的应用在调用的时候也可以根据相同的规则优先调用子环境。即使调用穿过了中间件比如队列,则传递的消息也附带有环境信息,trace也可以根据信息解析出路由规则并进行透传。不过为了避免消息被基准环境的app消费还是需要建特定子环境的topic。

其他应用场景

  • 压测
  • 应用分层
  • 故障演练

微信公众号:神奇的程序员


接受一线大厂内推(微软、阿里、头条、网易),详情请联系公众号或者发邮箱Andrewzhch@gmail.com