サイトアイコン IT & ライフハックブログ|学びと実践のためのアイデア集

Laravelのスケジューラとキューで定期処理を安定運用する方法:重複防止、監視、進捗通知

php elephant sticker

Photo by RealToughCandy.com on Pexels.com

Laravelで定期処理を安定して動かすには、実行時刻を決めるだけでは足りません。
処理の重複を防ぎ、失敗を検知し、安全に再実行できるところまで設計する必要があります。

この記事では、日次売上レポートを例に、スケジューラとキューの役割分担、冪等性、再試行、監視、障害対応、アクセシブルな進捗通知を実装順に整理します。
既存案件で app/Console/Kernel.php を使っている場合にも、同じ設計原則を適用できます。

最初に押さえる四つの用語

スケジューラ
「毎日3時」などの実行条件をアプリケーション側で管理し、条件に合うコマンドやジョブを起動する仕組みです。
キュー
すぐに終わらない仕事を待ち行列に入れ、Webリクエストとは別のワーカーに処理させる仕組みです。
ジョブ
キューで処理する仕事の単位です。
対象日や対象ファイルなど、実行に必要な情報を持たせます。
冪等性
同じ処理が複数回実行されても、売上の二重計上や通知の重複といった不整合を起こさない性質です。

役割は「スケジューラが起動を判断し、コマンドが仕事を投入し、ワーカーがジョブを実行する」と捉えると見通しがよくなります。

スケジューラとキューを分ける理由

スケジューラから重い集計を直接実行すると、処理時間の増加が次回の起動と衝突しやすくなります。
外部APIの一時障害が起きたときも、再試行や処理量の調整を個別に組み込みにくくなります。

そこで、スケジューラ側は「対象日のジョブをキューへ入れる」ところまでに絞ります。
集計、ファイル出力、通知はジョブ側へ分けると、次の責任が明確になります。

担当 主な責任 確認する失敗
スケジューラ 時刻と実行条件の判定 起動されない、同時に起動する
コマンド 対象の決定とジョブ投入 不正な対象を投入する、投入できない
キューワーカー ジョブの実行と再試行 滞留する、タイムアウトする、失敗する
業務処理 集計、保存、通知 二重更新、部分更新、通知漏れ

スケジュールをアプリケーションに定義する

サーバー側では、Laravelの schedule:run を毎分呼び出します。
プロジェクトのディレクトリへ移動してから実行する形にすると、相対パスに依存する処理も追いやすくなります。

* * * * * cd /path-to-your-project && php artisan schedule:run >> /dev/null 2>&1

近年のLaravelアプリケーションでは、スケジュールを routes/console.php に定義できます。
既存案件が app/Console/Kernel.php の schedule() を使っている場合は、無理に移動せず、利用中のLaravelバージョンと構成に合わせます。

<?php

use Illuminate\Support\Facades\Schedule;

Schedule::command('report:daily-sales')
    ->dailyAt('03:00')
    ->timezone('Asia/Tokyo')
    ->withoutOverlapping()
    ->onOneServer()
    ->emailOutputOnFailure('ops@example.com');

timezone() は時刻を解釈する地域を明示します。
ただし、夏時間を採用する地域では、時刻の重複や欠落への備えが別に必要です。

withoutOverlapping() は、前回のスケジュール処理が残っている間の重複起動をキャッシュロックで抑えます。
引数はロックの有効時間なので、通常の処理時間だけでなく、異常時に処理が残る時間も踏まえて決めます。

onOneServer() は、複数サーバーでスケジューラが動く構成の単一実行に使います。
各サーバーが同じ対応済みキャッシュを参照していなければ、この制御は成立しません。

コマンドを薄く保ち、ジョブへ処理を渡す

コマンドでは対象日を決め、ジョブを投入します。
対象日をオプションで受け取れるようにしておくと、障害復旧時に同じ入口から再実行できます。

public function handle(): int
{
    $date = $this->option('date') ?? now()->subDay()->toDateString();

    BuildSalesReport::dispatch($date)->onQueue('reports');

    $this->info("Dispatched BuildSalesReport for {$date}");

    return self::SUCCESS;
}

ここでコマンドが正常終了しても、レポート生成が完了したとは限りません。
コマンドが確認できるのは、原則としてジョブを投入できたところまでです。

そのため、emailOutputOnFailure() だけでは、投入後に発生したキュージョブの失敗を拾えません。
スケジューラの失敗通知と、キューの失敗通知を別々に用意します。

ジョブに再試行と失敗処理を持たせる

重い処理や一時的な外部障害が想定される処理は、ShouldQueue を実装したジョブへ移します。
再試行回数とタイムアウトは、処理時間の計測値と業務上の締め切りから決めます。

namespace App\Jobs;

use App\Services\Reports\SalesReportService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\RateLimited;
use Illuminate\Queue\SerializesModels;

class BuildSalesReport implements ShouldQueue, ShouldBeUnique
{
    use InteractsWithQueue, Queueable, SerializesModels;

    public int $tries = 3;
    public int $timeout = 3600;

    public function __construct(public string $date) {}

    public function uniqueId(): string
    {
        return $this->date;
    }

    public function middleware(): array
    {
        return [new RateLimited('reports')];
    }

    public function handle(SalesReportService $service): void
    {
        $service->build($this->date);
        $service->notifyCompleted($this->date);
    }

    public function failed(\Throwable $exception): void
    {
        // ジョブID、対象日、再実行手順を運用窓口へ通知する。
        // 資格情報や個人情報は通知とログに含めない。
    }
}

RateLimited('reports') の名前に対応するレート制限は、アプリケーション側で定義しておきます。
制限によりジョブがキューへ戻された場合も試行回数を消費するため、$tries を小さくしすぎると、本処理を始める前に失敗扱いになることがあります。

API呼び出し量を制御する考え方は、サイト内の「Laravelで利用量制限を設計する方法」でも整理しています。

重複防止を三層で設計する

「ロックを付ければ冪等になる」を意味しません。
ロックには期限があり、ネットワーク断やワーカー停止の境界では同じ仕事が再実行される可能性が残るからです。

重複防止は、次の三層を別々に設計します。

  1. 起動時の重複防止:withoutOverlapping() と onOneServer() で、同じ予定処理の同時起動を抑える。
  2. 投入時の重複防止:ShouldBeUnique と対象日などの一意キーで、同じジョブの重複投入を抑える。
  3. 更新時の冪等性:データベースの一意制約、アップサート、決定的な保存先を使い、再実行されても業務データを壊さない。

ShouldBeUnique もキャッシュロックを使うため、複数のワーカーやサーバーから同じキューへ投入する構成では、共有キャッシュを使います。

たとえば、2025-08-31 の売上レポートを常に同じ論理キーで保存すれば、再実行時に新しいレポートを無制限に増やさずに済みます。
ただし、「既にあるかを調べてから作る」だけでは、二つの処理が同時に確認を通る競合が残るため、保存先の一意制約と組み合わせます。

通知にも冪等性が必要です。
レポートの保存に成功した後で通知に失敗した場合、再実行で同じ完了メールを何通も送らないよう、通知済み状態か通知用の一意キーを管理します。

タイムアウトと再試行の整合を取る

ジョブの $timeout、ワーカーの --timeout、キュー接続の retry_after は別の設定です。
値の関係を誤ると、停止していないジョブが再びキューへ戻り、同時に処理される可能性があります。

Laravel公式ドキュメントでは、ワーカーのタイムアウトを retry_after より数秒短くするよう案内しています。
ジョブ固有のタイムアウトを使う場合も、ワーカー設定とキュー接続設定を一緒に確認します。

失敗の種類 例 基本方針
一時障害 通信断、外部APIの一時的な制限 待機時間を設けて再試行する
入力不正 必須列の欠落、形式違反 対象を隔離し、理由を記録する
恒久障害 権限不足、廃止された接続先 再試行を重ねず、運用担当へ判断を渡す

指数バックオフは、一時障害の直後に同じ要求を集中させないため、再試行間隔を段階的に長くする方法です。
入力不正や権限不足に同じ再試行を適用しても解消しないため、失敗の種類を先に分けます。

大量データを分割して処理する

大量データを一度にメモリへ載せると、入力件数の増加がそのままメモリ不足につながります。
データをチャンクで読み、成果物へ順次書き込む構成にします。

Order::whereDate('created_at', $this->date)
    ->orderBy('id')
    ->chunk(5_000, function ($orders) use ($writer) {
        foreach ($orders as $order) {
            $writer->appendRow($order);
        }
    });

分割単位は、日、店舗、カテゴリなど、再実行時に対象を特定できる境界にします。
分割したジョブを並列実行する場合も、最後のファイル確定や完了通知は、必要な子ジョブがそろった後に一度だけ行います。

キュー名を reports、imports、notifications のように分けると、処理量や優先度を調整しやすくなります。
ただし、キューを増やすほど監視対象も増えるため、運用担当が状態を区別できる範囲にとどめます。

メンテナンス時間とタイムゾーンを業務条件から決める

「夜間なら安全」とは限りません。
取引先APIの保守時間、データ確定時刻、利用者の地域、月末処理との競合を確認して実行時刻を決めます。

Schedule::command('notify:daily')
    ->dailyAt('08:00')
    ->timezone('Asia/Tokyo')
    ->skip(function () {
        return today('Asia/Tokyo')->isWeekend();
    });

休日判定が祝日や会社独自の休業日まで必要なら、isWeekend() だけでは足りません。
業務カレンダーを一つの判定元にまとめ、スケジュールと通知で同じ条件を使います。

監視では「動いた」と「終わった」を分ける

監視項目は、スケジューラ、キュー、業務結果の三段階に分けます。
一つの「成功」だけで表すと、ジョブを投入しただけなのか、成果物まで完成したのかを判別できません。

ログにはジョブID、対象日、処理単位を共通の検索キーとして残します。
例外メッセージだけでは、どのレポートが失敗したのかを運用担当が追えません。

Redisキューを使う構成では、Horizonで処理量、実行時間、失敗などを確認できます。
Horizonを含むメールキューの運用例は「Laravelでメール送信を非同期化する方法」で確認できます。

HorizonはRedisキュー向けです。
別のキューバックエンドを使う場合は、ワーカー監視、失敗ジョブ、待機時間を別の監視基盤へ送ります。

障害時の復旧手順を先に決める

データベースのトランザクションは、同じデータベース内の途中更新を戻すために使えます。
外部APIへの送信やファイル出力まで一緒に巻き戻せるわけではありません。

外部へ影響が出る処理では、処理単位ごとに状態を記録し、再開点と補償手順を決めます。
たとえば、レポートを一時ファイルへ出力し、検証後に確定パスへ移すと、途中のファイルを完成品として配布する事故を抑えられます。

運用手順には、次の情報を残します。

進捗通知を読み上げでも理解できる形にする

進捗画面は、色やアイコンだけで状態を伝えません。
「データ抽出:進行中」のように、工程名と状態を文字で組み合わせます。

Webアクセシビリティの基本を押さえたうえで、進捗の更新を支援技術へ伝えます。

<p id='report-status' role='status'>
  本日のレポート生成は60%完了(3/5ステップ)
</p>

<ol>
  <li><span aria-hidden='true'>⏳</span> データ抽出:進行中</li>
  <li><span aria-hidden='true'>✓</span> 集計:完了</li>
  <li>ファイル出力:待機中</li>
</ol>

role='status' の領域は、利用者の操作を中断しにくい形で更新を通知できます。
状態が変わっても、この領域へキーボードフォーカスを強制移動しません。

ポーリングのたびに同じ文言を設定すると、不要な再通知につながることがあります。
画面の文言は、受け取った状態が変わったときだけ更新します。

async function poll() {
  const response = await fetch('/api/report/progress?date=2025-08-31', {
    headers: { Accept: 'application/json' },
  });

  if (response.ok) {
    const { percent, stepText } = await response.json();
    const status = document.getElementById('report-status');
    const nextText = `進捗 ${percent}%:${stepText}`;

    if (status.textContent !== nextText) {
      status.textContent = nextText;
    }
  }

  setTimeout(poll, 15000);
}

poll();

完了メールと失敗メールは、HTML版に加えてプレーンテキスト版も用意します。
件名と本文には、結果、対象日、利用者が次に取れる行動を文字で示します。

テストで確認する境界

正常系だけでは、定期処理の運用品質を判断できません。
ジョブ投入、再実行、途中失敗、通知失敗を別々に検証します。

public function test_report_command_dispatches_job(): void
{
    Queue::fake();

    $this->artisan('report:daily-sales --date=2025-08-31')
        ->assertExitCode(0);

    Queue::assertPushed(BuildSalesReport::class, function ($job) {
        return $job->date === '2025-08-31';
    });
}

本番投入前のチェックリスト

定期処理を安定運用へつなげる

Laravelのスケジューラは実行条件を管理し、キューは時間のかかる仕事をワーカーへ渡します。
この分担に、三層の重複対策、整合の取れたタイムアウト、段階別の監視を組み合わせると、失敗箇所と復旧手順を特定しやすくなります。

実装の完了をゴールにせず、「再実行しても壊れない」「止まった場所が分かる」「利用者に状態が伝わる」を受け入れ条件に含めてください。

参考資料

この記事に関連する株式会社greedenの取り組み

定期処理は、実装だけでなく監視や障害時の復旧手順まで設計して初めて業務を支えます。株式会社greedenは、要件定義から開発、テスト、リリース後の保守運用まで一気通貫でWebシステム開発を支援します。

モバイルバージョンを終了