背景
コード化することで、同じ環境を正確に、何度でも再現できるようにするため。
インフラの構成をコードとして可視化することで、レビューや管理を容易にするため。
今回の取り組みで意識した点
・コスト削減のため、Nat ゲートウェイを配置せずに EC2 をパブリックサブネットに配置した。
・RDS をマルチ AZ として、可用性を高めるようにした。
ベストプラクティスは EC2 をプライベートサブネットに配置し、外部からアクセスできないようにしてセキュリティを高めます。
高可用性を実現するためには、マルチ AZ にします。
マルチ AZ にすると 2 つのアベイラビリティゾーンに展開され、片方が何らかの影響でダウンした場合、もう片方にフェイルオーバーされます
コスト比較(1ヶ月単位)
外部との通信
・Nat ゲートウェイ:固定 45 ドル+通信 0.005 ドル/GB
・EC2 単体:0.005 ドル/時間
データベース
RDS Multi-AZ:Single-AZ の約 2 倍(高可用性のトレードオフ)
要件整理
ネットワーク構成
項目 | 値 |
---|---|
VPC CIDR | 10.0.0.0/16 |
Internet Gateway | 配置済み |
ルートテーブル (0.0.0.0/0) | Internet Gateway |
サブネット
Availability Zone | パブリックサブネット CIDR | プライベートサブネット CIDR |
---|---|---|
1a | 10.0.1.0/24 | 10.0.10.0/24 |
1c | 10.0.2.0/24 | 10.0.20.0/24 |
アプリケーション構成
リソース | 配置先 | 設定/詳細 |
---|---|---|
ALB | パブリックサブネット | — |
EC2 | パブリックサブネット | - インスタンスタイプ:t3.micro - AMI:無料枠のもの - アクセス:SSM セッションマネージャー - セキュリティグループ:インバウンドは ALB からの HTTP 通信のみ許可 |
Auto Scaling Group | パブリックサブネット | - 最小:1 台 - 最大:3 台 |
RDS | プライベートサブネット | - Multi AZ - アクセス許可:EC2 の SG のみ |
全体の構成図
今回作る構成は、コスト最適化したもので以下のようになります。
Terraform 構成
以下のフォルダ構成としました。
├── backend.tf
├── bootstrap
│ ├── remote-state.tf
│ ├── terraform.tfstate
│ └── terraform.tfstate.backup
├── envs
│ └── dev.tfvars #projectとdb_passwordをここで指定
├── main.tf
├── modules
│ ├── alb
│ │ ├── main.tf
│ │ ├── outputs.tf
│ │ └── variables.tf
│ ├── compute
│ │ ├── main.tf
│ │ ├── outputs.tf
│ │ ├── variables.tf
│ │ └── userdata.sh
│ ├── network
│ │ ├── main.tf
│ │ ├── outputs.tf
│ │ └── variables.tf
│ └── rds
│ ├── main.tf
│ └── variables.tf
├── terraform.tfstate.d
│ └── dev
│ ├── terraform.tfstate
│ └── terraform.tfstate.backup
└── variables.tf
作成する手順
- リモートステート用の S3 バケットと DynamoDB を作成する。
- VPC、サブネット、インターネットゲートウェイ、ルートテーブルを作成する。
- 起動テンプレート、オートスケーリンググループ、ウェブサーバー用のセキュリティグループを作成する。
- ALB を作成する。
- RDS を作成する。
- ルートの main.tf で呼び出す
1. リモートステート用の S3 バケットと DynamoDB を作成する。
まずはじめに、Terraform の構成情報をチームで安全に共有するための「リモートステート」環境を構築します。具
体的には、tfstate ファイルを保管する S3 バケットと、複数人での同時実行による意図しない上書きを防ぐ(ロックするための)DynamoDB テーブルを作成します。
bootstrap
├── remote-state.tf
├── terraform.tfstate
└── terraform.tfstate.backup
プロジェクトのルートに bootstrap ディレクトリを作成します。
bootstrap ディレクトリの中に remote-state.tf をファイルを作成します。
以下 remote-state.tf の中身です。
provider "aws" {
region = "ap-northeast-1"
}
# S3バケット
resource "aws_s3_bucket" "state" {
bucket = "vpc-ec2-rds-statefile-〇〇〇〇" #リモートステート用のS3バケットの名前
}
# S3バケットのバージョニング設定
resource "aws_s3_bucket_versioning" "this" {
bucket = aws_s3_bucket.state.id
versioning_configuration {
status = "Enabled"
}
}
# S3バケットの暗号化設定
resource "aws_s3_bucket_server_side_encryption_configuration" "sse" {
bucket = aws_s3_bucket.state.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
# DynamoDBのテーブルを作成
resource "aws_dynamodb_table" "lock" {
name = "vpc-ec2-rds-statelock-〇〇〇〇" #リモートステート用のテーブル名
billing_mode = "PAY_PER_REQUEST"
hash_key = "LockID"
attribute {
name = "LockID"
type = "S" #Stringの"S"
}
}
# S3バケット名の出力
output "aws_s3_bucket_state" {
value = aws_s3_bucket.state.id
}
# DynamoDBテーブル名の出力
output "aws_dynamodb_table_lock" {
value = aws_dynamodb_table.lock.arn
}
以下をターミナルで実行し、S3 バケットと DynamoDB を作成します
cd ./bootstrap
terraform init
terraform plan
terraform apply
S3 バケットと DynamoDB テーブルを作成できたら、Terraform にその場所を教えるため、プロジェクトルートの backend.tf に以下の設定を記述します。
terraform {
backend "s3" {
bucket = "〇〇〇〇" #作成したリモートステート用のS3バケット名
key = "terraform.tfstate"
workspace_key_prefix = "envs"
region = "ap-northeast-1" #東京リージョン
dynamodb_table = "〇〇〇〇" #作成したリモートステート(ロック)用のテーブル名
encrypt = true
}
}
2.VPC を作成する。
次に、AWS 上にプライベートなネットワーク空間を構築します。VPC、サブネット、インターネットゲートウェイなど、すべてのインフラの基礎となるネットワーク関連のリソースを network モジュールとしてまとめていきます。
modules
└── network
├── main.tf
├── outputs.tf
└── variables.tf
##Availability_zoneの取得
data "aws_availability_zones" "available" {
state = "available"
}
#################################
#vpc
resource "aws_vpc" "this" {
cidr_block = var.vpc_cidr_block
enable_dns_hostnames = true
enable_dns_support = true
tags = {
Name = "${var.project}-vpc"
}
}
#################################
#Internet Gateway
resource "aws_internet_gateway" "igw" {
vpc_id = aws_vpc.this.id
tags = {
Name = "${var.project}-igw"
}
}
#################################
#Public Subnets
resource "aws_subnet" "public" {
for_each = { for idx, cidr in var.public_subnet_cidrs : idx => cidr }
vpc_id = aws_vpc.this.id
cidr_block = each.value
availability_zone = data.aws_availability_zones.available.names[each.key]
map_public_ip_on_launch = true
tags = {
Name = "${var.project}-public-${each.value}"
}
}
#################################
#Route Table
resource "aws_route_table" "public" {
vpc_id = aws_vpc.this.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.igw.id
}
}
resource "aws_route_table_association" "public" {
route_table_id = aws_route_table.public.id
for_each = aws_subnet.public
subnet_id = each.value.id
}
################################
#Private Subnets
resource "aws_subnet" "private" {
for_each = { for idx, cidr in var.private_subnet_cidrs : idx => cidr }
vpc_id = aws_vpc.this.id
cidr_block = each.value
availability_zone = data.aws_availability_zones.available.names[each.key]
tags = {
Name = "${var.project}-private-${each.value}"
}
}
variable "vpc_cidr_block" {
type = string
description = "vpcのcidrブロック"
}
variable "public_subnet_cidrs" {
type = list(string)
description = "パブリックサブネットのcidrリスト"
}
variable "private_subnet_cidrs" {
type = list(string)
description = "プライベートサブネットのcidrリスト"
}
variable "project" {
type = string
description = "リソースTagの名前"
}
#VPC_IDの出力
output "vpc_id" {
value = aws_vpc.this.id
}
###############################################
#パブリックサブネットの出力
output "public_subnet_ids" {
value = [for s in aws_subnet.public : s.id]
}
###############################################
#プライベートサブネットの出力
output "private_subnet_ids" {
value = [for s in aws_subnet.private : s.id]
}
outputs.tf は、他のリソースで参照するために必要になります。
例)modules/outputs.tf → ルートの main.tf(呼び出し) → 他の modules で参照
3. 起動テンプレート、オートスケーリンググループを作成する。
ウェブサーバーとなる EC2 インスタンスを定義します。ここでは、どのような EC2 を起動するかの設計図である「起動テンプレート」と、負荷に応じてインスタンス数を自動で増減させる「Auto ScalingGroup」を作成します。また、セキュアなアクセスを実現するための IAM ロールやセキュリティグループもこの compute モジュールで一括して設定します。
modules
└── compute
├── main.tf
├── outputs.tf
├── variables.tf
└── userdata.sh
##############################
#IAMロール
resource "aws_iam_role" "ssm" {
name = "${var.project}-ssm-role"
assume_role_policy = jsonencode({
Version = "2012-10-17",
Statement = [{
Effect = "Allow",
Principal = {
Service = ["ec2.amazonaws.com"]
},
Action = ["sts:AssumeRole"]
}]
})
}
resource "aws_iam_role_policy_attachment" "ssm_core" {
role = aws_iam_role.ssm.name
policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}
resource "aws_iam_instance_profile" "ssm" {
name = "${var.project}-ssm-profile"
role = aws_iam_role.ssm.name
}
##############################
#セキュリティグループ
resource "aws_security_group" "web" {
name = "${var.project}-web"
vpc_id = var.vpc_id
description = "Allow HTTP ALB"
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
security_groups = [var.alb_sg_id] #ALBからのアクセスを許可
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
##################################################
#起動テンプレート
resource "aws_launch_template" "web" {
name_prefix = "${var.project}-lt-"
image_id = var.ami_id
instance_type = var.instance_type
vpc_security_group_ids = [aws_security_group.web.id]
iam_instance_profile {
name = aws_iam_instance_profile.ssm.name
}
monitoring {
enabled = true
}
user_data = base64encode(file("${path.module}/userdata.sh"))
}
##################################################
#AutoScalingGroup
resource "aws_autoscaling_group" "web" {
name = "${var.project}-asg"
desired_capacity = 1
max_size = 3
min_size = 1
launch_template {
id = aws_launch_template.web.id
version = "$Latest"
}
vpc_zone_identifier = var.public_subnet_ids
target_group_arns = [var.alb_target_group_arn]
health_check_type = "ELB"
health_check_grace_period = 300
tag {
key = "Name"
value = "${var.project}-web"
propagate_at_launch = true
}
}
variable "instance_type" {
type = string
description = "ec2インスタンスのタイプ"
}
variable "ami_id" {
type = string
description = "AMIのID"
}
variable "vpc_id" {
type = string
description = "VPCのID"
}
variable "public_subnet_ids" {
type = list(string)
description = "パブリックサブネットのCIDRリスト"
}
variable "project" {
type = string
description = "プロジェクトの名前"
}
variable "alb_target_group_arn" {
type = string
description = "ALBのターゲットグループのARN"
}
variable "alb_sg_id" {
type = string
description = "ALBのセキュリティグループのID"
}
# セキュリティグループの出力
output "web_sg_id" {
value = aws_security_group.web.id
}
modules/compute ディレクトリに、EC2 インスタンス起動時に自動で実行されるスクリプト userdata.sh を作成します。
このスクリプトでは、ALB からのヘルスチェックに応答したり、デプロイが成功したことをブラウザで確認したりするために、Web サーバー(Nginx)をインストールしています。
#!/bin/bash
set -eux
# nginx をインストールして Listen
amazon-linux-extras install -y nginx1
systemctl enable --now nginx
# シンプルなヘルス用ページ
echo ok >/usr/share/nginx/html/health.html
set -eux
エラー発生時に即座にスクリプトを停止させるなど、デバッグに非常に役立ちます。
4. ALB を作成する。
ユーザーからの HTTP リクエストを受け付け、EC2 インスタンスにトラフィックを振り分けるための Application LoadBalancer (ALB) を作成します。この alb モジュールには、ALB 本体に加えて、トラフィックの転送先を定義するターゲットグループや、外部からのアクセスを制御するセキュリティグループの設定が含まれます。
modules
└── alb
├── main.tf
├── outputs.tf
└── variables.tf
######################################################
#アプリケーションロードバランサー
resource "aws_lb" "this" {
name = "${var.project}-alb"
internal = false
load_balancer_type = "application"
subnets = var.public_subnet_ids
security_groups = [aws_security_group.alb.id]
}
######################################################
#ALBのセキュリティグループ
resource "aws_security_group" "alb" {
name = "${var.project}-alb-sg"
vpc_id = var.vpc_id
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
######################################################
#ALBのターゲットグループ
resource "aws_lb_target_group" "web" {
name = "${var.project}-tg"
port = 80
protocol = "HTTP"
vpc_id = var.vpc_id
health_check {
path = var.health_check_path
interval = 30
matcher = "200"
healthy_threshold = 2
unhealthy_threshold = 2
timeout = 5
}
}
resource "aws_lb_listener" "http" {
load_balancer_arn = aws_lb.this.arn
port = 80
protocol = "HTTP"
default_action {
type = "forward"
forward {
target_group {
arn = aws_lb_target_group.web.arn
}
}
}
}
variable "project" {
type = string
description = "プロジェクトの名前"
}
variable "public_subnet_ids" {
type = list(string)
description = "パブリックサブネットのCIDRリスト"
}
variable "vpc_id" {
type = string
description = "VPCのID"
}
variable "health_check_path" {
type = string
default = "/health.html"
}
#ロードバランサーのdnsの出力
output "aws_dns" {
value = aws_lb.this.dns_name
}
#ALBのセキュリティグループの出力
output "alb_sg_id" {
value = aws_security_group.alb.id
}
#ALBのターゲットグループARNの出力
output "alb_target_group_arn" {
value = aws_lb_target_group.web.arn
}
5. RDS を作成する。
アプリケーションのデータを永続的に保存するためのデータベースを構築します。今回は可用性を高めるため、MySQL 互換の RDS を Multi-AZ 構成でプライベートサブネットに配置します。EC2 インスタンスからのみアクセスできるよう、セキュリティグループも厳密に設定します。
modules
└── rds
├── main.tf
└── variables.tf
#DBサブネットグループ
resource "aws_db_subnet_group" "this" {
name = "${var.project}-db-subnets"
subnet_ids = var.private_subnet_ids
}
###########################################
#RDSセキュリティグループ
resource "aws_security_group" "rds" {
name = "${var.project}-rds"
vpc_id = var.vpc_id
ingress {
from_port = 3306
to_port = 3306
protocol = "tcp"
security_groups = [var.web_sg_id]
}
}
###########################################
#RDSインスタンス
resource "aws_db_instance" "primary" {
identifier = "${var.project}-primary"
engine = "mysql"
engine_version = "8.0"
instance_class = "db.t3.micro"
allocated_storage = 20
username = "admin"
password = var.db_password
db_subnet_group_name = aws_db_subnet_group.this.name
vpc_security_group_ids = [aws_security_group.rds.id]
multi_az = true
skip_final_snapshot = true
backup_retention_period = 1
}
variable "project" {
type = string
description = "プロジェクトの名前"
}
variable "private_subnet_ids" {
type = list(string)
description = "プライベートサブネットのCIDRリスト"
}
variable "vpc_id" {
type = string
description = "VPCのID"
}
variable "web_sg_id" {
type = string
description = "セキュリティグループのID"
}
variable "db_password" {
type = string
description = "データベースのパスワード"
}
6. ルートの main.tf で呼び出す
最後に、これまで作成してきた各モジュール(network, compute, alb, rds)を、プロジェクトのルートにある main.tf で組み合わせて、インフラ全体を定義します。これにより、モジュール間のデータの受け渡し(VPCID やサブネット ID など)が行われ、一つのまとまったシステムとして機能します。
provider "aws" {
region = var.region
}
####################################################
#VPC
module "vpc" {
source = "./modules/network"
project = var.project #envsで指定
vpc_cidr_block = var.vpc_cidr
public_subnet_cidrs = ["10.0.1.0/24", "10.0.2.0/24"]
private_subnet_cidrs = ["10.0.10.0/24", "10.0.20.0/24"]
}
######################################################
#ALB
module "alb" {
source = "./modules/alb"
project = var.project
vpc_id = module.vpc.vpc_id
public_subnet_ids = module.vpc.public_subnet_ids
}
####################################################
#起動テンプレートとAuto Scaling Group
module "compute" {
source = "./modules/compute"
project = var.project
ami_id = var.ami_id
instance_type = var.instance_type
vpc_id = module.vpc.vpc_id
public_subnet_ids = module.vpc.public_subnet_ids
alb_sg_id = module.alb.alb_sg_id
alb_target_group_arn = module.alb.alb_target_group_arn
}
####################################################
#RDS
module "rds" {
source = "./modules/rds"
project = var.project
private_subnet_ids = module.vpc.private_subnet_ids
vpc_id = module.vpc.vpc_id
web_sg_id = module.compute.web_sg_id
db_password = var.db_password #envsで指定
}
variable "project" {
type = string
description = "プロジェクトの名前"
}
variable "vpc_cidr" {
type = string
default = "10.0.0.0/16"
}
variable "region" {
type = string
default = "ap-northeast-1"
}
variable "ami_id" {
type = string
default = "ami-01ead1eca9a200e01"
description = "AMIのID"
}
variable "instance_type" {
type = string
default = "t3.micro"
description = "EC2のインスタンスタイプ"
}
variable "db_password" {
type = string
description = "データベースのパスワード"
}
(発展)workspace を切り分け、変数を管理する。
変数として以下のようにすると開発環境、本番環境を分けられます。
envs
├── dev.tfvars #開発環境
└── prod.tfvars #本番環境
project = "〇〇" #プロジェクトの名前など、ルートmain.tfで変数化したが、variabels.tfでデフォルト値を設定していない場合
db_password = "〇〇〇〇〇〇" #データベースのパスワード
今回の例では dev.tfvars
に直接データベースのパスワードを記述しましたが、実際のプロジェクトでは、パスワードなどの機密情
報を Git で管理するのは非常に危険です。
より安全な方法として、AWS Secrets Manager や AWS Systems Manager Parameter Store
を使って機密情報を管理し、Terraform からはデータソースとしてそれを参照する方法があります。
デプロイ!!
最後に以下のコマンドをターミナルで実行し、AWS 環境にデプロイします。
今回は workspace を使い、dev という名前の開発環境用の作業領域を作成・選択してから、dev.tfvars を読み込んで実行します。
これにより将来 prod 環境などの別の環境が増えた場合でも、tfstate ファイルを安全に分離・管理することができます。
terraform init
terraform workspace new dev #①workspaceの作成
terraform workspace select dev #②workspaceの選択
terraform plan -var-file=envs/dev.tfvars #もしworkspace使用しない場合は、上記①,②をスキップ、-var-file以下を削除して実行
terraform apply -var-file=envs/dev.tfvars #もしworkspace使用しない場合は、上記①,②をスキップ、-var-file以下を削除して実行
デプロイしたリソースを削除するには以下のコマンドをターミナルで実行します
terraform destroy -var-file=envs/dev.tfvars #もしworkspace使用しない場合は、-var-file以下を削除して実行
まとめ
本記事では、Terraform を使い、一般的な Web システムのインフラ(VPC, ALB, EC2, RDS)をコードで構築する方法を解説しました。
今回の構築を通して、
- Terraform を用いた実践的なシステム構築の流れ
- modules を利用したコードの再利用性を高める重要性
- 不明点を解決するための公式ドキュメントの活用
など多くのことを学ぶことができました。この記事が、これから Terraform を学ぶ方々の助けになれば幸いです。
GitHubリポジトリはこちら
次に取り組む構成
今後は、サーバーレスアーキテクチャにも挑戦し、API Gateway, Lambda,DynamoDB を用いた API の構築にも取り組んでいきたいと考えています!