🚁

Go + OpenTelemetryで複数サービスのトレースを連携してみる!🚗

2024/05/30に公開

OpenTelemetryとは🔍

概要

OpenTelemetryに関する細かい解説記事は他にたくさんあるので、ここでは概要だけを簡単に紹介します。

公式ドキュメントによると以下のように言及されています。

OpenTelemetry is an Observability framework and toolkit designed to create and manage telemetry data such as traces, metrics, and logs. (OpenTelemetryはObservabilityフレームワークであり、トレース、メトリクス、ログなどの遠隔測定データを作成・管理するためのツールキットです。)

トレースやメトリクス、ログなどのシステムの状態を把握するためのデータを生成、管理するためのフレームワークのようです。OpenTelemetryの特徴として、上記のデータの作成や管理を主な機能として提供していて、それらの可視化は他のツールを使う必要があるということが挙げられます。また、コードを変更して、トレースやメトリクス、ログなどを収集するようにすることを計装と呼びます。

さらにOpenTelemetryは各種データをどういった形式で提供するのかの仕様やそれらのデータを生成、管理するためのAPIやSDKなども提供してくれており、本記事執筆の段階では以下の言語がサポートされています (データの種類によっては開発中のものもあります)。

  • C++
  • .NET
  • Erlang/Elixir
  • Go
  • Java
  • JavaScript
  • PHP
  • Python
  • Ruby
  • Rust
  • Swift

最新の情報はこちらを参考してもらえればと思います。

本記事は、言語としてはGo、集めるデータとしてはトレース、データの可視化としてJaegerを使用してみます。

Jaeger

Jaegerは分散トレーシングの可視化ツールです。
今回はメイン言語がGoということで、Goで作られた可視化ツールを選びました。

複数サービスのトレースを連携してみる!

今回のサンプル実装では、サービスを2つ立てます。それぞれのサービスの概要は以下のようにしてみます。

  • サービス1 (bookサーバー)
    • 提供するAPI: GET /books/:id (idに対応した本の詳細を取得する)
  • サービス2 (authorサーバー)
    • 提供するAPI: GET /authors/:id (idに対応した著者の詳細を取得する)

curl コマンドを使い /books/:id のAPIにアクセスし、bookサーバーがauthorサーバーの /authors/:id のAPIにアクセスするという流れにしてみます。

フレームワークとしてはginを使用し、計装ライブラリとしては https://github.com/open-telemetry/opentelemetry-go-contrib/tree/main/instrumentation/github.com/gin-gonic/gin/otelgin を使用しました。

今回は複数サービスのトレーシングを追うことにフォーカスしたいため、パフォーマンスなどに関する考察や議論は行いません。

サンプルの実装は https://github.com/k3forx/opentelemetry においてあります。

まずbookサーバーの実装だけしてみる

まずは分散トレーシングがどんなものか見るためにbookサーバーだけ実装して、トレースを可視化してみます。スパンは各アーキテクチャ層 (handler, usecase, repository) で生成するようにしています。

結果はこちらになります。

1つのトレースの中に複数のスパンが生成されていて、親子関係になっているのが分かると思います。

Jaegerでのトレースの見方を少しだけ解説しておきます。

  • 左側の段々になっている部分は親のスパンから子のスパンが生成されて親子関係ができていることを示しています
  • 右側にはスパン名や任意で付与できるタグの情報などが表示されています
    • 例えばhandler層で生成されたスパン (handler) には id=1name=GetByID などのメソッド名を表示させるようにしています)
  • 各スパンの開始時間と全体の時間経過もわかるようになっています
    • 例えばrepository層で生成されたスパン (repository) はリクエストの処理がスタートしてから48μs経過してから呼び出され、14.91msまで処理に時間がかかっていることがわかります
  • 各層が全体のリクエストのうちどれくらいを占めているは青緑の線の上の黒い線によって示されています
    • repository層が処理のほとんどを占めているのでわかりにくいですが、よく見るとusecase層の最初の部分やhandler層の最後の部分が少しだけ黒くなっていることがわかると思います

そして数値からもグラフからも分かるようにAPIリクエストにかかった時間のうち、repository層での処理が大部分を占めていることがわかると思います。

authorサーバーの実装もしてみる

続いてauthorサーバーの実装もしてみます。bookサーバーで本の内容を取得したのちに、著者の情報を取得するためにauthorサーバーにHTTPリクエストを投げます。

実際にbookサーバーにリクエストを投げた結果のトレースは以下のように形になりました。

  • bookサーバー側のトレース

  • authorサーバー側のトレース

着目したいのは、bookサーバーのトレースのusecaseとrepositoryの部分です。先ほどのbookサーバーのみを実装してトレースをとった場合と比べて、repositoryを呼んだ後のusecaseの処理が少しだけ時間がかかっています (先程のbookサーバーのみの実装と比べてusecase層の処理の最後の黒い部分が長くなっているいます)。おそらくこの部分がHTTPリクエストを行っている部分だと推測できます。

ここでいよいよ本題なのですが、bookサーバーとauthorサーバーのトレースが紐づけて、もう少し詳細にトレースを追えるようにしてみます。

Context Propagation (コンテキストの伝搬)

複数サービスでトレースを紐づけるために「コンテキストの伝搬」という概念が重要になってきます。「コンテキスト」と「伝搬」という概念についてもう少し詳しく見てみます。

Context

公式ドキュメントを引用してきました。

Context is an object that contains the information for the sending and receiving service, or execution unit, to correlate one signal with another.
(コンテキストとは、送受信サービスや実行ユニットが、ある信号と別の信号を関連付けるための情報を含むオブジェクトのことである。)

ここでいう信号 (signal) とはトレース、ログ、メトリクスの総称のことです。つまり、送受信サービスがあるトレースと別トレースを関連づけるための情報を含むオブジェクトと理解できそうです。

Propagation

こちらも公式ドキュメントを引用してきました。

Propagation is the mechanism that moves context between services and processes. It serializes or deserializes the context object and provides the relevant information to be propagated from one service to another.(プロパゲーションは、サービスとプロセス間でコンテキストを移動するメカニズムである。コンテキストオブジェクトをシリアライズまたはデシリアライズし、あるサービスから別のサービスに伝搬される関連情報を提供します。)

上記のコンテキストオブジェクトをサービス間やプロセス間で移動させる仕組みのことをpropagation (伝搬) と呼ぶようです。


もう少しだけpropagationについて深掘りしておきます。

先ほどの公式ドキュメントには、以下のような記述もあります。

Propagation is usually handled by instrumentation libraries and is transparent to the user. In the event that you need to manually propagate context, you can use Propagation APIs. (伝播は通常、計装ライブラリによって処理され、ユーザーには透過的である。手動でコンテキストを伝播する必要がある場合は、Propagation APIを使用することができます。)

OpenTelemetry maintains several official propagators. The default propagator is using the headers specified by the W3C TraceContext specification.(OpenTelemetry はいくつかの公式プロパゲータを保持しています。デフォルトのプロパゲータは W3C TraceContext 仕様で指定されたヘッダを使用しています。)

基本的にはpropagationは計装のライブラリによって処理されることが多く、開発者が意識することはなさそうですが、もし手動でcontext propagationを行う必要がある場合は、Propagation APIを利用するとよさそうです。

また、OpenTelemetryは公式のpropagatorをメンテしており、デフォルトのpropagatorはHTTPリクエストのヘッダーでコンテキストの伝搬を行うようになっており、その仕様はW3C TraceContextに記載があるそうです。

Go/ginでの実装

実際にGoや今回使用しているフレームワークのginではどのように実装されている、実装すれば良いのでしょうか?

まずは比較的分かりやすいginの実装から見てみます。今回の使用したライブラリは https://github.com/open-telemetry/opentelemetry-go-contrib/tree/main/instrumentation/github.com/gin-gonic/gin/otelgin です。

このライブラリの Middleware 関数の実装を見てみるとそれっぽい箇所を見つけました。

        // リクエストのヘッダーから何かをExtractして、context.Contextを生成
        ctx := cfg.Propagators.Extract(savedCtx, propagation.HeaderCarrier(c.Request.Header))

        // 省略

        // 生成されたcontext.Contextを使ってスパンをスタート
        ctx, span := tracer.Start(ctx, spanName, opts...)
        defer span.End()

        // gin.Context.Requestを、生成したcontext.Contextを使用したRequestに更新
        c.Request = c.Request.WithContext(ctx)

つまり、HTTPリクエストのヘッダーにトレースに関する情報が埋め込まれていた場合、それらをExtractしてcontext.Contextに埋め込み、後続の処理で使用するといった流れになっていそうです。

今回のケースではauthorサーバーがリクエストを受ける側なので、リクエストのヘッダーにトレースに関する情報があれば、あとはMiddleware関数がうまくやってくれて、後続の処理のトレースやスパンと紐付けができそうです。


リクエストのヘッダーにトレースに関する情報を付与するためにはどのような実装を行えば良いのでしょうか?

こちらに関しては上記のginのフレームワークのコードを見ても分からなかったので、Propagators APIを読んでみました。

リクエストのヘッダーにあるトレースの情報を抜き出すというExtract関数がありましたが、逆のことを行うInject関数も定義されています。

type TextMapPropagator interface {
    // Inject set cross-cutting concerns from the Context into the carrier.
    Inject(ctx context.Context, carrier TextMapCarrier)
    // 他のメソッド
}

OpenTelemetryのGoでの実装を見ると、このTextMapPropagatorインターフェイスを実装しているのはTraceContext構造体であることがわかります。

type TraceContext struct{}

var (
	_           TextMapPropagator = TraceContext{}
)

つまり、bookサーバーに対して以下のようにpropagatorとしてTraceContext構造体を使うことを明示しつつ、

	otel.SetTextMapPropagator(propagation.TraceContext{})

Inject関数を使って、HTTPリクエストを送る際のヘッダーにトレースの情報を埋め込むと良さそうです。

	var headerCarrier propagation.HeaderCarrier = map[string][]string{}
	otel.GetTextMapPropagator().Inject(ctx, headerCarrier)

	req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("http://author-server:8081/authors/%d", id), nil)
	if err != nil {
		return author_model.Author{}, fmt.Errorf("new request error: %w", err)
	}
	req.Header = http.Header(headerCarrier)

bookサーバーとauthorサーバーのトレースの紐付きをみてみる

上記の実装を行なってみて、トレースを紐付けた結果が以下になります。ちゃんとbookサーバーとauthorサーバーのトレースが紐づいているようです🎉🎉🎉

GET /books/:id をリクエストした時に一番時間がかかっているのは、bookサーバーのrepository層での処理というのが分かりやすくなりました。その次にauthorサーバーのrepository層、bookサーバーのusecase層で処理がかかっていそうです。

このように複数サービスのトレースを紐づけてそれを可視化すると、複数サービスでどこがパフォーマンスのボトルネックになっているのか、どのサービスがどのサービスと連携しているのかなど分散システムに関する有益な情報がたくさん得られることがわかりました。

今後やっていきたいこと

個人的には今後ネイティブアプリとバックエンドAPIがどのように連携しているか (e.g. どの画面でどのAPIが呼ばれているのか)、画面が表示されるまでに時間がかかっている原因はアプリにあるのかAPIにあるのかなどバックエンドサーバーに限らず、フロントのシステムとうまく連携できると良いのかなと考えていたりします。

実際に社内のFlutterエンジニアのadrienさんと協力してお試しで実装してみた結果がFlutter x OpenTelemetry 入門にまとまっていたりするので興味がある方はぜひ読んでいただければと思います。

各言語やフレームワーク、監視SaaSが独自にこういった解析ツールを提供するのではなく、OpenTelemetryというOSSで共通化された仕様に沿って計装を行えば良いというのがOpenTelemetryの大きな強みと言えると思いました。

Discussion