Laravelでタスクをスケジュール実行する

すっかりブログを書くのを忘れていました。Laravel5.2で開発をしていたんですが、少し時間があるのでハマったりして調べた事などを備忘録がてら残しておこうと思います。何日かに分けて書いていく予定です。今回は使ってみたらとても便利だったタスクスケジュール機能について書きます。

Laravelのタスクスケジュール機能

Laravelでは通常cronで仕込むようなスケジュールタスクをLaravelのアプリケーション内でphpで記述できるという機能があります。

Laravel 5.2 タスクスケジュール

従来のcronだとタスクごとにcronのファイルを設置・確認するのが案外面倒で、ぼくだけかもしれませんが特に毎回パーミッションではまってる記憶があります。Laravelのこの機能を利用すると、初期設定でcronを1つだけ仕掛けておけば後はphpのコーディングだけで済むのでタスクを増やす労力が減るように思います。なので積極的に使っています。

仕掛ける唯一のcronは下記のみです。これは1分毎に実行されます。

$ cat /etc/cron.d/schedule
nginx php /share/laravel-app/artisan schedule:run >> /dev/null 2>&1 

実際のスケジューリングはphpのコーディングで行います。

    protected function schedule(Schedule $schedule)
    {
        // 5分毎
        $schedule->command('command-name')->everyFiveMinutes();
        // 月末の23時に実行
        $schedule->command('command-name')->dailyAt('23:00')->when(function () {
            return Carbon::create()->isSameDay(Carbon::create()->endOfMonth());
        });
    }

everyFiveMinutesやdailyAtなどの定形のメソッドもありますし、whenにクロージャを渡してやればより柔軟にかつ動的に日時の指定を行う事ができます。たとえば上記のサンプルの2つ目は月末日をクロージャで判定させています。

これはcronとか使うよりだんぜん良いですね!

タスクの多重起動防止

さらにLaravelはwithoutOverlappingというメソッドを指定する事でタスクの多重起動を防止する事ができます。

 
$schedule->command('emails:send')->withoutOverlapping(); 

ただし、このwithoutOverlappingですが、単一のサーバーでないと意味がありません。つまり冗長構成にしてこのスケジュールタスクを利用している場合はサーバー台数分だけタスクが起動してしまいます。上記の例で言うとサーバーが3台だったらメールを飛ばすタスクが同時に3つ起動してしまうという事です。

冗長構成下でタスクの多重起動を防ぐには

こういった多重起動を防ぐのは古典的にはバッチ専用のサーバーを用意してというのがありますが、如何せんそれだけのために費用があがってしまうのも考えものなのでもう少し調べてみました。原理的にはこういうケースでは「ロックを取得してタスク起動する」という実装がされていれば良いだけなのでそういった実装例がないか探しました。

するとそのためのパッケージがありました。composerでインストールできます。

jdavidbakr/multi-server-event

multi-server-eventという名前が少しややこしいんですが、これはあくまでスケジュールイベントの事を指しているものと思われます(ソースを見てもスケジュールイベント部分の実装しかない)。

multi-server-eventの利用方法

利用方法は上記のPackaigistのページに詳しく書いてあります。

手順としては

  1. multi_server_eventというロック取得用のテーブルの作成(マイグレーション生成コマンドが用意されています)
  2. $scheduleオブジェクトを書き換えるためにdefineConsoleScheduleメソッドをオーバーライドする
  3. サーバー間で多重起動防止をしたいタスクにwithoutOverlappingMultiServerメソッドを指定する

という形になります。

原理的にはシンプルでロックを取得する部分は下記のようになっていました。

        // Delete any old completed runs that are more than 10 seconds ago
        DB::connection($this->connection)
            ->delete(
                'delete from `'.$this->lock_table.'` where `mutex` = ? and complete < now() - interval 10 second',
                [$this->key]
            );

        // Attempt to acquire the lock
        DB::connection($this->connection)
            ->insert(
                'insert ignore into `'.$this->lock_table.'` set `mutex` = ?, `lock` = ?, `start` = now()',
                [$this->key, $this->server_id]
            );

        // If the mutex already exists in the table, the above query will fail silently.
        // Now we will perform a select to see if we got the lock or not.
        $lock = DB::connection($this->connection)
            ->select(
                'select `lock` from `'.$this->lock_table.'` where mutex = ?',
                [$this->key]
            );

cron書式とコマンド文字列をmd5で一意の文字列にしそれをユニーク制約がかかっているmutexカラムに指定してinsertする。最後に自分のサーバーIDで行が保存できているか調べてロックが取得できてるかを判断する、という流れになっています。ちなみにこのmulti-server-eventはRedis等のキャッシュサーバーには対応していないですが、必要であれば上記のロジックをキャッシュサーバー用に再現すれば良いだけですね。

この機能シンプルなわりにとても使えると思うので、ぜひLaravel本家に入ってメンテされてくれると嬉しいです。