Laravel de Lambda

 PHP Laravelをサーバーレス化する方法として、[AWS SAM](https://aws.amazon.com/jp/serverless/sam/)を使用した手順は[Amazon Web Services ブログ](https://aws.amazon.com/jp/blogs/news/the-serverless-lamp-stack-part-4-building-a-serverless-laravel-application/)で公開されています。
 今回は[コンテナイメージ](https://aws.amazon.com/jp/blogs/news/new-for-aws-lambda-container-image-support/)化することで、より簡単に`AWS Lambda`上で動かす手順を記載します。

# 前提
## PHPをサーバーレス化するための必須ライブラリ
  - [bref](https://bref.sh/)
  - 拡張機能
    - [brefphp/extra-php-extensions](https://github.com/brefphp/extra-php-extensions)

## Laravel用Package
  - [brefphp/laravel-bridge](https://github.com/brefphp/laravel-bridge)


# Laravelのセットアップ
## Laravelインストーラー
```php
composer global require laravel/installer
```
## プロジェクト作成
```php
laravel new laravel-function
```

## ログ出力先として標準出力の追加
```php:config/logging.php
    ...
    'channels' => [
        ...
        'stdout' => [
            'driver' => 'monolog',
            'level' => env('LOG_LEVEL', 'debug'),
            'handler' => StreamHandler::class,
            'formatter' => env('LOG_STDERR_FORMATTER'),
            'with' => [
                'stream' => 'php://stdout',
            ],
        ],
    ],
    ...
```

# 必須ライブラリの追加
## bref
```shell
composer require bref/laravel-bridge --update-with-dependencies
```

### `worker.php`の作成
SQSからイベントメッセージを受信するためのエントリーポイントを作成

```shell
php artisan vendor:publish --tag=serverless-worker
```

## aws
```shell
composer require aws/aws-sdk-php-laravel
composer require aws/aws-sdk-php
```

### `config/aws.php`の作成
```shell
php artisan vendor:publish  --provider="Aws\Laravel\AwsServiceProvider"
```
### AWS用プロバイダ設定追加
```php:config/app.php
    'providers' => [
        ...
        Aws\Laravel\AwsServiceProvider::class,
        ...
    ],
    
    'aliases' => [
        ...
        'AWS' => Aws\Laravel\AwsFacade::class,
    ]
```
### Credentials
defaultの認証情報を利用するため、不要な設定は削除

```php:config/queue.php
...
        'sqs' => [
            'driver' => 'sqs',
//            'key' => env('AWS_ACCESS_KEY_ID'),
//            'secret' => env('AWS_SECRET_ACCESS_KEY'),
            'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'),
            'queue' => env('SQS_QUEUE', 'default'),
            'suffix' => env('SQS_SUFFIX'),
            'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
            'after_commit' => false,
        ],
...
```

# 処理の実装
## API
```php:routes/api.php
...
// API 例
Route::get('/greeting', function () {
  return 'Hello World';
});

// 非同期Job呼び出し用
Route::get('/job', function (Request $request) {
  $message = $request->get("msg");
  \App\Jobs\SimpleJob::dispatch($message);
  return 'OK';
});
```

## Job

SQS経由で非同期に実行するJob

```php:app/Jobs/SimpleJob.php
<?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\Log;

class SimpleJob implements ShouldQueue
{
    use Dispatchable;
    use InteractsWithQueue;
    use Queueable;
    use SerializesModels;

    /** @var string */
    private $message;

    public function __construct(string $message)
    {
        $this->message = $message;
    }

    /**
     * Execute the job.
     *
     * @return void
     */
    public function handle()
    {
        Log::info('Message: ' . $this->message);
    }
}
```

# ローカル実行環境
## localstack
localstackの初期化用シェルを作成

```shell:docker/sqs/init.sh
#!/bin/sh
awslocal sqs create-queue --queue-name localstack_dead_letter_queue
awslocal sqs create-queue --queue-name LaravelQueue \
  --attributes '{"RedrivePolicy": "{\"deadLetterTargetArn\":\"arn:aws:sqs:ap-northeast-1:000000000000:localstack_dead_letter_queue\",\"maxReceiveCount\":3}"}'
# Lambdaの作成
awslocal lambda create-function \
    --function-name 'laravel-lambda-job-bridge' \
    --runtime=provided.al2 \
    --role=dummyrole \
    --handler=worker.php
awslocal lambda create-event-source-mapping \
    --event-source-arn arn:aws:sqs:ap-northeast-1:000000000000:LaravelQueue  \
    --function-name "laravel-lambda-job-bridge"
```
## xdebug
設定ファイルを作成

```ini:docker/php/xdebug.ini
[xdebug]
xdebug.start_with_request=yes
xdebug.client_host=host.docker.internal
xdebug.client_port=9003
xdebug.log=/tmp/xdebug.log
```

## デバッグ用Dockerfile
- API用

```Dockerfile:debug.Dockerfile
FROM bref/php-81-fpm-dev
COPY --from=bref/extra-redis-php-81:0.11.19 /opt /opt
COPY --from=bref/extra-xdebug-php-81:0.11.19 /opt /opt

COPY docker/php/xdebug.ini /opt/bref/etc/php/conf.d/zz-xdebug.ini

COPY --from=composer:2.1.12 /usr/bin/composer /usr/bin/composer

ENV COMPOSER_ALLOW_SUPERUSER 1
ENV COMPOSER_HOME /composer
```
- JOB用

```Dockerfile:jobs.debug.Dockerfile
FROM bref/php-81
COPY --from=bref/extra-redis-php-81:0.11.19 /opt /opt
COPY --from=bref/extra-xdebug-php-81:0.11.19 /opt /opt

COPY docker/php/xdebug.ini /opt/bref/etc/php/conf.d/zz-xdebug.ini

COPY --from=composer:2.1.12 /usr/bin/composer /usr/bin/composer

ENV COMPOSER_ALLOW_SUPERUSER 1
ENV COMPOSER_HOME /composer
```


## docker-compose

```yaml:docker-compose.yml
version: "3.5"

services:
    web:
        image: bref/fpm-dev-gateway
        ports:
            - '8000:80'
        volumes:
            - .:/var/task
        links:
            - php
        environment:
            HANDLER: public/index.php
            DOCUMENT_ROOT: public
    php:
        build:
            context: .
            dockerfile: debug.Dockerfile
        env_file:
            - .env.example
        environment:
            LOG_CHANNEL: stdout
            QUEUE_CONNECTION: sqs
            SQS_PREFIX: 'http://localstack:4566/000000000000'
            SQS_QUEUE: LaravelQueue
            AWS_DEFAULT_REGION: ap-northeast-1
            XDEBUG_MODE: develop,debug
            PHP_IDE_CONFIG: serverName=docker
        volumes:
            - ~/.aws:/.aws
            - .:/var/task:ro

    # sqs
    localstack:
        image: localstack/localstack:latest
        environment:
            HOSTNAME_EXTERNAL: localstack
            DEFAULT_REGION: ap-northeast-1
            DATA_DIR: /tmp/localstack/data
            SERVICES: sqs
        ports:
            - "4566:4566"
        volumes:
            - ./docker/sqs:/docker-entrypoint-initaws.d

    job:
        build:
            context: .
            dockerfile: jobs.debug.Dockerfile
        depends_on:
            - localstack
        env_file:
            - .env.example
        environment:
            LOG_CHANNEL: stdout
            QUEUE_CONNECTION: sqs
            SQS_PREFIX: 'http://localstack:4566/000000000000'
            SQS_QUEUE: LaravelQueue
            AWS_CONFIG_FILE: /.aws/config
            AWS_SHARED_CREDENTIALS_FILE: /.aws/credentials
            AWS_DEFAULT_REGION: ap-northeast-1
            XDEBUG_MODE: develop,debug
            PHP_IDE_CONFIG: serverName=docker
        entrypoint:
            - php
            - artisan
            - queue:work
        volumes:
            - ~/.aws:/.aws
            - .:/var/task:ro
```

# ローカル実行
```shell
docker-compose up -d
```

## 動作確認

```shell
> curl 'http://localhost:8000/api/greeting'
Hello World
> curl 'http://localhost:8000/api/job?msg=Success'
OK
```


# Dockerイメージの作成
## Rest API用
- `Dockerfile`
  - `bref/php-81-fpm`を使用
  - Redisを利用する場合、[brefphp/extra-php-extensions](https://github.com/brefphp/extra-php-extensions)から `extra-redis-php-81`を使用
      - 今回は例示として追加

```Dockerfile:Dockerfile
FROM bref/php-81-fpm
COPY --from=bref/extra-redis-php-81:0.11.18 /opt /opt
COPY . /var/task
CMD [ "public/index.php" ]
```

```shell
docker build -t {AWS::AccountId}.dkr.ecr.ap-northeast-1.amazonaws.com/laravel-api-function:latest .
```

## Job 用
- `jobs.Dockerfile`
  - `bref/php-81`を使用
  - Redisを利用する場合、[brefphp/extra-php-extensions](https://github.com/brefphp/extra-php-extensions)から `extra-redis-php-81`を使用
      - 今回は例示として追加

```Dockerfile:jobs.Dockerfile
FROM bref/php-81
COPY --from=bref/extra-redis-php-81:0.11.18 /opt /opt
COPY . /var/task
CMD [ "worker.php" ]
```

```shell
docker build -f jobs.Dockerfile -t {AWS::AccountId}.dkr.ecr.ap-northeast-1.amazonaws.com/laravel-jobs-function:latest .
```

# ECR
## dockerイメージを保存するリポジトリを作成

```shell:laravel-api-function
aws ecr create-repository --repository-name laravel-api-function --region ap-northeast-1
```
```shell:laravel-jobs-function
aws ecr create-repository --repository-name laravel-jobs-function --region ap-northeast-1
```

## dockerイメージをリポジトリにプッシュ

- ECRログイン

```shell
aws ecr get-login-password --region ap-northeast-1 | docker login --username AWS --password-stdin {AWS::AccountId}.dkr.ecr.ap-northeast-1.amazonaws.com
```

- リポジトリにプッシュ

```shell:laravel-api-function
docker push {AWS::AccountId}.dkr.ecr.ap-northeast-1.amazonaws.com/laravel-api-function:latest
```
```shell:laravel-jobs-function
docker push {AWS::AccountId}.dkr.ecr.ap-northeast-1.amazonaws.com/laravel-jobs-function:latest
```

# SQS
動作確認用のSQSキューを作成

```shell
aws sqs create-queue --queue-name LaravelQueue
```

# デプロイ
## CloudFormation
### `API Gateway`から`Lambda Function`を起動する

- CloudFormationファイルの作成

```yaml:api.yaml
AWSTemplateFormatVersion: "2010-09-09"
Parameters:
  LogGroupName:
    Type: String
    Default: /aws/api-gateway/laravel
  FunctionName:
    Type: String
    Default: laravel-api-function
  QueueName:
    Type: String
    Default: LaravelQueue
Resources:
  ApiFunction:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: !Ref FunctionName
      MemorySize: 1024
      Timeout: 300
      Code:
        ImageUri: !Sub '${AWS::AccountId}.dkr.ecr.${AWS::Region}.amazonaws.com/laravel-api-function:latest'
      ImageConfig:
        Command:
          - public/index.php
      PackageType: Image
      Environment:
        Variables:
          APP_ENV: development
          LOG_LEVEL: debug
          LOG_CHANNEL: stdout
          QUEUE_CONNECTION: sqs
          SQS_PREFIX: !Sub 'https://sqs.${AWS::Region}.amazonaws.com/${AWS::AccountId}'
          SQS_QUEUE: LaravelQueue
      Role: !GetAtt Role.Arn
  Role:
    Type: AWS::IAM::Role
    Properties:
      RoleName: RoleForLaravelApiFunction
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Sid: ""
            Effect: Allow
            Principal:
              Service:
                - lambda.amazonaws.com
            Action: sts:AssumeRole
      Path: /
      Policies:
        - PolicyName: LaravelApiFunctionPolicy
          PolicyDocument:
            Version: 2012-10-17
            Statement:
              - Effect: Allow
                Action:
                  - logs:CreateLogGroup
                Resource:
                  - !Sub 'arn:aws:logs:${AWS::Region}:${AWS::AccountId}:*'
              - Effect: Allow
                Action:
                  - logs:CreateLogStream
                  - logs:PutLogEvents
                Resource:
                  - !Sub 'arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/*'
              - Effect: Allow
                Action:
                  - sqs:SendMessage
                  - sqs:GetQueueAttributes
                Resource:
                  - !Sub 'arn:aws:sqs:${AWS::Region}:${AWS::AccountId}:${QueueName}'

  HttpAPI:
    Type: AWS::ApiGatewayV2::Api
    Properties:
      Name: !Sub '${AWS::StackName}-apigw'
      ProtocolType: HTTP
  DefaultStage:
    Type: AWS::ApiGatewayV2::Stage
    Properties:
      StageName: $default
      ApiId: !Ref HttpAPI
      AutoDeploy: true
      AccessLogSettings:
        DestinationArn: !Sub 'arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:${LogGroupName}'
        Format: '{ "requestId":"$context.requestId", "ip": "$context.identity.sourceIp", "requestTime":"$context.requestTime", "httpMethod":"$context.httpMethod","routeKey":"$context.routeKey", "status":"$context.status","protocol":"$context.protocol", "responseLength":"$context.responseLength" }'
    DependsOn:
      - ApiGatewayLog
  ApiGatewayLog:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: !Ref LogGroupName
      RetentionInDays: 3

  GetGreeting:
    Type: AWS::ApiGatewayV2::Route
    Properties:
      RouteKey: "GET /api/greeting"
      ApiId: !Ref HttpAPI
      Target: !Join
        - "/"
        - - "integrations"
          - !Ref GETIntegration
  GetJob:
    Type: AWS::ApiGatewayV2::Route
    Properties:
      RouteKey: "GET /api/job"
      ApiId: !Ref HttpAPI
      Target: !Join
        - "/"
        - - "integrations"
          - !Ref GETIntegration
  GETIntegration:
    Type: AWS::ApiGatewayV2::Integration
    Properties:
      ApiId: !Ref HttpAPI
      PayloadFormatVersion: "2.0"
      IntegrationType: AWS_PROXY
      IntegrationMethod: GET
      IntegrationUri: !GetAtt ApiFunction.Arn
  Permission:
    Type: AWS::Lambda::Permission
    Properties:
      Action: lambda:InvokeFunction
      FunctionName: !Ref ApiFunction
      Principal: apigateway.amazonaws.com
      SourceArn: !Sub
        - 'arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${HttpAPI}/*'
        - HttpAPI: !Ref HttpAPI
```
- CloudFormationデプロイ

```shell
aws cloudformation deploy --template api.yaml --stack-name laravel-api-function  --capabilities CAPABILITY_NAMED_IAM
```

### `SQS`をトリガーに起動する
- CloudFormationファイルを作成

```yaml:jobs.yaml
AWSTemplateFormatVersion: "2010-09-09"
Parameters:
  FunctionName:
    Type: String
    Default: laravel-jobs-function
  QueueName:
    Type: String
    Default: LaravelQueue
Resources:
  JobFunction:
    Type: "AWS::Lambda::Function"
    Properties:
      FunctionName: !Ref FunctionName
      MemorySize: 1024
      Code:
        ImageUri: !Sub '${AWS::AccountId}.dkr.ecr.${AWS::Region}.amazonaws.com/laravel-jobs-function:latest'
      ImageConfig:
        Command:
          - worker.php
      PackageType: Image
      Environment:
        Variables:
          APP_ENV: development
          LOG_LEVEL: debug
          LOG_CHANNEL: stdout
          QUEUE_CONNECTION: sqs
          SQS_PREFIX: !Sub 'https://sqs.${AWS::Region}.amazonaws.com/${AWS::AccountId}'
          SQS_QUEUE: !Ref QueueName
      Role: !GetAtt Role.Arn
  Role:
    Type: AWS::IAM::Role
    Properties:
      RoleName: RoleForLaravelJobFunction
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Sid: ""
            Effect: Allow
            Principal:
              Service:
                - lambda.amazonaws.com
            Action: sts:AssumeRole
      Path: /
      Policies:
        - PolicyName: LaravelJobFunctionPolicy
          PolicyDocument:
            Version: 2012-10-17
            Statement:
              - Effect: Allow
                Action:
                  - logs:CreateLogGroup
                Resource:
                  - !Sub 'arn:aws:logs:${AWS::Region}:${AWS::AccountId}:*'
              - Effect: Allow
                Action:
                  - logs:CreateLogStream
                  - logs:PutLogEvents
                Resource:
                  - !Sub 'arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/*'
              - Effect: Allow
                Action:
                  - sqs:ReceiveMessage
                  - sqs:DeleteMessage
                  - sqs:GetQueueAttributes
                Resource:
                  - !Sub 'arn:aws:sqs:${AWS::Region}:${AWS::AccountId}:${QueueName}'
  SQSEvent:
    Type: AWS::Lambda::EventSourceMapping
    Properties:
      BatchSize: 10
      EventSourceArn: !Sub 'arn:aws:sqs:ap-northeast-1:${AWS::AccountId}:${QueueName}'
      FunctionName: !GetAtt JobFunction.Arn
      Enabled: true
```
- CloudFormationデプロイ

```shell
aws cloudformation deploy --template jobs.yaml --stack-name laravel-jobs-function  --capabilities CAPABILITY_NAMED_IAM
```

# 動作確認

## ApiEndpointの取得

```shell
> aws apigatewayv2 get-apis
{
    "Items": [
        {
            "ApiEndpoint": "https://xxxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com",
            "ApiId": "xxxxxxxxxx",
            "ApiKeySelectionExpression": "$request.header.x-api-key",
            "CreatedDate": "2021-12-02T00:00:00+00:00",
            "DisableExecuteApiEndpoint": false,
            "Name": "laravel-api-function-apigw",
            "ProtocolType": "HTTP",
            "RouteSelectionExpression": "$request.method $request.path",
            "Tags": {}
        }
    ]
}
```

## apiの確認

```shell:/api/greeting
> curl '{ApiEndpoint}/api/greeting'
Hello World
```
- レスポンスとして`Hello World`が表示される

## jobの動作確認

```shell:/api/job
> curl '{ApiEndpoint}/api/job?msg=Success'
OK
```
- レスポンスとして`OK`が表示される
- `CloudWatch Logs`の`/aws/lambda/laravel-jobs-function` に `Message: Success`のログが出力される

# まとめ
`PHP Laravel`のコンテナイメージ化を行い、`AWS Lambda`にデプロイ、動作確認するまでの手順を簡単に記載しました。
このまま業務に使用できるものではなく、実際にはVPCやRDSProxy等に接続するための設定、実装が必要になるでしょう。
また、Jobを非同期化するとしてもイベントメッセージがPHPに依存したJobクラスのシリアライズデータとなるため抽象化も難しく、APIとJobで実装をきれいに分けるのも困難です。
そのため、最適解ではありませんが、既存システムが`PHP Laravel`に依存しており、エンジニアに別の言語やフレームワーク等への移行を求める余裕がない場合、あるいはマイクロサービス化ほどの大げさなことにはしたくない場合、負荷を分散させる方法として手段の一つにはなると思います。