Logicky Blog

Logickyの開発ブログです

LaravelのQueueをForgeで管理してみる

Foregでデプロイすると、QueueのWorkerとかSupervisorとかを勝手に起動してくれるらしい。あと、Laravel Horizonも自動で起動できるらしい。これを試してみたいと思います。

コードでQueueを使う

Queues - Laravel 11.x - The PHP Framework For Web Artisans

QueueはRedisを使う想定です。Redisのキューから1つ取り出してjobを実行するといったプログラムも必要になりますが、これがWorkerと呼ばれます。このWorkerはjobが完了したら、Queueから次を取り出します。jobが失敗したらDBにログ記録をして、改めてキューに入れたりします。つまりこのWorkerが止まっちゃうと、Queueは一切機能しなくなるわけであります。本番サーバでもこのような仕組みを使う場合、当然Workerを起動しますが、Workerの動きを監視し、止まっちゃったら自動で再起動させる仕組みが必要で、これはSupervisorというのをよく使います。 Forgeを使うと、Supervisorの起動や管理をForgeにお任せできるそうなので、便利だし安心だと思いました。

まずは、コードでQueueを使うようにしましょう。

作成する仕組み

  • トップページに「MailQueue」リンクを追加。
  • MailQueueコントローラを作成。
  • トップページのリンクをクリックすると、MailQueueindexに遷移。
  • indexには送信先メールアドレスと内容を入力するフォームと送信ボタンがある。
  • 送信ボタンを押すとPOSTで、MailQueuesendMailが実行される。
  • sendMail関数では、SendMailJobがdispatchされる。
  • SendMailJobでは、5秒間停止した後に、送信先メールアドレス宛に内容を送信する。

Job作成

<?php

namespace App\Jobs;

use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Mail;

class SendMailJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public $email;
    public $content;

    public function __construct($email, $content)
    {
        $this->email = $email;
        $this->content = $content;
    }

    public function handle()
    {
        sleep(5);

        Mail::raw($this->content, function ($message) {
            $message->to($this->email)
                    ->subject('キューからのメール');
        });
    }
}

Controllerを作る

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use App\Jobs\SendMailJob;

class MailQueueController extends Controller
{
    public function index()
    {
        return view('mail-queue.index');
    }

    public function sendMail(Request $request)
    {
        $request->validate([
            'email' => 'required|email',
            'content' => 'required|string',
        ]);

        SendMailJob::dispatch($request->email, $request->content);

        return back()->with('status', 'メールがキューに追加されました!');
    }
}

Viewを作る

<x-guest-layout>
    <div class="py-12">
        <div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
            <div class="bg-white dark:bg-gray-800 overflow-hidden shadow-sm sm:rounded-lg">
                <div class="p-6 text-gray-900 dark:text-gray-100">
                    <div class="flex justify-between items-center mb-4">
                        <a href="{{ url('/') }}" class="text-sm text-gray-700 dark:text-gray-500 underline">トップに戻る</a>
                        <h2 class="font-semibold text-xl text-gray-800 dark:text-gray-200 leading-tight">
                            {{ __('メールキュー') }}
                        </h2>
                    </div>

                    @if (session('status'))
                        <div class="mb-4 font-medium text-sm text-green-600 dark:text-green-400">
                            {{ session('status') }}
                        </div>
                    @endif

                    <form method="POST" action="{{ route('mail.queue.send') }}">
                        @csrf
                        <div>
                            <x-label for="email" value="{{ __('Email') }}" />
                            <input id="email" class="block mt-1 w-full" type="email" name="email" required autofocus />
                        </div>

                        <div class="mt-4">
                            <x-label for="content" value="{{ __('Content') }}" />
                            <textarea id="content" class="block mt-1 w-full form-textarea rounded-md shadow-sm text-black" name="content" required></textarea>
                        </div>

                        <div class="flex items-center justify-end mt-4">
                            <x-button class="ml-4">
                                {{ __('Send Mail') }}
                            </x-button>
                        </div>
                    </form>
                </div>
            </div>
        </div>
    </div>
</x-guest-layout>

routerに追加する

<?php
use App\Http\Controllers\MailQueueController;

Route::get('/mail-queue', [MailQueueController::class, 'index'])->name('mail.queue.index');
Route::post('/mail-queue/send', [MailQueueController::class, 'sendMail'])->name('mail.queue.send');

環境変数調整

Redisを使うので、下記のようにします。

QUEUE_CONNECTION=redis

Workerを起動する

php artisan queue:work

Forgeでデプロイしてみる

リポジトリにプッシュして、Forgeで「Deploy Now」をクリックします。 すると、デプロイは失敗しました。

サーバには変更されているファイルがあり、package-lock.jsonであるとのことです。 これをコミットするなりしないと、git pullできないよ、といったようなエラーになります。

たしかに、デプロイ時に、pullした後に、npm install && npm run buildしてますので、それでpackage.jsonの中身が変わったのかもしれません。 npm ci && npm run buildに変更したら、デプロイが成功しました。

ForgeでQueue Workerの設定をする

サイト設定のQueueの画面は下記です。createをクリックしてWorkerを作成します。

また、デプロイスクリプトの最後に、下記を追加する必要があるようです。

$FORGE_PHP artisan queue:restart

基本的にはこれだけで、キューは動作しました。

本当にWorkerが止まっても再起動するか確認してみる

DigitalOceanのサーバでプロセスを止めたらどうなるか、一応見てみようと思います。

> ps -aux | grep queue
100467 .... php8.3 /home/forge/default/artisan queue:work redis --sleep=10 --daemon --quiet --queue="default"
> kill -9 100467

上記のようにやっても、一瞬で復活してきました。何回やっても復活しました。 ForgeのQueueの「Check worker status」のプロセスIDとも一致しております。 これを自動で勝ってに管理してくれるので安全安心ですね。

Laravel Horizonを試してみる

Laravel Horizon - Laravel 11.x - The PHP Framework For Web Artisans

どうもこれも、Windows環境だとインストールできなそうですね。。

github.com

上記に書いてあった、下記をやったらインストールできました。 Windowsでは動きませんが、これでForegeでデプロイした際にHorizonが使えるようになるのではないかと思いますので、やってみます。

> composer require laravel/horizon --ignore-platform-reqs
> php artisan horizon:install
> php artisan vendor:publish --provider="Laravel\Horizon\HorizonServiceProvider"

おーでましたよ。horizonが。

それにしても、デプロイが速くてよいのですが、単純にgit pullしているだけというのが、ちょっと厳しいかもなーと思いました。 先程もなぜかpullが成功せず、結局サーバ側でgit reset --hard HEADをして、git pull origin mainをしたら、なぜか成功しました。 git reset --hard HEADをしてから、Forgeの画面でデプロイすると、失敗するという謎挙動を繰り返しており、結局手動でやりました。 blue/greenデプロイとかいうやつは、別サーバに入れて入れ替える感じだと思うので、こういう謎挙動も起こらない上に、停止しないというメリットがありますね。その代わり、超遅いと思いますが。 Envoyerがそのようなサービスに該当するのかもしれません。

ちなみに、DigitalOceanのサーバは、rootだとgitコマンド使うとエラーになるので、sudo su -- forge としまして、forgeに変えてからgitコマンドを使う必要がありました。

ForgeでHorizonの設定をしてみる

forge.laravel.com

上記によると、キューワーカーは削除する必要があるらしい。 デプロイコマンドやデーモンの設定は自動で設定してくれたようです。

結局、ForgeでHorizonをONにするだけでほぼ自動で設定してくれたってことですね。キューワーカの作成をしていなければ何もしなくても設定できたということかなと思いました。お手軽ですね。 ただ、Horizonはブラウザの画面上で色々見られるっぽいので、それを確認してみたいです。

Horizonのダッシュボードを確認してみる

基本的には、/horizonでダッシュボードにアクセスできるようになっているようです。ローカル環境では普通に閲覧できますが、本番環境の場合は、403になります。 app/Providers/HorizonServiceProvider.phpで許可メールアドレスを追加したりすると、認証ユーザのメールアドレスをチェックして、OKの場合、アクセスできるようになります。 誰でも閲覧するようにする場合は、下記でいけます。ためしにこれで見てみたいと思います。

/**
 * Register the Horizon gate.
 *
 * This gate determines who can access Horizon in non-local environments.
 */
protected function gate(): void
{
    Gate::define('viewHorizon', function ($user) {
        return true;
        // return in_array($user->email, [
        // ]);
    });
}

あとは、5分おきに、horizon:snapshotを実行するようにすると、「ジョブとキューの待機時間とスループットに関する情報」を提供するメトリックをダッシュボードで表示できるようになるらしい。

Laravel Horizon - Laravel Upcoming - The PHP Framework For Web Artisans

5分おきに実行するように設定してみる

Task Scheduling - Laravel 11.x - The PHP Framework For Web Artisans

これを使ってスケジュール設定できるということで、しかも、Forgeの場合このサーバ側の設定が簡単にできるらしいです。

forge.laravel.com

Forgeのスケジュール設定機能は、Laravelのスケジュール設定機能に限定されないみたいですね。普通にCRONをForgeの管理画面で設定できるみたいな感じかなと思いました。 Laravelのスケジュール機能を使う場合は、このForgeのCRONに、1 分ごとにschedule:runが実行されるように設定する必要があるようです。

そして、実際に実行したい内容と実行頻度等は、routes/console.phpに書くようですね。

use Illuminate\Support\Facades\Schedule;

Schedule::command('horizon:snapshot')->everyFiveMinutes();

これで設定完了なようです。簡単ですね。下記でスケジュール設定が確認できます。

> php artisan schedule:list

あとはForgeのスケジュール画面で、下記を設定したらOKだと思います。