🛣️

LaravelにRoadRunner Jobsを導入して使ってみた結果(ベンチマークあり)

2024/08/01に公開

この記事は何?

Laravel OctaneをRoadRunnerで導入した際に、非同期Job(Queue)もRoadRunnerでやりたいと思ったのでRoadRunner Jobsを導入し、動かしてみた(ベンチマークあり)。

Laravel Octaneとは、RoadRunnerとの関係は?

Laravel Octane はLaravelでNode.jsのようなノンブロッキングリクエスト処理を実現するためのLaravelのプラグインです。
Laravel Octane を導入した場合、サーバの実行コマンドが php artisan serve から php artisan octane に変更する必要がありますが、 Laravel Octaneそれ自体はサーバーソフトウェアではなく、あくまでLaravelのプログラムのエントリ処理を細工するミドルウェアです。
非同期処理を実現するための「本体」として働くLaravelの以下のアプリケーションサーバやライブラリと組み合わせて使用することができます

  • Swoole
  • Open Swoole
  • FrankenPHP
  • RoadRunner

RoadRunner

RoadRunnerは、米国SpiralScout社が開発したPHP用のアプリケーションサーバで、nginxのようにPHPの前面に立ってリクエストを処理してくれます。
動作の手順としては、

  • 初めにRRがPHPのワーカープロセスを立ち上げておく
  • RRがリクエストを受け付ける
  • RRがリクエストをワーカーに転送する
  • ワーカーが処理する
  • ワーカーがRRにRPCを送信し処理完了の連絡をする
  • RRがレスポンスを返す
    という流れになっています。

公式
https://roadrunner.dev/

Documentation
https://docs.roadrunner.dev/docs

解説記事
https://zenn.dev/portinc/articles/php-roadrunner

RoadRunner Jobs

RoadRunner JobsはRoadRunnerの仕組みを使って非同期Jobの処理を行うためのプラグインです。Jobを処理する手順も通常のリクエストとほぼ一緒です。
RoadRunnerはPHPプログラムから見て非同期Jobを管理するマイクロサービスとして機能します。PHPからRRにJobでの処理を依頼したい場合、RPCを使って内部リクエストを送る点が特徴的です。
JobsプラグインへのRPCはソケットを使って送受信されます(エンコード/デコードはProtobuf使用)ので通信のオーバーヘッドは極めて少なく高速に動作します。

Laravel Octaneと RoadRunner Jobsの統合

後日別の記事で解説します。

ベンチマーク ~ RoadRunner Jobs vs LaravelのJob ~

RoadRunner Jobsを使うことで性能に差は出るでしょうか。RoadRunnerは非同期処理に特化したアプリケーションサーバなのでフレームワークを立ち上げるオーバーヘッドが少ないことが予想できます。本当にそうなのか検証してみたいと思います

条件:900 Jobを生成し、すべて処理する。Queueのパイプラインの数は1(一つずつ順番に実行
実行する処理は usleep(500000) で0.5秒待機するだけです。
計測対象:実行時間、オーバーヘッド
オーバーヘッド = ((実行時間 - 450) / 900) * 1000 (msec)

以下が実行結果です

Queue LaravelのJob RoadRunner Jobs
トータル実行時間 475.58sec 451.49sec
オーバーヘッド
(1タスクあたり)
28.42 msec 🚀 1.65 msec

というわけでLaravelのJobに比べ1タスク処理する際のオーバーヘッドはかなり小さいことが分かります。

(注)LaravelのQueueドライバはdatabase(ローカルのdockerコンテナ上のMySQL(8.1) で、RoadRunner Jobsのドライバはmemoryである点で条件が完全にそろってはいません。

LaravelのQueueドライバにオンメモリキューがないのでこちらを完全にそろえることはできませんでした(memcached等を使えば近くなるとは言え、今回は簡易的な比較なのでご容赦ください)。

とはいえどちらもプリフェッチが効いているのでQueueへのアクセス速度は支配的な要因ではないと想定しています。

非同期JobにRoadRunner Jobsを使ってみた感想

メリット

タスク処理の際のオーバーヘッドが圧倒的に少ない

これは前述のとおりで、私のプロジェクトでは約15分の1になります

キューワーカーの数を簡単に増やせる

2024-08-04 追記
RPCを通じてPHP側からリクエストしワーカーの数を動的に増やすことも可能です。
https://docs.roadrunner.dev/docs/php-worker/scaling

Laravelのキューワーカーを使用する場合、supervisorなどのミドルウェアを使ってプロセスを管理することが一般的だと思いますが、supervisorの設定が難解だったりしますし、場合によってはDockerfileをいじらないといけない場合も出てくるかと思います。
その点 RoadRunner Jobsは ./.rr.yaml の設定を変えればいいので簡単です。

キュー(パイプライン)の動的な操作ができる

RoadRunner Jobsでは個別のキュー(パイプライン)に対して pause resume といったAPIが用意されていて動的にキューを停止、再開することもできます。問題が発生したときにAdmin画面などからキューを停止する「非常停止ボタン」的なものを作っておくことも可能です。

キューパイプライン自体の動的な生成も可能です。

手動Acknowledgeによる細かなリトライ制御

Acknoledgeとは、ワーカーがキューを受け取ったことをRoadRunner側に伝えることです。これは手動でやる場合PHPのコード上で行います。RoadRunnerはacknowledgeが実施されてタスクが完了するまでワーカーにタスクを送り続けます(タスク実行中は送らない)。(厳密には、acknowledge/no-acknowledge のいずれか)
この仕様にどんなメリットがあるかというと

  • Acknowledge以前
    冪等/重要/失敗しやすい処理(例:外部サーバーへのリクエストなど)
    => 自動リトライ
  • Acknowledge以降
    非冪等/重要ではない/失敗しにくい処理 (例:ビジネスロジックによるデータ処理、DBへの書き込み)
    => 手動リトライ (ユーザにフォールバック)

のようにacknowledge以前と以降でタスクの性質を分けて細かなリトライ制御を行うことができます。
(ちなみに taskのdispatch時に ->withHeader('attempts', 回数) を指定しないと無限ループになります)

またRoadRunnerからタスクが送出されたときに自動でacknowledgeにする機能もあります。

カスタムJobドライバーが作れる

実際にやったわけではありませんが、RoadRunner JobsではカスタムJobドライバーをGoで書くことができるようです。
https://docs.roadrunner.dev/docs/customization/jobs-driver

デメリット

メモリリークの心配がある

これはRoadRunnerを使ってる以上仕方のないことではありますね。メモリリークを起こさないような実装にするしかありません。

特定キューの専用ワーカーを用意できない

Laravelでは例えば
php artisan queue:work --queue=abc
としてキューワーカーを起動すると、abcという名前のキューのJobのみを処理する 専用ワーカープロセスが起動します。しかしながらこういったことはRoadRunner Jobsではできなさそうです。

RoadRunner Jobsの内部実装を調査しているところですが、すべてのパイプラインのJobはオリジンからフェッチされた後一旦単一の優先度付キュー(バイナリヒープ)に集約され、マージされたあと、優先度順=>FIFO順でワーカーがconsumeしていくように見受けられます。
ですのでこのワーカーはこのパイプライン専用みたいな割り当てができない気がします。
カスタムドライバを実装するか、PHPのワーカーのスクリプトを工夫すればできるかもしれません。

追記
https://github.com/roadrunner-server/jobs/blob/3791490d12f04f3f8fa1b5d19484e7d4680310e8/plugin.go#L203
これを見る限り、 プラグイン 毎にワーカープールを生成しています。どうしても特定キュー専用のワーカーが欲しい場合、プラグイン名だけ変えたカスタムワーカードライバーと一緒にRRをコンパイルするのも一つの方法かもしれません。
実際RR用のビルドツール veloxを使えば(比較的)簡単にできますし、ビルドプロセスに組み込むことも(比較的)容易です。

追記2
専用ワーカーを用意するのはアンチパターンらしいです。たしかに紐づけたキューにJobがない場合マシンパワーが遊んでしまいますからね。
https://martinjoo.dev/laravel-queues-and-workers-in-production
実際、RoadRunner Jobsでは動的にパイプラインを止めたり再開したり、操作ができるのでそれで大量リクエスト時は対処できそうな気もします。
とは言え現実のプロダクトだと優先度付キューだけでは不十分で専用ワーカーを囲う必要のある場合もあるかとは思います。

Jobの状態管理は自分でする必要がある

タスク送出、実行開始時の ack() や、例外発生時の nack() やその他の処理については自分で記述する必要があります。またそのような処理にバグがあるとキューワーカーが暴走するおそれがあります。
またLaravelにはデフォルトで失敗したJobをシリアライズして手元のDBのfailed_jobsというカラムに保存し、いつでも再開できるようにする機能が備わっていますが、RoadRunner Jobsにはそういったものはありません。

Jobの状態把握をするビルトインの機能はない

結局これはLaravelのJobでもできないことので、期待のし過ぎだった面がありますが、例えば特定IDのタスクが処理中かどうかなどを把握するAPIなどはありません。
(RoadRunnerが中央集権的にすべてのワーカーを管理してるのでできるのではないかと一瞬期待した)
もちろんこれはRedisなどの永続化層にtask idをキーにして状態を保存・管理すればできることではあります。

追記
https://docs.roadrunner.dev/docs/customization/plugin#rpc-methods
こちらにある通り、プラグインで外部(PHP側)からRPCを受け取るメソッドを追加することができます。このメソッドのレシーバ *rpc は自分で定義することができ、プロパティとして *Pluginを持たせることができます。
https://github.com/roadrunner-server/jobs/blob/master/plugin.go#L48-L85
Plugin構造体は優先度付キューの実装(バイナリヒープ) queue をプロパティとして持っており、そこには Exists メソッドがあります。これらを使ってRRのバイナリヒープにJobが存在するかしないか返すAPIを含んだカスタムキュードライバ(プラグイン)を作ること自体は可能と思われます。
とはいえ、オリジン(SQSやメモリなど)にJobが含まれることの確認方法はオリジンの仕様依存となりますし、それを実現しようとするとキュードライバをスクラッチで書く必要が出てくるかもしれず、素直にRedis等を使ってJobの状態管理をしたほうがいい気がします。

結論:結局 RoadRunner Jobsは使うべき?LaravelのJobとの使い分けは?

使うべきかどうかはユースケースによると思います。

Laravel の JobとRoadRunner Jobsの機能性の違いは以下の表にまとめられます
基本的にLaravelのJobsでできることはRoadRunner Jobsでもできます。

FW Laravel(Job) RoadRunner Jobs
失敗時の対処 デフォルトでDBにJobを保全 アプリケーション側の
カスタムロジックで対処
専用ワーカー 可能 RRをカスタムビルドすれば可能
動的なキュー/ワーカーの操作 不可 可能

私の考えではより専門的でやや重いタスク(翻訳、PDF生成、画像生成、生成AIへのリクエスト、etc...)をユーザが自分の意志でリクエストすることが多いSaaSなどでは使い手があるのではないかと思っています。

  • RoadRunner Jobs に適していると思うJob

    • 処理時間が短い(数秒~数十秒)
    • 失敗時はユーザにリトライさせればいい
    • 専用のワーカーは必要ない
    • ユーザのリクエストに応じて実行する
      例:Pdfの生成
  • LaravelのJob に 適していると思うJob

    • 処理時間が長い(数分~数時間)
    • 失敗時にタスクの保全が必要
    • 専用のワーカーが必要
    • 決まったスケジュールで決まった件数実行する
      例:顧客との契約に基づくバッチ処理

とくにRoadRunner Jobs に適していると思うJob で書いたような 数秒~数十秒の 軽量なタスク については、これまで以下のようなジレンマがありました、

  • 非同期リクエストで実行する場合:
    ユーザが自由にリクエストできるような処理だと需要が予測できない一方で、ワーカー数が固定されているので、キューが詰まったときに対処できない
  • 同期リクエストで実行する場合:
    待ち時間が発生してUXが損なわれてしまう

RoadRunner Jobsではキューの制御やワーカーの増減をアプリケーションコード側から行うことができますので、UX向上につながることが期待できます。

(お気持ち)PHPerやバックエンドエンジニアにRoadRunnerをもっと知ってほしい

個人的なお気持ちになりますが、RoadRunnerは非常に面白いプロダクトだと思います。
以下が私が興味深いと思った点です

  • Go製のアプリケーションサーバをPHPの前面に立たせるというアーキテクチャ上の意思決定
  • 多彩な機能をPluginとして切り出すことで疎結合にし、拡張性を確保していること。
  • Endure Goridge など抽象化に必要不可欠なミドルウェアの自前実装
  • ソケットとProtobufを使ってPHPとのシームレスな連携と型安全を両立している点

注意深い抽象化と設計で複雑な機能と保守性、パフォーマンスの両立をシンプルに実現している良い例だと思います。

私自身そうですが、ジュニア~ミドルレベルのバックエンドエンジニアでよりレベルアップしたい方にとっては非常に参考になるプロジェクトだと思います。

Discussion