gRPC 微服务构建之链路追踪(OpenTracing)

在 gRPC 服务中应用 OpenTracing:使用 Zipkin 和 Jaeger 进行路追踪

Posted by pandaychen on June 1, 2020

0x00 前言

前一篇文章 微服务基础之 链路追踪(OpenTracing),介绍了 OpenTracing 的理论,本文基于 gRPC 与 Zipkin && Jaeger 来实现 Tracing 的应用。

0x01 回顾 OpenTracing 数据模型

一个 Tracer 包含了若干个 Span,Span 是追踪链路中的基本组成元素,一个 Span 表示一个独立的工作单元,在链路追踪中可以表示一个接口的调用,一个数据库操作的调用等等。

  • Span:调用链路的基本单元,使用 spanId 作为唯一标识;每个服务的每次调用都对应一个 Span,在其中记录服务名称、时间等基本信息
  • Trace:表示一个调用链路,由若干 Span 组成,使用 traceId 作为唯一标识,对应一次完整的服务请求

一个 Span 中包含如下内容:

  • 服务名称 (operation name,必选)
  • 服务开始时间(必选)
  • 服务的结束时间(必选)
  • Tags:K/V 形式
  • Logs:K/V 形式
  • SpanContext
  • Refrences:该 span 对一个或多个 span 的引用(通过引用 SpanContext)

详细说明下上面的字段:

1、Tags
每个 Span 可以有多个键值 K/V 对形式的 Tags,Tags 是没有时间戳的,支持简单的对 Span 进行注解和补充。Tags 是一个 K/V 类型的键值对,用户可以自定义该标签并保存。主要用于链路追踪结果对查询过滤。如某 Span 是调用 Redis ,而可以设置 redis 的标签,这样通过搜索 redis 关键字,可以查询出所有相关的 Span 以及 trace;又如 http.method="GET",http.status_code=200,其中 key 值必须为字符串,value 必须是字符串,布尔型或者数值型。Span 中的 Tag 仅自己可见,不会随着 SpanContext 传递给后续 Span。

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

2、Logs
Logs 也是一个 K/V 类型的键值对,与 Tags 不同的是,Logs 还会记录写入 Logs 的时间,因此 Logs 主要用于记录某些事件发生的时间。

span.LogFields(
	log.String("database","mysql"),
	log.Int("used_time":5),
	log.Int("start_ts":1596335100),
)

PS:Opentracing 给出了一些惯用的 Tags 和 Logs,链接

3、SpanContext(核心字段)
每个 Span 必须提供方法访问 SpanContext,SpanContext 代表跨越进程边界(在不同的 Span 中传递信息),传递到下级 Span 的状态。SpanContext 携带着一些用于跨服务通信的(跨进程)数据,主要包含:

  • 该 Span 的唯一标识信息,如:span_idtrace_idparent_idsampled
  • Baggage Items,为整条追踪连保存跨服务(跨进程)的 K/V 格式的用户自定义数据

4、Baggage Items
Baggage Items 与 Tags 类似,也是 K/V 键值对。与 tags 不同的是:Baggage Items 的 Key 和 Value 都只能是 string 格式,Baggage items 不仅当前 Span 可见,其会随着 SpanContext 传递给后续所有的子 Span。要小心谨慎的使用 Baggage Items:因为在所有的 Span 中传递这些 Key/Value 会带来不小的网络和 CPU 开销。Baggage 是存储在 SpanContext 中的一个键值对集合。它会在一条追踪链路上的所有 Span 内全局传输,包含这些 Span 对应的 SpanContexts

5、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 之间的关系。

6、Trace
Trace 表示一次完整的追踪链路,trace 由一个或多个 Span 组成。它表示从头到尾的一个请求的调用链,它的标识符是 traceID。 下图示例表示了一个由 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)

以时间轴的展现方式如下:

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

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

跟踪上下文

此外,跟踪上下文(Trace Context)也是很重要的场景,它定义了传播跟踪所需的所有信息,例如 traceID,parent-SpanId 等。OpenTracing 提供了两个处理跟踪上下文(Trace Context)的方法:

  • Inject(SpanContext,format,carrier):Inject 将跟踪上下文放入媒介,来保证跟踪链的连续性,常用于客户端
  • Extract(format.Carrier):一般从媒介(通常是 HTTP 头)获取跟踪上下文,常用于服务端

OpenTracing API 的路径

在 OpenTracing API 中,有三个主要对象:

  • Tracer
  • Span
  • SpanContext Tracer 可以创建 Spans 并了解如何跨流程边界对它们的元数据进行 Inject(序列化)和 Extract(反序列化),通常的有如下流程:
  • 开始一个新的 Span
  • Inject 一个 SpanContext 到一个载体
  • 从载体 Extract 一个 SpanContext
  • 由起点进程创建一个 Tracer,然后启动进程发起请求,每个动作产生一个 Span,如果有父子关系,Tracer 将它们关联
  • 当请求 / Span 完成后,Tracer 将跟踪信息推送到 Collector

inject-extract

0x02 ZipKin-Tracing 的一般流程

Zipkin 是一款开源的分布式实时数据追踪系统(Distributed Tracking System),由 Twitter 公司开发和贡献。其主要功能是聚合来自各个异构系统的实时监控数据。在链路追踪 Tracing Analysis 中,可以通过 Zipkin 上报 Golang 应用数据。

使用 Zipkin 上报数据的流程如下图所示: img

使用的 package:

下面介绍通过 Zipkin 将 Golang 应用数据上报至链路追踪控制台的方法: 1、创建 Tracer,Tracer 对象可以用来创建 Span 对象(记录分布式操作时间)。Tracer 对象还配置了上报数据的网关地址、本机 IP、采样频率等数据,您可以通过调整采样率来减少因上报数据产生的开销。

func getTracer(serviceName string, ip string) *zipkin.Tracer {
  // create a reporter to be used by the tracer
  reporter := httpreporter.NewReporter("http://tracing-analysis-dc-hz.aliyuncs.com/adapt_aokcdqnxyz@123456ff_abcdef123@abcdef123/api/v2/spans")
  // set-up the local endpoint for our service
  endpoint, _ := zipkin.NewEndpoint(serviceName, ip)
  // set-up our sampling strategy 设置采样率
  sampler := zipkin.NewModuloSampler(1)
  // initialize the tracer
  tracer, _ := zipkin.NewTracer(
    reporter,
    zipkin.WithLocalEndpoint(endpoint),
    zipkin.WithSampler(sampler),
  )
  return tracer;
}

2、记录请求数据,下面代码用于记录请求的根操作:

	// tracer can now be used to create spans.
	span := tracer.StartSpan("some_operation")
	// ... do some work ...
	// span 完成,必须调用 finish
	span.Finish()
	// Output:

如果需要记录请求的上一步和下一步操作,则需要传入上下文。如下代码所示,childSpanspan 的孩子节点:

	childSpan := tracer.StartSpan("some_operation2", zipkin.Parent(span.Context()))
		// ... do some work ...
	childSpan.Finish()

3、可选:(为了快速定位问题)可以为某个记录添加一些自定义标签(Tags),例如记录是否发生错误、请求的返回值等:

childSpan.Tag("http.status_code", statusCode)

4、在分布式系统中发送 RPC 请求时会带上 Tracing 数据,包括 TraceId、ParentSpanId、SpanId、Sampled 等。可以在 HTTP 请求中使用 Extract/Inject 方法在 HTTP Request Headers 上透传数据。即 在 Client 端执行 `Inject`,在 Server 端执行 `Extract`, 目前 Zipkin 已有组件支持以 HTTP、gRPC 这两种 RPC 协议透传 Context 信息。总体数据流程如下:

   Client Span                                                Server Span
┌──────────────────┐                                       ┌──────────────────┐
│                  │                                       │                  │
│   TraceContext   │           Http Request Headers        │   TraceContext   │
│ ┌──────────────┐ │          ┌───────────────────┐        │ ┌──────────────┐ │
│ │ TraceId      │ │          │ X-B3-TraceId      │        │ │ TraceId      │ │
│ │              │ │          │                   │        │ │              │ │
│ │ ParentSpanId │ │ Inject   │ X-B3-ParentSpanId │Extract │ │ ParentSpanId │ │
│ │              ├─┼─────────>│                   ├────────┼>│              │ │
│ │ SpanId       │ │          │ X-B3-SpanId       │        │ │ SpanId       │ │
│ │              │ │          │                   │        │ │              │ │
│ │ Sampled      │ │          │ X-B3-Sampled      │        │ │ Sampled      │ │
│ └──────────────┘ │          └───────────────────┘        │ └──────────────┘ │
│                  │                                       │                  │
└──────────────────┘                                       └──────────────────┘

这里以 HTTP 为例,在客户端调用 Inject 方法传入 Context 信息:

req, _ := http.NewRequest("GET", "/", nil)
// configure a function that injects a trace context into a reques
injector := b3.InjectHTTP(req)
injector(sp.Context())

在服务端调用 Extract 方法解析 Context 信息:

req, _ := http.NewRequest("GET", "/", nil)
b3.InjectHTTP(req)(sp.Context())

b.ResetTimer()
_ = b3.ExtractHTTP(copyRequest(req))

0x03 Jaeger-Tracing 的一般流程

本小节介绍使用 Jaeger 来实现链路追踪。Jaeger 是 Uber 开源的分布式追踪系统,也是遵循 Opentracing 的系统之一。

1、Jaeger 提供了 all-in-one 镜像,方便我们快速开始测试:

// 通过 http://localhost:16686 可以打开 Jaeger UI
$ 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 16686:16686 \
  -p 14268:14268 \
  -p 9411:9411 \
  jaegertracing/all-in-one:1.14

2、初始化 Jaeger tracer,设置 endpoint / 采样率等信息

import (
	"context"
	"errors"
	"fmt"
	"io"
	"time"

	"github.com/opentracing/opentracing-go"
	"github.com/opentracing/opentracing-go/log"
	"github.com/uber/jaeger-client-go"
	jaegercfg "github.com/uber/jaeger-client-go/config"
)
// initJaeger 将 jaeger tracer 设置为全局 tracer
func initJaeger(service string) io.Closer {
	cfg := jaegercfg.Configuration{
		// 将采样频率设置为 1,每一个 span 都记录,方便查看测试结果
		Sampler: &jaegercfg.SamplerConfig{
			Type:  jaeger.SamplerTypeConst,
			Param: 1,
		},
		Reporter: &jaegercfg.ReporterConfig{
			LogSpans: true,
			// 将 span 发往 jaeger-collector 的服务地址
			CollectorEndpoint: "http://localhost:14268/api/traces",
		},
	}
	closer, err := cfg.InitGlobalTracer(service, jaegercfg.Logger(jaeger.StdLogger))
	if err != nil {
		panic(fmt.Sprintf("ERROR: cannot init Jaeger: %v\n", err))
	}
	return closer
}

3、创建 tracer,生成 Root Span,下面这段代码创建了一个 Root span,并将该 Span 通过 context 传递给 Foo 方法,以便在 Foo 方法中将追踪链继续延续下去:

func main() {
	closer := initJaeger("in-process")
	defer closer.Close()
	// 获取 jaeger tracer
	t := opentracing.GlobalTracer()
	// 创建 root span
	sp := t.StartSpan("in-process-service")
	// main 执行完结束这个 span
	defer sp.Finish()
	// 将 span 传递给 Foo
	ctx := opentracing.ContextWithSpan(context.Background(), sp)
	Foo(ctx)
}

4、FooBar 方法模拟了独立的子操作 Span,Foo 方法调用了 Bar,假设在 Bar 中发生了一些错误,可以通过 span.LogFieldsspan.SetTag 将错误记录在追踪链中。StartSpanFromContext 这个方法看起来是直接从 ctx 中拿到 Span 并继承?

func Foo(ctx context.Context) {
    // 开始一个 span, 设置 span 的 operation_name=Foo
	span, ctx := opentracing.StartSpanFromContext(ctx, "Foo")
	defer span.Finish()
	// 将 context 传递给 Bar
	Bar(ctx)
	// 模拟执行耗时
	time.Sleep(1 * time.Second)
}
func Bar(ctx context.Context) {
    // 开始一个 span,设置 span 的 operation_name=Bar
	span, ctx := opentracing.StartSpanFromContext(ctx, "Bar")
	defer span.Finish()
	// 模拟执行耗时
	time.Sleep(2 * time.Second)

	// 假设 Bar 发生了某些错误
	err := errors.New("something wrong")
	span.LogFields(
		log.String("event", "error"),
		log.String("message", err.Error()),
	)
	span.SetTag("error", true)
}

最后,简单看下 StartSpanFromContext实现,印证了上面的猜想:

func StartSpanFromContextWithTracer(ctx context.Context, tracer Tracer, operationName string, opts ...StartSpanOption) (Span, context.Context) {
	if parentSpan := SpanFromContext(ctx); parentSpan != nil {
		opts = append(opts, ChildOf(parentSpan.Context()))
	}
	span := tracer.StartSpan(operationName, opts...)
	return span, ContextWithSpan(ctx, span)
}

小结下上面的过程,如果要确保追踪链在程序中不断开,需要将函数的第一个参数设置为 context.Context,通过 opentracing.ContextWithSpan 将保存到 context 中,通过 opentracing.StartSpanFromContext 开始一个新的子 span,然后设置直到调用流程结束。

假设我们需要在 gRPC 服务中调用另外一个微服务(如 RESTFul 服务),该如何跟踪?简单来说就是使用 HTTP 头作为媒介(Carrier)来传递跟踪信息(traceID)。下一小节,来看下 gRPC 中的 Opentracing 实现。

0x04 gRPC 中的 OpenTracing

本小节,介绍下 gRPC 与 OpenTracing 的结合使用,跟踪一个完整的 RPC 请求,从客户端到服务端的实现。分为两种:

  • gRPC-client 端,使用了 tracer.Inject 方法,可以将 Span 的 Context 信息注入到 carrier 中,再将 carrier 写入到 metadata 中,即完成 span 信息的传递
  • grpc-server 端,使用 tracer.Extract 函数将 carrier 从 metadata 中提取出来,再通过StartSpanChildOf方法创建新的子Span,这样 client 端与 server 端就能建立 Span 信息的关联

客户端

客户端的实现如下所示:

const (
    endpoint_url = "http://localhost:9411/api/v1/spans"		//Zipkin-UI 的 URL
    host_url = "localhost:5051"		// 作为标识
    service_name_cache_client = "cache service client"
    service_name_call_get = "callGet"
)

func newTracer () (opentracing.Tracer, zipkintracer.Collector, error) {
	// 创建 HTTP Collector,用来收集跟踪数据并将其发送到 Zipkin-UI
	collector, err := openzipkin.NewHTTPCollector(endpoint_url)
	if err != nil {
		return nil, nil, err
	}
	// 创建了一个记录器 (recorder) 来记录端口上的信息
	recorder :=openzipkin.NewRecorder(collector, true, host_url, service_name_cache_client)

	// 使用记录器 recorder 创建了一个新的跟踪器 (tracer)
	tracer, err := openzipkin.NewTracer(
		recorder,
		openzipkin.ClientServerSameSpan(true))

	if err != nil {
		return nil,nil,err
	}

	// 设置全局 tracer
	opentracing.SetGlobalTracer(tracer)

	return tracer,collector, nil
}


func DoRPCMethods(c pb.HelloServiceClient) ( []byte, error) {
    span := opentracing.StartSpan("RPC-CALL-METHOD-NAME")
    defer span.Finish()
    time.Sleep(5*time.Millisecond)
	// Put root span in context so it will be used in our calls to the client
	// 通过 opentracing.ContextWithSpan 拿到传递的 ctx
    ctx := opentracing.ContextWithSpan(context.Background(), span)
    //ctx := context.Background()
    getReq:=&pb.RPCMethodReq{}
    getResp, err :=RPCMethod(ctx, getReq)
    value := getResp.Value
    return value, err
}

func main(){
	// 初始化 tracer
	tracer, collector, err :=newTracer()
	if err != nil {
		panic(err)
	}
	defer collector.Close()
	// 注意这里使用了拦截器 OpenTracingClientInterceptor
	connection, err := grpc.Dial(host_url,
		grpc.WithInsecure(), grpc.WithUnaryInterceptor(otgrpc.OpenTracingClientInterceptor(tracer, otgrpc.LogPayloads())),
		)
	if err != nil {
		panic(err)
	}
	defer connection.Close()
	client := pb.NewHelloServiceClient(connection)
	err := DoRPCMethods(client)
	......
}

服务端实现

下面是服务端代码,同样使用拦截器 OpenTracingServerInterceptor 来构建,服务端的 RPC 方法中包含了一次对 Database 的请求。

func main(){
    connection, err := net.Listen(network, host_url)
	if err != nil {
		panic(err)
	}
	tracer,err  := newTracer()
	if err != nil {
		panic(err)
	}
	opts := []grpc.ServerOption{
		grpc.UnaryInterceptor(
			otgrpc.OpenTracingServerInterceptor(tracer,otgrpc.LogPayloads()),
		),
	}
	srv := grpc.NewServer(opts...)
	cs := initCache()
	pb.RegisterCacheServiceServer(srv, cs)

	err = srv.Serve(connection)
	if err != nil {
		panic(err)
	}
}

const service_name_db_query_user = "QueryDatabase"

func (c *HelloService) RPCMethod(ctx context.Context, req *pb.GetReq) (*pb.GetResp, error) {
    if parent := opentracing.SpanFromContext(ctx); parent != nil {
        pctx := parent.Context()
        if tracer := opentracing.GlobalTracer(); tracer != nil {
            mysqlSpan := tracer.StartSpan(service_name_db_query_user, opentracing.ChildOf(pctx))
            defer mysqlSpan.Finish()
            //do some operations
            time.Sleep(time.Millisecond * 10)
        }
    }
    key := req.GetKey()
    value := c.storage[key]
    fmt.Println("get called with return of value:", value)
    resp := &pb.GetResp{Value: value}
    return resp, nil
}

前一节,说到跨进程传递 Trace 的时候需要进行的 InjectExtract 操作,上面的示例代码并没有出现。那么客户端 / 服务端如何实现对 Span 的 Inject/Extract 呢?答案就是拦截器 otgrpc.OpenTracingServerInterceptor/OpenTracingClientInterceptor 方法,这里以 OpenTracingClientInterceptor 方法为例:

// OpenTracingClientInterceptor returns a grpc.UnaryClientInterceptor suitable
// for use in a grpc.Dial call.
//
// For example:
//
//     conn, err := grpc.Dial(
//         address,
//         ...,  // (existing DialOptions)
//         grpc.WithUnaryInterceptor(otgrpc.OpenTracingClientInterceptor(tracer)))
//
// All gRPC client spans will inject the OpenTracing SpanContext into the gRPC
// metadata; they will also look in the context.Context for an active
// in-process parent Span and establish a ChildOf reference if such a parent
// Span could be found.
func OpenTracingClientInterceptor(tracer opentracing.Tracer, optFuncs ...Option) grpc.UnaryClientInterceptor {
	otgrpcOpts := newOptions()
	otgrpcOpts.apply(optFuncs...)
	return func(
		ctx context.Context,
		method string,
		req, resp interface{},
		cc *grpc.ClientConn,
		invoker grpc.UnaryInvoker,
		opts ...grpc.CallOption,
	) error {
		var err error
		var parentCtx opentracing.SpanContext
		if parent := opentracing.SpanFromContext(ctx); parent != nil {
			parentCtx = parent.Context()
		}
		if otgrpcOpts.inclusionFunc != nil &&
			!otgrpcOpts.inclusionFunc(parentCtx, method, req, resp) {
			return invoker(ctx, method, req, resp, cc, opts...)
		}
		clientSpan := tracer.StartSpan(
			method,
			opentracing.ChildOf(parentCtx),
			ext.SpanKindRPCClient,
			gRPCComponentTag,
		)
		defer clientSpan.Finish()
		// 调用 injectSpanContext
		ctx = injectSpanContext(ctx, tracer, clientSpan)
		if otgrpcOpts.logPayloads {
			clientSpan.LogFields(log.Object("gRPC request", req))
		}
		err = invoker(ctx, method, req, resp, cc, opts...)
		if err == nil {
			if otgrpcOpts.logPayloads {
				clientSpan.LogFields(log.Object("gRPC response", resp))
			}
		} else {
			SetSpanTags(clientSpan, err, true)
			clientSpan.LogFields(log.String("event", "error"), log.String("message", err.Error()))
		}
		if otgrpcOpts.decorator != nil {
			otgrpcOpts.decorator(clientSpan, method, req, resp, err)
		}
		return err
	}
}

//injectSpanContext 方法
func injectSpanContext(ctx context.Context, tracer opentracing.Tracer, clientSpan opentracing.Span) context.Context {
	md, ok := metadata.FromOutgoingContext(ctx)
	if !ok {
		md = metadata.New(nil)
	} else {
		md = md.Copy()
	}
	mdWriter := metadataReaderWriter{md}
	err := tracer.Inject(clientSpan.Context(), opentracing.HTTPHeaders, mdWriter)
	// We have no better place to record an error than the Span itself :-/
	if err != nil {
		clientSpan.LogFields(log.String("event", "Tracer.Inject() failed"), log.Error(err))
	}
	return metadata.NewOutgoingContext(ctx, md)
}

从客户端 injectSpanContext 的实现来看,最终在 RPC 调用前,通过 metadata.NewOutgoingContext 将 Context 信息(包含了 Tracer),即获取了跟踪上下文并将其注入 HTTP 头,因此我们不需要再次调用 Inject 函数。而在服务器端,从 HTTP 头中 Extract 跟踪上下文并将其放入 Golang context 中,无须手动调用 Extract 方法。

但对于其他基于 HTTP 的服务(如 RESTFul-API 服务),情况就并非如此,需要写代码从服务器端的 HTTP 头中提取跟踪上下文,亦或也使用拦截器实现,如 Kratos 的 bm 框架的 Trace 拦截器

0x05 数据库追踪

见此,Xorm 实现 tracing 机制

0x06 TraceId 的生成机制

这里引用下阿里云推荐的 traceId 生成规则:

ali

TraceId 生成规则

TraceId 一般由接收请求经过的第一个服务器产生。规则是:服务器 IP + ID 产生的时间 + 自增序列 + 当前进程号(忽略 | 号),如 0ad1348f|1403169275002|1003|56696

  • 80ad1348f 即产生 TraceId 的机器的 IP(16 进制,每 2 位转换一次,10.209.52.143),可以根据这个规律来查找到请求经过的第一个服务器
  • 中间 131403169275002 是产生 TraceId 的时间
  • 之后的 41003 是一个自增的序列,从 1000 涨到 9000,到达 9000 后回到 1000 回环
  • 最后的 556696 是当前的进程 ID,为了防止单机多进程出现 TraceId 冲突的情况,所以在 TraceId 末尾添加了当前的进程 ID

SpanId 生成规则

SpanId 代表本次调用在整个调用链路树中的(相对)位置。

假设一个 Web 系统 A 接收了一次用户请求,那么在这个系统日志中,记录下的 SpanId 是 0,代表是整个调用的根节点,如果 A 系统处理这次请求,需要通过 RPC 依次调用 B、C、D 三个系统,那么在 A 系统的客户端日志中,SpanId 分别是 0.10.20.3,在 B、C、D 三个系统的服务端日志中,SpanId 也分别是 0.10.20.3;如果 C 系统在处理请求的时候又调用了 E,F 两个系统,那么 C 系统中对应的客户端日志是 0.2.10.2.2,E、F 两个系统对应的服务端日志也是 0.2.10.2.2。如果把一次调用中所有的 SpanId 收集起来,可以组成一棵完整的链路树。

0x07 一些代码上的细节

进程内与跨进程

在实现 tracing 逻辑的时候,一定要注意 Span 是否跨进程!二者的实现是不同的:

  1. 进程(同一个服务)内,通常用 StartSpanFromContext实现SpanFromContext实现 来模拟从 context 中启动一个子 span,
  2. 对于 gRPC 中 client 的 context 和 server 的 context 是跨进程 context,必须采用 tracer.Inject(客户端)以及 tracer.Extract(服务端)的方式,通过 metadata 来传递;
  3. 对于 http 框架,与 gRPC 类似(见下面例子)

生成span的方法

官方给出了4种常用例子

1、Creating a Span given an existing Go context.Context
If you use context.Context in your application, OpenTracing’s Go library will happily rely on it for Span propagation. To start a new (blocking child) Span, you can use StartSpanFromContext.

func xyz(ctx context.Context, ...) {
	//...
	span, ctx := opentracing.StartSpanFromContext(ctx, "operation_name")
	defer span.Finish()
	span.LogFields(
		log.String("event", "soft error"),
		log.String("type", "cache timeout"),
		log.Int("waited.millis", 1500))
	//...
}

2、Starting an empty trace by creating a “root span”

//It's always possible to create a "root" Span with no parent or other causal reference.
func xyz() {
	...
	sp := opentracing.StartSpan("operation_name")
	defer sp.Finish()
	...
}

3、Creating a (child) Span given an existing (parent) Span

func xyz(parentSpan opentracing.Span, ...) {
	//...
	sp := opentracing.StartSpan(
		"operation_name",
		opentracing.ChildOf(parentSpan.Context()))
	defer sp.Finish()
	//...
}

4、Serializing to the wire
跨进程Inject

func makeSomeRequest(ctx context.Context) ... {
	if span := opentracing.SpanFromContext(ctx); span != nil {
		httpClient := &http.Client{}
		httpReq, _ := http.NewRequest("GET", "http://myservice/", nil)

		// Transmit the span's TraceContext as HTTP headers on our
		// outbound request.
		opentracing.GlobalTracer().Inject(
			span.Context(),
			opentracing.HTTPHeaders,
			opentracing.HTTPHeadersCarrier(httpReq.Header))

		resp, err := httpClient.Do(httpReq)
		//...
	}
	//...
}

4、Deserializing from the wire
跨进程Extract

//...
http.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) {
	var serverSpan opentracing.Span
	appSpecificOperationName := ...
	wireContext, err := opentracing.GlobalTracer().Extract(
		opentracing.HTTPHeaders,
		opentracing.HTTPHeadersCarrier(req.Header))
	if err != nil {
		// Optionally record something about err here
	}

	// Create the span referring to the RPC client if available.
	// If wireContext == nil, a root span will be created.
	serverSpan = opentracing.StartSpan(
		appSpecificOperationName,
		ext.RPCServerOption(wireContext))

	defer serverSpan.Finish()

	ctx := opentracing.ContextWithSpan(context.Background(), serverSpan)
	...
}

StartSpanFromContext 的第二个返回值

先看 StartSpanFromContext 方法的定义:

type contextKey struct{}

var activeSpanKey = contextKey{}


func StartSpanFromContext(ctx context.Context, operationName string, opts ...StartSpanOption) (Span, context.Context) {
	//调用StartSpanFromContextWithTracer
	return StartSpanFromContextWithTracer(ctx, GlobalTracer(), operationName, opts...)
}

func StartSpanFromContextWithTracer(ctx context.Context, tracer Tracer, operationName string, opts ...StartSpanOption) (Span, context.Context) {
	if parentSpan := SpanFromContext(ctx); parentSpan != nil {
		opts = append(opts, ChildOf(parentSpan.Context()))
	}
	span := tracer.StartSpan(operationName, opts...)
	return span, ContextWithSpan(ctx, span)
}

// ContextWithSpan returns a new `context.Context` that holds a reference to
// the span. If span is nil, a new context without an active span is returned.
func ContextWithSpan(ctx context.Context, span Span) context.Context {
	if span != nil {
		if tracerWithHook, ok := span.Tracer().(TracerContextWithSpanExtension); ok {
			ctx = tracerWithHook.ContextWithSpanHook(ctx, span)
		}
	}
	return context.WithValue(ctx, activeSpanKey, span)
}

可以看到StartSpanFromContext方法的第2个返回值最终来源于ContextWithSpancontext.WithValue,就是使用StartSpanFromContext创建了子Span的时候,同时生成了一个子context,用于后续的进程内tracing的context上下文,如果在之后还需要启动进程内的函数调用,那么就需要传入这个新的context(而不是创建之前的context),如果不需要的话,那么可以忽略掉这个返回值

0x08 一个综合的示例

下图演示了一个HTTP->RPC的调用路径上的Span传递方法: img

0x09 小结

小结下,要实现 Tracing 机制及 Tracing 应用的关键点:

  • 选择合适的数据收集端
  • 明确当前是跨进程调用还是进程内调用,如何获取到 spanContext
  • 传输 Span 的方法,一般会放在各类 header 中,避免侵入业务
  • 采样率
  • 对于 Tracing-Span 的代码实现而言:
    • 如果 Tracing 不存在,那么则需要新建一个 Tracing,并且生成唯一的 TracingId
    • 如果 Tracing 存在,那么需要查看调用代码处是一个 Span(不存在 Span,需新建),还是一个子 Span(从当前的 Span Fork 一个新的)
    • Span 过程中,需要明确是否在本 Span 中进行 Finish(还是不需要),记录耗时,Tags,错误信息或者日志等

0x0A 参考