Jaeger Client Go 鏈路追蹤|入門詳解

痴者工良發表於2021-03-30

從何說起

之前參加檸檬大佬的訓練營(免費白嫖),在大佬的指導下我們技術蒸蒸日上,然後作業我們需要實現一個 Jaeger 後端,筆者採用 .NET + MongoDB 來實現(大佬說用C#寫的扣10分,嗚嗚嗚...),C# 版本的實現專案地址https://github.com/whuanle/DistributedTracing,專案支援 Jaeger Collector、Query 等。

現在筆者開始轉 Go 語言,所以開始 Go 重新實現一次,下一篇文章將完整介紹如何實現一個 Jaeger Collector。在這篇文章,我們可以先學習 Jaeger client Go 的使用方法,以及 Jaeger Go 的一些概念。

在此之前,建議讀者稍微看一下 分散式鏈路追蹤框架的基本實現原理 這篇文章,需要了解 Dapper 論文和一些 Jaeger 的概念。

接下來我們將一步步學習 Go 中的一些技術,後面慢慢展開 Jaeger Client。

Jaeger

OpenTracing 是開放式分散式追蹤規範,OpenTracing API 是一致,可表達,與供應商無關的API,用於分散式跟蹤和上下文傳播。

OpenTracing 的客戶端庫以及規範,可以到 Github 中檢視:https://github.com/opentracing/

Jaeger 是 Uber 開源的分散式跟蹤系統,詳細的介紹可以自行查閱資料。

部署 Jaeger

這裡我們需要部署一個 Jaeger 例項,以供微服務以及後面學習需要。

使用 Docker 部署很簡單,只需要執行下面一條命令即可:

docker run -d -p 5775:5775/udp -p 16686:16686 -p 14250:14250 -p 14268:14268 jaegertracing/all-in-one:latest

訪問 16686 埠,即可看到 UI 介面。

後面我們生成的鏈路追蹤資訊會推送到此服務,而且可以通過 Jaeger UI 查詢這些追蹤資訊。

JaegerUI

從示例瞭解 Jaeger Client Go

這裡,我們主要了解一些 Jaeger Client 的介面和結構體,瞭解一些程式碼的使用。

為了讓讀者方便了解 Trace、Span 等,可以看一下這個 Json 的大概結構:

        {
            "traceID": "2da97aa33839442e",
            "spans": [
                {
                    "traceID": "2da97aa33839442e",
                    "spanID": "ccb83780e27f016c",
                    "flags": 1,
                    "operationName": "format-string",
                    "references": [...],
                    "tags": [...],
                    "logs": [...],
                    "processID": "p1",
                    "warnings": null
                },
                ... ...
            ],
            "processes": {
                "p1": {
                    "serviceName": "hello-world",
                    "tags": [...]
                },
                "p2": ...,
            "warnings": null
        }

建立一個 client1 的專案,然後引入 Jaeger client 包。

go get -u github.com/uber/jaeger-client-go/

然後引入包

import (
	"github.com/uber/jaeger-client-go"
)

瞭解 trace、span

鏈路追蹤中的一個程式使用一個 trace 例項標識,每個服務或函式使用一個 span 標識,jaeger 包中有個函式可以建立空的 trace:

tracer := opentracing.GlobalTracer()	// 生產中不要使用

然後就是呼叫鏈中,生成父子關係的 Span:

func main() {
	tracer := opentracing.GlobalTracer()
	// 建立第一個 span A
	parentSpan := tracer.StartSpan("A")
    defer parentSpan.Finish()		// 可手動呼叫 Finish()

}
func B(tracer opentracing.Tracer,parentSpan opentracing.Span){
	// 繼承上下文關係,建立子 span
	childSpan := tracer.StartSpan(
		"B",
		opentracing.ChildOf(parentSpan.Context()),
		)
	defer childSpan.Finish()	// 可手動呼叫 Finish()
}

每個 span 表示呼叫鏈中的一個結點,每個結點都需要明確父 span。

現在,我們知道了,如何生成 trace{span1,span2},且 span1 -> span2 即 span1 呼叫 span2,或 span1 依賴於 span2。

tracer 配置

由於服務之間的呼叫是跨程式的,每個程式都有一些特點的標記,為了標識這些程式,我們需要在上下文間、span 攜帶一些資訊。

例如,我們在發起請求的第一個程式中,配置 trace,配置服務名稱等。

// 引入 jaegercfg "github.com/uber/jaeger-client-go/config"
	cfg := jaegercfg.Configuration{
		ServiceName: "client test", // 對其發起請求的的呼叫鏈,叫什麼服務
		Sampler: &jaegercfg.SamplerConfig{
			Type:  jaeger.SamplerTypeConst,
			Param: 1,
		},
		Reporter: &jaegercfg.ReporterConfig{
			LogSpans: true,
		},
	}

Sampler 是客戶端取樣率配置,可以通過 sampler.typesampler.param 屬性選擇取樣型別,後面詳細聊一下。

Reporter 可以配置如何上報,後面獨立小節聊一下這個配置。

傳遞上下文的時候,我們可以列印一些日誌:

	jLogger := jaegerlog.StdLogger

配置完畢後就可以建立 tracer 物件了:

	tracer, closer, err := cfg.NewTracer(
		jaegercfg.Logger(jLogger),
	)
	
	defer closer.Close()
	if err != nil {
	}

完整程式碼如下:

import (
    "github.com/opentracing/opentracing-go"
	"github.com/uber/jaeger-client-go"
	jaegercfg "github.com/uber/jaeger-client-go/config"
	jaegerlog "github.com/uber/jaeger-client-go/log"
)

func main() {

	cfg := jaegercfg.Configuration{
		ServiceName: "client test", // 對其發起請求的的呼叫鏈,叫什麼服務
		Sampler: &jaegercfg.SamplerConfig{
			Type:  jaeger.SamplerTypeConst,
			Param: 1,
		},
		Reporter: &jaegercfg.ReporterConfig{
			LogSpans: true,
		},
	}

	jLogger := jaegerlog.StdLogger
	tracer, closer, err := cfg.NewTracer(
		jaegercfg.Logger(jLogger),
	)

	defer closer.Close()
	if err != nil {
	}

	// 建立第一個 span A
	parentSpan := tracer.StartSpan("A")
	defer parentSpan.Finish()

	B(tracer,parentSpan)
}

func B(tracer opentracing.Tracer, parentSpan opentracing.Span) {
	// 繼承上下文關係,建立子 span
	childSpan := tracer.StartSpan(
		"B",
		opentracing.ChildOf(parentSpan.Context()),
	)
	defer childSpan.Finish()
}

啟動後:

2021/03/30 11:14:38 Initializing logging reporter
2021/03/30 11:14:38 Reporting span 689df7e83255d05d:75668e8ed5ec61da:689df7e83255d05d:1
2021/03/30 11:14:38 Reporting span 689df7e83255d05d:689df7e83255d05d:0000000000000000:1
2021/03/30 11:14:38 DEBUG: closing tracer
2021/03/30 11:14:38 DEBUG: closing reporter

Sampler 配置

sampler 配置程式碼示例:

		Sampler: &jaegercfg.SamplerConfig{
			Type:  jaeger.SamplerTypeConst,
			Param: 1,
		}

這個 sampler 可以使用 jaegercfg.SamplerConfig,通過 typeparam 兩個欄位來配置取樣器。

為什麼要配置取樣器?因為服務中的請求千千萬萬,如果每個請求都要記錄追蹤資訊併傳送到 Jaeger 後端,那麼面對高併發時,記錄鏈路追蹤以及推送追蹤資訊消耗的效能就不可忽視,會對系統帶來較大的影響。當我們配置 sampler 後,jaeger 會根據當前配置的取樣策略做出取樣行為。

詳細可以參考:https://www.jaegertracing.io/docs/1.22/sampling/

jaegercfg.SamplerConfig 結構體中的欄位 Param 是設定取樣率或速率,要根據 Type 而定。

下面對其關係進行說明:

Type Param 說明
"const" 0或1 取樣器始終對所有 tracer 做出相同的決定;要麼全部取樣,要麼全部不取樣
"probabilistic" 0.0~1.0 取樣器做出隨機取樣決策,Param 為取樣概率
"ratelimiting" N 取樣器一定的恆定速率對tracer進行取樣,Param=2.0,則限制每秒採集2條
"remote" 取樣器請諮詢Jaeger代理以獲取在當前服務中使用的適當取樣策略。

sampler.Type="remote"/sampler.Type=jaeger.SamplerTypeRemote 是取樣器的預設值,當我們不做配置時,會從 Jaeger 後端中央配置甚至動態地控制服務中的取樣策略。

Reporter 配置

看一下 ReporterConfig 的定義。

type ReporterConfig struct {
    QueueSize                  int `yaml:"queueSize"`
    BufferFlushInterval        time.Duration
    LogSpans                   bool   `yaml:"logSpans"`
    LocalAgentHostPort         string `yaml:"localAgentHostPort"`
    DisableAttemptReconnecting bool   `yaml:"disableAttemptReconnecting"`
    AttemptReconnectInterval   time.Duration
    CollectorEndpoint          string            `yaml:"collectorEndpoint"`
    User                       string            `yaml:"user"`
    Password                   string            `yaml:"password"`
    HTTPHeaders                map[string]string `yaml:"http_headers"`
}

Reporter 配置客戶端如何上報追蹤資訊的,所有欄位都是可選的。

這裡我們介紹幾個常用的配置欄位。

  • QUEUESIZE,設定佇列大小,儲存取樣的 span 資訊,佇列滿了後一次性傳送到 jaeger 後端;defaultQueueSize 預設為 100;

  • BufferFlushInterval 強制清空、推送佇列時間,對於流量不高的程式,佇列可能長時間不能滿,那麼設定這個時間,超時可以自動推送一次。對於高併發的情況,一般佇列很快就會滿的,滿了後也會自動推送。預設為1秒。

  • LogSpans 是否把 Log 也推送,span 中可以攜帶一些日誌資訊。

  • LocalAgentHostPort 要推送到的 Jaeger agent,預設埠 6831,是 Jaeger 接收壓縮格式的 thrift 協議的資料埠。

  • CollectorEndpoint 要推送到的 Jaeger Collector,用 Collector 就不用 agent 了。

例如通過 http 上傳 trace:

		Reporter: &jaegercfg.ReporterConfig{
			LogSpans:           true,
			CollectorEndpoint: "http://127.0.0.1:14268/api/traces",
		},

據黑洞大佬的提示,HTTP 走的就是 thrift,而 gRPC 是 .NET 特供,所以 reporter 格式只有一種,而且填寫 CollectorEndpoint,我們注意要填寫完整的資訊。

完整程式碼測試:

import (
	"bufio"
    "github.com/opentracing/opentracing-go"
	"github.com/uber/jaeger-client-go"
	jaegercfg "github.com/uber/jaeger-client-go/config"
	jaegerlog "github.com/uber/jaeger-client-go/log"
	"os"
)

func main() {

	var cfg = jaegercfg.Configuration{
		ServiceName: "client test", // 對其發起請求的的呼叫鏈,叫什麼服務
		Sampler: &jaegercfg.SamplerConfig{
			Type:  jaeger.SamplerTypeConst,
			Param: 1,
		},
		Reporter: &jaegercfg.ReporterConfig{
			LogSpans:           true,
			CollectorEndpoint: "http://127.0.0.1:14268/api/traces",
		},
	}

	jLogger := jaegerlog.StdLogger
	tracer, closer, _ := cfg.NewTracer(
		jaegercfg.Logger(jLogger),
	)

	// 建立第一個 span A
	parentSpan := tracer.StartSpan("A")
	// 呼叫其它服務
	B(tracer, parentSpan)
	// 結束 A
	parentSpan.Finish()
	// 結束當前 tracer
	closer.Close()

	reader := bufio.NewReader(os.Stdin)
	_, _ = reader.ReadByte()
}
func B(tracer opentracing.Tracer, parentSpan opentracing.Span) {
	// 繼承上下文關係,建立子 span
	childSpan := tracer.StartSpan(
		"B",
		opentracing.ChildOf(parentSpan.Context()),
	)
	defer childSpan.Finish()
}

執行後輸出結果:

2021/03/30 15:04:15 Initializing logging reporter
2021/03/30 15:04:15 Reporting span 715e0af47c7d9acb:7dc9a6b568951e4f:715e0af47c7d9acb:1
2021/03/30 15:04:15 Reporting span 715e0af47c7d9acb:715e0af47c7d9acb:0000000000000000:1
2021/03/30 15:04:15 DEBUG: closing tracer
2021/03/30 15:04:15 DEBUG: closing reporter
2021/03/30 15:04:15 DEBUG: flushed 1 spans
2021/03/30 15:04:15 DEBUG: flushed 1 spans

開啟 Jaeger UI,可以看到已經推送完畢(http://127.0.0.1:16686)。

上傳的trace

這時,我們可以抽象程式碼程式碼示例:

func CreateTracer(servieName string) (opentracing.Tracer, io.Closer, error) {
	var cfg = jaegercfg.Configuration{
		ServiceName: servieName,
		Sampler: &jaegercfg.SamplerConfig{
			Type:  jaeger.SamplerTypeConst,
			Param: 1,
		},
		Reporter: &jaegercfg.ReporterConfig{
			LogSpans:          true,
			// 按實際情況替換你的 ip
			CollectorEndpoint: "http://127.0.0.1:14268/api/traces",
		},
	}

	jLogger := jaegerlog.StdLogger
	tracer, closer, err := cfg.NewTracer(
		jaegercfg.Logger(jLogger),
	)
	return tracer, closer, err
}

這樣可以複用程式碼,呼叫函式建立一個新的 tracer。這個記下來,後面要用。

分散式系統與span

前面介紹瞭如何配置 tracer 、推送資料到 Jaeger Collector,接下來我們聊一下 Span。請看圖。

下圖是一個由使用者 X 請求發起的,穿過多個服務的分散式系統,A、B、C、D、E 表示不同的子系統或處理過程。

在這個圖中, A 是前端,B、C 是中間層、D、E 是 C 的後端。這些子系統通過 rpc 協議連線,例如 gRPC。

一個簡單實用的分散式鏈路追蹤系統的實現,就是對伺服器上每一次請求以及響應收集跟蹤識別符號(message identifiers)和時間戳(timestamped events)。

這裡,我們只需要記住,從 A 開始,A 需要依賴多個服務才能完成任務,每個服務可能是一個程式,也可能是一個程式中的另一個函式。這個要看你程式碼是怎麼寫的。後面會詳細說一下如何定義這種關係,現在大概瞭解一下即可。

span呼叫鏈

怎麼調、怎麼傳

如果有了解過 Jaeger 或讀過 分散式鏈路追蹤框架的基本實現原理 ,那麼已經大概瞭解的 Jaeger 的工作原理。

jaeger 是分散式鏈路追蹤工具,如果不用在跨程式上,那麼 Jaeger 就失去了意義。而微服務中跨程式呼叫,一般有 HTTP 和 gRPC 兩種,下面將來講解如何在 HTTP、gPRC 呼叫中傳遞 Jaeger 的 上下文。

HTTP,跨程式追蹤

A、B 兩個程式,A 通過 HTTP 呼叫 B 時,通過 Http Header 攜帶 trace 資訊(稱為上下文),然後 B 程式接收後,解析出來,在建立 trace 時跟傳遞而來的 上下文關聯起來。

一般使用中介軟體來處理別的程式傳遞而來的上下文。inject 函式打包上下文到 Header 中,而 extract 函式則將其解析出來。

這裡我們分為兩步,第一步從 A 程式中傳遞上下文資訊到 B 程式,為了方便演示已經實踐,我們使用 client-webserver 的形式,編寫程式碼。

客戶端

在 A 程式新建一個方法:

// 請求遠端服務,獲得使用者資訊
func GetUserInfo(tracer opentracing.Tracer, parentSpan opentracing.Span) {
	// 繼承上下文關係,建立子 span
	childSpan := tracer.StartSpan(
		"B",
		opentracing.ChildOf(parentSpan.Context()),
	)

	url := "http://127.0.0.1:8081/Get?username=痴者工良"
	req,_ := http.NewRequest("GET", url, nil)
	// 設定 tag,這個 tag 我們後面講
	ext.SpanKindRPCClient.Set(childSpan)
	ext.HTTPUrl.Set(childSpan, url)
	ext.HTTPMethod.Set(childSpan, "GET")
	tracer.Inject(childSpan.Context(), opentracing.HTTPHeaders, opentracing.HTTPHeadersCarrier(req.Header))
	resp, _ := http.DefaultClient.Do(req)
	_ = resp 	// 丟掉
	defer childSpan.Finish()
}

然後複用前面提到的 CreateTracer 函式。

main 函式改成:

func main() {
	tracer, closer, _ := CreateTracer("UserinfoService")
	// 建立第一個 span A
	parentSpan := tracer.StartSpan("A")
	// 呼叫其它服務
	GetUserInfo(tracer, parentSpan)
	// 結束 A
	parentSpan.Finish()
	// 結束當前 tracer
	closer.Close()

	reader := bufio.NewReader(os.Stdin)
	_, _ = reader.ReadByte()
}

完整程式碼可參考:https://github.com/whuanle/DistributedTracingGo/issues/1

Web 服務端

服務端我們使用 gin 來搭建。

新建一個 go 專案,在 main.go 目錄中,執行 go get -u github.com/gin-gonic/gin

建立一個函式,該函式可以從建立一個 tracer,並且繼承其它程式傳遞過來的上下文資訊。

// 從上下文中解析並建立一個新的 trace,獲得傳播的 上下文(SpanContext)
func CreateTracer(serviceName string, header http.Header) (opentracing.Tracer,opentracing.SpanContext, io.Closer, error) {
	var cfg = jaegercfg.Configuration{
		ServiceName: serviceName,
		Sampler: &jaegercfg.SamplerConfig{
			Type:  jaeger.SamplerTypeConst,
			Param: 1,
		},
		Reporter: &jaegercfg.ReporterConfig{
			LogSpans: true,
			// 按實際情況替換你的 ip
			CollectorEndpoint: "http://127.0.0.1:14268/api/traces",
		},
	}

	jLogger := jaegerlog.StdLogger
	tracer, closer, err := cfg.NewTracer(
		jaegercfg.Logger(jLogger),
	)
	// 繼承別的程式傳遞過來的上下文
	spanContext, _ := tracer.Extract(opentracing.HTTPHeaders,
		opentracing.HTTPHeadersCarrier(header))
	return tracer, spanContext, closer, err
}

為了解析 HTTP 傳遞而來的 span 上下文,我們需要通過中介軟體來解析了處理一些細節。

func UseOpenTracing() gin.HandlerFunc {
	handler := func(c *gin.Context) {
		// 使用 opentracing.GlobalTracer() 獲取全域性 Tracer
		tracer,spanContext, closer, _ := CreateTracer("userInfoWebService", c.Request.Header)
		defer closer.Close()
		// 生成依賴關係,並新建一個 span、
		// 這裡很重要,因為生成了  References []SpanReference 依賴關係
		startSpan:= tracer.StartSpan(c.Request.URL.Path,ext.RPCServerOption(spanContext))
		defer startSpan.Finish()

		// 記錄 tag
		// 記錄請求 Url
		ext.HTTPUrl.Set(startSpan, c.Request.URL.Path)
		// Http Method
		ext.HTTPMethod.Set(startSpan, c.Request.Method)
		// 記錄元件名稱
		ext.Component.Set(startSpan, "Gin-Http")

		// 在 header 中加上當前程式的上下文資訊
		c.Request=c.Request.WithContext(opentracing.ContextWithSpan(c.Request.Context(),startSpan))
		// 傳遞給下一個中介軟體
		c.Next()
		// 繼續設定 tag
		ext.HTTPStatusCode.Set(startSpan, uint16(c.Writer.Status()))
	}

	return handler
}

別忘記了 API 服務:

func GetUserInfo(ctx *gin.Context) {
	userName := ctx.Param("username")
	fmt.Println("收到請求,使用者名稱稱為:", userName)
	ctx.String(http.StatusOK, "他的部落格是 https://whuanle.cn")
}

然後是 main 方法:

func main() {
	r := gin.Default()
	// 插入中介軟體處理
	r.Use(UseOpenTracing())
	r.GET("/Get",GetUserInfo)
	r.Run("0.0.0.0:8081") // listen and serve on 0.0.0.0:8080 (for windows "localhost:8080")
}

完整程式碼可參考:https://github.com/whuanle/DistributedTracingGo/issues/2

分別啟動 webserver、client,會發現列印日誌。並且開啟 jaerger ui 介面,會出現相關的追蹤資訊。

Jaeger追蹤記錄

Tag 、 Log 和 Ref

Jaeger 的鏈路追蹤中,可以攜帶 Tag 和 Log,他們都是鍵值對的形式:

                        {
                            "key": "http.method",
                            "type": "string",
                            "value": "GET"
                        },

Tag 設定方法是 ext.xxxx,例如 :

ext.HTTPUrl.Set(startSpan, c.Request.URL.Path)

因為 opentracing 已經規定了所有的 Tag 型別,所以我們只需要呼叫 ext.xxx.Set() 設定即可。

前面寫示例的時候忘記把日誌也加一下了。。。日誌其實很簡單的,通過 span 物件呼叫函式即可設定。

示例(在中介軟體裡面加一下):

        startSpan.LogFields(
            log.String("event", "soft error"),
            log.String("type", "cache timeout"),
            log.Int("waited.millis", 1500))

TAG_LOG

ref 就是多個 span 之間的關係。span 可以是跨程式的,也可以是一個程式內的不同函式中的。

其中 span 的依賴關係表示示例:

                    "references": [
                        {
                            "refType": "CHILD_OF",
                            "traceID": "33ba35e7cc40172c",
                            "spanID": "1c7826fa185d1107"
                        }]

spanID 為其依賴的父 span。

可以看下面這張圖。

一個程式中的 tracer 可以包裝一些程式碼和操作,為多個 span 生成一些資訊,或建立父子關係。

而 遠端請求中傳遞的是 SpanContext,傳遞後,遠端服務也建立新的 tracer,然後從 SpanContext 生成 span 依賴關係。

子 span 中,其 reference 列表中,會帶有 父 span 的 span id。

span傳播

關於 Jaeger Client Go 的文章到此完畢,轉 Go 沒多久,大家可以互相交流喲。

相關文章