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を使用しています。
以下のように組み込むだけで、自動計装が行えるかつ、トレース情報の受け渡しが可能です。
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を使用します。
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を導入することになった人の役に立てば幸いです!
参考
- OpenTelemetryの公式ドキュメント
- Jaeger公式ドキュメント
- open-telemetry-goのわかりやすい解説
- インフラ系参考になる記事
- X-Ray との切り替えに関して役立ちそうな記事
- OpenTelemetryの無難な設定