分布式链路追踪:Jaeger与OpenTelemetry

分布式链路追踪:Jaeger与OpenTelemetry
分布式链路追踪:Jaeger与OpenTelemetry摘要: 本篇讲解Go服务分布式链路追踪实战集成OpenTelemetry SDK实现Trace创建与Span管理部署Jaeger后端查看调用链路实现跨服务HTTP header传递trace context分享异步goroutine中trace context丢失导致链路断裂的踩坑经验对比Jaeger、Zipkin、SkyWalking和Datadog四款追踪系统。开篇故事上个月有个用户反馈下单偶尔慢5秒到30秒不等。我们的下单链路经过4个服务网关、订单、库存、支付。日志分散在4台机器上靠时间戳对齐找问题找了两个小时没定位到。后来接了Jaeger做链路追踪一个trace ID串起整条调用链。再复现问题Jaeger UI上清清楚楚库存服务的某个DB查询Span花了8秒。如果没有链路追踪微服务排查问题基本靠猜。这篇从零开始讲怎么在Go服务里接入OpenTelemetry和Jaeger。一、OpenTelemetry SDK集成OpenTelemetry是CNCF的可观测性标准统一了Trace、Metrics、Logs三种数据。我们先用它接入Trace。packagetracingimport(contextfmtloggo.opentelemetry.io/otelgo.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttpgo.opentelemetry.io/otel/propagationgo.opentelemetry.io/otel/sdk/resourcesdktracego.opentelemetry.io/otel/sdk/tracesemconvgo.opentelemetry.io/otel/semconv/v1.21.0)// InitTracer 初始化OpenTelemetry tracer// endpoint: Jaeger的OTLP接收地址如localhost:4318// serviceName: 当前服务名如order-servicefuncInitTracer(endpoint,serviceNamestring)(func(),error){// 创建OTLP HTTP exporter发送trace数据到Jaeger// 使用HTTP协议端口4318exporter,err:otlptracehttp.New(context.Background(),otlptracehttp.WithEndpoint(endpoint),// 开发环境用HTTP不加密生产环境去掉Insecureotlptracehttp.WithInsecure(),)iferr!nil{returnnil,fmt.Errorf(创建exporter失败: %w,err)}// 定义资源信息标识当前服务// service.name和service.version会显示在Jaeger里res,err:resource.New(context.Background(),resource.WithAttributes(semconv.ServiceName(serviceName),semconv.ServiceVersion(1.0.0),),)iferr!nil{returnnil,fmt.Errorf(创建resource失败: %w,err)}// 创建tracer provider// AlwaysSample: 采样所有请求生产环境用ParentBased调整tp:sdktrace.NewTracerProvider(sdktrace.WithBatcher(exporter),sdktrace.WithResource(res),sdktrace.WithSampler(sdktrace.AlwaysSample()),)// 设置全局tracer providerotel.SetTracerProvider(tp)// 设置W3C Trace Context传播器// 用于跨服务传递trace contextotel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(propagation.TraceContext{},propagation.Baggage{},),)// 返回清理函数程序退出时调用returnfunc(){iferr:tp.Shutdown(context.Background());err!nil{log.Printf(tracer shutdown失败: %v,err)}},nil}初始化完成后就可以在业务代码里创建Span了。packagemainimport(contextfmtgo.opentelemetry.io/otelgo.opentelemetry.io/otel/attributego.opentelemetry.io/otel/codes)// ProcessOrder 处理订单的函数// 每个关键操作创建一个子SpanfuncProcessOrder(ctx context.Context,orderIDstring)error{// 获取全局tracer名称用于标识这个tracertracer:otel.Tracer(order-service)// 创建顶层Spanctx会传递trace contextctx,span:tracer.Start(ctx,ProcessOrder)deferspan.End()// 添加属性在Jaeger里可以看到span.SetAttributes(attribute.String(order.id,orderID),)// 子操作1: 验证库存verifySpan(ctx,orderID)// 子操作2: 创建支付iferr:createPayment(ctx,orderID);err!nil{// 记录错误到Spanspan.RecordError(err)span.SetStatus(codes.Error,err.Error())returnerr}// 子操作3: 发送通知(异步)gosendNotification(ctx,orderID)returnnil}// verifySpan 验证库存的子SpanfuncverifySpan(ctx context.Context,orderIDstring){tracer:otel.Tracer(order-service)// 从ctx继承父Span的trace context_,span:tracer.Start(ctx,VerifyInventory)deferspan.End()// 模拟库存检查// 这里写实际业务逻辑}// createPayment 创建支付的子SpanfunccreatePayment(ctx context.Context,orderIDstring)error{tracer:otel.Tracer(order-service)_,span:tracer.Start(ctx,CreatePayment)deferspan.End()// 模拟支付创建returnnil}// sendNotification 发送通知funcsendNotification(ctx context.Context,orderIDstring){tracer:otel.Tracer(order-service)_,span:tracer.Start(ctx,SendNotification)deferspan.End()fmt.Printf(发送通知: orderID%s\n,orderID)}每个Span有开始和结束时间Jaeger里能看到每个操作的耗时。Span之间通过ctx建立父子关系形成调用树。二、Jaeger后端部署Jaeger最简单的部署方式是用all-in-one镜像一个容器包含所有组件。# docker-compose.yamlversion:3.8services:jaeger:image:jaegertracing/all-in-one:1.52ports:# Jaeger UI-16686:16686# OTLP HTTP接收端口-4318:4318# OTLP gRPC接收端口-4317:4317environment:# 设置采样率生产环境用远程采样-COLLECTOR_OTLP_ENABLEDtrue启动后访问 http://localhost:16686 就能看到Jaeger UI。在下拉菜单里选择服务名点Find Traces就能看到调用链路。生产环境不要用all-in-one。用Jaeger的分布式部署Collector单独部署数据存到Elasticsearch或Cassandra。采样率也要调全量采样数据量太大一般1%到10%就够了。三、跨服务trace传递链路追踪的核心价值在于跨服务。一个请求从网关到订单服务再到库存服务trace ID必须一路传递下去。OpenTelemetry通过HTTP header实现这一点。packagemainimport(contextfmtnet/httpgo.opentelemetry.io/otelgo.opentelemetry.io/otel/propagation)// HTTPCall 发起HTTP请求并传递trace context// 调用下游服务时把当前Span的context注入到headerfuncHTTPCall(ctx context.Context,urlstring)error{tracer:otel.Tracer(order-service)// 创建子Span记录这次HTTP调用ctx,span:tracer.Start(ctx,HTTPCall:url)deferspan.End()// 创建HTTP请求req,err:http.NewRequestWithContext(ctx,GET,url,nil)iferr!nil{returnerr}// 关键步骤: 把trace context注入到HTTP header// 下游服务从header提取后就能继续这条traceotel.GetTextMapPropagator().Inject(ctx,propagation.HeaderCarrier(req.Header),)// 发送请求resp,err:http.DefaultClient.Do(req)iferr!nil{returnerr}deferresp.Body.Close()fmt.Printf(响应状态码: %d\n,resp.StatusCode)returnnil}// traceMiddleware HTTP服务端中间件// 从入站请求的header提取trace contextfunctraceMiddleware(next http.Handler)http.Handler{returnhttp.HandlerFunc(func(w http.ResponseWriter,r*http.Request){// 从header提取trace contextctx:otel.GetTextMapPropagator().Extract(r.Context(),propagation.HeaderCarrier(r.Header),)tracer:otel.Tracer(inventory-service)// 创建服务端Spanctx,span:tracer.Start(ctx,HTTP r.Method r.URL.Path,)deferspan.End()// 把带trace context的ctx传给下游handlernext.ServeHTTP(w,r.WithContext(ctx))})}客户端用Inject把trace context写入header服务端用Extract从header读出来。这样两个服务的Span就有了同一个trace ID在Jaeger里能看到完整的调用链。四、独家踩坑:goroutine中trace context丢失这个坑很隐蔽。我们的订单服务在创建订单后用goroutine异步发送通知和扣减积分。Jaeger里看到的通知和积分扣减Span跟主链路断了变成两条独立的trace。问题出在goroutine。go sendNotification(ctx, orderID)看起来传了ctx但ctx在goroutine启动后可能已经结束了父Span。goroutine里的Span找不到父Spantrace就断了。// 错误写法: goroutine中context可能失效funcBadAsyncExample(ctx context.Context,orderIDstring){tracer:otel.Tracer(order-service)_,span:tracer.Start(ctx,ProcessOrder)deferspan.End()// 问题: ctx里的Span可能在goroutine执行时已经End了gofunc(){// 这里创建的Span找不到父Span_,notifSpan:tracer.Start(ctx,SendNotification)defernotifSpan.End()// Span变成孤儿trace链路断裂}()}// 正确写法: 用context.WithoutCancel隔离取消信号// 再用Detach重新关联trace contextfuncGoodAsyncExample(ctx context.Context,orderIDstring){tracer:otel.Tracer(order-service)_,span:tracer.Start(ctx,ProcessOrder)deferspan.End()// 方案1: 使用context.WithoutCancel// 保留trace context但不受父context取消影响asyncCtx:context.WithoutCancel(ctx)gofunc(){// asyncCtx仍然携带trace context_,notifSpan:tracer.Start(asyncCtx,SendNotification)defernotifSpan.End()// trace链路完整能找到父Span}()}context.WithoutCancel是Go 1.21引入的它复制了ctx的value(包括trace context)但去掉了cancel信号。这样goroutine里的Span能关联到父Span同时不会被父context的cancel影响。另一个方案是在启动goroutine前把trace context提取出来在goroutine里重新注入。但context.WithoutCancel更简洁推荐用这个。五、对比分析追踪系统语言生态存储后端UI体验社区活跃度Jaeger多语言SDK丰富ES/Cassandra好功能全高(CNCF毕业)Zipkin多语言支持MySQL/ES一般简洁中SkyWalkingJava为主Go支持弱ES/H2好中文友好中(Apache)Datadog多语言SDKSaaS托管最好集成度高高(商业产品)Jaeger是CNCF毕业项目OpenTelemetry原生支持社区最活跃。Zipkin历史最久轻量但功能少。SkyWalking在Java生态很强Go支持一般。Datadog是商业产品体验最好但要花钱。总结与预告链路追踪是微服务可观测性的核心能力。OpenTelemetry统一了Trace标准Jaeger提供了开箱即用的后端。记住goroutine里要用context.WithoutCancel保留trace context否则链路会断。下一篇我们聊gRPC服务治理包括健康检查和负载均衡。

最新新闻

日新闻

周新闻

月新闻