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-opentelemetry
とopentelemetry-rust
では同じ名前の関数が存在する場合があり、どちらを使用するかを明確に意識していないと、トレースが正常に出力されないという問題に遭遇する可能性があります。この点にご注意ください。
トレースの取得
用語の整理(トレース)
いざ実装しようという前に、これから出てくる用語の整理をします。今ここで読んでもわからないと思うので、実装しながら理解を深めたら良いと思います。
Tracer
- Span生成を担う
- 逆にSpanは
Tracer
しか生成できない
TracerProvider
SpanProcessor
- スパンの出力するタイミングを制御を担う
BatchSpanProcessor
でまとめて送ることが多いように見える- 出力手段として
Exporter
を指定する
SpanExporter
- テレメトリデータを標準出力もしくは外部へ出力するのが
Exporter
であり、とりわけスパンを出力を担うのがSpanExporter
である - OpenTelemetryが定めたプロトコル(OTLP)用のエクスポーターや各ベンダーへ出力するためのエクスポーターなどを指定する
- テレメトリデータを標準出力もしくは外部へ出力するのが
こちらの用語はOpenTelemetry Specificationsで定められていてどの言語でも変わらないため、理解しておくと他のアプリケーションに対してもOpenTelemetryの計装のハードルが下がります。
トレース取得するまでの流れ
Rustのcrateの関係や流れは、わかりやすく説明してくださっています。
引用:RustでOpenTelemetry: Tracerの設定を仕様から理解する
引用:RustでOpenTelemetry: Tracerの設定を仕様から理解する
これもわかりやすいですが、自分の理解のために自分の言葉として説明します。
まずは、Opentelemetryが用意しているクレートを用いて、基本的なOpenTelemetryの設定をします。
SpanExporter
を設定する(opentelemetry-otlp
)- OpenTelemetry CollectorへはOTLPで通信するためOTLPエクスポーターを使う
TracerProvider
を設定する(opentelemetry
)- エクスポーターには1で設定したのを指定する
SpanProcessor
としてはバッチ出力するものを指定するResource
としてサービス名などを指定する
TracerProvider
からTracer
を出力する(opentelemetry-sdk
)
次に、tracing
が用意しているクレートを用いて、先ほどのOpenTelemetryの設定を反映させます。
TracingSubscriber
のOpenTelemetryLayer
にtracer
を設定する(tracing-opentelemetry
)tracing-opentelemetry
がopentelemetryからtracingへ変換してくれているTracingSubscriber
とはtracing
クレートのログ出力を制御するもので、例えばフィルタリングしたり、JSONにフォーマットしたりできる
TracingSubscriber
をグローバルに設定する
最後にスパンを出力する箇所を指定します。
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のログとして紐づけてくれるところも便利です。
コンテキスト伝搬
トレースを出力することは無事できました。しかし、API連携しているサービスでそれぞれトレースを出力させても、1つのトレースとしてつながりません。 なぜならトレースで重要なコンテキスト伝搬をしていないからです。本節ではその実装の解説をします。
用語の整理(コンテキスト伝搬)
- コンテキスト(Context)
- スパンを1つのトレースとして繋げるために受け渡しする情報のこと
- W3Cが定めているTrace Contextの規格に沿って設定していることが多い
- コンテキスト伝搬(ContextPropagation)
- コンテキストを別のサービスへ伝搬させること
- HTTPを使っているならヘッダーとして、キューを使っているならキューのKeyValue値などを使って伝搬させる
- Inject
- コンテキスト伝搬できるように生成したコンテキスト情報をヘッダーなどに付与すること
- Extract
- コンテキスト伝搬できるようにヘッダーなどからコンテキスト情報を抽出すること
- この行為をすることによって別サービスから送られてきたトレースIDを紐づけることができる
コンテキスト伝搬する流れ
Injectする場合の流れは以下の通りです。
TextMapPropagator
を設定する- TextMapPropagatorとはキーバリュー値を伝搬させるためのもの
- トレースの場合は
TraceContextPropagator
を設定すればOK
- Inject用の関数を用意する
- 初期化した
TextMapPropagator
を取得する - PropagatorAPIに実装されているinjectを使う
- 初期化した
- リクエストを送る前にヘッダーにInjectする
- リクエストを送る前にヘッダーを設定し、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を参考にすると良いと思います
- actix-webでルーティングを設定する箇所に
- reqwest-tracing
- こちらも公式に書いてある通り、
client
を生成する際にreqwest-tracing
が提供するTracingMiddleware
を入れてビルドし、そのclient
を使ってリクエストを送れば自動で注入する - コードを確認する限り、適切にコンテキストの注入が行われています。
- こちらも公式に書いてある通り、
まとめ
長くなりましたが、以上が今回OpenTelemetryをRustで実装した際に私が学んだことの備忘録記事です。記事を書く中で自分のOpenTelemetryの整理にもなったのでよかったです。ただし、まだ安定版が出ていないのか人気がないからなのか、参考記事は少なく、Github上のサンプルを覗いたりして苦労しました。他の方がそうならないようにこれをベースにもっと改良していただければと思います。
読んでくださった方の参考のために自分の学習で作ったサンプルアプリケーションを公開しておきます。拙いコードではありますが、少しでも参考になれば幸いです。
最後まで読んでいただきありがとうございました。
参考記事
- OpenTelemetryについて
- OpenTelemetry✖️Rust