🐾

Google Cloud で頑張ってトレースする


TL;DR

  • トレースする際はアプリ間で traceparent を受け渡す
  • CloudRun を使う際は標準化されているヘッダーでなく独自ヘッダーを使うと良い

前提

  • Google Cloud 環境で goで作成したアプリのトレースをする

基礎知識

OpenTelemetry とは

  • 分散システムやアプリケーションのパフォーマンスや動作を観測するためのオープンソースのツールキットです。
  • ログ、メトリクス、トレースといった観測データを収集、処理、エクスポートするための標準化されたフレームワークを提供します。
  • データ送信先は AWS X-Ray、DataDog、Zipkin などがあります。
  • Google Cloud では Trace エクスプローラで閲覧できます。

トレースを実現するデータ構造

  • 1つのトレースに対し、複数の Span を保持する構造になっています。
  • Span が持つデータは下記になります。
- SpanID(4e9caf3181b6960d)
- 親SpanID(34f5b537ec9b47d0)
- Span開始時間(2024-12-10T01:59:45.804Z)
- Span終了時間(2024-12-10T02:59:45.804Z)
- TraceID(2a5575cb97bf1a7df321a08666119d33)
  • Span は親子関係なのでツリー上に連結できます。
  • コード上では下記のようにするだけで階層的な span データを作成してくれます。
ctx1, span1 := otel.Tracer.Start(ctx, "処理1")
// 処理中1
ctx2, span2 := otel.Tracer.Start(ctx1, "処理2")
// 処理中2
span2.End()
span1.End()

トレースにログを関連付けるためのフィールド

  • 下記情報を付与することで Trace と関連付けてくれます。
{
  ...
  trace: "projects/{project_id}/traces/2a5575cb97bf1a7df321a08666119d33"
  spanId: "f15607489bb19ff9"
  traceSampled: true
  ...
}
// 例
logger.InfoContext(ctx, "処理中")

複数アプリで Trace を関連付ける仕組み

  • W3C で HTTP ヘッダーを利用して関連付ける方法が規格化されているのでこれを利用します。
    • traceparent ヘッダー
      • https://www.w3.org/TR/trace-context/#examples-of-http-traceparent-headers
      • トレース情報を保持します。
      • 形式は{version}-{trace-id}-{parent-id}-{trace-flags}
        • 現在、version は 00 のみ
        • parent-id は親スパンを一意に識別する ID
        • sampled-flags はサンプリングの有無を示し、トレース毎にサンプリングするしないを判断します。
      • traceparent: 00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01
    • tracestate ヘッダー
      • 異なるベンダーやサービスが独自の情報を保持します。
      • tracestate: congo=t61rcWkgMzE
  • https://qiita.com/sukatsu1222/items/82819461921deba761b9
  • CloudRun で試した限り tracestate は利用してなさそうなので、traceparent さえ伝搬できればトレースができます。

アプリケーションでどのように実現するか

  • 初期化処理(https://cloud.google.com/trace/docs/setup/go-ot?hl=ja#config-otel)
    • 基本フィールドの設定(サービス名・インスタンス ID など)
    • 送信方法の設定(逐次 or バッチで送信する)
    • サンプリングの設定(基本は親 span に従うがなければ常に計測 or 常にサンプリングするなど)
    // 設定可能な値
    "always_on": AlwaysOnSampler // 常にサンプリングする
    "always_off": AlwaysOffSampler
    "traceidratio": TraceIdRatioBased
    "parentbased_always_on": ParentBased(root=AlwaysOnSampler) // traceparentがあれば従い、なければ常にサンプリングする
    "parentbased_always_off": ParentBased(root=AlwaysOffSampler)
    "parentbased_traceidratio": ParentBased(root=TraceIdRatioBased)
    "parentbased_jaeger_remote": ParentBased(root=JaegerRemoteSampler)
  • リクエスト受信時の処理(https://cloud.google.com/trace/docs/setup/go-ot?hl=ja#server)
    • リクエストヘッダーから traceparent、tracestate を取得し Context に詰めます。
    • context の情報を基に子 span を作成して context を更新します。
    • 他アプリにリクエストする際に context の情報をヘッダーに詰めます。

Google Cloud の各種サービスでどのように利用するか

  • CloudRun で試すと tracestate は空だったので、traceparent を他サービスに伝搬させられれば計測できます。
  • 各種サービス
    • CloudRun
      • ヘッダーで traceparent を渡します。
    • PubSub
      • Pull 型はライブラリオプションを有効にするだけで機能しそうです。
      • Push 型は上記オプションを利用しても機能しなかったので、message の Attribute に traceparent を渡します。
        • オプションで push 先へのリクエスト時に traceparent ヘッダーが作成されることを期待しましたが、そういう挙動ではなさそうです。
    • Workflow
      • 引数や環境変数で traceparent を渡します。

CloudRun の挙動にあわせてトレースできるようにする

  • CloudRun は動きが特殊だったので、その問題点と対応を列挙しておきます。

問題その1.設定したサンプリングレートが反映されない

  • 挙動確認するために parentbased_always_on を全てのアプリに設定し、すべてのリクエストをトレースを試みましたが一部しかされませんでした。
  • どうやら CloudRun にリクエストされたタイミングで traceparent ヘッダーが発行されており、それに従ってサンプリングレートが決まってしまっているようでした。
  • この traceparent はアプリ実装に関係なく、インフラ側で自動的に発行されているようです
  • サンプリングは 10 秒ごとに 1 リクエストの割合になっており、設定の変更はできません。

対応

  • トレース開始する CloudRun では parentbased 設定をせずにサンプリングレートを設定します。
// NewTracerProviderのオプション
trace.WithSampler(trace.AlwaysSample())

問題その2.トップの Span が欠損する

  • リクエスト契機のトレースにおいて最上位の Span が取得できませんでした。

対応

  • 諦めました。見栄えは悪いですがトレース観点では問題にならない想定です。

問題その3.アプリを跨いだ時に Span が欠落し、階層構造が崩れる

  • 親アプリから子アプリへリクエストした際、リクエスト受信のタイミングで親 Span がない状態でトレースに表示されるようになりました。
  • 動きとしては、子アプリがリクエスト受信時に自動的に作成される(アプリの実装関係なくインフラが自動発行する) Span がサンプリングされない時がありそうです。
    • つまり、子アプリが受け取る traceparent ヘッダーに含まれる parent-id(親 spanID)がトレース側で確認できないために階層構造が崩れます。
    • 本来なら traceparent ヘッダーの trace-flag に基づいてサンプリングされるべきなのだが、そうなっていないようです。
      • trace-flag はヒントには利用するが遵守するわけではなさそうです
      • 恐らく CloudRun が自動的に発行する Span は無料になっているので、一定制限を掛けているのではないかと思います。

対応

  • traceparent ヘッダーとは異なるヘッダーで traceparent の受け渡しをします。
  • 初期化処理について、NewTextMapPropagatorの実装をみると、トレースするだけなら下記のように書き換えることができます。
- otel.SetTextMapPropagator(autoprop.NewTextMapPropagator())
+ otel.SetTextMapPropagator(propagation.TraceContext{})
// 初期化処理
- otel.SetTextMapPropagator(propagation.TraceContext{})
+ otel.SetTextMapPropagator(WrapTraceContext{})

// Wrapperの定義
type WrapTraceContext struct {
  propagation.TraceContext
}

func (w WrapTraceContext) Inject(ctx context.Context, carrier propagation.TextMapCarrier) {
  w.TraceContext.Inject(ctx, WrapTextMapCarrier{carrier})
}

func (w WrapTraceContext) Extract(ctx context.Context, carrier propagation.TextMapCarrier) context.Context {
  return w.TraceContext.Extract(ctx, WrapTextMapCarrier{carrier})
}

type WrapTextMapCarrier struct {
  propagation.TextMapCarrier
}

const customHeaderPrefix = "custom-"

func (w WrapTextMapCarrier) Set(key, value string) {
  // traceparentとtracestateにおいて、オリジナルのヘッダーとカスタムのヘッダーの両方に値をセットする
  w.TextMapCarrier.Set(key, value)
  w.TextMapCarrier.Set(customHeaderPrefix+key, value)
}

func (w WrapTextMapCarrier) Get(key string) string {
  // traceparentとtracestateにおいて、カスタムヘッダーを優先して取得する
  customHeaderVal := w.TextMapCarrier.Get(customHeaderPrefix + key)
  if customHeaderVal != "" {
    return customHeaderVal
  }
  return w.TextMapCarrier.Get(key)
}

参考