以前、ポートフォリオとしてタスク管理アプリを作成しました。そのインフラ構成には主にAWSを利用していましたが、大部分がAWSマネジメントコンソールからの手作業によって作成していました。
この方法は、初めて触れる技術を使用してインフラを構築する場合であれば、画面の指示に従いながら進めていくことができるというメリットがあります。しかし、インフラが大きくなり複雑化すると次第に管理が難しくなってきます。利用されているリソースを把握し、その変更が他へ与える影響も考慮する必要があります。
これらの問題に対応するため、インフラをコードで管理する Infrastructure as Code (IaC) を導入しました。これによって、使用されているリソースや依存環境が明確になり、またインフラの構築や削除をコマンドで容易に行えます。
IaCを実現するためのツールはいくつかありますが、ここではTerraformとServerless Framework (Serverless) を用いることにしました。Terraformのみで全ての構成管理を行うことも可能だと思いますが、Serverlessにはサーバレス構築に特化していることもあり、少ないコードで必要な構成ができるというメリットがあります。また既にServerlessによる構成をある程度行っており、これを活用する利点も考慮して併用する方法を採用しました。
それぞれが担う役割として、Terraformはリソース間で共有するインフラの構築を行い、Serverlessはアプリ固有のインフラ構築を行います。例えば、データベース自体 (RDSインスタンス) はTerraformによって管理される一方で、各テーブルの作成や削除はServerlessが担います。このTerraformとServerlessの併用する方法に関してはServerless公式サイトのブログを参考にしました。
そして最後に、Github Actions によって自動的にデプロイを行えるようにしました。
- アプリURL:
https://www.miwataru.com - GitHub (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://qiita.com/zuka-e/items/9a985f0dd5db21bc48d7
目次
- インフラ構成図
- ディレクトリ構成
- IAM
- 共有インフラ
- アプリ固有インフラ
- GitHub Actions
- まとめ
- 各種リンク
インフラ構成図
今回作成したインフラは以下の図のようになります。詳細はアプリ概要の記事に記述しています。
基本的に手作業で作成した元々の構成と同じですが一つ異なるのが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
に設定を行います。このガイドではその方法としてserverless
やaws
コマンドが紹介されており、これによって簡単に設定することができます。手動でファイルを作成してキー情報を追加することも可能です。
[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つのブロックからなります。それぞれ、ProviderをTerraform Registryからのインストール、AWS Providerの指定、リソースの作成を行っていますが、これを一つのファイルに収めることも別のファイルに分割することもできます。また、分割した場合でも他のファイルを読み込むコード等は不要です。
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
といったファイルに分ける構成にしています。
terraform/
├── versions.tf
├── main.tf
├── s3.tf
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
ブロック内でbackend
にremote
を指定します。
terraform {
backend "remote" {
organization = "organization_name"
workspaces {
name = "workspace_name"
}
}
}
このorganization
名とworkspace
名にはTerraform Cloud上で任意の名前で作成したものを使用します。
なお、backend
ブロック内での変数の利用は現時点 (v1.0.8) ではできないようです。代替案として、上記のようにハードコーディングするか、又は以下のように空オブジェクトを記述しておき都度コマンドラインから設定ファイルを指定して適用させる方法があります。
backend "remote" {}
organization = "organization_name"
workspaces {
name = "workspace_name"
}
terraform init -backend-config=backend.hcl
Input Variables
Input Variables とは、リソース作成の際のパラメータを指定する役割を持つ変数です。要するに他のプログラミング言語同様の変数の働きをするものですが、格納方法としてコマンドラインや環境変数の他、専用ファイル (terraform.tfvars
) やTerraform Cloudを利用することができます。また、パスワードなどの機密情報を安全に扱う役割も持ちます。
変数の定義にはvariable
ブロックを使用しラベルとして変数名を指定します。以下の例では、variables.tf
ファイルを作成し、そこにaws_profile
という名で変数を定義した上、説明文、型、デフォルト値の指定を行っています。
variable "aws_profile" {
description = "AWS profile"
type = string
default = "default"
}
前述の通りこの変数の値は変更することができ、terraform.tfvars
による方法の場合は以下のように記述します。
aws_profile = "dev"
変数の値を利用するにはvar
を用います。例としてprovider
ブロックに適用すると以下のようになります。
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、セキュリティグループ、RDS、ACM の作成に使用しています。
VPC
Terraformの基本を確認した所でAWSのインフラ構築に移り、まずはVPCの導入を行います。
VPCはプライベートな仮想ネットワークを構築するサービスです。今回の構成ではデータベース (RDS) を使用しておりそこでVPCが必要となります。
VPCの構成を行うには通常のresource
ブロックでも可能ですが、Module を使用することで関連するリソースも含めてより簡潔に作成することができます。VPC用のModuleを使用するには、module
ブロックでvpc
(任意のラベル名) を指定した上でsource
には使用するmoduleの場所としてterraform-aws-modules/vpc/aws
を指定します。
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
# ...
}
以降に入力値を加えて設定を行っていきます。
name = "main-vpc"
cidr = var.vpc_cidr_block
name
にはVPC名、cidr
にはCIDRブロックを指定しますが、ここで変数を利用しているため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
まで存在します。また今回サブネットはプライベートのみ使用するためパブリックサブネットは作成しません。表記として変数と文字列が混在する際は変数部を${}
で括ります。
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
さらに変数定義も追加します。
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を使用することでこれらを明示的に指定する必要がなく作成することができます。
次に、アクセス管理を行うためセキュリティグループを作成します。
セキュリティグループ
セキュリティグループはホワイトリスト方式でアクセス管理を行うサービスで、VPCに紐付けて使用します。作成にはModuleを使用し、ファイルはVPCと同じ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
以上でセキュリティグループ自体の作成は完了です。RDSなど個別のリソースに必要となるインバウンドルールについてはそれぞれの項目で追加していきます。
エンドポイント
vpc.tf
に含めるリソースとしてさらにVPCエンドポイントを作成します。これはVPC内外でのプライベート通信を可能にするサービスです。
通常、VPC内から外部のリソースへアクセスするためにはインターネットを介した通信を行うかNATゲートウェイを設置するなどの対応が必要となりますが、エンドポイントを使用することでこれらを行うことなくプライベートな接続を確保できるようになります。今回の例では、VPC内のLambdaからVPC外のDynamoDBやSESと接続するために使用しています。
DynamoDBはServerlessによって構築しますが、エンドポイントに関してはTerraformによって作成します。
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を用いて構築していきます。
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識別子) を指定します。
identifier = local.identifier
ここで使用している変数はlocals
ブロックで定義します。
locals {
identifier = "${lower(var.project)}-${var.stage}"
}
DB識別子は一意であることが求められます。ここではプロジェクト(アプリ) 名とステージ (dev
などの環境名) をハイフン(-
)で繋いだものにしました。DB識別子では大文字は使用できず、プロジェクト名には大文字を使用することがあるため、lower
関数を用いることで小文字にしています。これらの変数はvariables.tf
内で定義しておきます。
variable "project" {
type = string
default = "tf-project"
}
variable "stage" {
type = string
default = "production"
}
次に、主に性能部分に関連する設定を行っていきます。
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 - に記載があります。
次に、認証情報などデータベース接続に係る設定を行います。
name = "${lower(var.project)}_${var.stage}"
username = var.db_username
create_random_password = true
port = "3306"
name
は作成するデータベース名です。RDSインスタンスのDB識別子と異なりハイフンは使用できません。代わりにプロジェクト名とステージをアンダースコア(_
)で繋いでDB名としています。username
にはマスターユーザー名を指定しますが、これには変数定義時にsensitive
指定をすることで機密情報として扱います。
variable "db_username" {
type = string
sensitive = true
}
variable
ブロックでデフォルト値は指定せずterraform.tfvars
によって値を格納します。
db_username = "admin"
参考: Protect Sensitive Input Variables | Terraform - HashiCorp Learn
パスワードも機密情報ですがこちらはランダムパスワード作成を有効にすることで変数による指定は必要なくなります。そしてこの値は出力 (module.db.db_master_password
) から参照できるため内容が不明でも問題ありません。
次に、VPC Moduleの出力値を使用してVPCとの関連付けを行います。
subnet_ids = module.vpc.private_subnets
vpc_security_group_ids = [module.vote_service_sg.security_group_id]
次に、パラメータグループの設定です。
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ののドキュメントを参照してください。
次に、オプショングループを指定します。
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
次に、メンテナンスやバックアップに関する設定です。
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
(作成しない) としています。
次に、拡張モニタリングを設定します。
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
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の中での設定は以下のようになります。
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
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のパスワードを格納するパラメータを作成するには以下のような定義を行います。
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
を利用した例を示します。
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時間以内で(ホストゾーンを)削除した場合には課金の対象外となります。
Data Sources
これまでresource
とmodule
ブロックを使用してリソース作成のための設定を行ってきました。ただ今回は既にRoute53ホストゾーンの作成を手動で行ったため、リソースの作成は必要ありません。しかし、他のリソース作成時にはこのホストゾーンの情報が必要になることがあります。このようなTerraform管理下にないリソースの情報を取得するのに利用できるのが、Data Sourcesです。
Data Sourcesではresource
やmodule
の代わりにdata
ブロックを使用し、対象がホストゾーンの場合はaws_route53_zone
を指定します。
data "aws_route53_zone" "root" {
name = var.root_domain.name
}
参考: aws_route53_zone | Data Sources | hashicorp/aws | Terraform Registry
さて、これまでは変数定義にstring型を用いていましたが、上の例ではvar.root_domain
にname
をドット記法で繋げており、これはobject型の変数です。object変数の定義では以下のように波括弧の中に属性名と型をセットで指定します。
variable "root_domain" {
type = object({
name = string
})
}
DNSレコード
ホストゾーンを作成しその情報も参照できるようになったので、次にルートドメインのDNSレコードを追加していきます。Vercelでフロントエンドのデプロイを行っている場合、以下のように、Aレコードにはルートドメイン名、CNAMEレコードには先頭にwww
を付けたルートドメイン名を追加することになると思います。
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型変数を利用しています。そこで先程の変数を以下のように変更します。
variable "root_domain" {
type = object({
name = string
records = object({
a = list(string)
cname = list(string)
})
})
}
実際の値は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
を作成し、以下のように記述します。
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
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検証を行うのに使用するresource
はaws_ses_domain_identity
です。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
によって必要なレコード情報を出力します。
resource "aws_ses_domain_dkim" "root" {
domain = aws_ses_domain_identity.root.domain
}
このresource
の出力を利用してDNSレコードを登録するには次のようにします。
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ユーザーを作成することです。そしてこのユーザーに対してアクセスキーを発行します。
# 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_USERNAME
とMAIL_PASSWORD
に該当します。Terraformから値を渡すために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_type
はInterface
とします。
また、セキュリティグループsecurity_group_ids
とサブネットsubnet_ids
を配列で指定します。エンドポイントはサブネット毎に作成されそれぞれ課金されるため、必要なければサブネットは一つとしておきます。ただし各サブネットは異なるAZに設置されていますが、そのAZによっては対応していない場合があり、今回の場合はus-east-1a
b
d
の3つのみ対応しています。
最後に、プライベートDNSを有効にします。これにより、プライベートDNS名email-smtp.us-east-1.amazonaws.com
が付与され、後にメールホストとしてこれを使用します。
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インターフェイスで使用されるポートを追加します。
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上のアカウントで一意である必要があり、他のユーザーが既に使用している場合など既に存在するバケット名は利用できません。必須属性はこれだけですが、必要に応じて他の設定も加えます。
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
ディレクトリにファイルが出力されるため以下を加えます。
.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
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ロール名などの一部命名にも使用されることになります。
service: laravel
次にprovider
部で、これはアプリケーション全体に関する設定を行います。Serverlessはマルチクラウドのツールのため、name
にAWSを使用することを示し、またregion
の指定も行います。次のstage
の値は、使用する環境変数(.env
)ファイルの判定やAWSコンフィグの判定などに用いられる他、Lambda関数名などにも使用されます。そして、runtime
に指定しているprovided.al2
はカスタムランタイムで、Amazon Linux 2 を表します。
provider:
name: aws
region: us-east-1
stage: dev
runtime: provided.al2
次のpackage
部では主にLambdaパッケージに含めるファイルを指定します。今回出力されたファイルで使用されていたexclude
が非推奨だったため以下のように修正しました。
package:
patterns:
- "!node_modules/**"
- "!public/storage"
- "!resources/assets/**"
- "!storage/**"
- "!tests/**"
先頭に!
を付与することでパッケージから除外するようにしています。
参考: Serverless Framework Guide - AWS Lambda Guide - Packaging
そして、functions
部がLambda関数の設定です。初めのweb
は関数名で、先述のservice
とprovider.stage
から最終的な名前が決定し、この例では"laravel-dev-wev"となります。
handler
に指定するのは実行するファイル名です。次のtimeout
には関数の最大実行時間を指定します。初期状態で28秒となっていますが、これはAPI Gatwayに29秒の時間制限がありこれを下回る値にする必要があるためです。layers
にはBrefが提供するランタイムを使用します。これによりPHPでのLambdaの利用が可能となります。events
には関数実行のトリガーを指定し、httpApi
は API Gatewayを表します。このワイルドカード(*
)の指定で全てのリクエストをアプリケーションに送信することになります。
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上で実行できるようになっています。
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
を付与して確認なしで実行するようにしています。
上記で述べたようなBrefの機能を利用するため、plugins
には以下のパッケージが必要となります。
plugins:
- ./vendor/bref/bref
ここでは初めに出力された状態でのserverless.yml
を内容を確認してきました。以降は実際に構築するインフラに合わせて設定を変更していきます。
環境変数
ローカル環境でLaravelを開発する際、環境変数を扱うためにはこれまで.env
ファイルを使用していました。本番環境でこれらの値を参照するにはLambdaの環境変数としてセットします。
serverless.yml
内で環境変数を設定するにあたり、まずuseDotenv
を追加します。これにより、serverless.yml
ファイル内で.env
ファイルの値が利用できるようになります。
useDotenv: true
このとき、環境変数を読み込む際のファイルには.env
だけでなく.env.dev
などを代わりに用いることも可能です。ここで使用されるファイルはprovider.stage
プロパティの値によって決まり、例えば以下ように設定した場合には.env.dev
が読み込まれることになります。 (ただし.env.dev
が存在しない場合には.env
を探しに行きます。)
provider:
stage: dev
ところで、環境によって使用するファイルを柔軟に変更したい場合もありますが、これにはコマンドラインのオプションが使用でき、先程のstage
プロパティをstage: ${opt:stage}
に修正します。これによりserverless
コマンド実行の際に--stage
オプションを使用することで値を都度指定することが可能となります。
serverless deploy --stage dev
ここでオプションを指定しなかった場合にはエラーが発生しますが、デフォルト値を設定することでオプション指定を任意とすることも可能です。
provider:
stage: ${opt:stage, "dev"}
読み込まれた環境変数は${env:環境変数名}
記法によって参照できるようになります。
参考: Serverless Variables # Referencing Environment Variables
ただ、これだけではまだLambdaの環境変数には追加されるようにはなっていません。加えて、provider.environment
プロパティに各環境変数を追加することが必要です。
provider:
environment:
APP_NAME: ${env:APP_NAME}
ここまでの流れを整理すると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
は以下のような記法で設定しています。
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エンドポイントを設置する対応も必要でした。
それでは、serverless.yml
での設定を行っていきます。VPCに所属させるためには、セキュリティグループのIDとサブネットのIDを配列で指定します。関数毎にVPCを指定する場合はfunctions
配下に、全ての関数に適用する場合はprovider
配下に設定を行います。また、以下のように両方指定することでデフォルトのVPCと関数毎のVPCの使い分けも可能です。
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の設定は以下のようになります。
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 の設定がありました。
functions:
web:
events:
- httpApi: "*"
しかし、フロントエンド側からバックエンドのレスポンスを受け取るためには、追加でCORSの設定が必要となります。serverless.yml
では、provider.httpApi.cors
に設定します。
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
でも設定を行っていましたが、そのときは以下のようにワイルドカード(*
)での指定をしていました。
'allowed_methods' => ['*'],
'allowed_origins' => ['*'],
'allowed_headers' => ['*'],
'supports_credentials' => true,
API Gateway の場合もワイルドカード指定自体は可能ですが、実際に動作確認を行うとエラーが発生するなどして機能しませんでした。そこでブラウザの開発者ツールの"Network"タブの情報を頼りに設定を修正した結果、allowedHeaders
やallowedMethods
を個別に指定することで解決することができました。
参考: 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が利用できないという問題もあります (サブドメインは可) 。
serverless-domain-manager
Serverlessでカスタムドメインを設定するにはプラグインを導入する必要があります。
npm install serverless-domain-manager --save-dev
npm
でインストールを行い、plugins
に追加します。
plugins:
- serverless-domain-manager
そして、serverless.yml
のトップレベルにcustom
を追加し、以下のように設定します。
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
によって処理されることになります。
- 事前に作成した証明書 (
certificateArn
) を使用してカスタムドメイン (domainName
) 作成 - ホストゾーン (
hostsZoneId
) にAレコードを登録 (createRoute53Record
) - API Gateway のAPIマッピングを設定
参考:
Serverless Framework: Plugins
amplify-education/serverless-domain-manager - GitHub
また、カスタムドメインの設定に伴い、デフォルトのエンドポイントを無効化します。
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のテーブルを作成するには、以下のようにType
にAWS::DynamoDB::Table
を指定します。cacheTable
部は識別のために使用するリソース名です。詳細の設定はProperties
以下に加えていきます。
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のデフォルト設定に従い、それぞれの名前をcache
、key
としています。
KeySchema
には定義した属性に従って実際のキー設定を行います。上記ではkey
をパーティションキーとして扱っています。
また、無料枠で利用するため、BillingMode
にはPROVISIONED
を指定します。次にプロビジョンされるキャパシティーユニットを読み書きそれぞれ指定します。各キャパシティーユニットの消費率についてはDynamoDBのドキュメントのページを参照してください。実際に使用されたキャパシティーユニットの値は、DynamoDBマネジメントコンソール内にあるキャパシティーメトリクス表からも確認が可能です。
参考:
Get started - AWS CloudFormation
AWS::DynamoDB::Table - AWS CloudFormation
Lambda Role
最後に、Lambdaが他のリソース (DynamoDBとSES) にアクセスするために必要なポリシーを付与していきます。ServerlessではLambdaのロールが自動的に作成され、AWSLambdaVPCAccessExecutionRole
がデフォルトのポリシーとしてアタッチされています。これに加えて、各リソースアクセス用のAWS管理ポリシーが用意されているのでそれを以下のように設定します。
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
に検知するイベントを指定します。
name: Continuous Deployment # 適当なworkflow名
on:
push:
branches:
- development
pull_request:
実行するタイミングとして、プルリクエストの時と、development
ブランチにプッシュされた時を指定しています。なお、このプッシュにはプルリクエストのマージも含まれています。
ところで、通常の開発の流れとして、メインのブランチ (master
やmain
) から新たなブランチを作成し、プルリクエストによってそのメインのブランチにマージするという手法を取ると思います。しかし、ローカル環境からメインブランチに直接プッシュすることも可能であり、これはこの開発の流れを考慮すると望ましい状態ではありません。そこで、GitHubの Branch Protection を使用することで、メインブランチへの直接プッシュに制限を設け、プルリクエスト経由のマージを必須とします。
プッシュを制限した状態であれば、上記のイベントはプルリクエスト時と、それがdevelopment
ブランチにマージされた時と読み直すことができます。デフォルトブランチを変数として指定する方法を探しましたが現時点では用意されていないようです。
以降では、共有インフラとアプリ固有インフラに分けてjobs
を設定していきます。
共有インフラ構築
まずは、Terraformが担当している共用インフラを構築するためのJobを作成します。これは基本的に、TerraformのドキュメントのGitHub Actions のページに従い、初めに以下の手順から進めていきます。
- Terraform Cloud のアカウントを準備し、OrganizationとWorkspaceを作成
- Environment Variables に、AWSアクセスキーを追加
- Tokensページで API token を作成
- 作成した API token をGitHubのSecretsに追加
Terraform Cloud のOrganizationとWorkspaceは先述の通りBackendで使用するため、それぞれの名前を用いて以下のようなファイルを作成しておきます。
organization = # "Organization名"
workspaces {
name = # "Workspace名"
}
次に、jobs
の指定を行っていきます。直下の<job_id>
に一意となる適当な名前を付け、表示用のname
も指定しておきます。作業ディレクトリはterraform
になるので、working-directory
の指定も必要です。
jobs:
build-shared-infrastructure:
name: Build Shared Infrastructure
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./terraform
次のsteps
ではsetup-terraformアクションを使用し、GitHubのSecretsに追加しておいた Terraform Cloud 認証用のトークンを与えます。
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
の指定が必須となります。
- name: Terraform Format
id: fmt
run: terraform fmt -check
terraform init
を実行する際のbackend-config
には、作成しておいたbackend.dev.hcl
を指定します。
- name: Terraform Init
id: init
run: terraform init -backend-config=backend.dev.hcl
次のterraform plan
は、イベントがプルリクエストの場合のみ実行し、プルリクエストがdevelopment
ブランチにマージされるタイミングでは実行されません。
- 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
にするとシンタックスハイライトが良い具合に働きます。
- 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
でエラーが発生した場合には、本体の挙動と同様に処理を中断します。
- name: Terraform Plan Status
if: steps.plan.outcome == 'failure'
run: exit 1
最後に、プルリクエストがdevelopment
ブランチにマージされるタイミングで構成を適用します。
- 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は実行しないように制限を設けます。
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'
デプロイを実行する前に依存関係をインストールしておきます。
- 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 - Deployment - Bref
actions/cache - GitHub
次にパーミッションの設定を行います。
- name: Directory Permissions
run: chmod -R 777 storage bootstrap/cache
参考: main/ci/laravel.yml - actions/starter-workflows - GitHub
GitHub Actions 環境でServerlessを利用するためには、npm
でserverless
をグローバルインストールして、serverless
コマンドを実行できるようにする方法が明快で簡単です。
- name: Install Serverless globally
run: npm install -g serverless
次に、serverless
コマンドを使用して、GitHub Actions 環境でのAWS認証情報を設定します。
- 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
そして、stage
とaws-profie
を指定してデプロイを実行します。以下の例ではstage
がdev
のため、.env.dev
があればその内容を読み込みます。しかし、GitHub上にアップロードすることが好ましくない情報も一部あり、その場合はGitHubのSecretsに追加してそれをenv
として指定します。
- 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
でコンパイルを行います。
- name: Compile Assets
run: npm run prod
次にaws
コマンドを利用するため、aws-actions/configure-aws-credentials アクションを利用します。
- 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バケットに同期します。
- 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 による自動デプロイの設定が完了しました。
まとめ
さて、これまでInfrastructure as Code (IaC) 導入の手順を見てきました。IaCを実現するためのツールとして、TerraformとServerless Frameworkの構成をそれぞれ作成し、これらを連携して利用する方法も学びました。そして最後に、構成ファイルに基づいて自動的にデプロイするため、Github Actions の設定を行いました。
インフラをコードで管理することで現在の構成が明確になり、履歴を遡ることで変更点を確認することも容易になったと思います。また、アプリケーションに変更があった場合に都度デプロイ作業を行う手間を省くこともできました。一方で、IAMポリシーの権限管理や各リソースのパラメータの指定などに一段と深い理解が求められることとなり、IaCの導入時の他、構成の変更時にも躓くこともありました。特に今回使用したツールのバージョンの進化は早く大きな変更が施されることもあるため、情報収集の際には注意が必要です。