## 背景
元々、メモリ使用量の多いバッチ処理を **Sidekiq** で実行していましたが、以下の課題が発生しました。
1. **メモリ使用量の問題**
- 例えば、「ある日付の特定の時間に対する空き枠」のように、数十億件を超えるレコードを更新するジョブが増加し、Railsアプリケーションのメモリ使用量が急増。
- 一部のジョブがSidekiqのメモリ制限を超えてしまい、OOM Killerによってプロセスが強制終了されるケースが発生。
- 無料版のSidekiqをマルチスレッドで動作させていたため、プロセスがクラッシュすると、同一プロセス内の未処理のメッセージがすべて失われてしまった。
- メモリ使用量が大きいジョブの実行が失敗することで、結果を待つユーザーが長時間待たされる状況が発生。
2. **Sidekiq-Pro のコスト**
- OOM Killerによる強制終了で、未処理のジョブがRedisのキューに残り続けないようにするため、**Sidekiq-Pro($99/月 ≒ 約15,000円)** の導入を検討。
- しかし、コストが高いうえに、ジョブの成功結果を待つユーザーの待ち時間は解消されない見込みだった。
このような背景から、**Sidekiq以外の実行基盤でバッチ処理を実行する方法** を検討しました。
---
## 新しいシステム構成
Sidekiqを用いた非同期処理を **イベント駆動型(Event-Driven)** と **スケジュール駆動型(Schedule-Driven)** に分け、それぞれ別の実行基盤へ移行しました。
どちらも **Google Cloud Workflows から Cloud Run Jobs を起動** してバックグラウンド処理を行う設計です。
また、Cloud Run Jobs 内では **rakeタスク** を実行し、バッチ処理を実現しています。
### **スケジュール駆動型(Schedule-Driven)ジョブの移行**
1. **Cloud Scheduler** が **Railsアプリケーションのエンドポイント** に対してHTTPリクエストを定期実行。
2. **Railsアプリケーション** 内で **Google Cloud Workflows** を実行。
3. **Google Cloud Workflows** のステップ内で **Cloud Run Jobs** を起動し、ジョブを実行。
### **イベント駆動型(Event-Driven)ジョブの移行**
1. **Railsアプリケーション** から **Google Cloud Workflows** をトリガー。
2. **Google Cloud Workflows** のステップ内で **Cloud Run Jobs** を実行。
また、以下の追加機能を実装しました。
- **同時実行数の制御(スロットリング)**
- **workflows_locksテーブル** にスロット情報を保存し、RailsのAPIから同時実行数を返却できるようにすることで、Cloud Run Jobs の同時実行数を制御。
- 同時実行数制限に達しているときは、ttlの時間までGoogle Cloud Workflowsのコールバックを使用して待機し、ttlの時間までHTTPリクエストがない場合はcallbackを破棄してもう一度同時実行数を確認する。
- Workflowsから `POST /workflows/concurrency_count` を叩き、スロットに空きがあるかを判定。
- **スロットが埋まっている場合は、待機中のWorkflowsがFIFOで実行されるように制御。**
- **リトライ間隔の制御**
- Workflows内で `POST /workflows/retry_intervals` を叩き、リトライ間隔を動的に調整。
- 指数バックオフやランダムなリトライ間隔を活用し、時間経過ごとに適切な間隔を設定。
---
### エンドポイントとデータベースの説明
| API | 説明 |
| --- | --- |
| `POST /workflows/callback` | 作成したコールバックURLをDBに保存。 |
| `PUT /workflows/release_slot ` | ジョブ完了時にスロットを解放(Workflows の完了時に実行)。 |
| `POST /workflows/concurrency_count` | 同時実行数を確認。スロットが空いていれば `workflows_locks` テーブルにレコードを追加。 |
#### **workflows_locks テーブル**
| カラム | 説明 |
| --- | --- |
| `id` | プライマリキー |
| `execution_id` | Workflows の実行ID |
| `lock_key` | スロットリング単位のキー |
| `lock_ttl` | スロットの有効期限 |
| `status` | `ON` または `OFF`(スロットの状態) |
#### **workflows_callback_urls テーブル**
| カラム | 説明 |
| --- | --- |
| `id` | プライマリキー |
| `url` | コールバック用のURL |
| `workflows_name` | Workflowsの種類 |
| `status` | `active` または `inactive`(コールバック待機状態) |
---
## **工夫した点**
1. **Google Cloud Workflows の YAML を変更せずに済む設計**
- Workflowsの定義変更を減らすため、Sidekiqのように **Rubyコードで動的に設定** できる仕組みを導入。
- 具体的に、以下のような項目をRubyコードで定義可能にした。
- **リトライ間隔**
- **リトライ回数**
- **スロットリング(同時実行数、TTL)**
- **イベント駆動型 or スケジュール駆動型**
- **Cloud Run Jobs で実行する rake タスク名**
**(Rubyコード例)**
```ruby
class HogeWorkflows < BaseWorkflows
workflow_type :event_driven
rake_task "workflows:hoge"
retry_options({ count: 12 })
retry_interval do
(10.minutes + rand((-1.minute)..(1.minute))).to_i
end
concurrency_options({ limit: 2, ttl: 10.minutes })
end
```
2. **スロットリングの最適化**
- **1,000人以上のユーザーが同時実行する場合でも、待機時間を最小限にするフローを設計。**
- WorkflowsとRailsサーバー間のポーリングではなく、**`events.await_callback` を用いたことでコスト削減** を実現。(Workflows の料金はステップ数に応じて課金されるため。)
---
## **比較検討した実行基盤**
| サービス | 理由 |
| --- | --- |
| **Cloud Pub/Sub** | Dead Letter Queue があり、プロセスクラッシュ時のメッセージ損失を防げるが、ACK timeout が **10分** だったため、長時間実行するジョブに不適合。 |
| **Cloud Tasks** | Dead Letter Queue がなく、ACK timeout が **30分** だったため、1時間以上のジョブには不適合。 |
---
## **結果**
- **OOM Killerによる強制終了がほぼゼロに**
- メモリ消費の多いジョブは新基盤、軽量なジョブはSidekiqに分離し、安定性とリソース最適化を実現。
- **ランニングコストの削減**
- Sidekiq-Pro の **$150/月** のコストを **約$10/月** に抑えることができた。
---
## **まとめ**
Sidekiqのメモリ制限を回避しつつ、イベント駆動型・スケジュール駆動型のジョブを安定的に実行できる新しい実行基盤を構築しました。
Workflows + Cloud Run Jobs による処理の統合、スロットリング制御、リトライ間隔の動的設定により、**運用コストの削減とジョブ実行の安定化を両立** できました。