Wiz テックブログ

Wizは、最新のIoTやICTサービスをお客様に届ける「ITの総合商社」です。

SSRのバックエンドをServerlessFramework Laravelで構築する

こんにちは。バックエンドエンジニアの高橋です。

SSR(Serverless Next.js)のバックエンドを ServerlessFramework (Laravel) で作ってみました。

今回は以下の要件を満たす仕様

  • LaravelからS3へPutが出来る
  • Laravelからメール送信が出来る

また、GithubActionsによるCI/CDの実装と Cloud9を使ってクライアントからAuroraへ接続する方法までを紹介したいと思います。

アーキテクチャ

f:id:wiz_tak:20210420112420p:plain

セットアップ

ローカルでLaravelをインストールしてプロジェクトを作ったらbrefをインストールします。

$ composer require bref/bref bref/laravel-bridge
$ php artisan vendor:publish --tag=serverless-config

brefはLaravelアプリケーションのサーバレス用ライブラリで、全てを良き感じでやってくれます。

デプロイはServerlessFrameworkがCloudFormationを利用し実行します。

Lambdaで動作させるので、LaravelのSessionやStorage(filesystem)の設定に若干手を加えないといけません。

本家のドキュメント通りにやれば出来ると思いますのでここでは割愛します。

bref.sh

serverless.yml

環境変数を使うのにcustomをこんな感じにしています。

service: serverless-app

provider:
  name: aws
  runtime: provided.al2

  stage: ${opt:stage, self:custom.defaultStage}
  region: ${opt:region, self:custom.defaultRegion}

  environment:
    AWS_BUCKET: !Ref Storage
  iamRoleStatements:
    - Effect: Allow
      Action: s3:*
      Resource:
        - !Sub '${Storage.Arn}'
        - !Sub '${Storage.Arn}/*'

custom:
  defaultStage: develop
  defaultRegion: ap-northeast-1
  environments: ${file(./serverless_config/config.${opt:stage, self:custom.defaultStage}.yml)}
  secret: ${file(./serverless_config/secrets/secrets.${opt:stage, self:custom.defaultStage}.yml)}

package:
  exclude:
    - projdir/.env
    - projdir/node_modules/**
    - projdir/public/storage
    - projdir/resources/assets/**
    - projdir/storage/**
    - projdir/tests/**

envファイルは以下のようにそれぞれ環境毎に設置

/serverless_config/config.develop.yml

AWS_BUCKET_STORAGE: serverless-app-develop
AWS_BUCKET: serverless-app-assets-develop
DB_PORT: 3306
DB_DATABASE: serverless_app_develop

/serverless_config/secrets.develop.yml

USER_NAME: root
PASSWORD: password

APIとなるwebは使用するVPCとAuroraの接続情報を定義、artisanも同様に設定

タスクスケジュールを使う場合は、artisanのeventsに定義します。

〜〜
functions:

  # This function runs the Laravel website/API
  web:
    handler: public/index.php
    timeout: 28
    layers:
      - ${bref:layer.php-74-fpm}
    events:
      - httpApi: '*'

    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}

  # This function lets us run artisan commands in Lambda
  artisan:
    handler: artisan
    timeout: 120
    layers:
      - ${bref:layer.php-74}
      - ${bref:layer.console}

    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}

    # Cron Event
    events:
      - schedule:
        rate: cron(0 0 * * ? *)
        input: '"sample:command"'
〜〜

VPCリソースにはメインVPC/サブネット/LambdaとAuroraのセキュリティグループを記述

※ 後ほどVPCエンドポイントを使うのに必要なDNSホスト名とDNS解決を有効にしておきます。

〜〜
resources:
  Resources:

    # VPC
    VPC:
      Type: AWS::EC2::VPC
      Properties:
        EnableDnsHostnames: true
        EnableDnsSupport: true
        CidrBlock: 192.168.0.0/16
        Tags:
          - Key: Name
            Value: serverless-app-${opt:stage, self:custom.defaultStage}-vpc

    # Private Subnet A
    PrivateSubnetA:
      Type: AWS::EC2::Subnet
      Properties:
        VpcId: !Ref VPC
        CidrBlock: 192.168.3.0/24
        AvailabilityZone: ap-northeast-1a
        Tags:
          - Key: Name
            Value: serverless-app-${opt:stage, self:custom.defaultStage}-private-a

    # Private Subnet C
    PrivateSubnetC:
      Type: AWS::EC2::Subnet
      Properties:
        VpcId: !Ref VPC
        CidrBlock: 192.168.4.0/24
        AvailabilityZone: ap-northeast-1c
        Tags:
          - Key: Name
            Value: serverless-app-${opt:stage, self:custom.defaultStage}-private-c

    # Lambda Security Group
    LambdaSecurityGroup:
      Type: AWS::EC2::SecurityGroup
      Properties:
        GroupDescription: SecurityGroup for Lambda Functions
        VpcId: !Ref VPC
        Tags:
          - Key: Name
            Value: serverless-app-${opt:stage, self:custom.defaultStage}-lambda-sg

    # Aurora Security Group
    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: 192.168.0.0/16
        Tags:
          - Key: Name
            Value: serverless-app-${opt:stage, self:custom.defaultStage}-aurora-sg
      DependsOn: VPC
〜〜

DBリソースはAuroraのクラスターやサブネットパラメータグループを記述

RDSProxyを使わなくてもいいのでここではAuroraServerlessエンジンを指定していますが、 スケールにかかる時間やコストの心配があるならAurora+RDSProxyが良いかもしれません。

〜〜
    # DB Subnet
    DBSubnetGroup:
      Type: AWS::RDS::DBSubnetGroup
      Properties:
        DBSubnetGroupDescription: Subnet Group for Aurora $ {opt:stage, self:custom.defaultStage}
        DBSubnetGroupName: serverless-app-${opt:stage, self:custom.defaultStage}-aurora-sbng
        SubnetIds:
          - !Ref PrivateSubnetA
          - !Ref PrivateSubnetC

    # DB Cluster
    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
        Tags:
          - Key: Name
            Value: serverless-app-aurora-${opt:stage, self:custom.defaultStage}
      DependsOn: DBSubnetGroup

    # Parameter Group
    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"
〜〜

S3リソースにはStorageとAssetで使うバケットの設定を記述

Storageバケットにも参照出来るようにGetObjectを追記しています。

〜〜
    # S3 Storage Bucket
    Storage:
      Type: AWS::S3::Bucket
      Properties:
        BucketName: ${self:custom.environments.AWS_BUCKET_STORAGE}

    # S3 Storage Bucket Policy
    StorageBucketPolicy:
      Type: AWS::S3::BucketPolicy
      Properties:
        Bucket: !Ref Storage
        PolicyDocument:
          Statement:
            - Effect: Allow
              Principal: "*"
              Action: "s3:GetObject"
              Resource: !Join ["/", [!GetAtt Storage.Arn, "*"]]

    # S3 Asset Bucket
    Assets:
      Type: AWS::S3::Bucket
      Properties:
        BucketName: ${self:custom.environments.AWS_BUCKET}

    # S3 Asset Bucket Policy
    AssetsBucketPolicy:
      Type: AWS::S3::BucketPolicy
      Properties:
        Bucket: !Ref Assets
        PolicyDocument:
          Statement:
            - Effect: Allow
              Principal: "*"
              Action: "s3:GetObject"
              Resource: !Join ["/", [!GetAtt Assets.Arn, "*"]]

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

デプロイ

IAMでユーザーを作成し、Githubリポジトリのsecretsに以下を設定します。

AWS_ACCESS_KEY_ID
AWS_SECRET_ACCESS_KEY

.github/workflows/deploy.yml

name: DeployDevelop

on:
  push:
    branches:
      - stag
defaults:
  run:
    working-directory: ./projdir
jobs:
  deploy:
    name: deploy
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: [14.x]
    steps:
      - uses: actions/checkout@v2
      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v1
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: ap-northeast-1

      - name: Install Dependencies
        run: composer install --prefer-dist --optimize-autoloader --no-dev

      - name: npm Install Dependencies
        run: npm ci

      - name: copy env file
        run: |
          cp .env.develop .env

      - name: Output file contents
        run: |
          cat .env

      - name: Generate Key
        run: php artisan key:generate

      - name: Use Node.js ${{ matrix.node-version }}
        uses: actions/setup-node@v1
        with:
          node-version: ${{ matrix.node-version }}

      - name: Serverless Deploy Lambda
        run: |
          npm i -g serverless@2.x
          serverless deploy
        env:
          AWS_REGION: ap-northeast-1
          AWS_VERSION: latest
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

      - name: Migrate
        run: vendor/bin/bref cli --region=ap-northeast-1 serverless-app-develop-artisan -- migrate --force

後で作成したLambdaFunctionsをAWSコンソールから確認するとすぐ理解出来るかと思いますが、migrateはLambda関数名で実行させます。

またserverless をインストールしておけばクライアントマシンからでもdeploy出来ます。

$ serverless deploy 

これでAPIGatewayからLambdaが実行出来る環境が出来ました。

APIGatewayのAPI URLを確認してブラウザで確認してみます。

f:id:wiz_tak:20210420120157p:plain

他の必要リソースをTerraformで構築

クライアントマシンからAuroraへ接続するのにEC2が必要になるかと思いますが、

サーバレスなのにEC2を構築してしまう事に全く気持ちが乗らなかったので、Cloud9を使ってEC2を管理します。

当然Publicサブネットなどが必要なのでCloud9含めTerraformで構築します。

また今のままだとLambdaがVPCの外に出れないのでS3へアクセスしたりメール送信などが出来ません。

これらをするにはVPCエンドポイントが必要になるので、これもTerraformで一緒に構築します。

SESはVPCエンドポイントに対応していなく、SESを使う場合はNAT Gatewayが必要になります。

今回はS3 Putの為にVPCエンドポイントを引くので、同様にVPCエンドポイントを使ったSMTPの構成でメール送信を実現します。

まず、Lambdaの実行ロールに以下のポリシーをアタッチ

  • SESFullAccess
  • LambdaVPCExecutionRole

/env/develop/main.tf

module "provider" {
  source = "../../modules/common/provider"

  region = var.region
}

##===========================
## VPC Module
##===========================
module "vpc" {
  source = "../../modules/common/vpc"

  project                  = var.project
  stage                    = var.stage
  region                   = var.region
  private_subnet_ids       = var.private_subnet_ids
  lambda_security_group_id = var.lambda_security_group_id
  route_table_id           = var.route_table_id  
}

// VPC ID
output "vpc_id" {
  value = module.vpc.vpc_id
}

// Subnet ID
output "subnet_id" {
  value = module.vpc.web_subnet_a
}

// IGW
output "igw_id" {
  value = module.vpc.internet_gateway
}

// Route Table
output "rtb_id" {
  value = module.vpc.route_table
}

##===========================
## Route53 Module
##===========================
module "route53" {
  source = "../../modules/common/route53"

  project           = var.project
  stage             = var.stage
  region            = var.region
  domain            = var.domain
  host_zone_domain  = var.host_zone_domain
}

##===========================
## Cloud9 Module
##===========================
module "cloud9" {
  source = "../../modules/common/cloud9"

  public_subnet_a    = module.vpc.web_subnet_a

  project            = var.project
  stage              = var.stage
  region             = var.region
  ec2_instance_type  = var.ec2_instance_type
}

/env/develop/variables.tf

漏れのないように変数定義

##===============================
## Project Name
##===============================
variable "project" {
  default = "serverless-app"
}

##===============================
## Stage Name
##===============================
variable "stage" {
  default = "develop"
}

##===============================
## Region
##===============================
variable "region" {
  default = "ap-northeast-1"
}

##===============================
## Domain
##===============================
variable "domain" {
  default = "dev.serverless-sample.jp"
}

##===============================
## Private Subnet Ids
##===============================
variable "private_subnet_ids" {
  type = list(string)
  default = [
    "subnet-*****",
    "subnet-*****"
  ]
}

##===============================
## Lambda Security Group Id
##===============================
variable "lambda_security_group_id" {
  default = "sg-*****"
}

##===============================
## Route53 Host Zone Domain
##===============================
variable "host_zone_domain" {
  default = "dev.serverless-sample.jp"
}

##===============================
## Cloud9 EC2 Instance Type
##===============================
variable "ec2_instance_type" {
  default = "t2.micro"
}

##===============================
## Route Table ID
##===============================
variable "route_table_id" {
  default = "rtb-*****"
}

/modules/vpc/main.tf

variable "stage" {}
variable "project" {}
variable "region" {}
variable "private_subnet_ids" {}
variable "lambda_security_group_id" {}
variable "route_table_id" {}

#==================================
#
# VPC
#
#==================================

// VPCの定義
resource "aws_vpc" "main_vpc" {
  cidr_block                       = "192.168.0.0/16"
  enable_dns_hostnames             = true
  enable_dns_support               = true
  instance_tenancy                 = "default"
  assign_generated_ipv6_cidr_block = false

  tags = {
    Name  = "${var.project}-${var.stage}-vpc"
    STAGE = var.stage
  }
}

#==================================
#
# Public Subnet For EC2
#
#==================================

// パブリックサブネットの定義
resource "aws_subnet" "public_subnet_a" {
  vpc_id                  = aws_vpc.main_vpc.id
  cidr_block              = "192.168.1.0/24"
  map_public_ip_on_launch = true
  availability_zone       = "${var.region}a"

  tags = {
    Name = "${var.project}-${var.stage}-public-a"
  }
}

#==================================
#
# IGW
#
#==================================

// インターネットゲートウェイの定義
resource "aws_internet_gateway" "main_igw" {
  vpc_id = aws_vpc.main_vpc.id
  tags = {
    Name = "${var.project}-${var.stage}-igw"
  }
}

#==================================
#
# Public Route Table
#
#==================================

// パブリックルートテーブルの定義
resource "aws_route_table" "rtb_public" {
  vpc_id = aws_vpc.main_vpc.id
  tags = {
    Name = "${var.project}-${var.stage}-public-rtb"
  }
}

// ルートの定義
resource "aws_route" "route_igw_public" {
  route_table_id         = aws_route_table.rtb_public.id
  destination_cidr_block = "0.0.0.0/0"
  gateway_id             = aws_internet_gateway.main_igw.id
  depends_on             = [aws_route_table.rtb_public]
}

// ルートテーブルとサブネットの関連付け
resource "aws_route_table_association" "rtb_assoc_public_web" {
  count          = 2
  route_table_id = aws_route_table.rtb_public.id
  subnet_id      = element([aws_subnet.public_subnet_a.id], count.index)
}

#==================================
#
# SMTP Security Group
#
#==================================

// SMTPセキュリティグループの定義
resource "aws_security_group" "smtp_security_group" {
  name        = "${var.project}-${var.stage}-smpt-sg"
  description = "security group for SMTP"
  vpc_id      = aws_vpc.main_vpc.id

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = {
    Name = "${var.project}-${var.stage}-smpt-sg"
  }
}

// インバウンドルール
resource "aws_security_group_rule" "smtp_inbound_rule" {
  type                     = "ingress"
  from_port                = 587
  to_port                  = 587
  protocol                 = "tcp"
  source_security_group_id = var.lambda_security_group_id
  security_group_id        = aws_security_group.smtp_security_group.id
}

#==================================
#
# VPC Endpoint
#
#==================================

// S3
resource "aws_vpc_endpoint" "vpce_s3" {
  vpc_id              = aws_vpc.main_vpc.id
  service_name        = "com.amazonaws.ap-northeast-1.s3"
  vpc_endpoint_type   = "Gateway"
  private_dns_enabled = false
  route_table_ids     = [var.route_table_id]

  tags = {
    Name = "${var.project}-${var.stage}-vpce-s3"
  }
}

// SMTP
resource "aws_vpc_endpoint" "vpce_smtp" {
  vpc_id              = aws_vpc.main_vpc.id
  service_name        = "com.amazonaws.ap-northeast-1.email-smtp"
  vpc_endpoint_type   = "Interface"
  private_dns_enabled = true

  subnet_ids = var.private_subnet_ids

  security_group_ids = [
    aws_security_group.smtp_security_group.id,
  ]

  tags = {
    Name = "${var.project}-${var.stage}-vpce-smtp"
  }
}

/modules/cloud9/main.tf

Cloud9のarnはrootでコンソールにサインインしている都合上rootにしていますので適宜変更してください。

variable "stage" {}
variable "project" {}
variable "region" {}
variable "public_subnet_a" {}
variable "ec2_instance_type" {}

#==================================
#
# Cloud9 Enviroment
#
#==================================

data "aws_caller_identity" "self" {}

resource "aws_cloud9_environment_ec2" "cloud9_env" {
  instance_type = var.ec2_instance_type
  name          = "${var.project}-env"
  subnet_id     = var.public_subnet_a
  owner_arn     = "arn:aws:iam::${data.aws_caller_identity.self.account_id}:root"
}

Serverlessで構築したVPCリソースをインポートしてから実行

$ terraform import module.vpc.aws_vpc.main_vpc ****
$ terraform apply

クライアントマシンからAuroraへ接続

構築出来たら、クライアントからAuroraへ接続するのに以下の作業をします。

  1. RDS のセキュリティーグループのインバウンドに、Cloud9で作ったセキュリティグループを追加 f:id:wiz_tak:20210420122252p:plain

  2. Cloud9のセキュリティーグループのインバウンドを適宜設定 f:id:wiz_tak:20210420122446p:plain

  3. キーペア作成 f:id:wiz_tak:20210420122606p:plain

  4. クライアント側で、ssh-keygen -y 後にDLした鍵のフルパスをコピー f:id:wiz_tak:20210420123039p:plain

  5. 吐き出された公開鍵を Cloud9 EC2のauthorized_keysに設定 f:id:wiz_tak:20210420123452p:plain

  6. 接続確認

f:id:wiz_tak:20210420133540p:plain

メール送信設定

次にAWSコンソールSES画面のSMTP SettingからSMTPアカウントを作成して、 f:id:wiz_tak:20210420124438p:plain

Laravel の.envに以下のように設定します。

.env.dev

MAIL_MAILER=smtp
MAIL_HOST=email-smtp.ap-northeast-1.amazonaws.com
MAIL_PORT=587
MAIL_USERNAME=***********
MAIL_PASSWORD=***********
MAIL_ENCRYPTION=tls
MAIL_FROM_ADDRESS=null
MAIL_FROM_NAME="${APP_NAME}"

これでS3 StorageへのPutとメール送信のテストコードを書いて動けばOKです。

最後に

Wizではエンジニアを募集しております。 興味のある方、ぜひご覧下さい。

careers.012grp.co.jp