Yappli Tech Blog

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

LaravelのSanctumで一部のアクセスで401エラーが発生してしまった原因と対処法

はじめに

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

ヤプリの一部のサービスでは、APIトークン(BearerToken)を使った認証処理にSanctumを利用しています。
この認証処理において、一部のアクセスで401エラーが発生するという事象に遭遇したため、その原因と対処方法について紹介いたします。

事象

調査したところ、具体的には以下のような事象であることがわかりました。

  1. アクセストークン発行APIを実行し、Sanctumによりアクセストークンを発行
  2. 発行したアクセストークンをAuthorizationヘッダーへセットし、各種APIへアクセス
  3. 1と2を繰り返していると200が返ってきたり401エラーが返ってきたりする(約1割が401)
  4. 1と2の間に200ms以上sleepを挟むと401エラーは発生しない

上記から、発行したアクセストークンを利用して200が返ってくることがあるため、トークン自体が間違っているわけではないと判断しました。
また、sleepを挟むと401エラーが発生しなくなることから、1で発行したアクセストークンを認証時にうまく参照できていない可能性があると考えました。

原因

レプリケーション遅延による影響だった

該当サービスではデータベースにAWS Auroraを利用しており、負荷分散のため以下のようなよくある構成をとっています。

  • SELECTはReaderへ
  • INSERT、UPDATE、DELETEはWriterへ

このとき、Writerに書き込まれたデータを直後に参照しようとすると、WriterとReaderのレプリケーションが間に合わず、書き込まれたデータが取得できない可能性があります。
この問題に対応するために、Laravelには sticky オプションというのがあり、これを有効にすると一度Writerへ書き込まれたら以後の処理では自動的にWriterを参照してくれるようになります。

readouble.com

弊社でもこのオプションは有効にしていましたが、それでもこの事象が発生しました。これには落とし穴があったのです。

stickyオプションは同一リクエスト上でしか有効にならない

実はこのstickオプションは同一リクエスト上でしか有効になりません。
なので、今回のようにリクエストをまたぐケースでは、Writerへ書き込んだアクセストークンは認証時にはReaderで参照されるという挙動になります。
結果として、Writer→Readerのレプリケーション遅延によりWriterに書き込んだアクセストークンをReaderで参照できず、タイミングによって401が発生してしまっていました。

対処方法

対処方法は色々ありますが、今回はSanctumでのみ発生している事象だったため以下のようにミニマムで対応しました。

  1. Sanctumで認証するときだけWriterを参照させる
  2. それ以降は負荷分散のためReaderを参照させる

ソースコードのサンプルは以下です。

<?php

declare(strict_types=1);

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\DB;

class SanctumAuthentication
{
    public function handle(Request $request, Closure $next)
    {
        $originalConnection = DB::getDefaultConnection();

        try {
            // config/database.php で設定したwriterのコネクションを明示的に指定
            DB::setDefaultConnection('mysql_writer');

            $sanctum_guard = app('auth')->guard('sanctum');
            if (!$sanctum_guard->check()) {
                // 認証失敗時のハンドリング
            }
        } finally {
            // 必ず元のコネクションに戻す
            DB::setDefaultConnection($originalConnection);
        }

        return $next($request);
    }
}

やっていることはシンプルで、コネクションを一時的にwriterへ向けてその後元のコネクションに戻しているだけです。
注意点としては、ミドルウェア以後の処理がそのままWriterに向いたままになってしまっているとWriterが高負荷状態になる恐れがあるので、ログでのデバッグ + NewRelicなどのAPMで接続先が意図したエンドポイントへ向いているかどうかは慎重に確認することを強くおすすめします。

類似ケース

レプリケーション遅延によりある画面でDBへ更新をかけてリダイレクトしたあとに古いデータを参照してしまうケースもあるようです。

core-tech.jp

まとめ

Auroraのレプリケーション遅延は20msぐらいで非常に短い時間なのでまさかリクエストを跨いで遅延の影響を受けるとは意外でした。
Writer/Readerによる負荷分散を導入する際には、このような遅延も考慮した設計を心がける必要がありそうです。

さいごに

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

open.talentio.com

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