【rails】delayed_jobを使って非同期化をする方法
ことの始まり
現在運用しているサービスは、メール送信に失敗した際Bugsnagで通知を飛ばすようにしていた。
通知を受けたら手動でメールの再送を行っていたが、利用者数が増えるにつれてエラーもかなりの数発生するようになってきて、手動で対応するのが辛くなってきた。
もうこれは非同期化するしかない!
というわけで、イチから調べて実装することにした。
delayed_jobの導入
非同期化をするには様々な手法があるけど、よくあるdelayed_job
を使うことにした。
まずはGemfile
に追加する。
gem 'delayed_job_active_record' gem 'daemons'
demons
は実際にサーバーで稼働させるのに必要なgem。
delayed_job
はDBを使って動くので、以下のコマンドでテーブルを作ってあげる。
$ bundle install $ bundle exec rails generate delayed_job:active_record $ bundle exec rake db:migrate
続いてconfig/application.rb
でdelayed_job
のキューを登録する。
今回はActive Jobを使うのでdelayed_job
にすれば良い。
class Application < Rails::Application config.active_job.queue_adapter = :delayed_job end
これでdelayed_job
が導入できた。
delayed_jobの実装
例えばorder_mailer.rb
のsend
メソッドでメールを送信する場合、controller側はこんなコードになっている。
class OrdersController < ApplicationController def submit ~~~ #ここにはorderを保存する処理とかがいるはず # メールを送る処理 OrderMailer.send(order).deliver end end
これを非同期化させるには以下のようにすれば良い
def submit OrderMailer.send(order).deliver_later end
delayed_jobを使った繰り返し処理
deliver_later
を使うことで非同期化はできたものの、今のままだと1回失敗したらメールが送られないままになってしまう。
なので、以下の2つを実装していくことにする。
- 1回失敗しても何回かリトライ。
- 最終的に失敗した場合は通知。
そのためにはジョブファイルを作る必要がある。
今回はorderメールのジョブなので、order_mail_deliver_job
とかにしておく。
$ rails g job order_mail_deliver create spec/jobs/order_mail_deliver_job_spec.rb create app/jobs/application_job.rb # これは最初だけ create app/jobs/order_mail_deliver_job.rb
まずはorder_mail_deliver_job.rb
の中身から実装していく。
上で作ったメールを送る処理をこの中に移動。
class OrderMailDeliverJob < ApplicationJob queue_as :default def perform(order) OrderMailer.send(order).deliver_now # ジョブに登録されるのでdeliver_nowを使う end end
controller側に作ったコードを適用しておく。
def submit OrderMailDeliverJob.perform_later(order) # 非同期化なのでperform_laterを使う end
続いてリトライ機能を作成する。
ruby 5.1以上であればretry_on
を使えば楽なのだが、今回はruby 5.0だったので自分で設定が必要だった…
他のジョブを作った場合も考えてapplication_job.rb
にリトライ機能を実装する。
class ApplicationJob attr_reader :attempt_number # 実行回数確認用の変数 RETRY_LIMIT = 3 # リトライさせたい回数 rescue_from(StandardError) do |e| if retry_limit_exceeded? # リトライ回数上限を超えたら通知 Bugsnag.notify(e) else # 上限以下ならリトライ retry_job(wait: wait_time) # 何秒後にリトライするかをwaitに記載 end end def serialize super.merge("attempt_number" => (@attempt_number || 0) + 1) end def deserialize(job_data) super @attempt_number = job_data["attempt_number"] end private def retry_limit_exceeded? @attempt_number >= RETRY_LIMIT end def wait_time attempt_number**2 end end
分割する必要もないので、Bugsnagへの通知もまとめて書いてしまった。
ローカル環境で動作確認
delayed_job
をローカルで確認するにはワーカーを起動させる必要がある。
bin/rake jobs:work
Elastic Beanstalk環境でワーカーの起動
みなさん大好きELB環境ではdeployと同時にワーカーの起動が必要になる。
そのためには..ebextensions
にワーカー起動用のconfigファイルを追加する必要がある。
files: "/opt/elasticbeanstalk/hooks/appdeploy/post/99_restart_delayed_job.sh": mode: "000755" owner: root group: root content: | #!/usr/bin/env bash # Using similar syntax as the appdeploy pre hooks that is managed by AWS # Loading environment data EB_SCRIPT_DIR=$(/opt/elasticbeanstalk/bin/get-config container -k script_dir) EB_SUPPORT_DIR=$(/opt/elasticbeanstalk/bin/get-config container -k support_dir) EB_APP_USER=$(/opt/elasticbeanstalk/bin/get-config container -k app_user) EB_APP_CURRENT_DIR=$(/opt/elasticbeanstalk/bin/get-config container -k app_deploy_dir) EB_APP_PIDS_DIR=$(/opt/elasticbeanstalk/bin/get-config container -k app_pid_dir) # Setting up correct environment and ruby version so that bundle can load all gems . $EB_SUPPORT_DIR/envvars . $EB_SCRIPT_DIR/use-app-ruby.sh # Now we can do the actual restart of the worker. Make sure to have double quotes when using env vars in the command. cd $EB_APP_CURRENT_DIR su -s /bin/bash -c "bin/delayed_job -n 2 --pid-dir=$EB_APP_PIDS_DIR restart" $EB_APP_USER
一番最後のコードで-n 2
としているのはワーカーを2つ起動させるオプション。
サーバー上のワーカー起動確認
ジョブのワーカーが動いているかは配布した環境で以下のコードを叩く必要がある。
叩いて何かしら出てきていれば無事ワーカーが稼働していることがわかる。
$ ps aux | grep -v grep | grep delay app ~~~
まとめ
これでdelayed_job
を無事導入することができた。
初めてだったので環境配布は特にビビったけど、問題なく動いてくれた。
メール以外にもファイルのアップロードとかは非同期化ができるので、使い道はひろそうな予感。
なお、導入よりもrspecの方が100倍苦労したけど…長くなったのでそれは次の記事でまとめることにする。