OpenTelemetry Golang Agent 0.1.0-RC 重磅发布

简介: 程序语言与编译器团队和阿里云可观测团队开源了遵循 Opentelemetry 规范的 Golang Agent 0.1.0-RC 版本,希望能通过编译期自动插桩的手段实现无侵入式的 Golang 应用观测。

1. 前言


随着云原生概念的不断普及,Golang 编程语言愈发受到人们的青睐,越来越多的开发者使用 Golang 编程语言来编写应用程序以及编程库,然而在 OpenTelemetry 已经成为可观测事实标准的今天,OpenTelemetry 官方对 Golang 的支持却不甚完善,开发者几乎只能通过 opentelemetry-go[1]SDK 来进行手动埋点。


今天程序语言与编译器团队和阿里云可观测团队开源了遵循 Opentelemetry 规范的 Golang Agent[2]0.1.0-RC 版本,希望能通过编译期自动插桩的手段实现无侵入式的 Golang 应用观测。


2. OpenTelemetry For Golang 一览


可能很多同学会好奇,“OpenTelemetry 社区已经很成熟了,为什么还需要这一款 OpenTelemetry Golang Agent 呢?”。那么让我们以一个 Golang 开发者的视角,来看一看如果想对 Golang 应用进行监控,在 OpenTelemetry 社区中有哪些可选项。


2.1 SDK 手动插桩

进入社区,首先映入眼帘的就是 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()) // 打印 TraceId
  span.SetAttributes(attribute.String("key", "value"))
  span.SetStatus(codes.Ok, "Success")
  childMethod(ctx)
  span.End()
}


然而,随着项目的迭代,用户需要对越来越多的方法进行手动插桩,假设用户的业务从上面的只调用一个方法膨胀到 A->B->C 的方法调用,用户就需要同时在 B 方法与 C 方法中也添加同样的插桩逻辑,同时需要把上一层中方法的上下文传递至下一层。在项目规模逐渐变大的过程中,对 Go 应用进行应用监控的成本也随着项目的迭代而线性增长。


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()) // 打印 TraceId
  span.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()) // 打印 TraceId
  span.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()
}


2.2 eBPF 自动插桩

由于 SDK 手动插桩带来的复杂性,OpenTelemetry 社区也同时提供了一些自动插桩的办法[3]。官方提供的自动插桩办法基于 eBPF,基于此方案用户无需手动使用 SDK 更改业务代码,eBPF 可以自动识别到 Golang 应用并收集到应用的 HTTP,数据库,RPC 等调用的相关数据,同时对用户的上下文进行自动的透传,从而保证整个链路的完整性。


这时你可能有疑问,这种自动插桩的方式看起来很完美,用户用这个不就完事了吗?理想很丰满,现实很骨感,eBPF 插桩虽然有着上面这些优势,但是也存在着种种的限制:


  1. 由于 eBPF 的指令长度限制,此方案只能支持透传不超过 8 个 HTTP 请求头的上下文,这对于小应用可能还能凑合,但是对于生产级的应用是远远不能满足需求的。
  2. eBPF 方案对内核的版本有着一定的要求,最小支持的内核版本为 4.4 版本,对于用户来说,升级操作系统版本对于生产应用的风险太大,这在某种程度上也降低了本方案的可用性。
  3. eBPF 方案的性能相比之下较差,eBPF 自动插桩方案使用了 Uprobe 来对 Golang 的函数进行埋点插桩,Uprobe 在执行时会频繁往返于内核态与用户态,从而导致较大的性能开销。


2.3 InstrGen 自动插桩

既然 SDK 手动埋点方案如此繁琐,eBPF 自动插桩方案限制又这么多,那有没有什么“既要又要”的办法呢?OpenTelemetry 在它的 contrib[4]仓库提供了一种编译期自动插桩的工具 InstrGen。InstrGen 可以在编译期间对整个项目的语法树进行解析,并且在指定的方法处插入代码以实现应用监控的能力。这种编译时插桩的方法可以有效解决手动插桩以及 eBPF 自动插桩的相关痛点,理论上就是在“帮”用户写代码,自由度很高,然而 InstrGen 这个项目也有许多劝退用户的地方:


  1. 项目迭代慢,维护人员少:InstrGen 的维护者最近一次代码提交都已经在大概三个月之前(不包括 github 机器人)。
  2. 文档不完善,参与门槛高:社区的文档过于简单,用户很难通过文档来参与到社区的 bug 修复,测试等活动中来。
  3. 支持插件少,上下文无法自动传递:目前 InstrGen 无法支持 MySQL,Redis 等常用数据库的调用监控,同时 InstrGen 的上下文透传依然依赖于用户显示在函数上传递 context 参数,无法做到自动的上下文透传,对于用户来说依然有部分改造成本。


2.4 阿里云 OpenTelemetry Golang Agent

本次开源的 OpenTelemetry Golang Agent,思路与 InstrGen 基本一致,都是在编译期间对用户的代码进行自动的插桩。在正常情况下,go build 命令会经历以下主要步骤来编译一个 Golang 应用:


  1. 源码解析:Golang 编译器会先解析源代码文件,将其转化为抽象语法树(AST)。
  2. 类型检查: 解析后会进行类型检查,确保代码符合 Golang 的类型系统。
  3. 语义分析: 对程序的语义进行分析,包括变量的定义和使用、包导入等。
  4. 编译优化:将语法树转化为中间表示, 进行各种优化,提高代码执行效率。
  5. 代码生成: 生成目标平台的机器代码。
  6. 链接: 将不同包和库链接成一个单一的可执行文件。

image.png

而在使用 OpenTelemetry Golang Agent 后,上述步骤之前会增加两个阶段:预处理(Preprocess)和代码注入(Instrument)。

image.png

预处理

在这一阶段,工具会分析用户项目代码的三方库依赖,并与现有的插桩规则匹配以找到合适的插桩规则,并提前配置好这些插桩规则所需的额外依赖。


插桩规则准确定义了针对哪个版本的哪个框架、标准库要注入哪些代码。不同类型的插桩规则用于不同的目的,目前已有的插桩规则类型如下:


  • InstFuncRule: 在一个方法进入时、退出时注入代码
  • InstStructRule:修改结构体,新增一个字段
  • InstFileRule:新增一个文件参与原编译过程


当所有预处理工作准备就绪,会调用 go build -toolexec otelbuild cmd/app 进行编译。-toolexec 参数是自动插桩的核心,用于拦截常规的构建流程,替换为用户自定义的工具,使开发者可以更灵活地定制构建过程。这里唤起的 otelbuild 就是自动插桩工具,从而进入代码注入阶段。


代码注入

代码注入阶段将根据规则为目标函数插入蹦床代码。蹦床代码(Trampoline Jump)本质上是一个复杂的 If语句,通过它可以在目标函数入口和出口插入埋点代码,实现监控数据的收集。此外,我们还将在 AST 层面进行多项优化,尽可能减少蹦床代码的额外性能开销,优化代码执行效率。


完成以上步骤后,工具将修改编译参数,然后调用 go build cmd/app 进行正常编译,就如前文所述。


在编译完成后,自动插桩的逻辑就被一起编译到了生成的二进制文件中。同时,阿里云可观测团队开源的 OpenTelemetry Golang Agent 成功解决了 InstrGen 的一些痛点:


上下文传播优化

OpenTelemetry 中的 Context 是一种用于跨多个组件和服务传递信息的机制。它可以将分散在各处的服务(Span)链接到一起,形成完整的调用链(Trace)。以下是 Context 的典型使用方式:


func (tr *tracer) Start(
    ctx context.Context, // When creating a new Span, query the Parent Span from ctx
    name string, 
    options ...trace.SpanStartOption
)(
    context.Context, // Save the newly created Span to the context for transfer and subsequent use
    trace.Span
) { ... }


OpenTelemetry 的设计要求用户正确传递 context.Context,如果调用链某个环节没有传递 context.Context,最终调用 tracer.Start 时只能使用 context.Background() 或 nil,尽管不会报错,但调用链会中断。


为了让 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 的典型使用方式:


// 创建新的Baggage
b := 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 版本提供了较为丰富的插件支持,支持如下的常用框架:


Plugin Name

Repository Url

Min Supported Version

Max Supported Version

database/sql

https://pkg.go.dev/database/sql

-

-

echo

https://github.com/labstack/echo

v4.0.0

v4.12.0

gin

https://github.com/gin-gonic/gin

v1.7.0

v1.10.0

go-redis

https://github.com/redis/go-redis

v9.0.5

v9.5.1

gorm

https://github.com/go-gorm/gorm

v1.22.0

v1.25.9

logrus

https://github.com/sirupsen/logrus

v1.5.0

v1.9.3

mongodb

https://github.com/mongodb/mongo-go-driver

v1.11.1

v1.15.2

mux

https://github.com/gorilla/mux

v1.3.0

v1.8.1

net/http

https://pkg.go.dev/net/http

-

-

zap

https://github.com/uber-go/zap

v1.20.0

v1.27.0


更多的文档

项目提供了丰富的文档[5],帮助用户更好地了解并参与本项目,文档包括:



2.5 对比

image.png

可以看到,阿里云 Opentelemetry Golang Agent 在易用性,插桩工作量等各个方面都有着较为明显的优势。


3. 快速参与社区


我们热烈欢迎社区外部用户使用并参与贡献,如果您在使用过程中碰到疑问,可以首先查看本项目中的各种文档。

image.png

首先建议您运行本项目的 demo[6]以熟悉本项目的基本工作流程,项目提供了 demo 的使用文档[7]来帮助新手用户快速接入 OpenTelemetry Golang Agent 同时在 Jager 中快速查看本项目上报的数据。


如果您在使用过程中发现 bug 或是任何不满足需求的地方,您可以在社区 issue 列表[8]中详细描述您的问题。同时,如果您想参与社区,可以在 issue 列表中筛选出包含 contribution welcome 标签的 issue,并在 issue 下方留言,社区的成员会及时将 issue assign 给您并帮助您完成 PR 的提交。

image.png

在向社区提交 issue 时,可以遵照社区的 issue 模板:

image.png

通过填写 Bug report 模板来反馈您遇到的 bug:

image.png

同时通过填写 Feature request 模板来向我们反馈您所需要的新特性:

image.png


4. 社区 Roadmap


0.1.0-RC 版本是 OpenTelemetry Golang Agent 发布的第一个版本,目前只支持部分框架的 Trace 能力,后续我们的主要规划如下:


  • 支持更多的插件,例如 hertz,kitex,elasticsearch 等常用框架
  • 支持 Opentelemetry 规范的指标统计与上报,目前 Opentelemetry 的指标规范尚未完全稳定,待Opentelemetry的指标规范稳定将推进支持
  • Golang 运行时指标上报,帮助用户更好地监控 Golang 本身的 GC 次数,内存占用等关键信息
  • 支持 CPU/内存的持续剖析/代码热点
  • ......


目前我们计划将该开源项目捐献给 CNCF Opentelemetry,捐献的提案

5. 联系我们


开源项目地址:链接

image.png

相关链接:

[1] opentelemetry-go

[2] Golang Agent

[3] 办法

[4] contrib

[5] 文档

[6] demo

[7] 使用文档

[8] 社区 issue 列表


作者:牧思

作者介绍
目录