一、背景和现状
随着Kubernetes和容器化技术的普及,Golang在云原生领域和各类业务场景中占据了重要地位。越来越多的新兴业务选择Golang作为首选编程语言。借助丰富的RPC框架(如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)。
2.1、预处理阶段
在这一阶段,工具会分析用户项目代码的三方库依赖,并与现有的插桩规则匹配以找到合适的插桩规则,并提前配置好这些插桩规则所需的额外依赖。
插桩规则准确定义了针对哪个版本的哪个框架、标准库要注入哪些代码。不同类型的插桩规则用于不同的目的,目前已有的插桩规则类型如下:
- InstFuncRule: 在一个方法进入时、退出时注入代码
- InstStructRule:修改结构体,新增一个字段
- InstFileRule:新增一个文件参与原编译过程
当所有预处理工作准备就绪,会调用go build -toolexec aliyun-go-agent cmd/app进行编译。-toolexec参数是自动插桩的核心,用于拦截常规的构建流程,替换为用户自定义的工具,使开发者可以更灵活地定制构建过程。这里唤起的 aliyun-go-agent 就是自动插桩工具,从而进入代码注入阶段。
2.2、代码注入阶段
代码注入阶段将根据规则为目标函数插入蹦床代码。蹦床代码(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 { /* NO_NEWWLINE_PLACEHOLDER */ } 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:
// == 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 }
通过上述步骤,我们不仅为:
(*Transport).RoundTrip() 函数插入了监控代码,还确保了监控数据的准确性和上下文的传递。在进行编译期自动插桩时,这些操作都是自动完成的,为开发者节省了大量时间,减少了手动埋点的出错率。
为了满足商业化版本的需求,我们不仅实现了基础的无侵入自动插桩,还做了更多的优化和改进,以确保这一解决方案在实际生产环境中的高效性和可靠性。对此,我们特别关注了Context和Baggage的自动传播优化。此外,为了确保用户在使用ARMS Go Agent时不影响用户手动调用OpenTelemetry SDK的代码,我们还实现了对OpenTelemetry SDK的兼容性优化。
三、关键优化
3.1、Context传播优化
OpenTelemetry 中的 Context 是一种用于跨多个组件和服务传递信息的机制。它可以将分散在各处的服务(Span)链接到一起,形成完整的调用链(Trace)。以下是Context的典型使用方式:
func (tr *tracer) Start( ctx context.Context, // 创建新Span时,从ctx中查询Parent Span name string, options ...trace.SpanStartOption )( context.Context, // 将刚创建的Span保存到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。
3.2、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键值对复制到新协程中。
3.3、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
来源 | 阿里云开发者公众号
作者 | 青风、义泊、牧思