在微服務(wù)架構(gòu)中,調(diào)用鏈?zhǔn)锹L而復(fù)雜的层扶,要了解其中的每個環(huán)節(jié)及其性能,你需要全鏈路跟蹤烙荷。 它的原理很簡單镜会,你可以在每個請求開始時生成一個唯一的ID,并將其傳遞到整個調(diào)用鏈终抽。 該ID稱為CorrelationID1戳表,你可以用它來跟蹤整個請求并獲得各個調(diào)用環(huán)節(jié)的性能指標(biāo)。簡單來說有兩個問題需要解決昼伴。第一匾旭,如何在應(yīng)用程序內(nèi)部傳遞ID; 第二,當(dāng)你需要調(diào)用另一個微服務(wù)時圃郊,如何通過網(wǎng)絡(luò)傳遞ID价涝。
什么是OpenTracing?
現(xiàn)在有許多開源的分布式跟蹤庫可供選擇,其中最受歡迎的庫可能是Zipkin2和Jaeger3描沟。 選擇哪個是一個令人頭疼的問題飒泻,因為你現(xiàn)在可以選擇最受歡迎的一個鞭光,但是如果以后有一個更好的出現(xiàn)呢吏廉?OpenTracing?可以幫你解決這個問題。它建立了一套跟蹤庫的通用接口惰许,這樣你的程序只需要調(diào)用這些接口而不被具體的跟蹤庫綁定席覆,將來可以切換到不同的跟蹤庫而無需更改代碼。Zipkin和Jaeger都支持OpenTracing汹买。
如何跟蹤服務(wù)器端點(server endpoints)?
在下面的程序中我使用“Zipkin”作為跟蹤庫佩伤,用“OpenTracing”作為通用跟蹤接口聊倔。 跟蹤系統(tǒng)中通常有四個組件,下面我用Zipkin作為示例:
recorder(記錄器):記錄跟蹤數(shù)據(jù)
Reporter (or collecting agent)(報告器或收集代理):從記錄器收集數(shù)據(jù)并將數(shù)據(jù)發(fā)送到UI程序
Tracer:生成跟蹤數(shù)據(jù)
UI:負責(zé)在圖形UI中顯示跟蹤數(shù)據(jù)
上面是Zipkin的組件圖生巡,你可以在Zipkin Architecture中找到它耙蔑。
有兩種不同類型的跟蹤,一種是進程內(nèi)跟蹤(in-process)孤荣,另一種是跨進程跟蹤(cross-process)甸陌。 我們將首先討論跨進程跟蹤。
客戶端程序:
我們將用一個簡單的gRPC程序作為示例盐股,它分成客戶端和服務(wù)器端代碼钱豁。 我們想跟蹤一個完整的服務(wù)請求,它從客戶端到服務(wù)端并從服務(wù)端返回疯汁。 以下是在客戶端創(chuàng)建新跟蹤器的代碼牲尺。它首先創(chuàng)建“HTTP Collector”(the agent)用來收集跟蹤數(shù)據(jù)并將其發(fā)送到“Zipkin” UI, “endpointUrl”是“Zipkin” UI的URL幌蚊。 其次谤碳,它創(chuàng)建了一個記錄器(recorder)來記錄端點上的信息,“hostUrl”是gRPC(客戶端)呼叫的URL溢豆。第三估蹄,它用我們新建的記錄器創(chuàng)建了一個新的跟蹤器(tracer)。 最后沫换,它為“OpenTracing”設(shè)置了“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客戶端代碼讯赏。 它首先調(diào)用上面提到的函數(shù)“newTrace()”來創(chuàng)建跟蹤器垮兑,然后,它創(chuàng)建一個包含跟蹤器的gRPC調(diào)用連接漱挎。接下來系枪,它使用新建的gRPC連接創(chuàng)建緩存服務(wù)(Cache service)的gRPC客戶端。 最后磕谅,它通過gRPC客戶端來調(diào)用緩存服務(wù)的“Get”函數(shù)私爷。
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”膊夹,它表示從頭到尾的一個請求的調(diào)用鏈衬浑,它的標(biāo)識符是“traceID”。 一個“trace”包含有許多跨度(span)放刨,每個跨度捕獲調(diào)用鏈內(nèi)的一個工作單元工秩,并由“spanId”標(biāo)識。 每個跨度具有一個父跨度,并且一個“trace”的所有跨度形成有向無環(huán)圖(DAG)助币。 以下是跨度之間的關(guān)系圖浪听。 你可以從The OpenTracing Semantic Specification中找到它。
以下是函數(shù)“callGet”的代碼眉菱,它調(diào)用了gRPC服務(wù)端的“Get"函數(shù)迹栓。 在函數(shù)的開頭,OpenTracing為這個函數(shù)調(diào)用開啟了一個新的span俭缓,整個函數(shù)結(jié)束后迈螟,它也結(jié)束了這個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
}
服務(wù)端代碼:
下面是服務(wù)端代碼尔崔,它與客戶端代碼類似星虹,它調(diào)用了“newTracer()”(與客戶端“newTracer()”函數(shù)幾乎相同)來創(chuàng)建跟蹤器霜幼。然后盔憨,它創(chuàng)建了一個“OpenTracingServerInterceptor”热康,其中包含跟蹤器。 最后载弄,它使用我們剛創(chuàng)建的攔截器(Interceptor)創(chuàng)建了gRPC服務(wù)器耘拇。
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中看到的跟蹤和跨度的圖片。 在服務(wù)器端宇攻,我們不需要在函數(shù)內(nèi)部編寫任何代碼來生成span惫叛,我們需要做的就是創(chuàng)建跟蹤器(tracer),服務(wù)器攔截器自動為我們生成span逞刷。
怎樣跟蹤函數(shù)內(nèi)部?
上面的圖片沒有告訴我們函數(shù)內(nèi)部的跟蹤細節(jié)嘉涌, 我們需要編寫一些代碼來獲得它。
以下是服務(wù)器端“get”函數(shù)夸浅,我們在其中添加了跟蹤代碼仑最。 它首先從上下文獲取跨度(span),然后創(chuàng)建一個新的子跨度并使用我們剛剛獲得的跨度作為父跨度帆喇。 接下來警医,它執(zhí)行一些操作(例如數(shù)據(jù)庫查詢),然后結(jié)束(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
}
以下是它運行后的圖片预皇。 現(xiàn)在它在服務(wù)器端有一個新的跨度“db query user”。
以下是zipkin中的跟蹤數(shù)據(jù)婉刀。 你可以看到客戶端從8.016ms開始吟温,服務(wù)端也在同一時間啟動。 服務(wù)器端完成需要大約16ms路星。
怎樣跟蹤數(shù)據(jù)庫?
怎樣才能跟蹤數(shù)據(jù)庫內(nèi)部的操作溯街?首先诱桂,數(shù)據(jù)庫驅(qū)動程序需要支持跟蹤洋丐,另外你需要將跟蹤器(tracer)傳遞到數(shù)據(jù)庫函數(shù)中呈昔。如果數(shù)據(jù)庫驅(qū)動程序不支持跟蹤怎么辦?現(xiàn)在已經(jīng)有幾個開源驅(qū)動程序封裝器(Wrapper)友绝,它們可以封裝任何數(shù)據(jù)庫驅(qū)動程序并使其支持跟蹤堤尾。其中一個是instrumentedsql?(另外兩個是luna-duclos/instrumentedsql?和ocsql/driver.go?)。我簡要地看了一下他們的代碼迁客,他們的原理基本相同郭宝。它們都為底層數(shù)據(jù)庫的每個函數(shù)創(chuàng)建了一個封裝(Wrapper),并在每個數(shù)據(jù)庫操作之前啟動一個新的跨度掷漱,并在操作完成后結(jié)束跨度粘室。但是所有這些都只封裝了“database/sql”接口,這就意味著NoSQL數(shù)據(jù)庫沒有辦法使用他們卜范。如果你找不到支持你需要的NoSQL數(shù)據(jù)庫(例如MongoDB)的OpenTracing的驅(qū)動程序衔统,你可能需要自己編寫一個封裝(Wrapper),它并不困難。
一個問題是“如果我使用OpenTracing和Zipkin而數(shù)據(jù)庫驅(qū)動程序使用Openeracing和Jaeger海雪,那會有問題嗎锦爵?"這其實不會發(fā)生。我上面提到的大部分封裝都支持OpenTracing奥裸。在使用封裝時险掀,你需要注冊封裝了的SQL驅(qū)動程序,其中包含跟蹤器湾宙。在SQL驅(qū)動程序內(nèi)部樟氢,所有跟蹤函數(shù)都只調(diào)用了OpenTracing的接口,因此它們甚至不知道底層實現(xiàn)是Zipkin還是Jaeger∠丽現(xiàn)在使用OpenTarcing的好處終于體現(xiàn)出來了嗡害。在應(yīng)用程序中創(chuàng)建全局跟蹤器時(Global tracer),你需要決定是使用Zipkin還是Jaeger畦攘,但這之后霸妹,應(yīng)用程序或第三方庫中的每個函數(shù)都只調(diào)用OpenTracing接口,已經(jīng)與具體的跟蹤庫(Zipkin或Jaeger)沒關(guān)系了知押。
怎樣跟蹤服務(wù)調(diào)用?
假設(shè)我們需要在gRPC服務(wù)中調(diào)用另外一個微服務(wù)(例如RESTFul服務(wù))叹螟,該如何跟蹤?
簡單來說就是使用HTTP頭作為媒介(Carrier)來傳遞跟蹤信息(traceID)台盯。無論微服務(wù)是gRPC還是RESTFul罢绽,它們都使用HTTP協(xié)議。如果是消息隊列(Message Queue)静盅,則將跟蹤信息(traceID)放入消息報頭中良价。(Zipkin B3-propogation有“single header”和“multiple header”有兩種不同類型的跟蹤信息寝殴,但JMS僅支持“single header”)
一個重要的概念是“跟蹤上下文(trace context)”,它定義了傳播跟蹤所需的所有信息明垢,例如traceID蚣常,parentId(父spanId)等。有關(guān)詳細信息痊银,請閱讀跟蹤上下文(trace context)1?抵蚊。
OpenTracing提供了兩個處理“跟蹤上下文(trace context)”的函數(shù):“extract(format,carrier)”和“inject(SpanContext溯革,format贞绳,carrier)”。 “extarct()”從媒介(通常是HTTP頭)獲取跟蹤上下文致稀。 “inject”將跟蹤上下文放入媒介冈闭,來保證跟蹤鏈的連續(xù)性。以下是我從Zipkin獲取的b3-propagation圖抖单。
但是為什么我們沒有在上面的例子中調(diào)用這些函數(shù)呢萎攒?讓我們再來回顧一下代碼。在客戶端臭猜,在創(chuàng)建gRPC客戶端連接時躺酒,我們調(diào)用了一個為“OpenTracingClientInterceptor”的函數(shù)。 以下是“OpenTracingClientInterceptor”的部分代碼蔑歌,我從otgrpc11包中的“client.go”中得到了它羹应。它已經(jīng)從Go context12獲取了跟蹤上下文并將其注入HTTP頭,因此我們不再需要再次調(diào)用“inject”函數(shù)次屠。
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)
}
在服務(wù)器端园匹,我們還調(diào)用了一個函數(shù)“otgrpc.OpenTracingServerInterceptor”,其代碼類似于客戶端的“OpenTracingClientInterceptor”劫灶。它不是調(diào)用“inject”寫入跟蹤上下文裸违,而是從HTTP頭中提取(extract)跟蹤上下文并將其放入Go上下文(Go context)中本昏。 這就是我們不需要再次手動調(diào)用“extract()”的原因供汛。 我們可以直接從Go上下文中提取跟蹤上下文(opentracing.SpanFromContext(ctx))。 但對于其他基于HTTP的服務(wù)(如RESTFul服務(wù))涌穆, 情況就并非如此怔昨,因此我們需要寫代碼從服務(wù)器端的HTTP頭中提取跟蹤上下文。 當(dāng)然宿稀,您也可以使用攔截器或過濾器趁舀。
跟蹤庫之間的互兼容性
你也許會問“如果我的程序使用Zipkin和OpenTracing而需要調(diào)用的第三方微服務(wù)使用OpenTracing與Jaeger,它們會兼容嗎祝沸?"它看起來于我們之前詢問的數(shù)據(jù)庫問題類似矮烹,但實際上很不相同越庇。對于數(shù)據(jù)庫,因為應(yīng)用程序和數(shù)據(jù)庫在同一個進程中奉狈,它們可以共享相同的全局跟蹤器卤唉,因此更容易解決。對于微服務(wù)嘹吨,這種方式將不兼容搬味。因為OpenTracing只標(biāo)準(zhǔn)化了跟蹤接口境氢,它沒有標(biāo)準(zhǔn)化跟蹤上下文蟀拷。萬維網(wǎng)聯(lián)盟(W3C)正在制定跟蹤上下文(trace context)1?的標(biāo)準(zhǔn),并于2019-08-09年發(fā)布了候選推薦標(biāo)準(zhǔn)萍聊。OpenTracing沒有規(guī)定跟蹤上下文的格式问芬,而是把決定權(quán)留給了實現(xiàn)它的跟蹤庫。結(jié)果每個庫都選擇了自己獨有的的格式寿桨。例如此衅,Zipkin使用“X-B3-TraceId”作為跟蹤ID,Jaeger使用“uber-trace-id”亭螟,因此使用OpenTracing并不意味著不同的跟蹤庫可以進行跨網(wǎng)互操作挡鞍。 對于“Jaeger”來說有一個好處是你可以選擇使用“Zipkin兼容性功能"13來生成Zipkin跟蹤上下文, 這樣就可以與Zipkin相互兼容了预烙。對于其他情況墨微,你需要自己進行手動格式轉(zhuǎn)換(在“inject”和“extract”之間)。
全鏈路跟蹤設(shè)計
盡量少寫代碼
一個好的全鏈路跟蹤系統(tǒng)不需要用戶編寫很多跟蹤代碼扁掸。最理想的情況是你不需要任何代碼翘县,讓框架或庫負責(zé)處理它,當(dāng)然這比較困難谴分。 全鏈路跟蹤分成三個跟蹤級別:
跨進程跟蹤 (cross-process)(調(diào)用另一個微服務(wù))
數(shù)據(jù)庫跟蹤
進程內(nèi)部的跟蹤 (in-process)(在一個函數(shù)內(nèi)部的跟蹤)
跨進程跟蹤是最簡單的锈麸。你可以編寫攔截器或過濾器來跟蹤每個請求,它只需要編寫極少的編碼牺蹄。數(shù)據(jù)庫跟蹤也比較簡單忘伞。如果使用我們上面討論過的封裝器(Wrapper),你只需要注冊SQL驅(qū)動程序封裝器(Wrapper)并將go-context(里面有跟蹤上下文) 傳入數(shù)據(jù)庫函數(shù)沙兰。你可以使用依賴注入(Dependency Injection)這樣就可以用比較少的代碼來完成此操作氓奈。
進程內(nèi)跟蹤是最困難的,因為你必須為每個單獨的函數(shù)編寫跟蹤代碼∩耍現(xiàn)在還沒有一個很好的方法探颈,可以編寫一個通用的函數(shù)來跟蹤應(yīng)用程序中的每個函數(shù)(攔截器不是一個好選擇,因為它需要每個函數(shù)的參數(shù)和返回都必須是一個泛型類型(interface {}))训措。幸運的是伪节,對于大多數(shù)人來說光羞,前兩個級別的跟蹤應(yīng)該已經(jīng)足夠了。
有些人可能會使用服務(wù)網(wǎng)格(service mesh)來實現(xiàn)分布式跟蹤怀大,例如Istio或Linkerd纱兑。它確實是一個好主意,跟蹤最好由基礎(chǔ)架構(gòu)實現(xiàn)化借,而不是將業(yè)務(wù)邏輯代碼與跟蹤代碼混在一起潜慎,不過你將遇到我們剛才談到的同樣問題。服務(wù)網(wǎng)格只負責(zé)跨進程跟蹤蓖康,函數(shù)內(nèi)部或數(shù)據(jù)庫跟蹤任然需要你來編寫代碼铐炫。不過一些服務(wù)網(wǎng)格可以通過提供與流行跟蹤庫的集成,來簡化不同跟蹤庫跨網(wǎng)跟蹤時的的上下文格式轉(zhuǎn)換蒜焊。
跟蹤設(shè)計:
精心設(shè)計的跨度(span)倒信,服務(wù)名稱(service name),標(biāo)簽(tag)能充分發(fā)揮全鏈路跟蹤的作用泳梆,并使之簡單易用鳖悠。有關(guān)信息請閱讀語義約定(Semantic Conventions)1?。
將Trace ID記錄到日志
將跟蹤與日志記錄集成是一個常見的需求优妙,最重要的是將跟蹤ID記錄到整個調(diào)用鏈的日志消息中乘综。 目前OpenTracing不提供訪問traceID的方法。 你可以將“OpenTracing.SpanContext”轉(zhuǎn)換為特定跟蹤庫的“SpanContext”(Zipkin和Jaeger都可以通過“SpanContext”訪問traceID)或?qū)ⅰ癘penTracing.SpanContext”轉(zhuǎn)換為字符串并解析它以獲取traceID套硼。轉(zhuǎn)換為字符串更好卡辰,因為它不會破壞程序的依賴關(guān)系。 幸運的是不久的將來你就不需要它了熟菲,因為OpenTracing將提供訪問traceID的方法看政,請閱讀這里。
OpenTracing 和 OpenCensus
OpenCensus1?不是另一個通用跟蹤接口抄罕,它是一組庫允蚣,可以用來與其他跟蹤庫集成以完成跟蹤功能,因此它經(jīng)常與OpenTracing進行比較呆贿。 那么它與OpenTracing兼容嗎嚷兔?答案是否定的。 因此做入,在選擇跟蹤接口時(不論是OpenTracing還是OpenCensus)需要小心冒晰,以確保你需要調(diào)用的其他庫支持它。 一個好消息是竟块,你不需要在將來做出選擇壶运,因為它們會將項目合并為一個1?。
結(jié)論:
全鏈路跟蹤包括不同的場景浪秘,例如在函數(shù)內(nèi)部跟蹤蒋情,數(shù)據(jù)庫跟蹤和跨進程跟蹤埠况。 每個場景都有不同的問題和解決方案。如果你想設(shè)計更好的跟蹤解決方案或為你的應(yīng)用選擇最適合的跟蹤工具或庫棵癣,那你需要對每種情況都有清晰的了解辕翰。
源碼:
索引:
[1]Correlation IDs for microservices architectures
https://hilton.org.uk/blog/microservices-correlation-id
[2]Zipkin
https://zipkin.io
[3]Jaeger: open source, end-to-end distributed tracing
https://www.jaegertracing.io
[4]OpenTracing
https://opentracing.io/docs/getting-started
[5]Zipkin Architecture
https://zipkin.io/pages/architecture.html
[6]The OpenTracing Semantic Specification
https://opentracing.io/specification/
[7]instrumentedsql
https://github.com/ExpansiveWorlds/instrumentedsql
[8]luna-duclos/instrumentedsql
https://github.com/luna-duclos/instrumentedsql
[9]ocsql/driver.go
https://github.com/opencensus-integrations/ocsql/blob/master/driver.go
[10]Trace Context
https://www.w3.org/TR/trace-context/
[11]otgrpc
http://github.com/grpc-ecosystem/grpc-opentracing/go/otgrpc
[12]Go Concurrency Patterns: Context
https://blog.golang.org/context
[13]Zipkin compatibility features
https://github.com/jaegertracing/jaeger-client-go/tree/master/zipkin
[14]Semantic Conventions
https://github.com/opentracing/specification/blob/master/semantic_conventions.md
[15]OpenCensus
https://opencensus.io/
[16]merge the project into one
https://medium.com/opentracing/merging-opentracing-and-opencensus-f0fe9c7ca6f0