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

一、鏈路追蹤

​ 微服務架構是將單個應用程序被劃分成各種小而連接的服務,每一個服務完成一個單一的業務功能,相互之間保持獨立和解耦,每個服務都可以獨立演進。相對於傳統的單體服務,微服務具有隔離性、技術異構性、可擴展性以及簡化部署等優點。

​ 同樣的,微服務架構在帶來諸多益處的同時,也為系統增加了不少複雜性。它作為一種分佈式服務,通常部署於由不同的數據中心、不同的服務器組成的集群上。而且,同一個微服務系統可能是由不同的團隊、不同的語言開發而成。通常一個應用由多個微服務組成,微服務之間的數據交互需要通過遠過程調用的方式完成,所以在一個由眾多微服務構成的系統中,請求需要在各服務之間流轉,調用鏈路錯綜複雜,一旦出現問題,是很難進行問題定位和追查異常的。

​ 鏈路追蹤系統就是為解決上述問題而產生的,它用來追蹤每一個請求的完整調用鏈路,記錄從請求開始到請求結束期間調用的任務名稱、耗時、標籤數據以及日誌信息,並通過可視化的界面進行分析和展示,來幫助技術人員準確地定位異常服務、發現性能瓶頸、梳理調用鏈路以及預估系統容量。

​ 鏈路追蹤系統的理論模型幾乎都借鑒了 Google 的一篇論文」Dapper, a Large-Scale Distributed Systems Tracing Infrastructure」,典型產品有Uber jaeger、Twitter zipkin、淘寶鷹眼等。這些產品的實現方式雖然不盡相同,但核心步驟一般都有三個:數據採集、數據存儲和查詢展示

​ 鏈路追蹤系統第一步,也是最基本的工作就是數據採集。在這個過程中,鏈路追蹤系統需要侵入用戶代碼進行埋點,用於收集追蹤數據。但是由於不同的鏈路追蹤系統的API互不兼容,所以埋點代碼寫法各異,導致用戶在切換不同鏈路追蹤產品時需要做很大的改動。為了解決這類問題,於是誕生了OpenTracing規範,旨在統一鏈路追蹤系統的API。

二、OpenTracing規範

​ OpenTracing 是一套分佈式追蹤協議,與平台和語言無關,具有統一的接口規範,方便接入不同的分佈式追蹤系統。

​ OpenTracing語義規範詳見://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標籤是一個鍵值對。鍵必須是字符串,值可以是字符串、布爾或數值類型。常見標籤鍵可參考://github.com/opentracing/specification/blob/master/semantic_conventions.md

  • 一組Span日誌

    每一條Span日誌由一個鍵值對和一個相應的時間戳組成。鍵必須是字符串,值可以是任何類型。常見日誌鍵參考://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等。項目地址://github.com/jaegertracing

3.1 Jaeger組件

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-miLIEWHv-1604561903414)(//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)(//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 瀏覽器展示端口

​ 啟動後,我們可以訪問 //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)
}

註:
本文全部源代碼位於://github.com/wangshizebin/micro-service
本文時候用的開發工具為:goland 來自於嗖嗖下載