それマグで!

知識はカップより、マグでゆっくり頂きます。 takuya_1stのブログ

習慣に早くから配慮した者は、 おそらく人生の実りも大きい。

laravel のスケジュール実行とジョブと例外処理について調べておいた。

laravel のスケジュール実行

laravel で cron のようなジョブ実行ができる。cron書式で書いて旧世代のワタシに理解しやすくて便利そうなので、スケジュールをつかってcronを管理できたらいいなと思った

laravel のスケジュールの流れ

  • Kernel.phpに記述
  • artisan で実行
  • artisan がKernelを呼び出し
  • KernelがShcedule を呼び出し
  • Shceduleがcron書式をチェックする
  • cron書式がいまの日時分なら実行する
  • スケジュールがなくなるまで繰り返し。

スケジュールを作って実行する

最初に、スケジュールで実行したいジョブを作る。 app/Console/Kernel.php

class Kernel extends ConsoleKernel {
    ## .. snip
  protected function schedule ( Schedule $schedule ) {
    $schedule->exec( 'ls -alt /' )->everyMinute();
  }
    ## .. snip
  }

正しく登録されたか、確認しておく。

$ php artisan schedule:list
+-----------+-----------+-------------+----------------------------+
| Command   | Interval  | Description | Next Due                   |
+-----------+-----------+-------------+----------------------------+
| ls -alt / | * * * * * |             | 2021-08-23 12:41:00 +00:00 |
+-----------+-----------+-------------+----------------------------+

実行してみる。

php artisan schedule:run

実行結果。

$ php artisan schedule:run
[2021-08-23T12:41:38+00:00] Running scheduled command: ls -alt / > '/dev/null' 2>&1

連続実行するとどうなるか。

スケジュールの定義は、ソースコードに書いているだけです。さて、数秒間にartisanを連続起動するとどうなるでしょうか。

 for i in {1..10} ; do php artisan schedule:run ; done
[2021-08-23T12:43:07+00:00] Running scheduled command: ls -alt / > '/dev/null' 2>&1
[2021-08-23T12:43:08+00:00] Running scheduled command: ls -alt / > '/dev/null' 2>&1
[2021-08-23T12:43:09+00:00] Running scheduled command: ls -alt / > '/dev/null' 2>&1
[2021-08-23T12:43:09+00:00] Running scheduled command: ls -alt / > '/dev/null' 2>&1
...

上記の通り、artisan が実行されたら実行される。

毎回毎回、時刻と一致するか判定しTrueになるので毎回実行されちゃう。これは、arrtisanが実行されたタイミングに依存するので、10回artisanを起動させたら10回ジョブが起動する。これが公式の機能です。

これはcrontab に登録をモデルにしているからだと思われます。

そして、cronのようなタイマーに artisan schedule:run を仕込むことを前提としているからです。

crontab を使って毎分実行

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

これで、schedule:run が毎分1回実行されることが保証されます。これでいいのか。と疑問に思うけど、そういうもんです。

schedule:work / 単体でスケジュールを回す

無限ループの中で、1分に一度実行して、時間になるまで待って、時刻が到来したら更新がしたいとき、systemdやcrontab のタイマーに任せない環境があると思います。そういうときはどうすればいいのでしょうか。docker に systemd/crontab を入れろというのでしょうか。それは困ります。

定期ジョブ実行に特化したワーカープロセスが用意されています。

php artisan schedule:work

実行してみましょう。

$ php artisan schedule:work
Schedule worker started successfully.

[2021-08-23T12:48:01+00:00] Execution #1 output:
[2021-08-23T12:48:00+00:00] Running scheduled command: ls -alt / > '/dev/null' 2>&1
[2021-08-23T12:49:00+00:00] Running scheduled command: ls -alt / > '/dev/null' 2>&1

work 中にスケジュールの定義を変えてみる。

定期ジョブを実行するプロセスartisan schedule:work を実行中に、ジョブの定義を変えてみたらどうなるでしょうか。

  protected function schedule ( Schedule $schedule ) {
    $schedule->exec( 'ls -alt /' )->everyMinute();
      ## 追加する。
    $schedule->exec( 'pwd /' )->everyMinute();
  }

work の出力を待ってみると。変わっています。

[2021-08-23T12:50:01+00:00] Execution #3 output:
[2021-08-23T12:50:01+00:00] Running scheduled command: ls -alt / > '/dev/null' 2>&1
[2021-08-23T12:50:01+00:00] Running scheduled command: pwd / > '/dev/null' 2>&1

これは、安心です。 laravel のスケジューリングは、時間になったら 最初からサービスを起動し、Kernelを再評価してくれるようです。

となると、心配なのがシンタックスエラー

わざとエラーにしてみます。

  protected function schedule ( Schedule $schedule ) {
    $schedule->exec( 'ls -alt /' )->everyMinute();
    $schedule->exec( 'pwd /' )->everyMinute();
    あああ
  }

schedule:work の実行結果を見てみると

[2021-08-23T12:52:00+00:00] Execution #5 output:
PHP Parse error:  syntax error, unexpected '}' in ..../app/Console/Kernel.php on line 28
[2021-08-23T12:53:00+00:00] Execution #6 output:
PHP Parse error:  syntax error, unexpected '}' in ..../app/Console/Kernel.php on line 28```

エラーになります。しかし、エラーでもwork自体は止まりません。いいじゃん。

タイマーのの実行結果はどこに行くのか。

exec でコマンド実行したときのログを見ていると

Running scheduled command: ls -alt / > '/dev/null' 2>&1

このように記載されています。つまりコマンドの実行結果は、/dev/null へ。宇宙の彼方へ捨てられます。

これでは、困るのでなにか手を考えないといけません。

ログファイルに書き出すのは簡単ですね。

$schedule
    ->exec('ls -alt')
    ->appendOutputTo(storage_path('logs/my-job.log'));

ただし、ログのファイル名を変えたいとか、特定のエラーを捕まえたいとかそういう事はできないし想定されてないと思います。ログを書き出し、ログをrsyslogdでZabbixに流して監視的な形になると思います。ですが、やっぱりLaravel内部で完結させたいところです。

実行ログを保存するジョブを作る

実行ログを保存したり、実行中に条件分岐を作ったり、タイマー実行時になにか処理を加えたいときは、ジョブを作るのがベターだと思われます。

ジョブは、キューと同じジョブです。Queueで使うJobをスケジュール実行で使えます。

ジョブを作成する

キューを作るときと同じで、Jobは make:jobで作れます。

artisan make:job MyCronJob

app/Jobs/MyCronJob.php

class MyCronJob implements ShouldQueue {
  use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
  
  public function handle () {
    $proc=new Process(['ls','-alt']);
    $proc->run();
    dump($proc->getOutput());
  }
}

ジョブをスケジュールに登録

作ったジョブをスケジュールに登録します。

  protected function schedule ( Schedule $schedule ) {
    $schedule->job(new MyCronJob())->everyMinute();
    //$schedule->exec( 'ls -alt /' )->everyMinute();
    //$schedule->exec( 'pwd /' )->everyMinute();
  }

登録を確認します。

$ php artisan schedule:list
+---------+-----------+--------------------+----------------------------+
| Command | Interval  | Description        | Next Due                   |
+---------+-----------+--------------------+----------------------------+
|         | * * * * * | App\Jobs\MyCronJob | 2021-08-23 13:01:00 +00:00 |
+---------+-----------+--------------------+----------------------------+

実行します。

php artisan schedule:run
[2021-08-23T13:02:11+00:00] Running scheduled command: App\Jobs\MyCronJob
"""
drwxrwxrwx 1 takuya takuya    4096 Aug 23 21:23 database\n
drwxrwxrwx 1 takuya takuya    4096 Aug 23 17:52 app\n
drwxrwxrwx 1 takuya takuya    4096 Aug 23 16:39 public\n
drwxrwxrwx 1 takuya takuya    4096 Aug 23 16:39 node_modules\n
-rwxrwxrwx 1 takuya takuya  323529 Aug 23 16:39 package-lock.json\n
-rwxrwxrwx 1 takuya takuya    1890 Aug 23 16:37 composer.json\n
"""

ちゃんと実行されることがわかります。 これで、Jobを定義すれば、スケジュールの中で複雑なことができるとわかります。

時間のかかるジョブはどうなるのか。

時間のかかるジョブをどうするのかが問題です。

ジョブを作るとき、時間のかかる処理は小分けにして実行するのが基本です。たとえば1000件メールを送るのであれば、1件送信を1000回起動します。それで細切れにします。またジョブキューの仕組みを使ってワーカーに処理を任せてもいいでしょう。

でも、どうしても時間のかかる処理は出てきます。たとえば、画像を変換したり、動画を変換したり、pupeteer でスクレーピングしたりです。

時間のかかる処理がどうなるか見ておきましょう。 app/Console/Kernel.php

  protected function schedule ( Schedule $schedule ) {
    $schedule->exec( 'echo スケジュール開始' )->everyMinute();
    $schedule->exec( 'sleep 30' )->everyMinute();
    $schedule->exec( 'echo スケジュール 終了' )->everyMinute();
  }

sleep で お長時間時間を掛けてみます。

30秒くらいなら、なんともなさそうです。 '13:10:01+00'に開始したタスクは '13:10:31+00' には終わっています。 workで起動後、 毎分00+00に開始するので、次の時間にズレ込みません。大丈夫です。

$  php artisan schedule:work
[2021-08-23T13:09:01+00:00] Execution #5 output:
[2021-08-23T13:09:00+00:00] Running scheduled command: echo スケジュール開始 > '/dev/null' 2>&1
[2021-08-23T13:09:01+00:00] Running scheduled command: sleep 30 > '/dev/null' 2>&1
[2021-08-23T13:09:31+00:00] Running scheduled command: echo スケジュール 終了 > '/dev/null' 2>&1

[2021-08-23T13:10:01+00:00] Execution #6 output:
[2021-08-23T13:10:00+00:00] Running scheduled command: echo スケジュール開始 > '/dev/null' 2>&1
[2021-08-23T13:10:01+00:00] Running scheduled command: sleep 30 > '/dev/null' 2>&1
[2021-08-23T13:10:31+00:00] Running scheduled command: echo スケジュール 終了 > '/dev/null' 2>&1

60 秒掛けてみたらどうなるでしょうか。

  protected function schedule ( Schedule $schedule ) {
    $schedule->exec( 'echo スケジュール開始' )->everyMinute();
    $schedule->exec( 'sleep 61' )->everyMinute();
    $schedule->exec( 'echo スケジュール 終了' )->everyMinute();
  }

ちょっとややこしいですが、出力をよく見てみましょう。

[2021-08-23T13:11:01+00:00] Execution #7 output:
[2021-08-23T13:11:00+00:00] Running scheduled command: echo スケジュール開始 > '/dev/null' 2>&1
[2021-08-23T13:11:01+00:00] Running scheduled command: sleep 61 > '/dev/null' 2>&1

[2021-08-23T13:12:01+00:00] Execution #8 output:
[2021-08-23T13:12:01+00:00] Running scheduled command: echo スケジュール開始 > '/dev/null' 2>&1
[2021-08-23T13:12:01+00:00] Running scheduled command: sleep 61 > '/dev/null' 2>&1

[2021-08-23T13:12:02+00:00] Execution #7 output:
[2021-08-23T13:12:02+00:00] Running scheduled command: echo スケジュール 終了 > '/dev/null' 2>&1

前回のスケジュールが実行中に次のジョブが開始します。 |タスク| 13:11 | |13:12||13:13| |---|---|---|---|---|---|---| |1| 開始|sleep|終了| |2| |開始|sleep|終了| |3| ||開始|sleep|終了|

このように、前回のタスク終了前に、次回のスケジュールが起動します。

これは、思わぬトラブルを引き起こす可能性がありますね。

複数の sleep ジョブが溜まっていきます・・・・ dc452a4a2471dc94de51855926363368.png

そこで、取りうる選択肢として、オーバーラップさせない、若しくは起動して切り離す。あとは知らん。

オーバーラップを許さない場合 withoutOverlapping で修飾しておけば、重複起動しません。 前回のタスクが終わるまで次のタスクに入りません。

  protected function schedule ( Schedule $schedule ) {
    $schedule->exec( 'echo スケジュール開始' )->everyMinute();
    $schedule->exec( 'sleep 61' )->everyMinute()->withoutOverlapping();
    $schedule->exec( 'echo スケジュール 終了' )->everyMinute();
  }

withoutOverlapping をつけておけば、同一時間に複数起動することがなくなります。

$ php artisan schedule:run &
$ php artisan schedule:run 

すでに起動していたら、スキップされます。

タスク 13:11 13:12 13:13
1 echo 開始 sleep echo 終了
2 echo 開始 スキップ echo 終了
3 echo開始 sleep echo 終了

ただ、重複チェックが誤作動したので、php artisan cache:clear しないといけなかったので、自信を持って押せない機能ですね。

バックグラウンド実行 バックグラウンドで実行させて、処理を切り離すことも出来ますね。

$schedule->exec( 'sleep 120' )->everyMinute()->runInBackground();

シェルのバックグラウンド実行と、サブシェル( sub-shell ; command )機能を使って処理が終わったらartisan を呼び出して処理が終わったことを確認しているようです。

Linuxで実行した場合、実行元シェルから切り離されていた 。sh -c' ( command ) > &'ですね。

[2021-08-23T13:42:06+00:00] Running scheduled command: (sleep 120 > '/dev/null' 2>&1 ; '/usr/bin/php7.4' 'artisan' schedule:finish "framework/schedule-578c2e34750ad5f0982ff5a1a9f68959495cb9d0" "$?") > '/dev/null' 2>&1 &

バックグラウンドで時間のかかる処理を放置してもいいかもしれません。

f:id:takuya_1st:20220124115956p:plain

スケジュールで実行できるもの

  • artisan コマンド
  • exec シェルコマンド
  • Job クラス(shouldQueue)

になります。これらを組み合わせて使えばいいようですね。

スケジュールの実行結果を保存する。

ログファイルに書き出すのは簡単ですね。

$schedule
    ->exec('ls -alt')
    ->appendOutputTo(storage_path('logs/my-job.log'));

ただ、DBに書き出すとなると大変です。EventデザパタでonXXX に登録して、ハンドラでもらってこないといけない。

$schedule->exec('ls -alt')
  ->onFailureWithOutput(function($sterr){
    echo "error\n";
    echo $sterr;
  })
  ->onSuccessWithOutput(function($output){
    echo "success\n";
    echo $output;
  });

ここまでくると、独自のJob・artisanコマンドを実装したほうが楽そうです。

schedule->job の怖いところ

ジョブを作成して、スケジュールで回してもほぼ同じことができるのですが。 ジョブをQueableで作っていまうと、スケジューラーは、ジョブを実行せずに、キューに送ってしまう。

$schedule->job(new MyCronJob(),'default','database');

Schedule#jobの実装を見てみると・・・

  public function job ( $job, $queue = null, $connection = null ) {
    return $this->call( function() use ( $job, $queue, $connection ) {
      $job = is_string( $job ) ? Container::getInstance()->make( $job ) : $job;
      
      if ( $job instanceof ShouldQueue ) {
        $this->dispatchToQueue( $job, $queue ?? $job->queue, $connection ?? $job->connection );
      } else {
        $this->dispatchNow( $job );
      }
    } )->name( is_string( $job ) ? $job : get_class( $job ) );
  }

スケジュールに登録したジョブは、キューとして取り扱いできれば、すべてキューイングされている。キューのデフォルトがsync (即時実行)ならいいのだが、デフォルトがRedisなどなら、そちらに送られるだけで実行はされない。

次のように書いたジョブをスケジュール実行してもキューイングされるだけである。

class MyCronJob implements ShouldQueue {
  use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
  
  public function __construct () {
    $this->connection='database';
  }
  public function handle () {
    $proc=new Process(['ls','-alt']);
    $proc->run();
    dump($proc->getOutput());
  }
}

スケジュールで実行に登録しても、キューイングされるだけ。

$schedule->exec( 'echo スケジュール開始' )->everyMinute();
$schedule->job(new MyCronJob());
$schedule->exec( 'echo スケジュール 終了' )->everyMinute();

スケジュールと、キューは密接な関係にある。こういう仕様はちょっと予想を裏切るのでジョブキューのJobとスケジュールのJobは定義を分けてほしいかブリッジを入れてほしい。と思うことになるので、スケジュール用に使うジョブの雛形は、artisan make:job で作らないほうがいいかもしれない。

handle があるだけで、schedule->job からその場で十分に動くクラスとして使える。

class MySimpleCronJob  {
  public function handle () {
    $proc=new Process(['ls','-alt']);
    $proc->run();
    dump($proc->getOutput());
  }
}

また、ジョブで実行した場合、出力が取れない。

The emailOutputTo, emailOutputOnFailure, sendOutputTo, and appendOutputTo methods are exclusive to the command and exec methods.

また、ジョブで実行した場合、例外発生は、ログのみ記録される

スケジュール例外。これが一番問題で厄介な課題だとおもう。スケジュール実行した場合の例外は、ログでしかわからない。スケジュールでジョブ実行した場合、実行主体はスケジュールなので、例外はスケジュールの例外として記録される。オブジェクトが自分がどう処理されるか知っているというオブジェクト指向的には嬉しくない。

スケジュールで実行された・スケジュールでエラーになったを追わないと、ジョブ自体の例外によるデバッグが困難になる。なのでジョブ自体で完結するように作る必要があると思う。またLaravel自体の例外管理(App\Exception\Handler)を使うべきなのかもしれない。

スケジュールとジョブに関する例外はジョブの中で完結したいところではある。

ジョブ内部のエラー処理

ジョブ内部のエラー処理をどうするのか。 failed() を使う

class MyJob{
  public function failed ( $exception = null ) {
    dd($exception);
  }
}

しかし、ジョブのなかで failed を実装すると、デフォルトのジョブの失敗処理は動かない。まいった問題。

このあたりから考えると、スケジューリングで job を作って即時実行は望ましい処理じゃないとかもしれない。設計的にはLaravelはキューイングを前提にしていると思われる。$schedule->job() はマニュアルとどおり登録するためのものと割り切るほうが良さそう。

スケジュールで任意の関数・コードを実行/call

じゃあ、シェルコマンドでもなく、ジョブでもなく、任意のphpコードを実行するにはどうしたらいいのか。そこでcallableを渡すのである。

$schedule->call(function(){echo "aaaa";})->everyMinute();

でもcallable はめんどくさいなと思うと __invokeを実装しておけばいいのである。

In addition to scheduling using closures, you may also schedule invokable objects. Invokable objects are simple PHP classes that contain an __invoke method:

しかし、今度はジョブの失敗を クラス側で取れなくなる。

$schedule->call(new MyJob($job))->everyMinute();
class MyJob{
  public function __invoke () {
    throw  new \Exception('error');
  }
  public function failed ( $exception = null ) {
    dd($exception);
  }
}

また、実行時のログを見ても何が実行されたのかまるでわからない。

[2021-08-24T06:38:42+00:00] Running scheduled command: Callback

また、一覧をみても、何が実行されるのか、全くわからない。

php artisan schedule:list
+---------+-----------+-------------+----------------------------+
| Command | Interval  | Description | Next Due                   |
+---------+-----------+-------------+----------------------------+
|         | * * * * * |             | 2021-08-24 06:40:00 +00:00 |
+---------+-----------+-------------+----------------------------+

もう、手詰まり感がやばいですね。

コードを実行することもできるが、細かいことはできない。

スケジュールに登録するコードに名前をつける/name

実行されるCallableに名前をつける。

$schedule
    ->call(new MyJob())
    ->everyMinute()
    ->name(MyJob::class);

名前をつけておけば、 schecule:list で見られるようになる。

$ php artisan schedule:run
[2021-08-24T06:42:15+00:00] Running scheduled command: App\Jobs\MyFirstJob
$ php artisan schedule:list

+---------+-----------+---------------------+----------------------------+
| Command | Interval  | Description         | Next Due                   |
+---------+-----------+---------------------+----------------------------+
|         | * * * * * | App\Jobs\MyJob | 2021-08-24 06:41:00 +00:00 |
+---------+-----------+---------------------+----------------------------+

call 時は名前付け推奨ですね。

関数・コード実行(call)時の例外処理。

例外処理はどうするのか。

call時に、例外発生したら 、laravel の例外ハンドラによりログで処理される。

でも、エラー時の実行結果・成功時の実行結果を取ることが出来ない。

class MyJob{
  public function __invoke () {
    echo "aaaaaaaaaa";
    return 0;
  }
}

成功・エラーに関わらずエラーが取れない

$schedule->call(new MyJob($job))
    ->everyMinute()
    ->onSuccess(function(Stringable $args){
          dump('succeed');
          dump($args);// 取れない!
    })
    ->name(MyJob::class)

ちなみに、exec でも出力取れない

$schedule->exec('whoami')->onSuccess(function(Stringable $a){
    dump('exec succeed');
    dump($a);
});
$ php artisan schedule:run
[2021-08-24T07:14:41+00:00] Running scheduled command: whoami > '/dev/null' 2>&1
"exec succeed"
Illuminate\Support\Stringable^ {#884
  #value: ""
}

exce で出力を取る。動かない・・・と思ったら、変数名がoutput限定だった。そんなバカな。

$schedule->exec('whoami')->onSuccessWithOutput(function($output){
    dump('exec succeed');
    dump($output);
});

変数名が固定で入ってるやん。 src/Illuminate/Console/Scheduling/Event.php

    /**
     * Get a callback that provides output.
     *
     * @param  \Closure  $callback
     * @param  bool  $onlyIfOutputExists
     * @return \Closure
     */
    protected function withOutputCallback(Closure $callback, $onlyIfOutputExists = false)
    {
        return function (Container $container) use ($callback, $onlyIfOutputExists) {
            $output = $this->output && is_file($this->output) ? file_get_contents($this->output) : '';

            return $onlyIfOutputExists && empty($output)
                            ? null
                            : $container->call($callback, ['output' => new Stringable($output)]);
        };
    }

やってられん。Laravel使うときはコールバック変数名に注意しましょう。

Job で sync なタスクのバックグラウンド化

出来ません。すべてのジョブ実行を書き直すくらいの覚悟がないと出来ません。

runInBackground() していたとしても、ジョブにはrunInBackgroundが通知されません。

Illuminate\Console\Scheduling\Event Illuminate\Console\Scheduling\CallbackEvent

で定義されてますが、そこには__invoke時にバックグラウンドで調整するようなものが何もありません。

laravelのスケジュール機能は、キューイングとセットで使うと割り切ったほうが楽になると思います。

私は、schedule:work でキューイングして queue:work を同時に起動することにしました。