OpenTelemetryで分散トレーシングを実現する仕組み
はじめに
OpenTelemetryはトレースやメトリクス、ログを作成したり送信したりする仕組みです。
送信時のプロトコルや各言語用のSDKなど多くの要素で出来ています。
その中で、この記事ではOpenTelemetryが分散トレーシングを実現する仕組みについて解説します。
分散トレーシングとは
まず、分散トレーシングについて。
分散トレーシングは、マイクロサービスのように複数のサーバーにまたがるトレースを追跡・監視する仕組みです。
分散トレーシングは主にTrace
とSpan
という要素で出来ています。
Span
は時刻などの実行内容を持つデータで、Trace
はSpan
をノードとした木構造です。
図にすると下のようになり、図全体がTrace
で、棒一つづつがSpan
です。
ただし、Trace
は実体があるものではなく、プログラムはSpan
しか作りません。
バックエンドが、受け取ったSpan
を計算してTrace
を把握しています。
SpanからTraceを把握する方法
SpanからTraceを把握することができれば、分散トレーシングが出来ます。
Traceを把握するために重要なSpanの要素は3つです。
- TraceId
- SpanId
- ParentSpanId
ProtocolBuffersでSpanの中に指定されている要素です。
-
TraceId
はSpanが所属しているTraceのIDです。例えばhexで9023c11c...
というIDだった場合、9023c11c...
というTraceIdを持っているSpanは全て同じTraceに所属していることになります。 -
SpanId
は各Spanが持つ独自のIDで、Traceの中で一意です。 -
ParentSpanId
は親SpanのIDです。ParentSpanId
と同じSpanId
を持っているSpanが、そのSpanにとっての親です。
この3要素を全てのSpanが持つことにより、Spanの情報からTraceを木構造として把握することができます。
例えば、下のようなTraceです。
このように、ParentSpanId
と一致するSpanId
を親とすることでTraceが木になり、TraceId
が違うSpan
を別のTrace
に分類することが出来ます。
コード
この仕組みを実現するために、各言語のSDKでSpan
を作る方法が用意されています。
例えばgoでは、以下のコードでSpan
を作ることができます。
func CreateTrace() {
ctx, span := tracer.Start(context.Background(), "parent-span")
defer span.End()
ctx, span = tracer.Start(ctx, "child-span")
defer span.End()
}
これをstdoutに出力すると、下のように出力されます。
{
"Name": "child-span",
"SpanContext": {
"TraceID": "9023c11c3272a955da5f499faa9afa71",
"SpanID": "ca44f59e13b40d44",
},
"Parent": {
"TraceID": "9023c11c3272a955da5f499faa9afa71",
"SpanID": "70e471ef5735034d",
},
}
{
"Name": "parent-span",
"SpanContext": {
"TraceID": "9023c11c3272a955da5f499faa9afa71",
"SpanID": "70e471ef5735034d",
},
"Parent": {
"TraceID": "00000000000000000000000000000000",
"SpanID": "0000000000000000",
},
}
TraceId
,SpanId
,ParentSpanId
に相当する値が出力されているのを見ることが出来ます。
この例では、2つのTraceId
が9023c11c3272a955da5f499faa9afa71
と共通していることから、同じTrace
であることが確認できます。
また、child-span
のParentSpanId
(70e471ef5735034d)がparent-span
のSpanId
と一致していることから親子関係になっていることがわかります。
図にすると、下のようになります
「同じトレースのはずなのにトレースが別れてしまっている」という問題がでたときは、このような出力を見て同じTraceId
になっているかを確認することで問題が切り分けられるわけです。
Propagation
前章では、Spanの要素を使ってTraceが作れることを確認しました。
では、プログラムは自分のTraceIdをどのように把握すればいいのでしょうか?
同じプロセスで動いているのであればメモリを通してTraceIdを共有することが出来ますが、分散トレーシングは複数のマシンが動いている世界です。
そこで登場するのがPropagation
です。
考え方はとてもシンプルで、「自分のTraceId
とSpanId
をなんとかして渡す」というものです。
例えば、HTTPリクエストであればヘッダーに追加します。
ヘッダーで渡す方法は、歴史的な経緯から何通りかありますが、W3C
のやり方が広まっていると思うので、ここからはW3C
について解説します。
W3C Trace Context
W3C Trace Contextのフォーマットはその名の通りW3Cで仕様化されています。
W3Cを使う場合、HTTPリクエストのヘッダーにはtraceparent
を使います。
ヘッダーのフォーマットは${version}-${trace-id}-${parent-id}-${trace-flags}
で、curlにすると下のようになります。
curl \
-H "traceparent:00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01" \
localhost
これで、
- バージョンは
00
(現在00
以外ありません) - TraceIdは
4bf92f3577b34da6a3ce929d0e0e4736
- ParentSpanIdは
00f067aa0ba902b7
- サンプリングされているかどうかを表すために最後に
01
という情報をリクエスト先に伝えています。
これによって、別サービスにTraceの情報を伝えられるわけです。
この仕組みによって、例えば、下のように複数サービスをまたいだTraceを作ることが出来ます。
コード
W3Cのやり方をコードにする場合は、サーバーとクライアントを用意します。
// サーバーがlocalhost:9002で待つ
func RunServer() {
exp, _ := stdouttrace.New()
tp := sdktrace.NewTracerProvider(
sdktrace.WithBatcher(exp),
)
otelHandler := otelhttp.NewHandler(http.HandlerFunc(handler), "handle-request", otelhttp.WithTracerProvider(tp))
http.Handle("/", otelHandler)
http.ListenAndServe(":9002", nil)
}
func handler(w http.ResponseWriter, r *http.Request) {
fmt.Println("handled")
}
// クライアントがlocalhost:9002にアクセスする
func CreatePropagationTrace() {
ctx, span := tracer.Start(context.Background(), "hello-span")
defer span.End()
req, _ := otelhttp.Get(ctx, "http://localhost:9002")
io.ReadAll(req.Body)
}
これをstdoutに出力すると、自動計装も間に入って、下のような出力がされます。
// サーバー側のTrace
{
"Name": "handle-request", // <- サーバーが受け取るとできる自動計装
"SpanContext": {
"TraceID": "817f4043c5837f2bbb44562f3683f274",
"SpanID": "3bba3b994e029bfc",
},
"Parent": {
"TraceID": "817f4043c5837f2bbb44562f3683f274",
"SpanID": "892d624c6f0c01a6",
},
}
// クライアント側のTrace
{
"Name": "HTTP GET", // <- `otelhttp.Get`を呼ぶとできる自動計装
"SpanContext": {
"TraceID": "817f4043c5837f2bbb44562f3683f274",
"SpanID": "892d624c6f0c01a6",
},
"Parent": {
"TraceID": "817f4043c5837f2bbb44562f3683f274",
"SpanID": "1f312e90fb65c0e3",
},
}
{
"Name": "hello-span",
"SpanContext": {
"TraceID": "817f4043c5837f2bbb44562f3683f274",
"SpanID": "1f312e90fb65c0e3",
},
"Parent": {
"TraceID": "00000000000000000000000000000000",
"SpanID": "0000000000000000",
},
}
これを読むと、普通のプログラムと同じようにParentSpanId
がつながっていることがわかります
図にすると、下のようになります
このように、ヘッダーにTraceId
とSpanId
を渡すことで、複数サービスにまたがったTraceが作れます。
これで、分散トレーシングが実現できるというわけです。
終わりに
このように、分散トレーシングという厳つい名前がついていますが、結局はデータ(TraceId
,SpanId
,ParentSpanId
)を上手いこと渡しているだけとわかります。
以上、OpenTelemetryで分散トレーシングができる仕組みを紹介しました。
Discussion