Laravel『Queue Timeout』の原因と対処法

Laravel『Queue Timeout』の原因と対処法

Laravelの「Queue Timeout」は、キューワーカーがジョブ実行中にタイムアウト上限へ到達し、ワーカー側またはプロセスマネージャ(Supervisor/systemd)側によりジョブが強制終了される状態を指す。典型的には「TimeoutExceededException」「ジョブが途中で落ちて再試行が繰り返される」「失敗ジョブに入り続ける」「同じ処理が二重実行される」などの形で表面化する。原因は、処理時間そのものが長い(外部API/重いDB/大容量ファイル)か、タイムアウト設定(queue worker / supervisor / horizon / PHP)が噛み合っていないか、メモリ枯渇・デッドロック等で“進まないまま時間だけ経つ”かのどれかに収束する。

症状と発生条件(典型例)

よくあるメッセージ例。

Illuminate\Queue\TimeoutExceededException
The job has been attempted too many times.
Process exceeded the timeout of 60 seconds.
Killed

発生条件の典型:
・キューで画像変換、PDF生成、動画処理、CSV取込など重い処理を実行
・外部API呼び出しでレスポンス待ちが長い/タイムアウトが無い
・大量のDB更新/集計を1ジョブに詰め込んでいる
・デッドロック/ロック待ちで処理が止まる
・Supervisorのstopwaitsecsよりジョブが長く、再起動で途中killされる
・Horizonでtimeout設定が短い
・PHPの max_execution_time / FPM request_terminate_timeout が短い(環境による)

まず切り分け:どのタイムアウトで落ちているか(層の特定)

タイムアウトは複数箇所に存在し、最短のものが勝つ。主な層:
・Laravelワーカー(php artisan queue:work の –timeout)
・Horizon(config/horizon.php の timeout)
・Supervisor/systemd(stopwaitsecs / TimeoutStopSec)
・PHP(CLIなら基本無制限に近いが、環境やラッパにより制限あり)
・外部API/HTTPクライアント(接続・応答タイムアウト)
確認の第一歩:failed_jobs の exception を読み、どの層の文言か見分ける。

原因1:ジョブ処理が単純に長すぎる(設計の問題)

発生条件:
・1ジョブで全件処理(数万レコード更新、全画像生成、全メール送信など)
・ループ中に外部APIを直列で叩いている
対処:ジョブを分割し、1ジョブあたりの上限時間を短くする。
例:CSVを行単位/チャンク単位に分割してディスパッチする。

use Illuminate\Support\Facades\Bus;

$jobs = collect($rows)->chunk(500)->map(function ($chunk) {
    return new ImportChunkJob($chunk->all());
});

Bus::batch($jobs)->dispatch();

「分割 + 冪等(同じジョブが複数回走っても壊れない)」がタイムアウト対策の基本形。

原因2:ワーカーの –timeout が短い(queue:work)

発生条件:
・デフォルト/運用値が60秒など短く、実処理が超える
対処:queue:work の timeout を延ばす。

php artisan queue:work --timeout=300 --tries=3

ジョブ側で timeout を指定する運用もある(ジョブごとに上限を変える)。

class HeavyJob implements ShouldQueue
{
    public $timeout = 300;
    public $tries = 3;

    public function handle()
    {
        // ...
    }
}

注意点:timeoutを上げるだけだと、詰まり(ロック待ち、無限待ち)も延命してしまうため、処理分割とセットにする。

原因3:Supervisor/systemd の停止猶予が短く、再起動でkillされる

発生条件:
・デプロイやプロセス再起動(queue:restart / supervisor restart)時に、stopwaitsecs が短い
・ジョブが実行中なのにワーカーが強制停止される
対処:Supervisorの stopwaitsecs をジョブ最大実行時間より長くする。例(概念)。

; supervisor設定例(概念)
stopwaitsecs=600

systemdなら TimeoutStopSec を確認する(概念)。

# systemd(概念)
TimeoutStopSec=600

「Laravel側timeoutは長いのに、プロセスマネージャ側が先にkillする」ケースが多い。

原因4:Horizon の timeout が短い(Redisキュー)

発生条件:
・Horizon運用で、config/horizon.php の timeout が短い
・長いジョブが監視ポリシーで落とされる
対処:Horizonの supervisor 設定で timeout を調整する(概念)。

// config/horizon.php(概念)
'environments' => [
  'production' => [
    'supervisor-1' => [
      'connection' => 'redis',
      'queue' => ['default'],
      'timeout' => 300,
      'tries' => 3,
    ],
  ],
],

Horizonはワーカー数やバランスも含めて詰まりやすいので、重いジョブ専用キューを分けるのが有効。

原因5:外部API/HTTP待ちが長い、もしくは無限待ち

発生条件:
・外部サービスが遅い/落ちている
・HTTPクライアントに timeout 設定がない
対処:通信は必ず接続・応答のタイムアウトを設け、失敗時はリトライ戦略を分ける。

use Illuminate\Support\Facades\Http;

$response = Http::timeout(10)
    ->connectTimeout(3)
    ->retry(2, 200)
    ->get($url);

$response->throw();

外部API待ちを1ジョブで大量に直列処理すると簡単にtimeoutへ到達するため、分割・並列化・バルクAPI化の検討も必要。

原因6:DBロック/デッドロック/長時間トランザクションで進まない

発生条件:
・SELECT … FOR UPDATE を長時間保持
・大きいトランザクションで更新し続ける
・他処理と競合しロック待ちが発生
対処:
・トランザクション範囲を最小化
・更新対象をチャンク化
・ロック順序を揃える
・DB側のロック待ちタイムアウトやログを確認して根を潰す
更新を分割する例。

DB::transaction(function () use ($ids) {
    Model::whereIn('id', $ids)->update(['status' => 'done']);
}, 3);

“止まっている時間”が長いと、処理量が少なくてもtimeoutする。

原因7:メモリ枯渇→スワップ多発→タイムアウトに見える

発生条件:
・大配列/巨大レスポンス/画像処理でメモリを食い、極端に遅くなる
・OOMでkillされ、結果的にtimeoutっぽく見える
対処:
・chunk/cursorで逐次処理
・不要なリレーション eager load を避ける
・大きいファイルはストリーム処理
・ワーカーのメモリ上限(–memory)も適切に設定

php artisan queue:work --memory=256 --timeout=300

サンプル:重い処理を「分割 + 専用キュー + timeout指定」で安定化

例:画像変換を1枚ずつジョブ化し、長い処理は専用キューへ。

// dispatch側
foreach ($imageIds as $id) {
    ConvertImageJob::dispatch($id)->onQueue('heavy');
}

// job側
class ConvertImageJob implements ShouldQueue
{
    public $timeout = 180;
    public $tries = 3;

    public function __construct(public int $imageId) {}

    public function handle()
    {
        $image = Image::findOrFail($this->imageId);

        // 画像処理、S3アップロード等
    }
}

専用キューを別ワーカーで回す(概念)。

php artisan queue:work --queue=heavy --timeout=300 --tries=3
php artisan queue:work --queue=default --timeout=60  --tries=3

チェックリスト(上から順に確認する)

1) failed_jobs の例外は TimeoutExceededException か?それともSupervisor/systemdのkillログか?
2) そのジョブの平均実行時間は何秒か(ログで start/end を出して把握)
3) 1ジョブに詰め込みすぎていないか(分割・チャンク化できるか)
4) queue:work の –timeout と tries は実処理に合っているか
5) Supervisor/systemd の停止猶予(stopwaitsecs/TimeoutStopSec)が Laravel timeout より短くないか
6) Horizon運用なら horizon.php の timeout とキュー分離(heavy)を検討したか
7) 外部API/HTTPに timeout が設定されているか(無限待ちが無いか)
8) DBロック待ちや長いトランザクションが無いか(ロック・スロークエリを確認)
9) メモリ肥大で遅くなっていないか(chunk/cursor、–memory)