前回の続きになります。
はじめに
ローカルで Docker を用いて WEB アプリケーションを作成していて、どうやってデプロイするんだろうと思ったことはないですか?
私自身、Vercel を使ったデプロイ経験はありますが、インフラから環境構築を行うデプロイは未経験でした。
今回 Docker を用いて作成した簡易な Todo アプリを題材に、Terraform を使って AWS 上にインフラ環境をゼロから構築する手順をまとめてみました。
最終的に作成するアーキテクチャ
AWS リソースの各種設計
VPC 基本構成
項目 | 設定 |
---|---|
VPC 名称 | nextjs-todo-app-vpc |
CIDR ブロック | 10.0.0.0/16 |
リージョン |
ap-northeast-1 (東京) |
アベイラビリティゾーン |
ap-northeast-1a , ap-northeast-1c (2 つの AZ で冗長性) |
サブネット構成
種類 | CIDR | アベイラビリティゾーン | 備考 |
---|---|---|---|
パブリックサブネット | 10.0.101.0/24 | ap-northeast-1a | ALB 等を配置する想定 |
パブリックサブネット | 10.0.102.0/24 | ap-northeast-1c | 冗長化用 |
プライベートサブネット | 10.0.1.0/24 | ap-northeast-1a | RDS 等(外部に直接出ない) |
プライベートサブネット | 10.0.2.0/24 | ap-northeast-1c | 冗長化用 |
ネットワーク設定
項目 | 設定 |
---|---|
NAT Gateway | 無効(enable_nat_gateway = false ) |
DNS ホスト名 | 有効(enable_dns_hostnames = true ) |
なぜ NAT Gateway を無効にしたのか
- コスト最適化: NAT Gateway(月額約$45)のコスト削減
- 学習環境: 個人学習では十分なセキュリティレベル
- 代替案: ECS をパブリックサブネットに配置してインターネット接続を確保
リソース設計
1. コンピューティング (ECS Fargate)
項目 | 内容 |
---|---|
クラスター名 | nextjs-todo-app-ecs-cluster |
サービスタイプ | Fargate |
タスク数スケーリング | 最小 1、最大 4 |
Auto Scaling ポリシー | CPU 使用率 75%以上でスケールアウト(+1)、50%以下でスケールイン(-1) |
クールダウン | 60 秒 |
タスク定義 - CPU | 256 (0.25 vCPU) |
タスク定義 - メモリ | 512MB |
ネットワークモード | awsvpc |
コンテナポート | 3000 |
2. ロードバランサー (ALB)
項目 | 設定 |
---|---|
配置場所 | パブリックサブネット |
セキュリティグループ(インバウンド) | ポート 80(HTTP)を全世界から許可 |
セキュリティグループ(アウトバウンド) | 全トラフィック許可 |
ターゲットグループ ポート | 3000 |
ターゲットグループ プロトコル | HTTP |
ヘルスチェック | パス / 、間隔 30 秒 |
ターゲットタイプ | IP(Fargate 用) |
3. データベース (RDS PostgreSQL)
項目 | 設定 |
---|---|
エンジン | PostgreSQL 15 |
インスタンスタイプ | db.t3.micro |
ストレージ | 20GB(gp2 ) |
配置 | プライベートサブネットのサブネットグループ |
可用性 | シングル AZ(学習向けコスト最適化) |
パブリックアクセス | 無効 |
接続制限 | ECS サービスからのみポート 5432 を許可 |
4. コンテナレジストリ (ECR)
項目 | 設定 |
---|---|
リポジトリ名 | nextjs-todo-app |
5. モニタリング(予定)
カテゴリ | 監視対象 / 内容 |
---|---|
ALB | リクエスト数、エラー、ターゲットのヘルス |
ECS | CPU 使用率、メモリ使用率、稼働タスク数 |
RDS | CPU、接続数、メモリ、ストレージ使用量 |
アラーム | ECS の CPU 使用率に基づくスケーリング制御(しきい値連動) |
6. セキュリティ
IAM ロール
ロール名 | 目的 |
---|---|
ECS タスク実行ロール | ECR からのイメージプル、CloudWatch Logs 書き込み |
Secrets Manager アクセス権限 (付与先) | シークレットの読み取り(DB パスワード等) |
セキュリティグループ
リソース | インバウンド | アウトバウンド |
---|---|---|
ALB | ポート 80 を全世界から許可 | 全トラフィック許可 |
ECS サービス | ALB からのポート 3000 のみ許可 | 必要に応じて(例: 外部通信あるなら許可) |
RDS | ECS サービスからのポート 5432 のみ許可 | (内部通信のみなので通常制限なし) |
7. ステート管理
要素 | 詳細 |
---|---|
リモートステートバックエンド | S3 バケットを使用(例: nextjs-todo-app-terraform-state 、リージョン ap-northeast-1 ) |
レイヤー分離 |
base レイヤーと app レイヤーを別々に管理し、それぞれ独立した state を持つ |
Terraform 設計思想とレイヤー分割
なぜ base/app レイヤーに分割したのか
変更頻度によって base/app に分けており、コードが多くなる中で誤って基盤となる部分を削除したりすることのないように分けた。
レイヤー | 主なリソース | 役割 |
---|---|---|
base レイヤー(基盤リソース) | VPC、サブネット、セキュリティグループ、RDS(PostgreSQL)、ECR | ネットワーク/セキュリティの土台、データ永続化(DB)、コンテナイメージの格納基盤 |
app レイヤー(アプリケーションリソース) | ECS Fargate、ALB | アプリケーションの実行と外部公開(負荷分散・スケーリング) |
リモートステート管理
- S3 バックエンドによる状態共有
- DynamoDB ロックによる同時実行制御
実装 Terraform の構築
ファイル構成
ファイル構成は、以下のようになります。
Project-Root
├── docker-compose.yml #前回作成
├── frontend/ #前回作成
└── terraform/
terraform を構築していきます。
terraform の中は最終的にこのような形になります。
terraform/
├── app
│ ├── backend.tf
│ ├── bootstrap # リモートステート用
│ │ ├── remote-state-app.tf
│ │ └── terraform.tfstate
│ ├── main.tf
│ ├── modules
│ │ ├── alb
│ │ │ ├── main.tf
│ │ │ ├── outputs.tf
│ │ │ └── variables.tf
│ │ ├── ecs
│ │ │ ├── main.tf
│ │ │ ├── outputs.tf
│ │ │ └── variables.tf
│ │ └── monitoring # Cloudwatch用
│ │ ├── main.tf
│ │ └── variables.tf
│ ├── outputs.tf
│ └── variables.tf
└── base
├── backend.tf
├── bootstrap # リモートステート用
│ ├── remote-state-base.tf
│ └── terraform.tfstate
├── main.tf
├── modules
│ ├── ecr
│ │ ├── main.tf
│ │ ├── outputs.tf
│ │ └── variables.tf
│ └── rds
│ ├── main.tf
│ ├── outputs.tf
│ └── variables.tf
│
└── outputs.tf
1.リモートステートの実装から始めます
1.1 初めにリモートステート用の s3 バケットと DynamoDB テーブルを作成します。
terraform ディレクトリに app と base のディレクトリを作成します
mkdir terraform/app terraform/base
ここからは app,base ともに同じ手順になります。
今回は app ディレクトリで進めます。
app ディレクトリ内に bootstrap ディレクトリを作成します。
mkdir bootstrap
bootstrap ディレクトリの中に.tf ファイルを作成します。
ここのファイル名はどんなものでも大丈夫だと思います。
(例)remote-state-app.tf
touch remote-state-app.tf
作成したファイルに以下のコードを書きます。
#プロバイダ
provider "aws" {
region = "ap-northeast-1"
}
#ランダムIDを生成して、S3バケットとDynamoDBテーブルの名前に使用
resource "random_id" "suffix" {
byte_length = 4
}
#s3バケットの作成
resource "aws_s3_bucket" "terraform_state" {
bucket = "nextjs-todo-app-terraform-state-${random_id.suffix.hex}"
}
#DynamoDBテーブルの作成
resource "aws_dynamodb_table" "terraform_lock" {
name = "nextjs-todo-app-terraform-lock-${random_id.suffix.hex}"
billing_mode = "PAY_PER_REQUEST"
hash_key = "LockID"
attribute {
name = "LockID"
type = "S"
}
}
# s3バケットIDを出力(あとで参照するため)
output "s3_state_bucket_id" {
value = aws_s3_bucket.terraform_state.id
}
# DynamoDBのTableIDを出力(あとで参照するため)
output "dynamodb_lock_table_id" {
value = aws_dynamodb_table.terraform_lock.id
}
app ディレクトリで init→plan→apply をしてデプロイします。
terraform init
terraform plan
terraform apply -auto-approve
これでデプロイができ、先ほど output として定義した S3 バケット ID と DynamoDB テーブルの ID がターミナル上に出力されると思います。
あとで使用するためどこかにコピーしておいてください。
1.2 base/app レイヤの中に tf ファイルを作成します。
以下になるように目指します。
terraform/
├── app
│ ├── backend.tf
│ ├── bootstrap #先ほど作成
│ │ ├── remote-state-app.tf #リモートステート用のS3,DynamoDBテーブル作成ファイル
│ │ └── terraform.tfstate #ローカルのtfstateファイル
│ ├── main.tf #リソースの呼び出し
│ └── modules/ #各リソースの定義
│ ├── outputs.tf
│ └── variables.tf
└── base
├── backend.tf
├── bootstrap #先ほど作成
│ ├── remote-state-base.tf #リモートステート用のS3,DynamoDBテーブル作成ファイル
│ └── terraform.tfstate #ローカルのtfstateファイル
├── main.tf #リソースの呼び出し
├── modules/ #各リソースの定義
└── outputs.tf
上記構成になるように appとbase に main.tf や output.tf などを作成します。
1.3 backend.tf でリモートステートを定義(appとbase 共通)
先ほど作成した S3 と DynamoDB テーブルをここで記載してリモートステート管理にします。
こちらも appとbase ともに同様のコード内容となります。
terraform {
backend "s3" {
bucket = <先ほど作成したS3バケットID>
key = "global/s3/terraform.tfstate" # S3バケットのどこに保存するかを定義
region = "ap-northeast-1" # 今回は東京にしてます(お好みで変更してください)
dynamodb_table = <先ほど作成したDynamoDBテーブルID>
encrypt = true #暗号化するかどうか
}
}
2. 基盤となる base レイヤーで VPC,ECR,RDS を作成します
まず module ディレクトリを作成し、その中に ecr,rds ディレクトリを作成します。
vpc は terraform 公式が出している module を使用するためここでは作成しません。
全て main.tf に記載することも可能ですが、一つにまとめるとコードが多くなり管理が難しくなるためmodule として分けます。
ファイル構成としては以下のような形です。
base/
└── modules
├── ecr
│ ├── main.tf
│ ├── outputs.tf # 呼び出し時に他リソースに参照として渡す際に必要
│ └── variables.tf
└── rds
├── main.tf
├── outputs.tf # 呼び出し時に他リソースに参照として渡す際に必要
└── variables.tf
2.1 まずは ecr から作成
#ECR
resource "aws_ecr_repository" "app" {
name = var.repository_name #variable.tfで定義
image_tag_mutability = "MUTABLE"
image_scanning_configuration {
scan_on_push = true
}
tags = {
Project = var.project_name #variable.tfで定義
ManagedBy = "Terraform"
}
}
variable "repository_name" {
type = string
}
variable "project_name" {
type = string
}
output "repository_url" {
value = aws_ecr_repository.app.repository_url
}
2.2 次に rds を作成
#RDS用のセキュリティグループ
resource "aws_security_group" "rds" {
name = "${var.project_name}-rds-sg"
description = "Security group for RDS"
vpc_id = var.vpc_id
egress {
protocol = "-1"
from_port = 0
to_port = 0
cidr_blocks = ["0.0.0.0/0"]
}
}
#rdsサブネットグループ
resource "aws_db_subnet_group" "default" {
name = "${var.project_name}-rds-subnet-group"
subnet_ids = var.private_subnets
tags = {
Name = "${var.project_name}-sng"
}
}
#rdsインスタンス
resource "aws_db_instance" "default" {
identifier = "${var.project_name}-db"
engine = "postgres"
engine_version = "15"
instance_class = "db.t3.micro"
allocated_storage = 20
storage_type = "gp2"
db_name = var.db_name
username = var.db_username
password = var.db_password
db_subnet_group_name = aws_db_subnet_group.default.name
vpc_security_group_ids = [aws_security_group.rds.id]
skip_final_snapshot = true
publicly_accessible = false
}
variable "project_name" {
type = string
}
variable "vpc_id" {
type = string
}
variable "private_subnets" {
type = list(string)
}
variable "db_name" {
type = string
}
variable "db_username" {
type = string
}
variable "db_password" {
type = string
sensitive = true #trueにすることで安全に管理できます
}
output "rds_security_group_id" {
value = aws_security_group.rds.id
}
output "rds_endpoint" {
value = aws_db_instance.default.endpoint
}
output "rds_instance_id" {
value = aws_db_instance.default.identifier
}
output "db_name" {
value = aws_db_instance.default.db_name
}
output "db_username" {
value = aws_db_instance.default.username
}
output "db_password" {
value = aws_db_instance.default.password
sensitive = true #trueにすることで安全に管理できます
}
これで module の中身は完成しました!
3. 呼び出すための main.tf などのコードを書きます
base/
├── modules/
├── main.tf
├── outputs.tf # 呼び出し時に他リソースに参照として渡す際に必要
└── variables.tf
3.1 main.tf から
module "..." {
resource <定義したファイルの相対パス>
その他はそれぞれのリソースの
variables.tfで記載した内容を
ここに記載していきます。
}
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
required_version = ">= 1.0"
}
#プロバイダ
provider "aws" {
region = "ap-northeast-1"
}
#ECR
module "ecr" {
source = "./modules/ecr"
repository_name = "nextjs-todo-app"
project_name = "nextjs-todo-app-test"
}
#VPC
module "vpc" {
source = "terraform-aws-modules/vpc/aws" #Terraformの公式レジストリで公開されているモジュール
version = "5.9.0"
name = "nextjs-todo-app-vpc"
cidr = "10.0.0.0/16"
azs = ["ap-northeast-1a", "ap-northeast-1c"]
private_subnets = ["10.0.1.0/24", "10.0.2.0/24"]
public_subnets = ["10.0.101.0/24", "10.0.102.0/24"]
# NAT Gatewayはコスト削減のため無効化。
# ECSタスクはパブリックサブネットに配置し、インターネットアクセスを確保する。
enable_nat_gateway = false
single_nat_gateway = false
enable_dns_hostnames = true
tags = {
Project = "ECS-Deployment-Todo"
ManagedBy = "Terraform"
}
}
module "rds" {
source = "./modules/rds"
project_name = "nextjs-todo-app"
vpc_id = module.vpc.vpc_id
private_subnets = module.vpc.private_subnets
db_name = "app_db"
db_username = "testuser"
db_password = "password" # 学習用として簡単にしたが、本番で運用する場合は、複雑なパスワードにすることを推奨
}
3.2 outputs.tf
output "ecr_repository_url" {
value = module.ecr.repository_url
}
output "vpc_id" {
value = module.vpc.vpc_id
}
output "public_subnets" {
value = module.vpc.public_subnets
}
output "private_subnets" {
value = module.vpc.private_subnets
}
output "rds_security_group_id" {
value = module.rds.rds_security_group_id
}
output "rds_endpoint" {
value = module.rds.rds_endpoint
}
output "rds_instance_id" {
value = module.rds.rds_instance_id
}
output "db_name" {
value = module.rds.db_name
}
output "db_username" {
value = module.rds.db_username
}
output "db_password" {
value = module.rds.db_password
sensitive = true
}
base ディレクトリで init→plan→apply をしてデプロイします。
terraform init
terraform plan
terraform apply -auto-approve
terraform apply だと記載されている全てのリソースがデプロイされますが
terraform apply -target=module.<作成したいリソース>
とすることで対象を選択することができます。(destroy も同様)
4. 次に app レイヤーで ECS,ALB を構築します
まず module ディレクトリを作成し、その中に alb,ecs ディレクトリを作成します。
app/modules/
├── alb
│ ├── main.tf
│ ├── outputs.tf
│ └── variables.tf
└── ecs
├── main.tf
├── outputs.tf
└── variables.tf
4.1 まずは ALB から
#alb用のセキュリティグループ
resource "aws_security_group" "alb" {
name = "${var.project_name}-alb-sg"
description = "Security Group for ALB"
vpc_id = var.vpc_id
ingress {
protocol = "tcp"
from_port = 80
to_port = 80
cidr_blocks = ["0.0.0.0/0"]
}
egress {
protocol = "-1"
from_port = 0
to_port = 0
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "${var.project_name}-alb-sg"
}
}
#alb本体
resource "aws_lb" "main" {
name = "${var.project_name}-alb"
internal = false
load_balancer_type = "application"
security_groups = [aws_security_group.alb.id]
subnets = var.public_subnet_ids
tags = {
Name = "${var.project_name}-alb"
}
}
#ターゲットグループ
resource "aws_lb_target_group" "app" {
name = "${var.project_name}-tg"
port = 3000
protocol = "HTTP"
vpc_id = var.vpc_id
target_type = "ip" # Fargateタスクをターゲットにするため、ターゲットタイプは ip を指定
health_check {
# コンテナのルートパス('/')にアクセスし、ステータスコード200が返れば正常と判断
path = "/"
protocol = "HTTP"
matcher = "200"
interval = 30
timeout = 5
healthy_threshold = 2
unhealthy_threshold = 2
}
}
# ポート80(HTTP)で受け付けたリクエストを、ターゲットグループに転送するリスナー
resource "aws_lb_listener" "http" {
load_balancer_arn = aws_lb.main.arn
port = "80"
protocol = "HTTP"
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.app.arn
}
}
variable "project_name" {
type = string
}
variable "vpc_id" {
type = string
}
variable "public_subnet_ids" {
type = list(string)
}
output "target_group_arn" {
value = aws_lb_target_group.app.arn
}
output "dns_name" {
value = aws_lb.main.dns_name
}
output "security_group_id" {
value = aws_security_group.alb.id
}
output "load_balancer_arn_suffix" {
value = aws_lb.main.arn_suffix
}
output "alb_target_group_arn_suffix" {
value = aws_lb_target_group.app.arn_suffix
}
4.2 次に ECS
今回 Next.js で Nextauth を使用して認証機能を作成します。
NEXTAUTH_SECRET の値を Secrets Manager に保存し、使用できるようにするために
事前に AWS コンソールで以下を作成
- シークレット名:
nextjs-todo-app/nextauth-secret
- 値: NextAuth.js で使用するランダム文字列
#secretsのvalueFromでarnを構築するために必要
data "aws_caller_identity" "current" {
}
#ECSクラスター
resource "aws_ecs_cluster" "main" {
name = "${var.project_name}-cluster"
tags = {
Name = "${var.project_name}-cluster"
Project = var.project_name
ManagedBy = "Terraform"
}
}
#ECSタスク実行用のIAMロール
resource "aws_iam_role" "ecs_task_exection_role" {
name = "${var.project_name}-ecs_task_execution_role"
# このロールをECSタスクが引き受ける(Assume)ことを許可するポリシー
assume_role_policy = jsonencode({
Version = "2012-10-17",
Statement = [
{
Action = "sts:AssumeRole",
Effect = "Allow",
Principal = {
Service = ["ecs-tasks.amazonaws.com"]
}
}
]
})
tags = {
Project = var.project_name
ManagedBy = "Terraform"
}
}
# IAMロールにAWS管理ポリシーをアタッチ
# 一般的なECSタスク実行に必要な権限がまとまっているAWS管理のポリシー
resource "aws_iam_role_policy_attachment" "ecs_task_execution_role_policy" {
role = aws_iam_role.ecs_task_exection_role.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}
#ECSタスク実行ロールにSecrets Managerの読み取り権限を付与
resource "aws_iam_role_policy_attachment" "ecs_task_execution_secrets_policy" {
role = aws_iam_role.ecs_task_exection_role.name
policy_arn = "arn:aws:iam::aws:policy/SecretsManagerReadWrite"
}
#CloudWatch Logs グループ
resource "aws_cloudwatch_log_group" "app" {
name = "/ecs/${var.project_name}"
tags = {
Project = var.project_name
}
}
#ECSサービス用のセキュリティグループ
resource "aws_security_group" "ecs_service" {
name = "${var.project_name}-ecs-service-sg"
vpc_id = var.vpc_id
ingress {
protocol = "tcp"
from_port = 3000
to_port = 3000
security_groups = [var.alb_security_group_id]
}
egress {
protocol = "-1"
from_port = 0
to_port = 0
cidr_blocks = ["0.0.0.0/0"]
}
}
resource "aws_security_group_rule" "ecs_to_rds" {
type = "ingress"
from_port = 5432
to_port = 5432
protocol = "tcp"
source_security_group_id = aws_security_group.ecs_service.id
security_group_id = var.rds_security_group_id
}
# ECSタスク定義
resource "aws_ecs_task_definition" "app" {
family = "${var.project_name}-task"
network_mode = "awsvpc"
requires_compatibilities = ["FARGATE"]
cpu = "256"
memory = "512"
execution_role_arn = aws_iam_role.ecs_task_exection_role.arn
container_definitions = jsonencode([{
name = "${var.project_name}-container",
image = "${var.ecr_repository_url}:latest",
cpu = 256,
memory = 512,
essential = true,
portMappings = [{
containerPort = 3000,
hostPort = 3000
}],
environment = [
{ name = "DATABASE_URL", value = var.database_url },
{ name = "NEXTAUTH_URL", value = "http://${var.alb_dns_name}" }
],
# タスク実行ロールの権限を使い、Secrets Managerからシークレットを取得して環境変数として渡す(事前にSecrets Managerに登録必須)
secrets = [
{
name = "NEXTAUTH_SECRET",
valueFrom = "arn:aws:secretsmanager:${var.aws_region}:${data.aws_caller_identity.current.account_id}:secret:nextjs-todo-app/nextauth-secret-xxxxxxxxxxxx" #xxxxxxについては、Secrets Manager登録時に自動で追加されるのでマネジメントコンソールで確認する
}
]
logConfiguration = {
logDriver = "awslogs",
options = {
"awslogs-group" = aws_cloudwatch_log_group.app.name,
"awslogs-region" = var.aws_region,
"awslogs-stream-prefix" = "ecs"
}
}
}])
}
resource "aws_ecs_service" "main" {
name = "${var.project_name}-service"
cluster = aws_ecs_cluster.main.id
task_definition = aws_ecs_task_definition.app.arn
desired_count = 1
launch_type = "FARGATE"
network_configuration {
subnets = var.public_subnets # NAT Gatewayを無効にしたため、タスクがECRからイメージをプルできるよう、パブリックサブネットに配置
security_groups = [aws_security_group.ecs_service.id]
assign_public_ip = true #パブリックIPアドレスを割り当てることで、インターネットゲートウェイ経由での通信が可能となる
}
load_balancer {
target_group_arn = var.alb_target_group_arn
container_name = "${var.project_name}-container"
container_port = 3000
}
}
#Auto Scalingターゲット
resource "aws_appautoscaling_target" "ecs_service" {
max_capacity = 4
min_capacity = 1
resource_id = "service/${aws_ecs_cluster.main.name}/${aws_ecs_service.main.name}"
scalable_dimension = "ecs:service:DesiredCount" #スケール対象はECSサービスの希望タスク数
service_namespace = "ecs"
}
#Auto Scalingポリシー(Scale up)
resource "aws_appautoscaling_policy" "scale_up" {
name = "${var.project_name}-scale_up"
policy_type = "StepScaling" #スケーリングの種類
resource_id = aws_appautoscaling_target.ecs_service.resource_id
scalable_dimension = aws_appautoscaling_target.ecs_service.scalable_dimension
service_namespace = aws_appautoscaling_target.ecs_service.service_namespace
step_scaling_policy_configuration {
adjustment_type = "ChangeInCapacity"
cooldown = 60 #スケールアウト後、次のアクションまで60秒待つ
metric_aggregation_type = "Average"
step_adjustment {
metric_interval_lower_bound = 0
scaling_adjustment = 1
}
}
}
#Auto Scalingポリシー(Scale down)
resource "aws_appautoscaling_policy" "scale_down" {
name = "${var.project_name}-scale_down"
policy_type = "StepScaling" #スケーリングの種類
resource_id = aws_appautoscaling_target.ecs_service.resource_id
scalable_dimension = aws_appautoscaling_target.ecs_service.scalable_dimension
service_namespace = aws_appautoscaling_target.ecs_service.service_namespace
step_scaling_policy_configuration {
adjustment_type = "ChangeInCapacity"
cooldown = 60
metric_aggregation_type = "Average"
step_adjustment {
metric_interval_upper_bound = 0
scaling_adjustment = -1
}
}
}
#CloudWatchAlarms(スケールアウト用)
resource "aws_cloudwatch_metric_alarm" "scale_up" {
alarm_name = "${var.project_name}-scale_up"
comparison_operator = "GreaterThanOrEqualToThreshold"
evaluation_periods = 2
metric_name = "CPUUtilization"
namespace = "AWS/ECS"
period = 60
statistic = "Average"
threshold = 75
dimensions = {
ClusterName = aws_ecs_cluster.main.name
ServiceName = aws_ecs_service.main.name
}
alarm_actions = [aws_appautoscaling_policy.scale_up.arn]
}
#CloudWatchAlarms(スケールイン用)
resource "aws_cloudwatch_metric_alarm" "scale_down" {
alarm_name = "${var.project_name}-scale_down"
comparison_operator = "LessThanOrEqualToThreshold"
evaluation_periods = 2
metric_name = "CPUUtilization"
namespace = "AWS/ECS"
period = 60
statistic = "Average"
threshold = 50
dimensions = {
ClusterName = aws_ecs_cluster.main.name
ServiceName = aws_ecs_service.main.name
}
alarm_actions = [aws_appautoscaling_policy.scale_down.arn]
}
variable "project_name" {
type = string
}
variable "ecr_repository_url" {
type = string
}
variable "database_url" {
type = string
sensitive = true
}
variable "alb_dns_name" {
type = string
}
variable "aws_region" {
type = string
}
variable "public_subnets" {
type = list(string)
}
variable "alb_target_group_arn" {
type = string
}
variable "vpc_id" {
type = string
}
variable "alb_security_group_id" {
type = string
}
variable "rds_security_group_id" {
type = string
}
output "ecs_service_security_group_id" {
value = aws_security_group.ecs_service.id
}
output "cluster_name" {
value = aws_ecs_cluster.main.name
}
output "service_name" {
value = aws_ecs_service.main.name
}
5.呼び出すための main.tf などのコードを書きます
base/
├── modules/
├── main.tf
├── outputs.tf # 呼び出し時に他リソースに参照として渡す際に必要
└── variables.tf
5.1 base レイヤーの情報を参照する
app レイヤーの構築には、base レイヤーで作成した VPC の ID やサブネット ID が必要になり、これを安全かつ動的に取得するために Terraform の data "terraform_remote_state" ブロックを使用します。
これは base レイヤーのリモートステートファイル(S3 に保存される terraform.tfstate)を読み込み、その output 値を参照できるようにする機能です。これにより手動で ID 等をコピーする必要がなくなり、常にインフラの整合性を保つことができます。
5.2 まずは、main.tf から
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
required_version = ">= 1.0"
}
#プロバイダ
provider "aws" {
region = "ap-northeast-1"
}
#baseで作成したリソースを参照するための設定
data "terraform_remote_state" "base" {
backend = "s3"
config = {
bucket = "<base/bootstrapで作成したS3バケットID>"
key = "global/s3/terraform.tfstate"
region = "ap-northeast-1"
}
}
# ECS
module "ecs" {
source = "./modules/ecs"
project_name = "${var.project_name}-ecs"
aws_region = "ap-northeast-1"
vpc_id = data.terraform_remote_state.base.outputs.vpc_id
public_subnets = data.terraform_remote_state.base.outputs.public_subnets
ecr_repository_url = data.terraform_remote_state.base.outputs.ecr_repository_url
alb_target_group_arn = module.alb.target_group_arn
alb_dns_name = module.alb.dns_name
alb_security_group_id = module.alb.security_group_id
database_url = "postgresql://${data.terraform_remote_state.base.outputs.db_username}:${data.terraform_remote_state.base.outputs.db_password}@${data.terraform_remote_state.base.outputs.rds_endpoint}/${data.terraform_remote_state.base.outputs.db_name}?schema=public"
rds_security_group_id = data.terraform_remote_state.base.outputs.rds_security_group_id
}
# ALB
module "alb" {
source = "./modules/alb"
project_name = "${var.project_name}-alb"
vpc_id = data.terraform_remote_state.base.outputs.vpc_id
public_subnet_ids = data.terraform_remote_state.base.outputs.public_subnets
}
database_urlは以下のように設定します。
database_url = "postgresql://<ユーザー名 username>:<パスワード password>@<接続するエンドポイント endpoint>/<データベース名 db_name>?schema=public"
base レイヤーで rds を作成していますが、以下のようにすることで違う Terraform 環境で作成されたリソース出力値へ参照できるようになります。
database_url = "postgresql://${data.terraform_remote_state.base.outputs.db_username}:${data.terraform_remote_state.base.outputs.db_password}@${data.terraform_remote_state.base.outputs.rds_endpoint}/${data.terraform_remote_state.base.outputs.db_name}?schema=public"
5.3 variable.tf
variable "project_name" {
type = string
default = "nextjs-todo-app"
}
5.4 outputs.tf
output "ecs_service_security_group_id" {
value = module.ecs.ecs_service_security_group_id
}
output "alb_dns_name" {
value = module.alb.dns_name
}
5.5 デプロイ
app ディレクトリで init→plan→apply をしてデプロイします。
terraform init
terraform plan
terraform apply -auto-approve
これで AWS 上で作成したアプリをデプロイできるような環境が整いました。
まとめと次回予告
本記事では、Next.js Todo アプリを AWS ECS Fargate にデプロイするためのインフラを、Terraform を使って構築しました。
設計のポイント
- コスト最適化: NAT Gateway 無効で ECS をパブリックサブネットに配置
- 高可用性: ECS はマルチ AZ 構成(ap-northeast-1a, 1c)
- セキュリティ考慮: 適切なセキュリティグループ設定とプライベートサブネットの使用
- スケーラビリティ: Auto Scaling による負荷に応じた自動スケーリング
- モジュール化: Terraform コードが base/app レイヤーとモジュールに分割され、再利用性と保守性を向上
実用的な学び
- Terraform のモジュール化とレイヤー分割の実践
- AWS リソース間の依存関係の理解
- セキュリティグループによるネットワーク制御
- リモートステート管理の重要性
技術的な成果
- IaC(Infrastructure as Code)の実践: 手動構築ではなく、コードによる再現可能なインフラ
- AWS Well-Architected Framework: セキュリティ、コスト最適化、運用性を考慮した設計
- 実運用を意識した構成: 監視、スケーリング、セキュリティを組み込んだ本格的なアーキテクチャ
次回予告
次回は、このインフラ上にアプリケーションを自動デプロイする CI/CD パイプラインを構築します。
次回で扱う内容(予定)
- GitHub Actions による CI/CD パイプライン構築
- ECR への Docker イメージ自動プッシュ
- ECS サービスの自動更新
- Pull Request 時の terraform plan 自動実行
ここまでご覧いただきありがとうございました。