メドピア開発者ブログ

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

Amazon CloudFront環境におけるクライアントIPアドレスについて 〜CloudFront-Viewer-Addressの紹介〜

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

本日はRuby・Railsの話に限定せず、Amazon CloudFront を利用している方に役立つ情報をご提供します。

目次

はじめに

弊社は基本的にAWS上にRailsアプリケーションを構築しているため、CDNが必要になるとまず選択肢として挙がるのが 「Amazon CloudFront(以下CloudFront)」です。
「CloudFront」を最前列に配置して、その後ろに「ALB」と「Railsアプリケーションが稼働するECS」を置くような構成が主流です。

この構成の場合一つ困ることがあります。 それは「RailsアプリケーションからクライアントのIPアドレスを取得できない」という点です。

Railsアプリケーションへのリクエストの途中にプロキシやロードバランサーが挟まるとそれらのIPアドレスを「クライアントのIPアドレス」とRailsが誤認してしまうためです。

この話はCloudFrontやRailsに限定されたものではなく、古くからWeb業界で認識されている内容かと思います。
「古くからWeb業界で認識されている」ということは回避方法があります。

まずは従来の回避方法について簡単にご紹介します。

「X-Forwarded-For」を活用する方法

「X-Forwarded-For(以下XFF)」とはクライアントIP アドレスを特定するために利用されるHTTPヘッダーです。

developer.mozilla.org

以下のような構文で経路上のIPアドレスがすべて含まれます。

X-Forwarded-For: <client>, <proxy1>, <proxy2>

基本的にはXFFの先頭のIPアドレスが「クライアントのIPアドレス」となるわけですが、本ヘッダーは容易に改ざん可能(≒ IPスプーフィングリスクがある)なため一工夫必要です。
本件については以下のブログ記事が詳しいため詳細はこちらをご参照ください。

mrk21.hatenablog.com www.m3tech.blog

誤解を恐れずに簡単に説明すると「CloudFrontを含む信頼できるIPアドレスの一覧を取得しXFFからそれらを間引いた結果の末尾のIPアドレスをクライアントのIPアドレスとみなす」といった仕組みになります。

この仕組みの実装例として以下のようなGemがあります。

もちろんこれらのGemを使わずとも自前でCloudFrontのIPアドレスを取得・管理し、nginxなどでヘッダーを調整するようなアプローチもあるかと思います。

実装方法はともかく、この仕組みでクライアントの安全にIPアドレスを取得することが可能になります。
古くから伝わっている手法のためセキュリティ的なリスクは低いですが、以下のような問題点があります。

  • 背景も含め若干複雑な仕組みとなってしまう
  • (実装方法によっては)「CloudFrontのIPアドレスが更新された際に即時反映されず正しくクライアントのIPアドレスが取得できない」のリスクがある

「CloudFront-Viewer-Address」を活用する方法

上記XFFの問題を解決するための手法の一つとして、「CloudFront-Viewer-Address」をご紹介します。
「CloudFront-Viewer-Address」は2021年頃提供された機能で特別新しい情報ではないのですが、弊社内ではあまり認識されていなかったため改めて今回取り上げてみました。

詳細は以下のブログ記事がとても詳しいので是非ご参照ください。CloudFrontの設定方法についても詳細に書かれていたためとても参考になりました。

dev.classmethod.jp

簡単に説明しますとCloudFrontを経由するアクセスに対して、以下のような構文でCloudFrontのViewer(クライアント)のIPアドレスとポート番号をヘッダーに付け加えてくれる機能です。

CloudFront-Viewer-Address: <IPアドレス>:<ポート>

# IPv4 IPアドレス=192.0.2.0, ポート=46532
CloudFront-Viewer-Address: 192.0.2.0:46532  

# IPv6 IPアドレス=2001:DB8:0:0:8:800:200C:417A, ポート=46532
CloudFront-Viewer-Address: 2001:DB8:0:0:8:800:200C:417A:46532

本ヘッダーを活用することで、従来の手法(上記XFFの手法)よりもシンプルで確実にクライアントのIPアドレスを取得することができます。 一点注意点として、「CloudFront-Viewer-Address」はIPアドレスだけでなくポート番号が末尾に付与されるためここは微調整が必要です。

Railsエンジニアへ

ここからはRailsに限定されたお話です。
上記の通り「CloudFront-Viewer-Address」ヘッダーを利用することでクライアントのIPアドレスが取得できるのですが、いざRailsでリクエストヘッダーを参照するとなると少し面倒です。

正確にいうと、「単純にリクエストヘッダーを参照するだけ」であればこのように簡単に実装できます。

class ApplicationController
  def 本当のリモートIP
    if headers[:HTTP_CLOUDFRONT_VIEWER_ADDRESS].present?
      headers[:HTTP_CLOUDFRONT_VIEWER_ADDRESS].remove(/:\d+\z/)
    end
  end
end

しかし、従来の request.remote_ip のI/Fを維持するにはRack層で処理する必要がありそうです。 (モンキーパッチを当てたりすればどうとにでもなりますが、ここは健全なアプローチで話を進めます。)

というわけで、あらかじめ用意しておいたものがこちらになります。 github.com

本Gemをインストールするだけで、自動的に「CloudFront-Viewer-Address」を参照し request.remote_ip でクライアントのIPアドレスが取得できるようになります。
専用のRackミドルウェア( ActionPack::CloudfrontViewerAddress::RemoteIp )を作成し、自動的に ActionDispatch::RemoteIp の後ろにinsertするような作りとなっています。

注意点

本Gemは「CloudFront-Viewer-Address」の値を信頼して実装しています。
Railsに対しての全てのリクエストがCloudFrontを経由していれば本ヘッダーは改ざんされることがないため安全です。

しかし、CloudFrontを経由しないアクセスを受け付けている場合は、任意のIPアドレスを「CloudFront-Viewer-Address」に指定することができてしまうためIPスプーフィングのリスクがあります
各々のインフラ構成やIPアドレスの利用方法に応じて、ご利用の判断をしてください。

おまけ

CloudFrontには「CloudFront-Viewer-Address」の他にも便利なリクエストヘッダーを付与する機能があります。

例えばIPアドレスを元に「緯度・経度」「国名・都市名」なんかをCloudFrontが算出し、「CloudFront-Viewer-Address」と同じような感じでヘッダーに付与してくれるようなものです。
詳細はAWSの公式ドキュメントをご参照ください。 docs.aws.amazon.com

自前でGeo情報に問い合わせていたような処理を置き換えられることが期待できますね。

参考資料


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


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

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

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

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

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

EM目線で見たiOSDC/DroidKaigi

モバイル開発グループのリーダーを務める小林(@imk2o)です。 今年もiOSDCとDroidKaigiに参加してきました! 例年と異なり、今年はモバイル開発グループのマネジメントを行う立場で参加したこともあり、いつもとは異なる目線でカンファレンス参加への意義や、今後のモバイルアプリエンジニアのキャリアについて考えてみました。

開発コミュニティに関わること

まずこういったカンファレンスを「情報収集を行うための場」という見方をしていませんか? もちろん新しい技術や知見を獲得できる機会であることは間違いありませんが、もっと大切なことがあります。

それは他のエンジニアたちと直接意見交換することだと私は思います。 (これはiOSDC主宰の長谷川さんもよく仰っている言葉ですよね)

社内のエンジニア同士はもちろん、できれば社外のエンジニアとも積極的なコミュニケーションを取ってもらいたいなと思います。 現地にいくと、オンライン上では明かされない話や内情など、共感できる内容が聞けちゃうことが多いです。 企業ブースにいるエンジニアの方だけでなく、同じテーブルに座った方や待機列の後ろの方に「ちょっと話しませんか?」みたいに声をかけるだけで、実は皆さんけっこう会話してくれるんですよね(今回も何度かそういう機会がありました...優しい世界だ)。

アプリエンジニアのキャリア

キャリアについてはエンジニア共通の課題と言えるかもしれないですが、アプリエンジニアにはどんなキャリアがあるのかという点は、私もメンバーと会話するときによく悩んでいるところです。

そんな中DroidKaigiでのkonifarさんのセッションがまさにドンピシャのテーマだったので紹介しておきます。 Androidエンジニアに限らず、あらゆるエンジニアにとって参考になるのではないかと思います。 www.youtube.com

一度、自分のこれまでの経験を棚卸ししてみるとよさそうですね。 その中で自分の強みとか、伸ばしたいスキルを見つけ磨いておくと、必ずどこかで生かせる機会があるのではないでしょうか。 特にアプリエンジニアは、他ポジションのエンジニアやデザイナー、営業・企画チームなど様々な人と関わる機会が多いので、円滑にコミュニケーションするための知識やベーススキルを身につけ、守備範囲を広げやすい職種かなと思っています。

アプリエンジニアを持つマネージャの方へ

もしアプリエンジニアがiOSDC/DroidKaigiのようなカンファレンスの現地参加に消極的であるなら、ぜひ背中を押してあげてください。目前の仕事を一旦棚上げしてでも、行ってもらう価値はあると思います。 「楽しんでこい!」と送り出すとはいえ、マネージャとしては何かしらの成長・成果は期待したくなるでしょう。 一例ですが、

  • エンジニアコミュニティの輪を広げたか
  • 技術ブログに書く
  • 社内勉強会や報告会を行う
  • 業務に生かせる技術やソリューションの提案
  • 今後発表やLT登壇し自己成長やプレゼンスを上げる

などについて1 on 1で会話してみてもよいかなと思います。

ではまた来年、カンファレンスでお会いしましょう!


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


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

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

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

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

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

DroidKaigi 2024に参加してきました!

はじめに

こんにちは!メドピアにてモバイルアプリエンジニアをしている佐藤です。
今年のDroidKaigiに、弊社はサポーターとして協賛し、総勢3名のモバイルアプリエンジニアがオフラインにて参加しました。
実際に足を運んだセッションを中心に、DroidKaigi 2024の参加レポートをお届けいたします。

セッションについて

Android ViewからJetpack Composeへ 〜Jetpack Compose移行のすゝめ〜

syarihuさんによるセッションです。

youtu.be

このセッションではJetpack Composeへの移行の進め方が中心のお話となります。
弊社では複数のスマホアプリをリリースしておりますが、ほとんどのアプリがAndroidViewで実装されている為、今後移行を進めていく際の参考になる内容がてんこ盛りでした。
すでに動いているアプリではFullCompose化を目指すのはデグレのリスク面から腰が重くなってしまう部分がありますが、UIの描画だけをCompose化する方法や実例を基にした移行のお話などがあり、弊社アプリでも移行を進める際の戦略に取り入れて進めていきたいと思いました。

2024年のナビゲーション・フォーカス対応:Composeでキーボード・ナビゲーションをサポートしよう

Tiphaine (ティフェン)さんによるセッションです。

youtu.be

ナビゲーション・フォーカスの説明から実装方法、デバッグのやり方について触れられていました。
私はナビゲーション・フォーカス対応の実装経験がなかった為、とても興味深く勉強になる内容でした。
知識として知っているというだけでもアプリ改善の際の提案の幅が広がるので、本セッションを通じてAndroidアプリエンジニアとして一段階成長することが出来たと思います。

また、余談にはなりますが本セッションのスピーカーであるTiphaineさんは弊社の元社員であり、MedPeer時代には多くのことを学ばせていただきました。
セッション後には弊社モバイルアプリエンジニアと記念撮影も行い、ブログへの掲載も快諾してくださいました。
Tiphaineさんの多大なるご厚情に心より感謝申し上げます。

要約:
あざす〜!!!!!!!!!!!!!!!
今度みんなで飲みに行きましょ〜🍻

Tiphaineさん(写真中央)と弊社MBアプリエンジニアの王(写真左)と佐藤(写真右)

KSPの導入・移行を前向きに検討しよう!

shxun6934さんによるセッションです。

youtu.be

KSPとkaptの説明、移行のメリットなどについて触れられていました。
弊社のアプリの中にはDataBidingを多用しているアプリもあり、DataBindingはKSPに非対応であることからKSPとkaptを併用するとビルド時間が遅くなるというのはまさに直面しそうな内容だった為、本セッションで予め知れて良かったです。
今後K2への移行に伴いKSPとkapt周りは触れる部分になってきますので、このセッションの内容はタイムリーでとても参考になりました。

電池寿命を考えた位置情報の監視方法を考える(Geofence)

はるちろさんによるセッションです。

youtu.be

Geofenceの説明、自作のGeofenceとGMSの比較などについて触れられていました。
Geofenceの機能を扱うアプリはあまり多くはないと思いますが、弊社のリリースしているアプリの中にはGeofenceを用いているアプリもあった為とても親和性を感じるセッションでした。
私自身もGeofence周りのコードに触れたことはありますが、私が1から開発したのではなく前任者から引き継いだアプリの為Geofenceの理解は浅瀬だった為、本セッションを通じて理解を深められ本当に良かったです。
余談ですがこのセッションを聞いた当日にGeofenceに関連するissueが立てられた為、「楽しい〜!」ってなりながら対応を進められました!笑

仕組みから理解する!Composeプレビューを様々なバリエーションでスクリーンショットテストしよう

Sumio Toyama (sumio_tym)さんによるセッションです。

speakerdeck.com

各ライブラリの説明やテストの実装方法について触れられていました。
コードを中心にお話があり、本セッションを通じて実装方法が参考になるのはもちろんのこと、スクリーンショットテストへの理解もとても深まる内容でした。
本セッションのお話でもあったような特定のバリエーションにおけるUIのバグ周りはまさに後回しにしがちだったりナイトモード自体にまだ対応していなかったりだった為、今後取り入れていきたい内容が満載のセッションでした。

アプリをリリースできる状態に保ったまま段階的にリファクタリングするための戦略と戦術

Yuki Anzaiさんによるセッションです。

youtu.be

リファクタリングを進める上での戦略から実際にどのようにリファクタリングを行うかをコードを交えてのお話でした。
参考になるリファクタリング例がてんこ盛りで勉強になったのはもちろんのこと、品質を維持したままリファクタリングを進める上での戦略がとてもわかりやすく説明されておりました。
特に戦略①であった環境を整えるの部分については改めてとても大切だと実感しましたので、弊社モバイルアプリエンジニア内においても周知を行いたいと思います。

Jetpack ComposeにおけるShared Element Transitionsの実例と導入方法 またその仕組み

hyogaさんによるセッションです。

youtu.be

Shared Element Transitionsの説明から実装方法についてのお話でした。
Shared Element Transitionsへの理解が深まったのはもちろんのこと、サンプルアプリも素晴らしくこんな素敵なアプリのコードがGitHub上で公開されていることに感謝が堪えません。
特に遷移アニメーション中のHorizontalPagerのスクロールを無効にするバグ対応の部分は結構な躓きポイントだと思います。もし私が実装していたら余裕で頭を悩ませていた自信があります笑
これらの解消方法含め知ることが出来てとても面白いセッションでした。

分析に裏打ちされたアプリウィジェット開発 - Jetpack Glanceとともに

Miyabi Goujiさん、Yuri Oguraさんによるセッションです。

youtu.be

ウィジェットに関する説明、ウィジェットを設置することによるKPIへの貢献や効果検証、実装方法などについて触れられていました。
個人的にはこのセッションが一番面白かったです。
特にデータ分析周りにも触れられており、ウィジェットが事業に対してどのように貢献し、そして認知度の少ないウィジェット機能をどのように訴求したかについても話されており、本セッションの内容はモバイルアプリエンジニアだけでなくアプリに携わるチームメンバーに対しても知っておいてもらいたい内容でした。
YouTubeにアップロードされた後は早速社内のチームメンバーにもシェアしました!

2024年最新版!Android開発で役立つ生成AI徹底比較

Nishimyさん、wiroha(ゐろは)さんによるセッションです。

youtu.be

Gemini、GitHub Copilot、ChatGPTの比較から活用事例について触れられていました。
生成AIは今では開発においてなくてはならないツールの一つであり、弊社においてもCopilotは希望するエンジニア全員に、ChatGPTも社内版ChatGPTの構築が行われエンジニア以外にも多くのメンバーが活用しております。
各生成AIの実務レベルでの活用例を本セッションで知ることが出来、弊社においてもより一層生成AIの活用を促進していきたいと思える素晴らしいセッションでした。

社内版ChatGPTの構築に関しては記事も公開されていますので、興味のある方はご参照ください。

tech.medpeer.co.jp

使って知るCustomLayout. vs DailyScheduler

Saiki Iijimaさんによるセッションです。

youtu.be

CustomLayoutに関する説明からDailySchedulerの実装方法について触れられていました。
ゴリゴリのJetpackComposeセッションでCustomLayoutに関する理解が深まったのはもちろんのこと、8:28辺りにある各関数の使い分けが出来るフローチャートはわかりやすくて最高です。
レイアウト周りは触れることの多い箇所なので、本セッションを理解することでレイアウト構築における選択肢の幅が広がる素晴らしいセッションでした。

AndroidアプリのUIバリエーションをあの手この手で確認する

Nozomi Takumaさんによるセッションです。

youtu.be

タイトル通りUIバリエーションの確認に関する様々な説明について触れられていました。
多くの確認方法の説明がありとても勉強になりました。特にフォルダブル端末の確認方法については疎い部分がありましたので、フォルダブル端末の確認やVirtual sensors活用例の部分は本当に助かる情報でした。

実践!難読化ガイド

みっちゃんさんによるセッションです。

youtu.be

タイトル通り丸々難読化に関するお話です。
難読化周りはたまにしか触れないので若干苦手意識があったのですが、このセッションのお陰で難読化面白い!となりました。
特に原理原則に基づいて立ち向かおう!のお話はコアな内容でありながらもとてもわかりやすくまとめられておりめちゃくちゃ面白かったです。

余談にはなりますが弊社では定期的に勉強会を実施しているのですが、現在はOWASP Mobile Top 10に関する勉強会を実施しており、このセッションを聞く2日前に M7: Insufficient Binary Protection Threat Agents の章に関する勉強会を実施してリバースエンジニアリングに関して語った後でしたのでより一層難読化セッションが楽しめました。
難読化のセッションと合わせてOWASP Mobile Top 10のこちらの章を読むことでより一層理解が深まると思います。

スポンサーブースについて

スポンサーブースにも多くの人がいて賑わいを見せており、すべては回りきれませんでしたが私も多くのスポンサーブースに足を運ばせていただきました!
ブースに足を運ぶことで各社に対する理解が深まり、ブースへ足を運んだことをきっかけに数多くのアプリをインストールしました!
話を聞いた後に実際にその会社のアプリのUI/UXに触れるのはリアルで交流した後ということもありめちゃくちゃ面白いです。

話を聞いてサービス面で特に面白かったのはMagicPodさんです。
一部弊社のサービスでもコア機能となる箇所はEspressoを用いてUIテストの導入を進めたりもしましたが、コア機能ゆえに変更が入り壊れることが多々ありメンテナンスには苦労しているのですが、何とMagicPodさんではAIが自動でスクリプト修正を行なってくれるとのこと!!!
UIテストの導入コストが下がるだけでなくメンテナンスコストも下げられる素敵なサービスだと思いました!

magicpod.com

また、ノベルティも多くいただきました!
どれも素敵なノベルティばかりでしたが、個人的に特に嬉しかったのがタイミーさんとnewmoさんのノベルティです。

タイミーさんのブースではガチャを回しタンブラーが当たったのですが、飲み物を美味しく飲むのはもちろん、ロゴがとても可愛いのでMTGでちょっとした雑談の際のネタにもなりそうなので重宝しております。

newmoさんではフェイスタオルをいただきました。私は趣味で毎週スーパー銭湯に通い月一ペースで遠征も行なっており、新しい銭湯施設に行った際は温泉タオルを収集している位温泉タオルが好きなので、このノベルティは本当に嬉しかったです。
このフェイスタオルを持ってDroidKaigiの翌日にスーパー銭湯で早速活用させていただきました!
切実に販売もして欲しい…!笑

最後に

DroidKaigi 2024はセッションによる勉強、ブースでの交流と得ることが多くとても有意義な大会でした!
このような素敵な大会を提供してくださったDroidKaigi運営の方々・スピーカーの皆様・多くの企業・DroidKaigiに関わるすべての方々に感謝を申し上げます。
また来年も参加できることを楽しみにしております!

弊社MBアプリエンジニア小林(写真右)と佐藤(写真左)


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


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

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

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

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

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

YJIT有効化後にUnicornワーカーを増やした場合の各メトリクスの推移について

はじめに

こんにちは。サーバーサイドエンジニアの冨家(@asahi05020934)です。現在は、全国の医師が経験やナレッジを 「集合知」として共有し合う医師専用コミュニティサイト「MedPeer」の開発を行っています。

Ruby 3.2からYJITが実用段階になりました。「MedPeer」でもパフォーマンスを改善するためにYJITを有効化することになりました。

本番環境でYJITを有効化後、ECSサービスのMemory Utilizationに余裕があったので、ECSサービスのUnicornワーカー数を増やすことを検討しました。 しかし、YJIT有効化後のUnicornワーカー数とメトリクスに関してまとめられた記事が少なく、どのように推移していくのか予想を立てるのは簡単ではありませんでした。YJIT有効化後にUnicornワーカー数を増やすと各メトリクスはどのように増減していくのでしょうか。

そこで、今回は「MedPeer」の本番環境でYJIT有効化後にUnicornワーカー数を増やしていった場合の各メトリクスの増減を明らかにしていきます。

計測した環境

以下の環境をもとに計測を行いました。

  • ECSタスク数: 8
  • Rubyのバージョン: 3.3
  • Railsのバージョン: 7.0.8.4
  • ECSタスクが使用するメモリ量: 8192MiB
  • ECSタスクが使用するCPUユニット数: 4096

手順

以下の手順に沿って計測していきました。

  1. Unicornワーカー数が96の日の0時0分から23時59分までのメトリクスを計測する
  2. Unicornワーカー数を96から104に増やす
  3. Unicornワーカー数が104の日の0時0分から23時59分までのメトリクスを計測する
  4. Unicornワーカー数を104から112に増やす
  5. Unicornワーカー数が112の日の0時0分から23時59分までのメトリクスを計測する
  6. 1と3と5の計測した結果を比較する

それぞれの計測日を「MedPeer」のサービスの1つである「Web講演会」の全配信の日程に合わせることで、リクエスト数やリクエストの内容に大きな差異が起きないように工夫しました。

計測すると、Unicornワーカー数112の時点でECSサービスのMemory Utilization Maxの最大値が84.23%まで上昇しました。 これ以上Unicornワーカー数を増やすと本番環境がダウンする可能性を危惧し、Unicornワーカー数を112まで検証することにしました。

結果

表1は、Unicornワーカー数96、Unicornワーカー数104、Unicornワーカー数112の各メトリクスの平均値を表したものです。

Unicornワーカー数96 Unicornワーカー数104 Unicornワーカー数112
ECSサービスのCPU Utilization Avgの平均(%) 5.19 3.33 5.38
ECSサービスのMemory Utilization Avgの平均(%) 35.10 35.50 36.70
ALBのResponse Time p50の平均(ms) 50.00 51.00 48.00
ALBのResponse Time p90の平均(ms) 193.00 210.00 210.00
ALBのResponse Time p95の平均(ms) 317.00 380.00 327.00
ALBのResponse Time p99の平均(ms) 643.00 735.00 647.00
RDSのCPU Utilizationの平均(%) 5.32 4.15 5.87
レイテンシー p50の平均(ms)   41.10 40.80 39.90
レイテンシー p75の平均(ms)   68.40 68.00 68.50
レイテンシー p90の平均(ms)   150.30 159.00 154.00
レイテンシー p95の平均(ms)   255.50 289.30 253.10

表1 Unicornワーカー数96、Unicornワーカー数104、Unicornワーカー数112の各メトリクスの平均値

表2は、表1をもとに各メトリクスの変化率を計算したものです。

96から104の変化率(%) 104から112の変化率(%)
ECSサービスのCPU Utilization Avgの平均 -35.84 61.56
ECSサービスのMemory Utilization Avgの平均 1.14 3.38
ALBのResponse Time p50の平均 2.00 -5.88
ALBのResponse Time p90の平均 8.81 0.00
ALBのResponse Time p95の平均 19.87 -13.95
ALBのResponse Time p99の平均 14.31 -11.97
RDSのCPU Utilizationの平均 -21.99 41.45
レイテンシー p50の平均   -0.73 -2.21
レイテンシー p75の平均   -0.58 0.74
レイテンシー p90の平均   5.79 -3.14
レイテンシー p95の平均   13.23 -12.51

表2 Unicornワーカー数96、Unicornワーカー数104、Unicornワーカー数112の各メトリクスの変化率

考察

ECSサービスのCPU Utilization Avgの平均

計測結果

96から104の変化率は-35.84%であるのに対し、104から112の変化率は61.56%であることがわかりました。このことから、ワーカー数を上げることでECSサービスのCPU Utilization Avgは上がるとは限らないことがわかりました。

考察

直感に反する結果となりました。リクエストの負荷自体が少ない場合、ワーカーを増やしても処理するタスクが少ないため、CPU使用率はあまり上がらなかった可能性があると考えました。

ECSサービスのMemory Utilization Avgの平均

計測結果

ワーカー数を増やすたびに35.10%、35.50%、36.70%と増えています。このことから、ワーカー数を増やす程ECSサービスのメモリがより使われていくことがわかりました。

考察

直感通りの結果になりました。YJITはメタデータにもメモリを使用します。そのため、ワーカーを増やしてメタデータが増えたことで、メモリ使用量が増えることが考えられます。

ALBのResponse Time

計測結果

p50の平均はワーカー数を増やす度に50.00ms、51.00ms、48.00msとレスポンス速度が速くなっていることがわかりました。しかし、p90の平均、p95の平均、p99の平均はワーカー数を上げる度に速度が上がっていないことがわかりました。このことから、ワーカー数を上げることで、基本的なレスポンス速度は速くなるが、遅めのレスポンスは速度が速くなるとは限らないことがわかりました。

考察

p50の平均以外は、直感に反する結果となりました。p90以上ではネットワークの遅延が影響し、一部のリクエストが遅くなる可能性があると考えました。

RDSのCPU Utilizationの平均

計測結果

96から104の変化率は-21.99%であるのに対して、104から112の変化率は41.45%であることがわかりました。このことから、ワーカー数を上げることで、RDSのCPU Utilizationの平均は上がるとは限らないことがわかりました。

考察

直感に反する結果となりました。ワーカー数を増やしても、発行されるクエリがシンプルで、CPUを大量に使用しない場合、RDSのCPU利用率は上がらなかった可能性があると考えました。

レイテンシー

計測結果

p50の平均は、ワーカー数を上げるたびに41.10ms、40.80ms、39.90msと速くなっていることがわかりました。しかし、p75の平均、p90の平均、p95の平均はワーカー数を上げる度に速度が速くなっていないことがわかりました。このことから、ワーカー数を上げることで基本的なレイテンシーは速くなるが、遅めのレイテンシーは速くなるとは限らないことがわかりました。

考察

p50の平均以外は直感に反する結果となりました。高パーセンタイルのレイテンシーには、ネットワークの遅延や一時的な輻輳が影響することがあるので、順当に速くなっていかなかった可能性があると考えました。

結論

以上、「MedPeer」の本番環境でYJIT有効化後にUnicornワーカー数を増やした場合の各メトリクスの増減を明らかにしていきました。その結果、ワーカー数を増やすことで、「ECSサービスのMemory Utilization Avgの平均」は順当に増えていき、「ALBのResponse Time p50の平均」と「レイテンシー p50の平均」は順当に速くなることがわかりました。これは、Unicornワーカー数を増やすことでアプリケーションのメモリ使用量は増えていき、基本的な速度は改善していくということでしょう。

しかし、他のメトリクスはワーカーを増やすことによって順当に変化していきませんでした。より正確な原因については明らかになっていないので、今後の課題にしたいと思います。

最後に

今回の記事の他にもYJITに関連がある記事を執筆しているので、興味がある方はぜひ読んでみてください。


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


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

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

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

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

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

iOSDC Japan 2024に参加しました

みなさん、こんにちは!アプリエンジニアのオウです。

先日、iOSDC Japan 2024に参加してきました!今年、メドピアはシルバースポンサーとしてiOSDC Japanをサポートいたしました。会場はたくさんの参加者で賑わい、とても充実した時間を過ごすことができました。いくつか印象に残ったセッションを紹介したいと思います。

Appleウォレット / Googleウォレットに チケットを保存する方法 speakerdeck.com

このセッションでは、Apple WalletとGoogle Walletにパスを保存する方法についての説明です。実は今回のiOSDCで初めてチケットをApple Walletに保存したんですが、流れがスムーズで印象的でした。普段はWallet関連の開発には関わらないんですが、これを機にちょっと調べてみました。iOSでパスを新規作成するのはシンプルなんですが、更新フローになるとWeb APIやプッシュ通知を使うので結構複雑。今後のプロジェクトで使えたらいいなと思っています。

メインスレッドをブロックさせないためのSwift Concurrencyクイズ speakerdeck.com

このセッション、個人的には結構面白かったです。Swift Concurrencyのクイズを通して実践的な知識が身につく感じがしました。これまで意識していなかったんですが、SwiftのバージョンによってConcurrencyのルールが変わることもあるんですね。ActorとかGlobal Actor周りのルールは覚えなくても、コードを書くときにメインスレッドをブロックしないよう意識することが大事だと思います。ちなみに、5問中4問正解しました!!

Meet BrowserEngineKit speakerdeck.com

BrowserEngineKitについてのセッションでした。これはiOS17.4から使えるようになるみたいで、実際のプロジェクトで利用できるようになるのは少し先ですが、カスタマイズ性が高いので今後使ってみたいなと思っています。ただ、EUの制約に対する対応が必要だったりと、ストア公開にはいくつかハードルがあるみたいです。セキュリティ面の制限も厳しそうです。XPC frameworkも興味深いです。シンプルなAPIインターフェイスでプロセス間の安全なデータやり取りができるのが特徴です。

StoreKit2によるiOSのアプリ内課金のリニューアル speakerdeck.com

StoreKitからStoreKit2に移行する話についてのセッションでした。自分もいくつかのプロジェクトでStoreKitを使っているんですが、トランザクション処理が結構面倒だなと感じていました。StoreKit2ではトランザクション処理が簡単になり、非同期コードもより直感的に書けるようになっているのがいいですね。StoreKit Testingや返金対応も便利になっているので、今後のプロジェクトでも活用したいです。

すべてのヘルスケアデータを紐解く speakerdeck.com

iOS 17からヘルスケアデータをエクスポートできるようになったということで、どんなデータが取れるのかを紹介するセッションでした。普段あまり触らない分野なので、細かくデータの中身を説明してもらえて勉強になりました。そもそもCDAという標準規格があるのも知らなかったです。医療業界でこういったデータが活用される場面が増えるのかもしれません。

リョムキャットのパーフェクトSwiftネーミング教室 speakerdeck.com

このセッションでは、Swiftにおける命名規則についてのベストプラクティスを学びました。個人開発だとあまり気にしないかもしれませんが、チーム開発では読みやすくて保守しやすいコードを書くための命名ルールが本当に重要だなと感じました。

所感

今年のiOSDCは9年目の開催ということで、運営もとてもスムーズで、快適に参加することができました。会場の配置や時間管理、会場の気温など、細部にまで気を配った運営には感心しました。

イベント中に他の参加者や企業の方々と交流することで、普段は知ることのできない知識を得ることができ、とても有意義な時間を過ごせました。特にiOS関連の深い話題や、日常業務ではなかなか触れない技術について学べたのが大きな収穫でした。

ただ、残念だったのはAIに関する話題があまり取り上げられなかったことです。私は、今後のアプリ開発においてAIが非常に大きな影響を与えると考えています。AI技術の進展により、私たちの開発プロセスや提供するユーザー体験がどう変わっていくのか、自分自身でもリサーチしてみようと思います。

今回のiOSDCで得た新しい技術や他の開発者とのつながりは、今後の仕事にも役立つと感じました。来年もまた参加できることを楽しみにしています。


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


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

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

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

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

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

RailsアプリケーションにThrusterを導入する

こんにちは。サーバーサイドエンジニアの @atolix_です。

今回は37signalsが公開しているThrusterを、メドピアで本番運用をしているアプリケーションのkakariに導入してみました。

kakari.medpeer.jp

Thrusterとは

Thrusterはアセット配信やX-Sendfileのサポートといった機能を持ったGo言語製のHTTP/2プロキシです。

詳細な設定不要でRails標準のPumaサーバーと組み合わせて動作して、nginxを構成に加えた時と同様のリクエスト処理を行うことが可能です。

github.com

www.publickey1.jp

導入前の状況

導入以前のkakariは以下のような構成を取っていました。 アセット配信の為にALBの背後にnginxを置いています。

今回は管理コストを下げる為に、nginxを剥がしてThrusterからアセット配信を行えるように構成を変更していきます。

※Cloudfront等のCDNを使用する選択肢もありましたが、本サービスのクライアントへの影響を抑える為に、ドメイン及びインフラ構成の変更を最小限に留めることを優先して今回はThrusterを選定しています。

移行の前にnginxでやっていた処理と同等の事がThrusterでも可能かチェックします。

チェック項目 Thrusterで可能か
Body Sizeの制限 MAX_REQUEST_BODYというオプションで設定可能。
gzip圧縮 js/css ファイルは自動的に圧縮されて配信される(content-encoding: gzipが付与される)
配信するアセットのキャッシュと最大サイズ指定 キャッシュ可能。最大サイズはMAX_CACHE_ITEM_SIZEで指定。

移行手順

安全に切り替えを行う為に今回はECS serviceごと差し替える方法を取りました。

1. Rails側にThrusterを導入する

一時的にnginxでリクエストを受けるECS serviceと、Thrusterでリクエストを受けるECS serviceを並行に稼動させたいので、Thruster用のポートを8080番で開けます。

加えてnginxの設定と同等のカスタム設定を記述していきます。Thrusterのオプションに用いる環境変数は接頭辞にTHRUSTER_を付けても認識されるので、他の環境変数と区別しやすいように今回は付け加えています。

また、ThrusterはPumaをラップして起動するのでPumaの起動コマンドの頭にthrustを付け加えます。

# Dockerfile
ENV THRUSTER_HTTP_PORT 8080 # デフォルトでは80番ですが、nginxの設定とぶつからないように8080に変更
ENV THRUSTER_MAX_REQUEST_BODY 104857600 # リクエストの最大サイズ
ENV THRUSTER_MAX_CACHE_ITEM_SIZE 3145728 # キャッシュの最大サイズ
ENV THRUSTER_HTTP_READ_TIMEOUT 300 # READのタイムアウト秒数
ENV THRUSTER_HTTP_WRITE_TIMEOUT 300 # WRITEのタイムアウト秒数
...
CMD ["thrust", "bundle", "exec", "puma", "-C", "config/puma.rb"]

EXPOSE 3000 8080 # 3000番にリクエストを投げるとThrusterを無視してpumaにリクエストが飛ぶ

2. Thrusterでリクエストを受けるECS serviceを追加して並行稼動させる

コンテナ定義では先ほど開けた8080番をRailsコンテナのポートマッピングに指定しておきます。

# container_definition.json

{
    "name": "rails",
    "image": "${rails_image}",
    "portMappings": [
      {
        "hostPort": 8080,
        "protocol": "tcp",
        "containerPort": 8080
      }
    ],
    ...

ALBのTarget Groupでも8080番に対応したリソースを追加して、一旦はダミーのListener Ruleを追加します。

# app_alb.tf

resource "aws_alb_target_group" "app" {
  for_each = toset(["blue", "green"])

  name              = "${local.prefix}-app-tg-${each.key}"
  port              = 8080
  protocol          = "HTTP"
  vpc_id            = aws_vpc.main.id
  target_type       = "ip"
  proxy_protocol_v2 = false
  ...
}

resource "aws_alb_listener_rule" "app_listener_https_thruster" {
  listener_arn = aws_alb_listener.app_listener_https.arn
  ...
  condition {
    host_header {
      values = ["dummy.example.com"]
    }
  }
# ecs_service_app.tf

resource "aws_ecs_service" "app" {
  name    = "${local.prefix}-app"
  ...

  load_balancer {
    target_group_arn = aws_alb_target_group.app["blue"].id
    container_name   = "rails"
    container_port   = "8080"
  }

この段階ではThruster駆動のECS serviceにアクセスが飛ぶことはない状態です。

3. Thruster駆動のECS serviceにリクエストを流す

nginx駆動のserviceとThruster駆動のserviceそれぞれのTarget Groupのconditionを入れ替えて、リクエストの向きを切り替えます。

ここまででnginxをThrusterに差し替えることが出来ました。

実際のリクエストを見てしっかりキャッシュヒットしている事を確認しました。

移行後の影響

パフォーマンス面

Thruster切り替え後のパフォーマンスをDatadogで一週間分計測しました。

App Latency

パーセンタイル nginx駆動時(avg) Thruster駆動時(avg)
p50 15.3ms 13.4ms
p90 69.3ms 79.5ms
p95 125.7ms 133.5ms
p99 197.6ms 203.2ms

レイテンシは微妙に上がっています。 nginxよりパフォーマンスは劣ることは予想していましたが、そこまで差は大きくなさそうです。

App Task CPU

nginx駆動時(avg) Thruster駆動時(avg)
7.23% 8.35%

タスク全体ではやや増加していました(新たにgzip圧縮の処理が入っているのでその分上がっているかもしれません)

Rails Container CPU

nginx駆動時(avg) Thruster駆動時(avg)
4.42% 8.01%

ThrusterはRailsコンテナ内で実行されるのでこちらもCPU使用率が上がっていますね。

若干のパフォーマンス低下は見られましたが、現状は問題なくアプリケーションは動作しています。

まとめ

Thrusterに切り替えることでリクエストの処理をRailsコンテナ内で完結させる事が出来る為、nginxの管理コストを減らせる点はとても良いと感じました。

一方で勿論Railsコンテナのパフォーマンスにマイナスの影響が出る場合があるので、移行をする際は今までnginxでやっていた事と同等の処理が出来るか調査することが必須になりそうです。


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


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

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

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

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

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

SolidQueue解体新書

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

さて、Railsエンジニアの皆さんは非同期処理にどのようなライブラリを利用していますか?
ちなみに弊社では Sidekiq を利用するプロジェクトが多いです。

tech.medpeer.co.jp

今回はRailsでの非同期処理ライブラリの新たな選択肢として誕生した「SolidQueue」について解説します。

github.com

目次

🆕 更新履歴 🆕

2024/09/12

SolidQueue v0.9.0のリリースに伴い大きく変更が入ったため以下の点を加筆・修正しました。

  • デフォルトの設定ファイルリネーム config/solid_queue.yml -> config/queue.yml
  • RecurringTaskの設定が config/recurring.yml に切り出された
  • RecurringTaskの設定で command を指定できるようになり、Jobクラスを作成せずとも簡易的なスケジュール実行が実現できるようになった
  • RecurringTaskのスケジューリング管理がDispatcherから新設されたSchedulerに切り出された

🙋 はじめに 🙋

本記事では SolidQueueの内部実装の簡単な解説 と、それらを理解するために必要な SolidQueueの簡単な概要 について記述します。 SolidQueueの使い方や各種設定値についてはREADMEを参照してください。

本記事執筆時点でのSolidQueueの最新バージョンは v0.9.0 です。 現在SolidQueueはv1リリースに向けて活発に追加開発されているため、記述内容の一部が古くなっている可能性がありますがご留意ください。

📝 SolidQueueとは 📝

Railsの非同期処理を実装するためのフレームワークである ActiveJob はバックエンドのライブラリを選択できる仕組みとなっています。

例えば以下はRails 公式ドキュメントで紹介されているバックエンドライブラリです。

ActiveJobの詳細は公式ドキュメント参照してください。 https://guides.rubyonrails.org/active_job_basics.html

SolidQueue はここに新たに仲間入りするActiveJobバックエンドの一つです。 また、次期Railsのバージョン(Rails v8)ではこのSolidQueueがActiveJobのバックエンドライブラリのデフォルトになりました

🚀 SolidQueueの特徴 🚀

引用元 https://github.com/rails/solid_queue/blob/v0.9.0/README.md#solid-queue

Solid Queue is a DB-based queuing backend for Active Job, designed with simplicity and performance in mind.

Solid Queue は DB ベースの Active Job 用キューイングバックエンドで、シンプルさとパフォーマンスを念頭に設計されています。

DBベースのActiveJobバックエンドライブラリはこれまでにもDelayedJobGoodJob などがありました。 GoodJobは近年出てきたバックエンドライブラリとして注目されていますがPostgreSQL専用なので利用シーンが限定されています。

Solid QueueはMySQL, PostgreSQL or SQLiteの全てをサポート ※1 し、尚且つデータベースの比較的新しい機能である 「FOR UPDATE SKIP LOCKED」を活用することで高パフォーマンスを実現 しています。

SolidQueueの誕生経緯はSolidQueueの開発メンバーによるこちらの紹介記事をご参照ください。

dev.37signals.com

🔓 「FOR UPDATE SKIP LOCKED」 とは 🔓

簡単に説明すると「FOR UPDATEロック付きでSELECTをするが、すでにロックがかかっているレコードはスキップする(ロック待ちが発生しない)」という機能です。

SolidQueueではこれを 「複数のWorkerがJobテーブルから安全かつ高速に1件のレコードを取得する」という用途で利用しています。

この機能がSolidQueueの重要なポイントとなりますが、以下のような形でシンプルに実装されているためSolidQueueの内部実装を理解するための必須知識とはならなそうです。

https://github.com/rails/solid_queue/blob/v0.9.0/app/models/solid_queue/record.rb#L9-L15

# SolidQueue::Record (SolidQueueが提供するモデルの基底クラス)
def self.non_blocking_lock
  if SolidQueue.use_skip_locked
    lock(Arel.sql("FOR UPDATE SKIP LOCKED"))
  else
    lock
  end
end

https://github.com/rails/solid_queue/blob/v0.9.0/app/models/solid_queue/ready_execution.rb#L32-L34

# SolidQueue::ReadyExecution.rb 実行可能なJobの取得処理(抜粋)
def select_candidates(queue_relation, limit)
  queue_relation.ordered.limit(limit).non_blocking_lock.select(:id, :job_id)
end

📝「FOR UPDATE SKIP LOCKED」の詳細については TechRachoさんの記事がとても詳しいので興味のある方はこちらを参照ください。

techracho.bpsinc.jp

🍀 4種類のアクターについて 🍀

SolidQueueは以下の4種類の「アクター」により構成されています。

  • Dispatcher: Jobの実行タイミング管理や同時実行可能数制限などを担当
  • Worker: 実行待ちJobをポーリング的に取得し処理を実行する
  • Scheduler: 後述する RecurringTask のスケジュール管理を担当
  • Supervisor: 設定内容に基づき上記2つを起動させ稼働状況の監視をする

このようにそれぞれの役割が明確に分かれているため、「比較的処理が軽量なDispatcherは1つだけ立ち上げ、Workerは複数立ち上げる」のような調整が柔軟にできるようになっています。

起動方法

以下のコマンドを実行することで、設定ファイルの内容に基づいた種類・個数の「アクター」を起動することができます。 まず最初に「Supervisor」が1つ起動し、そのSupervisorにぶら下がる形 で「Dispatcher」と「Worker」がN個、必要に応じて「Scheduler」が一つ起動する作りとなっています。

# SolidQueueが提供しているコマンド
bin/jobs start           # Starts Solid Queue supervisor to dispatch and perform enqueued jobs. Default command.
# config/queue.yml
# (設定例) Dispatcherが1つ、Workerが2つでぞれぞれ対象とするキューなどの設定を分けることが可能
production:
  dispatchers:
    - polling_interval: 1
      batch_size: 500
      concurrency_maintenance_interval: 300
  workers:
    - queues: "*"
      threads: 3
      polling_interval: 2
    - queues: [ real_time, background ]
      threads: 5
      polling_interval: 0.1
      processes: 3

(おまけ)二つの起動モードについて

SolidQueueはSupervisorのプロセスフォークとしてDispatcherとWorkerを起動する作りとなっています。
しかし、プロセスフォークではなくスレッドで起動する起動モードが実装予定とされていました。 前者のプロセスフォークの実装は「forkモード」、後者のスレッドの実装は「async」と呼称されていました。

ただし、「asyncモード」は完全に削除され「forkモード」に一本化されることとなりました。 SolidQueueの内部構造を理解する上でこの歴史的経緯について把握する必要はありませんが、現在まさに開発過渡期のためコードリーディング時に本件を頭の片隅に置いておくと混乱を回避できます。

本件についての詳細を知りたい方は以下のPullRequestをご参照ください。

🚶 SolidQueue実装の歩き方 🚶

ここまではSolidQueueのコードを読む上で初めに理解しておいた方が良い前提知識でした。
それでは実際にSolidQueueのコードを読んでいきましょう。

SolidQueueは完全にActiveRecordとActiveJobに依存しています。 そのため素直なRailsアプリケーションを読む感覚で全体を読み解くことが出来ます。

まず大きく分けて以下の二つを理解していきましょう。

モデル

DBの取り扱いを含むビジネスロジックは全てモデル(app/models/solid_queue)として定義されています。 まずはここを読むことで各モデルにどのようなメソッドが定義されているか(≒ どのようなデータ操作があるか)が理解できます。

アクター

前述した4つのアクターの動きは lib/solid_queue に定義されています。 「dispatcher.rb」「worker.rb」「scheduler.rb」「supervisor.rb」のようにアクター毎にファイルが分かれているためその点を念頭に読むと理解が早いです。

コードを読む上での些細な注意事項ですが、37signals ※2が開発するシステムによくある特徴である「ConcernによるModule分割」が各所に多用されています。 慣れない人にとってはメソッドの定義箇所を追うのが大変かもしれませんが、処理を抽象化し綺麗に共通化・分離されている様は参考になると思います。

また、concurrent-ruby が時折利用されているため一定ここの知識がある方がリーディングは捗ります。

github.com

🥞 SolidQueueのモデル(テーブル)🥞

SolidQueueはデータベースをバックエンドとしているため、素直なActiveRecordを用いたモデルが提供されています。

本記事執筆時点では以下のテーブル群で構成されています。

  • solid_queue_jobs
  • solid_queue_scheduled_executions
  • solid_queue_ready_executions
  • solid_queue_claimed_executions
  • solid_queue_blocked_executions
  • solid_queue_failed_executions
  • solid_queue_pauses
  • solid_queue_processes
  • solid_queue_semaphores
  • solid_queue_recurring_executions
  • solid_queue_recurring_tasks

最新の情報は db/migrate を参照してください。

Jobの状態遷移

まずはJobにはどのような状態があるのかを確認しましょう。
以下が状態遷移図となります。

SolidQueue Jobの状態遷移(全て)

一見複雑な状態遷移に見えますが、基本的には 「Scheduled -> Ready -> Claimed -> Finished」 の一方通行です。
上記の遷移図から「同時実行数制限」「Job処理失敗・再実行」などを除いたシンプルな遷移図がこちらになります。

SolidQueue Job状態遷移(抜粋)

この基本の状態遷移を頭に入れた上でコードリーディングしていきましょう。

状態の表現方法

Jobモデルに対してhas_one関連のexecutionが紐づく形でJobの状態が管理されています。

※ 「Finished(処理完了)」に関しては「jobs.finished_at が設定されている」又は「Jobレコードが削除されている」という形で表現されています。(この二つの表現方法は preserve_finished_jobs 設定によって切り替わります)

https://github.com/rails/solid_queue/blob/v0.9.0/app/models/solid_queue/job/executable.rb#L86-L109

# SolidQueue::Job::Executable (JobにincludeされるConcernモジュール)
def finished?
  finished_at.present?
end

def status
  if finished?
    :finished
  elsif execution.present?
    execution.type
  end
end

def execution
  %w[ ready claimed failed ].reduce(nil) { |acc, status| acc || public_send("#{status}_execution") }
end

モデルの実装例

全てのモデルの実装について解説してしまうと日が暮れてしまうためここでは二つの例のみを挙げます。

まず「実行待ちのJobの中から実行対象のJobをロックしつつ取得し実行中状態に遷移させる SolidQueue::ReadyExecution.claim」 の実装を見てみましょう。 https://github.com/rails/solid_queue/blob/v0.9.0/app/models/solid_queue/ready_execution.rb

module SolidQueue
  class ReadyExecution < Execution # 「Ready(実行待ち状態)」を表現するJobのhas_oneモデル
    class << self
      def claim(queue_list, limit, process_id)
        # QueueSelector.new(queue_list, self).scoped_relations
        # => 指定されたキューに対応するReadyExecutionのRelationを返却
        QueueSelector.new(queue_list, self).scoped_relations.flat_map do |queue_relation|
          select_and_lock(queue_relation, process_id, limit).tap do |locked|
            limit -= locked.size
          end
        end
      end

      private
        def select_and_lock(queue_relation, process_id, limit)
          return [] if limit <= 0

          transaction do
            candidates = select_candidates(queue_relation, limit)
            lock_candidates(candidates, process_id)
          end
        end

        # 指定件数分の実行待ち状態のJobを「FOR UPDATE SKIP LOCKED」を指定しつつ取得する
        def select_candidates(queue_relation, limit)
          queue_relation.ordered.limit(limit).non_blocking_lock.select(:id, :job_id)
        end

        def lock_candidates(executions, process_id)
          return [] if executions.none?

          # SolidQueue::ClaimedExecution = 実行中状態
          # 実行中状態として登録しJobが重複実行されないようにした上で実行待ち状態を解除する。
          SolidQueue::ClaimedExecution.claiming(executions.map(&:job_id), process_id) do |claimed|
            ids_to_delete = executions.index_by(&:job_id).values_at(*claimed.map(&:job_id)).map(&:id)
            where(id: ids_to_delete).delete_all
          end
        end

次に「Job作成時の初期ステータス決定処理」を見てみましょう。

https://github.com/rails/solid_queue/blob/v0.9.0/app/models/solid_queue/job/executable.rb

# SolidQueue::Job::Executable (JobにincludeされるConcernモジュール)
after_create :prepare_for_execution

def prepare_for_execution
  # 実行予定日時を超過(due?)の場合はディスパッチ処理(後述)を実行
  if due? then dispatch
  else
    # 実行予定日時が未来の場合は「Scheduled(実行予約)」状態に遷移
    schedule
  end
end

def dispatch
  # 同時実行可能数制限ロックを取得できた場合は「Ready(実行待ち)」状態に遷移
  if acquire_concurrency_lock then ready
  else
    # ロックを取得できなかった場合は「Blocked(実行制限中)」状態に遷移
    block
  end
end

いかがでしょうか?
多少コメントで補足をいれていますが、ActiveRecordが使われた素直なRailsアプリケーションとして自然に読めますね。

他のモデル(app/models/solid_queue)にもこのような形で処理が定義されているため自身で読んでみてください。

詳細は割愛しますが Queue のようにデータベースで管理されていない概念についても綺麗にモデリングされている点も参考になりますね。

次は「Supervisor」「Dispatcher」「Worker」の3つのアクターがそれぞれどのような処理をしているかについて見ていきましょう。 ※ Schedulerに関しては、「Recurring tasks」の項で解説します。

😎 Supervisor 😎

Dispatcher・Workerアクターの起動

https://github.com/rails/solid_queue/blob/v0.9.0/lib/solid_queue/supervisor.rb

Supervisorが起動されると、設定ファイルに基づいてDispatcherとWorkerアクターを起動します。

プロセスの死活監視

SolidQueue::Processモデルを活用し稼働しているプロセス(DispatcherとWorker、Scheduler)の死活監視をします。

https://github.com/rails/solid_queue/blob/v0.9.0/app/models/solid_queue/process.rb

# SolidQueue::Process
create_table "solid_queue_processes" do |t|
  t.string "kind", null: false
  t.datetime "last_heartbeat_at", null: false
  t.bigint "supervisor_id"
  t.integer "pid", null: false
  t.string "hostname"
  t.text "metadata"
  t.datetime "created_at", null: false
  t.string "name", null: false
  t.index ["last_heartbeat_at"], name: "index_solid_queue_processes_on_last_heartbeat_at"
  t.index ["name", "supervisor_id"], name: "index_solid_queue_processes_on_name_and_supervisor_id", unique: true
  t.index ["supervisor_id"], name: "index_solid_queue_processes_on_supervisor_id"
end

各プロセスは一定間隔でハートビートイベントを発火( process.last_heartbeat_at のタイムスタンプ更新)しています。

https://github.com/rails/solid_queue/blob/v0.9.0/lib/solid_queue/processes/registrable.rb#L39-L46

# SolidQueue::Processes::Registrable (3アクターのプロセスクラスにincludeされるConcernモジュール)
def launch_heartbeat
  # 本処理の本質とは関係ありませんが `Concurrent::TimerTask` がとてもうまく活用されていて綺麗ですね。
  @heartbeat_task = Concurrent::TimerTask.new(execution_interval: SolidQueue.process_heartbeat_interval) do
    wrap_in_app_executor { heartbeat }
  end
  # 省略...
end

def heartbeat
  process.heartbeat
end

https://github.com/rails/solid_queue/blob/v0.9.0/app/models/solid_queue/process.rb#L22-L24

class SolidQueue::Process
  def heartbeat
    touch(:last_heartbeat_at)
  end
end

Supervisorはハートビートが一定間隔停止しているプロセスは停止したとみなし事後処理を実行します。

https://github.com/rails/solid_queue/blob/v0.9.0/lib/solid_queue/supervisor/maintenance.rb

# SolidQueue::Supervisor::Maintenance (SupervisorにincludeされるConcernモジュール)
def launch_maintenance_task
  @maintenance_task = Concurrent::TimerTask.new(run_now: true, execution_interval: SolidQueue.process_alive_threshold) do
    prune_dead_processes
  end

  @maintenance_task.execute
end

def prune_dead_processes
  # プロセスの停止処理を実行
  wrap_in_app_executor { SolidQueue::Process.prune }
end

🏹 Dispatcher 🏹

ポーリング

Dispatcherと後述するWorkerはポーリング処理を実行し続けます。
ポーリング処理は以下のように抽象化されたConcernとして実装されています。

https://github.com/rails/solid_queue/blob/v0.9.0/lib/solid_queue/processes/poller.rb#L24-L42

# SolidQueue::Processes::Poller (間接的に後述のDispatcherとWorkerにincludeされるConcernモジュール)
def start_loop
  loop do
    break if shutting_down?

    wrap_in_app_executor do
      unless poll > 0
        interruptible_sleep(polling_interval)
      end
    end
  end
ensure
  SolidQueue.instrument(:shutdown_process, process: self) do
    run_callbacks(:shutdown) { shutdown }
  end
end

def poll
  raise NotImplementedError
end

実行予約中Jobを監視&ディスパッチ(実行待ちに移動)

Dispatcherのメイン作業です。
deliver_laterなどで実行予約されたJobが実行タイミングを迎えたタイミングでディスパッチします。 あくまでディスパッチまでで、実際のJobを処理するのは後述のWorkerです。

https://github.com/rails/solid_queue/blob/v0.9.0/lib/solid_queue/dispatcher.rb#L26-L35

# SolidQueue::Dispatcher
def poll
  batch = dispatch_next_batch
  batch.size
end

def dispatch_next_batch
  with_polling_volume do
    # 実行予定日時(scheduled_at)が過去のJobを抽出しディスパッチする
    ScheduledExecution.dispatch_next_batch(batch_size)
  end
end

他にも「同時実行可能数制限のメンテナンス」もDispatcherの責務ですが、一旦解説を後回しとします。

🕺 Worker 🕺

実行待ち状態のJobをポーリング的に取得しJobを実行(perform)

Workerの責務はこれだけです。シンプルですね。

https://github.com/rails/solid_queue/blob/v0.9.0/lib/solid_queue/worker.rb#L21-L35

# SolidQueue::Worker
def poll
  claim_executions.then do |executions|
    executions.each do |execution|
      # Pool#postの中でJobがperformされる
      pool.post(execution)
    end

    executions.size
  end
end

def claim_executions
  with_polling_volume do
    # 実行待ち状態のJobを取得
    SolidQueue::ReadyExecution.claim(queues, pool.idle_threads, process_id)
  end
end

以上が3つのアクターの主な処理内容です。
アクターとして責務が分割されていることにより、それぞれの処理が単純化されていて理解しやすいですね。

これからはここまで言及しなかったSolidQueueの機能について簡単に紹介します。

🚧 Jobの同時実行可能数制限について 🚧

機能概要

Sidekiq Enterpriseの「Rate Limiting」のようなものです。

引用元 https://github.com/rails/solid_queue/blob/v0.9.0/README.md#concurrency-controls

Solid Queue extends Active Job with concurrency controls, that allows you to limit how many jobs of a certain type or with certain arguments can run at the same time. When limited in this way, jobs will be blocked from running, and they'll stay blocked until another job finishes and unblocks them, or after the set expiry time (concurrency limit's duration) elapses. Jobs are never discarded or lost, only blocked.

Solid Queue は Active Job に同時実行制御機能を拡張し、特定のタイプや引数を持つジョブを同時に実行できる数を制限することができます。 このように制限された場合、ジョブは実行がブロックされ、他のジョブが終了してブロックが解除されるまで、または設定された有効期限 (同時実行の制限時間) が経過するまでブロックされたままになります。 ジョブが破棄されたり失われたりすることはなく、ブロックされるだけです。

class MyJob < ApplicationJob
  limits_concurrency to: max_concurrent_executions, key: ->(arg1, arg2, **) { ... }, duration: max_interval_to_guarantee_concurrency_limit, group: concurrency_group

  def perform(arg1, arg2, arg3)
end

# 例: 同一アカウントに対してのお知らせ配信処理を同タイミングで最大2つまでに制限。しかし、Jobの実行が5分を超過するとその制限が解放される。
class DeliverAnnouncementToContactJob < ApplicationJob
  limits_concurrency to: 2, key: ->(contact) { contact.account }, duration: 5.minutes

  def perform(contact)

内部実装

その名の通り「セマフォ」を表現するモデルである SolidQueue::Semaphore(solid_queue_semaphore) というモデル(テーブル)が活用されていいます。

これは同時実行数制限対象(key)に対して、現時点で残り何件(value)のJobが実行可能かを表現しています。

SolidQueue::Semaphore
create_table "solid_queue_semaphores" do |t|
  t.string "key", null: false
  t.integer "value", default: 1, null: false
  t.datetime "expires_at", null: false
  t.datetime "created_at", null: false
  t.datetime "updated_at", null: false
  t.index ["expires_at"], name: "index_solid_queue_semaphores_on_expires_at"
  t.index ["key", "value"], name: "index_solid_queue_semaphores_on_key_and_value"
  t.index ["key"], name: "index_solid_queue_semaphores_on_key", unique: true
end

細かな処理内容については Semaphoreモデル に綺麗に定義されているためここを読み解くと良いです。

主に以下のような使われ方をしています。

# 同時実行数制限ロックの取得(booleanを返却)
# DispatcherがJobをReadyに移動するタイミングで呼び出される。
# ロックが取得できない場合はBlocked状態となる。
def acquire_concurrency_lock
  return true unless concurrency_limited?

  Semaphore.wait(job) # 指定したjobが実行可能か確認し、実行可能な場合は同時実行可能数(semaphores.value)を-1します。
end

# 同時実行数制限ロックの解放
# Jobの実行終了時などに呼び出される。
def release_concurrency_lock
  return false unless concurrency_limited?

  Semaphore.signal(job)
end

また、有効期限切れのSemaphoreを解放する処理がDispatcherの責務の一つとして定義されています。 詳細はここでは割愛しますが、興味がある方は SolidQueue::Dispatcher::ConcurrencyMaintenance をご参照ください。

⏰ Recurring tasks (cronでのスケジュール定義) ⏰

機能概要

引用元 https://github.com/rails/solid_queue/blob/v0.9.0/README.md#recurring-tasks

Solid Queue supports defining recurring tasks that run at specific times in the future, on a regular basis like cron jobs. These are managed by the scheduler process and are defined in their own configuration file.

Solid Queue は、cron ジョブのように将来の特定の時間に定期的に実行されるタスクの定義をサポートします。 これらはスケジューラー プロセスによって管理され、独自の設定ファイルで定義されます。

# config/recurring.yml
a_periodic_job:
  class: MyJob
  args: [ 42, { status: "custom_status" } ]
  schedule: every second
a_cleanup_task:
  command: "DeletedStuff.clear_all"
  schedule: every day at 9am

上記の通り、専用のJobクラスを定義せずに command を指定するだけで簡易的なスケジュール実行が実現できるのはとても便利そうですね。

内部実装

以下2つのモデルで管理されています。

  • SolidQueue::RecurringExecution
    • RecurringTaskが重複実行されないように排他制御するためのモデル(テーブル)です。
  • SolidQueue::RecurringTask
    • cronで定義されているスケジュールの設定値が格納されています。
    • 本機能を実装する上では本来不要ですが、後述のMissionControlの実装都合で追加されたモデル(テーブル)です。
# SolidQueue::RecurringExecution
create_table "solid_queue_recurring_executions" do |t|
  t.bigint "job_id", null: false
  t.string "task_key", null: false
  t.datetime "run_at", null: false # 実際に実行された日時ではなくcronで算出された日時が設定される
  t.datetime "created_at", null: false
  t.index ["job_id"], name: "index_solid_queue_recurring_executions_on_job_id", unique: true
  t.index ["task_key", "run_at"], name: "index_solid_queue_recurring_executions_on_task_key_and_run_at", unique: true
end

# SolidQueue::RecurringTask
create_table "solid_queue_recurring_tasks" do |t|
  t.string "key", null: false
  t.string "schedule", null: false
  t.string "command", limit: 2048
  t.string "class_name"
  t.text "arguments"
  t.string "queue_name"
  t.integer "priority", default: 0
  t.boolean "static", default: true, null: false
  t.text "description"
  t.datetime "created_at", null: false
  t.datetime "updated_at", null: false
  t.index ["key"], name: "index_solid_queue_recurring_tasks_on_key", unique: true
  t.index ["static"], name: "index_solid_queue_recurring_tasks_on_static"
end

Scheduler起動時に全てのRecurringTask定義に対して以下の処理が実行されます。 https://github.com/rails/solid_queue/blob/v0.9.0/lib/solid_queue/scheduler/recurring_schedule.rb

# SolidQueue::Scheduler::RecurringSchedule (DuspatcherにincludeされるConcernモジュール)
def schedule(task)
  # Concurrent::ScheduledTaskを利用し、cronに基づき「次回実行日時」に処理をスケジューリング
  scheduled_task = Concurrent::ScheduledTask.new(task.delay_from_now, args: [ self, task, task.next_time ]) do |thread_schedule, thread_task, thread_task_run_at|
    # 再帰的に本メソッドを呼び出し次回分のスケジュールを登録
    thread_schedule.schedule_task(thread_task)

    wrap_in_app_executor do
      # Jobを実行待ち状態で登録
      thread_task.enqueue(at: thread_task_run_at)
    end
  end
  # 省略...
end

注意点

以上の通りSolidQueueのRecurringTaskは「cron定義に基づいて次回実行を予約する」といったアプローチです。 そのため、cronによるJob実行予定日時が「SolidQueueが停止状態」となっていた場合にSolidQueue停止中はもちろんの事、次回SolidQueueが起動した際にも遡ってJobを実行することはありません。

(具体例)
cronが「毎朝7時(0 7 * * *) にDummyJob実行」と定義されている。 何かしらの事情により朝の6:55〜7:05の間SolidQueueが停止されていた場合、本日分のDummyJobは実行されない(エラーにもならない)。

Sidekiqのcron実装である SidekiqCron は、Sidekiq起動時に一定期間内であれば過去を遡ってJobを実行してくれる仕組みがあります。(詳細は こちら を参照ください)

どちらも一長一短ですが、Sidekiqなどの他のライブラリからSolidQueueに移行する際にはこういった細かな挙動の違いにも気をつける必要がありますね。

⛔ キューの一時停止 ⛔

他のライブラリと同様にSolidQueueにも特定のキューを停止する機能が備わっています。

Rails Consoleによる手作業( SolidQueue::Queue#pause )または、後述のUIダッシュボードでの操作によりキューを停止することが可能です。
停止されたキューは以下のテーブルに格納されます。

# SolidQueue::Pause
create_table "solid_queue_pauses" do |t|
  t.string "queue_name", null: false
  t.datetime "created_at", null: false
  t.index ["queue_name"], name: "index_solid_queue_pauses_on_queue_name", unique: true
end

このPauseはWorkerが処理対象Jobを抽出する際に参照され、当該キューに属するJobは処理対象から除外されます。

本記事では紹介していませんが、処理対象のキューを選択する処理もモデルとして表現されており「staging* のようなワイルドカード指定によるキュー名の前方一致」や「キューの優先順位」がシンプルに実装されているため読んでいて面白いです。
こちらも興味があれば覗いてみてください。 SolidQueue::QueueSelector

🔁 処理失敗したJobの再実行 🔁

Sidekiqのような「Job実行時に例外が発生し捕捉されなかった場合は自動再実行」のような仕組みはSolidQueue本体にはありません。

Jobを再実行するには以下のいずれかを選択する必要があります。

  • ActiveJobのretry_onを利用
    • ApplicationJobのような基底クラスでStandardErrorをretry_on対象とすることで実質的にSidekiqのような自動リトライを再現します。が、リトライ対象の例外は明示的に尚且つ限定的に指定することをオススメします。
  • 自前で特定の例外を捕捉し再度キューイング
  • 後述のUIダッシュボードで手動再実行

🖼️ UIダッシュボード 🖼️

SolidQueueと同様にRails公式ツールとして Mission Control — Jobs が開発されています。

github.com

※ 以下は本記事執筆時点最新バージョン v0.3.1 時点の内容です。

キューやジョブの一覧管理、失敗したジョブの再実行など基本的な機能は揃っています。 SolidQueue専用ではなく Resque にも対応しているようです。

SolidQueueと同様にこちらも絶賛開発が進行しているので今後の追加機能に期待です。

missin-control

導入時の注意点

Mission Controlは以下のGemが依存として定義されています。
rails/propshaft のようにかなり新しいアセット関連のGemが依存として定義されているため、プロジェクトによっては導入に一定のハードルがあるかもしれません。

https://github.com/rails/mission_control-jobs/blob/v0.3.1/mission_control-jobs.gemspec#L20-L23

# mission_control-jobs.gemspec
spec.add_dependency "rails", ">= 7.1"
spec.add_dependency "propshaft"
spec.add_dependency "importmap-rails"
spec.add_dependency "turbo-rails"
spec.add_dependency "stimulus-rails"
spec.add_dependency "irb", "~> 1.13"

🔥 パフォーマンスやDB負荷 🔥

Sidekiqを多用しているプロジェクトは「パフォーマンス(≒ Jobを捌く速度)」と「DB負荷」が気になるのではないでしょうか。
スタートアップな小さなサービスならまだしも、大量なJobを処理するようなサービスでSolidQueueを安全に利用できるのかは一定の不安があります。

一方、HEY ※3 では非同期処理の全てでSolidQueueが利用されているようです。

Solid Queueは現在、HEYのためだけに毎日2000万近くのジョブを実行しています。 すべてのアプリケーションの移行が完了したら、Solid Queue で 1 日あたり約 1 億件のジョブを処理することになります。 とてもSOLIDな1.0になるでしょう。 rosapolisによる素晴らしい仕事

ソリッド・キューがついに全HEYを制覇。 Resqueにさようなら! Resqueは長年にわたって私たちによく貢献してくれましたが、蓄積された複雑さをすべて圧縮できるクリーンシートの実装が必要な時期が来ていました。 何千万もの毎日のHEYジョブは、今ではSQだけで実行されている!

データベースのスペックやWorker数などは不明ですが、かなりの規模のプロジェクトでも十分に使えるポテンシャルは持っていそうです。

👋 まとめ 👋

簡単ではありますがSolidQueueの内部構造の解説でした。 普段から馴染みのあるActiveRecordで実装されているためとても理解がしやすかったです。

SolidQueueを採用する予定がない方にとっても、「Railsで開発されたジョブキューシステム」として捉えると良い学習対象となるのではないでしょうか。

本記事がきっかけで少しでもSolidQueueに興味を持っていただけたら幸いです。

👍 参考資料 👍

SolidQueueはまだまだ若いライブラリのため参考にできる資料が少ない印象です。
そんな中でもTechRachoさんの翻訳ブログはとても参考にさせていただきました。ありがとうございます。


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


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

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

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

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

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


  1. 「FOR UPDATE SKIP LOCKED」 をサポートしているデータベース(MySQL 8以上、またはPostgreSQL 9.5以上)で利用することが推奨されています。 これは「FOR UPDATE SKIP LOCKED」を利用できないと複数のWorkerが実行された際にロック待ちが発生しパフォーマンスが落ちてしまうためです。
  2. Ruby on Railsの作者であるDHHがCTOを務めるシステム開発会社。SolidQueueに関しても37signalsのメンバーが中心に開発をしている。
  3. 37signalsが提供しているメール・カレンダーサービス