前言
Cloud Native
OpenTelemetry For Golang 一览
Cloud Native
可能很多同学会好奇,“OpenTelemetry 社区已经很成熟了,为什么还需要这一款 OpenTelemetry Golang Agent 呢?”。那么让我们以一个 Golang 开发者的视角,来看一看如果想对 Golang 应用进行监控,在 OpenTelemetry 社区中有哪些可选项。
进入社区,首先映入眼帘的就是 Star 数最高的 opentelemetry-go SDK 了。使用 SDK 进行手动插桩在项目较小时确实轻松加愉快,比如用户如果需要统计某个接口的耗时,可以直接在这个接口的前后添加一个 Span。
func parentMethod(ctx context.Context) {tracer := otel.Tracer("otel-go-tracer")ctx, span := tracer.Start(ctx, "parent span")fmt.Println(span.SpanContext().TraceID()) // 打印 TraceIdspan.SetAttributes(attribute.String("key", "value"))span.SetStatus(codes.Ok, "Success")childMethod(ctx)span.End()}
func main() {shutdown := otel_util.InitOpenTelemetry()defer shutdown()for i:= 0; i < 10; i++ {ctx := context.Background()parentMethod(ctx)}time.Sleep(10 * time.Second)}func parentMethod(ctx context.Context) {tracer := otel.Tracer("otel-go-tracer")ctx, span := tracer.Start(ctx, "parent span")fmt.Println(span.SpanContext().TraceID()) // 打印 TraceIdspan.SetAttributes(attribute.String("key", "value"))span.SetStatus(codes.Ok, "Success")childMethod(ctx)span.End()}func childMethod(ctx context.Context) {tracer := otel.Tracer("otel-go-tracer")ctx, span := tracer.Start(ctx, "child span")span.SetStatus(codes.Ok, "Success")grandChildMethod(ctx)span.End()}func grandChildMethod(ctx context.Context) {tracer := otel.Tracer("otel-go-tracer")ctx, span := tracer.Start(ctx, "grandchild span")span.SetStatus(codes.Error, "error")// 业务代码...span.End()}
即使用户可以在项目不断迭代的过程中对新增的代码进行手动插桩,代码的历史债务依然会阻止用户对 Golang 应用进行有效地监控,例如在 A->B->C->D->...->Z 的调用链路中其中的某个方法的入参中由于历史原因没有添加上下文或者是传递了错误的上下文,就会导致整条链路串联失败,在用户的应用程序较为复杂的情况下,需要花较多的时间才能准确的找到这些上下文传递错误的地方并进行改造修复,从而带来较为高昂的监控成本。
func parentMethod(ctx context.Context) {tracer := otel.Tracer("otel-go-tracer")ctx, span := tracer.Start(ctx, "parent span")fmt.Println(span.SpanContext().TraceID()) // 打印 TraceIdspan.SetAttributes(attribute.String("key", "value"))span.SetStatus(codes.Ok, "Success")childMethod(context.TODO()) // 由于历史原因,传递了错误的上下文span.End()}func childMethod(ctx context.Context) {tracer := otel.Tracer("otel-go-tracer")ctx, span := tracer.Start(ctx, "child span")span.SetStatus(codes.Ok, "Success")grandChildMethod(ctx)span.End()}
由于 SDK 手动插桩带来的复杂性,OpenTelemetry 社区也同时提供了一些自动插桩的办法[3]。官方提供的自动插桩办法基于 eBPF,基于此方案用户无需手动使用 SDK 更改业务代码,eBPF 可以自动识别到 Golang 应用并收集到应用的 HTTP,数据库,RPC 等调用的相关数据,同时对用户的上下文进行自动的透传,从而保证整个链路的完整性。
这时你可能有疑问,这种自动插桩的方式看起来很完美,用户用这个不就完事了吗?理想很丰满,现实很骨感,eBPF 插桩虽然有着上面这些优势,但是也存在着种种的限制:
由于 eBPF 的指令长度限制,此方案只能支持透传不超过 8 个 HTTP 请求头的上下文,这对于小应用可能还能凑合,但是对于生产级的应用是远远不能满足需求的。 eBPF 方案对内核的版本有着一定的要求,最小支持的内核版本为 4.4 版本,对于用户来说,升级操作系统版本对于生产应用的风险太大,这在某种程度上也降低了本方案的可用性。 eBPF 方案的性能相比之下较差,eBPF 自动插桩方案使用了 Uprobe 来对 Golang 的函数进行埋点插桩,Uprobe 在执行时会频繁往返于内核态与用户态,从而导致较大的性能开销。
既然 SDK 手动埋点方案如此繁琐,eBPF 自动插桩方案限制又这么多,那有没有什么“既要又要”的办法呢?OpenTelemetry 在它的 contrib[4]仓库提供了一种编译期自动插桩的工具 InstrGen。InstrGen 可以在编译期间对整个项目的语法树进行解析,并且在指定的方法处插入代码以实现应用监控的能力。这种编译时插桩的方法可以有效解决手动插桩以及 eBPF 自动插桩的相关痛点,理论上就是在“帮”用户写代码,自由度很高,然而 InstrGen 这个项目也有许多劝退用户的地方:
项目迭代慢,维护人员少:InstrGen 的维护者最近一次代码提交都已经在大概三个月之前(不包括 github 机器人)。 文档不完善,参与门槛高:社区的文档过于简单,用户很难通过文档来参与到社区的 bug 修复,测试等活动中来。 支持插件少,上下文无法自动传递:目前 InstrGen 无法支持 MySQL,Redis 等常用数据库的调用监控,同时 InstrGen 的上下文透传依然依赖于用户显示在函数上传递 context 参数,无法做到自动的上下文透传,对于用户来说依然有部分改造成本。
本次开源的 OpenTelemetry Golang Agent,思路与 InstrGen 基本一致,都是在编译期间对用户的代码进行自动的插桩。在正常情况下,go build 命令会经历以下主要步骤来编译一个 Golang 应用:
源码解析:Golang 编译器会先解析源代码文件,将其转化为抽象语法树(AST)。 类型检查: 解析后会进行类型检查,确保代码符合 Golang 的类型系统。 语义分析: 对程序的语义进行分析,包括变量的定义和使用、包导入等。 编译优化:将语法树转化为中间表示, 进行各种优化,提高代码执行效率。 代码生成: 生成目标平台的机器代码。 链接: 将不同包和库链接成一个单一的可执行文件。
预处理
在这一阶段,工具会分析用户项目代码的三方库依赖,并与现有的插桩规则匹配以找到合适的插桩规则,并提前配置好这些插桩规则所需的额外依赖。
插桩规则准确定义了针对哪个版本的哪个框架、标准库要注入哪些代码。不同类型的插桩规则用于不同的目的,目前已有的插桩规则类型如下:
InstFuncRule: 在一个方法进入时、退出时注入代码 InstStructRule:修改结构体,新增一个字段 InstFileRule:新增一个文件参与原编译过程
代码注入
代码注入阶段将根据规则为目标函数插入蹦床代码。蹦床代码(Trampoline Jump)本质上是一个复杂的 If语句,通过它可以在目标函数入口和出口插入埋点代码,实现监控数据的收集。此外,我们还将在 AST 层面进行多项优化,尽可能减少蹦床代码的额外性能开销,优化代码执行效率。
完成以上步骤后,工具将修改编译参数,然后调用 go build cmd/app 进行正常编译,就如前文所述。
上下文传播优化
OpenTelemetry 中的 Context 是一种用于跨多个组件和服务传递信息的机制。它可以将分散在各处的服务(Span)链接到一起,形成完整的调用链(Trace)。以下是 Context 的典型使用方式:
func (tr *tracer) Start(ctx context.Context, // When creating a new Span, query the Parent Span from ctxname string,options ...trace.SpanStartOption)(context.Context, // Save the newly created Span to the context for transfer and subsequent usetrace.Span) { ... }
为了让 contex.Context 没有传递时,也能维持调用链,我们在创建新的 Span 时,还会将其保存到 Golang 运行时的协程结构体(GLS)中,创建新协程时也从当前协程中复制 GLS 数据。后续需要创建新的 Span 时,可以从 GLS 中查询出当前 Span 作为 Parent。
Span 是一种类似栈的结构,如下所示:
Span1+----Span2+----Span3+----Span4
创建 Span3 时,其 Parent 是 Span2;如果 Span3 和 Span2 都关闭了,创建Span4 时,其 Parent 应该是 Span1。因此,仅存储最新的 Span 是不够的,当最新的 Span 关闭时,需要更新次新的未关闭的 Span。为了解决这个问题,我们在 GLS 中设计了一个单向链表,每次创建 Span 时将其添加到链表尾部,关闭时从链表中移除。查询时总是返回链表尾部的最新未关闭的 Span。每当新 Trace 开始时,我们会清空 GLS 中的 Span 链表,以防现存的 Span 未正常关闭。通过这一机制,当 context.Context 是 context.Background() 或 nil 时,会自动从 GLS 中查询最近创建的 Span 作为 Parent,从而保护调用链的完整性。
Baggage传播优化
Baggage 是 OpenTelemetry 里面用于在 Trace 中存储和共享键值对的数据结构。Baggage 存储在 context.Context 中,可以随着 context.Context 的传递而传递。以下是 Baggage 的典型使用方式:
// 创建新的Baggageb := baggage.Baggage{}m, _ = baggage.NewMember("env", "test")b, _ = b.SetMember(m)// 将Baggage保存到ctx中ctx = baggage.ContextWithBaggage(ctx, b)// 在需要Baggage的地方,从ctx中读取出来bag = baggage.FromContext(ctx)
Baggage 保存在 context.Context 中,这意味着如果没有传递 context.Context,将无法读取正确的 Baggage,业务功能也会失效。为了解决这个问题,我们采用了与 Span 类似的优化措施:在接收到上游的 Baggage 或者调用 baggage.ContextWithBaggage(ctx, b) 时,将 Baggage 保存到 GLS 中。如果调用 baggage.FromContext(ctx) 时传入的 ctx 为 context.Background() 或 nil,会尝试从 GLS 读取 Baggage;同样,调用下游服务时如果 ctx 为空,也会从 GLS 中读取 Baggage 并注入到协议中。新 Trace 开始时,我们会清理 GLS 中的 Baggage,并在创建新协程时将有特殊意义的 Baggage 键值对复制到新协程中。
更丰富的插件支持
OpenTelemetry Golang Agent 0.1.0-RC 版本提供了较为丰富的插件支持,支持如下的常用框架:
更多的文档
项目提供了丰富的文档[5],帮助用户更好地了解并参与本项目,文档包括:
工作原理 https://github.com/alibaba/opentelemetry-go-auto-instrumentation/blob/main/docs/how-it-works.md 如何添加插件 https://github.com/alibaba/opentelemetry-go-auto-instrumentation/blob/main/docs/how-to-add-a-new-rule.md 如何 debug 项目 https://github.com/alibaba/opentelemetry-go-auto-instrumentation/blob/main/docs/how-to-debug.md 如何为插件编写测试 https://github.com/alibaba/opentelemetry-go-auto-instrumentation/blob/main/docs/how-to-write-tests-for-plugins.md 项目兼容性 https://github.com/alibaba/opentelemetry-go-auto-instrumentation/blob/main/docs/compatibility.md 性能压测 https://github.com/alibaba/opentelemetry-go-auto-instrumentation/blob/main/example/benchmark/benchmark.md demo 文档
https://github.com/alibaba/opentelemetry-go-auto-instrumentation/blob/main/example/demo/README.md
快速参与社区
Cloud Native
我们热烈欢迎社区外部用户使用并参与贡献,如果您在使用过程中碰到疑问,可以首先查看本项目中的各种文档。
首先建议您运行本项目的 demo[6]以熟悉本项目的基本工作流程,项目提供了 demo 的使用文档[7]来帮助新手用户快速接入 OpenTelemetry Golang Agent 同时在 Jager 中快速查看本项目上报的数据。
如果您在使用过程中发现 bug 或是任何不满足需求的地方,您可以在社区 issue 列表[8]中详细描述您的问题。同时,如果您想参与社区,可以在 issue 列表中筛选出包含 contribution welcome 标签的 issue,并在 issue 下方留言,社区的成员会及时将 issue assign 给您并帮助您完成 PR 的提交。
在向社区提交 issue 时,可以遵照社区的 issue 模板:
通过填写 Bug report 模板来反馈您遇到的 bug:
同时通过填写 Feature request 模板来向我们反馈您所需要的新特性:
社区 Roadmap
Cloud Native
0.1.0-RC 版本是 OpenTelemetry Golang Agent 发布的第一个版本,目前只支持部分框架的 Trace 能力,后续我们的主要规划如下:
支持更多的插件,例如 hertz,kitex,elasticsearch 等常用框架 支持 Opentelemetry 规范的指标统计与上报,目前 Opentelemetry 的指标规范尚未完全稳定,待Opentelemetry的指标规范稳定将推进支持 Golang 运行时指标上报,帮助用户更好地监控 Golang 本身的 GC 次数,内存占用等关键信息 支持 CPU/内存的持续剖析/代码热点 ......
目前我们计划将该开源项目捐献给 CNCF Opentelemetry,捐献的提案见:https://github.com/open-telemetry/community/issues/1961
联系我们
Cloud Native
开源项目地址:https://github.com/alibaba/opentelemetry-go-auto-instrumentation
相关链接:
https://github.com/open-telemetry/opentelemetry-go
https://github.com/alibaba/opentelemetry-go-auto-instrumentation
[3] 办法
https://github.com/open-telemetry/opentelemetry-go-contrib
https://github.com/alibaba/opentelemetry-go-auto-instrumentation/tree/main/docs
[6] demo
https://github.com/alibaba/opentelemetry-go-auto-instrumentation/tree/main/example/demo
https://github.com/alibaba/opentelemetry-go-auto-instrumentation/blob/main/example/demo/README.md
https://github.com/alibaba/opentelemetry-go-auto-instrumentation/issues