メドピア開発者ブログ

集合知により医療を再発明しようと邁進しているヘルステックカンパニーのエンジニアブログです。読者に有用な情報発信ができるよう心がけたいので応援のほどよろしくお願いします。

RubyKaigi 2025 に@naitoh が登壇します #rubykaigi

皆様こんにちは、メドピアのサーバーサイドエンジニアの内藤(@naitoh)です。

この度、2025/04/16(水)-18(金)の3日間で開催される「RubyKaigi 2025」に登壇させていただくこととなりました! タイトルは「Improvement of REXML and speed up using StringScanner」となります。

rubykaigi.org

スケジュールは、 Day2 11:50 〜 12:20 / Pearls Room を予定しています。 ぜひセッションにお越しください。

登壇内容について

セッションでは以下の内容をお伝えします。

REXML is a standard XML library (Bundled Gem) for Ruby implemented in Pure Ruby. It is up to 40% faster between rexml 3.2.6 gem attached to Ruby 3.3.0 and rexml 3.4.0 gem attached to Ruby 3.4.0. Through our REXML speedup efforts using StringScanner, I will explain why using StringScanner is faster and how it can be implemented to make it faster.

昨年の RubyKaigi 2024 のLT でお話した話

の続きで、 REXML のXMLパース処理を StringScanner を使ってREXML 3.2.6 (Ruby 3.3.0 添付のバージョン) からREXML 3.4.0 (Ruby 3.4.0 添付のバージョン) の間で約4割速くしたので、StringScanner を使うと何故パース処理が速くなるのか、どのような点に気をつけてパース処理を書けば速くなるのかなど、高速化のポイントを皆様に紹介します。

おわりに

それでは皆様、当日お会いできることを楽しみにしております!


是非読者になってください!


メドピアでは一緒に働く仲間を募集しています。
ご応募をお待ちしております!

■募集ポジションはこちら medpeer.co.jp

■エンジニア紹介ページはこちら engineer.medpeer.co.jp

■メドピア公式YouTube  www.youtube.com

■メドピア公式note
style.medpeer.co.jp

Railsの「ActiveSupport::ErrorReporter」って知ってる?

こんにちは。サーバーサイドエンジニアの三村(@t_mimura39)です。

またまた「ClinPeerアプリ開発の裏側連載記事」です。 tech.medpeer.co.jp

今回はClinPeerで活用しているRailsの ActiveSupport::ErrorReporter についてご紹介します。

目次

ActiveSupport::ErrorReporter とは

Railsに標準添付されているエラー管理の仕組みです。

↓これが

begin
  do_something
rescue SomethingIsBroken => error
  MyErrorReportingService.notify(error)
end

↓こうなります。

Rails.error.handle(SomethingIsBroken) do
  do_something
end

詳細はRails公式ドキュメントをご参照ください。

guides.rubyonrails.org

以上になります。ありがとうございました。

と、終わるわけにもいかないのでもう少し深い話を書きます。

なぜ ActiveSupport::ErrorReporter を使うのか

上述の通り典型的な例外ハンドリング処理に対して統一的なI/Fを提供してくれるのですが、それ以外にもいくつかの利点があります。

まず一つ目はPubSub的な仕組みになっているため「例外発生時に実行したい処理」の増減に対して柔軟に対応可能という点です。

ClinPeerでは例外発生時に以下2種類の処理を実行しています。

  • Rollbarへの例外通知
    • エラー管理サービスとして利用しているRollbarに例外情報を通知します。他SaaSを利用しているプロジェクトは良いように読み替えてください。
  • ログ出力
    • 上記のようなエラー管理サービスが不調な場合でも例外状況を把握できるようにログも出力しています。

例えば以下のように初期設定をしてみます。

# config/initializers/rails_error_subscriber.rb
class RailsLoggerErrorSubscriber
  def report(error, handled:, severity:, context:, source: nil)
    error_message = error.message
    message = "#{error.class}: #{error_message}\n#{(error.backtrace || caller)&.join("\n")}"
    severity = :warn if severity == :warning
    Rails.logger.public_send(severity, message)
  end
end

class RollbarErrorSubscriber
  def report(error, handled:, severity:, context:, source: nil)
    extra = context.is_a?(Hash) ? context.deep_dup : {}
    Rollbar.log(severity, error, extra)
  end
end

Rails.application.config.after_initialize do
  Rails.error.subscribe(RailsLoggerErrorSubscriber.new)
  Rails.error.subscribe(RollbarErrorSubscriber.new)
end

Rails.error.reportRails.error.handle で例外を処理する際に、登録したサブスクライバー全てに例外情報を通知することができます。

# これを
begin
  do_something
rescue SomethingIsBroken => error
  Rails.logger.error("#{error.class}: #{error_message}\n#{(error.backtrace || caller)&.join("\n")}")
  Rollbar.log(:error, error)
end

# こう書き換えられて
begin
  do_something
rescue SomethingIsBroken => error
  Rails.error.report(error)
end

# こう書くこともできる
Rails.error.handle(SomethingIsBroken) do
  do_something
end

この仕組みは「Rollbarから別のサービスに乗り換えるケース」でもとても役立ちます。そうしたケースでもRailsアプリケーション内の例外ハンドリング処理に手を加えずにinitializerの中身を変更するだけで対応が可能になります。

実行コンテキストの注入

どのリクエスト・ジョブで発生した例外なのかを表す「実行コンテキスト」情報がエラー通知には付与されて欲しいものです。それを便利に取り扱うための仕組みが ActiveSupport::ErrorReporter には用意されています。
各サブスクライバーに定義するreportメソッドの引数には context というものがあります。

def report(error, handled:, severity:, context:, source: nil)

本連載記事を購読してくださっている方はもうお気づきかもしれません。
はい、この context の実体は ActiveSupport::ExecutionContext です。

https://github.com/rails/rails/blob/v8.0.2/activesupport/lib/active_support/error_reporter.rb#L224

ActiveSupport::ExecutionContext の詳細については以下の記事をご参照ください。

ClinPeer Railsプロジェクトのオブザーバビリティ強化施策#実行コンテキスト

かなり掻い摘んで説明すると、 context にはリクエストやジョブが実行されているActionControllerやActiveJobのインスタンスが格納されています。

ClinPeerではこのようなSubscriberを定義することで、Rollbarへの全てのエラー通知に自動的に実行コンテキスト情報が付与されるようにしています。

class RollbarErrorSubscriber
  def report(error, handled:, severity:, context:, source: nil) # rubocop:disable Lint/UnusedMethodArgument
    extra = context.is_a?(Hash) ? context.deep_dup : {}

    controller = extra[:controller]

    extract_context!(extra)

    extra[:custom_data_method_context] = source

    scope = { request: controller&.rollbar_request_data, person: controller&.rollbar_person_data }
    Rollbar.scoped(scope) { Rollbar.log(severity, error, extra) }
  end

  private

  def extract_context!(context)
    # 現在実行されているコントローラまたはジョブの情報が設定されている
    # https://github.com/rails/rails/blob/v8.0.2/actionpack/lib/action_controller/metal/instrumentation.rb#L60
    # https://github.com/rails/rails/blob/v8.0.2/activejob/lib/active_job/execution.rb#L66
    controler_or_job = context.delete(:controller) || context.delete(:job)
    return unless controler_or_job.present? && controler_or_job.respond_to?(:_execution_context)

    context.reverse_merge!(controler_or_job._execution_context)
  end
end

なぜ ActiveSupport::ErrorReporter を使うのか(本当のメリット)

「PubSubな仕組みの便利さ」「実行コンテキスト注入の仕組み」について説明しましたが、実はこの程度であれば十分に自前で実装することが可能です。
その2点よりも遥かに大きな強みとして私が考えるのは「Railsが提供しているI/F」という点です。

この Rails.error.report というI/FがRails公式で提供されているため、Rails内やフレームワーク的なGemでの例外処理にデフォルトで組み込まれやすくなります。

実際にRails内でも何箇所か ActiveSupport::ErrorReporter が利用されています。

Rails内での利用例

v8.0.2時点

これを執筆している今現在も ActiveSupport::ErrorReporter の活用が進んでいます。
以下は2025年4月時点でのmainに取り込まれているPRです。

Rails以外の利用例

などのRails以外のGemでも ActiveSupport::ErrorReporter が利用が進んでいます。

また、ClinPeerでは自前でRollbarのSubscriberを定義していますが、 RollbarSentry が公式でSubscriberを定義していたりします。 実行コンテキスト周りにこだわる必要がなければそれらのGemを導入するだけでほどほどに例外通知される状態になります。

各フレームワーク層で ActiveSupport::ErrorReporter を活用した例外通知を実装してくれることで、アプリケーション内での例外補足を一定サボれるだけでなく、これまで考慮外にあった例外なんかを漏れなく補足することもできるようになり嬉しいですね。

おわり

偉そうに ActiveSupport::ErrorReporter について語りましたが、実はClinPeerの開発を始めるまで存在も知りませんでした(Railsの更新はマメにウォッチしているつもりなのですが)。まだまだRailsには伸び代があり、痒い所に手が届く感じが気持ちが良いですね。

既存のRailsシステムの例外ハンドリング処理に手を加えるのは大変ですが、こういった所でも小まめにRails Wayに乗っておくと将来の技術的負債の解消に繋がるので導入を検討してはいかがでしょうか。


是非読者になってください!


メドピアでは一緒に働く仲間を募集しています。
ご応募をお待ちしております!

■募集ポジションはこちら medpeer.co.jp

■エンジニア紹介ページはこちら engineer.medpeer.co.jp

■メドピア公式YouTube  www.youtube.com

■メドピア公式note
style.medpeer.co.jp

技術顧問Matzとは、どんな話をしているの?

こんにちは、組織開発グループの榎本です。

弊社の技術顧問にはまつもとゆきひろ(通称・Matz)さんがおり、Matzさん(以下、親しみも込めてMatzと表記します)とは定期的に「Matz会」と称して、Google Meetを繋いでリモートミーティングを開催しています。

Matz会のテーマは多岐にわたりますが、「毎回Matzとどんな話をしているの?」と気になっている開発者の方もいるかもしれません。本記事では、過去のMatz会開催実績を元に、その内容の一部をお伝えできればと思います。

講演の再演

過去のテーマ例:

  • RubyKaigi Keynote の再演
  • Matzが登壇したイベントの講演の再演
  • Matzチャンネルで話していたトピックの再演

一番わかりやすいのはこちらでしょうか。全員が全員、RubyKaigi現地に足を運んで、生Matzのキーノートを聴けるわけではないので、RubyKaigi閉会後すぐにMatzに再演してもらえるのは良い機会となりました。

tech.medpeer.co.jp 👆️RubyKaigi閉会後に行った感想戦の様子

過去に好評だった会でいうと、「動的型付け言語と大規模開発」の再演は、Matzの型に対する考え方を深く聞くことができました。

Rubyの最新機能の解説

過去のテーマ例:

  • YJIT
  • Prism
  • 次期Rubyバージョン解説

最新バージョンのRubyで導入される技術について、Matzに解説をしてもらいました。Rubyのリリースノートだけでは知ることのできない導入経緯(なぜその機能が入ったか、どんな課題を解決したいのか)や裏エピソード(Rubyコアチーム内でどんなコミュニケーションがあったのか)などが聞けて良かったです。

Rubyのコア技術の解説

過去のテーマ例:

  • YARV について
  • Ruby GVL について
  • Ruby GC の仕組み

このあたりの話を完全に理解するには、コンピュータサイエンスの知識も必要になってくるので、なかなか難しいテーマではありました。しかしエンジニアの知的好奇心を刺激する良いテーマだったと感じています。ときに話題はCRubyの内部実装に及ぶこともあり、Rubyエンジニアにとっては話についていくのでイッパイイッパイ、という人も多かったようです。

OSS

過去のテーマ例:

  • OSSへの貢献方法
  • Rubyのコミュニティ運営について
  • Rubyの新機能の意思決定について

OSS貢献未経験のエンジニアから「OSSに貢献したいけど貢献の仕方がわからない...」「OSSに貢献したいけどどうしたらいいの?」という声があり、それに対してMatzからいろいろアドバイスを貰えたのは良い機会でした。今後社内からOSS貢献するエンジニアが増えていくといいなと考えています。

Rubyのコミュニティ運営についても伺いました。個人的にあまり表に出てこないRubyの意思決定(新機能のAccept、あるいは提案のReject)の裏事情的な話が聞けたのが良かったと感じています。Rubyの進化の裏にあるMatzの様々な葛藤を垣間見ることによって、Rubyというプログラミング言語の存在の有り難みを改めて感じることができました。

キャリア論

過去のテーマ例:

  • エンジニア・キャリア戦略
  • AIと開発者の今後

Matzの考えるキャリア戦略について語ってもらいました。キャリアに関しては若手からシニアまで悩む開発者が多いと思うので、キャリアに迷う開発者にとって参考になる話だったと思います。

昨今のAIトレンドも取り入れて「AIと開発者の今後ってどうなると思う?」みたいなテーマについても語っていただきました。

Matzへプレゼン

過去のテーマ例:

  • 弊社メンバーが作成した LTをプレゼン
  • 弊社の事業紹介をプレゼン

弊社メンバーが過去に行った発表内容や弊社の展開している事業について Matz にプレゼンテーションを行う機会を設けました。Matzから内容について直接フィードバックいただける良い機会になりました。

tech.medpeer.co.jp 👆️MatzにRubyKaigi関連LTをプレゼンする様子

その他

過去のテーマ例:

  • Matzの開発環境について
  • Matzに何でも質問コーナー
  • mrubyについて

エンジニアとしては、Matzが普段使っているPC、OS、キーボード、デスク環境など気になる方も多いのではないかと思います。そのあたりの開発環境について掘り下げさせてもらいました。

またMatz会では毎回最後に「Matzに何でも質問コーナー」のような質問時間を設けています。この時間では時間の許す限り、弊社メンバーから思い思いのMatzに聞きたいことをぶつけています。生Matzをカンファレンスで見かけることはあっても、時間をとってもらって直接質問する機会はなかなか訪れないので、メンバーにとっては嬉しい機会になっていると感じます。

そしてMatzさんは月一でWeb会議を開いて頂いているので、そこで毎月疑問などをぶつけられます。(中略)こう言った著名な技術顧問の方がいるおかげで会社全体の技術力の底上げになっていると思います。 Railsの実装やRuby内部のことが気になった際にすぐに質問できるという環境はエンジニアにとってものすごく良い環境だと思っています。

tech.medpeer.co.jp

さいごに

いかがでしたでしょうか。

本記事で紹介したテーマは、Matz会運営チームが中心となって決めています。運営チームとしては、Rubyのコアな技術話からキャリアや開発環境などのカジュアルなテーマまで、幅広い開発者に楽しんでもらえるようなテーマ設定を心がけています。こうすることでジュニアレベルのエンジニアからシニアレベルのエンジニアまで、またRubyエンジニアから普段Rubyを使わないエンジニアまで、楽しんでもらえるテーマ設定になっていると思います。

本記事で紹介したトピックをMatzから直接聴きたい!という方がいらっしゃれば、ぜひ弊社への入社をご検討いただければと思います!


是非読者になってください!


メドピアでは一緒に働く仲間を募集しています。
ご応募をお待ちしております!

■募集ポジションはこちら medpeer.co.jp

■エンジニア紹介ページはこちら engineer.medpeer.co.jp

■メドピア公式YouTube  www.youtube.com

■メドピア公式note
style.medpeer.co.jp

Devin AIは技術的負債解消の救世主となるか?

Answer: 救世主まではいかないが、間違いなく助けになる。


組織開発グループの榎本です。世は大AIコード生成時代、皆さんバイブコーディングしていますか?

弊社においてもDevin、Clineを試験的に導入して活用し始めていますが、本記事では「Devin AIが技術的負債の解消に役立った話」を紹介したいと思います。

Devin AIとは

Devin AI(以下、Devinと表記)については既に各所で話題なので詳しい説明は省きますが、一言でいうと開発を手伝ってくれるAIエージェントです。イメージとしては、開発チームに新たに入ってくるジュニアクラスの開発者 が一番近いと言えるでしょう。

devin.ai

Clineとの違いとしては、クラウド上に独自の開発環境を持っていることでしょうか。一度クラウドに開発環境を作っておけば、Web UIやSlackから指示出しするだけで自律的にタスクをこなしてくれます。つまり、手元に動作する開発環境がなくとも、指示出しだけで開発が進められてしまうのがDevinの利点と言えます。

DevinにWebUIからコードフォーマットの指示出しをしている様子

技術的負債の現状

さて、現在私が対峙している技術的負債は下記のようなものです。

  • オレオレ・フレームワーク(OSSではない独自のフレームワーク実装)
  • デッドコード多数
  • テストなし
  • コード重複多数
  • 不要なファイル、不要な関数、不要な定数・変数が多数あり
  • 一貫性のないコーディングスタイル

これらの問題を効率的に解消するためにDevinの力が使えるのでは、と考えました。

どれくらいレガシー
発表資料「10年モノのレガシーPHPアプリケーションを移植しきるまでの泥臭くも長い軌跡 / legacy-php-app-migration - Speaker Deck」より引用

2023年時点のAI活用

2023年に「この負債解消にAIは活用できないものか...」と試したのですが、そのときはせいぜい ChatGPT や GitHub Copilot に読み解けない複雑なコードをぶん投げてわかりやすく解説してもらう程度でした。

そのとき抱いたAIに対する印象としては、「AIはコード・リーディングの助けにはなるが、コード・ライティングに関しては自分で書いたほうが速い」というものでした。つまり、役には立つが、技術的負債解消に大きく寄与するものとは言えませんでした。

AIは銀の弾丸か
発表資料「10年モノのレガシーPHPアプリケーションを移植しきるまでの泥臭くも長い軌跡 / legacy-php-app-migration - Speaker Deck」より引用

2025年、Devinの活用

しかしDevinは違いました。きちんと開発ルール(Devin用語的にはKnowledge)さえ整備しておけば、簡単なタスクはある程度雑な指示でも適切にこなしてくれました。

活用ユースケース

実際にDevinに依頼したタスクとしては下記のようなものです。

  • テストを書かせる
    • your/target/file の関数fooのテストコードを書いて」
  • コード・フォーマット
    • ※ 事前に 使用するフォーマッターの設定を済ませておき、フォーマットコマンドを開発ルールに明記しておく
    • your/target/file にコードフォーマットをかけて」
  • 簡単なリファクタリング
    • 「AクラスとBクラスを統合して」
    • 「CクラスをディレクトリXに移動して」
    • 「Dクラスの命名を適切なクラス命名 Xyzに変更して」
  • 不要コード削除
    • 「Aクラスの不要関数を削除して」
    • 「Bクラスの不要定数を削除して」
    • 「Cクラスの不要な require 処理を削除して」
    • 「Dファイルは不要なファイルだから削除して」
  • ライブラリ追加
    • 「このプロジェクトに静的解析ツールを追加したい。適切なツールを追加して」

技術的負債として長年放置されたコードベースを触っていると、作業を進めている中で無限にリファクタリングしたい箇所が出てきます。その中でも上述した簡単なリファクタリング・タスクをDevinに丸投げできるのはとても便利でした。

うまくいかなかった指示の例

逆にうまくいかなかった指示出しの例としては、下記のようなものです。

  • 「このプロジェクト全体から不要なコードを見つけて削除して」
    • Devinの計算資源(Devin用語的にはACU)を消費した割には、大した成果は出なかった
    • 対象がでかすぎて、結局「どこを重点的にリファクタしたい?」と逆質問されて止まった
  • 「(複雑なコードの)テストを書いて or リファクタリングをして」
    • Devinはシンプルなクラスのユニットテスト実装はそこそこ悪くないものを書いてくれた
    • 一方、複雑に入り組んだスパゲッティのようなコードのテストを書かせると、モックするコードだらけで何をテストしているか全くわからないコードが出力された
    • リファクタリングも同様で、それっぽいリファクタリングはしてくれるが、元のコードが複雑すぎてリファクタリングされてもレビュワーがその正当性を自信を持ってレビューできず、マージができない

AIエージェントが変なところでスタックして計算資源を使い続けるパターンはDevinに限らずあるようなので、変なループに入ったらさっさと「損切り(タスクの強制終了)」をしてしまうのがよさそうに感じました。

複雑なタスク依頼は工夫が必要

複雑なコードに関しては人間側がある程度適切な戦略を提示する必要があるようにも感じました。例えば下記のようなものです。

  • 「処理は流れは大きく変えずにリファクタリングして」
  • 「{手本となるユニットテストファイルのパス} のように、モックは最小限にしてテストを書いて」

新米エンジニアに難解なタスクを丸投げしてうまくいかないのと同様に、Devinにいきなり難しいタスクを依頼してもうまくはいかない ようです。

シンプルなプロンプトで複雑な巨大関数をDevinにリファクタさせた例。人間がレビューできるレベルの差分にはなっていない。

良い指示出し・悪い指示出し例に関しては、下記の公式ドキュメントにまとまっているので、あわせてご参照ください。

docs.devin.ai

良かった点

上述したユースケースはどれも簡単なものなので、別に人間に任せても問題なく遂行できます。

ではDevinに任せるメリットは何でしょうか?

  • 一人で作業が完結する
    • DevinがPull Request作成の主体となることで、それを私がレビュー → マージすることで一人で作業が完結します
    • 「人間のApproveがマージ条件」と設定しているチームにとって、レビュー待ちがボトルネックになりがちですが、一人で作業が完結する場合はPR作成→レビューがシームレスに実行できます
    • 結果、PRの作成数・マージ数が飛躍的に向上しました
  • 必要に応じてDevinを分身させることができる
    • 上述したタスクはそれぞれ独立しており、互いに依存性のないタスクです
    • したがって、それぞれのタスクを Devin を一時的に5体に分身させ進めてもらうことも可能です(やっていることは指示出しを並列に行うだけです)
  • 24時間/365日対応可能
    • 働き方改革により時間外労働の上限規制が厳しい昨今に、24時間365日対応可能なDevinは心強いです
    • 自分が稼働していない時間に、Devinが手足となって働いてくれます
      • ランチ休憩前に指示出し → 休憩後、成果確認
      • 終業前に指示出し → 翌日、成果確認
  • 文句を言わない
    • 新人エンジニアにつまらない単純作業を依頼し続けるのは、依頼者としても気まずいし、作業者としてもウンザリしちゃいますよね
    • その点、Devin は文句を言うことはなく、遠慮なくタスクを依頼し続けられるのが有り難い存在です
  • 小さいタスクに脳内ワーキングメモリを消費しなくて済む
    • 例えばあるファイルにコードフォーマットをかけるだけの簡単なタスクがあったとして、簡単だけど考えることはたくさんあります
      • 適切なブランチ名を考えて、ブランチ作成
      • 変更を行う
      • 適切なコミットメッセージとともに変更をコミット
      • git push する
      • 適切なDescriptionとともにPull Request作成
      • CIがパスすることを確認
      • 適切なレビュワーにレビュー依頼→Approve
      • Approve もらったらPRをマージ
    • このように簡単なタスクだったとしても、脳のワーキングメモリへの負荷は重く、タスクのコンテキストスイッチ・コストが少なからず発生します
    • これらの作業手順を「xxxをフォーマットしといて」の一言だけで片付けられるのは大革命です

どれくらい成果上がった?

下記は技術的負債対象リポジトリのPR数を月ごとに棒グラフに表示したものです。

  • 🔵青: 私の作成したPR数
  • 🔴赤: 私の指示でDevinに作らせたPR数

筆者のPR数と筆者の指示によってDevinに作らせたPR数

Devin導入月である2025年2月から明らかにPR数が増えていることが見て取れます。簡単なタスクな積み重ねではあるものの、一ヶ月間でDevinと力を合わせて100PR近く作成できたのは、私の開発者としてのキャリア史上初だったのでAIによる生産性向上の力を感じさせられました。

課題

一方で使ってみて感じた課題は下記です。

  • レビューの客観性の担保
    • 多くの開発現場で<第三者視点によるレビュー>をMUSTにしているかと思います
    • 開発者自らDevinに指示出ししてPR作成してもらい、指示出しした開発者がレビューを行うのは考え方によっては<セルフレビュー>とも言え、レビューの客観性が薄まる点が課題
    • 本記事で紹介した簡単なタスクは問題ないように思いますが、クリティカルな変更は然るべき第三者視点を持った開発者のレビューを受ける必要があると感じています
  • めちゃめちゃ疲弊します
    • 一人でエンドレスに働くことができるので、本気で一日Devinと協働するととても疲弊します
    • Devinとの働き方イメージ
      • Devin指示出しA → Devin指示出しB → 自分の作業進める → Devin AのPRレビュー&マージ → Devin BのPRレビュー&マージ → 自分の作業に戻る → Devin AとDevin Bの変更をリリース
    • 一日みっちりペアプロやったときと同じような疲労感に似ています
  • やってもらうタスクはあくまでジュニア開発者レベル
    • シニアエンジニアレベルの構造的なリファクタリングをやらせるのはまだ難しいなと感じました
    • 今後どこまで難しいタスクをスムーズにできるようになっていくのか、AIエージェントの進化が楽しみです

(おまけ)ACU消費量の目安

Devinの使用料金ですが、2025年3月現在、 Teamプランが月500ドル、月に使えるACU(計算資源)は250 ACUまでとなっています。

devin.ai

タスクの種類 消費ACUs
簡単なタスク 0.5 ~ 3 ACUs
重めのタスク 5 ~ 10 ACUs

※弊社では1 SessionあたりのACU Usage Limitを10に設定しているので10が最大となります

簡単なタスク依頼のACU消費例

重いタスクのACU消費例

まとめ

とにかくリファクタリングで手数が必要な技術的負債の解消には、Devinはとても役立つと感じました。


是非読者になってください!


メドピアでは一緒に働く仲間を募集しています。
ご応募をお待ちしております!

■募集ポジションはこちら medpeer.co.jp

■エンジニア紹介ページはこちら engineer.medpeer.co.jp

■メドピア公式YouTube  www.youtube.com

■メドピア公式note
style.medpeer.co.jp

ClinPeer Railsプロジェクトのオブザーバビリティ強化施策

こんにちは。サーバーサイドエンジニアの三村(@t_mimura39)です。

N番目の「ClinPeerアプリ開発の裏側連載記事」でございます(連載記事を同時執筆しているため本記事が何番目になるのかもはや不明)。 tech.medpeer.co.jp

今回はClinPeerで実践した「Railsプロジェクトのオブザーバビリティ強化施策」の紹介記事になります。少しマイナーなネタですね。

目次

オブザーバビリティとは

正直「オブザーバビリティ(可観測性)」という言葉を使いたかっただけなので、本記事を読むにあたってオブザーバビリティの定義について知らなくても大丈夫です。

詳細について知りたい方はこちらの記事やご自身で「オブザーバビリティ」について検索してください。
aws.amazon.com

ちなみにここ最近「オブザーバビリティ」とセットで語られがちの OpenTelemetry については本記事で取り扱いません。あくまでRailsアプリケーションに特化した施策の紹介をします。

「それ、オブザーバビリティ関係なくね?」という内容も多数含まれているかとは思いますが細かいことは気にしない方向でお願いします。

ログ

アクセスログの構造化

Rails標準のアクセスログは人間には読みやすいのですが、構造化されていないため機械的な検索で不便です。 Lograge などを活用してアクセスログを構造化しましょう。

Rails標準アクセスログ

Started GET "/" for 127.0.0.1 at 2012-03-10 14:28:14 +0100
Processing by HomeController#index as HTML
  Rendered text template within layouts/application (0.0ms)
  Rendered layouts/_assets.html.erb (2.0ms)
  Rendered layouts/_top.html.erb (2.6ms)
  Rendered layouts/_about.html.erb (0.3ms)
  Rendered layouts/_google_analytics.html.erb (0.4ms)
Completed 200 OK in 79ms (Views: 78.8ms | ActiveRecord: 0.0ms)
Lograge適用後

method=GET path=/ format=json controller=HomeController action=index status=200 duration=79.0 view=78.8 db=0.0

さらにClinPeerでは Lograge::Formatters::Json を利用して、JSON形式のログを出力しています。

{
  "method": "GET",
  "path": "/",
  "format": "json",
  "controller": "HomeController",
  "action": "index",
  "status": 200,
  "duration": 79.0,
  "view": 78.8,
  "db": 0.0
}

その他ログの構造化

LogrageはあくまでActionControllerのアクセスログのみを取り扱います。Jobのログや Rails.logger.info などで手動で出力するログなんかも構造化するために専用のJSONログフォーマッターを定義します。

ただJSON形式に変換するだけでなく、以下のような処理もいれています。

  • プロセスIDとスレッドID、Gitリビジョンなどのメタ情報の自動付与
  • Rails.logger.tagged により付与されるタグ情報の加工
class JsonLogFormatter < Logger::Formatter
  def call(severity, timestamp, _progname, message)
    log = {
      level: severity,
      pid: ::Process.pid,
      tid:,
      timestamp: timestamp.iso8601(3),
      revision: Rails.application.config.x.revision,
    }

    # Rails.logger.taggedを利用してログ出力すると「[hoge] [fuga] message」のような形式になる
    # これを解析しやすいJSON構造に加工する
    # ref. https://github.com/rails/rails/blob/v8.0.1/activesupport/lib/active_support/tagged_logging.rb

    # `#current_tags` はRails.logger.taggedで指定されたタグ配列が返却される
    # current_tags => ["hoge", "fuga"]
    if (tags = current_tags).present?

      # ActiveSupport::TaggedLogging::Formatter が自動で付与する 「[タグ1] [タグ2] ログ」のタグ部分を削除する
      last_tag = tags.last
      message = message.split("\n").map { it.split("[#{last_tag}] ").last }.join("\n")
      log[:tags] = tags
    end

    # for lograge
    # 既にJSON形式のメッセージが指定された場合はJSONの入れ子にならないようにマージする
    begin
      parsed = JSON.parse(message, symbolize_names: true)
      if parsed.is_a?(Hash)
        log.merge!(parsed)
      else
        # JSONパース可能だがObject(Hash)でない値(数値やboolなど)はmessageフィールドに設定する
        log[:message] = message
      end
    rescue JSON::ParserError
      log[:message] = message
    end

    "#{log.to_json}\n"
  end

  private

  # Sidekiqのtidに合わせている。ClinPeerではSidekiqを利用していないが参考実装としてSidekiqの処理を流用している。
  # ref. https://github.com/sidekiq/sidekiq/blob/0226e311d02de8ea77563c17c96a3d6527d0ec7f/lib/sidekiq/logger.rb#L80-L82
  def tid
    Thread.current["clinpeer_json_log_formatter_tid"] ||= (Thread.current.object_id ^ ::Process.pid).to_fs(36)
  end
end

このJSONログフォーマッターを活用することで、Railsが出力するログが全て構造化されたJSON文字列になります。

Rails.logger.tagged("タグです") { it.info("Hello") }
{
  "level": "INFO",
  "pid": 32619,
  "tid": "h6z",
  "timestamp": "2025-03-24T14:09:55.512+09:00",
  "revision": "96c042567198fb957ff81d210d0b3435dedbca67",
  "tags": [
    "タグです"
  ],
  "message": "Hello"
}

実行コンテキスト

上記JSONログフォーマッターでは汎用的なメタ情報としてプロセスIDなどを自動付与しましたが、「ログインしているユーザーのID」や「ジョブID」などログ出力時の実行コンテキスト情報が実際のトラブル発生時の調査では知りたくなるものです。
では、これらの情報はどのように管理しログに含めると良いでしょうか。

まず、ログに含めたい「実行コンテキスト」とは何かを整理します。

ActionController系のHTTPリクエストの場合

  • X-Request-ID
    • 同一リクエスト内で出力されたログを串刺し検索しやすいようにします。
    • Railsシステムから別の内部システムのAPIを呼び出す際なんかにリクエストIDを伝搬しておくとサービスを横断してログの検索がしやすくなります。
  • リクエストホスト
    • ClinPeerはドメインを分離して複数サービスを提供しているため、ぱっと見でどのホストへのリクエストなのかを判別しやすい形としたいです。
  • UserAgent
    • 大まかにどういった端末で発生している問題なのかを判別する際に有用ですね。
  • ログインユーザーID
    • 特定ユーザーの行動を追いやすくします。
    • サービス毎にログインするユーザーモデルが異なるなんてこともありますよね。

ActiveJobで処理する非同期処理の場合

  • ジョブID
    • 同一ジョブ内で出力されたログを串刺し検索しやすいようにします
  • ジョブ名
  • ジョブ引数
    • ClinPeerでは個人情報を含むセンシティブな値を引数に指定しないルールとしています。

このように「ActionController」と「ActiveJob」で大きく異なり、その中でもリクエストやジョブに応じて項目の増減がありそうです。
これらの実行コンテキスト情報を管理する仕組みとして ActiveSupport::ExecutionContext に目をつけました。

www.rubydoc.info

ActiveSupport::ExecutionContext はグローバルなオブジェクトで ActiveSupport::ExecutionContext.to_h[:key] のように任意の値を格納する箱として利用できます。
また、その名の通り実行コンテキストを表すものであるため「ActionController(HTTPリクエスト)」や「ActiveJob(非同期処理)」の実行毎に値がリセットされます。それに加えてRailsにより自動的に ActionControllerActiveJobのインスタンスが格納されるという特典付きです。

※ ちなみに ActiveSupport::ExecutionContext は本来Rails内部でのみ利用されるものであり nodocなAPI なのですが、とても便利なのでClinPeerでは今後の非互換な変更に恐れずに活用しています。

ということで、これでログフォーマッターの中から現在実行されている「ActionController」や「ActiveJob」のオブジェクトを参照することができるようになりました。
あとは各種コントローラーやジョブクラス毎に実行コンテキスト情報を定義できれば良いため簡易的な専用モジュールを開発しました。

# frozen_string_literal: true

module ExecutionContextStorable
  extend ActiveSupport::Concern

  included do
    class_attribute :_execution_context_procs,
                    instance_predicate: false,
                    instance_writer: false,
                    default: {}
  end

  class_methods do
    def execution_context(key, execution_context_procs)
      # Controllerの継承階層毎に設定値を分けるためにあえて冗長な代入形式で記述している。
      # class_attributeはセッターメソッド(代入)が呼び出されると新たにクラス属性を再定義する仕組みである。
      # self._execution_context_procs[key] = block と記述すると継承コントローラの設定値が破壊的に変更されてしまいController毎に設定を分離することができなくなる。
      self._execution_context_procs = { **_execution_context_procs, key => execution_context_procs }
    end
  end

  def _execution_context
    _execution_context_procs.transform_values do |execution_context_proc|
      instance_exec(&execution_context_proc)
    rescue StandardError => e
      raise if Rails.error.debug_mode

      # execution_contextの算出でエラーが発生した場合は個別にエラー通知しつつ処理を継続する
      Rails.error.report(e, context: { controller: nil, job: nil })
      "算出エラー"
    end
  end
end

使い方はこんな感じです↓

class ApplicationController < ActionController::Base
  include ExecutionContextStorable

  execution_context :host, -> { request.host }
  execution_context :request_id, -> { request.request_id }
  execution_context :remote_ip, -> { request.remote_ip }
  execution_context :user_agent, -> { request.user_agent }
module Admin
  class ApplicationController < ::ApplicationController
    execution_context :user_id, -> { Current.user&.id&.to_s }
# config/initializers/job_setup.rb
Rails.application.config.to_prepare do
  require "execution_context_storable"

  [
    ApplicationJob,
    ActionMailer::MailDeliveryJob,
    SolidQueue::RecurringJob,
    MaintenanceTasks::TaskJob,
  ].each do |job_class|
    job_class.class_exec do
      include ExecutionContextStorable

      execution_context :job_id, -> { job_id }
      execution_context :job_name, -> { self.class.name }
      execution_context :job_arguments,
                        -> { arguments.map { it.is_a?(ActiveRecord::Base) ? it.to_global_id.to_s : it } }
    end
  end
end

このように実行コンテキストを定義にすることで、程よくログを拡張することができます。

class JsonLogFormatter < Logger::Formatter
  def call(severity, timestamp, _progname, message)
    log = {
      level: severity,
      pid: ::Process.pid,
      tid:,
      timestamp: timestamp.iso8601,
      revision: Rails.application.config.x.revision,
      **execution_context, # ← 実行コンテキスト情報を自動付与
    }
  ...

  def execution_context
    context = ActiveSupport::ExecutionContext.to_h
    controler_or_job = context.delete(:controller) || context.delete(:job)
    if controler_or_job.present? && controler_or_job.respond_to?(:_execution_context)
      context.reverse_merge!(controler_or_job._execution_context)
    end

    # JSON変換できないようなオブジェクトが含まれている場合を考慮して強引に文字列変換している
    context.transform_values(&:to_s)
  end

リクエストログのトレースID

ClinPeerは「CloudFront -> ALB -> Rails(Puma)」の流れでリクエストが処理されます。
各コンポーネントで出力されるリクエストログを紐づけられるように、Railsが出力するログに二つのメタ情報を実行コンテキストとして定義します。

# app/controllers/application_controller.rb

class ApplicationController < ActionController::Base
  include ExecutionContextStorable

  # 省略

  execution_context :amzn_trace_id, -> { request.headers["HTTP_X_AMZN_TRACE_ID"] }
  execution_context :amz_cf_id, -> { request.headers["HTTP_X_AMZ_CF_ID"] }

詳細についてはAWSの記事をご参照ください。 repost.aws

When CloudFront forwards an HTTP request to its origin, it automatically injects the X-Amz-Cf-Id header, which contains an opaque string uniquely identifying the request. This value is logged in the CloudFront access logs under the x-edge-request-id field. Similarly, ALB automatically injects the X-Amzn-Trace-Id header when forwarding a request to the target group. This header's value is logged in the ALB access logs under the trace_id field.


CloudFront が HTTP リクエストをそのオリジンに転送すると、X-Amz-Cf-Idリクエストを一意に識別する不透明な文字列を含むヘッダーが自動的に挿入されます。この値は、CloudFront アクセス ログのx-edge-request-idフィールドに記録されます。 同様に、ALB はX-Amzn-Trace-Idリクエストをターゲット グループに転送するときにヘッダーを自動的に挿入します。このヘッダーの値は、ALB アクセス ログのtrace_idフィールドに記録されます。

ログ出力例

ここまでの「構造化」や「実行コンテキストの定義」を整備することで、以下のようなログが出力されるようになります。

ActionController(HTTPリクエスト)

{
  "level": "INFO",
  "pid": "12345",
  "tid": "54321",
  "timestamp": "2024-07-23T22:38:57.512+09:00",
  "revision": "96c042567198fb957ff81d210d0b3435dedbca67",
  "host": "example.com",
  "request_id": "1d30c56b-5354-4f46-92e7-fb5286bd09e0",
  "remote_ip": "127.0.0.1",
  "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) ...",
  "amzn_trace_id": "Root=1-67565de7-4e2740f76c1c1df17ee5f652",
  "amz_cf_id": "iJCXbHIkFi7CSfYGlHL3DWJZ1JQQKAToCOfunzS1T3xzatQ7-2TBRA==",
  "user_id": "1234",
  "message": "abc"
}

ActiveJob(非同期処理)

{
  "level": "INFO",
  "pid": "12345",
  "tid": "54321",
  "timestamp": "2024-07-23T22:38:57.512+09:00",
  "revision": "96c042567198fb957ff81d210d0b3435dedbca67",
  "job_id": "e99344cd653d255cc259330a",
  "job_name": "MaintenanceTasks::TaskJob",
  "job_arguments": "[]",
  "message": "abc"
}

これくらいの情報が半自動的にログ出力されるようになっているため、何かトラブルが発生してもログから大抵の状況は判別可能です。

query_log_tagsの有効化

ActiveRecordが発行するSQLに自動でコメントを付与してくれる機能も有効化します。これは、スロークエリーの原因調査などでとても重宝します。

api.rubyonrails.org

Rails.application.configure do
  config.active_record.query_log_tags_enabled = true
  config.active_record.cache_query_log_tags = true
  config.active_record.query_log_tags = [
    :application,
    :namespaced_controller,
    :action,
    :job,
    {
      revision: -> { Setting.revision },
      request_id: ->(context) { context[:controller]&.request&.request_id },
      job_id: ->(context) { context[:job]&.job_id },
    },
  ]
end

余談ですが、ブロック引数として渡されている context の正体は前述の ActiveSupport::ExecutionContext です。元々は本query_log_tagsに特化した実行コンテキスト管理の仕組みをRails内で汎用的に利用できるように切り出されたものが ActiveSupport::ExecutionContext だったりします。

github.com

この設定を有効化することで、発行されるSQLに以下のようにコメントが付与されるようになります。(ここでは見やすいように改行をいれています)

INSERT INTO `admin_users` (`created_at`, `updated_at`, `email_address`, 省略...) 
/*
action='create',
application='Clinpeer',
namespaced_controller='admin%2Fadmin_users',
request_id='158a5197-71b8-4864-a714-4fed2a523db7',
revision='36b596184898d19be179bd13d5572256f12b2bba'
*/

さて、ログの話は少し飽きてきたので話題を変えましょう。

ActiveStorage+S3

ClinPeerでは「Amazon S3」と「ActiveStorage」の組み合わせでファイルを管理しています。
ただ単純にファイルのアップロード・ダウンロード・配信をするだけであれば素直にActiveStorageを利用するだけで良いのですが、少し手を加えてより良い形でファイルを管理してみます。

ActiveStorageを利用してファイルをS3にアップロードした場合、 ActiveStorageが管理するテーブルである active_storage_blobs にS3オブジェクトのパス情報が格納されます。
通常これで困ることはないのですが、一つ気になる点がありました。

それは「active_storage_blobs のレコードは物理削除されたが、S3バケット上にオブジェクトが残されると当該S3オブジェクトは迷子になってしまう」という点です。
本来 active_storage_blobs のレコードが削除(destroy)されると、自動で対応するS3オブジェクトも削除されますが以下のようなケースでは注意が必要です。

  • active_storage_blobs をActiveRecordのコールバックが発火されない形(delete など)で削除した場合
  • S3のライフサイクルポリシーの設定により「削除マーカー」が有効化されており、削除したと思っていたオブジェクトが実は削除マーカーによる論理削除状態であった場合

こういった状況では迷子になったS3オブジェクトがどのような経緯でアップロードされたのか、お掃除的に完全物理削除して良いのかの判断が困難になります。
ということで、ClinPeerではS3オブジェクトそのもののメタ情報を拡充させることにしました。

Amazon S3にはオブジェクトアップロード時に任意の値を同時に登録することができる「オブジェクトメタデータ」という仕組みがあります。ここにファイルアップロード時の情報を格納することでオブザーバビリティを向上させようという狙いです。

docs.aws.amazon.com

ActiveStorageの正規な利用方法では上記のような対応はできないため、少しだけモンキーパッチを当てて対応しています。
プログラマなら言葉ではなくコードで語れ ということで実装をそのまま紹介します。

# lib/monkey_patches/active_storage_blob_trace_custom_metadata.rb
# frozen_string_literal: true

raise("Consider removing this patch") unless ActiveStorage::VERSION::STRING.in?(["8.0.2", "8.1.0.alpha"])

# https://github.com/rails/rails/blob/v8.0.2/activestorage/app/models/active_storage/blob.rb
# rubocop:disable Layout/LineLength
module MonkeyPatches
  module ActiveStorageBlobTraceCustomMetadata
    extend ActiveSupport::Concern

    prepended do
      attr_accessor :record

      before_validation :set_trace_custom_metadata, if: :new_record?
    end

    private

    def set_trace_custom_metadata
      execution_context = ActiveSupport::ExecutionContext.to_h
      execution_context_attrs = {}
      if (controller = execution_context[:controller]).present?
        execution_context_attrs[:request] = "#{controller.class}/#{controller.request.request_id}"
      end
      if (job = execution_context[:job]).present?
        execution_context_attrs[:job] = "#{job.class}/#{job.job_id}"
      end
      self.custom_metadata = {
        uploaded_by: record&.to_global_id&.to_s,
        revision: Rails.application.config.x.revision,
        **execution_context_attrs,
        **custom_metadata.symbolize_keys,
      }
    end

    class_methods do
      # https://github.com/rails/rails/blob/v8.0.2/activestorage/app/models/active_storage/blob.rb#L80-L99
      # 基本的な処理はそのままで引数として渡されてきたrecordをnewまで引き回すようにしているだけ
      def build_after_unfurling(io:, filename:, key: nil, content_type: nil, metadata: nil, service_name: nil, identify: true, record: nil)
        new(key:, filename:, content_type:, metadata:, service_name:, record:).tap do |blob|
          blob.unfurl(io, identify:)
        end
      end

      def create_after_unfurling!(io:, filename:, key: nil, content_type: nil, metadata: nil, service_name: nil, identify: true, record: nil)
        build_after_unfurling(key:, io:, filename:, content_type:, metadata:, service_name:, identify:, record:).tap(&:save!)
      end

      def create_and_upload!(io:, filename:, key: nil, content_type: nil, metadata: nil, service_name: nil, identify: true, record: nil)
        create_after_unfurling!(key:, io:, filename:, content_type:, metadata:, service_name:, identify:, record:).tap do |blob|
          blob.upload_without_unfurling(io)
        end
      end
    end
  end
end
# rubocop:enable Layout/LineLength

ActiveSupport.on_load(:active_storage_blob) do
  prepend(MonkeyPatches::ActiveStorageBlobTraceCustomMetadata)
end

主にニ種類の情報をS3オブジェクトのメタデータとして登録しています。

ファイルアップロードのきっかけとなったレコード情報

user.avatar.attach(params[:avatar]) のようなアップロード処理を実行した際に、 uploaded_by = gid://clinpeer/User/123 のようなメタ情報を付与します。こうすることで、どのレコードに関連するファイルだったのかをS3オブジェクトのメタデータから読み取ることができます。
また、ActiveStorageで管理するBlobデータは複数レコードに関連することができます。これをS3メタデータ上に表現することはややこしくなるためあくまで「アップロード時のレコード情報」に限定しています。

モンキーパッチは些細なもので、元々ファイルアップロード時に ActiveStorage::Blob モデルまで渡されてきたレコード情報をもう少し引き回して ActiveStorage::Blob#custom_metadata に格納するだけです。

ActiveStorage::Blob#custom_metadata にメタ情報を設定しておくとS3アップロード時のオブジェクトメタデータとして登録してくれるのですが、どうやら正規の手段ではこのcustom_metadataをファイルアップロード時に指定する方法がないようです。

ファイルアップロード時の実行コンテキスト情報

前述のログ出力時の実行コンテキストと似たような内容です。ここでも ActiveSupport::ExecutionContext を活用してファイルアップロード時の「ActionController」や「ActiveJob」の情報をS3オブジェクトのメタ情報として登録します。
ここではログより簡素に "#{controller.class}/#{controller.request.request_id}" "#{job.class}/#{job.job_id}" のようななオレオレフォーマットでHTTPリクエストと非同期処理Jobを特定できる情報を設定しています。

ログと同様に実行コンテキスト情報を充実させても良かったのですが、本件はログより参照する機会が限定されていると考えてメタデータは必要最低限に留めるようにしました。

モンキーパッチやめたい

この程度でもモンキーパッチを抱えることは負債につながるリスクがあるため、できればRails本家に一定サポートして欲しいものです。
本件の内容をProposalとして https://discuss.rubyonrails.org に投げましたが現在はまだ反応がありません。

discuss.rubyonrails.org

Amazon S3 メタデータ

ちなみに最近「Amazon S3 メタデータ」と呼ばれる機能が新規にリリースされました。これはS3オブジェクトのメタデータをApache Icebergテーブルに保存し、Amazon Athenaなどのツールを活用することで高速にメタデータの検索ができるようになるものです。

dev.classmethod.jp

これを活用するとさらにオブザーバビリティが向上しそうですが、手が回っておらず今後の伸び代ということで寝かせています。

APM

先の述べた通り、OpenTelemetryなんかは利用しておらず直接DataDog APMを活用している現状です。
特筆するほどの工夫はないため割愛します。

docs.datadoghq.com

エラー管理

弊社では主に Rollbar を利用しています。
ClinPeerではRollbarへのエラー通知処理に関しても実行コンテキストの渡し方を少し工夫しているのですが、それに関しては後日別の記事でご紹介予定です。

おわり

今回はClinPeerで実践した「Railsプロジェクトのオブザーバビリティ強化施策」について紹介しました。
システム運用する上で、何か問題が発生した際に「推測」ではなく「決定的な根拠」を元に起きている事象を説明できる状態が重要だと考えます。そのためにも「ただ動くものを作って終わり」ではなく「万が一この事象が発生したらどうなるのか」「予めこのような考慮をいれておいたら役に立つことがあるのではないか」などを常日頃考えるようにすると良いかと思います。

本記事が世間のRailsプロジェクトのオブザーバビリティレベルにほんの少しでも貢献できたら幸いです。


是非読者になってください!


メドピアでは一緒に働く仲間を募集しています。
ご応募をお待ちしております!

■募集ポジションはこちら medpeer.co.jp

■エンジニア紹介ページはこちら engineer.medpeer.co.jp

■メドピア公式YouTube  www.youtube.com

■メドピア公式note
style.medpeer.co.jp

ClinPeer Maintenance Tasks Gemの活用事例

こんにちは!
サーバーサイドエンジニアの伏見です。

弊社で新しくリリースした「ClinPeer」の裏側連載。

tech.medpeer.co.jp

この記事では、サーバーサイドで導入したGem Maintenance Tasks について書いていきます。

導入のモチベーション

開発初期や今後の運用の中で、データマイグレーション*1を考える場面が多くなるだろうという考えがありました。
Rails ConsoleやRake Taskで運用することがあるかと思いますが*2、以下のような懸念がありました。

  • 本番環境のサーバーにアクセスをして実行することが必要になる、実行の権限管理の問題が生じる
  • コンソールでの手動操作になるため、誤操作のリスクがある
  • 同期的にJobが実行されるため、コネクションが切れるなどのリスクがある
  • 実行した履歴が追いづらい

このような課題に対して、Maintenance Tasks はそれらを解決の見込みがあるのではとなりました。

  • 実行権限をサービスの管理画面のアカウント権限に寄せることができる
    • 本番サーバーへのアクセス権限と比べて、権限を委譲するのが容易になる
  • 専用のダッシュボードがある
    • 100%防げるわけではないが、実行するコードが画面に表示されたり、実行の確認がCUI操作より容易である
    • 作成されているタスク以外の操作が画面上からはできないため、誤操作を減らせるという利点がある
    • 実行状況や、履歴を見ることができる
  • Jobの実行が非同期である
    • Maintenance Tasks はActive Job に依存してJobが実行される

社内の別サービスですでにMaintenance Tasksを導入して運用している事例もあり、ClinPeer でもMaintenance Tasks の導入に至りました。

ディレクトリ構成

Maintanance Tasks のタスクファイル自体は以下のディレクトリに配置していました。

app/tasks/maintenance
├─ [namespace] # 運用で継続して実行が見込まれるタスクを、namespaceを切って格納する
└─ one_shot # 単発で実行後、削除を予定しているタスク

基本的にはMaintanance Tasks のgenerate コマンドで生成される構成に寄りつつ、継続で実行することが見込まれるものと、そうでないものでディレクトリを分けるという運用を行なっておりました。

metadataの設定

Maintenance Tasksは実行ごとにmetadataという形で追加の情報を付与することができます。*3
ClinPeerでは、taskを実行したユーザーのIDをmetadataとして渡すようにしています。

MaintenanceTasks.metadata = lambda {
  # 本来Admin名前空間以外からはCurrentオブジェクトを参照できないようにしているがここは例外的に許容する。
  { user_id: Admin::Current.user&.id }
}

こちらによって、ダッシュボード上で実行されたタスクを見た時に、誰が操作を行ったのかが分かりやすくなります。

callbackの設定

Maintenance Tasksでは実行中のライフサイクルごとに、callbackを設定することができます。*4
ClinPeerではcallbackを利用して、実行状態のslack通知を行っていました。

公式ドキュメントではタスクファイルに設定を記述する方法を紹介をされていますが、共通の設定にするため、metadataと同様initializersに記載しています。

Rails.autoloaders.main.on_load("MaintenanceTasks::Task") do
  MaintenanceTasks::Task.class_eval do
    %i[start pause cancel complete error].each do |event|
      public_send(:"after_#{event}") { notify(event) }
    end

    private

    def notify(event)
      SendSlackJob.perform_later # Slackに通知を送信する処理、詳細は省略
    end
  end
end

Error Reporter

ここからは小ネタです。

Rails にはエラー発生時の処理をまとめるために、Error Reporter という仕組みが提供されています。 (Error Reporter については、後日紹介記事が書かれる予定なので、詳細についてはご期待ください)
(三村追記)紹介記事書きました! tech.medpeer.co.jp

Maintenance Tasks は、Jobのエラー発生時、Error Reporter でエラー情報が送信されるようになりました。
Error Reporter をすでに利用しているサービスであれば、エラーハンドリング周りの処理を書く必要が無くなります。

github.com

ClinPeerでも元々Error Reporter を使用していて、自前でエラーハンドリング周りの処理を記載していましたが、このアップデート以降は自前で書いていた処理を削除することができました。

Maintenance Tasks とSolid Queue の併用

Maintenance Tasks で使用されているGemに job-iterationがあります。
このGem はJob の中断や再開を可能にするActiveJob の拡張機能です。
ClinPeer でMaintenance Tasks を導入した際、job-iteration がSolid Queue のサポートが不十分だったため、TERM シグナルの受信する処理を自前で記載しておりました。
こちらも、Solid Queue 公式でサポートされ、プロセス停止時に差し込めるフックが追加されています。

github.com

github.com

終わりに

ClinPeer でのMaintenance Tasks の利用にあたってのtips や小ネタを紹介してきました。
開発開始から早い段階で導入し、リリース後の現在も引き続き利用していますが特に大きな問題もなく現在は利用することができています。
ぜひ同じような課題感を感じていたら、利用を検討してみてください。


是非読者になってください!


メドピアでは一緒に働く仲間を募集しています。
ご応募をお待ちしております!

■募集ポジションはこちら medpeer.co.jp

■エンジニア紹介ページはこちら engineer.medpeer.co.jp

■メドピア公式YouTube  www.youtube.com

■メドピア公式note
style.medpeer.co.jp

*1:ここでのデータマイグレーションは、データそのものの移行、変更に焦点を当てています。

*2:https://speakerdeck.com/ohbarye/data-migration-on-rails で詳しく話されています

*3:https://github.com/Shopify/maintenance_tasks?tab=readme-ov-file#metadata

*4:https://github.com/Shopify/maintenance_tasks?tab=readme-ov-file#using-task-callbacks

ClinPeer Railsプロジェクトの設定値管理(2025年版)

こんにちは。サーバーサイドエンジニアの三村(@t_mimura39)です。

こちらでご案内した通り、弊社で新しくリリースした「ClinPeer」の裏側をご紹介します。 tech.medpeer.co.jp

今回は小ネタとして「Railsプロジェクトの設定値管理」についてご紹介します。

目次

設定値管理とは

ここでは「実行環境毎に異なる値」を「Rubyなどのプログラムコードとは別のYAMLなどのファイル」で管理する方法について言及します。
例えば、「SaaSのAPIを利用するための接続情報」だったり、もっと単純に「異常発生時の通知先Slackチャンネル」のようなものがあります。
この仕組みを利用することでプログラムの複雑さを軽減することができ、可読性の向上に繋がります。

before

# 環境差分を設定値として切り出さずにプログラム内でif分岐
def host
  if Rails.env.production?
    "prd.example.com"
  else
    "dev.example.com"
  end
end

after

# 環境差分を設定ファイルに切り出すことでプログラムが単純化する
def host
  Setting.host
end
production:
  host: prd.example.com
development:
  host: dev.example.com

Railsプロジェクトでの設定値管理の選択肢

Settingslogic

github.com

馴染み深い方も多いのではないでしょうか。
シンプルな機能で使い勝手も良かったため私も多用していました。

しかし、ここ最近 メンテナンスが停滞しており 今後の継続利用が難しい状況です。
脱Settingslogicを検討した・されているプロジェクトが多くあるかと思います。

SettingsCabinet

github.com

Settingslogicの置き換えを狙って作成されたGemです。
採用事例は少ないですが、薄い実装のGemのためSettingslogicの使い勝手が特に気に入っている方はこちらを検討してみると良いかと思います。 zenn.dev

その他諸々Gem

他にも様々な設定値管理用のGemがありますがここでは割愛します。
以下のようなGemがありますが、いずれも機能過多に感じます。

github.com github.com github.com github.com

Rails標準 config_for

というわけで、今回採用した方法はこちらになります。

api.rubyonrails.org

脱Settingslogicからconfig_forに乗り換えたプロジェクトも多いのではないでしょうか。
しかし、config_forには「設定値をネストできない」という弱点があります。正確にはネストはできますがHashでの管理となるため参照する際に気持ちが良くありません。

# config/dummy.yml
development:
  a:
    b: c
Rails.application.config_for(:dummy)
#=> {a: {b: "c"}}

Rails.application.config_for(:dummy).a
#=> {b: "c"}

Rails.application.config_for(:dummy).a[:b]
#=> "c"

Rails.application.config_for(:dummy).a.b
undefined method 'b' for an instance of Hash (NoMethodError)

ClinPeerでは少し工夫してconfig_forを利用することで本弱点を回避しています。

config_forの活用方法(定義・参照)

一つの config/application.yml などに全ての設定値を集約定義するのではなく、設定値の種類毎にファイルを分離して管理しています。
以下のような使い方です。

$ tree config/settings/
config/settings/
├── application.yml
├── firebase.yml
├── host.yml
├── rollbar.yml
├── sendgrid.yml
├── slack.yml
└── zendesk.yml
# config/settings/sendgrid.yml
development:
  api_key: dummy_development_api_key
test:
  api_key: dummy_test_api_key
Rails.env
#=> "development"
Setting::SendGrid.api_key
#=> "dummy_development_api_key"

このように管理する理由は二つあります。
まず一つ目は「単純に管理がしやすい」点です。全ての設定値を一つの設定ファイルで管理すると数百、数千行にもなることがあり管理が煩雑になります。

二つ目は「config_forの弱点(設定値のネストがしにくい)を軽減できる」点です。設定値のネストがしたくなる粒度でファイルを分割することで本問題を回避することができます。ファイルが量産されることによる管理の煩雑さが懸念としてありますが、現状問題になったことはありません。

config_for活用方法(裏側)

このような形で Setting名前空間配下に設定値種類毎の専用モジュールを定義する形で実装しています。

# config/initializers/002_setting.rb
module Setting
  {
    "Application" => "config/settings/application.yml",
    "Firebase" => "config/settings/firebase.yml",
    "Host" => "config/settings/host.yml",
    "Rollbar" => "config/settings/rollbar.yml",
    "SendGrid" => "config/settings/sendgrid.yml",
    "Slack" => "config/settings/slack.yml",
    "Zendesk" => "config/settings/zendesk.yml",
  }.each do |k, v|
    const_set k, Rails.application.config_for(Rails.root.join(v))
  end

  def self.method_missing(name)
    Application[name]
  end

  def self.respond_to_missing?(name, _include_private)
    Application.key?(name)
  end
end

ちなみに、 application.yml にはより汎用的な設定値を定義しており Setting.revision のようなシンプルなI/Fで参照できるようにしています。

なぜ config.x. を使わないのか

Rails Guide では config.x.payment_processing.schedule = :dailyconfig.payment = config_for(:payment) のような使い方が紹介されています。
もちろんこのような形でも利用可能ですが、 config.x.payment_processing.schedule のような参照方法は長く気持ちが良くないため採用しませんでした。
Setting::PaymentProcessing.schedule のようなI/Fの方が美しいですよね。

また、I/Fが美しいということはテスト時にもモックしやすいと同義です(要出典)。

allow(Setting::PaymentProcessing).to receive(:schedule).and_return(:daily)

なぜ動的に定義しないのか

Rails.root.glob("config/settings/*.yml") のような形で config/settings 配下に配置されているファイルに対応するモジュールを動的に定義することも可能です。が、ClinPeerはあえてそのような形にはせずに先のような「モジュール名」と「ファイルパス」のマッピングを定義しています。
大した理由はないのですが、余談程度にご説明します。
設定値の多くはSaaS毎に定義されます。SaaSの名称は専用のinflection定義が必要になる場面があり(SendGridなど)その管理コストを払うくらいであれば明示的にマッピング定義をした方が分かりやすくて良いだろうと判断した結果になります。
あとはシンプルにコード検索もしやすいですね。

こういった仕組みは一度作られると中々改善されないので、些細なところまでこだわりました。

秘匿値管理方法

少し話を変えて、ClinPeer内での秘匿値の管理方法についてご紹介します。
秘匿値の特性に応じて主に2種類の管理方法を使い分けています。

管理方法 参照・更新可能者 特性
環境変数 DBパスワード、JWT秘密鍵 一部のリードレベルのエンジニアのみ 漏洩すると個人情報の流出につながるリスクのある秘匿性の高い値
Rails標準のCredential管理 SaaSのAPIキー ClinPeer開発エンジニア全員 万が一漏洩してもイタズラ程度でしか悪用されることがない値

環境変数

実際の秘匿値はRailsリポジトリとは別のTerraform管理用リポジトリで暗号化された状態で管理されています。
Rails ECSコンテナ起動時に環境変数として注入されるため、一部のエンジニア以外は設定値に触れることなくセキュアに管理することができます。

一方で、秘匿値を追加する際に二つのリポジトリを跨いで修正し、デプロイに関しても順序を気をつける必要があるため若干管理が煩雑になるデメリットがあります。

Rails標準のCredential管理

皆様お馴染みの bin/rails credentials:editそれです。

ClinPeerではRails標準の「development, test, production」の他に「staging」というRAILS_ENVも定義しています。
Multi Environment Credentialsの機能を活用して環境毎に分離して秘匿値を管理しています。

「Multi Environment Credentials」って便利なのにRails公式のドキュメントに記載ないのですよね。以下のブログが詳しそうだったため詳細はこちらをご参照ください。 blog.saeloun.com

ClinPeerプロジェクト発足時にはこれらのどちらかを選択するか悩みましたが、どちらを利用しても良いのだということに気がつき適宜使い分ける運用としています。

参照方法

秘匿値の管理方法は上記の通り2種類ありますが、アプリケーションコード上で参照する際にはその点を意識しなくて済むような形としたいです。
設定YAMLファイルの中でERB評価する形で値を埋め込むことで、Setting::SendGrid.api_key Setting::Rollbar.server_access_token のような統一的なI/Fで参照できるようになっています。

# config/settings/sendgrid.yml
development:
  api_key: dummy_api_key
test:
  api_key: dummy_api_key
staging:
  api_key: <%= ENV["SENDGRID_API_KEY"] %>
production:
  api_key: <%= ENV["SENDGRID_API_KEY"] %>
# config/settings/rollbar.yml
development:
  server_access_token:
test:
  server_access_token:
staging:
  server_access_token: <%= Rails.application.credentials.dig(:rollbar, :server_access_token) %>
production:
  server_access_token: <%= Rails.application.credentials.dig(:rollbar, :server_access_token) %>

Rubyな値を一度YAMLに変換した後にconfig_forでRubyな値に変換するという若干無駄なコストを払っていますが、この使い方が今のところ一番扱いやすく感じています。

おわり

前回のブログ記事(ClinPeer Railsプロジェクトの技術選定(2025年版) - メドピア開発者ブログ)とは違い、少し実装に踏み込んだ話をしてみました。
今回のような小ネタが続く予定ですが、どうぞお楽しみください。

(オマケ)Slack通知先チャンネルの管理方法について

ClinPeerでは特定のイベント発生をトリガーとして、Slackチャンネルに通知するような実装がいくつかあります。
実行環境毎に通知先を分ける必要があり、その通知先管理についても本config_forの仕組みを活用しています。

その際のTipsとして、「通知先チャンネル名」をそのまま設定値として管理するのではなく「通知先チャンネルのID」を管理することをオススメします。 (SlackのAPIは通知先を指定する際にチャンネル名でなくチャンネルIDを受け付けているため、大抵のコードは置き換えるだけで動作するはずです)

# config/settings/slack_channel.yml
development:
  ops: "C07AAAAAAAA" # playground
test:
  ops: "C07AAAAAAAA" # playground
staging:
  ops: "C07AAAAAAAA" # playground
production:
  ops: "C07BBBBBBBB" # ops

このようにしておくと、万が一Slackのチャンネル名が変更された際にも通知処理でエラーにならずに継続動作します。 設定値管理とは本質的には無関係の超小ネタでした。


是非読者になってください!


メドピアでは一緒に働く仲間を募集しています。
ご応募をお待ちしております!

■募集ポジションはこちら medpeer.co.jp

■エンジニア紹介ページはこちら engineer.medpeer.co.jp

■メドピア公式YouTube  www.youtube.com

■メドピア公式note
style.medpeer.co.jp