微服務 - 如何解決鏈路追蹤問題
阿新 • • 發佈:2020-11-05
### 一、鏈路追蹤
微服務架構是將單個應用程式被劃分成各種小而連線的服務,每一個服務完成一個單一的業務功能,相互之間保持獨立和解耦,每個服務都可以獨立演進。相對於傳統的單體服務,微服務具有隔離性、技術異構性、可擴充套件性以及簡化部署等優點。
同樣的,微服務架構在帶來諸多益處的同時,也為系統增加了不少複雜性。它作為一種分散式服務,通常部署於由不同的資料中心、不同的伺服器組成的叢集上。而且,同一個微服務系統可能是由不同的團隊、不同的語言開發而成。通常一個應用由多個微服務組成,微服務之間的資料互動需要通過遠過程呼叫的方式完成,所以在一個由眾多微服務構成的系統中,請求需要在各服務之間流轉,呼叫鏈路錯綜複雜,一旦出現問題,是很難進行問題定位和追查異常的。
鏈路追蹤系統就是為解決上述問題而產生的,它用來追蹤每一個請求的完整呼叫鏈路,記錄從請求開始到請求結束期間呼叫的任務名稱、耗時、標籤資料以及日誌資訊,並通過視覺化的介面進行分析和展示,來幫助技術人員準確地定位異常服務、發現效能瓶頸、梳理呼叫鏈路以及預估系統容量。
鏈路追蹤系統的理論模型幾乎都借鑑了 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