关于微服务:一文详解|Go-分布式链路追踪实现原理

75次阅读

共计 8408 个字符,预计需要花费 22 分钟才能阅读完成。

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

type 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 http

type 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 跳转回原 target

func 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:noinline
func originalRoundTrip(t *http.Transport, req *http.Request) (*http.Response, error) {return RoundTrip(t, req)
}

type wrappedTransport struct {t *http.Transport}

//go:noinline
func (t *wrappedTransport) RoundTrip(req *http.Request) (*http.Response, error) {return originalRoundTrip(t.t, req)
}

//go:noinline
func tracedRoundTrip(t *http.Transport, req *http.Request) (*http.Response, error) {req = contextWithSpan(req)
  return otelhttp.NewTransport(&wrappedTransport{t: t}).RoundTrip(req)
}

//go:noinline
func 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/

正文完
 0