0
0

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 でデプロイしてみた(インフラ構築編)

Posted at

はじめに

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

私自身、Vercel を使ったデプロイはありますが、自ら環境構築してというのはありませんでした。

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

作成するアーキテクチャ

architect.png

AWS リソースの各種設計

VPC 基本構成

  • CIDR: 10.0.0.0/16
  • 名前: nextjs-todo-app-vpc
  • リージョン: ap-northeast-1 (東京)
  • アベイラビリティゾーン: ap-northeast-1a と ap-northeast-1c (2 つの AZ)

サブネット構成

パブリックサブネット

  • 10.0.101.0/24 (ap-northeast-1a)
  • 10.0.102.0/24 (ap-northeast-1c)

プライベートサブネット

  • 10.0.1.0/24 (ap-northeast-1a)
  • 10.0.2.0/24 (ap-northeast-1c)

ネットワーク設定

  • NAT ゲートウェイ: 無効 (enable_nat_gateway = false)
  • DNS ホスト名: 有効 (enable_dns_hostnames = true)

なぜ NAT Gateway を無効にしたのか

  • コスト最適化: NAT Gateway(月額約$45)のコスト削減
  • 学習環境: 個人学習では十分なセキュリティレベル
  • 代替案: ECS をパブリックサブネットに配置してインターネット接続を確保

リソース設計

1. コンピューティング (ECS Fargate)

  • ECS クラスター: nextjs-todo-app-ecs-cluster
  • ECS サービス:
    • Fargate タイプ
    • 最小 1 タスク、最大 4 タスク
  • Auto Scaling 設定:
    • CPU 使用率 75%以上で 1 タスク追加
    • CPU 使用率 50%以下で 1 タスク削減
    • クールダウン期間: 60 秒
  • タスク定義:
    • CPU: 256 (0.25 vCPU)
    • メモリ: 512MB
    • ネットワークモード: awsvpc
    • コンテナポート: 3000

2. ロードバランサー (ALB)

  • ALB: パブリックサブネットに配置
  • セキュリティグループ:
    • インバウンド: ポート 80 (HTTP) を全世界から許可
    • アウトバウンド: すべてのトラフィックを許可
  • ターゲットグループ:
    • ポート: 3000
    • プロトコル: HTTP
    • ヘルスチェック: パス /、30 秒間隔
    • ターゲットタイプ: IP (Fargate 用)

3. データベース (RDS PostgreSQL)

  • インスタンスタイプ: db.t3.micro
  • エンジン: PostgreSQL 15
  • ストレージ: 20GB (gp2)
  • 配置: プライベートサブネットのサブネットグループ
  • 可用性: シングル AZ 運用(学習環境のためコスト最適化)
  • セキュリティ:
    • パブリックアクセス: 無効
    • ECS サービスからのみポート 5432 へのアクセスを許可

4. コンテナレジストリ (ECR)

  • リポジトリ名: nextjs-todo-app

5. モニタリング

  • CloudWatch ダッシュボード(後で実装):
    • ALB メトリクス (リクエスト数、エラー、ヘルス)
    • ECS サービス使用率 (CPU、メモリ、タスク数)
    • RDS データベース (CPU、接続数、メモリ、ストレージ)
  • CloudWatch アラーム:
    • ECS サービスの CPU 使用率に基づくスケーリング

6. セキュリティ

  • IAM ロール:
    • ECS タスク実行ロール (ECR からのイメージプル、CloudWatch ログの書き込み権限)
    • Secrets Manager からの読み取り権限
  • セキュリティグループ:
    • ALB: 80 ポートを全世界から許可
    • ECS サービス: ALB からの 3000 ポートのみ許可
    • RDS: ECS サービスからの 5432 ポートのみ許可

7. ステート管理

  • Terraform 状態管理:
    • S3 バケットを使用したリモートステート
    • base レイヤーと app レイヤーに分離

Terraform 設計思想とレイヤー分割

なぜ base/app レイヤーに分割したのか

変更頻度によって base/app に分けており、
コードが多くなる中で誤って基盤となる部分を削除したりすることのないように分けた。

base レイヤー (基盤リソース)

  • VPC、サブネット、セキュリティグループ
  • RDS、ECR

app レイヤー (アプリケーションリソース)

  • ECS、ALB

リモートステート管理

  • S3 バックエンドによる状態共有
  • DynamoDB ロックによる同時実行制御

実装 Terraform の構築

ファイル構成

このプロジェクトに以下のディレクトリを作成します。

Project-name
├── 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

初めにリモートステート用の 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 がターミナル上に出力されると思います。
あとで使用するためどこかにコピーしておいてください。

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 などを作成します。

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 #暗号化するかどうか
  }
}

先に基盤となる 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

まずは 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
}

次に 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 の中身は完成しました!

次に呼び出すための main.tf などのコードを書きます

base/
├── modules/
├── main.tf
├── outputs.tf # 呼び出し時に他リソースに参照として渡す際に必要
└── variables.tf

まずは、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" # 学習用として簡単にしたが、本番で運用する場合は、複雑なパスワードにすることを推奨
}

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 も同様)

次に app レイヤーで ECS,ALB を構築します

まず module ディレクトリを作成し、その中に alb,ecs ディレクトリを作成します。

app/modules/
├── alb
│   ├── main.tf
│   ├── outputs.tf
│   └── variables.tf
└── ecs
    ├── main.tf
    ├── outputs.tf
    └── variables.tf

###  まずは 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
}

次に 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

}

次に呼び出すための main.tf などのコードを書きます

base/
├── modules/
├── main.tf
├── outputs.tf # 呼び出し時に他リソースに参照として渡す際に必要
└── variables.tf

base レイヤーの情報を参照する

app レイヤーの構築には、base レイヤーで作成した VPC の ID やサブネット ID が必要になり、これを安全かつ動的に取得するために Terraform の data "terraform_remote_state" ブロックを使用します。

これは base レイヤーのリモートステートファイル(S3 に保存される terraform.tfstate)を読み込み、その output 値を参照できるようにする機能です。
これにより手動で ID 等をコピーする必要がなくなり、常にインフラの整合性を保つことができます。

まずは、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
}

ecs で以下のようにしてます。

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"
app/variables.tf
variable "project_name" {
  type    = string
  default = "nextjs-todo-app"
}
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

}

app ディレクトリで init→plan→apply をしてデプロイします。

terraform init

terraform plan

terraform apply -auto-approve

これで AWS 上で作成したアプリをデプロイできるような環境が整いました。

今回のアーキテクチャの特徴

  • コスト最適化: NAT Gateway 無効で ECS をパブリックサブネットに配置
  • 高可用性: ECS はマルチ AZ 構成(ap-northeast-1a, 1c)
  • セキュリティ考慮: 適切なセキュリティグループ設定とプライベートサブネットの使用
  • スケーラビリティ: Auto Scaling による負荷に応じた自動スケーリング
  • モジュール化: Terraform コードが base/app レイヤーとモジュールに分割され、再利用性と保守性を向上

終わりに

これで Next.js アプリケーションをデプロイするための、セキュアなインフラ構築が Terraform によって完成しました。

次に実際にこの環境で動作させる Next.js で作成した Todo アプリを紹介します。また、GitHub Actions を使用した CI/CD も構築していきます。

次回も見ていただけたら嬉しいです。

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?