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

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

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

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

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

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

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

- アプリURL: ~~[https://www.miwataru.com](https://www.miwataru.com)~~
- GitHub (Terraform): [https://github.com/zuka-e/laravel-react-task-spa/tree/development/terraform](https://github.com/zuka-e/laravel-react-task-spa/tree/development/terraform)
- GitHub (Serverless): [https://github.com/zuka-e/laravel-react-task-spa/blob/development/backend/serverless.yml](https://github.com/zuka-e/laravel-react-task-spa/blob/development/backend/serverless.yml)
- アプリ概要: [https://qiita.com/zuka-e/items/9a985f0dd5db21bc48d7](https://qiita.com/zuka-e/items/9a985f0dd5db21bc48d7)

## 目次

- [インフラ構成図](#インフラ構成図)
- [ディレクトリ構成](#ディレクトリ構成)
- [IAM](#iam)
  - [ユーザー](#ユーザー)
      - [アクセスキー](#アクセスキー)
  - [ポリシー](#ポリシー)
      - [ポリシー設定例](#ポリシー設定例)
  - [ロール](#ロール)
- [共有インフラ](#共有インフラ)
  - [Terraform](#terraform)
      - [ファイル構成](#ファイル構成)
      - [Initialization](#initialization)
      - [`terraform/.gitignore`](#terraformgitignore)
      - [State](#state)
      - [Backend](#backend)
      - [Input Variables](#input-variables)
      - [Terraform Cloud](#terraform-cloud)
      - [Module](#module)
  - [VPC](#vpc)
      - [セキュリティグループ](#セキュリティグループ)
      - [エンドポイント](#エンドポイント)
  - [RDS](#rds)
      - [RDSインスタンス](#rdsインスタンス)
      - [`rds.tf`](#rdstf)
  - [Systems Manager](#systems-manager)
      - [パラメータストア](#パラメータストア)
          - [パラメータ階層](#パラメータ階層)
          - [パラメータタイプ](#パラメータタイプ)
  - [Route53](#route53)
      - [ネームサーバー移転](#ネームサーバー移転)
      - [Data Sources](#data-sources)
      - [DNSレコード](#dnsレコード)
  - [ACM](#acm)
      - [証明書のリクエストと検証](#証明書のリクエストと検証)
  - [SES](#ses)
      - [サンドボックス](#サンドボックス)
      - [ID検証](#id検証)
      - [DKIM](#dkim)
      - [SMTP Credencials](#smtp-credencials)
      - [VPCエンドポイント (Interface型)](#vpcエンドポイント-interface型)
  - [S3](#s3)
- [アプリ固有インフラ](#アプリ固有インフラ)
    - [Lambda 概要](#lambda-概要)
    - [Serverless Framework](#serverless-framework)
      - [`backend/.gitignore`](#backendgitignore)
      - [Bref](#bref)
      - [`serverless.yml`](#serverlessyml)
      - [環境変数](#環境変数)
    - [SSMパラメータストア参照](#ssmパラメータストア参照)
    - [VPC Lambda](#vpc-lambda)
    - [API Gateway](#api-gateway)
      - [CORS](#cors)
      - [カスタムドメイン](#カスタムドメイン)
          - [serverless-domain-manager](#serverless-domain-manager)
    - [DynamoDB](#dynamodb)
    - [Lambda Role](#lambda-role)
- [GitHub Actions](#github-actions)
  - [共有インフラ構築](#共有インフラ構築)
  - [アプリ固有インフラ構築](#アプリ固有インフラ構築)
- [まとめ](#まとめ)
- [各種リンク](#各種リンク)

## インフラ構成図

今回作成したインフラは以下の図のようになります。詳細は[アプリ概要の記事](https://qiita.com/zuka-e/items/9a985f0dd5db21bc48d7#インフラ構成図)に記述しています。

![Infrastructure_configuration_diagram](https://raw.githubusercontent.com/zuka-e/images/miwataru/Miwataru/AWS_diagram.svg)

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

## ディレクトリ構成

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

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

## IAM

[IAM (Identity and Access Management)](https://docs.aws.amazon.com/IAM/latest/UserGuide/introduction.html) は、AWSにおける認証やアクセス管理を行うサービスです。TerraformやServerlessなどプログラムからAWSにアクセスにするには認証情報が必要となり、IAMによって管理します。

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

### ユーザー

[IAM ユーザー](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_users.html)はAWSを利用するユーザーやアプリケーションの識別子として機能します。これにより各ユーザーにとって必要な権限だけを割り当てるアクセス管理ができます。

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

#### アクセスキー

[アクセスキーとシークレットアクセスキー](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_access-keys.html)はプログラムによるAWSへのアクセスを行う際に必要となる認証情報で、IAM ユーザーを作成すると同時に発行することが可能です。

これらのキー情報は決められた場所にセットすることで利用できるようになります。[そのための方法は複数存在](https://www.serverless.com/framework/docs/providers/aws/guide/credentials#using-aws-access-keys)しますが、[Serverlessのガイド](https://www.serverless.com/framework/docs/providers/aws/guide/credentials#using-aws-profiles)を参考にして`~/.aws/credentials`に設定を行います。このガイドではその方法として`serverless`や`aws`コマンドが紹介されており、これによって簡単に設定することができます。手動でファイルを作成してキー情報を追加することも可能です。

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

### ポリシー

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

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

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

#### ポリシー設定例

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

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

<details><summary>Terraform用</summary><div>

```json
{
    "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": "*"
        }
    ]
}
```

</details></div>

<details><summary>Serverless用</summary><div>

```json
{
    "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"
        }
    ]
}
```

</details></div>

### ロール

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

## 共有インフラ

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

### Terraform

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

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

これから利用するにあたり、まず初めに[公式サイトの手順](https://learn.hashicorp.com/tutorials/terraform/install-cli?in=terraform/aws-get-started)に従ってインストールとコマンド補完機能の設定を行います。Macの場合は以下のような手順となります。

```bash
# インストール
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`でアップグレードを行います。

```bash
$ 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つのブロックからなります。それぞれ、[Provider](https://learn.hashicorp.com/tutorials/terraform/aws-build?in=terraform/aws-get-started#providers)を[Terraform Registry](https://registry.terraform.io/)からのインストール、AWS Providerの指定、リソースの作成を行っていますが、これを一つのファイルに収めることも別のファイルに分割することもできます。また、分割した場合でも他のファイルを読み込むコード等は不要です。

```hcl: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.tf`、`main.tf`、`s3.tf`といったファイルに分ける構成にしています。

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

> 参考： [Build Infrastructure | Terraform - HashiCorp Learn](https://learn.hashicorp.com/tutorials/terraform/aws-build?in=terraform/aws-get-started)

#### Initialization

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

```bash
terraform -chdir=terraform init
```

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

#### `terraform/.gitignore`

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

#### State

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

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

#### Backend

[Backend](https://www.terraform.io/docs/language/settings/backends/index.html)はTerraformを実行しStateを保管する場所の設定です。ローカルの他、Amazon S3などを指定することができます。Stateの管理場所には可用性が求められ、また複数人で作業を行う場合には共有可能な場所を用いる必要があります。公式ドキュメントでは、その場所として[後述](#terraform-cloud)のTerraform Cloud又はTerraform Enterpriseを使用することを[推奨して](https://www.terraform.io/docs/language/settings/backends/index.html#recommended-backends)います。

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


```hcl:main.tf
terraform {
  backend "remote" {
    organization = "organization_name"

    workspaces {
      name = "workspace_name"
    }
  }
}
```

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

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

```hcl:main.tf
backend "remote" {}
```

```hcl:backend.hcl
organization = "organization_name"

workspaces {
  name = "workspace_name"
}
```

```bash
terraform init -backend-config=backend.hcl
```

> 参考： [Backend Type: remote - Terraform by HashiCorp](https://www.terraform.io/docs/language/settings/backends/remote.html)

#### Input Variables

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

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

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

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

```hcl:terraform.tfvars
aws_profile = "dev"
```

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

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

#### Terraform Cloud

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

#### Module

[ファイル構成の項目](#ファイル構成)で触れたように、リソースを構築するためには`resource`ブロックによって定義していました。しかし頻繁に同様の構成を行う場合には、[Module](https://www.terraform.io/docs/language/modules/index.html)を用いることで効率化や再利用性の向上を図ることができます。

Moduleとは、複数のリソース構成を統合してパッケージ化したものです。例えば、VPCを利用する際にはサブネットやルートテーブルなどが密接に関連しており、基本的に同時に作成することになりますが、Moduleを用いることでVPCと同一の文脈でこれらの設定を行うことができます。主要なModuleは[公式から提供されて](https://registry.terraform.io/browse/modules?provider=aws)おり、今回の構成では、[VPC](#vpc)、[セキュリティグループ](#セキュリティグループ)、[RDS](#rds)、[ACM](#acm) の作成に使用しています。

### VPC

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

[VPC](https://docs.aws.amazon.com/vpc/latest/userguide/what-is-amazon-vpc.html)はプライベートな仮想ネットワークを構築するサービスです。今回の構成ではデータベース (RDS) を使用しておりそこでVPCが必要となります。

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

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

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

```hcl:vpc.tf
name = "main-vpc"
cidr = var.vpc_cidr_block
```

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

```hcl: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`まで存在します。また今回サブネットはプライベートのみ使用するためパブリックサブネットは作成しません。表記として変数と文字列が混在する際は変数部を`${}`で括ります。

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

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

```hcl: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](https://registry.terraform.io/modules/terraform-aws-modules/vpc/aws/latest)

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

#### セキュリティグループ

[セキュリティグループ](https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html)はホワイトリスト方式でアクセス管理を行うサービスで、VPCに紐付けて使用します。作成には[Module](https://github.com/terraform-aws-modules/terraform-aws-security-group)を使用し、ファイルはVPCと同じ`vpc.tf`です。

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

VPCと関連付けるためには`vpc_id`を指定する必要があります。これはVPCが作成されてから判明する値ですが、前項のVPC設定の出力値を指定することでそれを使用することができます。なお、VPC Moduleで指定可能な出力値については[README](https://github.com/terraform-aws-modules/terraform-aws-vpc#outputs)に記載があります。

```hcl
vpc_id = module.vpc.vpc_id
```

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

```hcl
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](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/security_group#basic-usage)

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

#### エンドポイント

`vpc.tf`に含めるリソースとしてさらに[VPCエンドポイント](https://docs.aws.amazon.com/vpc/latest/privatelink/vpc-endpoints.html)を作成します。これはVPC内外でのプライベート通信を可能にするサービスです。

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

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

```hcl: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エンドポイント](https://docs.aws.amazon.com/vpc/latest/privatelink/vpce-gateway.html)と呼ばれるもので、現時点ではDynamoDBとS3でのみ選択可能なエンドポイントタイプです。今回明示的に指定していませんがデフォルト値がGateway型になっています。

> 参考： [aws_vpc_endpoint | Resources | hashicorp/aws | Terraform Registry](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/vpc_endpoint)

SESで使用するInterface型のエンドポイントについては[SESの項目](#vpcエンドポイント-interface型)で作成します。

### RDS

[RDS](https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/Welcome.html)はリレーショナルデータベースの構築や管理を行うマネージドサービスです。新たに`rds.tf`ファイルを作成し[RDS用のModule](https://github.com/terraform-aws-modules/terraform-aws-rds)を用いて構築していきます。

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

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

```hcl
locals {
  engine               = "mariadb"
  major_engine_version = "10.4"
}
```

```hcl
engine = local.engine
```
以上を踏まえて実際にRDSの設定を行っていきます。

#### RDSインスタンス

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

```hcl:rds.tf
identifier = local.identifier
```

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

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

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

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

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

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

```hcl: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 -](https://docs.aws.amazon.com/AmazonRDS/latest/APIReference/API_CreateDBInstance.html) に記載があります。

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

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

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

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

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

```hcl:terraform.tfvars
db_username = "admin"
```

> 参考： [Protect Sensitive Input Variables | Terraform - HashiCorp Learn](https://learn.hashicorp.com/tutorials/terraform/sensitive-variables?in=terraform/configuration-language)

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

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

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

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

```hcl: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ののドキュメント](https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/Appendix.MariaDB.Parameters.html)を参照してください。

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

```hcl: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](https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/Appendix.MariaDB.Options.html)

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

```hcl: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` (作成しない) としています。

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

```hcl: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`は以下のようになります。

<details><summary><code>rds.tf</code></summary><div>

```hcl: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"
    },
  ]
}
```

</details></div>

> 参考：
> [terraform-aws-modules/rds/aws | Terraform Registry](https://registry.terraform.io/modules/terraform-aws-modules/rds/aws/latest)
> [aws_db_instance | Resources | hashicorp/aws | Terraform Registry](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/db_instance)

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

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

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

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

なお、この`ingress_with_self`は内部には`aws_security_group`の`ingress`が`self`の状態で使用されています。

> 参考：
> [terraform-aws-security-group/main.tf - GitHub](https://github.com/terraform-aws-modules/terraform-aws-security-group/blob/master/main.tf)
> [aws_security_group | Resources | hashicorp/aws | Terraform Registry](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/security_group#ingress)

### Systems Manager

[Systems Manager](https://docs.aws.amazon.com/systems-manager/latest/userguide/what-is-systems-manager.html#service-naming-history)とは、運用データの監視やタスク自動化などを可能とする運用管理サービスです。[かつてはSSMの略称で呼ばれており](https://docs.aws.amazon.com/systems-manager/latest/userguide/what-is-systems-manager.html#service-naming-history)CLIコマンドなどにその名残があります。またTerraformにおいてもSSMの呼称で扱われています。Systems Manager には上記の他様々な機能が存在しますが、その中でも今回利用するのはパラメータストアに限ります。

#### パラメータストア

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

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

##### パラメータ階層

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

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

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

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

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

##### パラメータタイプ

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

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

```hcl: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`で [取得することができ](https://github.com/terraform-aws-modules/terraform-aws-vpc#output_private_subnets)ます。これは配列形式になっているため、[組み込み関数](https://www.terraform.io/docs/language/functions/index.html)の一つである[`join`](https://www.terraform.io/docs/language/functions/join.html)を使用し、文字列に変換しています。

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

> 参考： [aws_ssm_parameter | Resources | hashicorp/aws | Terraform Registry](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ssm_parameter)

### Route53

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

#### ネームサーバー移転

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

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

> 参考： [Amazon Route 53 pricing - Amazon Web Services](https://aws.amazon.com/route53/pricing)

#### Data Sources

これまで`resource`と`module`ブロックを使用してリソース作成のための設定を行ってきました。ただ今回は既にRoute53ホストゾーンの作成を手動で行ったため、リソースの作成は必要ありません。しかし、他のリソース作成時にはこのホストゾーンの情報が必要になることがあります。このようなTerraform管理下にないリソースの情報を取得するのに利用できるのが、[Data Sources](https://www.terraform.io/docs/language/data-sources/index.html)です。

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

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

> 参考： [aws_route53_zone | Data Sources | hashicorp/aws | Terraform Registry](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/route53_zone)

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

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

#### DNSレコード

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

```hcl: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型変数を利用しています。そこで先程の変数を以下のように変更します。

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

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

```hcl: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](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/route53_record)

### ACM

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

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

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

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

```hcl: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検証を同時に行うかどうかの指定で、これは内部に`resource`の`aws_acm_certificate_validation`が使用されています。

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

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

> 参考：
> [terraform-aws-modules/terraform-aws-acm - GitHub](https://github.com/terraform-aws-modules/terraform-aws-acm)
> [aws_acm_certificate | Resources | hashicorp/aws | Terraform Registry](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/acm_certificate)

### SES

ここでは、Amazon SES (Simple Email Servises) の設定を行っていきます。[SES](https://docs.aws.amazon.com/ses/latest/dg/Welcome.html)はメールの送受信サービスです。メールアドレスの他、取得したドメインを使用したメールの送受信が可能で、永久無料枠も提供されています。

#### サンドボックス

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

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

> 参考： [Moving out of the Amazon SES sandbox - Amazon Simple Email Service](https://docs.aws.amazon.com/ses/latest/dg/request-production-access.html)

#### ID検証

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

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

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

> 参考：
> [Verified identities in Amazon SES - Amazon Simple Email Service](https://docs.aws.amazon.com/ses/latest/dg/verify-addresses-and-domains.html)
> [aws_ses_domain_identity | Resources | hashicorp/aws | Terraform Registry](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ses_domain_identity)

#### DKIM

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

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

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

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

```hcl: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](https://docs.aws.amazon.com/ses/latest/dg/send-email-authentication-dkim.html)
> [aws_ses_domain_dkim | Resources | hashicorp/aws | Terraform Registry](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ses_domain_dkim)

#### SMTP Credencials

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

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

```hcl: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()`関数を利用することで達成可能ですがその場合[リソースが毎回置き換えられるという問題](https://github.com/hashicorp/terraform/issues/22461)があります。その代わりとして、ここではコード追加時点の時刻を末尾に付与することにしました。

> 参考：
> [Obtaining Amazon SES SMTP credentials - Amazon Simple Email Service](https://docs.aws.amazon.com/ses/latest/dg/smtp-credentials.html)
> [aws_iam_user_policy | Resources | hashicorp/aws | Terraform Registry](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_user_policy)

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

```hcl: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](https://docs.aws.amazon.com/ses/latest/dg/send-email-concepts-credentials.html)
> [aws_iam_access_key | Resources | hashicorp/aws | Terraform Registry](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_access_key#attributes-reference)

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

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

料金について、Gatewayエンドポイントは[無料で利用可能](https://docs.aws.amazon.com/vpc/latest/privatelink/vpce-gateway.html#gateway-endpoint-pricing)ですが、一方、Interfaceエンドポイントは[使用時間に応じたコストが発生](https://aws.amazon.com/privatelink/pricing/?nc1=h_ls)します。例えば、一箇所のAZにエンドポイントを設置した場合のコストは、**us-east-1**リージョンの場合**0.01USD/h**なので一日あたりでは0.24USDとなります (利用当初の価格設定) 。
今回の構成にかかるコストの大半を占めているのはこのInterfaceエンドポイントです。

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

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

最後に、[プライベートDNS](https://docs.aws.amazon.com/vpc/latest/privatelink/verify-domains.html)を有効にします。これにより、プライベートDNS名`email-smtp.us-east-1.amazonaws.com`が付与され、後にメールホストとしてこれを使用します。

```hcl: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インターフェイスで使用されるポートを追加します。

```hcl: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](https://docs.aws.amazon.com/ses/latest/dg/send-email-set-up-vpc-endpoints.html)

### S3

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

S3を利用してファイルを格納するためにはまず[バケットを作成する必要があります](https://docs.aws.amazon.com/AmazonS3/latest/userguide/creating-buckets-s3.html)。Terraformでこれを行うため、新たに`s3.tf`を作成し設定を加えていきます。

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

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

  versioning {
    enabled = true
  }
}
```

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

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

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

> 参考： [aws_s3_bucket | Resources | hashicorp/aws | Terraform Registry](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/s3_bucket)

## アプリ固有インフラ

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

### Lambda 概要

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

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

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

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

### Serverless Framework

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

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

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

#### `backend/.gitignore`

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

```gitignore:.gitignore
.serverless/
```

#### Bref

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

Brefのインストールを行うにあたり、ドキュメントを見ると[インストールの項目](https://bref.sh/docs/installation.html)が存在しますが、[Laravelでの構築用に別途インストール手順の記載](https://bref.sh/docs/frameworks/laravel.html)があるためそちらに従います。

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

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

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

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

<details><summary>出力された<code>serverless.yml</code></summary><div>

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

</details></div>

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

#### `serverless.yml`

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

```yml:serverless.yml
service: laravel
```

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

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

次の`package`部では主にLambdaパッケージに含めるファイルを指定します。今回出力されたファイルで使用されていた`exclude`が[非推奨](https://www.serverless.com/framework/docs/deprecations#new-way-to-define-packaging-patterns)だったため以下のように修正しました。

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

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

> 参考： [Serverless Framework Guide - AWS Lambda Guide - Packaging](https://www.serverless.com/framework/docs/providers/aws/guide/packaging/#patterns)

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

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

```yml: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](https://bref.sh/docs/runtimes/http.html)
> [Serverless Framework - AWS Lambda Guide - Functions](https://www.serverless.com/framework/docs/providers/aws/guide/functions)

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

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

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

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

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

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

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

> 参考： [Console commands - Bref](https://bref.sh/docs/runtimes/console.html)

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

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

> 参考： [PHP runtimes for AWS Lambda - Bref # Usage](https://bref.sh/docs/runtimes/#usage)

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

#### 環境変数

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

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

```yml:serverless.yml
useDotenv: true
```

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

```yml:serverless.yml
provider:
  stage: dev
```

> 参考： [Resolution of environment variables](https://www.serverless.com/framework/docs/environment-variables)

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

```bash
serverless deploy --stage dev
```

> 参考： [Serverless Variables # Referencing CLI Options](https://www.serverless.com/framework/docs/providers/aws/guide/variables#referencing-cli-options)

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

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

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

> 参考： [Serverless Variables # Referencing Environment Variables](https://www.serverless.com/framework/docs/providers/aws/guide/variables#referencing-environment-variables)

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

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

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

```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`は以下のような記法で設定しています。

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

これは、環境変数の`APP_NAME`と`${sls:stage}`の値 (参照: [Serverless Variables # Referencing Serverless Core Variables](https://www.serverless.com/framework/docs/providers/aws/guide/variables#referencing-serverless-core-variables)) を展開した上でパラメータストアから該当する値を決定し、その前の文字列と結合している例です。

> 参考： [Serverless Variables # Reference Variables using the SSM Parameter Store](https://www.serverless.com/framework/docs/providers/aws/guide/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](https://bref.sh/docs/environment/database.html)

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

```yml: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](https://www.serverless.com/framework/docs/providers/aws/guide/functions#vpc-configuration)

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

```yml: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](https://docs.aws.amazon.com/apigateway/latest/developerguide/welcome.html) はLambdaで構成した各種関数にアクセスするための窓口のような役割を担います。

#### CORS

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

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

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

```yml: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`でも設定を行っていましたが、そのときは以下のようにワイルドカード(`*`)での指定をしていました。

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

'allowed_origins' => ['*'],

'allowed_headers' => ['*'],

'supports_credentials' => true,
```

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

> 参考： [Serverless Framework - AWS Lambda Events - HTTP API # CORS Setup](https://www.serverless.com/framework/docs/providers/aws/events/http-api#cors-setup)

#### カスタムドメイン

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

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

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

> 参考： [Custom domain names - Bref](https://bref.sh/docs/environment/custom-domains.html)

##### serverless-domain-manager

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

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

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

```yml:serverless.yml
plugins:
  - serverless-domain-manager
```

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

```yml: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`も付随するようになります。

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

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

> 参考：
> [Serverless Framework: Plugins](https://www.serverless.com/plugins/serverless-domain-manager)
> [amplify-education/serverless-domain-manager - GitHub](https://github.com/amplify-education/serverless-domain-manager)

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

```yml:serverless.yml
provider:
  httpApi:
    disableDefaultEndpoint: true
```

> 参考： [Serverless Framework - AWS Lambda Events - HTTP API # Disable Default Endpoint](https://www.serverless.com/framework/docs/providers/aws/events/http-api#disable-default-endpoint)

### DynamoDB

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

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

> [Serverless Framework - AWS Lambda Guide - AWS Infrastructure Resources](https://www.serverless.com/framework/docs/providers/aws/guide/resources)

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

```yml: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のデフォルト設定](https://laravel.com/docs/8.x/cache#dynamodb)に従い、それぞれの名前を`cache`、`key`としています。

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

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

> 参考：
> [Get started - AWS CloudFormation](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/GettingStarted.Walkthrough.html)
> [AWS::DynamoDB::Table - AWS CloudFormation](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-dynamodb-table.html)

### Lambda Role

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

```yml: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](https://www.serverless.com/framework/docs/providers/aws/guide/iam#the-default-iam-role)

## GitHub Actions

ここからは、これまでに作成してきた構成ファイルを用いて、GitHub Actions による自動デプロイを行っていきます。GitHub Actions の概要については[過去記事](https://qiita.com/zuka-e/items/3faf100cbcdf7ec40ee6#github-actions)で触れています。

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

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

on:
  push:
    branches:
      - development
  pull_request:
```

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

ところで、通常の開発の流れとして、メインのブランチ (`master`や`main`) から新たなブランチを作成し、プルリクエストによってそのメインのブランチにマージするという手法を取ると思います。しかし、ローカル環境からメインブランチに直接プッシュすることも可能であり、これはこの開発の流れを考慮すると望ましい状態ではありません。そこで、GitHubの [Branch Protection](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/defining-the-mergeability-of-pull-requests/about-protected-branches) を使用することで、メインブランチへの直接プッシュに制限を設け、プルリクエスト経由のマージを必須とします。

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

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

### 共有インフラ構築

まずは、Terraformが担当している共用インフラを構築するためのJobを作成します。これは基本的に、[TerraformのドキュメントのGitHub Actions のページ](https://learn.hashicorp.com/tutorials/terraform/github-actions?in=terraform/automation)に従い、初めに以下の手順から進めていきます。

1. Terraform Cloud のアカウントを準備し、OrganizationとWorkspaceを作成
2. Environment Variables に、AWSアクセスキーを追加
3. [Tokensページ](https://app.terraform.io/app/settings/tokens)で API token を作成
4. 作成した API token をGitHubのSecretsに追加

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

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

workspaces {
  name =  # "Workspace名"
}
```

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

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

次の`steps`では[setup-terraformアクション](https://github.com/hashicorp/setup-terraform)を使用し、GitHubのSecretsに追加しておいた Terraform Cloud 認証用のトークンを与えます。

```yml:.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`の指定が必須となります。

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

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

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

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

```yml:.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アクション](https://github.com/actions/github-script)を用いることで、各ステップの実行結果と`terraform plan`の標準出力がプルリクエストのコメントに追加されるようになります。

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

```yml:.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`でエラーが発生した場合には、本体の挙動と同様に処理を中断します。

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

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

```yml:.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](https://learn.hashicorp.com/tutorials/terraform/github-actions?in=terraform/automation)
> [hashicorp/learn-terraform-github-actions](https://github.com/hashicorp/learn-terraform-github-actions) - GitHub

### アプリ固有インフラ構築

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

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

```yml:.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'
```

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

```yml:.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 found`、`Target class [view] does not exist`というエラーが発生したため、スキップは行っていません。

> 参考：
> [Automating deployments](https://bref.sh/docs/deploy.html#automating-deployments) - Deployment - Bref
> [actions/cache](https://github.com/actions/cache) - GitHub

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

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

> 参考： [main/ci/laravel.yml](https://github.com/actions/starter-workflows/blob/main/ci/laravel.yml) - actions/starter-workflows - GitHub

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

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

> 参考： [Serverless Dashboard - Running in your own CI/CD](https://www.serverless.com/framework/docs/guides/cicd/running-in-your-own-cicd#install-the-serverless-framework-open-source-cli)

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

```yml:.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](https://www.serverless.com/framework/docs/providers/aws/guide/credentials#using-aws-profiles) - Serverless Framework - AWS Lambda Guide - Credentials

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

```yml:.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`でコンパイルを行います。

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

次に`aws`コマンドを利用するため、[aws-actions/configure-aws-credentials アクション](https://github.com/aws-actions/configure-aws-credentials)を利用します。

```yml:.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バケット](#s3)に同期します。

```yml:.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](https://docs.aws.amazon.com/cli/latest/reference/index.html)
> [get-parameter — AWS CLI 1.22.5 Command Reference](https://docs.aws.amazon.com/cli/latest/reference/ssm/get-parameter.html)
> [sync — AWS CLI 1.22.5 Command Reference](https://docs.aws.amazon.com/cli/latest/reference/s3/sync.html)

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

> 参考： [Workflow syntax for GitHub Actions - GitHub Docs](https://docs.github.com/en/actions/learn-github-actions/workflow-syntax-for-github-actions)

## まとめ

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

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

## 各種リンク

- アプリURL: ~~[https://www.miwataru.com](https://www.miwataru.com)~~
- GitHub (Terraform): [https://github.com/zuka-e/laravel-react-task-spa/tree/development/terraform](https://github.com/zuka-e/laravel-react-task-spa/tree/development/terraform)
- GitHub (Serverless): [https://github.com/zuka-e/laravel-react-task-spa/blob/development/backend/serverless.yml](https://github.com/zuka-e/laravel-react-task-spa/blob/development/backend/serverless.yml)
- アプリ概要: [https://qiita.com/zuka-e/items/9a985f0dd5db21bc48d7](https://qiita.com/zuka-e/items/9a985f0dd5db21bc48d7)