17
13

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

【AWS】 Terraform & Serverless による IaC の導入

Last updated at Posted at 2021-11-15

以前、ポートフォリオとしてタスク管理アプリを作成しました。そのインフラ構成には主にAWSを利用していましたが、大部分がAWSマネジメントコンソールからの手作業によって作成していました。

この方法は、初めて触れる技術を使用してインフラを構築する場合であれば、画面の指示に従いながら進めていくことができるというメリットがあります。しかし、インフラが大きくなり複雑化すると次第に管理が難しくなってきます。利用されているリソースを把握し、その変更が他へ与える影響も考慮する必要があります。

これらの問題に対応するため、インフラをコードで管理する Infrastructure as Code (IaC) を導入しました。これによって、使用されているリソースや依存環境が明確になり、またインフラの構築や削除をコマンドで容易に行えます。

IaCを実現するためのツールはいくつかありますが、ここではTerraformServerless Framework (Serverless) を用いることにしました。Terraformのみで全ての構成管理を行うことも可能だと思いますが、Serverlessにはサーバレス構築に特化していることもあり、少ないコードで必要な構成ができるというメリットがあります。また既にServerlessによる構成をある程度行っており、これを活用する利点も考慮して併用する方法を採用しました。

それぞれが担う役割として、Terraformはリソース間で共有するインフラの構築を行い、Serverlessはアプリ固有のインフラ構築を行います。例えば、データベース自体 (RDSインスタンス) はTerraformによって管理される一方で、各テーブルの作成や削除はServerlessが担います。このTerraformとServerlessの併用する方法に関してはServerless公式サイトのブログを参考にしました。

そして最後に、Github Actions によって自動的にデプロイを行えるようにしました。

目次

インフラ構成図

今回作成したインフラは以下の図のようになります。詳細はアプリ概要の記事に記述しています。

Infrastructure_configuration_diagram

基本的に手作業で作成した元々の構成と同じですが一つ異なるのがDNSです。以前はドメイン取得元 (お名前.com) のサービスを利用していましたが、IaC導入に伴いRoute53の方が都合が良いためこちらを利用するように変更しています。これには後述のネームサーバー移転が必要です。

ディレクトリ構成

Terraformのファイル群はterraformディレクトリに格納し、アプリ固有インフラを構成するServerlessの設定ファイルserverless.ymlはLaravelと同一のディレクトリであるbackendに格納しています。

├── backend/
│   ├── serverless.yml
├── terraform/
│   ├── acm.tf
│   ...

IAM

IAM (Identity and Access Management) は、AWSにおける認証やアクセス管理を行うサービスです。TerraformやServerlessなどプログラムからAWSにアクセスにするには認証情報が必要となり、IAMによって管理します。

AWSにアクセスするにはIAM ユーザーを作成します。加えて、AWSの各リソース (VPC、RDS 等) の操作 (読み書き等) を行うには、このユーザーに対してIAM ポリシーを設定します。

ユーザー

IAM ユーザーはAWSを利用するユーザーやアプリケーションの識別子として機能します。これにより各ユーザーにとって必要な権限だけを割り当てるアクセス管理ができます。

IAM ユーザーを作成する方法はいくつかありますが、今回はマネジメントコンソールを使用しました。AWSのガイドに従ってIAM ユーザーを作成する手順を進め、途中の認証情報タイプの選択の箇所でアクセスキー - プログラムによるアクセスにチェックを入れます。そして作成された後に発行されるアクセスキーの組み合わせを保管しておきます。

アクセスキー

アクセスキーとシークレットアクセスキーはプログラムによるAWSへのアクセスを行う際に必要となる認証情報で、IAM ユーザーを作成すると同時に発行することが可能です。

これらのキー情報は決められた場所にセットすることで利用できるようになります。そのための方法は複数存在しますが、Serverlessのガイドを参考にして~/.aws/credentialsに設定を行います。このガイドではその方法としてserverlessawsコマンドが紹介されており、これによって簡単に設定することができます。手動でファイルを作成してキー情報を追加することも可能です。

~/.aws/credentials
[default]
aws_access_key_id=XXXXXXXXXXXXXXX
aws_secret_access_key=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX

ポリシー

作成直後のIAMユーザーは権限が付与されていません。AWSのリソースを操作するためにはこのユーザーに対して必要な権限を付与する必要があり、これにはIAM ポリシーを利用します。

IAM ポリシーはJSON形式でアクセス許可を定義する機能で、ユーザーがアクセスできるリソースや実行できるアクションなど細かく設定することが可能です。主要なポリシーはマネージドポリシーとして初めから用意されており、作成したユーザーにアタッチすることでアクセス管理を行うことができます。詳細に設定を行う場合にはカスタマー管理のポリシーとして作成していきます。

安全性を保つためにはユーザーに付与する権限を最小限に抑えることが重要ですが、事前に必要な権限を正確に把握することは少々困難です。そのため、権限を緩めに許可しておきつつ不要だと判明した権限を除去していくか、初めに強い制限を設定しながら徐々に必要な権限を追加していくアプローチを取ります。必要な権限が付与されていない場合は実行の都度エラーが発生するので、そのメッセージに従ってポリシーを修正していくこともできます。

ポリシー設定例

今回利用したポリシーの実装例として、Terraformによるインフラ構築に必要なものとServerlessによる構築に必要なものに分けて以下に記載します。ただ、やや独特な記述になっていることやアクセス可能なリソースとしてワイルドカードを用いていることなどまだ改良の余地があります。

少々長めなので折り畳みにしています。

Terraform用
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": "ssm:DescribeParameters",
            "Resource": "*"
        },
        {
            "Sid": "SSMParameter",
            "Effect": "Allow",
            "Action": [
                "ssm:DeleteParameter",
                "ssm:GetParameter",
                "ssm:GetParameters",
                "ssm:ListTagsForResource",
                "ssm:PutParameter"
            ],
            "Resource": "arn:aws:ssm:*:*:parameter/*"
        },
        {
            "Sid": "VPC",
            "Effect": "Allow",
            "Action": [
                "ec2:AssociateRouteTable",
                "ec2:AttachInternetGateway",
                "ec2:AuthorizeSecurityGroupEgress",
                "ec2:AuthorizeSecurityGroupIngress",
                "ec2:CreateInternetGateway",
                "ec2:CreateNetworkAcl",
                "ec2:CreateNetworkAclEntry",
                "ec2:CreateRouteTable",
                "ec2:CreateSecurityGroup",
                "ec2:CreateSubnet",
                "ec2:CreateTags",
                "ec2:CreateVpc",
                "ec2:CreateVpcEndpoint",
                "ec2:DeleteInternetGateway",
                "ec2:DeleteNetworkAcl",
                "ec2:DeleteNetworkAclEntry",
                "ec2:DeleteRouteTable",
                "ec2:DeleteSecurityGroup",
                "ec2:DeleteSubnet",
                "ec2:DeleteVpc",
                "ec2:DeleteVpcEndpoints",
                "ec2:Describe*",
                "ec2:DetachInternetGateway",
                "ec2:DisassociateRouteTable",
                "ec2:ModifyVpcAttribute",
                "ec2:ModifyVpcEndpoint",
                "ec2:RevokeSecurityGroupEgress",
                "ec2:RevokeSecurityGroupIngress"
            ],
            "Resource": "*"
        },
        {
            "Sid": "RDSMonitortingRole",
            "Effect": "Allow",
            "Action": [
                "iam:AttachRolePolicy",
                "iam:CreateRole",
                "iam:DeleteRole",
                "iam:DeleteRolePolicy",
                "iam:DetachRolePolicy",
                "iam:GetRole",
                "iam:ListAttachedRolePolicies",
                "iam:ListInstanceProfilesForRole",
                "iam:ListRolePolicies",
                "iam:PutRolePolicy",
                "iam:TagRole"
            ],
            "Resource": "arn:aws:iam::*:role/*"
        },
        {
            "Sid": "RDSMonitortingRole1",
            "Effect": "Allow",
            "Action": "iam:PassRole",
            "Resource": "arn:aws:iam::*:role/*rds-monitoring-role"
        },
        {
            "Sid": "ServiceRoleForRDS",
            "Effect": "Allow",
            "Action": "iam:CreateServiceLinkedRole",
            "Resource": "arn:aws:iam::*:role/aws-service-role/rds.amazonaws.com/*"
        },
        {
            "Sid": "RDS",
            "Effect": "Allow",
            "Action": [
                "rds:AddTagsToResource",
                "rds:CreateDBInstance",
                "rds:CreateDBParameterGroup",
                "rds:CreateDBSubnetGroup",
                "rds:CreateOptionGroup",
                "rds:DeleteDBInstance",
                "rds:DeleteDBParameterGroup",
                "rds:DeleteDBSubnetGroup",
                "rds:DeleteOptionGroup",
                "rds:DescribeDBInstances",
                "rds:DescribeDBSubnetGroups",
                "rds:DescribeDBParameterGroups",
                "rds:DescribeDBParameters",
                "rds:DescribeOptionGroups",
                "rds:ListTagsForResource",
                "rds:ModifyDBInstance",
                "rds:ModifyDBParameterGroup",
                "rds:ModifyOptionGroup"
            ],
            "Resource": "*"
        },
        {
            "Sid": "Route53HostedZone",
            "Effect": "Allow",
            "Action": [
                "route53:CreateHostedZone",
                "route53:GetChange",
                "route53:GetHostedZone",
                "route53:ListHostedZones",
                "route53:ListTagsForResource"
            ],
            "Resource": "*"
        },
        {
            "Sid": "Route53Record",
            "Effect": "Allow",
            "Action": [
                "route53:ChangeResourceRecordSets",
                "route53:ListResourceRecordSets"
            ],
            "Resource": "arn:aws:route53:::hostedzone/*"
        },
        {
            "Sid": "ACM",
            "Effect": "Allow",
            "Action": [
                "acm:DeleteCertificate",
                "acm:DescribeCertificate",
                "acm:ListTagsForCertificate",
                "acm:RequestCertificate"
            ],
            "Resource": "*"
        },
        {
            "Sid": "SES",
            "Effect": "Allow",
            "Action": [
                "ses:DeleteIdentity",
                "ses:GetIdentityDkimAttributes",
                "ses:GetIdentityVerificationAttributes",
                "ses:VerifyDomainDkim",
                "ses:VerifyDomainIdentity"
            ],
            "Resource": "*"
        },
        {
            "Sid": "SMTPUser",
            "Effect": "Allow",
            "Action": [
                "iam:CreateAccessKey",
                "iam:CreateUser",
                "iam:DeleteAccessKey",
                "iam:DeleteUser",
                "iam:DeleteUserPolicy",
                "iam:GetUser",
                "iam:GetUserPolicy",
                "iam:ListAccessKeys",
                "iam:ListGroupsForUser",
                "iam:PutUserPolicy",
                "iam:UpdateUser"
            ],
            "Resource": "*"
        },
        {
            "Sid": "S3Bucket",
            "Effect": "Allow",
            "Action": [
                "s3:CreateBucket",
                "s3:DeleteBucket",
                "s3:DeleteBucketPolicy",
                "s3:Get*",
                "s3:ListAllMyBuckets",
                "s3:ListBucket",
                "s3:ListBucketVersions",
                "s3:PutBucketAcl",
                "s3:PutBucketPolicy",
                "s3:PutBucketVersioning",
                "s3:PutObject",
                "s3:PutObjectAcl",
                "s3:DeleteObject"
            ],
            "Resource": "*"
        },
        {
            "Sid": "S3Object",
            "Effect": "Allow",
            "Action": [
                "s3:PutObject",
                "s3:PutObjectAcl",
                "s3:GetObject",
                "s3:GetObjectAcl",
                "s3:DeleteObject"
            ],
            "Resource": "*"
        }
    ]
}
Serverless用
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": "ssm:DescribeParameters",
            "Resource": "*"
        },
        {
            "Sid": "SSMParameter",
            "Effect": "Allow",
            "Action": [
                "ssm:DeleteParameter",
                "ssm:GetParameter",
                "ssm:GetParameters",
                "ssm:ListTagsForResource",
                "ssm:PutParameter"
            ],
            "Resource": "arn:aws:ssm:*:*:parameter/*"
        },
        {
            "Effect": "Allow",
            "Action": "iam:CreateServiceLinkedRole",
            "Resource": "arn:aws:iam::*:role/aws-service-role/apigateway.amazonaws.com/*"
        },
        {
            "Sid": "Serverless",
            "Effect": "Allow",
            "Action": [
                "apigateway:*",
                "cloudformation:CancelUpdateStack",
                "cloudformation:ContinueUpdateRollback",
                "cloudformation:CreateChangeSet",
                "cloudformation:CreateStack",
                "cloudformation:CreateUploadBucket",
                "cloudformation:DeleteStack",
                "cloudformation:Describe*",
                "cloudformation:EstimateTemplateCost",
                "cloudformation:ExecuteChangeSet",
                "cloudformation:Get*",
                "cloudformation:List*",
                "cloudformation:UpdateStack",
                "cloudformation:UpdateTerminationProtection",
                "cloudformation:ValidateTemplate",
                "events:DeleteRule",
                "events:DescribeRule",
                "events:ListRuleNamesByTarget",
                "events:ListRules",
                "events:ListTargetsByRule",
                "events:PutRule",
                "events:PutTargets",
                "events:RemoveTargets",
                "iam:AttachRolePolicy",
                "iam:CreateRole",
                "iam:DeleteRole",
                "iam:DeleteRolePolicy",
                "iam:DetachRolePolicy",
                "iam:GetRole",
                "iam:PutRolePolicy",
                "lambda:*",
                "logs:CreateLogGroup",
                "logs:DeleteLogGroup",
                "logs:DescribeLogGroups",
                "logs:DescribeLogStreams",
                "logs:FilterLogEvents",
                "logs:GetLogEvents",
                "logs:PutSubscriptionFilter",
                "s3:CreateBucket",
                "s3:DeleteBucket",
                "s3:DeleteBucketPolicy",
                "s3:DeleteObject",
                "s3:DeleteObjectVersion",
                "s3:GetObject",
                "s3:GetObjectVersion",
                "s3:ListAllMyBuckets",
                "s3:ListBucket",
                "s3:PutBucketNotification",
                "s3:PutBucketPolicy",
                "s3:PutBucketTagging",
                "s3:PutBucketWebsite",
                "s3:PutEncryptionConfiguration",
                "s3:PutObject"
            ],
            "Resource": "*"
        },
        {
            "Sid": "LambdaRole",
            "Effect": "Allow",
            "Action": "iam:PassRole",
            "Resource": "arn:aws:iam::*:role/*lambdaRole"
        },
        {
            "Sid": "DynamoDBCacheTable",
            "Effect": "Allow",
            "Action": [
                "dynamodb:CreateTable",
                "dynamodb:DeleteTable",
                "dynamodb:DescribeContributorInsights",
                "dynamodb:DescribeTable",
                "dynamodb:DescribeTimeToLive",
                "dynamodb:GetItem",
                "dynamodb:UpdateContributorInsights",
                "dynamodb:UpdateTimeToLive"
            ],
            "Resource": "arn:aws:dynamodb:*:*:table/cache"
        }
    ]
}

ロール

IAM ロールは、IAM ユーザーと同様に、ポリシーをアタッチしてアクセス管理を行う機能です。ただし、パスワードやアクセスキーなどは持たず、一時的な認証情報を用いて許可されたアクションを実行します。ロールは自分で作成する他、RDSなどのAWSリソースを作成した際に"サービスにリンクされたロール"として自動的に作成されること場合があります。

共有インフラ

冒頭で述べたように、今回のインフラ構築にはTerraformとServerlessを併用しており、この内Terraformは複数のアプリケーションで共有することができるようなリソースの作成を担当しています。

Terraform

Terraformは、オープンソースのIaCツールで、HashiCorp Configuration Language (HCL) と呼ばれる言語を用いて構成ファイルを作成し、それに基づいたリソースの作成を行うことができます。

特徴として、構成ファイルは宣言的であり各リソースがあるべき状態を示します。例えば構成ファイルに記されたリソースが既に存在する場合には作成や更新処理を行いません。また、Planという機能により、作成や更新が行われるリソースの内容を実行前に確認することが可能です。さらに、リソース間の依存関係を自動的に解決する機能を備えており、実行の順序を意識することなく複雑な構成に対応することができます。また、マルチクラウドに対応しており、今回利用したAWSの他、GCPやAzureでも利用することができます。

これから利用するにあたり、まず初めに公式サイトの手順に従ってインストールとコマンド補完機能の設定を行います。Macの場合は以下のような手順となります。

# インストール
brew tap hashicorp/tap
brew install hashicorp/tap/terraform
brew update && brew upgrade hashicorp/tap/terraform
# 補完機能追加
terraform -install-autocomplete
exec $SHELL -l # シェル再起動

Terraformはバージョンの進化が早く、実装中に何度かアップグレードを促すメッセージが表示されました。コマンドを実行した際に以下のような表示がされた場合にはbrew upgrade tarraformでアップグレードを行います。

$ terraform init
| Error: Error loading state:
│     Remote workspace Terraform version "1.0.7" does not match local Terraform version "1.0.6"

ファイル構成

Terraformの構成は拡張子がtfのファイルに記述していきますが、ファイル名やディレクトリ構成などはある程度自由に設定することができます。

例えば、以下のコードはAWSを利用する場合の基本的な構成で、3つのブロックからなります。それぞれ、ProviderTerraform Registryからのインストール、AWS Providerの指定、リソースの作成を行っていますが、これを一つのファイルに収めることも別のファイルに分割することもできます。また、分割した場合でも他のファイルを読み込むコード等は不要です。

main.tf
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "3.57.0"
    }
  }
}

provider "aws" {
  profile = "default"
  region  = "us-east-1"
}

resource "aws_s3_bucket" "main" {
  bucket = "unique_backet_name"
}

構築するリソースの数や種類が増えてくると必要なコード量も膨大になるので分割する方が見通しが良くなります。今回の例では、上のブロックからversions.tfmain.tfs3.tfといったファイルに分ける構成にしています。

terraform/
├── versions.tf
├── main.tf
├── s3.tf

参考: Build Infrastructure | Terraform - HashiCorp Learn

Initialization

Terraformの設定ファイルを新たに作成した際にはterraform initの実行を行います。このときカレントディレクトリに構成ファイルが存在している必要がありますが、作業ディレクトリを指定して実行することもできます。

terraform -chdir=terraform init

初めて初期化を実行した場合、いくつかファイルやディレクトリが生成されますが、バージョン管理の対象外とすべきものも含まれているため、それらに対処すべく.gitignoreを作成します。

terraform/.gitignore

GitHubのリポジトリにTerraform用の.gitignoreテンプレートがあるためこれを流用します。秘匿情報を格納するファイルも扱うため初めに行っておきたい手順です。

State

インフラの構築はterraform applyコマンドによって実行され、この時、作成されたリソース構成の状態を示すファイル (State) としてterraform.tfstateが作成または更新されます。このStateが適切に運用されていない場合、例えば既存のリソースとの相違やファイル自体が破損した場合、リソースの重複や競合が発生する要因となりTerraformによるインフラ管理ができなくなってしまいます。

よって、このStateを維持しつつTerraformの実行時には確実に利用できる状態にしておくことが求められます。保存場所はデフォルトではローカルの作業ディレクトリですが、Backendブロックによって指定することもできます。

Backend

BackendはTerraformを実行しStateを保管する場所の設定です。ローカルの他、Amazon S3などを指定することができます。Stateの管理場所には可用性が求められ、また複数人で作業を行う場合には共有可能な場所を用いる必要があります。公式ドキュメントでは、その場所として後述のTerraform Cloud又はTerraform Enterpriseを使用することを推奨しています。

Backendにローカルを使用する場合は特に設定不要で、Stateはデフォルトでローカルに保存されます。一方、Terraform Cloudを使用する場合はterraformブロック内でbackendremoteを指定します。

main.tf
terraform {
  backend "remote" {
    organization = "organization_name"

    workspaces {
      name = "workspace_name"
    }
  }
}

このorganization名とworkspace名にはTerraform Cloud上で任意の名前で作成したものを使用します。

なお、backendブロック内での変数の利用は現時点 (v1.0.8) ではできないようです。代替案として、上記のようにハードコーディングするか、又は以下のように空オブジェクトを記述しておき都度コマンドラインから設定ファイルを指定して適用させる方法があります。

main.tf
backend "remote" {}
backend.hcl
organization = "organization_name"

workspaces {
  name = "workspace_name"
}
terraform init -backend-config=backend.hcl

参考: Backend Type: remote - Terraform by HashiCorp

Input Variables

Input Variables とは、リソース作成の際のパラメータを指定する役割を持つ変数です。要するに他のプログラミング言語同様の変数の働きをするものですが、格納方法としてコマンドラインや環境変数の他、専用ファイル (terraform.tfvars) やTerraform Cloudを利用することができます。また、パスワードなどの機密情報を安全に扱う役割も持ちます。

変数の定義にはvariableブロックを使用しラベルとして変数名を指定します。以下の例では、variables.tfファイルを作成し、そこにaws_profileという名で変数を定義した上、説明文、型、デフォルト値の指定を行っています。

variables.tf
variable "aws_profile" {
  description = "AWS profile"
  type        = string
  default     = "default"
}

前述の通りこの変数の値は変更することができ、terraform.tfvarsによる方法の場合は以下のように記述します。

terraform.tfvars
aws_profile = "dev"

変数の値を利用するにはvarを用います。例としてproviderブロックに適用すると以下のようになります。

main.tf
provider "aws" {
  profile = var.aws_profile
  region  = "us-east-1"
}

Terraform Cloud

これまで見てきたように、Stateや機密情報の管理にはTerraform Cloudが利用できます。他の方法を選ぶことも可能ですが、こちらは専用のツールということもあり複雑な設定もなく、プロジェクトやステージ毎のインフラ管理やStateの差分管理など、単なる共有スペース以上の機能が利用できます。また、今回のような小規模のケースでは無料で使用することができます。利用するには事前に登録が必要となりますがその際クレジットカードの登録は不要です。

Module

ファイル構成の項目で触れたように、リソースを構築するためにはresourceブロックによって定義していました。しかし頻繁に同様の構成を行う場合には、Moduleを用いることで効率化や再利用性の向上を図ることができます。

Moduleとは、複数のリソース構成を統合してパッケージ化したものです。例えば、VPCを利用する際にはサブネットやルートテーブルなどが密接に関連しており、基本的に同時に作成することになりますが、Moduleを用いることでVPCと同一の文脈でこれらの設定を行うことができます。主要なModuleは公式から提供されており、今回の構成では、VPCセキュリティグループRDSACM の作成に使用しています。

VPC

Terraformの基本を確認した所でAWSのインフラ構築に移り、まずはVPCの導入を行います。

VPCはプライベートな仮想ネットワークを構築するサービスです。今回の構成ではデータベース (RDS) を使用しておりそこでVPCが必要となります。

VPCの構成を行うには通常のresourceブロックでも可能ですが、Module を使用することで関連するリソースも含めてより簡潔に作成することができます。VPC用のModuleを使用するには、moduleブロックでvpc (任意のラベル名) を指定した上でsourceには使用するmoduleの場所としてterraform-aws-modules/vpc/awsを指定します。

vpc.tf
module "vpc" {
  source = "terraform-aws-modules/vpc/aws"
  # ...
}

以降に入力値を加えて設定を行っていきます。

vpc.tf
name = "main-vpc"
cidr = var.vpc_cidr_block

nameにはVPC名、cidrにはCIDRブロックを指定しますが、ここで変数を利用しているためvariables.tfに新たに変数定義を追加します。

variables.tf
variable "vpc_cidr_block" {
  type        = string
  default     = "10.0.0.0/16"
}

次に、AZとサブネットを指定します。AZの数はリージョンによって異なり、us-east-1の場合はus-east-1aからus-east-1fまで存在します。また今回サブネットはプライベートのみ使用するためパブリックサブネットは作成しません。表記として変数と文字列が混在する際は変数部を${}で括ります。

vpc.tf
azs = [
  "${var.aws_region}a",
  "${var.aws_region}b",
  "${var.aws_region}c",
  "${var.aws_region}d",
  "${var.aws_region}e",
  "${var.aws_region}f"
]

private_subnets = var.vpc_private_subnets

さらに変数定義も追加します。

variables.tf
variable "aws_region" {
  type        = string
  default     = "us-east-1"
}

variable "vpc_private_subnets" {
  type        = list(string)
  default = [
    "10.0.1.0/24",
    "10.0.2.0/24",
    "10.0.3.0/24",
    "10.0.4.0/24",
    "10.0.5.0/24",
    "10.0.6.0/24"
  ]
}

作成された各サブネットはそれぞれのAZに置かれ、またサブネットの数だけルートテーブルが作成され、それぞれに紐付きます。Moduleを使用することでこれらを明示的に指定する必要がなく作成することができます。

参考: terraform-aws-modules/vpc/aws | Terraform Registry

次に、アクセス管理を行うためセキュリティグループを作成します。

セキュリティグループ

セキュリティグループはホワイトリスト方式でアクセス管理を行うサービスで、VPCに紐付けて使用します。作成にはModuleを使用し、ファイルはVPCと同じvpc.tfです。

vpc.tf
module "security_group" {
  source = "terraform-aws-modules/security-group/aws"
  # ...
}

VPCと関連付けるためにはvpc_idを指定する必要があります。これはVPCが作成されてから判明する値ですが、前項のVPC設定の出力値を指定することでそれを使用することができます。なお、VPC Moduleで指定可能な出力値についてはREADMEに記載があります。

vpc_id = module.vpc.vpc_id

そしてアウトバウンドルールには全てのトラフィックを許可する設定を加えます。通常デフォルトで設定されるルールですが、Terraformでは明示的に指定する必要があります。

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

Terraform removes the default egress rule, so it needs to be recreated

https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/security_group#basic-usage

以上でセキュリティグループ自体の作成は完了です。RDSなど個別のリソースに必要となるインバウンドルールについてはそれぞれの項目で追加していきます。

エンドポイント

vpc.tfに含めるリソースとしてさらにVPCエンドポイントを作成します。これはVPC内外でのプライベート通信を可能にするサービスです。

通常、VPC内から外部のリソースへアクセスするためにはインターネットを介した通信を行うかNATゲートウェイを設置するなどの対応が必要となりますが、エンドポイントを使用することでこれらを行うことなくプライベートな接続を確保できるようになります。今回の例では、VPC内のLambdaからVPC外のDynamoDBSESと接続するために使用しています。

DynamoDBはServerlessによって構築しますが、エンドポイントに関してはTerraformによって作成します。

vpc.tf
resource "aws_vpc_endpoint" "dynamodb" {
  vpc_id          = module.vpc.vpc_id
  service_name    = "com.amazonaws.${var.aws_region}.dynamodb"
  route_table_ids = module.vpc.private_route_table_ids
}

service_nameは既定値で、リージョンを間に挟む値となっているため変更に備え変数で置き換えます。またroute_table_idsにはVPC Moduleの出力値の内プライベートサブネットに関連付いたルートテーブルIDの配列を使用します。ここで選択したルートテーブルに関連付くサブネットからはエンドポイントのサービスにアクセスできるようになります。

ここで作成したエンドポイントは特にGatewayエンドポイントと呼ばれるもので、現時点ではDynamoDBとS3でのみ選択可能なエンドポイントタイプです。今回明示的に指定していませんがデフォルト値がGateway型になっています。

参考: aws_vpc_endpoint | Resources | hashicorp/aws | Terraform Registry

SESで使用するInterface型のエンドポイントについてはSESの項目で作成します。

RDS

RDSはリレーショナルデータベースの構築や管理を行うマネージドサービスです。新たにrds.tfファイルを作成しRDS用のModuleを用いて構築していきます。

rds.tf
module "db" {
  source = "terraform-aws-modules/rds/aws"
  # ...
}

ところで、今回の設定中に同じ表現を使用する箇所がいくつか出てきます。そのような場合にローカル変数のように利用できるのがLocal Valuesです。以下のようにlocalsブロックで変数を定義し、使用時にlocal記法によってアクセスすることができます。

locals {
  engine               = "mariadb"
  major_engine_version = "10.4"
}
engine = local.engine

以上を踏まえて実際にRDSの設定を行っていきます。

RDSインスタンス

まずはRDSインスタンス名 (DB識別子) を指定します。

rds.tf
identifier = local.identifier

ここで使用している変数はlocalsブロックで定義します。

rds.tf
locals {
  identifier = "${lower(var.project)}-${var.stage}"
}

DB識別子は一意であることが求められます。ここではプロジェクト(アプリ) 名とステージ (devなどの環境名) をハイフン(-)で繋いだものにしました。DB識別子では大文字は使用できず、プロジェクト名には大文字を使用することがあるため、lower関数を用いることで小文字にしています。これらの変数はvariables.tf内で定義しておきます。

variables.tf
variable "project" {
  type        = string
  default     = "tf-project"
}

variable "stage" {
  type        = string
  default     = "production"
}

次に、主に性能部分に関連する設定を行っていきます。

rds.tf
engine         = local.engine               # "mysql", "postgres" etc
engine_version = local.major_engine_version # マイナーバージョン自動アップグレード有効 (デフォルト)
instance_class = "db.t2.micro"              # インスタンスクラス

storage_type          = "gp2" # 汎用SSD
allocated_storage     = 20    # ストレージ割り当て (GiB単位)
max_allocated_storage = 0     # オートスケーリング無効化

無料枠の範囲内で使用する前提なのでスペック関連はほぼ決まっており、インスタンスクラスはT2 micro、ストレージは汎用SSDの20GiBで、オートスケーリングは利用していません。

engineにはMariaDBをしており、engine_versionにはメジャーバージョンまでの指定を行っています。デフォルトでマイナーバージョン自動アップグレードが有効化されているため、マイナーバージョンの指定まで行うとその設定との整合性が保てなくなってしまいます。なお、ここで指定可能な値はAWSのドキュメント - CreateDBInstance - に記載があります。

次に、認証情報などデータベース接続に係る設定を行います。

rds.tf
name                   = "${lower(var.project)}_${var.stage}"
username               = var.db_username
create_random_password = true
port                   = "3306"

nameは作成するデータベース名です。RDSインスタンスのDB識別子と異なりハイフンは使用できません。代わりにプロジェクト名とステージをアンダースコア(_)で繋いでDB名としています。usernameにはマスターユーザー名を指定しますが、これには変数定義時にsensitive指定をすることで機密情報として扱います。

variables.tf
variable "db_username" {
  type        = string
  sensitive   = true
}

variableブロックでデフォルト値は指定せずterraform.tfvarsによって値を格納します。

terraform.tfvars
db_username = "admin"

参考: Protect Sensitive Input Variables | Terraform - HashiCorp Learn

パスワードも機密情報ですがこちらはランダムパスワード作成を有効にすることで変数による指定は必要なくなります。そしてこの値は出力 (module.db.db_master_password) から参照できるため内容が不明でも問題ありません。

次に、VPC Moduleの出力値を使用してVPCとの関連付けを行います。

rds.tf
subnet_ids             = module.vpc.private_subnets
vpc_security_group_ids = [module.vote_service_sg.security_group_id]

次に、パラメータグループの設定です。

rds.tf
family = "${local.engine}${local.major_engine_version}"

parameters = [
  {
    name  = "character_set_client"
    value = "utf8mb4"
  },
  {
    name  = "character_set_server"
    value = "utf8mb4"
  }
]

familyはパラメータグループのファミリーで、上記は今回の場合mariadb10.4を表しています。次のparametersではパラメータ名と値をセットで指定します。指定(不)可能なパラメータについてはAWSののドキュメントを参照してください。

次に、オプショングループを指定します。

rds.tf
major_engine_version = local.major_engine_version

options = [
  {
    option_name = "MARIADB_AUDIT_PLUGIN"
  }
]

major_engine_versionにはエンジンの (メジャー) バージョンを指定します。続いてoptionsに使用するオプションを追加しますが、MariaDBで使用可能なオプションは現時点ではMARIADB_AUDIT_PLUGINのみです。これはDBアクティビティを記録するために使用されます。

参考: Options for MariaDB database engine - Amazon Relational Database Service

次に、メンテナンスやバックアップに関する設定です。

rds.tf
maintenance_window      = "Sat:15:00-Sat:18:00" # UTC (GMT)
backup_window           = "18:00-21:00" # `maintenance_window`と重ならない時間
backup_retention_period = 7             # バックアップの保持日数
skip_final_snapshot     = true          # DB削除時のスナップショット作成有無

maintenance_windowにはメンテナンス (パッチ適用やマイナーアップグレード) を行う時間を指定します。この時間はUTC (GMT) での指定のため上記の場合日本時間 (UTC+9) ではSun:00:00-03:00となります。因みにus-east-1リージョン (バージニア北部) ではUTC-5です。

backup_windowには自動バックアップの期間を選択します。これはmaintenance_windowとは重ならない時間を指定する必要があります。次にこのバックアップを保持する期間をbackup_retention_periodに日単位で指定します。skip_final_snapshotにはDBインスタンスが削除された際のスナップショット作成有無を選択し、ここでは特に不要のためtrue (作成しない) としています。

次に、拡張モニタリングを設定します。

rds.tf
monitoring_interval    = 60
create_monitoring_role = true
monitoring_role_name   = "${local.identifier}-rds-monitoring-role"

monitoring_intervalはデフォルト値が0になっておりこれは無効を表します。上記のように60と指定することで有効化しメトリクスを60秒間隔で取得するようにしています。またモニタリング用のIAMロールが必要となりますが、create_monitoring_roleによって新規作成を選択し、そのロール名をmonitoring_role_nameで指定しています。これもDB識別子と同様に、他で作成したロール名との重複を避けるような命名が必要です。

rds.tf

以上、主要箇所の設定を纏めるとrds.tfは以下のようになります。

rds.tf
rds.tf
locals {
  identifier           = "${lower(var.project)}-${var.stage}"
  engine               = "mariadb"
  major_engine_version = "10.4"
}

module "db" {
  source = "terraform-aws-modules/rds/aws"

  identifier = local.identifier

  engine         = local.engine
  engine_version = local.major_engine_version
  instance_class = "db.t2.micro"

  storage_type          = "gp2"
  allocated_storage     = 20
  max_allocated_storage = 0

  name                   = "${lower(var.project)}_${var.stage}"
  username               = var.db_username
  create_random_password = true
  port                   = "3306"

  subnet_ids             = module.vpc.private_subnets
  vpc_security_group_ids = [module.vote_service_sg.security_group_id]

  family = "${local.engine}${local.major_engine_version}"
  major_engine_version = local.major_engine_version

  maintenance_window      = "Sat:15:00-Sat:18:00"
  backup_window           = "18:00-21:00"
  backup_retention_period = 7
  skip_final_snapshot     = true

  create_monitoring_role = true
  monitoring_role_name   = "${local.identifier}-rds-monitoring-role"
  monitoring_interval    = 60

  enabled_cloudwatch_logs_exports = ["audit", "error", "general", "slowquery"]

  parameters = [
    {
      name  = "character_set_client"
      value = "utf8mb4"
    },
    {
      name  = "character_set_server"
      value = "utf8mb4"
    }
  ]

  options = [
    {
      option_name = "MARIADB_AUDIT_PLUGIN"
    },
  ]
}

参考:
terraform-aws-modules/rds/aws | Terraform Registry
aws_db_instance | Resources | hashicorp/aws | Terraform Registry

ところで、ここで作成したRDSインスタンスが所属するVPCに設定されたセキュリティグループにはインバウンドルールが未設定の状態でした。そしてRDSと接続するためにはそれを許可するルールを追加する必要があります。

今回RDSと接続することになるリソースはLambdaで、これはRDSと同じVPCに設置することになります。この場合同一セキュリティグループのリソースからの通信を許可するという指定を行うことができ、セキュリティグループModuleの中での設定は以下のようになります。

vpc.tf
ingress_with_self = [
  {
    from_port   = 3306
    to_port     = 3306
    protocol    = "tcp"
  },
]

ingress_with_selfは同一セキュリティグループをソースとするインバウンドルールを表しています。そしてfrom_portto_portによってポート範囲を指定しますが、両方同じ値にすることで、MariaDB (MySQL) で使用するTCP 3306ポートのみを指定しています。

なお、このingress_with_selfは内部にはaws_security_groupingressselfの状態で使用されています。

参考:
terraform-aws-security-group/main.tf - GitHub
aws_security_group | Resources | hashicorp/aws | Terraform Registry

Systems Manager

Systems Managerとは、運用データの監視やタスク自動化などを可能とする運用管理サービスです。かつてはSSMの略称で呼ばれておりCLIコマンドなどにその名残があります。またTerraformにおいてもSSMの呼称で扱われています。Systems Manager には上記の他様々な機能が存在しますが、その中でも今回利用するのはパラメータストアに限ります。

パラメータストア

パラメータストアとは、データ管理用ストレージを提供するサービスで、IDやパスワードなどの機密情報を安全に管理することができます。今回の場合、Serverlessとの値の共有 (主にTerraform側で出力した値をServerless側が取得) の用途で利用しています。これらのツールにはパラメータの格納や取得の仕組みが整っているため利用も簡単です。

それでは新たにssm.tfを作成しそこに設定を行っていきます。

パラメータ階層

パラメータにアクセスするためのキーの役割を担うパラメータ名は階層型の値で指定します。階層とはディレクトリのようにスラッシュ区切りの形式です。パラメータの管理を容易にするため、階層はプロジェクト情報を示すものを使用します。それを踏まえて、例えばDBのパスワードを格納するパラメータを作成するには以下のような定義を行います。

ssm.tf
resource "aws_ssm_parameter" "db_password" {
  name        = "/${var.project}/${var.stage}/DB_PASSWORD"
  type        = "SecureString"
  value       = module.db.db_master_password
}

パラメータストアを利用するためにはresourceaws_ssm_parameterを指定し任意の名前 (ここではdb_password) を付与します。パラメータ名はnameの部分で、ここでは親階層にプロジェクト名 (var.project)とステージ (var.stage)を設置したDB_PASSWORDという名前を指定しました。

valueにはDBのパスワードを入れることになりますが、DB設定時にパスワードはランダムに生成した値を使用するようにしており、その値はmodule.db.db_master_passwordによって取得できるのでした。よって上記のような設定を行っています。

次にパラメータのタイプを指定します。

パラメータタイプ

パラメータに指定可能なタイプにはStringStringListSecureStringがあります。パスワードなど機密情報を格納する場合にはSecureStringを指定することになり、前述の例ではDBのパスワードを扱っているためこれに該当します。パラメータの設定と取得共に特別の手順不要でこの暗号化した値を利用することができます。

配列の情報を格納するにはStringListを指定します。ただし、配列を格納する際に利用できるというだけであって、配列としての格納はできず、カンマ区切りの文字列を使用する必要があります。サブネットのCIDRブロック配列をパラメータストアに追加する際にStringListを利用した例を示します。

ssm.tf
resource "aws_ssm_parameter" "private_subnets" {
  name        = "/${var.project}/${var.stage}/PRIVATE_SUBNET_IDS"
  type        = "StringList"
  value       = join(",", module.vpc.private_subnets)
}

Moduleで作成したVPCのプライベートサブネットIDはmodule.vpc.private_subnets取得することができます。これは配列形式になっているため、組み込み関数の一つであるjoinを使用し、文字列に変換しています。

以上のように、Terraformで作成したリソースの情報を手軽にパラメータストアに格納することができました。その他必要に応じてパラメータを作成し、Serverlessとの連携に使用します。

参考: aws_ssm_parameter | Resources | hashicorp/aws | Terraform Registry

Route53

Route53はDNSサービスです。取得したドメインによってWebサービスを表示させるために使用しています。初めに述べたように、ドメインの取得自体は別で行っており、当初はDNSも取得元のサービスを利用していました。ただ、DNSレコードを追加する必要があるなどドメインに依存するリソースをTerraformで稼働させる上では同じようにTerraform管理下に置いた方が都合が良いと考え移行することにしました。

ネームサーバー移転

DNSをAWSで利用するにはネームサーバーの移転手続きを行う必要があります。これはドメインの移管とは異なり、ドメインの管理自体は引き続きドメイン取得元が行います。移行の手順はAWSのドキュメントに記載されており、ステップに従っていくだけです。要約すると、マネジメントコンソールでRoute53ホストゾーンを作成し、そのドメイン取得元のネームサーバーをNSレコード内のネームサーバーへの置き換えを行います。

ところで、これまで見てきたリソースは基本的に無料枠の範囲内で収まるように設定を行ってきました。しかし、Route53には無料枠はなく月に$0.50の固定費が日割りなしで課されます。ただし、12時間以内で(ホストゾーンを)削除した場合には課金の対象外となります。

参考: Amazon Route 53 pricing - Amazon Web Services

Data Sources

これまでresourcemoduleブロックを使用してリソース作成のための設定を行ってきました。ただ今回は既にRoute53ホストゾーンの作成を手動で行ったため、リソースの作成は必要ありません。しかし、他のリソース作成時にはこのホストゾーンの情報が必要になることがあります。このようなTerraform管理下にないリソースの情報を取得するのに利用できるのが、Data Sourcesです。

Data Sourcesではresourcemoduleの代わりにdataブロックを使用し、対象がホストゾーンの場合はaws_route53_zoneを指定します。

route53.tf
data "aws_route53_zone" "root" {
  name = var.root_domain.name
}

参考: aws_route53_zone | Data Sources | hashicorp/aws | Terraform Registry

さて、これまでは変数定義にstring型を用いていましたが、上の例ではvar.root_domainnameをドット記法で繋げており、これはobject型の変数です。object変数の定義では以下のように波括弧の中に属性名と型をセットで指定します。

variables.tf
variable "root_domain" {
  type = object({
    name = string
  })
}

DNSレコード

ホストゾーンを作成しその情報も参照できるようになったので、次にルートドメインのDNSレコードを追加していきます。Vercelでフロントエンドのデプロイを行っている場合、以下のように、Aレコードにはルートドメイン名、CNAMEレコードには先頭にwwwを付けたルートドメイン名を追加することになると思います。

route53.tf
resource "aws_route53_record" "root" {
  zone_id = data.aws_route53_zone.root.zone_id
  name    = data.aws_route53_zone.root.name
  type    = "A"
  ttl     = "300"
  records = var.root_domain.records.a
}

resource "aws_route53_record" "www" {
  zone_id = data.aws_route53_zone.root.zone_id
  name    = "www.${data.aws_route53_zone.root.name}"
  type    = "CNAME"
  ttl     = "300"
  records = var.root_domain.records.cname
}

レコードの値 (records) の指定に再度object型変数を利用しています。そこで先程の変数を以下のように変更します。

variables.tf
variable "root_domain" {
  type = object({
    name = string
    records = object({
      a     = list(string)
      cname = list(string)
    })
  })
}

実際の値はterraform.tfvarsに記載し、今回の場合以下のようになります。

terraform.tfvars
root_domain = {
  name = "miwataru.com"
  records = {
    a     = ["76.76.21.21"]
    cname = ["cname.vercel-dns.com"]
  }
}

参考: aws_route53_record | Resources | hashicorp/aws | Terraform Registry

ACM

ここからはACM (AWS Certificate Manager) の設定を行います。ACMは、複雑な手続き必要なくSSL/TLS証明書の発行や管理を行うことができるサービスです。無料で利用可能で、リソースに紐付けられた状態であれば証明書の更新も自動で行われます。

証明書のリクエストと検証

ACMの設定にはModuleを利用する方法を採用しています。これは証明書のリクエストと証明書発行に必要となる検証を同時に行うものです。resourceを使用する場合はそれぞれ設定が必要になります。

例によって新規ファイルacm.tfを作成し、以下のように記述します。

acm.tf
module "acm" {
  source = "terraform-aws-modules/acm/aws"

  domain_name = "*.${data.aws_route53_zone.root.name}"
  zone_id     = data.aws_route53_zone.root.zone_id

  wait_for_validation = true

  depends_on = [
    data.aws_route53_zone.root
  ]
}

domain_nameには対象となるドメインを指定します。ワイルドカード (*) と共に用いることでサブドメインを含めて利用可能な証明書 (ワイルドカード証明書) の発行を行うことができます。またDNS検証にCNAMEレコードの登録が必要となるためzone_idでホストゾーンの指定を行います。

wait_for_validationはDNS検証を同時に行うかどうかの指定で、これは内部にresourceaws_acm_certificate_validationが使用されています。

depends_onは依存関係を示します。初めに述べた通り、Terraformではリソースの依存関係を自動で解決しますが、このように明示的な指定を行うことも可能です。これによってテキストエディタ (ここではVSCodeを想定) で参照元参照先に対するコードジャンプができるようになります。depends_onの指定なしでも多くの場合この機能は有効ではありますが、例外もあり、今回の場合依存関係は検知されなかったため明示する方法を採っています。

以上により、Route53のホストゾーンにCNAMEレコードが登録され証明書が発行されることになります。

参考:
terraform-aws-modules/terraform-aws-acm - GitHub
aws_acm_certificate | Resources | hashicorp/aws | Terraform Registry

SES

ここでは、Amazon SES (Simple Email Servises) の設定を行っていきます。SESはメールの送受信サービスです。メールアドレスの他、取得したドメインを使用したメールの送受信が可能で、永久無料枠も提供されています。

サンドボックス

SESは初期状態ではサンドボックスモードに設定されています。この状態では送信先や送信可能数に制限が生じ、事前に検証されたメールアドレス (又はドメイン) に対してのみメールの送信が可能となっています。ユーザー登録時に認証用メールを送るために使用するのでこれでは要件を満たしません。そこでサンドボックスの解除を行うことになります。

このサンドボックス解除にはAWSに対する申請を必要とします。メールサービスの利用目的や送信先及びその頻度などを申告し、AWSからの制限解除許可がなければ無制限のメール送信ができません。審査の結果が判明するまでには数時間から数日掛かる場合もあるので早めに行っておくのが望ましいです。審査が行われるため通過しないこともありますが、その場合には詳細な情報を加えることで再度申請可能です。

参考: Moving out of the Amazon SES sandbox - Amazon Simple Email Service

ID検証

サンドボックス状態では事前に検証された相手先にのみメール送信が可能でした。これと同様に、メール送信に利用するアドレス又はドメインも検証が必要となります。メールが受信できるアドレスであれば検証を行うことが可能で、手軽に行うことができます。一方、ドメインを検証に利用した場合にはサブドメインのメールアドレスも含めて利用できるようになるため、ドメインを所持しているのであればこちらを利用するのが便利です。

TerraformでID検証を行うのに使用するresourceaws_ses_domain_identityです。ses.tfを作成して以下のコードを追加します。

ses.tf
resource "aws_ses_domain_identity" "root" {
  domain = data.aws_route53_zone.root.name
}

参考:
Verified identities in Amazon SES - Amazon Simple Email Service
aws_ses_domain_identity | Resources | hashicorp/aws | Terraform Registry

DKIM

ID検証の方法としてドメインを選択した場合にはメール認証も行う必要があり、この認証の手段として使用されるのが、DomainKeys Identified Mail (DKIM) です。DKIMはドメインのなりすましやメールの改竄防止などの認証機能を提供します。

DKIM認証を有効にするには、ID作成時に提供される3つのCNAMEレコードをDNSサーバーに登録することが必要です。Terraformにおいてはaws_ses_domain_dkimによって必要なレコード情報を出力します。

ses.tf
resource "aws_ses_domain_dkim" "root" {
  domain = aws_ses_domain_identity.root.domain
}

このresourceの出力を利用してDNSレコードを登録するには次のようにします。

ses.tf
resource "aws_route53_record" "root_amazonses_dkim_record" {
  count   = 3
  zone_id = data.aws_route53_zone.root.zone_id
  name    = "${element(aws_ses_domain_dkim.root.dkim_tokens, count.index)}._domainkey"
  type    = "CNAME"
  ttl     = "600"
  records = ["${element(aws_ses_domain_dkim.root.dkim_tokens, count.index)}.dkim.amazonses.com"]
}

参考:
Authenticating Email with DKIM in Amazon SES - Amazon Simple Email Service
aws_ses_domain_dkim | Resources | hashicorp/aws | Terraform Registry

SMTP Credencials

SESを利用してメール送信を行う方法にはSMTPインターフェイスによるものとSES APIによるものがありますが、今回採用するのはSMTPインターフェイスの方です。そしてこの場合、SMTPユーザー名とパスワードが認証情報として必要になります。

認証情報を用意するのに必要な手順は、まず適切な権限 (ポリシー) を付与したIAMユーザーを作成することです。そしてこのユーザーに対してアクセスキーを発行します。

ses.tf
# IAMユーザー作成
resource "aws_iam_user" "smtp" {
  name = "ses-smtp-user.20210920-091305" # 一意のユーザー名
}

# アクセスキー発行
resource "aws_iam_access_key" "smtp" {
  user = aws_iam_user.smtp.name
}

# ポリシーのアタッチ
resource "aws_iam_user_policy" "smtp" {
  name = "AmazonSesSendingAccess"
  user = aws_iam_user.smtp.name

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = [
          "ses:SendRawEmail",
        ]
        Effect   = "Allow"
        Resource = "*"
      },
    ]
  })
}

IAMユーザー名を指定するにあたり一意になるように注意する必要があります。timestamp()関数を利用することで達成可能ですがその場合リソースが毎回置き換えられるという問題があります。その代わりとして、ここではコード追加時点の時刻を末尾に付与することにしました。

参考:
Obtaining Amazon SES SMTP credentials - Amazon Simple Email Service
aws_iam_user_policy | Resources | hashicorp/aws | Terraform Registry

リソースを作成するためのIAMユーザーを識別する認証情報としては、アクセスキーとシークレットアクセスキーを利用していましたが、SMTPインターフェイスで使用する認証情報はユーザー名とパスワードです。ユーザー名にはアクセスキーを使用あれるため実質同一の値です。一方、パスワードにはシークレットアクセスキーが変換された値 (ses_smtp_password_v4) が用いられます。これらの値はLaravelの環境変数MAIL_USERNAMEMAIL_PASSWORDに該当します。Terraformから値を渡すためにssm.tfにコードを追加します。

ssm.tf
resource "aws_ssm_parameter" "smtp_username" {
  name        = "/${var.project}/${var.stage}/MAIL_USERNAME"
  type        = "SecureString"
  value       = aws_iam_access_key.smtp.id
  description = "Access key ID used as the STMP username"
}

resource "aws_ssm_parameter" "smtp_password" {
  name        = "/${var.project}/${var.stage}/MAIL_PASSWORD"
  type        = "SecureString"
  value       = aws_iam_access_key.smtp.ses_smtp_password_v4
  description = "Secret access key converted into an SES SMTP password"
}

参考:
Types of Amazon SES credentials - Amazon Simple Email Service
aws_iam_access_key | Resources | hashicorp/aws | Terraform Registry

VPCエンドポイント (Interface型)

DynamoDB接続用にVPCエンドポイント (Gateway型)の構成を行った際に述べた通り、VPC内外のプライベート通信を行うためにエンドポイントを設置しますが、Gateway型はSESでは使用できません。したがって、Interface型のエンドポイントを構築します。

料金について、Gatewayエンドポイントは無料で利用可能ですが、一方、Interfaceエンドポイントは使用時間に応じたコストが発生します。例えば、一箇所のAZにエンドポイントを設置した場合のコストは、us-east-1リージョンの場合0.01USD/hなので一日あたりでは0.24USDとなります (利用当初の価格設定) 。
今回の構成にかかるコストの大半を占めているのはこのInterfaceエンドポイントです。

それでは、vpc.tfにエンドポイント設定を追加していきます。Gateway型と同様にVPCを指定し、サービス名は末尾がemail-smtpで、エンドポイントタイプvpc_endpoint_typeInterfaceとします。

また、セキュリティグループsecurity_group_idsとサブネットsubnet_idsを配列で指定します。エンドポイントはサブネット毎に作成されそれぞれ課金されるため、必要なければサブネットは一つとしておきます。ただし各サブネットは異なるAZに設置されていますが、そのAZによっては対応していない場合があり、今回の場合はus-east-1a b dの3つのみ対応しています。

最後に、プライベートDNSを有効にします。これにより、プライベートDNS名email-smtp.us-east-1.amazonaws.comが付与され、後にメールホストとしてこれを使用します。

vpc.tf
resource "aws_vpc_endpoint" "ses" {
  vpc_id            = module.vpc.vpc_id
  service_name      = "com.amazonaws.${local.region}.email-smtp"
  vpc_endpoint_type = "Interface"

  security_group_ids  = [module.security_group.security_group_id]
  subnet_ids          = [module.vpc.private_subnets[0]]
  private_dns_enabled = true
}

このエンドポイントを通してSESにアクセスするには、さらにセキュリティグループのインバウンドルールに設定を加える必要があります。これについては、RDSの構成時にvpc.tfファイルを作成しingress_with_selfによって設定を行っているので、ここにSMTPインターフェイスで使用されるポートを追加します。

vpc.tf
  ingress_with_self = [
    {
      from_port   = 587
      to_port     = 587
      protocol    = "tcp"
      description = "SES SMTP interface"
    },
    {
      from_port   = 3306
    # ...
    },
  ]

以上で、VPCからSESを利用するためのエンドポイントの設定は完了です。

参考: Setting up VPC endpoints with Amazon SES - Amazon Simple Email Service

S3

ここではS3の構成を行います。S3 (Simple Storage Service) は、高可用性、高耐久性などが特徴のオブジェクト型ストレージサービスです。今回はデバッグ用ツール (Telescope) のアセット (CSSやJS) の管理を主な目的として用いています。

S3を利用してファイルを格納するためにはまずバケットを作成する必要があります。Terraformでこれを行うため、新たにs3.tfを作成し設定を加えていきます。

バケットの作成にはaws_s3_bucketを使用し、任意のバケット名を指定します。このバケット名はAWS上のアカウントで一意である必要があり、他のユーザーが既に使用している場合など既に存在するバケット名は利用できません。必須属性はこれだけですが、必要に応じて他の設定も加えます。

s3.tf
resource "aws_s3_bucket" "main" {
  bucket = "${lower(var.project)}-${var.stage}" # バケット名 (AWS上で一意)
  acl    = "private" # Access control list (デフォルト値)

  versioning {
    enabled = true
  }
}

aclはアクセスコントロールリストの設定で、privateバケット所有者のみが読み書きの権利を有します。デフォルト値はprivateですが上記では明示的に加えています。今回格納するアセットファイルはpublicディレクトリに配置されており公開データとして扱いますが、バケット内のデータ (オブジェクト) 毎にACLを設定できるため、ここではprivateでも問題ありません。

また、バージョニングを有効化しています。これにより、仮にオブジェクトを削除したとしても復元が可能となります。

以上でS3バケットの作成設定は完了しました。このバケットを使用したデータの格納はGitHub Actionsの項目で行います。

参考: aws_s3_bucket | Resources | hashicorp/aws | Terraform Registry

アプリ固有インフラ

ここからは作業ディレクトリをbackendに移行し、アプリケーション毎に固有のリソースを作成していきます。

Lambda 概要

改めて、Lambdaは、サーバー管理不要 (サーバレス) でコードの実行が可能なマネージド型のコンピューティングサービスです。メモリやCPUなどの管理やアプリケーションのモニタリング、またオートスケーリングや冗長構成などの機能を内包しており、設定なしで拡張性や可用性を確保することができます。その代わり、EC2のようにインスタンスにログインして構成の確認や変更などを行うことは不可能となります。

また、イベントをトリガーとして関数の実行をするのが大きな特徴で、HTTPアクセスをイベントとして利用できる (API Gatewayを使用する) 他、時間をトリガーとした関数の実行や、RDSのデータ保存をトリガーとして実行するなど、様々なイベントを使用することが可能で高い汎用性があります。

課金体系にもメリットがあり、Lambdaは関数が呼び出された時のみ稼働しその実行時間に対しての従量課金のため非常にコスト効率が良く安価な利用が可能です。さらに永久無料枠も存在するため費用を掛けずに利用を開始することができます。

Serverlessを使用することで、比較的容易にLambdaによるサーバレス構成のアプリケーションをデプロイすることが可能です。

Serverless Framework

Serverless Framework (Serverless) は、サーバレスアプリケーションのデプロイを補助するためのツールです。サーバレスのため、AWS Lambda や Google Cloud Functions などのFaaSを使用した構成が前提となり、今回の場合はLambdaを使っていくことになります。なお、Serverlessは内部にCloudFormationが利用されています。

Serverlessを利用することで、Lambdaへのデプロイだけでなく周辺環境を用意に整えることができるという利点があります。例えば、アプリケーションのログを出力するためのCloudWatch Logsが設定なしで構築され、必要なIAMロールも同時に追加されます。また、アプリケーションをZIPファイルに変換しS3にアップロードするという手順も自動的に行われます。

また、アプリ作成時点で、Lambdaはランタイム (実行言語) としてPHPをサポートしていませんでした。しかしその場合でも、カスタムランタイムという独自のランタイムを作成することで対応することが可能です。後述のBrefというツールを利用することでこのカスタムランタイムの作成の手間を省くことができますが、これはServerlessに依存しているため、Serverlessを使用する理由の一つになっています。

backend/.gitignore

Terraformの場合と同様に、インフラ構築時に出力されるファイルには秘匿すべき情報が含まれているため.gitignoreに追加します。Serverlessでは.serverlessディレクトリにファイルが出力されるため以下を加えます。

.gitignore
.serverless/

Bref

Brefは、PHPアプリケーションをLambdaにデプロイするためのツールです。先述の通り、カスタムランタイム作成に係る問題を解決します。さらに、PHPの使用に特化していることもあり、ドキュメントにはLaravelのアプリケーションを想定した設定について豊富に記載されています。

Brefのインストールを行うにあたり、ドキュメントを見るとインストールの項目が存在しますが、Laravelでの構築用に別途インストール手順の記載があるためそちらに従います。

sail composer require bref/bref bref/laravel-bridge --update-with-dependencies

インストールが完了した後に設定ファイルを出力します。

sail artisan vendor:publish --tag=serverless-config

上記コマンドを実行するとserverless.ymlが作成されます。

出力されたserverless.yml
serverless.yml
service: laravel

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

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
  artisan:
    handler: artisan
    timeout: 120 # in seconds
    layers:
      - ${bref:layer.php-74} # PHP
      - ${bref:layer.console} # The "console" layer

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

ここで出力されたファイルには、使用するバージョンによってはServerlessで非推奨とされる設定が含まれていることがあります。その場合servelessコマンドを実行する際に警告が発せられるので都度対応します。

serverless.yml

出力されたserverless.ymlの内容について簡単に見ていきます。まず最上位にあるserviceですが、これはアプリケーション名を表します。ZIPファイルやLambdaの関数名、IAMロール名などの一部命名にも使用されることになります。

serverless.yml
service: laravel

次にprovider部で、これはアプリケーション全体に関する設定を行います。Serverlessはマルチクラウドのツールのため、nameにAWSを使用することを示し、またregionの指定も行います。次のstageの値は、使用する環境変数(.env)ファイルの判定やAWSコンフィグの判定などに用いられる他、Lambda関数名などにも使用されます。そして、runtimeに指定しているprovided.al2カスタムランタイムで、Amazon Linux 2 を表します。

serverless.yml
provider:
  name: aws
  region: us-east-1
  stage: dev
  runtime: provided.al2

次のpackage部では主にLambdaパッケージに含めるファイルを指定します。今回出力されたファイルで使用されていたexclude非推奨だったため以下のように修正しました。

serverless.yml
package:
  patterns:
    - "!node_modules/**"
    - "!public/storage"
    - "!resources/assets/**"
    - "!storage/**"
    - "!tests/**"

先頭に!を付与することでパッケージから除外するようにしています。

参考: Serverless Framework Guide - AWS Lambda Guide - Packaging

そして、functions部がLambda関数の設定です。初めのwebは関数名で、先述のserviceprovider.stageから最終的な名前が決定し、この例では"laravel-dev-wev"となります。

handlerに指定するのは実行するファイル名です。次のtimeoutには関数の最大実行時間を指定します。初期状態で28秒となっていますが、これはAPI Gatwayに29秒の時間制限がありこれを下回る値にする必要があるためです。layersにはBrefが提供するランタイムを使用します。これによりPHPでのLambdaの利用が可能となります。eventsには関数実行のトリガーを指定し、httpApiは API Gatewayを表します。このワイルドカード(*)の指定で全てのリクエストをアプリケーションに送信することになります。

serverless.yml
functions:
  web:
    handler: public/index.php
    timeout: 28 # in seconds (API Gateway has a timeout of 29 seconds)
    layers:
      - ${bref:layer.php-80-fpm}
    events:
      - httpApi: "*"

参考:
Web applications on AWS Lambda - Bref
Serverless Framework - AWS Lambda Guide - Functions

同様にしてartisan関数の設定も用意されており、これによりartisanコマンドがLambda上で実行できるようになっています。

serverless.yml
artisan:
  handler: artisan
  timeout: 120 # in seconds
  layers:
    - ${bref:layer.php-80} # PHP
    - ${bref:layer.console} # The "console" layer

artisanコマンドを実行するには、実際にはbref cliコマンドを利用して以下のような形式を取ります。

vendor/bin/bref cli <関数名> -- <コマンド>

例えばphp artisan migrateを実行する場合、これまでの設定をそのまま用いれば以下のようになります。

vendor/bin/bref cli laravel-dev-artisan -- migrate --force

--の後にartisan以下のコマンドを入力し、オプションも通常通り使用可能です。ここで実行するコマンドではproduction環境でmigrateを行った場合の確認に応答することができないため、--forceを付与して確認なしで実行するようにしています。

参考: Console commands - Bref

上記で述べたようなBrefの機能を利用するため、pluginsには以下のパッケージが必要となります。

serverless.yml
plugins:
  - ./vendor/bref/bref

参考: PHP runtimes for AWS Lambda - Bref # Usage

ここでは初めに出力された状態でのserverless.ymlを内容を確認してきました。以降は実際に構築するインフラに合わせて設定を変更していきます。

環境変数

ローカル環境でLaravelを開発する際、環境変数を扱うためにはこれまで.envファイルを使用していました。本番環境でこれらの値を参照するにはLambdaの環境変数としてセットします。

serverless.yml内で環境変数を設定するにあたり、まずuseDotenvを追加します。これにより、serverless.ymlファイル内で.envファイルの値が利用できるようになります。

serverless.yml
useDotenv: true

このとき、環境変数を読み込む際のファイルには.envだけでなく.env.devなどを代わりに用いることも可能です。ここで使用されるファイルはprovider.stageプロパティの値によって決まり、例えば以下ように設定した場合には.env.devが読み込まれることになります。 (ただし.env.devが存在しない場合には.envを探しに行きます。)

serverless.yml
provider:
  stage: dev

参考: Resolution of environment variables

ところで、環境によって使用するファイルを柔軟に変更したい場合もありますが、これにはコマンドラインのオプションが使用でき、先程のstageプロパティをstage: ${opt:stage}に修正します。これによりserverlessコマンド実行の際に--stageオプションを使用することで値を都度指定することが可能となります。

serverless deploy --stage dev

参考: Serverless Variables # Referencing CLI Options

ここでオプションを指定しなかった場合にはエラーが発生しますが、デフォルト値を設定することでオプション指定を任意とすることも可能です。

serverless.yml
provider:
  stage: ${opt:stage, "dev"}

読み込まれた環境変数は${env:環境変数名}記法によって参照できるようになります。

参考: Serverless Variables # Referencing Environment Variables

ただ、これだけではまだLambdaの環境変数には追加されるようにはなっていません。加えて、provider.environmentプロパティに各環境変数を追加することが必要です。

serverless.yml
provider:
  environment:
    APP_NAME: ${env:APP_NAME}

ここまでの流れを整理するとserverless.ymlは以下のようになります。

serverless.yml
useDotenv: true

provider:
  name: aws
  stage: ${opt:stage}
  environment:
    APP_NAME: ${env:APP_NAME}

SSMパラメータストア参照

さて、環境変数をLambdaに設定できるようにはなりましたが、Terraformによって構築されたリソースの情報は事前にすることはできません。そのためTerraform側ではこれらの値を、Systems Manager パラメータストアに格納していました。これらの値を参照することで、残りの必要な環境変数を揃えていきます。

Serverlessでは、${ssm:/階層/値}記法によってパラメータを取得することができます。パラメータ格納時にタイプをSecureStringに設定していたとしてもそのまま参照可能です。また、このとき内部にさらに環境変数を使用することもでき、例えばAPP_URLは以下のような記法で設定しています。

serverless.yml
APP_URL: https://api.${ssm:/${env:APP_NAME}/${sls:stage}/DOMAIN_NAME}

これは、環境変数のAPP_NAME${sls:stage}の値 (参照: Serverless Variables # Referencing Serverless Core Variables) を展開した上でパラメータストアから該当する値を決定し、その前の文字列と結合している例です。

参考: Serverless Variables # Reference Variables using the SSM Parameter Store

VPC Lambda

LambdaはVPCを必要とせず動作が可能ですが、作成したRDSはVPC内の同一セキュリティグループからのみアクセスを許可するようにしていました。よって、LambdaからRDSに接続するためには、このVPCにLambdaも所属させることが必要です。一方、LambdaがVPC内にあることでインターネットアクセスができなくなる問題があり、それに対してはVPCエンドポイントを設置する対応も必要でした。

参考: Using a database - Bref

それでは、serverless.ymlでの設定を行っていきます。VPCに所属させるためには、セキュリティグループのIDとサブネットのIDを配列で指定します。関数毎にVPCを指定する場合はfunctions配下に、全ての関数に適用する場合はprovider配下に設定を行います。また、以下のように両方指定することでデフォルトのVPCと関数毎のVPCの使い分けも可能です。

serverless.yml
provider:
  vpc:
    securityGroupIds:
      - securityGroupId1
    subnetIds:
      - subnetId1
      - subnetId2

functions:
  web: # `provider`で設定したVPCを上書き
    handler: public/index.php
    vpc:
      securityGroupIds:
        - securityGroupId1
      subnetIds:
        - subnetId1
        - subnetId2

参考: Serverless Framework - AWS Lambda Guide - Functions # VPC Configuration

さて、今回使用するVPCはTerraformにより構築し、ここで必要な値はパラメータストアに格納しています。それらを使用するとVPCの設定は以下のようになります。

serverless.yml
vpc:
  securityGroupIds:
    - ${ssm:/${env:APP_NAME}/${sls:stage}/SECURITY_GROUP_ID}
  subnetIds: ${ssm:/${env:APP_NAME}/${sls:stage}/PRIVATE_SUBNET_IDS}

ここで使用しているPRIVATE_SUBNET_IDSパラメータタイプがStringListの値でした。これはカンマの区切りの文字列ですが、上記ではそのまま配列として扱うことができています。

API Gateway

次に、API Gateway について設定を行っていきます。API Gateway はLambdaで構成した各種関数にアクセスするための窓口のような役割を担います。

CORS

初めにserverless.ymlを出力した段階で既にLambdaのトリガーとして API Gateway の設定がありました。

serverless.yml
functions:
  web:
    events:
      - httpApi: "*"

しかし、フロントエンド側からバックエンドのレスポンスを受け取るためには、追加でCORSの設定が必要となります。serverless.ymlでは、provider.httpApi.corsに設定します。

serverless.yml
provider:
  httpApi:
    cors:
      allowedOrigins:
        - https://www.${ssm:/${env:APP_NAME}/${sls:stage}/DOMAIN_NAME}
      allowedHeaders:
        - Content-Type
        - X-XSRF-TOKEN
      allowedMethods:
        - OPTIONS
        - POST
        - GET
        - PATCH
        - PUT
        - DELETE
      allowCredentials: true

allowedOriginsには、ユーザーがアクセスするフロントエンド側のURLを指定します。今回の場合、具体的にはhttps://www.miwataru.comですが、パラメータストアを利用することで値の再利用を行っています。

ところで、CORSはLaravelのconfig/cors.phpでも設定を行っていましたが、そのときは以下のようにワイルドカード(*)での指定をしていました。

cors.php
'allowed_methods' => ['*'],

'allowed_origins' => ['*'],

'allowed_headers' => ['*'],

'supports_credentials' => true,

API Gateway の場合もワイルドカード指定自体は可能ですが、実際に動作確認を行うとエラーが発生するなどして機能しませんでした。そこでブラウザの開発者ツールの"Network"タブの情報を頼りに設定を修正した結果、allowedHeadersallowedMethodsを個別に指定することで解決することができました。

参考: Serverless Framework - AWS Lambda Events - HTTP API # CORS Setup

カスタムドメイン

API Gateway は作成されるとランダムなドメインを生成します。

https://<random>.execute-api.<region>.amazonaws.com/

しかし、毎回ランダムに値が変化するというのも不便なので、取得したドメインを利用して API Gateway にカスタムドメインを付与します。また、Laravelのconfig/session.phpの設定が'same_site' => 'lax'になっている関係上、メインのドメインと別のドメインではCookieが利用できないという問題もあります (サブドメインは可) 。

参考: Custom domain names - Bref

serverless-domain-manager

Serverlessでカスタムドメインを設定するにはプラグインを導入する必要があります。

npm install serverless-domain-manager --save-dev

npmでインストールを行い、pluginsに追加します。

serverless.yml
plugins:
  - serverless-domain-manager

そして、serverless.ymlのトップレベルにcustomを追加し、以下のように設定します。

serverless.yml
custom:
  customDomain:
    hostsZoneId: ${ssm:/${env:APP_NAME}/${sls:stage}/HOSTED_ZONE_ID}
    domainName: api.${ssm:/${env:APP_NAME}/${sls:stage}/DOMAIN_NAME}
    certificateArn: ${ssm:/${env:APP_NAME}/${sls:stage}/CERTIFICATE_ARN}
    createRoute53Record: true
    endpointType: "regional"
    securityPolicy: tls_1_2
    apiType: http
    autoDomain: true

カスタムドメインの設定には、証明書 (certificateArn) が必要となるため、Terraform側で情報をパラメータストアに格納しておきます。domainName部が追加するカスタムドメイン名で、今回の場合はメインドメインにapiを付与したサブドメインです。

また、今回 HTTP API を利用しているため、apiType: httpを指定し、その場合必然的にendpointType: "regional"を指定することになります。

カスタムドメインを作成するにはserverless create_domainコマンドを使用し、作成後はserverless deploy実行時にカスタムドメインを利用したデプロイが行われますが、autoDomain: trueを指定することで、serverless deploy時にserverless create_domainも付随するようになります。

以上を纏めると、以下の3点がserverless-domain-managerによって処理されることになります。

  1. 事前に作成した証明書 (certificateArn) を使用してカスタムドメイン (domainName) 作成
  2. ホストゾーン (hostsZoneId) にAレコードを登録 (createRoute53Record)
  3. API Gateway のAPIマッピングを設定

参考:
Serverless Framework: Plugins
amplify-education/serverless-domain-manager - GitHub

また、カスタムドメインの設定に伴い、デフォルトのエンドポイントを無効化します。

serverless.yml
provider:
  httpApi:
    disableDefaultEndpoint: true

参考: Serverless Framework - AWS Lambda Events - HTTP API # Disable Default Endpoint

DynamoDB

DynanoDBはキーバリュー型のNoSQLで、高いスケーラビリティや高速なパフォーマンスを特徴としています。ここではキャッシュ及びセッション用のデータベースとして使用しています。このような用途としては通常ElastiCacheを採用することが多いようですが、DynamoDBでも代用可能でコスト面でも優位なのでこちらを採用しています。

Serverlessでリソース構築を行うにはresourceプロパティに記述していくことになります。ここではCloudFormationの構文が使用されます。

Serverless Framework - AWS Lambda Guide - AWS Infrastructure Resources

DynamoDBのテーブルを作成するには、以下のようにTypeAWS::DynamoDB::Tableを指定します。cacheTable部は識別のために使用するリソース名です。詳細の設定はProperties以下に加えていきます。

serverless.yml
resources:
  Resources:
    cacheTable:
      Type: AWS::DynamoDB::Table
      Properties:
        TableName: ${env:DYNAMODB_CACHE_TABLE, "cache"} # テーブル名
        AttributeDefinitions:
          - AttributeName: key # 属性名 (キー名として利用)
            AttributeType: S   # 属性タイプ (S: String)
        KeySchema:
          - AttributeName: key # キー名 (上で定義した属性名)
            KeyType: HASH      # HASH: パーティションキー, RANGE: ソートキー
        BillingMode: PROVISIONED # 無料枠の対象
        ProvisionedThroughput:
          ReadCapacityUnits: 1  # プロビジョンされる読込キャパシティーユニット
          WriteCapacityUnits: 1 # プロビジョンされる書込キャパシティーユニット

TableNameにはテーブル名を、AttributeDefinitionsにはキーとして利用する名前とタイプを指定します。ここでは、Laravelのデフォルト設定に従い、それぞれの名前をcachekeyとしています。

KeySchemaには定義した属性に従って実際のキー設定を行います。上記ではkeyをパーティションキーとして扱っています。

また、無料枠で利用するため、BillingModeにはPROVISIONEDを指定します。次にプロビジョンされるキャパシティーユニットを読み書きそれぞれ指定します。各キャパシティーユニットの消費率についてはDynamoDBのドキュメントのページを参照してください。実際に使用されたキャパシティーユニットの値は、DynamoDBマネジメントコンソール内にあるキャパシティーメトリクス表からも確認が可能です。

参考:
Get started - AWS CloudFormation
AWS::DynamoDB::Table - AWS CloudFormation

Lambda Role

最後に、Lambdaが他のリソース (DynamoDBとSES) にアクセスするために必要なポリシーを付与していきます。ServerlessではLambdaのロールが自動的に作成され、AWSLambdaVPCAccessExecutionRoleがデフォルトのポリシーとしてアタッチされています。これに加えて、各リソースアクセス用のAWS管理ポリシーが用意されているのでそれを以下のように設定します。

serverless.yml
provider:
  iam:
    role:
      managedPolicies:
        - "arn:aws:iam::aws:policy/service-role/AWSLambdaDynamoDBExecutionRole"
        - "arn:aws:iam::aws:policy/AmazonDynamoDBFullAccess"
        - "arn:aws:iam::aws:policy/AmazonSESFullAccess"

参考:: Serverless Framework - AWS Lambda Guide - IAM # The Default IAM Role

GitHub Actions

ここからは、これまでに作成してきた構成ファイルを用いて、GitHub Actions による自動デプロイを行っていきます。GitHub Actions の概要については過去記事で触れています。

それでは、workflowの作成に移ります。まず.github/workflows/deploy.ymlなど適当なファイル名で作成し、トップレベルに任意のnameを与え、onに検知するイベントを指定します。

.github/workflows/deploy.yml
name: Continuous Deployment # 適当なworkflow名

on:
  push:
    branches:
      - development
  pull_request:

実行するタイミングとして、プルリクエストの時と、developmentブランチにプッシュされた時を指定しています。なお、このプッシュにはプルリクエストのマージも含まれています。

ところで、通常の開発の流れとして、メインのブランチ (mastermain) から新たなブランチを作成し、プルリクエストによってそのメインのブランチにマージするという手法を取ると思います。しかし、ローカル環境からメインブランチに直接プッシュすることも可能であり、これはこの開発の流れを考慮すると望ましい状態ではありません。そこで、GitHubの Branch Protection を使用することで、メインブランチへの直接プッシュに制限を設け、プルリクエスト経由のマージを必須とします。

プッシュを制限した状態であれば、上記のイベントはプルリクエスト時と、それがdevelopmentブランチにマージされた時と読み直すことができます。デフォルトブランチを変数として指定する方法を探しましたが現時点では用意されていないようです。

以降では、共有インフラとアプリ固有インフラに分けてjobsを設定していきます。

共有インフラ構築

まずは、Terraformが担当している共用インフラを構築するためのJobを作成します。これは基本的に、TerraformのドキュメントのGitHub Actions のページに従い、初めに以下の手順から進めていきます。

  1. Terraform Cloud のアカウントを準備し、OrganizationとWorkspaceを作成
  2. Environment Variables に、AWSアクセスキーを追加
  3. Tokensページで API token を作成
  4. 作成した API token をGitHubのSecretsに追加

Terraform Cloud のOrganizationとWorkspaceは先述の通りBackendで使用するため、それぞれの名前を用いて以下のようなファイルを作成しておきます。

backend.dev.hcl
organization = # "Organization名"

workspaces {
  name =  # "Workspace名"
}

次に、jobsの指定を行っていきます。直下の<job_id>に一意となる適当な名前を付け、表示用のnameも指定しておきます。作業ディレクトリはterraformになるので、working-directoryの指定も必要です。

.github/workflows/deploy.yml
jobs:
  build-shared-infrastructure:
    name: Build Shared Infrastructure
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: ./terraform

次のstepsではsetup-terraformアクションを使用し、GitHubのSecretsに追加しておいた Terraform Cloud 認証用のトークンを与えます。

.github/workflows/deploy.yml
steps:
  - name: Check out repository code
    uses: actions/checkout@v2

  - name: Setup Terraform
    uses: hashicorp/setup-terraform@v1
    with:
      cli_config_credentials_token: ${{ secrets.TF_API_TOKEN }}

次に、Terraformファイルのフォーマットチェックを行います。この時フォーマットに問題がある場合には終了ステータスが0以外となり、以降のアクションがスキップされます。また、後の工程でこのステップの出力を扱うためidの指定が必須となります。

.github/workflows/deploy.yml
- name: Terraform Format
  id: fmt
  run: terraform fmt -check

terraform initを実行する際のbackend-configには、作成しておいたbackend.dev.hclを指定します。

.github/workflows/deploy.yml
- name: Terraform Init
  id: init
  run: terraform init -backend-config=backend.dev.hcl

次のterraform planは、イベントがプルリクエストの場合のみ実行し、プルリクエストがdevelopmentブランチにマージされるタイミングでは実行されません。

.github/workflows/deploy.yml
- name: Terraform Plan
  id: plan
  if: github.event_name == 'pull_request'
  run: terraform plan -no-color
  continue-on-error: true

上記のcontinue-on-error: trueがあることによって、エラーが発生した際にも以降の処理をキャンセルしなくなります。これは次のアクションをエラー時にも実行するための指定です。

以下のように、github-scriptアクションを用いることで、各ステップの実行結果とterraform planの標準出力がプルリクエストのコメントに追加されるようになります。

\`\`\`terraformの部分はコードブロックですが、ドキュメント通りの記法 (\`\`\`\n) では機能しなかったため変更を加えています。また、ブロック内は標準出力になっていますが、言語指定をterraformにするとシンタックスハイライトが良い具合に働きます。

.github/workflows/deploy.yml
- uses: actions/github-script@0.9.0
  if: github.event_name == 'pull_request'
  env:
    PLAN: ${{ steps.plan.outputs.stdout }}
  with:
    github-token: ${{ secrets.GITHUB_TOKEN }}
    script: |
      const output = `
        #### Terraform Format and Style 🖌\`${{ steps.fmt.outcome }}\`
        #### Terraform Initialization ⚙️\`${{ steps.init.outcome }}\`
        #### Terraform Validation 🤖\`${{ steps.validate.outcome }}\`
        #### Terraform Plan 📖\`${{ steps.plan.outcome }}\`

        <details><summary>Show Plan</summary>

        \`\`\`terraform
        ${process.env.PLAN}
        \`\`\`

        </details>

        *Pusher: @${{ github.actor }}, Action: \`${{ github.event_name }}\`*
      `;

      github.issues.createComment({
        issue_number: context.issue.number,
        owner: context.repo.owner,
        repo: context.repo.repo,
        body: output
      })

上記で目的のアクションを実行し終えたので、terraform planでエラーが発生した場合には、本体の挙動と同様に処理を中断します。

.github/workflows/deploy.yml
- name: Terraform Plan Status
  if: steps.plan.outcome == 'failure'
  run: exit 1

最後に、プルリクエストがdevelopmentブランチにマージされるタイミングで構成を適用します。

.github/workflows/deploy.yml
- name: Terraform Apply
  if: github.ref == 'refs/heads/development' && github.event_name == 'push'
  run: terraform apply -auto-approve

以上で、イベントとしてはプルリクエストも検知しつつ、実行するアクションを制御してインフラ構築まで行うことができました。

参考:
Automate Terraform with GitHub Actions | Terraform - HashiCorp Learn
hashicorp/learn-terraform-github-actions - GitHub

アプリ固有インフラ構築

ここではServerlessによるアプリ固有インフラ構築のためのJobを、共有インフラと同じ.github/workflows/deploy.ymlに作成します。参考にしたのはServerlessが提供する GitHub Actions 用のリポジトリです。

初めに、Jobの実行条件を指定します。ここで作成するリソースは先述のbuild-shared-infrastructureで構築するインフラに依存するため、needsを使用してそのJobが正常に終了した場合のみ実行するようにします。また、このworkflowはプルリクエスト時にも実行するようになっていますが、その時にはこのJobは実行しないように制限を設けます。

.github/workflows/deploy.yml
jobs:
  build-shared-infrastructure:
    ...

  build-app-specific-infrastructure:
    name: Build App-Specific Infrastructure
    needs: build-shared-infrastructure
    if: github.ref == 'refs/heads/development' && github.event_name == 'push'

デプロイを実行する前に依存関係をインストールしておきます。

.github/workflows/deploy.yml
- name: Cache Node.js modules
  id: node-cache
  uses: actions/cache@v2
  with:
    path: ./backend/.npm
    key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
    restore-keys: |
      ${{ runner.os }}-node-

- name: Install Node.js dependencies
  if: steps.node-cache.outputs.cache-hit != 'true'
  run: npm ci

- name: Cache Composer packages
  id: composer-cache
  uses: actions/cache@v2
  with:
    path: ./backend/vendor
    key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
    restore-keys: |
      ${{ runner.os }}-composer-

- name: Install Composer dependencies
  if: steps.composer-cache.outputs.cache-hit != 'true'
  run: composer install --prefer-dist --optimize-autoloader --no-dev

キャッシュがヒットした場合のインストールスキップについて、それぞれ、"serverless-domain-manager" not foundTarget class [view] does not existというエラーが発生したため、スキップは行っていません。

参考:
Automating deployments - Deployment - Bref
actions/cache - GitHub

次にパーミッションの設定を行います。

.github/workflows/deploy.yml
- name: Directory Permissions
  run: chmod -R 777 storage bootstrap/cache

参考: main/ci/laravel.yml - actions/starter-workflows - GitHub

GitHub Actions 環境でServerlessを利用するためには、npmserverlessをグローバルインストールして、serverlessコマンドを実行できるようにする方法が明快で簡単です。

.github/workflows/deploy.yml
- name: Install Serverless globally
  run: npm install -g serverless

参考: Serverless Dashboard - Running in your own CI/CD

次に、serverlessコマンドを使用して、GitHub Actions 環境でのAWS認証情報を設定します。

.github/workflows/deploy.yml
- name: Serverless Config Credentials
  run: |
    serverless config credentials \
      --provider aws \
      --key ${{ secrets.AWS_ACCESS_KEY_ID }} \
      --secret ${{ secrets.AWS_SECRET_ACCESS_KEY }} \
      --profile dev

参考: Using AWS Profiles - Serverless Framework - AWS Lambda Guide - Credentials

そして、stageaws-profieを指定してデプロイを実行します。以下の例ではstagedevのため、.env.devがあればその内容を読み込みます。しかし、GitHub上にアップロードすることが好ましくない情報も一部あり、その場合はGitHubのSecretsに追加してそれをenvとして指定します。

.github/workflows/deploy.yml
- name: Serverless Deploy
  run: serverless deploy --stage dev --aws-profile dev --verbose
  env:
    APP_KEY: ${{ secrets.APP_KEY }}
    ADMIN_EMAIL: ${{ secrets.ADMIN_EMAIL }}

最後に、Laravelで使用する静的ファイルをアップロードするため、npm run prodでコンパイルを行います。

.github/workflows/deploy.yml
- name: Compile Assets
  run: npm run prod

次にawsコマンドを利用するため、aws-actions/configure-aws-credentials アクションを利用します。

.github/workflows/deploy.yml
- 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: us-east-1

npm run prodによってpublicに出力されたアセットをTerraformで作成したS3バケットに同期します。

.github/workflows/deploy.yml
- name: Synchronize Assets with S3 Buckets
  run: |
    S3_BUCKET=$(
      aws ssm get-parameter \
        --name "/Miwataru/dev/S3_BUCKET" \
        --with-decryption \
        --query Parameter.Value \
        --output text \
        --profile dev \
    )
    aws s3 sync public/ s3://${S3_BUCKET}/public \
      --delete --exclude index.php --profile dev --acl public-read

最後のACLの指定(--acl)をpublic-readとすることによって、S3バケットのACLとしてはprivateとしながら、アセットとしてアクセスすることができるようになります。

参考:
aws — AWS CLI 1.22.5 Command Reference
get-parameter — AWS CLI 1.22.5 Command Reference
sync — AWS CLI 1.22.5 Command Reference

以上で、GiuHub Actions による自動デプロイの設定が完了しました。

参考: Workflow syntax for GitHub Actions - GitHub Docs

まとめ

さて、これまでInfrastructure as Code (IaC) 導入の手順を見てきました。IaCを実現するためのツールとして、TerraformServerless Frameworkの構成をそれぞれ作成し、これらを連携して利用する方法も学びました。そして最後に、構成ファイルに基づいて自動的にデプロイするため、Github Actions の設定を行いました。

インフラをコードで管理することで現在の構成が明確になり、履歴を遡ることで変更点を確認することも容易になったと思います。また、アプリケーションに変更があった場合に都度デプロイ作業を行う手間を省くこともできました。一方で、IAMポリシーの権限管理や各リソースのパラメータの指定などに一段と深い理解が求められることとなり、IaCの導入時の他、構成の変更時にも躓くこともありました。特に今回使用したツールのバージョンの進化は早く大きな変更が施されることもあるため、情報収集の際には注意が必要です。

各種リンク

17
13
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
17
13

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?