分布式鏈路追蹤(Tracing)系統(tǒng) - Jaeger在Golang中的使用

先從微服務(wù)說起

微服務(wù)

一個完整的微服務(wù)體系至少需要包括:

  • CI / CD 也就是自動化部署
  • 服務(wù)發(fā)現(xiàn)
  • 統(tǒng)一的PRC協(xié)議
  • 監(jiān)控
  • 追蹤(Tracing)

要配置上面這些東西可謂說超級復(fù)雜, 所以我建議讀者 如果可以直接使用istio

istio

它強(qiáng)大到包含了微服務(wù)開發(fā)需要考慮的所有東西, 上圖中的"Observe"就包括了這篇文章所說的"鏈路追蹤(Tracing)".

但軟件行業(yè)沒有銀彈, 強(qiáng)大的工具自然需要強(qiáng)大的人員去管理, 在進(jìn)階為大佬之前, 還是得研究一些傳統(tǒng)的方案以便成長, 所以便有了這篇文章.

Tracing在微服務(wù)中的作用

和傳統(tǒng)單體服務(wù)不同, 微服務(wù)通常部署在一個分布式的系統(tǒng)中, 并且一個請求可能會經(jīng)過好幾個微服務(wù)的處理, 這樣的環(huán)境下錯誤和性能問題就會更容易發(fā)生, 所以觀察(Observe)尤為重要,
這就是Tracing的用武之地, 它收集調(diào)用過程中的信息并可視化, 讓你知道在每一個服務(wù)調(diào)用過程的耗時等情況, 以便及早發(fā)現(xiàn)問題.

Jaeger UI

在上圖可以看到api層一共花了4.03s, 然后其中調(diào)用其他服務(wù): 'service-1'花了2.12s, 而service-1又調(diào)用了'service-2'花費(fèi)了2.12s, 用這樣的圖示很容易就能排查到系統(tǒng)存在的問題. 在這里我只展示了時間, 如果需要追蹤其他信息(如錯誤信息)也是可以實(shí)現(xiàn)的.

為什么是Jaeger

筆者正在學(xué)習(xí)Golang, 選用使用Golang并開源的Tracing系統(tǒng) - Jaeger當(dāng)然就不再需要理由了

Uber出品也不會太差。

安裝

官方文檔在此

為了快速上手, 官方提供了"All in One"的docker鏡像, 啟動Jaeger服務(wù)只需要一行代碼:

$ 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 16686:16686 \
  -p 14268:14268 \
  -p 9411:9411 \
  jaegertracing/all-in-one:1.12

具體端口作用就不再贅述, 官方文檔都有.

All in One只應(yīng)該用于實(shí)驗(yàn)環(huán)境. 如果是生產(chǎn)環(huán)境, 你需要按官方[這樣部署].(https://www.jaegertracing.io/docs/1.12/deployment/)
本文在后面會講到部署并使用Elasticsearch作為存儲后端.

現(xiàn)在用于測試的服務(wù)端就完成了, 你可以訪問http://{host}:16686來訪問JaegerUI, 它像這樣:

JeagerUi

客戶端

部署完服務(wù)器就可以編寫客戶端了, 官方提供了Go/Java/Node.js/Python/C++/C#語言的客戶端庫, 讀者可自行選擇, 使用方式可在各自的倉庫中查看.

我也只實(shí)驗(yàn)了Golang客戶端, 先從最簡單的場景入手:

在單體應(yīng)用中實(shí)現(xiàn)Tracing.

在編寫代碼之前還得理解下Jaeger中最基礎(chǔ)的幾個概念, 也是OpenTracing
的數(shù)據(jù)模型: Trace / Span

  • Trace: 調(diào)用鏈, 其中包含了多個Span.
  • Span: 跨度, 計量的最小單位, 每個跨度都有開始時間與截止時間. Span和Span之間可以存在References(關(guān)系): ChildOf 與 FollowsFrom

如下圖 (來至開放分布式追蹤(OpenTracing)入門與 Jaeger 實(shí)現(xiàn))

單個 Trace 中,span 間的因果關(guān)系


        [Span A]  ←←←(the root span)
            |
     +------+------+
     |             |
 [Span B]      [Span C] ←←←(Span C 是 Span A 的孩子節(jié)點(diǎn), ChildOf)
     |             |
 [Span D]      +---+-------+
               |           |
           [Span E]    [Span F] >>> [Span G] >>> [Span H]
                                       ↑
                                       ↑
                                       ↑
                         (Span G 在 Span F 后被調(diào)用, FollowsFrom)

接下來是代碼時間, 參考項(xiàng)目的Readme和搜索引擎不難寫出以下代碼

package tests

import (
    "context"
    "github.com/opentracing/opentracing-go"
    "github.com/uber/jaeger-client-go"
    "log"
    "testing"
    "time"

    jaegercfg "github.com/uber/jaeger-client-go/config"
)

func TestJaeger(t *testing.T) {
    cfg := jaegercfg.Configuration{
        Sampler: &jaegercfg.SamplerConfig{
            Type:  jaeger.SamplerTypeConst,
            Param: 1,
        },
        Reporter: &jaegercfg.ReporterConfig{
            LogSpans:           true,
            LocalAgentHostPort: "{host}:6831", // 替換host
        },
    }

    closer, err := cfg.InitGlobalTracer(
        "serviceName",
    )
    if err != nil {
        log.Printf("Could not initialize jaeger tracer: %s", err.Error())
        return
    }

    var ctx = context.TODO()
    span1, ctx := opentracing.StartSpanFromContext(ctx, "span_1")
    time.Sleep(time.Second / 2)

    span11, _ := opentracing.StartSpanFromContext(ctx, "span_1-1")
    time.Sleep(time.Second / 2)
    span11.Finish()

    span1.Finish()

    defer closer.Close()
}

代碼唯一需要注意的地方是closer, 這個closer在程序結(jié)束時一定記得關(guān)閉, 因?yàn)樵诳蛻舳酥衧pan信息的發(fā)送不是同步發(fā)送的, 而是有一個暫存區(qū), 調(diào)用closer.Close()就會讓暫存區(qū)的span發(fā)送到agent.

運(yùn)行之, 我們就可以在UI看到:


點(diǎn)擊進(jìn)入詳情就能看到我們剛剛收集到的調(diào)用信息


通過Grpc中間件使用

在單體程序中, 父子Span通過context關(guān)聯(lián), 而context是在內(nèi)存中的, 顯而易見這樣的方法在垮應(yīng)用的場景下是行不通的.

垮應(yīng)用通訊使用的方式通常是"序列化", 在jaeger-client-go庫中也是通過類似的操作去傳遞信息, 它們叫:Tracer.Inject() 與 Tracer.Extract().

其中inject方法支持將span系列化成幾種格式:

  • Binary: 二進(jìn)制
  • TextMap: key=>value
  • HTTPHeaders: Http頭, 其實(shí)也是key=>value

正好grpc支持傳遞metadata也是string的key=>value形式, 所以我們就能通過metadata實(shí)現(xiàn)在不同應(yīng)用間傳遞Span了.

這段代碼在github上有人實(shí)現(xiàn)了: https://github.com/grpc-ecosystem/go-grpc-middleware

題外話:上面的庫使用到了grpc的Interceptor, 但grpc不支持多個Interceptor, 所以當(dāng)你又使用到了其他中間件(如grpc_retry)的話就能導(dǎo)致沖突. 同樣也可以使用這個庫grpc_middleware.ChainUnaryClient解決這個問題.

在grpc服務(wù)端的中間件代碼如下(已省略錯誤處理)

import (
    "context"
    "github.com/grpc-ecosystem/go-grpc-middleware/tracing/opentracing"
    "google.golang.org/grpc"
)

jcfg := jaegercfg.Configuration{
        Sampler: &jaegercfg.SamplerConfig{
            Type:  "const",
            Param: 1,
        },
        ServiceName: "serviceName",
    }

report := jaegercfg.ReporterConfig{
        LogSpans:           true,
        LocalAgentHostPort: "locahost:6831",
    }

reporter, _ := report.NewReporter(serviceName, jaeger.NewNullMetrics(), jaeger.NullLogger)
tracer, closer, _ = jcfg.NewTracer(
        jaegercfg.Reporter(reporter),
)

server := grpc.NewServer(grpc.UnaryInterceptor(grpc_opentracing.UnaryServerInterceptor(grpc_opentracing.WithTracer(tracer))))

在grpc客戶端的中間件代碼如下

conn, err := grpc.Dial(addr, grpc.WithUnaryInterceptor(grpc_opentracing.UnaryClientInterceptor(
    grpc_opentracing.WithTracer(tracer),
)))

現(xiàn)在服務(wù)端和客戶端之間的調(diào)用情況就能被jaeger收集到了.

在業(yè)務(wù)代碼中使用

有時候只監(jiān)控一個"api"是不夠的,還需要監(jiān)控到程序中的代碼片段(如方法),可以這樣封裝一個方法


package tracer

type SpanOption func(span opentracing.Span)

func SpanWithError(err error) SpanOption {
    return func(span opentracing.Span) {
        if err != nil {
            ext.Error.Set(span, true)
            span.LogFields(tlog.String("event", "error"), tlog.String("msg", err.Error()))
        }
    }
}

// example:
// SpanWithLog(
//    "event", "soft error",
//    "type", "cache timeout",
//    "waited.millis", 1500)
func SpanWithLog(arg ...interface{}) SpanOption {
    return func(span opentracing.Span) {
        span.LogKV(arg...)
    }
}

func Start(tracer opentracing.Tracer, spanName string, ctx context.Context) (newCtx context.Context, finish func(...SpanOption)) {
    if ctx == nil {
        ctx = context.TODO()
    }
    span, newCtx := opentracing.StartSpanFromContextWithTracer(ctx, tracer, spanName,
        opentracing.Tag{Key: string(ext.Component), Value: "func"},
    )

    finish = func(ops ...SpanOption) {
        for _, o := range ops {
            o(span)
        }
        span.Finish()
    }

    return
}

使用

newCtx, finish := tracer.Start("DoSomeThing", ctx)
err := DoSomeThing(newCtx)
finish(tracer.SpanWithError(err))
if err != nil{
  ...
}

最后能得到一個像這樣的結(jié)果


可以看到在服務(wù)的調(diào)用過程中各個span的時間,這個span可以是一個微服務(wù)之間的調(diào)用也可以是某個方法的調(diào)用。

點(diǎn)開某個span也能看到額外的log信息。

通過Gin中間件中使用

在我的項(xiàng)目中使用http服務(wù)作為網(wǎng)關(guān)提供給前端使用,那么這個http服務(wù)層就是root span而不用關(guān)心父span了,編寫代碼就要簡單一些。

封裝一個gin中間件就能實(shí)現(xiàn)

import (
    "context"
    "github.com/gin-gonic/gin"
    "github.com/opentracing/opentracing-go"
    "github.com/opentracing/opentracing-go/ext"
)

jcfg := jaegercfg.Configuration{
        Sampler: &jaegercfg.SamplerConfig{
            Type:  "const",
            Param: 1,
        },
        ServiceName: "serviceName",
    }

report := jaegercfg.ReporterConfig{
        LogSpans:           true,
        LocalAgentHostPort: "locahost:6831",
    }

reporter, _ := report.NewReporter(serviceName, jaeger.NewNullMetrics(), jaeger.NullLogger)
tracer, closer, _ = jcfg.NewTracer(
        jaegercfg.Reporter(reporter),
)

engine.Use(func(ctx *gin.Context) {
        path := ctx.Request.URL.Path

        span := tracer.StartSpan(path,
            ext.SpanKindRPCServer)
        ext.HTTPUrl.Set(span, path)
        ext.HTTPMethod.Set(span, ctx.Request.Method)
        c := opentracing.ContextWithSpan(context.Background(), span)

        ctx.Set("ctx", c)

        ctx.Next()

        ext.HTTPStatusCode.Set(span, uint16(ctx.Writer.Status()))
        span.Finish()
    })

如果需要向下層傳遞context則這樣獲取context

func Api(gtx *gin.Context) {
  ctx = gtx.Get("ctx").(context.Context)
}

結(jié)語

使用trace會入侵部分代碼,特別是追蹤一個方法,但這是不可避免的。

甚至需要每個方法都需要添加上ctx, 關(guān)于這點(diǎn)有興趣的朋友可以讀一下這篇文章: Golang Context 是好的設(shè)計嗎?
(原文找不到了, 將就看一下)

但其實(shí)并不是整個系統(tǒng)的服務(wù)都需要追蹤,可只針對于重要或者有性能問題的地方進(jìn)行追蹤。

部署篇

使用Elasticsearch作為存儲后端

在一篇文章 開放分布式追蹤(OpenTracing)入門與 Jaeger 實(shí)現(xiàn)中偶然發(fā)現(xiàn)阿里云支持為Jaeger提供存儲后端, 但怕于阿里云拖更, 所以也就沒使用阿里云產(chǎn)品.

筆者對于Elasticsearch更為熟悉, 故選擇它了.

es的部署就不說了.

這里是jaeger的docker-compose.yaml

version: '2'
services:
  jaeger-agent:
    image: jaegertracing/jaeger-agent:1.12
    stdin_open: true
    tty: true
    links:
    - jaeger-collector:jaeger-collector
    ports:
    - 6831:6831/udp
    command:
    - --reporter.grpc.host-port=jaeger-collector:14250

  jaeger-collector:
    image: jaegertracing/jaeger-collector:1.12
    environment:
      SPAN_STORAGE_TYPE: elasticsearch
      ES_SERVER_URLS: http://elasticsearch:9200
    stdin_open: true
    external_links:
    - elasticsearch/elasticsearch:elasticsearch
    tty: true

  jaeger-query:
    image: jaegertracing/jaeger-query:1.12
    environment:
      SPAN_STORAGE_TYPE: elasticsearch
      ES_SERVER_URLS: http://elasticsearch:9200
    stdin_open: true
    external_links:
    - elasticsearch/elasticsearch:elasticsearch
    tty: true
    ports:
    - 16686:16686/tcp

其中agent和collect都被設(shè)計成無狀態(tài)的,也就意味著他們可以被放在代理(如Nginx)后面而實(shí)現(xiàn)負(fù)載均衡。

幸運(yùn)的是筆者在部署過程中沒有遇見任何問題,所以也就沒有"疑難雜癥"環(huán)節(jié)了。一般來說遇到的問題都可以去issue搜到。

疑難雜癥

<trace-without-root-span>

這個錯誤原因是: B span 是歸屬于 A span的, 但Jaeger服務(wù)器只收集到了B span, 但沒有收集到父級A span, 這時候B span就是一個 without-root-span.

可能原因有下:

  • 忘記調(diào)用Finish()
  • 在程序退出時沒有調(diào)用Closer.Close(), 這會導(dǎo)致緩沖區(qū)的spans沒有被push到服務(wù)器
  • 等待一段時間, 緩沖器Span會經(jīng)過一段時間(在Golang Client里默認(rèn)是1S)才會被Push到服務(wù)器
  • 發(fā)送的Spans個數(shù)大于了QueueSize, 多余QueueSize的Spans可能會被丟棄, 這篇文檔有提到, 可以通過以下代碼配置 QueueSize:
    report := jaegercfg.ReporterConfig{
          LogSpans:           false,
          QueueSize:          1000,
          LocalAgentHostPort: agent,
          CollectorEndpoint:  collector,
      }
    

有時候Jaeger上有數(shù)據(jù), 有時候沒有

由于客戶端和Jaeger-Agent之間是通過UDP協(xié)議傳輸?shù)? 所以如果測試服務(wù)器與Jager-Agent服務(wù)之間是外網(wǎng)網(wǎng)絡(luò)環(huán)境, 則可能會導(dǎo)致丟包, 通常包越大越容易丟包.

解決辦法是將Agent部署到本機(jī), 不過在開發(fā)環(huán)境為了方便也可以將客戶端配置使用Jaeger-Collector, 這時會使用HTTP協(xié)議發(fā)送Spans.

這在官方文檔中有提到:

JAEGER_AGENT_HOST defines hostname for reporting spans over UDP/Thrift. To avoid packet loss, the agent is expected to run on the same machine as the application. This var is useful when there are multiple networking namespaces on the host.

JAEGER_ENDPOINT defines the URL for reporting spans via HTTP/Thrift. This setting allows for a deployment mode where spans are submitted directly to the collector.

相關(guān)文章

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

友情鏈接更多精彩內(nèi)容