Yappli Tech Blog

株式会社ヤプリの開発メンバーによるブログです。最新の技術情報からチーム・働き方に関するテーマまで、日々の熱い想いを持って発信していきます。

Laravelのジョブですべてのリトライに失敗したらエラー通知させる方法

はじめに

こんにちは。サーバーサイドエンジニアの佐野きよ(@Kiyo_Karl2)です。

弊社では一部のサービスで分析のためRedshiftを利用しています。利用シーンとしては、例えば「ユーザーにより商品の購買が行なわれたときにジョブを利用してこの購買データをバックグラウンドでRedshiftへ投入する」といった感じです。(本当は脱Redshiftしたい・・)
しかし、Redshiftではデフォルトだとメンテナンスウィンドウが毎週1回行われ30分~1時間ほど停止するため、この間はRedshiftへの接続が失敗します。そのため、失敗した場合はジョブを1時間後にリトライさせるといった処理をしています。

なので、毎回メンテナンスのたびにジョブのリトライが走るのですが、リトライの度にSlackへエラー通知が飛んできてしまい狼アラートになりかけていました。そこで、今回のようにリトライすれば良いとわかりきっているエラーについてはリトライの度に即通知するのではなく、すべてのリトライに失敗したときにはじめて通知を行うような改善を行いました。
この記事ではその内容について紹介したいと思います。

repost.aws

【前提】Laravelのジョブの失敗時の挙動について

Laravelのジョブが失敗したときは以下のような挙動となるため、すべてのリトライに失敗したときのみエラー通知させるには失敗時の挙動について理解しておく必要があります。 これはLaravelのドキュメントには明示的に記載が無いため、手元で確認したところ以下の失敗時は以下のような挙動となってそうでした。

  • $triesプロパティを設定してリトライさせるようにしている場合、すべてのリトライに失敗したあとにfailed(Throwable $e)が呼ばれる
  • $tries - 1回まではhandle()メソッドで例外を投げていてもfailed(Throwable $e)は呼ばれないため、そのまま上位のハンドラーへ例外が到達する
    • また、$tries - 1回目でもhandle()で例外を投げなければそもそもリトライ機構が発火しない
    • このとき、上位のハンドラーの共通処理でSlack通知をしていると、リトライが発生するたびにSlackへ通知されてしまう。が、かといってhandle()で例外を投げないとリトライ機構が発火しない

上記のような挙動となるので、ジョブですべてのリトライに失敗したらエラー通知するようにするには少し工夫をする必要があります。
こういった特定の例外(リトライさせれば良いとわかりきっている場合)についてはリトライがすべて失敗してからエラー通知させたいシーンってあると思うのですが、公式ドキュメントにも言及が無いので少し工夫をする必要があります。(もっと良い方法があれば是非教えてください…!)

Laravelのジョブキューの基本的な機能については以下を参照して下さい。 readouble.com

すべてのリトライに失敗したらエラー通知させる方法

上述したジョブの挙動について把握したところで、いきなりですが実装イメージを載せます。
こちらについて順を追って解説していきます。

Job側のコード(一部抜粋)

<?php

// ...(省略)

    /**
     * Execute the job.
     *
     * @return void
     * @throws Exception|QueryException|RetryableJobException
     */
    public function handle(): void
    {
        try {
            // Redshiftへのinsertなど諸々の処理
        } catch (QueryException $e) {
            // Redshiftのメンテナンス時は接続に失敗するのでリトライさせる。
            if ($this->attempts() < $this->tries) {
                Log::warning('リトライ可能な例外が発生しました。', ['exception' => $e]);
                throw new RetryableJobException('リトライ可能な例外が発生しました。');
            }

            // すべてのリトライで失敗したら例外を投げる。
            throw new Exception($e->getMessage(), $e->getCode(), $e);
        }
    }

    public function failed(Throwable $e): void
    {
        Log::error($e);
        $this->notifySlack($e);
    }

app/Exceptions/Handler.php(共通のエラーハンドラー、一部抜粋)

<?php

// ...(省略)

class Handler extends ExceptionHandler
{
    use SlackNotifiableTrait;

    /**
     * A list of the exception types that are not reported.
     *
     * @var string[]
     */
    protected $dontReport = [
        RetryableJobException::class,
    ];
    
    // ...(省略)
   
    /**
     * Render an exception into an HTTP response.
     *
     * @param \Illuminate\Http\Request $request
     * @param \Throwable $exception
     *
     * @return \Symfony\Component\HttpFoundation\Response
     * @throws \Throwable
     */
    public function render($request, Throwable $exception)
    {
        if (strpos($request->getRequestUri(), '/manage/') !== false) {
            // ...いろいろな共通処理

            if ($exception instanceof AuthorizationException) {
                return response()->json([
                    'status' => false,
                    'errors' => ['common' => [$exception->getMessage()]],
                ], 403);
            }
            // ...いろいろな共通処理
        }

        \Log::error($exception);

        return response()->json([
            'status' => false,
            'errors' => ['common' => ['Internal Server Error']],
        ], 500);
    }
}

リトライしたい例外をcatch

Redshiftへの接続が失敗した場合、LaravelではQueryExceptionが投げられます。このジョブではRedshiftへの接続以外でQueryExceptionが投げられることはないため、Redshiftへの接続失敗 = QueryExceptionという前提で処理をしています。
ここで注意したいのは、「即通知すべき例外だったのにリトライ処理に入ってしまって障害検知が遅れてしまう恐れはないか?」という点です。今回のケースだとリトライ処理が失敗してから1時間の間隔を空けて最大3回リトライさせるようにしているため、初回で気付くべき例外もうっかりcatchしてしまうと最大で3時間障害検知が遅れてしまうことになります。なので、catchする例外のスコープについては十分に調査しておく必要があります。

リトライさせるためだけの独自例外をつくる

app/Exceptions/Handler.php$dontReportプロパティにRetryableJobExceptionクラスを追記することで、この例外がリトライの度にハンドラーに到達してもSlack通知されることを防ぐことができます。
ジョブの試行回数が$tries未満だった場合、handle()RetryableJobExceptionを投げるようにすることでリトライ機構を発火させている間はSlack通知をせずに、最後のリトライが失敗したときにエラーをSlackへ通知するといった挙動が実現できます。

まとめ

以上、Laravelのジョブですべてのリトライに失敗したらエラー通知するようにする方法の紹介でした。検索しても同じような問題に直面している記事がほぼ無かったため、少しでも参考になりましたら幸いです。
この他にもなかなかLaravelのジョブの挙動はクセがあって大変な面もありますが、とは言え非常に便利なのでうまく付き合っていきたいですね!

さいごに

ヤプリでは引き続きサーバーサイドエンジニアを随時募集しています! 興味を持った方、是非一度カジュアル面談を受けてみませんか…??

open.talentio.com

最後まで読んでいただきありがとうございました!