1. 程式人生 > >微服務 - 如何解決鏈路追蹤問題

微服務 - 如何解決鏈路追蹤問題

### 一、鏈路追蹤 ​ 微服務架構是將單個應用程式被劃分成各種小而連線的服務,每一個服務完成一個單一的業務功能,相互之間保持獨立和解耦,每個服務都可以獨立演進。相對於傳統的單體服務,微服務具有隔離性、技術異構性、可擴充套件性以及簡化部署等優點。 ​ 同樣的,微服務架構在帶來諸多益處的同時,也為系統增加了不少複雜性。它作為一種分散式服務,通常部署於由不同的資料中心、不同的伺服器組成的叢集上。而且,同一個微服務系統可能是由不同的團隊、不同的語言開發而成。通常一個應用由多個微服務組成,微服務之間的資料互動需要通過遠過程呼叫的方式完成,所以在一個由眾多微服務構成的系統中,請求需要在各服務之間流轉,呼叫鏈路錯綜複雜,一旦出現問題,是很難進行問題定位和追查異常的。 ​ 鏈路追蹤系統就是為解決上述問題而產生的,它用來追蹤每一個請求的完整呼叫鏈路,記錄從請求開始到請求結束期間呼叫的任務名稱、耗時、標籤資料以及日誌資訊,並通過視覺化的介面進行分析和展示,來幫助技術人員準確地定位異常服務、發現效能瓶頸、梳理呼叫鏈路以及預估系統容量。 ​ 鏈路追蹤系統的理論模型幾乎都借鑑了 Google 的一篇論文”Dapper, a Large-Scale Distributed Systems Tracing Infrastructure”,典型產品有Uber jaeger、Twitter zipkin、淘寶鷹眼等。這些產品的實現方式雖然不盡相同,但核心步驟一般都有三個:**資料採集、資料儲存和查詢展示**。 ​ 鏈路追蹤系統第一步,也是最基本的工作就是資料採集。在這個過程中,鏈路追蹤系統需要侵入使用者程式碼進行埋點,用於收集追蹤資料。但是由於不同的鏈路追蹤系統的API互不相容,所以埋點程式碼寫法各異,導致使用者在切換不同鏈路追蹤產品時需要做很大的改動。為了解決這類問題,於是誕生了OpenTracing規範,旨在統一鏈路追蹤系統的API。 ### 二、OpenTracing規範 ​ OpenTracing 是一套分散式追蹤協議,與平臺和語言無關,具有統一的介面規範,方便接入不同的分散式追蹤系統。 ​ OpenTracing語義規範詳見:https://github.com/opentracing/specification/blob/master/specification.md #### 2.1 資料模型(Data Model) ​ OpenTracing語義規範中定義的資料模型有 Trace、Sapn以及Reference。 ##### 2.1.1 Trace ​ Trace表示一條完整的追蹤鏈路,例如:一個事務或者一個流程的執行過程。一個 Trace 是由一個或者多個 Span 組成的有向無環圖(DAG)。 下圖表示一個由8個Span組成的Trace: ``` [Span A] ←←←(the root span) | +------+------+ | | [Span B] [Span C] ←←←(Span C is a `ChildOf` Span A) | | [Span D] +---+-------+ | | [Span E] [Span F] >>> [Span G] >>> [Span H] ↑ ↑ ↑ (Span G `FollowsFrom` Span F) ``` 按照時間軸方式更為直觀地展現該Trace: ``` ––|–––––––|–––––––|–––––––|–––––––|–––––––|–––––––|–––––––|–> time [Span A···················································] [Span B··············································] [Span D··········································] [Span C········································] [Span E·······] [Span F··] [Span G··] [Span H··] ``` ##### 2.1.2 Span ​ Span表示一個獨立的工作單元,它是一條追蹤鏈路的基本組成要素。例如:一次RPC呼叫、一次函式呼叫或者一次Http請求。 每個Span封裝瞭如下狀態: - 操作名稱 用於表示該Span的任務名稱。 例如:一個 RPC方法名, 一個函式名,或者大型任務中的子任務名稱。 - 開始時間戳 任務開始時間。 - 結束時間戳。 任務結束時間。通過Span的結束時間戳和開始時間戳,就能夠計算出該Span的整體耗時。 - 一組Span標籤 每一個Span標籤是一個鍵值對。鍵必須是字串,值可以是字串、布林或數值型別。常見標籤鍵可參考:https://github.com/opentracing/specification/blob/master/semantic_conventions.md - 一組Span日誌 每一條Span日誌由一個鍵值對和一個相應的時間戳組成。鍵必須是字串,值可以是任何型別。常見日誌鍵參考:https://github.com/opentracing/specification/blob/master/semantic_conventions.md ##### 2.1.3 Reference ​ 一個Span可以與一個或者多個Span存在因果關係,這種關係稱為Reference。OpenTracing目前定義了兩種關係:ChildOf(父子)關係 和 FollowsFrom(跟隨)關係。 - ChildOf關係 父Span的執行依賴子Span的執行結果,此時子Span對父Span的Reference關係是ChildOf。比如對於一次RPC呼叫,服務端的Span(子Span)與客戶端呼叫的Span(父Span)就是ChildOf關係。 - FollowsFrom關係 父Span的執行不依賴子Span的執行結果,此時子Span對父Span的Reference關係是FollowFrom。FollowFrom常用於表示非同步呼叫,例如訊息佇列中Consumer Span與Producer Span之間的關係。 #### 2.2 應用介面(API) ##### 2.2.1 Tracer ​ Tracer介面用於建立Span、跨程序注入資料和提取資料。通常具有以下功能: - Start a new span 建立並啟動一個新的Span。 - Inject 將SpanContext注入載體(Carrier)。 - Extract 從載體(Carrier)中提取SpanContext。 ##### 2.2.2 Span - Retrieve a SpanContext 返回Span對應的SpanContext。 - Overwrite the operation name 更新操作名稱。 - Set a span tag 設定Span標籤資料。 - Log structured data 記錄結構化資料。 - Set a baggage item baggage item是字串型的鍵值對,它對應於某個 Span,隨Trace一起傳播。由於每個鍵值都會被拷貝到每一個本地及遠端的子Span,這可能導致巨大的網路和CPU開銷。 - Get a baggage item 獲取baggage item的值。 - Finish 結束一個Span。 ##### 2.2.3 Span Context ​ 用於攜帶跨越服務邊界的資料,包括trace ID、Span ID以及需要傳播到下游Span的baggage資料。在OpenTracing中,強制要求SpanContext例項不可變,以避免在Span完成和引用時出現複雜的生命週期問題。 ##### 2.2.4 NoopTracer ​ 所有對OpenTracing API的實現,必須提供某種形式的NoopTracer,用於標記控制OpenTracing或注入對測試無害的東西。 ## 三、Jaeger ​ Jaeger是Uber開源的分散式追蹤系統,它的應用介面完全遵循OpenTracing規範。jaeger本身採用go語言編寫,具有跨平臺跨語言的特性,提供了各種語言的客戶端呼叫介面,例如c++、java、go、python、ruby、php、nodejs等。專案地址:https://github.com/jaegertracing #### 3.1 Jaeger元件 [外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片儲存下來直接上傳(img-miLIEWHv-1604561903414)(https://i.loli.net/2020/04/13/bvTxdUkBRuawY1F.png)] - **jaeger-client** jaeger的客戶端程式碼庫,它實現了OpenTracing協議。當我們的應用程式將其裝配後,負責收集資料,併發送到jaeger-agent。**這是我們唯一需要編寫程式碼的地方**。 - **jaeger-agent** 負責接收從jaeger-client發來的Trace/Span資訊,並批量上傳到jaeger-collector。 - **jaeger-collector** 負責接收從jaeger-agent發來的Trace/Span資訊,並經過校驗、索引等處理,然後寫入到後端儲存。 - **data store** 負責資料儲存。Jaeger的資料儲存是一個可插拔的元件,目前支援Cassandra、ElasticSearch和Kafka。 - **jaeger-query & ui** 負責資料查詢,並通過前端介面展示查詢結果。 [外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片儲存下來直接上傳(img-ogkrm3Hb-1604561903417)(https://i.loli.net/2020/04/13/UMoHYtlX1ydsx5Q.jpg)] #### 3.2 快速入門 ​ Jaeger官方提供了all-in-one映象,方便快速進行測試: ``` # 拉取映象 $docker pull jaegertracing/all-in-one:latest # 執行映象 $docker run -d --name jaeger \ -e COLLECTOR_ZIPKIN_HTTP_PORT=9411 \ -p 5775:5775/udp \ -p 6831:6831/udp \ -p 6832:6832/udp \ -p 5778:5778 \ -p 14268:14268 \ -p 9411:9411 \ -p 16686:16686 \ jaegertracing/all-in-one:latest ``` ​ 通過all-in-one映象啟動,我們發現Jaeger佔據了很多埠。以下是埠使用說明: | 埠 | 協議 | 所屬模組 | 功能 | | ------ | ----- | --------- | ------ | | 5775 | UDP | agent | 接收壓縮格式的Zipkin thrift資料 | | 6831 | UDP | agent | 接收壓縮格式的Jaeger thrift資料 | | 6832 | UDP | agent | 接收二進位制格式的Jaeger thrift資料 | | 5778 | HTTP | agent | 服務配置、取樣策略埠 | | 14268 | HTTP | collector | 接收由客戶端直接傳送的Jaeger thrift資料 | | 9411 | HTTP | collector | 接收Zipkin傳送的json或者thrift資料 | | 16686 | HTTP | query | 瀏覽器展示埠 | ​ 啟動後,我們可以訪問 [http://localhost:16686](http://localhost:16686) ,在瀏覽器中檢視和查詢收集的資料。 ​ 由於通過all-in-one映象方式收集的資料都儲存在docker中,無法持久儲存,所以只能用於開發或者測試環境,無法用於生產環境。生產環境中需要依據實際情況,分別部署各個元件。 ## 四、Jaeger在業務程式碼中的應用 ​ 系統中使用Jaeger非常簡單,只需要在原有程式中插入少量程式碼。以下程式碼模擬了一個查詢使用者賬戶餘額,執行扣款的業務場景: #### 4.1 初始化jaeger函式 ​ 主要是按照實際需要配置有關引數,例如服務名稱、取樣模式、取樣比例等等。 ``` func initJaeger() (tracer opentracing.Tracer, closer io.Closer, err error) { // 構造配置資訊 cfg := &config.Configuration{ // 設定服務名稱 ServiceName: "ServiceAmount", // 設定取樣引數 Sampler: &config.SamplerConfig{ Type: "const", // 全取樣模式 Param: 1, // 開啟狀態 }, } // 生成一條新tracer tracer, closer, err = cfg.NewTracer() if err == nil { // 設定tracer為全域性單例物件 opentracing.SetGlobalTracer(tracer) } return } ``` #### 4.2 檢測使用者餘額函式 ​ 用於檢測使用者餘額,模擬一個子任務Span。 ``` func CheckBalance(request string, ctx context.Context) { // 建立子span span, _ := opentracing.StartSpanFromContext(ctx, "CheckBalance") // 模擬系統進行一系列的操作,耗時1/3秒 time.Sleep(time.Second / 3) // 示例:將需要追蹤的資訊放入tag span.SetTag("request", request) span.SetTag("reply", "CheckBalance reply") // 結束當前span span.Finish() log.Println("CheckBalance is done") } ``` #### 4.3 從使用者賬戶扣款函式 ​ 從使用者賬戶扣款,模擬一個子任務span。 ``` func Reduction(request string, ctx context.Context) { // 建立子span span, _ := opentracing.StartSpanFromContext(ctx, "Reduction") // 模擬系統進行一系列的操作,耗時1/2秒 time.Sleep(time.Second / 2) // 示例:將需要追蹤的資訊放入tag span.SetTag("request", request) span.SetTag("reply", "Reduction reply") // 結束當前span span.Finish() log.Println("Reduction is done") } ``` #### 4.4 主函式 ​ 初始化jaeger環境,生成tracer,建立父span,以及呼叫查詢餘額和扣款兩個子任務span。 ``` package main import ( "context" "fmt" "github.com/opentracing/opentracing-go" "github.com/uber/jaeger-client-go/config" "io" "log" "time" ) func main() { // 初始化jaeger,建立一條新tracer tracer, closer, err := initJaeger() if err != nil { panic(fmt.Sprintf("ERROR: cannot init Jaeger: %v\n", err)) } defer closer.Close() // 建立一個新span,作為父span,開始計費過程 span := tracer.StartSpan("CalculateFee") // 生成父span的context ctx := opentracing.ContextWithSpan(context.Background(), span) // 示例:設定一個span標籤資訊 span.SetTag("db.instance", "customers") // 示例:輸出一條span日誌資訊 span.LogKV("event", "timed out") // 將父span的context作為引數,呼叫檢測使用者餘額函式 CheckBalance("CheckBalance request", ctx) // 將父span的context作為引數,呼叫扣款函式 Reduction("Reduction request", ctx) // 結束父span span.Finish() } ``` ## 五、Jaeger在gRPC微服務中的應用 ​ 我們依然模擬了一個查詢使用者賬戶餘額,執行扣款的業務場景,並把查詢使用者賬戶餘額和執行扣款功能改造為gRPC微服務: #### 5.1 gRPC Server端程式碼 main.go: ​ 程式碼使用了第三方依賴庫github.com/grpc-ecosystem/go-grpc-middleware/tracing/opentracing,該依賴庫將OpenTracing封裝為通用的gRPC中介軟體,並通過gRPC攔截器無縫嵌入gRPC服務中。 ``` package main import ( "fmt" "github.com/grpc-ecosystem/go-grpc-middleware/tracing/opentracing" "github.com/opentracing/opentracing-go" "github.com/uber/jaeger-client-go/config" "google.golang.org/grpc" "google.golang.org/grpc/reflection" "grpc-jaeger-server/account" "io" "log" "net" ) // 初始化jaeger func initJaeger() (tracer opentracing.Tracer, closer io.Closer, err error) { // 構造配置資訊 cfg := &config.Configuration{ // 設定服務名稱 ServiceName: "ServiceAmount", // 設定取樣引數 Sampler: &config.SamplerConfig{ Type: "const", // 全取樣模式 Param: 1, // 開啟全取樣模式 }, } // 生成一條新tracer tracer, closer, err = cfg.NewTracer() if err == nil { // 設定tracer為全域性單例物件 opentracing.SetGlobalTracer(tracer) } return } func main() { // 初始化jaeger,建立一條新tracer tracer, closer, err := initJaeger() if err != nil { panic(fmt.Sprintf("ERROR: cannot init Jaeger: %v\n", err)) } defer closer.Close() log.Println("succeed to init jaeger") // 註冊gRPC account服務 server := grpc.NewServer(grpc.UnaryInterceptor(grpc_opentracing.UnaryServerInterceptor(grpc_opentracing.WithTracer(tracer)))) account.RegisterAccountServer(server, &AccountServer{}) reflection.Register(server) log.Println("succeed to register account service") // 監聽gRPC account服務埠 listener, err := net.Listen("tcp", ":8080") if err != nil { log.Println(err) return } log.Println("starting register account service") // 開啟gRpc account服務 if err := server.Serve(listener); err != nil { log.Println(err) return } } ``` 計費微服務 accountsever.go: ``` package main import ( "github.com/opentracing/opentracing-go" "golang.org/x/net/context" "grpc-jaeger-server/account" "time" ) // 計費服務 type AccountServer struct{} // 檢測使用者餘額微服務,模擬子span任務 func (s *AccountServer) CheckBalance(ctx context.Context, request *account.CheckBalanceRequest) (response *account.CheckBalanceResponse, err error) { response = &account.CheckBalanceResponse{ Reply: "CheckBalance Reply", // 處理結果 } // 建立子span span, _ := opentracing.StartSpanFromContext(ctx, "CheckBalance") // 模擬系統進行一系列的操作,耗時1/3秒 time.Sleep(time.Second / 3) // 將需要追蹤的資訊放入tag span.SetTag("request", request) span.SetTag("reply", response) // 結束當前span span.Finish() return response, err } // 從使用者賬戶扣款微服務,模擬子span任務 func (s *AccountServer) Reduction(ctx context.Context, request *account.ReductionRequest) (response *account.ReductionResponse, err error) { response = &account.ReductionResponse{ Reply: "Reduction Reply", // 處理結果 } // 建立子span span, _ := opentracing.StartSpanFromContext(ctx, "Reduction") // 模擬系統進行一系列的操作,耗時1/3秒 time.Sleep(time.Second / 3) // 將需要追蹤的資訊放入tag span.SetTag("request", request) span.SetTag("reply", response) // 結束當前span span.Finish() return response, err } ``` #### 5.2 gRPC Client端程式碼main.go: ``` package main import ( "context" "fmt" "github.com/grpc-ecosystem/go-grpc-middleware/tracing/opentracing" "github.com/opentracing/opentracing-go" "github.com/uber/jaeger-client-go/config" "google.golang.org/grpc" "grpc-jaeger-client/account" "io" "log" ) // 初始化jaeger func initJaeger() (tracer opentracing.Tracer, closer io.Closer, err error) { // 構造配置資訊 cfg := &config.Configuration{ // 設定服務名稱 ServiceName: "ServiceAmount", // 設定取樣引數 Sampler: &config.SamplerConfig{ Type: "const", // 全取樣模式 Param: 1, // 開啟全取樣模式 }, } // 生成一條新tracer tracer, closer, err = cfg.NewTracer() if err == nil { // 設定tracer為全域性單例物件 opentracing.SetGlobalTracer(tracer) } return } func main() { // 初始化jaeger,建立一條新tracer tracer, closer, err := initJaeger() if err != nil { panic(fmt.Sprintf("ERROR: cannot init Jaeger: %v\n", err)) } defer closer.Close() log.Println("succeed to init jaeger") // 建立一個新span,作為父span span := tracer.StartSpan("CalculateFee") // 函式返回時關閉span defer span.Finish() // 生成span的context ctx := opentracing.ContextWithSpan(context.Background(), span) // 連線gRPC server conn, err := grpc.Dial("localhost:8080", grpc.WithInsecure(), grpc.WithUnaryInterceptor(grpc_opentracing.UnaryClientInterceptor(grpc_opentracing.WithTracer(tracer), ))) if err != nil { log.Println(err) return } // 建立gRPC計費服務客戶端 client := account.NewAccountClient(conn) // 將父span的context作為引數,呼叫檢測使用者餘額的gRPC微服務 checkBalanceResponse, err := client.CheckBalance(ctx, &account.CheckBalanceRequest{ Account: "user account", }) if err != nil { log.Println(err) return } log.Println(checkBalanceResponse) // 將父span的context作為引數,呼叫扣款的gRPC微服務 reductionResponse, err := client.Reduction(ctx, &account.ReductionRequest{ Account: "user account", Amount: 1, }) if err != nil { log.Println(err) return } log.Println(reductionResponse) } ``` 注: 本文全部原始碼位於:https://github.com/wangshizebin/micro-service 本文時候用的開發工具為:[goland](http://www.sousou88.com/software/2072802.html) 來自於[嗖嗖下載](http://www.sousou8