最近「オブザーバビリティ(Observability)」が気になっていたので、オライリーの「オブザーバビリティ・エンジニアリング」を読みました。
O'Reilly Japan - オブザーバビリティ・エンジニアリング
その本の7章ではOpenTelemetryが紹介されていました。
OpenTelemetryの公式サイトを見ると、
OpenTelemetry is a collection of tools, APIs, and SDKs. Use it to instrument, generate, collect, and export telemetry data (metrics, logs, and traces) to help you analyze your software’s performance and behavior.
とあり、気になりました。
そこで、各RailsアプリにOpenTelemetryを組み込んでみたときのメモを残します。
目次
- 環境
- 1つのRailsアプリにOpenTelemetryを組み込む
- 連携する各RailsアプリにOpenTelemetryを組み込む
- Jaegerで収集したデータを表示する
- ActiveJobで連携したときの収集データをJaegerで表示する
- 資料
- ソースコード
環境
- Rails 7.0.5
- opentelemetry-sdk 1.2
- opentelemetry-instrumentation-all 0.39.1
- opentelemetry-exporter-otlp 0.25.0
- Jaeger 1.46.0
今回は、1つのRailsアプリにOpenTelemetryを組み込み、収集したデータをコンソールへ出力するところからはじめます。
最終的には、Railsで2つのアプリ (parent_app
と child_app
)にそれぞれOpenTelemetryを組み込み、Jaegerで収集したデータを表示するところまでためします。
なお、今回の2つのRailsアプリはともにAPIモードで rails new
したものを使います。
また、ざっくりとした全体図は以下のとおりです。
┌─────────┐ ┌──────────────┐ ┌──────────────┐ │ Browser │ │ parent_app ├─────►│ child_app │ │ ├───────►│ │ │ │ └─────────┘ │ │◄─────┤ │ └───────┬──────┘ └───────┬──────┘ │ │ │ │ │ │ │ ┌───────┐ │ │ │ │ │ └─────►│Jaeger │◄─────┘ │ │ └───────┘
ちなみに、OpenTelemetryの各機能に関する言語ごとの状況は以下にありました。RubyだとTracesのみStable、あとは未実装のようです。
Instrumentation | OpenTelemetry
1つのRailsアプリにOpenTelemetryを組み込む
OpenTelemetryの公式ドキュメントを読んだところ、まずはRubyの Automatic instrumentation
に沿って試してみるのが良さそうでした。
Automatic instrumentation | OpenTelemetry
Railsアプリを作成
はじめに、APIモードで rails new
します。
続いて、必要なgemをGemfileへ追加します。コンソールに出力する場合は以下の2つのgemを追加すれば良さそうでした。
gem "opentelemetry-sdk", "~> 1.2" gem "opentelemetry-instrumentation-all", "~> 0.39.1"
Gemfileに追加したら、 bundle install
しておきます。
OpenTelemetryの設定ファイルを追加
次に、 config/initializers/opentelemetry.rb
ファイルを作成し、OpenTelemetry用の設定を追加します。なお、今回はためすだけなので、
service_name
は適当な名前 (parent_app
)use_all
ですべてのinstrumentationを使う
とします。
また、以下の公式ドキュメントによると、収集したデータをコンソールへ出力する場合は、環境変数 OTEL_TRACES_EXPORTER
を設定しておけば良さそうでした。今回はためすだけなので、設定ファイルに記載してしまいます。
Run the instrumented app | Getting Started | OpenTelemetry
なお、公式ドキュメントの環境変数 OTEL_TRACES_EXPORTER
の説明には console
の記載はなかったため、Ruby版だけで使えるものかもしれません。
Environment Variable Specification | OpenTelemetry
設定ファイルの全体像はこんな感じです。
config/initializers/opentelemetry.rb
# 収集したデータをコンソールへ出力する ENV['OTEL_TRACES_EXPORTER'] = 'console' # OpenTelemetryの設定 OpenTelemetry::SDK.configure do |c| c.service_name = 'parent_app' # 適当な名前 c.use_all() # enables all instrumentation! end
コントローラとルーティングを追加
コントローラではJSONを返します。
hello_controller.rb
class HelloController < ApplicationController def index render json: {message: 'hello'} end end
コントローラを元にルーティングを作成します。
なお、後でリソースベースではないルーティングを追加することから、 get
な書き方にしています。
routes.rb
Rails.application.routes.draw do get '/hello', to: 'hello#index' end
動作確認
これでアプリが完成したので、作成したRailsアプリを起動します。なお、後でRailsアプリを追加するため、ポートは 9000
にしておきます。
curlで動作確認をすると、レスポンスがありました。
$ curl http://localhost:9000/hello {"message":"hello"}
また、コンソールにも収集したデータが出力されていました。
#<struct OpenTelemetry::SDK::Trace::SpanData name="HelloController#index", kind=:server, status=#<OpenTelemetry::Trace::Status:0x00007f5eb5e16250 @code=1, @description="">, parent_span_id="\x00\x00\x00\x00\x00\x00\x00\x00", total_recorded_attributes=8, total_recorded_events=0, total_recorded_links=0, start_timestamp=1687597098836007785, end_timestamp=1687597098965513677, attributes={ "http.method"=>"GET", "http.host"=>"localhost:3000", "http.scheme"=>"http", "http.target"=>"/hello", "http.user_agent"=>"curl/7.81.0", "code.namespace"=>"HelloController", "code.function"=>"index", "http.status_code"=>200 }, links=nil, events=nil, resource=#<OpenTelemetry::SDK::Resources::Resource:0x00007f5eb74a8658 @attributes={ "service.name"=>"parent_app", "process.pid"=>1050472, "process.command"=>"/path/to/parent_app/bin/rails", "process.runtime.name"=>"ruby", "process.runtime.version"=>"3.2.2", "process.runtime.description"=>"ruby 3.2.2 (2023-03-30 revision e51014f9c0) [x86_64-linux]", "telemetry.sdk.name"=>"opentelemetry", "telemetry.sdk.language"=>"ruby", "telemetry.sdk.version"=>"1.2.1" }>, instrumentation_scope=#<struct OpenTelemetry::SDK::InstrumentationScope name="OpenTelemetry::Instrumentation::Rack", version="0.23.2">, span_id="I\xBC\xFB#\xEA\xC7u\xD8", trace_id="^\xF3Z\x0E`\x04\xCC\xCB\xC9\x99\x83\xB8\x12\t3\xCD", trace_flags=#<OpenTelemetry::Trace::TraceFlags:0x00007f5eb5e6e360 @flags=1>, tracestate=#<OpenTelemetry::Trace::Tracestate:0x00007f5eb5e6d050 @hash={}>>
連携する各RailsアプリにOpenTelemetryを組み込む
続いて、連携する各RailsアプリにOpenTelemetryを組み込んでみます。
なお、連携したときに取得できるデータは
trace_id
は同じspan_id
は別
となる想定です。
連携先のRailsアプリを作成
まず、連携先のRailsアプリである child_app
をAPIモードで rails new
します。
続いて、 config/initializers/opentelemetry.rb
を作成します。 service_name
以外は parent_app
と同じとします。
# 収集したデータをコンソールへ出力する ENV['OTEL_TRACES_EXPORTER'] = 'console' # OpenTelemetryの設定 OpenTelemetry::SDK.configure do |c| c.service_name = 'child_app' # 適当な名前 c.use_all() # enables all instrumentation! end
そして、コントローラとルーティングを作成します。
messages_controller.rb
class MessagesController < ApplicationController def index render json: {message: 'Hi from child'} end end
routes.rb
Rails.application.routes.draw do resources :messages, only: %i[index] end
Ruby版での、Context Propagation設定について
parent_app
と child_app
で同じ trace_id
を使う場合、 Context Propagationという仕組みを使って trace_id
を連携先に渡す必要があります。
Context Propagation | Overview | OpenTelemetry
trace_id
を渡す方法としてはいくつかあるようでした。
Propagators Distribution | Propagators API | OpenTelemetry
Ruby版ではどうすればよいのかを見たところ、
The OpenTelemetry Ruby SDK will take care of context propagation as long as your service is leveraging auto-instrumented libraries.
https://opentelemetry.io/docs/instrumentation/ruby/manual/#context-propagation
とのことでした。
また、その値はどこに設定されるかというと
In order to propagate trace context over the wire, a propagator must be registered with the OpenTelemetry SDK. The W3 TraceContext and Baggage propagators are configured by default
https://opentelemetry.io/docs/instrumentation/ruby/manual/#context-propagation
のようで、デフォルトではHTTPリクエストヘッダの traceparent
に自動で設定されそうです。
そこで、traceparent
をもらう側である child_app
でHTTPリクエストヘッダの中身をコンソール出力し、自動的に設定されているかを確認します。
今回は application_controller.rb
にログ出力処理を追加します。
参考:Rails でリクエストの HTTP ヘッダを取得してログに出力する - xyk blog
class ApplicationController < ActionController::API before_action :log_http_headers before_action :log_all_http_headers private def log_http_headers logger.info('traceparent header ===============>') logger.info(request.headers['HTTP_TRACEPARENT']) logger.info('traceparent header <===============') end private def log_all_http_headers logger.info('all request header ===============>') request.headers.sort.map { |k, v| logger.info "#{k}:#{v}" } logger.info('all request header <===============') end end
parent_appからchild_appへ、Faradayを使って連携する
残すは、 parent_app
から child_app
へHTTPリクエストを飛ばすところです。
今回は Faraday
gemを使って、連携処理を実装します。
lostisland/faraday: Simple, but flexible HTTP client library, with support for multiple backends.
parent_app
のGemfileに faraday
を追加し、bundle install
します。
gem 'faraday'
今回はデモ的なアプリのため、 parent_app
のコントローラにてFaradayを使った連携処理を実装してしまいます。
class HelloController < ApplicationController def message_from_child response = Faraday.get('http://localhost:9001/messages') render json: {message: response.body} end end
ルーティングも追加します。
Rails.application.routes.draw do get '/message_from_child', to: 'hello#message_from_child' end
動作確認
以上で準備ができたため、 parent_app
を9000ポートで、 child_app
を9001ポートでそれぞれ起動します。
curlで動作確認すると、連携した結果が返ってきました。
$ curl http://localhost:9000/message_from_child {"message":"{\"message\":\"Hi from child\"}"}
parent_app
ではOpenTelemetryによりコンソールへ4件出力されていました。気になるところだけ抜粋します。
#<struct OpenTelemetry::SDK::Trace::SpanData name="connect", kind=:internal, parent_span_id="-3\xEE\x9CL\xE3\x10\xB9", start_timestamp=1687670979094625151, end_timestamp=1687670979095422757, attributes={"net.peer.name"=>"localhost", "net.peer.port"=>9001}, span_id="&)8Om\x0Ft\xD1", trace_id="\xC0H\xF1\xDBW%\x87p_\xC5\xD4\x1F\x15\x7FX\x8F", ... #<struct OpenTelemetry::SDK::Trace::SpanData name="HTTP GET", kind=:client, parent_span_id="-3\xEE\x9CL\xE3\x10\xB9", start_timestamp=1687670979097511327, end_timestamp=1687670979195886262, attributes={"http.method"=>"GET", "http.scheme"=>"http", "http.target"=>"/messages", "net.peer.name"=>"localhost", "net.peer.port"=>9001, "http.status_code"=>200}, span_id="\x9F\x18\xEC\x85Z\xCC\x15\xC7", trace_id="\xC0H\xF1\xDBW%\x87p_\xC5\xD4\x1F\x15\x7FX\x8F", ... #<struct OpenTelemetry::SDK::Trace::SpanData name="HTTP GET", kind=:client, parent_span_id="'\xB1-RT\xBA\xB0\xB8", start_timestamp=1687670979094368383, end_timestamp=1687670979198343682, attributes={"http.method"=>"GET", "http.url"=>"http://localhost:9001/messages", "net.peer.name"=>"localhost", "http.status_code"=>200},span_id="-3\xEE\x9CL\xE3\x10\xB9", trace_id="\xC0H\xF1\xDBW%\x87p_\xC5\xD4\x1F\x15\x7FX\x8F", ... #<struct OpenTelemetry::SDK::Trace::SpanData name="HelloController#message_from_child", kind=:server, parent_span_id="\x00\x00\x00\x00\x00\x00\x00\x00", start_timestamp=1687670979019617738, end_timestamp=1687670979200919667, attributes={"http.method"=>"GET", "http.host"=>"localhost:9000", "http.scheme"=>"http", "http.target"=>"/message_from_child", "http.user_agent"=>"curl/7.81.0", "code.namespace"=>"HelloController", "code.function"=>"message_from_child", "http.status_code"=>200}, span_id="'\xB1-RT\xBA\xB0\xB8", trace_id="\xC0H\xF1\xDBW%\x87p_\xC5\xD4\x1F\x15\x7FX\x8F", ...
一方、 child_app
では、HTTPリクエストヘッダとOpenTelemetryのデータが出力されていました。
traceparent header ===============> 00-c048f1db572587705fc5d41f157f588f-9f18ec855acc15c7-01 traceparent header <=============== all request header ===============> GATEWAY_INTERFACE:CGI/1.2 HTTP_ACCEPT:*/* HTTP_ACCEPT_ENCODING:gzip;q=1.0,deflate;q=0.6,identity;q=0.3 HTTP_HOST:localhost:9001 HTTP_TRACEPARENT:00-c048f1db572587705fc5d41f157f588f-9f18ec855acc15c7-01 HTTP_USER_AGENT:Faraday v2.7.7 HTTP_VERSION:HTTP/1.1 ... #<struct OpenTelemetry::SDK::Trace::SpanData name="MessagesController#index", kind=:server, parent_span_id="\x9F\x18\xEC\x85Z\xCC\x15\xC7", start_timestamp=1687670979102702979, end_timestamp=1687670979192294516, attributes={"http.method"=>"GET", "http.host"=>"localhost:9001", "http.scheme"=>"http", "http.target"=>"/messages", "http.user_agent"=>"Faraday v2.7.7", "code.namespace"=>"MessagesController", "code.function"=>"index", "http.status_code"=>200}, span_id="\xFF\xAEW'\xC8s*\xE2", trace_id="\xC0H\xF1\xDBW%\x87p_\xC5\xD4\x1F\x15\x7FX\x8F", ...
上記より、
trace_id
は\xC0H\xF1\xDBW%\x87p_\xC5\xD4\x1F\x15\x7FX\x8F
で同じspan_id
はそれぞれ違う
となっており、想定通りでした。
Jaegerで収集したデータを表示する
ここまではOpenTelemetryで収集したデータをコンソールへ出力していました。
ただ、それだと正しく収集できているかわかりづらいため、OpenTelemetryで収集したデータを何かに渡して表示してみます。
公式ドキュメントによると、表示するための方法として
- OpenTelemetry controllerを介して渡す
- Jaegerなどのツールへ直接渡す
があるようでした。
OTLP endpoint | Exporters | OpenTelemetry
今回は、お手軽そうな、Jaegerへ直接データを渡して表示する方法をためしてみます。
Jaegerの準備
Jaegerの準備について、今回はオールインワンのDockerイメージを使うことにします。
Getting Started — Jaeger documentation
また、Docker Composeを使ってJaegerを起動します。
なお、Jaegerの公式ドキュメントに従い、
- ポート
16686
- JaegerのUI表示用
- ポート
4318
- OpenTelemetry連携用
のポートを開けておきます。
Docker ComposeがV2になったので、 compose.yaml
ファイルに内容を記載していきます。
Docker Compose V2で変わったdocker-compose.ymlの書き方
services: jaeger: image: jaegertracing/all-in-one:1.46 ports: - "16686:16686" - "4318:4318"
準備ができたら起動しておきます。
$ docker compose up -d
Railsアプリの修正
今までコンソールへ出力していたため、設定を変更します。
parent_app
と child_app
ともに、 ENV['OTEL_TRACES_EXPORTER'] = 'console'
をコメントアウトします。
# 以下の行をコメントアウトするだけ # ENV['OTEL_TRACES_EXPORTER'] = 'console' OpenTelemetry::SDK.configure do |c| c.service_name = 'child_app' c.use_all() # enables all instrumentation! end
また、Jaegerへ連携できるよう、gem opentelemetry-exporter-otlp
をGemfileへ追加し、bundle install
しておきます。
動作確認
curlで2つのRailsアプリが連携するエンドポイントへリクエストを飛ばすと、レスポンスが返ってきました。
$ curl http://localhost:9000/message_from_child {"message":"{\"message\":\"Hi from child\"}"}t
続いて、Jaeger UIへアクセスします。
http://localhost:16686/
Searchの Service
で parent_app
を選択し、 Find Trace
ボタンを押すと、トレースの一覧が表示されます。
トレースをクリックすると、詳細が表示されました。Jaegerへのデータの連携ができているようです。
ActiveJobで連携したときの収集データをJaegerで表示する
ここまでは同期的な連携についてOpenTelemetryで見てきました。
次はActiveJobを使った非同期連携の場合にはどのように表示されるか見ていきます。
child_appに非同期連携用エンドポイントを作成
コントローラに create
メソッドを追加します。
また、リクエストパラメータを正しく受け取れているか確認するため、ログに内容を出力します。
class MessagesController < ApplicationController def create message = params[:message] logger.info('=== start ===============>') logger.info(message) logger.info('<== end ================') end end
ルーティングも追加します。
Rails.application.routes.draw do resources :messages, only: %i[index create] end
parent_appにActiveJobとそれを起動するエンドポイントを追加
まずはActiveJobから追加します。
ジョブの中で child_app
へリクエストを飛ばす処理を実装します。
class ParentJob < ApplicationJob queue_as :default def perform(message) connection = Faraday.new('http://localhost:9001') connection.post('/messages') do |cn| cn.headers['Content-Type'] = 'application/json' cn.body = {message: message}.to_json end end end
続いて、ActiveJobを起動するエンドポイントを作成します。今回は
という実装にします。
class HelloController < ApplicationController def child_and_job Faraday.get('http://localhost:9001/messages') ParentJob.perform_later('hi') render json: {message: 'done'} end end
最後にルーティングを追加します。
Rails.application.routes.draw do post '/child_and_job', to: 'hello#child_and_job' end
動作確認
curlでPOSTリクエストすると、レスポンスが返ってきます。
$ curl -X POST http://localhost:9000/child_and_job {"message":"done"}
parent_app
のログを見ると、ジョブが実行されていることがわかります。
Started POST "/child_and_job" for 127.0.0.1 at 2023-06-25 15:54:32 +0900 ... [ActiveJob] Enqueued ParentJob (Job ID: 16556578-1068-4399-ba1f-af7357cce95f) to Async(default) with arguments: "hi" Completed 200 OK in 19ms (Views: 0.3ms | Allocations: 2924) [ActiveJob] [ParentJob] [16556578-1068-4399-ba1f-af7357cce95f] Performing ParentJob (Job ID: 16556578-1068-4399-ba1f-af7357cce95f) from Async(default) enqueued at 2023-06-25T06:54:32Z with arguments: "hi" [ActiveJob] [ParentJob] [16556578-1068-4399-ba1f-af7357cce95f] Performed ParentJob (Job ID: 16556578-1068-4399-ba1f-af7357cce95f) from Async(default) in 12.6ms
続いて child_app
のログを見ると、2回リクエストのあったことが分かります。
ただ、 traceparent
の値は
- 同期リクエスト
- 00-c453de21927acd7934f7c6ecccae49df-d637e607cad1965d-01
- ジョブでのリクエスト
- 00-7f626290dceb8d0240510406231d95d9-81a81c320e463a99-01
と異なっていました。
Started GET "/messages" for 127.0.0.1 at 2023-06-25 15:54:32 +0900 traceparent header ===============> 00-c453de21927acd7934f7c6ecccae49df-d637e607cad1965d-01 traceparent header <=============== Completed 200 OK in 4ms (Views: 0.3ms | Allocations: 1140) Started POST "/messages" for 127.0.0.1 at 2023-06-25 15:54:32 +0900 Processing by MessagesController#create as */* Parameters: {"message"=>"hi"} traceparent header ===============> 00-7f626290dceb8d0240510406231d95d9-81a81c320e463a99-01 traceparent header <=============== === start ===============> hi <== end ================ Completed 204 No Content in 4ms (Allocations: 1193)
Jaegerにて確認すると、2つのトレースが表示されました。
1つは同期リクエストのトレースでした。
もう1つは、ジョブでのリクエストのトレースでした。
これにより、 traceparent
の値が異なると、Jaegerでは別のトレースとして表示されることが分かりました。
以上で、OpenTelemetryで収集したデータをJaegerで表示するところまでできました。
資料
- OpenTelemetryでWebシステムの処理を追跡しよう - DjangoCongress JP 2022 - Google スライド
- 分散トレーシングとOpenTelemetryのススメ/Getting started distributed tracing and OpenTelemetry - Speaker Deck
- 今日から分散トレーシングに対応しないといけなくなった人のための opentelemetry-go 入門 - Cybozu Inside Out | サイボウズエンジニアのブログ
- 2022年版 OpenTelemetryを知れば世界が平和に - じゃあ、おうちで学べる
- 仕様と実装から理解するOpenTelemetryの全体像
- [OpenTelemetry] Ruby(Rails) で ZipkinやJaegerを動かす | Fand.jp Blog
- Ruby | OpenTelemetry
- OpenTelemetry for Ruby | Honeycomb
- Cloud RunでOpenTelemetry Collectorをサイドカーとして動かす
- NginxでOpenTelemetry(旧バージョンを含む) - ymtdzzz.dev
- OpenTelemetryのカレンダー | Advent Calendar 2022 - Qiita
- Observability Conference 2022レポート 記事一覧 | Think IT(シンクイット)
ソースコード
Githubに上げました。
https://github.com/thinkAmi-sandbox/rails_with_opentelemetry-example
今回のプルリクはこちら。
https://github.com/thinkAmi-sandbox/rails_with_opentelemetry-example/pull/1