從基本概念到如何部署 demo 實戰了解 OpenTelemetry,從那個 demo 中也可以得知整個 OpenTelemetry 體系的復雜性,包含了太多的組件和概念。
為了能更清晰的了解每個關鍵組件的作用以及原理,我打算分為幾期來講解 OpenTelemetry 的三個核心組件:
首先以 Trace 講起。
開始之前還是先復習一下 Trace 的歷史背景。
如今現代的分布式追蹤的起源源自于 Google 在 2010 年發布的一篇論文:
在這篇論文中提出了分布式追蹤的幾個核心概念:
之后 Twitter 受到了 Dapper 的啟發開源了現在我們熟知的 Zipkin,包含了存儲和可視化 UI 展示我們的追蹤鏈路。
Uber 也在 2015 年開源了 Jaeger 項目,它的功能和 Zipkin 類似,但目前我們用的較多的還是 Jaeger;現在已經成為 CNCF 的托管項目。
之后陸續出現過 OpenTracing 和 OpenCensus 項目,他們都企圖統一分布式追蹤這一領域。
直到 OpenTelemetry 的出現整合了以上兩個項目,并且逐漸成為可觀測領域的標準。
更多歷史背景可以參考之前的文章:OpenTelemetry 實踐指南:歷史、架構與基本概念
這里我們結合 Dapper 論文中的資料進行分析,在這個調用中用戶發起了一次請求,內部系統經歷了 4 次 RPC 調用。
從第二張圖會看到一些關鍵信息:
parentId 很好理解,主要是定義調用的主次關系;要注意的是并行調用時 parentId 是同一個。
spanId 在可以理解為每一個獨立的操作,在這里就是一次 RPC 調用;同理一次數據庫操作、消息的收發都是一個 span。
span 的更多內容在后文繼續講解。
當我們把某一個具體的 span 放大會看到更加詳細的信息,其中最關鍵的如下:
由于一個完整的 trace 鏈路由 N 個 span 組成,所以這個鏈路必須得有一個唯一的 traceId 將這些 span 串聯起來。這樣才可以在可視化的時候更好的展示鏈路信息。
以上的這些字段很容易理解,都是一些必須的信息。
在 Dapper 論文中使用 Annotations 來存放 span 的屬性,也就是剛才那些字段,當然也可以自定義存放一些數據,比如圖中的 "foo"
。
OpenTelemetry 的 trace 自然也是基于 Dapper 的,只是額外做了一些優化,比如在剛才那些字段的基礎上新增了一些概念:
{ "name": "/v1/sys/health", "context": { "trace_id": "7bba9f33312b3dbb8b2c2c62bb7abe2d", "span_id": "086e83747d0e381e" }, "parent_id": "", "start_time": "2021-10-22 16:04:01.209458162 +0000 UTC", "end_time": "2021-10-22 16:04:01.209514132 +0000 UTC", "status_code": "STATUS_CODE_OK", "status_message": "", "attributes": { "net.transport": "IP.TCP", "net.peer.ip": "172.17.0.1", "net.peer.port": "51820", "net.host.ip": "10.177.2.152", "net.host.port": "26040", "http.method": "GET", "http.target": "/v1/sys/health", "http.server_name": "mortar-gateway", "http.route": "/v1/sys/health", "http.user_agent": "Consul Health Check", "http.scheme": "http", "http.host": "10.177.2.152:26040", "http.flavor": "1.1" }, "events": [ { "name": "", "message": "OK", "timestamp": "2021-10-22 16:04:01.209512872 +0000 UTC" } ]}
以這個 JSON 為例,新增了:
比如我們常用的 Redis 客戶端 lettuce,它就會自己記錄一些 Attributes。
如果有多個 span 存在依賴關系:
[Span A] ←←←(the root span) | +------+------+ | | [Span B] [Span C] ←←←(Span C is a `child` of Span A) | | [Span D] +---+-------+ | | [Span E] [Span F]
大部分的可視化工具都是以時間線的方式進行展示:
––|–––––––|–––––––|–––––––|–––––––|–––––––|–––––––|–––––––|–> time [Span A···················································] [Span B··········································] [Span D······································] [Span C····················································] [Span E·······] [Span F··]
這些和 Dapper 中描述的概念沒有本質區別。
Span 還內置了一些 Status:
默認情況下是 Unset,出現錯誤時則是 Error,一切正常時則是 Ok。
通過可視化頁面很容易得知某個 trace 中 span 的異常情況,點進去后可以看到具體的異常 span 以及它的錯誤日志。
最后是 Span 的類型:
Client 和 Server 非常好理解,比如我們有一個 gRPC 接口,調用方的 Span 是 client,而服務端的 Span 自然就是 Server。
Internal 則是內部組件調用產生的 Span,這類 Span 相對會少一些。
Producer 和 Consumer 一般指的是發起異步調用時的 Span,我們常見的就是往消息隊列里生產和消費消息。
通過這幾種類型的 Span 也可以了解到什么情況下會創建 Span,通常是以下幾種場景:
通常在一個函數內部再調用其他的本地函數是不用創建 span 的,不然這個鏈路會非常的長。
當然也有一些特殊情況,比如我的某個內部函數非常重要,需要單獨關心它的調用時長。
此時我們就可以使用 Annotations 來單獨創建自己的 Span。
這個 Annotations 和 Dapper 中的不是同一個,只是 Java 中的注解。
@Override public void sayHello(HelloRequest request, StreamObserver<HelloReply> responseObserver) { Executors.newFixedThreadPool(1).execute(() -> { myMethod(request.getName()); }); HelloReply reply = HelloReply.newBuilder() .setMessage("Hello ==> " + request.getName()) .build(); responseObserver.onNext(reply); responseObserver.onCompleted(); } @SneakyThrows @WithSpan public void myMethod(@SpanAttribute("request.name") String name) { TimeUnit.SECONDS.sleep(1); log.info("myMethod:{}", name); }
以這段代碼為例,這是一個 gRPC 的服務端接口,在這個接口中調用了一個函數 myMethod,默認情況下并不會為它單獨創建一個 Span。
但如果我們想單獨記錄它,就可以使用 @WithSpan 這個注解,同時也可以使用 @SpanAttribute 來自定義 attribute。
最終的效果如下:
此時就會單獨為這個函數創建一個 Span。
需要單獨引入一個依賴:
<dependencies> <dependency> <groupId>io.opentelemetry.instrumentation</groupId> <artifactId>opentelemetry-instrumentation-annotations</artifactId> <version>2.3.0</version> </dependency></dependencies>
上下文傳播也是 Trace 中非常重要的概念,剛才提到了每個 Span 都有自己不可變的上下文,那么后續的 Span 如何和上游的 Span 進行關聯呢?
這里有兩種情況:
同一個進程也分為兩種情況:
單線程的比較好處理,我們只需要把數據寫入 ThreadLocal 中就可以做到線程隔離。
private static final ThreadLocal<Context> THREAD_LOCAL_STORAGE = new ThreadLocal<>();@Override @Nullable public Context current() { return THREAD_LOCAL_STORAGE.get(); }
這點我們可以通過源碼 io.opentelemetry.context.ThreadLocalContextStorage看到具體的實現過程。
而如果是多線程時:
Executors.newFixedThreadPool(1).execute(() -> { myMethod(request.getName()); });
則需要對使用的線程池進行單獨處理,將父線程中 threadlocal 中的數據拷貝出來進行傳遞,比如有阿里提供的 TransmittableThreadLocal,可以提供對線程池的支持。
而如果是垮進程的場景,就需要將 context 的信息進行序列化傳遞。
如果是 gRPC 調用會將信息存放到 metadata 中。
HTTP 調用則是存放在 header 中。
消息隊列,比如 Pulsar 也可以將數據存放在消息中的 header 中進行傳遞。
數據一旦跨進程傳輸成功后,就和單進程一樣的處理方式了。
有時候我們需要通過垮 Span 傳遞信息,比如如上圖所示:我們需要在 serverB 中拿到 serverA 中收到的一個請求參數:http://127.0.0.1:8181/request/?name/=1232。
這個數據默認會作為 span 的 attribute ,但只會存在于第一個 span。
如果我們想要在后續的 span 中也能拿到這個數據,甚至是垮進程也能獲取到。
那就需要使用 Baggage 這個對象了。
它的使用也很簡單:
@RequestMapping("/request") public String request(@RequestParam String name) { // 寫入 Baggage.current().toBuilder(). put("request.name", name).build() .storeInContext(Context.current()).makeCurrent();} // 獲取String value = Baggage.current().getEntryValue("request.name"); log.info("request.name: {}", value);
只要是屬于同一個 trace 的調用就可以直接獲取到數據。
traceId 也是垮 Span 傳遞的。
而它的原理也是通過往 context 中寫入數據實現的:
@Immutable class BaggageContextKey { static final ContextKey<Baggage> KEY = ContextKey.named("opentelemetry-baggage-key"); private BaggageContextKey() {} }
而這個 context 是通過一個 entries 數據存儲數據的,不管是在內部還是外部的跨進程調用,OpenTelemetry 都會將 context 通過 Context Propagation 傳遞出去。
Trace 這部分的內容我覺得比 Metrics 和 Logs 更加復雜一些,畢竟多了一些數據結構;現在的內容也只是冰山一角,現在也在做 trace 的一些定制化開發,后續有新的進展會接著更新。
參考鏈接:
本文鏈接:http://www.tebozhan.com/showinfo-26-92748-0.html從 Dapper 到 OpenTelemetry:分布式追蹤的演進之旅
聲明:本網頁內容旨在傳播知識,若有侵權等問題請及時與本網聯系,我們將在第一時間刪除處理。郵件:2376512515@qq.com