0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2025-05-18

概要

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イメージだとうまくいかない部分があったので、このイメージを採用

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

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

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

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コマンドでデプロイ

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

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?