Opentelemetry 實踐分享 - Golang 篇

OpenTelemetry

Opentelemetry 是一個 CNCF 社區下一個開源的可觀測性框架,或者也可以說是一組工具、API 和 SDK 的集合,來檢測、生成、收集和導出可觀測性數據(指標、日誌和鏈路),以幫助我們分析軟件的性能和行爲。

優點

過去,檢測代碼的方式會有所不同,因爲每個可觀測性後端都有自己的檢測庫和代理,用於向工具發送數據。

這意味着沒有用於將數據發送到可觀察性後端的標準化數據格式,由於缺乏標準化,最終結果是缺乏數據可移植性和用戶維護儀器庫的負擔。

Opentelemetry 因此而生,擁有來自雲提供商、 供應商和最終用戶的廣泛行業支持和採用,提供了:

缺點

有別於 Istio ,它並不是一個開箱即用的工具,也是更有侵入性的,但是根據我們的經驗:

越不具侵入性的工具,就越無法做出更深更廣的觀測

我們爲了獲取更深、更廣的指標,勢必要侵入性地進行觀測,因此,採用 Istio envoy 提供的指標是不夠的。而此時,Opentelemetry 正在逐漸形成行業標準,受到許多供應商支持,是我們一個很好的選擇。

OpenTelemetry 架構

如上圖所示,整體的組織架構實際可以理解爲兩部分:

  1. 將可觀測性數據 (trace, metric, log) 全部導出(push)到 otel collector,無論你是通過什麼形式,來自什麼組件,如:
# example config for otel collector's receivers
receivers:
 otlp:
   protocols:
     grpc:
       endpoint: 0.0.0.0:4317
     http:
       endpoint: 0.0.0.0:4318
  1. 將不同類型的數據按需求導出 (push or pull) 到具體的可觀測性工具,如
# example config for otel collector's exporters
exporters:
 jaeger:
   endpoint: jaeger-operator-jaeger-collector.observability:14250
   tls:
     insecure: true
 loki:
   endpoint: http://localhost:3100/loki/api/v1/push
 prometheus:
   endpoint: 0.0.0.0:8889
   resource_to_telemetry_conversion:
     enabled: true

項目組織結構

Opentelemetry 項目組織結構繁多而複雜,官方共有 59 個 repo,但我可以大致按以下結構進行梳理:

首先,Opentelemetry 提供了官方的opentelemetry-collector,作爲整個項目的核心倉庫,用以整和所有可觀測性指標,也整合了opentelemetry-collector-contrib提供的第三方服務,這兩個項目統一構成collector,但是作爲開發者,我們不需要過多關心。

然後,針對不同的語言,基本每種語言都提供了三個倉庫作以下用途:

Golang 實踐指南

Trace(stable)

初始化

我們需要構造一個全局的TraceProvider,下面的例子構造的 provider 採用的 http exporter,即將 traces 通過 http 協議發送給指定的opentelemetry-collector

import (
 "context"
 "go.opentelemetry.io/otel"
 "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
 "go.opentelemetry.io/otel/propagation"
 sdktrace "go.opentelemetry.io/otel/sdk/trace"
)

func initTracer(ctx context.Context) (*sdktrace.TracerProvider, error) {
 exp, err := otlptracehttp.New(ctx)
 if err != nil {
  return nil, err
 }
 tp := sdktrace.NewTracerProvider(
  sdktrace.WithSampler(sdktrace.AlwaysSample()),
  sdktrace.WithBatcher(exp),
 )
 otel.SetTracerProvider(tp)
 otel.SetTextMapPropagator(propagation.TraceContext{})
 return tp, nil
}

注意:

  1. 全局TraceProvider通過otel.SetTracerProvider()設置,獲取時,也可直接調otel.GetTracerProvider()

我建議大家直接設置爲全局的,而不是作爲局部變量傳來傳去的一個好處是,當我們引用了第三方庫,它通常也會默認使用全局的 provider,這樣就能簡單的保證我們一個程序只有一個 provider,也就是說,只會把數據發送到一個 collector。

  1. 初始化的過程中,不需要指定 opentelemetry-collector endpoint等配置,我們統一通過環境變量注入。如:

支持的環境變量:

採樣器

Go SDK 提供了幾個基本的採樣器:

除此之外,根據Sampler接口:

// Sampler decides whether a trace should be sampled and exported.
type Sampler interface {
 // DO NOT CHANGE: any modification will not be backwards compatible and
 // must never be done outside of a new major release.

 // ShouldSample returns a SamplingResult based on a decision made from the
 // passed parameters.
 ShouldSample(parameters SamplingParameters) SamplingResult
 // DO NOT CHANGE: any modification will not be backwards compatible and
 // must never be done outside of a new major release.

 // Description returns information describing the Sampler.
 Description() string
 // DO NOT CHANGE: any modification will not be backwards compatible and
 // must never be done outside of a new major release.
}

我們可以編寫自己的採樣器,eg:

import (
 "go.opentelemetry.io/otel"
 sdktrace "go.opentelemetry.io/otel/sdk/trace"
)

// kubegems sampler, ignore samples whitch contains "kubegems.ignore" attrbute.
type kubegemsSampler struct{}

func (as kubegemsSampler) ShouldSample(p sdktrace.SamplingParameters) sdktrace.SamplingResult {
 result := sdktrace.SamplingResult{
  Tracestate: trace.SpanContextFromContext(p.ParentContext).TraceState(),
 }
 shouldSample := true
 for _, att := range p.Attributes {
  if att.Key == "kubegems.ignore" && att.Value.AsBool() == true {
   shouldSample = false
   break
  }
 }
 if shouldSample {
  result.Decision = sdktrace.RecordAndSample
 } else {
  result.Decision = sdktrace.Drop
 }
 return result
}

func (as kubegemsSampler) Description() string {
 return "KubegemsSampler"
}

使用採樣器時,我們需要注意以下問題:

假如有兩個服務爲 A,B, 調用關係爲 A -> B, 我們想要爲其設置採樣率爲 50%,怎麼設?

直接爲兩個服務都設置

sdktrace.WithSampler(sdktrace.TraceIDRatioBased(0.5))

這樣設置後,A 的採樣率自然是 50%,但 B 的採樣率並不會成了 25%,測試發現它仍然是 50%。我們可以查閱設計文檔:

The TraceIdRatioBased MUST ignore the parent SampledFlag. To respect the parent SampledFlag, the TraceIdRatioBased should be used as a delegate of the ParentBased sampler specified below.

也就是說,它只會根據 parent span 來決定是否被採樣

  1. 使用ParentBased採樣器(最好的方法)

    sdktrace.WithSampler(sdktrace.ParentBased(sdktrace.TraceIDRatioBased(0.5))),

    ParentBased Sampler顯式地配置有parent span情況下地採樣策略,默認情況下使用如下策略:

    func configureSamplersForParentBased(samplers []ParentBasedSamplerOption) samplerConfig {
     c := samplerConfig{
      remoteParentSampled:    AlwaysSample(),
      remoteParentNotSampled: NeverSample(),
      localParentSampled:     AlwaysSample(),
      localParentNotSampled:  NeverSample(),
     }
        
     for _, so := range samplers {
      c = so.apply(c)
     }
        
     return c
    }

    remoteParentSampled: AlwaysSample()爲例:它是說,默認情況下,如果這個 span 來自遠程的parent span,而且parent spane已經被採樣了,那麼,這個 span 也會被採樣。

    我們也可以調整ParentBasedSamplerOption參數,eg:

    sdktrace.WithSampler(sdktrace.ParentBased(sdktrace.TraceIDRatioBased(0.5), sdktrace.WithRemoteParentSampled(sdktrace.NeverSample()))),

    它表示,當parent span被採樣時,自己不採樣,當然,這是不合理的。

埋點

我們可以在想要記錄 trace 的地方,通過tracer.Start()創建一個新 span 來埋點。

當然,在 span 中,我可以主要可以添加以下幾類信息:

// get user name by user id
func getUser(ctx context.Context, id string) (string, error) {
 // start a new span from context.
 newCtx, span := tracer.Start(ctx, "getUser", trace.WithAttributes(attribute.String("user.id", id)))
 defer span.End()
 // add start event
 span.AddEvent("start to get user",
  trace.WithTimestamp(time.Now()),
 )
 var username string
 // get user name from db, if you want to trace it, `WithContext` is necessary.
 result := getDB().WithContext(newCtx).Raw(`select username from users where id = ?`, id).Scan(&username)
 if result.Error != nil || result.RowsAffected == 0 {
  err := fmt.Errorf("user %s not found", id)
  span.SetStatus(codes.Error, err.Error())
  return "", err
 }
 // set user info in span's attributes
 span.SetAttributes(attribute.String("user.name", username))
 // add end event
 span.AddEvent("end to get user",
  trace.WithTimestamp(time.Now()),
  trace.WithAttributes(attribute.String("user.name", username)),
 )
 span.SetStatus(codes.Ok, "")
 return username, nil
}

屆時,span 大概長這個樣子:

另外,關於 span 的父子關係,是通過 context 上下文來傳遞的。

tracer.Start(ctx context.Context, ...)中,如果傳入的 ctx 中沒有 span,那麼返回的就是root span;如果有,那返回的就是該 span 的子 span。

因此,我們能通過 context 串聯起清晰的鏈路調用,但也因此,我們需要非常關注 context 的使用。

跨進程傳播

Openletemetry 提供 propagator在進程間交換的消息中讀取和寫入上下文數據的對象,詳見 https://opentelemetry.io/docs/reference/specification/context/api-propagators/

Openletemetry 實現了兩種 propagator API:

propagator實現兩個方法:

TraceContext

使用 TraceContext 在下游Inject和上游Extract來打通服務間調用鏈路, eg:

  1. 設置 propagater:
    otel.SetTextMapPropagator(propagation.TraceContext{})
  1. client:
import (
 "net/http"
 "go.opentelemetry.io/otel"
 "go.opentelemetry.io/otel/propagation"
)

func DoRequest(){
    ...
    req, err := http.NewRequestWithContext(ctx, method, addr, body)
    // inject to http.Request by propagator to do distribute tracing
    otel.GetTextMapPropagator().Inject(req.Context(), propagation.HeaderCarrier(req.Header))
    http.DefaultClient.Do(req)
    ...   
}
  1. server:
import (
 "go.opentelemetry.io/otel/propagation"
)   

func HandleRequest(){
    ...
 // extract from http.Request by propagator to do distribute tracing
    ctx := cfg.Propagators.Extract(req.Context(), propagation.HeaderCarrier(req.Header))
    ctx, span := tracer.Start(ctx, spanName, opts...)
    defer span.End()
    req = req.WithContext(ctx)
    ...
}

如果你想了解更多關於TraceContext的信息,可以閱讀文檔:https://www.w3.org/TR/trace-context/,因爲它遵從W3C Trace Context format標準。

Baggage

使用 Baggage 在進程間傳遞信息,在使用它之前,我們需要弄清楚兩個問題:

  1. 爲什麼我們需要 Baggage?
  1. Baggage 應該用來做什麼?

    Baggage 應該用於我們可以向第三方公開的非敏感數據,因爲它與當前上下文一起存儲在 HTTP 標頭中。

    建議用來傳播包括 ** 帳戶標識、用戶 ID、產品 ID 和原始 IP ** 等內容。將它們向下傳遞之後,我們就可以將它們添加到下游服務中的 Span 中,以便在在可觀察性後端中進行搜索時更輕鬆地進行過濾。

比如說,在 kubegems 中有兩個服務:apiagent,以一次用戶請求獲取 k8s 資源爲例:

在這種情況下,假如我們想要在 agent 的 trace 信息中,知道這個請求時哪個用戶發起的,就可以藉助 baggage 來實現:

首先,初始化TextMapPropagator時,需要加上Baggage Propagator:

    otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(propagation.TraceContext{}, propagation.Baggage{}))

然後,在apiagent發起請求時,注入user namebaggage:

import (
 "net/http"
 "go.opentelemetry.io/otel"
 "go.opentelemetry.io/otel/propagation"
    "go.opentelemetry.io/otel/baggage"
)

func DoRequest(){
 ...
    userBaggage, err := baggage.Parse(fmt.Sprintf("user.id=%d,user., user.ID, user.Username))
 if err != nil {
  otel.Handle(err)
 }

 req, err := http.NewRequestWithContext(baggage.ContextWithBaggage(ctx, userBaggage), clientreq.Method, addr, body)
 if err != nil {
  return nil, err
 }
    otel.GetTextMapPropagator().Inject(req.Context(), propagation.HeaderCarrier(req.Header))
    http.DefaultClient.Do(req)
    ...
}

最後,在agent解析 baggage 並設置爲 attributes:

import (
 "go.opentelemetry.io/otel/propagation"
        "go.opentelemetry.io/otel/baggage"
)   

func HandleRequest(){
    ...
 // extract from http.Request by propagator to do distribute tracing
    ctx := cfg.Propagators.Extract(req.Context(), propagation.HeaderCarrier(req.Header))
    ctx, span := tracer.Start(ctx, spanName, opts...)
    defer span.End()

        reqBaggage := baggage.FromContext(ctx)
    span.SetAttributes(
        attribute.String("user.id", reqBaggage.Member("user.id").Value()),
        attribute.String("user.name", reqBaggage.Member("user.name").Value()),
    )    
    req = req.WithContext(ctx)
    ...
}

如果你想了解更多關於Baggage的信息,可以閱讀文檔:https://www.w3.org/TR/baggage/,因爲它遵從W3C Baggage format標準。

理解 propagator

無論是TraceContext還是Baggage,在我們選用的TextMapPropagator中,都是採用TextMapCarrier來實現

// TextMapCarrier is the storage medium used by a TextMapPropagator.
type TextMapCarrier interface {
    ...
}

TextMapCarrier,目前的唯一實現是HeaderCarrier

// HeaderCarrier adapts http.Header to satisfy the TextMapCarrier interface.
type HeaderCarrier http.Header

也就是說,不管我們採用http還是grpc協議,只要我們採用TextMapPropagator,實現信息傳播的,是 http 協議 header。

我們可以通過 Debug 來追蹤這一過程,首先, 在client端的Inject方法打上斷點,觀察它是怎麼把要傳播的信息注入進去的:

可以看到,注入前 context 已經帶有了user.iduser.name信息,然後下一步:

通過把 ctx 帶的信息注入進headr, 此時請求的Header中已經帶有了TraceparentBaggage信息。

然後我們在server端的Extract方法打上斷點,觀察它是怎麼解析出傳播的信息的。

很顯然,它通過從client請求的 header 中提取Traceparent來獲取traceIDspanID, 來關聯上下游,再提取Baggage來獲取來自client的信息。

其他形式的 propagator

對基於 http 協議的進程間通信,我們使用TextMapPropagator完全足夠,但如果說要針對沒有HeaderCarrier實現的通信協議,官方有計劃開發binary propagator來實現, 詳見 https://github.com/open-telemetry/opentelemetry-specification/issues/437

Metrics(alpha)

由於 opentelemety go 標準庫的 metric 實現還是 alpha,極不穩定,文檔幾乎沒有,請謹慎使用。

初始化

import (
 "context"
 "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp"
 "go.opentelemetry.io/otel/metric/global"
 sdkmetric "go.opentelemetry.io/otel/sdk/metric"
)

func initMeter(ctx context.Context) (*sdkmetric.MeterProvider, error) {
 exp, err := otlpmetrichttp.New(ctx)
 if err != nil {
  return nil, err
 }
 mp := sdkmetric.NewMeterProvider(sdkmetric.WithReader(sdkmetric.NewPeriodicReader(exp, sdkmetric.WithInterval(15*time.Second))))
 global.SetMeterProvider(mp)
 return mp, nil
}

要注意的配置主要是NewPeriodicReader(), 它用來設置我們收集並向opentelemetry collector發送指標的時間間隔。

在 kubegems 上,我們的opentelemetry collector使用的是pometheus exporter來導出監控指標,並設置有30sscrape_interval,因此,我們這裏的WithInterval()最好是小於30s以保證監控數據的及時性。

使用

以下的示例是 kubegems 爲gin框架添加的metrics實現,參照了net/httpopentelemetry實現(https://github.com/open-telemetry/opentelemetry-go-contrib/tree/main/instrumentation/net/http/otelhttp),記錄了兩個指標:

import (
 "time"

 "github.com/gin-gonic/gin"
 "go.opentelemetry.io/otel"
 "go.opentelemetry.io/otel/metric/global"
 "go.opentelemetry.io/otel/metric/instrument/syncfloat64"
 "go.opentelemetry.io/otel/metric/instrument/syncint64"
 "go.opentelemetry.io/otel/propagation"
 semconv "go.opentelemetry.io/otel/semconv/v1.12.0"
)

// Server HTTP metrics.
const (
 RequestCount          = "http.server.request_count"           // Incoming request count total
 ServerLatency         = "http.server.duration"                // Incoming end to end duration, microseconds
)

const (
 instrumentationName = "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
)

var (
 counters       map[string]syncint64.Counter
 valueRecorders map[string]syncfloat64.Histogram
)

func MeterMiddleware(service string) gin.HandlerFunc {
 counters = make(map[string]syncint64.Counter)
 valueRecorders = make(map[string]syncfloat64.Histogram)
 meter := global.MeterProvider().Meter(instrumentationName)

 requestCounter, _ := meter.SyncInt64().Counter(RequestCount)
 serverLatencyMeasure, _ := meter.SyncFloat64().Histogram(ServerLatency)

 counters[RequestCount] = requestCounter
 valueRecorders[ServerLatency] = serverLatencyMeasure
 return func(c *gin.Context) {
  requestStartTime := time.Now()
  attributes := semconv.HTTPServerMetricAttributesFromHTTPRequest(service, c.Request)
  ctx := otel.GetTextMapPropagator().Extract(c.Request.Context(), propagation.HeaderCarrier(c.Request.Header))

  c.Next()
  // Use floating point division here for higher precision (instead of Millisecond method).
  // 由於Bucket分辨率的問題,這裏只能記錄爲millseconds而不是seconds
  elapsedTime := float64(time.Since(requestStartTime)) / float64(time.Millisecond)
  counters[RequestCount].Add(ctx, 1, attributes...)
  valueRecorders[ServerLatency].Record(ctx, elapsedTime, attributes...)
 }
}

Log (not implemented yet)

opentelemetry 目前還未針對 go 有相關的實現。

但是,假如我們的應用運行在kubegems上,其中的日誌收集、查詢功能本身就提供了相關的能力,所以在官方的標準推出之前,我們也可以先通過span.SpanContext().TraceID()獲取trace-id,自行在日誌中打印trace-id,來實現trace-log關聯。

下面以 gin 和 beego 框架爲例,簡單講解一下:

gin 可以添加個打印日誌的middleware

func logMiddleware() gin.HandlerFunc {
 return func(c *gin.Context) {
  start := time.Now()
  ctx := otel.GetTextMapPropagator().Extract(c.Request.Context(), propagation.HeaderCarrier(c.Request.Header))
  span := trace.SpanFromContext(ctx)

  c.Next()
  statusCode := c.Writer.Status()
  logrus.WithFields(logrus.Fields{
   "method":   c.Request.Method,
   "path":     c.Request.URL.Path,
   "trace-id": span.SpanContext().TraceID(),
   "code":     statusCode,
   "latency":  time.Since(start).String(),
   "sampled":  span.SpanContext().IsSampled(),
  }).Info(http.StatusText(statusCode))
 }
}

beego 可以添加個filter:

 beego.InsertFilter("*", beego.BeforeRouter, func(c *bcontext.Context) {
  ctx := otel.GetTextMapPropagator().Extract(c.Request.Context(), propagation.HeaderCarrier(c.Request.Header))
  newctx, span := tracer.Start(ctx, "getUserFromBaggage")
  defer span.End()
  logrus.WithFields(logrus.Fields{
   "method":   c.Request.Method,
   "path":     c.Request.URL.Path,
   "trace-id": span.SpanContext().TraceID(),
   "sampled":  span.SpanContext().IsSampled(),
  }).Info("handle request")

  reqBaggage := baggage.FromContext(newctx)
  span.SetAttributes(
   attribute.String("user.id", reqBaggage.Member("user.id").Value()),
   attribute.String("user.name", reqBaggage.Member("user.name").Value()),
  )
  c.Request = c.Request.WithContext(newctx)
 })

Kubegems 接入 Opentelemetry

假如我們的應用程序,已經在代碼層面接入了 opentelemetry,我們只需要爲其添加幾個環境變量(爲統一 kubegems 上應用程序的接入,不建議修改):

    - name: OTEL_K8S_NODE_NAME
      valueFrom:
        fieldRef:
          apiVersion: v1
          fieldPath: spec.nodeName
    - name: OTEL_K8S_POD_NAME
      valueFrom:
        fieldRef:
          apiVersion: v1
          fieldPath: metadata.name
    - name: OTEL_SERVICE_NAME
      valueFrom:
        fieldRef:
          apiVersion: v1
          fieldPath: metadata.labels['app']
    - name: OTEL_K8S_NAMESPACE
      valueFrom:
        fieldRef:
          apiVersion: v1
          fieldPath: metadata.namespace
    - name: OTEL_RESOURCE_ATTRIBUTES
      value: service.name=$(OTEL_SERVICE_NAME),namespace=$(OTEL_K8S_NAMESPACE),node=$(OTEL_K8S_NODE_NAME),pod=$(OTEL_K8S_POD_NAME)
    - name: OTEL_EXPORTER_OTLP_ENDPOINT
      value: http://opentelemetry-collector.observability:4318 # grpc change to 4317 port
    - name: OTEL_EXPORTER_OTLP_INSECURE
      value: "true"

示例程序

我們通過示例程序 otel-demo 來演示、使用 opentelemetry 基本功能,該 demo 功能如下:

代碼演示

獲取代碼並部署:

$ git clone https://github.com/jojotong/otel-demo.git
$ cd otel-demo
$ make build docker-build docker-push deploy

重點:sampler, propagator, baggage 使用,gorm 接入

kubegems 功能演示

重點:trace, metric, log 聯動查詢

應用性能

trace 詳情

trace -> log

log -> monitor

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