一、链路追踪

微服务架构是将单个应用程序被划分成各种小而连接的服务,每一个服务完成一个单一的业务功能,相互之间保持独立和解耦,每个服务都可以独立演进。相对于传统的单体服务,微服务具有隔离性、技术异构性、可扩展性以及简化部署等优点。

同样的,微服务架构在带来诸多益处的同时,也为系统增加了不少复杂性。它作为一种分布式服务,通常部署于由不同的数据中心、不同的服务器组成的集群上。而且,同一个微服务系统可能是由不同的团队、不同的语言开发而成。通常一个应用由多个微服务组成,微服务之间的数据交互需要通过远过程调用的方式完成,所以在一个由众多微服务构成的系统中,请求需要在各服务之间流转,调用链路错综复杂,一旦出现问题,是很难进行问题定位和追查异常的。

链路追踪系统就是为解决上述问题而产生的,它用来追踪每一个请求的完整调用链路,记录从请求开始到请求结束期间调用的任务名称、耗时、标签数据以及日志信息,并通过可视化的界面进行分析和展示,来帮助技术人员准确地定位异常服务、发现性能瓶颈、梳理调用链路以及预估系统容量。

链路追踪系统的理论模型几乎都借鉴了 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)
}