写点什么

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

作者:非晓为骁
  • 2022 年 3 月 11 日
  • 本文字数:5198 字

    阅读完需:约 17 分钟

本文档主要是在 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:一周内会发布



链路描述


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


  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 个

  3. / : 跨服务请求通过 request 的 header 写入 trace id 和 span id 实现的

  4. /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,这里用的是jaegerfunc 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 上面。



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





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

用户头像

非晓为骁

关注

no pain no gain 2019.04.10 加入

用我的勤奋,一点一点地努力,提升自己的能力,拓展自己的视野,提高自己的认知。 我的知乎:https://www.zhihu.com/people/zhengfke

评论

发布
暂无评论
Go HTTP Server 基于OpenTelemetry 使用Jaeger - 代码实操_Go_非晓为骁_InfoQ写作平台