为Go应用无侵入地添加任意代码

66 阅读10分钟

背景

在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自动插桩的模块化扩展的使用方式。

记录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

可以看到如下输出, 表示注入是成功的:

该示例可以在github.com/alibaba/ope…中找到。

替换标准库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]

防止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.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

该示例可以在github.com/alibaba/ope…中找到。

使请求具备流量防护能力

假设我们准备基于sentinel-golang给grpc-go unary请求增加流量防护的能力,也可以通过自动插桩的方式,在grpc client处通过注入中间件的方式来实现。

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

package hook

import (
    "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...)
}

第二步,编写下面的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"
    "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

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

总结和展望

Golang编译期自动插桩成功解决了微服务监控中繁琐的手动埋点问题,并已商业化上线至阿里云公有云,为客户提供强大的监控能力。这项技术最初的设计初衷是为了让用户能够在不改动现有代码的前提下轻松地插入监控代码,从而实现对应用程序性能状态的实时监测与分析,但它的实际应用领域超越预期,包括服务治理、代码审计、应用安全、代码调试等,甚至在许多未被探索的领域中也展现出潜力。

我们决定将这项创新方案开源,并捐赠给OpenTelemetry社区[4],目前已经达成贡献意向,后续我们的代码将迁移到OpenTelemetry社区仓库。开源不仅促进技术共享与提升,借助社区的力量还可以持续探索该方案在更多领域上的可能。

最后诚邀大家试用我们的商业化产品[5][6],并加入我们的钉钉群(开源群:102565007776,商业化群:35568145),,共同提升Go应用监控与服务治理能力。通过群策群力,我们相信能为Golang开发者社区带来更加优质的云原生体验。

[1] Go自动插桩开源项目

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

[3] Pattern-Defeating快排算法论文

[4] 在OpenTelemetry社区讨论捐献项目

[5] 阿里云ARMS Go Agent商业版

[6] 阿里云MSE Go Agent商业版