一、链路追踪
微服务架构是将单个应用程序被划分成各种小而连接的服务,每一个服务完成一个单一的业务功能,相互之间保持独立和解耦,每个服务都可以独立演进。相对于传统的单体服务,微服务具有隔离性、技术异构性、可扩展性以及简化部署等优点。
同样的,微服务架构在带来诸多益处的同时,也为系统增加了不少复杂性。它作为一种分布式服务,通常部署于由不同的数据中心、不同的服务器组成的集群上。而且,同一个微服务系统可能是由不同的团队、不同的语言开发而成。通常一个应用由多个微服务组成,微服务之间的数据交互需要通过远过程调用的方式完成,所以在一个由众多微服务构成的系统中,请求需要在各服务之间流转,调用链路错综复杂,一旦出现问题,是很难进行问题定位和追查异常的。
链路追踪系统就是为解决上述问题而产生的,它用来追踪每一个请求的完整调用链路,记录从请求开始到请求结束期间调用的任务名称、耗时、标签数据以及日志信息,并通过可视化的界面进行分析和展示,来帮助技术人员准确地定位异常服务、发现性能瓶颈、梳理调用链路以及预估系统容量。
链路追踪系统的理论模型几乎都借鉴了 Google 的一篇论文”Dapper, a Large-Scale Distributed Systems Tracing Infrastructure”,典型产品有Uber jaeger、Twitter zipkin、淘宝鹰眼等。这些产品的实现方式虽然不尽相同,但核心步骤一般都有三个:数据采集、数据存储和查询展示。
链路追踪系统第一步,也是最基本的工作就是数据采集。在这个过程中,链路追踪系统需要侵入用户代码进行埋点,用于收集追踪数据。但是由于不同的链路追踪系统的API互不兼容,所以埋点代码写法各异,导致用户在切换不同链路追踪产品时需要做很大的改动。为了解决这类问题,于是诞生了OpenTracing规范,旨在统一链路追踪系统的API。
二、OpenTracing规范
OpenTracing 是一套分布式追踪协议,与平台和语言无关,具有统一的接口规范,方便接入不同的分布式追踪系统。
OpenTracing语义规范详见:https://github.com/opentracing/specification/blob/master/specification.md
2.1 数据模型(Data Model)
OpenTracing语义规范中定义的数据模型有 Trace、Sapn以及Reference。
2.1.1 Trace
Trace表示一条完整的追踪链路,例如:一个事务或者一个流程的执行过程。一个 Trace 是由一个或者多个 Span 组成的有向无环图(DAG)。
下图表示一个由8个Span组成的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)
按照时间轴方式更为直观地展现该Trace:
––|–––––––|–––––––|–––––––|–––––––|–––––––|–––––––|–––––––|–> time
[Span A···················································]
[Span B··············································]
[Span D··········································]
[Span C········································]
[Span E·······] [Span F··] [Span G··] [Span H··]
2.1.2 Span
Span表示一个独立的工作单元,它是一条追踪链路的基本组成要素。例如:一次RPC调用、一次函数调用或者一次Http请求。
每个Span封装了如下状态:
-
操作名称
用于表示该Span的任务名称。例如:一个 RPC方法名, 一个函数名,或者大型任务中的子任务名称。
-
开始时间戳
任务开始时间。
-
结束时间戳。
任务结束时间。通过Span的结束时间戳和开始时间戳,就能够计算出该Span的整体耗时。
-
一组Span标签
每一个Span标签是一个键值对。键必须是字符串,值可以是字符串、布尔或数值类型。常见标签键可参考:https://github.com/opentracing/specification/blob/master/semantic_conventions.md
-
一组Span日志
每一条Span日志由一个键值对和一个相应的时间戳组成。键必须是字符串,值可以是任何类型。常见日志键参考:https://github.com/opentracing/specification/blob/master/semantic_conventions.md
2.1.3 Reference
一个Span可以与一个或者多个Span存在因果关系,这种关系称为Reference。OpenTracing目前定义了两种关系:ChildOf(父子)关系 和 FollowsFrom(跟随)关系。
-
ChildOf关系
父Span的执行依赖子Span的执行结果,此时子Span对父Span的Reference关系是ChildOf。比如对于一次RPC调用,服务端的Span(子Span)与客户端调用的Span(父Span)就是ChildOf关系。
-
FollowsFrom关系
父Span的执行不依赖子Span的执行结果,此时子Span对父Span的Reference关系是FollowFrom。FollowFrom常用于表示异步调用,例如消息队列中Consumer Span与Producer Span之间的关系。
2.2 应用接口(API)
2.2.1 Tracer
Tracer接口用于创建Span、跨进程注入数据和提取数据。通常具有以下功能:
-
Start a new span
创建并启动一个新的Span。 -
Inject
将SpanContext注入载体(Carrier)。 -
Extract
从载体(Carrier)中提取SpanContext。
2.2.2 Span
-
Retrieve a SpanContext
返回Span对应的SpanContext。 -
Overwrite the operation name
更新操作名称。 -
Set a span tag
设置Span标签数据。 -
Log structured data
记录结构化数据。 -
Set a baggage item
baggage item是字符串型的键值对,它对应于某个 Span,随Trace一起传播。由于每个键值都会被拷贝到每一个本地及远程的子Span,这可能导致巨大的网络和CPU开销。 -
Get a baggage item
获取baggage item的值。 -
Finish
结束一个Span。
2.2.3 Span Context
用于携带跨越服务边界的数据,包括trace ID、Span ID以及需要传播到下游Span的baggage数据。在OpenTracing中,强制要求SpanContext实例不可变,以避免在Span完成和引用时出现复杂的生命周期问题。
2.2.4 NoopTracer
所有对OpenTracing API的实现,必须提供某种形式的NoopTracer,用于标记控制OpenTracing或注入对测试无害的东西。
三、Jaeger
Jaeger是Uber开源的分布式追踪系统,它的应用接口完全遵循OpenTracing规范。jaeger本身采用go语言编写,具有跨平台跨语言的特性,提供了各种语言的客户端调用接口,例如c++、java、go、python、ruby、php、nodejs等。项目地址:https://github.com/jaegertracing
3.1 Jaeger组件
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-miLIEWHv-1604561903414)(https://i.loli.net/2020/04/13/bvTxdUkBRuawY1F.png)]
-
jaeger-client
jaeger的客户端代码库,它实现了OpenTracing协议。当我们的应用程序将其装配后,负责收集数据,并发送到jaeger-agent。这是我们唯一需要编写代码的地方。
-
jaeger-agent
负责接收从jaeger-client发来的Trace/Span信息,并批量上传到jaeger-collector。
-
jaeger-collector
负责接收从jaeger-agent发来的Trace/Span信息,并经过校验、索引等处理,然后写入到后端存储。
-
data store
负责数据存储。Jaeger的数据存储是一个可插拔的组件,目前支持Cassandra、ElasticSearch和Kafka。
-
jaeger-query & ui
负责数据查询,并通过前端界面展示查询结果。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ogkrm3Hb-1604561903417)(https://i.loli.net/2020/04/13/UMoHYtlX1ydsx5Q.jpg)]
3.2 快速入门
Jaeger官方提供了all-in-one镜像,方便快速进行测试:
#?拉取镜像
$docker?pull?jaegertracing/all-in-one:latest
#?运行镜像
$docker?run?-d?--name?jaeger?\
??-e?COLLECTOR_ZIPKIN_HTTP_PORT=9411?\
??-p?5775:5775/udp?\
??-p?6831:6831/udp?\
??-p?6832:6832/udp?\
??-p?5778:5778?\
??-p?14268:14268?\
??-p?9411:9411?\
??-p?16686:16686?\
??jaegertracing/all-in-one:latest
通过all-in-one镜像启动,我们发现Jaeger占据了很多端口。以下是端口使用说明:
端口 | 协议 | 所属模块 | 功能 |
---|---|---|---|
5775 | UDP | agent | 接收压缩格式的Zipkin thrift数据 |
6831 | UDP | agent | 接收压缩格式的Jaeger thrift数据 |
6832 | UDP | agent | 接收二进制格式的Jaeger thrift数据 |
5778 | HTTP | agent | 服务配置、采样策略端口 |
14268 | HTTP | collector | 接收由客户端直接发送的Jaeger thrift数据 |
9411 | HTTP | collector | 接收Zipkin发送的json或者thrift数据 |
16686 | HTTP | query | 浏览器展示端口 |
启动后,我们可以访问 http://localhost:16686 ,在浏览器中查看和查询收集的数据。
由于通过all-in-one镜像方式收集的数据都存储在docker中,无法持久保存,所以只能用于开发或者测试环境,无法用于生产环境。生产环境中需要依据实际情况,分别部署各个组件。
四、Jaeger在业务代码中的应用
系统中使用Jaeger非常简单,只需要在原有程序中插入少量代码。以下代码模拟了一个查询用户账户余额,执行扣款的业务场景:
4.1 初始化jaeger函数
主要是按照实际需要配置有关参数,例如服务名称、采样模式、采样比例等等。
func?initJaeger()?(tracer?opentracing.Tracer,?closer?io.Closer,?err?error)?{
?//?构造配置信息
?cfg?:=?&config.Configuration{
??//?设置服务名称
??ServiceName:?"ServiceAmount",
??//?设置采样参数
??Sampler:?&config.SamplerConfig{
???Type:??"const",?//?全采样模式
???Param:?1,???????//?开启状态
??},
?}
?
?//?生成一条新tracer
?tracer,?closer,?err?=?cfg.NewTracer()
?if?err?==?nil?{
??//?设置tracer为全局单例对象
??opentracing.SetGlobalTracer(tracer)
?}
?return
}
4.2 检测用户余额函数
用于检测用户余额,模拟一个子任务Span。
func?CheckBalance(request?string,?ctx?context.Context)?{
?//?创建子span
?span,?_?:=?opentracing.StartSpanFromContext(ctx,?"CheckBalance")
?//?模拟系统进行一系列的操作,耗时1/3秒
?time.Sleep(time.Second?/?3)
?//?示例:将需要追踪的信息放入tag
?span.SetTag("request",?request)
?span.SetTag("reply",?"CheckBalance?reply")
?//?结束当前span
?span.Finish()
?log.Println("CheckBalance?is?done")
}
4.3 从用户账户扣款函数
从用户账户扣款,模拟一个子任务span。
func?Reduction(request?string,?ctx?context.Context)?{
?//?创建子span
?span,?_?:=?opentracing.StartSpanFromContext(ctx,?"Reduction")
?//?模拟系统进行一系列的操作,耗时1/2秒
?time.Sleep(time.Second?/?2)
?//?示例:将需要追踪的信息放入tag
?span.SetTag("request",?request)
?span.SetTag("reply",?"Reduction?reply")
?//?结束当前span
?span.Finish()
?log.Println("Reduction?is?done")
}
4.4 主函数
初始化jaeger环境,生成tracer,创建父span,以及调用查询余额和扣款两个子任务span。
package?main
import?(
?"context"
?"fmt"
?"github.com/opentracing/opentracing-go"
?"github.com/uber/jaeger-client-go/config"
?"io"
?"log"
?"time"
)
func?main()?{
?//?初始化jaeger,创建一条新tracer
?tracer,?closer,?err?:=?initJaeger()
?if?err?!=?nil?{
??panic(fmt.Sprintf("ERROR:?cannot?init?Jaeger:?%v\n",?err))
?}
?defer?closer.Close()
?//?创建一个新span,作为父span,开始计费过程
?span :=?tracer.StartSpan("CalculateFee")
?
?//?生成父span的context
?ctx?:=?opentracing.ContextWithSpan(context.Background(),?span)
?//?示例:设置一个span标签信息
?span.SetTag("db.instance",?"customers")
?//?示例:输出一条span日志信息
?span.LogKV("event",?"timed?out")
?//?将父span的context作为参数,调用检测用户余额函数
?CheckBalance("CheckBalance?request",?ctx)
?//?将父span的context作为参数,调用扣款函数
?Reduction("Reduction?request",?ctx)
?//?结束父span
?span.Finish()
}
五、Jaeger在gRPC微服务中的应用
我们依然模拟了一个查询用户账户余额,执行扣款的业务场景,并把查询用户账户余额和执行扣款功能改造为gRPC微服务:
5.1 gRPC Server端代码
main.go:
代码使用了第三方依赖库github.com/grpc-ecosystem/go-grpc-middleware/tracing/opentracing,该依赖库将OpenTracing封装为通用的gRPC中间件,并通过gRPC拦截器无缝嵌入gRPC服务中。
package?main
import?(
?"fmt"
?"github.com/grpc-ecosystem/go-grpc-middleware/tracing/opentracing"
?"github.com/opentracing/opentracing-go"
?"github.com/uber/jaeger-client-go/config"
?"google.golang.org/grpc"
?"google.golang.org/grpc/reflection"
?"grpc-jaeger-server/account"
?"io"
?"log"
?"net"
)
//?初始化jaeger
func?initJaeger()?(tracer?opentracing.Tracer,?closer?io.Closer,?err?error)?{
?//?构造配置信息
?cfg?:=?&config.Configuration{
??//?设置服务名称
??ServiceName:?"ServiceAmount",
??//?设置采样参数
??Sampler:?&config.SamplerConfig{
???Type:??"const",?//?全采样模式
???Param:?1,???????//?开启全采样模式
??},
?}
?//?生成一条新tracer
?tracer,?closer,?err?=?cfg.NewTracer()
?if?err?==?nil?{
??//?设置tracer为全局单例对象
??opentracing.SetGlobalTracer(tracer)
?}
?return
}
func?main()?{
?//?初始化jaeger,创建一条新tracer
?tracer,?closer,?err?:=?initJaeger()
?if?err?!=?nil?{
??panic(fmt.Sprintf("ERROR:?cannot?init?Jaeger:?%v\n",?err))
?}
?defer?closer.Close()
?log.Println("succeed?to?init?jaeger")
?//?注册gRPC?account服务
?server?:=?grpc.NewServer(grpc.UnaryInterceptor(grpc_opentracing.UnaryServerInterceptor(grpc_opentracing.WithTracer(tracer))))
?account.RegisterAccountServer(server,?&AccountServer{})
?reflection.Register(server)
?log.Println("succeed?to?register?account?service")
?//?监听gRPC?account服务端口
?listener,?err?:=?net.Listen("tcp",?":8080")
?if?err?!=?nil?{
??log.Println(err)
??return
?}
?log.Println("starting?register?account?service")
?//?开启gRpc?account服务
?if?err?:=?server.Serve(listener);?err?!=?nil?{
??log.Println(err)
??return
?}
}
计费微服务 accountsever.go:
package?main
import?(
?"github.com/opentracing/opentracing-go"
?"golang.org/x/net/context"
?"grpc-jaeger-server/account"
?"time"
)
//?计费服务
type?AccountServer?struct{}
//?检测用户余额微服务,模拟子span任务
func?(s?*AccountServer)?CheckBalance(ctx?context.Context,?request?*account.CheckBalanceRequest)?(response?*account.CheckBalanceResponse,?err?error)?{
?response?=?&account.CheckBalanceResponse{
??Reply:?"CheckBalance?Reply",?//?处理结果
?}
?//?创建子span
?span,?_?:=?opentracing.StartSpanFromContext(ctx,?"CheckBalance")
?//?模拟系统进行一系列的操作,耗时1/3秒
?time.Sleep(time.Second?/?3)
?//?将需要追踪的信息放入tag
?span.SetTag("request",?request)
?span.SetTag("reply",?response)
?//?结束当前span
?span.Finish()
?return?response,?err
}
//?从用户账户扣款微服务,模拟子span任务
func?(s?*AccountServer)?Reduction(ctx?context.Context,?request?*account.ReductionRequest)?(response?*account.ReductionResponse,?err?error)?{
?response?=?&account.ReductionResponse{
??Reply:?"Reduction?Reply",?//?处理结果
?}
?//?创建子span
?span,?_?:=?opentracing.StartSpanFromContext(ctx,?"Reduction")
?//?模拟系统进行一系列的操作,耗时1/3秒
?time.Sleep(time.Second?/?3)
?//?将需要追踪的信息放入tag
?span.SetTag("request",?request)
?span.SetTag("reply",?response)
?//?结束当前span
?span.Finish()
?return?response,?err
}
5.2 gRPC Client端代码main.go:
package?main
import?(
?"context"
?"fmt"
?"github.com/grpc-ecosystem/go-grpc-middleware/tracing/opentracing"
?"github.com/opentracing/opentracing-go"
?"github.com/uber/jaeger-client-go/config"
?"google.golang.org/grpc"
?"grpc-jaeger-client/account"
?"io"
?"log"
)
//?初始化jaeger
func?initJaeger()?(tracer?opentracing.Tracer,?closer?io.Closer,?err?error)?{
?//?构造配置信息
?cfg?:=?&config.Configuration{
??//?设置服务名称
??ServiceName:?"ServiceAmount",
??//?设置采样参数
??Sampler:?&config.SamplerConfig{
???Type:??"const",?//?全采样模式
???Param:?1,???????//?开启全采样模式
??},
?}
?//?生成一条新tracer
?tracer,?closer,?err?=?cfg.NewTracer()
?if?err?==?nil?{
??//?设置tracer为全局单例对象
??opentracing.SetGlobalTracer(tracer)
?}
?return
}
func?main()?{
?//?初始化jaeger,创建一条新tracer
?tracer,?closer,?err?:=?initJaeger()
?if?err?!=?nil?{
??panic(fmt.Sprintf("ERROR:?cannot?init?Jaeger:?%v\n",?err))
?}
?defer?closer.Close()
?log.Println("succeed?to?init?jaeger")
?//?创建一个新span,作为父span
?span :=?tracer.StartSpan("CalculateFee")
?//?函数返回时关闭span
?defer?span.Finish()
?//?生成span的context
?ctx?:=?opentracing.ContextWithSpan(context.Background(),?span)
?//?连接gRPC?server
?conn,?err?:=?grpc.Dial("localhost:8080",
??grpc.WithInsecure(),
??grpc.WithUnaryInterceptor(grpc_opentracing.UnaryClientInterceptor(grpc_opentracing.WithTracer(tracer),
??)))
?if?err?!=?nil?{
??log.Println(err)
??return
?}
?//?创建gRPC计费服务客户端
?client?:=?account.NewAccountClient(conn)
?//?将父span的context作为参数,调用检测用户余额的gRPC微服务
?checkBalanceResponse,?err?:=?client.CheckBalance(ctx,
??&account.CheckBalanceRequest{
???Account:?"user?account",
??})
?if?err?!=?nil?{
??log.Println(err)
??return
?}
?log.Println(checkBalanceResponse)
?//?将父span的context作为参数,调用扣款的gRPC微服务
?reductionResponse,?err?:=?client.Reduction(ctx,
??&account.ReductionRequest{
???Account:?"user?account",
???Amount:?1,
??})
?if?err?!=?nil?{
??log.Println(err)
??return
?}
?log.Println(reductionResponse)
}
?