0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Todo アプリ(Next.js)を ECS Fargate でデプロイしてみた(インフラ構築編)

Last updated at Posted at 2025-08-03

前回の続きになります。

はじめに

ローカルで Docker を用いて WEB アプリケーションを作成していて、どうやってデプロイするんだろうと思ったことはないですか?

私自身、Vercel を使ったデプロイ経験はありますが、インフラから環境構築を行うデプロイは未経験でした。

今回 Docker を用いて作成した簡易な Todo アプリを題材に、Terraform を使って AWS 上にインフラ環境をゼロから構築する手順をまとめてみました。

最終的に作成するアーキテクチャ

architect.png

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

作成したファイルに以下のコードを書きます。

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 ともに同様のコード内容となります。

base/backend.tf
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/main.tf
#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"
  }
}
ecr/variables.tf
variable "repository_name" {
  type = string

}

variable "project_name" {
  type = string
}
ecr/output.tf
output "repository_url" {
  value = aws_ecr_repository.app.repository_url
}

2.2 次に rds を作成

rds/main.tf
#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
}

rds/variables.tf
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にすることで安全に管理できます
}
rds/outputs.tf
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で記載した内容を
  ここに記載していきます。
}
main.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

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
}

Terraform 公式レジストリについてはこちら
今回使用している terraform-aws-modules/vpc/aws については、こちら

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/main.tf
#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
  }
}
alb/variables.tf
variable "project_name" {
  type = string
}

variable "vpc_id" {
  type = string
}

variable "public_subnet_ids" {
  type = list(string)
}
alb/outputs.tf
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 で使用するランダム文字列
ecs/main.tf
#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]
}

ecs/variables.tf
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
}
ecs/outputs.tf
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 から

app/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

app/variables.tf
variable "project_name" {
  type    = string
  default = "nextjs-todo-app"
}

5.4 outputs.tf

app/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 自動実行

ここまでご覧いただきありがとうございました。

0
1
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
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?