SmartHR Tech Blog

SmartHR 開発者ブログ

Sidekiq アンチパターン: 序

こんにちは

どうも、ぷりんたいです。さいきん、 Mastodon がTwitter廃人たちの間で大ブームですね。

今日はそんな Mastodon の話……ではなく、 Mastodon でも採用されている Ruby 製のバックグラウンドジョブフレームワーク Sidekiq を軸に非同期処理の話をします。

ターゲット読者は、 Sidekiq で非同期処理をはじめたばかりの入門レベル程度の方を想定しています。

アンチパターン

アンチパターンとは、一言でいえば「よくないやり方」のことです。

みなさんの現場でも当座をしのぐことだけを目的として、エイヤ設計やソイヤ実装をしてしまうこともあるかもしれません。しかし、それらはしばしの眠りの後に技術的負債、または技術的致命傷としてプロダクトに跳ね返ってきます。

そういった、現場で起こりがちな問題の早期発見や予防をするために、誤った設計や実装の例を集め分類されたもののことを一般的にアンチパターンと呼び、初中級者の転ばぬ先の杖として重用されています。

さて、今日のウェブアプリケーションにおいて非同期処理は必要不可欠のものとなっています。ところが非同期処理は人類にとって早すぎる概念でもあり、一見問題のない単純な処理を切り出したはずが地雷原を埋設していたという話はよく耳にします。

この記事では Sidekiq (on Rails) を題材に不慣れな人が陥りがちなアンチパターンを紹介していきたいと思います。

名は動作を表すべき

Bad: PdfWorker WebhookWorker ImportWorker

非同期処理にしたいという気持ちが先行しすぎていて、ワーカーの名前からどんな動きをするのかがわかりません。ワーカーに一度つけた名前を変えるのは結構たいへんです。クラス名には魂をこめましょう。

Good: PayslipPdfGenerateWorker WebhookNotificationWorker CrewImportWorker

例外握り潰し

def perform(arg)
  # something to do
rescue => e
  logger.error "#{e.class}: #{e.message}"
end
def perform(arg)
  # something to do
rescue => e
  raise '処理に失敗しました><'
end

これらは、絶対にやってはいけません!

前者は、 perform 実行中にいかなる例外が発生しようとも rescue 節でキャッチしてしまうので、 perform を呼び出した worker はジョブが成功したものとみなしてしまいます。 後者は、発生した例外を RuntimeError で上書きしてしまい、どのような例外が発生してジョブが失敗したのかが分からなくなってしまいます。
キャッチした例外はちゃんと perform の外に投げなおしてあげましょう!

また、次のように rescue 節の中で例外が発生し得る処理を記述するのも危険です。1行1行に魂を込めましょう。

def perform(arg)
  # something to do
rescue => e
  1 / Random.rand(2) # 50% chance to raise ZeroDivisionError
  raise e
end

日付処理

1ヵ月後に契約更新を迎えるユーザに通知を送りたいときに、日次バッチを実装するというケースで起こりがちな事例です。

# デイリーバッチで20時に実行
def perform
  Customer.where(subscription_end_at: Time.zone.today + 1.month).find_each do |customer|
    SubscriptionRenewNotifyMailer.notify(customer_id: customer.id).deliver_now
  end
end

このワーカーには下記のような問題点があります。

  1. ワーカー上で実際に走った時刻が24時を過ぎていたらどうなるだろう?
    キューが詰まっていて、予約された時刻と実行される時刻がずれてしまうのは非同期処理あるあるです。
    この実装ではワーカーが実行された日付 (Time.zone.today)を基準にしているので、4/20分で実行されてほしかったのに4/21分として実行されてしまう、なんてことが起こってしまいます。
    キューイング時の引数に日付を渡せば実行される少なくとも実行時の時刻に依存することはなくなりますし、もしかするとより良い方法があるかもしれません。
  2. ActionMailer#deliver_now が例外を出したらどうなるだろう?
    #deliver_now は同期的にメールを送信するメソッドですが、設定によって送信に失敗した時に例外を出したりメール送信に失敗したことを握りつぶしたりする、ちょっと使いづらい性質を持ちます。
    例外を吐いた場合にきちんと例外処理をしていなければリトライ時にメールの多重送信が起こったり、握りつぶされた場合は対象顧客にメールが送られていないのにジョブが完了してしまって気づかないなんてこともありえますが、これを各ワーカーの中で都度ハンドリングをするのは面倒ですので、 #deliver_later でメール送信の責務を分離してしまうのがよいでしょう。
  3. 1ヵ月後って、 Date + 1.month で大丈夫? もしかしたら、1ヵ月後より30日後の方がよいかもしれませんね。
Date.parse('2017-01-27') + 1.month # => Mon, 27 Feb 2017
Date.parse('2017-01-28') + 1.month # => Tue, 28 Feb 2017
Date.parse('2017-01-29') + 1.month # => Tue, 28 Feb 2017
Date.parse('2017-01-30') + 1.month # => Tue, 28 Feb 2017
Date.parse('2017-01-31') + 1.month # => Tue, 28 Feb 2017

上記を踏まえると、このような実装がよいかもしれません。

def perform(target_date)
  Customer.where(subscription_end_at: Time.zone.parse(target_date) + 30.days).find_each do |customer|
    # 契約期間中に一度通知した場合は通知しない
    if customer.renew_notified_at.try(:between?, customer.subscription_begin_at, customer.subscription_end_at)
      SubscriptionRenewNotifyMailer.notify(customer_id: customer.id).deliver_later
      customer.touch(:renew_notified_at)
    end
  end
end

引数の取扱い

Sidekiq では、予約されたジョブの引数はJSONの配列にシリアライズされて Redis 上に永続化されます。それでは、次のようなワーカーを見てみましょう。

class WebhookWorker
  include Sidekiq::Worker

  def perform(url, channel, message)
    payload = {channel: channel, message: message}
    RestClient.post(url, payload.to_json)
  end
end

一見問題なさそうですが、ある意味王道のアンチパターンを踏んでいます。Webhook あるあるでパラメータを追加/変更したい!みたいなことは起こりがちですが、単純にワーカーが受け取れる引数を増やしたらどうなるでしょうか。

def perform(url, username, channel, message)

おっと、これではいけません。Sidekiq ではジョブを予約した時の変数との対応は実行時に考慮してもらえないので思わぬトラブルを招きます。

WebhookWorker.perform_async('https://hooks.example.com/T00000000/XXXXX', 'general', 'hello, webhooks!')

のように予約されたジョブが既に存在した場合、 username'general' が、 channel'hello, webhooks!' が、 message には nil がセットされて実行されてしまい、よくわからないことになってしまいます。

また、Sidekiq は今のところキーワード引数も使えませんので、運用上引数の増減が予想されるようなワーカーでは、引数に Hash を使うとよいでしょう。そもそも、最低限のパラメータはモデルに持たせるのがよいですね!

def perform(webhook_id, payload = {})
  webhook = Webhook.find(webhook_id)
  RestClient.post(webhook.url, webhook.post_options.merge(payload))
end

おわりに

数は少ないですが、よく見かけがちな実装上のアンチパターンを紹介しました。一応「序」と銘打ったので、次があるとすれば中級編の「破」を書きます。

ウェブサービスの種類や規模に応じて非同期処理の形もさまざまと思いますが、一定の規模に達するまでの間であれば Sidekiq は機能豊富で個人的にもイチオシです。

それでは、よい非同期ライフを!