分布式追踪 - Jaeger

概念

当咱们把系统微服务化后,想查询某个接口一次请求的耗时信息,须要登陆多台机器查询相关日志才行。 以下图所示架构,当对应服务集群化部署后,想要查询到某一次请求信息更是难上加难。那咱们有什么办法能够解决这个问题么?html

答案固然是有的,分布式追踪系统正是为了解决这个问题而生。分布式跟踪为描述和分析跨进程事务提供了一种解决方案。如Google Dapper论文 (业界的分布式追踪系统基本都是以这篇论文为基础进行实现)所述,分布式跟踪的一些使用场景包括:java

  1. 异常检测,问题诊断
  2. 分布式系统内各组件的调用状况
  3. 性能/延迟优化
  4. 服务依赖性分析

micro-arch

由于业界分布式追踪系统众多,各家Api定义上有必定的差别,为了统一标准,因而OpenTracing出现了。node

什么是OpenTracing?python

OpenTracing经过提供平台无关、厂商无关的API,使得开发人员可以方便的添加或更换追踪系统的实现。OpenTracing正在为全球的分布式追踪,提供统一的概念和数据标准。git

除了OpenTracing外,还有OpenCensus 这个项目,OpenCensus 由google发起,它除了包含tracing外,还包含度量(metrics)。github

两套分布式追踪框架,都有不少追随者,都想统一对方,但最终结果是对峙不下,最后两个组织一块儿组队新建了OpenTelemetry项目。项目的第一宗旨就是:兼容OpenTracing和OpenSensus。对于使用OpenTracing或OpenSensus的应用不须要从新改动就能够接入OpenTelemetry。sql

模型

tracing-mental-model

从应用角度看分布式追踪系统所处的位置docker

示例

先来看两张效果图感觉一下(以jaeger ui为例) traces-jaeger-indexshell

trace-detail-jaeger

语义

这里的语义以 OpenTracing 为基础。数据库

数据模型

[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)

时序图

有些时候,使用下面这种基于时间轴的时序图能够更好的展示Trace(调用链)

––|–––––––|–––––––|–––––––|–––––––|–––––––|–––––––|–––––––|–> time

 [Span A···················································]
   [Span B··············································]
      [Span D··········································]
    [Span C········································]
         [Span E·······]        [Span F··] [Span G··] [Span H··]

trace-flow 这种展示方式增长显示了执行时间的上下文,相关服务间的层次关系,进程或者任务的串行或并行调用关系。这样的视图有助于发现系统调用的关键路径。经过关注关键路径的执行过程,项目团队可专一于优化路径中的关键位置,最大幅度的提高系统性能。

Trace

一条Trace是指一个请求包含的调用链(包含下游全部请求的调用链), 一条Trace能够被认为是一个由多个Span组成的有向无环图, SpanSpan的关系被命名为References

Operation Names

每个Span都有一个操做名称,这个名称简单,并具备可读性高。(例如:一个RPC方法的名称,一个函数名,或者一个大型计算过程当中的子任务或阶段)

Span

一个Span表明系统中具备开始时间和执行时长的逻辑运行单元 。具体能够理解为一次方法调用, 一个程序块的调用或者一次RPC/数据库访问。

每一个Span包含如下的状态:

  • An operation name,操做名称
  • A start timestamp,起始时间
  • A finish timestamp,结束时间
  • Span Tags,一组键值对构成的Span标签集合。键值对中,键必须为string,值能够是字符串,布尔,或者数字类型。
  • Span Logs,一组span的日志集合。 每次log操做包含一个键值对,以及一个时间戳。 键值对中,键必须为string,值能够是任意类型。 可是须要注意,不是全部的支持OpenTracing的Tracer,都须要支持全部的值类型。
  • SpanContext,Span上下文对象 (下面会详细说明)
  • References(Span间关系),相关的零个或者多个Span(Span间经过SpanContext创建这种关系)

每个SpanContext包含如下状态:

  • 任何一个OpenTracing的实现,都须要将当前调用链的状态(例如: spanID 和traceID ),依赖一个独特的Span去跨进程边界传输
  • Baggage Items,Trace的随行数据,是一个键值对集合,它存在于trace中,也须要跨进程边界传输

References

一个Span能够和一个或者多个Span间存在因果关系。 OpenTracing定义了两种关系:ChildOfFollowsFrom这两种引用类型表明了子节点和父节点间的直接因果关系

ChildOf 引用

一个Span多是一个父级Span的孩子,即ChildOf 关系。在ChildOf 引用关系下,父级span某种程度上取决于子Span。下面这些状况会构成ChildOf 关系:

  • 一个RPC调用的服务端的Span,和RPC服务客户端的Span构成ChildOf 关系
  • 一个sql insert操做的Span,和ORM的save方法的Span构成ChildOf 关系
  • 不少span能够并行工做(或者分布式工做)均可能是一个父级的Span的子项,他会合并全部子Span的执行结果,并在指按期限内返回

下面表述一个ChildOf 关系的父子节点关系的时序图:

FollowsFrom 引用

一些父级节点不以任何方式依然他们子节点的执行结果,这种状况下,咱们说这些子Span和父Span之间是FollowsFrom 的因果关系。 下面表述一个FollowsFrom 关系的父子节点关系的时序图:

[-Parent Span-]  [-Child Span-]
    [-Parent Span--]
     [-Child Span-]
    [-Parent Span-]
                [-Child Span-]

Tags

每一个Span能够有多个键值对(key:value)形式的TagsTags是没有时间戳的,支持简单的对Span进行注解和补充。 Span的tag不会跨进程传输,所以它们不会被子级的span继承。

必填参数

  • tag key,必须是string类型
  • tag value,类型为字符串,布尔或者数字 注意,OpenTracing标准包含**"standard tags,标准Tag"**,此文档中定义了Tag的标准含义。

Logs

每一个Span能够进行屡次Logs操做,每一次Logs操做,都须要一个带时间戳的时间名称,以及可选的任意大小的存储结构。 必填参数

  • 一个或者多个键值对,其中键必须是字符串类型,值能够是任意类型。某些OpenTracing实现,可能支持更多的log值类型。 可选参数
  • 一个明确的时间戳。若是指定时间戳,那么它必须在span的开始和结束时间以内。 注意,OpenTracing标准包含**"standard log keys,标准log的键"**,此文档中定义了这些键的标准含义。

SpanContext

SpanContext更多的是一个“概念” 。每一个Span都必须提供方法访问SpanContext。SpanContext表明跨越进程边界,传递到下级Span的状态,并用于封装Baggage 。 OpenTracing的使用者仅仅须要,在建立Span、向传输协议Inject(注入)和从传输协议中Extract(提取)时使用 。

Baggage

Baggage元素是一个键值对集合,将这些值设置给给定的SpanSpanSpanContext,以及全部和此Span有直接或者间接关系的本地Span 也就是说,baggage元素随Trace一块儿应用程序调用过程 一同传播
Baggage拥有强大功能,也会有很大的消耗。因为Baggage的全局传输,若是包含的数量量太大,或者元素太多,它将下降系统的吞吐量或增长RPC的延迟。

Inject and Extract

SpanContext能够经过Injected操做向Carrier增长,或者经过ExtractedCarrier中获取,跨进程通信数据。经过这种方式,SpanContexts能够跨越进程边界,并提供足够的信息来创建跨进程的span间关系(所以能够实现跨进程连续追踪)。

  • Inject 类比传递序列化后的参数
  • Extract 反序列化Inject的参数值

传递方式例如:

  1. 依赖HTTP头传递(B3-header)
  2. Dubbo 定制Filter经过 RpcContext 设置 Attachment 来传递

Carrier能够是一个接口或者一个数据载体,他对于跨进程通信是十分有帮助的。Carrier负责将追踪状态从一个进程"carries"传递到另外一个进程 。OpenTracing规定全部平台的实现者支持两种Carrier格式:基于"text map"(基于字符串的map)的格式和基于"binary"(二进制)的格式。

  • text map 格式的 Carrier是一个平台惯用的map格式,基于unicode编码的字符串字符串键值对
  • binary 格式的 Carrier 是一个不透明的二进制数组(更紧凑和有效) tracing-extract

Jaeger

Jaeger是 Uber 推出的一款开源分布式追踪系统(已从CNCF毕业),兼容 OpenTracing API。 它用于监视和诊断基于微服务的分布式系统,功能包括:

  1. 分布式上下文传播
  2. 分布式链路跟踪
  3. 服务依赖分析

技术栈

架构

Jaeger能够做为单个进程进行部署,也能够做为可扩展的分布式系统进行部署。 Jaeger 主要由如下几部分组成,架构很是清晰:

  • Jaeger Client - 为不一样语言实现了符合 OpenTracing 标准的 SDK。应用程序经过 API 写入数据,client library 把 trace 信息按照应用程序指定的采样策略传递给 jaeger-agent.
  • Agent - 它是一个监听在 UDP 端口上接收 span 数据的网络守护进程,它会将数据批量发送给 collector。它被设计成一个基础组件,部署到全部的宿主机上。Agent 将 client library 和 collector 解耦,为 client library 屏蔽了路由和发现 collector 的细节.
  • Collector - 接收 jaeger-agent 发送来的数据,而后将数据写入后端存储。Collector 被设计成无状态的组件,所以您能够同时运行任意数量的 jaeger-collector。 当前,咱们的管道会分析数据并为其创建索引,执行任何转换并最终存储它们。 Jaeger的存储设备是一个可插拔组件,目前支持 Cassandra, Elasticsearch and Kafka 存储.
  • Query - 接收查询请求,而后从后端存储系统中检索 trace 并经过 UI 进行展现.
  • Ingester - 后端存储被设计成一个可插拔的组件,支持将数据写入 Cassandra, Elasticsearch.

Jaeger包含两种架构方案: 1、收集器数据直接写入存储架构(tracing数据直接写入存储) architecture-v1

2、收集器数据缓冲后异步写入存储架构(tracing数据经过kafka缓冲后再异步消费写入存储) architecture-v2.png

我的推荐采用第二种架构方式部署

部署

为了快速搭建Jaeger环境,这里安装基于Helm部署(须要先搭建 Kubernetes 集群),能够参考前面写的文章来搭建。从 https://github.com/jaegertracing/helm-charts/tree/master/charts/jaeger 这里能够找到详细的部署流程,能够一步一步跟着执行部署。这里采用 收集器数据直接写入存储架构 部署

helm install jaeger jaegertracing/jaeger

官方推荐使用jaeger-operator来部署,可参考: https://www.jaegertracing.io/docs/1.17/operator/ 安装完成后查看服务状态

kubectl get svc
NAME               TYPE        CLUSTER-IP       EXTERNAL-IP   PORT(S)                                         AGE
jaeger-agent       ClusterIP   10.97.3.215      <none>        5775/UDP,6831/UDP,6832/UDP,5778/TCP,14271/TCP   7m45s
jaeger-cassandra   ClusterIP   None             <none>        7000/TCP,7001/TCP,7199/TCP,9042/TCP,9160/TCP    7m45s
jaeger-collector   ClusterIP   10.111.141.231   <none>        14250/TCP,14267/TCP,14268/TCP,14269/TCP         7m45s
jaeger-query       ClusterIP   10.97.103.64     <none>        80/TCP,16687/TCP                                7m45s

要访问jaeger ui 须要查看jaeger-query项目对外暴露的端口,咱们看到经过helm安装,咱们采用的默认配置,这里的网络类型是ClusterIP,若是想外网访问能够先临时改为NodePort的方式,执行以下命令编辑对应配置:

kubectl edit service jaeger-query

找到最下面的ClusterIP改为NodePort保存便可,保存后会自动生效

kubectl get svc
NAME               TYPE        CLUSTER-IP       EXTERNAL-IP   PORT(S)                                         AGE
jaeger-agent       ClusterIP   10.97.3.215      <none>        5775/UDP,6831/UDP,6832/UDP,5778/TCP,14271/TCP   8m38s
jaeger-cassandra   ClusterIP   None             <none>        7000/TCP,7001/TCP,7199/TCP,9042/TCP,9160/TCP    8m38s
jaeger-collector   ClusterIP   10.111.141.231   <none>        14250/TCP,14267/TCP,14268/TCP,14269/TCP         8m38s
jaeger-query       NodePort    10.97.103.64     <none>        80:31067/TCP,16687:31381/TCP                    8m38s

能够发现如今jaeger-query的网络类型已经变成了NodePort,如今能够经过流量访问Jaeger Ui了 这里的地址是 http://47.57.100.110:31067/search (注意,IP地址及端口根据本身控制台的实际输出填入就行) 进入页面后能够到刚才部署的UI界面,并查询jaeger-query项目自己的tracing信息。 我在列表页面找到一个trace_id: 73c00aa573bf1ed0 临时保存下它,后面分析会用到,打开后界面以下。 jaeger-query-ui.png

traces存储结构

咱们能够在jaeger源代码中找到后端cassandra的存储结构,具体信息能够看这里,位置比较隐蔽:

https://github.com/jaegertracing/jaeger/blob/master/plugin/storage/cassandra/schema/v001.cql.tmpl

不过咱们能够登陆Pod查看建立后的数据结构信息(cassandra)。让咱们一探究竟,首先登入cassandra对应的docker镜像,而后经过cql 链接cassandra集群。

若是对cql不了解的能够查看对应文档: https://cassandra.apache.org/doc/latest/cql/

kubectl exec -it jaeger-cassandra-0 --container jaeger-cassandra -- /bin/bash
cqlsh

Connected to jaeger at 127.0.0.1:9042.
[cqlsh 5.0.1 | Cassandra 3.11.6 | CQL spec 3.4.4 | Native protocol v4]
Use HELP for help.

进入对应的space,查看里面对应的表信息

cqlsh> desc keyspaces;    #查看有哪些keyspaces

jaeger_v1_test  system_auth  system_distributed
system_schema   system       system_traces     

cqlsh> use jaeger_v1_test;   #切换到jaeger对应的space
cqlsh:jaeger_v1_test> desc tables;    #查看jaeger space下面的表信息

service_name_index  service_names  service_operation_index  traces            
dependencies_v2     tag_index      duration_index           operation_names_v2

咱们能够一个一个的表信息查看。这里咱们主要看下保存咱们trace信息的表 service_name_index

cqlsh:jaeger_v1_test> desc traces;

CREATE TABLE jaeger_v1_test.traces (
    trace_id blob,
    span_id bigint,
    span_hash bigint,
    duration bigint,
    flags int,
    logs list<frozen<log>>,
    operation_name text,
    parent_id bigint,
    process frozen<process>,
    refs list<frozen<span_ref>>,
    start_time bigint,
    tags list<frozen<keyvalue>>,
    PRIMARY KEY (trace_id, span_id, span_hash)
) WITH CLUSTERING ORDER BY (span_id ASC, span_hash ASC)
    AND bloom_filter_fp_chance = 0.01
    AND caching = {'keys': 'ALL', 'rows_per_partition': 'NONE'}
    AND comment = ''
    AND compaction = {'class': 'org.apache.cassandra.db.compaction.TimeWindowCompactionStrategy', 'compaction_window_size': '1', 'compaction_window_unit': 'HOURS', 'max_threshold': '32', 'min_threshold': '4'}
    AND compression = {'chunk_length_in_kb': '64', 'class': 'org.apache.cassandra.io.compress.LZ4Compressor'}
    AND crc_check_chance = 1.0
    AND dclocal_read_repair_chance = 0.0
    AND default_time_to_live = 172800
    AND gc_grace_seconds = 10800
    AND max_index_interval = 2048
    AND memtable_flush_period_in_ms = 0
    AND min_index_interval = 128
    AND read_repair_chance = 0.0
    AND speculative_retry = 'NONE';

还记得咱们开始保存的那个trace_id: 73c00aa573bf1ed0 么,如今咱们能够在这个表中查看它是如何保存的,咱们可使用下面的cql进行查询,查询前须要对界面上的trace_id进行补位填充0x0000000000000000 ,这里必定要注意,最终在cql里面查询的trace_id为:0x000000000000000073c00aa573bf1ed0

cqlsh:jaeger_v1_test> expand on;
Now Expanded output is enabled
cqlsh:jaeger_v1_test> select * from traces where trace_id=0x000000000000000073c00aa573bf1ed0;

@ Row 1
----------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
 trace_id       | 0x000000000000000073c00aa573bf1ed0
 span_id        | 2300299680491247480
 span_hash      | 1417161953846781420
 duration       | 204491
 flags          | 1
 logs           | [{ts: 1589363774632310, fields: [{key: 'event', value_type: 'string', value_string: 'searching', value_bool: False, value_long: 0, value_double: 0, value_binary: null}, {key: 'trace_id', value_type: 'string', value_string: '4c423cfb69721367', value_bool: False, value_long: 0, value_double: 0, value_binary: null}]}]
 operation_name | readTrace
 parent_id      | 0
 process        | {service_name: 'jaeger-query', tags: [{key: 'jaeger.version', value_type: 'string', value_string: 'Go-2.22.1', value_bool: False, value_long: 0, value_double: 0, value_binary: null}, {key: 'hostname', value_type: 'string', value_string: 'jaeger-query-55c77745b5-ff8tt', value_bool: False, value_long: 0, value_double: 0, value_binary: null}, {key: 'ip', value_type: 'string', value_string: '192.168.61.148', value_bool: False, value_long: 0, value_double: 0, value_binary: null}, {key: 'client-uuid', value_type: 'string', value_string: '363a86b295da9842', value_bool: False, value_long: 0, value_double: 0, value_binary: null}]}
 refs           | [{ref_type: 'child-of', trace_id: 0x000000000000000073c00aa573bf1ed0, span_id: 8340678215617945296}]
 start_time     | 1589363774627367
 tags           | [{key: 'db.statement', value_type: 'string', value_string: '\n\t\tSELECT trace_id, span_id, parent_id, operation_name, flags, start_time, duration, tags, logs, refs, process\n\t\tFROM traces\n\t\tWHERE trace_id = ?', value_bool: False, value_long: 0, value_double: 0, value_binary: null}, {key: 'db.type', value_type: 'string', value_string: 'cassandra', value_bool: False, value_long: 0, value_double: 0, value_binary: null}, {key: 'component', value_type: 'string', value_string: 'gocql', value_bool: False, value_long: 0, value_double: 0, value_binary: null}, {key: 'internal.span.format', value_type: 'string', value_string: 'proto', value_bool: False, value_long: 0, value_double: 0, value_binary: null}]

此处省略4个Row....
(5 rows)

由于找到这个trace_id包含了5个span,因此这里查询出来了5条记录,能够经过这段文本及上面的图片进行一一观察,能够发现存储结构仍是很是清晰的,UI界面须要展现的信息基本均可以很容易从里面取到。

咱们再回过头来看看jaeger client 库thrift的结构(源码见:jaeger.thrift)

# 标签
struct Tag {
  1: required string  key
  2: required TagType vType
  3: optional string  vStr
  4: optional double  vDouble
  5: optional bool    vBool
  6: optional i64     vLong
  7: optional binary  vBinary
}

# 日志
struct Log {
  1: required i64       timestamp
  2: required list<Tag> fields
}

enum SpanRefType { CHILD_OF, FOLLOWS_FROM }

# Span 之间的关系
struct SpanRef {
  1: required SpanRefType refType
  2: required i64         traceIdLow
  3: required i64         traceIdHigh
  4: required i64         spanId
}

# Span
struct Span {
  1:  required i64           traceIdLow   # the least significant 64 bits of a traceID
  2:  required i64           traceIdHigh  # the most significant 64 bits of a traceID; 0 when only 64bit IDs are used
  3:  required i64           spanId       # unique span id (only unique within a given trace)
  4:  required i64           parentSpanId # since nearly all spans will have parents spans, CHILD_OF refs do not have to be explicit
  5:  required string        operationName
  6:  optional list<SpanRef> references   # causal references to other spans
  7:  required i32           flags        # a bit field used to propagate sampling decisions. 1 signifies a SAMPLED span, 2 signifies a DEBUG span.
  8:  required i64           startTime
  9:  required i64           duration
  10: optional list<Tag>     tags
  11: optional list<Log>     logs
}

基本上能够跟存储的数据结构一一对应上。

采样策略

Jaeger客户端支持4种采样策略,分别是:

  1. Constant (sampler.type=const) 采样率的可设置的值为 0 和 1,分别表示关闭采样和所有采样
  2. Probabilistic (sampler.type=probabilistic) 按照几率采样,取值可在 0 至 1 之间,例如设置为 0.5 的话意为只对 50% 的请求采样
  3. Rate Limiting (sampler.type=ratelimiting) 设置每秒的采样次数上限 。 例如,当sampler.param = 2.0时,它将以每秒2条迹线的速率对请求进行采样。
  4. Remote (sampler.type=remote) 此为默认策略。 采样遵循远程设置,取值的含义和 probabilistic 相同,都意为采样的几率,只不过设置为 remote 后,Client 会从 Jaeger Agent 中动态获取采样率设置。 为了最大程度地减小开销,Jaeger默认采用 0.1% 的采样策略采集数据 (1000次里面采集1次)。

客户端

全部Jaeger客户端库都支持OpenTracing API ,下面这些都是官方支持的客户端库

语言 GitHub Repo
Go jaegertracing/jaeger-client-go
Java jaegertracing/jaeger-client-java
Node.js jaegertracing/jaeger-client-node
Python jaegertracing/jaeger-client-python
C++ jaegertracing/jaeger-client-cpp
C# jaegertracing/jaeger-client-csharp

其余语言的客户端库还在开发中,具体进展能够来这里查看 issue #366

参考文献

https://www.jaegertracing.io/docs/

https://github.com/jaegertracing/jaeger