链路追踪

Golang
460
0
0
2022-08-31

opentracing实战

  • 解决微服务架构下,链路追踪,问题定位和可视化分析等问题,链路追踪还是微服务可观测性的重要基石,本文就实战了在Go项目中如何将opentracing落地。

Opentracing

注意:Opentracing 是一套标准接口,而不是具体实现。
这里就实战opentracing + jaeger 的链路追踪方案。其中 opentracing 是一套标准接口,而jaeger是包含了 opentracing 的实现的一套工具。 Trace链路简单示例如下:

Trace

描述在分布式系统中的一次”事务”。

Span

表示工作流的一部分的命名和定时操作。可以接受标签(Tag Key:Value),以及附加到特定span实例的标注(Annotation),如时间戳和结构化日志。

SpanContext

追踪伴随分布式事务的信息,包括它通过网络或通过消息总线将服务传递给服务的时间。span上下文包含TraceId、SpanId和追踪系统需要传播到下游服务的其他数据。

实战

  • 这里我准备的是 Go 项目,服务之间通过gRPC通信。链路如下:
 +-- process internal trace2
                                |
                     +---> process internal trace1
                     |
                     |                 +---> server-b trace(gRPC)
entry(HTTP) ---> server-a trace--gRPC--|
                                       +---> server-c trace(gRPC)
                                                   |
                                                   +----> process internal trace3

从上图中可以明确,我们的目标是:实践 *跨服务调用 **服务内部调用 *的链路追踪,配合jaeger我们还可以将链路信息可视化。

落地

为了回答这个问题,我把这个问题结合opentracing的概念再分解一下:
1. ParentSpan 从哪儿来?
2. ChildSpan由ParentSpan创建,那么什么时候创建?
3. 链路Tracer从哪儿来?
4. Trace信息怎么传递?
5. 链路信息如何搜集?

1. ParentSpan 从哪儿来?

在上述的实践目标中,我们只有一个入口服务,那么很明显每一次的”事务”都是在这个入口(http entry)开启的。我写了如下的中间件(基于gin)。
// 这个定义是因为opentracing没有定义获取traceId 和spanId的方法,而我又实践了 zipkin 和 jaeger
type getTraceID func(spCtx opentracing.SpanContext) string

// get trace info from header, if not then create an new one
func Opentracing(getTraceIdFromSpanContext getTraceID) gin.HandlerFunc {
    return func(c *gin.Context) {
        // prepare work ... 
        // 这里首先尝试从客户端请求中获取到trace链路信息,如果获取到则创建child span.
        carrier := opentracing.HTTPHeadersCarrier(c.Request.Header)
        clientSpCtx, err := tracer.Extract(opentracing.HTTPHeaders, carrier)
        if err != nil {
            log.Printf("could not extract trace data from http header, err=%v\n", err)
        }
        // derive a span or create an root span
        sp = tracer.StartSpan(
            c.Request.RequestURI,
            opentracing.ChildOf(clientSpCtx),
        )
        defer sp.Finish()
        // do some work 
        // ... 
        // 将含有span的 context.Context 设置到 gin.Context 用于传递span
        ctx = opentracing.ContextWithSpan(c.Request.Context(), sp)
        c.Set(_traceContextKey, ctx)
        traceId := getTraceIdFromSpanContext(sp.Context())
        c.Header("X-Trace-Id", traceId)
        // continue process request
        c.Next()
        // do some work  
        // ...
    }
}

2. ChildSpan由ParentSpan创建,那么什么时候创建?

其实在上述的中间件里已经有了体现(跨服务调用时)。这里主要有两种场景:跨服务调用,服务内部调,这两种的区别在于是否是同一个进程。 针对跨服务调用,我们使用了gRPC来通信,那么创建的时机就在于gRPC客户端发起调用时,需要创建一个childSpan并传递给服务端,服务端需要解析到该span并在当次请求中使用。 而对于服务内部的调用,相较而言会更简单一点,直接使用该span创建childSpan就好了。
这里需要注意的是,一般来说在Go开发过程中推荐使用Context作为函数调用的第一个参数,opentracing也考虑了这一点,如下: 官方提供了对应的方法,来帮助使用者把span和context.Context一起使用。
// ContextWithSpan returns a new `context.Context` that holds a reference to
// the span. If span is nil, a new context without an active span is returned.
func ContextWithSpan(ctx context.Context, span Span) context.Context {
    if span != nil {
        if tracerWithHook, ok := span.Tracer().(TracerContextWithSpanExtension); ok {
            ctx = tracerWithHook.ContextWithSpanHook(ctx, span)
        }
    }
    return context.WithValue(ctx, activeSpanKey, span)
}

// SpanFromContext returns the `Span` previously associated with `ctx`, or
// `nil` if no such `Span` could be found.
//
// NOTE: context.Context != SpanContext: the former is Go's intra-process
// context propagation mechanism, and the latter houses OpenTracing's per-Span
// identity and baggage information.
func SpanFromContext(ctx context.Context) Span {
    val := ctx.Value(activeSpanKey)
    if sp, ok := val.(Span); ok {
        return sp
    }
    return nil
}
在中间件那里,我们已经把 context.Context 设置到了 gin.Context 中去了,因此在后续的使用中,我们需要把它从 gin.Context 取出并传递。
// traceHdl is a trace handler from HTTP request
func traceHdl(c *gin.Context) {
    // get root Context from request 
    // TODO: try to use c.Request.WithContext() to set context
    ctx, ok := c.Get(x.GetTraceContextKey())
    if !ok {
        panic("impossible")
    }
    // ctx在服务内部传递,ctx含有span信息 
    if err := clientCall(ctx.(context.Context)); err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"message": err.Error()})
        return
    }
    // response to client
    c.JSON(http.StatusOK, gin.H{"message": "traceHdl done"})
}
func clientCall(ctx context.Context) error {
    // 注意:这里使用了gRPC来做跨服务的调用,对于ctx的处理,是通过interceptor实现的。 
    // 这部分代码会在后面贴上,但是逻辑也跟类似,只是多了需要适配gRPC数据传递的规则。
    _, err := serverAConn.PingA(ctx, &pb.PingAReq{
        Now:  time.Now().Unix(),
        From: "client",
    })
    if err != nil {
        return err
    }
    // 这里演示进程内链路 
    return processInternalTrace1(ctx)
}
// internal process trace example 1
func processInternalTrace1(ctx context.Context) error {
    ctx2, sp := x.StartSpanFromContext(ctx)
    defer sp.Finish()
    println("processInternalTrace1 called")
    // do some ops
    time.Sleep(10 * time.Millisecond)
    return processInternalTrace2(ctx2)
}
  • gRPC interceptor 实现如下:服务端工作内容相似,只是从inject操作变成了extract。
// client interceptor
func OpenTracingClientInterceptor(tracer opentracing.Tracer, optFuncs ...Option) grpc.UnaryClientInterceptor {
    otgrpcOpts := newOptions()
    otgrpcOpts.apply(optFuncs...)
    return func(
        ctx context.Context,m
        ethod string, 
        req, resp interface{}, 
        cc *grpc.ClientConn, 
        invoker grpc.UnaryInvoker, 
        opts ...grpc.CallOption,
    ) error {
        var err error 
        var parentCtx opentracing.SpanContext
        if parent := opentracing.SpanFromContext(ctx); parent != nil {
            parentCtx = parent.Context()
        }
        // ...
        clientSpan := tracer.StartSpan(
            method,
            opentracing.ChildOf(parentCtx),
            ext.SpanKindRPCClient,
            gRPCComponentTag,
        )
        defer clientSpan.Finish()
        // 注入
        ctx = injectSpanContext(ctx, tracer, clientSpan)
        // ... 
        // 调用
        err = invoker(ctx, method, req, resp, cc, opts...)
        // ...       
        return err 
    }
}

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)
}
至此我们就介绍完了,服务间(gRPC)调用和服务内(context)调用的childSpan的创建和传递。

3. 链路Tracer从哪儿来?

先说Tracer有什么用,Tracer是用来管理Span的统筹者,负责创建span和传播span。
// Tracer负责创建span和传播span
type Tracer interface {

    // Create, start, and return a new Span with the given `operationName` and 
    // incorporate the given StartSpanOption `opts`. (Note that `opts` borrows 
    // from the "functional options" pattern, per 
    // http://dave.cheney.net/2014/10/17/functional-options-for-friendly-apis) 
    // 
    // A Span with no SpanReference options (e.g., opentracing.ChildOf() or 
    // opentracing.FollowsFrom()) becomes the root of its own trace. 
    // 
    // Examples: 
    // 
    //     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() takes the `sm` SpanContext instance and injects it for 
    // propagation within `carrier`. The actual type of `carrier` depends on 
    // the value of `format`. 
    // 
    // OpenTracing defines a common set of `format` values (see BuiltinFormat), 
    // and each has an expected carrier type. 
    // 
    // Other packages may declare their own `format` values, much like the keys 
    // used by `context.Context` (see https://godoc.org/context#WithValue). 
    // 
    // Example usage (sans error handling): 
    // 
    //     carrier := opentracing.HTTPHeadersCarrier(httpReq.Header) 
    //     err := tracer.Inject( 
    //         span.Context(), 
    //         opentracing.HTTPHeaders, 
    //         carrier) 
    // 
    // NOTE: All opentracing.Tracer implementations MUST support all 
    // BuiltinFormats. 
    // 
    // Implementations may return opentracing.ErrUnsupportedFormat if `format` 
    // is not supported by (or not known by) the implementation. 
    // 
    // Implementations may return opentracing.ErrInvalidCarrier or any other 
    // implementation-specific error if the format is supported but injection 
    // fails anyway. 
    // 
    // See Tracer.Extract(). 
    Inject(sm SpanContext, format interface{}, carrier interface{}) error

    // Extract() returns a SpanContext instance given `format` and `carrier`. 
    // 
    // OpenTracing defines a common set of `format` values (see BuiltinFormat), 
    // and each has an expected carrier type. 
    // 
    // Other packages may declare their own `format` values, much like the keys 
    // used by `context.Context` (see 
    // https://godoc.org/golang.org/x/net/context#WithValue). 
    // 
    // Example usage (with StartSpan): 
    // 
    // 
    //     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) 
    //     } 
    // 
    // 
    // NOTE: All opentracing.Tracer implementations MUST support all 
    // BuiltinFormats. 
    // 
    // Return values: 
    //  - A successful Extract returns a SpanContext instance and a nil error 
    //  - If there was simply no SpanContext to extract in `carrier`, Extract() 
    //    returns (nil, opentracing.ErrSpanContextNotFound) 
    //  - If `format` is unsupported or unrecognized, Extract() returns (nil, 
    //    opentracing.ErrUnsupportedFormat) 
    //  - If there are more fundamental problems with the `carrier` object, 
    //    Extract() may return opentracing.ErrInvalidCarrier, 
    //    opentracing.ErrSpanContextCorrupted, or implementation-specific 
    //    errors. 
    // 
    // See Tracer.Inject(). 
    Extract(format interface{}, carrier interface{}) (SpanContext, error)
}
之前已经提到了了opentracing是一套接口,那么具体的实现是在其他的工具中完成的,如jaeger。创建的时候就需要jaeger的支持了,如下:
// 使用jaeger来创建一个tracer,并注入到opentracing全局中去
func BootTracerWrapper(localServiceName string, hostPort string) error {
    tracer, err := xjaeger.BootJaegerTracer(localServiceName, hostPort)
    if err != nil {
        return errors.Wrap(err, "BootTracerWrapper.BootZipkinTracer")
    }

    // 注入到全局后,就可以通过 opentracing.GlobalTracer() 来使用 tracer了 
    opentracing.SetGlobalTracer(tracer)

    return nil
}

func BootJaegerTracer(localServiceName, hostPort string) (opentracing.Tracer, error) {
    cfg := &config.Configuration{
        Sampler: &config.SamplerConfig{
            Type:  jaeger.SamplerTypeConst,
            Param: 1,
        },
        ServiceName: localServiceName, // 服务名
        Reporter: &config.ReporterConfig{
            LogSpans:          true,
            CollectorEndpoint: _jaegerRecorderEndpoint,
        }, // 链路搜集配置
    }
    tracer, _, err := cfg.NewTracer(
        config.Logger(jaegerlog.StdLogger),
        config.ZipkinSharedRPCSpan(true),
    )
    if err != nil {
        return nil, errors.Wrap(err, "BootJaegerTracer")
    }

    return tracer, nil
}

4. Trace信息怎么传递?

在 Child-Span什么时候创建? 中提到了 Inject, Extract 方法,这两个方法就是用来辅助Trace信息传递的方法, 具体的实现也是在jaeger中实现的,有兴趣的可以自行查阅代码。

5. 链路信息如何搜集?

在 链路Tracer从哪儿来 已经提到了jaeger在创建tracer时可以指定搜集器的配置, 因此上报动作会在jaeger中完成,但是要注意的是,显式调用 sp.Finish() 才会触发上报动作。

总结

最终实战结果截图如下: 图中包含了调用链路层级关系,每个环节的耗时情况,请求的入参和结果数据(异常信息,如有)等。同时还支持自定义(Tag 和 Annotation 功能), 如果还想要更多的信息,那么推荐使用这两个功能组合来满足需求。

再多的案例和文档,都没有自己上手实践的效果好,建议实际运行并查阅 *opentracing *源码。所有的代码均可在 https://github.com/yeqown/opentracing-practice 中找到。