[業界方案]用Jaeger來學習分散式追蹤系統Opentracing

羅西的思考發表於2020-09-11

[業界方案]用Jaeger來學習分散式追蹤系統Opentracing

0x00 摘要

筆者之前有過zipkin的經驗,希望擴充套件到Opentracing,於是在學習Jaeger基礎上總結出此文,與大家分享。

0x01 緣由 & 問題

1.1 選擇Jaeger

JaegerUber 開發的一款呼叫鏈服務端產品,開發語言為 golang ,能夠相容接收 OpenTracing 格式的資料。根據其發展歷史,可以說是 Zipkin 的升級版。另外,其基於 udp (也可以 http )的傳輸協議,更加定位了其高效、迅速的特點。

在前文 [業界方案] 用SOFATracer學習分散式追蹤系統Opentracing ,我們使用SOFATracer來進行學習,本次我們選擇了Jaeger,這又是什麼原因?具體如下:

  • Jaeger是Opentracing官方推薦的。
  • Jaeger支援Opentracing高版本。

而且我們正好可以和SOFATracer進行對比印證。

1.2 問題

讓我們用問題來引導閱讀。

  • Jaeger 和 SOFATracer 對比如何?
  • spanId是怎麼生成的,有什麼規則?
  • traceId是怎麼生成的,有什麼規則?
  • 客戶端哪裡生成的Span?
  • ParentSpan 從哪兒來?
  • ChildSpan由ParentSpan建立,那麼什麼時候建立?
  • Trace資訊怎麼傳遞?
  • 伺服器接收到請求之後做什麼?
  • SpanContext在伺服器端怎麼處理?
  • 鏈路資訊如何蒐集?

1.3 本文討論範圍

1.3.1 Jaeger構成

Jaeger主要由以下幾部分組成:

  1. Jaeger Client: 為了不同語言實現了符合OpenTracing標準的SDK。應用程式通過API寫入資料, client library把trace資訊按照應用程式制定的取樣策略傳遞給jaeger-agent。
  2. Agent: 他是一個監聽在UDP埠上接收span資料的網路守護程式,它會將資料批量傳送給collector。他被設計成一個基礎元件,部署到所有的宿主機上。Agent將client library和collector解耦,為client library遮蔽了路由和發現collector的細節。
  3. Collector:接收jaeger-agent傳送來的資料,然後將資料寫入後端儲存。Collector被設計成無狀態的元件,因此使用者可以執行任意數量的Collector。
  4. Data Store:後端儲存被設計成一個可插拔的元件,支援資料寫入cassandra, elastic search。
  5. Query:接收查詢請求,然後從後端儲存系統中檢索tarce並通過UI進行展示。Query是無狀態的,可以啟動多個例項。把他們部署在nginx這樣的負載均衡器後面。

本文只討論 Jaeger Client 功能

1.3.2 全鏈路跟蹤

全鏈路跟蹤分成三個跟蹤級別:

  • 跨程式跟蹤 (cross-process)(呼叫另一個微服務)
  • 資料庫跟蹤
  • 程式內部的跟蹤 (in-process)(在一個函式內部的跟蹤)

本文只討論 跨程式跟蹤 (cross-process),因為跨程式跟蹤是最簡單的 ^_^。對於跨程式跟蹤,你可以編寫攔截器或過濾器來跟蹤每個請求,它只需要編寫極少的程式碼。

0x02 背景知識

因為前文已經對背景知識做了較詳細的介紹,本文只是提一下幾個必要概念。

分散式追蹤系統發展很快,種類繁多,但核心步驟一般有三個:程式碼埋點,資料儲存、查詢展示

在資料採集過程,需要侵入使用者程式碼做埋點,不同系統的API不相容會導致切換追蹤系統需要做很大的改動。為了解決這個問題,誕生了opentracing 規範。

   +-------------+  +---------+  +----------+  +------------+
   | Application |  | Library |  |   OSS    |  |  RPC/IPC   |
   |    Code     |  |  Code   |  | Services |  | Frameworks |
   +-------------+  +---------+  +----------+  +------------+
          |              |             |             |
          |              |             |             |
          v              v             v             v
     +-----------------------------------------------------+
     | · · · · · · · · · · OpenTracing · · · · · · · · · · |
     +-----------------------------------------------------+
       |               |                |               |
       |               |                |               |
       v               v                v               v
 +-----------+  +-------------+  +-------------+  +-----------+
 |  Tracing  |  |   Logging   |  |   Metrics   |  |  Tracing  |
 | System A  |  | Framework B |  | Framework C |  | System D  |
 +-----------+  +-------------+  +-------------+  +-----------+

大多數分散式追蹤系統的思想模型都來自Google's Dapper論文,OpenTracing也使用相似的術語。有幾個基本概念我們需要提前瞭解清楚:

  • Trace(追蹤) :Dapper 將一個呼叫過程構建成一棵呼叫樹(稱為Tracer),Tracer樹中的每個節點表示鏈路呼叫中的一個模組或系統。 通過一個全域性唯一的 traceId 來標識一個請求呼叫鏈。在廣義上,一個trace代表了一個事務或者流程在(分散式)系統中的執行過程。在OpenTracing標準中,trace是多個span組成的一個有向無環圖(DAG),每一個span代表trace中被命名並計時的連續性的執行片段。
  • Span(跨度) :一個span代表系統中具有開始時間和執行時長的邏輯執行單元,即應用中的一個邏輯操作。span之間通過巢狀或者順序排列建立邏輯因果關係。一個span可以被理解為一次方法呼叫,一個程式塊的呼叫,或者一次RPC/資料庫訪問,只要是一個具有完整時間週期的程式訪問,都可以被認為是一個span。Dapper中,一個span 包含以下階段(不同軟體可能有不同的實現 ,比如有的會細分為 Client Span 和 Server Span):
    • Start: 發起呼叫
    • cleint send(cs): 客戶端傳送請求
    • Server Recv(sr):服務端收到請求
    • Server Send(ss): 服務端傳送響應
    • Client Recv(cr) : 客戶端收到服務端響應
    • End: 整個鏈路完成。
     Client                             Server

+--------------+     Request        +--------------+
| Client Send  | +----------------> |Server Receive|
+------+-------+                    +------+-------+
       |                                   |
       |                                   v
       |                            +------+--------+
       |                            |Server Business|
       |                            +------+--------+
       |                                   |
       |                                   |
       v                                   v
+------+--------+    Response       +------+-------+
|Client Receive | <---------------+ |Server Send   |
+------+--------+                   +------+-------+
       |                                   |
       |                                   |
       v                                   v
  • Logs :每個span可以進行多次Logs操作,每一次Logs操作,都需要一個帶時間戳的時間名稱,以及可選的任意大小的儲存結構。比較適合記錄日誌、異常棧等一些和時間相關的資訊。
  • Tags :每個span可以有多個鍵值對(key :value)形式的Tags,Tags是沒有時間戳的,支援簡單的對span進行註解和補充。記錄的資訊適用於span從建立到完成的任何時刻。再說直白點就是記錄和時間點無關的資訊,這個主要是和下面的Logs作區分。
  • Baggage Items:這個主要是用於跨程式全域性傳輸資料
  • SpanContext :SpanContext更像是一個“概念”,而不是通用 OpenTracing 層的有用功能。在建立Span、向傳輸協議Inject(注入)和從傳輸協議中Extract(提取)呼叫鏈資訊時,SpanContext發揮著重要作用。

0x03 示例程式碼

3.1 程式碼

程式碼全部來自 https://github.com/yurishkuro/opentracing-tutorial,大家可以自己去下載。

這裡的tracer使用的是 JaegerTracer。

public class Hello {

    private final Tracer tracer;
    private final OkHttpClient client;

    private Hello(Tracer tracer) {
        this.tracer = tracer;
        this.client = new OkHttpClient();
    }

    private String getHttp(int port, String path, String param, String value) {
        try {
            HttpUrl url = new HttpUrl.Builder().scheme("http").host("localhost").port(port).addPathSegment(path)
                    .addQueryParameter(param, value).build();
            Request.Builder requestBuilder = new Request.Builder().url(url);
            
            Span activeSpan = tracer.activeSpan();
            Tags.SPAN_KIND.set(activeSpan, Tags.SPAN_KIND_CLIENT);
            Tags.HTTP_METHOD.set(activeSpan, "GET");
            Tags.HTTP_URL.set(activeSpan, url.toString());
            tracer.inject(activeSpan.context(), Format.Builtin.HTTP_HEADERS, Tracing.requestBuilderCarrier(requestBuilder));

            Request request = requestBuilder.build();
            Response response = client.newCall(request).execute();

            Tags.HTTP_STATUS.set(activeSpan, response.code());
            if (response.code() != 200) {
                throw new RuntimeException("Bad HTTP result: " + response);
            }
            return response.body().string();
        } catch (Exception e) {
            Tags.ERROR.set(tracer.activeSpan(), true);
            tracer.activeSpan().log(ImmutableMap.of(Fields.EVENT, "error", Fields.ERROR_OBJECT, e));
            throw new RuntimeException(e);
        }
    }

    private void sayHello(String helloTo, String greeting) {
        Span span = tracer.buildSpan("say-hello").start();
        try (Scope scope = tracer.scopeManager().activate(span)) {
            span.setTag("hello-to", helloTo);
            span.setBaggageItem("greeting", greeting);

            String helloStr = formatString(helloTo);
            printHello(helloStr);
        } finally {
            span.finish();
        }
    }

    private String formatString(String helloTo) {
        Span span = tracer.buildSpan("formatString").start();
        try (Scope scope = tracer.scopeManager().activate(span)) {
            String helloStr = getHttp(8081, "format", "helloTo", helloTo);
            span.log(ImmutableMap.of("event", "string-format", "value", helloStr));
            return helloStr;
        } finally {
            span.finish();
        }
    }

    private void printHello(String helloStr) {
        Span span = tracer.buildSpan("printHello").start();
        try (Scope scope = tracer.scopeManager().activate(span)) {
            getHttp(8082, "publish", "helloStr", helloStr);
            span.log(ImmutableMap.of("event", "println"));
        } finally {
            span.finish();
        }
    }

    public static void main(String[] args) {
        try (JaegerTracer tracer = Tracing.init("hello-world")) {
            new Hello(tracer).sayHello("helloTo", "greeting");
        }
    }
}

3.2 dropwizard

此處雖然不是SOFATracer和Jaeger的本質區別,但是也挺有趣,即SOFATracer是使用SprintBoot來做示例程式碼,而此處是使用dropwizard來做示例

可能有人對dropwizard不熟悉,現在大致講解如下:

  • Dropwizard是Coda HaleYammer公司時創立的,它旨在提升公司分散式系統的架構(現在叫:微服務)。雖然它最早被用來構建REST Web 服務,而現在它具備了越來越多的功能,但是它的目標始終是作為輕量化、為生產環境準備且容易使用的web框架。
  • Dropwizard與Spring Boot類似,也是構建微服務可選的工具,但是它顯得比Spring Boot更加規範一些。它使用的元件一般不會做可選替換,而好處是可以不需要那麼多的修飾,比如寫基於REST的web服務。
  • Dropwizard預設也不具備依賴注入的容器(像Spring或者CDI),你當然可以自行新增,但是Dropwizard推薦你把微服務弄的簡單一些,不需要這些額外的元件。
  • 就像Spring Boot一樣,Dropwizard推薦將整個工程打包成一個可執行的jar,通過這種方式開發人員不用在擔心程式執行的應用伺服器是什麼,需要什麼額外的配置,應用再也不需要被構建成war包了,而且也不會有那麼多複雜層級的類載入器了。

Dropwizard在優秀的三方庫協助下,提供了不錯的抽象層,使之更有效率,更簡單的編寫生產用途的微服務。

  • Servlet容器使用Jetty
  • REST/JAX-RS實現使用Jersey
  • JSON序列化使用Jackson
  • 整合Hibernate Validator
  • Guava
  • Metrics
  • SLF4J + Logback
  • 資料訪問層上使用JDBI

Dropwizard偏執的認為框架就是用來寫程式碼的,因此對於框架的底層技術棧的調整,原則上Dropwizard是拒絕的。正因為它這麼做,使得Dropwizard開發起程式碼來更快,而且配置更加容易。

對於我們的示例程式碼,對Dropwizard使用舉例如下,即使用 Dropwizard 建立了兩個服務和一個測試client。

io.dropwizard.Application

public class Formatter extends Application<Configuration> {

    private final Tracer tracer;

    private Formatter(Tracer tracer) {
        this.tracer = tracer;
    }

    @Path("/format")
    @Produces(MediaType.TEXT_PLAIN)
    public class FormatterResource {

        @GET
        public String format(@QueryParam("helloTo") String helloTo, @Context HttpHeaders httpHeaders) {
            Span span = Tracing.startServerSpan(tracer, httpHeaders, "format");
            try (Scope scope = tracer.scopeManager().activate(span)) {
                String greeting = span.getBaggageItem("greeting");
                if (greeting == null) {
                    greeting = "Hello";
                }
                String helloStr = String.format("%s, %s!", greeting, helloTo);
                span.log(ImmutableMap.of("event", "string-format", "value", helloStr));
                return helloStr;
            } finally {
                span.finish();
            }
        }
    }

    @Override
    public void run(Configuration configuration, Environment environment) throws Exception {
        environment.jersey().register(new FormatterResource());
    }

    public static void main(String[] args) throws Exception {
        System.setProperty("dw.server.applicationConnectors[0].port", "8081");
        System.setProperty("dw.server.adminConnectors[0].port", "9081");
        try (JaegerTracer tracer = Tracing.init("formatter")) {
            new Formatter(tracer).run("server");
        }
    }
}

0x04 鏈路邏輯

對於一個元件來說,一次處理過程一般是產生一個 Span;這個 Span 的生命週期是從接收到請求到返回響應這段過程。

這裡需要考慮的問題是如何與上下游鏈路關聯起來呢?在 Opentracing 規範中,可以在 Tracer 中 extract 出一個跨程式傳遞的 SpanContext 。然後通過這個 SpanContext 所攜帶的資訊將當前節點關聯到整個 Tracer 鏈路中去,當然有提取(extract)就會有對應的注入(inject)。

鏈路的構建一般是 client-server-client-server 這種模式的,那這裡就很清楚了,就是會在 client 端進行注入(inject),然後再 server 端進行提取(extract),反覆進行,然後一直傳遞下去。

在拿到 SpanContext 之後,此時當前的 Span 就可以關聯到這條鏈路中了,那麼剩餘的事情就是收集當前元件的一些資料;整個過程大概分為以下幾個階段:

  • 從請求中提取 spanContext
  • 構建 Span,並將當前 Span 存入當前 tracer上下文中(SofaTraceContext.push(Span)) 。
  • 設定一些資訊到 Span 中
  • 返回響應
  • Span 結束&上報

0x05 資料模型

5.1 Tracer & JaegerTracer

Jaeger中的Tracer控制了一個完整的服務的追蹤,包括註冊服務名(serviceName),傳送span(reporter),取樣(sampler),對span的序列化與反序列化以及傳輸(registry的injector,extractor),統計追蹤系統的資訊(metrics,如傳送span成功數量等)。

因此opentracing建議每個服務使用一個Tracer,除此之外Tracer還擔負構造span,獲取當前span以及獲取scopeManager的功能。

通過opentracing的規範亦可以看出,opentracing對Tracer的功能描述為:Tracer is a simple, thin interface for Span creation and propagation across arbitrary transports。而jaeger只是在其基礎上增加了其他功能。

Tracer是opentracing給出的介面。

package io.opentracing;
public interface Tracer extends Closeable {
    ScopeManager scopeManager();
    Span activeSpan();
    Scope activateSpan(Span var1);
    Tracer.SpanBuilder buildSpan(String var1);
    <C> void inject(SpanContext var1, Format<C> var2, C var3);
    <C> SpanContext extract(Format<C> var1, C var2);
    void close();
}

JaegerTracer 實現了 io.opentracing.Tracer。

public class JaegerTracer implements Tracer, Closeable {
    private final String version;
    private final String serviceName;
    private final Reporter reporter;
    private final Sampler sampler;
    private final Map<String, ?> tags;
    private final boolean zipkinSharedRpcSpan;
    private final boolean expandExceptionLogs;
    private final boolean useTraceId128Bit;
    private final PropagationRegistry registry;
    private final Clock clock;
    private final Metrics metrics;
    private final ScopeManager scopeManager;
    private final BaggageSetter baggageSetter;
    private final JaegerObjectFactory objectFactory;
    private final int ipv4;
}

5.2 Span & JaegerSpan

io.opentracing.Span 是 Opentracing 給出的概念。

public interface Span {
    SpanContext context();
    Span setTag(String var1, String var2);
    Span setTag(String var1, boolean var2);
    Span setTag(String var1, Number var2);
    <T> Span setTag(Tag<T> var1, T var2);
    Span setBaggageItem(String var1, String var2);
    String getBaggageItem(String var1);
    Span setOperationName(String var1);
    void finish();
    void finish(long var1);
}

JaegerSpan 實現了 io.opentracing.SPan。

public class JaegerSpan implements Span {
  private final JaegerTracer tracer;
  private final long startTimeMicroseconds;
  private final long startTimeNanoTicks;
  private final boolean computeDurationViaNanoTicks;
  private final Map<String, Object> tags;
  private long durationMicroseconds; // span durationMicroseconds
  private String operationName;
  private final List<Reference> references;
  private JaegerSpanContext context;
  private List<LogData> logs;
  private boolean finished = false; // to prevent the same span from getting reported multiple times
}

在jaeger的實現中,Span的資訊分為如下幾方面:

  • span核心資訊,如:traceId,spanId,parentId,baggage等
  • log資訊 與tag的區別是帶有時間戳
  • tag資訊
  • span的其他資訊,如:startTime,duration

其中span的核心資訊儲存在SpanContext

5.3 SpanContext & JaegerSpanContext

JaegerSpanContext 實現了 io.opentracing.SpanContext

public interface SpanContext {
    String toTraceId();
    String toSpanId();
    Iterable<Entry<String, String>> baggageItems();
}
public class JaegerSpanContext implements SpanContext {
  protected static final byte flagSampled = 1;
  protected static final byte flagDebug = 2;
  private final long traceIdLow;
  private final long traceIdHigh;
  private final long spanId;
  private final long parentId;
  private final byte flags;
  private final Map<String, String> baggage;
  private final String debugId;
  private final JaegerObjectFactory objectFactory;
  private final String traceIdAsString;
  private final String spanIdAsString;
}

span的核心資訊儲存在SpanContext中,在構建span時候就會建立,為了防止使用者擅自修改核心資訊,spanContext中的所有成員都是final修飾的。

根據opentracing的規範, SpanContext represents Span state that must propagate to descendant Spans and across process boundaries. SpanContext is logically divided into two pieces:
(1) the user-level "Baggage" that propagates across Span boundaries and
(2) any Tracer-implementation-specific fields that are needed to identify or otherwise contextualize the associated Span instance (e.g., a tuple).

上面是說SpanContext代表的是span中必須傳遞的資訊,在邏輯上分為兩部分,一分部分是普通的traceId,spanId等資訊,另一部分是baggage這種使用者自定義需要傳遞的資訊。

JaegerSpanContext這裡只是儲存了上下文環境應有的資訊,與 SofaTraceContext 不同,SofaTraceContext 裡面還存有Span,但是在 Jaeger,這個功能是在 ScopeManager中完成的

5.4 Reporter

預設的 RemoteReporter 實現了 Reporter,功能就是我們在前文中所說的傳送報告。

public class RemoteReporter implements Reporter {
  private static final int DEFAULT_CLOSE_ENQUEUE_TIMEOUT_MILLIS = 1000;
  public static final int DEFAULT_FLUSH_INTERVAL_MS = 1000;
  public static final int DEFAULT_MAX_QUEUE_SIZE = 100;

  private final Sender sender;
  private final int closeEnqueueTimeout;

  @ToString.Exclude private final BlockingQueue<Command> commandQueue;
  @ToString.Exclude private final Timer flushTimer;
  @ToString.Exclude private final Thread queueProcessorThread;
  @ToString.Exclude private final QueueProcessor queueProcessor;
  @ToString.Exclude private final Metrics metrics;
}

5.5 Scope

OpenTracing 抽象了Scope(active span) 和 ScopeManager(設定Scope與獲取當前Scope)概念。簡單來說,OpenTracing-Java的實現中, 用ScopeScopeManager 來處理了OpenTracing中的上下文 (即:get_current_span 過程);

為什麼要抽象出Scope的概念?直接使用ThreadLocal 儲存Span不就可以了嗎?

: 首先理解Scope是什麼?Scope 是Active Span的一個容器, Scope 代表著當前活躍的Span; 是對當前活躍Span的一個抽象, 代表了當前上下文所處於的一個過程;

另外, ThreadLocalScope 還記錄了 toRestore Span, 這樣結束時,可以恢復到上一個Span的狀態;

我理解如果只是 get_current_span() 邏輯的話,直接把 span 塞到 ThreadLocal裡就可以線上程內傳遞了;但是ScopeManager看程式碼是這樣實現的,ScopeManager 包含一個 Scope, Scope 又包含了 當前Span, recover Scope;我理解它的好處是: 這樣就保證了,如果開啟一個子Span(子span 會產生孫子span), 這樣 子span 結束後,還可以回到 父span (這樣可以繼續產生以 父span 為基礎的兄弟span), 如果只是ThreadLocal 裡塞一個當前span的話,是解決不了這種情況的。

或者說

在多執行緒環境下ScopeManager管理著各個執行緒的Scope,而每個執行緒中的Scope管理著該執行緒中的Span。這樣當某個執行緒需要獲取其執行緒中當前 活動的 span時,可以通過ScopeManager找到對應該執行緒的Scope,並從Scope中取出該執行緒 活動的 span。

Scope 物件是 Active Span的容器;通過Scope能拿到當前上下文內的Active Span;

io.opentracing.util.ThreadLocalScope 是Scope的一個實現,通過ThreadLocal 來儲存;

  • 建構函式就是把目前Span暫存,然後把傳入的引數Span設定為當前Span。即 將之前活動的scope作為當前scope的屬性toRestore來儲存,並將當前scope設定到scopeManager中作為當前執行緒最新的scope。
  • 在操作當span操作完成(span.finish)時,需要呼叫scope.close方法做恢復,觸發關聯新的啟用span,否則呼叫鏈條會出錯。

具體定義如下:

public class ThreadLocalScope implements Scope {
    private final ThreadLocalScopeManager scopeManager;
    private final Span wrapped; // 當前 Active Span
    private final ThreadLocalScope toRestore; // 上一Active Span,wrapped 結束時,會恢復到此Span

    ThreadLocalScope(ThreadLocalScopeManager scopeManager, Span wrapped) {
        this.scopeManager = scopeManager;
        this.wrapped = wrapped;
        // 這兩句設定了當前活動Scope
        this.toRestore = scopeManager.tlsScope.get();
        scopeManager.tlsScope.set(this);
    }

    @Override
    public void close() {
        if (scopeManager.tlsScope.get() != this) {
            // This shouldn't happen if users call methods in the expected order. Bail out.
            return;
        }
        scopeManager.tlsScope.set(toRestore);
    }

    Span span() {
        return wrapped;
    }
}

5.6 ScopeManager

Scope是站在CPU角度啟用或者失效Span。ScopeManager管理Scope。一個Scope裡可以有多個span,但是隻有一個啟用的span。

在多執行緒環境下ScopeManager管理著各個執行緒的Scope,而每個執行緒中的Scope管理著該執行緒中的Span。這樣當某個執行緒需要獲取其執行緒中當前活動的 span時,可以通過ScopeManager找到對應該執行緒的Scope,並從Scope中取出該執行緒 活動的 span。

有了ScopeManager, 我們就可以通過 scopeManager.activeSpan() 方法獲取到當前Span, 並且通過scopeManager().activate(span) 方法設定當前上下文active span;

io.opentracing.util.ThreadLocalScopeManager 是opentracing提供的ScopeManager的實現,Jaeger並沒有自己重寫一個新類,而是直接使用ThreadLocalScopeManager。

  • activate 函式的作用是 啟用當前 Span。返回Scope(可以理解為 代表當前 Span 活躍的一個階段)。即呼叫ThreadLocalScope的構造方法,將傳入的span啟用為 當前活動的 span。我們看一下ThreadLocalScope建構函式就能發現,與其說是啟用傳入的span倒不如說是啟用包裹(wrapped)該span的scope當前活動的 scope。

    Span 活躍期結束後,需要關閉 Scope, 推薦使用 try-with-resources 關閉。

  • activeSpan函式則是返回當前 啟用(active)狀態Span, 無則返回null。

public class ThreadLocalScopeManager implements ScopeManager {
    // 使用原始的ThreadLocal 來儲存 Active Span; ScopeManager中僅包含一個 Scope( Active Span), 即當前上下文中的 active span
    final ThreadLocal<ThreadLocalScope> tlsScope = new ThreadLocal<ThreadLocalScope>();

    // 可以看到,activate 函式就是把span放進一個新生成的 ThreadLocalScope 中,其實就是tlsScope 成員變數中。
    @Override
    public Scope activate(Span span) {
        return new ThreadLocalScope(this, span);
    }

    @Override
    public Span activeSpan() { 
        ThreadLocalScope scope = tlsScope.get();
        return scope == null ? null : scope.span();
    }
}

Jaeger使用scopeManager來處理管理了上下文,可以從 scopeManager中拿到當前上下文Span;那具體是在哪裡設定的父子關係呢?

在OpenTracing-Java實現中, 是在 tracer.start() 方法中處理的;start() 方法中通過 scopeManager 判斷是存在active span,若存在則生成CHILD_OF關係的上下文, 如果不存在則createNewContext;

這點和SOFATtacer不同,SOFATtacer把這個上下文管理功能放在了SofaTraceContext之中,確實在分析程式碼時候感到有些許混亂。

5.7 SpanID & TraceID

SpanId 和 TraceID 都是在構建SpanContext 時候生成的。

private JaegerSpanContext createNewContext() {
  String debugId = getDebugId();
  long spanId = Utils.uniqueId();  // span
  long traceIdLow = spanId;  // trace
  long traceIdHigh = isUseTraceId128Bit() ? Utils.uniqueId() : 0;
	......
}

具體規則如下:

public static long uniqueId() {
  long val = 0;
  while (val == 0) {
    val = Java6CompatibleThreadLocalRandom.current().nextLong();
  }
  return val;
}

然後是呼叫到了ThreadLocalRandom # current。

public static Random current() {
  if (threadLocalRandomPresent) {
    return ThreadLocalRandomAccessor.getCurrentThreadLocalRandom();
  } else {
    return threadLocal.get();
  }
}

static class ThreadLocalRandomAccessor {
    @IgnoreJRERequirement
    private static Random getCurrentThreadLocalRandom() {
      return ThreadLocalRandom.current();
    }
}

最後格式如下:

context = {JaegerSpanContext@1701} "c29c9e0f4a0a681c:36217443515fc248:c29c9e0f4a0a681c:1"
 traceIdLow = -4423486945480775652
 traceIdHigh = 0
 spanId = 3900526584756421192
 parentId = -4423486945480775652
 flags = 1
 baggage = {HashMap@1693}  size = 1
 debugId = null
 objectFactory = {JaegerObjectFactory@1673} 
 traceIdAsString = "c29c9e0f4a0a681c"
 spanIdAsString = "36217443515fc248"

0x06 啟動

6.1 手動埋點

要通過Jaeger將Java應用資料上報至鏈路追蹤控制檯,首先需要完成埋點工作。本示例為手動埋點

6.2 pom配置

pom.xml中新增了對Jaeger客戶端的依賴。

<dependency>
    <groupId>io.jaegertracing</groupId>
    <artifactId>jaeger-client</artifactId>
    <version>${jaeger.version}</version>
</dependency>

6.3 啟動

示例程式碼並沒有使用注入的元件,而是手動啟動,具體啟動/初始化程式碼如下:

public final class Tracing {
    private Tracing() { }
    
    public static JaegerTracer init(String service) {
        SamplerConfiguration samplerConfig = SamplerConfiguration.fromEnv()
                .withType(ConstSampler.TYPE)
                .withParam(1);

        ReporterConfiguration reporterConfig = ReporterConfiguration.fromEnv()
                .withLogSpans(true);

        // 這裡啟動
        Configuration config = new Configuration(service)
                .withSampler(samplerConfig)
                .withReporter(reporterConfig);

        return config.getTracer();
    }
}

示例中啟動的 io.dropwizard.Application 都會呼叫init進行初始化。

try (JaegerTracer tracer = Tracing.init("publisher")) {
    new Publisher(tracer).run("server");
}

具體啟動邏輯都是在 io.jaegertracing.Configuration 中完成的。我們可以看到其中實現了眾多配置和一個tracer

6.4 構建Tracer

上節程式碼中有 config.getTracer(); ,這就是 jaeger採用builder模式來構建Tracer

public class Configuration {
    private String serviceName;
    private Configuration.SamplerConfiguration samplerConfig;
    private Configuration.ReporterConfiguration reporterConfig;
    private Configuration.CodecConfiguration codecConfig;
    private MetricsFactory metricsFactory;
    private Map<String, String> tracerTags;
    private boolean useTraceId128Bit;
    private JaegerTracer tracer;
  
    public synchronized JaegerTracer getTracer() {
      if (tracer != null) {
        return tracer;
      }

      tracer = getTracerBuilder().build(); // 構建
      return tracer;
    }
  
    ......
}  

build()方法最終完成了Tracer物件的構造。

  • 預設使用RemoteReporter來report Span到agent,
  • 取樣預設使用RemoteControlledSampler
  • 共同使用的metrics是在Builder內部類中的有預設值的成員變數metrics
public JaegerTracer build() {
  if (reporter == null) {
    reporter = new RemoteReporter.Builder()
        .withMetrics(metrics)
        .build();
  }
  if (sampler == null) {
    sampler = new RemoteControlledSampler.Builder(serviceName)
        .withMetrics(metrics)
        .build();
  }
  return createTracer();
}

protected JaegerTracer createTracer() {
      return new JaegerTracer(this);
}

Tracer物件可以用來建立Span物件以便記錄分散式操作時間、通過Extract/Inject方法跨機器透傳資料、或設定當前Span。Tracer物件還配置了上報資料的閘道器地址、本機IP地址、取樣率、服務名等資料。使用者可以通過調整取樣率來減少因上報資料產生的開銷。

在啟動之後,使用者得到 Tracer 來進行後續手動埋點。

JaegerTracer tracer = Tracing.init("hello-world")

0x07 客戶端傳送

下面都是手動埋點。

7.1 構建Span

構造Span物件是一件很簡單的事情,通過opentracing對Tracer介面的規定可知Span是由Tracer負責構造的,如下我們“啟動”了一個Span(實際上只是構造了該物件而已):

Span span = tracer.buildSpan("printHello").start();

Tracer中的start方法(開啟一個Span) 使用了scopeManager 來獲取上下文,從而來處理父子關係;

public JaegerSpan start() {
      // 此處從ScopeManager獲取上下文(執行緒)中,獲取到啟用的Span, 而後建立父子關係
      if (this.references.isEmpty() && !this.ignoreActiveSpan && null != JaegerTracer.this.scopeManager.activeSpan()) {
                this.asChildOf(JaegerTracer.this.scopeManager.activeSpan());
      }

      JaegerSpanContext context;
      if (!this.references.isEmpty() && ((Reference)this.references.get(0)).getSpanContext().hasTrace()) {
                context = this.createChildContext();
      } else {
                context = this.createNewContext();
      }
      ...
      return jaegerSpan;
}

7.2 Parent Span

本示例中會涉及到兩個Span:Parent Span 和 Child Span。我們首先介紹 Parent Span。

其大致策略是:

  • 呼叫 tracer.buildSpan("say-hello").start() 生成Span
    • asChildOf(scopeManager.activeSpan()); 這裡構建了Span之間的關係,即本 span在初始化時就先構建了與之前span的關係。
    • createNewContext() 或者 createChildContext()。如果是root span就隨機生成id作為traceId與spanId,如果不是root span則使用reference屬性中找到該span的parent span(根據是否為child_of的關係來判斷)獲取其traceId作為自己的traceId,獲取其spanId作為自己的parentId。
  • 呼叫 tracer.scopeManager().activate 函式就是把span放進一個新生成的 ThreadLocalScope 中,其實就是 tlsScope 成員變數中。 結果是後續可以通過tracer.scopeManager.activeSpan();獲取span資訊。
  • setTag
  • setBaggageItem
  • 最後finish

具體程式碼如下:

private void sayHello(String helloTo, String greeting) {
        Span span = tracer.buildSpan("say-hello").start();
        try (Scope scope = tracer.scopeManager().activate(span)) {
            span.setTag("hello-to", helloTo);
            span.setBaggageItem("greeting", greeting);
            String helloStr = formatString(helloTo);
            printHello(helloStr);
        } finally {
            span.finish();
        }
}

得到的執行時Span如下:

span = {JaegerSpan@1685} 
 startTimeMicroseconds = 1598707136698000
 startTimeNanoTicks = 1018098763618500
 computeDurationViaNanoTicks = true
 tags = {HashMap@1700}  size = 2
 durationMicroseconds = 0
 operationName = "say-hello"
 references = {ArrayList@1701}  size = 0
 context = {JaegerSpanContext@1666} "c8b87cc5fb01ef31:c8b87cc5fb01ef31:0:1"
  traceIdLow = -3983296680647594191
  traceIdHigh = 0
  spanId = -3983296680647594191
  parentId = 0
  flags = 1
  baggage = {Collections$EmptyMap@1704}  size = 0
  debugId = null
  objectFactory = {JaegerObjectFactory@994} 
  traceIdAsString = "c8b87cc5fb01ef31"
  spanIdAsString = "c8b87cc5fb01ef31"
 logs = null
 finished = false

7.3 Child Span

示例程式碼然後在 formatString 中會:

  • 生成一個子 Span
  • 加入了Tag
  • 呼叫Inject方法傳入Context資訊。
  • 並且會呼叫http請求。

具體程式碼如下:

private String getHttp(int port, String path, String param, String value) {
		HttpUrl url = new HttpUrl.Builder().scheme("http").host("localhost").port(port).addPathSegment(path)
                    .addQueryParameter(param, value).build();
		Request.Builder requestBuilder = new Request.Builder().url(url);
  
  	Span activeSpan = tracer.activeSpan();

    Tags.SPAN_KIND.set(activeSpan, Tags.SPAN_KIND_CLIENT);
    Tags.HTTP_METHOD.set(activeSpan, "GET");
    Tags.HTTP_URL.set(activeSpan, url.toString());

    tracer.inject(activeSpan.context(), Format.Builtin.HTTP_HEADERS, 
                  Tracing.requestBuilderCarrier(requestBuilder));

    Request request = requestBuilder.build();
    Response response = client.newCall(request).execute();
}  

7.4 Inject

上文中的 tracer.inject 函式,是用來把 SpanContext 的資訊序列化到 Request.Builder 之中。這樣後續操作就可以把序列化之後的資訊轉換到 Header之中。

tracer.inject(activeSpan.context(), Format.Builtin.HTTP_HEADERS, 
              Tracing.requestBuilderCarrier(requestBuilder));

具體序列化程式碼如下:

public void inject(JaegerSpanContext spanContext, TextMap carrier) {
  carrier.put(contextKey, encodedValue(contextAsString(spanContext)));
  for (Map.Entry<String, String> entry : spanContext.baggageItems()) {
    carrier.put(keys.prefixedKey(entry.getKey(), baggagePrefix), encodedValue(entry.getValue()));
  }
}

7.5 Finish

當服務端返回之後,在Client端,jaeger會進行後續操作:finish,report

呼叫span.finish()方法標誌著span的結束。finish方法應該是對應span例項的最後一個呼叫的方法。在span中finish方法還只是校驗和記錄的作用,真正傳送span的就是開頭提到的tracer,tracer包含了sampler、report等全域性的功能,因此在finish中呼叫了tracer.report(span)方法。而tracer中的report方法是使用其成員report的report方法,上面講過預設實現是RemoteReporter,它預設使用的是UdpSender

span.finish會觸發span上報。呼叫了 JaegerSpan.finishWithDuration。其中會判斷本次Trace是否取樣。如果是取樣了,就會上報。

  @Override
  public void finish(long finishMicros) {
    finishWithDuration(finishMicros - startTimeMicroseconds);
  }

  private void finishWithDuration(long durationMicros) {
    synchronized (this) {
      if (finished) {
        log.warn("Span has already been finished; will not be reported again.");
        return;
      }
      finished = true;

      this.durationMicroseconds = durationMicros;
    }

    if (context.isSampled()) {
      tracer.reportSpan(this);
    }
  }

7.6 Reporter

上報是在 RemoteReporter 中。

RemoteReporter中有一個BlockingQueue佇列其作用是接收Command介面的實現類,其長度可在構造方法中傳入。在RemoteReporter的建構函式中開啟了兩個守護執行緒。一個執行緒定時往BlockingQueue佇列中新增flush命令,另外一個執行緒不停的從BlockingQueue佇列中take資料,然後執行Command.excute()方法。而report(span)方法就是往BlockingQueue佇列中新增AppendCommand類。

  @Override
  public void report(JaegerSpan span) {
    // Its better to drop spans, than to block here
    boolean added = commandQueue.offer(new AppendCommand(span));

    if (!added) {
      metrics.reporterDropped.inc(1);
    }
  }

可以看到如果返回的added變數為false,也就是佇列滿了無法再加入資料,就會拋棄該span的,最終該span的資訊不會傳送到agent中。因此佇列的長度也是有一定的影響。

AppendCommand類的excute()方法為:

class AppendCommand implements Command {
    private final Span span;

    public AppendCommand(Span span) {
      this.span = span;
    }

    @Override
    public void execute() throws SenderException {
      sender.append(span);
    }
  }

所以,我們看到,execute()方法並不是真正的傳送span了,而只是把span新增到sender中去,由sender實現span的傳送,reporter類只負責傳送重新整理與傳送的命令。

如果我們繼續深入下去,會發現UdpSender是抽象類ThriftSender的實現類,sender.append(span)方法呼叫的是ThriftSenderappend(Span)方法,而該方法又會呼叫ThriftSenderflush()方法,最後這個flush()方法會呼叫抽象類ThriftSender的抽象方法send(Process process, List spans)

Jaeger中其他Reporter如下 :

  • CompositeReporter顧名思義就是將各個reporter組合起來,內部有一個list,它所實現的介面的 report(Span span)方法也只是把list中的所有reporter依次呼叫report(Span span)方法而已。
  • InMemoryReporter類是將Span存到記憶體中,該類含有一個list用於儲存span,該類中的report方法即為將span通過add方法新增到list中,通過getSpans()方法獲取到list,同時有clear()方法清除list資料。
  • LoggingReporter類作用是將span作為日誌內容列印出來,其report方法即為log.info()列印span的內容。
  • NoopReporter是一個實現了Reporter介面但是實現方法為空的一個類,表示使用該類report span將毫無影響。

0x08 服務端接受

8.1 手動埋點

服務端也是手動埋點。

public class FormatterResource {
    @GET
    public String format(@QueryParam("helloTo") String helloTo, @Context HttpHeaders httpHeaders) {
        Span span = Tracing.startServerSpan(tracer, httpHeaders, "format");
        try (Scope scope = tracer.scopeManager().activate(span)) {
            String greeting = span.getBaggageItem("greeting");
            if (greeting == null) {
                greeting = "Hello";
            }
            String helloStr = String.format("%s, %s!", greeting, helloTo);
            span.log(ImmutableMap.of("event", "string-format", "value", helloStr));
            return helloStr;
        } finally {
            span.finish();
        }
    }
}

8.2 業務邏輯

業務邏輯在 startServerSpan 之中:

  • 呼叫Extract方法解析Context資訊。
  • 根據是否有Parent Context 來進行Span構建,其中會用到SpanContext。

具體程式碼如下:

public static Span startServerSpan(Tracer tracer, javax.ws.rs.core.HttpHeaders httpHeaders, String operationName) {
        // format the headers for extraction
        MultivaluedMap<String, String> rawHeaders = httpHeaders.getRequestHeaders();
        final HashMap<String, String> headers = new HashMap<String, String>();
        for (String key : rawHeaders.keySet()) {
            headers.put(key, rawHeaders.get(key).get(0));
        }

        Tracer.SpanBuilder spanBuilder;
        try {
            SpanContext parentSpanCtx = tracer.extract(Format.Builtin.HTTP_HEADERS, new TextMapAdapter(headers));
            if (parentSpanCtx == null) {
                spanBuilder = tracer.buildSpan(operationName);
            } else {
                spanBuilder = tracer.buildSpan(operationName).asChildOf(parentSpanCtx);
            }
        } catch (IllegalArgumentException e) {
            spanBuilder = tracer.buildSpan(operationName);
        }
        // TODO could add more tags like http.url
        return spanBuilder.withTag(Tags.SPAN_KIND.getKey(), Tags.SPAN_KIND_SERVER).start();
}

8.3 解析Context

解析程式碼如下:

public JaegerSpanContext extract(TextMap carrier) {
  JaegerSpanContext context = null;
  Map<String, String> baggage = null;
  String debugId = null;
  for (Map.Entry<String, String> entry : carrier) {
    // TODO there should be no lower-case here
    String key = entry.getKey().toLowerCase(Locale.ROOT);
    if (key.equals(contextKey)) {
      context = contextFromString(decodedValue(entry.getValue()));
    } else if (key.equals(Constants.DEBUG_ID_HEADER_KEY)) {
      debugId = decodedValue(entry.getValue());
    } else if (key.startsWith(baggagePrefix)) {
      if (baggage == null) {
        baggage = new HashMap<String, String>();
      }
      baggage.put(keys.unprefixedKey(key, baggagePrefix), decodedValue(entry.getValue()));
    } else if (key.equals(Constants.BAGGAGE_HEADER_KEY)) {
      baggage = parseBaggageHeader(decodedValue(entry.getValue()), baggage);
    }
  }
  if (debugId == null && baggage == null) {
    return context;
  }
  return objectFactory.createSpanContext(
    context == null ? 0L : context.getTraceIdHigh(),
    context == null ? 0L : context.getTraceIdLow(),
    context == null ? 0L : context.getSpanId(),
    context == null ? 0L : context.getParentId(),
    context == null ? (byte)0 : context.getFlags(),
    baggage,
    debugId);
}

0x09 問題解答

  • Jaeger 和 SOFATracer 對比如何?

    • Jaeger對OpenTracing支援的更完備,版本更高。
  • spanId是怎麼生成的,有什麼規則?

  • traceId是怎麼生成的,有什麼規則?

    • 最終都是呼叫到 ThreadLocalRandom # current # nextLong 完成,舉例如下:

    •  traceIdLow = -4423486945480775652
       traceIdHigh = 0
       spanId = 3900526584756421192
       parentId = -4423486945480775652
      
  • 客戶端哪裡生成的Span?

    • 本示例程式碼是手動呼叫 tracer.buildSpan("say-hello").start() 生成Span。
  • ParentSpan 從哪兒來?

    • 在 客戶端傳送階段,先從 scopeManager.activeSpan 獲取當前活動span。如果不為空,則需要給新span設定父親Span。

      • if (references.isEmpty() && !ignoreActiveSpan && null != scopeManager.activeSpan()) {
          asChildOf(scopeManager.activeSpan());
        }
        
  • ChildSpan由ParentSpan建立,那麼什麼時候建立?

    • 在OpenTracing-Java實現中, 是在 tracer.start() 方法中處理的;start() 方法中通過 scopeManager 判斷是存在active span ,若存在則生成CHILD_OF關係的上下文, 如果不存在則createNewContext;
  • Trace資訊怎麼傳遞?

    • 把 SpanContext 的資訊序列化到 Request.Builder 之中。後續操作把序列化之後的資訊轉換到 Header之中,然後就可以傳遞。
  • 伺服器接收到請求之後做什麼?

    • 呼叫Extract方法解析Context資訊。
    • 根據是否有Parent Context 來進行Span構建,其中會用到SpanContext。
    • 進行具體其他業務。
  • SpanContext在伺服器端怎麼處理?見上問題回答。

  • 鏈路資訊如何蒐集?

    • 取樣是對於整條鏈路來說的,也就是說從 RootSpan 被建立開始,就已經決定了當前鏈路資料是否會被記錄了。
    • 如果已經確定本次Trace被取樣,就會傳送報告。

0xFF 參考

分散式追蹤系統 -- Opentracing

開放分散式追蹤(OpenTracing)入門與 Jaeger 實現

OpenTracing 語義說明

分散式追蹤系統概述及主流開源系統對比

Skywalking分散式追蹤與監控:起始篇

分散式全鏈路監控 -- opentracing小試

opentracing實戰

Go微服務全鏈路跟蹤詳解

OpenTracing Java Library教程(3)——跨服務傳遞SpanContext

OpenTracing Java Library教程(1)——trace和span入門

螞蟻金服分散式鏈路跟蹤元件 SOFATracer 總覽|剖析

螞蟻金服開源分散式鏈路跟蹤元件 SOFATracer 鏈路透傳原理與SLF4J MDC 的擴充套件能力剖析

螞蟻金服開源分散式鏈路跟蹤元件 SOFATracer 取樣策略和原始碼剖析

https://github.com/sofastack-guides/sofa-tracer-guides

The OpenTracing Semantic Specification

OpenTracing Java Library教程(2)——程式間傳遞SpanContext

OpenTracing Java Library教程(4)——Baggage介紹

https://github.com/yurishkuro/opentracing-tutorial

微服務系統架構之分散式traceId追蹤參考實現

監控之traceid

jaeger程式碼閱讀思路整理

分散式系統中如何優雅地追蹤日誌(原理篇)traceid

sky-walking的traceId生成

分散式鏈路追蹤系列番外篇一(jaeger非同步批量傳送span)

分散式鏈路追蹤系列番外篇二(Spark Job優化記)

Jaeger服務端埋點分析

通過Jaeger上報Java應用資料

OpenTracing(Jaeger) 遭遇多執行緒

OpenTracing-Java Scope與ScopeManager

OpenTracing-Java實現的靈魂十問

OpenTracing實現思路(附OpenTracing-Jaeger-Java例項)

OpenTracing API 自動埋點調研

Jaeger服務端埋點分析

OpenTracing(Jaeger) 遭遇多執行緒

jaegeropentracing的Java-client完整分散式追蹤鏈

基於opentracing + jaeger 實現全鏈路追蹤

jaeger程式碼閱讀思路整理

相關文章