こんにちは。バックエンドエンジニアの高橋です。
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)の設定に若干手を加えないといけません。
本家のドキュメント通りにやれば出来ると思いますのでここでは割愛します。
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を確認してブラウザで確認してみます。
他の必要リソースを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へ接続するのに以下の作業をします。
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ではエンジニアを募集しております。 興味のある方、ぜひご覧下さい。