服务一多,链路一长,线上问题排查就像大海捞针。日志分散在不同节点,调用关系全靠猜,你是否也曾为此困扰?本文将分享我们在 easyms.golang 项目中,如何从零开始,一步步引入 OpenTelemetry 和 Jaeger,串联起 HTTP、gRPC、RabbitMQ 和 GORM,最终构建起清晰的分布式调用链。这不只是一篇教程,更是一次真实落地的复盘与思考。
相关话题也欢迎到 云栈社区 继续交流。
微服务时代的“迷雾”与“寻路”
这周我全面升级了 easyms.golang 的微服务,扩充了不少“家庭成员”。但服务数量增长的同时,问题也随之放大:当一个用户请求在多个服务间流转,涉及 HTTP、gRPC 调用、消息队列传递,甚至多次数据库操作时,一旦出现异常或性能瓶颈,怎么才能快速、准确地定位问题?
传统日志分析在单体应用里还能勉强应对,但在分布式系统中,日志散落在各个服务节点,时间戳还可能不同步,排查往往演变成一场“海里捞针”的战役。我们迫切需要一种机制,把这些分散的足迹串联起来,形成完整的“故事线”。
这,就是引入分布式追踪的核心驱动力。
选择之路:OpenTelemetry + Jaeger 的契合
在选择 APM(应用性能监控)方案时,我在 Jaeger 和 SkyWalking 之间犹豫过。SkyWalking 在我的 .NETCore 微服务架构中用过,是老朋友;但也能看到很多文献都在介绍 Jaeger。
对于我们的 Go 技术栈项目,OpenTelemetry + Jaeger 是更合适的组合:
- 遵循云原生开放标准,减少厂商锁定
- 将来如果要切换到其他后端(如 Grafana Tempo),基本不需要改业务代码,通常只需替换 Exporter 配置
- 相比一体化 APM 方案,这种组合更贴合
easyms 现有的可观测性体系(例如我们已集成 Prometheus 做指标、Loki 做日志),形成“各司其职、协同工作”的弹性架构
如果你也在做 Go 微服务相关的架构演进,可以在 Go 板块看到更多同类实践与踩坑经验。
实战演练:在 easyms.golang 中构建追踪体系
接下来以 easyms.golang 为例,按步骤拆解我们如何把 OpenTelemetry 集成到关键组件中。
第一步:奠定基础 - 追踪模块与配置
我们先在 internal/shared/ 目录下创建 tracing 包,作为 OpenTelemetry 的统一初始化入口。
1) tracing 模块(internal/shared/tracing/tracing.go)
该模块负责初始化 OTel 的 TracerProvider,并配置 OTLP HTTP Exporter 将追踪数据发送到 Jaeger。
package tracing
import (
"context"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
"go.opentelemetry.io/otel/propagation"
"go.opentelemetry.io/otel/sdk/resource"
"go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.21.0"
)
// InitTracerProvider 初始化并注册 OpenTelemetry Tracer Provider。
func InitTracerProvider(serviceName, endpoint string) (func(context.Context) error, error) {
// 配置 OTLP HTTP Exporter,指向 Jaeger Collector
exporter, err := otlptracehttp.New(context.Background(), otlptracehttp.WithEndpoint(endpoint), otlptracehttp.WithInsecure())
if err != nil {
return nil, err
}
// 定义服务资源,用于在 Jaeger UI 中识别服务
res, err := resource.New(context.Background(),
resource.WithAttributes(semconv.ServiceNameKey.String(serviceName)),
)
if err != nil {
return nil, err
}
// 创建 TracerProvider,配置批量处理器和资源
tp := trace.NewTracerProvider(
trace.WithBatcher(exporter),
trace.WithResource(res),
)
// 设置全局 TracerProvider 和 W3C Trace Context 传播器
otel.SetTracerProvider(tp)
otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(propagation.TraceContext{}, propagation.Baggage{}))
return tp.Shutdown, nil // 返回 shutdown 函数,用于优雅关闭
}
2) 配置模型(internal/shared/models/config.go)
为了让追踪启用/关闭、Collector 地址等可配置,我们在 AppConfig 中新增 TracingConfig。
package models
// ... 其他导入
// AppConfig 定义应用核心配置
type AppConfig struct {
// ... 其他配置
Tracing TracingConfig `yaml:"tracing,omitempty"` // 新增分布式追踪配置
}
// TracingConfig 定义分布式追踪配置
type TracingConfig struct {
Enable bool `yaml:"enable"`
Endpoint string `yaml:"endpoint"` // 例如: http://jaeger:14268/api/traces
}
3) 服务入口初始化(internal/services/auth/cmd/authsvc/main.go)
每个服务的 main.go 在加载配置后,根据 TracingConfig 决定是否启用追踪,并在退出时确保 flush 数据。
package main
// ... 其他导入
import "easyms/internal/shared/tracing" // 引入 tracing 包
func main() {
// ... 配置加载逻辑
appConfig := config.GetAppConfig()
// ... 日志初始化
// 初始化分布式追踪系统
if appConfig.Tracing.Enable {
shutdown, err := tracing.InitTracerProvider(serverName, appConfig.Tracing.Endpoint)
if err != nil {
logger.Error(err, "Failed to initialize tracer provider", serverName)
} else {
defer shutdown(context.Background()) // 确保服务退出时刷新数据
}
}
// ... 其他服务启动逻辑
}
第二步:上下文传播 - 打通微服务间的“任督二脉”
上下文传播是全链路追踪的关键:它确保 Trace ID 能在不同组件、不同服务之间稳定传递。没有传播,再多埋点也只是“各自为战”。
1) HTTP 入口(Gin)
Gin 场景下,OpenTelemetry 提供了中间件 otelgin,直接挂到路由即可。
package main
// ... 其他导入
import "go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin" // 引入 otelgin
func main() {
// ...
g := gin.Default()
// 添加 OpenTelemetry Gin 中间件
if appConfig.Tracing.Enable {
g.Use(otelgin.Middleware(serverName))
}
// ...
}
2) gRPC 服务间调用
gRPC 追踪通过 otelgrpc 实现,利用 StatsHandler 机制分别覆盖服务端与客户端。
package main
// ... 其他导入
import "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc" // 引入 otelgrpc
func main() {
// ... gRPC 服务端启动
go func() {
// ...
var s *grpc.Server
if appConfig.Tracing.Enable {
// gRPC 服务端 StatsHandler
s = grpc.NewServer(grpc.StatsHandler(otelgrpc.NewServerHandler()))
} else {
s = grpc.NewServer()
}
// ...
}()
// ... gRPC-Gateway 客户端调用
go func() {
// ...
var opts []grpc.DialOption
if appConfig.Tracing.Enable {
// gRPC 客户端 StatsHandler
opts = []grpc.DialOption{
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithStatsHandler(otelgrpc.NewClientHandler()),
}
} else {
opts = []grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())}
}
// ...
}()
}
3) 消息队列(RabbitMQ)- 巧妙的适配
消息队列是追踪里最容易断链的环节:因为它不是“调用”,而是“投递”。要想把 Trace ID 传过去,必须把上下文注入到消息头里,并在消费端取出来恢复。
但这里会遇到类型不匹配:amqp.Table(map[string]interface{})与 OTel TextMapCarrier 期望的(map[string]string)不同,所以我们实现一个自定义适配器 amqpHeadersCarrier。
// internal/shared/mq/rabbitmq.go
package mq
// ... 其他导入
import (
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/propagation"
)
// amqpHeadersCarrier 实现了 propagation.TextMapCarrier 接口
type amqpHeadersCarrier map[string]interface{}
func (c amqpHeadersCarrier) Get(key string) string { /* ... */ }
func (c amqpHeadersCarrier) Set(key, value string) { c[key] = value }
func (c amqpHeadersCarrier) Keys() []string { /* ... */ }
// --- 发布端 (Publish 方法中) ---
func (p *RabbitMQPublisher) Publish(ctx context.Context, event Event) error {
// ...
if event.Headers == nil {
event.Headers = make(map[string]interface{})
}
// 使用自定义 carrier 注入追踪信息
otel.GetTextMapPropagator().Inject(ctx, amqpHeadersCarrier(event.Headers))
msg := amqp.Publishing{
// ...
Headers: amqp.Table(event.Headers), // 将 Headers 传递给 AMQP
}
// ...
}
// --- 消费端 (Consume 方法中) ---
func (c *RabbitMQConsumer) Consume(...) error {
// ...
go func() {
for d := range msgs {
// 使用自定义 carrier 从消息头提取追踪信息
propagator := otel.GetTextMapPropagator()
ctx := propagator.Extract(context.Background(), amqpHeadersCarrier(d.Headers))
// 将带有追踪信息的 ctx 传递给业务 handler
if err := handler(ctx, d.Body); err == nil { /* ... */ }
}
}()
// ...
}
4) 数据库操作(GORM)
GORM 提供了 OpenTelemetry 插件。只要在初始化 gorm.DB 时注册即可把 SQL 访问纳入 Span。
// internal/shared/db/factory.go
package db
// ... 其他导入
import "gorm.io/plugin/opentelemetry/tracing" // 引入 GORM OTel 插件
// createGormDB 是一个辅助函数,用于创建和配置 gorm.DB 实例
func createGormDB(dialector gorm.Dialector, dbType string) (*gorm.DB, error) {
// ...
db, err := gorm.Open(dialector, config)
// ...
// 注册 OpenTelemetry 插件
if err := db.Use(tracing.NewPlugin()); err != nil {
return nil, err
}
return db, nil
}
一次有价值的重构:context.Context 的全面引入
为了让 GORM 插件能从上下文中获取父 Span,我们对 db 包中的所有数据库操作接口和实现做了全面重构:为其增加 context.Context 参数。
这项工作涉及面很广,但收益也很明确:
- 强制遵循 Go 并发最佳实践
- 确保
Context 在整个调用栈中正确传递
- 为未来的超时控制、取消操作等打下坚实基础
做链路追踪时,你是否也遇到过“Span 断在数据库层”的情况?很多时候,根因就是 Context 没传下去。
第三步:本地验证 - 部署 Jaeger
我们在 docker-compose.yaml 中添加 Jaeger 服务,并让各微服务指向 Jaeger 的 OTLP 收集器。
# deploy/docker/docker-compose.yaml
services:
# ... 其他服务
jaeger:
image: jaegertracing/all-in-one:latest
container_name: jaeger
ports:
- "16686:16686" # Jaeger UI 端口
- "14268:14268" # OTLP HTTP Collector 端口
networks:
- easy-network
environment:
- COLLECTOR_OTLP_ENABLED=true # 启用 OTLP 收集器
auth-svc:
# ...
environment:
- EASYMS_TRACING_ENABLE=true
- EASYMS_TRACING_ENDPOINT=http://jaeger:14268/api/traces
depends_on:
- jaeger # 确保 Jaeger 先启动
# ...
成果展示:系统运行状态的全局洞察
完成改造并启动环境后,访问 Jaeger UI( http://localhost:16686 )。在服务列表中选择任意服务,点击“查找追踪”,就能看到清晰的调用链图。

例如,一个从 API Gateway 发起,经过认证服务、消息队列,最终由订单服务处理的请求,它的完整生命周期、每个环节耗时、服务依赖关系,会以直观图形呈现出来。排查问题和分析瓶颈的效率会明显提升。
我们能清晰看到:
- 请求从哪个服务开始,经过了哪些服务
- 每个服务内部执行了哪些操作(HTTP 处理、gRPC 调用、SQL 查询、消息发布/消费)
- 每个操作的耗时,从而快速定位性能瓶颈
- 服务间的错误传递,帮助追踪异常源头
如果你也在做这类分布式系统的系统设计与治理,相关方法论可以在 后端 & 架构 进一步延伸阅读。
总结与展望
通过这次实践,我们为 easyms.golang 集成了基于 OpenTelemetry 的全链路追踪能力。过程涉及多处改造,但主线很明确:构建统一的 tracing 模块,并利用 OTel 生态对各组件进行“无侵入式”埋点。
这次改造不仅解决了当下的排错痛点,也为未来的可观测性建设打下了基础。接下来还可以继续探索:
- 智能采样策略:生产环境里根据业务重要性或错误率动态调整采样,平衡性能开销与覆盖率
- 业务上下文传递:利用 OpenTelemetry Baggage 在链路中传递用户 ID、租户 ID 等,实现更细粒度的业务追踪
- 日志与追踪关联:在日志中自动注入 Trace ID 和 Span ID,并在 Grafana 等工具中实现从追踪到日志的跳转,打通可观测性的三大支柱
项目源码
本文讨论的 easyms.golang 项目已开源: