作者:古琦
背景
自 2024 年 6 月 26 日,阿里云 ARMS 團隊正式推出面向 Go 應用的可觀測性監控功能以來,我們與程序語言及編譯器團隊攜手并進,持續深耕技術優化與功能拓展。這一創新性的解決方案旨在為開發者提供更為全面、深入且高效的應用性能監控體驗,助力企業在數字化轉型中實現卓越的系統穩定性與性能表現。
從商業化版本的首次亮相至今,我們已歷經五次重大版本迭代及若干次精細化的小版本更新。相較于初始版本,系統性能實現了翻倍提升,同時在功能層面亦展現出前所未有的豐富性與靈活性。新增特性包括但不限于智能化應用診斷、高度可定制的擴展能力、靈活的應用開關機制、接口全量采樣以及代碼熱點分析等模塊。這些功能的引入不僅顯著提升了系統的實用性,也贏得了廣大用戶的廣泛認可與積極反饋。而基于編譯時插樁(Compile-time Instrumentation)的技術路徑,更被實踐證明是 Go 語言應用監控領域的一次突破性創舉,堪稱當前最優解。
為進一步賦能用戶在復雜場景下快速定位與解決問題,我們結合近期發布的一系列全新功能,精心梳理了一套從接入到問題發現、再到問題排查與精準定位的最佳實踐指南。
應用接入
通過 ARMS 提供的 Instgo 工具,只需要在?go build?前添加 instgo 命令,無需用修改一行代碼,通過編譯時插樁的方式實現監控能力注入[1]。
instgo go build {arg1} {arg2} {arg3}
智能告警
應用接入到 ARMS 后,可以在應用列表查看到應用的名稱,點擊進去查看到應用詳情,包括了請求數、錯誤數、延遲等指標,還提供了每個接口的指標、以及依賴的接口指標,為了快速發現問題,可以通過配置應用的告警來第一時間發現問題。
可以創建對應的告警,如最近 1 分鐘調用響應時間大于等于 500ms 就報警。
應用詳情
通過監控告警第一時間發現問題后,到對應服務的詳情查看這個接口的平均耗時非常長,即知道了告警是由于這個接口導致的。
查看對應的調用鏈,可以按耗時排列,找到耗時最長的調用鏈:
點擊查看調用鏈詳情,可以看到它的子 span 調用時間都非常短,可以確定是這個接口本身慢導致的,而不是其他對外請求導致的。
應用診斷
通過上述應用詳情找到了請求慢的接口后,如何確認這時候的問題呢,我們可以通過應用診斷來發現問題,在應用監控中除了指標、鏈路、日志外,Profiling 的數據成為了應用監控的四大支柱之一。
通過 Profiling 數據能快速發現性能的瓶頸,ARMS Go 可觀測提供了 CPU、內存、代碼熱點三個 Profiling 功能,用于快速發現應用性能問題。
ARMS 的持續剖析能力跟通過類似?https://github.com/grafana/pyroscope?或者 go 提供的 pprof 等工具相比,ARMS 提供的 Profiling 能力可以做到隨開隨關,通過應用設置-持續性能剖析設置即可進行開關設置,無需重啟,直接生效。
CPU Profiling
CPU Profiling 用于收集和分析 Go 應用程序中的 CPU 使用情況,了解你的程序在運行時有多少時間花費在各個函數上。通過分析這些數據,開發者可以識別出程序中最耗費 CPU 時間的部分,ARMS 提供的 CPU Profiling 數據會采集每分鐘的 CPU ?運行情況,通過下面的火焰圖即可找到當前執行時間最長的函數。
除了每分鐘的數據之外,還提供了 CPU Profiling 數據的對比功能,對比前后 CPU 的消耗的不同,確定性能瓶頸。
內存 Profiling
跟 CPU Profiling 一樣,內存 Profiling 也提供了對比的功能,可以對比前后不同時刻內存分配的情況,找到內存分配的熱點。
除了通過內存 Profiling 找到內存分配熱點外,還可以通過 Runtime 監控,找到每個時刻 Goroutines 數量、以及堆對象的數量來看某個時刻是否異常,是否因為流量突增導致的數量增加。
代碼熱點
在出現應用請求超時、響應慢的時候,為了快速定位到性能問題,從提供服務找到出現響應慢的接口,跳轉到調用鏈,從調用鏈分析看出來對應接口在某些請求中響應的時間超出正常值很多,這時候如果還要進一步定位到這個請求執行過程中響應慢的函數是哪個,則無法通過單純的調用鏈分析獲取到,代碼熱點就是用來解決這個問題。點開對應的 Trace,通過放大鏡即可查看當前的調用 Profiling[2]:
可以看到 main 中的?onCpu?函數消耗時間長達 0.62 秒,這樣去排查這個函數的問題即可。
自定義擴展
通過上述方式可以查看到大部分問題,我們還提供了自定義擴展的功能[3],通過一個規則+一段待注入的代碼組成,通過 Go Agent 的能力,在編譯時完成代碼的插樁,而不需要去修改原始代碼,這個功能的優勢是對于一些非項目開發人員可以在不修改原始代碼的情況下完成相關功能實現。以下是我們經常會碰到的通過自定義擴展可以解決的問題:
日志打印
為了快速定位問題或者業務需求,日志可以記錄非常詳細的信息,比如函數的出入參數、Http 的返回的 body、sql 的請求語句參數等,以下是介紹打印?sql?請求的語句、參數:
第一步,創建 hook 文件夾,使用?go mod init hook?初始化該文件夾,然后新增下面的 hook.go 代碼,它是即將注入的代碼:
package hookimport ("database/sql""fmt""github.com/alibaba/opentelemetry-go-auto-instrumentation/pkg/api"
)func sqlQueryOnEnter(call api.CallContext, db *sql.DB, query string, args ...interface{}) {fmt.Println("sql is ", query)fmt.Println("sql arg is", args)
}
第二步,編寫測試 Demo。創建文件夾并使用?go mod init demo?初始化,然后添加 main.go
package mainimport ("context""database/sql""fmt"_ "github.com/go-sql-driver/mysql"
)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)maliciousAnd := "'foo' AND 1 = 1"injectedSql := fmt.Sprintf("SELECT * FROM userx WHERE id = '0' AND name = %s", maliciousAnd)db.Query(injectedSql, "abc")
}
第三步,在 Demo 文件夾下編寫下面的 conf.json 配置,告訴工具我們想要將 hook 代碼注入到?database/sql:😦*DB).Query()。
[{"ImportPath": "database/sql","Function": "Query","ReceiverType": "*DB","OnEnter": "sqlQueryOnEnter","Path": "/path/to/hook" # Path修改為hook代碼的本地路徑
}]
第四步,切換到 Demo 目錄,使用 instgo 工具編譯并執行程序,以驗證 SQL 注入保護的效果。
$ ./instgo set --rule=./conf.json
$ 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
$ ./instgo go build .
$ ./demo
可以看到,使用?instgo?工具編譯出的二進制文件成功檢測到了潛在的 SQL 注入攻擊,并打印出了相應日志:
sql is SELECT * FROM userx WHERE id = '0' AND name = 'foo' AND 1 = 1
sql arg is [abc]
記錄Span
ARMS 鏈路追蹤記錄的 span 信息都是對開源的 SDK 進行埋點獲取的,用戶在業務中如果有關心的函數需要記錄可以通過自定義插件的功能,記錄當前函數的 span。
第一步,創建 hook文件夾,使用?go mod init hook?初始化該文件夾,然后新增下面的 hook.go 代碼,它是即將注入的代碼:
package hookimport ("context""fmt""github.com/alibaba/opentelemetry-go-auto-instrumentation/pkg/api""go.opentelemetry.io/otel""go.opentelemetry.io/otel/attribute"
)func requestDbOnEnter(call api.CallContext) {tracer := otel.GetTracerProvider().Tracer("")_, span := tracer.Start(context.Background(), "Client/User defined span")span.SetAttributes(attribute.String("client", "client-with-ot"))span.SetAttributes(attribute.Bool("user.defined", true))span.End()fmt.Println(span.SpanContext().SpanID().String())
}
第二步,編寫測試 Demo。創建文件夾并使用?go mod init demo?初始化,然后添加 main.go
package mainimport ("demo/common"_ "github.com/go-sql-driver/mysql"_ "go.opentelemetry.io/otel"
)func main() {common.RequestDb()
}
common?文件夾下增加?common.go?如下:
package commonimport ("context""database/sql""fmt"_ "github.com/go-sql-driver/mysql"
)func RequestDb() {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)maliciousAnd := "'foo' AND 1 = 1"injectedSql := fmt.Sprintf("SELECT * FROM userx WHERE id = '0' AND name = %s", maliciousAnd)db.Query(injectedSql, "abc")
}
第三步,在 Demo文件夾下編寫下面的 conf.json 配置,告訴工具我們想要將 hook 代碼注入到?common/RequestDb()。
[{"ImportPath": "demo/common","Function": "RequestDb","ReceiverType": "","OnEnter": "requestDbOnEnter","Path": "/path/to/hook" # Path修改為hook代碼的本地路徑
}]
第四步,切換到 Demo 目錄,使用 instgo 工具編譯并執行程序,以驗證 SQL 注入保護的效果。
$ ./instgo set --rule=./conf.json
$ 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
$ ./instgo go build .
$ ./demo
可以看到,使用?instgo?工具編譯出的二進制文件成功創建了 span,并打印出了相應 trace spanId:
0000000000000000
如果上報 span 到服務端,則可以看到自定義的 span。
流量回放
除了簡單的打印日志和創建 Span 外,還可以對生產的請求進行錄制,用于開發和測試階段回歸,提高測試質量,減少線上故障,以下是介紹通過對 Http 的請求、返回進行記錄,將這些數據可以記錄到日志或者數據庫中,用于下次測試回歸。
第一步,創建 hook 文件夾,使用?go mod init hook?初始化該文件夾,然后新增下面的 hook.go 代碼,它是即將注入的代碼:
package hookimport ("encoding/json""fmt""github.com/alibaba/opentelemetry-go-auto-instrumentation/pkg/api""io""net/http"
)func httpClientOnEnter(call api.CallContext, t *http.Transport, req *http.Request) {if req == nil {return}h, _ := json.Marshal(req.Header)fmt.Println("http request header is ", string(h))if req.GetBody == nil {return}requestBody, err := req.GetBody()if err != nil {return}defer requestBody.Close()requestData, err := io.ReadAll(requestBody)if err != nil {return}fmt.Println("http request body is ", string(requestData))
}
第二步,編寫測試 Demo。創建文件夾并使用?go mod init demo?初始化,然后添加 main.go
package mainimport ("bytes""context""encoding/json""net/http""time""unicode"
)func hello(w http.ResponseWriter, r *http.Request) {_, err := w.Write([]byte("Hello Http!"))if err != nil {panic(err)}
}func setupHttp() {http.Handle("/http-service1", http.HandlerFunc(hello))err := http.ListenAndServe(":9114", nil)if err != nil {panic(err)}
}// 定義一個結構體用于構造 JSON 數據
type RequestBody struct {Name string `json:"name"`Email string `json:"email"`
}func requestServer() {ctx := context.Background()reqBody := RequestBody{Name: "Alice",Email: "alice@example.com",}// 將結構體序列化為 JSON 格式jsonData, err := json.Marshal(reqBody)if err != nil {return}req, err := http.NewRequestWithContext(ctx, "POST", "http://localhost:9114/http-service1", bytes.NewBuffer(jsonData))if err != nil {panic(err)}req.Header.Add("Content-Type", "application/json")req.Header.Add("test-key", "log")req.Header.Add("hello", "arms")client := &http.Client{}resp, err := client.Do(req)if err != nil {panic(err)}defer resp.Body.Close()
}func Is(s string) bool {for i := 0; i < len(s); i++ {if s[i] > unicode.MaxASCII {return false}}return true
}
func main() {go setupHttp()time.Sleep(3 * time.Second)requestServer()
}
第三步,在 Demo文件夾下編寫下面的 conf.json 配置,告訴工具我們想要將 hook 代碼注入到?net/http:😦*Transport).RoundTrip()。
[{"ImportPath": "net/http","Function": "RoundTrip","ReceiverType": "*Transport","OnEnter": "httpClientOnEnter","OnExit": "","Path": "/path/to/hook" # Path修改為hook代碼的本地路徑
}]
第四步,切換到 Demo 目錄,使用 instgo 工具編譯并執行程序,以驗證 SQL 注入保護的效果。
$ ./instgo set --rule=./conf.json
$ ./instgo go build .
$ ./demo
可以看到,使用?instgo?工具編譯出的二進制文件成功獲取到了請求的 header 和 body,并打印出了相應日志:
http request header is {"Content-Type":["application/json"],"Hello":["arms"],"Test-Key":["log"]}
http request body is {"name":"Alice","email":"alice@example.com"}
通過自定義插件打印了日志,或者通過已有代碼的日志也可以進行快速查看問題,我們提供了 TraceID 和 SpanID 關聯到日志的能力[4]。
按需全采
針對一些重要的接口如果需要全采樣,可以通過應用設置-采樣設置配置接口名稱,也可以通過前綴、后綴匹配來配置,這樣這個接口的請求都會被采樣到,避免被丟掉。
后續
為了進一步提升系統的可觀測性與診斷能力,我們正致力于引入一系列高級性能分析工具,包括 Goroutine Profiling(協程剖析)、Mutex Profiling(互斥鎖剖析)、Block Profiling(阻塞剖析)以及 Go Trace(Go語言運行軌跡追蹤)。這些功能將為開發者提供更深入的洞察力,幫助他們在復雜的應用場景中精準定位性能瓶頸與潛在問題。
與此同時,我們將擴展對前沿技術的支持,特別是與大語言模型(LLM)相關的插件生態。例如,我們將集成 langchaingo 這一高效的語言處理框架,并引入 dify 的創新組件,如 dify-sandbox(沙盒環境)和 dify-plugin-daemon(插件守護進程),以滿足開發者在多樣化場景下的需求。
我們還計劃推出一套在線調試工具,旨在為用戶打造一個實時、交互式的問題診斷平臺。通過這一平臺,開發者可以快速定位并解決復雜問題,從而大幅縮短故障排查時間,提升系統的穩定性和可靠性。我們相信,這些能力的引入將為開發者帶來前所未有的便捷體驗,同時推動技術生態的進一步繁榮與發展。
最后誠邀大家試用我們的商業化產品,并加入我們的釘釘群(開源群:102565007776,商業化群:35568145) ,共同提升 Go 應用監控與服務治理能力。通過群策群力,我們相信能為 Golang開發者社區帶來更加優質的云原生體驗。
相關鏈接:
[1] instgo 工具介紹:
https://help.aliyun.com/zh/arms/application-monitoring/developer-reference/instgo-tool-introduction
[2]?代碼熱點:
https://help.aliyun.com/zh/arms/application-monitoring/user-guide/use-hotspot-code-to-diagnose-slow-calls-in-go-applications
[3]?自定義擴展:
https://help.aliyun.com/zh/arms/application-monitoring/use-cases/use-golang-agent-to-customize-scalability
[4] Go 應用日志 Trace 關聯:
https://help.aliyun.com/zh/arms/application-monitoring/use-cases/associate-trace-ids-with-business-logs-for-a-go-application