RubyのOpenTelemetryトレース手動計装とOpenTelemetry Collectorのサンプリングを試してみた
Ruby on RailsのOpenTelemetryトレース自動計装を試したんだけど、至極簡単すぎて特段書くこともなかった(サンプルコード)。mrasuさんのOpenTelemetryでRubyを使う時の無難な設定に全部書いてあったすぎる。
(※あとから考えたら自動計装というかzero-code計装じゃなくて、ライブラリでの計装だったな。いずれにせよインポートするだけでそれ以上は何もせずとも計装される。)
Rubyでの手動計装
自動計装の次は手動計装も押さえておくか、というわけでシンプルなプログラムを書いて実験してみる。
require 'opentelemetry/sdk' require 'opentelemetry/exporter/otlp' # Uncomment to use all instrumentation # require 'opentelemetry/instrumentation/all' def otel_config OpenTelemetry::SDK.configure do |c| c.service_name = 'tiny-ruby-trace-sample' c.service_version = '1.0.0' c.resource = OpenTelemetry::SDK::Resources::Resource.create( OpenTelemetry::SemanticConventions::Resource::DEPLOYMENT_ENVIRONMENT => 'development', OpenTelemetry::SemanticConventions::Resource::HOST_NAME => Socket.gethostname ) c.add_span_processor( OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new( OpenTelemetry::Exporter::OTLP::Exporter.new( endpoint: 'http://localhost:4318/v1/traces' ) ) ) # Uncomment to use all instrumentation # c.use_all end end def main $stdout.sync = true tracer = OpenTelemetry.tracer_provider.tracer('tiny-ruby-trace-sample') print "start (PID #{$$}): " 1.upto(20) do |i| tracer.in_span('main') do |span| span.set_attribute('myname', "parent-#{i}") span.set_attribute('parameter', Time.now.to_f.to_s) print '+' 1.upto(4) do |i2| tracer.in_span('child') do |child_span| child_span.set_attribute('myname', "child-#{i}-#{i2}") child_span.set_attribute('parameter', Time.now.to_f.to_s) if i % 8 == 0 && i2 == 2 error_msg = "forced error #{$$}" child_span.record_exception(StandardError.new(error_msg)) child_span.status = OpenTelemetry::Trace::Status.error(error_msg) sleep(1 + rand(0.5)) print 'E' else sleep(0.1 + rand(0.1)) print '.' end end end end end OpenTelemetry.tracer_provider.shutdown puts '' end otel_config main
SDKの設定はRailsと同じ。OpenTelemetry.trace_provider.tracer
にサービス名を指定してトレーサオブジェクトを作って、あとはそのオブジェクトのin_span
のブロック範囲でスパンが作られる。ブロック内でさらにin_span
を使えば子スパンになる。簡単。
属性・値はスパンオブジェクトのset_attribute
で設定する。エラーを記録したいときには、スパンオブジェクトのrecord_exception
にExceptionオブジェクトを指定する(属性もattributes
パラメータで指定できるようだ)。
record_exception
ではstatus codeが変わってくれなかったので、status
をOpenTelemetry::Trace::Status.error
にしている。どちらもエラーイベントメッセージ向けの引数をとるのでrecord_exception
との棲み分けが微妙に難しいが、シンプルに済ませるならstatus
だけでいいし、exceptionの詳細を入れたければrecord_exception
を併用するといい、ということかな。
当初、アプリケーションを実行してもトレースオブジェクトの作成までは順調に進んでいるのに4318ポートへのポストが全然なくて悩んでいた。結局、tracer_providerはブロック終了時にバッファにあるものをフラッシュしてくれるわけではないので、常駐せずに終了する場合はshutdown
を呼び出してフラッシュさせるのが適切っぽい。
アプリケーションの挙動としては、20個の親トレースを順に実行し、その中ではそれぞれ4個の子トレースを実行する。親トレースが8番目・16番目のとき、かつ子トレースの2番目でエラーを発生させるようにした。
進捗状況が見えないと寂しいので、記号表示させている。
$ bundle exec ruby main.rb start (PID 2667019): +....+....+....+....+....+....+....+.E..+....+....+....+....+....+....+....+.E..+....+....+....+....
OpenTelemetry Collectorのサンプリング
同じホストでOpenTelemetry CollectorをOTLPのhttp受け口で立てておけば、アプリケーションのトレースを受け取ることができる。そしてそれをJaegerやMackerel Vaxilaにさらに送ることもできる。
トレースは便利だが、大量に送ると自前ホストであればストレージを圧迫するし、SaaSならコストにかかってくる。このトレードオフをどうするかというのは今後業務で問われることは間違いないので、そのようなケースの代表的な手段であるサンプリングについて少し触っておくことにする。
OpenTelemetry Collectorで可能なサンプリングとしては、主に2種類がある。
- ヘッドベースサンプリング:入ってくるトレースについて、10%といった確率的なサンプリングを行う。単純でOpenTelemetry Collectorの動作環境への負荷は低い。ただし、障害解析に重要なトレースを見落とす可能性は常にある
- テールベースサンプリング:トレースが終了するまで待ってからサンプリング対象にするかどうかを決定する。エラーを含むトレースやレイテンシーの大きなトレースなど、見落としたくないトレースを確実に拾い、定常的なトレースを捨てるといった高度なサンプリングができる。一方で、一定量のトレースをメモリに蓄積して処理するため、OpenTelemetry Collectorの動作環境への負荷が高く、水平スケールもしづらいという難点がある。対象化ポリシー自体は人間が指定するため、当てはまらない重要なトレースを見落とす可能性がある
デメリットについては確かに難しいところで、『Learning OpenTelemetry』でもサンプリングはコストが本当に問題になるまですべきでないと書かれていた。
とはいえ、私はサンプリングを試してみたいのである。
まずはヘッドベースサンプリングから試してみよう。これはprobabilistic_sampler(probabilisticsamplerprocessor)というプロセッサが対応する。
otel-col.yamlを用意した。
receivers: otlp: protocols: http: processors: resource/namespace: attributes: - key: service.namespace value: "kmuto/ruby-otel-trace-test" action: upsert batch: send_batch_size: 5000 send_batch_max_size: 5000 probabilistic_sampler: sampling_percentage: 10 exporters: debug: verbosity: detailed otlphttp/vaxila: endpoint: "https://otlp-vaxila.mackerelio.com" headers: Accept: "*/*" "Mackerel-Api-Key": ${env:MACKEREL_APIKEY} service: pipelines: traces: receivers: [otlp] processors: [probabilistic_sampler, resource/namespace, batch] exporters: [debug, otlphttp/vaxila]
probabilistic_sampler
のsampling_percentage
でサンプリング確率を指定する。0なら全部棄却し、100以上なら全部拾う。
10%に設定した実際の実行では、20トレースのうち、1つだけのこともあれば、2つ、3つ採択されることもあるようだった。デフォルトではproportionalというモードが使われているが、ほかにhash_seedやequalizingというものがあり、拾い出し方に差があるらしい。ドキュメントも難解。
いずれにせよ、エラースパンの存在有無は考慮されず、いざ障害が発生してそれを拾えているかは運任せだ。なるほど。
強制的にサンプリングさせる方法として、スパンのsampling.priority
属性に正の数値(たとえば100)を指定して対象にすることはできる(ここで取り出されたものは確率の範囲外にもなるようだ)。ただ、子スパンにこれを指定しても子スパン単独のトレースになってしまい、使い勝手としては微妙なところ。自分が何か間違っている可能性もある。
次にテールベースサンプリングを試そう。これはtail_sampling(tailsamplingprocessor)プロセッサとなる。
# dist/otelcol-ruby-tracer --config otel-col.yaml receivers: otlp: protocols: http: processors: resource/namespace: attributes: - key: service.namespace value: "kmuto/ruby-otel-trace-test" action: upsert batch: send_batch_size: 5000 send_batch_max_size: 5000 tail_sampling: policies: - name: error-spans type: status_code status_code: status_codes: [ERROR] - name: sampling type: probabilistic probabilistic: sampling_percentage: 40 exporters: debug: verbosity: detailed otlphttp/vaxila: endpoint: "https://otlp-vaxila.mackerelio.com" headers: Accept: "*/*" "Mackerel-Api-Key": ${env:MACKEREL_APIKEY} service: pipelines: traces: receivers: [otlp] processors: [tail_sampling, resource/namespace, batch] exporters: [debug, otlphttp/vaxila]
tail_sampling
のpolicies
でポリシーを定義していく。type
で使いたいポリシーを選び、そのパラメータを記述する。ドキュメントを見るだになかなか恐しい代物で、特にandやcompositeはあまり触りたくないなぁ……。
policies
は配列になっていて、定義した順に評価されていき、合致したものは次のポリシーには進まずにそこで通過となるようだ。
ここでは2つのポリシーを用意してみた。
- error-spans:status_codeポリシーを利用。スパンのstatus codeが
ERROR
だったらサンプリング対象とする。status_code
からさらにstatus_codes
の子を持つのがややこしいね(status_code
はポリシーのほうの名前なので仕方ないが) - sampling:probabilisticポリシーを利用。これはヘッドベースサンプリングで使っていたのと同様に、確率的にサンプリングする(先のprobabilistic_samplerとの違いはドキュメントにめっちゃ書かれている)。
sampling_percentage
で確率を指定する。
エラースパンを含むトレースは確実に取得し、残りについては40%の確率でサンプリングするという構成だ。
アプリケーションを実行すると、エラースパンを含むトレース2つがしっかり保持されており、それ以外のトレースは7〜8個と期待どおりの挙動になっている。
ひとまずRubyで実験のためのアプリケーションができて、ヘッドベースサンプリング、テールベースサンプリングの両方を試すことができた。実際の運用を想定すると、テールベースサンプリングについてはもっと詳細についてさわりながら知る必要があり、ほかのプロセッサについても知見を深めなければならなそうである。