面向OpenTelemetry的Golang应用无侵入插桩技术

简介: 文章主要讲述了阿里云 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,需要开发人员手动进行埋点,但手动埋点存在以下问题:


  • 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)。

image.png



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执行埋点代码。

image.png

接下来我们以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() 函数插入了监控代码,还确保了监控数据的准确性和上下文的传递。在进行编译期自动插桩时,这些操作都是自动完成的,为开发者节省了大量时间,减少了手动埋点的出错率。


为了满足商业化版本的需求,我们不仅实现了基础的无侵入自动插桩,还做了更多的优化和改进,以确保这一解决方案在实际生产环境中的高效性和可靠性。对此,我们特别关注了ContextBaggage的自动传播优化。此外,为了确保用户在使用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.Contextcontext.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。

image.png

通过以上方法,用户使用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









来源  |  阿里云开发者公众号
作者  |  青风、义泊、牧思



相关文章
|
23天前
|
运维 监控 Cloud Native
一行代码都不改,Golang 应用链路指标日志全知道
本文将通过阿里云开源的 Golang Agent,帮助用户实现“一行代码都不改”就能获取到应用产生的各种观测数据,同时提升运维团队和研发团队的幸福感。
|
3月前
|
算法 安全 测试技术
golang 栈数据结构的实现和应用
本文详细介绍了“栈”这一数据结构的特点,并用Golang实现栈。栈是一种FILO(First In Last Out,即先进后出或后进先出)的数据结构。文章展示了如何用slice和链表来实现栈,并通过golang benchmark测试了二者的性能差异。此外,还提供了几个使用栈结构解决的实际算法问题示例,如有效的括号匹配等。
golang 栈数据结构的实现和应用
|
2月前
|
中间件 Go 数据处理
应用golang的管道-过滤器架构风格
【10月更文挑战第1天】本文介绍了一种面向数据流的软件架构设计模式——管道-过滤器(Pipe and Filter),并通过Go语言的Gin框架实现了一个Web应用示例。该模式通过将数据处理流程分解为一系列独立的组件(过滤器),并利用管道连接这些组件,实现了模块化、可扩展性和高效的分布式处理。文中详细讲解了Gin框架的基本使用、中间件的应用以及性能优化方法,展示了如何构建高性能的Web服务。
87 0
|
3月前
|
监控 NoSQL Go
OpenTelemetry Golang Agent 0.1.0-RC 重磅发布
程序语言与编译器团队和阿里云可观测团队开源了遵循 Opentelemetry 规范的 Golang Agent 0.1.0-RC 版本,希望能通过编译期自动插桩的手段实现无侵入式的 Golang 应用观测。
|
4月前
|
存储 Prometheus 监控
Golang 搭建 WebSocket 应用(六) - 监控
Golang 搭建 WebSocket 应用(六) - 监控
48 3
|
4月前
|
人工智能 缓存 安全
Golang 搭建 WebSocket 应用(七) - 性能、可用性
Golang 搭建 WebSocket 应用(七) - 性能、可用性
60 1
|
4月前
|
人工智能 数据库连接 Go
Golang 搭建 WebSocket 应用(五) - 消息推送日志
Golang 搭建 WebSocket 应用(五) - 消息推送日志
44 1
|
4月前
|
人工智能 Go
Golang 搭建 WebSocket 应用(二) - 基本群聊 demo
Golang 搭建 WebSocket 应用(二) - 基本群聊 demo
48 1
|
4月前
|
人工智能 网络协议 应用服务中间件
Golang 搭建 WebSocket 应用(一) - 初识 gorilla/websocket
Golang 搭建 WebSocket 应用(一) - 初识 gorilla/websocket
212 1
|
4月前
|
Go 开发者