在微服務架構中,調用鏈是漫長而複雜的,要了解其中的每一個環節及其性能,你須要全鏈路跟蹤。 它的原理很簡單,你能夠在每一個請求開始時生成一個惟一的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…