OpenTelemetryを使ったエンドツーエンドトレーシング

Nicolas Fränkel

Nicolas Fränkel

August 31, 2022

Ecosystem

マイクロサービスを実装するかどうか(そしておそらく実装すべきではない)に関わらず、あなたのシステムはおそらく複数のコンポーネントで構成されています。最もシンプルなシステムは、リバースプロキシ、アプリケーション、データベースで構成されているでしょう。この場合、監視は良いアイデアであるだけでなく、必須です。リクエストが通過するコンポーネントの数が多ければ多いほど、その必要性は高まります。

しかし、監視は旅の始まりに過ぎません。リクエストが大量に失敗し始めたとき、すべてのコンポーネントにわたる集約されたビューが必要です。これはトレーシングと呼ばれ、_オブザーバビリティ_の柱の一つです。他の2つはメトリクスとログです。

この記事では、トレースに焦点を当て、オブザーバビリティへの旅を始める方法について説明します。

W3C Trace Context仕様

トレーシングソリューションは、異なる技術スタック間で動作する標準フォーマットを提供するべきです。そのようなフォーマットは、正式な仕様またはデファクトスタンダードに準拠する必要があります。

仕様が突然現れることは稀であることを理解する必要があります。一般的に、市場にはすでにいくつかの異なる実装が存在しています。ほとんどの場合、新しい仕様は追加の実装を引き起こします。有名なXKCDコミックがそれを描いています:

the famous XKCD comic describes

しかし、時には奇跡が起こります:市場が新しい仕様に従うことがあります。ここで、Trace ContextはW3Cの仕様であり、それがうまくいったようです:

この仕様は、分散トレーシングシナリオを可能にするコンテキスト情報を伝播するための標準HTTPヘッダーと値のフォーマットを定義します。この仕様は、サービス間でコンテキスト情報がどのように送信され、変更されるかを標準化します。コンテキスト情報は、分散システム内の個々のリクエストを一意に識別し、プロバイダー固有のコンテキスト情報を追加および伝播する手段も定義します。 -- https://www.w3.org/TR/trace-context/

このドキュメントから2つの重要な概念が浮かび上がります:

  • トレースは、複数のコンポーネントにまたがるリクエストのパスを追跡します
  • スパンは単一のコンポーネントにバインドされ、親子関係によって別のスパンにリンクされます

![トレースとスパン](https://static-site.apiseven.com/wp-content/uploads/2022/08/trace-spans-1024x393.png "トレースとスパン" "トレースとスパン")

この記事を書いている時点で、この仕様はW3Cの推奨事項であり、これは最終段階です。

Trace Contextにはすでに多くの実装があります。そのうちの一つがOpenTelemetryです。

OpenTelemetryをゴールデンスタンダードとして

ITの運用部分に近ければ近いほど、OpenTelemetryについて聞いたことがある可能性が高くなります:

OpenTelemetryは、ツール、API、SDKのコレクションです。これを使用して、ソフトウェアのパフォーマンスと動作を分析するためのテレメトリデータ(メトリクス、ログ、トレース)を計測、生成、収集、エクスポートします。OpenTelemetryは、いくつかの言語で一般的に利用可能であり、使用に適しています。 -- https://opentelemetry.io/

OpenTelemetryはCNCFが管理するプロジェクトです。OpenTelemetryの前には2つのプロジェクトがありました:

  • OpenTracing:その名の通り、トレースに焦点を当てていました
  • OpenCensus:メトリクスとトレースを管理することを目的としていました

両プロジェクトが統合され、ログが追加されました。OpenTelemetryは現在、オブザーバビリティに焦点を当てた一連の「レイヤー」を提供しています:

  • さまざまな言語での計測API
  • さまざまな言語での正規の実装
  • コレクターなどのインフラストラクチャコンポーネント
  • W3CのTrace Contextなどの相互運用性フォーマット

OpenTelemetryはTrace Contextの実装ですが、それ以上のことを行います。Trace ContextはHTTPに限定されていますが、OpenTelemetryはKafkaなどの非Webコンポーネントをまたがるスパンを許可します。これはこのブログ記事の範囲外です。

ユースケース

私のお気に入りのユースケースはeコマースショップなので、それを変更しないことにしましょう。この場合、ショップはマイクロサービスを中心に設計されており、それぞれがREST APIを介してアクセス可能で、APIゲートウェイの背後で保護されています。ブログ記事のためのアーキテクチャを簡素化するために、2つのマイクロサービスのみを使用します:catalogは製品を管理し、pricingは製品の価格を処理します。

ユーザーがアプリに到着すると、ホームページがすべての製品を取得し、それぞれの価格を取得して表示します。

複数のコンポーネントをまたがるリクエストフローの例

さらに興味深くするために、catalogはKotlinでコーディングされたSpring Bootアプリケーションであり、pricingはPython Flaskアプリケーションです。

トレーシングにより、リクエストのパスをゲートウェイ、両方のマイクロサービス、そして可能であればデータベースをまたがって追跡できるようにする必要があります。

ゲートウェイでのトレース

エントリーポイントはトレーシングの最も興味深い部分です。なぜなら、トレースIDを生成するべきだからです:この場合、エントリーポイントはゲートウェイです。デモを実装するためにApache APISIXを使用します:

Apache APISIXは、ロードバランシング、ダイナミックアップストリーム、カナリアリリース、サーキットブレーカー、認証、オブザーバビリティなどの豊富なトラフィック管理機能を提供します。-- https://apisix.apache.org/

Apache APISIXはプラグインアーキテクチャに基づいており、OpenTelemetryプラグインを提供します:

opentelemetryプラグインは、OpenTelemetry仕様に従ってトレーシングデータを報告するために使用できます。このプラグインは、HTTPを介したバイナリエンコードされたOLTPのみをサポートします。-- https://apisix.apache.org/docs/apisix/plugins/opentelemetry/

opentelemetryプラグインを設定しましょう:

apisix:
  enable_admin: false #1
  config_center: yaml #1
plugins:
  - opentelemetry #2
plugin_attr:
  opentelemetry:
    resource:
      service.name: APISIX #3
    collector:
      address: jaeger:4318 #4
  1. デモを簡単に追跡できるように、Apache APISIXをスタンドアロンモードで実行します。これは本番環境でも良いプラクティスです
  2. opentelemetryをグローバルプラグインとして設定します
  3. サービスの名前を設定します。これはトレース表示コンポーネントに表示される名前です
  4. トレースをjaegerサービスに送信します。次のセクションで説明します。

すべてのルートをトレースしたいので、各ルートにプラグインを追加する代わりに、プラグインをグローバルに設定するべきです:

global_rules:
  - id: 1
    plugins:
      opentelemetry:
        sampler:
          name: always_on #1
  1. トレーシングはパフォーマンスに影響を与えます。トレースするほど、パフォーマンスへの影響が大きくなります。したがって、パフォーマンスへの影響とオブザーバビリティの利点を慎重にバランスを取るべきです。しかし、デモではすべてのリクエストをトレースしたいと思います。

トレースの収集、保存、表示

Trace ContextはW3Cの仕様であり、OpenTelemetryはデファクトスタンダードですが、市場にはトレースを収集、保存、表示するための多くのソリューションが存在します。各ソリューションは、これら3つの機能のすべてを提供する場合もあれば、一部のみを提供する場合もあります。例えば、Elasticスタックは保存と表示を処理しますが、収集には他のものに頼る必要があります。一方、JaegerZipkinは、これら3つの機能をすべて満たす完全なスイートを提供します。

JaegerとZipkinはOpenTelemetryよりも前から存在しているため、それぞれ独自のトレーストランスポートフォーマットを持っています。ただし、OpenTelemetryフォーマットとの統合も提供しています。

このブログ記事の範囲では、正確なソリューションは重要ではありません。必要なのは機能だけです。私はJaegerを選択しました。なぜなら、すべての機能を1つのDockerイメージに含むオールインワンイメージを提供しているからです。すべての機能にはそれぞれのコンポーネントがありますが、同じイメージに組み込まれているため、設定がはるかに簡単になります。

イメージの関連ポートは次のとおりです:

ポートプロトコルコンポーネント機能
16686HTTPクエリフロントエンドを提供
4317HTTPコレクターOpenTelemetry Protocol (OTLP) over gRPCを受け入れる(有効な場合)
4318HTTPコレクターOpenTelemetry Protocol (OTLP) over HTTPを受け入れる(有効な場合)

Docker Composeの設定は次のようになります:

services:
  jaeger:
    image: jaegertracing/all-in-one:1.37 #1
    environment:
      - COLLECTOR_OTLP_ENABLED=true #2
    ports:
      - "16686:16686" #3
  1. all-in-oneイメージを使用します
  2. 非常に重要:OpenTelemetryフォーマットでコレクターを有効にします
  3. UIポートを公開します

インフラストラクチャを設定したので、アプリケーションでトレースを有効にすることに集中できます。

Flaskアプリでのトレース

pricingサービスはシンプルなFlaskアプリケーションです。データベースから単一の製品の価格を取得するための単一のエンドポイントを提供します。

@app.route('/price/<product_str>')                           #1-2
def price(product_str: str) -> Dict[str, object]:
    product_id = int(product_str)
    price: Price = Price.query.get(product_id)               #3
    if price is None:
        return jsonify({'error': 'Product not found'}), 404
    else:
        low: float = price.value - price.jitter              #4
        high: float = price.value + price.jitter             #4
        return {
            'product_id': product_id,
            'price': round(uniform(low, high), 2)            #4
        }
  1. エンドポイント
  2. ルートは製品のIDを必要とします
  3. SQLAlchemyを使用してデータベースからデータを取得します
  4. 実際の価格エンジンは時間とともに同じ価格を返すことはありません。楽しみのために価格を少しランダム化しましょう

警告:1回の呼び出しで単一の価格を取得することは非常に非効率的です。製品の数だけ呼び出しが必要ですが、トレースをより興味深くするために行っています。実際の運用では、ルートは複数の製品IDを受け入れ、1つのリクエスト-レスポンスで関連するすべての価格を取得できるべきです。

次に、アプリケーションを計測する時が来ました。2つのオプションがあります:自動計測と手動計測です。自動計測は低労力で即効性がありますが、手動計測は開発時間を集中させる必要があります。私は、まず自動計測から始め、必要に応じて手動計測を追加することをお勧めします。

いくつかのPythonパッケージを追加する必要があります:

opentelemetry-distro[otlp]==0.33b0
opentelemetry-instrumentation
opentelemetry-instrumentation-flask

いくつかのパラメータを設定する必要があります:

pricing:
  build: ./pricing
  environment:
    OTEL_EXPORTER_OTLP_ENDPOINT: http://jaeger:4317 #1
    OTEL_RESOURCE_ATTRIBUTES: service.name=pricing #2
    OTEL_METRICS_EXPORTER: none #3
    OTEL_LOGS_EXPORTER: none #3
  1. トレースをJaegerに送信します
  2. サービスの名前を設定します。これはトレース表示コンポーネントに表示される名前です
  3. ログやメトリクスには興味がありません

次に、標準のflask runコマンドの代わりに、それをラップします:

opentelemetry-instrument flask run

これだけで、メソッド呼び出しやFlaskルートからのスパンを収集できます。

必要に応じて、手動で追加のスパンを追加することもできます。例えば:

from opentelemetry import trace

@app.route('/price/<product_str>')
def price(product_str: str) -> Dict[str, object]:
    product_id = int(product_str)
    with tracer.start_as_current_span("SELECT * FROM PRICE WHERE ID=:id", attributes={":id": product_id}) as span: #1
        price: Price = Price.query.get(product_id)
    # ...
  1. 設定されたラベルと属性を持つ追加のスパンを追加します

Spring Bootアプリでのトレース

catalogサービスは、Kotlinで開発されたReactiveなSpring Bootアプリケーションです。2つのエンドポイントを提供します:

  • 1つは単一の製品を取得するため
  • もう1つはすべての製品を取得するため

両方とも、まず製品データベースを参照し、その後、上記のpricingサービスに価格を問い合わせます。

Pythonと同様に、自動計測と手動計測を活用できます。まず、低いハードルの自動計測から始めましょう。JVMでは、エージェントを介してこれを実現します:

java -javaagent:opentelemetry-javaagent.jar -jar catalog.jar

Pythonと同様に、すべてのメソッド呼び出しとHTTPエントリーポイントのスパンを作成します。また、JDBC呼び出しも計測しますが、Reactiveスタックを使用しているため、R2DBCを使用します。記録のために、サポートを追加するためのGitHub issueが開かれています。

デフォルトの動作を設定する必要があります:

catalog:
  build: ./catalog
  environment:
    APP_PRICING_ENDPOINT: http://pricing:5000/price
    OTEL_EXPORTER_OTLP_ENDPOINT: http://jaeger:4317 #1
    OTEL_RESOURCE_ATTRIBUTES: service.name=orders #2
    OTEL_METRICS_EXPORTER: none #3
    OTEL_LOGS_EXPORTER: none #3
  1. トレースをJaegerに送信します
  2. サービスの名前を設定します。これはトレース表示コンポーネントに表示される名前です
  3. ログやメトリクスには興味がありません

Pythonと同様に、手動計測を追加することでゲームを上げることができます。2つのオプションがあります:プログラムによるものとアノテーションベースのものです。前者は少し複雑ですが、Spring Cloud Sleuthを導入しない限りは少し手間がかかります。アノテーションを追加しましょう。

追加の依存関係が必要です:

<dependency>
    <groupId>io.opentelemetry.instrumentation</groupId>
    <artifactId>opentelemetry-instrumentation-annotations</artifactId>
    <version>1.17.0-alpha</version>
</dependency>

注意:このアーティファクトは最近io.opentelemetry:opentelemetry-extension-annotationsから移動されました。

これで、コードにアノテーションを追加できます:

@WithSpan("ProductHandler.fetch")                                               //1
suspend fun fetch(@SpanAttribute("id") id: Long): Result<Product> {             //2
    val product = repository.findById(id)
    return if (product == null) Result.failure(IllegalArgumentException("Product $id not found"))
    else Result.success(product)
}
  1. 設定されたラベルを持つ追加のスパンを追加します
  2. パラメータを属性として使用し、キーをidに設定し、値はパラメータの実行時値です

結果

これで、シンプルなデモを操作して結果を見ることができます:

curl localhost:9080/products
curl localhost:9080/products/1

レスポンスは興味深いものではありませんが、Jaeger UIを見てみましょう。両方のトレースが見つかります。1つは呼び出しごとです:

Jaeger UIでのトレースのリスト

単一のトレースのスパンを詳しく見ることができます:

単一のトレースを構成するすべてのスパン

上記のUML図がなくても、シーケンスフローを推測できることに注意してください。さらに良いことに、シーケンスはコンポーネント内部の呼び出しを表示します。

各スパンには、自動計測によって追加された属性と、手動で追加した属性が含まれています:

スパンの属性

結論

この記事では、APIゲートウェイ、異なる技術スタックに基づく2つのアプリケーション、およびそれぞれのデータベースをまたがるリクエストを追跡することで、トレーシングを紹介しました。トレーシングの表面だけをかすめました:実際の世界では、トレーシングにはKafkaやメッセージキューなどのHTTPに関連しないコンポーネントが含まれる可能性があります。

それでも、ほとんどのシステムは何らかの形でHTTPに依存しています。設定は簡単ではありませんが、それほど難しくもありません。コンポーネントをまたがるHTTPリクエストをトレースすることは、システムのオブザーバビリティへの旅を始める良いスタートです。

この記事の完全なソースコードはGitHubにあります。

さらに進むために:

Tags: