分布式追踪实战

时间:2022-07-22
本文章向大家介绍分布式追踪实战,主要内容包括其使用实例、应用技巧、基本知识点总结和需要注意事项,具有一定的参考价值,需要的朋友可以参考一下。

本文概述 分布式监控 的一些概念,并进行分布式追踪实战。

分布式监控概述

分布式监控是一个市场庞大的领域,尤其在现在微服务越来越被广泛采用的的现代,监控和追踪系统可以说百花齐放,诞生了很多开源框架和商业公司。

本质上,无论监控还是日志,关注的其实是同一个东西:打点和收集分析。这里的点可以是一段无结构或者有结构的日志,也可以是一个数字,或者是带 id 上下文的结构化数据。既然要 打点,那么就存在以下几个问题:如何打点,如何收集、展示、分析。细分一下可以分成以下几个领域。

  • 收集 agent : agent 一般是节点 deamon 形式,负责监控数据、日志的收集上报 比如 collectd, es apm agent, elastic beats, prometheus exporter 等
  • 框架 library: 监控、日志数据也可以在应用客户端直接上报,不经过 agent,如 jaeger client, prometheus client 等
  • tranport:可选转发层 比如一些常见的消息队列 kafka 等
  • 收集 collector:接受 agent 数据
  • 数据处理:监控数据的分析处理
  • 存储:监控日志数据存储,比如 prometheus 自带 tsdb,open tsdb, influx db,es..
  • 可视化/Dashboarding:kibana, prometheus ui, grafana
  • 告警 alterting: grafana, prometheus alter manager,

这里强烈推荐一个网站 https://openapm.io/landscape 在这个网站上你可以选择一些组件,构建出你自己的监控系统。比如下图就是笔者拖出来的一个可以被真实使用的监控系统。这个监控系统中,节点上使用 collectd + promethues exporter 来收集节点数据,应用端使用 promethues 收集监控数据,监控数据在 promethues server 汇总,并在 influxdb 持久化存储,日志数据使用 elk。 grafana 进行集中展示。

image

监控、追踪和日志

Logging,Metrics 和 Tracing 有各自专注的部分。

Logging - 用于记录离散的事件。例如,应用程序的调试信息或错误信息。它是我们诊断问题的依据。

Metrics - 用于记录可聚合的数据。例如,队列的当前深度可被定义为一个度量值,在元素入队或出队时被更新;HTTP 请求个数可被定义为一个计数器,新请求到来时进行累加。

Tracing - 用于记录请求范围内的信息。例如,一次远程方法调用的执行过程和耗时。它是我们排查系统性能问题的利器。

以上的分类办法 参考自 https://zhuanlan.zhihu.com/p/34318538,下图来自 http://peter.bourgon.org/blog/2017/02/21/metrics-tracing-and-logging.html

image.png

但是这张图除了好看之外,对监控的理解其实很不对, 以 request scoped 或者可否 aggregatable 进行划分并不是一个准确的划分方式,比如说,用日志也能打点绘制监控数据;基于 logid 的日志方式也能追踪调用链。

那么如何正确的理解 监控、追踪和日志 之间的关系呢。考虑 这样的一个原始服务端应用:

  1. 起初开发者打了一些本地日志,用于分析和做 debug
  2. 后来服务端单节点不能满足要求,副本加到了 3个,此时只利用本地日志变得不太方便,开发者就接入了 elk,将日志进行统一的收集和展示
  3. 开发者要求变得更高了,希望看到各种请求的 code 分布和 latency,此时日志变得很麻烦,给分析带来很大的成本,因此开发者又接入了 metric系统,对请求相关指标进行统计
  4. 开发者发现,某种请求耗时过长,考虑优化,在日志中写入耗时数据是一个办法,使用 logid(request id)的方式 分析是一个办法,但是不够直观。同时也不方便跨进程的追踪(开发者还调用了其他服务),所以开发者觉得接入 opentrace 直观分析各部调用耗时。

从上面的各步可以看出,开发者对于监控的要求是逐渐增加的,日志和监控 trace 直接的要求越来越高,但是从本质上看,三者并无区别,在日志中 写入耗时数据和使用 专用的监控系统,只是在分析和展示步骤有所不同。因此可以看出 其中一切都来自开发者对于应用的监控需求,而工作的原理都是打点。监控和追踪是日志的高级形式,本质并无不同,理解了这一点,你就能不变应万变了。换个说法,监控和追踪是将日志格式化和专用化的一种方式。当日志专注于 metric类数据,使用监控系统更为方便,当日志系统带有 context 属性(在 opentrace 里面就是 span,你也可以理解成 logid),那么使用专用的 trace 系统更为方便。但是本质都是日志,所以当 es 说他同时能支持 日志、监控、追踪的时候,你就不会觉得奇怪了吧。

image

追踪

由于笔者在监控方面已经写过一些文章(未来可能会重新整理),不再赘述,本文重点会介绍一下追踪(trace)以及 opentrace 规范。

opentrace

经过上文的分类,大家应该理解,trace 其实也是一种特殊的日志,opentrace 则是用来定义这种特殊的日志规范,而这种日志规范,最特殊的地方在于 span 的定义,即单个日志不是孤岛,通过 span 的串联,他是能组成一组调用链的。

一个tracer过程中,各span的关系


        [Span A]  ←←←(the root span)
            |
     +------+------+
     |             |
 [Span B]      [Span C] ←←←(Span C 是 Span A 的孩子节点, ChildOf)
     |             |
 [Span D]      +---+-------+
               |           |
           [Span E]    [Span F] >>> [Span G] >>> [Span H]
                                       ↑
                                       ↑
                                       ↑
                         (Span G 在 Span F 后被调用, FollowsFrom)
                         
tracer与span的时间轴关系


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

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

当然,除了 span 之外,opentrace 还有其他关注这种特殊日志的定义,比如:

  • trace: 一组 span 组成的 dag, 可以理解为调用链
  • span: 核心,代表系统中具有开始时间和执行时长的逻辑运行单元
  • Operation Name:Span 的操作名
  • Inter-Span References:Span 间关系,即 这个 dag 的组织方式
  • Logs,Tags: 以 Span 为载体记录的 日志和 属性消息
  • Baggage:也是 Span 为载体记录的消息,和 logs,tags 不同的是,baggage 也是会跨越进程传递的。这个怎么理解呢,理论上大部分内容只需要 span id 传递,其他内容本地记录收集就可以,而 有些内容同时也希望作为上下文被传递,这时候就是 baggage的使用场景

opentrace client 分析

opentrace 定义的是一个规范,具体的实现了这个规范的又 Zipkin,Jaeger 等,opentrace 的规范保证,只要使用 opentrace client 的代码,底层实现的切换,内部的代码无需修改。

opentrace client 具体定义了哪些东西呢。和上面讲的概念对应,其实 opentrace 的定义很简洁,主要就 三个 interface,Tracer, Span, SpanContext

type Tracer interface {
    // 用于新建,启动,返回一个新的 Span
	// 比如:
	//
	//     var tracer opentracing.Tracer = ...
	//
	//     // The root-span case:
	//     sp := tracer.StartSpan("GetFeed")
	//
	//     // The vanilla child span case:
	//     sp := tracer.StartSpan(
	//         "GetFeed",
	//         opentracing.ChildOf(parentSpan.Context()))
	//
	//     // All the bells and whistles:
	//     sp := tracer.StartSpan(
	//         "GetFeed",
	//         opentracing.ChildOf(parentSpan.Context()),
	//         opentracing.Tag{"user_agent", loggedReq.UserAgent},
	//         opentracing.StartTime(loggedReq.Timestamp),
	//     )
	//
	StartSpan(operationName string, opts ...StartSpanOption) Span

	// 注入 Inject 和 Extract 是 Span 传递的关键,Inject 将 Span 信息以某种格式注入载体,
	//  Extract 则是做提取,以最常见的 Http 为例,Inject 将 Span 信息以 Http Header 的方式
	// 注入,提取的时候则 从 Http Header 提取,这里只要 Inject 和 Extract 对应就可以,你也可以
	// 定义自己的 Http 注入方式
	// 
	// 比如:
	//
	//     carrier := opentracing.HTTPHeadersCarrier(httpReq.Header)
	//     err := tracer.Inject(
	//         span.Context(),
	//         opentracing.HTTPHeaders,
	//         carrier)
	//
	Inject(sm SpanContext, format interface{}, carrier interface{}) error
    
    // 提取,这里给了一个最常见的例子,理解这个例子:StartSpan 有两种情况:一是过来的请求里面有 Span
    // 信息了,那么要 Start with clientContext;二是 过来的请求没有 Span,那么 新建一个新的无关的 Span
	// 
	// 例子:
	//
	//
	//     carrier := opentracing.HTTPHeadersCarrier(httpReq.Header)
	//     clientContext, err := tracer.Extract(opentracing.HTTPHeaders, carrier)
	//
	//     // ... assuming the ultimate goal here is to resume the trace with a
	//     // server-side Span:
	//     var serverSpan opentracing.Span
	//     if err == nil {
	//         span = tracer.StartSpan(
	//             rpcMethodName, ext.RPCServerOption(clientContext))
	//     } else {
	//         span = tracer.StartSpan(rpcMethodName)
	//     }
	Extract(format interface{}, carrier interface{}) (SpanContext, error)
}

// SpanContext represents Span state that must propagate to descendant Spans and across process
// boundaries (e.g., a <trace_id, span_id, sampled> tuple).
type SpanContext interface {
	// ForeachBaggageItem grants access to all baggage items stored in the
	// SpanContext.
	ForeachBaggageItem(handler func(k, v string) bool)
}

// Span represents an active, un-finished span in the OpenTracing system.
//
// Spans are created by the Tracer interface.
// 这里注释进行了删减,比较重要的是 SetOperationName/SetTag/LogFields => Finish
type Span interface {
	// Sets the end timestamp and finalizes Span state.
	Finish()
	// FinishWithOptions is like Finish() but with explicit control over timestamps and log data.
	FinishWithOptions(opts FinishOptions)

	// Context() yields the SpanContext for this Span. Note that the return
	// value of Context() is still valid after a call to Span.Finish(), as is
	// a call to Span.Context() after a call to Span.Finish().
	Context() SpanContext

	// Sets or changes the operation name.
	SetOperationName(operationName string) Span

	// Adds a tag to the span.
	SetTag(key string, value interface{}) Span

	// LogFields is an efficient and type-checked way to record key:value
	// logging data about a Span, though the programming interface is a little
	// more verbose than LogKV(). Here's an example:
	//
	//    span.LogFields(
	//        log.String("event", "soft error"),
	//        log.String("type", "cache timeout"),
	//        log.Int("waited.millis", 1500))
	//
	// Also see Span.FinishWithOptions() and FinishOptions.BulkLogData.
	LogFields(fields ...log.Field)

	// LogKV is a concise, readable way to record key:value logging data about
	// a Span, though unfortunately this also makes it less efficient and less
	// type-safe than LogFields(). Here's an example:
	//
	//    span.LogKV(
	//        "event", "soft error",
	//        "type", "cache timeout",
	//        "waited.millis", 1500)
	//
	LogKV(alternatingKeyValues ...interface{})

	// SetBaggageItem sets a key:value pair on this Span and its SpanContext
	// that also propagates to descendants of this Span.
	SetBaggageItem(restrictedKey, value string) Span

	// Gets the value for a baggage item given its key. Returns the empty string
	// if the value isn't found in this Span.
	BaggageItem(restrictedKey string) string

	// Provides access to the Tracer that created this Span.
	Tracer() Tracer
}

span 如何传递

span 如何传递是 opentrace client 中比较重要的内容,毕竟如何 span 不能被跨进程传递,那么和本地日志的区别不是很大了(当然也有这样的使用场景,比如对本进程内的一组函数进行耗时分析)。opentrace client 内置了两种传递、存储方式,分别是 TextMap 和 HTTPHeaders:

  • TextMap 将 Span 信息写入一个 map
  • 而 HTTPHeaders 类似,只是把 Span 信息写入一个 Http Header,这样 Span 信息就实现了跨 Http 调用

理解了 Span 的原理,自己实现类似的传递方式并不困难,比如在 Grpc-go 里面使用 Meta 字段(类似 HttpHeader)传递Span 信息.

一个具体实现:Jaeger

一个 OpenTrace 的实现系统通常出来实现了 Opentrace 协议的 客户端之外,还包括

  • Agent (本地收集)/ Collector (汇总,Server 端)
  • 一个 DB (通常使用 cassandra 或者 Elasticsearch,也可用 memory 存在内存里面 用于测试) 做持久化存储
  • 一个 UI 用于展示
image

Jagger Client 的 Inject 使用的 Http Header 如下

func (p Propagator) Inject(
	sc jaeger.SpanContext,
	abstractCarrier interface{},
) error {
	textMapWriter, ok := abstractCarrier.(opentracing.TextMapWriter)
	if !ok {
		return opentracing.ErrInvalidCarrier
	}

	textMapWriter.Set("x-b3-traceid", sc.TraceID().String())
	if sc.ParentID() != 0 {
		textMapWriter.Set("x-b3-parentspanid", strconv.FormatUint(uint64(sc.ParentID()), 16))
	}
	textMapWriter.Set("x-b3-spanid", strconv.FormatUint(uint64(sc.SpanID()), 16))
	if sc.IsSampled() {
		textMapWriter.Set("x-b3-sampled", "1")
	} else {
		textMapWriter.Set("x-b3-sampled", "0")
	}
	sc.ForeachBaggageItem(func(k, v string) bool {
		textMapWriter.Set(p.baggagePrefix+k, v)
		return true
	})
	return nil
}

实战

实战例子改编自 https://github.com/yurishkuro/opentracing-tutorial

这个例子中我们使用 一个 client, 两个 server(publish,formatstring),其中 publish server 收到请求后,同时会异步的发一个 回调消息到 mq,而 client 端则等待这个异步消息并推出。

这个例子中除了最基础的 trace 使用,意在解释当常见的 carrier 不能满足要求,如何通过封装消息的方式来包装 span 信息

type Message struct {
	Body string
	Extra map[string]string
}

我们的做法为封装 mq 消息为 Message, 其中 Body 是实际 mq消息内容,而 Extra 为 Span 消息。通过定制 mq sdk,我们可以做到 Message 格式对用户不暴露。

本地启动 mq 和 jaegertracing

// jaegertracing
docker run --rm -p 6831:6831/udp -p 6832:6832/udp  -p 16686:16686 jaegertracing/all-in-one:1.7 --log-level=debug
// rabbitmq, 启动后进入容器新建 root 用户,新建一个 test queue
docker run --name rabbitmq -p 15672:15672 -p 5672:5672 ccr.ccs.tencentyun.com/wajika/rabbitmq-management:3.7.8

启动 server publish,formatstring后,运行一次 client,打开 http://localhost:16686/ 查看 trace 效果

image

实战代码在 https://github.com/u2takey/trace-example

参考