1. 程式人生 > 程式設計 >Go微服務全鏈路跟蹤詳解

Go微服務全鏈路跟蹤詳解

在微服務架構中,呼叫鏈是漫長而複雜的,要了解其中的每個環節及其效能,你需要全鏈路跟蹤。 它的原理很簡單,你可以在每個請求開始時生成一個唯一的ID,並將其傳遞到整個呼叫鏈。 該ID稱為CorrelationID¹,你可以用它來跟蹤整個請求並獲得各個呼叫環節的效能指標。簡單來說有兩個問題需要解決。第一,如何在應用程式內部傳遞ID; 第二,當你需要呼叫另一個微服務時,如何通過網路傳遞ID。

什麼是OpenTracing?

現在有許多開源的分散式跟蹤庫可供選擇,其中最受歡迎的庫可能是Zipkin²和Jaeger³。 選擇哪個是一個令人頭疼的問題,因為你現在可以選擇最受歡迎的一個,但是如果以後有一個更好的出現呢?

OpenTracing⁴可以幫你解決這個問題。它建立了一套跟蹤庫的通用介面,這樣你的程式只需要呼叫這些介面而不被具體的跟蹤庫繫結,將來可以切換到不同的跟蹤庫而無需更改程式碼。Zipkin和Jaeger都支援OpenTracing。

如何跟蹤伺服器端點(server endpoints)?

在下面的程式中我使用“Zipkin”作為跟蹤庫,用“OpenTracing”作為通用跟蹤介面。 跟蹤系統中通常有四個元件,下面我用Zipkin作為示例:

  • recorder(記錄器):記錄跟蹤資料
  • Reporter (or collecting agent)(報告器或收集代理):從記錄器收集資料並將資料傳送到UI程式
  • Tracer:生成跟蹤資料
  • UI:負責在圖形UI中顯示跟蹤資料

file

上面是Zipkin的元件圖,你可以在Zipkin Architecture中找到它。

有兩種不同型別的跟蹤,一種是程式內跟蹤(in-process),另一種是跨程式跟蹤(cross-process)。 我們將首先討論跨程式跟蹤。

客戶端程式:

我們將用一個簡單的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,err
    }
    opentracing.SetGlobalTracer(tracer)

    return tracer,collector,nil
}複製程式碼

以下是gRPC客戶端程式碼。 它首先呼叫上面提到的函式“newTrace()”來建立跟蹤器,然後,它建立一個包含跟蹤器的gRPC呼叫連線。接下來,它使用新建的gRPC連線建立快取服務(Cache service)的gRPC客戶端。 最後,它通過gRPC客戶端來呼叫快取服務的“Get”函式。

key:="123"
    tracer,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中找到它。

file

以下是函式“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。

file

怎樣跟蹤函式內部?

上面的圖片沒有告訴我們函式內部的跟蹤細節, 我們需要編寫一些程式碼來獲得它。

以下是伺服器端“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”。

file

以下是zipkin中的跟蹤資料。 你可以看到客戶端從8.016ms開始,服務端也在同一時間啟動。 伺服器端完成需要大約16ms。

file

怎樣跟蹤資料庫?

怎樣才能跟蹤資料庫內部的操作?首先,資料庫驅動程式需要支援跟蹤,另外你需要將跟蹤器(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圖。

file

但是為什麼我們沒有在上面的例子中呼叫這些函式呢?讓我們再來回顧一下程式碼。在客戶端,在建立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”之間)。

全鏈路跟蹤設計

儘量少寫程式碼

一個好的全鏈路跟蹤系統不需要使用者編寫很多跟蹤程式碼。最理想的情況是你不需要任何程式碼,讓框架或庫負責處理它,當然這比較困難。 全鏈路跟蹤分成三個跟蹤級別:

  • 跨程式跟蹤 (cross-process)(呼叫另一個微服務)
  • 資料庫跟蹤
  • 程式內部的跟蹤 (in-process)(在一個函式內部的跟蹤)

跨程式跟蹤是最簡單的。你可以編寫攔截器或過濾器來跟蹤每個請求,它只需要編寫極少的編碼。資料庫跟蹤也比較簡單。如果使用我們上面討論過的封裝器(Wrapper),你只需要註冊SQL驅動程式封裝器(Wrapper)並將go-context(裡面有跟蹤上下文) 傳入資料庫函式。你可以使用依賴注入(Dependency Injection)這樣就可以用比較少的程式碼來完成此操作。

程式內跟蹤是最困難的,因為你必須為每個單獨的函式編寫跟蹤程式碼。現在還沒有一個很好的方法,可以編寫一個通用的函式來跟蹤應用程式中的每個函式(攔截器不是一個好選擇,因為它需要每個函式的引數和返回都必須是一個泛型型別(interface {}))。幸運的是,對於大多數人來說,前兩個級別的跟蹤應該已經足夠了。

有些人可能會使用服務網格(service mesh)來實現分散式跟蹤,例如IstioLinkerd。它確實是一個好主意,跟蹤最好由基礎架構實現,而不是將業務邏輯程式碼與跟蹤程式碼混在一起,不過你將遇到我們剛才談到的同樣問題。服務網格只負責跨程式跟蹤,函式內部或資料庫跟蹤任然需要你來編寫程式碼。不過一些服務網格可以通過提供與流行跟蹤庫的整合,來簡化不同跟蹤庫跨網跟蹤時的的上下文格式轉換。

跟蹤設計:

精心設計的跨度(span),服務名稱(service name),標籤(tag)能充分發揮全鏈路跟蹤的作用,並使之簡單易用。有關資訊請閱讀語義約定(Semantic Conventions)¹⁴。

將Trace ID記錄到日誌

將跟蹤與日誌記錄整合是一個常見的需求,最重要的是將跟蹤ID記錄到整個呼叫鏈的日誌訊息中。 目前OpenTracing不提供訪問traceID的方法。 你可以將“OpenTracing.SpanContext”轉換為特定跟蹤庫的“SpanContext”(Zipkin和Jaeger都可以通過“SpanContext”訪問traceID)或將“OpenTracing.SpanContext”轉換為字串並解析它以獲取traceID。轉換為字串更好,因為它不會破壞程式的依賴關係。 幸運的是不久的將來你就不需要它了,因為OpenTracing將提供訪問traceID的方法,請閱讀這裡

OpenTracing 和 OpenCensus

OpenCensus¹⁵不是另一個通用跟蹤介面,它是一組庫,可以用來與其他跟蹤庫整合以完成跟蹤功能,因此它經常與OpenTracing進行比較。 那麼它與OpenTracing相容嗎?答案是否定的。 因此,在選擇跟蹤介面時(不論是OpenTracing還是OpenCensus)需要小心,以確保你需要呼叫的其他庫支援它。 一個好訊息是,你不需要在將來做出選擇,因為它們會將專案合併為一個¹⁶。

結論:

全鏈路跟蹤包括不同的場景,例如在函式內部跟蹤,資料庫跟蹤和跨程式跟蹤。 每個場景都有不同的問題和解決方案。如果你想設計更好的跟蹤解決方案或為你的應用選擇最適合的跟蹤工具或庫,那你需要對每種情況都有清晰的瞭解。

原始碼:

完整原始碼的github連結

索引:

[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…