仕様と実装から理解するOpenTelemetryの全体像
分散トレーシングの標準仕様及びFWを提供するOpenTelemetryについて、公式ドキュメントを読めば基本的な概念については理解できると思います。
ただ、実際にContext Propagationがどう行われているのかとか、APIとSDKがどこで実装されているのかとか、裏側までイメージするまで少し時間がかかったのでそのときに色々メモったものをまとめてみたいと思います。
なお、OpenTelemetryとOpenTracing、OpenCensusなどの関連や歴史的な経緯については既に色々記事があるようですので取り上げません。
対象読者
なんとなくOpenTelemetryの全体像は理解しているが、下記の点までは理解できてない
- 各Componentと具体的な実装の結びつき
- Context Propagationが具体的にどう行われるか(≒
parent_span_id
やその他のSpan Contextがどう呼び出し先サービスに伝わるのか) - どのリポジトリがドキュメントのどこを実装しているか
Overview
ざっくりと全体をまとめてみるとこんな感じです。
Signals
OpenTelemetryでは収集されるデータの種類がいくつかあり、それらをまとめてSignalと読んでいます。
Traceが最も代表的なSignalですが、他にもいくつかあります。
- Traces
- Metrics
- Logs
- Baggage
これらはそれぞれ異なる特徴を持っており互いに独立した存在ですが、収集する際のフローは基本的には同じですので、今回はTracesを中心に見ていこうと思います。
(各Signalの詳細については公式ドキュメントを参照)
全体フロー
Traceが生成されてバックエンドに送信される流れを見ていく中で、各ComponentのSpecificationがどこに定義されていて、その実装がどこにあるのかについて見ていくことで理解を深めていこうと思います。
SpecificationとImplementationはセットである
OpenTelemetryは分散トレーシングに関わる各仕様や、具体的な実装を包括的に策定・提供することを目的としたプロジェクトになります。
よって、何かの仕様があれば必ずその実装が存在するという認識をしておくと理解が進みやすい気がしています。
Signalの生成と送信(instrumentation)
主体:各アプリケーションやプラットフォーム(図のMicroservice AやB、ミドルウェアやk8sのようなプラットフォームなど)※auto instrumentの場合はenvoyのようなsidecar proxyが担うこともある
Signalを生成して送信できる状態にすることを「○○(計測対象)をinstrumentする」と呼んでおり、そのためにはいくつか手段があります。
- manual instrumantation
- アプリケーションのコードを変更して手動でinstrumentする方法
- 実際には言語毎に用意されたライブラリ(API & SDK)を使用して実装する
- auto instrumentation
- アプリのコードを書き換えずにinstrumentする方法
auto instrumentationはバイトコードを自動的に注入する方法や、eBPFやsidecar proxyを使用した方法がありますが、そうして自動取得した情報を内部でSpanに変換して送信しているのは同じです。
2022/8/2追記:envoyのようなproxyを経由するパターンではアプリケーション側でheaderを伝播させないといけないので厳密にはmanual instrumentationと呼べるかもしれません。
生成及び送信は基本的にはOpenTelemetry(または3rd party)が提供する言語毎のライブラリを使用しますが、APIとSDKがセットになっており、実装から辿ると何がAPIで何がSDKなのかわからず混乱します。
そのため、まずはSpecificationから見ていきます。
Specification
- API
- コアになるデータ型や各クラスが持つべき関数(とその引数や返り値)などを定義しており、言語に依存しないレベルで仕様を定義
- 詳細な仕様:https://opentelemetry.io/docs/reference/specification/trace/api/
- SDK
- APIと似ているが、もう少し言語仕様に寄った部分の仕様を定義している(Tracerなどの生成方法や設定方法、Span Processor、Span ExporterなどCollectorに関わる仕様など)
- 私自身APIとの明確な違いを見いだせずにいますが、Tracerの取得や破棄など、言語によって実装方法がぶれてきそうな部分についてSDK実装者が迷わないように具体的にinterfaceとして落とし込んでくれているような印象を受けています
- 詳細な仕様:https://opentelemetry.io/docs/reference/specification/trace/sdk/
APIの例として「Spanの生成」の仕様を見てみます。
(勝手に翻訳)
Tracer
以外の、Span
を生成するAPIがあってはいけない(MUST NOT)。
→Tracer
のみがSpan
を生成できる。
…
このAPIは以下のパラメーターを受け入れなければならない:
・Span
の名前(必須)
といった感じで、どのクラス(言語によりclass, struct, etc)がどんなAPIを持たないといけないかを定義してますが、具体的な関数名や型など、言語に依存する部分までは言及してません。ただ、これを見ればSpanを生成するのがTracerであり、具体的にどんなパラメーターを受ける関数があるのかを明確に理解することができます。
続いて、SDKの例としてSpan Exporter
の仕様を見てみます。
(勝手に翻訳)
Export(batch)
読み取り専用Span
をバッチで送信する。この機能を実装するProtocol Exporter
は、通常データをシリアライズして宛先に送信することが期待されている。
…
Export()
は無期限に処理をブロックしてはならず(MUST NOT)、適切なタイムアウトを設定しなければならない(MUST)。タイムアウト超過後、呼び出し結果はエラー(Failure
)を返す。
…
Note: 言語の実装上慣用的な場合は、呼び出し結果は非同期メカニズムやコールバックで返る場合もある。
といった感じです。
implementation
それぞれ実際にどこに実装されているのか見てみます。今回はGoの実装として下記のライブラリを見てみます。
まずはSpanの生成から。Specificationより、Tracer
というstructを探せば見つけられそうです。
// https://github.com/open-telemetry/opentelemetry-go/blob/main/trace/trace.go#L486-L504
Start(ctx context.Context, spanName string, opts ...SpanStartOption) (context.Context, Span)
すぐ見つかりました。GoだとTracer.Start()
でSpan生成が定義されているようです。生成後のattribute追加やその他オペレーションについてはSpan interface
に定義されているので、Speficiation通りの実装になっています。
続いて、Export(batch)
を確認します。Span生成と同様にSpanExporter
というstructを探します。
// https://github.com/open-telemetry/opentelemetry-go/blob/694c9a413da927dc999803c2a6f7f2a4a4465837/sdk/trace/span_exporter.go#L19-L47
ExportSpans(ctx context.Context, spans []ReadOnlySpan) error
具体的な実装(gRPC)はここに実装されていて、内部的にOTLPのClientを使用して送信していることがわかります(OTLPについてはCollector関連で後述)。
こちらも同様にSpecification通りの実装になっていることがわかります。
Contextの伝播(Context Propagation)
SDK&APIの責務としてもう一つ重要なのがContext Propagationです。あるサービスから別のサービスを呼び出したときに、呼び出し元サービス(親)から呼び出し先(子)にSpan等の必要な情報を渡してあげる必要があります。
最初の図でいうところのこのあたりです。
Specification
Context Propagationの前提となるProtocolレベルの仕様はこちらに定義されています。
詳細についてはここでは触れませんが、trace周りの情報を子サービスに伝達するためにRequest Headerを使用する決まりにしています。
Value = 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
base16(version) = 00
base16(trace-id) = 4bf92f3577b34da6a3ce929d0e0e4736
base16(parent-id) = 00f067aa0ba902b7
base16(trace-flags) = 01 // sampled
OpenTelemetryのSpecificationでは、より上位のAPIを定義しています。
Context本体の仕様や、Contextを伝播するPropagatorのAPI仕様について定義しています。
Implementation
実装についても確認してみます。propagatorとかでpackageを辿っても見つけられそうですが、W3C定義のtraceparent
とかで調べてみると一発です。
このあたりにspecification通りの実装が行われていることが確認できます。
Collector
Collectorについて簡単におさらいしておきます。
Collectorは下記のcomponentで構成されています。
- Receiver
- 送信されてきたSignalを受け取り、Collectorが扱える形式に変換する
- 送信元はOpenTelemetry以外にもPrometheus(Metricsの場合)やJaegerなど色々なベンダーの形式が想定される
- Processor
- 取得したSignalの加工やフィルタリングを行う
- 例えば個人情報のフィルタリングなど
- Exporter
- 最終的なSignalを各バックエンドに対応した形式で送信する
Collectorのデプロイはagent(sidecarなどでアプリと同居しlocalhostで通信)か、中央集権的にserviceとしてデプロイする方法があります。
また、Collectorの利用は任意であり、各Microserviceから対応するバックエンドに直接Exportすることも可能です(API&SDKにもExporterありましたね)。ただし、原則Collectorの使用が推奨されています。
Specification
CollectorについてのSpecificationは特に無いですが、Collectorを構成する各コンポーネントはOpenTelemetry Protocol(OTLP)に準拠して通信を行います(前述したSDKのExporterの実装など、他のコンポーネントも一部OTLPに依存しています)。
また、念のため確認しておくと、ProcessorについてはTracing SDKのSpan Processorとは別の概念なので注意です。
- Span Processor(Tracing SDK)
- Spanの開始と終了をhookして属性を追加したりフィルタリングするのに使用
- serverのmiddleware的なイメージで、interfaceを満たすことで自由に処理を差し込むことができる
- Processor(Collectorの構成要素)
- 送られてきたSignalをバックエンドに送信する前にフィルタリングや集計などを行いたい場合に使用
Implementation
OTLPはProtocol Buffersとして定義されているので、下記にproto定義と自動生成された実装があります。
Collectorの実装はこちら
Receiver
Receirver(Trace用)の具体的な実装はこちらにあります。
// https://github.com/open-telemetry/opentelemetry-collector/blob/a831d516f02bb01216c4c1bb6464286840ab45d1/receiver/otlpreceiver/internal/trace/otlp.go
func (r *Receiver) Export(ctx context.Context, req ptraceotlp.Request) (ptraceotlp.Response, error) {
td := req.Traces()
// We need to ensure that it propagates the receiver name as a tag
numSpans := td.SpanCount()
if numSpans == 0 {
return ptraceotlp.NewResponse(), nil
}
ctx = r.obsrecv.StartTracesOp(ctx)
err := r.nextConsumer.ConsumeTraces(ctx, td)
r.obsrecv.EndTracesOp(ctx, dataFormatProtobuf, numSpans, err)
return ptraceotlp.NewResponse(), err
}
ReceiverはSignalの受信だけ行うComponentなので、それ以上のことはせずに次のComponentに渡します。
Processor
Processorについては、今回は例としてBatch Processorを見てみます。
// https://github.com/open-telemetry/opentelemetry-collector/blob/main/processor/batchprocessor/batch_processor.go
func (bp *batchProcessor) processItem(item interface{}) {
bp.batch.add(item)
sent := false
for bp.batch.itemCount() >= bp.sendBatchSize {
sent = true
bp.sendItems(statBatchSizeTriggerSend)
}
if sent {
bp.stopTimer()
bp.resetTimer()
}
}
// ...
// ConsumeTraces implements TracesProcessor
func (bp *batchProcessor) ConsumeTraces(_ context.Context, td ptrace.Traces) error {
bp.newItem <- td
return nil
}
受信したTraceをメモリに蓄積していき、あらかじめ指定したサイズに達したらまとめて後続のComponentに送信するような実装になっています。
Exporter
// https://github.com/open-telemetry/opentelemetry-collector/blob/main/exporter/otlpexporter/otlp.go
func (e *exporter) pushTraces(ctx context.Context, td ptrace.Traces) error {
req := ptraceotlp.NewRequestFromTraces(td)
_, err := e.traceExporter.Export(e.enhanceContext(ctx), req, e.callOptions...)
return processError(err)
}
e.traceExporter
はOTLPのclientなので、受け取ったTraceをそのまま送信するだけの処理になっています。
今回紹介したのはcoreなものだけですが、contribのリポジトリには様々な形式に対応したextensionがありますので、既存のリソースを活用するだけでもかなり柔軟にトレーシングを行うことができそうです。
おわりに
以上、Specification(仕様)とImplementation(実装)という軸でOpenTelemetryを見てみましたが、ドキュメントを読むだけに比べて具体的に理解できた気がします(チュートリアルも何本がやりましたが)。
当然今回取り上げた部分はほんの一部で、他にも多くの仕様や実装(3rd party製のものを含む)がありますので、あとは実際に動かしながら理解を進めていこうと思います。
誤りやご意見など、何かありましたらコメントか@ymtdzzzまでいただければと思います。
Discussion