🕌

OpenTelemetry+Go 計装サンプル大全 with Cloud Trace ~意外なつまづきポイントを添えて~

2024/02/26に公開

はじめに

こんにちは。クラウドエース株式会社で主にアプリケーション開発を担当している水野です。

今回は、Go 言語でトレース計装する際のサンプル集をご紹介します。
OpenTelemetry は、アプリケーションのオブザーバビリティを実現するための OSS です。

対象読者

手動計装は、アプリケーションコードを煩雑化してしまう可能性もあるため
できるだけ最小限な構成で手軽に導入したい」という方もいると思います。
そんな方に すぐ活用できる!ような記事となっています。

使用技術

  • Golang
  • OpenTelemetry
  • Google Cloud
    • Cloud Trace
    • Cloud Logging
    • Cloud Pub/Sub
    • Cloud SQL

方針

  • アプリケーション実装は最小限にする(ライブラリの力をできるだけ借りる)
  • 計装箇所
    • API (incoming, outgoing)
      • http
      • grpc
    • RDB
      • sql
    • メッセージキュー
      • publish
      • subscribe
        alt text
  • その他
    • ログとの連携

OpenTelemetryのセットアップ

まずは、トレース計装するための OpenTelemetry のセットアップです。
主に以下のような設定をしています。(各設定の説明は割愛します)

tracer.go
import (
    "context"
    "log"

    texporter "github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/trace"
    "go.opentelemetry.io/contrib/detectors/gcp"
    "go.opentelemetry.io/otel"
    "go.opentelemetry.io/otel/propagation"
    "go.opentelemetry.io/otel/sdk/resource"
    sdktrace "go.opentelemetry.io/otel/sdk/trace"
    semconv "go.opentelemetry.io/otel/semconv/v1.9.0"
)
// 呼び出し側はtp.Shutdown()でクリーンアップを行う
func InitializeTracer() *sdktrace.TracerProvider {
    ctx := context.Background()
    exporter, err := texporter.New(texporter.WithProjectID("PROJECT_ID"))
    if err != nil {
    	log.Println(err)
    }

    res, err := resource.New(ctx,
    	resource.WithDetectors(gcp.NewDetector()),
    	resource.WithAttributes(
    		semconv.ServiceNameKey.String("アプリケーションサービス名"),
    	),
    )
    if err != nil {
    	log.Fatalln(err)
    }
    tp := sdktrace.NewTracerProvider(
        // エキスポーターをCloud Traceに設定
    	sdktrace.WithBatcher(exporter),
    	sdktrace.WithResource(res),
    )
    otel.SetTracerProvider(tp)
    // ここを設定しないとトレース伝搬されない
    otel.SetTextMapPropagator(propagation.TraceContext{})
    return tp
}

API (outgoing request)

alt text

http

OpenTelemetry が公式で提供している opentelemetry-go-contrib/instrumentation という OSS があり、以下のようなライブラリがあります。

イメージとしては、このようなトレースを取得できます。
alt text
こちらは、/api/v1/employee/attendances/:dateというAPIのリクエスト内で、/api/v1/attendances/detail/api/v1/attendance/:attendanceIDの外部APIを呼び出しています。

incoming

Gin を使用したサンプルをご紹介します。
以下のコードを追加するだけです。簡単!

main.go
func main() {
    r := gin.Default()
    // Request.Context()をgin.Contextに引き渡す
+   r.ContextWithFallback = true
+   r.Use(otelgin.Middleware("アプリケーションサービス名"))
    r.GET("/ping", ping)
    r.Run()
}

func ping(c *gin.Context) {
    c.JSON(200, gin.H{
    	"message": "pong",
    })
}

outgoing

net/http を使用したサンプルをご紹介します。
こちらも以下のコードを追加するだけです。簡単!

request.go
func DoRequest(ctx context.Context) {
    req, err := http.NewRequestWithContext(ctx, "GET", "https://example.com", nil)
    if err != nil {
        log.Fatal(err)
    }
+   client := http.Client{
+       Transport: otelhttp.NewTransport(http.DefaultTransport),
+   }
    resp, err := client.Do(req)
    if err != nil {
        log.Fatal(err)
    }
    resp.Body.Close()
}

grpc

OpenTelemetry が公式で otelgrpc を提供していますので、こちらのサンプルをご紹介します。
こちらも修正が最小限でシンプルですね。

incoming

server.go
s := grpc.NewServer(
+  grpc.StatsHandler(otelgrpc.NewServerHandler()),
)

outgoing

client.go
conn, err := grpc.Dial("127.0.0.1:7777",
    grpc.WithTransportCredentials(insecure.NewCredentials()),
+   grpc.WithStatsHandler(otelgrpc.NewClientHandler()),
)
if err != nil {
    log.Fatalf("did not connect: %s", err)
}
defer func() { _ = conn.Close() }()

RDB

alt text

sql

残念ながら OpenTelemetry が公式でサポートしていません。
そのため、サードパーティライブラリをご紹介します。

  • sqlcommenter(Googleが提供しているライブラリ)
  • XSAM/otelsql(OpenTelemetryチームからレビューを受けたライブラリ)
  • uptrace/otelsql(Uptraceが提供しているライブラリ)

今回は sqlcommenter を使用したサンプルをご紹介します。
こちらも簡単な修正で導入ができます。

client.go
dbURI := "root:root@tcp(127.0.0.1:3306/dbName)?parseTime=true"
- dbPool, err := sql.Open("mysql", dbURI)
+ dbPool, err := gosql.Open("mysql", dbURI, sqlcommentercore.CommenterOptions{})

トレースはこのように取得できます。
alt text

さらに、sqlcommenterは、Cloud Trace と Cloud SQL の Query Insights を自動連携してくれるため、以下のような調査が簡単にできます。これは地味に嬉しいですね。

  • 問題のある SQL クエリがリクエスト全体にどれくらい影響でているか
  • アプリケーションのどこが原因で SQL クエリに問題がでているか

メッセージキュー(Pub/Sub)

alt text
残念ながら OpenTelemetry が公式でサポートしていません。また、適切なサードパーティライブラリも現状ないため、自力で実装する必要があります。
でも、ご安心ください。1,2行修正するだけです。

詳しい内容の解説は、Google 山口さんの記事が丁寧にまとまっています。よろしければこちらもご覧ください。
https://zenn.dev/google_cloud_jp/articles/20230626-pubsub-trace

こちらでは、基本的にGoogle 山口さんの記事から必要な部分を抜粋して、ご紹介します。
以下、サンプルです。

publisher

msg := pubsub.Message{Data: m}
...(中略)...
if msg.Attributes == nil {
    msg.Attributes = make(map[string]string)
}
+ otel.GetTextMapPropagator().Inject(ctx, propagation.MapCarrier(msg.Attributes))

subscriber

if msg.Attributes != nil {
+   propagator := otel.GetTextMapPropagator()
+   ctx = propagator.Extract(ctx, propagation.MapCarrier(msg.Attributes))
}

そして、このようなトレースが取得できます。
alt text

その他

ログとの連携

ログとの連携を行うことで以下のようなことができます。

  • リクエスト内のどのタイミングでログが出力されたか
  • リクエスト内で出力したログのグループ化

今回は、Cloud Logging を使用したサンプルをご紹介します。
OpenTelemetry のライブラリでトレースとスパンを取得し、そこに構造化ログを紐づけるカタチです。

logger.go
func NewLogger(ctx context.Context) *slog.Logger {
    l := slog.New(slog.NewJSONHandler(os.Stdout, nil))
    return setTrace(ctx, l)
}

func setTrace(ctx context.Context, l *slog.Logger) *slog.Logger {
    projectID := "PROJECT_ID"
    // ここでスパンとトレースを取得
    span := trace.SpanFromContext(ctx)
    sc := span.SpanContext()
    if !sc.IsValid() {
    	return l
    }
    traceStr := fmt.Sprintf("projects/%s/traces/%s", projectID, sc.TraceID().String())
    return l.With(
        // ログにトレースを紐づける
    	slog.String("logging.googleapis.com/trace", traceStr),
        // スパンも紐づけたい場合
    	slog.String("logging.googleapis.com/spanId", sc.SpanID().String()),
    )
}

Cloud Logging での詳しい解説は、公式ドキュメントをご覧ください。
https://cloud.google.com/trace/docs/trace-log-integration?hl=ja

このようにトレースが取得でき、リクエスト内のどのタイミングでログが出力されたかが明確になります。
alt text
また、トレースIDでログを絞り込みすることで、エラーになったリクエスト内でどのエラーログを出力しているかも一目で分かります。
alt text

おわりに

Go 言語でトレース計装する際のサンプル集をご紹介しました。
手動計装でも最小限のアプリケーション変更でトレース導入できそうですね。ぜひ皆さんも試してみてください。

Discussion