Bootstrap

Go HTTP Server 基于OpenTelemetry 使用Jaeger - 代码实操

本文档主要是在go的http server的请求上加上链路追踪,链路追踪系统用的是jaeger,标准用的是OpenTelemetry。本文档的代码用的是原生的go http server的代码实现,不是用gin或者是go-zero里面的链路追踪封装,旨在了解链路最终到底在请求之间是怎么加上去的。通过这个文档希望你能够了解到go http server如何加上链路追踪的,OpenTelemetry是怎么用上去的。

本文相关版本及代码如下:

  • Go version:v1.17.7

  • Jaeger:1.28

  • OpenTelementry:v1.4.0

  • github源码连接:https://github.com/zxmfke/train/tree/main/trace

相关文章需要了解概念及jaeger部署的可以见这两篇文章:

  • jaeger部署:https://xie.infoq.cn/article/6ef5becb3aa5873f526196269

  • 浅谈云原生可观测性-Tracing:一周内会发布

链路描述

本文档代码通过上面这个图稍微简单讲解一下:

主要的是这么一个实现流程,跨服务Tracing的实现上面这张图里面之列了代码里面的往请求头写header的方法,实际代码中有另一个方法通过spanContext里面的Baggage来实现的。

通过这么一个例子,可以了解Tracing到底会怎么Tracing,可以Tracing什么东西。

服务端-S-svc1

main

下面代码是svc1里面http server启动的main函数,主要是两部分:

func main() {

  var err error

  err = tracerProvider("http://127.0.0.1:14268/api/traces")
  if err != nil {
    log.Fatal(err)
  }

  otel.SetTracerProvider(tp)

  ctx, cancel := context.WithCancel(context.Background())
  defer cancel()

  defer func(ctx context.Context) {
    ctx, cancel = context.WithTimeout(ctx, time.Second*5)
    defer cancel()
    if err := tp.Shutdown(ctx); err != nil {
      log.Fatal(err)
    }
  }(ctx)

    // 添加路由
  http.HandleFunc("/baggage", MainBaggageHandler)
  http.HandleFunc("/", MainHandler)
  http.ListenAndServe("127.0.0.1:8060", nil)
}

tracerProvider里面就是初始化一个jaeger的接收器,然后去给tracesdk当做数据收集器的实例。

jaeger可以看到我这边用的是WithCollectorEndpoint,只连jaeger的collector,正常来说是要连jaeger agent的,通过jaeger.WithAgentEndpoint的方法,不过两个我都试过了是可以的。因为是自己在测试,所以就没那么考究了。

// tracerProvider is 返回一个openTelemetry TraceProvider,这里用的是jaeger
func tracerProvider(url string) error {
  fmt.Println("init traceProvider")

  // 创建jaeger provider
  // 可以直接连collector也可以连agent
  exp, err := jaeger.New(jaeger.WithCollectorEndpoint(jaeger.WithEndpoint(url)))
  if err != nil {
    return err
  }
  tp = tracesdk.NewTracerProvider(
    tracesdk.WithBatcher(exp),
    tracesdk.WithResource(resource.NewWithAttributes(
      semconv.SchemaURL,
      semconv.ServiceNameKey.String(service),
      attribute.String("environment", environment),
      attribute.Int64("ID", id),
    )),
  )
  return nil
}
MainHandler()

MainHandler就是对'/'的请求函数,从这里开始就是我们trace的开头,就是链路描述中S的一开始。

第6-7行,就是创建一个新的span,因为没有父span,所以用的是一个新的context来当做parent span,这个span的名称叫做“index-handler”。注意,span的创建必须在函数内主流程之前,因为从哪里start,就从哪里开始记录。

tp.Tracer表示获取全局的tracer provider,也就是在main中初始化trace provider。

tr.start就是生成一个新的span,表示我这个方法要开始trace了,这是其中的一段。新生成的span,会有一个对应的spanContext,用来记录随行的数据,比如trace id和span id。

注意的是有一个新的span,就要记得span.End(),不然不会记录。

10-17就是调用funA和funB,为了能够看到数据,在代码里面用time.Sleep,假装耗时。注意,如果还有往下调用方法,那么要把这个spanCtx往下传递。

func MainHandler(w http.ResponseWriter, r *http.Request) {
  fmt.Fprintln(w, "hello world")

  fmt.Println("index handler")

  tr := tp.Tracer("component-main")
  spanCtx, span := tr.Start(context.Background(), "index-handler")
  defer span.End()

  time.Sleep(time.Second * 1)
  wg := &sync.WaitGroup{}

  wg.Add(1)
  go funA(spanCtx, wg)
  funB(spanCtx)

  wg.Wait()
}
funA

funA的目的就是来实现往span里面写tags,也就是spanTags的属性。

同样的,因为funA是一个新的被调用到的方法,所以在这个里面会初始化一个新的span。注意,和MainHandler不同的是,这个是MainHandler调用funA,所以需要使用MainHandler传下来的spanCtx来当做本次生成新span的ctx。所以第11行,用的是传入的ctx。这样子funA的span就会关联到MainHandler开始的trace了。

往span里面写一些记录,用的是SetAttributes,key必须是string,value必须是string,bool,或者数值。如果是对象的,可以序列化之后当做value。这里一般就可以是自己业务里面的请求参数,日志信息等想要不通过查日志,在web上看到的数据。

func funA(ctx context.Context, wg *sync.WaitGroup) {

  defer wg.Done()

  fmt.Println("do function a")

  // Use the global TracerProvider.
  tr := otel.Tracer("component-main")

  // 如果有调用子方法的,需要用这个spanctx,不然会挂到父span上面
  _, span := tr.Start(ctx, "func-a")

  // 只能有特定数据类型
  span.SetAttributes(attribute.KeyValue{
    Key:   "isGetHere",
    Value: attribute.BoolValue(true),
  })

  span.SetAttributes(attribute.KeyValue{
    Key:   "current time",
    Value: attribute.StringValue(time.Now().Format("2006-01-02 15:04:05")),
  })

  type _LogStruct struct {
    CurrentTime time.Time `json:"current_time"`
    PassByWho   string    `json:"pass_by_who"`
    Name        string    `json:"name"`
  }

  logTest := _LogStruct{
    CurrentTime: time.Now(),
    PassByWho:   "postman",
    Name:        "func-a",
  }

  b, _ := json.Marshal(logTest)

  span.SetAttributes(attribute.Key("这是测试日志的key").String(string(b)))

  time.Sleep(time.Second * 1)

  defer span.End()
}
funB

funB就是为了实现跨服务的trace吗,就是调用svc2的接口。

同样的第5-7行会生成新的span,及对应的spanCtx。和MainHandler调用funA不同,跨服务传递需要调用Inject函数来实现,具体内部逻辑是怎样的我还未研究

这个函数通过往request header里面写trace-id和span-id的方法传递,第15-16行。

func funB(ctx context.Context) {

  fmt.Println("do function b")

  tr := otel.Tracer("component-main")

  spanCtx, span := tr.Start(ctx, "func-b")

  fmt.Println("trace:", span.SpanContext().TraceID().String(), ", span: ", span.SpanContext().SpanID())

  client := &http.Client{}
  req, _ := http.NewRequest("POST", "http://localhost:8090/service-2", nil)

  // header写入trace-id和span-id
  req.Header.Set("trace-id", span.SpanContext().TraceID().String())
  req.Header.Set("span-id", span.SpanContext().SpanID().String())

  p := otel.GetTextMapPropagator()
  p.Inject(spanCtx, propagation.HeaderCarrier(req.Header))

  // 发送请求
  _, _ = client.Do(req)

  //结束当前请求的span
  defer span.End()
}
funcBWithBaggage

funcBWithBaggage就是用Baggage的方式传trace id和span id。

因为用的是baggage,所以inject的对象得是propagation.Baggage{},传入的ctx也是用baggage包一层的ctxBaggage。

func funcBWithBaggage(ctx context.Context) {

    ...
    
    // 使用baggage写入trace id和span id
  p := propagation.Baggage{}

  traceMember, _ := baggage.NewMember("trace-id", span.SpanContext().TraceID().String())
  spanMember, _ := baggage.NewMember("span-id", span.SpanContext().SpanID().String())

  b, _ := baggage.New(traceMember, spanMember)

  ctxBaggage := baggage.ContextWithBaggage(spanCtx, b)

  p.Inject(ctxBaggage, propagation.HeaderCarrier(req.Header))

    ...
}

服务端-S'-svc2

svc2和svc1的main,tracer provider的代码都是一样的就不再讲了。

主要是讲一下路由:

func main() {
    ...
    
  http.HandleFunc("/service-2", MainHandler)
  http.HandleFunc("/service-2-baggage", MainHandlerWithBaggage)
  http.ListenAndServe("127.0.0.1:8090", nil)
}
MainHandler

因为是跨服务被调用,所以和svc1的MainHandler有很大区别

解析首先就是要用第5-6行,因为在request有inject,那server就会有对应的extract。如果不用这个pctx来生成trace用的span,直接用请求过来的r.ctx,那么是记录不到request那一边的trace的,会自己生成一个新的。

生成新的spanCtx是通过trace.NewSpanContext,然后必须使用trace.ContextWithRemoteSpanContext再包一层,最后再拿这个sct去生成本方法的span。

通过这样子的方式生成的span,才能实现跨服务的trace。

其实跨服务的思路和同一个服务内的思路是一样的,只不过区别在于,同服务内,会自己帮你生成spanCtx,或者说简单点,跨服务就必须自己组装。

func MainHandler(w http.ResponseWriter, r *http.Request) {
  
    ...
    
  var propagator = otel.GetTextMapPropagator()
  pctx := propagator.Extract(r.Context(), propagation.HeaderCarrier(r.Header))
  tr := tp.Tracer("component-main")

  traceID := r.Header.Get("trace-id")
  spanID := r.Header.Get("span-id")

  fmt.Println("parent trace-id : ", traceID)

  traceid, _ := trace.TraceIDFromHex(traceID)
  spanid, _ := trace.SpanIDFromHex(spanID)

  spanCtx := trace.NewSpanContext(trace.SpanContextConfig{
    TraceID:    traceid,
    SpanID:     spanid,
    TraceFlags: trace.FlagsSampled, //这个没写,是不会记录的
    TraceState: trace.TraceState{},
    Remote:     true,
  })

  // 不用pctx,不会把spanctx当做parentCtx
  sct := trace.ContextWithRemoteSpanContext(pctx, spanCtx)

  _, span := tr.Start(sct, "func-c")

  sc := span.SpanContext()
  fmt.Println("trace:", sc.TraceID().String(), ", span: ", sc.SpanID())

  defer span.End()

  // 必须放在span start之后
  time.Sleep(time.Second * 2)
}
MainHandlerWithBaggage

和MainHandler的区别在于propagator需要用propagation.Baggage{},然后用baggage.FromContext把baggage的数据取出来,通过这样的方式取出trace id和span id。Baggage和request header的方法,我没想出来有什么区别,顶多就是一个在http request看不到,一个在http request看得到。因为baggage是span context里面的随行数据,就蛮实现以下

func MainHandlerWithBaggage(w http.ResponseWriter, r *http.Request) {
  ...
    
  var propagator = propagation.TextMapPropagator(propagation.Baggage{})

  pctx := propagator.Extract(r.Context(), propagation.HeaderCarrier(r.Header))
  tr := tp.Tracer("component-main")

  bag := baggage.FromContext(pctx)

  traceid, _ := trace.TraceIDFromHex(bag.Member("trace-id").Value())
  spanid, _ := trace.SpanIDFromHex(bag.Member("span-id").Value())

    ...
    
  _, span := tr.Start(sct, "func-c-with-baggage")

  sc := span.SpanContext()

  ...
}

调用结果

这边就用了request header的方式,没有用baggage的了。

可以看到完整的链路追踪的过程,就是链路描述里面的流程。tags也会记录在对应的span上面。

通过右上角Trace Timeline下拉框选择Trace Graph,可以看到这个链路图

后续也会尝试去分析一下,开源框架是怎么把链路追踪封进去的,主要一个区别我初步看了下是在context,因为go原生context比较有限。