鏈路追蹤

董雷發表於2022-02-17

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 中找到。

本作品採用《CC 協議》,轉載必須註明作者和本文連結
good good study day day up

相關文章