kmuto’s blog

はてな社でMackerel CREをやっています。料理と旅行といろんなIT技術

RubyのOpenTelemetryトレース手動計装とOpenTelemetry Collectorのサンプリングを試してみた

Ruby on RailsのOpenTelemetryトレース自動計装を試したんだけど、至極簡単すぎて特段書くこともなかった(サンプルコード)。mrasuさんのOpenTelemetryでRubyを使う時の無難な設定に全部書いてあったすぎる。

(※あとから考えたら自動計装というかzero-code計装じゃなくて、ライブラリでの計装だったな。いずれにせよインポートするだけでそれ以上は何もせずとも計装される。)

Rubyでの手動計装

自動計装の次は手動計装も押さえておくか、というわけでシンプルなプログラムを書いて実験してみる。

github.com

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が変わってくれなかったので、statusOpenTelemetry::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_samplersampling_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_samplingpoliciesでポリシーを定義していく。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で実験のためのアプリケーションができて、ヘッドベースサンプリング、テールベースサンプリングの両方を試すことができた。実際の運用を想定すると、テールベースサンプリングについてはもっと詳細についてさわりながら知る必要があり、ほかのプロセッサについても知見を深めなければならなそうである。