分布式鏈路追蹤(Tracing)系統 – Jaeger在Golang中的使用


先從微服務說起

微服務

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

  • CI / CD 也就是自動化部署
  • 服務發現
  • 統一的PRC協議
  • 監控
  • 追蹤(Tracing)

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


istio

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

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

Tracing在微服務中的作用

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


Jaeger UI

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

為什么是Jaeger

筆者正在學習Golang, 選用使用Golang並開源的Tracing系統 – Jaeger當然就不再需要理由了. (`⌒´メ)

Uber出品也不會太差。

安裝

官方文檔在此

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

$ 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只應該用於實驗環境. 如果是生產環境, 你需要按官方[這樣部署].(https://www.jaegertracing.io/docs/1.12/deployment/)
本文在后面會講到部署並使用Elasticsearch作為存儲后端.

現在用於測試的服務端就完成了, 你可以訪問http://{host}:16686來訪問JaegerUI, 它就像這樣:


JeagerUi

客戶端

現在就可以編寫客戶端了, 官方提供了Go/Java/Node.js/Python/C++/C#語言的客戶端庫, 讀者可自行選擇, 使用方式可在各自的倉庫中查看.

我也只實驗了Golang客戶端, 先從最簡單的場景入手:

在單體應用中實現Tracing.

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

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

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

單個 Trace 中,span 間的因果關系
        [Span A]  ←←←(the root span)
            |
     +------+------+
     |             |
 [Span B]      [Span C] ←←←(Span C 是 Span A 的孩子節點, ChildOf)
     |             |
 [Span D]      +---+-------+
               |           |
           [Span E]    [Span F] >>> [Span G] >>> [Span H]
                                       ↑
                                       ↑
                                       ↑
                         (Span G 在 Span F 后被調用, FollowsFrom)

接下來是代碼時間, 參考項目的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在程序結束時一定記得關閉, 因為在客戶端中span信息的發送不是同步發送的, 而是有一個暫存區, 調用closer.Close()就會讓暫存區的span強制發送到agent.

運行之, 我們就可以在UI看到:

點擊進入詳情就能看到我們剛剛收集到的調用信息

通過Grpc中間件使用

在單體程序中, 父子Span通過context關聯, 而context是在內存中的, 顯而易見這樣的方法在垮應用的場景下是行不通的.

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

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

  • Binary: 二進制
  • TextMap: key=>value
  • HTTPHeaders: Http頭, 其實也是key=>value

正好grpc支持傳遞metadata也是string的key=>value形式, 所以我們就能通過metadata實現在不同應用間傳遞Span了.

這段代碼在github上有人實現了: https://github.com/grpc-ecosystem/go-grpc-middleware

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

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

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),
)))

現在服務端和客戶端之間的調用情況就能被jaeger收集到了.

在業務代碼中使用

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


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{
  ...
}

最后能得到一個像這樣的結果

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

點開某個span也能看到額外的log信息。

通過Gin中間件中使用

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

封裝一個gin中間件就能實現

import (
    "context"
    "github.com/gin-gonic/gin"
    "github.com/opentracing/opentracing-go"
    "github.com/opentracing/opentracing-go/ext"
)
engine.Use(func(ctx *gin.Context) {
        path := ctx.Request.URL.Path
        span := j.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)
}

結語

使用trace會入侵部分代碼,特別是追蹤一個方法,但這是不可避免的(使用istio框架能緩解這個問題,建議有興趣的朋友研究一下)。其實並不是整個系統的服務都需要追蹤,可只針對於重要或者有性能問題的地方進行追蹤。

部署篇

使用Elasticsearch作為存儲后端

在一篇文章 開放分布式追蹤(OpenTracing)入門與 Jaeger 實現中偶然發現阿里雲支持為Jaeger提供存儲后端, 但怕於阿里雲拖更, 所以也就沒使用阿里雲產品.

筆者對於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都被設計成無狀態的,也就意味着他們可以被放在代理(如Nginx)后面而實現負載均衡。

幸運的是筆者在部署過程中沒有遇見任何問題,所以也就沒有”疑難雜症”環節了。一般來說遇到的問題都可以去issue搜到。

相關文章


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM