本文档主要是在 go 的 http server 的请求上加上链路追踪,链路追踪系统用的是 jaeger,标准用的是 OpenTelemetry。本文档的代码用的是原生的 go http server 的代码实现,不是用 gin 或者是 go-zero 里面的链路追踪封装,旨在了解链路最终到底在请求之间是怎么加上去的。通过这个文档希望你能够了解到 go http server 如何加上链路追踪的,OpenTelemetry 是怎么用上去的。
本文相关版本及代码如下:
相关文章需要了解概念及 jaeger 部署的可以见这两篇文章:
链路描述
本文档代码通过上面这个图稍微简单讲解一下:
C 代表客户端,S 代表服务端,F 代表方法
会有两个服务端 S 代表代码中的 svc1,S'代表代码中的 svc2
S 收到请求后会开协程调用 Fa,然后调用 Fb
Fb 会去跨服务请求 S'的接口
S'收到请求后执行 Fc
主要的是这么一个实现流程,跨服务 Tracing 的实现上面这张图里面之列了代码里面的往请求头写 header 的方法,实际代码中有另一个方法通过 spanContext 里面的 Baggage 来实现的。
通过这么一个例子,可以了解 Tracing 到底会怎么 Tracing,可以 Tracing 什么东西。
服务端-S-svc1
main
下面代码是 svc1 里面 http server 启动的 main 函数,主要是两部分:
初始化一个全局的 trace provider,要用哪个链路追踪系统。下面代码中的 tp 是一个全局变量
添加路由,路由有 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 的代码都是一样的就不再讲了。
主要是讲一下路由:
/service-2 : 用来接收通过 request header 方式的请求
/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 有很大区别
需要获取请求的 trace id 和 span id
需要自己生成父 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 比较有限。
评论