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://blog.csdn.net/weixin_40242845/article/details/123421309
  • 浅谈云原生可观测性-Tracing:一周内会发

链路描述

Go HTTP Server 基于OpenTelemetry 使用Jaeger - 代码实操_第1张图片

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

  1. C代表客户端,S代表服务端,F代表方法
  2. 会有两个服务端S代表代码中的svc1,S’代表代码中的svc2
  3. S收到请求后会开协程调用Fa,然后调用Fb
  4. Fb会去跨服务请求S’的接口
  5. S’收到请求后执行Fc

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

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


服务端-S-svc1

main

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

  1. 初始化一个全局的trace provider,要用哪个链路追踪系统。下面代码中的tp是一个全局变量
  2. 添加路由,路由有2个
    • / : 跨服务请求通过request的header写入trace id和span id实现的
    • /baggage : 跨服务请求通过baggage item实现的
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的代码都是一样的就不再讲了。

主要是讲一下路由:

  1. /service-2 : 用来接收通过request header方式的请求
  2. /service-2-baggage : 用来接收通过baggage item方式的请求
func main() {
    ...
    
	http.HandleFunc("/service-2", MainHandler)
	http.HandleFunc("/service-2-baggage", MainHandlerWithBaggage)
	http.ListenAndServe("127.0.0.1:8090", nil)
}
MainHandler

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

  1. 需要获取请求的trace id和span id
  2. 需要自己生成父span context

解析首先就是要用第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上面。

Go HTTP Server 基于OpenTelemetry 使用Jaeger - 代码实操_第2张图片

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

Go HTTP Server 基于OpenTelemetry 使用Jaeger - 代码实操_第3张图片


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

你可能感兴趣的:(Go,golang,http,开发语言,jaeger,openTelemetry)