OpenTelemetry 實戰:從零實現分布式鏈路追蹤
背景
之前寫過一篇 從 Dapper 到 OpenTelemetry:分布式追蹤的演進之旅的文章,主要是從概念上講解了 Trace 在 OpenTelemetry 的中的場景和使用。
也寫過一篇 實操 OpenTelemetry:通過 Demo 掌握微服務監控的藝術:如何從一個 demo 開始集成 OpenTelemetry。
但還是有不少小伙伴反饋說無法快速上手(可能也是這個 demo 的項目比較多),于是我準備從 0 開始從真實的代碼一步步帶大家集成 OpenTelemetry,因為 OpenTelemetry 本身是跨多種語言的,所以也會以兩種語言為(Java、Golang)主進行講解。
使用這兩種語言主要是因為 Java 幾乎全是自動埋點,而 Golang 因為語言特性,大部分都得硬編碼埋點;覆蓋到這兩種場景后其他語言也是類似的,頂多只是 API 名稱有些許區別。
在這個過程中也會穿插一些 OpenTelemetry 的原理,希望整個過程下來大家可以在項目中實際運用起來,同時也能知其所以然。
項目結構
在這個過程中會涉及到以下項目:
名稱 | 作用 | 語言 | 版本 |
java-demo | 發送 gRPC 請求的客戶端 | Java | opentelemetry-agent: 2.4.0/SpringBoot: 2.7.14 |
k8s-combat | 提供 gRPC 服務的服務端 | Golang | go.opentelemetry.io/otel: 1.28/ Go: 1.22 |
Jaeger | trace 存儲的服務端以及 TraceUI 展示 | Golang | jaegertracing/all-in-one:1.56 |
opentelemetry-collector-contrib | OpenTelemetry 的 collector 服務端,用于收集 trace/metrics/logs 然后寫入到遠端存儲 | Golang | otel/opentelemetry-collector-contrib:0.98.0 |
圖片
在開始之前我們先看看實際的效果,我們需要先把 collector 和 Jaeger 部署好:
docker run --rm -d --name jaeger \
-e COLLECTOR_ZIPKIN_HOST_PORT=:9411 \
-p 6831:6831/udp \
-p 6832:6832/udp \
-p 5778:5778 \
-p 16686:16686 \
-p 4317:4317 \
-p 4318:4318 \
-p 14250:14250 \
-p 14268:14268 \
-p 14269:14269 \
-p 9411:9411 \
jaegertracing/all-in-one:1.56
docker run --rm -d -v $(pwd)/coll-config.yaml:/etc/otelcol-contrib/config.yaml --name coll \
-p 5318:4318 \
-p 5317:4317 \
otel/opentelemetry-collector-contrib:0.98.0
這里有一個 coll-config 的配置文件如下:
receivers:
otlp:
protocols:
grpc:
http:
exporters:
debug:
otlp:
endpoint: "127.0.0.1:4317"
tls:
insecure: true
processors:
batch:
service:
pipelines:
traces:
receivers: [otlp]
processors: [batch]
exporters: [otlp, debug]
重點是這里的 endpoint: "127.0.0.1:4317" 我們需要配置位 Jaeger 的 IP 和端口。
更多關于這里的配置會在后續單獨的 collector 章節中講解。
這兩個服務都啟動成功后再啟動我們的 Java 客戶端和 Go 服務端:
java -javaagent:opentelemetry-javaagent-2.4.0-SNAPSHOT.jar \
-Dotel.traces.exporter=otlp \
-Dotel.metrics.exporter=otlp \
-Dotel.logs.exporter=none \
-Dotel.service.name=demo \
-Dotel.exporter.otlp.protocol=grpc \
-Dotel.propagators=tracecontext,baggage \
-Dotel.exporter.otlp.endpoint=http://127.0.0.1:5317 \
-jar target/demo-0.0.1-SNAPSHOT.jar
# Golang
export OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:5317
export OTEL_RESOURCE_ATTRIBUTES=service.name=k8s-combat
./k8s-combat
可以看到不管是 Java 還是 Golang 應用都是需要配置 OTEL_EXPORTER_OTLP_ENDPOINT 參數,也就是 opentelemetry-collector-contrib 的地址。
其余的一些配置在后面會講到。
curl http://127.0.0.1:9191/request\?name\=1232
然后我們觸發一下 Java 客戶端的入口,就可以在 JaegerUI 中查詢到剛才的鏈路了。http://localhost:16686/search
圖片
這樣整個 trace 鏈路就串起來了。
Java 應用
下面來看看具體的應用代碼里是如何編寫的。
Java 是基于 springboot 編寫的,具體 springboot 的使用就不再贅述了。
因為我們應用是使用 gRPC 通信的,所以需要提供一個 helloworld.proto 的 pb 文件:
syntax = "proto3";
option go_package = "google.golang.org/grpc/examples/helloworld/helloworld";
option java_multiple_files = true;
option java_package = "io.grpc.examples.helloworld";
option java_outer_classname = "HelloWorldProto";
package helloworld;
// The greeting service definition.
service Greeter {
// Sends a greeting
rpc SayHello (HelloRequest) returns (HelloReply) {}
}
// The request message containing the user's name.
message HelloRequest {
string name = 1;
}
// The response message containing the greetings
message HelloReply {
string message = 1;
}
這個文件也沒啥好說的,就定義了一個簡單的 SayHello 接口。
<dependency>
<groupId>net.devh</groupId>
<artifactId>grpc-spring-boot-starter</artifactId>
<version>3.1.0.RELEASE</version>
</dependency>
<dependency>
<groupId>io.grpc</groupId>
<artifactId>grpc-stub</artifactId>
<version>${grpc.version}</version>
</dependency>
<dependency>
<groupId>io.grpc</groupId>
<artifactId>grpc-protobuf</artifactId>
<version>${grpc.version}</version>
</dependency>
在 Java 中使用了 grpc-spring-boot-starter 這個庫來處理 gRPC 的客戶端和服務端請求。
grpc:
server:
port: 9192
client:
greeter:
address: 'static://127.0.0.1:50051'
enableKeepAlive: true
keepAliveWithoutCalls: true
negotiationType: plaintext
然后我們定義了一個接口用于接收請求觸發 gRPC 的調用:
@RequestMapping("/request")
public String request(@RequestParam String name) {
log.info("request: {}", request);
HelloReply abc = greeterStub.sayHello(io.grpc.examples.helloworld.HelloRequest.newBuilder().setName(request.getName()).build());
return abc.getMessage();
}
Java 應用的實現非常簡單,和我們日常日常開發沒有任何區別;唯一的區別就是在啟動時需要加入一個 javaagent以及一些啟動參數。
java -javaagent:opentelemetry-javaagent-2.4.0-SNAPSHOT.jar \
-Dotel.traces.exporter=otlp \
-Dotel.metrics.exporter=otlp \
-Dotel.logs.exporter=none \
-Dotel.service.name=demo \
-Dotel.exporter.otlp.protocol=grpc \
-Dotel.propagators=tracecontext,baggage \
-Dotel.exporter.otlp.endpoint=http://127.0.0.1:5317 \
-jar target/demo-0.0.1-SNAPSHOT.jar
下面來仔細看看這些參數
名稱 | 作用 |
javaagent:opentelemetry-javaagent-2.4.0-SNAPSHOT.jar | 這個沒啥好說的,指定一個 javaagent |
otel.traces.exporter | 指定 trace 以什么格式傳輸(默認是這里的 |
otel.metrics.exporter | 同上,只是指定的是 metrics 的傳輸方式,我們在之后講解指標的時候會用到。 |
otel.service.name | 定義在 trace 中的應用名稱,springboot 會默認使用 |
otel.exporter.otlp.protocol | 指定傳輸協議;除了 grpc 之外還有 |
otel.propagators | 指定我們跨服務傳播上下文的時候使用哪種格式,默認是 W3C Trace Context,baggage,當然也有其他的- |
otel.exporter.otlp.endpoint | 指定 collector 的 endpoint |
更多細節的參數大家可以在這里找到: | |
Golang 應用
接著我們來看看 Go 是如何集成 OpenTelemetry 的。
在創建好項目之后我們需要添加 OpenTelemetry 所提供的包:
go get "go.opentelemetry.io/otel" \
"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc" \
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" \
"go.opentelemetry.io/otel/propagation" \
"go.opentelemetry.io/otel/sdk/metric" \
"go.opentelemetry.io/otel/sdk/resource" \
"go.opentelemetry.io/otel/sdk/trace" \ "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc"\
然后我們需要創建一個初始化 tracer 的函數:
func initTracerProvider() *sdktrace.TracerProvider {
ctx := context.Background()
exporter, err := otlptracegrpc.New(ctx)
if err != nil {
log.Printf("new otlp trace grpc exporter failed: %v", err)
}
tp := sdktrace.NewTracerProvider(
sdktrace.WithBatcher(exporter),
sdktrace.WithResource(initResource()),
)
otel.SetTracerProvider(tp)
otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(propagation.TraceContext{}, propagation.Baggage{}))
return tp
}
因為我們使用的是 grpc 協議上報 otlp 數據,所以這里使用的是 exporter, err := otlptracegrpc.New(ctx) 創建了一個 exporter。
otel.SetTextMapPropagator() 這個函數里配置數據和剛才 Java 里配置的 -Dotel.propagators=tracecontext,baggage 是一樣的效果。
與此同時我們還需要提供一個 initResource() 的函數:
func initResource() *sdkresource.Resource {
initResourcesOnce.Do(func() {
extraResources, _ := sdkresource.New(
context.Background(),
sdkresource.WithOS(),
sdkresource.WithProcess(),
sdkresource.WithContainer(),
sdkresource.WithHost(),
)
resource, _ = sdkresource.Merge(
sdkresource.Default(),
extraResources,
)
})
return resource
}
這個函數用來告訴 trace 需要暴露那些 resource,也就是我們在這里看到進程相關的屬性:
圖片
比如這里的 sdkresource.WithOS(), 就會顯示 OS 的類型和描述。
func WithOS() Option {
return WithDetectors(
osTypeDetector{},
osDescriptionDetector{},
)}
而 sdkresource.WithProcess(), 顯示的數據就更多了。
func WithProcess() Option {
return WithDetectors(
processPIDDetector{},
processExecutableNameDetector{},
processExecutablePathDetector{},
processCommandArgsDetector{},
processOwnerDetector{},
processRuntimeNameDetector{},
processRuntimeVersionDetector{},
processRuntimeDescriptionDetector{},
)}
以上這些代碼在 Java 中都是由 agent 指定創建的。
// Init OpenTelemetry start
tp := initTracerProvider()
defer func() {
if err := tp.Shutdown(context.Background()); err != nil {
log.Printf("Error shutting down tracer provider: %v", err)
}}()
err := runtime.Start(runtime.WithMinimumReadMemStatsInterval(time.Second))
if err != nil {
log.Err(err)
}
tracer = tp.Tracer("k8s-combat")
// Init OpenTelemetry end
之后我們需要在 main 函數一開始就初始化 traceProvider。
對于 grpc 來說,OpenTelemetry 的 Go-SDK 提供了自動埋點,但我們也得手動配置一下:
s := grpc.NewServer(
grpc.StatsHandler(otelgrpc.NewServerHandler()),
)
pb.RegisterGreeterServer(s, &server{})
使用 grpc.StatsHandler(otelgrpc.NewServerHandler()), 將 OTel 的 serverHandle 加入進去,這個 handle 會自動創建 grpc 服務端的 span。
對 trace/span 概念還有不了解的朋友可以查看這篇文章。
var port = ":50051"
lis, err := net.Listen("tcp", port)
if err != nil {
log.Fatal().Msgf("failed to listen: %v", err)
}
s := grpc.NewServer(
grpc.StatsHandler(otelgrpc.NewServerHandler()),
)
pb.RegisterGreeterServer(s, &server{})
if err := s.Serve(lis); err != nil {
log.Fatal().Msgf("failed to serve: %v", err)
} else {
log.Printf("served on %s \n", port)
}
接著我們只需要啟動這個 grpc 服務即可,就算完成了 Go 服務的集成。
從這里可以看出 Java 相對于 Go 來說會簡單許多,只需要配置一個 agent 就可以不該一行代碼支持目前市面上流行的絕大多數框架。
圖片
自定義 span 的 attribute
我們在看鏈路信息的時候其實看的最多的是某個 span 里的 attribute 數據(有些地方又稱為 tag) 如下圖所示:
圖片
這里會展示當前 span 的各種信息,但如果我們想要額外加一些自己關心的數據應該如何添加呢?
message HelloRequest {
string name = 1;
}
比如我們想知道這個 grpc 接口里的 name 參數,如上圖所示那樣展示在 span 中。
好在 OpenTelemetry 已經考慮到類似的需求:
span := trace.SpanFromContext(ctx)
span.SetAttributes(attribute.String("request.name", in.Name))
我們使用 span := trace.SpanFromContext(ctx) 獲取到當前的 span,然后調用 SetAttributes 就可以添加自定義的數據了。
對應的 Java 也有類似的函數。
除了新增 attribute 之外還可以新增 Event,Link 等數據,使用方式也是類似的。
// AddEvent adds an event with the provided name and options.
AddEvent(name string, options ...EventOption)
// AddLink adds a link.
// Adding links at span creation using WithLinks is preferred to calling AddLink
// later, for contexts that are available during span creation, because head
// sampling decisions can only consider information present during span creation.
AddLink(link Link)
自定義新增 span
同理我們可能不局限于為某個 span 新增 attribute,也有可能想要新增一個新的 span 來記錄關鍵的調用信息。
默認情況下只有 OpenTelemetry 實現過的組件的核心函數才會有 span,自己代碼里的函數調用是不會創建span 的。
func (s *server) span(ctx context.Context) {
ctx, span := tracer.Start(ctx, "hello-span")
defer span.End()
// do some work
log.Printf("create span")
}
在 Go 中只需要手動 Start 一個 span 即可。
對應到 Java 稍微簡單一些,只需要為函數添加一個注解即可。
@WithSpan("span")
public void span(@SpanAttribute("request.name") String name) {
TimeUnit.SECONDS.sleep(1);
log.info("span:{}", name);
}
只不過得單獨引入一個依賴:
<dependency>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-api</artifactId>
</dependency>
<dependency>
<groupId>io.opentelemetry.instrumentation</groupId>
<artifactId>opentelemetry-instrumentation-annotations</artifactId>
<version>2.3.0</version>
</dependency>
最終我們在 Jaeger UI 上看到的效果如下:
圖片
總結
圖片
最后總結一下,OpenTelemetry 支持許多流行的語言,主要分為兩類:是否支持自動埋點。
圖片
這里 Go 也可以零代碼埋點,是使用了 eBPF,本文暫不做介紹。
對于支持自動埋點的語言就很簡單,只需要配置下 agent 即可;而原生的 Go 語言不支持自動埋點就得手動使用 OpenTelemetry 提供的 SDK 處理一些關鍵步驟;總體來說也不算復雜。
參考鏈接:
- https://opentelemetry.io/docs/languages/java/configuration/
- https://github.com/open-telemetry/opentelemetry-java-instrumentation/blob/main/docs/supported-libraries.md
- https://crossoverjie.top/2024/06/06/ob/OpenTelemetry-trace-concept/