在微服务架构中,调用链是漫长而复杂的,要了解其中的每一个环节及其性能,你须要全链路跟踪。 它的原理很简单,你能够在每一个请求开始时生成一个惟一的ID,并将其传递到整个调用链。 该ID称为CorrelationID¹,你能够用它来跟踪整个请求并得到各个调用环节的性能指标。简单来讲有两个问题须要解决。第一,如何在应用程序内部传递ID; 第二,当你须要调用另外一个微服务时,如何经过网络传递ID。html
如今有许多开源的分布式跟踪库可供选择,其中最受欢迎的库多是Zipkin²和Jaeger³。 选择哪一个是一个使人头疼的问题,由于你如今能够选择最受欢迎的一个,可是若是之后有一个更好的出现呢?OpenTracing⁴能够帮你解决这个问题。它创建了一套跟踪库的通用接口,这样你的程序只须要调用这些接口而不被具体的跟踪库绑定,未来能够切换到不一样的跟踪库而无需更改代码。Zipkin和Jaeger都支持OpenTracing。mysql
在下面的程序中我使用“Zipkin”做为跟踪库,用“OpenTracing”做为通用跟踪接口。 跟踪系统中一般有四个组件,下面我用Zipkin做为示例:git
上面是Zipkin的组件图,你能够在Zipkin Architecture中找到它。github
有两种不一样类型的跟踪,一种是进程内跟踪(in-process),另外一种是跨进程跟踪(cross-process)。 咱们将首先讨论跨进程跟踪。golang
客户端程序:sql
咱们将用一个简单的gRPC程序做为示例,它分红客户端和服务器端代码。 咱们想跟踪一个完整的服务请求,它从客户端到服务端并从服务端返回。 如下是在客户端建立新跟踪器的代码。它首先建立“HTTP Collector”(the agent)用来收集跟踪数据并将其发送到“Zipkin” UI, “endpointUrl”是“Zipkin” UI的URL。 其次,它建立了一个记录器(recorder)来记录端点上的信息,“hostUrl”是gRPC(客户端)呼叫的URL。第三,它用咱们新建的记录器建立了一个新的跟踪器(tracer)。 最后,它为“OpenTracing”设置了“GlobalTracer”,这样你能够在程序中的任何地方访问它。数据库
const (
endpoint_url = "http://localhost:9411/api/v1/spans"
host_url = "localhost:5051"
service_name_cache_client = "cache service client"
service_name_call_get = "callGet"
)
func newTracer () (opentracing.Tracer, zipkintracer.Collector, error) {
collector, err := openzipkin.NewHTTPCollector(endpoint_url)
if err != nil {
return nil, nil, err
}
recorder :=openzipkin.NewRecorder(collector, true, host_url, service_name_cache_client)
tracer, err := openzipkin.NewTracer(
recorder,
openzipkin.ClientServerSameSpan(true))
if err != nil {
return nil,nil,err
}
opentracing.SetGlobalTracer(tracer)
return tracer,collector, nil
}复制代码
如下是gRPC客户端代码。 它首先调用上面提到的函数“newTrace()”来建立跟踪器,而后,它建立一个包含跟踪器的gRPC调用链接。接下来,它使用新建的gRPC链接建立缓存服务(Cache service)的gRPC客户端。 最后,它经过gRPC客户端来调用缓存服务的“Get”函数。api
key:="123"
tracer, collector, err :=newTracer()
if err != nil {
panic(err)
}
defer collector.Close()
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.NewCacheServiceClient(connection)
value, err := callGet(key, client)复制代码
Trace 和 Span:缓存
在OpenTracing中,一个重要的概念是“trace”,它表示从头至尾的一个请求的调用链,它的标识符是“traceID”。 一个“trace”包含有许多跨度(span),每一个跨度捕获调用链内的一个工做单元,并由“spanId”标识。 每一个跨度具备一个父跨度,而且一个“trace”的全部跨度造成有向无环图(DAG)。 如下是跨度之间的关系图。 你能够从The OpenTracing Semantic Specification中找到它。服务器
如下是函数“callGet”的代码,它调用了gRPC服务端的“Get"函数。 在函数的开头,OpenTracing为这个函数调用开启了一个新的span,整个函数结束后,它也结束了这个span。
const service_name_call_get = "callGet"
func callGet(key string, c pb.CacheServiceClient) ( []byte, error) {
span := opentracing.StartSpan(service_name_call_get)
defer span.Finish()
time.Sleep(5*time.Millisecond)
// Put root span in context so it will be used in our calls to the client.
ctx := opentracing.ContextWithSpan(context.Background(), span)
//ctx := context.Background()
getReq:=&pb.GetReq{Key:key}
getResp, err :=c.Get(ctx, getReq )
value := getResp.Value
return value, err
}复制代码
服务端代码:
下面是服务端代码,它与客户端代码相似,它调用了“newTracer()”(与客户端“newTracer()”函数几乎相同)来建立跟踪器。而后,它建立了一个“OpenTracingServerInterceptor”,其中包含跟踪器。 最后,它使用咱们刚建立的拦截器(Interceptor)建立了gRPC服务器。
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)
} else {
fmt.Println("server listening on port 5051")
}复制代码
如下是运行上述代码后在Zipkin中看到的跟踪和跨度的图片。 在服务器端,咱们不须要在函数内部编写任何代码来生成span,咱们须要作的就是建立跟踪器(tracer),服务器拦截器自动为咱们生成span。
上面的图片没有告诉咱们函数内部的跟踪细节, 咱们须要编写一些代码来得到它。
如下是服务器端“get”函数,咱们在其中添加了跟踪代码。 它首先从上下文获取跨度(span),而后建立一个新的子跨度并使用咱们刚刚得到的跨度做为父跨度。 接下来,它执行一些操做(例如数据库查询),而后结束(mysqlSpan.Finish())子跨度。
const service_name_db_query_user = "db query user"
func (c *CacheService) Get(ctx context.Context, req *pb.GetReq) (*pb.GetResp, error) {
time.Sleep(5*time.Millisecond)
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
}复制代码
如下是它运行后的图片。 如今它在服务器端有一个新的跨度“db query user”。
如下是zipkin中的跟踪数据。 你能够看到客户端从8.016ms开始,服务端也在同一时间启动。 服务器端完成须要大约16ms。
怎样才能跟踪数据库内部的操做?首先,数据库驱动程序须要支持跟踪,另外你须要将跟踪器(tracer)传递到数据库函数中。若是数据库驱动程序不支持跟踪怎么办?如今已经有几个开源驱动程序封装器(Wrapper),它们能够封装任何数据库驱动程序并使其支持跟踪。其中一个是instrumentedsql⁷(另外两个是luna-duclos/instrumentedsql⁸和ocsql/driver.go⁹)。我简要地看了一下他们的代码,他们的原理基本相同。它们都为底层数据库的每一个函数建立了一个封装(Wrapper),并在每一个数据库操做以前启动一个新的跨度,并在操做完成后结束跨度。可是全部这些都只封装了“database/sql”接口,这就意味着NoSQL数据库没有办法使用他们。若是你找不到支持你须要的NoSQL数据库(例如MongoDB)的OpenTracing的驱动程序,你可能须要本身编写一个封装(Wrapper),它并不困难。
一个问题是“若是我使用OpenTracing和Zipkin而数据库驱动程序使用Openeracing和Jaeger,那会有问题吗?"这其实不会发生。我上面提到的大部分封装都支持OpenTracing。在使用封装时,你须要注册封装了的SQL驱动程序,其中包含跟踪器。在SQL驱动程序内部,全部跟踪函数都只调用了OpenTracing的接口,所以它们甚至不知道底层实现是Zipkin仍是Jaeger。如今使用OpenTarcing的好处终于体现出来了。在应用程序中建立全局跟踪器时(Global tracer),你须要决定是使用Zipkin仍是Jaeger,但这以后,应用程序或第三方库中的每一个函数都只调用OpenTracing接口,已经与具体的跟踪库(Zipkin或Jaeger)不要紧了。
假设咱们须要在gRPC服务中调用另一个微服务(例如RESTFul服务),该如何跟踪?
简单来讲就是使用HTTP头做为媒介(Carrier)来传递跟踪信息(traceID)。不管微服务是gRPC仍是RESTFul,它们都使用HTTP协议。若是是消息队列(Message Queue),则将跟踪信息(traceID)放入消息报头中。(Zipkin B3-propogation有“single header”和“multiple header”有两种不一样类型的跟踪信息,但JMS仅支持“single header”)
一个重要的概念是“跟踪上下文(trace context)”,它定义了传播跟踪所需的全部信息,例如traceID,parentId(父spanId)等。有关详细信息,请阅读跟踪上下文(trace context)¹⁰。
OpenTracing提供了两个处理“跟踪上下文(trace context)”的函数:“extract(format,carrier)”和“inject(SpanContext,format,carrier)”。 “extarct()”从媒介(一般是HTTP头)获取跟踪上下文。 “inject”将跟踪上下文放入媒介,来保证跟踪链的连续性。如下是我从Zipkin获取的b3-propagation图。
可是为何咱们没有在上面的例子中调用这些函数呢?让咱们再来回顾一下代码。在客户端,在建立gRPC客户端链接时,咱们调用了一个为“OpenTracingClientInterceptor”的函数。 如下是“OpenTracingClientInterceptor”的部分代码,我从otgrpc¹¹包中的“client.go”中获得了它。它已经从Go context¹²获取了跟踪上下文并将其注入HTTP头,所以咱们再也不须要再次调用“inject”函数。
func OpenTracingClientInterceptor(tracer opentracing.Tracer, optFuncs ...Option)
grpc.UnaryClientInterceptor {
...
ctx = injectSpanContext(ctx, tracer, clientSpan)
...
}
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)
}复制代码
在服务器端,咱们还调用了一个函数“otgrpc.OpenTracingServerInterceptor”,其代码相似于客户端的“OpenTracingClientInterceptor”。它不是调用“inject”写入跟踪上下文,而是从HTTP头中提取(extract)跟踪上下文并将其放入Go上下文(Go context)中。 这就是咱们不须要再次手动调用“extract()”的缘由。 咱们能够直接从Go上下文中提取跟踪上下文(opentracing.SpanFromContext(ctx))。 但对于其余基于HTTP的服务(如RESTFul服务), 状况就并不是如此,所以咱们须要写代码从服务器端的HTTP头中提取跟踪上下文。 固然,您也可使用拦截器或过滤器。
你也许会问“若是个人程序使用Zipkin和OpenTracing而须要调用的第三方微服务使用OpenTracing与Jaeger,它们会兼容吗?"它看起来于咱们以前询问的数据库问题相似,但实际上很不相同。对于数据库,由于应用程序和数据库在同一个进程中,它们能够共享相同的全局跟踪器,所以更容易解决。对于微服务,这种方式将不兼容。由于OpenTracing只标准化了跟踪接口,它没有标准化跟踪上下文。万维网联盟(W3C)正在制定跟踪上下文(trace context)¹⁰的标准,并于2019-08-09年发布了候选推荐标准。OpenTracing没有规定跟踪上下文的格式,而是把决定权留给了实现它的跟踪库。结果每一个库都选择了本身独有的的格式。例如,Zipkin使用“X-B3-TraceId”做为跟踪ID,Jaeger使用“uber-trace-id”,所以使用OpenTracing并不意味着不一样的跟踪库能够进行跨网互操做。 对于“Jaeger”来讲有一个好处是你能够选择使用“Zipkin兼容性功能"¹³来生成Zipkin跟踪上下文, 这样就能够与Zipkin相互兼容了。对于其余状况,你须要本身进行手动格式转换(在“inject”和“extract”之间)。
尽可能少写代码
一个好的全链路跟踪系统不须要用户编写不少跟踪代码。最理想的状况是你不须要任何代码,让框架或库负责处理它,固然这比较困难。 全链路跟踪分红三个跟踪级别:
跨进程跟踪是最简单的。你能够编写拦截器或过滤器来跟踪每一个请求,它只须要编写极少的编码。数据库跟踪也比较简单。若是使用咱们上面讨论过的封装器(Wrapper),你只须要注册SQL驱动程序封装器(Wrapper)并将go-context(里面有跟踪上下文) 传入数据库函数。你可使用依赖注入(Dependency Injection)这样就能够用比较少的代码来完成此操做。
进程内跟踪是最困难的,由于你必须为每一个单独的函数编写跟踪代码。如今尚未一个很好的方法,能够编写一个通用的函数来跟踪应用程序中的每一个函数(拦截器不是一个好选择,由于它须要每一个函数的参数和返回都必须是一个泛型类型(interface {}))。幸运的是,对于大多数人来讲,前两个级别的跟踪应该已经足够了。
有些人可能会使用服务网格(service mesh)来实现分布式跟踪,例如Istio或Linkerd。它确实是一个好主意,跟踪最好由基础架构实现,而不是将业务逻辑代码与跟踪代码混在一块儿,不过你将遇到咱们刚才谈到的一样问题。服务网格只负责跨进程跟踪,函数内部或数据库跟踪任然须要你来编写代码。不过一些服务网格能够经过提供与流行跟踪库的集成,来简化不一样跟踪库跨网跟踪时的的上下文格式转换。
跟踪设计:
精心设计的跨度(span),服务名称(service name),标签(tag)能充分发挥全链路跟踪的做用,并使之简单易用。有关信息请阅读语义约定(Semantic Conventions)¹⁴。
将Trace ID记录到日志
将跟踪与日志记录集成是一个常见的需求,最重要的是将跟踪ID记录到整个调用链的日志消息中。 目前OpenTracing不提供访问traceID的方法。 你能够将“OpenTracing.SpanContext”转换为特定跟踪库的“SpanContext”(Zipkin和Jaeger均可以经过“SpanContext”访问traceID)或将“OpenTracing.SpanContext”转换为字符串并解析它以获取traceID。转换为字符串更好,由于它不会破坏程序的依赖关系。 幸运的是不久的未来你就不须要它了,由于OpenTracing将提供访问traceID的方法,请阅读这里。
OpenCensus¹⁵不是另外一个通用跟踪接口,它是一组库,能够用来与其余跟踪库集成以完成跟踪功能,所以它常常与OpenTracing进行比较。 那么它与OpenTracing兼容吗?答案是否认的。 所以,在选择跟踪接口时(不管是OpenTracing仍是OpenCensus)须要当心,以确保你须要调用的其余库支持它。 一个好消息是,你不须要在未来作出选择,由于它们会将项目合并为一个¹⁶。
全链路跟踪包括不一样的场景,例如在函数内部跟踪,数据库跟踪和跨进程跟踪。 每一个场景都有不一样的问题和解决方案。若是你想设计更好的跟踪解决方案或为你的应用选择最适合的跟踪工具或库,那你须要对每种状况都有清晰的了解。
[1]Correlation IDs for microservices architectureshilton.org.uk/blog/micros…
[2]Zipkinzipkin.io
[3]Jaeger: open source, end-to-end distributed tracingwww.jaegertracing.io
[4]OpenTracingopentracing.io/docs/gettin…
[5]Zipkin Architecturezipkin.io/pages/archi…
[6]The OpenTracing Semantic Specificationopentracing.io/specificati…
[7]instrumentedsqlgithub.com/ExpansiveWo…
[8]luna-duclos/instrumentedsqlgithub.com/luna-duclos…
[9]ocsql/driver.gogithub.com/opencensus-…
[10]Trace Contextwww.w3.org/TR/trace-co…
[11]otgrpcgithub.com/grpc-ecosys…
[12]Go Concurrency Patterns: Contextblog.golang.org/context
[13]Zipkin compatibility featuresgithub.com/jaegertraci…
[14]Semantic Conventionsgithub.com/opentracing…
[15]OpenCensusopencensus.io/
[16]merge the project into onemedium.com/opentracing…