今日から分散トレーシングに対応しないといけなくなった人のための opentelemetry-go 入門

こんにちは。SRE/データストアチーム の飯塚です。

私たちのチームではデータベースを代理で操作したり情報を取得したりするサービスをいくつか作り、それをプロダクトチームが利用できるように gRPC 経由で提供しています。ところで、ある日突然「分散トレーシングを活用していくことになったので、あなたのチームのサービスも対応させてください」とお願いされたらどうすればよいでしょうか?私はこれまでにいろいろなカンファレンスで分散トレーシングや OpenTelemetry についての講演を聞いていたので、理念は理解した、便利そうだ、導入してみたい、と思ったことは何度かありました。しかし実際に導入しようとして SDK のドキュメントを開いてみると、理解しなければいけない(ように見える)概念や、使い方をマスターしないといけない(ように見える)API の数に圧倒されてしまい、後回しにしてしまっていました。

今回は一念発起してこれまで私に導入をためらわせていた部分を調べ、具体的に必要なアクションを自分なりに整理することができました。この記事では、読者の皆様が同じように「あなたのチームが作っているアプリケーションを分散トレーシングに対応させてください」と言われたときのために opentelemetry-go を最低限利用可能になる知識を提供します。

何を学ぶ必要があるか?

OpenTelemetry をアプリケーションに導入しようとしたときには大きく分けて2つの処理を書くことになります。

  1. Span の作り込み
  2. Trace の送信に関する設定

Span というのは分散トレーシングの説明でよく出てくる以下のような図のひとつの帯のことです。それぞれの Span は処理の名前や処理の開始、終了時間、その他の補助情報 (attributes) を含みます。複数の Span を関連付けることにより Trace が構成されます。アプリケーションを分散トレーシングに対応させるというのは関心がある部分に Span を作り、Span の関連付けを設定する処理をコードに埋め込むことだといえます。

https://opentelemetry.io/docs/concepts/observability-primer/ より。複数の Span を関連付けることにより Trace がつくられます

ここで 2. の「Trace の送信に関する設定」についてはチームの中で少数の詳しい人だけが知っておくだけでなんとかなるといえるかもしれません。一方で 1. の「Span の作り込み」はアプリケーションを分散トレーシングに対応させようとしたときにはそのアプリケーションの開発者の大半がある程度理解していることが求められます。そうでなければ一部の関数のみしか分散トレーシングに対応していないといったことになりかねません。

そういうわけで 1. の Span の作り込みから先に説明します。

Span の作り込み

実は Trace の送信に関する設定を何も準備していなくても Span の作り込みをするためのコードを書き始めることができるようになっています。送信先が設定されていない状態で Trace を収集するコードをアプリケーションに埋め込んでも壊れるわけではないので安心してください(ただ Trace が記録されないだけです)。アプリケーションを分散トレーシングに対応させることが決まったら、さっそく Span の作り込みを始めましょう。

紛らわしい用語: context

Go 言語で開発をしている人にとって紛らわしい用語のため、先に説明をしておきます。

OpenTelemetry における context は Go 言語の context.Context とは違うもので、trace ID や parent span ID など trace 内での呼び出しで伝播させる必要がある情報のことです。しかしたいへん紛らわしいことに、Go 言語の context.Context はこの情報を格納するのにとても便利なため、context (otel) は結局のところ context.Context (Go) に保存されがちです。

Span を作り込むコードで context.Context を引数に渡している箇所は context (otel) を渡しているのだと考えてください。

tracer を定義する

最初にやることはグローバル変数で tracer を定義することです。パッケージごとに tracer.go に定義するとよいかもしれません。

import (
  "go.opentelemetry.io/otel"
)

var tracer = otel.Tracer("github.com/cybozu-go/deadbeef/foo/bar/baz")

Tracer の役割は以下の3つです。

  • モジュール名を与える(第一引数)
  • この Tracer から作られた Span に共通して与えたい attributes を付与する(この例では利用していない)
  • TracerProvider との関連付け(後述)

第一引数に渡される文字列は instrumentation name と呼ばれ instrumentation が実行されているモジュール(ライブラリ)の名前が入ることが期待されています。あとから Jaeger などで Trace を参照するときに「特定のモジュール(ライブラリ)に関係している Trace を絞り込む」という検索をするのに使えます。Go で書かれたアプリケーションでは パッケージ名を渡すことが推奨されています。よく使われているライブラリでも、たとえば以下のような instrumentation name が渡されています。

  • go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc
  • go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp

OpenTelemetry に入門した人が instrumentation name についてよくやりがちな間違いには以下のようなものがあります。

  • Span の名前をつけてしまう
  • Trace を記録しているサービスやアプリケーションの名前を付けてしまう

Span の名前はこのあと tracer.Start() するときに渡します。また、Trace を記録しているサービスやアプリケーションの名前はあとで TracerProvider を設定するときに resource という仕組みでまとめて設定します。

Tracer に付与する attributes はたとえばモジュール(ライブラリ)のバージョンを付与したりするのに使います。こちらも同様に後述する TracerProvider に対して resource として与えるのが適切な場合も多いため、ここで付与する必要があるものは少ないかもしれません。

Span を生成する

Trace の Span を作りたい処理に行きついたら、さきほどグローバル変数に定義した tracer を使って tracer.Start() を呼び出すことで Span を生成できます。

import (
  "go.opentelemetry.io/otel/attribute"
  "go.opentelemetry.io/otel/trace"
)

func DoSomething(ctx context.Context, foo string, bar string) error {
  ctx, span := tracer.Start(ctx, "DoSomething", trace.WithAttributes(
    attribute.String("foo", foo),
    attribute.String("bar", bar),
  ))
  defer span.End()
  ...
}

先ほどの context について説明でも述べたように、引数に渡している context.Context には trace ID, parent Span ID などの context 情報が埋め込まれています。埋め込まれていなかった場合は作られる Span が root Span になります。戻り値の context.Context には parent Span ID が新しく設定された context 情報が埋め込まれています。

attributes として渡した追加情報は Jaeger などでも参照することができ、その処理がどのようなパラメータで実行されたのか知るのに役立ちます。

span.End() は Span が終わった時間を記録します。呼び出し忘れるとメモリなどのリソースがリークします。

Event や Status の設定

Span の作り込みの基本は上記の2つ(tracer の定義と tracer.Start() の呼び出し)だけです。これだけでも十分有用ですが、さらに作り込みたい場合は次のような機能があります。

まず span.AddEvent でイベントを設定することができます。Span は開始時刻と終了時刻の2つで表されましたが、Event は記録した時刻のみで表されます。Jaeger では Span は帯として表示されたのに対し、Event は点として表示されます。実際に OpenTelemetry を組み込んでいると開始時刻、終了時刻の概念がなく「Span というよりは Event だな~」と感じる瞬間があるかと思うので、そのようなときには Event を使ってもよいかもしれません。

ほかにも Span status を設定 したり error を記録 したりすることもできます。Jaeger では status がエラーの Span はマークが付くようになっているため、適切に設定しておくとエラーの原因を追いかけたりするのに有用かもしれません。

Trace の送信に関する設定

Span の作り込みでは使いこなす必要がある API はそれほど多くありませんでした。一方、Trace の送信に関する設定では以下のようなことに取り組む必要があります。

  • exporter の設定
  • resource の設定
  • sampler の設定
  • TracerProvider の設定
  • context propagator の設定

設定項目が無数にあり圧倒されてしまいがちですが、必要になるまで細かい調整を後回しにして標準的な設定をするだけならそれほど大変ではありません。

exporter の設定

OpenTelemetry の exporter は Prometheus exporter とは違うものです。OpenTelemetry で計測した Signals(Trace, Metric, Log のこと)はさまざまなプロトコル(たとえば trace だけでも otlpgrpc, otlphttp, jaeger, zipkin など)に対応したバックエンドに送信することができるようになっていますが、その送信先、あるいは送信処理を行うモジュールのことを exporter と呼びます。

opentelemetry-go では さまざまな exporter を実装しているため、利用するバックエンドに応じて exporter を設定する必要があります。ここでは otlpgrpc プロトコルを使う場合の説明をします。

otlptracegrpc のドキュメントを読むと gRPC のエンドポイント、証明書の利用の有無、その他接続に関するオプションの設定項目があり気が重くなります。しかしほとんどのユースケースでは何も設定する必要はなく otlptracegrpc.New() とだけ書けばよいです。ほとんどの設定は環境変数で変更可能です。たとえばバックエンドのエンドポイントは OTEL_EXPORTER_OTLP_TRACES_ENDPOINT で与えることができます。

import (
  "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
)

exporter, err := otlptracegrpc.New(ctx)
if err != nil {
    return nil, err
}

resource の設定

resource は Trace の生成者の属性(たとえばホスト名、アプリケーションの名前、バージョンなど)を表明するためのものです。似た要素として Span に対して付与していた attributes がありますが、attributes はそれぞれの Span 固有の情報を入れるものであるのに対し、resource はグローバルな情報を入れるためのものという違いがあります。

設定がおすすめされている resource はたくさんあります が、service.name だけは Required ということになっています。service.name を設定すると Jaeger でも Search タブの一番上にある Service というプルダウンで絞り込めるようになっていて便利なので、必ず指定することをおすすめします。

import (
  "go.opentelemetry.io/otel/sdk/resource"
  semconv "go.opentelemetry.io/otel/semconv/v1.17.0"
)

res := resource.NewWithAttributes(
    semconv.SchemaURL,
    semconv.ServiceName(serviceName),
)

sampler の設定

sampler はバックエンドに送信する Trace をフィルタリングする機能です。全ての Trace を送信するとコストやパフォーマンスに悪影響があるため関心がある Trace だけを送信したい、という用途で使われます。

分散トレーシングを活用する前に適切な設定を予想するのは難しいため、実際にどのような Trace が不要か分かってから調整するという進め方になることが多いのではないでしょうか。Go のコードで何も設定しなかった場合には環境変数 OTEL_TRACES_SAMPLER と OTEL_TRACES_SAMPLER_ARG の値に基づいて設定されます。最初は Go のコードでは何も設定せず、環境変数で調整可能にしておくのがよいのかもしれません。たとえば sdktrace.WithSampler(sdktrace.AlwaysSample()) とコードで設定したくなったときには環境変数で OTEL_TRACES_SAMPLER=always_on を与えることになります。

TracerProvider の設定

TracerProvider は var tracer = otel.Tracer() で作る tracer のインスタンスを生成する factory のようなものです。これまでに設定してきた exporter, resource, sampler は TracerProvider を作るときのオプションとして渡すことで有効化されます。

import (
  "go.opentelemetry.io/otel"
  sdktrace "go.opentelemetry.io/otel/sdk/trace"
)

tp := sdktrace.NewTracerProvider(
    // 今回は sampler を設定しないことにした
    sdktrace.WithResource(res), // resource
    sdktrace.WithBatcher(exporter), // exporter
)
otel.SetTracerProvider(tp)

実は Span を作り込むときに使っていた otel.Tracer() は otel.GetTracerProvider().Tracer() の略で、otel.SetTracerProvider() でグローバル変数に設定された TracerProvider から tracer のインスタンスを生成しています。グローバル変数を使うのは少し心が痛みますが、ほとんどのユースケースではこれで対応できるはずです。もし「このモジュールの Trace は Jaeger に送る。あのモジュールの Trace は Grafana Tempo に送る」のように送信先を使い分けたい場合はグローバルの TracerProvider を使う方法では対応しきれませんが、そのような必要が生じるまでは気にせずに otel.Tracer() を使う方法で対応できるはずです。

WithBatcher() は trace がある程度溜まってからバッチで exporter に送信するというオプションです。WithBatcher() 以外の選択肢としては WithSyncer() というものがありますが、not recommended for production use ということなので気にせず WithBatcher() を使えばよいでしょう。WithBatcher() にはたくさんオプションがあるので(たとえば何件溜まったら送るか、件数が少ないときでも何秒経ったら送るか、など)一通り理解しなければいけないのではと不安になりますが、これについても 環境変数であとからいくらでも設定できる ので心配する必要はありません。

context propagator の設定

Span の作り込みの説明をしたときに、OpenTelemetry における context とは trace ID や parent span ID など Trace 内での呼び出しで伝播させる必要がある情報だと説明しました。context (otel) はよく Go 言語の context.Context に埋め込まれますが、これは同一プロセス内での関数呼び出しでの話で、ネットワーク越しに別のコンポーネントを呼び出す分散トレーシングでは context を HTTP ヘッダなど(この伝達媒体のことを Carrier と呼ぶ)に乗せることになります。

この context の受け渡しの仕組みを抽象化したものを context propagator と呼んでおり、opentelemetry-go ではいくつかの仕様に対応した context propagator が実装されています。opentelemetry-go のコアのパッケージでは W3C Trace Context, W3C Baggage、contrib のパッケージでは B3, AWS X-Ray などの実装が含まれています。利用者はこれらの提供された実装を利用するか、あるいは自作の propagator を作ることになります。

context を Carrier に埋め込む処理を Inject, context を Carrier から取り出す処理を Extract と呼びます。propagator は HTTP や gRPC で外部と通信するときに手動で Inject, Extract することでも使えるのですが、それだと大変すぎるので普通は otelhttp や otelgrpc などのインターセプタに Extract, Inject をお任せします。このとき利用する propagator をインターセプタに明示的に設定しなかった場合は otel.SetTextMapPropagator によりグローバル変数に設定された propagator が利用されるようになっています。もし W3C Trace Context をデフォルトの propagator として利用するなら以下のように設定することになります。

import (
  "go.opentelemetry.io/otel"
  "go.opentelemetry.io/otel/propagation"
)

otel.SetTextMapPropagator(propagation.TraceContext{})

gRPC のインターセプタを設定する

context の Inject, Extract は大変なのでインターセプタを利用するという説明をしました。HTTP サーバー向けには otelhttp, gRPC サーバー向けには otelgrpc というインターセプタが提供されています。otelgrpc は gRPC の metadata から context を取り出して context.Context に埋め込んでくれます。また gRPC のリクエストの処理に対応して自動的に Span を開始、終了してくれます(自動計装)。このときに使われる propagator は前述の通り otel.SetTextMapPropagator() で設定していたものがデフォルトで使われます。

ヘルスチェックのリクエストでも Trace を記録していると量が多くて大変なので フィルターを設定する とよいでしょう。

var traceFilter = filters.None(
  // ここに Trace を記録したくない RPC の条件を列挙する
  filters.FullMethodName("/cybozu.deadbeef.v1.Probe/Ready"),
)

server := grpc.NewServer(
  grpc.ChainUnaryInterceptor(
    otelgrpc.UnaryServerInterceptor(otelgrpc.WithInterceptorFilter(traceFilter)),
    ... // ほかに使いたいインターセプタがあれば
  ),
)

動作確認する

分散トレーシングのバックエンドには有償無償さまざまな選択肢がありますが、アプリケーションを分散トレーシングに対応させたいときにはローカル開発環境で動作確認ができたほうが捗ることが多いと思います。Jaeger は jaegertracing/all-in-one というイメージを提供しており、動作確認に使えるバックエンドを簡単に立ち上げることができます。

www.jaegertracing.io

Docker Compose ならコンテナをひとつ立てるだけ、Kubernetes なら Pod をひとつ立てるだけです。分散トレーシングに対応したアプリケーションを開発したいときにはこのバックエンドを立ち上げられるように準備しておき、いつでも動作確認できるようにしておくのをお勧めします。

おわりに

この記事では opentelemetry-go の利用を始めるのに必要な知識を提供しました。私のチームでの OpenTelemetry の活用はまだまだ始まったばかりなので、これからガシガシ実装して分散トレーシングによって得られる恩恵を享受していきたいです。