Go自动插桩 | 基于 OpenTelemetry 的无侵入Golang 应用自动插桩
背景和现状
随着Kubernetes和容器化技术的普及,Golang 在云原生领域和各类业务场景中占据了重要地位。越来越多的新兴业务选择 Golang 作为首选编程语言。借助丰富的RPC框架(如 Apache Dubbo-Go、Gin、Kratos、Kitex等),Golang 在微服务生态中愈加成熟,并被用于大量重要的开源项目,如 OpenTelemetry Collector、ETCD、Prometheus、Istio、Higress等。
然而,相比 Java 可以使用字节码增强技术来实现无侵入的应用监控,Golang 在这方面依然处于劣势。当前,大多数面向 Golang 应用的监控能力主要通过 SDK 方式接入,如 OpenTelemetry SDK,需要开发人员手动进行埋点,但手动埋点存在以下问题:
- Trace 埋点繁琐:每个调用点都需埋点,并需注意 Trace 上下文的传递,防止链路串联错误。
- Metrics 统计复杂:每次调用都需要统计,还需注意指标发散问题。
- SDK 版本更新频繁:Golang 官方仅维持最新两个版本,业务应用升级时需同时升级SDK,工作量很大。
为了解决这些问题,阿里云 ARMS 团队与程序语言与编译器团队合作研发了基于 OpenTelemetry 的无侵入 Golang 应用自动插桩解决方案,实现了编译期自动插桩能力,并发布了商业化版本ARMS Go Agent[1]。
编译期自动插桩
在正常情况下,go build
命令会经历以下主要步骤来编译一个Golang 应用:
- 源码解析: Golang 编译器会先解析源代码文件,将其转化为抽象语法树(AST)。
- 类型检查: 解析后会进行类型检查,确保代码符合 Golang 的类型系统。
- 语义分析: 对程序的语义进行分析,包括变量的定义和使用、包导入等。
- 编译优化:将语法树转化为中间表示, 进行各种优化,提高代码执行效率。
- 代码生成: 生成目标平台的机器代码。
- 链接: 将不同包和库链接成一个单一的可执行文件。
使用自动插桩工具后,上述步骤之前会增加两个阶段:预处理(Preprocess)和代码注入(Instrument)。
预处理
在这一阶段,工具会分析用户项目代码的三方库依赖,并与现有的插桩规则匹配以找到合适的插桩规则,并提前配置好这些插桩规则所需的额外依赖。
插桩规则准确定义了针对哪个版本的哪个框架、标准库要注入哪些代码。不同类型的插桩规则用于不同的目的,目前已有的插桩规则类型如下:
- InstFuncRule: 在一个方法进入时、退出时注入代码
- InstStructRule:修改结构体,新增一个字段
- InstFileRule:新增一个文件参与原编译过程
当所有预处理工作准备就绪,会调用go build -toolexec aliyun-go-agent cmd/app
进行编译。-toolexec
参数是自动插桩的核心,用于拦截常规的构建流程,替换为用户自定义的工具,使开发者可以更灵活地定制构建过程。这里唤起的 aliyun-go-agent
就是自动插桩工具,从而进入代码注入阶段。
代码注入
代码注入阶段将根据规则为目标函数插入蹦床代码。蹦床代码(Trampoline Jump)本质上是一个复杂的 If语句,通过它可以在目标函数入口和出口插入埋点代码,实现监控数据的收集。此外,我们还将在 AST 层面进行多项优化,尽可能减少蹦床代码的额外性能开销,优化代码执行效率。
完成以上步骤后,工具将修改编译参数,然后调用 go build cmd/app
进行正常编译,就如前文所述。
**<font style="color:rgb(0, 0, 0);">net/http</font>**
示例
首先,我们区分以下三种类型的函数:RawFunc,TrampolineFunc,HookFunc。RawFunc 是需要注入的原函数。TrampolineFunc 是跳床函数。HookFunc 是 onEnter/onExit 这些需要插入到原函数入口、退出的埋点代码。RawFunc 通过插入的蹦床代码跳转到 TrampolineFunc,然后 TrampolineFunc 构造上下文、准备 recover 错误处理,最后跳转到 HookFunc 执行埋点代码。
接下来我们以<font style="color:rgb(0, 0, 0);">net/http</font>
为例,演示编译期自动插桩如何为目标函数<font style="color:rgb(0, 0, 0);">(*Transport).RoundTrip()</font>
插入监控代码的。框架会在该函数入口生成下面这样的蹦床代码,它是一个 If 语句(实际上是一行,写成多行方便演示),会跳转到TrampolineFunc:
其中<font style="color:rgb(0, 0, 0);">OtelOnEnterTrampoline_RoundTrip37639</font>
就是TrampolineFunc,它会准备好错误处理和调用上下文,然后跳转到<font style="color:rgb(0, 0, 0);">ClientOnEnterImpl</font>
:
<font style="color:rgb(0, 0, 0);">ClientOnEnterImpl</font>
就是 HookFunc,也就是我们的埋点代码,可以在里面进行 trace、上报 metrics数据等等。<font style="color:rgb(0, 0, 0);">ClientOnEnterImpl</font>
是一个函数指针,会在预处理阶段自动生成的<font style="color:rgb(0, 0, 0);">otel_setup_inst.go</font>
中提前配置好,它实际指向<font style="color:rgb(0, 0, 0);">clientOnEnter</font>
:
通过上述步骤,我们不仅为<font style="color:rgb(0, 0, 0);">(*Transport).RoundTrip()</font>
函数插入了监控代码,还确保了监控数据的准确性和上下文的传递。在进行编译期自动插桩时,这些操作都是自动完成的,为开发者节省了大量时间,减少了手动埋点的出错率。
为了满足商业化版本的需求,我们不仅实现了基础的无侵入自动插桩,还做了更多的优化和改进,以确保这一解决方案在实际生产环境中的高效性和可靠性。对此,我们特别关注了Context
和Baggage
的自动传播优化。此外,为了确保用户在使用 ARMS Go Agent 时不影响用户手动调用 OpenTelemetry SDK的代码,我们还实现了对 OpenTelemetry SDK 的兼容性优化。
关键优化
Context传播优化
OpenTelemetry 中的 Context 是一种用于跨多个组件和服务传递信息的机制。它可以将分散在各处的服务(Span)链接到一起,形成完整的调用链(Trace)。以下是 Context 的典型使用方式:
OpenTelemetry 的设计要求用户正确传递context.Context
,如果调用链某个环节没有传递context.Context
,最终调用tracer.Start
时只能使用context.Background()
或 nil,尽管不会报错,但调用链会中断。
为了让contex.Context
没有传递时,也能维持调用链,我们在创建新的 Span 时,还会将其保存到Golang 运行时的协程结构体(GLS)中,创建新协程时也从当前协程中复制GLS数据。后续需要创建新的 Span 时,可以从 GLS 中查询出当前 Span 作为 Parent。
Span 是一种类似栈的结构,如下所示:
创建 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保存在<font style="color:rgb(33, 37, 41);">context.Context</font>
中,这意味着如果没有传递context.Context,将无法读取正确的Baggage,业务功能也会失效。为了解决这个问题,我们采用了与Span类似的优化措施:在接收到上游的Baggage或者调用<font style="color:rgb(33, 37, 41);">baggage.ContextWithBaggage(ctx, b)</font>
时,将Baggage保存到GLS中。如果调用<font style="color:rgb(33, 37, 41);">baggage.FromContext(ctx)</font>
时传入的ctx为<font style="color:rgb(33, 37, 41);">context.Background()</font>
或nil,会尝试从GLS读取Baggage;同样,调用下游服务时如果ctx为空,也会从GLS中读取Baggage并注入到协议中。新Trace开始时,我们会清理GLS中的Baggage,并在创建新协程时将有特殊意义的Baggage键值对复制到新协程中。
OpenTelemetry SDK 兼容优化
在实际生产使用中,用户除了关注三方库和中间件产生的遥测数据,也会使用OpenTelemetry SDK对业务代码进行手动埋点,以收集关键路径的运行情况。为满足这一需求,我们对不同版本的 OpenTelemetry SDK做了兼容性优化,通过 shadow 机制,确保用户手动调用OpenTelemetry SDK代码与ARMS Go Agent共存时,业务代码和框架的Span可以串联起来。
所谓 shadow 机制是指 ARMS Go Agent 自身维护一套最小依赖的 OpenTelemetry SDK,并修改包名和删除不必要的依赖,避免编译时的依赖冲突。同时,ARMS Go Agent 使用桥接器模式来了一套“移花接木”,即将用户业务代码中的 trace_provider 包装为ARMS Go Agent提供的 trace_provider,从而将用户业务代码中生成的Span桥接到ARMS Go Agent生成的Span。
通过以上方法,用户使用OpenTelemetry SDK埋点的业务产生的Span能和ARMS Go Agent产生的Span一起上报到ARMS服务端并在控制台界面进行展示,实现了零成本迁移用户存量自定义监控的能力。
总结
自动插桩解决方案有效解决了微服务监控中繁琐的手动埋点问题,通过在编译阶段智能注入监控代码,大幅减轻了开发者的负担并做到了对业务代码的零侵入,该方案的商业化产品[1]已经成功上线并服务阿里云公有云客户,在实践中验证其接入的方便性、高效性和实用性。
我们已将该创新方案开源[2],并计划捐赠给OpenTelemetry社区[3]。我们将积极推动开源生态发展,用实际行动促进技术的共享与迭代,为社区、阿里云上Golang开发者和企业用户提供高效可观测解决方案,助力用户更好地管理和优化微服务架构下的应用性能与稳定性。
最后,欢迎大家加入我们的开源和商业化钉钉群(群号: 35568145),一起建设更好的Golang应用监控能力。
[2] https://github.com/alibaba/opentelemetry-go-auto-instrumentation