🌲

OpenTelemetryで分散トレーシングを実現する仕組み

2023/11/25に公開

はじめに

OpenTelemetryはトレースやメトリクス、ログを作成したり送信したりする仕組みです。
送信時のプロトコルや各言語用のSDKなど多くの要素で出来ています。

その中で、この記事ではOpenTelemetryが分散トレーシングを実現する仕組みについて解説します。

https://opentelemetry.io/

分散トレーシングとは

まず、分散トレーシングについて。
分散トレーシングは、マイクロサービスのように複数のサーバーにまたがるトレースを追跡・監視する仕組みです。

分散トレーシングは主にTraceSpanという要素で出来ています。
Spanは時刻などの実行内容を持つデータで、TraceSpanをノードとした木構造です。

図にすると下のようになり、図全体がTraceで、棒一つづつがSpanです。
Traceの画像

ただし、Traceは実体があるものではなく、プログラムはSpanしか作りません。
バックエンドが、受け取ったSpanを計算してTraceを把握しています。

SpanからTraceを把握する方法

SpanからTraceを把握することができれば、分散トレーシングが出来ます。

Traceを把握するために重要なSpanの要素は3つです。

  • TraceId
  • SpanId
  • ParentSpanId

ProtocolBuffersでSpanの中に指定されている要素です。
https://github.com/open-telemetry/opentelemetry-proto/blob/ea449ae0e9b282f96ec12a09e796dbb3d390ed4f/opentelemetry/proto/trace/v1/trace.proto#L83-L110

  • 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つのTraceId9023c11c3272a955da5f499faa9afa71と共通していることから、同じTraceであることが確認できます。
また、child-spanParentSpanId(70e471ef5735034d)がparent-spanSpanIdと一致していることから親子関係になっていることがわかります。
図にすると、下のようになります

「同じトレースのはずなのにトレースが別れてしまっている」という問題がでたときは、このような出力を見て同じTraceIdになっているかを確認することで問題が切り分けられるわけです。

Propagation

前章では、Spanの要素を使ってTraceが作れることを確認しました。
では、プログラムは自分のTraceIdをどのように把握すればいいのでしょうか?
同じプロセスで動いているのであればメモリを通してTraceIdを共有することが出来ますが、分散トレーシングは複数のマシンが動いている世界です。

そこで登場するのがPropagationです。

考え方はとてもシンプルで、「自分のTraceIdSpanIdをなんとかして渡す」というものです。
例えば、HTTPリクエストであればヘッダーに追加します。

ヘッダーで渡す方法は、歴史的な経緯から何通りかありますが、W3Cのやり方が広まっていると思うので、ここからはW3Cについて解説します。

W3C Trace Context

W3C Trace Contextのフォーマットはその名の通りW3Cで仕様化されています。
https://www.w3.org/TR/trace-context/

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がつながっていることがわかります
図にすると、下のようになります

このように、ヘッダーにTraceIdSpanIdを渡すことで、複数サービスにまたがったTraceが作れます。
これで、分散トレーシングが実現できるというわけです。

終わりに

このように、分散トレーシングという厳つい名前がついていますが、結局はデータ(TraceId,SpanId,ParentSpanId)を上手いこと渡しているだけとわかります。

以上、OpenTelemetryで分散トレーシングができる仕組みを紹介しました。

Vaxila

Discussion