終極套娃 2.0|雲原生 PaaS 平臺的可觀測性實踐分享

erda_terminus_io發表於2022-04-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 的可觀測性資料模型。

image.png
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, "http://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, "http://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 的執行時拓撲:

image.png
Erda 執行時拓撲

通過該拓撲,我們能夠看到 Erda 自身在架構設計上存在的諸多問題,比如服務的迴圈依賴、和存在離群服務等。根據自身的觀測資料,我們也可以在每個版本迭代中逐步去優化 Erda 的呼叫架構。

對於我們隔壁的 SRE 團隊,也可以根據 Erda APM 自動分析的呼叫異常產生的告警訊息,能夠第一時間知道平臺的異常狀態:

image.png

最後,對於我們的開發團隊,基於觀測資料,能夠很容易地洞察到平臺的慢呼叫,以及根據 Trace 分析故障和效能瓶頸:

image.png

image.png

小 L:“除了上面這些,我們還可以把平臺的日誌、頁面訪問速度等都使用類似的思路接入到 Erda 的可觀測性平臺。”

小濤恍然大悟道:“我知道了,原來套娃觀測還可以這麼玩!以後就可以放心地喝著咖啡做自己的工作了?。”


我們致力於決社群使用者在實際生產環境中反饋的問題和需求,
如果您有任何疑問或建議,
歡迎關注【爾達Erda】公眾號給我們留言,
加入 Erda 使用者群參與交流或在 Github 上與我們討論!

相關文章