Timee Product Team Blog

タイミー開発者ブログ

OpenTelemetryで環境ごとにObservability Backend(Jaeger、Datadog)を切り替えてエンジョイしてみたよ

こんにちは! タイミーで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つの方法で送信できます。

  1. 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()
}
  1. Otel SDK経由

こちらではOtel SDKを使用してTraceProviderを生成します。

参照:https://docs.datadoghq.com/ja/opentelemetry/interoperability/otlp_ingest_in_the_agent/?tab=host#enabling-otlp-ingestion-on-the-datadog-agent

送信するポートは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/agentjaegertracing/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_idruntime-idは存在しません。

local環境(Jaegar)

http://localhost:16686/search でトレース情報を見ることができます。Datadogと一部のメタデータが異なっていたりと、完全に同一ではないものの、概ね同じ内容を見ることができます。

Datadogのトレースと異なる点としては、サービス名として与えていたMoneyForestがotel.libraryにマッピングされていましたが、こちらではotel.scopeにマッピングされているという点です。

実装してわかったこと

GoのOtel SDKから生成されるsdktrace.TracerProviderではShutdownはcontextを引数に取るものの、ddotel.TracerProviderShutdownは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自体の理解も深めて、さらに楽しいエンジニアライフを送りたいと思います!またね〜