イベントループのブロックを解消:BullMQとRedisによるNode.jsのスケーリング手法

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

2:14 AMの緊急呼び出し

PagerDutyが鳴っただけではなく、悲鳴を上げているようでした。目をこすりながらGrafanaのダッシュボードを開くと、APIのレスポンスタイムはすでに異常な数値を示していました。通常は平均200msでしたが、今は8.5秒というスパイクが発生しています。ユーザーは「サインアップ」をクリックした後、永遠に待たされた挙げ句、504 Gateway Timeoutを目にすることになります。驚いたことに、データベースのCPU使用率は12%でアイドル状態、メモリ使用量も安定していました。システムはクラッシュしていたのではなく、ただ「固まって」いたのです。

ログを分析すると、単純ですが致命的なボトルネックが見つかりました。突然のマーケティングキャンペーンにより、サインアップ率が3倍に急増していたのです。

新規登録のたびに、重いタスクが連鎖的に発生していました。5MBのカスタムPDFウェルカムキットの生成、4つの異なるサイズのプロフィール画像の作成、 school そしてレスポンスの遅い外部SMTPサーバーへのアクセスです。Node.jsはシングルスレッドであるため、これらのCPU負荷の高いタスクがイベントループを占有してしまいました。他のユーザーが単純なプロフィールページを読み込もうとしても、PDF生成タスクの後ろで行列を作って待たされることになったのです。

イベントループは万能ツールではない

Node.jsがI/Oには優れている一方で、重い計算処理は苦手であることを忘れがちです。Webサーバーに画像処理をさせたり、レスポンスを返す前に遅い外部APIを待たせたりすることは、ユーザーの接続を「人質」に取っているのと同じです。単にそのリクエストを遅くしているだけでなく、イベントループが新しいリクエストを受け付けるのを妨げているのです。

今回の障害シナリオでは、await mailer.send(...) の呼び出しに1リクエストあたり3〜5秒かかっていました。201 Created を返す前にこの処理の完了を待っていたため、プロセス全体が停止してしまいました。高速な非同期切り替えのために設計された環境で、同期的な重い処理を実行しようとしていたのが原因です。

目的に適したツールの選択

目標はシンプルでした。ユーザーのリクエストを即座に受け付け、重い処理は別の場所で行うことです。私は3つの一般的な戦略を検討しました。

  • setTimeout または setImmediate: これは「クイックで場当たり的な」修正方法です。タスクを次のイベントループのティックに回せますが、危険も伴います。サーバーが再起動したりクラッシュしたりすると、保留中のタスクは消えてしまいます。リトライロジックもモニタリングもセーフティネットもありません。
  • RabbitMQ: 堅牢なエンタープライズ向けメッセージブローカーです。強力ですが、今回のスタックには過剰に感じました。基本的なメール送信のためだけに、大量のボイラープレートとAMQPプロトコルの深い知識が必要です。
  • BullMQ と Redis: これが今回の勝者です。BullMQはRedisを活用して、メッセージキューイング、リトライ、ジョブの永続化を処理します。Redisはすでにキャッシュとして導入されていたため、新しいインフラを追加することなく、数分で稼働させることができました。

BullMQによるアーキテクチャ設計

BullMQはプロデューサー・コンシューマー(Producer-Consumer)モデルで動作します。APIはプロデューサー(Producer)として機能し、Redisベースのキューに「ジョブ」を渡します。別のワーカープロセス(Consumer)が、余裕があるときにそのジョブを拾います。ワーカーが失敗してもジョブは失われず、設定したルールに基づいてリトライするためにRedisに残ります。この構成は、月間数百万件のジョブを処理する上でも非常に堅牢であることがわかりました。

環境の準備

まずはRedisインスタンスを起動することから始まります。Dockerを使用している場合、コマンド1つで本番環境に近い環境を構築できます。

docker run -d -p 6379:6379 redis:alpine

次に、必要なライブラリをプロジェクトにインストールします。

npm install bullmq ioredis

ステップ1:プロデューサーの定義

プロデューサーの唯一の仕事は、メッセージをキューに入れてすぐに立ち去ることです。これにより、APIルートの高速性が維持されます。

import { Queue } from 'bullmq';
import Redis from 'ioredis';

const connection = new Redis({ host: 'localhost', port: 6379 });
const emailQueue = new Queue('email-tasks', { connection });

async function addWelcomeEmailJob(userData) {
  // 処理をオフロードして即座にレスポンスを返す
  await emailQueue.add('send-welcome-email', {
    email: userData.email,
    name: userData.name,
  }, {
    attempts: 3,
    backoff: {
      type: 'exponential',
      delay: 5000, // 失敗時は5秒、10秒、20秒と間隔を空けてリトライ
    },
  });
}

ステップ2:ワーカーの構築

ワーカーは専用のプロセスです。メインのAPIサーバーのリソースをトラフィック処理に集中させるため、別個の安価なインスタンスで実行することも可能です。

import { Worker } from 'bullmq';
import Redis from 'ioredis';

const connection = new Redis({ host: 'localhost', port: 6379 });

const worker = new Worker('email-tasks', async (job) => {
  if (job.name === 'send-welcome-email') {
    const { email, name } = job.data;
    
    // PDF生成やSMTP呼び出しなどの重い処理をシミュレート
    await new Promise((resolve) => setTimeout(resolve, 2000));
    console.log(`成功:ウェルカムキットが ${email} に送信されました`);
  }
}, { connection });

worker.on('failed', (job, err) => {
  console.error(`ジョブ ${job.id} が失敗しました: ${err.message}`);
});

導入後の効果

メールと画像処理のロジックをBullMQに移行した後、結果は劇的に変わりました。サインアップのエンドポイントのレスポンスタイムは、4.2秒からわずか45msにまで激減しました。ユーザーは即座に確認を受け取り、一方で「重い」処理はバックグラウンドで行われます。もしSMTPプロバイダーが10分間ダウンしても、BullMQは一時停止して後でリトライするだけです。データが失われることも、ユーザーをイライラさせることもありません。

高度な機能

基本をマスターすれば、BullMQのより高度な機能を活用できます。

  • 遅延ジョブ(Delayed Jobs): ユーザーが参加してから正確に48時間後に「チェックイン」メールを送信するようにスケジュールできます。
  • 優先度レベル(Priority Levels): 1万件の「ニュースレター」ジョブが保留されていても、「パスワードリセット」ジョブを最優先で処理させることができます。
  • 並列制御(Concurrency Control): ワーカーを微調整して10件や20件のジョブを同時に処理させ、システムに負荷をかけすぎずにCPU使用率を最大化できます。

レジリエンス(回復力)のための構築

バックグラウンドワーカーへの移行により、アプリの監視方法が変わります。エラーはリクエスト/レスポンスのサイクル内で発生しなくなるため、標準的なAPIログには表示されません。そこで、BullBoardの導入を強くお勧めします。これはキューの状況を視覚的に把握できるダッシュボードで、失敗したジョブをワンクリックで手動リトライすることも可能です。

APIは工場労働者ではなく、無駄のない交通管制官のように機能すべきです。重い処理をBullMQとRedisに委任することで、ユーザーが10人であろうと1万人であろうと、アプリケーションの応答性を維持することができます。

Share: