LoginSignup
9
6

More than 1 year has passed since last update.

はじめてのTerraform【リソース生成編】

Last updated at Posted at 2022-01-03

はじめに

当記事では、AWSのECS/Fargateインフラを
Terraformでコードから再現する作業をまとめています。

なお、Terraformのインストールなど事前準備は
別記事でまとめていますので、まずはそちらをご確認ください。

また、Terraformではなく、AWSマネジメントコンソールからGUIで
ECS/Fargateインフラを構築する手順は別記事でまとめていますので
ご興味がございましたら、ご覧ください。

前提として、ECS/FargateにホストしているアプリはRailsとなりますので
その点をご理解ください。

使用技術

  • Terraform: 1.1.2
  • ECS/Fargate: blue/greenデプロイメント
  • Rails: 6
  • MySQL: 8

インフラ構成図

下記通り、ECS/Fargateに関連するリソース一式をTerraformでコードから生成します。

スクリーンショット 2022-01-02 7.57.55.png

作業内容

  1. 基礎部
  2. ネットワーク
  3. セキュリティグループ
  4. EC2/ RDS
  5. ALB/ Route53/ ACM
  6. ECS/ SystemsManager
  7. CloudFront/ S3 

1. 基礎部

まず、Terraform/プロバイダーのバージョン指定や変数などの
基盤部分をmain.tfでまとめます。

terraform/main.tf
# -------------------------------------------
# Terraform configuration
# -------------------------------------------
terraform {
  required_version = ">=1.1"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~>3.0"
    }
  }
}

# -------------------------------------------
# Provider
# -------------------------------------------
provider "aws" {
  profile = "terraform"
  region  = "ap-northeast-1"
}

provider "aws" {
  alias   = "virginia"
  profile = "terraform"
  region  = "us-east-1"
}

# -------------------------------------------
# Variables
# -------------------------------------------
variable "tool" {
  type = string
}

variable "project" {
  type = string
}

variable "environment" {
  type = string
}

続いて、terraform.tfvarsに、変数に代入する値を
各自でアプリに合わせて入力してください。

私の場合は、AWSマネジメントコンソールからGUIで作成したECS/Fargateと
新しく作成するTerraformベースのECS/Fargateを区別するために

toolにterraformという値を設定しています。

terraform/terraform.tfvars

tool        = "terraform"
project     = "アプリ名"
environment = "production"

また、modulesには、IAMロールだけ書いています。
(後の実装で役立ってきますが、正直2つのIAMロールしか作成しませんので
活用されている訳ではないです。)

module無しで記述しても労力的には変化ないと思いますが
個人的な可読性とmoduleを使ってみたいという気持ちで
採用してますので、ご了承ください。

terraform/modules/iam_role/main.tf
variable "name" {}
variable "policy" {}
variable "identifier" {}

resource "aws_iam_role" "default" {
  name               = var.name
  assume_role_policy = data.aws_iam_policy_document.assume_role.json
}

data "aws_iam_policy_document" "assume_role" {
  statement {
    actions = ["sts:AssumeRole"]
    principals {
      type        = "Service"
      identifiers = [var.identifier]
    }
  }
}

resource "aws_iam_policy" "default" {
  name   = var.name
  policy = var.policy
}

resource "aws_iam_role_policy_attachment" "default" {
  role       = aws_iam_role.default.name
  policy_arn = aws_iam_policy.default.arn
}

output "iam_role_arn" {
  value = aws_iam_role.default.arn
}

output "iam_role_name" {
  value = aws_iam_role.default.name
}

そして、terraformディレクトリに.gitignoreを新たに配置しています。
複数.gitignoreが存在する場合は、最下層の.gitignoreが優先されます。

コード自体は、gitignore ioでterraformを検索して
テンプレートを貼り付けているだけです。

terraform/.gitignore

# Created by https://www.toptal.com/developers/gitignore/api/terraform
# Edit at https://www.toptal.com/developers/gitignore?templates=terraform

### Terraform ###
# Local .terraform directories
**/.terraform/*

# .tfstate files
*.tfstate
*.tfstate.*

# Crash log files
crash.log

# Exclude all .tfvars files, which are likely to contain sentitive data, such as
# password, private keys, and other secrets. These should not be part of version
# control as they are data points which are potentially sensitive and subject
# to change depending on the environment.
#
*.tfvars

# Ignore override files as they are usually used to override resources locally and so
# are not checked in
override.tf
override.tf.json
*_override.tf
*_override.tf.json

# Include override files you do wish to add to version control using negated pattern
# !example_override.tf

# Include tfplan files to ignore the plan output of command: terraform plan -out=tfplan
# example: *tfplan*

# Ignore CLI configuration files
.terraformrc
terraform.rc

# End of https://www.toptal.com/developers/gitignore/api/terraform

以上を踏まえて、最終的なディレクトリ構造は下記の通りです。
(これから各リソースのコードを書いていくと同ディレクトリ構造になります。)

ターミナル

 % tree terraform

terraform
├── acm.tf
├── cloudfront.tf
├── ec2.tf
├── ecs.tf
├── elb.tf
├── main.tf
├── modules
│   ├── iam_role
│       └── main.tf
├── network.tf
├── rds.tf
├── route53.tf
├── s3.tf
├── security_group.tf
├── ssm.tf
├── terraform.tfstate
├── terraform.tfstate.backup
└── terraform.tfvars


2. ネットワーク

では、各リソースの土台となるネットワーク部分から作成していきましょう。

まず、VPC/SubnetなどのリソースをCidrブロックで構想します。

VPC:ネットワーク全体

VPC名称 CIDR アドレス範囲
app-production-vpc 10.0.0.0/16 10.0.0.0 - 10.0.255.255

サブネット:リソースごとの領域

サブネット名称 CIDR アドレス範囲 region 用途 ルートテーブル
ecs-1a-subnet 10.0.1.0/24 10.0.1.0 - 10.0.1.255 ap-northeast-1a ECS(ALB/fargate) public-rt
ecs-1c-subnet 10.0.2.0/24 10.0.2.0 - 10.0.2.255 ap-northeast-1c ECS(ALB/fargate) public-rt
db-1a-subnet 10.0.11.0/24 10.0.11.0 - 10.0.11.255 ap-northeast-1a DB private-rt
db-1c-subnet 10.0.12.0/24 10.0.12.0 - 10.0.12.255 ap-northeast-1c DB private-rt
management-ec2-1a-subnet 10.0.21.0/24 10.0.21.0 - 10.0.21.255 ap-northeast-1a 管理用EC2 public-rt
management-ec2-1c-subnet 10.0.22.0/24 10.0.22.0 - 10.0.22.255 ap-northeast-1c 管理用EC2 public-rt

インターネットゲートウェイ:インターネットに繋がる唯一の出入り口

インターネットゲートウェイ名称
igw

ルートテーブル:サブネットの通信経路を決める

ルートテーブル名称 送信先 ターゲット
public-rt 10.0.0.0/24, 0.0.0.0/0 local, igw
private-rt 10.0.0.0/24 local

それでは、構想したものをterraformコードに落とし込んでいきます。

terraformコードは、HCL(HashiCorpLanguage)という文法に基づいています。

HCLについて、全ては解説しませんので、ご了承ください。

resourceブロックでは、resourceに続く、
1つ目の””で囲われた文字列は生成するAWSリソースを
2つ目の””で囲われた文字列はterraform内の識別子を
記述しています。

※terraform内の識別子は、自分の好きな名称を決められます。

なお、resourceブロックで定義したリソースは他のresourceブロックから参照できます。
例えば、ECS用サブネットに着目すると、
vpc_id =の後にaws_vpc.vpc.idとVPCのリソースを参照しています。

さらにidの箇所は属性といい、各リソースによって使用できるものが異なります。
詳細を知りたい場合は、Terraformの公式ページから確認します。

一例ではありますが、VPCを生成するaws_vpcというリソースであれば
下記のように使える属性を調べます。

スクリーンショット 2022-01-02 14.59.58.png

では、AWSマネジメントコンソールからGUIで
ネットワークの各リソースを関連づけしていたように
terraformコードで、各リソースを紐付けるように記述していきます。

terraform/network.tf
# -------------------------------------------
# VPC
# -------------------------------------------

resource "aws_vpc" "vpc" {
  cidr_block           = "10.0.0.0/16"
  enable_dns_support   = true
  enable_dns_hostnames = true

  tags = {
    Name = "${var.tool}-${var.project}-${var.environment}-vpc"
  }
}

# -------------------------------------------
# Subnet (マルチAZ)
# -------------------------------------------

### ECS用サブネット
resource "aws_subnet" "ecs_public_subnet_1a" {
  vpc_id                  = aws_vpc.vpc.id
  availability_zone       = "ap-northeast-1a"
  cidr_block              = "10.0.1.0/24"
  map_public_ip_on_launch = true

  tags = {
    Name = "${var.tool}-${var.project}-${var.environment}-ecs-public-subnet-1a"
  }
}

resource "aws_subnet" "ecs_public_subnet_1c" {
  vpc_id                  = aws_vpc.vpc.id
  availability_zone       = "ap-northeast-1c"
  cidr_block              = "10.0.2.0/24"
  map_public_ip_on_launch = true

  tags = {
    Name = "${var.tool}-${var.project}-${var.environment}-ecs-public-subnet-1c"
  }
}

### DB用サブネット
resource "aws_subnet" "db_private_subnet_1a" {
  vpc_id                  = aws_vpc.vpc.id
  availability_zone       = "ap-northeast-1a"
  cidr_block              = "10.0.11.0/24"
  map_public_ip_on_launch = false

  tags = {
    Name = "${var.tool}-${var.project}-${var.environment}-db-private-subnet-1a"
  }
}

resource "aws_subnet" "db_private_subnet_1c" {
  vpc_id                  = aws_vpc.vpc.id
  availability_zone       = "ap-northeast-1c"
  cidr_block              = "10.0.12.0/24"
  map_public_ip_on_launch = false

  tags = {
    Name = "${var.tool}-${var.project}-${var.environment}-db-private-subnet-1c"
  }
}

### マネジメントEC2用サブネット
resource "aws_subnet" "management_ec2_public_subnet_1a" {
  vpc_id                  = aws_vpc.vpc.id
  availability_zone       = "ap-northeast-1a"
  cidr_block              = "10.0.21.0/24"
  map_public_ip_on_launch = true

  tags = {
    Name = "${var.tool}-${var.project}-${var.environment}-management-ec2-public-subnet-1a"
  }
}

resource "aws_subnet" "management_ec2_public_subnet_1c" {
  vpc_id                  = aws_vpc.vpc.id
  availability_zone       = "ap-northeast-1c"
  cidr_block              = "10.0.22.0/24"
  map_public_ip_on_launch = true

  tags = {
    Name = "${var.tool}-${var.project}-${var.environment}-management-ec2-public-subnet-1c"
  }
}

# -------------------------------------------
# InternetGateway
# -------------------------------------------

resource "aws_internet_gateway" "igw" {
  vpc_id = aws_vpc.vpc.id

  tags = {
    Name = "${var.tool}-${var.project}-${var.environment}-igw"
  }
}

# -------------------------------------------
# RootTable
# -------------------------------------------

### Public
resource "aws_route_table" "public_rt" {
  vpc_id = aws_vpc.vpc.id

  tags = {
    Name = "${var.tool}-${var.project}-${var.environment}-public-rt"
  }
}

# Publicには、インターネットゲートウェイに繋がるルートを追加する
resource "aws_route" "public_rt_igw_r" {
  route_table_id         = aws_route_table.public_rt.id
  destination_cidr_block = "0.0.0.0/0"
  gateway_id             = aws_internet_gateway.igw.id
}

resource "aws_route_table_association" "ecs_public_rt_1a" {
  route_table_id = aws_route_table.public_rt.id
  subnet_id      = aws_subnet.ecs_public_subnet_1a.id
}
resource "aws_route_table_association" "ecs_public_rt_1c" {
  route_table_id = aws_route_table.public_rt.id
  subnet_id      = aws_subnet.ecs_public_subnet_1c.id
}
resource "aws_route_table_association" "management_ec2_public_rt_1a" {
  route_table_id = aws_route_table.public_rt.id
  subnet_id      = aws_subnet.management_ec2_public_subnet_1a.id
}
resource "aws_route_table_association" "management_ec2_public_rt_1c" {
  route_table_id = aws_route_table.public_rt.id
  subnet_id      = aws_subnet.management_ec2_public_subnet_1c.id
}

### Private
resource "aws_route_table" "private_rt" {
  vpc_id = aws_vpc.vpc.id

  tags = {
    Name = "${var.tool}-${var.project}-${var.environment}-private-rt"
  }
}

resource "aws_route_table_association" "db_private_rt_1a" {
  route_table_id = aws_route_table.private_rt.id
  subnet_id      = aws_subnet.db_private_subnet_1a.id
}
resource "aws_route_table_association" "db_private_rt_1c" {
  route_table_id = aws_route_table.private_rt.id
  subnet_id      = aws_subnet.db_private_subnet_1c.id
}

ここまで、記述できたらAWSリソース生成のために
ターミナルから下記terraformコマンドを実行します。
(次章以降、当コマンドの実行は重複するため原則省略します。)

ターミナル

# Terraformの初期化
% terraform init

# コードを整列するようフォーマットする
% terraform fmt

# 現コードで生成されるリソースを確認
% terraform plan

# リソース生成の実行
% terraform apply -auto-approve

問題なく処理が完了したら、マネジメントコンソール画面から確認してみましょう。

スクリーンショット 2022-01-02 15.31.52.png

3. セキュリティグループ

続いて、AWSサービスのファイアーウォールにあたる
セキュリティグループを設定していきます。

セキュリティグループでは、リソースへの接続を制限する
インバウンドルールに注意して設定していきます。
(アウトバウンドルールは制限をかけていません。)

セキュリティグループ名称 タイプ ソース 用途
ecs-sg HTTP, HTTPS, カスタムTCP 0.0.0.0, 0.0.0.0, management-ec2-sg ALB/fargate
db-sg MySQL ecs-sg, management-ec2-sg DB
management-ec2-sg SSH 0.0.0.0 管理用EC2

ECSは、ユーザーからのアクセスが想定されるために、
HTTP/HTTPSのプロトコルで全IPアドレスを許可する設定にします。

DBは、ECSと管理用EC2のみアクセスできるように
2つのセキュリティグループに限定して許可する設定とします。

管理用EC2には、SSHプロトコルのみ接続が可能とします。

terraform/security_group.tf
# -------------------------------------------
# SecurtyGroup
# -------------------------------------------

### 1.ECS用 ###
resource "aws_security_group" "ecs_sg" {
  name   = "ecs-sg"
  vpc_id = aws_vpc.vpc.id

  tags = {
    Name = "${var.tool}-${var.project}-${var.environment}-ecs-sg"
  }
}

resource "aws_security_group_rule" "ecs_ingress_http" {
  type              = "ingress"
  from_port         = 80
  to_port           = 80
  protocol          = "tcp"
  cidr_blocks       = ["0.0.0.0/0"]
  security_group_id = aws_security_group.ecs_sg.id
}

resource "aws_security_group_rule" "ecs_ingress_https" {
  type              = "ingress"
  from_port         = 443
  to_port           = 443
  protocol          = "tcp"
  cidr_blocks       = ["0.0.0.0/0"]
  security_group_id = aws_security_group.ecs_sg.id
}

resource "aws_security_group_rule" "ecs_ingress_custom_tcp" {
  type                     = "ingress"
  from_port                = 10080
  to_port                  = 10080
  protocol                 = "tcp"
  source_security_group_id = aws_security_group.management_ec2_sg.id
  security_group_id        = aws_security_group.ecs_sg.id
}

resource "aws_security_group_rule" "ecs_egress" {
  type              = "egress"
  from_port         = 0
  to_port           = 0
  protocol          = "-1"
  cidr_blocks       = ["0.0.0.0/0"]
  security_group_id = aws_security_group.ecs_sg.id
}

### 2. DB用 ###
resource "aws_security_group" "db_sg" {
  name   = "db-sg"
  vpc_id = aws_vpc.vpc.id

  tags = {
    Name = "${var.tool}-${var.project}-${var.environment}-db-sg"
  }
}

resource "aws_security_group_rule" "db_ingress_mysql_from_ecs" {
  type                     = "ingress"
  from_port                = 3306
  to_port                  = 3306
  protocol                 = "tcp"
  source_security_group_id = aws_security_group.ecs_sg.id
  security_group_id        = aws_security_group.db_sg.id
}

resource "aws_security_group_rule" "db_ingress_mysql_from_management_ec2" {
  type                     = "ingress"
  from_port                = 3306
  to_port                  = 3306
  protocol                 = "tcp"
  source_security_group_id = aws_security_group.management_ec2_sg.id
  security_group_id        = aws_security_group.db_sg.id
}

resource "aws_security_group_rule" "db_egress" {
  type              = "egress"
  from_port         = 0
  to_port           = 0
  protocol          = "-1"
  cidr_blocks       = ["0.0.0.0/0"]
  security_group_id = aws_security_group.db_sg.id
}

### 3.マネジメントEC2用 ###
resource "aws_security_group" "management_ec2_sg" {
  name   = "management-ec2-sg"
  vpc_id = aws_vpc.vpc.id

  tags = {
    Name = "${var.tool}-${var.project}-${var.environment}-management-ec2-sg"
  }
}

resource "aws_security_group_rule" "management_ec2_ingress_ssh" {
  type              = "ingress"
  from_port         = 22
  to_port           = 22
  protocol          = "tcp"
  cidr_blocks       = ["0.0.0.0/0"]
  security_group_id = aws_security_group.management_ec2_sg.id
}

resource "aws_security_group_rule" "management_ec2_egress" {
  type              = "egress"
  from_port         = 0
  to_port           = 0
  protocol          = "-1"
  cidr_blocks       = ["0.0.0.0/0"]
  security_group_id = aws_security_group.management_ec2_sg.id
}

では、terraformコマンドを実行して、リソースが追加で
生成できたかをマネジメントコンソールで確認します。

4. EC2/ RDS

次は、EC2とRDSをネットワークに配置していきましょう。

⑴ EC2

では、EC2から着手していきます。

EC2本体のコードを書く前に

  • AMI
  • key pair

を先に準備していきます。

はじめにAMIから準備します。

ただ、AMIはマネジメントコンソールからAMIを特定するIDをコピーして
直接EC2のamiに貼り付けても問題はありません。

スクリーンショット 2022-01-02 16.10.34.png

当記事では、dataブロックを用いて
AWS外部データにアクセスして、指定条件からamiを特定し
それをEC2のamiに紐づける方法で実装しています。

最初にamiの詳細情報を調べるためにaws cliを使います。
ターミナルから下記コマンドを実行して詳細情報を確認しましょう。

ターミナル

# acmの詳細情報を表示する
% aws ec2 describe-images --image-ids ami-0218d08a1f9dac831


{
    "Images": [
        {
           ----省略----

            ### nameフィルターで使用
            "Description": "Amazon Linux 2 Kernel 5.10 AMI 2.0.20211201.0 x86_64 HVM gp2",
            "EnaSupport": true,
            "Hypervisor": "xen",
            "ImageOwnerAlias": "amazon",
            "Name": "amzn2-ami-kernel-5.10-hvm-2.0.20211201.0-x86_64-gp2",
            "RootDeviceName": "/dev/xvda",
            ### root-device-typeフィルターで使用
            "RootDeviceType": "ebs",
            "SriovNetSupport": "simple",
            ### virtualization-typeフィルターで使用
            "VirtualizationType": "hvm"
        }
    ]
}

では、さきほど調べた3つの情報を各filterに設定していきます。

なお、補足ですが
most_recentは最新版を選択する設定で、
更に3つあるfilterのうち、nameは
*(ワイルドカード)を用いて、どの日付でもヒットするよう条件を緩めています。

これによって、複数ヒットするamiから最新版を選んでくれる
と設定になります。

terraform/ec2.tf

### AMI ###
data "aws_ami" "management_ec2_ami" {
  most_recent = true
  owners      = ["self", "amazon"]

  filter {
    name   = "name"
    values = ["amzn2-ami-kernel-5.10-hvm-2.0.*-x86_64-gp2"]
  }
  filter {
    name   = "root-device-type"
    values = ["ebs"]
  }
  filter {
    name   = "virtualization-type"
    values = ["hvm"]
  }
}

続いて、key pairです。

マネジメントコンソールからGUI操作で構築していた時
最初に、AWS側にキーペア(公開鍵+秘密鍵)を生成し、
そのキーペアのうち、秘密鍵(~.pem)をローカルに渡して
ローカルからAWSリソースへSSHで接続可能としていました。

Terraformから生成する場合
ローカルでキーペア(公開鍵+秘密鍵)を作成して
キーペアのうち、公開鍵(~.pub)をAWSに登録します。

いずれも最終的には

  • AWS: 公開鍵
  • ローカル: 秘密鍵

という状態になります。

では、最初にターミナルからキーペアを作成します。

ターミナル

# 既存のSSHキーが存在するか確認
% ls -al ~/.ssh

# 未使用の無駄なSSHキーがあれば削除しておく
% rm -rf ~/.ssh/未使用のキー

# RSA4096ビット形式のSSHキーを作成する
% ssh-keygen -t rsa -b 4096

 ↓ 質問に答えていく
# SSHキーの保存先パスとファイル名を決める
#  -> そのままEnterと押すと()内のデフォルト値で生成される 
#    ※ 今回は「keypair_aws」としている
Enter file in which to save the key (/Users/ユーザー名/.ssh/id_rsa): /Users/ユーザー名/.ssh/keypair_aws

# SSHキーのパスフレーズ設定
Enter passphrase (empty for no passphrase): 任意のパスワードを入力

# パスフレーズの再確認
Enter same passphrase again: 再度パスワードを入力

 ↓ 入力が完了すると、秘密鍵と公開鍵が生成される

Your identification has been saved in /Users/ユーザー名/.ssh/keypair_aws.
Your public key has been saved in /Users/ユーザー名/.ssh/keypair_aws.pub.

# 生成されたSSHキーの確認
% ls -al ~/.ssh

lsコマンドで表示されるファイルのうち
.pubの拡張子がついているファイルは公開鍵を指します。
何もついていない鍵は秘密鍵ですので、拡張子「.pem」を付けておきましょう。

それでは、生成したキーペアの公開鍵(~.pub)を
terraformコードで、AWSに登録していきます。

terraform/ec2.tf
### key pair ###
resource "aws_key_pair" "keypair" {
  key_name   = "${var.tool}-${var.project}-${var.environment}-management-ec2-keypair"
  public_key = file("~/.ssh/keypair_aws.pub")

  tags = {
    Name = "${var.tool}-${var.project}-${var.environment}-management-ec2-keypair"
  }
}

では、EC2本体を実装していきます。

先ほど設定したAMIとkeypairをEC2に紐づける他に、aws_eipで
ElasticIPをEC2に関連づけてIPアドレスを固定化させています。

最後のoutputは、自動生成されたEC2のパブリックIPアドレスを出力しています。
SSH接続時に必要なIPアドレスをコードからも確認できる仕組みとしています。

terraform/ec2.tf

# -------------------------------------------
# Management EC2
# -------------------------------------------
### AMI ###
data "aws_ami" "management_ec2_ami" {
  most_recent = true
  owners      = ["self", "amazon"]

  filter {
    name   = "name"
    values = ["amzn2-ami-kernel-5.10-hvm-2.0.*-x86_64-gp2"]
  }
  filter {
    name   = "root-device-type"
    values = ["ebs"]
  }
  filter {
    name   = "virtualization-type"
    values = ["hvm"]
  }
}

### key pair ###
resource "aws_key_pair" "keypair" {
  key_name   = "${var.tool}-${var.project}-${var.environment}-management-ec2-keypair"
  public_key = file("~/.ssh/keypair_aws.pub")

  tags = {
    Name = "${var.tool}-${var.project}-${var.environment}-management-ec2-keypair"
  }
}

### EC2 instance ###
resource "aws_instance" "management_ec2" {
  ami                         = data.aws_ami.management_ec2_ami.id
  instance_type               = "t2.micro"
  subnet_id                   = aws_subnet.management_ec2_public_subnet_1a.id
  associate_public_ip_address = true
  vpc_security_group_ids      = [aws_security_group.management_ec2_sg.id]
  key_name                    = aws_key_pair.keypair.key_name

  tags = {
    Name = "${var.tool}-${var.project}-${var.environment}-management-ec2"
  }
}

### Elastic IP ###
resource "aws_eip" "management_ec2_eip" {
  vpc      = true
  instance = aws_instance.management_ec2.id

  tags = {
    Name = "${var.tool}-${var.project}-${var.environment}-management-ec2-eip"
  }
}

output "ElasticIP" {
  value = aws_eip.management_ec2_eip.public_ip
}

⑵ RDS

続いて、RDSです。

一部のみ解説いたします。

パラメータグループは、MySQLのデフォルトでセットされていない
日本語に対応させるため、文字コードにutf8mb4を指定しています。

サブネットグループは、RDS生成時には必須である為
作成済みのサブネットをまとめる設定をしています。

random_stringは、ランダムな文字列を生成できるTerraformのツールです。
ここでは、記号を使用しない16文字で生成するルールとしています。
パスワードはgitignoreで無視させているterraform.tfstateに出力されます。

identifierは、AWSで生成されるDBの名称
nameは、MySQLで生成されるDBの名称です。
(RailsがMySQLに接続する時はnameで定めた文字列をdatabase.ymlに定義しておく必要がある)

deletion_protectionは削除保護になりますので
むやみにDBを削除させない為にはtrueとしておいた方が安全でしょう。
私の場合は、まだポートフォリオが完成していないので
削除しやすいようにfalseとしています。

terraform/rds.tf
# -------------------------------------------
# ParameterGroup
# -------------------------------------------

resource "aws_db_parameter_group" "mysql_parametergroup" {
  name   = "${var.tool}-${var.project}-${var.environment}-mysql-parametergroup"
  family = "mysql8.0"

  parameter {
    name  = "character_set_database"
    value = "utf8mb4"
  }

  parameter {
    name  = "character_set_server"
    value = "utf8mb4"
  }
}

# -------------------------------------------
# SubnetGroup
# -------------------------------------------

resource "aws_db_subnet_group" "mysql_subnet_group" {
  name = "${var.tool}-${var.project}-${var.environment}-mysql-subnetgroup"
  subnet_ids = [
    aws_subnet.db_private_subnet_1a.id,
    aws_subnet.db_private_subnet_1c.id
  ]

  tags = {
    Name = "${var.tool}-${var.project}-${var.environment}-mysql-subnetgroup"
  }
}

# -------------------------------------------
# RDS instance
# -------------------------------------------

resource "random_string" "db_password" {
  length  = 16
  special = false
}

resource "aws_db_instance" "mysql" {
  engine         = "mysql"
  engine_version = "8.0.27"
  # AWSで作成されるデータベース名
  identifier     = "${var.tool}-${var.project}-${var.environment}-mysql"
  username       = "admin"
  password       = random_string.db_password.result
  instance_class = "db.t2.micro"

  allocated_storage     = 20
  max_allocated_storage = 50
  storage_type          = "gp2"
  storage_encrypted     = false

  multi_az               = false
  availability_zone      = "ap-northeast-1a"
  db_subnet_group_name   = aws_db_subnet_group.mysql_subnet_group.name
  vpc_security_group_ids = [aws_security_group.db_sg.id]
  publicly_accessible    = false
  port                   = 3306

  # MySQLで作成されるデータベース名
  name                 = "${var.project}_db"
  parameter_group_name = aws_db_parameter_group.mysql_parametergroup.name

  backup_window              = "04:00-05:00"
  backup_retention_period    = 7
  maintenance_window         = "Mon:05:00-Mon:06:00"
  auto_minor_version_upgrade = false

  deletion_protection = false
  skip_final_snapshot = true
  apply_immediately   = true

  tags = {
    Name = "${var.tool}-${var.project}-${var.environment}-mysql"
  }
}

output "rds_endpoint" {
  value = aws_db_instance.mysql.address
}

ここまで完了できたら、これまでと同様にterraformコマンドで
現時点のリソースを反映させましょう。

5. ALB/ Route53/ ACM

続いて、
・通信をリスナーごとに振り分けるロードバランサーのALB
・名前解決を行うDNSサーバーのRoute53
・HTTPS通信で必要になる証明書のACM
を生成していきます。

⑴ ALB

では、まずALBから実装していきましょう。

ここでも一部のみ解説します。

enable_deletion_protectioは削除保護をする設定ですが
今回はコメントアウトで設定していませんので
terraform destroyコマンドで消しやすいようにしています。

access_logsは、後で実装予定のアクセスログ用S3が関わってきますので
現時点はコメントアウトしています。

そして、ポイントとなるリスナーとターゲットグループ
以下の通りで作成していきます。

リスナー名称 ポート番号 デフォルトアクション 用途 備考
http 80  redirect  リダイレクト専用 80ポートを443ポートにリダイレクトさせる
https 443  forward  本番用リスナー 443ポートからアクセスしたユーザー
custom_10080 10080  forward  テスト用リスナー 10080ポートからアクセスしたテストユーザー
ターゲットグループ名称 ポート番号 用途
blue_tg 443 blue/greenデプロイメントの転送先 
green_tg 10080 blue/greenデプロイメントの転送先 

通信の流れを理解する為に
リスナー: 一般/テストユーザーなどの外から通信を受け付ける対象
ターゲットグループ: 受け取った通信を流すネットワーク内の場所
でイメージを捉えておくと良いと思います。

なお、health_checkのpathは、アプリ構成によって異なる箇所です。
ご自身のヘルスチェックを行うパスに合わせましょう。

terraform/elb.tf

# -------------------------------------------
# ALB
# -------------------------------------------

### ALB本体 ###

resource "aws_lb" "alb" {
  name               = "${var.tool}-${var.project}-${var.environment}-alb"
  load_balancer_type = "application"
  internal           = false
  # 削除保護は開発時には設定しない
  # enable_deletion_protection = true

  subnets = [
    aws_subnet.ecs_public_subnet_1a.id,
    aws_subnet.ecs_public_subnet_1c.id,
  ]

  ### アクセスログ用のS3
  #access_logs {
  #  bucket  = aws_s3_bucket.alb_accesslogs_bucket.id
  #  enabled = true
  #}

  security_groups = [
    aws_security_group.ecs_sg.id
  ]
}

output "alb_dns_name" {
  value = aws_lb.alb.dns_name
}

### リスナー ###

## 1.HTTPリスナー ##
resource "aws_lb_listener" "http" {
  load_balancer_arn = aws_lb.alb.arn
  port              = "80"
  protocol          = "HTTP"

  default_action {
    type = "redirect"

    redirect {
      port        = "443"
      protocol    = "HTTPS"
      status_code = "HTTP_301"
    }
  }
}

## 2.プロダクションリスナー(HTTPS) ##
resource "aws_lb_listener" "https" {
  load_balancer_arn = aws_lb.alb.arn
  port              = "443"
  protocol          = "HTTPS"
  ssl_policy        = "ELBSecurityPolicy-2016-08"
  certificate_arn   = aws_acm_certificate.tokyo_cert.arn

  default_action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.blue_tg.arn
  }
}

## 3.テストリスナー(カスタムTCP:10080) ##
resource "aws_lb_listener" "custom_10080" {
  load_balancer_arn = aws_lb.alb.arn
  port              = "10080"
  protocol          = "HTTP"

  default_action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.green_tg.arn
  }
}


# -------------------------------------------
# TargetGroup
# -------------------------------------------

### 1.blue target group ###
resource "aws_lb_target_group" "blue_tg" {
  name                 = "${var.tool}-${var.project}-${var.environment}-blue-tg"
  target_type          = "ip"
  vpc_id               = aws_vpc.vpc.id
  port                 = 80
  protocol             = "HTTP"
  deregistration_delay = 300

  health_check {
    path                = "/api/healthcheck"
    healthy_threshold   = 3
    unhealthy_threshold = 2
    timeout             = 5
    interval            = 15
    matcher             = 200
    port                = "traffic-port"
    protocol            = "HTTP"
  }

  depends_on = [aws_lb.alb]
}

### 2.green target group ###
resource "aws_lb_target_group" "green_tg" {
  name                 = "${var.tool}-${var.project}-${var.environment}-green-tg"
  target_type          = "ip"
  vpc_id               = aws_vpc.vpc.id
  port                 = 10080
  protocol             = "HTTP"
  deregistration_delay = 300

  health_check {
    path                = "/api/healthcheck"
    healthy_threshold   = 3
    unhealthy_threshold = 2
    timeout             = 5
    interval            = 15
    matcher             = 200
    port                = "traffic-port"
    protocol            = "HTTP"
  }

  depends_on = [aws_lb.alb]
}

⑵ Route53

次はRoute53の実装です。

私は、AWSのRoute53からドメインを購入しています

もし異なるレジストラからドメインを取得している方は
ホストゾーンなど少しだけ実装が違うかもしれません。

ここでは、
DNSサーバーを担うAWSサービスのRoute53
ドメイン名をもとにアクセスしてきた通信をALBに向けるという
名前解決の設定を行います。

なお、a_record_cloudfrontは、後の実装で関わってくる
CloudFront用のレコードなので、現時点ではコメントアウトしています。

表でまとめると下記となります。

レコード名称 ドメイン 対象リソース
a_record_alb Route53で取得したドメイン名 ALB
a_record_cloudfront ホスト名.Route53で取得したドメイン名 CloudFront

また、route53_acm_dns_resolveは
証明書ACMで使うDNS検証用のレコード設定です。
次章で説明しますが、terraform公式コードを参考にしています。

terraform/route53.tf

# -------------------------------------------
# Route53
# -------------------------------------------

### ドメインの取得(レジストリ:Route53) ###
data "aws_route53_zone" "hostzone" {
  name = "Route53で取得したドメイン名"
}

### Aレコード:ALB ###
resource "aws_route53_record" "a_record_alb" {
  zone_id = data.aws_route53_zone.hostzone.zone_id
  name    = data.aws_route53_zone.hostzone.name
  type    = "A"

  alias {
    name                   = aws_lb.alb.dns_name
    zone_id                = aws_lb.alb.zone_id
    evaluate_target_health = true
  }
}

output "domain_name" {
  value = aws_route53_record.a_record_alb.name
}

### 後の実装
### Aレコード:CloudFront ###
#resource "aws_route53_record" "a_record_cloudfront" {
#  zone_id = data.aws_route53_zone.hostzone.zone_id
#  name    = "ホスト名.Route53で取得したドメイン名"
#  type    = "A"

#  alias {
#    name                   = aws_cloudfront_distribution.cf.domain_name
#    zone_id                = aws_cloudfront_distribution.cf.hosted_zone_id
#    evaluate_target_health = true
#  }
#}

### 証明書のDNS検証用レコード ###
resource "aws_route53_record" "route53_acm_dns_resolve" {
  for_each = {
    for dvo in aws_acm_certificate.tokyo_cert.domain_validation_options : dvo.domain_name => {
      name   = dvo.resource_record_name
      type   = dvo.resource_record_type
      record = dvo.resource_record_value
    }
  }

  allow_overwrite = true
  zone_id         = data.aws_route53_zone.hostzone.zone_id
  name            = each.value.name
  type            = each.value.type
  records         = [each.value.record]
  ttl             = 60
}

⑶ ACM

続いて、ACMの作成です。

ACM証明書は、以下の通りで2つ発行します。

ACM名称 リージョン 用途 備考
tokyo_cert ap-northeast-1 ALB デフォルトのリージョン
virginia_cert us-east-1 CloudFront providerのエイリアスを用いてリージョン切り替え

CloudFront用のvirginia_certは、東京リージョンでは作成できません。
そこでmain.tfに記述しているproviderのaliasで工夫し、
リージョンを切替しています。

なお、ACM証明書のDNS検証については
Terraform公式ページにコードが記述されていますので、ご確認ください。

terraform/main.tf
----省略----

# -------------------------------------------
# Provider
# -------------------------------------------
provider "aws" {
  profile = "terraform"
  region  = "ap-northeast-1"
}

provider "aws" {
  alias   = "virginia"
  profile = "terraform"
  region  = "us-east-1"
}

----省略----

terraform/acm.tf

# -------------------------------------------
# AWS Certificate Manager
# -------------------------------------------

### 1、ALB用のHTTPS証明書 ###
resource "aws_acm_certificate" "tokyo_cert" {
  domain_name       = data.aws_route53_zone.hostzone.name
  validation_method = "DNS"

  tags = {
    Name = "${var.tool}-${var.project}-${var.environment}-alb-sslcert"
  }

  lifecycle {
    create_before_destroy = true
  }
}

### 証明書のDNS検証 ###
resource "aws_acm_certificate_validation" "cert_valid" {
  certificate_arn         = aws_acm_certificate.tokyo_cert.arn
  validation_record_fqdns = [for record in aws_route53_record.route53_acm_dns_resolve : record.fqdn]
}

### 2、CloudFront用のHTTPS証明書 ###
resource "aws_acm_certificate" "virginia_cert" {
  provider          = aws.virginia
  domain_name       = "*.取得したドメイン名"
  validation_method = "DNS"

  tags = {
    Name = "${var.tool}-${var.project}-${var.environment}-cloudfront-sslcert"
  }

  lifecycle {
    create_before_destroy = true
  }
}

ここまでできたら、terraformコマンドを実行してリソースを生成します。

参考まで:(検証作業)

なお、リソース生成後、リスナー側を検証したい場合
defaullt_actionをfixed-responseにして
プレーンテキストが返されるか見てみるのも良いと思います。

検証は、ターミナルからcurlコマンドを実行すると出来ます。

terraform/elb.tf
### リスナー ###

## 2.プロダクションリスナー(HTTPS) ##
resource "aws_lb_listener" "https" {
  load_balancer_arn = aws_lb.alb.arn
  port              = "443"
  protocol          = "HTTPS"
  ssl_policy        = "ELBSecurityPolicy-2016-08"
  certificate_arn   = aws_acm_certificate.tokyo_cert.arn

  ## 一時的にコメントアウト
  #default_action {
  #  type             = "forward"
  #  target_group_arn = aws_lb_target_group.blue_tg.arn
  #}

  ## 検証: プレーンテキストがレスポンスされるか
  default_action {
    type = "fixed-response"

    fixed_response {
      content_type = "text/plain"
      message_body = "HTTPのテスト"
      status_code  = "200"
    }
  }
}

ターミナル

% curl https://ドメイン名

6. SystemsManager/ ECS

それでは、
・秘匿情報を管理するSystemsManager
・コンテナを制御するECS(cluster, service, task-def)
を作成していきましょう。

⑴ SystemsManager

まず、SystemsManagerから実装します。

ここでは、RailsからMySQLに接続するに必要な3つの情報を
SystemsManagerに登録しています。

名称 Key値 Value値 備考
db_username "/container-param/db-username" admin そのまま使用
db_password "/container-param/db-password" uninitialized aws cliで書き換え
db_host "/container-param/db-host" uninitialized aws cliで書き換え

全てタイプはSecureStringです。

Value値について補足ですが、
db_username「admin」をそのままの値として利用し、
db_password, db_host仮の値として「uninitialized」とし、
SystemsManager登録後に、ターミナルからaws cliで値を上書きします。

terraform/ssm.tf

# -------------------------------------------
# Systems Manager
# -------------------------------------------

resource "aws_ssm_parameter" "db_username" {
  name        = "/container-param/db-username"
  value       = "admin"
  type        = "SecureString"
  description = "MySQLのユーザー名"

  lifecycle {
    ignore_changes = [value]
  }
}

resource "aws_ssm_parameter" "db_password" {
  name        = "/container-param/db-password"
  value       = "uninitialized"
  type        = "SecureString"
  description = "AWSCLIを用いて初期値から変更"

  lifecycle {
    ignore_changes = [value]
  }
}

resource "aws_ssm_parameter" "db_host" {
  name        = "/container-param/db-host"
  value       = "uninitialized"
  type        = "SecureString"
  description = "AWSCLIを用いて初期値から変更"

  lifecycle {
    ignore_changes = [value]
  }
}

ここまでで、terraformコマンドを実行してリソースを生成しましょう。

リソース生成後に、ターミナルからaws cliを実行して
SystemsManagerのパラメーターストアに登録している2つ値を上書きします。

なお、RDSパスワードはrandom_stringが自動生成しています。
その為、VScodeterraform.tfstateファイル内を
passwordで検索をかければ、パスワードが表示できるはずです。

RDSエンドポイントは、マネジメントコンソールでもterraform.tfstateでも
どちらでもいいのでコピペしてください。

ターミナル

% aws ssm put-parameter --name '/container-param/db-password' --type SecureString --value 'RDSパスワードの値' --overwrite

% aws ssm put-parameter --name '/container-param/db-host' --type SecureString --value 'RDSエンドポイントの値' --overwrite

それでは、値が更新できているかをマネジメントコンソールの
SystemsManagerのパラメータストアから確認しましょう。

db-host, db-passwordをクリックして、保存されている値が
先ほど、ターミナルから入力した値を合致していればOKです。

スクリーンショット 2022-01-03 11.04.23.png

⑵ ECS

ここから、ECSの設定に入ります。

コードがかなり膨大なので、一部だけ解説します。

desired_countは、ECSで起動させるタスク数です。
当記事では1としておりますが、このままでは障害が発生した時に
再起動まで時間が掛かる為、可用性を意識するなら2以上が望ましいでしょう。

deployment_controllerは、blue/greenデプロイメントを実現する為に
AWSのCodeDeployを指定しています。
Terraform公式ページにコードが載っていますので、ご確認ください。

container_definitionsは、
file参照ではなく、jsonencodeで各項目を直接記述しています。
secretsは、
先ほどSystemsManagerに登録した情報を読み取り、
コンテナ(Rails)の環境変数に埋め込んでいます。

ecs_task_execution_role と ecs_code_deploy_role は
IAMロールで、モジュールを用いて、それぞれ作成しています。
1つ目のdataブロックでAWS公式のIAMロールを参照し、
2つ目のdataブロックでポリシードキュメントとして定義しておき
最後にmoduleで同ポリシードキュメントを紐づけています。
(タスク実行ロールは、statementでSystemsManagerへのアクセス権限を付けています。)

terraform/ecs.tf
# -------------------------------------------
# ECS
# -------------------------------------------

### クラスター ###
resource "aws_ecs_cluster" "cluster" {
  name = "${var.tool}-${var.project}-${var.environment}-ecs-backend-rails-cluster"
}

### サービス ###
resource "aws_ecs_service" "service" {
  name            = "${var.tool}-${var.project}-${var.environment}-ecs-backend-rails-service"
  cluster         = aws_ecs_cluster.cluster.arn
  task_definition = aws_ecs_task_definition.task_def.arn
  # 最終的に可用性を上げるために2に変更する
  desired_count                     = 1
  launch_type                       = "FARGATE"
  platform_version                  = "1.4.0"
  health_check_grace_period_seconds = 180

  network_configuration {
    assign_public_ip = true
    security_groups  = [aws_security_group.ecs_sg.id]

    subnets = [
      aws_subnet.ecs_public_subnet_1a.id,
      aws_subnet.ecs_public_subnet_1c.id
    ]
  }

  load_balancer {
    target_group_arn = aws_lb_target_group.blue_tg.arn
    container_name   = "自分のコンテナ名"
    container_port   = 80
  }

  deployment_controller {
    type = "CODE_DEPLOY"
  }

  lifecycle {
    ignore_changes = [task_definition, load_balancer]
  }

  depends_on = [aws_db_instance.mysql]
}

### タスク定義 ###
resource "aws_ecs_task_definition" "task_def" {
  family                   = "${var.tool}-${var.project}-${var.environment}-ecs-backend-rails-task-def"
  cpu                      = 256
  memory                   = 512
  network_mode             = "awsvpc"
  requires_compatibilities = ["FARGATE"]
  execution_role_arn       = module.ecs_task_execution_role.iam_role_arn

  container_definitions = jsonencode([
    {
      name      = "自分のコンテナ名"
      image     = aws_ecr_repository.backend.repository_url
      essential = true
      portMappings = [
        {
          containerPort = 80
          hostPort      = 80
        }
      ]
      logConfiguration = {
        logDriver : "awslogs",
        options : {
          awslogs-region : "ap-northeast-1",
          awslogs-stream-prefix : "backend",
          awslogs-group : aws_cloudwatch_log_group.ecs_backend.name
        }
      },
      secrets = [
        {
          name      = "DB_USERNAME",
          valueFrom = "/container-param/db-username"
        },
        {
          name      = "DB_PASSWORD",
          valueFrom = "/container-param/db-password"
        },
        {
          name      = "DB_HOST",
          valueFrom = "/container-param/db-host"
        }
      ]
    }
  ])
}

### 1.IAMロール:タスク実行ロール ###
data "aws_iam_policy" "ecs_task_execution_role_policy" {
  arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}

data "aws_iam_policy_document" "ecs_task_execution" {
  source_json = data.aws_iam_policy.ecs_task_execution_role_policy.policy

  statement {
    effect    = "Allow"
    actions   = ["ssm:GetParameters"]
    resources = ["*"]
  }
}

module "ecs_task_execution_role" {
  source     = "./modules/iam_role"
  name       = "ecs-task-execution"
  identifier = "ecs-tasks.amazonaws.com"
  policy     = data.aws_iam_policy_document.ecs_task_execution.json
}

### 2.IAMロール:CodeDeployロール ###
data "aws_iam_policy" "ecs_code_deploy_role_policy" {
  arn = "arn:aws:iam::aws:policy/AWSCodeDeployRoleForECS"
}

data "aws_iam_policy_document" "ecs_code_deploy" {
  source_json = data.aws_iam_policy.ecs_code_deploy_role_policy.policy
}

module "ecs_code_deploy_role" {
  source     = "./modules/iam_role"
  name       = "ecs-code-deploy"
  identifier = "codedeploy.amazonaws.com"
  policy     = data.aws_iam_policy_document.ecs_code_deploy.json
}

### CodeDeploy ###

resource "aws_codedeploy_app" "codedeploy_app" {
  compute_platform = "ECS"
  name             = "${var.tool}-${var.project}-${var.environment}-codedeploy-app"
}

resource "aws_codedeploy_deployment_group" "codedeploy_dg" {
  app_name               = aws_codedeploy_app.codedeploy_app.name
  deployment_group_name  = "${var.tool}-${var.project}-${var.environment}-codedeploy-dg"
  service_role_arn       = module.ecs_code_deploy_role.iam_role_arn
  deployment_config_name = "CodeDeployDefault.ECSAllAtOnce"

  auto_rollback_configuration {
    enabled = true
    events  = ["DEPLOYMENT_FAILURE"]
  }

  blue_green_deployment_config {
    deployment_ready_option {
      action_on_timeout = "CONTINUE_DEPLOYMENT"
    }

    terminate_blue_instances_on_deployment_success {
      action                           = "TERMINATE"
      termination_wait_time_in_minutes = 5
    }
  }

  deployment_style {
    deployment_option = "WITH_TRAFFIC_CONTROL"
    deployment_type   = "BLUE_GREEN"
  }

  ecs_service {
    cluster_name = aws_ecs_cluster.cluster.name
    service_name = aws_ecs_service.service.name
  }

  load_balancer_info {
    target_group_pair_info {
      prod_traffic_route {
        listener_arns = [aws_lb_listener.https.arn]
      }

      target_group {
        name = aws_lb_target_group.blue_tg.name
      }

      target_group {
        name = aws_lb_target_group.green_tg.name
      }
    }
  }
}

### ECR ###
resource "aws_ecr_repository" "backend" {
  name = "${var.tool}-${var.project}-${var.environment}-backend-rails-repo"
}

### CloudWatch Logs ###
resource "aws_cloudwatch_log_group" "ecs_backend" {
  name              = "/${var.tool}-${var.project}-${var.environment}/ecs/backend"
  retention_in_days = 180
}

ここまでできたら、同じようにterraformコマンドを実行してみましょう。

リソース生成後の動作検証はECS起動の確認作業を実施してください。

(番外) ECS Exec

ECS/Fargate構成で生成したコンテナをローカルから操作する為の
追加実装なので、こちらは必要であれば実装してください。

私はコンテナからRaisのマイグレーション作業など試したかったので
実装しました。

それでは、最初はECSのサービスで
enable_execute_commandをtrueとして
execute_commandコマンドが使用できるように有効化させます。

続いて、ECSのタスク定義で
task_role_arnに新しく作成するIAMロールを紐付けます。

最後にECSのタスク定義に紐付けるIAMロールを作成します。
ここではstatementでjsonコードを直接記述し、ポリシーを定めています。
(dataブロックでベースとなるポリシーは取得せずにコードで書いています。)

■ポリシー

terraform/ecs.tf
### サービス ###
resource "aws_ecs_service" "service" {
  ----省略----
  enable_execute_command            = true
  ----省略----
}

### タスク定義 ###
resource "aws_ecs_task_definition" "task_def" {
  ----省略----
  task_role_arn            = module.ecs_task_role.iam_role_arn
  ----省略----
}

### 1.IAMロール:タスク実行ロール ###
----省略----

### 2.IAMロール:タスクロール ###
data "aws_iam_policy_document" "ecs_task" {
  version = "2012-10-17"
  statement {
    effect = "Allow"
    actions = [
      "ssmmessages:CreateControlChannel",
      "ssmmessages:CreateDataChannel",
      "ssmmessages:OpenControlChannel",
      "ssmmessages:OpenDataChannel"
    ]
    resources = ["*"]
  }
}

module "ecs_task_role" {
  source     = "./modules/iam_role"
  name       = "ecs-task"
  identifier = "ecs-tasks.amazonaws.com"
  policy     = data.aws_iam_policy_document.ecs_task.json
}

### 3.IAMロール:CodeDeployロール ###
----省略----

7. CloudFront/ S3 

続いて、
・コンテンツを高速配信するCloudFront
・画像やログを保管するS3
を作成します。

⑴ CloudFront

まずは、CloudFrontから実装します。

ここでも、一部のみの解説となります。

originのdomain_nameとorigin_idでは、接続先となる
パブリックアクセス用S3バケットを紐づけています。

origin_access_identityはS3にアクセスする為に設定が必要なものです。
別リソースaws_cloudfront_origin_access_identityで
定義しているものを参照しています。

default_cache_behaviorでは、
どういうURLを受け付けて、どこに振り分けるかを設定する項目です。
ここでは、S3による静的コンテンツの表示を目的としている為
query_string, headers, cookies の情報は転送しないようにしており、
min_ttl以降でキャッシュの設定をしています。

restrictionsでは、ロケーションを指定してアクセスを制限できます。
ですが、ここでは制限をかけずに明記だけしています。

aliasesでは、取得したドメインにホスト名を付して定義しています。

viewer_certificateは、証明書の設定です。
以前にACMで作成した証明書virginia_certを関連付けしています。

terraform/cloudfront.tf
# -------------------------------------------
# CloudFront
# -------------------------------------------
resource "aws_cloudfront_distribution" "cf" {
  enabled     = true
  price_class = "PriceClass_All"

  origin {
    domain_name = aws_s3_bucket.public_access_bucket.bucket_regional_domain_name
    origin_id   = aws_s3_bucket.public_access_bucket.id

    s3_origin_config {
      origin_access_identity = aws_cloudfront_origin_access_identity.cf_s3_origin_access_identity.cloudfront_access_identity_path
    }
  }

  default_cache_behavior {
    allowed_methods  = ["GET", "HEAD"]
    cached_methods   = ["GET", "HEAD"]
    target_origin_id = aws_s3_bucket.public_access_bucket.id

    forwarded_values {
      query_string = false
      headers      = []
      cookies {
        forward = "none"
      }
    }

    viewer_protocol_policy = "redirect-to-https"
    min_ttl                = 0
    default_ttl            = 86400
    max_ttl                = 31536000
    compress               = true
  }

  restrictions {
    geo_restriction {
      restriction_type = "none"
    }
  }

  aliases = ["ホスト名.ドメイン名"]

  viewer_certificate {
    acm_certificate_arn      = aws_acm_certificate.virginia_cert.arn
    minimum_protocol_version = "TLSv1.2_2019"
    ssl_support_method       = "sni-only"
  }
}

resource "aws_cloudfront_origin_access_identity" "cf_s3_origin_access_identity" {
  comment = "public access bucket identity"
}

⑵ S3

最後に、S3バケットを作成します。

作成するS3は次の2つです。

  1. Railsからの画像投稿を保存するS3
  2. ALBのアクセスログを保管するS3

1.は、Railsでcarrier_waveを使っている前提となりますが、
config.fog_directoryで、パブリックアクセス用のS3名
config.asset_hostで、保存先のURL名
を設定します。

config/initializers/carrier_wave.rb

if Rails.env.production?
  CarrierWave.configure do |config|
    config.fog_credentials = {
      provider: 'AWS',
      aws_access_key_id: Rails.application.credentials.dig(:aws, :access_key_id),
      aws_secret_access_key: Rails.application.credentials.dig(:aws, :secret_access_key),
      region: 'ap-northeast-1'
    }
    config.fog_directory = 'パブリックアクセス用のS3名'
    config.asset_host = 'https://ホスト名.ドメイン名'
  end
end

2.は、elb.tfでコメントアウトしていたものをコメントインさせて
ALBにS3を紐付けして、アクセスログ用のS3も設定します。

terraform/elb.tf

# -------------------------------------------
# ALB
# -------------------------------------------

----省略----

  ### コメントイン
  ## アクセスログ用のS3
  access_logs {
    bucket  = aws_s3_bucket.alb_accesslogs_bucket.id
    enabled = true
  }
}

S3の設定は以下のように行います。

terraform/s3.tf
# -------------------------------------------
# Public Access Bucket
# -------------------------------------------
resource "aws_s3_bucket" "public_access_bucket" {
  bucket        = "${var.tool}-${var.project}-${var.environment}-public-access-bucket"
  force_destroy = true
  acl           = "public-read"

  cors_rule {
    allowed_origins = ["https://ドメイン名"]
    allowed_methods = ["GET"]
    allowed_headers = ["*"]
    max_age_seconds = 3600
  }
}

# -------------------------------------------
# ALB AccessLogs Bucket
# -------------------------------------------
resource "aws_s3_bucket" "alb_accesslogs_bucket" {
  bucket        = "${var.tool}-${var.project}-${var.environment}-alb-accesslogs-bucket"
  force_destroy = true

  lifecycle_rule {
    enabled = true
    expiration {
      days = "180"
    }
  }
}

resource "aws_s3_bucket_policy" "alb_log" {
  bucket = aws_s3_bucket.alb_accesslogs_bucket.id
  policy = data.aws_iam_policy_document.alb_log.json
}

data "aws_iam_policy_document" "alb_log" {
  statement {
    effect    = "Allow"
    actions   = ["s3:PutObject"]
    resources = ["arn:aws:s3:::${aws_s3_bucket.alb_accesslogs_bucket.id}/*"]

    principals {
      type        = "AWS"
      identifiers = ["582318560864"]
    }
  }
}

ここまで実装できたら、今までと同様でterraformコマンドを実行して
リソースを生成します。

これでRailsの画像投稿も機能しますので完成です!!

ECS起動の確認作業

Terraformの実装は完了しましたが、
現状はECRにイメージが存在しない状態です。

その為、ECRのタスクでコンテナ生成が上手くいきません。

そこで、当記事ではターミナルから手動で
ローカルからECRにイメージをpushします。

マネジメントコンソールのECR画面から
対象リポジトリにチェックを入れて
プッシュコマンドの表示をクリックすると
イメージpushに関わる実行コマンドを表示されます。

<手動パターン>

ターミナル

# 1.Dockerコマンドが使用できるようにAWSに認証させる
% aws ecr get-login-password --region ap-northeast-1 | docker login --username AWS --password-stdin AWSアカウントID.dkr.ecr.ap-northeast-1.amazonaws.com

# 2.本番用のDockerfile.productionをもとにイメージを生成する
% docker build -f Dockerfile.production -t ECRレポジトリ名 .

# 3.ECRに保存するためにAWS指定のタグ付けを行う
% docker tag ECRレポジトリ名:latest AWSアカウントID.dkr.ecr.ap-northeast-1.amazonaws.com/ECRレポジトリ名:latest

# 4.指定タグをつけたイメージをECRに保存する
% docker push AWSアカウントID.dkr.ecr.ap-northeast-1.amazonaws.com/ECRレポジトリ名:latest

もし、CircleCIのOrbsを用いて、自動デプロイを実装していれば
ECR/ECSに関わるAWSリソース名を全て変更すれば機能します。
参考までにCircleCIの設定ファイルを載せておきます。

<CircleCIで自動パターン>

.circleci/config.yml
version: 2.1
jobs:
  build-and-test:
    docker:
      - image: circleci/ruby:2.7.3-node-browsers
        environment:
          RAILS_ENV: 'test'
      - image: circleci/mysql:8.0
        command: mysqld --default-authentication-plugin=mysql_native_password
        environment:
          MYSQL_ALLOW_EMPTY_PASSWORD: 'true'
          MYSQL_ROOT_HOST: '%'
    steps:
      - checkout
      - restore_cache:
          keys:
            - v1-dependencies-{{ checksum "Gemfile.lock" }}
            - v1-dependencies-
      - run:
          name: install dependencies
          command: |
            bundle install --jobs=4 --retry=3 --path vendor/bundle
      - save_cache:
          paths:
          - ./vendor/bundle
          key: v1-dependencies-{{ checksum "Gemfile.lock" }}
      - run: yarn add @fortawesome/fontawesome-free
      - run: mv config/database.yml.ci config/database.yml
      - run: bundle exec rake db:create
      - run: bundle exec rake db:schema:load
      # rubocopのコード解析は一旦保留にする
      # - run:
      #     name: Rubocop
      #     command: bundle exec rubocop
      - run:
          name: RSpec
          command: |
            mkdir /tmp/test-results
            TEST_FILES="$(circleci tests glob "spec/**/*_spec.rb" | \
              circleci tests split --split-by=timings)"
            bundle exec rspec \
              --format progress --format RspecJunitFormatter \
              --out /tmp/test-results/rspec.xml \
              $TEST_FILES
      - store_test_results:
          path: /tmp/test-results
      - store_artifacts:
          path: tmp/screenshots
          destination: test-screenshots

orbs:
  aws-ecr: circleci/aws-ecr@7.3.0
  aws-ecs: circleci/aws-ecs@2.2.1
workflows:
  build-and-deploy:
    jobs:
      - build-and-test
      - aws-ecr/build-and-push-image:
          filters:
            branches:
              only: master
          extra-build-args: '--build-arg RAILS_MASTER_KEY=${RAILS_MASTER_KEY}'
          ### terraformで作成したECR名称に変更 
          repo: ECRレポジトリ名
          dockerfile: Dockerfile.production
      - aws-ecs/deploy-service-update:
          filters:
            branches:
              only: master
          requires:
            - aws-ecr/build-and-push-image
          ### terraformで作成したリソース名に全て変更
          cluster-name: 'クラスター名'
          service-name: 'サービス名'
          family: 'タスク定義名'
          deployment-controller: 'CODE_DEPLOY'
          codedeploy-application-name: 'CodeDeployのアプリケーション名'
          codedeploy-deployment-group-name: 'CodeDeployのデプロイメントグループ名'
          codedeploy-load-balanced-container-name: 'コンテナ名'

上記作業を実施後に
マネジメントコンソールのサービス内のイベントタブで
「service サービス名 has reached a steady state.」
のメッセージが確認できれば成功です。

スクリーンショット 2022-02-14 17.48.21.png

参考動画/書籍

参考記事

終わりに

一度、Terraformでインフラが構築できれば、後から
部分的に構成を変更したり、削除したり、追加したりも容易になるし
terraform最高!

ただ、正直なところ、細かい部分は設定できていないし、
これから加筆修正も加えないとな…

長い記事となりましたが、最後までお読み頂き
ありがとうございました。

9
6
0

Register as a new user and use Qiita more conveniently

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