OpenTelemetry を使って AWS (X-Ray, Cloudwatch Logs) にトレースとログを計装する事に興味があったので調べた内容を記そうと思います。

構成

今回検証してみた構成は下記の様な構成です。AWS を用いた場合 ECS や EKS, Lambda で Go アプリを起動する事が通常ですが、今回は docker-compose で検証しました。ただ ECS, EKS に置き換えるのは比較的簡単だと思います。

        trace post          PutTelemetryRecords
+--------+      +----------------+      +-----------------+
| Go App | -+-> | Otel Collector | ---> |    AWS X-Ray    |
+--------+  |   +----------------+      +-----------------+
            |   +----------------+      +-----------------+
            +-> |   Fluent-Bit   | ---> | Cloudwatch Logs |
                +----------------+      +-----------------+
                                PutLogEvents

ログとトレースの紐づけ

ログは Cloudwatch Logs へ、トレース情報は AWS X-Ray へ転送しますが、このログとトレースを紐付けると、運用する上で追跡が容易になります。この紐づけは AWS の場合は簡単で下記の要件を満たせば紐づけがされます。

  • トレース ID は Otel 形式から X-Ray 形式に変換して (下記 IdOtel2Xray() 関数) トレース送信 (参考: https://zenn.dev/k6s4i53rx/articles/69ef65b84dd799)
  • トレース ID をログに埋め込む (下記 log.Printf() )
  • OpenTelemetry Exporter が初期化の際に毎回用いられる OpenTelemetry リソース作成の際にリソース属性として Cloudwatch Log Group を指定

Go アプリ

早速ですがログとトレース情報を紐づけしつつ AWS に送信する Go アプリのコードの例を記します。このアプリはサイドカーで起動している otel-collector の 4317 ポートに送信し、その otel-collector は AWS X-Ray にトレースを送信。更にログは追加でサイドカー起動している fluent-bit を使って AWS Cloudwatch に送信する想定でコードを書いています。

package main

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

	"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
	"go.opentelemetry.io/otel"
	"go.opentelemetry.io/otel/exporters/otlp/otlptrace"
	"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"
	semconv "go.opentelemetry.io/otel/semconv/v1.10.0"
)

func initTracer() {
        ctx := context.Background()

        collectorAddress := os.Getenv("OTEL_COLLECTOR_ADDRESS")
        if collectorAddress == "" {
                collectorAddress = "otel-collector:4317"
        }

        // gRPCを使用してOpenTelemetry Collectorと通信するクライアントを作成します
        traceClient := otlptracegrpc.NewClient(
                otlptracegrpc.WithEndpoint(collectorAddress),
                otlptracegrpc.WithInsecure(),
        )

        // 作成したクライアントを使用して
        // トレースデータをエクスポートするエクスポーターを作成します
        traceExporter, err := otlptrace.New(ctx, traceClient)
        if err != nil {
                log.Fatalf("Failed to create trace exporter: %v", err)
        }

        // 新しいTracerProviderを作成します
        // これはトレースデータを生成するTracerを作成するためのものです
        tp := trace.NewTracerProvider(
                trace.WithBatcher(traceExporter),
                trace.WithResource(newResource()),
        )

        // 作成したTracerProviderをグローバルなTracerProviderとして設定します
        otel.SetTracerProvider(tp)
        // TraceContextを使用したPropagatorを設定します。
        // これはトレースコンテキストを伝播させるためのものです。
        otel.SetTextMapPropagator(propagation.TraceContext{})
}


func main() {
	initTracer()

    // http ハンドラ
	http.Handle("/", otelhttp.NewHandler(http.HandlerFunc(home), "Home"))

	log.Println("Server is running on port 8080...")
	log.Fatal(http.ListenAndServe(":8080", nil))
}

func home(w http.ResponseWriter, r *http.Request) {
    // Span の設定
	ctx := r.Context()
	tr := otel.GetTracerProvider().Tracer("example")
	_, span := tr.Start(ctx, "about")
	defer span.End()

    // Otel 形式の ID を X-Ray 形式に変換
	traceID := IdOtel2Xray(span.SpanContext().TraceID().String())

	log.Printf("TraceID: %s, About us page", traceID)
	w.Write([]byte("Welcome to the Home Page!"))
}

// OpenTelemetry Exporter が初期化の際に毎回用いられる OpenTelemetry リソース作成
// の際にリソース属性として Cloudwatch Log Group を指定
func newResource() *resource.Resource {
	LogGroupNames := []string{"otel-test"}
	return resource.NewWithAttributes(
		semconv.SchemaURL,
		semconv.AWSLogGroupNamesKey.StringSlice(LogGroupNames),
	)
}

func IdOtel2Xray(OtelId string) string {
	xrayId := "1-" + OtelId[0:8] + "-" + OtelId[8:]
	return xrayId
}

Docker 関連ファイル

下記が docker-compose.yaml です。前述した通り otel-collector と fluent-bit をサイドカーとして起動しています。また AWS に情報を送信するためその両者では AWS 認証を行っています。

version: '3.8'

services:
  go-app:
    build: .
    ports:
      - "8080:8080"
    environment:
      - OTEL_COLLECTOR_ADDRESS=otel-collector:4317
    depends_on:
      - otel-collector

  otel-collector:
    image: public.ecr.aws/aws-observability/aws-otel-collector:latest
    command: ["--config=/etc/otel-agent-config.yaml"]
    volumes:
      - ./otel-agent-config.yaml:/etc/otel-agent-config.yaml
    environment:
      - AWS_ACCESS_KEY_ID=****
      - AWS_SECRET_ACCESS_KEY=*****
      - AWS_REGION=ap-northeast-1
    ports:
      - "4317:4317"

  fluent-bit:
    image: fluent/fluent-bit:latest
    volumes:
      - /var/lib/docker/containers:/var/lib/docker/containers
      - /var/run/docker.sock:/var/run/docker.sock
      - ./fluent-bit.conf:/fluent-bit/etc/fluent-bit.conf
    environment:
      - AWS_ACCESS_KEY_ID=****
      - AWS_SECRET_ACCESS_KEY=****
      - AWS_REGION=ap-northeast-1

下記は Dockerfile です。

FROM golang:1.21 AS builder

WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download

COPY . .
RUN CGO_ENABLED=0 go build -o myapp .

FROM alpine:latest

WORKDIR /root/
COPY --from=builder /app/myapp .

CMD ["./myapp"]

fluent-bit ファイル

下記は fluent-bit.conf です。ここで otel-test という Cloudwatch Log Group を指定していますが、これは予め AWS Console で作成しておきます。

[SERVICE]
    Flush        1
    Daemon       Off
    Log_Level    info
    Parsers_File parsers.conf

[INPUT]
    Name              tail
    Tag               docker.*
    Path              /var/lib/docker/containers/*/*.log
    Parser            docker
    DB                /var/log/flb_docker.db
    Mem_Buf_Limit     5MB
    Skip_Long_Lines   On
    Refresh_Interval  10

[OUTPUT]
    Name              cloudwatch_logs
    Match             *
    region            ap-northeast-1
    log_group_name    otel-test
    log_stream_prefix go-app-
    auto_create_group On

OpenTelemetry ファイル

下記は OpenTelemetry が参照する otel-agent-config.yaml ファイルです。

Reciever として otlp を指定、Exporter として X-Ray を指定。つまり Go アプリからのトレース情報を otel-collector が受け取り AWS X-Ray へ転送する役割を担っています。

receivers:
  otlp:
    protocols:
      grpc:
      http:

processors:
  batch:

exporters:
  awsxray:
    region: 'ap-northeast-1'

service:
  telemetry:
    logs:
      level: "DEBUG"
  pipelines:
    traces:
      receivers:
        - otlp
      exporters:
        - awsxray

動作確認

ブラウザで http://localhost:8080/ へアクセスする。結果として AWS Console の X-Ray の画面を確認すると下記のようにトレース情報が出力されそこからログが追跡出来る事が分かります。

otel

まとめ

今回の Span 設定は http.HandlerFun(home) の home() 関数の中で行いました。よってトレース情報は単純な直線情報となっています。

func home(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	tr := otel.GetTracerProvider().Tracer("example")
	_, span := tr.Start(ctx, "about")
	defer span.End()

	traceID := IdOtel2Xray(span.SpanContext().TraceID().String())

	log.Printf("TraceID: %s, About us page", traceID)
	w.Write([]byte("Welcome to the Home Page!"))
}

通常は http リクエストの処理、DB 書き込み、メモリサーバ読み込み等の処理ごとに設定すべきです。これらの処理ごとに Span を記すことで、各処理ごとに要している時間やログを追跡することが可能になります。