Techouse Developers Blog

テックハウス開発者ブログ|マルチプロダクト型スタートアップ|エンジニアによる技術情報を発信|SaaS、求人プラットフォーム、DX推進

Kaigi on Rails 2025 Webアプリケーションにおける非同期ジョブの設計原則を振り返りつつ実践してみた

ogp

Kaigi on Rails 2025 に参加しました

こんにちは、Techouseでエンジニアインターンをしているez0momongaと申します。

9/25、26の2日に渡って開催された、Ruby on Rails についてのカンファレンスKaigi on Rails 2025に参加して参りました。

Techouseでも各プロダクトにおいてRuby on Railsを用いたWebアプリケーションを開発しています。普段からRailsに触れる機会が多いため、今回のカンファレンスは非常に楽しみにしておりました。 普段の課題で感じる身近な課題についてのセッションや、最新の技術動向を知ることができるセッションなど、非常に多くの学びを得ることができ、大変有意義な時間を過ごせました。

本記事では、私が参加したセッションの中から、特に印象に残った@morihirokさんによるセッション「Sidekiq その前に:Webアプリケーションにおける非同期ジョブ設計原則」について振り返ります。 また、自分が遭遇したケースを例に、設計原則をどのように適用できるかを考察してみます。

非同期ジョブとは

セッションの内容へ入る前に、「非同期ジョブ」という用語を簡単に説明しておきます。

非同期ジョブとは、Webサーバーのリクエスト処理とは別のプロセスで実行される処理のことです。

通常のWebアプリケーションでは、ユーザーからのリクエストを受け取ったWebサーバーがその場で処理を行い、結果を返します(同期処理)。一方、非同期ジョブでは、時間のかかる処理を「ジョブ」としてジョブキュー(実行待ちのジョブが並ぶ待ち行列)に登録(エンキュー)し、Webサーバーとは別のワーカープロセスで処理します。Webサーバーはジョブをキューに登録したらすぐにレスポンスを返すため、ユーザーを待たせずに済みます。

Railsでは、ActiveJobというフレームワークが非同期ジョブの抽象化を提供しており、Sidekiq、Resque、Delayed Jobなどの様々なバックエンド(実際にジョブを実行する基盤)を選択できます。

セッションの内容

セッションでは、まず「なぜWebアプリケーションに非同期ジョブが必要なのか」という問いから始まり、非同期ジョブの考え方と、それに基づく「非同期ジョブは、短時間(数秒以内)で終わるシンプルな処理にする」 という設計原則が述べられました。

なぜ非同期ジョブが必要か

多くのWebサーバーは、受け取ったリクエストごとに限られた数のプロセスやスレッドといった処理枠を割り当てて処理します。 メール送信や外部API連携など、時間のかかる処理をWebサーバー内で同期的に実行すると、その処理枠が占有されてしまいます。 後続のリクエストは残された少ない処理枠で処理を待たされることになり、以下のようなデメリットが生じます。

  • ユーザー体験の悪化: クライアントへのレスポンスが遅延し、操作感が悪くなる。最悪の場合、タイムアウトで打ち切られる。
  • 他のプロセス・スレッドへの影響: CPUやメモリ、DBといったリソースを占有すると、他のプロセスやスレッドで使えなくなり、全体のパフォーマンスが低下する可能性がある。
  • システムの整合性への影響: 途中で処理が停止した場合、副作用が残り、データの不整合を引き起こす可能性がある。

この問題に対して、並列度を上げる(プロセス数やスレッド数を増やす)ことも考えられますが、CPUといったハードウェアのリソースは有限のため限界があり、コンテキストスイッチといった形でオーバーヘッドも増大するため、根本的な解決にはなりません。 サーバーをスケールアップ・スケールアウトすることも考えられますが、その分コストもかかります。

非同期ジョブを利用する場合、時間のかかる処理をWebサーバーとは別のプロセスに委譲することで 、サーバーはすぐにレスポンスを返すことができ、ユーザー体験を損なわずに済みます。 このように、非同期ジョブはWebアプリケーションを「速く」「安く」することができます。

非同期ジョブを導入する前に考えるべきこと

しかし、非同期ジョブの導入はシステムに複雑さをもたらすというトレードオフがあります。 そのため、セッションではまず以下の2点を自問することの重要性が強調されました。

1. 本当に非同期ジョブが必要か?

セッションでは外部サービスのAPI実行結果をブラウザに表示する例(スライド p.26-31)が紹介されました。 同期処理ならシンプルな構成が、非同期ジョブを導入した途端に考慮事項が一気に増えます。   この例から分かるように、非同期ジョブの導入は以下のような複雑性を生みます。

  • 結果の取得方法: クライアントが非同期ジョブの結果をどのように受け取るか(ポーリング?、WebSocket?)
  • エラーハンドリング: 非同期ジョブが失敗した場合の再試行や通知をどのように行うか
  • データの永続化: ジョブの結果をどのように保存し、後で参照できるようにするか(データベース? キャッシュ?)

2. バッチ処理で代替できないか?

ユーザーに即時で結果を返す必要がない処理や大量のデータを扱う処理は、定期的なバッチ処理であらかじめ計算しておく方が、システムをシンプルに設計できます。

非同期ジョブの設計原則

非同期ジョブを導入すると判断した場合に従うべき設計原則として、「非同期ジョブは、短時間(数秒以内)で終わるシンプルな処理にする」 という点が繰り返し述べられました。

Webサーバーと同様の理由で、ジョブを小さくシンプルに保つことには、主に以下のようなメリットがあります。

  • スループットの向上: 個々のジョブがすぐに完了する方が全体としての処理速度が向上する。
  • 失敗時の影響範囲の限定と復旧の容易さ: 長時間ジョブが途中で失敗した場合の復旧は困難だが、小さなジョブであれば単純なリトライで対応できるケースが多くなる。
  • 可観測性の向上: ジョブの実行状況(開始と終了)を把握しやすくなる。
  • デプロイへの影響の軽減: デプロイ時に実行中のジョブの完了を待つ必要性が低減される。

原則を破る必要がある場合

とはいえ、現実的には長時間にわたる処理が必要なケースも存在します 。 その場合は、「中断・再開ができるように作る」ことが重要になると説明されました。 そのための具体的な方法としては、以下のようなライブラリや機能があります。

  • shopify/job-iteration gem/Sidekiq Iteration: ActiveJobやSidekiqで、デプロイ時などに中断・再開が可能なジョブを記述できる。
  • Active Job Continuation: ジョブを複数のステップに分割し、中断された場合に最後の完了ステップから続行できる。(Rails 8.1から導入)
  • Sidekiq Batches: ジョブ自体は小さな粒度に保ちつつ、バッチ(ジョブの集まり)全体としての進捗管理や、全件完了時のコールバック処理を差し込める。(Sidekiq Proで提供)

これらのツールを活用することで、ジョブの実行を小さな単位に分割しつつ、全体としての処理の進捗管理や完了時のコールバックを実現できます。

非同期ジョブを設計する際の思考フロー

セッションの最後に、非同期ジョブを設計する際の思考フローが提示されました:

1. 本当に非同期ジョブが必要か?
   ↓ Yes
2. バッチ処理で代替できないか?
   ↓ No
3. 長時間ジョブを避ける設計ができないか?
   ↓ できない
4. 中断・再開を支援する仕組みの導入を検討

非同期ジョブは便利な機能ですが、その分システムの複雑性も増します。 他のセッションでも多数述べられていたように、まずは標準的な機能(Rails Way)で要件を満たせないかを考え、本当に必要になったときに複雑な仕組みを導入するといった姿勢が大事ですね。

Sidekiq Best Practiceを深掘る

セッションでは「非同期ジョブは短時間でシンプルに」という設計原則が繰り返し強調されました。 この原則を実際のコードレベルで実現するため、セッション後に改めてSidekiq公式のBest Practiceを確認してみました。

ここでは、セッション内容と特に関連の深い2つのプラクティスについて解説します。

パラメータは小さくシンプルにする

Sidekiq公式Wikiでは、ジョブのパラメータについて明確なルールを示しています。

ジョブに渡すパラメータは、JSON互換の型(string, integer, float, boolean, null, array, hash)でなければなりません。 これは、SidekiqがパラメータをJSON.dumpでシリアライズしてRedisに保存し、ワーカープロセスがJSON.loadでデシリアライズして使用するためです。 SymbolやDate、TimeなどのRuby固有のオブジェクトは、この変換の往復で正しく復元されません。

特に重要なのが、ActiveRecordオブジェクトについての警告です:

Don't pass ActiveRecord objects! Pass identifiers instead. 出典

公式が強調するのは、ジョブがエンキューされてから実行されるまでの間にデータが変更される可能性があるという点です。 セッションで示された「注文情報一括処理」の例を考えてみます。 10,000件の注文を「1件ごとの短時間ジョブ」に分割する設計では、各ジョブがエンキューされてから実際に実行されるまでに時間差が生じます。 この間に、注文がキャンセルされたり、ステータスが変わったりする可能性があります:

# Bad: オブジェクトをそのまま渡すと、エンキュー時点のデータで処理される
order = Order.find(123)
ProcessOrderJob.perform_async(order)

# Good: IDを渡して都度ロードすれば、実行時点の最新データを取得できる
ProcessOrderJob.perform_async(order.id)

class ProcessOrderJob
  include Sidekiq::Worker
  
  def perform(order_id)
    order = Order.find(order_id)  # 実行時点の最新データ
    # 処理...
  end
end

IDだけを渡すことで、常に最新の状態で処理でき、ジョブの見通しも良くなります。 これは、セッションで強調された「シンプルな処理」を実現するための実装指針です。

冪等性とトランザクションを意識する

Sidekiq公式Wikiでは、重要な前提として以下を明記しています:

Sidekiq will execute your job at least once, not exactly once. 出典

Sidekiqはジョブを「少なくとも1回」実行します(「ちょうど1回」ではありません)。 ネットワーク障害やプロセスのクラッシュなどにより、ジョブが複数回実行される可能性があります。 したがって、ジョブは何度実行されても最終的にシステムが同じ状態になる(冪等である)必要があります。

以下のように、冪等でない実装では、リトライのたびに問題が発生します:

# Bad: リトライする度に重複してカウントされる
class IncrementCounterJob
  include Sidekiq::Worker
  
  def perform(user_id)
    user = User.find(user_id)
    user.login_count += 1
    user.save!
  end
end

冪等な実装では、何度実行しても結果が同じになります:

# Good: 何度実行しても結果が同じ
class RecordLoginJob
  include Sidekiq::Worker
  
  def perform(user_id, login_at)
    # タイムスタンプをキーにして重複を防ぐ
    LoginHistory.find_or_create_by!(
      user_id: user_id,
      logged_in_at: login_at
    )
  end
end

公式Wikiでは、冪等性を実現する方法として「データベーストランザクションを使う」、「エラーに対して堅牢なコードを書く」という選択肢を示しています。 トランザクションを使う場合の例:

class ProcessOrderJob
  include Sidekiq::Job
  
  def perform(order_id)
    order = Order.find(order_id)
    
    ActiveRecord::Base.transaction do
      order.items.each do |item|
        item.product.decrement!(:stock, item.quantity)
      end
      order.update!(status: 'processing')
    end
  end
end

トランザクションでは、処理の途中で例外が発生した場合にすべての変更がロールバックされ、部分的なデータが残ることを防げます。 この原子性(Atomicity)により、在庫の減算と注文ステータスの更新が「両方成功」か「両方失敗」のどちらかになるため、エラーに対して堅牢なコードを書くことができます。


これらのプラクティスは、セッションで強調された「短時間でシンプルな処理」という原則を、実際のコードレベルで実現するための具体的な指針となります。公式のベストプラクティスに従うことで、より堅牢で保守性の高い非同期ジョブシステムを構築できます。

設計原則を実践してみる

ここからは、私が関わっているプロダクト、JobHouse で遭遇した実装ケースを例に、上記の設計原則をどのように適用できるかを考えてみます。

背景:顧客情報の外部CRM同期

タスク内容としては、顧客情報を外部CRMツールに定期的に反映・同期するというものでした。 なお、JobHouse では、非同期ジョブの実行基盤として Sidekiq Pro を利用しています。

  • 数千〜数万件の顧客をDBで管理
  • 前回実行からの差分(新規作成・更新された顧客レコード)を外部CRMツールに同期
  • 1回の実行で処理する対象は数十〜数百件程度
  • Sidekiq + sidekiq-cron 1で定期実行ジョブとして実装

改善前:長時間ジョブによる課題

改善前は、以下のような実装でした。

class SyncCustomerWorker < ApplicationWorker
  def perform
    # 前回実行時から現在までに更新された顧客を取得
    customers = Customer.where(updated_at: last_execution_time..Time.current)

    # 各顧客を順次処理
    customers.each do |customer|
      # 外部CRMツールに反映
      sync_with_crm(customer)
    rescue => e
      # エラーをログに記録するだけで、処理は続行
      Rails.logger.error(e)
    end
  end
end

このコードを見て、セッションで学んだ原則に照らし合わせると、以下のような課題があることに気づきます。

  1. 長時間ジョブ: 数十〜数百件の顧客処理に数分要し、その間Sidekiqのスレッドを占有
  2. 並行実行不可: 1つのジョブで全顧客を順次処理するため、直列実行しかできずスループットが低い
  3. エラーハンドリングが難しい: イテレーション構造のため、エラーが発生した場合の処理が複雑になり、結果的にエラーをキャッチして無視するという妥協的な実装になっている

改善:ジョブの分割

実装方法

セッションで学んだ「非同期ジョブは短時間でシンプルに」という設計原則に基づき、1つの長時間ジョブを2段階のジョブに分割しました:

  1. エンキュー用ジョブ:更新された顧客を検索し、各顧客の同期ジョブをエンキュー
  2. 実行用ジョブ:1顧客ごとの同期処理

エンキュー用ジョブ(各顧客の同期ジョブを登録):

class SyncCustomerBatchWorker < ApplicationWorker
  def perform
    # 更新された顧客のIDを取得
    customer_ids = Customer.where(updated_at: last_execution_time..Time.current).pluck(:id)
    # 各顧客ごとに同期ジョブをエンキュー
    customer_ids.each do |id|
        SyncCustomerWorker.perform_async(id)
    end
  end
end

実行用ジョブ(1人の顧客の同期を実行):

class SyncCustomerWorker < ApplicationWorker
  sidekiq_options retry: 3  # 失敗時は3回まで自動リトライ

  def perform(customer_id)
    # IDから顧客データを取得(実行時点の最新データ)
    customer = Customer.find(customer_id)
    # 外部CRMツールに反映
    sync_with_crm(customer)
  rescue => e
    Rails.logger.error(e)
    raise  # リトライのために例外を再送出
  end
end

改善の効果

この分割により、セッションで述べられていた「短時間でシンプルな処理」というメリットを、以下の形で実現できました。

スループット向上

ジョブの分割により、以下2つの観点でスループットが向上します:

  • 並列実行による高速化: 複数のワーカースレッドで同時に処理できるようになり、同じ数の顧客を処理する時間が短縮される。改善前は1スレッドで順次処理していたが、改善後は複数スレッドで並列実行できるため、処理完了までの時間が大幅に短縮される。
  • Sidekiq全体のスループット向上: 1ジョブあたりの実行時間が短いため、スレッドがすぐに解放される。これにより、この同期処理だけでなく、他の種類のジョブ(メール送信、画像処理など)も待たずに処理できるようになり、Sidekiq全体の処理効率が向上する。

エラーハンドリングの改善

ジョブ分割により1ジョブあたりの処理範囲が小さくなり、失敗の影響範囲が限定されます。

これにより以下が可能になります:

  • retry機能の活用: 失敗した顧客のみを再処理できるため、エラーが発生した時の対応が改善前(ループ内でエラーをキャッチして無視)と比べて明確になる。
  • 可観測性の向上: 各ジョブの実行状況を個別に把握でき、どの顧客で問題が発生したのかが明確になる。

Best Practiceの適用

実行用ジョブでは、パラメータとして顧客IDのみを渡すようにすることで、エンキューから実行までの間にデータが変更されても最新の情報を取得できるようにしています。

また、この同期処理は「指定されたIDの顧客の最新情報をCRMに反映する」という操作であり、何度実行しても結果が同じになるため、冪等性が担保されています。


「短時間でシンプルに」という設計原則に従うだけで、スループットからエラーハンドリングまで、当初想定していなかった複数の改善が同時に実現できました。長時間実行されているジョブがあれば、分割できないか検討してみると、思わぬ改善につながるかもしれません。

まとめ

本記事では、@morihirokさんのセッション内容の振り返りと、実際のプロダクトでの実践例を紹介しました。

Webサーバーのリクエスト処理と対比した非同期ジョブの必要性の説明や、非同期ジョブを導入したことによる設計のトレードオフの説明などにより、全体を通して非常に分かりやすいセッション内容でした。

また、このセッションのみならず、カンファレンス全体を通じて、「設計に正解はない。あるのはトレードオフ」という考え方が繰り返し語られていました。 今回のセッションで示されたように、非同期ジョブと言った便利なツールをただ導入するのではなく、その功罪両面を理解し、状況に応じて適切な設計判断ができる力が求められているのだと改めて感じました。

最後になりますが、本記事を執筆するきっかけとなった @morihirokさん 、Kaigi on Rails 2025 運営の皆様にこの場を借りて感謝を申し上げます。


Techouseでは、社会課題の解決に一緒に取り組むエンジニアを募集しております。 ご応募お待ちしております。

jp.techouse.com


  1. sidekiq-cron は、Sidekiqジョブをcronによりスケジュール実行するgemです。