📉

Rails の非同期処理を Sidekiq から Cloud Tasks にリプレイスして Cloud Run のコストが6分の1になった話

2024/08/01に公開

成果

最終的に、Cloud Run のコストが$6/day前後から$1/day前後に!

ちなみに、Cloud Tasks は1ヶ月あたり最初の100万回のオペレーションまで無料なので余裕で収まっています。

モチベーション

  • 今回リプレイスを検討したシステムは軽量な非同期処理が大半で、もともと絶対に Sidekiq でないと困るということが少なかった
  • Sidekiq は Redis をポーリングしてジョブを取得する方式なので、Cloud Run で実行するには min-instances を1以上にしなければいけない
    • 何もジョブがない状態が続いてインスタンスが0になると起こしてくれる人がいないので...
  • 絶対に Sidekiq でないと困らないなら Cloud Tasksにして、非同期処理がない時は寝ていても良いようにしたい => コストダウン!
    • Pub/Sub との比較検討もしましたが今回は割愛します

システム構成

非同期処理を Event-driven な処理と Scheduled-driven な処理に分けて考えます。

Event-driven

  • 会員登録が行われたら非同期でメールを送信する
  • 予約が行われたら非同期で外部APIを呼び出す

などのように、何らかのイベントをトリガーにして実行したい非同期処理です。

Scheduled-driven

  • 1日1回とある集計を行い結果を保存する
  • 1時間おきにデータをチェックし何らかの処理を行う

などのように、定期的に実行したい、いわゆるバッチ処理です。

Before

リプレイス前、Event-driven な処理は ActiveJob の perform_later を使い Redis にエンキュー、Sidekiq がジョブを取り出し実行するという、とても普通な方式で動いていました。

Scheduled-driven な処理は、指定されたジョブを perform_later するだけの簡単な API(指定されたジョブが有効なものかホワイトリスト形式でチェックします)を用意し、Cloud Scheduler から HTTP Reqeust で呼び出すようにしていました。その後は Event-driven と同様に Sidekiq がジョブを処理してくれるという、こちらもとても普通な方式で動いていました。

After

リプレイス後はこんな構成になりました。詳細は後述します。

リプレイスの流れ

Event-driven

まずは、 ActiveJob 相当のインタフェース(主に perform_laterperform_now など)で Cloud Tasks へエンキューする(タスクを作成する)処理を実装したクラスを用意し、各ジョブが継承するクラスを ApplicationJob から変更しました。タスクには http_request の body に実行したいジョブの名前と引数を含めるだけなので割とシンプルな実装です。以下の Tasks::CloudTaskJob は何らかのライブラリではなく自前実装です(念のため)。

- class GuestsCleanupJob < ApplicationJob
+ class GuestsCleanupJob < Tasks::CloudTaskJob

次に、Cloud Tasks からの HTTP Request を受け付ける API を用意しました。こちらも認証用のヘッダーや指定されたジョブが有効なジョブかホワイトリスト形式でチェックし、リクエストの body に含まれるジョブの名前と引数を使って処理を行うだけのシンプルな実装です。

なんで ActiveJob もやめたの?

当初は QueueAdapter として実装して

config.active_job.queue_adapter = :sidekiq

を切り替えることも検討しました。以下のようにジョブごとに設定することもできるので段階的な移行も可能です。

class GuestsCleanupJob < ApplicationJob
  self.queue_adapter = :resque
  # ...
end

なのですが、ActiveJob のリトライ機構と Cloud Tasks のリトライ機構をそれぞれ使いこなす設計を考えている中で「Sidekiq の頃も ActiveJob のリトライ機構と Sidekiq のリトライ機構をそれぞれ理解して使いこなすのは、既に理解している人にとっては大きな問題にならないけど、はじめて触る人にとって決して優しい設計とは言えないよな〜」と、ややモヤっとしていたので、今回は ActiveJob を経由せずに Cloud Tasks を直接操作することにしました。

似たような話題がこちらにもあります。

https://andycroll.com/ruby/use-sidekiq-directly-not-through-active-job/

https://techracho.bpsinc.jp/hachi8833/2023_03_06/127675

Scheduled-driven

Scheduled-driven な処理について、最終的にこの方式はやめることになるのですが、はじめは Cloud Scheduler から Cloud Run ジョブで実行することにしました。何も考えずにジョブの数だけ Cloud Run ジョブをデプロイすると、16個もデプロイすることになりとてもつらいので、1つだけデプロイしておいて、その Cloud Run ジョブの実行引数を override して使い回すことにしました。

以下は Terraform の抜粋です。抜粋なのでこのままでは動きません(念のため)🙏

locals {
  override_job_name = "shared-scheduled-job"
  jobs = [
    {
      name         = "RemindMeetingJob"
      schedule     = "0 */1 * * *"
      rails_runner = "RemindMeetingJob.new.perform"
    }
    # これがあと15個ある
  ]
}

resource "google_cloud_scheduler_job" "scheduled-job" {
  for_each = { for i in local.jobs : i.name => i }

  http_target {
    body = base64encode(jsonencode({
      overrides : {
        containerOverrides : {
          args : ["berglas", "exec", "--", "bundle", "exec", "rails", "runner", each.value.rails_runner]
        }
      }
    }))

    uri = "https://${local.region}-run.googleapis.com/apis/run.googleapis.com/v1/namespaces/${local.project_id}/jobs/${local.override_job_name}:run"
  }

  schedule = each.value.schedule
}

他にも各種環境の調整や GitHub Actions で行っているデプロイまわりにもいろいろ手を入れ、ステージング環境で動作確認をした後、本番環境も移行しました。冒頭のコストのチャートにも書きましたが移行時にゴタゴタもありつつ、一旦はこの設計で安定して本番稼働するところまでは持っていきました。

その後しばらくして、あることに気付きます。

Cloud Run ジョブで軽い処理を行うのって、もしかしてコスパ悪くない??

Cloud Run ジョブをやめる

Cloud Run ジョブが、処理自体は数ms〜数百msで終わるのに Rails の起動にまぁまぁな時間がかかるので Cloud Run を Always-on で起動しているのとコスト的にほぼ変わらない感じになっていました...

使わない時に寝ていて欲しいから Sidekiq をやめ、Cloud Run ジョブを採用して「使わない時に寝ている」状態は実現できましたが、このままではコスト的なメリットが全くありません。

であれば、Scheduled-driven なジョブも Event-driven のために用意した Cloud Run サービス(この環境は min-instances0 にしています)で処理する方が良さそうです。

ただし、たまたま今回リプレイスしたシステムのバッチ処理に時間がかかるものがほぼ無いというラッキーがあったのでこの判断をできました。とはいえ、もしも時間のかかるバッチ処理があってもそれだけ Cloud Run ジョブで実行するのが良さそうです。

ということで実際に設計を変更します。といっても、Cloud Scheduler の HTTP ターゲットを Cloud Run ジョブから Event-driven 用の Cloud Run サービスに切り替えるだけなのでとても簡単にできました。

結果、この変更だけで狙い通り、むしろ期待以上にコストを下げられました。

$3/day前後から$1/day前後に🎉

最後に

コストを気にする必要がないのであれば初手で Sidekiq を選ぶのが鉄板でしょうし、ましてやハイパフォーマンスが求められるシステムであれば Sidekiq Pro を使い倒すのが良いと思います。

詳細は書いていませんが、Cloud Tasks ならではの悩みポイントはもちろんありますし、その対策にまぁまぁの工数を使ったのも事実です。

一方で、せっかく Cloud Run を使っているのであれば使わない時に寝ていてもらうという選択肢を取れるのでそれを活用したい。コストも下げたい。という気持ちで今回このようなリプレイスを行いました。

似たような境遇の方の参考になれば嬉しいです!それでは!

株式会社モニクル

Discussion