LaravelをAWS Lambdaのカスタムランタイム（Docker）でSQSトリガー動作させる構成を作ってみた

# 概要
SQS+Lambdaで非同期処理を構築する。
Lambdaはカスタムランタイムを使い、Docker+Laravel（既存のwebサーバで動いているもの）という構成にする。

# 背景
webアプリがLaravel（+inertia+vue+ts）で動いています。
非同期処理をwebサーバの同サーバにてスーパーバイザー（常駐ワーカー型）で動かそうとしておりましたが、webサーバへの負荷やwebサーバがスケーリングしたときにややこしくなるのが少し懸念なので、webサーバ外にこの仕組みを作りたいと思いました。
そこでSQS+Lambdaという構成がいいんじゃないかと思いました。
またLambdaを使う際に、元々webサーバで動いているソースを使用し、クラスを流用したいと思いました。

# SQSのメッセージを処理していく方式
大きく分けて二つあります。

- 常駐ワーカー型
- イベント駆動型

## 常駐ワーカー型
常駐ワーカー型は、Laravel の php artisan queue:work コマンドなどを用いて、キューの監視プロセスを 常時起動し続ける 処理モデルです。
EC2、Fargate、ECS などの仮想サーバ上に常駐ワーカーをデプロイして、キュー（SQSやRedisなど）をポーリングし続けることで、新しいジョブが投入され次第即時に処理します。

## イベント駆動型
イベント駆動型は、AWS Lambda などのサーバレスアーキテクチャを利用し、SQSなどのイベントをトリガーにして 必要なときだけワーカーを起動する 方式です。
Laravelアプリケーションを Lambda にコンテナとしてデプロイし、ジョブがキューに積まれたタイミングで Lambda が実行され、1件または複数件のジョブを処理します。

## 常駐ワーカー型とイベント駆動型
GPTに教えてもらいました。
|項目  |常駐ワーカー型  |イベント駆動型  |
|---|---|---|
|起動方式  |コンテナ内で php artisan queue:work を常時ループ  |SQSメッセージを契機に1回ごとにLambda起動  |
|起動コスト  |Lambdaがずっと動き続けるので 課金が高くなりやすい  |実行されたときだけ起動、アイドル時は課金なし  |
|スケーリング  |Lambda1つを並列実行できない（手動並列化が必要）  |メッセージ数に応じて自動で並列実行される  |
|実行モデル  |whileループ + queue:work の常駐型  |ハンドラー → artisan 1回実行  |
|アーキテクチャのシンプルさ  |Laravel標準の書き方に近く、シンプル  |LambdaのRuntime APIを自前実装する必要あり  |
|初期化コスト  |Laravelが最初に1回だけ初期化され続ける  |Lambda起動のたびに 毎回 Laravelを初期化  |
|長時間実行  |問題なし（制限なし）  |Lambda制限で最大15分まで  |
|コールドスタート影響  |少ない（常に起動中）  |あり（毎回初期化）  |
|デプロイのしやすさ  |1つの queue:work Lambda で共通処理可能  |処理ごとに Lambda を分割しやすい（環境変数で切替可）  |
|保守・運用負荷  |queue数が増えると管理が複雑化しやすい）  |LambdaのトリガーとIAMで明示的に管理できる



## どちらを採用したか
イベント駆動型を採用しました。
なぜかというと、Lambdaのうまみを引き出せそうだったからです。（起動コストやスケーリング）


# 構築手順
## 前提
- WindowsのWSL上でDockerで動くlaravelを基にする
- serverlessコマンドを事前にインストール済

## 構成図
```
laravel
    ├─ app
    │    ├─ Console
    │          └─ Cmmands
    │                └─ LambdaSampleHandler.php
    ├─ lambda
    │    ├─ .dockerignore
    │    ├─ Dockerfile
    │    ├─ handler
    │    └─ serverless.yml
```

## 手順

### Dockerfile
brefイメージだとうまくいかない部分があったので、このイメージを採用
```Dockerfile
FROM php:8.3-cli
WORKDIR /app

COPY . .

RUN apt-get update && apt-get install -y \
    unzip \
    git \
    curl \
    && rm -rf /var/lib/apt/lists/*
RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
RUN composer install --optimize-autoloader --no-scripts
RUN composer dump-autoload --optimize

RUN php artisan config:clear
RUN php artisan route:clear
RUN php artisan view:clear


RUN chmod 755 /app/lambda/handler
RUN chmod -R 777 /app/storage

ENTRYPOINT [ "/app/lambda/handler" ]
```

### handler
こちらのサイトを参考にさせていただきました。
https://www.rainorshine.asia/2023/05/29/post3968.html
```php:handler
#!/usr/bin/env php
<?php
define('LARAVEL_START', microtime(true));

require __DIR__ . '/../vendor/autoload.php';

use GuzzleHttp\Client;
use Symfony\Component\Console\Input\ArgvInput;
use Symfony\Component\Console\Output\BufferedOutput;
use Illuminate\Contracts\Console\Kernel;
(new class () {
    private readonly string $baseUrl;

    private readonly Client $client;

    public function __construct()
    {
        $runtimeApi = getenv('AWS_LAMBDA_RUNTIME_API');
        if ($runtimeApi === '') {
            throw new LogicException('Missing Runtime API Server configuration.');
        }
        $this->baseUrl = "http://{$runtimeApi}/2018-06-01";
        $this->client = new Client();
    }

    /**
     * @return void
     */
    public function handle(): void
    {
        // CMD で渡されるコマンドライン引数からコマンド名を得る
        $argv = $_SERVER['argv'];
        if (count($argv) < 2) {
            throw new LogicException('No command specified.');
        }

        /** @var Illuminate\Foundation\Application $app */
        $app = require __DIR__.'/../bootstrap/app.php';
        $kernel = $app->make(Kernel::class);

        while (true) {
            [$invocationId, $payload] = $this->getNextRequest();
            try {
                $input = new ArgvInput([...$argv, $payload]);
                $output = new BufferedOutput();
                $result = $kernel->handle($input, $output);
                if ($result !== 0) {
                    throw new RuntimeException($output->fetch());
                }
                $this->sendResponse($invocationId, $output->fetch());
            } catch (Throwable $e) {
                $this->handleFailure($invocationId, $e);
            }
        }
    }

    /**
     * @return array{invocationId: string, payload: string}
     */
    private function getNextRequest(): array
    {
        $url = "{$this->baseUrl}/runtime/invocation/next";
        $response = $this->client->get($url);
        $invocationId = $response->getHeaderLine('lambda-runtime-aws-request-id');
        $payload = (string)$response->getBody();
        return [$invocationId, $payload];
    }

    /**
     * @param string $invocationId
     * @param string $response
     *
     * @return void
     */
    private function sendResponse(string $invocationId, string $response): void
    {
        $url = "{$this->baseUrl}/runtime/invocation/{$invocationId}/response";
        $this->client->post($url, [
            'headers' => [
                'Content-Type' => 'application/json',
            ],
            'body' => $response,
        ]);
    }

    /**
     * @param string $invocationId
     * @param Throwable $exception
     *
     * @return void
     */
    private function handleFailure(string $invocationId, Throwable $exception): void
    {
        $url = "{$this->baseUrl}/runtime/invocation/{$invocationId}/error";
        $data = [
            'errorType' => get_class($exception),
            'errorMessage' => $exception->getMessage(),
            'errorTrace' => $exception->getTrace(),
        ];
        $payload = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        $this->client->post($url, [
            'headers' => [
                'Content-Type' => 'application/json',
            ],
            'body' => $payload,
        ]);
    }
})->handle();
```

### .dockerignore
```.dockerignore
vendor
node_modules
.git
```


### serverless.yml
```serverless.yml
service: laravel-lambda-handler

provider:
  name: aws
  region: ap-northeast-1
  ecr:
    images:
      workerimage:
        uri: <awsアカウントID>.dkr.ecr.ap-northeast-1.amazonaws.com/laravel-lambda-fixed:latest

functions:
  sqsWorker:
    image:
      name: workerimage
    events:
      - sqs:
          arn: arn:aws:sqs:ap-northeast-1:<awsアカウントID>:laravel-job-queue
```


### LambdaSampleHandler.php
こちらのサイトを参考にさせていただきました。
https://www.rainorshine.asia/2023/05/29/post3968.html

常駐ワーカーがjobクラスを処理していくので、シームレスにコネクション変えただけで変更できるような作りにしたかったので、jobクラスを呼ぶようにしております。
```LambdaSampleHandler.php
<?php
namespace App\Console\Commands;

use Illuminate\Console\Command;

use function json_decode;
use function json_encode;

/**
 * @package App\Console\Commands\Lambda
 */
class LambdaSampleHandler extends Command
{
    protected $signature = 'lambda:sample {event}';
    protected $description = 'Lambda用SQSキューメッセージ処理コマンドの例';

    /**
     * Execute the console command.
     *
     * @return int
     */
    public function handle(): int
    {
        $event = json_decode($this->argument('event'), true);
        $result = $this->exec($event);
        $this->output->write($result);
        return 0;
    }

    private function exec(array $event): string
    {
        foreach ($event['Records'] as $record) {
            $body = json_decode($record['body'], true);

            $jobClass = $body['displayName'] ?? $body['data']['commandName'] ?? null;
            $serialized = $body['data']['command'] ?? null;

            if ($jobClass && class_exists($jobClass) && is_string($serialized)) {
                try {
                    /** @var object $job */
                    $job = unserialize($serialized);

                    if (method_exists($job, 'handle')) {
                        $job->handle();
                    } else {
                        \Log::warning("Jobに handle メソッドが存在しません: {$jobClass}");
                    }
                } catch (\Throwable $e) {
                    \Log::error("Job復元または実行に失敗: " . $e->getMessage(), [
                        'exception' => $e,
                        'job' => $jobClass,
                        'raw' => $serialized,
                    ]);
                }
            } else {
                \Log::warning("無効なジョブ形式: class={$jobClass}, serialized=" . substr((string)$serialized, 0, 100));
            }
        }

        return json_encode([
            'batchItemFailures' => [],
        ]);
    }

}
```

### コマンド
#### laravelルートへ移動
```
$ cd laravel
```

#### Dockerコンテナをビルド
最後にserverlessコマンドたたいた時にエラーがでてハマりました。
原因は
--no-cache --provenance=false
これをつけてなかったかららしく、こちらのサイトを参考にさせていただきました。
https://qiita.com/s_moriyama/items/f1cfae33f7381fa81e32
```bash
docker build --no-cache --provenance=false --file lambda/Dockerfile -t laravel-lambda-fixed .
docker tag laravel-lambda-fixed <awsアカウントID>.dkr.ecr.ap-northeast-1.amazonaws.com/laravel-lambda-fixed:latest
```
#### ECRへpush
```bash
aws ecr get-login-password --region ap-northeast-1 | docker login --username AWS --password-stdin <awsアカウントID>.dkr.ecr.ap-northeast-1.amazonaws.com
docker push <awsアカウントID>.dkr.ecr.ap-northeast-1.amazonaws.com/laravel-lambda-fixed:latest
```
#### serverlessコマンドでデプロイ
```bash
cd lambda
serverless deploy
```
最後こんなのでます。
```
✔ Service deployed to stack laravel-lambda-handler-dev (39s)

functions:
  sqsWorker: laravel-lambda-handler-dev-sqsWorker
```

#### AWS LambdaのCMDの上書き
lambdaのデプロイされた関数のイメージタグにこんな箇所があり、ここをHandlerのコマンド引数にしましょう
```
class LambdaSampleHandler extends Command
{
    protected $signature = 'lambda:sample {event}';
```
CMDの上書き　lambda:sample
![スクリーンショット 2025-05-25 162611.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1032988/7f837334-4c3f-44c8-8171-516b873a4b43.png)