LaravelをAWS Lambdaで動作させてデータベースにはAurora Serverlessを使ってみた

LaravelをAWS Lambdaで動作させることができる素敵な時代になっていたので、Laravel SailとLaravel Breezeでログイン機能を実装したプロジェクトをServerless Frameworkを利用してデプロイしてみました。

## 前提
[こちらのGitHubリポジトリ](https://github.com/kai-kou/laravel-sail-sample-projects/tree/main)を利用してデプロイ環境を構築する手順です。
環境構築後の内容は[こちらのブランチ](https://github.com/kai-kou/laravel-sail-sample-projects/tree/feature/deploy-aws-lambda)においています。

## 手順
### Serverless Frameworkのインストール
セットアップスクリプトが提供されているのでそれを利用します。

```console
> curl -o- -L https://slss.io/install | bash
> serverless -v
Framework Core: 2.30.3 (standalone)
Plugin: 4.5.1
SDK: 4.2.0
Components: 3.7.4
```
Serverless Getting Started Guide
https://www.serverless.com/framework/docs/getting-started/
### Brefを利用してAWS Lambda用にLaravelを構成する
Brefというパッケージを利用するとAWS LambdaでLaravelを動作させる環境構築がとても簡単にできます。
Bref - Serverless PHP made simple
https://bref.sh/
brefphp/laravel-bridge: Package to use Laravel on AWS Lambda with Bref
https://github.com/brefphp/laravel-bridge
LaravelをコンテナにしてLambdaでデプロイするのが超簡単になった2021年 - Qiita
https://qiita.com/umihico/items/514cf792d30bf3706ef5
パッケージをインストールしてServerless Framework用の設定ファイルを生成します。

```console
> ./vendor/bin/sail composer require bref/bref bref/laravel-bridge
> ./vendor/bin/sail artisan vendor:publish --tag=serverless-config
```
### `serverless.yml`の編集
生成された`serverless.yml`ファイルにAurora Serverlessに必要となるリソースを追加します。下記の記事を参考にさせてもらいました。(感謝

Serverless FrameworkでAurora Postgres + VPC Lambda + RDS Proxyをデプロイする | DevelopersIO
https://dev.classmethod.jp/articles/rds-proxy-deploy-with-serverless-framework/
Aurora Serverless をCloudFormationを利用して設置してみた | DevelopersIO
https://dev.classmethod.jp/articles/aurora-serverless-by-cfn/
Brefを使って完全サーバレスな、Symfony + Vue.js + Aurora Serverless製TODOリストを作る - Qiita
https://qiita.com/ippey_s/items/bbb4e329c0a919d1a8b5

`serverless.yml`と設定ファイルは下記のようになりました。

```yaml:serverless.yml
service: laravel

provider:
  name: aws
  # The AWS region in which to deploy (us-east-1 is the default)
  region: ap-northeast-1
  # The stage of the application, e.g. dev, production, staging… ('dev' is the default)
  stage: dev
  runtime: provided.al2

custom:
  defaultStage: dev
  profiles:
    dev: sls-itg
    stg: sls-stg
    prd: sls-prd
  environments: ${file(./config/config.${opt:stage, self:custom.defaultStage}.yml)}
  secret: ${file(./config/secret/.secret.${opt:stage, self:custom.defaultStage}.yml)}

package:
  # Directories to exclude from deployment
  exclude:
    - node_modules/**
    - public/storage
    - resources/assets/**
    - storage/**
    - tests/**

functions:
  # This function runs the Laravel website/API
  web:
    handler: public/index.php
    timeout: 28 # in seconds (API Gateway has a timeout of 29 seconds)
    layers:
      - ${bref:layer.php-74-fpm}
    events:
      - httpApi: "*"
      # This function lets us run artisan commands in Lambda
    vpc:
      securityGroupIds:
        - !Ref LambdaSecurityGroup
      subnetIds:
        - !Ref PrivateSubnetA
        - !Ref PrivateSubnetC
    environment:
      DB_PORT: ${self:custom.environments.DB_PORT}
      DB_HOST: !GetAtt DBCluster.Endpoint.Address
      DB_PASSWORD: ${self:custom.secret.PASSWORD}

  artisan:
    handler: artisan
    timeout: 120 # in seconds
    layers:
      - ${bref:layer.php-74} # PHP
      - ${bref:layer.console} # The "console" layer
    vpc:
      securityGroupIds:
        - !Ref LambdaSecurityGroup
      subnetIds:
        - !Ref PrivateSubnetA
        - !Ref PrivateSubnetC
    environment:
      DB_PORT: ${self:custom.environments.DB_PORT}
      DB_HOST: !GetAtt DBCluster.Endpoint.Address
      DB_PASSWORD: ${self:custom.secret.PASSWORD}

plugins:
  # We need to include the Bref plugin
  - ./vendor/bref/bref

resources:
  Resources:
    # The S3 bucket that stores the assets
    Assets:
      Type: AWS::S3::Bucket
      Properties:
        BucketName: ${self:custom.environments.AWS_BUCKET}
    # The policy that makes the bucket publicly readable
    AssetsBucketPolicy:
      Type: AWS::S3::BucketPolicy
      Properties:
        Bucket: !Ref Assets # References the bucket we defined above
        PolicyDocument:
          Statement:
            - Effect: Allow
              Principal: "*" # everyone
              Action: "s3:GetObject" # to read
              Resource: !Join ["/", [!GetAtt Assets.Arn, "*"]] # things in the bucket
              # alternatively you can write out Resource: 'arn:aws:s3:::<bucket-name>/*'
    ## VPC Resource
    VPC:
      Type: AWS::EC2::VPC
      Properties:
        CidrBlock: 10.0.0.0/24
        Tags:
          - { Key: Name, Value: Sample VPC }
    PrivateSubnetA:
      Type: AWS::EC2::Subnet
      Properties:
        VpcId: !Ref VPC
        CidrBlock: 10.0.0.0/25
        AvailabilityZone: ap-northeast-1a
        Tags:
          - { Key: Name, Value: Sample Private A }
    PrivateSubnetC:
      Type: AWS::EC2::Subnet
      Properties:
        VpcId: !Ref VPC
        CidrBlock: 10.0.0.128/25
        AvailabilityZone: ap-northeast-1c
        Tags:
          - { Key: Name, Value: Sample Private C }
    LambdaSecurityGroup:
      Type: AWS::EC2::SecurityGroup
      Properties:
        GroupDescription: SecurityGroup for Lambda Functions
        VpcId: !Ref VPC
        Tags:
          - Key: "Name"
            Value: "LambdaSecurityGroup"
    AuroraSecurityGroup:
      Type: AWS::EC2::SecurityGroup
      Properties:
        GroupDescription: SecurityGroup for Aurora
        VpcId: !Ref VPC
        SecurityGroupIngress:
          - IpProtocol: tcp
            FromPort: ${self:custom.environments.DB_PORT}
            ToPort: ${self:custom.environments.DB_PORT}
            CidrIp: 10.0.0.0/24
        Tags:
          - Key: "Name"
            Value: "AuroraSecurityGroup"
      DependsOn: VPC
    ## RDS Resource
    DBSubnetGroup:
      Type: AWS::RDS::DBSubnetGroup
      Properties:
        DBSubnetGroupDescription: "SampleDB subnet group"
        DBSubnetGroupName: sampledb-subnet-group
        SubnetIds:
          - !Ref PrivateSubnetA
          - !Ref PrivateSubnetC
    DBCluster:
      Type: AWS::RDS::DBCluster
      Properties:
        DatabaseName: ${self:custom.environments.DB_DATABASE}
        Engine: aurora-mysql
        EngineMode: serverless
        MasterUsername: ${self:custom.secret.USER_NAME}
        MasterUserPassword: ${self:custom.secret.PASSWORD}
        DBClusterParameterGroupName: !Ref DBClusterParameterGroup
        DBSubnetGroupName: !Ref DBSubnetGroup
        VpcSecurityGroupIds:
          - !Ref AuroraSecurityGroup
      DependsOn: DBSubnetGroup
    DBClusterParameterGroup:
      Type: AWS::RDS::DBClusterParameterGroup
      Properties:
        Description: A parameter group for aurora
        Family: aurora-mysql5.7
        Parameters:
          time_zone: "Asia/Tokyo"
          character_set_client: "utf8"
          character_set_connection: "utf8"
          character_set_database: "utf8"
          character_set_results: "utf8"
          character_set_server: "utf8"
    AuroraSecret:
      Type: AWS::SecretsManager::Secret
      Properties:
        Name: Sample/aurora
        SecretString: '{"username":"${self:custom.secret.USER_NAME}", "password":"${self:custom.secret.PASSWORD}"}'
    SecretTargetAttachment:
      Type: AWS::SecretsManager::SecretTargetAttachment
      Properties:
        SecretId: !Ref AuroraSecret
        TargetId: !Ref DBCluster
        TargetType: "AWS::RDS::DBCluster"
      DependsOn: DBCluster
```

```yaml:config/secret/.secret.dev.yml
USER_NAME: root
PASSWORD: password
```

データベース名、バケット名は任意で指定します。

```yaml:config/config.dev.yml
DB_PORT: 3306
DB_DATABASE: hoge_app
AWS_BUCKET: kai-laravel-test
```

#### Lambda関数に環境変数を追加する
Brefが自動生成してくれるLambda関数のリソースにAurora Serverlessへアクセスするための環境変数を設定します。Aurora Serverlessの場合、インスタンスは自動で生成されるので、`DBCluster.Endpoint.Address`でエンドポイントを取得します。

```yaml
functions:
  # This function runs the Laravel website/API
  web:
    handler: public/index.php
    timeout: 28 # in seconds (API Gateway has a timeout of 29 seconds)
    layers:
      - ${bref:layer.php-74-fpm}
    events:
      - httpApi: "*"
      # This function lets us run artisan commands in Lambda
    vpc:
      securityGroupIds:
        - !Ref LambdaSecurityGroup
      subnetIds:
        - !Ref PrivateSubnetA
        - !Ref PrivateSubnetC
    environment:
      DB_PORT: ${self:custom.environments.DB_PORT}
      DB_HOST: !GetAtt DBCluster.Endpoint.Address
      DB_PASSWORD: ${self:custom.secret.PASSWORD}

  artisan:
    handler: artisan
    timeout: 120 # in seconds
    layers:
      - ${bref:layer.php-74} # PHP
      - ${bref:layer.console} # The "console" layer
    vpc:
      securityGroupIds:
        - !Ref LambdaSecurityGroup
      subnetIds:
        - !Ref PrivateSubnetA
        - !Ref PrivateSubnetC
    environment:
      DB_PORT: ${self:custom.environments.DB_PORT}
      DB_HOST: !GetAtt DBCluster.Endpoint.Address
      DB_PASSWORD: ${self:custom.secret.PASSWORD}
```

#### クラスターの設定

`EngineMode: serverless`と設定するとAurora Serverlessとしてクラスター作成できます。

AWS::RDS::DBCluster - AWS CloudFormation
https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/aws-resource-rds-dbcluster.html#cfn-rds-dbcluster-enginemode

```yaml
    DBCluster:
      Type: AWS::RDS::DBCluster
      Properties:
        DatabaseName: ${self:custom.environments.DB_DATABASE}
        Engine: aurora-mysql
        EngineMode: serverless
        MasterUsername: ${self:custom.secret.USER_NAME}
        MasterUserPassword: ${self:custom.secret.PASSWORD}
        DBClusterParameterGroupName: !Ref DBClusterParameterGroup
        DBSubnetGroupName: !Ref DBSubnetGroup
        VpcSecurityGroupIds:
          - !Ref AuroraSecurityGroup
      DependsOn: DBSubnetGroup
```

DBClusterParameterGroupで`character_set`を指定していますが、これはMySQL5.7.7未満だと標準のcharasetが`utf8mb4`となり、unique制約をつけたカラムで文字数オーバーになるのを回避するためです。
詳細は下記が詳しいです。

Laravel5.4以上、MySQL5.7.7未満 でusersテーブルのマイグレーションを実行すると Syntax error が発生する - Qiita
https://qiita.com/beer_geek/items/6e4264db142745ea666f

```yaml
    DBClusterParameterGroup:
      Type: AWS::RDS::DBClusterParameterGroup
      Properties:
        Description: A parameter group for aurora
        Family: aurora-mysql5.7
        Parameters:
          time_zone: "Asia/Tokyo"
          character_set_client: "utf8"
          character_set_connection: "utf8"
          character_set_database: "utf8"
          character_set_results: "utf8"
          character_set_server: "utf8"
```

### デプロイ
`serverless deploy`コマンドでデプロイします。時間がかかるのでデプロイ中はお茶でものんでまったりと待ちます。

```console
> ./vendor/bin/sail npm run prod
> serverless deploy
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/48549/def4dceb-63b1-d717-524d-2105558781d4.png)
デプロイ後、エンドポイントにアクセスして以下のように表示されたらOKです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/48549/7fb6dcc0-b87a-a4ba-3779-f556ff52e444.png)

### 静的ファイルの配置

S3バケットにプロジェクトのpublicディレクトリをコピーします。これはAWS Lambda関数へのアクセスだけだと、`public/css/app.css`などへアクセスできないので、デプロイで作成した、アセット用のS3バケットへファイルを配置するためです。
ファイルがないとブラウザでアクセスした場合、ログイン画面へアクセスすると以下のようになります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/48549/85a836fd-c583-d387-3f53-e73718ac3fc1.png)

```console
> aws s3 sync public s3://kai-laravel-test/public --delete
```

ファイルコピー後、下記のようになっていたらOKです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/48549/1141e8d3-be06-df92-7458-8699b10478b1.png)

#### マイグレーション

Brefには`cli`コマンドが提供されているので、それを利用してAWS Lambdaの`laravel-dev-artisan`関数を利用してマイグレーションします。
`cli`コマンドはAWSのクレデンシャルファイルを参照するので、`sail`コマンドを挟まないほうが手っ取り早いです。

```console
> ./vendor/bin/bref cli --region=ap-northeast-1 laravel-dev-artisan -- migrate
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/48549/81664d0d-7422-bf0f-27d1-6fa50671e408.png)

### 動作確認

実際にデプロイした環境にアクセスしています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/48549/ab81e15e-fc29-ae28-4a2c-2e734397d451.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/48549/ba244bdc-34d4-95e7-b553-5b657c139dcc.png)
今回はメール送信については対応していないので、パスワード変更のためのメール送信はエラーとなります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/48549/33415064-5732-773b-69b1-83c962e59ebc.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/48549/29b869b5-af41-84ee-ae0f-9b8b2571c677.png)

### リソースの削除

動作確認ができてリソースが不要であれば削除します。S3バケットにファイルがあるまま削除処理を実行するとエラーになるので、ファイルを削除しておきます。

```console
> aws s3 rm --recursive s3://kai-laravel-test/
> serverless remove
```

## まとめ

実用するにはまだまだ対応するべきことがありますが、ひとまずはやりたいことが実現できました。
やったぜ

## 参考

Serverless Getting Started Guide
https://www.serverless.com/framework/docs/getting-started/
Bref - Serverless PHP made simple
https://bref.sh/
brefphp/laravel-bridge: Package to use Laravel on AWS Lambda with Bref
https://github.com/brefphp/laravel-bridge
LaravelをコンテナにしてLambdaでデプロイするのが超簡単になった2021年 - Qiita
https://qiita.com/umihico/items/514cf792d30bf3706ef5
Serverless FrameworkでAurora Postgres + VPC Lambda + RDS Proxyをデプロイする | DevelopersIO
https://dev.classmethod.jp/articles/rds-proxy-deploy-with-serverless-framework/
Aurora Serverless をCloudFormationを利用して設置してみた | DevelopersIO
https://dev.classmethod.jp/articles/aurora-serverless-by-cfn/
Brefを使って完全サーバレスな、Symfony + Vue.js + Aurora Serverless製TODOリストを作る - Qiita
https://qiita.com/ippey_s/items/bbb4e329c0a919d1a8b5
AWS::RDS::DBCluster - AWS CloudFormation
https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/aws-resource-rds-dbcluster.html#cfn-rds-dbcluster-enginemode
Laravel5.4以上、MySQL5.7.7未満 でusersテーブルのマイグレーションを実行すると Syntax error が発生する - Qiita
https://qiita.com/beer_geek/items/6e4264db142745ea666f
CreateDBClusterParameterGroup-Amazon Relational Database Service
https://docs.aws.amazon.com/AmazonRDS/latest/APIReference/API_CreateDBClusterParameterGroup.html
AWS::RDS::DBInstance - AWS CloudFormation
https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/aws-properties-rds-database-instance.html#cfn-rds-dbinstance-masterusername
エラー: The policy failed legacy parsing - Qiita
https://qiita.com/sot528/items/f248db0b69c7e1d34220
Cloudformationでエラー: Has prohibited field Resource - Qiita
https://qiita.com/sot528/items/2263fab0dbb55d6ce032
Aurora Serverlessの導入時に気をつけるべきこと | DevelopersIO
https://dev.classmethod.jp/articles/lessons-learned-from-up-and-running-aurora-serverless/
Console commands - Bref
https://bref.sh/docs/runtimes/console.html
Serverless Frameworkで環境変数を外部ファイルから読み込み、環境毎に自動で切り替えてみる | DevelopersIO
https://dev.classmethod.jp/articles/serverless-framework-conf-change/
データベースの使用-Bref
https://bref.sh/docs/environment/database.html
Brefが進化してたのでサーバーレスLaravel環境作ってみた - Qiita
https://qiita.com/ippey_s/items/25129dde8c7fe85479e4
Creating serverless PHP websites - Bref
https://bref.sh/docs/websites.html#architectures