最終的に、Cloud Run のコストが$6/day前後から$1/day前後に!
ちなみに、Cloud Tasks は1ヶ月あたり最初の100万回のオペレーションまで無料なので余裕で収まっています。
min-instances
を1以上にしなければいけない非同期処理を Event-driven な処理と Scheduled-driven な処理に分けて考えます。
などのように、何らかのイベントをトリガーにして実行したい非同期処理です。
などのように、定期的に実行したい、いわゆるバッチ処理です。
リプレイス前、Event-driven な処理は ActiveJob のperform_later
を使い Redis にエンキュー、Sidekiq がジョブを取り出し実行するという、とても普通な方式で動いていました。
Scheduled-driven な処理は、指定されたジョブをperform_later
するだけの簡単な API(指定されたジョブが有効なものかホワイトリスト形式でチェックします)を用意し、Cloud Scheduler から HTTP Reqeust で呼び出すようにしていました。その後は Event-driven と同様に Sidekiq がジョブを処理してくれるという、こちらもとても普通な方式で動いていました。
リプレイス後はこんな構成になりました。詳細は後述します。
まずは、 ActiveJob 相当のインタフェース(主にperform_later
やperform_now
など)で Cloud Tasks へエンキューする(タスクを作成する)処理を実装したクラスを用意し、各ジョブが継承するクラスをApplicationJob
から変更しました。タスクには http_request の body に実行したいジョブの名前と引数を含めるだけなので割とシンプルな実装です。以下のTasks::CloudTaskJob
は何らかのライブラリではなく自前実装です(念のため)。
- class GuestsCleanupJob < ApplicationJob+ class GuestsCleanupJob < Tasks::CloudTaskJob
次に、Cloud Tasks からの HTTP Request を受け付ける API を用意しました。こちらも認証用のヘッダーや指定されたジョブが有効なジョブかホワイトリスト形式でチェックし、リクエストの body に含まれるジョブの名前と引数を使って処理を行うだけのシンプルな実装です。
当初はQueueAdapter として実装して
config.active_job.queue_adapter=:sidekiq
を切り替えることも検討しました。以下のようにジョブごとに設定することもできるので段階的な移行も可能です。
classGuestsCleanupJob< ApplicationJobself.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 な処理について、最終的にこの方式はやめることになるのですが、はじめは 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 ジョブが、処理自体は数ms〜数百msで終わるのに Rails の起動にまぁまぁな時間がかかるので Cloud Run を Always-on で起動しているのとコスト的にほぼ変わらない感じになっていました...
使わない時に寝ていて欲しいから Sidekiq をやめ、Cloud Run ジョブを採用して「使わない時に寝ている」状態は実現できましたが、このままではコスト的なメリットが全くありません。
であれば、Scheduled-driven なジョブも Event-driven のために用意した Cloud Run サービス(この環境はmin-instances
を0
にしています)で処理する方が良さそうです。
ただし、たまたま今回リプレイスしたシステムのバッチ処理に時間がかかるものがほぼ無いというラッキーがあったのでこの判断をできました。とはいえ、もしも時間のかかるバッチ処理があってもそれだけ Cloud Run ジョブで実行するのが良さそうです。
ということで実際に設計を変更します。といっても、Cloud Scheduler の HTTP ターゲットを Cloud Run ジョブから Event-driven 用の Cloud Run サービスに切り替えるだけなのでとても簡単にできました。
結果、この変更だけで狙い通り、むしろ期待以上にコストを下げられました。
$3/day前後から$1/day前後に🎉
コストを気にする必要がないのであれば初手で Sidekiq を選ぶのが鉄板でしょうし、ましてやハイパフォーマンスが求められるシステムであれば Sidekiq Pro を使い倒すのが良いと思います。
詳細は書いていませんが、Cloud Tasks ならではの悩みポイントはもちろんありますし、その対策にまぁまぁの工数を使ったのも事実です。
一方で、せっかく Cloud Run を使っているのであれば使わない時に寝ていてもらうという選択肢を取れるのでそれを活用したい。コストも下げたい。という気持ちで今回このようなリプレイスを行いました。
似たような境遇の方の参考になれば嬉しいです!それでは!
株式会社モニクルは、「金融の力で、安心を届ける。」をミッションとする金融サービステック企業です。
もしこの記事を読んで会社にも興味を持っていただけたら、↓ の Culture Deck(会社説明資料)を読んでみてください。
https://speakerdeck.com/moniclegroup/culture-deck
エンジニア採用サイトはこちら
バッジを受け取った著者にはZennから現金やAmazonギフトカードが還元されます。