はじめに
ローカルで Docker を用いて WEB アプリを作成していて、どうやってデプロイするんだろうと思ったことはないですか?
私自身、Vercel を使ったデプロイはありますが、自ら環境構築してというのはありませんでした。
今回 Docker を用いて作成した簡易な ToDo アプリを題材に、Terraform を使って AWS 上にインフラ環境をゼロから構築する手順をまとめてみました。
作成するアーキテクチャ
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
作成したファイルに以下のコードを書きます。
#プロバイダ
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 ともに同様のコード内容となります。
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
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
}
次に 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 の中身は完成しました!
次に呼び出すための main.tf などのコードを書きます
base/
├── modules/
├── main.tf
├── outputs.tf # 呼び出し時に他リソースに参照として渡す際に必要
└── variables.tf
まずは、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" # 学習用として簡単にしたが、本番で運用する場合は、複雑なパスワードにすることを推奨
}
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 も同様)
次に app レイヤーで ECS,ALB を構築します
まず module ディレクトリを作成し、その中に alb,ecs ディレクトリを作成します。
app/modules/
├── alb
│ ├── main.tf
│ ├── outputs.tf
│ └── variables.tf
└── ecs
├── main.tf
├── outputs.tf
└── variables.tf
### まずは 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
}
次に 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
}
次に呼び出すための 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 から
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"
variable "project_name" {
type = string
default = "nextjs-todo-app"
}
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 も構築していきます。
次回も見ていただけたら嬉しいです。