こんにちは。バックエンドエンジニアの高橋です。
SSR(Serverless Next.js)のバックエンドを ServerlessFramework (Laravel) で作ってみました。
今回は以下の要件を満たす仕様
- LaravelからS3へPutが出来る
- Laravelからメール送信が出来る
また、GithubActionsによるCI/CDの実装と
Cloud9を使ってクライアントからAuroraへ接続する方法までを紹介したいと思います。
セットアップ
ローカルで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:
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}
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}
events:
- schedule:
rate: cron(0 0 * * ? *)
input: '"sample:command"'
〜〜
VPCリソースにはメインVPC/サブネット/LambdaとAuroraのセキュリティグループを記述
※ 後ほどVPCエンドポイントを使うのに必要なDNSホスト名とDNS解決を有効にしておきます。
〜〜
resources:
Resources:
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
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
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
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
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が良いかもしれません。
〜〜
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
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
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を追記しています。
〜〜
Storage:
Type: AWS::S3::Bucket
Properties:
BucketName: ${self:custom.environments.AWS_BUCKET_STORAGE}
StorageBucketPolicy:
Type: AWS::S3::BucketPolicy
Properties:
Bucket: !Ref Storage
PolicyDocument:
Statement:
- Effect: Allow
Principal: "*"
Action: "s3:GetObject"
Resource: !Join ["/", [!GetAtt Storage.Arn, "*"]]
Assets:
Type: AWS::S3::Bucket
Properties:
BucketName: ${self:custom.environments.AWS_BUCKET}
AssetsBucketPolicy:
Type: AWS::S3::BucketPolicy
Properties:
Bucket: !Ref Assets
PolicyDocument:
Statement:
- Effect: Allow
Principal: "*"
Action: "s3:GetObject"
Resource: !Join ["/", [!GetAtt Assets.Arn, "*"]]
plugins:
- ./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を確認してブラウザで確認してみます。
クライアントマシンから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へ接続するのに以下の作業をします。
RDS のセキュリティーグループのインバウンドに、Cloud9で作ったセキュリティグループを追加
Cloud9のセキュリティーグループのインバウンドを適宜設定
キーペア作成
クライアント側で、ssh-keygen -y 後にDLした鍵のフルパスをコピー
吐き出された公開鍵を Cloud9 EC2のauthorized_keysに設定
接続確認
メール送信設定
次にAWSコンソールSES画面のSMTP SettingからSMTPアカウントを作成して、
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