Opentracing Version: 1.1 | Jaeger version: 1.38

实验代码库: rectcircle/learn-observability

业界背景

OpenTracing 通过提供平台无关、厂商无关的 API,使得开发人员能够方便的添加(或更换)追踪系统的实现。在 2016 年 11 月, CNCF (云原生计算基金会) 技术委员会投票接受 OpenTracing 作为Hosted 项目,这是 CNCF 的第三个项目,第一个是 Kubernetes,第二个是 Prometheus。

2022 年 1 月 31 日,CNCF 正式宣布 OpenTracing 归档,OpenTracing 和 OpenCensus 一起合并到了 OpenTelemetry,并提供了 OpenTracing 的兼容层,更多参见: CNCF 博客github issue

💡 OpenTelemetry和 OpenCensus 相比不支持 metrics。

由于 OpenTracing 归档不久,因此应该有很多历史项目仍然在使用 OpenTracing,因此了解 OpenTracing 仍十分有必要。

解决的问题

在单体应用中,用户请求只会由一个服务进行处理。在这种场景,只需要在上报日志和指标时,携带唯一的无状态的请求 ID,即方便的检索某个请求的日志和指标。

而随着微服务的到来,用户请求在后端会形成一个多个微服务之间的调用链。在这种场景,如何方便的检索某个请求涉及的所有微服务的请求日志和指标,是一个亟需解决的问题。

OpenTracing 就是一个解决该问题的业界标准,其定义了如下概念:

  • Trace: 调用链,一般情况下,一个用户请求就会形成一个调用链,调用链是一个有向无环图。
  • Span: 一次调用,在微服务场景一般定义为一次 RPC 调用。Span 时 Trace 这个有向无环图的节点。

有了这两个概念,就可以解决如上问题,流程如下所示:

  • 用户的请求到达第一个服务的处理函数时,会生成一个 TraceID_1、SpanID_1。
    • 该服务上报日志、指标时,会携带 TraceID_1、SpanID_1 这两个参数。
    • 该服务调用其他微服务时,会将 TraceID_1、SpanID_1 作为隐含参数传递下去。
  • 某服务的某个函数被调用时,会解析使用传递过来的 TraceID_1,并创建新的 SpanID_2。
    • 该服务上报日志、指标时,会携带 TraceID_1、SpanID_2、上一级的 SpanID_1、两个 Span 的关系。
    • 该服务调用其他微服务时,会将 TraceID_1、SpanID_2 作为隐含参数传递下去,依次类推。
  • 在查询时,通过 TraceID_1 或者 SpanID_1、SpanID_2, 即可方便的检索某个请求涉及的所有微服务的请求日志和指标。
  • 其他说明
    • 通过如上推演,日志和指标在上报和存储上,携带 <TraceID, SpanID, PreviousSpanID, Relation> 四元组即可。
    • 需要记录每个 SpanID 的起止时间,这样可以很方便分析整个 Trace 中哪个 Span 的耗时情况。
    • OpenTracing 标准只定义的上报用的客户端的概念和 API 标准,参见下文。而以上所有 TraceID 并不在 OpenTracing 标准中,也就是说 Trace 是隐式的,因为通过 Span 的关系即可获取到整个有向无环图。

以上就是 OpenTracing 的核心部分的概念和实现流程推演,除此之外,OpenTracing 值得一提的:

  • 上面的 SpanID_1 和 SpanID_2 的关系是父子关系(ChildOf),父子关系一般是同步调用,父需要等待子的完成。OpenTracing 还定义了一种跟随关系 (FollowsFrom),跟随关系一般是异步调用,父不需要等待子的完成,在微服务场景,通过消息队列的异步处理可以使用 FollowsFrom 关系。
  • Span 可以关联如下内容:
    • Tags 类型为 map[string]字符串,bool,数字
    • Log 包含如下字段:
      • map[string]any
      • 可选的时间
  • 从上文可以看出,如果想使用 OpenTracing,需要再 RPC 调用时,传递 TraceID 和 SpanID 这两个参数(被称为 SpanContext)。除了这两个参数外,OpenTracing 还可以在 RPC 调用过程中传递一个 map[string]string 类型的数据 baggage (注意该特性存在较大的开销)。

OpenTracing 标准实现 Jaeger

Uber 开源的 Jaeger 是 OpenTracing 的一个标准的后端实现。关于的架构部署,本文不过多介绍。

本部分将主要介绍,通过 Docker 一键启动一个 Jaeger 服务,以及 Jaeger 客户端的使用。

一键启动

Jaeger Getting Started

docker run --rm -it --name jaeger \
  -e COLLECTOR_ZIPKIN_HOST_PORT=:9411 \
  -e COLLECTOR_OTLP_ENABLED=true \
  -p 6831:6831/udp \
  -p 6832:6832/udp \
  -p 5778:5778 \
  -p 16686:16686 \
  -p 4317:4317 \
  -p 4318:4318 \
  -p 14250:14250 \
  -p 14268:14268 \
  -p 14269:14269 \
  -p 9411:9411 \
  jaegertracing/all-in-one:1.38

创建 Tracer

Jaeger 提供了多种语言的客户端,这里仅介绍 Go 语言的客户端库 uber/jaeger-client-go。通过该库可以创建一个实现了 opentracing/opentracing-go 接口的客户端。

01-opentracing/tracing/tracing.go

package tracing

import (
	"fmt"

	"github.com/uber/jaeger-client-go/rpcmetrics"

	"github.com/opentracing/opentracing-go"
	"github.com/uber/jaeger-client-go/config"
)

// 创建一个 Jaeger opentracing.Tracer
func NewTracer(serviceName string) (opentracing.Tracer, error) {
	cfg := &config.Configuration{
		// 服务名
		ServiceName: serviceName,
		// 采样配置
		// 以创建 tracing 的节点的配置有效,如 A -> B -> C,则 A 采样策略生效,B、C 遵循 A 的决定。
		Sampler: &config.SamplerConfig{
			// 更多参见:https://www.jaegertracing.io/docs/1.38/sampling/#client-sampling-configuration
			// const: Param 为 1 表示全部采样(全部上报),为 0 关闭采样(永远不上报),
			Type:  "const",
			Param: 1,
		},
		Reporter: &config.ReporterConfig{
			// 将 span 提交日志,上报到外部日志服务
			LogSpans: true,
		},
	}
	_, err := cfg.FromEnv()
	if err != nil {
		return nil, fmt.Errorf("cannot parse Jaeger env vars: %s", err)
	}

	metricsFactory := NewJaegerMetricsFactory()
	tracer, _, err := cfg.NewTracer(
		// 用来记录 Jaeger 自身的一些错误以及 Span 提交(需启用 Reporter.LogSpans),到外部日志服务。
		config.Logger(NewJaegerLogger()),
		// 用来上报 Span 的一些统计指标到外部 Metrics 服务。
		config.Metrics(metricsFactory),
		// 用来观察 Span 创建的事件。
		config.Observer(rpcmetrics.NewObserver(metricsFactory, rpcmetrics.DefaultNameNormalizer)),
	)
	if err != nil {
		return nil, fmt.Errorf("cannot initialize Jaeger Tracer: %s", err)
	}
	return tracer, nil
}

Jaeger 创建 opentracing.Tracer 的配置可以分为如下两类:

  • Tracer 的配置
    • 服务名
    • 采样器
  • Jaeger 自身的监控
    • Span 提交日志
    • Span 相关的指标
    • Span 创建事件的回调函数

最终返回的 opentracing.Tracer 是 OpenTracing 标准定义的,参见下文。

编程接口 API

OpenTracing语义标准 | opentracing/opentracing-go

示例参见:01-opentracing/tracing/tracing_test.go

创建和完成 Span

func Service2B(tracer2 opentracing.Tracer, httpHeader http.Header) {
	// 准备 Span 一个,一般在中间件中实现,反序列化 SpanContext
	var BSpan opentracing.Span
	tags := opentracing.Tags{"b": 2}
	previousContext, err := tracer2.Extract(opentracing.HTTPHeaders, httpHeader)
	if err == nil {
		BSpan = tracer2.StartSpan("B", tags, opentracing.ChildOf(previousContext))
	} else {
		BSpan = tracer2.StartSpan("B", tags)
	}
	defer BSpan.Finish()

	// ...
}

func Service1A(tracer1, tracer2 opentracing.Tracer) {
	// 准备 Span 一个,一般在中间件中实现
	ASpan := tracer1.StartSpan("A", opentracing.Tags{"a": 1})
	defer ASpan.Finish()

	// ...
}

记录 Span 日志

func Service1A(tracer1, tracer2 opentracing.Tracer) {
	// ...
	ASpan.LogFields(log.String("message", "Service1A called"))
	// ...
}

设置和读取 Span 的 baggage

func Service1A(tracer1, tracer2 opentracing.Tracer) {
	// ...
	ASpan.SetBaggageItem("BaggageA", "123")
	// ...
}

func Service2B(tracer2 opentracing.Tracer, httpHeader http.Header) {
	// ...
	// 业务逻辑
	BSpan.LogFields(
		log.String("message", "Service2B called"),
		log.String("BaggageA", BSpan.BaggageItem("BaggageA")),
	)
}

SpanContext 序列化和反序列化

func TestExtractAndInject(t *testing.T) {
	tracer, err := NewTracer("test")
	if err != nil {
		t.Fatal(err)
	}
	rootSpan := tracer.StartSpan("root")
	rootSpan.SetBaggageItem("BaggageRoot", "456")

	textMapCarrier := opentracing.TextMapCarrier{}
	tracer.Inject(rootSpan.Context(), opentracing.TextMap, textMapCarrier)
	fmt.Printf("=== textMapCarrier: %v\n", textMapCarrier)
	httpHeaderCarrier := opentracing.HTTPHeadersCarrier(http.Header{})
	tracer.Inject(rootSpan.Context(), opentracing.HTTPHeaders, httpHeaderCarrier)
	fmt.Printf("=== httpHeaderCarrier: %v\n", httpHeaderCarrier)
	binaryCarrier := &bytes.Buffer{}
	tracer.Inject(rootSpan.Context(), opentracing.Binary, binaryCarrier)
	fmt.Printf("=== binaryCarrier: %v\n", binaryCarrier)

	root1SpanContext, err := tracer.Extract(opentracing.TextMap, textMapCarrier)
	if err != nil {
		panic(err)
	}
	root2SpanContext, err := tracer.Extract(opentracing.HTTPHeaders, httpHeaderCarrier)
	if err != nil {
		panic(err)
	}
	root3SpanContext, err := tracer.Extract(opentracing.Binary, binaryCarrier)
	if err != nil {
		panic(err)
	}
	child1Span := tracer.StartSpan("child", opentracing.ChildOf(root1SpanContext))
	fmt.Printf("=== child1Span BaggageRoot: %v\n", child1Span.BaggageItem(strings.ToLower("BaggageRoot"))) // 使用 opentracing.TextMap 像是个 bug,不区分大小写。
	child2Span := tracer.StartSpan("child", opentracing.ChildOf(root2SpanContext))
	fmt.Printf("=== child2Span BaggageRoot: %v\n", child2Span.BaggageItem(strings.ToLower("BaggageRoot"))) // 使用 opentracing.HTTPHeaders 像是个 bug,不区分大小写。
	child3Span := tracer.StartSpan("child", opentracing.ChildOf(root3SpanContext))
	fmt.Printf("=== child3Span BaggageRoot: %v\n", child3Span.BaggageItem("BaggageRoot"))
}

输出如下:

=== textMapCarrier: map[uber-trace-id:55d2ee3a9f8863f5:55d2ee3a9f8863f5:0000000000000000:1 uberctx-BaggageRoot:456]
=== httpHeaderCarrier: map[Uber-Trace-Id:[55d2ee3a9f8863f5:55d2ee3a9f8863f5:0000000000000000:1] Uberctx-Baggageroot:[456]]
=== child1Span BaggageRoot: 456
=== child2Span BaggageRoot: 456
=== child3Span BaggageRoot: 456

说明:

设置 Span 的属性

Tags 和 OperationName 除了可以在 Span 的时候设置外,还可以随时添加:

框架集成

原理

从上文的编程接口可以看出,如果微服务想接入 OpenTracing,需要做如下事情:

  • Server:从请求的参数获取序列化的 carrier,如果存在则通过 Tracer.Extract 反序列化为 SpanContext,并通过 StartSpan 函数创建 Span。并注入 context.Context 中,以给处理函数使用。
  • Client:从 context.Context 中获取 Span,并通过 Tracer.Inject 序列化到 carrier 中,并在调用 Server 的时候传递。

HTTPServer

func Middleware(tr opentracing.Tracer, h http.Handler, options ...MWOption) http.Handler
  • 使用 Middleware 函数对 http.Handler 进行包装即可,更多参见:go docs

HTTPClient

package tracing

import (
	"fmt"
	"io/ioutil"
	"net/http"
	"testing"

	"github.com/opentracing-contrib/go-stdlib/nethttp"
)

func TestHTTPClient(t *testing.T) {
	tracer, err := NewTracer("test")

	client := &http.Client{Transport: &nethttp.Transport{}}
	req, err := http.NewRequest("GET", "http://qq.com", nil)
	if err != nil {
		t.Fatal(err)
	}
	// req = req.WithContext(ctx) // extend existing trace, if any

	req, ht := nethttp.TraceRequest(tracer, req)
	defer ht.Finish()

	res, err := client.Do(req)
	if err != nil {
		t.Fatal(err)
	}
	defer res.Body.Close()
	respBody, err := ioutil.ReadAll(res.Body)
	if err != nil {
		t.Fatal(err)
	}
	fmt.Println(string(respBody))
}

接入流程

  • 使用 nethttp.Transport 创建 http.Client
  • 使用 req = req.WithContext(ctx) 注入 ctx。
  • 使用 req, ht := nethttp.TraceRequest(tracer, req) 包装 request。
  • 使用 defer ht.Finish() 设置结束函数调用。

实例

需求描述

假设我们在开发短信验证码登录需求,该需求包含如下接口:

  • 验证码发送需求

假设我们的服务依赖关系如下所示:

                        ------> Redis
                       |
API 服务 ---(RPC)---> 认证服务 ---(MQ)---> 短信服务

其他说明:

  • RPC 协议使用 HTTP。
  • 示例代码使用 Go 和标准库实现。

实验代码

参见: rectcircle/learn-observability

部署测试

首先,参考:Jaeger 一键启动,启动 Jaeger 服务。

然后,启动测试服务。

# 第一个 shell
JAEGER_AGENT_HOST=localhost go run ./01-opentracing/api
# 第二个 shell
JAEGER_AGENT_HOST=localhost go run ./01-opentracing/auth
# 第三个 shell
JAEGER_AGENT_HOST=localhost go run ./01-opentracing/sms

发起请求:

curl -v 'localhost:8080/api/v1/SendSMSCode?PhoneNumber=123'

访问 http://localhost:16686 查看 tracing。

总结

通过接入 OpenTracing 可以实现:

  • 从请求粒度追踪,某个请求对各个微服务调用链每个节点的起止时间和日志。

OpenTracing 不足和问题:

参考