1.背景说明
由于我们的项目是微服务方向,中后台服务现在已经有10多类左右服务模块,且各个服务/模块之间的调用关系复杂,部分服务与服务之间还存在一些proxy服务(实现服务的多活部署)。这些现象就导致在开发调试、问题跟踪上都会逐步出现问题。因此,前段时间对当前微服务中较流行的两款开源分布式tracing系统:Zipkin和Jaeger分别进行了调研。
在一个微服务分布式架构的系统中,可能存在复杂的、深层的层层服务调用关系,大致如下图
微服务架构中各个服务调用关系,是不是很像程序中各个函数的层层调用关系
上面这幅图很好理解,用户浏览器发起请求,先到应用A;A 会去调用B、C;B 会去调用F ……如此层层调用到最后一层,最终完成对于客户请求的一次处理(可能是读请求,也可能是写请求)。
试想一下,这个过程中会出现哪些可能的问题?
对于第一种情况,如果客户端请求是一个写请求,调用链中每一步都有写操作,前面几步执行成功,到了第三步执行失败,那么在分布式系统中就需要采用分布式事务的机制去回滚前几步的写操作!
对于第二种情况,可能因为应用F 出现异常拖垮整个服务链路,甚至出现雪崩现象,严重影响系统的可用性。本文先不讨论如何应对这种场景,只是先提一下,这种问题在分布式系统中采用熔断机制和降级机制保证系统在出现此类异常时还有高可用性!
那我们再看看1、2、3 的场景中都可能涉及到的问题,在复杂的调用链路中假设存在一条调用链路响应缓慢、或者处理异常,如何定位这个有问题的服务呢?大多数开发的第一反应可能是看日志,依次分析调用链路上各个系统的日志文件,然后定位具体出问题的那个服务,但只要有过类似排查经历的同学都会知道,真他妈痛苦,海量的日志中定位问题,真的是痛苦得很!
2.跟踪链
首先,简要说下什么是跟踪链,如果有相关基础的同学可以略过,或者也可以快速过一下该段。基本上,任何说到跟踪链相关的文章,都会贴出下面这张图:
图 1. 一个简单的tracing调用过程
图1是Google的Dapper论文里的一个例子:图示中,A~E五个节点表示五个服务。用户发起一次请求RequestX到A,同时由于该请求依赖服务B与C,因此A分别发送RPC请求到B和C,B处理完请求后会直接返回到A,但是服务C还依赖服务D和E,因此还要发起两个RPC请求分别到D和E,D和E处理完毕后回到C,C才继续回复到A,最终A会回复用户ReplyX。对于这样一个请求,简单实用的分布式跟踪的实现,就是为 服务器 上每一次发送和接收动作来收集跟踪标识符和时间戳。
在Dapper这篇论文中,Trace和Span是两个很重要的名词。我们使用Trace表示对一次请求完整调用链的跟踪,而将两个服务例如上面的服务A和服务B的请求/响应过程叫做一次Span,trace是通过span来体现的, 通过一句话总结,我们可以将一次trace,看成是span的有向图,而这个有向图的边即为span。为了可以更好的理解这两个名词,我们可以再来看一下面的调用图。
[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)上图中,包含了8个span信息
图 2. 某次tracing过程的spans关系图
––|–––––––|–––––––|–––––––|–––––––|–––––––|–––––––|–––––––|–> time
[Span A···················································]
[Span B··············································]
[Span D··········································]
[Span C········································]
[Span E·······] [Span F··] [Span G··] [Span H··]
图 3. Spans的时间轴关系图
而分布式跟踪系统要做的,就是记录每次发送和接受动作的标识符和时间戳,将一次请求涉及到的所有服务串联起来,只有这样才能搞清楚一次请求的完整调用链。
3. OpenTracing
在具体介绍Jaeger之前,还需要赘述一下另一个关键词:OpenTracing。
一句话总结, OpenTracing 是一套标准,它通过提供平台无关、厂商无关的API,使得开发人员能够方便的添加(或更换)追踪系统的实现(我们在测试使用中是基本上通过两行代码的更改就可以在Zipkin和Jaeger之间切换)。OpenTracing提供了用于运营支撑系统的和针对特定平台的辅助程序库。程序库的具体信息请参考详细的规范。OpenTracing 已进入 CNCF (云原生计算基金会,著名的Kubernetes、gRPC和Prometheus等均孵化于此),正在为全球的分布式追踪,提供统一的概念和数据标准。
当然,本文之前提到的Zipkin和Jaeger,都是支持OpenTracing标准的。
3.1 引用关系
OpenTracing标准中目前定义了两种类型的引用:
3.1.1 ChildOf引用
一个span A可以是ChildOf另一个span B。以下所有内容均构成ChildOf关系:
[-Parent Span---------]
[-Child Span----]
[-Parent Span--------------]
[-Child Span A----]
[-Child Span B----]
[-Child Span C----]
[-Child Span D---------------]
[-Child Span E----]
图 4. Span的ChildOf引用
3.1.2 FollowsFrom引用
一些parent Spans不以任何方式依赖他们的child Spans的结果。 在这些情况下,我们只说child Span从因果意义上来自parent Span。
4. Jaeger介绍
如果细心的话,通过上面CNCF的地址,我们可以发现,Jaeger现在也成为了CNCF的孵化项目。
为了深入了解下jaeger的工作原理,首先,我们来看一下Jaeger的架构设计图:
图 5. Jaeger架构设计图
在上图,我们看到Jaeger系统中:黄色部分是我们的应用程序代码;红色部门表示instrument操作,即把我们应用程序与jaeger-client装载起来,从而开始了应用程序到Jaeger的的数据交互操作。将该部分放大来看,我们可以参考下图,了解详细的数据交互方式:
图 6. Jaeger的instrumentation过程
在图5中,我们可以观察到Jaeger的完整的概览设计。从中我们会发现Jaeger有5个模块元素,如下所列出,接下来我们来分别解释这五个模块的作用:
1.Jaeger-client
2.Agent
3.Collector
4.Data Store
5.UI
Jaeger-client(客户端库)
client是客户端lib,便于不同语言的项目来介入到Jaeger中,当我们的应用程序装载上之后,client会负责收集并发送数据到Agent。当前Jaeger的SDK支持有如下:
--官方
1.Go
2.Java
3.Node
4.Python
5.C++
--非官方
1.PHP
3.Others
client是支持OpenTracing标准的,如上文提到,Zipkin也是支持OpenTracing标准的,这也就意味着,我们的应用程序在嵌入Jaeger-client的地方,可以随时替换成Zipkin,对业务是完全透明的。
Agent(客户端代理)
jaeger的agent,是一个监听在 UDP 端口上接收 span 数据的网络守护进程。 如同大多数分布式系统都拥有一个Agent一样,Jaeger的Agent有以下几类特点:
agent收集并汇聚这些span信息到collector;
agent的被设计成一个基础组件,旨在 作为基础架构组件部署到所有宿主机 ;
agent将client library 和 collector 解耦,为 client library 屏蔽了路由和发现 collector 的细节;
Collector(数据收集处理)
collector,顾名思义,从agent收集traces信息,并通过处理管道处理他们,再写入后端存储(backends)。
当前的collector工作主要是管理trace,建立索引,执行相关转换,并最终存储它们。
Collector中运行着sampling逻辑,根据我们设定的sampling方式对数据进行收集和处理。
Data Store(数据存储)
jaeger的data store是组件的方式。
当前可以支持 Cassandra和ElasticSearch(当然也支持纯内存方式,但是不适用于生产环境).
Query & UI(数据查询与前端界面展示)
Query查询是一种从存储中检索trace,并提供UI以显示它们的服务。上图中就展示了一次Trace的数据流向,作为一次系统作用的数据传播/执行图,即可以在Jaeger UI上展示出来。
5. 部署方式
Jaeger的部署由于方案的不同,会依赖不同的服务,这些第三方基础服务的部署安装不再该文范围内,如docker、Elasticsearch、Cassandra等
5.1 All in one
为了方便大家快速使用,Jaeger直接提供一个All in one的docker镜像,通过All in one的image,我们可以通过以下命令直接启动一套完整的Jaeger tracing系统:
$ docker run -d -e \
COLLECTOR_ZIPKIN_HTTP_PORT=9411 \
-p 5775:5775/udp \
-p 6831:6831/udp \
-p 6832:6832/udp \
-p 5778:5778 \
-p 16686:16686 \
-p 14268:14268 \
-p 9411:9411 \
jaegertracing/all-in-one:latest
一旦启动成功后,就可以去http://localhost:16686看到Jaeger UI了,如下所示。
图 7. Jaeger UI的首页
注:在All in one模式下,Data Store使用的是内存,因此若重启dockre容器后就看不到之前的数据了。所以,该模式仅可用于前期demo或者验证,不可在生产环境中这样部署。
5.2 独立部署
当然我们更推荐的方式是独立部署,独立部署也可以分为docker镜像方式和binary 方式,官网有详细的 docker镜像方式启动命令介绍 ,在这儿就不再粘贴复制了。
关于binary方式部署,可以参看github上的 Jaeger的二进制包 。地址提供了mac、 linux 和windows的三大操作系统的binary包。以linux为例,解压后我们可以发现有以下几个bin包,分别清晰地对应了我们之前说的几个模块:
drwxrwxr-x 3 2000 2000 4.0K May 28 23:29 jaeger-ui-build
-rwxrwxr-x 1 2000 2000 27M May 28 23:29 jaeger-standalone
-rwxrwxr-x 1 2000 2000 22M May 28 23:29 jaeger-query
-rwxrwxr-x 1 2000 2000 25M May 28 23:29 jaeger-collector
-rwxrwxr-x 1 2000 2000 16M May 28 23:29 jaeger-agent
注:Jaeger同时也提供可Kubernetes and OpenShift的模板。可参考github地址有详细介绍
5.3 端口说明
通过上述All in one启动方式,我们直接发现了Jaeger启动时占据了很多端口,当然,并不是所有的端口都是必需的,这儿简单列出这些端口的说明如下:
端口 协议 所属模块 功能
5775 UDP agent 通过兼容性Thrift协议,接收Zipkin thrift类型数据
6831 UDP agent 通过兼容性Thrift协议,接收Jaeger thrift类型数据
6832 UDP agent 通过二进制Thrift协议,接收Jaeger thrift类型数据
5778 HTTP agent 配置控制服务接口
16686 HTTP query 客户端前端界面展示端口
14268 HTTP collector 接收客户端Zipkin thrift类型数据
14267 HTTP collector 接收客户端Jaeger thrift类型数据
9411 HTTP collector Zipkin兼容endpoint
6. Sampling
关于Jaeger系统中的采样方式,我们可以通过一个例子来解释。
假设有三个服务A,B,C,且存在一个简单的调用方式:A->B->C, 当服务A收到请求时,Jaeger检查该请求有没有trace信息,如果没有,将为其生成新的trace(TraceId为随机生成的),并基于当前的取样策略进行sampling。该取样策略会随着请求一路广播到服务B和C,因此这些服务将必须再做采样的策略选择。这种采样方式确保了当一个trace被采用后,它的所有后续spans都会被存储起来(若每层服务都再做一次采样策略选择的话,我们就会很难获取到完整的一个trace了)。
7. 代码侵入性
关于装载的方法,不同语言在网上的代码示例都很多。对于tracing这种监控类系统,一定不能对我们的业务代码侵入太高,因为我们的项目代码为golang,下面以golang代码中grpc下,jaeger的代码侵入性比较(为了提高可读性,直接展示出diff信息):
图 8. tracing的代码侵入展示
我们看到,利用grpc的interceptor功能,jager只需要在拦截器中插入一个interceptor即可,整体代码的影响之有5行左右。(注:在业务代码中,grpc使用context来携带信息)
8. Jaeger使用
当我们正是使用jager后,可以通过两种方式来进行查看:
根据TraceId搜索
9.接口说明
命名空间 | 接口名称 | 功能 |
YAML | LoadFile | 加载配置文件 |
jaegertracing | Config::parse | 解析配置文件 |
| Tracer::make | 创建跟踪器 |
opentracing | Tracer::InitGlobal | 初始化对象 |
| Tracer::Global() | 获取对象 |
| Tracer::StartSpan | 开始跨度追踪 |
| Tracer::Inject | 注入(用于跨进程跟踪) |
| Tracer::Extract | 提取(用于跨进程跟踪) |
| Tracer::Close | 关闭跨度追踪 |
| ChildOf | 返回一个StartSpanOption 指向一个依赖的父跨度 |
| FollowsFrom | 返回一个StartSpanOption 指向父跨度,父跨度不以任何方式依赖它们子跨度的执行结果 |
| Span::SetTag | 向跨度添加标签 |
| Span::Log | 向跨度记录日志 |
10.配置文件说明
disabled: false //指示配置是否返回误操作跟踪器
reporter:
endpoint: http://192.168.2.244:14268/api/traces //客户端直接连接到收集器
logSpans: true //是否记录跨度
sampler:
type: const //const 采样器对记录做一致操作 param=1全采集 param=0 全不采集
//probabilistic 随机采集信息 param=0.1 表示10条轨迹中大约采样1条
//ratelimiting 使用漏斗速率限制器来确保一定的恒定速率对轨迹进行采集 param=2.0 表示以每秒2条轨迹的速率对请求进行采样
//remote 向agent代理咨询当前服务使用的适当采样策略,动态控制采样策略
param: 1
11、Demo程序
分为客户端和服务端 主要为了实现进程间追踪
分别执行make生成执行文件
执行./server
执行./client
完成后打开http://192.168.2.244:16686页面
12、性能影响
根据Jaeger页面显示,相关接口调用网络耗时在1ms以内,影响可以忽略不计,内存方面以Text方式接入情况下传递字符串包括4部分spanID:parentID:traceID:traceFlags,例如uber-trace-id:d28be46d9f7e9cb1:883ab824105bab12:d28be46d9f7e9cb1:1不到70字节,对内存影响应该不是很大。
https://github.com/jaegertracing/jaeger-client-cpp