終極套娃 2.0|雲原生 PaaS 平台的可觀測性實踐分享
- 2022 年 4 月 21 日
- 筆記
某個周一上午,小濤像往常一樣泡上一杯熱咖啡 ☕️,準備打開項目協同開始新一天的工作,突然隔壁的小文喊道:「快看,用戶支援群里炸鍋了 …」
用戶 A:「Git 服務有點問題,程式碼提交失敗了!」
用戶 B:「幫忙看一下,執行流水線報錯……」
用戶 C:「我們的系統今天要上線,現在部署頁面都打不開了,都要急壞了!」
用戶 D:……
小濤只得先放下手中的咖啡,螢幕切換到堡壘機,登錄到伺服器上一套行雲流水的操作,「哦,原來是上周末上線的程式碼漏了一個參數驗證造成 panic 了」,小濤指著螢幕上一段容器的日誌對小文說到。
十分鐘後,小文使用修復後的安裝包更新了線上的系統,用戶的問題也得到了解決。
雖然故障修復了,但是小濤也陷入了沉思,「為什麼我們沒有在用戶之前感知到系統的異常呢?現在排查問題還需要登錄到堡壘機上看容器的日誌,有沒有更快捷的方式和更短的時間裡排查到線上故障發生的原因?」
這時,坐在對面的小 L 說道:「我們都在給用戶講幫助他們實現系統的可觀測性,是時候 Erda 也需要被觀測了。」
小濤:「那要怎麼做呢…?」且聽我們娓娓道來~
通常情況下,我們會搭建獨立的分散式追蹤、監控和日誌系統來協助開發團隊解決微服務系統中的診斷和觀測問題。但同時 Erda 本身也提供了功能齊全的服務觀測能力,而且在社區也有一些追蹤系統(比如 Apache SkyWalking 和 Jaeger)都提供了自身的可觀測性,給我們提供了使用平台能力觀測自身的另一種思路。
最終,我們選擇了在 Erda 平台上實現 Erda 自身的可觀測,使用該方案的考慮如下:
-
平台已經提供了服務觀測能力,再引入外部平台造成重複建設,對平台使用的資源成本也有增加
-
開發團隊日常使用自己的平台來排查故障和性能問題,吃自己的狗糧對產品的提升也有一定的幫助
-
對於可觀測性系統的核心組件比如 Kafka 和 數據計算組件,我們通過 SRE 團隊的巡檢工具來旁路覆蓋,並在出問題時觸發報警消息
Erda 微服務觀測平台提供了 APM、用戶體驗監控、鏈路追蹤、日誌分析等不同視角的觀測和診斷工具,本著物盡其用的原則,我們也把 Erda 產生的不同觀測數據分別進行了處理,具體的實現細節且繼續往下看。
OpenTelemetry 數據接入
在之前的文章里我們介紹了如何在 Erda 上接入 Jaeger Trace ,首先我們想到的也是使用 Jaeger Go SDK 作為鏈路追蹤的實現,但 Jaeger 作為主要實現的 OpenTracing 已經停止維護,因此我們把目光放到了新一代的可觀測性標準 OpenTelemetry 上面。
OpenTelemetry 是 CNCF 的一個可觀測性項目,由 OpenTracing 和 OpenCensus 合併而來,旨在提供可觀測性領域的標準化方案,解決觀測數據的數據模型、採集、處理、導出等的標準化問題,提供與三方 vendor 無關的服務。
如下圖所示,在 Erda 可觀測性平台接入 OpenTelemetry 的 Trace 數據,我們需求在 gateway 組件實現 otlp 協議的 receiver,並且在數據消費端實現一個新的 span analysis組件把 otlp 的數據分析為 Erda APM 的可觀測性數據模型。
OpenTelemetry 數據接入和處理流程
其中,gateway 組件使用 Golang 輕量級實現,核心的邏輯是解析 otlp 的 proto 數據,並且添加對租戶數據的鑒權和限流。
關鍵程式碼參考 receivers/opentelemetry
span_analysis 組件基於 Flink 實現,通過 DynamicGap 時間窗口,把 opentelemetry 的 span 數據聚合分析後產生如下的 Metrics:
-
service_node 描述服務的節點和實例
-
service_call_* 描述服務和介面的調用指標,包括 HTTP、RPC、DB 和 Cache
-
service_call_*_error 描述服務的異常調用,包括 HTTP、RPC、DB 和 Cache
-
service_relation 描述服務之間的調用關係
同時 span_analysis 也會把 otlp 的 span 轉換為 Erda 的 span 標準模型,將上面的 metrics 和轉換後的 span 數據流轉到 kafka ,再被 Erda 可觀測性平台的現有數據消費組件消費和存儲。
關鍵程式碼參考 analyzer/tracing
通過上面的方式,我們就完成了 Erda 對 OpenTelemetry Trace 數據的接入和處理。
接下來,我們再來看一下 Erda 自身的服務是如何對接 OpenTelemetry。
Golang 無侵入的調用攔截
Erda 作為一款雲原生 PaaS 平台,也理所當然的使用雲原生領域最流行的 Golang 進行開發實現,但在 Erda 早期的時候,我們並沒有在任何平台的邏輯中預置追蹤的埋點。所以即使在 OpenTelemetry 提供了開箱即用的 Go SDK 的情況下,我們只在核心邏輯中進行手動的 Span 接入都是一個需要投入巨大成本的工作。
在我之前的 Java 和 .NET Core 項目經驗中,都會使用 AOP 的方式來實現性能和調用鏈路埋點這類非業務相關的邏輯。雖然 Golang 語言並沒有提供類似 Java Agent 的機制允許我們在程式運行中修改程式碼邏輯,但我們仍從 monkey 項目中受到了啟發,並在對 monkey 、pinpoint-apm/go-aop-agent 和 gohook 進行充分的對比和測試後,我們選擇了使用 gohook 作為 Erda 的 AOP 實現思路,最終在 erda-infra 中提供了自動追蹤埋點的實現。
關於 monkey 的原理可以參考 monkey-patching-in-go
以 http-server 的自動追蹤為例,我們的核心實現如下:
//go:linkname serverHandler net/http.serverHandler
type serverHandler struct {
srv *http.Server
}
//go:linkname serveHTTP net/http.serverHandler.ServeHTTP
//go:noinline
func serveHTTP(s *serverHandler, rw http.ResponseWriter, req *http.Request)
//go:noinline
func originalServeHTTP(s *serverHandler, rw http.ResponseWriter, req *http.Request) {}
var tracedServerHandler = otelhttp.NewHandler(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
injectcontext.SetContext(r.Context())
defer injectcontext.ClearContext()
s := getServerHandler(r.Context())
originalServeHTTP(s, rw, r)
}), "", otelhttp.WithSpanNameFormatter(func(operation string, r *http.Request) string {
u := *r.URL
u.RawQuery = ""
u.ForceQuery = false
return r.Method + " " + u.String()
}))
type _serverHandlerKey int8
const serverHandlerKey _serverHandlerKey = 0
func withServerHandler(ctx context.Context, s *serverHandler) context.Context {
return context.WithValue(ctx, serverHandlerKey, s)
}
func getServerHandler(ctx context.Context) *serverHandler {
return ctx.Value(serverHandlerKey).(*serverHandler)
}
//go:noinline
func wrappedHTTPHandler(s *serverHandler, rw http.ResponseWriter, req *http.Request) {
req = req.WithContext(withServerHandler(req.Context(), s))
tracedServerHandler.ServeHTTP(rw, req)
}
func init() {
hook.Hook(serveHTTP, wrappedHTTPHandler, originalServeHTTP)
}
在解決了 Golang 的自動埋點後,我們還遇到的一個棘手問題是在非同步的場景中,因為上下文的切換導致 TraceContext 無法傳遞到下一個 Goroutine 中。同樣在參考了 Java 的 Future 和 C# 的 Task 兩種非同步編程模型後,我們也實現了自動傳遞 Trace 上下文的非同步 API:
future1 := parallel.Go(ctx, func(ctx context.Context) (interface{}, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "//www.baidu.com/api_1", nil)
if err != nil {
return nil, err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
byts, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, err
}
return string(byts), nil
})
future2 := parallel.Go(ctx, func(ctx context.Context) (interface{}, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "//www.baidu.com/api_2", nil)
if err != nil {
return nil, err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
byts, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, err
}
return string(byts), nil
}, parallel.WithTimeout(10*time.Second))
body1, err := future1.Get()
if err != nil {
return nil, err
}
body2, err := future2.Get()
if err != nil {
return nil, err
}
return &pb.HelloResponse{
Success: true,
Data: body1.(string) + body2.(string),
}, nil
寫在最後
在使用 OpenTelemetry 把 Erda 平台調用產生的 Trace 數據接入到 Erda 自身的 APM 中後,我們首先能得到的收益是可以直觀的得到 Erda 的運行時拓撲:
Erda 運行時拓撲
通過該拓撲,我們能夠看到 Erda 自身在架構設計上存在的諸多問題,比如服務的循環依賴、和存在離群服務等。根據自身的觀測數據,我們也可以在每個版本迭代中逐步去優化 Erda 的調用架構。
對於我們隔壁的 SRE 團隊,也可以根據 Erda APM 自動分析的調用異常產生的告警消息,能夠第一時間知道平台的異常狀態:
最後,對於我們的開發團隊,基於觀測數據,能夠很容易地洞察到平台的慢調用,以及根據 Trace 分析故障和性能瓶頸:
小 L:「除了上面這些,我們還可以把平台的日誌、頁面訪問速度等都使用類似的思路接入到 Erda 的可觀測性平台。」
小濤恍然大悟道:「我知道了,原來套娃觀測還可以這麼玩!以後就可以放心地喝著咖啡做自己的工作了😄。」
我們致力於決社區用戶在實際生產環境中回饋的問題和需求,
如果您有任何疑問或建議,
歡迎關注【爾達Erda】公眾號給我們留言,
加入 Erda 用戶群參與交流或在 Github 上與我們討論!