Go 自动插桩:提升 Go 应用监控与服务治理的利器

阿里云推出的 Go 自动插桩技术,让开发者无需改动源代码即可对 Go 应用进行全面监控和治理,极大提升开发效率和系统稳定性。

原文标题:为Go应用无侵入地添加任意代码

原文作者:阿里云开发者

冷月清谈:

本文重点介绍了 Go 自动插桩技术,这项技术由阿里云 ARMS 团队、编译器团队和 MSE 团队联手打造,在不修改源代码的情况下,即可对 Go 应用进行全面的监控和治理。通过预处理和代码注入两个步骤,该技术绕过编译流程的限制,在目标函数的入口和出口处注入用户自定义的代码逻辑,从而实现更细粒度的控制、监控、治理和安全。

在模块化扩展方面,用户可以通过简单的 JSON 配置文件,将自定义代码注入到任意目标函数,甚至是非标准库函数或具有高级定制需求的场景中。这带来了极大的灵活性和扩展性,使用户能够根据实际需要定制监控和治理能力,提升开发效率和系统稳定性。

怜星夜思:

1、模块化扩展的优势体现在哪里?
2、文章中提到的防止 SQL 代码注入的示例,能否详细解释一下注入的代码是如何实现的?
3、除了文章中提到的应用场景,Go 自动插桩技术还有哪些潜在的应用领域?

原文内容

阿里妹导读


这篇文章旨在提供技术深度和实践指南,帮助开发者理解并应用这项创新技术来提高Golang应用的监控与服务治理能力。在接下来的部分,我们将通过一些实际案例,进一步展示如何在不同场景中应用这项技术,提供更多实践启示。
作者|青风、古琦、牧思、如漫



背景

在Go语言的开发过程中,尽管该语言以卓越的性能和高效的编码能力闻名,但在应用程序监控与服务治理方面,仍面临显著的成本与技术挑战。传统解决方案通常需要开发者手动调整源代码,这不仅增加了工作量,还对现有架构产生了影响,使得无缝集成变得异常困难。特别是在复杂的异构系统中,实现全面而细致的监控和服务优化几乎成为既耗时又需要专家经验的任务。此情此景下,寻求一种既能减少侵入性又能有效提升运维效率的方法论,成为了业界共同追求的目标。

为了解决这一问题,阿里云ARMS团队、编译器团队、MSE团队携手合作,共同发布并开源[1]了Go语言的编译期自动插桩技术[2]。这项技术以其零侵入的特点,为Golang应用提供了与Java监控能力媲美的解决方案。开发者无需对现有代码进行任何修改,只需简单地将go build替换为新编译命令,即可实现对Go应用的全面监控和治理。

在开源版本中,我们支持了16个主流开源框架(在商业化版中支持38个主流开源框架),同时考虑到用户的多样化需求,特别是使用了未在支持列表中的框架或高级定制需求,我们进一步推出了模块化插桩扩展功能。用户只需通过简单的JSON配置,即可零侵入注入自定义代码到任意目标函数,不需要修改原来代码仓库的代码,通过模块化插桩扩展的方式即可完成代码注入,从而实现更细粒度的控制、监控、治理和安全。

模块化扩展原理

在正常情况下,go build命令会经过六个主要步骤:源码分析、类型检查、语义分析、编译优化、代码生成和链接,来编译一个 Go 应用程序。然而,使用自动插桩工具后,在这些步骤之前会增加两个步骤:预处理(Preprocess)和代码注入(Instrument)。


预处理

在这一阶段,工具首先读取用户定义的 rule.json配置文件,它详细说明了需要在哪些框架或标准库的哪些版本中插入自定义的 hook 代码。rule.json 配置文件的内容完全由用户控制,一个典型的示例如下:

[{
 "ImportPath": "google.golang.org/grpc",
 "Function": "NewClient",
 "OnEnter": "grpcNewClientOnEnter",
 "OnExit": "grpcNewClientOnExit",
 "Path": "/path/to/my/code"
}]
这个配置表示希望在google.golang.org/grpc库的NewClient函数入口和出口分别插入grpcNewClientOnEntergrpcNewClientOnExit这两个代码段。需要插入的这两个函数代码位于本地路径/path/to/my/code

接下来工具会分析项目的第三方库依赖,并将其与 rule.json 中的自定义的插桩规则进行匹配,同时提前配置这些规则所需的额外依赖。当所有预处理工作完成后,工具将拦截常规的编译流程,在每个包的编译过程前面额外加入一个代码注入阶段。


代码注入

在代码注入阶段,工具会根据 rule.json 的配置,为目标函数(如NewClient)插入蹦床代码(Trampoline Code)。蹦床代码的主要作用是作为逻辑上的跳板来处理异常和填充上下文,最终它会跳转到用户自定义的grpcNewClientOnEntergrpcNewClientOnExit函数,以完成监控数据的收集或服务流量的治理。由于蹦床代码是性能攸关的,我们在AST(抽象语法树)层面还会对蹦床代码做一系列优化,确保它的开销降到最低,关于优化部分感兴趣的读者可以访问项目源码,这里不再赘述。

通过以上步骤,工具有效地在保证代码功能完整性的前提下插入了用户指定的代码逻辑,随后,工具修改必要的编译参数,然后执行常规编译以生成最终的应用程序。

使用示例

在了解了上述原理之后,我们将通过几个例子演示Go自动插桩的模块化扩展的使用方式。

1、记录http请求的Header

以net/http为例,很多用户都关心请求的参数、body用来定位问题,这里我们使用自定义插桩的能力,介绍如何获取请求的header和返回的header。

第一步,创建hook文件夹,使用go mod init hook初始化该文件夹,然后新增下面的hook.go代码,它是即将注入的代码:

package hook

import (
 “encoding/json”
 “fmt”
 “github.com/alibaba/opentelemetry-go-auto-instrumentation/pkg/api
 “net/http”
)

// 注意:注入代码第一个参数必须是api.CallContext,后续参数和目标函数参数一致
func httpClientEnterHook(call api.CallContext, t *http.Transport, req *http.Request) {
 header, _ := json.Marshal(req.Header)
 fmt.Println("request header is ", string(header))
}
// 注意:注入代码第一个参数必须是api.CallContext,后续参数和目标函数返回值一致
func httpClientExitHook(call api.CallContext, res *http.Response, err error) {
 header, _ := json.Marshal(res.Header)
 fmt.Println("response header is ", string(header))
}

第二步,编写下面的conf.json配置,告诉工具我们想要将hook代码注入到:

net/http::(*Transport).RoundTrip

[{
 "ImportPath":"net/http",
 "Function":"RoundTrip",
 "OnEnter":"httpClientEnterHook",
 "ReceiverType": "*Transport",
 "OnExit": "httpClientExitHook",
 "Path": "/path/to/hook" # Path修改为hook代码的本地路径
}]
第三步,编写测试Demo。创建文件夹并使用go mod init demo初始化,然后添加main.go
package main

import (
 “context”
 “fmt”
 “io/ioutil”
 “log”
 “net/http”
)

func main() {
 // 定义请求的URL
 req, _ := http.NewRequestWithContext(context.Background(), “GET”, “http://www.aliyun.com”, nil)
 req.Header.Set(“otelbuild”, “true”)
 client := &http.Client{}
 resp, _ := client.Do(req)

 // 确保在函数结束时关闭响应的主体
 defer resp.Body.Close()
}

第四步,切换到demo目录,使用otelbuild工具编译并执行程序,以验证效果。
$ ./otelbuild -rule=conf.json -- main.go
$ ./main
可以看到如下输出, 表示注入是成功的:

图片

该示例可以在:

https://github.com/alibaba/opentelemetry-go-auto-instrumentation/tree/main/example/extension/netHttp中找到。

2、替换标准库sort算法

Golang标准库中目前使用的排序算法是pdqsort(Pattern-Defeating Quick Sort)[3],由计算机科学家Orson R. L. Peters发明。pdqsort会检测输入数据的特定模式,如部分排序、有序或反序排列,并选择合适的策略来处理。例如,当数据接近有序时,pdqsort会切换到插入排序,它的名称Pattern-Defeating也反映了它对特定数据模式的特殊优化。

图片

假设你在创造新的快排算法,或者发现在特定的工作负载下,另一种快排算法如DualPivot Quick Sort速度更快,这时候借助插桩工具可以非常简单的替换标准库排序算法,快速验证新算法。
第一步,创建hook文件夹,使用go mod init hook初始化该文件夹,然后新增下面的hook.go代码,它是即将注入的代码:
package hook

import (
 “github.com/alibaba/opentelemetry-go-auto-instrumentation/pkg/api
)

func partition(arr int, low, high int) (int, int) {
 if arr[low] > arr[high] {
   arr[low], arr[high] = arr[high], arr[low]
 }
 lp := low + 1
 g := high - 1
 k := low + 1
 p := arr[low]
 q := arr[high]
 for k <= g {
   if arr[k] < p {
     arr[k], arr[lp] = arr[lp], arr[k]
     lp++
   } else if arr[k] >= q {
     for arr[g] > q && k < g {
       g–
     }
     arr[k], arr[g] = arr[g], arr[k]
     g–
     if arr[k] < p {
       arr[k], arr[lp] = arr[lp], arr[k]
       lp++
     }
   }
   k++
 }
 lp–
 g++
 arr[low], arr[lp] = arr[lp], arr[low]
 arr[high], arr[g] = arr[g], arr[high]
 return lp, g
}

func dualPivotQuickSort(arr int, low, high int) {
 if low < high {
   lp, rp := partition(arr, low, high)
   dualPivotQuickSort(arr, low, lp-1)
   dualPivotQuickSort(arr, lp+1, rp-1)
   dualPivotQuickSort(arr, rp+1, high)
 }
}

func sortOnEnter(call api.CallContext, arr int) {
 // 使用dual pivot qsort
 dualPivotQuickSort(arr, 0, len(arr)-1)
 // 跳过原始的sort算法
 call.SetSkipCall(true)
}

第二步,编写下面的conf.json配置,告诉工具我们想要将hook代码注入到sort.Ints
[{
 "ImportPath":"sort",
 "Function":"Ints",
 "OnEnter":"sortOnEnter",
 "Path":"/path/to/hook" # Path修改为hook代码的本地路径
}]
第三步,编写测试Demo。创建文件夹并使用go mod init demo初始化,然后添加main.go。
package main

import (
 “fmt”
 “sort”
)

func main() {
 arr := int{6, 3, 7, 9, 4, 4}
 sort.Ints(arr)
 fmt.Printf(“== %v\n”, arr)
}

第四步,切换到demo目录,使用otelbuild工具编译并执行程序,以验证dual pivot quicksort效果。
$ ./otelbuild -rule=conf.json -- main.go
$ ./main
== [3 4 4 6 7 9]

3、防止SQL代码注入

为了防止SQL代码注入,可以在database/sql::(*DB).Query()查询中注入额外的代码,以检查SQL语句是否存在注入风险并及时拦截。
第一步,创建hook文件夹,使用go mod init hook初始化该文件夹,然后新增下面的hook.go代码,它是即将注入的代码:
package hook

import (
 “database/sql”
 “errors”
 “github.com/alibaba/opentelemetry-go-auto-instrumentation/pkg/api
 “log”
 “strings”
)

func checkSqlInjection(query string) error {
 patterns := string{“–”, “;”, “/*”, " or ", " and ", “'”}
 for _, pattern := range patterns {
   if strings.Contains(strings.ToLower(query), pattern) {
     return errors.New(“potential SQL injection detected”)
   }
 }
 return nil
}

func sqlQueryOnEnter(call api.CallContext, db *sql.DB, query string, args …interface{}) {
 if err := checkSqlInjection(query); err != nil {
   log.Fatalf(“sqlQueryOnEnter %v”, err)
 }
}

第二步,编写下面的conf.json配置,告诉工具我们想要将hook代码注入到database/sql::(*DB).Query()

[{
 "ImportPath": "database/sql",
 "Function": "Query",
 "ReceiverType": "*DB",
 "OnEnter": "sqlQueryOnEnter",
 "Path": "/path/to/hook" # Path修改为hook代码的本地路径
}]
第三步,编写测试Demo。创建文件夹并使用go mod init demo初始化,然后添加main.go。
package main

import (
 “context”
 “database/sql”
 “fmt”
 _ “GitHub - go-sql-driver/mysql: Go MySQL Driver is a MySQL driver for Go's (golang) database/sql package
 “os”
 “time”
)

func main() {
 mysqlDSN := “test:test@tcp(127.0.0.1:3306)/test”
 db, _ := sql.Open(“mysql”, mysqlDSN)

   db.ExecContext(context.Background(), CREATE TABLE IF NOT EXISTS usersx (id char(255), name VARCHAR(255), age INTEGER))
   db.ExecContext(context.Background(), INSERT INTO usersx (id, name, age) VALUE ( ?, ?, ?), “0”, “foo”, 10)

   # SQL中注入恶意代码,抓取整个表的信息
   maliciousAnd := “‘foo’ AND 1 = 1”
 injectedSql := fmt.Sprintf(“SELECT * FROM userx WHERE id = ‘0’ AND name = %s”, maliciousAnd)
 db.Query(injectedSql)
}

第四步,切换到demo目录,使用otelbuild工具编译并执行程序,以验证SQL注入保护的效果。

$ ./otelbuild -rule=conf.json -- main.go
$ docker run -d -p 3306:3306 -p 33060:33060 -e MYSQL_USER=test -e MYSQL_PASSWORD=test -e MYSQL_DATABASE=test -e MYSQL_ALLOW_EMPTY_PASSWORD=yes mysql:8.0.36
$ ./main


可以看到,使用otelbuild工具编译出的二进制文件成功检测到了潜在的sql注入攻击,并打印出了相应日志:


2024/11/04 21:12:47 sqlQueryOnEnter potential SQL injection detected

该示例可以在:

https://github.com/alibaba/opentelemetry-go-auto-instrumentation/tree/main/example/extension/sqlinject中找到。

4、使请求具备流量防护能力

假设我们准备基于sentinel-golang给grpc-go unary请求增加流量防护的能力,也可以通过自动插桩的方式,在grpc client处通过注入中间件的方式来实现。
第一步,创建hook文件夹,使用go mod init hook初始化该文件夹,然后新增下面的hook.go代码,它是即将注入的代码:
package hook

import (
   “context”
   “grpc package - google.golang.org/grpc - Go Packages
   sentinel “github.com/sentinel-golang/api
   “github.com/sentinel-golang/core/base
   pkgapi “github.com/alibaba/opentelemetry-go-auto-instrumentation/pkg/api
)

// 在 gRPC 客户端入口添加流量防护中间件
func newClientOnEnter(call pkgapi.CallContext, target string, opts …grpc.DialOption) {
   opts = append(opts, grpc.WithChainUnaryInterceptor(unaryClientInterceptor))
}

// 基于 sentinel-golang 的流量防护中间件
func unaryClientInterceptor(ctx context.Context, method string, req, reply interface{}, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts …grpc.CallOption) error {
   entry, blockErr := sentinel.Entry(
       method,
       sentinel.WithResourceType(base.ResTypeRPC),
       sentinel.WithTrafficType(base.Outbound),
   )
   defer func() {
       if entry != nil {
           entry.Exit()
       }
   }()
   
   if blockErr != nil {
       return blockErr
   }
   return invoker(ctx, method, req, reply, cc, opts…)
}

第二步,编写下面的conf.json配置,告诉工具我们想要将hook代码注入到google.golang.org/grpc::NewClient
[{
 "ImportPath": "google.golang.org/grpc",
 "Function": "NewClient",
 "OnEnter": "newClientOnEnter",
 "Path": "/path/to/hook"  # Path修改为hook代码的本地路径
}]
第三步,编写测试Demo。创建文件夹并使用go mod init demo初始化,然后添加main.go。
package main

import (
   “context”
   “fmt”
   “grpc package - google.golang.org/grpc - Go Packages
   pb “path/to/your/protobuf” // 替换为你的 proto 文件路径
)

func main() {
   // 连接到 GRPC 服务器
   conn, _ := grpc.Dial(“localhost:50051”, grpc.WithInsecure())
   client := pb.NewYourServiceClient(conn)

   // 发送 gRPC 请求
   response, _ := client.YourMethod(context.Background(), &pb.YourRequest{})
   fmt.Println("Response: ", response)
}

第四步,切换到demo目录,使用otelbuild工具编译并执行程序,以验证效果。

$ ./otelbuild -rule=conf.json -- main.go
$ ./main
如果希望给grpc-go stream请求增加防护规则也是同理。除此之外,如果希望使请求具备灰度路由、标签路由、百分比路由等灰度发布能力,还可以针对框架的负载均衡器进行按需增强,具有非常高的自主性和扩展性。

总结和展望

Golang编译期自动插桩成功解决了微服务监控中繁琐的手动埋点问题,并已商业化上线至阿里云公有云,为客户提供强大的监控能力。这项技术最初的设计初衷是为了让用户能够在不改动现有代码的前提下轻松地插入监控代码,从而实现对应用程序性能状态的实时监测与分析,但它的实际应用领域超越预期,包括服务治理、代码审计、应用安全、代码调试等,甚至在许多未被探索的领域中也展现出潜力。
我们决定将这项创新方案开源,并捐赠给OpenTelemetry社区[4],目前已经达成贡献意向,后续我们的代码将迁移到OpenTelemetry社区仓库。开源不仅促进技术共享与提升,借助社区的力量还可以持续探索该方案在更多领域上的可能。
最后诚邀大家试用我们的商业化产品[5][6],并加入我们的钉钉群(开源群:102565007776,商业化群:35568145),共同提升Go应用监控与服务治理能力。通过群策群力,我们相信能为Golang开发者社区带来更加优质的云原生体验。
参考链接:

[1] Go自动插桩开源项目:https://github.com/alibaba/opentelemetry-go-auto-instrumentation

[2] 

[3] Pattern-Defeating快排算法论文:https://arxiv.org/pdf/2106.05123

[4] 在OpenTelemetry社区讨论捐献项目:https://github.com/open-telemetry/community/issues/1961

[5] 阿里云ARMS Go Agent商业版:https://help.aliyun.com/zh/arms/tracing-analysis/monitor-go-applications/

[6] 阿里云MSE Go Agent商业版:https://help.aliyun.com/zh/mse/getting-started/ack-microservice-application-access-mse-governance-center-golang-version

高可用及共享存储 Web 服务


随着业务规模的增长,数据请求和并发访问量增大、静态文件高频变更,企业需要搭建一个高可用和共享存储的网站架构,以确保网站服务能够7*24小时运行的同时,可保障数据一致性和共享性,并降低数据重复存储的成本。


点击阅读原文查看详情。


在需要使用未在支持列表中的框架或有高级定制需求时,模块化扩展提供了极大的便利性,用户可以轻松扩展插桩能力,满足多样化的需求,避免了传统修改源代码带来的繁琐和风险。

模块化扩展增强了 Go 自动插桩技术的适用性,使其不局限于标准库函数,而是能够扩展到各种场景,满足开发者对不同框架和高级定制的需求。这大大提高了技术的实用性和价值。

模块化扩展允许用户灵活地添加自定义代码到任何目标函数,而不需要修改原始代码库。这提供了更细 granularity 的控制,可实现针对特定场景的监控、治理和安全措施。

该技术可用于代码审计,通过注入自定义代码来检查代码质量、合规性,甚至可以实现代码自动化修复。

在应用安全领域,它可以注入代码来检测和阻止安全漏洞,例如跨站脚本攻击和缓冲区溢出。

注入的代码通过调用 checkSqlInjection 函数检查 SQL 查询中的恶意模式。此函数扫描查询字符串中是否存在诸如 “–”、“;” 遗漏等潜在注入模式,如果检测到任何模式,则会抛出错误,防止执行恶意查询。

代码中使用正则表达式逐个字符地检查 SQL 查询字符串,查找预定义的恶意模式。如果找到任何匹配项,它会立即阻止查询执行,防止恶意代码对数据库造成损害。

注入的代码实质上是一个中间件,在执行 SQL 查询之前拦截并检查查询字符串。它采用白名单机制,过滤掉包含已知恶意模式的查询,从而有效防止 SQL 注入攻击。

在代码调试方面,它可以注入代码来收集执行时信息,帮助开发人员快速定位和解决问题。