本文档主要是在go的http server的请求上加上链路追踪,链路追踪系统用的是jaeger,标准用的是OpenTelemetry。本文档的代码用的是原生的go http server的代码实现,不是用gin或者是go-zero里面的链路追踪封装,旨在了解链路最终到底在请求之间是怎么加上去的。通过这个文档希望你能够了解到go http server如何加上链路追踪的,OpenTelemetry是怎么用上去的。
本文相关版本及代码如下:
相关文章需要了解概念及jaeger部署的可以见这两篇文章:
本文档代码通过上面这个图稍微简单讲解一下:
主要的是这么一个实现流程,跨服务Tracing的实现上面这张图里面之列了代码里面的往请求头写header的方法,实际代码中有另一个方法通过spanContext里面的Baggage来实现的。
通过这么一个例子,可以了解Tracing到底会怎么Tracing,可以Tracing什么东西。
下面代码是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就是对’/'的请求函数,从这里开始就是我们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的目的就是来实现往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就是为了实现跨服务的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就是用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))
...
}
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)
}
因为是跨服务被调用,所以和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)
}
和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比较有限。