🐕

OpenTelemetryでの分散トレーシングでTraceについて学んだので整理する

に公開

概要

今回業務でOpenTelemetryを使った分散トレーシングを実装しました。
その過程で理解できていなかった概念に触れることができたので、学んだ内容をまとめます。

前提

  1. 参考コードはGoを使って書きます
  2. なお、私たちのシステムでは API と Worker の処理をつなぐために Temporal を利用しています。
    1. Temporal 自体はワークフローエンジンであり、詳細は本記事の範囲外ですが、ここでは「API → Worker の処理が Temporal 経由で呼ばれる」くらいの理解で問題ありません。
    2. この内部通信部分ではデフォルトで W3C TraceContext が使われるため、ベンダー依存せずにトレースを繋げられるのがポイントです。

https://temporal.io/

やろうとしたこと

今回実現する必要があった要件は以下の通りです。

添付内容を説明すると、

  1. マイクロサービス全体はDatadogでモニタリングをしている
  2. そのため、私たちのシステム(=自システム)ではDatadogの形式でトレースを受け取れる必要がある
  3. かつ、別のマイクロサービスをリクエストする場合もDatadog形式のコンテキストでリクエストする必要がある
  4. ただし、ベンダーロックインしている現状に課題があり、自システムではOpenTelemetryの標準規格であるW3C TraceContextでの通信を目指したかった
    1. そもそもOpenTelemetryを採用している理由として、ベンダーに依存しないオープンな標準を使うことで、将来的な拡張性・可搬性を確保したいという狙いがあった
    2. また、トレースだけでなくメトリクスやログも統一的に扱える仕組みを提供しており、観測基盤全体を一貫して構築できるメリットがある
  5. そのため、外部とはDatadogで通信をさせて、内部ではW3C TraceContextでの通信を実現したい

それらを踏まえて下記のような対応を実施しました。

ポイントについて触れていきます。

  1. 外部サービスから自システムのExtractを行う
    1. Datadog と W3C TraceContextの仕様の差異(W3Cは16進数のID、Datadogは10進数のIDを用いる)を満たすために、独自でDatadogPropagatorを実装して、そのExtract(抽出)の処理をここでは使用する
  2. 自システム内(API ↔ Worker)では W3C TraceContext を使う
    1. 内部のサービス間通信は OpenTelemetry の標準仕様である W3C TraceContext を用いてトレースを伝播させることで、ベンダーに依存せず一貫した形式で扱えるようにした
  3. Workerから外部サービスへのHTTPリクエストを実行する際に、Datadog仕様のtraceである必要があるためInjectを行う
    1. 1と同様のDatadogPropagatorを使用して、Inject(注入)の処理をここでは使用する

新出する単語や概念については後半で説明していきます。

OpenTelemetryでトレースを繋げるために

順番が前後してしまいますが、前提としてOpenTelemetryでのトレースを繋げる流れや
必要な要素について説明します。
トレースを外部から内部、内部から外部へと途切れずにつなげるためには、
OpenTelemetryで定義されている以下の仕組みを理解する必要があります。

  • W3C TraceContext : OpenTelemetryが標準で使うフォーマット
  • DatadogとW3C TraceContextの違い : 互換性を取る上で重要になる
  • Propagator (Inject / Extract) : トレース情報を通信に載せたり取り出したりする仕組み
  • Tracer / TracerProvider : トレースを生成する仕組み

説明していきます。

W3C TraceContext

https://www.w3.org/TR/trace-context/
W3C TraceContext は、分散トレーシングにおけるトレース情報の伝播を標準化する仕様です。
主に以下の2つの HTTP ヘッダーを通して、トレース情報を正しく引き継ぎます。

  • traceparent:トレース ID・スパン ID・フラグなどの必須コンテキストを送受信するヘッダー
  • tracestate:ベンダー固有の情報を追加して連携できる拡張的なヘッダー

この標準を利用することで、Datadog や OpenTelemetry など複数のトレーシングツール間でもトレースを途切れさせずに統合できます。

DatadogとW3C TraceContextの違い

DatadogとW3C TraceContextはトレースの仕様が異なるため、標準では相互運用できません。そのままでは分散システムにおいてトレースを繋げられないため、変換処理が必要になります。
主な違いは「使用するヘッダ」と「IDのフォーマット」です。

項目 W3C TraceContext Datadog
ヘッダ traceparent, tracestate x-datadog-trace-id, x-datadog-parent-id, x-datadog-sampling-priority, x-datadog-origin
trace-id 128bit, 16進数表記 64bit, 10進数表記
span-id 64bit, 16進数表記 64bit, 10進数表記
サンプリング情報 trace-flags (1bitで記録) x-datadog-sampling-priority ヘッダ
ベンダー拡張 tracestate に記録 Datadog 独自ヘッダで直接管理

Propagator (Inject / Extract)

https://opentelemetry.io/docs/specs/otel/context/api-propagators/
Propagatorは、サービス間でのトレース情報の取り出し・書き込みを担当するコンポーネントです。

  • Extract(抽出): 通信媒体からトレース情報を取り出してContextに格納
  • Inject(注入): Contextからトレース情報を取り出して通信媒体に書き込み

通信媒体(Carrier)は通信方法によって異なります:

  • HTTP → ヘッダー(http.Header)
  • gRPC → metadata

今回はHTTP通信なので以下のようになります。

  • Extract: Carrier (HTTPヘッダ) から trace-id, span-id を取り出して Context に格納する
  • Inject: Context から trace-id, span-id を取り出して Carrier に書き込む

このPropagatorを設定することで仕様が異なるトレースを利用しているシステムでもトレースを伝播することが可能になります。

Tracer / TracerProvider

https://opentelemetry.io/ja/docs/concepts/signals/traces/

  • Tracer: トレースを生成するコンポーネント
  • TracerProvider: Tracerのファクトリー。アプリケーション起動時にグローバルに設定される

TracerProviderでは以下の設定を行います:

  • リソース: 実行環境やSDKバージョンなどのメタデータ
  • エクスポーター: トレースデータの送信先と送信方法

実装について

業務で使用しているコードをそのまま公開できないため、概念を説明するためのサンプル実装を示します。

構成

.
├─ shared/propagator/datadog.go
├─ api/main.go
├─ api/otel.go
├─ api/temporal.go             
├─ worker/main.go
└─ worker/httpclient.go

Datadog Propagator

shared/propagator/datadog.go
package propagator

import (
	"context"

	"go.opentelemetry.io/otel/propagation"
)

type DatadogPropagator struct{}

func (DatadogPropagator) Fields() []string {
	return []string{
		"x-datadog-trace-id",
		"x-datadog-parent-id",
		"x-datadog-sampling-priority",
		"x-datadog-origin",
	}
}

func (DatadogPropagator) Inject(ctx context.Context, carrier propagation.TextMapCarrier) {
	// W3C(16進) -> Datadog(10進) の変換を書き込む
}

func (DatadogPropagator) Extract(ctx context.Context, carrier propagation.TextMapCarrier) context.Context {
	// Datadog(10進) -> W3C(16進) の変換を取り出す
	return ctx
}

参考資料:

api/otel.go

api/otel.go
package main

import (
	"context"

	"go.opentelemetry.io/otel"
	sdktrace "go.opentelemetry.io/otel/sdk/trace"
)

func initTracer() func(context.Context) error {
	tp := sdktrace.NewTracerProvider(
		// Exporter / Sampler は環境に合わせて
	)
	// トレーサープロバイダーをセットしている
	otel.SetTracerProvider(tp)

	// 本来は以下のようにTextMapPropagatorをグローバル設定にする
	//   otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
	//       propagation.TraceContext{},
	//       propagation.Baggage{},
	//   ))
	// こうすることで、Propagatorの設定がグローバルになり、W3C TraceContextとBaggageでの設定のPropagatorが有効になります
	// この設定がない場合Propagatorの設定はNo-op(何もしない)になります
	// ただし、今回のサンプルコードではmiddlewareの設定でリクエストに対してDatadogのPropagatorを明示的に設定するようにしてるため、ここでは設定していません
	// ここで設定もできるが、W3C TraceContextとDatadogが要所要所で利用されるため、どの設定が有効になってるか理解がしづらいので、このようにしています
	// @see https://github.com/open-telemetry/opentelemetry-go/blob/v1.38.0/propagation.go#L11-L15

	
	// トレーサープロバイダーの終了関数を返却する
	return tp.Shutdown
}

実際の実装では、tracerProviderにエクスポーターやリソースの設定を追加します。

api/temporal.go

api/temporal.go
package main

import (
	"context"

	"go.temporal.io/sdk/client"
	"go.temporal.io/sdk/contrib/opentelemetry"
)

func newTemporalClient(ctx context.Context) (client.Client, error) {
	// W3C TraceContext を使うための OTel Tracing Interceptor を最小設定で追加
	ti := opentelemetry.NewTracingInterceptor(opentelemetry.TracerOptions{}) 
	opts := client.Options{
		HostPort: "localhost:7233", // Temporal server address
		Interceptors: []client.Interceptor{
			ti,
		},
	}
	return client.DialContext(ctx, opts)
}

TemporalはAPIとWorkerの処理を繋ぐワークフローエンジンです。
内部通信では標準設定でW3C TraceContextが使用されます。

api/main.go

api/main.go
package main

import (
	"context"
	"log"
	"net/http"

	"github.com/labstack/echo/v4"
	"go.opentelemetry.io/contrib/instrumentation/github.com/labstack/echo/otelecho"
	"go.opentelemetry.io/otel"
	"go.opentelemetry.io/otel/propagation" // コメント内でのみ使用

	myprop "yourmodule/shared/propagator"
)

func main() {
	shutdown := initTracer()
	// 終了時にトレーサーも終了させている
	defer func() { _ = shutdown(context.Background()) }()

	e := echo.New()
	e.Use(otelecho.Middleware(
		"api",
		otelecho.WithPropagators(myprop.DatadogPropagator{}),
	))

	// (例)Temporalへワークフロー開始だけ行う最小のAPI
	e.POST("/start", func(c echo.Context) error {
		ctx := c.Request().Context()
		// ここで c.Request().Context() には W3C の SpanContext が入っている
		// それは、APIがリクエストされたときはDatadog仕様だったが、
		// otelecho.Middlewareが最初に動くことでExtractが作用するから
		// Datadog→W3C TraceContextの形に変換されるため

		tc, err := newTemporalClient(ctx)
		if err != nil {
			return c.String(http.StatusInternalServerError, err.Error())
		}
		defer tc.Close()

		// ここで ctx には Extract 済みの W3C SpanContext が入っている想定
		// 実際の Workflow/Options は環境に合わせて
		_ = otel.Tracer("api") // 必要なら span を開始
		// _, err = tc.ExecuteWorkflow(ctx, client.StartWorkflowOptions{ID:"wf-1"}, YourWorkflow, payload)
		// if err != nil { return c.String(http.StatusInternalServerError, err.Error()) }
		return c.String(http.StatusOK, "started")
	})


	log.Println("API listening on :8080")
	log.Fatal(e.Start(":8080"))
}

概念理解のために最小限の実装にしたAPIです。TemporalClientを呼び出すことで、Workerに処理がリクエストされます。

worker/httpclient.go

worker/httpclient.go
package main

import (
	"net/http"

	"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"

	myprop "yourmodule/shared/propagator"
)

func newExternalHTTPClient() *http.Client {
	return &http.Client{
		Transport: otelhttp.NewTransport(
			http.DefaultTransport,
			otelhttp.WithPropagators(myprop.DatadogPropagator{}),
		),
	}
}

これも最低限ですが、otelhttpパッケージを利用して、Transport(RoundTripper)にDatadogPropagatorを指定しています。
Transportは、HTTPリクエスト時の本処理の前処理のようなものだと考えてもらって良いです。
この設定により、HTTPリクエスト時にW3C TraceContextからDatadog仕様のトレース情報にInject(注入)されるようになります。

worker/main.go

worker/main.go
package main

import (
	"context"
	"log"
	"net/http"

	"go.opentelemetry.io/otel"
	sdktrace "go.opentelemetry.io/otel/sdk/trace"
)

func initTracer() func(context.Context) error {
	tp := sdktrace.NewTracerProvider()
	otel.SetTracerProvider(tp)
	
	return tp.Shutdown
}

func main() {
	shutdown := initTracer()
	defer func() { _ = shutdown(context.Background()) }()

	httpClient := newExternalHTTPClient()

	// 実際はTemporal Workerのハンドラ内で外部呼び出しを行う想定
	req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "https://example.com/ping", nil)
	if err != nil {
		log.Println("request creation error:", err)
		return
	}
	
	_, err = httpClient.Do(req) // Datadog 形式で Inject される
	if err != nil {
		log.Println("external call error:", err)
	}
}

この処理は本来Temporalを利用してることから、Workflow/Activityの実行がされるべきですが、
今回はトレースに特化した話なので、割愛しています。

まとめ

今回はW3C TraceContextとDatadogを繋げる実装が主な目的でしたが、OpenTelemetryの仕様や概念を体系的に学ぶ良い機会となりました。

普段は個別の技術ポイントの理解にとどまることが多いですが、今回は全体的な構造から理解することで、より深い学びを得ることができました。

Discussion