OpenTelemetry Go Auto Instrumentation

414
下载
opentelemetry-go-auto-instrumentation 是阿里巴巴开源的基于OpenTelemetry规范的Golang应用无侵入编译时注入监控方案,可以在用户完全不修改业务代码的情况下对用户的Golang应用进行监控。

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 应用:

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

使用自动插桩工具后,上述步骤之前会增加两个阶段:预处理(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:

func (t *Transport) RoundTrip(req *Request) (retVal0 *Response, retVal1 error) {
if callContext37639, _ := OtelOnEnterTrampoline_RoundTrip37639(&t, &req); false { /* NO_NEWWLINE_PLACEHOLDER */
} else {
defer OtelOnExitTrampoline_RoundTrip37639(callContext37639, &retVal0, &retVal1)
}
return t.roundTrip(req)
}

其中<font style="color:rgb(0, 0, 0);">OtelOnEnterTrampoline_RoundTrip37639</font>就是TrampolineFunc,它会准备好错误处理和调用上下文,然后跳转到<font style="color:rgb(0, 0, 0);">ClientOnEnterImpl</font> :

func OtelOnEnterTrampoline_RoundTrip37639(t **Transport, req **Request) (*CallContext, bool) {
defer func() {
if err := recover(); err != nil {
println("failed to exec onEnter hook", "clientOnEnter")
if e, ok := err.(error); ok {
println(e.Error())
}
fetchStack, printStack := OtelGetStackImpl, OtelPrintStackImpl
if fetchStack != nil && printStack != nil {
printStack(fetchStack())
}
}
}()
callContext := &CallContext{
Params: nil,
ReturnVals: nil,
SkipCall: false,
}
callContext.Params = []interface{}{t, req}
ClientOnEnterImpl(callContext, *t, *req)
return callContext, callContext.SkipCall
}
var ClientOnEnterImpl func(callContext *CallContext, t *Transport, req *Request)

<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>

// == otel_setup_inst.go
package otel_rules
import http328 "net/http"
...
func init() {
http328.ClientOnEnterImpl = clientOnEnter
...
}
// == otel_rule_http59729.go
func clientOnEnter(call *http.CallContext, t *http.Transport, req *http.Request) {
...
var tracer trace.Tracer
if span := trace.SpanFromContext(req.Context()); span.SpanContext().IsValid() {
tracer = span.TracerProvider().Tracer("")
} else {
tracer = otel.GetTracerProvider().Tracer("")
}
opts := append([]trace.SpanStartOption{}, trace.WithSpanKind(trace.SpanKindClient))
ctx, span := tracer.Start(req.Context(), req.URL.Path, opts...)
var attrs []attribute.KeyValue
attrs = append(attrs, semconv.HTTPMethodKey.String(req.Method))
attrs = append(attrs, attributes.MakeSpanAttrs(req.URL.Path, req.URL.Host, attributes.Http)...)
span.SetAttributes(attrs...)
bag := baggage.FromContext(ctx)
if mem, err := baggage.NewMemberRaw(constants.BAGGAGE_PARENT_PID, attributes.Pid); err == nil {
bag, _ = bag.SetMember(mem)
}
if mem, err := baggage.NewMemberRaw(constants.BAGGAGE_PARENT_RPC, sdktrace.GetRpc()); err == nil {
bag, _ = bag.SetMember(mem)
}
sdktrace.SetGLocalData(constants.TRACE_ID, span.SpanContext().TraceID().String())
sdktrace.SetGLocalData(constants.SPAN_ID, span.SpanContext().SpanID().String())
ctx = baggage.ContextWithBaggage(ctx, bag)
otel.GetTextMapPropagator().Inject(ctx, propagation.HeaderCarrier(req.Header))
req = req.WithContext(ctx)
*(call.Params[1].(**http.Request)) = req
return
}

通过上述步骤,我们不仅为<font style="color:rgb(0, 0, 0);">(*Transport).RoundTrip()</font>函数插入了监控代码,还确保了监控数据的准确性和上下文的传递。在进行编译期自动插桩时,这些操作都是自动完成的,为开发者节省了大量时间,减少了手动埋点的出错率。

为了满足商业化版本的需求,我们不仅实现了基础的无侵入自动插桩,还做了更多的优化和改进,以确保这一解决方案在实际生产环境中的高效性和可靠性。对此,我们特别关注了ContextBaggage的自动传播优化。此外,为了确保用户在使用 ARMS Go Agent 时不影响用户手动调用 OpenTelemetry SDK的代码,我们还实现了对 OpenTelemetry SDK 的兼容性优化。

关键优化

Context传播优化

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.Contextcontext.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保存在<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应用监控能力。

[1] https://help.aliyun.com/zh/arms/application-monitoring/getting-started/monitoring-the-golang-applications

[2] https://github.com/alibaba/opentelemetry-go-auto-instrumentation

[3] https://github.com/open-telemetry/community/issues/1961


observability.cn Authors 2024 | Documentation Distributed under CC-BY-4.0
Copyright © 2017-2024, Alibaba. All rights reserved. Alibaba has registered trademarks and uses trademarks.
浙ICP备2021005855号-32