分布式跟踪总结

现在越来越多的应用迁移到基于微服务的云原生的架构之上,微服务架构很强大,但是同时也带来了很多的挑战,尤其是如何对应用进行调试,如何监控多个服务间的调用关系和状态。如何有效的对微服务架构进行有效的监控成为微服务架构运维成功的关键。用软件架构的语言来说就是要增强微服务架构的可观测性(Observability)。通过分布式追踪,追踪服务请求是如何在各个分布的组件中进行处理的细节。分布式追踪正在被越来越多的应用所采用。分布式追踪可以通过对微服务调用链的跟踪,构建一个从服务请求开始到各个微服务交互的全部调用过程的视图。用户可以从中了解到诸如应用调用的时延,网络调用(HTTP,RPC)的生命周期,系统的性能瓶颈等等信息。

分布式追踪的概念

谷歌在2010年4月发表了一篇论文《Dapper, a Large-Scale Distributed Systems Tracing Infrastructure》,介绍了分布式追踪的概念。对于分布式追踪,主要有以下的几个概念:
追踪 Trace:就是由分布的微服务协作所支撑的一个事务。一个追踪,包含为该事务提供服务的各个服务请求。
跨度 Span:Span是事务中的一个工作流,一个Span包含了时间戳,日志和标签信息。Span之间包含父子关系,或者主从(Followup)关系。
跨度上下文 Span Context:跨度上下文是支撑分布式追踪的关键,它可以在调用的服务之间传递,上下文的内容包括诸如:从一个服务传递到另一个服务的时间,追踪的ID,Span的ID还有其它需要从上游服务传递到下游服务的信息。

OpenTracing 标准概念

基于谷歌提出的概念OpenTracing定义了一个开放的分布式追踪的标准。
Opentracing是分布式链路追踪的一种规范标准,是CNCF(云原生计算基金会)下的项目之一。和一般的规范标准不同,Opentracing不是传输协议,消息格式层面上的规范标准,而是一种语言层面上的API标准。以Go语言为例,只要某链路追踪系统实现了Opentracing规定的接口(interface),符合Opentracing定义的表现行为,那么就可以说该应用符合Opentracing标准。
OpenTracing是一个薄的标准化层,位于应用程序/库代码与使用跟踪和因果关系数据的各种系统之间。如下图:

opentracing

Trace
Trace表示一次完整的追踪链路,trace由一个或多个span组成。下图示例表示了一个由8个span组成的trace:

trace示例

时间轴的展现方式会更容易理解:

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

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

Span
Span是分布式追踪的基本组成单元,表示一个分布式系统中的单独的工作单元。每一个Span可以包含其它Span的引用。多个Span在一起构成了Trace。
OpenTracing的规范定义每一个Span都包含了以下内容:

  • 操作名(Operation Name),标志该操作是什么
  • 标签 (Tag),标签是一个名值对,用户可以加入任何对追踪有意义的信息
  • 日志(Logs),日志也定义为名值对。用于捕获调试信息,或者相关Span的相关信息
  • 跨度上下文 (SpanContext),SpanContext负责子微服务系统边界传递数据。它主要包含两部分:1)和实现无关的状态信息,例如Trace ID,Span ID;2)行李项 (Baggage Item),跨流程边界的键值对
  • References, 该span对一个或多个span的引用(通过引用SpanContext)

Tags
Tags以K/V键值对的形式保存用户自定义标签,主要用于链路追踪结果的查询过滤。例如: http.method="GET",http.status_code=200。其中key值必须为字符串,value必须是字符串,布尔型或者数值型。
span中的tag仅自己可见,不会随着 SpanContext传递给后续span。
例如:

span.SetTag("http.method","GET")
span.SetTag("http.status_code",200)

Logs
Logs与tags类似,也是K/V键值对形式。与tags不同的是,logs还会记录写入logs的时间,因此logs主要用于记录某些事件发生的时间。logs的key值同样必须为字符串,但对value类型则没有限制。例如:

span.LogFields(
    log.String("event", "soft error"),
    log.String("type", "cache timeout"),
    log.Int("waited.millis", 1500),
)

SpanContext
SpanContext携带着一些用于跨服务通信的(跨进程)数据,主要包含:
足够在系统中标识该span的信息,比如:span_id,trace_id。
Baggage Items,为整条追踪连保存跨服务(跨进程)的K/V格式的用户自定义数据。

Baggage Items
Baggage Items与tags类似,也是K/V键值对。与tags不同的是:其key跟value都只能是字符串格式
Baggage items不仅当前span可见,其会随着SpanContext传递给后续所有的子span。要小心谨慎的使用baggage items——因为在所有的span中传递这些K,V会带来不小的网络和CPU开销。

References
Opentracing定义了两种引用关系:ChildOf和FollowFrom。
ChildOf: 父span的执行依赖子span的执行结果时,此时子span对父span的引用关系是ChildOf。比如对于一次RPC调用,服务端的span(子span)与客户端调用的span(父span)是ChildOf关系。
FollowFrom:父span的执不依赖子span执行结果时,此时子span对父span的引用关系是FollowFrom。FollowFrom常用于异步调用的表示,例如消息队列中consumerspan与producerspan之间的关系。

实现原理

要实现分布式追踪,如何传递SpanContext是关键。OpenTracing定义了两个方法Inject和Extract用于SpanContext的注入和提取。


使用注入/提取来传播跟踪

抽取过程是注入的逆过程,从carrier,也就是HTTP Headers,构建SpanContext。
整个过程类似客户端和服务器传递数据的序列化和反序列化的过程。这里的Carrier字典支持Key为string类型,value为string或者Binary格式(Bytes)。
OpenTracing是一组规范,大部分编程语言都有对应的代码实现。opentracing-go是OpenTracing的Go平台API。
trace定义

// Tracer是一个简单的接口,用于Span创建和SpanContext传播。
type Tracer interface {
    // 创建,启动并返回具有给定`operationName`的新Span,合并给定的StartSpanOption`opts'。
    StartSpan(operationName string, opts ...StartSpanOption) Span

    // Inject()接受`sm` SpanContext实例并往其注入在“载体”内传播。载体的实际类型取决于 format的值。
    Inject(sm SpanContext, format interface{}, carrier interface{}) error

    //  Extract()返回给定了`format`和`carrier'的SpanContext实例。
    Extract(format interface{}, carrier interface{}) (SpanContext, error)
}

span定义

// Span表示OpenTracing系统中未完成的活动跨度。Span由Tracer接口创建。
type Span interface {
    // 设置结束时间戳记,并确定Span状态。
    Finish()
    // FinishWithOptions类似于Finish函数,但具有显式控制时间戳和日志数据
    FinishWithOptions(opts FinishOptions)

    // Context()产生此Span的SpanContext。
    Context() SpanContext

    // 设置或更改操作名称。返回对此Span的引用以进行链接。
    SetOperationName(operationName string) Span

    // 向span添加标签。
    SetTag(key string, value interface{}) Span

    // LogFields是一种有效且经过类型检查的方式来记录键值对,比LogKV()更详细
    LogFields(fields ...log.Field)

    // LogKV 是一种简洁,易读的方式来记录键值对
    LogKV(alternatingKeyValues ...interface{})

    // SetBaggageItem在此Span及其SpanContext上设置键值对,也会传播到该Span的后代。
    SetBaggageItem(restrictedKey, value string) Span

    // 在给定其键的情况下获取BaggageItem的值。
    BaggageItem(restrictedKey string) string

    // 提供创建这个span的trace链接.
    Tracer() Tracer
}

分布式调用追踪(APM)一览

  1. google的Drapper--未开源,最早的APM
  2. 阿里-鹰眼--未开源
  3. 大众点评——CAT--跨服务的跟踪功能与点评内部的RPC框架集成,这部分未开源且项目在2014.1已经停止维护。服务粒度的监控,通过代码埋点的方式来实现监控,比如: 拦截器,注解,过滤器等,对代码的侵入性较大,集成成本较高。
  4. Hydra-京东: 与dubbo框架集成,对于服务级别的跟踪统计,现有业务可以无缝接入。对于细粒度的兴趣点,需要业务人员手动添加.开源项目已于2013年6月停止维护
  5. PinPoint-naver,字节码探针技术,代码无侵入,体系完善不易修改,支持java,技术栈支持dubbo.其他语言社区支援中
  6. zipkin--java方便集成于springcloud,社区支持的插件也包括dubbo,rabbit,mysql,httpclient等(https://github.com/openzipkin/brave/tree/master/instrumentation),同时支持php,go,js等语言客户端,界面功能较为简单,本身无告警功能,可能需要二次开发。代码入侵度小。
  7. uber-jaeger, Jaeger支持java/c++/go/node/php,在界面上较为完善(对比zipkin),但是也无告警功能。代码入侵度小。dubbo目前无插件支持,可二次开发。
  8. skywalking -华为,类似于PinPoint,目前还在apache孵化中,网上吞吐量对比中强于pinpoint,实际未验证。本身支持dubbo

针对目前开源在维护的几种方案进行对比

pinpoint zipkin jaeger skywalking
OpenTracing兼容
客户端支持语言 java、php java,c#,go,php等 java,c#,go,php等 Java, .NET Core, NodeJS and PHP
存储 hbase ES,mysql,Cassandra,内存 ES,kafka,Cassandra,内存 ES,H2,mysql,TIDB,sharding sphere
传输协议支持 thrift http,MQ udp/http gRPC
ui丰富程度
实现方式-代码侵入性 字节码注入,无侵入 拦截请求,侵入 拦截请求,侵入 字节码注入,无侵入
扩展性
trace查询 不支持 支持 支持 支持
告警支持 支持 不支持 不支持 支持
jvm监控 支持 不支持 不支持 支持
性能损失

Jaeger

Jaeger最早是由Uber开发的分布式追踪系统,同样基于Dapper的设计理念。现在Jaeger是CNCF(Cloud Native Computing Foundation)的一个项目。如果你对CNCF这个组织有所了解,那么你可以推测出这个项目应该和Kubernetes有非常紧密的集成。

jeager

按照数据流向,整体可以分为四个部分:

  • jaeger-client:Jaeger 的客户端,实现了 OpenTracing 的 API,支持主流编程语言。客户端直接集成在目标 Application 中,其作用是记录和发送 Span 到Jaeger Agent。在 Application 中调用 Jaeger Client Library 记录 Span 的过程通常被称为埋点。
  • jaeger-agent:暂存 Jaeger Client 发来的 Span,并批量向 Jaeger Collector 发送 Span,一般每台机器上都会部署一个 Jaeger Agent。官方的介绍中还强调了 Jaeger Agent 可以将服务发现的功能从 Client 中抽离出来,不过从架构角度讲,如果是部署在 Kubernetes 或者是 Nomad 中,Jaeger Agent 存在的意义并不大。
  • jaeger-collector:接受 Jaeger Agent 发来的数据,并将其写入存储后端,目前支持采用 Cassandra 和 Elasticsearch 作为存储后端。个人还是比较推荐用 Elasticsearch,既可以和日志服务共用同一个 ES,又可以使用 Kibana 对 Trace 数据进行额外的分析。架构图中的存储后端是 Cassandra,旁边还有一个 Spark,讲的就是可以用 Spark 等其他工具对存储后端中的 Span 进行直接分析。
  • jaeger-query & jaeger-ui:读取存储后端中的数据,以直观的形式呈现。

这个架构很像ELK,Collector之前类似Logstash负责采集数据,Query类似Elastic负责搜索,而UI类似Kibana负责用户界面和交互。这样的分布式架构使得Jaeger的扩展性更好,可以根据需要,构建不同的部署。

Jaeger作为分布式追踪的后起之秀,随着云原生和K8s的广泛采用,正变得越来越流行。利用官方给出的K8s部署模版,用户可以快速的在自己的k8s集群上部署Jaeger。

应用调用如何接入分布式跟踪

网络调用主要分为两类调用:HTTP和RPC。只需要在每一次网络调用的前后将调用包装成一个span,然后通过traceId串接起来,就能实现分布式跟踪。
HTTP Middleware
对于每个 HTTP 请求,可以在 HTTP Server 中增加 Middleware,为每个请求都记录一个 Span,并且在生成 Trace ID 后,将其作为 Request ID 使用。
web框架都会有“拦截器”一样的中间件能力。代码如下:

package middleware

import (
    "context"
    "net/http"

    "github.com/opentracing/opentracing-go"
    "github.com/opentracing/opentracing-go/ext"
    "github.com/jaegertracing/jaeger-client-go"
    "github.com/pengsrc/go-shared/buffer"
    
    "example/constants"
)

// TraceSpan is a middleware that initialize a tracing span and injects span
// context to r.Context(). In one word, this middleware kept an eye on the
// whole HTTP request that the server receives.
func TraceSpan(next http.Handler) http.Handler {
    fn := func(w http.ResponseWriter, r *http.Request) {
        tracer := opentracing.GlobalTracer()
        if tracer == nil {
            // Tracer not found, just skip.
            next.ServeHTTP(w, r)
        }

        buf := buffer.GlobalBytesPool().Get()
        buf.AppendString("HTTP ")
        buf.AppendString(r.Method)

        // Start span.
        span := opentracing.StartSpan(buf.String())
        rc := opentracing.ContextWithSpan(r.Context(), span)

        // Set request ID for context.
        if sc, ok := span.Context().(jaeger.SpanContext); ok {
            rc = context.WithValue(rc, constants.RequestID, sc.TraceID().String())
        }

        next.ServeHTTP(w, r.WithContext(rc))

        // Finish span.
        wrapper, ok := w.(WrapResponseWriter)
        if ok {
            ext.HTTPStatusCode.Set(span, uint16(wrapper.Status()))
        }
        span.Finish()
    }
    return http.HandlerFunc(fn)
}

RPC Middleware
下面以公司的kite调用为例,对于每个RPC 请求,也可以增加一个 OpenTracingMW,为每个请求都记录一个 Span。代码如下:
客户端调用

// OpenTracingMW creates a middleware for tracing functionality.
func OpenTracingMW(next endpoint.EndPoint) endpoint.EndPoint {
    return func(ctx context.Context, request interface{}) (interface{}, error) {
        if _, ok := opentracing.GlobalTracer().(opentracing.NoopTracer); ok {
            return next(ctx, request)
        }
        r := GetRPCInfo(ctx)
        normOperation := trace.FormatOperationName(r.From, r.FromMethod)
        span, ctx := opentracing.StartSpanFromContext(ctx, normOperation)

        // pass trace context out of process using Base.Extra
        injectTraceIntoExtra(request, ctx)
        fillSpanDataBeforeCall(ctx, r)
        begin := time.Now()
        resp, err := next(ctx, request)
        finishOptions := fillSpanDataAfterCall(ctx, r, resp, err)

        fillPostSpanAfterCall(ctx, r, resp, err, begin)
        // finishing span
        span.FinishWithOptions(finishOptions)
        return resp, err
    }
}

服务端调用

// OpentracingMW
func OpenTracingMW(next endpoint.EndPoint) endpoint.EndPoint {
    return func(ctx context.Context, request interface{}) (interface{}, error) {
        rpcInfo := GetRPCInfo(ctx)
        normOperation := trace.FormatOperationName(rpcInfo.Service, rpcInfo.Method)
        if r, ok := request.(endpoint.KiteRequest); ok && r.IsSetBase() {
            b := r.GetBase()
            if extra, ok := b.(extraBase); ok {
                var span opentracing.Span
                if spanCtx, err := kitc.SpanCtxFromTextMap(extra.GetExtra()); err == nil {
                    // logs.Info("span-ctx: %s from client-side", trace.JSpanContextToString(spanCtx))
                    span = opentracing.StartSpan(normOperation, opentracing.ChildOf(spanCtx))
                } else {
                    span = opentracing.StartSpan(normOperation)
                }
                // finishing span. opentracing.StartSpan should not return nil object
                defer span.Finish()
                ctx = opentracing.ContextWithSpan(ctx, span)
                if EnableDyeLog && trace.IsDebug(span) {
                    ctx = context.WithValue(ctx, logs.DynamicLogLevelKey, DyeLogLevel)
                }
                rpcInfo.TraceTag = trace.JSpanContextToString(span.Context())
            }
        }

        fillSpanDataBeforeHandler(ctx, rpcInfo)
        ctx = posttrace.ContextWithPostTraceRecorder(ctx, normOperation, false)
        resp, err := next(ctx, request)
        fillSpanDataAfterHandler(ctx, rpcInfo, resp, err)
        reportPostSpansAfterHandler(ctx, rpcInfo, resp, err)
        return resp, err
    }
}

参考

https://pjw.io/articles/2018/05/08/opentracing-explanations/