编译时插桩,Go应用监控的最佳选择

图片

阿里妹导读


本文讲解了阿里云编译器团队和可观测团队为了实现Go应用监控选择编译时插桩的原因,同时还介绍了其他的监控方案以及它们的优缺点。
可观测性是以系统的指标、日志、链路追踪、持续剖析四大数据支柱为基础,从宏观到微观,通过不同数据之间互相关联,衍生出如数据监控、问题分析、系统诊断等一系列的能力。
图片
Java[1]可以通过字节码增强的技术实现无侵入的应用监控(开源社区有非常多的无侵入Agent实现方案,技术非常成熟),可以轻松获取到关键监控数据,相比Java,Go因为语言的特点,应用运行的时候已经被编译成一个二进制文件,无法再做类似Java字节码增强的方式进行动态插桩,在应用监控领域的生态并不完善,可观测的四大数据支柱无法通过无侵入的方式来实现,使得用户的接入成本变高,当前针对Go应用的可观测能力,有3种解决方案:
  • SDK方案

  • eBPF方案

  • 编译期自动注入方案
以下分别来介绍这几个方案以及对应的开源实现:

SDK方案

在可观测领域,随着OpenTracing 被OTel 收编,目前被广泛使用的SDK就是OTel Go SDK[2],通过在业务代码的每个需要的地方进行手动增加埋点,如下所示:
package main
import (  "context"  "fmt"  "go.opentelemetry.io/otel"  "go.opentelemetry.io/otel/attribute"  "go.opentelemetry.io/otel/sdk/trace"  "io"  "net/http")
func init() {  tp := trace.NewTracerProvider()  otel.SetTracerProvider(tp)}
func main() {  for {    tracer := otel.GetTracerProvider().Tracer("")    ctx, span := tracer.Start(context.Background(), "Client/User defined span")    otel.GetTextMapPropagator()    req, err := http.NewRequestWithContext(ctx, "GET", "http://otel-server:9000/http-service1", nil)    if err != nil {      fmt.Println(err.Error())      continue    }    client := &http.Client{}    resp, err := client.Do(req)    if err != nil {      fmt.Println(err.Error())      continue    }    defer resp.Body.Close()    b, err := io.ReadAll(resp.Body)    if err != nil {      fmt.Println(err.Error())      continue    }    fmt.Println(string(b))    span.SetAttributes(attribute.String("client", "client-with-ot"))    span.SetAttributes(attribute.Bool("user.defined", true))    span.End()  }}

先定义好一个TraceProvider,然后在发起请求的地方获取tracer,使用tracer.Start创建一个span,然后发起请求,在请求结束后使用span.End()。

这是一个简单的http的请求,如果是复杂的业务应用,会涉及多个调用,比如调用redis、mysql、mq、es等中间件,需要在每个调用的地方都进行埋点,同时还需要处理好span Context的传递、baggage的传递,以及及时调用span End。
OTel 的spanContext都是通过context进行传递,如下所示:
func testContext() {  tracer := otel.Tracer("app-tracer")  opts := append([]trace.SpanStartOption{}, trace.WithSpanKind(trace.SpanKindServer))  rootCtx, rootSpan := tracer.Start(context.Background(), getRandomSpanName(), opts...)  if !rootSpan.SpanContext().IsValid() {    panic("invalid root span")  }
 go func() {    opts1 := append([]trace.SpanStartOption{}, trace.WithSpanKind(trace.SpanKindInternal))    _, subSpan1 := tracer.Start(rootCtx, getRandomSpanName(), opts1...)    defer func() {      subSpan1.End()    }()  }()
 go func() {    opts2 := append([]trace.SpanStartOption{}, trace.WithSpanKind(trace.SpanKindInternal))    _, subSpan2 := tracer.Start(rootCtx, getRandomSpanName(), opts2...)    defer func() {      subSpan2.End()    }()  }()  rootSpan.End()}

上述的2个新创建的协程里面使用了rootCtx,这样2个协程里面创建的span会是rootSpan的子span,在业务代码中也需要类似的方式进行传递,如果不正确传递context会导致调用链路无法串联在一起,也可能会造成链路错乱。

同时OpenTelemetry Go SDK 目前保持着2周到4周会发布一个版本

https://github.com/open-telemetry/opentelemetry-go/releases,更新速度非常快,经常会有前后不兼容的情况,业务升级OTel Go SDK会导致代码也需要进行修改,成本非常高。

eBPF方案

eBPF(扩展的伯克利数据包过滤器)作为Linux内核中的一个高效且灵活的虚拟机,允许开发者自定义运行程序,并通过特定接口将这些程序加载到内核空间执行。这一特性使得eBPF成为了构建各类系统监控解决方案的理想选择之一。
图片
近年来,基于eBPF技术开发的各种开源项目如雨后春笋般涌现出来,其中包括
  • pixie(https://github.com/pixie-io/pixie

  • beyla(https://github.com/grafana/beyla

  • opentelemetry-go-instrumentation(https://github.com/open-telemetry/opentelemetry-go-instrumentation

  • deepflow(https://github.com/deepflowio/deepflow)
等知名项目。它们共同致力于利用eBPF的强大能力来实现诸如性能分析(Profiling)、网络流量监测(Network Monitoring)、度量指标收集(Metric Collection)及分布式追踪(Distributed Tracing)等功能。
eBPF可以通过在不同位置的挂载点完成对数据流的抓取,比如tracepoint、kprobe等,也可以使用uprobe针对用户态函数进行hook,以协议解析为例,随着业务复杂度的提升以及不同使用场景的要求,用户态的协议非常多,有RPC类型的http、https、grpc、dubbo等,还有中间件的mysql、redis、es、mq、ck等,要通过eBPF抓取的数据完成数据解析并实现指标的统计难度非常大。
以使用eBPF监控Go应用为例,因其独特的并发模型而广泛采用异步处理机制,若想精确地进行跨协程上下文传递或深入到应用程序内部进行细粒度的跟踪,则通常还需要额外引入SDK来进行辅助支持,完成不同协程之间的上下文传递。
尽管上述项目在功能上存在一定程度的相似性,但由于eBPF自身的一些限制因素,比如eBPF 通常仅限于具有提升权限的 Linux 环境,同时针对内核的版本有要求,对于某些应用场景尤其是涉及到复杂应用层逻辑追踪时,单独依靠eBPF往往难以达到理想效果。
就性能开销而言,eBPF相对于进程内的Agent稍显落后,因为 uprobe 的触发需要在用户空间和内核之间进行上下文切换,这对于访问量特别大的一些接口难以承受。

编译时插桩方案

在这个方案前我们在eBPF方案做了非常多的探索,希望使用eBPF一劳永逸的解决非Java语言的各种监控问题,特别是Go应用(在当前除了Java外使用最广泛的语言),经过长时间的探索,发现无法达成如Java一样实现完全无死角的监控能力,这也正让我们开始思考通过其他方式解决这个问题,基于Go toolexec能力,编译时插桩实现Go的应用监控变得可行。
Go应用的编译流程如下:
图片
使用简单的go build 即可获得最终可以执行的二进制文件,go build 的过程通过以下的:

图片

在经过词法分析、语法分析后生成一些.a的中间态文件,最终通过Link的方式将.a文件生成为二进制文件。通过这个步骤可以看出我们可以在编译前端到编译后端中间进行hook的操作,因此我们将对应的编译流程改成如下方式:

图片

通过AST语法树分析,查找到监控的埋点,根据提前定义好的埋点规则,在编译前插入需要的监控代码,然后经过完成的Go编译流程将代码注入到最终的二进制中,这个方案与程序员手写代码完全没有区别,由于经过了完整的编译流程,不会产生一些不可预料的错误。

使用阿里云可观测Go Agent能力,只需要下载一个编译工具instgo,然后修改一下编译语句即可快速接入,如下所示:
当前的编译语句:
当前的编译语句:
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build main.go
使用Aliyun Go Agent:
wget "http://arms-apm-cn-hangzhou.oss-cn-hangzhou.aliyuncs.com/instgo/instgo-linux-amd64" -O instgochmod +x instgoCGO_ENABLED=0 GOOS=linux GOARCH=amd64 ./instgo go build main.go

通过wget下载instgo编译工具,只需要简单修改在go build前添加instgo即可完成监控能力注入。

我们可以在插入的代码中实现跟Java应用监控完全一样的监控能力,如链路追踪、指标统计、持续剖析、动态配置、代码热点、日志Trace关联等等,在插件丰富度上我们支持了40+的常见插件[4],包含了RPC框架、DB、Cache、MQ、Log等,在性能上,5%的消耗即可支持1000 qps[5],通过动态开关控制、Agent版本灰度等实现生产的可用性和风险控制能力。

总结

图片

本文讲解了阿里云编译器团队和可观测团队为了实现Go应用监控为什么选择编译时插桩的原因,同时还介绍了其他的监控方案,以及它们的优缺点。我们相信阿里云Go Agent(Instgo)是一个非常强大的工具,可以帮助我们实现针对Go应用更好的APM能力,同时还能保持应用程序的安全性和可靠性。

为了推广编译时注入的方案,同时为Go开发者提供更多的选择,提升效率,我们的Agent进行了开源[7],欢迎大家加入我们的钉钉群(开源群:102565007776,商业化群:35568145),共同提升编译时插桩在Go应用监控的能力。
参考链接:


云上公网架构设计和安全管理


云上公网的设计可以帮助企业更加统一、安全地管理自己的云上互联网出入口,同时可以实现统一监控运维和公网的成本优化。