在分布式、微服务架构下,利用一个申请往往贯通多个分布式服务,这给利用的故障排查、性能优化带来新的挑战。分布式链路追踪作为解决分布式应用可观测问题的重要技术,愈发成为分布式应用不可短少的基础设施。本文将具体介绍分布式链路的外围概念、架构原理和相干开源标准协议,并分享咱们在实现无侵入 Go 采集 Sdk 方面的一些实际。


为什么须要分布式链路追踪零碎

微服务架构给运维、排障带来新挑战

在分布式架构下,当用户从浏览器客户端发动一个申请时,后端解决逻辑往往贯通多个分布式服务,这时会浮现很多问题,比方:

  1. 申请整体耗时较长,具体慢在哪个服务?
  2. 申请过程中出错了,具体是哪个服务报错?
  3. 某个服务的申请量如何,接口成功率如何?

答复这些问题变得不是那么简略,咱们不仅仅须要晓得某一个服务的接口解决统计数据,还须要理解两个服务之间的接口调用依赖关系,只有建设起整个申请在多个服务间的时空程序,能力更好的帮忙咱们了解和定位问题,而这,正是分布式链路追踪零碎能够解决的。

分布式链路追踪零碎如何帮忙咱们

分布式链路追踪技术的核心思想:在用户一次分布式申请服务的调⽤过程中,将申请在所有子系统间的调用过程和时空关系追踪记录下来,还原成调用链路集中展现,信息包含各个服务节点上的耗时、申请具体达到哪台机器上、每个服务节点的申请状态等等。

如上图所示,通过分布式链路追踪构建出残缺的申请链路后,能够很直观地看到申请耗时次要消耗在哪个服务环节,帮忙咱们更疾速聚焦问题。

同时,还能够对采集的链路数据做进一步的剖析,从而能够建设整个零碎各服务间的依赖关系、以及流量状况,帮忙咱们更好地排查零碎的循环依赖、热点服务等问题。

分布式链路追踪零碎架构概览

外围概念

在分布式链路追踪零碎中,最外围的概念,便是链路追踪的数据模型定义,次要包含 Trace 和 Span。

其中,Trace 是一个逻辑概念,示意一次(分布式)申请通过的所有部分操作(Span)形成的一条残缺的有向无环图,其中所有的 Span 的 TraceId 雷同。

Span 则是实在的数据实体模型,示意一次(分布式)申请过程的一个步骤或操作,代表零碎中一个逻辑运行单元,Span 之间通过嵌套或者顺序排列建设因果关系。Span 数据在采集端生成,之后上报到服务端,做进一步的解决。其蕴含如下要害属性:

  • Name:操作名称,如一个 RPC 办法的名称,一个函数名
  • StartTime/EndTime:起始工夫和完结工夫,操作的生命周期
  • ParentSpanId:父级 Span 的 ID
  • Attributes:属性,一组 <K,V> 键值对形成的汇合
  • Event:操作期间产生的事件
  • SpanContext:Span 上下文内容,通常用于在 Span 间流传,其外围字段包含 TraceId、SpanId

个别架构

分布式链路追踪零碎的外围工作是:围绕 Span 的生成、流传、采集、解决、存储、可视化、剖析,构建分布式链路追踪零碎。其个别的架构如下如所示:

  • 咱们看到,在利用端须要通过侵入或者非侵入的形式,注入 Tracing Sdk,以跟踪、生成、流传和上报申请调用链路数据;
  • Collect agent 个别是在凑近利用侧的一个边缘计算层,次要用于进步 Tracing Sdk 的写性能,和缩小 back-end 的计算压力;
  • 采集的链路跟踪数据上报到后端时,首先通过 Gateway 做一个鉴权,之后进入 kafka 这样的 MQ 进行音讯的缓冲存储;
  • 在数据写入存储层之前,咱们可能须要对音讯队列中的数据做一些荡涤和剖析的操作,荡涤是为了标准和适配不同的数据源上报的数据,剖析通常是为了反对更高级的业务性能,比方流量统计、谬误剖析等,这部分通常采纳flink这类的流解决框架来实现;
  • 存储层会是服务端设计选型的一个重点,要思考数据量级和查问场景的特点来设计选型,通常的抉择包含应用 Elasticsearch、Cassandra、或 Clickhouse 这类开源产品;
  • 流解决剖析后的后果,一方面作为存储长久化下来,另一方面也会进入告警零碎,以被动发现问题来告诉用户,如错误率超过指定阈值收回告警告诉这样的需要等。

方才讲的,是一个通用的架构,咱们并没有波及每个模块的细节,尤其是服务端,每个模块细讲起来都要很花些功夫,受篇幅所限,咱们把注意力集中到凑近利用侧的 Tracing Sdk,重点看看在利用侧具体是如何实现链路数据的跟踪和采集的。

协定规范和开源实现

方才咱们提到 Tracing Sdk,其实这只是一个概念,具体到实现,抉择可能会十分多,这其中的起因,次要是因为:

  1. 不同的编程语言的利用,可能采纳不同技术原理来实现对调用链的跟踪
  2. 不同的链路追踪后端,可能采纳不同的数据传输协定

以后,风行的链路追踪后端,比方 Zipin、Jaeger、PinPoint、Skywalking、Erda,都有供给用集成的 sdk,导致咱们在切换后端时利用侧可能也须要做较大的调整。

社区也呈现过不同的协定,试图解决采集侧的这种乱象,比方 OpenTracing、OpenCensus 协定,这两个协定也别离有一些大厂跟进反对,但最近几年,这两者曾经走向了交融对立,产生了一个新的规范 OpenTelemetry,这两年倒退迅猛,曾经逐步成为行业标准。

OpenTelemetry 定义了数据采集的规范 api,并提供了一组针对多语言的开箱即用的 sdk 实现工具,这样,利用只须要与 OpenTelemetry 外围 api 包强耦合,不须要与特定的实现强耦合。

利用侧调用链跟踪实现计划概览

利用侧外围工作

利用侧围绕 Span,有三个外围工作要实现:

  1. 生成 Span:操作开始构建 Span 并填充 StartTime,操作实现时填充 EndTime 信息,期间可追加 Attributes、Event 等
  2. 流传 Span:过程内通过 context.Context、过程间通过申请的 header 作为 SpanContext 的载体,流传的外围信息是 TraceId 和 ParentSpanId
  3. 上报 Span:生成的 Span 通过 tracing exporter 发送给 collect agent / back-end server

要实现 Span 的生成和流传,要求咱们可能拦挡利用的要害操作(函数)过程,并增加 Span 相干的逻辑。实现这个目标会有很多办法,不过,在列举这些办法之前,咱们先看看在 OpenTelemetry 提供的 go sdk 中是如何做的。

基于 OTEL 库实现调用拦挡

OpenTelemetry 的 go sdk 实现调用链拦挡的基本思路是:基于 AOP 的思维,采纳装璜器模式,通过包装替换指标包(如 net/http)的外围接口或组件,实现在外围调用过程前后增加 Span 相干逻辑。当然,这样的做法是有肯定的侵入性的,须要手动替换应用原接口实现的代码调用改为包装接口实现。

咱们以一个 http server 的例子来阐明,在 go 语言中,具体是如何做的:

假如有两个服务 serverA 和 serverB,其中 serverA 的接口收到申请后,外部会通过 httpclient 进一步发动到 serverB 的申请,那么 serverA 的外围代码可能如下图所示:

以 serverA 节点为例,在 serverA 节点应该产生至多两个 Span:

  1. Span1,记录 httpServer 收到一个申请后外部整体处理过程的一个耗时状况
  2. Span2,记录 httpServer 解决申请过程中,发动的另一个到 serverB 的 http 申请的耗时状况
  3. 并且 Span1 应该是 Span2 的 ParentSpan

咱们能够借助 OpenTelemetry 提供的 sdk 来实现 Span 的生成、流传和上报,上报的逻辑受篇幅所限咱们不再详述,重点来看看如何生成这两个 Span,并使这两个 Span 之间建设关联,即 Span 的生成和流传 。

HttpServer Handler 生成 Span 过程

对于 httpserver 来讲,咱们晓得其外围就是 http.Handler 这个接口。因而,能够通过实现一个针对 http.Handler 接口的拦截器,来负责 Span 的生成和流传。

package httptype Handler interface {    ServeHTTP(ResponseWriter, *Request)}http.ListenAndServe(":8090", http.DefaultServeMux)

要应用 OpenTelemetry Sdk 提供的 http.Handler 装璜器,须要如下调整 http.ListenAndServe 办法:

import (  "net/http"  "go.opentelemetry.io/otel"  "go.opentelemetry.io/otel/sdk/trace"  "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp")wrappedHttpHandler := otelhttp.NewHandler(http.DefaultServeMux, ...)http.ListenAndServe(":8090", wrappedHttpHandler)

如图所示,wrppedHttpHandler 中将次要实现如下逻辑(精简思考,此处局部为伪代码):

ctx := tracer.Extract(r.ctx, r.Header):从申请的 header 中提取 traceparent header 并解析,提取 TraceId和 SpanId,进而构建 SpanContext 对象,并最终存储在 ctx 中;

ctx, span := tracer.Start(ctx, genOperation(r)):生成跟踪以后申请处理过程的 Span(即前文所述的Span1),并记录开始工夫,这时会从 ctx 中读取 SpanContext,将 SpanContext.TraceId 作为以后 Span 的TraceId,将 SpanContext.SpanId 作为以后 Span的ParentSpanId,而后将本人作为新的 SpanContext 写入返回的 ctx 中;

r.WithContext(ctx):将新生成的 SpanContext 增加到申请 r 的 context 中,以便被拦挡的 handler 外部在处理过程中,能够从 r.ctx 中拿到 Span1 的 SpanId 作为其 ParentSpanId 属性,从而建设 Span 之间的父子关系;

span.End():当 innerHttpHandler.ServeHTTP(w,r) 执行实现后,就须要对 Span1 记录一下解决实现的工夫,而后将它发送给 exporter 上报到服务端。

HttpClient 申请生成 Span 过程

咱们再接着看 serverA 外部去申请 serverB 时的 httpclient 申请是如何生成 Span 的(即前文说的 Span2)。咱们晓得,httpclient 发送申请的要害操作是 http.RoundTriper 接口:

package httptype RoundTripper interface {  RoundTrip(*Request) (*Response, error)}

OpenTelemetry 提供了基于这个接口的一个拦截器实现,咱们须要应用这个实现包装一下 httpclient 原来应用的 RoundTripper 实现,代码调整如下:

import (  "net/http"  "go.opentelemetry.io/otel"  "go.opentelemetry.io/otel/sdk/trace"  "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp")wrappedTransport := otelhttp.NewTransport(http.DefaultTransport)client := http.Client{Transport: wrappedTransport}

如图所示,wrappedTransport 将次要实现以下工作(精简思考,此处局部为伪代码):

req, _ := http.NewRequestWithContext(r.ctx, “GET”,url, nil) :这里咱们将上一步 http.Handler 的申请的 ctx,传递到 httpclient 要收回的 request 中,这样在之后咱们就能够从 request.Context() 中提取出 Span1 的信息,来建设 Span 之间的关联;

ctx, span := tracer.Start(r.Context(), url):执行 client.Do() 之后,将首先进入 WrappedTransport.RoundTrip() 办法,这里生成新的 Span(Span2),开始记录 httpclient 申请的耗时状况,与前文一样,Start 办法外部会从 r.Context() 中提取出 Span1 的 SpanContext,并将其 SpanId 作为以后 Span(Span2)的 ParentSpanId,从而建设了 Span 之间的嵌套关系,同时返回的 ctx 中保留的 SpanContext 将是新生成的 Span(Span2)的信息;

tracer.Inject(ctx, r.Header):这一步的目标是将以后 SpanContext 中的 TraceId 和 SpanId 等信息写入到 r.Header 中,以便可能随着 http 申请发送到 serverB,之后在 serverB 中与以后 Span 建设关联;

span.End():期待 httpclient 申请发送到 serverB 并收到响应当前,标记以后 Span 跟踪完结,设置 EndTime 并提交给 exporter 以上报到服务端。

基于 OTEL 库实现调用链跟踪总结

咱们比拟具体的介绍了应用 OpenTelemetry 库,是如何实现链路的要害信息(TraceId、SpanId)是如何在过程间和过程内流传的,咱们对这种跟踪实现形式做个小的总结:

如上剖析所展现的,应用这种形式的话,对代码还是有肯定的侵入性,并且对代码有另一个要求,就是放弃 context.Context 对象在各操作间的传递,比方,方才咱们在 serverA 中创立 httpclient 申请时,应用的是
http.NewRequestWithContext(r.ctx, ...) 而非http.NewRequest(...)办法,另外开启 goroutine 的异步场景也须要留神 ctx 的传递。

非侵入调用链跟踪实现思路

咱们方才具体展现了基于惯例的一种具备肯定侵入性的实现,其侵入性次要体现在:咱们须要显式的手动增加代码应用具备跟踪性能的组件包装原代码,这进一步会导致利用代码须要显式的援用具体版本的 OpenTelemetry instrumentation 包,这不利于可观测代码的独立保护和降级。

那咱们有没有能够实现非侵入跟踪调用链的计划可选?

所谓无侵入,其实也只是集成的形式不同,集成的指标其实是差不多的,最终都是要通过某种形式,实现对要害调用函数的拦挡,并退出非凡逻辑,无侵入重点在于代码无需批改或极少批改。

上图列出了当初可能的一些无侵入集成的实现思路,与 .net、java 这类有 IL 语言的编程语言不同,go 间接编译为机器码,导致无侵入的计划实现起来绝对比拟麻烦,具体有如下几种思路:

  1. 编译阶段注入:能够扩大编译器,批改编译过程中的ast,插入跟踪代码,须要适配不同编译器版本。
  2. 启动阶段注入:批改编译后的机器码,插入跟踪代码,须要适配不同 CPU 架构。如 monkey, gohook。
  3. 运行阶段注入:通过内核提供的 eBPF 能力,监听程序要害函数执行,插入跟踪代码,前景光明!如,tcpdump,bpftrace。

Go 非侵入链路追踪实现原理

Erda 我的项目的外围代码次要是基于 golang 编写的,咱们基于前文所述的 OpenTelemetry sdk,采纳基于批改机器码的的形式,实现了一种无侵入的链路追踪形式。

前文提到,应用 OpenTelemetry sdk 须要代码做一些调整,咱们看看这些调整如何以非侵入的形式主动的实现:

咱们以 httpclient 为例,做简要的解释。

gohook 框架提供的 hook 接口的签名如下:

// target 要hook的指标函数// replacement 要替换为的函数// trampoline 将源函数入口拷贝到的地位,可用于从replcement跳转回原targetfunc Hook(target, replacement, trampoline interface{}) error

对于 http.Client,咱们能够抉择 hook DefaultTransport.RoundTrip() 办法,当该办法执行时,咱们通过 otelhttp.NewTransport() 包装起原 DefaultTransport 对象,但须要留神的是,咱们不能将 DefaultTransport 间接作为 otelhttp.NewTransport() 的参数,因为其 RoundTrip() 办法曾经被咱们替换了,而其原来真正的办法被写到了 trampoline 中,所以这里咱们须要一个中间层,来连贯 DefaultTransport 与其原来的 RoundTrip 办法。具体代码如下:

//go:linkname RoundTrip net/http.(*Transport).RoundTrip//go:noinline// RoundTrip .func RoundTrip(t *http.Transport, req *http.Request) (*http.Response, error)//go:noinlinefunc originalRoundTrip(t *http.Transport, req *http.Request) (*http.Response, error) {  return RoundTrip(t, req)}type wrappedTransport struct {  t *http.Transport}//go:noinlinefunc (t *wrappedTransport) RoundTrip(req *http.Request) (*http.Response, error) {  return originalRoundTrip(t.t, req)}//go:noinlinefunc tracedRoundTrip(t *http.Transport, req *http.Request) (*http.Response, error) {  req = contextWithSpan(req)  return otelhttp.NewTransport(&wrappedTransport{t: t}).RoundTrip(req)}//go:noinlinefunc contextWithSpan(req *http.Request) *http.Request {  ctx := req.Context()  if span := trace.SpanFromContext(ctx); !span.SpanContext().IsValid() {    pctx := injectcontext.GetContext()    if pctx != nil {      if span := trace.SpanFromContext(pctx); span.SpanContext().IsValid() {        ctx = trace.ContextWithSpan(ctx, span)        req = req.WithContext(ctx)      }    }  }  return req}func init() {  gohook.Hook(RoundTrip, tracedRoundTrip, originalRoundTrip)}

咱们应用 init() 函数实现了主动增加 hook,因而用户程序里只须要在 main 文件中 import 该包,即可实现无侵入的集成。

值得一提的是 req = contextWithSpan(req) 函数,外部会顺次尝试从 req.Context() 和 咱们保留的 goroutineContext map 中查看是否蕴含 SpanContext,并将其赋值给 req,这样便能够解除了必须应用 http.NewRequestWithContext(...) 写法的要求。

具体的代码能够查看 Erda 仓库:
https://github.com/erda-proje...

参考链接

  • https://opentelemetry.io/regi...
  • https://opentelemetry.io/docs...
  • https://www.ipeapea.cn/post/g...
  • https://github.com/brahma-ads...
  • https://www.jianshu.com/p/7b3...
  • https://paper.seebug.org/1749/