自分が在籍している会社でKafkaを利用したマイクロサービスが増えてきているので、昔からオブザーバビリティの向上というものにちゃんと着手したかったのだが、最近になってやっと手を動かせる所まで優先度を上げられた。
という訳で、ここしばらくは社内にあるマイクロサービス群にOpenTelemetryによる計装を入れまくっている訳だが、大分可視化が進んできたので、これを社内のメンバーに周知しなければならない。
とは言え、説明したい内容が余りに一般的な知識なので、社内向けのクローズドなドキュメントとして書くのは勿体無いので、オープンなブログの方にまとめることにする。
(会社のテックブログに書いた方がいいのではという話はあるが、仕事っぽくなると面倒臭い……)
基本的にはOpenTelemetryの公式ドキュメントを自分なりに解釈して要点を絞る、という形で解説していくつもりなので、公式ドキュメントは一通り読んで理解したよ、という人は読む必要はない。
オブザーバビリティとは
オブザーバビリティという言葉が昨今広く言及される様になったが、そもそもオブザーバビリティとは何が出来ることを指してるのか。
それはシステムの内部がどの様に動いているかを知らなかったとしても、外形的な監視によってそのシステムが想定した形で動作していることが観測できることを指している。
そして観測内容を通じて、システムに起きている問題や未知の事象に対する疑問に解答できる様なヒントを与えることを目的としている。
昨今の成長したシステムは非常に複雑で、全てのコンポーネントでその内部の挙動を詳しく理解している人、というのは大抵の組織でほとんど存在しない。内部の挙動に関する知識に依存しない形でシステムの動作に対する疑問にある程度答えられる状況を作らなければ、とてもじゃないが運用していく人間が足りなくなってしまう。
そういう訳で、昨今オブザーバビリティという言葉が指すシステム特性が非常に重要になっている。
OpenTelemetryはこの様なオブザーバビリティ実現のために必要な構成要素を定義した仕様であり、それを実装したコンポーネントやライブラリなども含んでいる。
何を観測するのか
OpenTelemetryにおいては、こういったことを可能にするための観測対象を大きく3つに区分している。
- トレース: ある一連の処理を示すSpanとそのSpan同士の関連性を繋げたもの。後述する。
- メトリクス: 一定期間内のアプリケーションやインフラにおける動作情報を数値化したもの。CPU利用率とかメモリ消費とかI/O統計とかが代表的。
- ログ: ある瞬間に起きたことを説明するタイムスタンプ付のメッセージ。アクセスログ、エラーログ、重要な処理が完了したことを示すログ、デバッグログなど。
メトリクスとログに関しては、昔から収集していることが多かったし、その二つを活用した自動アラートの仕組みを構築しているところも多いだろう。新しく理解することが多いのはトレースである。OpenTelemetryもトレースを取得するための規格策定と実装から始まっていて、今はメトリクスやログも含めて総合的に扱えるオブザーバビリティの基盤という形に発展してきた。
という訳で、メトリクスとログについては改めてここでは解説せず、トレースに絞ってより詳しく見ていく。
トレース(分散トレース)とは
ある一つの処理の開始から終了までをトラックしたものをSpanと呼ぶ。そのSpan同士を親子構造もしくはリンクによって繋げて複数の処理の関係性を表現したものがトレースである。
トレースにおいて重要なのは、複数のサービス間を跨ぐ様な処理の関係性も表現できることだ。この表現力のおかげで、あるサービスが受け取ったリクエストが更に内側のマイクロサービスにリクエストを投げてその結果をクライアントに返すといった処理の関係性を示すことができて、一連の処理の中でどういったサービスが関係していて、それぞれにどれぐらいの処理時間がかかっているかを記録することができる。処理の関係性は同期的なリクエストに限った話ではなく、RailsにおけるActiveJobを利用するケースや、SQSなどのキューを介してワーカーに処理を移譲するケースなどの、非同期処理も含まれる。
もしトレースの可視化が無ければ、実際にエンドユーザーが体験するリクエストから結果までの時間の中で、どの処理がボトルネックになっているか、どういったサービスが関係しているかを把握するのがかなり難しくなる。特に非同期処理の場合は、ある一連の処理が終端に到達するまでに実際にどれぐらいの時間がかかっているかを把握するだけでも、それなりの困難を伴う。
マイクロサービスアーキテクチャやサーバーレスコンポーネントの組み合わせでシステムを構成する際には、こういった課題は大きな問題だったが、トレースの取得が一般化したことで大分把握しやすい世界になってきた。
メトリクスやログはある単体のサービスやノードで完結している情報なので、取得して可視化するまでの流れが分かり易いが、トレースにおいては複数のノード間で関係性を伝達する必要があるため、それをどうやって実現しているかを理解しておいた方が良いだろう。
ここからはトレースを実現するための構成要素をより詳しく見ていく。
Span
Spanはある一つの処理単位を表現する。具体的には以下の様な情報を持っている構造体になる。
- Name: Spanの名前。メソッド名とかリクエストURLなんかが入る
- Parent span ID: 親(呼び出し元)になるSpanのID。一連のトレースの視点の場合は空になり、そういったSpanはRoot Spanと呼ばれる
- 開始時刻、終了時刻: 処理の開始と終了を示すタイムスタンプ
- Span Context: トレース全体を示すIDや自身のSpan IDなどを保持する不変のデータ構造
- Attributes: Spanに関係するメタデータを表現するキーバリュー構造
- Events: Spanの中で起きた一つ一つの事象を表現する。Spanの中でも特に重要な処理が開始された記録など
- Span Links: 明確に親子関係ではないが、関連しているSpanを示す。複数の非同期処理を待ち受けている場合など、親が一つに絞れないケースなどを表現できる
- Span Status: そのSpanが成功に終わったかエラーに終わったかを表現する。
特に重要なのは名前とタイムスタンプで、つまりSpanとはある処理の開始時間と終了時間に名前を付けてメタデータを付与したもの、ということになる。
Context Propagation
Context
分散トレーシングにおいて、ある処理Aとある処理BがAPIの境界を跨いでるとする。例えばエンドユーザーから何らかのリスト取得APIがリクエストされたとして(A)、その処理の中でユーザーの認証処理を内部の認証基盤にリクエストして認証する処理がある(B)、という場合を考える。
この時、AとBの処理が関連ある実行単位であることを示すためのデータを保持しているオブジェクトをContextと呼ぶ。実態はキーバリューの形でデータを持っているシンプルなオブジェクトだが、満たさなければいけない仕様やContext操作のために実装しておかなければいけないAPIがOpenTelemetryの仕様に規定されている。
Propagation
Contextを複数のサービスに跨って持ち回るには、それを伝播させる仕組みが必要になる。OpenTelemetry仕様ではその実現のためのPropagator APIが規定されているが、現在仕様がちゃんと定まっていて実装が存在しているのはTextMapPropagatorのみである。
簡単に言えば、HTTPのリクエストヘッダーの様なテキストで表現できるキーバリュー構造を用いてContextオブジェクトを持ち回るためのルールを規定している。
現在、OpenTelemetryで利用するヘッダー構造の仕様としてデフォルトで利用されるのはW3C TraceContextという仕様だ。詳しく知りたい場合はリンク先のW3Cの文書を読んでもらうとして、ここで簡単に説明しておくと、現在この仕様はtrace-idとparent-idとtrace-flagsの持ち方を規定している。trace-idは一連の分散トレース全体を示すユニークなIDで、parent-idは親のSpanを示すユニークなIDで、trace-flagsはトレースがサンプリングされているかとかトレースのレベルなどを表現するいくつかのフラグを表現したものだ。
非常にざっくりした言い方をすると、HTTPのリクエストヘッダやそれに類する仕組みを用いて、ルールに則ってtrace-idとparent-idを伝播させることで、各アプリケーションが自分の処理を表現するSpanを構築する時に、どのトレースに属していて、どのSpanから呼ばれたのかを判断する、ということだ。Spanには多くの情報が付与されるが、Spanは各システムの責任で外部に送信される。トレース全体で最低限持ち回らなければいけないのは2つのIDとフラグだけで、システム間で伝播している実際の情報量はとても小さいので、システム間での情報伝達のオーバーヘッドは小さい。
この様に各Span自体は親のIDを知っているだけで独立したデータ構造で、それをどう表現するかは可視化のためのアプリケーション(Grafana, Jaeger, Zipkin, etc)やWebサービス(Datadog, Splunk, etc)の責任範囲になる。
計装と収集の分離
OpenTelemetryはこういったトレースやメトリックの概念を規定するだけでなく、それらを転送するプロトコルも一緒に規定している。これをOpenTelemetry Protocol (OTLP)と呼ぶ。
現在、OTLPはgRPCとHTTPで計測データを送るための仕様を定義している。
OTLPという仕様のおかげで、各計装ライブラリがデータを可視化基盤に送る時の実装を統一できるし、後述するCollectorを利用してデータ集約してから複数の基盤に転送するといったことが可能になっている。OTLPを利用して直接可視化基盤に対して計装データを送っても良いが、転送効率やトレースメトリクスの収集などを考慮すると、Collectorを利用してデータを送信する構成を取るのが推奨される。
OpenTelemetry Collector
OpenTelemetryのコンポーネント群として用意してくれているベンダー非依存な計装データ転送のための仕組みがOpenTelemetry Collectorだ。Collectorはこれまでに解説してきたトレースだけでなく、メトリクスやログも扱うことができる。
似たコンポーネントを挙げて表現するなら、fluentdみたいなものと言って良いかと思う。
Collectorは以下の様な構成要素を持つ。それぞれの構成要素はプラガブルになっており、必要に応じて選択して利用できる。
receiver
データを受付ける責務を受け持つ。OTLPが基本になるがCollectorはメトリクスやログも扱うし、既存のサービスからのexportも受け付けることを想定しているので、公式でメンテナンスされているだけでも大量のreceiverが存在する。
例えば、snmpやsyslogやjmxなどログやメトリクスの出力としてよく利用されるものに加えて、Kafkaを利用してデータを集約するためのkafkareceiverなども存在する。
exporter
データを外部に出力する責務を受け持つ。これもOTLPが基本でOTLPを直接受付けられる可視化基盤であればこれだけで十分だが、AWSのcloudwatch logsに出力したり、syslogに送ったり、datadogやsplunkの様な外部サービスに送ったりといったことに対応できる様になっている。
processor
データを加工したり、フィルタして除外したりする時に利用する。
SpanのAttributesに含まれる情報を利用して、送信先をコントロールしたり、不要な情報を除外したり、表記を書き換えたりなどを行う。
OpenTelemetryは仕様のバージョンがよく更新されているので、ライブラリによっては同じsemanticを表すAttributeが微妙に異なっていたりする。こういったものを正規化する目的などにも利用できる。
また、一定期間のデータをメモリ上に蓄積してまとめて処理するためのbatch processorやメモリ消費を一定に抑えるためのmemorylimiterなどもprocessorに含まれている。
connector
processorに似ているが、内部的にはexporterとreceiverとして扱える様になっている。
例えば、トレースメトリクスの収集などに利用する。トレースメトリクスはSpanの秒間リクエスト数や所要時間の統計など、トレースに関連して発生するメトリクスのことだ。connectorを利用するとSpanをbatch processorで集約し、connectorに渡すことでトレース情報から生成したメトリクスをメトリクス送信のパイプラインに繋げるといったことができる。datadogやgrafana cloud向けにそういった処理を行うconnectorが提供されている。
設定例
OpenTelemetry Collectorはyamlで設定を記述する。
receivers: otlp: protocols: grpc: endpoint: 0.0.0.0:4317 http: endpoint: 0.0.0.0:4318 processors: memory_limiter: check_interval: 1s limit_percentage: ${env:MEMORY_LIMITER_LIMIT_PERCENTAGE:-75} spike_limit_percentage: ${env:MEMORY_LIMITER_SPIKE_LIMIT_PERCENTAGE:-20} batch: exporters: kafka: brokers: - ${env:KAFKA_BROKER_0}:9092 - ${env:KAFKA_BROKER_1}:9092 - ${env:KAFKA_BROKER_2}:9092 protocol_version: 2.1.0 partition_traces_by_id: true producer: compression: zstd service: pipelines: traces: receivers: [otlp] processors: [memory_limiter, batch] exporters: [kafka]
こんな感じで、OTLPで受付けたトレースデータをKafka Brokerに転送するといった処理を記述できる。
ocb (OpenTelemetry Collector Builder)
OpenTelemetryはGoで実装されたシングルバイナリのコンポーネントなので、ビルド済みのものとして配布されているものには多くのプラグインが含まれている。
もし、必要なものが既に分かっていて、より小さいバイナリが欲しい場合は、ocbというツールを使うことで必要なプラグインだけに絞ったビルドを作成することができる。 また、自分で実装した独自のプラグインを組込むためにも利用できる。
利用方法は難しくないので、リンク先を読めば使い方は大体分かるだろう。
提供されているプラグインを知るには
GitHubのリポジトリを確認するのが一番早い。 Collector本体のリポジトリと、オプショナルで提供されているcontribリポジトリを参照すると何が提供されているかが分かる。
アプリケーション上での計装方法
大きく分けて自動計装と手動計装があり、自動計装でかなりの範囲がカバーできるのでそちらを利用することが多いと思うが、登場する概念を先に説明するために手動計装から解説する。
手動計装
OpenTelemetryは各言語向けにAPIとSDKが用意されており、メジャーな言語は大体カバーされている。
ここではRubyのSDKを利用して手動でSpanを作成する処理を公式ドキュメントから引用する。
まず、SDKのgemをインストールする。
gem install opentelemetry-sdk
or
source "https://rubygems.org" gem "opentelemetry-sdk"
次にTracerを準備するコードを書く。
# If in a Rails app, this lives in config/initializers/opentelemetry.rb require "opentelemetry/sdk" OpenTelemetry::SDK.configure do |c| c.service_name = '<YOUR_SERVICE_NAME>' end # 'Tracer' can be used throughout your code now MyAppTracer = OpenTelemetry.tracer_provider.tracer('<YOUR_TRACER_NAME>')
例えば、Railsのinitializerなどで初期構成を行ってTracerオブジェクトを定数に格納しておく。Tracerはスレッドセーフに実装することが仕様で決められており、大抵の場合はアプリケーションに対してシングルトンで良い。
Tracerが準備できたら計測したい場所で新しくSpanを作成する。
require "opentelemetry/sdk" def parent_work MyAppTracer.in_span("parent") do |span| # do some work that the 'parent' span tracks! child_work # do some more work afterwards end end def child_work MyAppTracer.in_span("child") do |span| # do some work that the 'child' span tracks! end end
Rubyであれば、do-endのブロックをSpan計測区間として直感的に表現できる。OpenTelemetryのAPIは上記の様にあるSpan計測区間の中でin_spanメソッドを呼んでSpanを作成すると暗黙的に親を認識して入れ子のSpanにしてくれる。
現在のSpanを取得したい場合は以下の様にすれば良い。
current_span = OpenTelemetry::Trace.current_span current_span.add_attributes({ "my.cool.attribute" => "a value", "my.first.name" => "Oscar" })
取得したSpanに対してattributesを追加することもできる。
Spanの情報はContextオブジェクト保持されていて、Rubyの実装ではContextオブジェクトはスレッドローカルストレージに確保される様になっている。
def stack Thread.current[STACK_KEY] ||= [] end
なのでクラスメソッドを呼ぶと現在のスレッドの最新のContextを経由してSpanを取得することができる。
基本的にはこれだけ覚えておけばトレースを記録することができる様になるが、もう少し登場概念について説明しておく。
TracerProvider
Tracerを生成するためのファクトリで、アプリケーション単位で構成される。後述するResourceやアプリケーションからcollectorや外部サービスにデータを送信するためのexporterの情報を持っている。
Tracer
TracerProviderから生成される実際にSpanを作成するためのオブジェクト。
Span
Tracerが作成するアプリケーションの処理単位ごとの情報を保持するオブジェクト。詳しくは上記を参照。
Resource
Attributeと似ていて分かりにくいのだが、テレメトリデータを生成するエンティティを表す属性値を示す。
どういうことかと言えば、インフラ情報やプロセス名などアプリケーションの単位で不変なものを表現する。例えば以下の様なものが該当する。
- コンテナID
- コンテナのイメージ名
- OS情報
- デプロイ先の環境名(production, staging, etc)
- プロセス名と引数
- クラウドベンダー特有の情報
- ECSのタスク名やcluster arn
- GCPのCloudRunの実行ID
これらはSpanに付随する情報としても利用されるが、Spanごとに変化することは無いものとして扱われるので別の概念が割当てられている。
自動計装
OpenTelemetryの文脈ではZero-code Instrumentationとも表現される。
RubyはOpenTelemetryでの定義上はZero-code Instrumentationには含まれていないのだが、実際ほとんど同じ様なものなので、この記事では自動計装とまとめてしまう。
Rubyでは以下の様にすることで自動計装が有効になる。
# Gemfile gem "opentelemetry-instrumentation-all"
# config/initializers/opentelemetry.rb require 'opentelemetry/sdk' require 'opentelemetry/instrumentation/all' OpenTelemetry::SDK.configure do |c| c.service_name = '<YOUR SERVICE>' c.use_all() # enables all instrumentation! end
この様にすることでopentelemetry-instrumentation-allに含まれる全ての計装処理が有効になる。
個別に利用する場合は以下の様になる。
OpenTelemetry::SDK.configure do |c| c.use 'OpenTelemetry::Instrumentation::Rack' end
opentelemetry-instrumentation-allに含まれる計装を知りたい時はopentelemetry-ruby-contribリポジトリを参照する。
ここを見ると、HTTPのリクエストやrack、railsのエントリポイント、Active Recordの呼び出しsidekiqの呼び出しなどで自動的に計装してくれることが分かる。
Rubyではどうやって自動計装を実現しているのか
ここでは、active_recordのinstrumentation実装例を見てみよう。
# lib/opentelemetry/instrumentation/active_record/instrumentation.rb module OpenTelemetry module Instrumentation module ActiveRecord # The Instrumentation class contains logic to detect and install the ActiveRecord instrumentation class Instrumentation < OpenTelemetry::Instrumentation::Base MINIMUM_VERSION = Gem::Version.new('7') install do |_config| require_dependencies patch_activerecord end present do defined?(::ActiveRecord) end compatible do gem_version >= MINIMUM_VERSION end private def gem_version ::ActiveRecord.version end def require_dependencies require 'active_support/lazy_load_hooks' require_relative 'patches/querying' require_relative 'patches/persistence' require_relative 'patches/persistence_class_methods' require_relative 'patches/persistence_insert_class_methods' require_relative 'patches/transactions_class_methods' require_relative 'patches/validations' require_relative 'patches/relation_persistence' end def patch_activerecord ::ActiveSupport.on_load(:active_record) do # Modules to prepend to ActiveRecord::Base are grouped by the source # module that they are defined in as they are included into ActiveRecord::Base # Example: Patches::PersistenceClassMethods refers to https://github.com/rails/rails/blob/v7.0.0/activerecord/lib/active_record/persistence.rb#L10 # which is included into ActiveRecord::Base in https://github.com/rails/rails/blob/914caca2d31bd753f47f9168f2a375921d9e91cc/activerecord/lib/active_record/base.rb#L283 ::ActiveRecord::Base.prepend(Patches::Querying) ::ActiveRecord::Base.prepend(Patches::Persistence) ::ActiveRecord::Base.prepend(Patches::PersistenceClassMethods) ::ActiveRecord::Base.prepend(Patches::PersistenceInsertClassMethods) ::ActiveRecord::Base.prepend(Patches::TransactionsClassMethods) ::ActiveRecord::Base.prepend(Patches::Validations) ::ActiveRecord::Relation.prepend(Patches::RelationPersistence) end end end end end end
# lib/opentelemetry/instrumentation/active_record/patches/querying.rb module OpenTelemetry module Instrumentation module ActiveRecord module Patches # Module to prepend to ActiveRecord::Base for instrumentation module Querying def self.prepended(base) class << base prepend ClassMethods end end # Contains ActiveRecord::Querying to be patched module ClassMethods method_name = ::ActiveRecord.version >= Gem::Version.new('7.0.0') ? :_query_by_sql : :find_by_sql define_method(method_name) do |*args, **kwargs, &block| tracer.in_span("#{self} query") do super(*args, **kwargs, &block) end end private def tracer ActiveRecord::Instrumentation.instance.tracer end end end end end end end
DSL的な実装になっているが、基本的にはライブラリの必要なクラスに対してモジュールをprependすることでSpan作成の処理を挟み込んでいる。
そして、その中でTracer#in_spanを呼んでspanを作成するというシンプルな実装になっている。
もし自分が利用しているライブラリ向けのinstrumentationが提供されていない場合は、この実装を参考にしてmodule prependする様なパッチを当てることで、アプリケーションエンジニアが意識しない形でトレースを取得することが出来るだろう。
Tracerの設定方法
トレースの作成自体は上記の様なAPIを利用して行えるが、アプリケーションに対して取得したデータをどこに送るのかや、共通で追加したいメタデータなどを設定しなければならない。
プログラム上で指定することは可能だが、昨今のコンフィグの入力方法としては環境変数を利用する方法を理解しておくのが良いだろう。このやり方なら言語非依存なのでどの言語で書かれたアプリケーションでも同じ様に設定できる。
設定に利用できる環境変数は以下のリンクから一覧できる。
ここで示されている環境変数を指定することで、計測したデータの送信先やトレースをサンプリングする割合などを設定できる。大量にアクセスがあるシステムだとトレースに関するデータを全て収集していると物凄いデータ量になってしまうので、サンプリングの設定は非常に大事な要素の一つだ。
トレース取得の結果得られるもの
分散トレーシングが可能になると、上記の画像の様に複数のサービスを跨いだ処理のフレームグラフが得られるだけではない。
各サービス間の関係性が分かるので、サービスやミドルウェアへの依存関係やリクエスト量などを可視化することもできる。
特にマイクロサービスアーキテクチャを採用していると、このサービスが何のサービスから入力を受けて、どのサービスに向けて出力しているのか、このサービスはRDBを利用しているのか否かといった情報がすぐに分からなくなってしまう。
コンポーネント同士で担当チームが違うことも有り得るので、自分達の扱うコンポーネントが隣接しているものが何かと、それを管理しているチームがどこかが簡単に把握できることはとても重要だ。
こういった需要に応えられる様に、多くの可視化基盤ではService GraphやService Mapと呼ばれる機能が提供されている。例えがGrafanaだと以下の画像の様に可視化される。

こういったビューがあると、自分がフォーカスしているサービスに関係しているサービスが何なのかを即座に把握できる様になるし、システムの全体像を描くシステム構成図も簡易的なものであれば自動的に作成することができる。
ある程度複雑になってしまったシステムでは、何もかもモノリスで済む様に構成するのはかなり難しいので、こういった仕組みは開発の大きな助けになるだろう。
より進んだオブザーバビリティへ
この記事ではOpenTelemetryとその中でも特にトレースを中心にした解説をしてきたが、最初に述べた様にオブザーバビリティはトレースだけに留まるものではなく、システムの動作を外から観測可能にして開発の助けになる情報が得られる状態を目指すものだ。 その可視化対象はメトリクス、ログ、運用におけるプレイブックの管理、プロファイラの計測結果、担当チームへのコンタクト情報、など多岐に渡る。
最近の可視化基盤では、これらの情報を関連付けて統合的なビューとして見られる様な機能が提供されていることも多い。
アプリケーションを開発する時に、こういった可視化しておくと後々便利になる情報を意識しておくと、未来のスムーズな開発体験に繋げることができるだろう。仮にインフラエンジニアやSREが社内に居るとしても、アプリケーション開発者自身が健全な運用に必要な知識を得ておくことはDevOpsの精神としても重要なことだと思う。
卑近な言い方をすると、ちゃんと役に立つ情報を収集してまとめておけば、他人に何度もシステム構成を説明する手間も省けるし、説明資料をメンテする工数も削減できるし、多くの人間がボトルネック調査をしやすくなる、という感じで仕事が楽になるので、そのために役に立つ情報はちゃんと学んでおこう、ということだ。
最近は色々と便利なものが出来てるので、ちゃんと活用してしっかり楽をしつつより良い開発を目指していこう。