Node.jsの開発者なら、遅かれ早かれ誰もが同じ壁にぶつかります。ユーザーがボタンをクリックし、ルートハンドラーが重いタスクの処理に追われ始めると、HTTPリクエストがそのまま止まってしまいます。一括メール送信、サードパーティCRMへのレコード同期、あるいはPDFレポートの生成などがその例です。ブラウザは読み込み中のまま回転し続け、モバイルアプリはタイムアウトします。ユーザーの不満は募り、サーバーは失ってはならないコネクションスロットを浪費していきます。解決策は、その作業をリクエストパスから切り離し、Redisをバックエンドとしたバックグラウンドジョブキューに移行することです。Node.jsのエコシステムにおいて、この領域を牽引する2つのライブラリがBullとBullMQです。この2つのどちらを選ぶかは、「どちらが優れているか」という問題ではなく、「プロジェクトが現在どのような状況にあり、どこへ向かおうとしているか」を理解するかどうかの問題です。
長年活躍してきた主力
Bullは、長年にわたりNode.jsのバックグラウンド処理における標準であり続けてきました。安定しており、実戦で鍛えられ、数え切れないほどのプロダクション環境で稼働しています。ジョブの予約実行、失敗したインポートの自動リトライ、あるいはニュースレター配信よりも決済のWebhookを優先させるといった厳格な優先順位付けが必要な場合でも、Bullは問題なく処理してくれます。APIはコールバック指向であるため、Promiseがまだ珍しかった頃の古いコードベースにもスムーズに適合します。長年Bullに頼ってきたチームは、その挙動を熟知しています。このライブラリは状態をRedisに保持するため、Nodeプロセスが再起動してもジョブは失われません。その信頼性ゆえに、すでに機能しているシステムをあえて変更しようという圧力を感じない企業が数多く存在するのです。
BullMQによる変化
BullMQはその後継です。TypeScriptでゼロから再構築されており、そのインターフェース全体がasync/awaitを中心に設計されています。ここ数年、モダンなNode.jsのコードを書いてきた人なら、その構文にすぐに馴染めるはずです。しかし、その違いは型定義やPromiseチェーンよりも深いところにあります。BullMQは、キューとワーカーの間の明確な分離を強制します。Bullでは、キューがワーカーの実行役を兼ねていることがよくありました。一方、BullMQでは、あるファイルでキューを定義し、別のファイルでワーカーを定義します。この分離は、実際のプロダクションシステムにおけるスケーリングの仕組みを反映しています。APIサーバーはキューにジョブを追加するだけに専念し、ワーカーコンテナのフリートはジョブの処理のみを行う、といった構成が可能です。システムが成長しても、アーキテクチャの可読性は保たれます。
優位性を決定づける機能
BullMQが真に一歩リードしているのは、Bullには提供できない機能においてです。実際のアプリケーションにおいて特に重要な3つの追加機能を紹介します。
ジョブフロー
複雑なワークフローが、単一のバックグラウンド関数に収まることは稀です。画像処理パイプラインを構築していると想像してください。ユーザーが未加工の写真をアップロードすると、バックエンドはサムネイルを作成し、圧縮プレビューを生成し、OCRスキャンを実行し、最後にすべてが完了したことをフロントエンドに通知する必要があります。Bullの場合、これらすべてのステップを、一つの巨大で壊れやすいハンドラーに詰め込むことになるでしょう。BullMQは「ジョブフロー(job flows)」を導入しており、親ジョブと子ジョブを明示的に連鎖させることができます。依存関係を定義できるため、サムネイル作成とOCRジョブの両方が成功した後にのみ、通知ステップを実行するといった制御が可能です。OCRが失敗した場合、サムネイルを再処理することなく、その部分だけをリトライできます。ロジックはモジュール化され、可観測性が高まり、深夜3時に何か問題が発生した際もデバッグが格段に容易になります。
グループレート制限
マルチテナントのSaaSアプリケーションを運営しているなら、特定の顧客がワーカーをパンクさせてしまうことを心配したことがあるでしょう。単一のテナントが1万件のエクスポートジョブをキューに入れ、他のすべてのユーザーを停滞させてしまう可能性があります。BullMQには「グループレート制限(group rate limiting)」が備わっており、テナントごと、あるいはAPIキーごとに処理速度を制限できます。例えば、テナントAには毎分50回の外部API呼び出しを許可しつつ、テナントBにはそれとは独立した同じ枠(バケット)を割り当てるといったことが可能です。この制限は、単一のマシン上だけでなく、すべてのワーカーインスタンスにわたってグローバルに適用されます。これは、いざという時にその重要性を痛感することになる、まさに「安全弁」のような機能です。
モダンなインターフェース
BullMQはレガシーなコールバック形式を廃止し、現代的なAPIを採用しています。エラーハンドリングは標準的なPromiseパターンに従います。TypeScriptの定義は、後付けのコミュニティパッケージではなく、ファーストクラスの機能として組み込まれています。新規プロジェクトを開始する場合、開発体験(DX)は著しく向上します。エディタはキューのオプションを自動補完し、リンターはジョブ名の欠落を検知します。認知負荷は軽減されます。
変わらないRedisの存在
この決定における一つの実用的な救いは、インフラストラクチャにあります。BullとBullMQはどちらも、ジョブの状態、メタデータ、およびスケジュールをRedisに保存します。内部のキー構造は異なりますが、基盤となるテクノロジーは同一です。すでにBullのためにRedisを運用している場合、BullMQを採用するために新しいデータベースに交換したり、デプロイメントのトポロジーを再考したりする必要はありません。移行の課題はアプリケーションコードにあり、サーバー費用にあるのではありません。
移行の現実
とは言え、BullからBullMQへの移行は、そのまま置き換えられる(drop-in replacement)ものではありません。APIコールが変わり、イベント名も異なります。プロセッサの定義方法や並行性の制御方法も大幅に書き換える必要があるため、キューと通信するすべてのファイルに手を加えることになります。さらに重要なのは、スイッチを切り替えるだけで、古いジョブが新しいシステムで完了することを期待することはできないという点です。同じRedisインスタンスに対してBullMQワーカーを立ち上げる前に、既存のBullキューを完全に空にする必要があります。そうしないと、同じキー空間内で2つの異なるフォーマットが衝突するリスクがあります。メンテナンス時間を設けるか、ブルーグリーンデプロイメントによる切り替えを計画してください。これには実作業が必要であり、その作業に見合うだけの価値があるかを見極める必要があります。
どちらを選ぶべきか
もし現在のBullのセットアップが不満なく順調に動いているのであれば、そのままにしておきましょう。安定性には価値があります。バックグラウンドキューはインフラストラクチャであり、流行を追うためのものではありません。もし、親子ワークフローやテナントごとのレート制限がどうしても必要で、チームが現在のアーキテクチャに苦戦しているのであれば、移行は理にかなっています。関心の分離がより明確になり、モダンなAPIを採用することで、時間の経過とともにその努力が報われるでしょう。
新規プロジェクトであれば、選択はより単純です。BullMQから始めましょう。BullMQは定期的なアップデートが行われており、最新のJavaScript標準をそのままサポートしており、半年でライブラリの限界を感じることなく、複雑なジョブフローを構築できる余裕があります。メンテナンス側がすでに移行してしまったAPIの上に、技術的負債を築くことを避けることができます。
真の教訓
ジョブキューの目的は、HTTPレスポンスを高速に保ち、ユーザーを待たせないことです。Bullは今でもその役割を立派に果たしています。BullMQは、モダンなNode.jsアプリケーションの構築・拡張方法に適合した構造でそれを実現します。問題は、どちらのライブラリが純粋に優れているかではありません。現在の苦痛が移行に見合うものかどうか、そして次のプロジェクトが、次の資金調達ラウンドや製品ローンチの前に交換する必要のない基盤を必要としているかどうか、なのです。
