從 Dapper 到 OpenTelemetry:分布式追蹤的演進(jìn)之旅
從基本概念到如何部署 demo 實(shí)戰(zhàn)了解 OpenTelemetry,從那個(gè) demo 中也可以得知整個(gè) OpenTelemetry 體系的復(fù)雜性,包含了太多的組件和概念。
為了能更清晰的了解每個(gè)關(guān)鍵組件的作用以及原理,我打算分為幾期來講解 OpenTelemetry 的三個(gè)核心組件:
- Trace
- Metrics
- Logs
首先以 Trace 講起。
Trace
開始之前還是先復(fù)習(xí)一下 Trace 的歷史背景。
如今現(xiàn)代的分布式追蹤的起源源自于 Google 在 2010 年發(fā)布的一篇論文:
- Dapper, a Large-Scale Distributed Systems Tracing Infrastructure
在這篇論文中提出了分布式追蹤的幾個(gè)核心概念:
- Trace
- Span
- Span 的一些基礎(chǔ)數(shù)據(jù)結(jié)構(gòu)
- 可視化追蹤以及展示
之后 Twitter 受到了 Dapper 的啟發(fā)開源了現(xiàn)在我們熟知的 Zipkin,包含了存儲(chǔ)和可視化 UI 展示我們的追蹤鏈路。
Uber 也在 2015 年開源了 Jaeger 項(xiàng)目,它的功能和 Zipkin 類似,但目前我們用的較多的還是 Jaeger;現(xiàn)在已經(jīng)成為 CNCF 的托管項(xiàng)目。
之后陸續(xù)出現(xiàn)過 OpenTracing 和 OpenCensus 項(xiàng)目,他們都企圖統(tǒng)一分布式追蹤這一領(lǐng)域。
直到 OpenTelemetry 的出現(xiàn)整合了以上兩個(gè)項(xiàng)目,并且逐漸成為可觀測(cè)領(lǐng)域的標(biāo)準(zhǔn)。
更多歷史背景可以參考之前的文章:OpenTelemetry 實(shí)踐指南:歷史、架構(gòu)與基本概念
這里我們結(jié)合 Dapper 論文中的資料進(jìn)行分析,在這個(gè)調(diào)用中用戶發(fā)起了一次請(qǐng)求,內(nèi)部系統(tǒng)經(jīng)歷了 4 次 RPC 調(diào)用。
從第二張圖會(huì)看到一些關(guān)鍵信息:
- spanName
- parentId
- spanId
parentId 很好理解,主要是定義調(diào)用的主次關(guān)系;要注意的是并行調(diào)用時(shí) parentId 是同一個(gè)。
spanId 在可以理解為每一個(gè)獨(dú)立的操作,在這里就是一次 RPC 調(diào)用;同理一次數(shù)據(jù)庫操作、消息的收發(fā)都是一個(gè) span。
span 的更多內(nèi)容在后文繼續(xù)講解。
Span
當(dāng)我們把某一個(gè)具體的 span 放大會(huì)看到更加詳細(xì)的信息,其中最關(guān)鍵的如下:
- traceId
- spanName
- spanId
- parentId
- 開始時(shí)間
- 結(jié)束時(shí)間
由于一個(gè)完整的 trace 鏈路由 N 個(gè) span 組成,所以這個(gè)鏈路必須得有一個(gè)唯一的 traceId 將這些 span 串聯(lián)起來。這樣才可以在可視化的時(shí)候更好的展示鏈路信息。
以上的這些字段很容易理解,都是一些必須的信息。
在 Dapper 論文中使用 Annotations 來存放 span 的屬性,也就是剛才那些字段,當(dāng)然也可以自定義存放一些數(shù)據(jù),比如圖中的 "foo"
。
OpenTelemetry 中的 Span
OpenTelemetry 的 trace 自然也是基于 Dapper 的,只是額外做了一些優(yōu)化,比如在剛才那些字段的基礎(chǔ)上新增了一些概念:
{
"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"
}
]
}
以這個(gè) JSON 為例,新增了:
- [ ] Span Context
- Span 的上下文,存放的都是不可變的數(shù)據(jù),因?yàn)槊總€(gè) Span 之間是存在關(guān)聯(lián)關(guān)系的,這些關(guān)聯(lián)關(guān)系都是存放在 context 中,主要就是 trace_id, span_id.
- Attributes: 可以理解為 Dapper 中的 Annotations,存放的是我們自定義的鍵值對(duì),通常是由我們常用第三方開源 Instrumentation 內(nèi)置的一些屬性。
- Span Events: Span 的一些關(guān)鍵事件。
比如我們常用的 Redis 客戶端 lettuce,它就會(huì)自己記錄一些 Attributes。
如果有多個(gè) span 存在依賴關(guān)系:
[Span A] ←←←(the root span)
|
+------+------+
| |
[Span B] [Span C] ←←←(Span C is a `child` of Span A)
| |
[Span D] +---+-------+
| |
[Span E] [Span F]
大部分的可視化工具都是以時(shí)間線的方式進(jìn)行展示:
––|–––––––|–––––––|–––––––|–––––––|–––––––|–––––––|–––––––|–> time
[Span A···················································]
[Span B··········································]
[Span D······································]
[Span C····················································]
[Span E·······] [Span F··]
這些和 Dapper 中描述的概念沒有本質(zhì)區(qū)別。
Span Status
Span 還內(nèi)置了一些 Status:
- Unset
- Error
- Ok
默認(rèn)情況下是 Unset,出現(xiàn)錯(cuò)誤時(shí)則是 Error,一切正常時(shí)則是 Ok。
通過可視化頁面很容易得知某個(gè) trace 中 span 的異常情況,點(diǎn)進(jìn)去后可以看到具體的異常 span 以及它的錯(cuò)誤日志。
Span Kind
最后是 Span 的類型:
- Client
- Server
- Internal
- Producer
- Consumer
Client 和 Server 非常好理解,比如我們有一個(gè) gRPC 接口,調(diào)用方的 Span 是 client,而服務(wù)端的 Span 自然就是 Server。
Internal 則是內(nèi)部組件調(diào)用產(chǎn)生的 Span,這類 Span 相對(duì)會(huì)少一些。
Producer 和 Consumer 一般指的是發(fā)起異步調(diào)用時(shí)的 Span,我們常見的就是往消息隊(duì)列里生產(chǎn)和消費(fèi)消息。
通過這幾種類型的 Span 也可以了解到什么情況下會(huì)創(chuàng)建 Span,通常是以下幾種場(chǎng)景:
- RPC 調(diào)用
- 數(shù)據(jù)庫(Redis、MySQL、Mongo 等等)操作
- 生產(chǎn)和消費(fèi)消息
- 有意義的內(nèi)部調(diào)用
通常在一個(gè)函數(shù)內(nèi)部再調(diào)用其他的本地函數(shù)是不用創(chuàng)建 span 的,不然這個(gè)鏈路會(huì)非常的長。
Annotations
當(dāng)然也有一些特殊情況,比如我的某個(gè)內(nèi)部函數(shù)非常重要,需要單獨(dú)關(guān)心它的調(diào)用時(shí)長。
此時(shí)我們就可以使用 Annotations 來單獨(dú)創(chuàng)建自己的 Span。
這個(gè) Annotations 和 Dapper 中的不是同一個(gè),只是 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);
}
以這段代碼為例,這是一個(gè) gRPC 的服務(wù)端接口,在這個(gè)接口中調(diào)用了一個(gè)函數(shù) myMethod,默認(rèn)情況下并不會(huì)為它單獨(dú)創(chuàng)建一個(gè) Span。
但如果我們想單獨(dú)記錄它,就可以使用 @WithSpan 這個(gè)注解,同時(shí)也可以使用 @SpanAttribute 來自定義 attribute。
最終的效果如下:
此時(shí)就會(huì)單獨(dú)為這個(gè)函數(shù)創(chuàng)建一個(gè) Span。
需要單獨(dú)引入一個(gè)依賴:
<dependencies>
<dependency>
<groupId>io.opentelemetry.instrumentation</groupId>
<artifactId>opentelemetry-instrumentation-annotations</artifactId>
<version>2.3.0</version>
</dependency>
</dependencies>
Context Propagation
上下文傳播也是 Trace 中非常重要的概念,剛才提到了每個(gè) Span 都有自己不可變的上下文,那么后續(xù)的 Span 如何和上游的 Span 進(jìn)行關(guān)聯(lián)呢?
這里有兩種情況:
- 同一進(jìn)程
- 垮進(jìn)程
同一進(jìn)程
同一個(gè)進(jìn)程也分為兩種情況:
- 單線程
- 多線程
單線程的比較好處理,我們只需要把數(shù)據(jù)寫入 ThreadLocal 中就可以做到線程隔離。
private static final ThreadLocal<Context> THREAD_LOCAL_STORAGE = new ThreadLocal<>();
@Override
@Nullable
public Context current() {
return THREAD_LOCAL_STORAGE.get();
}
這點(diǎn)我們可以通過源碼 io.opentelemetry.context.ThreadLocalContextStorage看到具體的實(shí)現(xiàn)過程。
而如果是多線程時(shí):
Executors.newFixedThreadPool(1).execute(() -> {
myMethod(request.getName());
});
則需要對(duì)使用的線程池進(jìn)行單獨(dú)處理,將父線程中 threadlocal 中的數(shù)據(jù)拷貝出來進(jìn)行傳遞,比如有阿里提供的 TransmittableThreadLocal,可以提供對(duì)線程池的支持。
跨進(jìn)程
而如果是垮進(jìn)程的場(chǎng)景,就需要將 context 的信息進(jìn)行序列化傳遞。
如果是 gRPC 調(diào)用會(huì)將信息存放到 metadata 中。
HTTP 調(diào)用則是存放在 header 中。
消息隊(duì)列,比如 Pulsar 也可以將數(shù)據(jù)存放在消息中的 header 中進(jìn)行傳遞。
數(shù)據(jù)一旦跨進(jìn)程傳輸成功后,就和單進(jìn)程一樣的處理方式了。
Baggage
有時(shí)候我們需要通過垮 Span 傳遞信息,比如如上圖所示:我們需要在 serverB 中拿到 serverA 中收到的一個(gè)請(qǐng)求參數(shù):http://127.0.0.1:8181/request\?name\=1232。
這個(gè)數(shù)據(jù)默認(rèn)會(huì)作為 span 的 attribute ,但只會(huì)存在于第一個(gè) span。
如果我們想要在后續(xù)的 span 中也能拿到這個(gè)數(shù)據(jù),甚至是垮進(jìn)程也能獲取到。
那就需要使用 Baggage 這個(gè)對(duì)象了。
它的使用也很簡單:
@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);
只要是屬于同一個(gè) trace 的調(diào)用就可以直接獲取到數(shù)據(jù)。
traceId 也是垮 Span 傳遞的。
而它的原理也是通過往 context 中寫入數(shù)據(jù)實(shí)現(xiàn)的:
@Immutable
class BaggageContextKey {
static final ContextKey<Baggage> KEY = ContextKey.named("opentelemetry-baggage-key");
private BaggageContextKey() {}
}
而這個(gè) context 是通過一個(gè) entries 數(shù)據(jù)存儲(chǔ)數(shù)據(jù)的,不管是在內(nèi)部還是外部的跨進(jìn)程調(diào)用,OpenTelemetry 都會(huì)將 context 通過 Context Propagation 傳遞出去。
總結(jié)
Trace 這部分的內(nèi)容我覺得比 Metrics 和 Logs 更加復(fù)雜一些,畢竟多了一些數(shù)據(jù)結(jié)構(gòu);現(xiàn)在的內(nèi)容也只是冰山一角,現(xiàn)在也在做 trace 的一些定制化開發(fā),后續(xù)有新的進(jìn)展會(huì)接著更新。
參考鏈接:
- https://static.googleusercontent.com/media/research.google.com/zh-CN//archive/papers/dapper-2010-1.pdf。
- https://opentelemetry.io/docs/languages/java/automatic/annotations/。
- https://opentelemetry.io/docs/specs/otel/overview/#tracing-signal。
- https://opentelemetry.io/docs/concepts/context-propagation/。
- https://opentelemetry.io/docs/concepts/observability-primer/#distributed-traces。
- https://tech.meituan.com/2023/04/20/traceid-google-dapper-mtrace.html。