Istio最佳實踐系列:如何實現方法級呼叫跟蹤?

騰訊雲原生發表於2021-04-15

趙化冰,騰訊雲高階工程師,Istio Member,ServiceMesher 管理委員,Istio 專案貢獻者,熱衷於開源、網路和雲端計算。目前主要從事服務網格的開源和研發工作。

引言

TCM(Tencent Cloud Mesh)是騰訊雲上提供的基於Istio 進行增強,和 Istio API 完全相容的 Service Mesh 託管服務,可以幫助使用者以較小的遷移成本和維護代價快速利用到 Service Mesh 提供的流量管理和服務治理能力。本系列文章將介紹 TCM 上的最佳實踐,本文將介紹如何利用 Spring 和 OpenTracing 簡化應用程式的Tracing 上下文傳遞,以及如何在 Istio 提供的程式間呼叫跟蹤基礎上實現方法級別的細粒度呼叫跟蹤。

分散式呼叫跟蹤和 OpenTracing 規範

什麼是分散式呼叫跟蹤?

相比傳統的“巨石”應用,微服務的一個主要變化是將應用中的不同模組拆分為了獨立的程式。在微服務架構下,原來程式內的方法呼叫成為了跨程式的RPC呼叫。相對於單一程式的方法呼叫,跨程式呼叫的除錯和故障分析是非常困難的,很難用傳統的偵錯程式或者日誌列印來對分散式呼叫進行檢視和分析。

如上圖所示,一個來自客戶端的請求經過了多個微服務程式。如果要對該請求進行分析,則必須將該請求經過的所有服務的相關資訊都收集起來並關聯在一起,這就是“分散式呼叫跟蹤”。

什麼是OpenTracing?

CNCF OpenTracing專案

OpenTracingCNCF(雲原生計算基金會)下的一個專案,其中包含了一套分散式呼叫跟蹤的標準規範,各種語言的API,程式設計框架和函式庫。OpenTracing的目的是定義一套分散式呼叫跟蹤的標準,以統一各種分散式呼叫跟蹤的實現。目前已有大量支援OpenTracing規範的Tracer實現,包括Jager,Skywalking,LightStep等。在微服務應用中採用OpenTracing API實現分散式呼叫跟蹤,可以避免vendor locking,以最小的代價和任意一個相容OpenTracing的基礎設施進行對接。

OpenTracing概念模型

OpenTracing的概念模型參見下圖:


圖源自 https://opentracing.io/
如圖所示,OpenTracing中主要包含下述幾個概念:

  • Trace: 描述一個分散式系統中的端到端事務,例如來自客戶端的一個請求。
  • Span:一個具有名稱和時間長度的操作,例如一個REST呼叫或者資料庫操作等。Span是分散式呼叫跟蹤的最小跟蹤單位,一個Trace由多段Span組成。
  • Span context:分散式呼叫跟蹤的上下文資訊,包括Trace id,Span id以及其它需要傳遞到下游服務的內容。一個OpenTracing的實現需要將Span context通過某種序列化機制(Wire Protocol)在程式邊界上進行傳遞,以將不同程式中的Span關聯到同一個Trace上。這些Wire Protocol可以是基於文字的,例如HTTP header,也可以是二進位制協議。

OpenTracing資料模型

一個Trace可以看成由多個相互關聯的Span組成的有向無環圖(DAG圖)。下圖是一個由8個Span組成的Trace:

        [Span A]  ←←←(the root span)
            |
     +------+------+
     |                |
 [Span B]      [Span C] ←←←(Span C is a `ChildOf` Span A)
     |                  |
 [Span D]      +---+-------+
                   |              |
               [Span E]    [Span F] >>> [Span G] >>> [Span H]
                                                    ↑
                                                    ↑
                                                    ↑
                            (Span G `FollowsFrom` Span F)

上圖的trace也可以按照時間先後順序表示如下:

––|–––––––|–––––––|–––––––|–––––––|–––––––|–––––––|–––––––|–> time
 [Span A···················································]
   [Span B··············································]
      [Span D··········································]
    [Span C········································]
         [Span E·······]        [Span F··] [Span G··] [Span H··]

Span的資料結構中包含以下內容:

  • name: Span所代表的操作名稱,例如REST介面對應的資源名稱。
  • Start timestamp: Span所代表操作的開始時間
  • Finish timestamp: Span所代表的操作的的結束時間
  • Tags:一系列標籤,每個標籤由一個key value鍵值對組成。該標籤可以是任何有利於呼叫分析的資訊,例如方法名,URL等。
  • SpanContext:用於跨程式邊界傳遞Span相關資訊,在進行傳遞時需要結合一種序列化協議(Wire Protocol)使用。
  • References:該Span引用的其它關聯Span,主要有兩種引用關係,Childof和FollowsFrom。
    • Childof: 最常用的一種引用關係,表示Parent Span和Child Span之間存在直接的依賴關係。例RPC服務端Span和RPC客戶端Span,或者資料庫SQL插入Span和ORM Save動作Span之間的關係。
    • FollowsFrom:如果Parent Span並不依賴Child Span的執行結果,則可以用FollowsFrom表示。例如網上商店購物付款後會向使用者發一個郵件通知,但無論郵件通知是否傳送成功,都不影響付款成功的狀態,這種情況則適用於用FollowsFrom表示。

跨程式呼叫資訊傳播

SpanContext是OpenTracing中一個讓人比較迷惑的概念。在OpenTracing的概念模型中提到SpanContext用於跨程式邊界傳遞分散式呼叫的上下文。但實際上OpenTracing只定義一個SpanContext的抽象介面,該介面封裝了分散式呼叫中一個Span的相關上下文內容,包括該Span所屬的Trace id,Span id以及其它需要傳遞到downstream服務的資訊。SpanContext自身並不能實現跨程式的上下文傳遞,需要由Tracer(Tracer是一個遵循OpenTracing協議的實現,如Jaeger,Skywalking的Tracer)將SpanContext序列化後通過Wire Protocol傳遞到下一個程式中,然後在下一個程式將SpanContext反序列化,得到相關的上下文資訊,以用於生成Child Span。

為了為各種具體實現提供最大的靈活性,OpenTracing只是提出了跨程式傳遞SpanContext的要求,並未規定將SpanContext進行序列化並在網路中傳遞的具體實現方式。各個不同的Tracer可以根據自己的情況使用不同的Wire Protocol來傳遞SpanContext。

在基於HTTP協議的分散式呼叫中,通常會使用HTTP Header來傳遞SpanContext的內容。常見的Wire Protocol包含Zipkin使用的b3 HTTP header,Jaeger使用的uber-trace-id HTTP Header,LightStep使用的"x-ot-span-context" HTTP Header等。Istio/Envoy支援b3 header和x-ot-span-context header,可以和Zipkin,Jaeger及LightStep對接。其中b3 HTTP header的示例如下:

X-B3-TraceId: 80f198ee56343ba864fe8b2a57d3eff7
X-B3-ParentSpanId: 05e3ac9a4f6e3b90
X-B3-SpanId: e457b5a2e4d86bd1
X-B3-Sampled: 1

Istio對分散式呼叫跟蹤的支援

Istio/Envoy為微服務提供了開箱即用的分散式呼叫跟蹤功能。在安裝了Istio和Envoy的微服務系統中,Envoy會攔截服務的入向和出向請求,為微服務的每個呼叫請求自動生成呼叫跟蹤資料。通過在服務網格中接入一個分散式跟蹤的後端系統,例如zipkin或者Jaeger,就可以檢視一個分散式請求的詳細內容,例如該請求經過了哪些服務,呼叫了哪個REST介面,每個REST介面所花費的時間等。

需要注意的是,Istio/Envoy雖然在此過程中完成了大部分工作,但還是要求對應用程式碼進行少量修改:應用程式碼中需要將收到的上游HTTP請求中的b3 header拷貝到其向下遊發起的HTTP請求的header中,以將呼叫跟蹤上下文傳遞到下游服務。這部分程式碼不能由Envoy代勞,原因是Envoy並不清楚其代理的服務中的業務邏輯,無法將入向請求和出向請求按照業務邏輯進行關聯。這部分程式碼量雖然不大,但需要對每一處發起HTTP請求的程式碼都進行修改,非常繁瑣而且容易遺漏。當然,可以將發起HTTP請求的程式碼封裝為一個程式碼庫來供業務模組使用,來簡化該工作。

下面以一個簡單的網上商店示例程式來展示Istio如何提供分散式呼叫跟蹤。該示例程式由eshop,inventory,billing,delivery幾個微服務組成,結構如下圖所示:

eshop微服務接收來自客戶端的請求,然後呼叫inventory,billing,delivery這幾個後端微服務的REST介面來實現使用者購買商品的checkout業務邏輯。本例的程式碼可以從github下載:https://github.com/aeraki-framework/method-level-tracing-with-istio

如下面的程式碼所示,我們需要在eshop微服務的應用程式碼中傳遞b3 HTTP Header。

 @RequestMapping(value = "/checkout")
public String checkout(@RequestHeader HttpHeaders headers) {
    String result = "";
    // Use HTTP GET in this demo. In a real world use case,We should use HTTP POST
    // instead.
    // The three services are bundled in one jar for simplicity. To make it work,
    // define three services in Kubernets.
    result += restTemplate.exchange("http://inventory:8080/createOrder", HttpMethod.GET,
            new HttpEntity<>(passTracingHeader(headers)), String.class).getBody();
    result += "<BR>";
    result += restTemplate.exchange("http://billing:8080/payment", HttpMethod.GET,
            new HttpEntity<>(passTracingHeader(headers)), String.class).getBody();
    result += "<BR>";
    result += restTemplate.exchange("http://delivery:8080/arrangeDelivery", HttpMethod.GET,
            new HttpEntity<>(passTracingHeader(headers)), String.class).getBody();
    return result;
}
private HttpHeaders passTracingHeader(HttpHeaders headers) {
    HttpHeaders tracingHeaders = new HttpHeaders();
    extractHeader(headers, tracingHeaders, "x-request-id");
    extractHeader(headers, tracingHeaders, "x-b3-traceid");
    extractHeader(headers, tracingHeaders, "x-b3-spanid");
    extractHeader(headers, tracingHeaders, "x-b3-parentspanid");
    extractHeader(headers, tracingHeaders, "x-b3-sampled");
    extractHeader(headers, tracingHeaders, "x-b3-flags");
    extractHeader(headers, tracingHeaders, "x-ot-span-context");
    return tracingHeaders;
}

下面我們來測試一下eshop例項程式。我們可以自己搭建一個Kubernetes叢集並安裝Istio以用於測試。這裡為了方便,直接使用騰訊雲上提供的全託管的服務網格 TCM,並在建立的 Mesh 中加入了一個容器服務TKE 叢集來進行測試。

在 TKE 叢集中部署該程式,檢視Istio分散式呼叫跟蹤的效果。

git clone git@github.com:aeraki-framework/method-level-tracing-with-istio.git
cd method-level-tracing-with-istio
git checkout without-opentracing
kubectl apply -f k8s/eshop.yaml
  • 在瀏覽器中開啟地址:http://${INGRESS_EXTERNAL_IP}/checkout ,以觸發呼叫eshop示例程式的REST介面。
  • 在瀏覽器中開啟 TCM 的介面,檢視生成的分散式呼叫跟蹤資訊。

TCM 圖形介面直觀地展示了這次呼叫的詳細資訊,可以看到客戶端請求從Ingressgateway進入到系統中,然後呼叫了eshop微服務的checkout介面,checkout呼叫有三個child span,分別對應到inventory,billing和delivery三個微服務的REST介面。

使用OpenTracing來傳遞分散式跟蹤上下文

OpenTracing提供了基於Spring的程式碼埋點,因此我們可以使用OpenTracing Spring框架來提供HTTP header的傳遞,以避免這部分硬編碼工作。在Spring中採用OpenTracing來傳遞分散式跟蹤上下文非常簡單,只需要下述兩個步驟:

  • 在Maven POM檔案中宣告相關的依賴,一是對OpenTracing SPring Cloud Starter的依賴;另外由於Istio 採用了Zipkin的上報介面,我們也需要引入Zipkin的相關依賴。
  • 在Spring Application中宣告一個Tracer bean。如下所示,注意我們需要把 Istio 中的zpkin上報地址設定到OKHttpSernder中。
@Bean
    public io.opentracing.Tracer zipkinTracer() {
        String zipkinEndpoint = System.getenv("ZIPKIN_ENDPOINT");
        if (zipkinEndpoint == null || zipkinEndpoint == ""){
            zipkinEndpoint = "http://zipkin.istio-system:9411/api/v2/spans";
        }
        OkHttpSender sender = OkHttpSender.create(zipkinEndpoint);
        Reporter spanReporter = AsyncReporter.create(sender);
        Tracing braveTracing = Tracing.newBuilder()
                .localServiceName("my-service")
                .propagationFactory(B3Propagation.FACTORY)
                .spanReporter(spanReporter)
                .build();
        Tracing braveTracer = Tracing.newBuilder()
                .localServiceName("spring-boot")
                .spanReporter(spanReporter)
                .propagationFactory(B3Propagation.FACTORY)
                .traceId128Bit(true)
                .sampler(Sampler.ALWAYS_SAMPLE)
                .build();
        return BraveTracer.create(braveTracer);
    }

部署採用OpenTracing進行HTTP header傳遞的程式版本,其呼叫跟蹤資訊如下所示:

從上圖中可以看到,相比在應用程式碼中直接傳遞HTTP header的方式,採用OpenTracing進行程式碼埋點後,相同的呼叫增加了7個名稱字首為spring-boot的Span,這7個Span是由OpenTracing的tracer生成的。雖然我們並沒有在程式碼中顯示建立這些Span,但OpenTracing的程式碼埋點會自動為每一個REST請求生成一個Span,並根據呼叫關係關聯起來。

OpenTracing生成的這些Span為我們提供了更詳細的分散式呼叫跟蹤資訊,從這些資訊中可以分析出一個HTTP呼叫從客戶端應用程式碼發起請求,到經過客戶端的Envoy,再到服務端的Envoy,最後到服務端接受到請求各個步驟的耗時情況。從圖中可以看到,Envoy轉發的耗時在1毫秒左右,相對於業務程式碼的處理時長非常短,對這個應用而言,Envoy的處理和轉發對於業務請求的處理效率基本沒有影響。

在Istio呼叫跟蹤鏈中加入方法級的呼叫跟蹤資訊

Istio/Envoy提供了跨服務邊界的呼叫鏈資訊,在大部分情況下,服務粒度的呼叫鏈資訊對於系統效能和故障分析已經足夠。但對於某些服務,需要採用更細粒度的呼叫資訊來進行分析,例如一個REST請求內部的業務邏輯和資料庫訪問分別的耗時情況。在這種情況下,我們需要在服務程式碼中進行埋點,並將服務程式碼中上報的呼叫跟蹤資料和Envoy生成的呼叫跟蹤資料進行關聯,以統一呈現Envoy和服務程式碼中生成的呼叫資料。

在方法中增加呼叫跟蹤的程式碼是類似的,因此我們用AOP + Annotation的方式實現,以簡化程式碼。
首先定義一個Traced註解和對應的AOP實現邏輯:

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@Documented
public @interface Traced {
}
@Aspect
@Component
public class TracingAspect {
    @Autowired
    Tracer tracer;
    @Around("@annotation(com.zhaohuabing.demo.instrument.Traced)")
    public Object aroundAdvice(ProceedingJoinPoint jp) throws Throwable {
        String class_name = jp.getTarget().getClass().getName();
        String method_name = jp.getSignature().getName();
        Span span = tracer.buildSpan(class_name + "." + method_name).withTag("class", class_name)
                .withTag("method", method_name).start();
        Object result = jp.proceed();
        span.finish();
        return result;
    }
}

然後在需要進行呼叫跟蹤的方法上加上Traced註解:

@Component
public class DBAccess {
    @Traced
    public void save2db() {
        try {
            Thread.sleep((long) (Math.random() * 100));
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

@Component
public class BankTransaction {
    @Traced
    public void transfer() {
        try {
            Thread.sleep((long) (Math.random() * 100));
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

demo程式的master branch已經加入了方法級程式碼跟蹤,可以直接部署。

git checkout master
kubectl apply -f k8s/eshop.yaml

效果如下圖所示,可以看到trace中增加了transfer和save2db兩個方法級的Span。

可以開啟一個方法的Span,檢視詳細資訊,包括Java類名和呼叫的方法名等,在AOP程式碼中還可以根據需要新增出現異常時的異常堆疊等資訊。

總結

Istio/Envoy為微服務應用提供了分散式呼叫跟蹤功能,提高了服務呼叫的可見性。我們可以使用OpenTracing來代替應用硬編碼,以傳遞分散式跟蹤的相關http header;還可以通過OpenTracing將方法級的呼叫資訊加入到Istio/Envoy預設提供的呼叫鏈跟蹤資訊中,以提供更細粒度的呼叫跟蹤資訊。

下一步

除了同步呼叫之外,非同步訊息也是微服務架構中常見的一種通訊方式。在下一篇文章中,我將繼續利用eshop demo程式來探討如何通過OpenTracing將Kafka非同步訊息也納入到Istio的分散式呼叫跟蹤中。

參考資料
  1. 本文中eshop示例程式的原始碼
  2. Opentracing docs
  3. Opentracing specification
  4. Opentracing wire protocols
  5. Istio Trace context propagation
  6. Zipkin-b3-propagation
  7. OpenTracing Project Deep Dive

相關文章