TerraformでAWSの雛形を作成してみる
はじめに
レスキューナウではスキルアップのためAWSやGCPアカウントの貸与制度があります。
しかも月額1万円まで利用可能という太っ腹な制度です!
私はこの制度を使ってAWSの学習をし、6月にSAA-C03を取得しました。
かなりお世話になっている制度なのですが、AWSの学習では様々なリソースを作る必要がありそのまま放置しておくとお金がかかってしまいます。
管理コンソールから手でポチポチつくっていくのもいいんですが、ネットワークやDBなどよく使う物を毎回手で作成&削除するのがしんどくなってきたのでよく使う構成をTerraformで作成することにしました。
Terraformについてはここ1ヶ月くらいで入門してみましたがとても便利で楽しいですね!
作成する構成
今回は東京リージョンで2つのアベイラビリティゾーン(ap-northeast-1c,ap-northeast-1d)にわたって3層のサブネット(Public,Private,Isolated)を構築してそれぞれの層ごとにセキュリティグループを作成しました。
Privateサプネットのap-northeast-1cに踏み台サーバーとしてEC2を配置します。
踏み台サーバーから外部に接続できるようにNATも用意しました。
またIsolatedサブネットにAuroraを配置し、踏み台サーバからアクセスできるようにします。
※主にNATとAuroraに費用がかかってきます。
作成したコードのリポジトリはこちら
2023/08/05 tflintを導入し、修正を加えています。ネットワーク図
ディレクトリ構成
├── modules
│ ├── bastion ... 踏み台サーバ
│ │ ├── main.tf
│ │ ├── outputs.tf
│ │ └── variables.tf
│ ├── db ... Auroraデータベース
│ │ ├── main.tf
│ │ ├── outputs.tf
│ │ └── variables.tf
│ └── network ... ネットワーク
│ ├── main.tf
│ ├── outputs.tf
│ └── variables.tf
├── main.tf
├── outputs.tf
└── variables.tf
基本設定
main.tf
ざっくり解説
AWS上でクラウドインフラストラクチャを作成するためのものです。バージョンはTerraform 0.14.0以上、AWSプロバイダは5.5.0を指定しています。
プロバイダ設定ではAWSリージョンとデフォルトのタグが設定されています。
次に、ネットワーク、踏み台(bastion)、データベースのモジュールを設定しています。
これらのモジュールは各々独立したサブディレクトリに格納されており、それぞれの役割に応じてAWSリソースを生成します。
ネットワークモジュールは基本的なネットワーキングの構成を、bastionモジュールはネットワークのプライベートセキュリティグループとサブネットを使用した踏み台ホストを、データベースモジュールは、ネットワークの分離されたセキュリティグループとサブネットを使用したAuroraデータベースをそれぞれ作成します。
# -------------------------------------
# Terraform configuration
# -------------------------------------
terraform {
required_version = ">= 0.14.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.5.0"
}
}
}
# -------------------------------------
# Provider configuration
# -------------------------------------
provider "aws" {
region = var.region
default_tags {
tags = {
project = var.project
env = var.env
managed_by = "terraform"
}
}
}
# -------------------------------------
# Network
# -------------------------------------
module "network" {
source = "./modules/network"
project = var.project
env = var.env
availability_zones = var.availability_zones
}
# -------------------------------------
# Bastion
# -------------------------------------
module "bastion" {
source = "./modules/bastion"
project = var.project
env = var.env
security_group_ids = [module.network.private_security_group_id]
subnet_id = module.network.private_subnet_ids[0]
}
# -------------------------------------
# Database
# -------------------------------------
module "db" {
source = "./modules/db"
project = var.project
env = var.env
vpc_security_group_ids = [module.network.isolated_security_group_id]
subnet_ids = module.network.isolated_subnet_ids
rds_scaling_min_capacity = var.rds_scaling_min_capacity
rds_scaling_max_capacity = var.rds_scaling_max_capacity
}
variables.tf
ざっくり解説
AWSリソース作成に使用する変数が定義されています。それぞれ以下のように設定されています。
変数名 | 説明 |
---|---|
region | AWSのリージョン。デフォルトは"ap-northeast-1"(東京リージョン)。 |
project | プロジェクト名。デフォルトは"terraform-template"。 |
env | 環境名。デフォルトは"dev"。 |
availability_zones | 使用するアベイラビリティーゾーン。デフォルトは東京リージョンの"ap-northeast-1c"と"ap-northeast-1d"。 |
rds_scaling_min_capacity rds_scaling_max_capacity |
RDSのスケーリングに関する設定で、最小と最大のキャパシティを指定します。デフォルトはそれぞれ0.5と1です。 |
variable "region" {
description = "AWS region"
default = "ap-northeast-1"
}
variable "project" {
default = "terraform-template"
}
variable "env" {
default = "dev"
}
variable "availability_zones" {
default = ["ap-northeast-1c", "ap-northeast-1d"]
}
variable "rds_scaling_min_capacity" {
default = 0.5
}
variable "rds_scaling_max_capacity" {
default = 1
}
outputs.tf
出力がないので空です
ネットワーク
modules/network/main.tf
ざっくり解説
VPC、サブネット、インターネットゲートウェイ、Elastic IP、NATゲートウェイ、ルートテーブル、ルート、セキュリティグループなどのAWSリソースを作成しています。
VPCはCIDRブロックが"10.0.0.0/16"を指定しています。
Public、Private、Isolatedの3つのタイプのサブネットをそれぞれ2つずつ作成します。
それぞれのサブネットは上記のVPCに関連付けられ、異なるCIDRブロックと異なるアベイラビリティゾーンを使用します。
インターネットゲートウェイとNATゲートウェイを作成し、これらをVPCとサブネットに関連付けます。
3つのタイプのルートテーブルを作成し、各サブネットに関連付けます。
インターネットゲートウェイとNATゲートウェイへのルートを定義します。
Public、Private、Isolatedの3つのタイプのセキュリティグループを作成します。
これらは異なるポートとトラフィックタイプを許可します。
/*******************************
* VPC
*******************************/
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
tags = {
Name = "${var.project}-${var.env}-vpc"
}
}
/*******************************
* Subnet - public
*******************************/
resource "aws_subnet" "public1" {
vpc_id = aws_vpc.main.id
availability_zone = var.availability_zones[0]
cidr_block = "10.0.1.0/24"
tags = {
Name = "${var.project}-${var.env}-subnet-public-${var.availability_zones[0]}"
}
}
resource "aws_subnet" "public2" {
vpc_id = aws_vpc.main.id
availability_zone = var.availability_zones[1]
cidr_block = "10.0.2.0/24"
tags = {
Name = "${var.project}-${var.env}-subnet-public-${var.availability_zones[1]}"
}
}
/******************************
* Subnet - private
******************************/
resource "aws_subnet" "private1" {
vpc_id = aws_vpc.main.id
availability_zone = var.availability_zones[0]
cidr_block = "10.0.3.0/24"
tags = {
Name = "${var.project}-${var.env}-subnet-private-${var.availability_zones[0]}"
}
}
resource "aws_subnet" "private2" {
vpc_id = aws_vpc.main.id
availability_zone = var.availability_zones[1]
cidr_block = "10.0.4.0/24"
tags = {
Name = "${var.project}-${var.env}-subnet-private-${var.availability_zones[1]}"
}
}
/******************************
* Subnet - isolated
******************************/
resource "aws_subnet" "isolated1" {
vpc_id = aws_vpc.main.id
availability_zone = var.availability_zones[0]
cidr_block = "10.0.5.0/24"
tags = {
Name = "${var.project}-${var.env}-subnet-isolated-${var.availability_zones[0]}"
}
}
resource "aws_subnet" "isolated2" {
vpc_id = aws_vpc.main.id
availability_zone = var.availability_zones[1]
cidr_block = "10.0.6.0/24"
tags = {
Name = "${var.project}-${var.env}-subnet-isolated-${var.availability_zones[1]}"
}
}
/******************************
* InternetGateway
******************************/
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = {
Name = "${var.project}-${var.env}-igw"
}
}
/******************************
* Elastic IP
******************************/
resource "aws_eip" "for_nat" {
domain = "vpc"
tags = {
Name = "${var.project}-${var.env}-eip-for-nat-${var.availability_zones[0]}"
}
}
/******************************
* NAT
******************************/
resource "aws_nat_gateway" "nat" {
allocation_id = aws_eip.for_nat.id
subnet_id = aws_subnet.public1.id
tags = {
Name = "${var.project}-${var.env}-nat-${var.availability_zones[0]}"
}
depends_on = [aws_internet_gateway.main]
}
/******************************
* RouteTable
******************************/
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
tags = {
Name = "${var.project}-${var.env}-rtb-public"
}
}
resource "aws_route_table" "private" {
vpc_id = aws_vpc.main.id
tags = {
Name = "${var.project}-${var.env}-rtb-private"
}
}
resource "aws_route_table" "isolated" {
vpc_id = aws_vpc.main.id
tags = {
Name = "${var.project}-${var.env}-rtb-isolated"
}
}
/******************************
* Route
******************************/
resource "aws_route" "to_igw" {
destination_cidr_block = "0.0.0.0/0"
route_table_id = aws_route_table.public.id
gateway_id = aws_internet_gateway.main.id
}
resource "aws_route" "to_nat" {
destination_cidr_block = "0.0.0.0/0"
route_table_id = aws_route_table.private.id
nat_gateway_id = aws_nat_gateway.nat.id
}
/******************************
* SubnetRouteTableAssociation
******************************/
resource "aws_route_table_association" "public1" {
subnet_id = aws_subnet.public1.id
route_table_id = aws_route_table.public.id
}
resource "aws_route_table_association" "public2" {
subnet_id = aws_subnet.public2.id
route_table_id = aws_route_table.public.id
}
resource "aws_route_table_association" "private1" {
subnet_id = aws_subnet.private1.id
route_table_id = aws_route_table.private.id
}
resource "aws_route_table_association" "private2" {
subnet_id = aws_subnet.private2.id
route_table_id = aws_route_table.private.id
}
resource "aws_route_table_association" "isolated1" {
subnet_id = aws_subnet.isolated1.id
route_table_id = aws_route_table.isolated.id
}
resource "aws_route_table_association" "isolated2" {
subnet_id = aws_subnet.isolated2.id
route_table_id = aws_route_table.isolated.id
}
/******************************
* SecurityGroup - public
******************************/
resource "aws_security_group" "public" {
name = "${var.project}-${var.env}-sg-public"
description = "public security group"
vpc_id = aws_vpc.main.id
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
ipv6_cidr_blocks = ["::/0"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
ipv6_cidr_blocks = ["::/0"]
}
tags = {
Name = "${var.project}-${var.env}-sg-public"
}
}
/******************************
* SecurityGroup - private
******************************/
resource "aws_security_group" "private" {
name = "${var.project}-${var.env}-sg-private"
description = "private security group"
vpc_id = aws_vpc.main.id
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
self = true
security_groups = [aws_security_group.public.id]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
ipv6_cidr_blocks = ["::/0"]
}
tags = {
Name = "${var.project}-${var.env}-sg-private"
}
}
/******************************
* SecurityGroup - isolated
******************************/
resource "aws_security_group" "isolated" {
name = "${var.project}-${var.env}-sg-isolated"
description = "isolated security group"
vpc_id = aws_vpc.main.id
ingress {
from_port = 3306
to_port = 3306
protocol = "tcp"
self = true
security_groups = [aws_security_group.private.id]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
ipv6_cidr_blocks = ["::/0"]
}
tags = {
Name = "${var.project}-${var.env}-sg-isolated"
}
}
modules/network/variables.tf
ざっくり解説
3つの変数を定義しています。projectとenvは空のデフォルト値を持っていて、これらは実行時にユーザーから値を提供する必要があります。
今回は呼び出し元のmain.tfで設定しています。それに対して、availability_zonesはデフォルト値が与えられており、ap-northeast-1cとap-northeast-1dのリストです。ユーザーが実行時に別の値を提供しない限り、このデフォルト値が使用されます。
variable "project" {}
variable "env" {}
variable "availability_zones" {
default = ["ap-northeast-1c", "ap-northeast-1d"]
}
modules/network/outputs.tf
ざっくり解説
複数の出力値を定義しています。これらはTerraformがリソースを作成または変更した後に出力され、他の設定やユーザーによって利用できます。
具体的には、vpc_id, public_security_group_id, private_security_group_id, isolated_security_group_idはそれぞれ、作成されたVPC、Publicセキュリティグループ、Privateセキュリティグループ、IsolatedセキュリティグループのIDを出力します。
また、public_subnet_ids, private_subnet_ids, isolated_subnet_idsはそれぞれ、作成されたPublic、Private、IsolatedのサブネットのIDのリストを出力します。
output "vpc_id" {
value = aws_vpc.main.id
}
output "public_security_group_id" {
value = aws_security_group.public.id
}
output "private_security_group_id" {
value = aws_security_group.private.id
}
output "isolated_security_group_id" {
value = aws_security_group.isolated.id
}
output "public_subnet_ids" {
value = [aws_subnet.public1.id, aws_subnet.public2.id]
}
output "private_subnet_ids" {
value = [aws_subnet.private1.id, aws_subnet.private2.id]
}
output "isolated_subnet_ids" {
value = [aws_subnet.isolated1.id, aws_subnet.isolated2.id]
}
データベース
modules/db/main.tf
ざっくり解説
Aurora Serverless v2 RDSクラスタを作成します。
ランダムなパスワードを生成し、それをAWS Secrets Managerで管理します。クラスタ、クラスタインスタンス、クラスタパラメータグループ、DBサブネットグループを設定します。
パラメータグループでは、データベースの文字セットをutf8mb4に設定します。クラスタはスケーラブルで、指定された最小・最大容量で動作します。
DBはプライベートアクセスのみ可能です。
# ******************************
# RDS Cluster - Aurora Serverless v2 (MySQL8.0)
# ******************************
resource "random_password" "rds_secret" {
length = 20
special = false
}
locals {
rds_username = "root"
rds_dbname = "app_db"
rds_password = random_password.rds_secret.result
}
resource "aws_secretsmanager_secret" "rds" {
name = "${var.project}-${var.env}-secret-rds"
}
resource "aws_secretsmanager_secret_version" "rds" {
secret_id = aws_secretsmanager_secret.rds.id
secret_string = jsonencode({
username = local.rds_username,
password = local.rds_password,
dbname = local.rds_dbname,
host = aws_rds_cluster.main.endpoint
}
)
}
resource "aws_rds_cluster" "main" {
cluster_identifier = "${var.project}-${var.env}-mysql80-slv2-cluster"
engine = "aurora-mysql"
engine_version = "8.0.mysql_aurora.3.03.0"
db_cluster_parameter_group_name = aws_rds_cluster_parameter_group.main.name
master_username = local.rds_username
master_password = local.rds_password
vpc_security_group_ids = var.vpc_security_group_ids
db_subnet_group_name = aws_db_subnet_group.main.name
skip_final_snapshot = true
apply_immediately = true
enabled_cloudwatch_logs_exports = ["audit", "error", "slowquery"]
backup_retention_period = 7
serverlessv2_scaling_configuration {
min_capacity = var.rds_scaling_min_capacity
max_capacity = var.rds_scaling_max_capacity
}
}
resource "aws_rds_cluster_instance" "cluster_instances" {
count = 1
cluster_identifier = aws_rds_cluster.main.id
identifier = "${aws_rds_cluster.main.cluster_identifier}-serverless-${count.index}"
engine = "aurora-mysql"
instance_class = "db.serverless"
db_subnet_group_name = aws_db_subnet_group.main.name
publicly_accessible = false
}
resource "aws_rds_cluster_parameter_group" "main" {
name = "${var.project}-${var.env}-cluster-paramgrp-rds"
family = "aurora-mysql8.0"
description = "RDS cluster parameter group"
parameter {
name = "character_set_server"
value = "utf8mb4"
}
parameter {
name = "character_set_client"
value = "utf8mb4"
}
parameter {
name = "character_set_database"
value = "utf8mb4"
}
parameter {
name = "character_set_results"
value = "utf8mb4"
}
parameter {
name = "character_set_filesystem"
value = "utf8mb4"
}
parameter {
name = "character_set_connection"
value = "utf8mb4"
}
}
resource "aws_db_subnet_group" "main" {
name = "${var.project}-${var.env}-db-subnetgrp"
subnet_ids = var.subnet_ids
}
modules/db/variables.tf
ざっくり解説
projectとenvはプロジェクトと環境に関する情報を格納します。vpc_security_group_idsとsubnet_idsは、リソースが配置されるVPCとサブネットを示します。rds_scaling_min_capacityとrds_scaling_max_capacityは、RDSのスケーリングに関するパラメータで、デフォルト値が設定されています。
variable "project" {}
variable "env" {}
variable "vpc_security_group_ids" {}
variable "subnet_ids" {}
variable "rds_scaling_min_capacity" {
default = 0.5
}
variable "rds_scaling_max_capacity" {
default = 1
}
modules/db/outputs.tf
ざっくり解説
AWS Secrets Managerで生成されたRDSのシークレット情報(ここではARN)を出力するものです。mysql_secret_arnという名前の出力変数には、AWS Secrets ManagerのRDSシークレットのARNが格納されます。
output "mysql_secret_arn" {
value = aws_secretsmanager_secret.rds.arn
}
踏み台サーバー
modules/bastion/main.tf
ざっくり解説
AWSのEC2インスタンスを作成し、そのインスタンスを踏み台サーバーとして設定します。
Amazon Linux 2の最新のAMIを取得して使用します。
EC2インスタンスのタイプは"t3.nano"、ルートボリュームサイズは10GiBの"gp2"ボリュームです。
インスタンスにはIAMロールがアタッチされ、これによりEC2インスタンスが他のAWSサービスと連携するための権限が付与されます。また、IAMインスタンスプロファイルを作成して、EC2インスタンスがこのIAMロールを引き受けることを可能にします。
AWS Systems Manager (SSM) を使用して、インスタンス作成時にシェルスクリプトを実行します。このスクリプトはAWS CLI v2とSession Manager Pluginのインストール、さらにMySQLクライアントのインストールを行います。これにより、踏み台サーバーはAWSリソースへのアクセスとMySQLデータベースへの接続を可能にします。
# ******************************
# EC2 Instance - 踏み台サーバー
# ******************************
data "aws_ssm_parameter" "amzn2_ami" {
name = "/aws/service/ami-amazon-linux-latest/amzn2-ami-hvm-x86_64-gp2"
}
resource "aws_instance" "main" {
ami = data.aws_ssm_parameter.amzn2_ami.value
instance_type = "t3.nano"
vpc_security_group_ids = var.security_group_ids
subnet_id = var.subnet_id
iam_instance_profile = aws_iam_instance_profile.main.name
# EBSのルートボリューム設定
root_block_device {
# ボリュームサイズ(GiB)
volume_size = 10
# ボリュームタイプ
volume_type = "gp2"
# EBSのNameタグ
tags = {
Name = "${var.project}-${var.env}-ebs-bastion"
}
}
tags = {
Name = "${var.project}-${var.env}-ec2-bastion"
}
}
resource "aws_ssm_association" "main" {
association_name = "${var.project}-${var.env}-association-bastion"
name = "AWS-RunShellScript"
targets {
key = "InstanceIds"
values = [aws_instance.main.id]
}
parameters = {
"commands" = <<EOF
cd /root
curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip"
unzip awscliv2.zip
sudo ./aws/install --bin-dir /usr/local/bin --install-dir /usr/local/aws-cli --update
sudo yum install -y https://s3.amazonaws.com/session-manager-downloads/plugin/latest/linux_64bit/session-manager-plugin.rpm
sudo yum remove -y mariadb-libs
sudo yum localinstall -y http://dev.mysql.com/get/mysql80-community-release-el7-7.noarch.rpm
sudo yum install -y mysql-community-client
EOF
}
}
# ******************************
# IAM Role
# ******************************
resource "aws_iam_role" "main" {
name = "${var.project}-${var.env}-role-ec2-bastion"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = "sts:AssumeRole"
Effect = "Allow"
Sid = ""
Principal = {
Service = "ec2.amazonaws.com"
}
},
]
})
inline_policy {
name = "default"
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = [
"ec2messages:*",
"ssm:UpdateInstanceInformation",
"ssmmessages:*",
"ecs:ExecuteCommand",
"ecs:DescribeTasks"
]
Effect = "Allow"
Resource = "*"
},
]
})
}
path = "/"
}
# ******************************
# IAM InstanceProfile
# ******************************
resource "aws_iam_instance_profile" "main" {
name = "${var.project}-${var.env}-instance-profile"
role = aws_iam_role.main.name
}
modules/bastion/variables.tf
ざっくり解説
projectとenvはプロジェクトと環境に関する情報を格納します。
security_group_idsはAWS EC2インスタンスに関連付けるセキュリティグループのIDを指定します。
subnet_idはEC2インスタンスが配置されるサブネットのIDを指定します。
variable "project" {}
variable "env" {}
variable "security_group_ids" {}
variable "subnet_id" {}
modules/bastion/outputs.tf
出力がないので空です
実行してみる
ローカル環境でterraformを実行して環境を構築する
※default profileにご注意ください
% terraform init
% terraform plan
% terraform apply
Auroraがあるので結構待ちます。
※tfファイルで変数化している箇所はapply実行時に引数や環境変数で変更可能です。
問題無くapplyが終わったら踏み台サーバに接続して動作を確認してみます。
AWSコンソールのEC2 > インスタンス から作成した踏み台サーバにチェックを入れで「接続」をクリックします。
インスタンスに接続でセッションマネージャーのタブを選択して接続をクリックします。
すると、踏み台サーバーに接続できるので外部に接続可能か試してみます。
接続できました。次はAuroraです。
terraformでSecret Managerに接続情報が作成されているのでその情報を元に接続を試してみます。
無事に接続できました!
次に環境を削除してみます。
% terraform destroy
エラー無くコマンドが終了すれば削除完了です。
※注意:削除後30日以内に再度構築を行うとSecret Manager関連で以下のエラーがでます。
╷
│ Error: creating Secrets Manager Secret: InvalidRequestException: You can't create this secret because a secret with this name is already scheduled for deletion.
│
│ with module.db.aws_secretsmanager_secret.rds,
│ on modules/db/main.tf line 13, in resource "aws_secretsmanager_secret" "rds":
│ 13: resource "aws_secretsmanager_secret" "rds" {
│
これはシークレットの復旧期間という仕組みによるものでシークレットの削除が保留されていることにより発生します。デフォルトの復旧期間は30日らしいので同名のシークレットが残っていてエラーが発生します。
そのため次のコマンドで強制的に削除を行う必要があります。
aws secretsmanager delete-secret --secret-id <シークレットid> --force-delete-without-recovery
実際に復旧期間のシークレットを即時削除してみる
% aws secretsmanager delete-secret --secret-id terraform-template-dev-secret-rds --force-delete-without-recovery
{
"ARN": "arn:aws:secretsmanager:ap-northeast-1:xxxxxxxxxxxx:secret:terraform-template-dev-secret-rds-XXXXXX",
"Name": "terraform-template-dev-secret-rds",
"DeletionDate": "2023-07-31T11:32:51.397000+09:00"
}
これでapplyしてもエラーが出なくなります。
最後に
以上でapply,deployするだけで簡単にAWSの基本的な構成ができようになりました。
この環境をベースにして webサーバを追加したりALBを追加したり色々試してみると捗るとおもいます。
今回はローカルからterraformコマンドを実行しましたが、次のステップとしてTerraform Cloudを使ってGitHubと連携させてみるのも楽しいと思います。
こちらの記事Terraform Cloud x AWS に入門できる「AWS Modernization Workshop with HashiCorp Terraform Cloud」もオススメです。
Discussion