バックグラウンドジョブの設計|リトライ・冪等性・デッドレターキューで壊れない非同期処理

kento_morota 13分で読めます
社内のAI・IT活用、技術がわかる相手に無料相談。 相談 →
目次

「ジョブキューを入れたのに、失敗したジョブがどこに行ったのかわからない」「リトライさせたら同じメールが3通届いた」「ワーカーが止まっていたことに翌朝まで気づかなかった」――バックグラウンドジョブは導入するだけなら簡単ですが、「壊れない」ように設計するには押さえるべき原則がいくつかあります。

この記事では、なぜメール送信・画像処理・外部API呼び出しをジョブ化するのかという前提から始めて、指数バックオフによるリトライ、冪等キーで二重実行を防ぐ方法、デッドレターキューと再処理の運用、ワーカーの監視までを扱います。BullMQ(Node.js)・Sidekiq(Ruby)・Celery(Python)はライブラリこそ違えど設計の考え方は共通なので、どれを使っていても応用できる内容です。ブローカー(RabbitMQ・SQS・Kafkaなど)の選び方はメッセージキュー入門に譲り、本記事はワーカー側の設計に集中します。

なぜメール送信・画像処理・外部API呼び出しをジョブにするのか

「失敗しうる・時間がかかる・後でよい」処理が対象

ジョブ化すべき処理には共通の性質があります。第一に、相手が外部にあって失敗しうること。メール配信サービスや決済API、SaaSのWebhookは、こちらの都合と無関係に遅延したり一時的にエラーを返したりします。第二に、時間がかかること。画像のリサイズやPDF生成、CSVの一括取り込みは数秒〜数分かかり、HTTPリクエストの中で待たせるべきではありません。第三に、ユーザーの操作と同期している必要がないこと。注文確認メールが3秒後に届いても誰も困りません。

この3つを満たす処理をリクエストの中に置いたままにすると、外部サービスの不調がそのまま自社の障害になり、レスポンス時間はユーザーの体感を悪化させます。ジョブ化は、こうした処理を「後で、別のプロセスで、失敗したらやり直す」形に変える設計です。

ジョブに渡すのは「IDだけ」にする

ジョブのペイロード(引数)には、注文オブジェクト全体ではなく注文IDだけを入れるのが原則です。ジョブが実行されるのは登録から数秒後かもしれませんし、リトライで1時間後かもしれません。その間にデータが更新されていたら、古いスナップショットで処理してしまいます。ワーカーは実行時にIDでDBから最新の状態を読み直すべきです。ペイロードが小さければ、キューの容量やシリアライズの問題も避けられます。

リトライ設計:指数バックオフと「リトライしてよい失敗」の見極め

なぜ即時リトライは逆効果なのか

失敗した瞬間に同じリクエストを投げ直しても、相手がまだ過負荷なら同じように失敗し、しかも相手の負荷をさらに増やします。ワーカーが10台あれば、10台が一斉に失敗して一斉にリトライし、相手をさらに追い詰める「リトライストーム」になります。

そこで、リトライ間隔を回数ごとに倍々に伸ばす指数バックオフを使います。1回目は1秒後、2回目は2秒後、3回目は4秒後…と伸ばし、さらに各間隔にランダムなずれ(ジッター)を加えて、複数ワーカーのリトライが同じ瞬間に重ならないようにします。ライブラリのデフォルトも概ねこの方式で、Sidekiqは失敗回数の4乗に比例した間隔で最大25回、BullMQはbackoff: { type: "exponential", delay: 1000 }のように指定します。HTTPクライアント側のタイムアウトとリトライの詳細はタイムアウト・リトライ・サーキットブレーカーを参照してください。

リトライしてはいけない失敗を分ける

すべての失敗をリトライするのは危険です。失敗は大きく2種類に分けられます。

失敗の種類対応
一時的(transient)タイムアウト、接続拒否、HTTP 429/503、DBのデッドロックバックオフ付きでリトライ
恒久的(permanent)HTTP 400/404/422、バリデーションエラー、対象レコードが削除済みリトライせず即座に失敗扱いにしてデッドレターへ

例えば「宛先メールアドレスの形式が不正」というエラーは100回リトライしても成功しません。ワーカーのコードでは、恒久的な失敗を表す専用の例外クラスを投げてリトライ対象から外すのが定石です。BullMQならUnrecoverableError、Celeryならautoretry_forに含めない例外がこれに当たります。

import { Worker, UnrecoverableError } from "bullmq";

const worker = new Worker("mail", async (job) => {
  const order = await db.orders.findById(job.data.orderId);
  if (!order) {
    // 対象が消えている:何度やっても無駄なのでリトライしない
    throw new UnrecoverableError(`order ${job.data.orderId} not found`);
  }
  await mailer.sendOrderConfirmation(order); // タイムアウト等は通常の例外→リトライ
}, {
  connection,
  concurrency: 5,
});

// 登録側
await mailQueue.add("order_confirmation", { orderId }, {
  attempts: 5,
  backoff: { type: "exponential", delay: 2000 }, // 2s, 4s, 8s, 16s...
  removeOnComplete: 1000,
});

冪等性:同じジョブが2回動いても壊れないようにする

二重実行は「起こるかもしれない」ではなく「起こる」

ジョブキューの多くはat-least-once(少なくとも1回)配信です。ワーカーが処理を終えた直後、完了通知を送る前にプロセスが落ちれば、同じジョブがもう一度実行されます。デプロイでワーカーを再起動するたびに、この状況は現実に発生します。「メール送信」「決済APIの呼び出し」「ポイント付与」のように外部に副作用がある処理は、2回動くと実害が出ます。

対策は冪等性(同じ操作を何度実行しても結果が1回分と同じになる性質)をワーカーに持たせることです。具体的には、「この処理はもう実行済みか」を判定できる冪等キーを使います。

冪等キーの実装パターン

もっとも確実なのは、DBのユニーク制約を使う方法です。「注文123の確認メール」という処理に対応するキー(例:order_confirmation:123)を、処理の直前に専用テーブルへ挿入します。既に存在すればユニーク制約違反になるので、処理をスキップします。

CREATE TABLE idempotency_keys (
  key        text PRIMARY KEY,
  created_at timestamptz NOT NULL DEFAULT now()
);
async function sendOnce(key, fn) {
  try {
    await db.query("INSERT INTO idempotency_keys (key) VALUES ($1)", [key]);
  } catch (e) {
    if (e.code === "23505") return "skipped"; // unique_violation:実行済み
    throw e;
  }
  await fn();
  return "done";
}

await sendOnce(`order_confirmation:${orderId}`, () => mailer.send(...));

外部APIが冪等キーをサポートしている場合(StripeのIdempotency-Keyヘッダーなど)は、それも併用します。API側で重複を弾いてくれるため、「キーを記録した直後にワーカーが落ちて、実際には送信されていない」という穴も埋められます。逆に、外部APIが冪等キーに対応していない場合は、キーの記録と送信の間にわずかな窓が残ることを理解したうえで、「重複より欠落のほうがまし」か「欠落より重複のほうがまし」かを業務的に判断してどちら側に倒すか決めます。

デッドレターキューと再処理の運用

リトライを使い切ったジョブの行き先

デッドレターキュー(DLQ)は、規定回数のリトライに失敗したジョブや、恒久的エラーで即座に失敗したジョブを隔離しておく場所です。これがないと、失敗したジョブは黙って消えるか、キューの先頭に居座って後続を止め続けるかのどちらかになります。SidekiqやBullMQでは「failed」「dead」といった状態として保持され、SQSではmaxReceiveCountを超えたメッセージが指定した別のキューに移されます。

DLQの重要な役割は「失敗の証拠を残す」ことです。ペイロード・失敗回数・最後のエラーメッセージ・スタックトレースが残っていれば、原因を調べて修正した後に再処理できます。

再処理の手順

DLQに溜まったジョブは、次の手順で扱います。

  1. DLQの件数をアラートで検知する(1件でも入ったら通知、が基本)。
  2. エラーメッセージで分類する。「外部APIの障害」なら原因が解消してから一括再投入、「バグ」ならコードを修正してから再投入、「データ不正」なら個別に対応してジョブは破棄。
  3. 再投入前に、そのジョブが冪等であることを確認する。冪等でないジョブの一括再投入は二次災害の原因になる。
  4. 再投入後、DLQに戻ってこないことを確認する。
# SQSのDLQから元のキューへ再投入する例(AWS CLI)
aws sqs start-message-move-task \
  --source-arn arn:aws:sqs:ap-northeast-1:123456789012:mail-dlq \
  --destination-arn arn:aws:sqs:ap-northeast-1:123456789012:mail \
  --max-number-of-messages-per-second 10

DLQに残ったジョブを放置せず、定期的に空にする運用ルール(例:週次で棚卸し)を決めておくと、本当に問題が起きたときにノイズに埋もれません。

ワーカーの監視:止まっていることに気づく仕組み

見るべき4つの指標

ワーカーはユーザーから見えない場所で動くため、止まっても画面上は何も起きません。次の指標を必ず監視対象に入れてください。

  • キューの滞留数:処理待ちジョブの数。増え続けていればワーカーが足りないか止まっている。
  • 最古のジョブの待ち時間:滞留数よりも「一番古いジョブが何分待っているか」の方が異常を検知しやすい。
  • 失敗率とDLQ件数:急増は外部サービスの障害かデプロイ起因のバグ。
  • ワーカープロセスの生存:ハートビートを定期的に記録し、一定時間更新がなければ通知する。

これらのメトリクスをどこに送って可視化するかは監視とオブザーバビリティ入門で解説しています。BullMQならBull Board、SidekiqならWeb UI、CeleryならFlowerといった管理画面が標準的に用意されているので、まずはそれを立ち上げるところから始めるとよいでしょう。

グレースフルシャットダウンを忘れない

デプロイのたびにワーカーを強制終了していると、処理中のジョブが中断され、at-least-onceの再実行と冪等性の議論がそのまま現実になります。ワーカーはSIGTERMを受けたら新規ジョブの取得を止め、処理中のジョブを完了してから終了するように設定します。シグナルの扱いはグレースフルシャットダウン入門を参照してください。

トラブル事例:夜間バッチのジョブが翌朝まで一切動かなかった

症状

毎晩23時に登録される請求書生成ジョブが、ある朝確認すると1件も処理されていませんでした。キューには数千件が滞留しており、ワーカーのプロセスは生きていました。

原因

先頭のジョブが、外部の帳票APIの応答待ちでハングしていました。HTTPクライアントにタイムアウトを設定しておらず、相手側がコネクションを保持したまま応答しないケースで無限に待ち続けていたのです。ワーカーの並列度は1に設定されていたため、その1件が全体を止めていました。プロセスは生きているので死活監視には引っかからず、キューの滞留数もアラート対象にしていなかったため、発見が翌朝になりました。

対処

  1. HTTPクライアントに接続10秒・読み取り30秒のタイムアウトを設定し、超過時は例外としてリトライ対象にした。
  2. ジョブ自体にも実行時間の上限(BullMQなら処理内でAbortSignal.timeout、Celeryならtime_limit)を設け、上限を超えたジョブは失敗扱いにして次に進むようにした。
  3. 「最古のジョブの待ち時間が15分を超えたら通知」というアラートを追加した。
# Celeryの例:ソフト/ハードの実行時間上限とリトライ設定
@app.task(
    bind=True,
    autoretry_for=(requests.Timeout, requests.ConnectionError),
    retry_backoff=True,        # 指数バックオフ
    retry_backoff_max=600,     # 最大10分
    retry_jitter=True,
    max_retries=5,
    soft_time_limit=60,
    time_limit=90,
)
def generate_invoice(self, invoice_id):
    ...

まとめ

壊れないバックグラウンドジョブの設計は、次の原則に集約されます。ペイロードにはIDだけを渡す。失敗は一時的か恒久的かを分け、一時的な失敗だけを指数バックオフ+ジッターでリトライする。すべてのジョブは二重実行される前提で、冪等キーによって副作用を1回に抑える。リトライを使い切ったジョブはデッドレターキューに隔離し、原因別に再処理する。そして、滞留数・最古ジョブの待ち時間・DLQ件数・ワーカーの生存を監視し、止まったことにすぐ気づけるようにする。

BullMQ・Sidekiq・Celeryはいずれもこれらを実現する機能を備えていますが、デフォルトのまま使うのではなく、自分のジョブの性質に合わせてリトライ回数・タイムアウト・冪等性を明示的に設計することが、安定運用への近道です。

非同期処理の設計見直しや、失敗ジョブが溜まって手に負えなくなっている状況の立て直しは、Harmonic Societyのシステム開発・インフラ支援にご相談ください。

#バックグラウンドジョブ#冪等性#リトライ#非同期処理

Harmonic Society

この記事の内容、自社の業務でも活かせそうですか?

ローカルLLM・AI・クラウドなどの技術導入を、要件整理からPoC・社内展開まで代表エンジニアが伴走します。オンライン対応・全国OK。まずは30分の無料相談から。売り込みはしません。

共有:
無料メルマガ

週1回、最新の技術記事をお届け

AI・クラウド・開発の最新記事を毎週月曜にメールでお届けします。登録は無料、いつでも解除できます。

プライバシーポリシーに基づき管理します

関連記事

Related / 9 articles

  1. プログラミング

    DDoS攻撃の仕組みと対策入門|レイヤー別の防御とCDN・クラウドの活用

    DDoS攻撃をボリューム型・プロトコル型・アプリ層に分けて仕組みを解説し、自前サーバーで防げない理由、CloudflareやAWS Shieldの標準防御、オリジンIPの隠し方、レートリミットとBot対策、攻撃を受けたときの初動、費用が跳ね上がるDenial of Walletへの備えまでわかります。

  2. プログラミング

    WAFとは?仕組み・導入パターン・誤検知対策|Webアプリを攻撃から守る実践ガイド

    WAFがファイアウォールやIDSと何が違うのか、シグネチャとマネージドルールの仕組み、Cloudflare WAF・AWS WAF・ModSecurityの比較、フォーム送信がブロックされる誤検知の調査と例外設定、ログ監視、WAFが代替できないことまで実践的に解説します。

  3. プログラミング

    セキュリティヘッダー入門|CSP・HSTS・X-Frame-Optionsの設定と効果を実践解説

    CSP・HSTS・X-Frame-Options・X-Content-Type-Optionsなど主要セキュリティヘッダーが防ぐ攻撃と、CSPのReport-Onlyからの段階導入、nonce/hash、HSTS preloadの不可逆リスク、Nginx・Next.js・Astroでの設定例、確認方法を解説します。

  4. プログラミング

    クラウドの通信費(Egress)入門|データ転送量課金の仕組みと転送コストを抑える設計

    クラウドの「受信無料・送信有料」の原則、AZ間・リージョン間・インターネット向けの単価差、NATゲートウェイ処理料の罠、CDNで転送量を減らす方法、バックアップやログ転送の見落とし、請求書で転送料を特定する手順を解説。想定外の請求を防げます。

  5. プログラミング

    秘密情報をGitに入れない仕組み|.gitignore・git-secrets・履歴から漏れた鍵の削除

    APIキーや.envをGitにコミットしてしまう典型経路と、.gitignore・.env.exampleの運用、pre-commitでのgitleaks検知、GitHub secret scanningの活用、漏れた鍵の無効化と履歴書き換え(git filter-repo)の手順を解説。仕組みで再発を防げます。

  6. プログラミング

    開発・ステージング・本番環境の分離設計|環境差分をなくす構成とアクセス制御

    開発・ステージング・本番それぞれの目的と、構成をコードで揃える方法、環境別の設定注入、本番データを使わないテストデータ戦略、ステージングの保護(Basic認証・IP制限・noindex)、コストを抑える運用までを解説。環境差分による本番障害を防げます。

  7. プログラミング

    ngrok・Cloudflare Tunnelでローカルを公開|Webhook開発とデモ環境の作り方

    NAT内のローカル環境にStripeやLINEのWebhookを届けるトンネリングの仕組みを解説。ngrok・Cloudflare Tunnel・localtunnelの比較、固定ドメインと認証、リクエスト検査、公開時のセキュリティ、自宅サーバー公開への応用までわかります。

  8. プログラミング

    ローカル開発環境のHTTPS化|mkcert・hostsファイル・自己署名証明書の正しい使い方

    ローカル開発をHTTPS前提にすべき理由(Secure Cookie・Service Worker・OAuth)と、mkcertでローカルCAを作りhostsで独自ドメインを割り当ててVite・Next.js・Dockerで使う手順を解説。証明書警告を無視する癖の危険も理解できます。

  9. プログラミング

    localhost・0.0.0.0・127.0.0.1の違い|ポートとUnixソケットを理解して「つながらない」を解決

    localhost・127.0.0.1・0.0.0.0の意味の違い、Dockerで外から接続できない原因、host.docker.internal、ポート競合の調べ方、Unixソケットの利点と権限、1024未満ポートの制約を解説。「つながらない」を仕組みから解決できます。

Harmonic Society

「読んで終わり」にせず、自社の業務で試してみませんか?

AI・ローカルLLM・クラウドの導入を、要件整理からPoC・社内展開まで代表エンジニアが伴走します。オンライン対応・全国OK・売り込みなし。

無料・30分・オンラインOK|1営業日以内に返信します