包阅导读总结
1. 关键词:Golang、无侵入插桩、监控、OpenTelemetry、阿里云
2. 总结:本文介绍了阿里云 ARMS 团队与程序语言与编译器团队合作研发的面向 OpenTelemetry 的 Golang 应用无侵入插桩技术,阐述了其研发背景、编译步骤、代码注入示例、优化改进及成果,包括 Context 和 Baggage 自动传播优化、OpenTelemetry SDK 兼容优化,该方案已商业化并开源。
3. 主要内容:
– 背景
– 随着 Golang 应用增多,其监控面临挑战,相比 Java 处于劣势。
– 解决方案
– 介绍了正常 go build 命令编译主要步骤。
– 增加预处理和代码注入阶段,分析三方库依赖,匹配插桩规则。
– 以 net/http 注入为例,展示函数类型和监控代码插入过程。
– 优化和改进
– Context 自动传播优化,设计单向链表保存 Span 信息。
– Baggage 自动传播优化,将其保存到 GLS 中。
– OpenTelemetry SDK 兼容优化,采用 shadow 机制。
– 成果
– 商业化产品上线服务客户,开源并计划捐赠给社区。
思维导图:
文章地址:https://mp.weixin.qq.com/s/FKCwzRB5Ujhe1stOH2ibXg
文章来源:mp.weixin.qq.com
作者:青风、义泊、牧思
发布时间:2024/8/1 10:24
语言:中文
总字数:4838字
预计阅读时间:20分钟
评分:91分
标签:OpenTelemetry,Golang,无侵入插桩,监控技术,编译优化
以下为原文内容
本内容来源于用户推荐转载,旨在分享知识与观点,如有侵权请联系删除 联系邮箱 media@ilingban.com
文章主要讲述了阿里云 ARMS 团队与程序语言与编译器团队合作研发的面向OpenTelemetry的Golang应用无侵入插桩技术解决方案,旨在解决Golang应用监控的挑战。
随着Kubernetes和容器化技术的普及,Golang在云原生领域和各类业务场景中占据了重要地位。越来越多的新兴业务选择Golang作为首选编程语言。借助丰富的RPC框架(如Dubbo-Go、Gin、Kratos、Kitex等),Golang在微服务生态中愈加成熟,并被用于大量重要的开源项目,如OpenTelemetry Collector、ETCD、Prometheus、Istio、Higress等。
然而,相比Java可以使用字节码增强技术来实现无侵入的应用监控,Golang在这方面依然处于劣势。当前,大多数面向Golang应用的监控能力主要通过SDK方式接入,如OpenTelemetry SDK,需要开发人员手动进行埋点,但手动埋点存在以下问题:
为了解决这些问题,阿里云ARMS团队与程序语言与编译器团队合作研发了面向OpenTelemetry的Golang应用无侵入插桩技术解决方案,并发布了商业化版本ARMS Go Agent[1]。
在正常情况下,go build命令会经历以下主要步骤来编译一个Golang应用:
6. 链接:将不同包和库链接成一个单一的可执行文件。
使用自动插桩工具后,上述步骤之前会增加两个阶段:预处理(Preprocess)和代码注入(Instrument)。
在这一阶段,工具会分析用户项目代码的三方库依赖,并与现有的插桩规则匹配以找到合适的插桩规则,并提前配置好这些插桩规则所需的额外依赖。
插桩规则准确定义了针对哪个版本的哪个框架、标准库要注入哪些代码。不同类型的插桩规则用于不同的目的,目前已有的插桩规则类型如下:
当所有预处理工作准备就绪,会调用go build -toolexec aliyun-go-agent cmd/app进行编译。-toolexec参数是自动插桩的核心,用于拦截常规的构建流程,替换为用户自定义的工具,使开发者可以更灵活地定制构建过程。这里唤起的aliyun-go-agent就是自动插桩工具,从而进入代码注入阶段。
代码注入阶段将根据规则为目标函数插入蹦床代码。蹦床代码(Trampoline Jump)本质上是一个复杂的If语句,通过它可以在目标函数入口和出口插入埋点代码,实现监控数据的收集。此外,我们还将在AST层面进行多项优化,尽可能减少蹦床代码的额外性能开销,优化代码执行效率。
完成以上步骤后,工具将修改编译参数,然后调用go build cmd/app进行正常编译,就如前文所述。
以net/http注入为例:
首先,我们区分以下三种类型的函数:RawFunc,TrampolineFunc,HookFunc。RawFunc是需要注入的原函数。TrampolineFunc是跳床函数。HookFunc是onEnter/onExit这些需要插入到原函数入口、退出的埋点代码。RawFunc 通过插入的TJump跳转到TrampolineFunc,然后TrampolineFunc构造上下文、准备recover错误处理,最后跳转到HookFunc执行埋点代码。
接下来我们以net/http为例,演示编译期自动插桩如何为目标函数(*Transport).RoundTrip()插入监控代码的。框架会在该函数入口生成下面这样的TJump,它是一个If语句(实际上是一行,写成多行方便演示),会跳转到TrampolineFunc:
func (t *Transport) RoundTrip(req *Request) (retVal0 *Response, retVal1 error) {
if callContext37639, _ := OtelOnEnterTrampoline_RoundTrip37639(&t, &req); false {
} else {
defer OtelOnExitTrampoline_RoundTrip37639(callContext37639, &retVal0, &retVal1)
}
return t.roundTrip(req)
}
其中OtelOnEnterTrampoline_RoundTrip37639就是TrampolineFunc,它会准备好错误处理和调用上下文,然后跳转到ClientOnEnterImpl :
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)
ClientOnEnterImpl就是HookFunc,也就是我们的埋点代码,可以在里面进行trace、上报metrics数据等等。ClientOnEnterImpl是一个函数指针,会在预处理阶段自动生成的otel_setup_inst.go中提前配置好,它实际指向clientOnEnter:
package otel_rules
import http328 "net/http"
...
func init() {
http328.ClientOnEnterImpl = clientOnEnter
...
}
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
}
通过上述步骤,我们不仅为:
(*Transport).RoundTrip()函数插入了监控代码,还确保了监控数据的准确性和上下文的传递。在进行编译期自动插桩时,这些操作都是自动完成的,为开发者节省了大量时间,减少了手动埋点的出错率。
为了满足商业化版本的需求,我们不仅实现了基础的无侵入自动插桩,还做了更多的优化和改进,以确保这一解决方案在实际生产环境中的高效性和可靠性。对此,我们特别关注了Context和Baggage的自动传播优化。此外,为了确保用户在使用ARMS Go Agent时不影响用户手动调用OpenTelemetry SDK的代码,我们还实现了对OpenTelemetry SDK的兼容性优化。
OpenTelemetry 中的 Context 是一种用于跨多个组件和服务传递信息的机制。它可以将分散在各处的服务(Span)链接到一起,形成完整的调用链(Trace)。以下是Context的典型使用方式:
func (tr *tracer) Start(
ctx context.Context,
name string,
options ...trace.SpanStartOption
)(
context.Context,
trace.Span
) { ... }
OpenTelemetry的设计要求用户正确传递context.Context,如果调用链某个环节没有传递context.Context,最终调用tracer.Start时只能使用context.Background()或nil,尽管不会报错,但调用链会中断。
为了让contex.Context没有传递时,也能维持调用链,我们在创建新的Span时,还会将其保存到Go运行时的协程结构体(GLS)中,创建新协程时复制老的GLS中的Span信息。需要用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,从而保护调用链的完整性。上述修改参考skywalking-go。
Baggage是OpenTelemetry里面用于在Trace中存储和共享键值对的数据结构。Baggage存储在context.Context中,可以随着context.Context的传递而传递。以下是Baggage的典型使用方式:
b := baggage.Baggage{}
m, _ = baggage.NewMember("env", "test")
b, _ = b.SetMember(m)
ctx = baggage.ContextWithBaggage(ctx, b)
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键值对复制到新协程中。 3.3、OpenTelemetrySDK兼容优化
在实际生产使用中,用户除了关注三方库和中间件产生的遥测数据,也会使用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