为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"}]
接下来工具会分析项目的第三方库依赖,并将其与 rule.json 中的自定义的插桩规则进行匹配,同时提前配置这些规则所需的额外依赖。当所有预处理工作完成后,工具将拦截常规的编译流程,在每个包的编译过程前面额外加入一个代码注入阶段。
代码注入
在代码注入阶段,工具会根据 rule.json 的配置,为目标函数(如NewClient)插入蹦床代码(Trampoline Code)。蹦床代码的主要作用是作为逻辑上的跳板来处理异常和填充上下文,最终它会跳转到用户自定义的grpcNewClientOnEnter和grpcNewClientOnExit函数,以完成监控数据的收集或服务流量的治理。由于蹦床代码是性能攸关的,我们在AST(抽象语法树)层面还会对蹦床代码做一系列优化,确保它的开销降到最低,关于优化部分感兴趣的读者可以访问项目源码,这里不再赘述。
通过以上步骤,工具有效地在保证代码功能完整性的前提下插入了用户指定的代码逻辑,随后,工具修改必要的编译参数,然后执行常规编译以生成最终的应用程序。
使用示例
在了解了上述原理之后,我们将通过几个例子演示Go自动插桩的模块化扩展的使用方式。
1、记录http请求的Header
以net/http为例,很多用户都关心请求的参数、body用来定位问题,这里我们使用自定义插桩的能力,介绍如何获取请求的header和返回的header。
第一步,创建hook文件夹,使用go mod init hook初始化该文件夹,然后新增下面的hook.go代码,它是即将注入的代码:
package hookimport ("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代码的本地路径}]
package mainimport ("context""fmt""io/ioutil""log""net/http")func main() {// 定义请求的URLreq, _ := 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()}
$ ./otelbuild -rule=conf.json -- main.go$ ./main
https://github.com/alibaba/opentelemetry-go-auto-instrumentation/tree/main/example/extension/netHttp中找到。
2、替换标准库sort算法
package hookimport ("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 + 1g := high - 1k := low + 1p := 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 qsortdualPivotQuickSort(arr, 0, len(arr)-1)// 跳过原始的sort算法call.SetSkipCall(true)}
[{"ImportPath":"sort","Function":"Ints","OnEnter":"sortOnEnter","Path":"/path/to/hook" # Path修改为hook代码的本地路径}]
package mainimport ("fmt""sort")func main() {arr := []int{6, 3, 7, 9, 4, 4}sort.Ints(arr)fmt.Printf("== %v\n", arr)}
$ ./otelbuild -rule=conf.json -- main.go$ ./main== [3 4 4 6 7 9]
3、防止SQL代码注入
package hookimport ("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代码的本地路径}]
package mainimport ("context""database/sql""fmt"_ "github.com/go-sql-driver/mysql""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
该示例可以在:
4、使请求具备流量防护能力
package hookimport ("context""google.golang.org/grpc"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...)}
[{"ImportPath": "google.golang.org/grpc","Function": "NewClient","OnEnter": "newClientOnEnter","Path": "/path/to/hook" # Path修改为hook代码的本地路径}]
package mainimport ("context""fmt""google.golang.org/grpc"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
总结和展望
[1] Go自动插桩开源项目:https://github.com/alibaba/opentelemetry-go-auto-instrumentation
[2]面向OpenTelemetry的Golang应用无侵入插桩技术
[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小时运行的同时,可保障数据一致性和共享性,并降低数据重复存储的成本。
点击阅读原文查看详情。