在分散式、微服務架構下,應用一個請求往往貫穿多個分散式服務,這給應用的故障排查、效能優化帶來新的挑戰。分散式鏈路追蹤作為解決分散式應用可觀測問題的重要技術,愈發成為分散式應用不可缺少的基礎設施。本文將詳細介紹分散式鏈路的核心概念、架構原理和相關開源標準協定,並分享我們在實現無侵入 Go 採集 Sdk 方面的一些實踐。
在分散式架構下,當用戶從瀏覽器使用者端發起一個請求時,後端處理邏輯往往貫穿多個分散式服務,這時會浮現很多問題,比如:
回答這些問題變得不是那麼簡單,我們不僅僅需要知道某一個服務的介面處理統計資料,還需要了解兩個服務之間的介面呼叫依賴關係,只有建立起整個請求在多個服務間的時空順序,才能更好的幫助我們理解和定位問題,而這,正是分散式鏈路追蹤系統可以解決的。
分散式鏈路追蹤技術的核心思想:在使用者一次分散式請求服務的調⽤過程中,將請求在所有子系統間的呼叫過程和時空關係追蹤記錄下來,還原成呼叫鏈路集中展示,資訊包括各個服務節點上的耗時、請求具體到達哪臺機器上、每個服務節點的請求狀態等等。
如上圖所示,通過分散式鏈路追蹤構建出完整的請求鏈路後,可以很直觀地看到請求耗時主要耗費在哪個服務環節,幫助我們更快速聚焦問題。
同時,還可以對採集的鏈路資料做進一步的分析,從而可以建立整個系統各服務間的依賴關係、以及流量情況,幫助我們更好地排查系統的迴圈依賴、熱點服務等問題。
在分散式鏈路追蹤系統中,最核心的概念,便是鏈路追蹤的資料模型定義,主要包括 Trace 和 Span。
其中,Trace 是一個邏輯概念,表示一次(分散式)請求經過的所有區域性操作(Span)構成的一條完整的有向無環圖,其中所有的 Span 的 TraceId 相同。
Span 則是真實的資料實體模型,表示一次(分散式)請求過程的一個步驟或操作,代表系統中一個邏輯執行單元,Span 之間通過巢狀或者順序排列建立因果關係。Span 資料在採集端生成,之後上報到伺服器端,做進一步的處理。其包含如下關鍵屬性:
分散式鏈路追蹤系統的核心任務是:圍繞 Span 的生成、傳播、採集、處理、儲存、視覺化、分析,構建分散式鏈路追蹤系統。其一般的架構如下如所示:
剛才講的,是一個通用的架構,我們並沒有涉及每個模組的細節,尤其是伺服器端,每個模組細講起來都要很花些功夫,受篇幅所限,我們把注意力集中到靠近應用側的 Tracing Sdk,重點看看在應用側具體是如何實現鏈路資料的跟蹤和採集的。
剛才我們提到 Tracing Sdk,其實這只是一個概念,具體到實現,選擇可能會非常多,這其中的原因,主要是因為:
當前,流行的鏈路追蹤後端,比如 Zipin、Jaeger、PinPoint、Skywalking、Erda,都有供應用整合的 sdk,導致我們在切換後端時應用側可能也需要做較大的調整。
社群也出現過不同的協定,試圖解決採集側的這種亂象,比如 OpenTracing、OpenCensus 協定,這兩個協定也分別有一些大廠跟進支援,但最近幾年,這兩者已經走向了融合統一,產生了一個新的標準 OpenTelemetry,這兩年發展迅猛,已經逐漸成為行業標準。
OpenTelemetry 定義了資料採集的標準 api,並提供了一組針對多語言的開箱即用的 sdk 實現工具,這樣,應用只需要與 OpenTelemetry 核心 api 包強耦合,不需要與特定的實現強耦合。
應用側圍繞 Span,有三個核心任務要完成:
要實現 Span 的生成和傳播,要求我們能夠攔截應用的關鍵操作(函數)過程,並新增 Span 相關的邏輯。實現這個目的會有很多方法,不過,在羅列這些方法之前,我們先看看在 OpenTelemetry 提供的 go sdk 中是如何做的。
OpenTelemetry 的 go sdk 實現呼叫鏈攔截的基本思路是:基於 AOP 的思想,採用裝飾器模式,通過包裝替換目標包(如 net/http)的核心介面或元件,實現在核心呼叫過程前後新增 Span 相關邏輯。當然,這樣的做法是有一定的侵入性的,需要手動替換使用原介面實現的程式碼呼叫改為包裝介面實現。
我們以一個 http server 的例子來說明,在 go 語言中,具體是如何做的:
假設有兩個服務 serverA 和 serverB,其中 serverA 的介面收到請求後,內部會通過 httpclient 進一步發起到 serverB 的請求,那麼 serverA 的核心程式碼可能如下圖所示:
以 serverA 節點為例,在 serverA 節點應該產生至少兩個 Span:
我們可以藉助 OpenTelemetry 提供的 sdk 來實現 Span 的生成、傳播和上報,上報的邏輯受篇幅所限我們不再詳述,重點來看看如何生成這兩個 Span,並使這兩個 Span 之間建立關聯,即 Span 的生成和傳播 。
對於 httpserver 來講,我們知道其核心就是 http.Handler 這個介面。因此,可以通過實現一個針對 http.Handler 介面的攔截器,來負責 Span 的生成和傳播。
package http
type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}
http.ListenAndServe(":8090", http.DefaultServeMux)
要使用 OpenTelemetry Sdk 提供的 http.Handler 裝飾器,需要如下調整 http.ListenAndServe 方法:
import (
"net/http"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/sdk/trace"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
)
wrappedHttpHandler := otelhttp.NewHandler(http.DefaultServeMux, ...)
http.ListenAndServe(":8090", wrappedHttpHandler)
如圖所示,wrppedHttpHandler 中將主要實現如下邏輯(精簡考慮,此處部分為虛擬碼):
① ctx := tracer.Extract(r.ctx, r.Header)
:從請求的 header 中提取 traceparent header 並解析,提取 TraceId和 SpanId,進而構建 SpanContext 物件,並最終儲存在 ctx 中;
② ctx, span := tracer.Start(ctx, genOperation(r))
:生成跟蹤當前請求處理過程的 Span(即前文所述的Span1),並記錄開始時間,這時會從 ctx 中讀取 SpanContext,將 SpanContext.TraceId 作為當前 Span 的TraceId,將 SpanContext.SpanId 作為當前 Span的ParentSpanId,然後將自己作為新的 SpanContext 寫入返回的 ctx 中;
③ r.WithContext(ctx)
:將新生成的 SpanContext 新增到請求 r 的 context 中,以便被攔截的 handler 內部在處理過程中,可以從 r.ctx 中拿到 Span1 的 SpanId 作為其 ParentSpanId 屬性,從而建立 Span 之間的父子關係;
④ span.End()
:當 innerHttpHandler.ServeHTTP(w,r) 執行完成後,就需要對 Span1 記錄一下處理完成的時間,然後將它傳送給 exporter 上報到伺服器端。
我們再接著看 serverA 內部去請求 serverB 時的 httpclient 請求是如何生成 Span 的(即前文說的 Span2)。我們知道,httpclient 傳送請求的關鍵操作是 http.RoundTriper 介面:
package http
type RoundTripper interface {
RoundTrip(*Request) (*Response, error)
}
OpenTelemetry 提供了基於這個介面的一個攔截器實現,我們需要使用這個實現包裝一下 httpclient 原來使用的 RoundTripper 實現,程式碼調整如下:
import (
"net/http"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/sdk/trace"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
)
wrappedTransport := otelhttp.NewTransport(http.DefaultTransport)
client := http.Client{Transport: wrappedTransport}
如圖所示,wrappedTransport 將主要完成以下任務(精簡考慮,此處部分為虛擬碼):
① req, _ := http.NewRequestWithContext(r.ctx, 「GET」,url, nil)
:這裡我們將上一步 http.Handler 的請求的 ctx,傳遞到 httpclient 要發出的 request 中,這樣在之後我們就可以從 request.Context() 中提取出 Span1 的資訊,來建立 Span 之間的關聯;
② ctx, span := tracer.Start(r.Context(), url)
:執行 client.Do() 之後,將首先進入 WrappedTransport.RoundTrip() 方法,這裡生成新的 Span(Span2),開始記錄 httpclient 請求的耗時情況,與前文一樣,Start 方法內部會從 r.Context() 中提取出 Span1 的 SpanContext,並將其 SpanId 作為當前 Span(Span2)的 ParentSpanId,從而建立了 Span 之間的巢狀關係,同時返回的 ctx 中儲存的 SpanContext 將是新生成的 Span(Span2)的資訊;
③ tracer.Inject(ctx, r.Header)
:這一步的目的是將當前 SpanContext 中的 TraceId 和 SpanId 等資訊寫入到 r.Header 中,以便能夠隨著 http 請求傳送到 serverB,之後在 serverB 中與當前 Span 建立關聯;
④ span.End()
:等待 httpclient 請求傳送到 serverB 並收到響應以後,標記當前 Span 跟蹤結束,設定 EndTime 並提交給 exporter 以上報到伺服器端。
我們比較詳細的介紹了使用 OpenTelemetry 庫,是如何實現鏈路的關鍵資訊(TraceId、SpanId)是如何在程序間和程序內傳播的,我們對這種跟蹤實現方式做個小的總結:
如上分析所展示的,使用這種方式的話,對程式碼還是有一定的侵入性,並且對程式碼有另一個要求,就是保持 context.Context 物件在各操作間的傳遞,比如,剛才我們在 serverA 中建立 httpclient 請求時,使用的是
http.NewRequestWithContext(r.ctx, ...)
而非http.NewRequest(...)
方法,另外開啟 goroutine 的非同步場景也需要注意 ctx 的傳遞。
我們剛才詳細展示了基於常規的一種具有一定侵入性的實現,其侵入性主要表現在:我們需要顯式的手動新增程式碼使用具有跟蹤功能的元件包裝原始碼,這進一步會導致應用程式碼需要顯式的參照具體版本的 OpenTelemetry instrumentation 包,這不利於可觀測程式碼的獨立維護和升級。
那我們有沒有可以實現非侵入跟蹤呼叫鏈的方案可選?
所謂無侵入,其實也只是整合的方式不同,整合的目標其實是差不多的,最終都是要通過某種方式,實現對關鍵呼叫函數的攔截,並加入特殊邏輯,無侵入重點在於程式碼無需修改或極少修改。
上圖列出了現在可能的一些無侵入整合的實現思路,與 .net、java 這類有 IL 語言的程式語言不同,go 直接編譯為機器碼,導致無侵入的方案實現起來相對比較麻煩,具體有如下幾種思路:
Erda 專案的核心程式碼主要是基於 golang 編寫的,我們基於前文所述的 OpenTelemetry sdk,採用基於修改機器碼的的方式,實現了一種無侵入的鏈路追蹤方式。
前文提到,使用 OpenTelemetry sdk 需要程式碼做一些調整,我們看看這些調整如何以非侵入的方式自動的完成:
我們以 httpclient 為例,做簡要的解釋。
gohook 框架提供的 hook 介面的簽名如下:
// target 要hook的目標函數
// replacement 要替換為的函數
// trampoline 將源函數入口拷貝到的位置,可用於從replcement跳轉回原target
func Hook(target, replacement, trampoline interface{}) error
對於 http.Client
,我們可以選擇 hook DefaultTransport.RoundTrip()
方法,當該方法執行時,我們通過 otelhttp.NewTransport()
包裝起原 DefaultTransport
物件,但需要注意的是,我們不能將 DefaultTransport
直接作為 otelhttp.NewTransport()
的引數,因為其 RoundTrip()
方法已經被我們替換了,而其原來真正的方法被寫到了 trampoline
中,所以這裡我們需要一箇中間層,來連線 DefaultTransport
與其原來的 RoundTrip
方法。具體程式碼如下:
//go:linkname RoundTrip net/http.(*Transport).RoundTrip
//go:noinline
// RoundTrip .
func RoundTrip(t *http.Transport, req *http.Request) (*http.Response, error)
//go:noinline
func originalRoundTrip(t *http.Transport, req *http.Request) (*http.Response, error) {
return RoundTrip(t, req)
}
type wrappedTransport struct {
t *http.Transport
}
//go:noinline
func (t *wrappedTransport) RoundTrip(req *http.Request) (*http.Response, error) {
return originalRoundTrip(t.t, req)
}
//go:noinline
func tracedRoundTrip(t *http.Transport, req *http.Request) (*http.Response, error) {
req = contextWithSpan(req)
return otelhttp.NewTransport(&wrappedTransport{t: t}).RoundTrip(req)
}
//go:noinline
func contextWithSpan(req *http.Request) *http.Request {
ctx := req.Context()
if span := trace.SpanFromContext(ctx); !span.SpanContext().IsValid() {
pctx := injectcontext.GetContext()
if pctx != nil {
if span := trace.SpanFromContext(pctx); span.SpanContext().IsValid() {
ctx = trace.ContextWithSpan(ctx, span)
req = req.WithContext(ctx)
}
}
}
return req
}
func init() {
gohook.Hook(RoundTrip, tracedRoundTrip, originalRoundTrip)
}
我們使用 init()
函數實現了自動新增 hook,因此使用者程式裡只需要在 main 檔案中 import 該包,即可實現無侵入的整合。
值得一提的是 req = contextWithSpan(req)
函數,內部會依次嘗試從 req.Context()
和 我們儲存的 goroutineContext map
中檢查是否包含 SpanContext
,並將其賦值給 req
,這樣便可以解除了必須使用 http.NewRequestWithContext(...)
寫法的要求。
詳細的程式碼可以檢視 Erda 倉庫:
https://github.com/erda-project/erda-infra/tree/master/pkg/trace