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

[前回](https://qiita.com/kai_kou/items/cf9a58c4d4e12f487b9a)、データベースにServerless Frameworkを利用していたのをAuroraとRDS Proxyを利用する版となります。
LaravelをAWS Lambdaで動作させることができる素敵な時代になっていたので、Laravel SailとLaravel Breezeでログイン機

## 前提

[こちらの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 RDSProxy.Endpoint
      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 RDSProxy.Endpoint
      DB_PASSWORD: ${self:custom.secret.PASSWORD}

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

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"
    # Aurora Serverlessの場合は不要
    DBInstance1:
      Type: AWS::RDS::DBInstance
      Properties:
        DBClusterIdentifier: !Ref DBCluster
        DBSubnetGroupName: !Ref DBSubnetGroup
        Engine: aurora-mysql
        DBInstanceClass: db.t3.medium
      DependsOn: DBCluster
    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
    # Aurora ServerlessだとRDSProxyは使えない
    # https://docs.aws.amazon.com/ja_jp/AmazonRDS/latest/AuroraUserGuide/rds-proxy.html#rds-proxy.limitations
    ProxyRole:
      Type: AWS::IAM::Role
      Properties:
        RoleName: sample-proxy-role
        AssumeRolePolicyDocument:
          Version: "2012-10-17"
          Statement:
            - Effect: Allow
              Principal:
                Service:
                  - "rds.amazonaws.com"
              Action:
                - "sts:AssumeRole"
        Path: /
      DependsOn: DBCluster
    ProxyRolePolicies:
      Type: "AWS::IAM::Policy"
      Properties:
        PolicyName: RdsProxyPolicy
        PolicyDocument:
          Version: "2012-10-17"
          Statement:
            - Effect: Allow
              Action:
                - "secretsmanager:GetResourcePolicy"
                - "secretsmanager:GetSecretValue"
                - "secretsmanager:DescribeSecret"
                - "secretsmanager:ListSecretVersionIds"
              Resource:
                - !Ref AuroraSecret
            - Effect: Allow
              Action:
                - "kms:Decrypt"
              Resource: "arn:aws:kms:${self:provider.region}:#{AWS::AccountId}:key/*"
              Condition:
                StringEquals:
                  kms:ViaService: "secretsmanager.${self:provider.region}.amazonaws.com"
        Roles:
          - Ref: ProxyRole
      DependsOn: AuroraSecret
    RDSProxy:
      Type: AWS::RDS::DBProxy
      Properties:
        DBProxyName: SampleAuroraProxy
        Auth:
          - SecretArn: !Ref AuroraSecret
        VpcSecurityGroupIds:
          - !Ref AuroraSecurityGroup
        VpcSubnetIds:
          - !Ref PrivateSubnetA
          - !Ref PrivateSubnetC
        EngineFamily: MYSQL
        RequireTLS: false
        RoleArn: !GetAtt ProxyRole.Arn
      DependsOn: AuroraSecret
    DBProxyTargetGroup:
      Type: AWS::RDS::DBProxyTargetGroup
      Properties:
        TargetGroupName: default
        DBProxyName: !Ref RDSProxy
        DBClusterIdentifiers:
          - !Ref DBCluster
      DependsOn: RDSProxy
```

```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関数のリソースにRDS Proxyへアクセスするための環境変数を設定します。`RDSProxy.Endpoint`でエンドポイントを取得します。

```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 RDSProxy.Endpoint
      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 RDSProxy.Endpoint
      DB_PASSWORD: ${self:custom.secret.PASSWORD}
```

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

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
```

## まとめ

データベースのリソース以外はAurora Serverlessとほぼほぼ同じとなりました。
本来ならAurora ServerlessとRDS Proxyの組み合わせを試したかったのですが、Aurora Serverlessには対応していなかったのが、残念ポイントでした。

Amazon RDS Proxy による接続の管理 - Amazon Aurora
https://docs.aws.amazon.com/ja_jp/AmazonRDS/latest/AuroraUserGuide/rds-proxy.html#rds-proxy.limitations

>Aurora サーバーレスクラスターでは RDS Proxy を使用できません。


## 参考

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

Amazon RDS Proxy による接続の管理 - Amazon Aurora
https://docs.aws.amazon.com/ja_jp/AmazonRDS/latest/AuroraUserGuide/rds-proxy.html#rds-proxy.limitations