个人博客原文链接:分布式链路追踪(OpenTracing标准)和 Jaeger 实现
OpenTracing 简介
OpenTracing 是一个中立的(厂商无关、平台无关)分布式追踪的 API 规范,提供了统一接口方便开发者在自己的服务中集成一种或者多种分布式追踪的实现。
OpenTracing 诞生的背景
开发和工程团队因为系统组件水平扩展、开发团队小型化、敏捷开发、CD(持续集成)、解耦等各种需求,开始使用微服务的架构取代以前好的单机系统。 也就是说,当一个生产系统面对真正的高并发,或者解耦成大量微服务时,以前很容易实现的重点任务变得困难了。过程中需要面临一系列问题:用户体验优化、后台真是错误原因分析,分布式系统内各组件的调用情况等。随着服务数量的增多和内部调用链的复杂化,仅凭借日志和性能监控很难做到 “See the Whole Picture”,在进行问题排查或是性能分析的时候,无异于盲人摸象。
分布式追踪能够帮助开发者直观分析请求链路,快速定位性能瓶颈,逐渐优化服务间依赖,也有助于开发者从更宏观的角度更好地理解整个分布式系统。已有的分布式跟踪系统(例如,Zipkin, Dapper, HTrace, X-Trace等)旨在解决这些问题,但是他们使用不兼容的 API 来实现各自的应用需求。尽管这些分布式追踪系统有着相似的 API 语法,但各种语言的开发人员依然很难将他们各自的系统(使用不同的语言和技术)和特定的分布式追踪系统进行整合。
在这种情况下,OpenTracing 通过提供平台无关、厂商无关的API,使得开发人员能够方便的添加(或更换)追踪系统的实现。OpenTracing 定义了一套通用的数据上报接口,要求各个分布式追踪系统都来实现这套接口。这样一来,应用程序只需要对接 OpenTracing,而无需关心后端采用的到底什么分布式追踪系统,因此开发者可以无缝切换分布式追踪系统,也使得在通用代码库增加对分布式追踪的支持成为可能。
目前,主流的分布式追踪实现基本上都已经支持 OpenTracing,包括 Jaeger,Zipkin,Appdash 等。
分布式追踪的相关概念
Tracing
Wikipedia 中,对 Tracing 的定义是,在软件工程中,Tracing 指使用特定的日志记录程序的执行信息,与之相近的还有两个概念,它们分别是 Logging 和 Metrics。
- Logging:用于记录离散的事件,包含程序执行到某一点或某一阶段的详细信息。例如,应用程序的调试信息或错误信息。它是我们诊断问题的依据。
- Metrics:可聚合的数据,且通常是固定类型的时序数据。例如,队列的当前深度可被定义为一个度量值,在元素入队或出队时被更新;HTTP 请求个数可被定义为一个计数器,新请求到来时进行累加。
- Tracing:记录单个请求的处理流程,其中包括服务调用和处理时长等信息。
这三者也有相交重叠的部门:
- Logging & Metrics:可聚合的事件。例如分析某对象存储的 Nginx 日志,统计某段时间内 GET、PUT、DELETE、OPTIONS 操作的总数。
- Metrics & Tracing:单个请求中的可计量数据。例如 SQL 执行总时长、gRPC 调用总次数。
- Tracing & Logging:请求阶段的标签数据。例如在 Tracing 的信息中标记详细的错误原因。
针对每种分析需求,都有非常强大的集中式分析工具。
- Logging:ELK,近几年势头最猛的日志分析服务。
- Metrics:Prometheus,第二个加入 CNCF 的开源项目,非常好用。
- Tracing:OpenTracing 和 Jaeger,Jaeger 是 Uber 开源的一个兼容 OpenTracing 标准的分布式追踪服务。目前 Jaeger 也加入了 CNCF。
分布式追踪的核心步骤
分布式追踪系统大体分为三个部分,数据采集、数据持久化、数据展示。
数据采集是指在代码中埋点,设置请求中要上报的阶段,以及设置当前记录的阶段隶属于哪个上级阶段。数据持久化则是指将上报的数据落盘存储,例如 Jaeger 就支持多种存储后端,可选用 Cassandra 或者 Elasticsearch。数据展示则是前端根据 Trace ID 查询与之关联的请求阶段,并在界面上呈现。
一个典型的 Trace 案例如下:
请求从客户端发出,请求首先到达负载均衡,接着进行认证服务,计费服务,然后请求资源,最后返回结果。
当数据被采集存储之后,分布式追踪系统会采用包含时间轴的的时序图来呈现这个 Trace:
OpenTracing 数据模型
Traces
一个 trace 代表一个潜在的,分布式的,存在并行数据或并行执行轨迹(潜在的分布式、并行)的系统。也可以理解成一个调用链,一个 trace 可以认为是多个 span 的有向无环图(DAG)。
Spans
一个 span 代表系统中具有开始时间和执行时长的逻辑运行单元,可以理解成某个处理阶段,一次方法调用,一个程序块的调用,或者一次 RPC/数据库访问。只要是一个具有完整时间周期的程序访问,都可以被认为是一个 span。span 之间通过嵌套或者顺序排列建立逻辑因果关系。
每个 Span 包含以下的状态:
- 操作名称(An operation name)
- 起始时间(A start timestamp)
- 结束时间(A finish timestamp)
- Span Tag:一组 KV 值,作为阶段的标签集合。键值对中,键必须为 string,值可以是字符串,布尔,或者数字类型。
- 阶段日志(Span Logs):一组 span 的日志集合。 每次 log 操作包含一个键值对,以及一个时间戳。 键值对中,键必须为 string,值可以是任意类型。 但是需要注意,不是所有的支持 OpenTracing 的 Tracer,都需要支持所有的值类型。
- 阶段上下文(SpanContext),其中包含 TraceID 和 SpanID
- 引用关系(Reference):Span 和 Span 之间的关系被命名 Reference,Span 之间通过 SpanContext 建立这种关系。
Span Reference
一个 span 可以和一个或者多个 span 间存在因果关系。OpenTracing 定义了两种关系:ChildOf
和 FollowsFrom
。这两种引用类型代表了子节点和父节点间的直接因果关系。未来,OpenTracing将支持非因果关系的span引用关系。(例如:多个span被批量处理,span在同一个队列中,等等)
ChildOf
引用: 一个 span 可能是一个父级 span 的孩子,即 “ChildOf” 关系。在" ChildOf" 引用关系下,父级 span 某种程度上取决于子 span。下面这些情况会构成 “ChildOf” 关系:
- 一个 RPC 调用的服务端的 span,和 RPC 服务客户端的 span 构成 ChildOf 关系
- 一个 sql insert 操作的 span,和 ORM 的 save 方法的 span 构成 ChildOf 关系
- 很多 span 可以并行工作(或者分布式工作)都可能是一个父级的 span 的子项,他会合并所有子span的执行结果,并在指定期限内返回
一个具有 ChildOf
父子节点关系的时序图如下:
[-Parent Span---------]
[-Child Span----]
[-Parent Span--------------]
[-Child Span A----]
[-Child Span B----]
[-Child Span C----]
[-Child Span D---------------]
[-Child Span E----]
FollowsFrom
引用: 一些父级节点不以任何方式依赖他们子节点的执行结果,这种情况下,就说这些子 span 和父 span 之间是 “FollowsFrom” 的因果关系。“FollowsFrom” 关系可以被分为很多不同的子类型,未来版本的 OpenTracing 中将正式的区分这些类型。
一个具有 FollowsFrom
父子节点关系的时序图如下:
[-Parent Span-] [-Child Span-]
[-Parent Span--]
[-Child Span-]
[-Parent Span-]
[-Child Span-]
综上,在一个 tracer 过程中,各 span 可以有如下关系:
[Span A] ←←←(the root span)
|
+------+------+
| |
[Span B] [Span C] ←←←(Span C 是 Span A 的孩子节点, ChildOf)
| |
[Span D] +---+-------+
| |
[Span E] [Span F] >>> [Span G] >>> [Span H]
↑
↑
↑
(Span G 在 Span F 后被调用, FollowsFrom)
上述 tracer 与 span 的时间轴关系如下:
––|–––––––|–––––––|–––––––|–––––––|–––––––|–––––––|–––––––|–> time
[Span A···················································]
[Span B··············································]
[Span D··········································]
[Span C········································]
[Span E·······] [Span F··] [Span G··] [Span H··]
SpanContext
每个 span 必须提供方法访问 SpanContext。SpanContext 代表跨越进程边界,传递到下级 span 的状态。(例如,包含 <trace_id, span_id, sampled>
元组),并用于封装 Baggage 。SpanContext 在跨越进程边界和追踪图中创建边界的时候会被使用。
每一个 SpanContext 包含以下特点:
- 任何一个 OpenTracing 的实现,都需要将当前调用链的状态(例如:trace 和 span 的 id),依赖一个独特的 Span 去跨进程边界传输。
- Baggage Items,Trace 的随行数据,是一个键值对集合,它存在于 trace 中,也需要跨进程边界传输
Inject 和 Extract
SpanContexts 可以通过 Injected 操作向载体( Carrier)增加,或者通过 Extracted 从 Carrier 中获取,跨进程通讯数据(例如:将 HTTP 头作为 Carrier 携带 SpanContexts)。通过这种方式,SpanContexts 可以跨越进程边界,并提供足够的信息来建立跨进程的 span 间关系(因此可以实现跨进程连续追踪)。
Baggage
**Baggage **是存储在 SpanContext 中的一个键值对( SpanContext )集合。它会在一条追踪链路上的所有 span 当中传输,包含这些 span 对应的 SpanContexts。在这种情况下,“Baggage” 会随着 trace 一同传播,他因此得名(Baggage 可理解为随着 trace 运行过程传送的行李)。鉴于全栈 OpenTracing 集成的需要,Baggage 通过透明化的传输任意应用程序的数据,实现强大的功能。例如:可以在最终用户的手机端添加一个 Baggage 元素,并通过分布式追踪系统传递到存储层,然后再通过反向构建调用栈,定位过程中消耗很大的 SQL 查询语句。
Baggage 拥有强大功能,也会有很大的消耗。由于 Baggage 的全局传输,如果包含的数量量太大,或者元素太多,它将降低系统的吞吐量或增加 RPC 的延迟。
Baggage 与 Span Tags 的区别
- Baggage 在全局范围内,伴随业务系统的调用跨进城传输数据。而 Span 的 tag 不会进行传输,因为它们不会被子级的 span 继承。
- span 的 tag 可以用来记录业务相关的数据,并存储与追踪系统中。而在实现 OpenTracing 时,可以选择是否存储 Baggage 中的非业务数据,OpenTracing 标准不强制要求实现此特性。
OpenTracing 定义的 API
各平台 API 支持
目前来说,下面这些平台都支持了 OpenTracing 规范定义的 API:
- Go - https://github.com/opentracing/opentracing-go
- Python - https://github.com/opentracing/opentracing-python
- Javascript - https://github.com/opentracing/opentracing-javascript
- Objective-C - https://github.com/opentracing/opentracing-objc
- Java - https://github.com/opentracing/opentracing-java
- C++ - https://github.com/opentracing/opentracing-cpp
PHP 和 Ruby 的 API 目前也正在研发当中。
相关 API
OpenTracing 标准中有三个重要的相互关联的类型,分别是 Tracer
, Span
和 SpanContext
。一般来说,每个行为都会在各语言实现层面上,会演变成一个方法,而实际上由于方法重载,很可能演变成一系列相似的方法。
当讨论“可选”参数时,需要强调的是,不同的语言针对可选参数有不同理解,概念和实现方式 。例如,在Go中,习惯使用 ”functional Options”,而在 Java 中,可能使用 builder 模式。
Tracer
Tracer
接口用来创建 Span
,以及处理如何处理 Inject
(serialize) 和 Extract
(deserialize),用于跨进程边界传递。它具有如下官方能力:
创建一个新 Span
必填参数
- operation name, 操作名, 一个具有可读性的字符串,代表这个span所做的工作(例如:RPC 方法名,方法名,或者一个大型计算中的某个阶段或子任务)。操作名应该是一个抽象、通用,明确、具有统计意义的名称。因此,
"get_user"
作为操作名,比"get_user/314159"
更好。
例如,假设一个获取账户信息的span会有如下可能的名称:
操作名 | 指导意见 |
---|---|
get |
太抽象 |
get_account/792 |
太明确 |
get_account |
正确的操作名,关于account_id=792 的信息应该使用 Tag 操作 |
可选参数
- 零个或者多个关联(references)的
SpanContext
,如果可能,同时快速指定关系类型,ChildOf
还是FollowsFrom
。 - 一个可选的显性传递的开始时间;如果忽略,当前时间被用作开始时间。
- 零个或者多个tag。
返回值,返回一个已经启动 Span
实例(已启动,但未结束)
将 SpanContext
上下文 Inject(注入)到 carrier
必填参数:
SpanContext
实例- format(格式化)描述,一般会是一个字符串常量,但不做强制要求。通过此描述,通知
Tracer
,如何对SpanContext
进行编码放入到 carrier 中。 - carrier,根据 format 确定。
Tracer
根据 format 声明的格式,将SpanContext
序列化到 carrier 对象中。
将 SpanContext
上下文从 carrier 中 Extract(提取)
必填参数
- format(格式化)描述,一般会是一个字符串常量,但不做强制要求。通过此描述,通知
Tracer
,如何从 carrier 中解码SpanContext
。 - carrier,根据format确定。
Tracer
根据 format 声明的格式,从 carrier 中解码SpanContext
。
返回值,返回一个 SpanContext
实例,可以使用这个 SpanContext
实例,通过 Tracer
创建新的 Span
。
注意,对于Inject(注入)和Extract(提取),format 是必须的。
Inject(注入)和 Extract(提取)依赖于可扩展的 format 参数。format 参数规定了另一个参数 ”carrier” 的类型,同时约束了 ”carrier” 中 SpanContext
是如何编码的。所有的 Tracer 实现,都必须支持下面的 format:
- Text Map: 基于字符串:字符串的 map,对于 key 和 value,不约束字符集。
- HTTP Headers: 适合作为 HTTP 头信息的,基于字符串:字符串的map。(RFC 7230.在工程实践中,如何处理 HTTP 头具有多样性,强烈建议 trace r的使用者谨慎使用 HTTP 头的键值空间和转义符)
- Binary: 一个简单的二进制大对象,记录
SpanContext
的信息。
Span
获取 Span
的 SpanContext
不需要任何参数。
返回值,Span
构建时传入的 SpanContext
。这个返回值在 Span
结束后(span.finish()
),依然可以使用。
复写操作名(operation name)
必填参数
- 新的操作名 operation name,覆盖构建
Span
时,传入的操作名。
结束Span
可选参数
- 一个明确的完成时间;如果省略此参数,使用当前时间作为完成时间。当
Span
结束后(span.finish()
),除了通过Span
获取SpanContext
外,他所有方法都不允许被调用。
为Span
设置tag
必填参数
- tag key,必须是 string 类型
- tag value,类型为字符串,布尔或者数字
Log结构化数据
必填参数
- 一个或者多个键值对,其中键必须是字符串类型,值可以是任意类型。某些 OpenTracing 实现,可能支持更多的log值类型。
可选参数
- 一个明确的时间戳。如果指定时间戳,那么它必须在 span 的开始和结束时间之内。
设置一个baggage(随行数据)元素
Baggage 元素是一个键值对集合,将这些值设置给给定的 Span
,Span
的 SpanContext
,以及所有和此 Span
有直接或者间接关系的本地 Span
。 也就是说,baggage 元素随 trace 一起保持在带内传递。
带内传递,在这里指随应用程序调用过程一起传递
Baggage 元素具有强大的功能,使得 OpenTracing 能够实现全栈集成(例如:任意的应用程序数据,可以在移动端创建它,显然的,它会一直传递了系统最底层的存储系统),同时他也会产生巨大的开销,每一个键值都会被拷贝到每一个本地和远程的下级相关的 span 中,因此,总体上,他会有明显的网络和CPU开销。
必填参数
- baggage key, 字符串类型
- baggage value, 字符串类型
获取一个 baggage 元素
必填参数
- baggage key, 字符串类型
返回值,相应的 baggage value ,或者可以标识元素值不存在的返回值。
SpanContext
相对于 OpenTracing 中其他的功能,SpanContext
更多的是一个“概念”。也就是说,OpenTracing 实现中,需要重点考虑,并提供一套自己的 API。 OpenTracing 的使用者仅仅需要,在创建s pan、向传输协议 Inject(注入)和从传输协议中 Extract(提取)时,使用SpanContext
和 Reference
。
OpenTracing 要求,SpanContext
是不可变的,目的是防止由于 Span
的结束和相互关系,造成的复杂生命周期问题。
遍历所有的 baggage 元素
遍历模型依赖于语言,实现方式可能不一致。在语义上,要求调用者可以通过给定的 SpanContext
实例,高效的遍历所有的 baggage 元素
NoopTracer
所有的 OpenTracing API 实现,必须提供某种方式的 NoopTracer
实现。NoopTracer
可以被用作控制或者测试时,进行无害的 inject 注入(等等)。例如,在 OpenTracing-Java 实现中,NoopTracer
在他自己的模块中。
可选 API 元素
有些语言的 OpenTracing 实现,为了在串行处理中,传递活跃的 Span
或 SpanContext
,提供了一些工具类。例如,opentracing-go
中,通过 context.Context
机制,可以设置和获取活跃的 Span
。
OpenTracing 入门实践
这里以官方一个简单 demo 记录如何 OpenTracing:
server.go
在 server.go 当中,首先定义了几个调用点 handler,这些调用点共同组成了一个 server。
接着,为了监控这个程序,在入口处(HomeHandler)中设置了一个 span,这个 span 记录了 HomeHandler 方法完成所需要的时间,同时通过判断 homeHandler 方法是否正确返回,决定是否通过 tags 和 logs 记录方法调用的错误信息。
另一方面,为了构建真正的端到端追踪,还需要包含调用HTTP请求的客户端的span信息。所以需要在端到端过程中传递 span 的上下文信息,使得各端中的 span 可以合并到一个追踪过程中。这就是API中 Inject/Extract 的职责。homeHandler方法在第一次被调用时,创建一个根span,将关于本地追踪调用的 span 的元信息,设置到 http 的头上,并传递出去。
在 ServiceHandler 中,通过 http头获取到前面注入的元数据,并根据获取情况,还可以指定 span 之间的关系。
具体的代码实例如下:
package server
import (
"fmt"
"github.com/opentracing/opentracing-go"
"log"
"math/rand"
"net/http"
"time"
)
func IndexHandler(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`<a href = "/home"> Click here to start a request </a>`))
}
// HomeHandler "/home" 路径下
func HomeHandler(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("Request started\n"))
// 在入口处设置一个 span
span := opentracing.StartSpan("GET /home")
defer span.Finish()
// 创建请求
asyncReq, _ := http.NewRequest("GET", "http://localhost:8888/async", nil)
// 将关于本地追踪调用的 span 的元信息,设置到 http 的头上,并准备传递出去
err := span.Tracer().Inject(span.Context(),
opentracing.TextMap,
opentracing.HTTPHeadersCarrier(asyncReq.Header))
if err != nil {
log.Fatalf("%s: Could not inject span context into async request header: %v", r.URL.Path, err)
}
// 为 span 设置 tags 和 logs
go