logo
kmnky blog

RustでOpenTelemetryを計装

はじめに

今年のはじめに掲げた目標のとおり、RustアプリケーションへOpenTelemetryを導入し、トレースを出力するための学習を行いました。本記事はその備忘録です。

対象読者は「RustアプリケーションにOpenTelemetryを導入したい方」を想定しています。RustやOpenTelemetry、さらにその基盤にあるオブザーバビリティ関連の用語については最低限の説明に留めていますので、ご了承ください。

OpenTelemetry

概要

OpenTelemetryは、オブザーバビリティに関するテレメトリーデータの標準規格および取得用ライブラリを提供するプロジェクトです。共通仕様があることでベンダーロックインを回避できるだけでなく、複数ツール間でテレメトリーを相関付けた分析が容易になります。従来から同様の流れはありましたが、OpenTelemetryの登場により事実上の標準となりました。

詳細は本題から外れるため、最後の「参考記事」に関連ドキュメントを列挙しています。興味があればご参照ください。

Rustでの開発状況

執筆時点では、OpenTelemetryの開発状況はすべてBetaで、トレースのための正式な安定版はリリースされていません。 その主な要因の一つとして、トレースの取得方法がOpenTelemetryのAPIを使用するか、tokioが提供しているtracingクレートを使用するかが定まっていないことが挙げられます。この点についてはGitHub Issueで継続的に議論されており、現時点では方針が確定していないようです。

本記事では、tracingクレートおよびopentelemetryと連携させるtracing-opentelemetryを利用することを前提に記載します。採用した理由としては、

  • すでにtracingクレートを利用していたものがあった
  • アノテーションやトレースとログの紐付けなど何かと便利だと思った
  • tokioから提供されている

からです。参考にした、RustでOpenTelemetryと似たような理由になりました。

実装時に気をつけたい点として、opentelemetry-rustクレートとtracingクレートの両方を参照することがあります。tracing-opentelemetryopentelemetry-rustでは同じ名前の関数が存在する場合があり、どちらを使用するかを明確に意識していないと、トレースが正常に出力されないという問題に遭遇する可能性があります。この点にご注意ください。

トレースの取得

用語の整理(トレース)

いざ実装しようという前に、これから出てくる用語の整理をします。今ここで読んでもわからないと思うので、実装しながら理解を深めたら良いと思います。

  • Tracer
    • Span生成を担う
    • 逆にSpanはTracerしか生成できない
  • TracerProvider
    • Tracerの生成を担い、Tracerの共通設定を行う
    • 設定できるもの
  • SpanProcessor
    • スパンの出力するタイミングを制御を担う
    • BatchSpanProcessorでまとめて送ることが多いように見える
    • 出力手段としてExporterを指定する
  • SpanExporter
    • テレメトリデータを標準出力もしくは外部へ出力するのがExporterであり、とりわけスパンを出力を担うのがSpanExporterである
    • OpenTelemetryが定めたプロトコル(OTLP)用のエクスポーターや各ベンダーへ出力するためのエクスポーターなどを指定する

こちらの用語はOpenTelemetry Specificationsで定められていてどの言語でも変わらないため、理解しておくと他のアプリケーションに対してもOpenTelemetryの計装のハードルが下がります。

トレース取得するまでの流れ

Rustのcrateの関係や流れは、わかりやすく説明してくださっています。 crateの関係 引用:RustでOpenTelemetry: Tracerの設定を仕様から理解する

Traceの生成からexportまでの流れ 引用:RustでOpenTelemetry: Tracerの設定を仕様から理解する

これもわかりやすいですが、自分の理解のために自分の言葉として説明します。

まずは、Opentelemetryが用意しているクレートを用いて、基本的なOpenTelemetryの設定をします。

  1. SpanExporterを設定する(opentelemetry-otlp
    1. OpenTelemetry CollectorへはOTLPで通信するためOTLPエクスポーターを使う
  2. TracerProviderを設定する(opentelemetry
    1. エクスポーターには1で設定したのを指定する
    2. SpanProcessorとしてはバッチ出力するものを指定する
    3. Resourceとしてサービス名などを指定する
  3. TracerProviderからTracerを出力する(opentelemetry-sdk

次に、tracingが用意しているクレートを用いて、先ほどのOpenTelemetryの設定を反映させます。

  1. TracingSubscriberOpenTelemetryLayertracerを設定する(tracing-opentelemetry
    1. tracing-opentelemetryがopentelemetryからtracingへ変換してくれている
    2. TracingSubscriberとはtracingクレートのログ出力を制御するもので、例えばフィルタリングしたり、JSONにフォーマットしたりできる
  2. TracingSubscriberをグローバルに設定する

最後にスパンを出力する箇所を指定します。

  1. tracingの機能であるアノテーションを用いて設定する

計装

まずは1〜3のOpenTelemetryの部分です。

use opentelemetry::trace::TracerProvider;
use opentelemetry_otlp::WithExportConfig;
use opentelemetry_sdk::trace::{SdkTracerProvider, Tracer};
 
// tracerを初期化する関数
pub fn init_tracer(service_name: &str) -> (Tracer, SdkTracerProvider) {
 
    // OTLPエクスポーターを設定
    let otlp_exporter = opentelemetry_otlp::SpanExporter::builder()
        .with_tonic()
        .with_endpoint("http://otel-collector:4318")
        .build()
        .expect("Failed to build the span exporter");
 
    // TracerProviderの設定
    let provider = SdkTracerProvider::builder()
        .with_resource(
            Resource::builder()
                .with_service_name(service_name.to_string()) // サービス名を付与
                .build(),
        )
        .with_batch_exporter(otlp_exporter) // BatchProcessorを指定
        .build();
    global::set_tracer_provider(provider.clone());
 
    // 3. TracerとTracerProviderを返す
    (provider.tracer(service_name.to_owned()), provider)
}

これをTracingに連携させます。

use opentelemetry_sdk::trace::{SdkTracerProvider, Tracer};
use tracing_opentelemetry::OpenTelemetryLayer;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::{fmt, EnvFilter, Registry};
 
pub fn init_telemetry(service_name: &str) -> opentelemetry_sdk::trace::SdkTracerProvider {
 
    // 4. OpenTelemetryLayerにTracerを設定する
    let (tracer, provider) = init_tracer(service_name);
    let telemetry = OpenTelemetryLayer::new(tracer);
 
    // 5. Subscriberを設定する
    let env_filter = EnvFilter::try_from_default_env().unwrap_or(EnvFilter::new("info"));
    let log_format = std::env::var("RUST_LOG_FORMAT").unwrap_or_else(|_| "pretty".to_string());
    let subscriber = Registry::default()
        .with(env_filter)
        .with(telemetry)
        .with(fmt::Layer::default());
    tracing::subscriber::set_global_default(subscriber)
        .expect("Failed to install `tracing` subscriber.");
 
    provider
}

作成したinit_telemetryをアプリケーション開始時に実行することでtracingクレートを使ってトレースを出力できます。 トレースを出力する箇所はtracing::instrumentというアノテーションで指定します。以下、ヘルスチェックを例に計装してみます。

// tracing::instrumentと記載された箇所がスパンとして出力される
#[tracing::instrument()]
#[get("/health")]
async fn health_check() -> impl Responder {
    HttpResponse::Ok().body("OK")
}
 
// instrumentの引数に設定するタグなどを指定することができる(ここではhttp.uriを追加している)
#[tracing::instrument(fields(http.uri = "/db-health", http.status_code = tracing::field::Empty))]
#[get("/db-health")]
async fn db_health_check(pool: web::Data<PgPool>) -> impl Responder {
    info!("SELECT 1");
    match sqlx::query("SELECT 1").fetch_one(&**pool).await {
        Ok(_) => {
            Span::current().record("http.status_code", 200);
            HttpResponse::Ok().body("DB OK")
        }
        Err(_) => {
            error!("Database connection failed");
            HttpResponse::InternalServerError().body("DB Error")
        }
    }
}

tracingクレートを使うメリットは、アノテーションだけでなく、出力するログをSpanのログとして紐づけてくれるところも便利です。

Jaegerでのトレース表示

コンテキスト伝搬

トレースを出力することは無事できました。しかし、API連携しているサービスでそれぞれトレースを出力させても、1つのトレースとしてつながりません。 なぜならトレースで重要なコンテキスト伝搬をしていないからです。本節ではその実装の解説をします。

用語の整理(コンテキスト伝搬)

  • コンテキスト(Context)
    • スパンを1つのトレースとして繋げるために受け渡しする情報のこと
    • W3Cが定めているTrace Contextの規格に沿って設定していることが多い
  • コンテキスト伝搬(ContextPropagation)
    • コンテキストを別のサービスへ伝搬させること
    • HTTPを使っているならヘッダーとして、キューを使っているならキューのKeyValue値などを使って伝搬させる
  • Inject
    • コンテキスト伝搬できるように生成したコンテキスト情報をヘッダーなどに付与すること
  • Extract
    • コンテキスト伝搬できるようにヘッダーなどからコンテキスト情報を抽出すること
    • この行為をすることによって別サービスから送られてきたトレースIDを紐づけることができる

コンテキスト伝搬する流れ

Injectする場合の流れは以下の通りです。

  1. TextMapPropagatorを設定する
    1. TextMapPropagatorとはキーバリュー値を伝搬させるためのもの
    2. トレースの場合はTraceContextPropagatorを設定すればOK
  2. Inject用の関数を用意する
    1. 初期化したTextMapPropagatorを取得する
    2. PropagatorAPIに実装されているinjectを使う
  3. リクエストを送る前にヘッダーにInjectする
    1. リクエストを送る前にヘッダーを設定し、2の関数を呼び出す

Extractもほぼ同様で、リクエストを受け取った後に呼び出すようにすればOKです。

Inject実装例

初期化用の関数を用意し、アプリケーション実行時に呼び出すようにします。

pub fn init_propagation() {
    let trace_propagator = TraceContextPropagator::new();
 
    global::set_text_map_propagator(trace_propagator);
}

Inject用の関数を用意します。

/// OpenTelemetryのコンテキストをヘッダーに注入する
///
/// # 引数
/// * `context` - 注入するOpenTelemetryコンテキスト
/// * `headers` - 注入先のヘッダーマップ
pub fn inject_context(context: &Context, headers: &mut reqwest::header::HeaderMap) {
    opentelemetry::global::get_text_map_propagator(|propagator| {
        propagator.inject_context(context, &mut HeaderInjector(headers));
    });
}

用意した関数を用いて、リクエスト送信前に現状のコンテキスト情報をヘッダーに注入します。

use reqwest::{
    header::{HeaderMap, HeaderValue, AUTHORIZATION},
    Client,
};
use tracing::{info, Span};
use tracing_opentelemetry::OpenTelemetrySpanExt;
 
pub async fn create_user_and_profile(
  // 略
) -> Result<UserResponse, Box<dyn std::error::Error>> {
 
    //略
 
    // headerにContextを注入
    let mut headers = HeaderMap::new();
    inject_context(&Span::current().context(), &mut headers);
 
    // リクエスト送信時にheaderを指定する
    let client = Client::new();
    let profile_response = client
        .post(format!("{}/api/profiles/register/", profile_api_url))
        .headers(headers)
        .json(&json!({
            "username": username,
            "email": email,
            "auth_user_id": user_id
        }))
        .send()
        .await?;
 
    // 略
}

こちらで伝搬されます。

専用クレートを使用して楽しましょう

上記で一つ一つInject/Extractをすれば伝搬できますが大変です。ただ、よく使われるフレームワークには自動で計装してくれるクレートを誰かが開発してくれるため、それを使わない手はないです。

今回のサンプルアプリケーションに当てはめると、以下が使えそうです。

  • tracing-actix-web
    • actix-webでルーティングを設定する箇所に.wrap(tracing_actix_web::TracingLogger::default())を埋め込む
    • actix-webで受信してルーティングする箇所にコンテキストを抽出してくれる
    • 書き方は公式GithubのExampleを参考にすると良いと思います
  • reqwest-tracing
    • こちらも公式に書いてある通り、clientを生成する際にreqwest-tracingが提供するTracingMiddlewareを入れてビルドし、そのclientを使ってリクエストを送れば自動で注入する
    • コードを確認する限り、適切にコンテキストの注入が行われています。

まとめ

長くなりましたが、以上が今回OpenTelemetryをRustで実装した際に私が学んだことの備忘録記事です。記事を書く中で自分のOpenTelemetryの整理にもなったのでよかったです。ただし、まだ安定版が出ていないのか人気がないからなのか、参考記事は少なく、Github上のサンプルを覗いたりして苦労しました。他の方がそうならないようにこれをベースにもっと改良していただければと思います。

読んでくださった方の参考のために自分の学習で作ったサンプルアプリケーションを公開しておきます。拙いコードではありますが、少しでも参考になれば幸いです。

最後まで読んでいただきありがとうございました。

参考記事