opentracing實戰
- 解決微服務架構下,鏈路追蹤,問題定位和視覺化分析等問題,鏈路追蹤還是微服務可觀測性的重要基石,本文就實戰了在Go專案中如何將opentracing落地。
Opentracing
注意:Opentracing 是一套標準介面,而不是具體實現。
這裡就實戰opentracing + jaeger 的鏈路追蹤方案。其中 opentracing 是一套標準介面,而jaeger是包含了 opentracing 的實現的一套工具。 Trace鏈路簡單示例如下:
Trace
描述在分散式系統中的一次”事務”。
Span
表示工作流的一部分的命名和定時操作。可以接受標籤(Tag Key:Value),以及附加到特定span例項的標註(Annotation),如時間戳和結構化日誌。
SpanContext
追蹤伴隨分散式事務的資訊,包括它通過網路或通過訊息匯流排將服務傳遞給服務的時間。span上下文包含TraceId、SpanId和追蹤系統需要傳播到下游服務的其他資料。
實戰
- 這裡我準備的是 Go 專案,服務之間通過gRPC通訊。鏈路如下:
+-- process internal trace2
|
+---> process internal trace1
|
| +---> server-b trace(gRPC)
entry(HTTP) ---> server-a trace--gRPC--|
+---> server-c trace(gRPC)
|
+----> process internal trace3
從上圖中可以明確,我們的目標是:實踐 *跨服務呼叫 *和 *服務內部呼叫 *的鏈路追蹤,配合jaeger我們還可以將鏈路資訊視覺化。
落地
為了回答這個問題,我把這個問題結合opentracing的概念再分解一下:
1. ParentSpan 從哪兒來?
2. ChildSpan由ParentSpan建立,那麼什麼時候建立?
3. 鏈路Tracer從哪兒來?
4. Trace資訊怎麼傳遞?
5. 鏈路資訊如何蒐集?
1. ParentSpan 從哪兒來?
在上述的實踐目標中,我們只有一個入口服務,那麼很明顯每一次的”事務”都是在這個入口(http entry)開啟的。我寫了如下的中介軟體(基於gin)。
// 這個定義是因為opentracing沒有定義獲取traceId 和spanId的方法,而我又實踐了 zipkin 和 jaeger
type getTraceID func(spCtx opentracing.SpanContext) string
// get trace info from header, if not then create an new one
func Opentracing(getTraceIdFromSpanContext getTraceID) gin.HandlerFunc {
return func(c *gin.Context) {
// prepare work ...
// 這裡首先嚐試從客戶端請求中獲取到trace鏈路資訊,如果獲取到則建立child span.
carrier := opentracing.HTTPHeadersCarrier(c.Request.Header)
clientSpCtx, err := tracer.Extract(opentracing.HTTPHeaders, carrier)
if err != nil {
log.Printf("could not extract trace data from http header, err=%v\n", err)
}
// derive a span or create an root span
sp = tracer.StartSpan(
c.Request.RequestURI,
opentracing.ChildOf(clientSpCtx),
)
defer sp.Finish()
// do some work
// ...
// 將含有span的 context.Context 設定到 gin.Context 用於傳遞span
ctx = opentracing.ContextWithSpan(c.Request.Context(), sp)
c.Set(_traceContextKey, ctx)
traceId := getTraceIdFromSpanContext(sp.Context())
c.Header("X-Trace-Id", traceId)
// continue process request
c.Next()
// do some work
// ...
}
}
2. ChildSpan由ParentSpan建立,那麼什麼時候建立?
其實在上述的中介軟體裡已經有了體現(跨服務呼叫時)。這裡主要有兩種場景:跨服務呼叫,服務內部調,這兩種的區別在於是否是同一個程式。 針對跨服務呼叫,我們使用了gRPC來通訊,那麼建立的時機就在於gRPC客戶端發起呼叫時,需要建立一個childSpan並傳遞給服務端,服務端需要解析到該span並在當次請求中使用。 而對於服務內部的呼叫,相較而言會更簡單一點,直接使用該span建立childSpan就好了。
這裡需要注意的是,一般來說在Go開發過程中推薦使用Context作為函式呼叫的第一個引數,opentracing也考慮了這一點,如下: 官方提供了對應的方法,來幫助使用者把span和context.Context一起使用。
// ContextWithSpan returns a new `context.Context` that holds a reference to
// the span. If span is nil, a new context without an active span is returned.
func ContextWithSpan(ctx context.Context, span Span) context.Context {
if span != nil {
if tracerWithHook, ok := span.Tracer().(TracerContextWithSpanExtension); ok {
ctx = tracerWithHook.ContextWithSpanHook(ctx, span)
}
}
return context.WithValue(ctx, activeSpanKey, span)
}
// SpanFromContext returns the `Span` previously associated with `ctx`, or
// `nil` if no such `Span` could be found.
//
// NOTE: context.Context != SpanContext: the former is Go's intra-process
// context propagation mechanism, and the latter houses OpenTracing's per-Span
// identity and baggage information.
func SpanFromContext(ctx context.Context) Span {
val := ctx.Value(activeSpanKey)
if sp, ok := val.(Span); ok {
return sp
}
return nil
}
在中介軟體那裡,我們已經把 context.Context 設定到了 gin.Context 中去了,因此在後續的使用中,我們需要把它從 gin.Context 取出並傳遞。
// traceHdl is a trace handler from HTTP request
func traceHdl(c *gin.Context) {
// get root Context from request
// TODO: try to use c.Request.WithContext() to set context
ctx, ok := c.Get(x.GetTraceContextKey())
if !ok {
panic("impossible")
}
// ctx在服務內部傳遞,ctx含有span資訊
if err := clientCall(ctx.(context.Context)); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"message": err.Error()})
return
}
// response to client
c.JSON(http.StatusOK, gin.H{"message": "traceHdl done"})
}
func clientCall(ctx context.Context) error {
// 注意:這裡使用了gRPC來做跨服務的呼叫,對於ctx的處理,是通過interceptor實現的。
// 這部分程式碼會在後面貼上,但是邏輯也跟類似,只是多了需要適配gRPC資料傳遞的規則。
_, err := serverAConn.PingA(ctx, &pb.PingAReq{
Now: time.Now().Unix(),
From: "client",
})
if err != nil {
return err
}
// 這裡演示程式內鏈路
return processInternalTrace1(ctx)
}
// internal process trace example 1
func processInternalTrace1(ctx context.Context) error {
ctx2, sp := x.StartSpanFromContext(ctx)
defer sp.Finish()
println("processInternalTrace1 called")
// do some ops
time.Sleep(10 * time.Millisecond)
return processInternalTrace2(ctx2)
}
- gRPC interceptor 實現如下:服務端工作內容相似,只是從inject操作變成了extract。
// client interceptor
func OpenTracingClientInterceptor(tracer opentracing.Tracer, optFuncs ...Option) grpc.UnaryClientInterceptor {
otgrpcOpts := newOptions()
otgrpcOpts.apply(optFuncs...)
return func(
ctx context.Context,m
ethod string,
req, resp interface{},
cc *grpc.ClientConn,
invoker grpc.UnaryInvoker,
opts ...grpc.CallOption,
) error {
var err error
var parentCtx opentracing.SpanContext
if parent := opentracing.SpanFromContext(ctx); parent != nil {
parentCtx = parent.Context()
}
// ...
clientSpan := tracer.StartSpan(
method,
opentracing.ChildOf(parentCtx),
ext.SpanKindRPCClient,
gRPCComponentTag,
)
defer clientSpan.Finish()
// 注入
ctx = injectSpanContext(ctx, tracer, clientSpan)
// ...
// 呼叫
err = invoker(ctx, method, req, resp, cc, opts...)
// ...
return err
}
}
func injectSpanContext(ctx context.Context, tracer opentracing.Tracer, clientSpan opentracing.Span) context.Context {
md, ok := metadata.FromOutgoingContext(ctx)
if !ok {
md = metadata.New(nil)
} else {
md = md.Copy()
}
mdWriter := metadataReaderWriter{md}
err := tracer.Inject(clientSpan.Context(), opentracing.HTTPHeaders, mdWriter)
// We have no better place to record an error than the Span itself :-/
if err != nil {
clientSpan.LogFields(log.String("event", "Tracer.Inject() failed"), log.Error(err))
}
return metadata.NewOutgoingContext(ctx, md)
}
至此我們就介紹完了,服務間(gRPC)呼叫和服務內(context)呼叫的childSpan的建立和傳遞。
3. 鏈路Tracer從哪兒來?
先說Tracer有什麼用,Tracer是用來管理Span的統籌者,負責建立span和傳播span。
// Tracer負責建立span和傳播span
type Tracer interface {
// Create, start, and return a new Span with the given `operationName` and
// incorporate the given StartSpanOption `opts`. (Note that `opts` borrows
// from the "functional options" pattern, per
// http://dave.cheney.net/2014/10/17/functional-options-for-friendly-apis)
//
// A Span with no SpanReference options (e.g., opentracing.ChildOf() or
// opentracing.FollowsFrom()) becomes the root of its own trace.
//
// Examples:
//
// var tracer opentracing.Tracer = ...
//
// // The root-span case:
// sp := tracer.StartSpan("GetFeed")
//
// // The vanilla child span case:
// sp := tracer.StartSpan(
// "GetFeed",
// opentracing.ChildOf(parentSpan.Context()))
//
// // All the bells and whistles:
// sp := tracer.StartSpan(
// "GetFeed",
// opentracing.ChildOf(parentSpan.Context()),
// opentracing.Tag{"user_agent", loggedReq.UserAgent},
// opentracing.StartTime(loggedReq.Timestamp),
// )
//
StartSpan(operationName string, opts ...StartSpanOption) Span
// Inject() takes the `sm` SpanContext instance and injects it for
// propagation within `carrier`. The actual type of `carrier` depends on
// the value of `format`.
//
// OpenTracing defines a common set of `format` values (see BuiltinFormat),
// and each has an expected carrier type.
//
// Other packages may declare their own `format` values, much like the keys
// used by `context.Context` (see https://godoc.org/context#WithValue).
//
// Example usage (sans error handling):
//
// carrier := opentracing.HTTPHeadersCarrier(httpReq.Header)
// err := tracer.Inject(
// span.Context(),
// opentracing.HTTPHeaders,
// carrier)
//
// NOTE: All opentracing.Tracer implementations MUST support all
// BuiltinFormats.
//
// Implementations may return opentracing.ErrUnsupportedFormat if `format`
// is not supported by (or not known by) the implementation.
//
// Implementations may return opentracing.ErrInvalidCarrier or any other
// implementation-specific error if the format is supported but injection
// fails anyway.
//
// See Tracer.Extract().
Inject(sm SpanContext, format interface{}, carrier interface{}) error
// Extract() returns a SpanContext instance given `format` and `carrier`.
//
// OpenTracing defines a common set of `format` values (see BuiltinFormat),
// and each has an expected carrier type.
//
// Other packages may declare their own `format` values, much like the keys
// used by `context.Context` (see
// https://godoc.org/golang.org/x/net/context#WithValue).
//
// Example usage (with StartSpan):
//
//
// carrier := opentracing.HTTPHeadersCarrier(httpReq.Header)
// clientContext, err := tracer.Extract(opentracing.HTTPHeaders, carrier)
//
// // ... assuming the ultimate goal here is to resume the trace with a
// // server-side Span:
// var serverSpan opentracing.Span
// if err == nil {
// span = tracer.StartSpan(
// rpcMethodName, ext.RPCServerOption(clientContext))
// } else {
// span = tracer.StartSpan(rpcMethodName)
// }
//
//
// NOTE: All opentracing.Tracer implementations MUST support all
// BuiltinFormats.
//
// Return values:
// - A successful Extract returns a SpanContext instance and a nil error
// - If there was simply no SpanContext to extract in `carrier`, Extract()
// returns (nil, opentracing.ErrSpanContextNotFound)
// - If `format` is unsupported or unrecognized, Extract() returns (nil,
// opentracing.ErrUnsupportedFormat)
// - If there are more fundamental problems with the `carrier` object,
// Extract() may return opentracing.ErrInvalidCarrier,
// opentracing.ErrSpanContextCorrupted, or implementation-specific
// errors.
//
// See Tracer.Inject().
Extract(format interface{}, carrier interface{}) (SpanContext, error)
}
之前已經提到了了opentracing是一套介面,那麼具體的實現是在其他的工具中完成的,如jaeger。建立的時候就需要jaeger的支援了,如下:
// 使用jaeger來建立一個tracer,並注入到opentracing全域性中去
func BootTracerWrapper(localServiceName string, hostPort string) error {
tracer, err := xjaeger.BootJaegerTracer(localServiceName, hostPort)
if err != nil {
return errors.Wrap(err, "BootTracerWrapper.BootZipkinTracer")
}
// 注入到全域性後,就可以通過 opentracing.GlobalTracer() 來使用 tracer了
opentracing.SetGlobalTracer(tracer)
return nil
}
func BootJaegerTracer(localServiceName, hostPort string) (opentracing.Tracer, error) {
cfg := &config.Configuration{
Sampler: &config.SamplerConfig{
Type: jaeger.SamplerTypeConst,
Param: 1,
},
ServiceName: localServiceName, // 服務名
Reporter: &config.ReporterConfig{
LogSpans: true,
CollectorEndpoint: _jaegerRecorderEndpoint,
}, // 鏈路蒐集配置
}
tracer, _, err := cfg.NewTracer(
config.Logger(jaegerlog.StdLogger),
config.ZipkinSharedRPCSpan(true),
)
if err != nil {
return nil, errors.Wrap(err, "BootJaegerTracer")
}
return tracer, nil
}
4. Trace資訊怎麼傳遞?
在 Child-Span什麼時候建立? 中提到了 Inject, Extract 方法,這兩個方法就是用來輔助Trace資訊傳遞的方法, 具體的實現也是在jaeger中實現的,有興趣的可以自行查閱程式碼。
5. 鏈路資訊如何蒐集?
在 鏈路Tracer從哪兒來 已經提到了jaeger在建立tracer時可以指定蒐集器的配置, 因此上報動作會在jaeger中完成,但是要注意的是,顯式呼叫 sp.Finish() 才會觸發上報動作。
總結
最終實戰結果截圖如下: 圖中包含了呼叫鏈路層級關係,每個環節的耗時情況,請求的入參和結果資料(異常資訊,如有)等。同時還支援自定義(Tag 和 Annotation 功能), 如果還想要更多的資訊,那麼推薦使用這兩個功能組合來滿足需求。
再多的案例和文件,都沒有自己上手實踐的效果好,建議實際執行並查閱 *opentracing *原始碼。所有的程式碼均可在 https://github.com/yeqown/opentracing-practice 中找到。
本作品採用《CC 協議》,轉載必須註明作者和本文連結