分佈式鏈路追蹤 Jaeger 在 Golang 中的使用

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

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

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

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

Tracing 在微服務中的作用

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

爲什麼是 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, 它就像這樣:

客戶端

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

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

在單體應用中實現 Tracing.

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

如下圖 (來至開放分佈式追蹤(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(https://github.com/jaegertracing/jaeger-client-go) 和搜索引擎不難寫出以下代碼

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 系列化成幾種格式:

正好 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 作爲存儲後端

筆者對於 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 搜到。

轉自:

cnblogs.com/ExMan/p/12084524.html

Go 開發大全

參與維護一個非常全面的 Go 開源技術資源庫。日常分享 Go, 雲原生、k8s、Docker 和微服務方面的技術文章和行業動態。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/3q8KBWDqWCzvW0a5SPA1ug