OpenTelemetryについて何も知らなかった人が分散トレーシング環境を構築してみた(Jaeger, X-Ray)

GMO メイクショップ コアグループでエンジニアをしている池田です。
今回はOpenTelemetryを用いた分散トレーシング環境を構築したので、そのプロセスを綴っていきます。

導入の目的

今回OpenTelemetryを導入した目的は以下の通りです。 環境ごとに導入する目的が異なります。 ローカル、開発環境で基本は運用して、ステージング、本番では調査の時だけ稼働させる予定です。

  • ローカル環境
    • ログを見やすくする
      • 標準出力しているログが見にくい
      • コンテナが分かれているため、コンテナごとにログを確認する必要がある
  • 開発環境

前提

プロジェクトについて

  • 言語: Go
  • 環境
    • AWS
    • ecspresso

筆者について

構成

ローカル環境

ローカルでは手軽に導入が可能なJaegerを使います。 各サービスがJaegerのコレクターに対してgRPCで通信を行いトレースデータを送信します。

開発環境

開発環境ではaws-otel-collectorを使用します。 アプリ本体のサイドカーとしてaws-otel-collectorを走らせ、アプリからaws-otel-collectorへgRPCで通信し、トレースデータを送信し、さらにそこからX-Rayへデータを送信します。 X-Ray画面上ではトレースIDでCloudWatchのログを抽出しています。そのため、CloudWatchにログ出力する際にトレースIDを付与しています。

セットアップ

まず初めにセットアップ用のコードを書きます。 今回はGoなのでこちらを参考にします。 opentelemetry.io

今回はローカルでJaeger、開発環境でX-Rayとバックエンドが異なるので、少し工夫が必要です。 一部省略している部分はありますが、下記のように実装しています。

import (
    "context"
    "errors"
    "time"

    "go.opentelemetry.io/contrib/propagators/aws/xray"
    "go.opentelemetry.io/otel"
    "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
    "go.opentelemetry.io/otel/propagation"
    "go.opentelemetry.io/otel/sdk/resource"
    "go.opentelemetry.io/otel/sdk/trace"
    // このバージョン指定が間違っていると動かない場合があったので注意(お使いになるSDKのバージョンに合わせていただく)
    semconv "go.opentelemetry.io/otel/semconv/v1.24.0"
)

// ...

func newPropagator() propagation.TextMapPropagator {
    if env.GetEnv().IsLocal {
        return propagation.NewCompositeTextMapPropagator(
            propagation.TraceContext{},
            propagation.Baggage{},
        )
    }
    return xray.Propagator{}
}

func newTraceProvider(ctx context.Context, serviceName string) (*trace.TracerProvider, error) {
  // トレースデータの送信先がJaegerとX-Rayで異なるため、環境変数からエンドポイントを取得
    traceExporter, err := otlptracegrpc.New(ctx, otlptracegrpc.WithEndpoint(env.GetEnv().OtelExporterOtlpEndpoint), otlptracegrpc.WithInsecure())
    if err != nil {
        return nil, err
    }

    rs, err := resource.New(
        ctx,
        resource.WithSchemaURL(semconv.SchemaURL),
        resource.WithHost(),
        resource.WithTelemetrySDK(),
        resource.WithOSType(),
        resource.WithAttributes(
            semconv.ServiceNameKey.String(serviceName), semconv.TelemetrySDKLanguageGo,
      // X-Rayでログを抽出する時にロググループを指定する必要がある
            semconv.AWSLogGroupNamesKey.StringSlice([]string{env.GetEnv().AwsCloudWatchLogGroup}),
        ),
    )
    if err != nil {
        return nil, err
    }

    // X-Rayの場合はIDGeneratorが必要になるため、ローカルとそれ以外の環境で分ける
    var traceProvider *trace.TracerProvider
    if env.GetEnv().IsLocal {
        traceProvider = trace.NewTracerProvider(
            trace.WithBatcher(traceExporter, trace.WithBatchTimeout(time.Second*5)),
            trace.WithResource(rs),
        )
    } else {
        traceProvider = trace.NewTracerProvider(
            trace.WithBatcher(traceExporter, trace.WithBatchTimeout(time.Second*5)),
            trace.WithResource(rs),
            trace.WithIDGenerator(xray.NewIDGenerator()),
        )
    }
    return traceProvider, nil
}

Jaegerコンテナの準備

jaegertracing/all-in-oneのイメージを使うと、すぐにJaegerを使用することができます。
コレクターとトレースログを見るためのUIが含まれています。

version: "3.7"
services:
  jaeger:
    container_name: jaeger
    image: jaegertracing/all-in-one:1.54
    ports:
      - "16686:16686"
      - "4317:4317"
      - "4318:4318"
    environment:
      - COLLECTOR_OTLP_ENABLED=true
    networks:
      - common_link
networks:
  common_link:
    external: true

ポート

  • 16686: トレースログ確認のためのUI
  • 4317 : gRPC
  • 4318 : HTTP

docker compose up -d で起動すると、Jaegerのコレクターとトレースログを見るためのUIが立ち上がります。

計装

トレースの構成要素について

計装するにあたり、こちらを読んでおくとトレースIDやスパンIDなどの理解が深まります。ぜひ読んでみてください。

トレーサーの定義

import "go.opentelemetry.io/otel"

var (
    ControllerTracer = otel.Tracer("github.com/xxxx/xxxx/controller")
    ServiceTracer = otel.Tracer("github.com/xxxx/xxxx/service")
)

トレーサーをグローバルな変数として定義します。
当プロジェクトでは上記のようにパッケージごとにトレーサーを定義することにしました。
こちらでもベストプラクティスとして推奨されているようです。 github.com

スパンの生成

定義したトレーサーを使用してスパンの生成を行います。

ctx, span := otel.ControllerTracer.Start(ctx, "span name")
defer span.End()

サービス間でトレースデータを共有する

異なるサービスを同様のトレースデータとして扱うためには、トレースIDなどのトレースデータをサービス間で受け渡す必要があります。 そのあたりの実装を自前で行うのは大変です。なので、ライブラリの力を借ります。 otelhttpやotelgrpcを使用すると、ミドルウェアとして組み込むだけで実現可能です。 当プロジェクトではコンテナ間の通信に、gRPCを使用しているため、otelgrpcを使用しています。

pkg.go.dev

以下のように組み込むだけで、自動計装が行えるかつ、トレース情報の受け渡しが可能です。

conn, err := grpc.DialContext(grpc.WithStatsHandler(otelgrpc.NewClientHandler(otelgrpc.WithPropagators(otel.GetTextMapPropagator()))))
srv := grpc.NewServer(grpc.StatsHandler(otelgrpc.NewServerHandler(otelgrpc.WithPropagators(otel.GetTextMapPropagator()))))

ログ出力

Jaeger

Jaegerのトレースログ上にログ出力するために必要な作業を行います。 下記のようなライブラリを用いて実装することも可能ですが、今回は自前で実装しました。 uptrace.dev

自前で実装するにあたり参考にしたのは、Jaeger公式がサンプルとして載せているコードです。 こちらではspan.AddEventというメソッドを使用して、スパンに対して追加の情報を付与しています。 github.com

上記を参考に実装しログに出力している内容をスパンのイベントとして出力することに成功しました。 一部省略している部分はありますが、下記のように実装しています。

type Logger struct {
    zapLogger *zap.SugaredLogger
    span      trace.Span
}

func (l *Logger) Infof(template string, args ...any) {
    l.zapLogger.Infof(template, l.getLogArgs(args)...)
    l.logToSpan("info", fmt.Sprintf(template, l.getLogArgs(args)...))
}

func FromContext(ctx context.Context) plog.Logger {
    if ctx != nil {
        if logger, ok := ctx.Value(ctxLoggerKey{}).(*Logger); ok {
            if span := trace.SpanFromContext(ctx); span != nil && span.SpanContext().IsValid() {
                logger.span = span
            }
            return logger
        }
    }
  // ...
}

func (l *Logger) logToSpan(level string, outPut string) {
    if l.span == nil {
        return
    }
    attr := []attribute.KeyValue{attribute.String("level", level)}
    if level == "error" {
        l.span.SetAttributes(attribute.Bool("error", true))
    }
    l.span.AddEvent(outPut, trace.WithAttributes(attr...))
}

AWS X-Ray

前述したように、X-Ray画面上でトレースIDでCloudWatchのログを抽出しています。 なので。ログ出力する内容にトレースIDを付与する必要があります。 contextからトレースIDの取得が可能なので、このような感じでトレースIDを取得して、ログ出力する内容に追加します。

if span := trace.SpanFromContext(ctx); env.GetEnv().IsDevelopment && span != nil && span.SpanContext().IsValid() {
    fields.OtelTraceId = span.SpanContext().TraceID().String()
}

aws-otel-collectorの設定

AWS環境でトレースログを収集するためのコレクターとしてaws-otel-collectorを使用します。

hub.docker.com

aws-otel-collectorイメージをベースとしつつ、設定内容を変えたいので、otel-config.yamlに設定内容を記述します。 その設定内容でecs-default-config.yamlを上書きします。

FROM amazon/aws-otel-collector:latest
COPY ./conf/otel-config.yaml /etc/ecs/ecs-default-config.yaml
extensions:
  health_check:

receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317
      http:
        endpoint: 0.0.0.0:4318
  awsxray:
    endpoint: 0.0.0.0:2000
    transport: udp

processors:
  memory_limiter:
    check_interval: 1s
    limit_mib: 50
  batch/traces:
    timeout: 1s
    send_batch_size: 50

exporters:
  awsxray:

service:
  extensions: [health_check]
  pipelines:
    traces:
      receivers: [otlp, awsxray]
      processors: [memory_limiter, batch/traces]
      exporters: [awsxray]
  • receivers: トレースデータの受信についての設定
  • processors: データを圧縮し、データ送信に必要な発信接続数を削減するためのバッチ処理を行なうための設定
  • exporters: トレースデータの送信先についての設定

ECSのタスク定義では、上記の2ファイルから作成したイメージを使用します。 タスクの実行ロールにAWSXRayDaemonWriteAccessを付与する必要があります。 下記はタスク定義の一部です。

{
  "containerDefinitions": [
    {
      "command": ["--config=/etc/ecs/ecs-default-config.yaml"],
      "essential": true,
      "healthCheck": {
        "command": ["/healthcheck"],
        "interval": 5,
        "retries": 5,
        "startPeriod": 1,
        "timeout": 6
      },
      "image": "xxxxxxx.dkr.ecr.ap-northeast-1.amazonaws.com/otel-collector:latest",
      "name": "aws-otel-collector"
    }
  ]
}

完成したもの

ローカル環境(Jaeger)

localhost:16686にアクセスすると、このようなトレースログを確認することができます。

実際のトレースログは都合上お見せできないので、画像は差し替えてます。

開発環境(AWS X-Ray)

AWSマネジメントコンソールでX-Rayの画面を見てみると、このように処理時間とCloudWatchから抽出したログを確認することができます。

まとめ

導入してみた感想としては、トレースID、スパンIDなどのOpenTelemetryの専門用語や概念から学ぶ必要があり大変でした。 ですが、勉強になることもたくさんあったのでやってみてよかったし、無事に導入できて安心しました。 このトレースログを活用して、ボトルネックの特定に役立てば良いかと思います。 また、当記事が私と同じようにOpenTelemetryを導入することになった人の役に立てば幸いです!

参考