こんにちは! タイミーでPlatform Engineerをしている @MoneyForest です。
こちらは Timee Product Advent Calendar 2024 の10日目の記事です。
2024年8月に入社して、幸いにもチームメンバーにも恵まれて楽しく働いています。 個人的にキャッチアップがあまりできていなかった OpenTelemetry を題材にして実装をしてみたので、ここから得られた気づきや知見を共有したいと思います。
はじめに
アプリケーションの可観測性(Observability)を担保する上で、APM(Application Performance Monitoring)は重要です。
現在、JaegerやPrometheusのような非商用のものから、DatadogやNew RelicやSplunkなど様々なAPMを利用できるツールが存在します。(本記事ではこれらを「Observability Backend」と呼称します。)
これらのObservability BackendでAPMを利用するには、各々が提供する独自のSDKを使用してTraceやSpanを送信する必要があり、以下のような課題がありました。
- アプリケーションの実装に各SDKの仕様が滲み出る
- Observability Backendの切り替えが困難
OpenTelemetryの登場により、オブザーバビリティに関する様々な概念が抽象化され、統一された仕様が提供されるようになりました。
これにより、アプリケーションコードとObservability Backendの実装を分離し、状況に応じて適切なツールを選択できるようになりました。
つまり、ローカルではJaegerのような非商用のツールで開発中にAPMを利用し、デプロイしたアプリケーションではDatadogのような商用ツールでリッチなUIでAPMを利用したり、モニタリングアラートを組んだりなどができるということです。
本記事では、Go言語でOpenTelemetryを活用して、環境ごとにObservability Backend(Jaeger、Datadog)を切り替える実装を行うことで、これらのメリットを体感していきたいと思います。
実装してみる
実装の全量はこちらにあります。
https://github.com/MoneyForest/advent-calender-2024
共通インターフェースの定義・初期化処理
OpenTelemetryに準拠した共通インターフェースを定義することで、各バックエンドの実装を抽象化します。(この実装ではinfrastructure/tracer.go
に配置しています。)
共通インターフェースはOpenTelemetryのSDKの仕様に則してインターフェースを定義します。 例えばShutdownメソッドは、OpenTelemetryの仕様に基づき定められているものです。トレースデータの欠損を防ぐために必要な標準仕様となっています。
参照:https://opentelemetry.io/docs/specs/otel/trace/sdk/#shutdown
また、今回は環境に応じてObservability Backendを切り替える実装のため、envを条件に初期化するProviderを切り替えています。
// infrastructure/tracer.go package infrastructure import "context" type TracerProviderWrapper interface { Shutdown(context.Context) error } func InitTracer(env string) (TracerProviderWrapper, error) { switch env { case "dev": return InitDatadog() default: return InitJaeger() } }
Observability Backend固有の初期化処理
次に各Observability Backendごとの初期化処理を書いていきます。
Datadog
Datadogは2つの方法で送信できます。
- ddotelアダプター経由
参照:https://docs.datadoghq.com/ja/tracing/trace_collection/custom_instrumentation/go/otel/
こちらではDatadogのSDKであるdd-trace-goからddotelというアダプターを通じてOtelに準拠したTraceProviderを生成します。
送信するポートはDatadog Agentのデフォルトポートである8126になります。
// infrastructure/datadog.go package infrastructure import ( "context" "os" "go.opentelemetry.io/otel" ddotel "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/opentelemetry" ddtracer "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer" ) type ddTracerProvider struct { provider *ddotel.TracerProvider } func InitDatadog() (*ddTracerProvider, error) { tp := ddotel.NewTracerProvider( ddtracer.WithService(os.Getenv("SERVICE")), // MoneyForest ddtracer.WithEnv(os.Getenv("ENV")), ddtracer.WithServiceVersion("1.0.0"), ddtracer.WithAgentAddr("localhost:8126"), ) otel.SetTracerProvider(tp) return &ddTracerProvider{provider: tp}, nil } func (p *ddTracerProvider) Shutdown(ctx context.Context) error { return p.provider.Shutdown() }
- Otel SDK経由
こちらではOtel SDKを使用してTraceProviderを生成します。
送信するポートはOTLP HTTPのデフォルトポートである4318になります。
// infrastructure/datadog.go package infrastructure import ( "context" "os" "go.opentelemetry.io/otel" ddotel "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/opentelemetry" ddtracer "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer" ) type ddTracerProvider struct { provider *sdktrace.TracerProvider } func InitDatadog() (*ddTracerProvider, error) { exporter, err := otlptracehttp.New( context.Background(), otlptracehttp.WithEndpoint("localhost:4318"), otlptracehttp.WithInsecure(), otlptracehttp.WithURLPath("/v1/traces"), ) if err != nil { return nil, err } tp := sdktrace.NewTracerProvider( sdktrace.WithBatcher(exporter), sdktrace.WithResource(resource.NewWithAttributes( semconv.SchemaURL, semconv.ServiceNameKey.String(os.Getenv("SERVICE")), // MoneyForest semconv.DeploymentEnvironmentKey.String(os.Getenv("ENV")), )), ) otel.SetTracerProvider(tp) return &ddTracerProvider{provider: tp}, nil } func (p *ddTracerProvider) Shutdown(ctx context.Context) error { return p.provider.Shutdown(ctx) }
Jaeger
Jaegerはjaegar-client-go
のSDKがありましたが、2024/5に非推奨となっています。
今はOpenTelemetryに準拠することが推奨されているため、Otel SDKによる実装を行います。
// infrastructure/jaeger.go package infrastructure import ( "context" "os" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp" "go.opentelemetry.io/otel/sdk/resource" sdktrace "go.opentelemetry.io/otel/sdk/trace" semconv "go.opentelemetry.io/otel/semconv/v1.4.0" ) type jaegerTracerProvider struct { provider *sdktrace.TracerProvider } func InitJaeger() (jaegerTracerProvider, error) { exporter, err := otlptracehttp.New( context.Background(), otlptracehttp.WithEndpoint("localhost:4318"), otlptracehttp.WithInsecure(), ) if err != nil { return jaegerTracerProvider{}, err } tp := sdktrace.NewTracerProvider( sdktrace.WithBatcher(exporter), sdktrace.WithResource(resource.NewWithAttributes( semconv.SchemaURL, semconv.ServiceNameKey.String(os.Getenv("SERVICE")), semconv.DeploymentEnvironmentKey.String(os.Getenv("ENV")), )), ) otel.SetTracerProvider(tp) return jaegerTracerProvider{provider: tp}, nil } func (p jaegerTracerProvider) Shutdown(ctx context.Context) error { return p.provider.Shutdown(ctx) }
アプリケーションでの利用
アプリケーションでは、OpenTelemetryの標準APIを通じてTracerを使用することで、Observability Backendに依存しない一貫した実装が可能になります。
package main import ( "context" "crypto/rand" "fmt" "log" "os" _ "github.com/go-sql-driver/mysql" "github.com/MoneyForest/timee-advent-calender-2024/internal" "github.com/MoneyForest/timee-advent-calender-2024/internal/handler" "github.com/MoneyForest/timee-advent-calender-2024/internal/infrastructure" ) func main() { // Initialize tracer tp, err := infrastructure.InitTracer(os.Getenv("ENV")) if err != nil { log.Fatal(err) } defer tp.Shutdown(context.Background()) // ... }
func (h *UserHandler) CreateUser(ctx context.Context, email string) error { tracer := otel.Tracer(os.Getenv("SERVICE")) ctx, span := tracer.Start(ctx, "UserHandler.CreateUser") defer span.End() span.SetAttributes( attribute.String("email", email), attribute.String("handler", "UserHandler"), attribute.String("method", "CreateUser"), ) return h.userUsecase.CreateUser(ctx, email) }
動かしてみる
これまでの実装がうまくいっていれば、JaegerとDatadogで同じようなAPMを見ることができる想定です。確認してみましょう。
Docker Composeでgcr.io/datadoghq/agent
とjaegertracing/all-in-one
のサイドカーを立てます。
本来はdatadog-agentはdev環境以上に必要で、ECSのサイドカーなどに定義すればよいです。 (リポジトリではMySQLやRedisのコンテナも建てていますが省略します。)
JaegarはUIまでパッケージングされたイメージが公式から提供されており、こちらにトレースを送信することで、localhostのUIからAPMを確認できます。
datadog-agent: image: gcr.io/datadoghq/agent:latest environment: - DD_API_KEY=${DD_API_KEY} - DD_SITE=datadoghq.com - DD_APM_ENABLED=true - DD_APM_NON_LOCAL_TRAFFIC=true - DD_OTLP_CONFIG_RECEIVER_PROTOCOLS_HTTP_ENDPOINT=0.0.0.0:4318 ports: - "8126:8126" - "4319:4318" # OTLP HTTP (ローカルマシンのポートはJaegarと被るのでずらしている) volumes: - /var/run/docker.sock:/var/run/docker.sock - /proc/:/host/proc/:ro - /sys/fs/cgroup:/host/sys/fs/cgroup:ro jaeger: image: jaegertracing/all-in-one:latest ports: - "16686:16686" # UI - "14250:14250" - "4318:4318" # OTLP HTTP environment: - COLLECTOR_OTLP_ENABLED=true
dev環境(Datadog)
https://app.datadoghq.com/apm/traces でトレース情報を見ることができます。
以下はddotelを使用して送信したメトリクスです。
以下はOtel SDKを使用して送信したメトリクスです。
1.
と異なる点として、SpanのAttributeにotel
の構造体があります。また、process_id
やruntime-id
は存在しません。
local環境(Jaegar)
http://localhost:16686/search でトレース情報を見ることができます。Datadogと一部のメタデータが異なっていたりと、完全に同一ではないものの、概ね同じ内容を見ることができます。
Datadogのトレースと異なる点としては、サービス名として与えていたMoneyForestがotel.libraryにマッピングされていましたが、こちらではotel.scopeにマッピングされているという点です。
実装してわかったこと
GoのOtel SDKから生成されるsdktrace.TracerProvider
ではShutdown
はcontextを引数に取るものの、ddotel.TracerProvider
のShutdown
はcontentを引数に取らない、などの細かな差異があり、共通インターフェースの方で吸収する必要がありました。
Datadogの実装でOtel SDKを直接使う方を採用すれば、共通インターフェースを用意する必要がなく、以下のようにInitTracer
は初期化の条件分岐のみを書いた実装が可能です。
func InitTracer(env string) (*sdktrace.TracerProvider, error) { switch env { case "dev": return initDatadog() default: return initJaeger() } }
またddotelのライブラリにはadded to the OpenTelemetry API will default to being a no-op until implemented by this library.
のコメントがあり、新機能の利用にはGoのOtel SDKではなくDatadog Agent側での実装を待つ必要がありそうでした。
そのため、Datadogの独自機能を重視する場合はddotelを採用し、Otelへの準拠を重視する場合はOtel SDKで実装するなど、実装の際に意思決定が必要になりそうです。
また、細かなマッピングの違いも存在しそうなため、Traceを使ってモニタリングアラートを組んでいる場合は注意して切り替える必要がありそうです。(例えばDatadogからNew Relicに変更する場合など)
商用のツールでは料金体系なども異なるため、仕様が同じでもサンプリングレートの値の調整が必要になることもあるでしょう。
まとめ
OpenTelemetryを活用することで、トレーシング実装でより一貫性のあるコードを実現できることがわかりました。
これにより、環境に応じて適切なObservability Backendを利用したり、移行することが容易になったりするのは間違いなさそうです。
一方で標準仕様についてはまだまだ理解が浅いので、OpenTelemetry自体の理解も深めて、さらに楽しいエンジニアライフを送りたいと思います!またね〜