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?

【第2回】IaC で作る個人開発 AWS 環境 — Terraform で VPC から ECS まで構築する

0
Last updated at Posted at 2026-06-13

シリーズ目次

タイトル リンク
第1回 IaC / Terraform 入門 — init/plan/apply から S3 backend まで https://qiita.com/tanaka_taro_JP_KYUSYU/items/d9150a89f6b3b8fb12bc)
第2回 AWS 構築編 — VPC から ECS まで Terraform で組む(本記事)
第3回 Cloudflare + CI/CD 編 — フロントエンド・証明書・自動デプロイ https://qiita.com/tanaka_taro_JP_KYUSYU/items/025d69a882d86bc19ceb)
第4回 運用・保守・スケール編 — 監視・コスト・障害対応の実践 https://qiita.com/tanaka_taro_JP_KYUSYU/items/c5574b5ee3f6d2676ce0)

はじめに

前回は Terraform の基本操作(init / plan / apply / destroy)と変数・S3 バックエンドを学びました。今回はいよいよ 本番 AWS 環境を Terraform で組み上げます

作るのは、Spring Boot バックエンドを ECS Fargate で動かし、RDS MySQL・ElastiCache Valkey をデータ層に持つ構成です。フロントエンドの Cloudflare 接続は第3回で行います。

本記事で作る全体図

                    ┌──────────────────────────────────────────────┐
                    │  AWS VPC (ap-northeast-1)                    │
                    │                                              │
  インターネット     │  ┌──────────────────────────────────┐        │
      │             │  │ パブリックサブネット (AZ-a / AZ-c) │        │
      ▼             │  │                                  │        │
 ┌──────────┐       │  │   ┌──────────────────────────┐   │        │
 │   ALB    │◀─────┼──┼───┤  ECS Fargate タスク       │   │        │
 │ (HTTPS)  │       │  │   │  Spring Boot :8080       │   │        │
 └──────────┘       │  │   └─────────────┬────────────┘   │        │
                    │  └─────────────────┼────────────────┘        │
                    │                    │                         │
                    │  ┌─────────────────▼─────────────────┐       │
                    │  │ プライベートサブネット (AZ-a / AZ-c)│       │
                    │  │                                   │       │
                    │  │  ┌──────────────┐ ┌───────────┐   │       │
                    │  │  │ RDS MySQL 8.0│ │ElastiCache│   │       │
                    │  │  │  db.t4g.micro│ │  Valkey   │   │       │
                    │  │  └──────────────┘ └───────────┘   │       │
                    │  └───────────────────────────────────┘       │
                    │                                              │
                    │  その他: ECR / ACM / SES / Secrets Manager    │
                    └──────────────────────────────────────────────┘

 ※ 第3回で Cloudflare → ALB へのプロキシを接続します

進め方のポリシー — 小さく apply して小さく確認

「全部書いてから一気に apply」は何かが壊れたときの切り分けが地獄になります。本記事はこの順番で apply を刻みます。

  1. 予算アラート(最初の保険)
  2. network モジュール(VPC・SG)
  3. data モジュール(RDS・ElastiCache)
  4. app モジュール(ECR・ALB・ACM・ECS)
  5. 初回コンテナデプロイ

各ステップで plan の差分を見て意図通りか確認してから apply します。

下ごしらえ — ディレクトリ構成と backend 設定

ディレクトリ構成

infra/
├── envs/
│   └── prod/
│       ├── main.tf          # モジュール呼び出し・繋ぎ込み
│       ├── variables.tf
│       ├── outputs.tf
│       ├── terraform.tfvars  # 実際の値(git 管理外)
│       └── backend.tf
└── modules/
    ├── network/
    │   ├── main.tf
    │   ├── variables.tf
    │   └── outputs.tf
    ├── data/
    │   ├── main.tf
    │   ├── variables.tf
    │   └── outputs.tf
    └── app/
        ├── main.tf
        ├── variables.tf
        └── outputs.tf

なぜ2層構成にするか: envs/prod がすべての設定値を持ち、modules/ が再利用可能なリソース群を担当します。将来ステージング環境を作る場合は envs/staging/ を追加するだけで済みます。

S3 backend の設定

第1回で作成した S3 バケット・DynamoDB テーブルを参照します。

# infra/envs/prod/backend.tf
terraform {
  backend "s3" {
    bucket         = "your-tfstate-bucket"   # 第1回で作成したバケット名
    key            = "prod/terraform.tfstate"
    region         = "ap-northeast-1"
    dynamodb_table = "terraform-locks"
    encrypt        = true
  }
}

terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

provider "aws" {
  region = var.aws_region

  default_tags {
    tags = {
      Project     = "myapp"
      Environment = "prod"
      ManagedBy   = "terraform"
    }
  }
}

default_tags を provider に設定しておくと、全リソースに自動でタグが付きます。コスト配分タグやリソース検索で後々助かります。

最初の apply — 予算アラート

AWS を使い始めて最大の事故は「リソースを消し忘れて課金が膨らむ」ことです。予算アラートを最初にコード化して保険をかけます。

# infra/envs/prod/main.tf(最初はここだけ)

# 月 $80 を超えたらメールで通知する予算アラート
resource "aws_budgets_budget" "monthly" {
  name         = "myapp-prod-monthly"
  budget_type  = "COST"
  limit_amount = "80"
  limit_unit   = "USD"
  time_unit    = "MONTHLY"

  notification {
    comparison_operator        = "GREATER_THAN"
    threshold                  = 80
    threshold_type             = "PERCENTAGE"
    notification_type          = "ACTUAL"
    subscriber_email_addresses = [var.alert_email]
  }

  notification {
    comparison_operator        = "GREATER_THAN"
    threshold                  = 100
    threshold_type             = "PERCENTAGE"
    notification_type          = "FORECASTED"   # 予測ベースで先に警告
    subscriber_email_addresses = [var.alert_email]
  }
}
# infra/envs/prod/variables.tf
variable "aws_region" {
  default = "ap-northeast-1"
}

variable "alert_email" {
  description = "予算アラートの送信先メールアドレス"
}
# 確認してから apply
terraform -chdir=infra/envs/prod plan
terraform -chdir=infra/envs/prod apply

月額目安: AWS Budgets は 2 予算まで無料。apply 後は AWS コンソールの Billing → Budgets で確認できます。

network モジュール — VPC・サブネット・セキュリティグループ

NAT Gateway を使わない理由(重要な設計判断)

教科書的な AWS 構成では ECS タスクをプライベートサブネットに置き、NAT Gateway 経由でインターネットに出ます。しかし NAT Gateway は月 $32〜40 の固定費がかかります(転送量別途)。

個人開発の初期コスト構成では無視できない金額なので、本記事では ECS タスクをパブリックサブネットに置き、セキュリティグループで通信を絞る構成を採用します。

構成 月額 セキュリティ 採用タイミング
NAT Gateway あり +$40 ECS タスクの IP が外部から直接到達不可 規制対応・厳密な境界が必要なとき
パブリックサブネット + SG 絞り(本記事) +$0 SG で ALB からのトラフィックのみ許可 個人開発・MVP フェーズ

ECS タスクにパブリック IP が割り当たりますが、セキュリティグループで「8080 番を ALB からのみ」に絞れば外部から直接叩くことはできません。これは多くの個人開発プロジェクトで採用されているコスト最適な選択です。

将来トラフィックが増え NAT Gateway の費用対効果が見合うようになったら、プライベートサブネットへ移行するのが現実的な順序です。

コード

# infra/modules/network/main.tf

resource "aws_vpc" "main" {
  cidr_block           = var.vpc_cidr
  enable_dns_hostnames = true
  enable_dns_support   = true
}

# --- パブリックサブネット(ALB + ECS タスク) ---

resource "aws_subnet" "public" {
  count                   = 2
  vpc_id                  = aws_vpc.main.id
  cidr_block              = var.public_subnet_cidrs[count.index]
  availability_zone       = var.availability_zones[count.index]
  map_public_ip_on_launch = true   # ECS タスクにパブリック IP を付与
}

# --- プライベートサブネット(RDS・ElastiCache) ---

resource "aws_subnet" "private" {
  count             = 2
  vpc_id            = aws_vpc.main.id
  cidr_block        = var.private_subnet_cidrs[count.index]
  availability_zone = var.availability_zones[count.index]
}

# --- IGW とルートテーブル(パブリックサブネット用) ---

resource "aws_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id
}

resource "aws_route_table" "public" {
  vpc_id = aws_vpc.main.id

  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_internet_gateway.main.id
  }
}

resource "aws_route_table_association" "public" {
  count          = 2
  subnet_id      = aws_subnet.public[count.index].id
  route_table_id = aws_route_table.public.id
}

# --- セキュリティグループ ---

# ALB: インターネットから 443 を受け付ける
resource "aws_security_group" "alb" {
  name   = "${var.prefix}-alb"
  vpc_id = aws_vpc.main.id

  ingress {
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  # HTTP → HTTPS リダイレクト用(任意)
  ingress {
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

# ECS: ALB からの 8080 のみ受け付ける
resource "aws_security_group" "ecs" {
  name   = "${var.prefix}-ecs"
  vpc_id = aws_vpc.main.id

  ingress {
    from_port       = 8080
    to_port         = 8080
    protocol        = "tcp"
    security_groups = [aws_security_group.alb.id]   # ALB SG からのみ
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]   # ECR pull・SES 送信などのアウトバウンド
  }
}

# RDS: ECS からの 3306 のみ受け付ける
resource "aws_security_group" "rds" {
  name   = "${var.prefix}-rds"
  vpc_id = aws_vpc.main.id

  ingress {
    from_port       = 3306
    to_port         = 3306
    protocol        = "tcp"
    security_groups = [aws_security_group.ecs.id]
  }
}

# ElastiCache (Valkey): ECS からの 6379 のみ受け付ける
resource "aws_security_group" "cache" {
  name   = "${var.prefix}-cache"
  vpc_id = aws_vpc.main.id

  ingress {
    from_port       = 6379
    to_port         = 6379
    protocol        = "tcp"
    security_groups = [aws_security_group.ecs.id]
  }
}
# infra/modules/network/variables.tf
variable "prefix"               { }
variable "vpc_cidr"             { default = "10.0.0.0/16" }
variable "availability_zones"   { type = list(string) }
variable "public_subnet_cidrs"  { type = list(string) }
variable "private_subnet_cidrs" { type = list(string) }
# infra/modules/network/outputs.tf
output "vpc_id"            { value = aws_vpc.main.id }
output "public_subnet_ids" { value = aws_subnet.public[*].id }
output "private_subnet_ids"{ value = aws_subnet.private[*].id }
output "sg_alb_id"         { value = aws_security_group.alb.id }
output "sg_ecs_id"         { value = aws_security_group.ecs.id }
output "sg_rds_id"         { value = aws_security_group.rds.id }
output "sg_cache_id"       { value = aws_security_group.cache.id }
# infra/envs/prod/main.tf に追加
module "network" {
  source = "../../modules/network"

  prefix             = "myapp-prod"
  availability_zones = ["ap-northeast-1a", "ap-northeast-1c"]
  public_subnet_cidrs  = ["10.0.1.0/24", "10.0.2.0/24"]
  private_subnet_cidrs = ["10.0.11.0/24", "10.0.12.0/24"]
}

月額目安: VPC・サブネット・IGW・ルートテーブル・SG は無料。ALB は別途 app モジュールで作成(約 $20/月)。destroy 時は他のリソースを先に削除してからでないと依存エラーが出ます。

data モジュール — RDS・ElastiCache・Secrets Manager

シークレット管理の考え方(state に秘密を残さない)

Terraform の terraform.tfstate はリソースの状態をそのまま JSON で保持します。パスワードを aws_db_instancepassword に直接渡すと tfstate にパスワードが平文で記録されます(S3 バックエンドで暗号化していても、terraform show で見えてしまう)。

本記事では「Terraform で Secrets Manager に箱(シークレットリソース)だけ作り、値は手動で投入する」方式を採ります。

Terraform が作るもの           手動で行うこと
─────────────────────────────────────────────
aws_secretsmanager_secret      aws secretsmanager put-secret-value
(箱だけ。値は空)                (パスワード等の実値を流し込む)

こうすることで tfstate にシークレットの値が入りません。

コード

# infra/modules/data/main.tf

# --- Secrets Manager(箱だけ Terraform で作る) ---

resource "aws_secretsmanager_secret" "db_password" {
  name                    = "${var.prefix}/db-password"
  recovery_window_in_days = 7   # 削除後 7 日は復元可能
  description             = "RDS MySQL のマスターパスワード"
}

resource "aws_secretsmanager_secret" "app_secrets" {
  name                    = "${var.prefix}/app-secrets"
  recovery_window_in_days = 7
  description             = "アプリ用シークレット群(JWT 秘密鍵・決済キーなど)"
}

# --- RDS サブネットグループ ---

resource "aws_db_subnet_group" "main" {
  name       = "${var.prefix}-db"
  subnet_ids = var.private_subnet_ids
}

# --- RDS MySQL 8.0 ---

resource "aws_db_instance" "mysql" {
  identifier        = "${var.prefix}-mysql"
  engine            = "mysql"
  engine_version    = "8.0"
  instance_class    = "db.t4g.micro"
  allocated_storage = 20
  storage_type      = "gp3"

  db_name  = var.db_name
  username = var.db_username
  # パスワードは Secrets Manager から参照(後述の初回投入が必要)
  # manage_master_user_password を使う方法もあるが、
  # アプリ側の接続文字列組み立てが複雑になるため、
  # 詳細は公式 aws_db_instance ドキュメントの manage_master_user_password を参照
  password = var.db_password_placeholder   # apply 後すぐ Secrets Manager に本値を投入する

  db_subnet_group_name   = aws_db_subnet_group.main.name
  vpc_security_group_ids = [var.sg_rds_id]

  backup_retention_period = 7       # 自動バックアップ 7 日
  skip_final_snapshot     = false   # destroy 時にスナップショットを取る
  final_snapshot_identifier = "${var.prefix}-mysql-final"
  deletion_protection     = true    # コンソールから明示的に外すまで削除不可

  # マルチ AZ は月 +$15 なので、初期はシングル AZ
  multi_az = false

  # パラメータグループで文字コードを utf8mb4 に(任意。デフォルトでも動くが明示推奨)
  # 詳細は aws_db_parameter_group リソースを参照
}

# --- ElastiCache サブネットグループ ---

resource "aws_elasticache_subnet_group" "main" {
  name       = "${var.prefix}-cache"
  subnet_ids = var.private_subnet_ids
}

# --- ElastiCache Valkey ---

resource "aws_elasticache_cluster" "valkey" {
  cluster_id           = "${var.prefix}-valkey"
  engine               = "valkey"
  node_type            = "cache.t4g.micro"
  num_cache_nodes      = 1
  parameter_group_name = "default.valkey8"   # バージョンに合わせて変更

  subnet_group_name  = aws_elasticache_subnet_group.main.name
  security_group_ids = [var.sg_cache_id]
}
# infra/modules/data/variables.tf
variable "prefix"                  { }
variable "private_subnet_ids"      { type = list(string) }
variable "sg_rds_id"               { }
variable "sg_cache_id"             { }
variable "db_name"                 { default = "myapp" }
variable "db_username"             { default = "myapp_user" }
variable "db_password_placeholder" {
  description = "初回 apply 用の仮パスワード。apply 後すぐ Secrets Manager の値を本パスワードに更新し、ここも更新する"
  sensitive   = true
}
# infra/modules/data/outputs.tf
output "db_endpoint"              { value = aws_db_instance.mysql.endpoint }
output "cache_endpoint"           { value = aws_elasticache_cluster.valkey.cache_nodes[0].address }
output "secret_arn_db_password"   { value = aws_secretsmanager_secret.db_password.arn }
output "secret_arn_app_secrets"   { value = aws_secretsmanager_secret.app_secrets.arn }

apply 後の手動作業 — Secrets Manager に値を投入する

# DB パスワードを Secrets Manager に投入(初回のみ)
aws secretsmanager put-secret-value \
  --secret-id "myapp-prod/db-password" \
  --secret-string '{"password":"your-strong-password-here"}'

# アプリシークレット群を投入
aws secretsmanager put-secret-value \
  --secret-id "myapp-prod/app-secrets" \
  --secret-string '{
    "JWT_SECRET": "...",
    "STRIPE_SECRET_KEY": "...",
    "STRIPE_WEBHOOK_SECRET": "..."
  }'

月額目安: RDS db.t4g.micro シングル AZ 約 $15 / ElastiCache cache.t4g.micro 約 $12 / Secrets Manager 1シークレットあたり $0.40。

destroy の注意: RDS は deletion_protection = true を外してから destroy する必要があります。skip_final_snapshot = false にしているため、destroy 時にスナップショットが自動で取られます(これ自体は追加課金なし。ただしスナップショットを手動で削除しない限り残り続けるので注意)。

app モジュール — ECR・ALB・ACM・ECS

全体の依存関係

ECR リポジトリ(コンテナイメージ置き場)
    ↓ イメージ push 後
ACM 証明書(HTTPS 化)
    ↓
ALB + ターゲットグループ
    ↓
ECS クラスター → タスク定義 → サービス

タスクロールとタスク実行ロールの違い

ECS Fargate では 2 種類の IAM ロールが登場します。混乱しやすいのでここで整理します。

┌─────────────────────────────────────────────────┐
│  ECS コントロールプレーン(AWS 管理)              │
│      ↓ タスク起動時に使うロール                   │
│  【タスク実行ロール】                             │
│  ・ECR からコンテナイメージを pull する            │
│  ・CloudWatch Logs にログを書き込む               │
│  ・Secrets Manager からシークレットを取得して      │
│    コンテナの環境変数に注入する                    │
└─────────────────────────────────────────────────┘

┌──────────────────────────────────────────────────┐
│  コンテナ内で動くアプリ(Spring Boot)             │
│      ↓ アプリが AWS サービスを呼ぶときに使うロール  │
│  【タスクロール】                                 │
│  ・SES でメール送信                               │
│  ・S3 / R2 に presigned URL を発行               │
│  ・(必要に応じて他の AWS サービス)                │
└─────────────────────────────────────────────────┘

タスク実行ロール = 「コンテナを起動するための AWS 権限」
タスクロール = 「起動したコンテナ内アプリが使う AWS 権限」

コード

# infra/modules/app/main.tf

# --- ECR ---

resource "aws_ecr_repository" "app" {
  name                 = "${var.prefix}/backend"
  image_tag_mutability = "MUTABLE"   # latest タグを上書き可能にする

  image_scanning_configuration {
    scan_on_push = true   # push 時に脆弱性スキャン(無料)
  }
}

# 古いイメージを自動削除(ストレージ節約)
resource "aws_ecr_lifecycle_policy" "app" {
  repository = aws_ecr_repository.app.name

  policy = jsonencode({
    rules = [{
      rulePriority = 1
      description  = "最新 10 世代だけ残す"
      selection = {
        tagStatus   = "any"
        countType   = "imageCountMoreThan"
        countNumber = 10
      }
      action = { type = "expire" }
    }]
  })
}

# --- ACM 証明書(DNS 検証) ---

resource "aws_acm_certificate" "main" {
  domain_name       = var.domain_name
  validation_method = "DNS"

  lifecycle {
    create_before_destroy = true
  }
}

# ※ DNS 検証レコードは第3回(Cloudflare 設定)で追加します
# ここでは証明書リソースだけ作り、検証レコードの情報を output に出します

output "acm_validation_options" {
  value = aws_acm_certificate.main.domain_validation_options
  description = "第3回で Cloudflare に追加する CNAME レコードの情報"
}

# --- ALB ---

resource "aws_lb" "main" {
  name               = "${var.prefix}-alb"
  internal           = false
  load_balancer_type = "application"
  security_groups    = [var.sg_alb_id]
  subnets            = var.public_subnet_ids
}

resource "aws_lb_target_group" "app" {
  name        = "${var.prefix}-tg"
  port        = 8080
  protocol    = "HTTP"
  vpc_id      = var.vpc_id
  target_type = "ip"   # Fargate は ip タイプ

  health_check {
    path                = "/actuator/health"
    interval            = 30
    timeout             = 10
    healthy_threshold   = 2
    unhealthy_threshold = 3
    matcher             = "200"
  }
}

# HTTP → HTTPS リダイレクト
resource "aws_lb_listener" "http_redirect" {
  load_balancer_arn = aws_lb.main.arn
  port              = 80
  protocol          = "HTTP"

  default_action {
    type = "redirect"
    redirect {
      port        = "443"
      protocol    = "HTTPS"
      status_code = "HTTP_301"
    }
  }
}

# HTTPS リスナー(証明書が検証済みになってから apply する)
resource "aws_lb_listener" "https" {
  load_balancer_arn = aws_lb.main.arn
  port              = 443
  protocol          = "HTTPS"
  ssl_policy        = "ELBSecurityPolicy-TLS13-1-2-2021-06"
  certificate_arn   = aws_acm_certificate.main.arn

  default_action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.app.arn
  }
}

# --- IAM ロール(タスク実行ロール) ---

data "aws_iam_policy_document" "ecs_task_assume" {
  statement {
    actions = ["sts:AssumeRole"]
    principals {
      type        = "Service"
      identifiers = ["ecs-tasks.amazonaws.com"]
    }
  }
}

resource "aws_iam_role" "task_execution" {
  name               = "${var.prefix}-task-execution"
  assume_role_policy = data.aws_iam_policy_document.ecs_task_assume.json
}

resource "aws_iam_role_policy_attachment" "task_execution_policy" {
  role       = aws_iam_role.task_execution.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}

# Secrets Manager へのアクセスをタスク実行ロールに追加
resource "aws_iam_role_policy" "task_execution_secrets" {
  name = "secrets-access"
  role = aws_iam_role.task_execution.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect   = "Allow"
      Action   = ["secretsmanager:GetSecretValue"]
      Resource = var.secret_arns   # data モジュールの output を渡す
    }]
  })
}

# --- IAM ロール(タスクロール — アプリが使う権限) ---

resource "aws_iam_role" "task" {
  name               = "${var.prefix}-task"
  assume_role_policy = data.aws_iam_policy_document.ecs_task_assume.json
}

# SES でメール送信する権限
resource "aws_iam_role_policy" "task_ses" {
  name = "ses-send"
  role = aws_iam_role.task.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect   = "Allow"
      Action   = ["ses:SendEmail", "ses:SendRawEmail"]
      Resource = "*"
      # より厳密にする場合は Resource を SES の ARN に絞る
      # 詳細は AWS IAM ドキュメントの SES アクション一覧を参照
    }]
  })
}

# --- CloudWatch Logs ---

resource "aws_cloudwatch_log_group" "app" {
  name              = "/ecs/${var.prefix}"
 # 保持期間は必ず指定する。デフォルトは「無期限」で、
 # ログの取り込み・保管は従量課金のため、放置すると静かに固定費が育つ     
  retention_in_days = 30
}

# --- ECS クラスター ---

resource "aws_ecs_cluster" "main" {
  name = "${var.prefix}"

  setting {
    name  = "containerInsights"
    value = "enabled"   # CPU / メモリ / ネットワークのメトリクスを CloudWatch に送る
  }
}

# --- タスク定義 ---

resource "aws_ecs_task_definition" "app" {
  family                   = "${var.prefix}-app"
  requires_compatibilities = ["FARGATE"]
  network_mode             = "awsvpc"
  cpu                      = 256   # 0.25 vCPU
  memory                   = 1024  # 1 GB(Spring Boot は 512 MB だとギリギリなので 1 GB から)

  execution_role_arn = aws_iam_role.task_execution.arn
  task_role_arn      = aws_iam_role.task.arn

  container_definitions = jsonencode([{
    name      = "backend"
    image     = "${aws_ecr_repository.app.repository_url}:latest"
    essential = true

    portMappings = [{
      containerPort = 8080
      protocol      = "tcp"
    }]

    # 環境変数(秘密でない値)
    environment = [
      { name = "SPRING_PROFILES_ACTIVE", value = "prod" },
      { name = "SERVER_PORT",            value = "8080" },
      { name = "DB_HOST",                value = var.db_host },
      { name = "CACHE_HOST",             value = var.cache_host },
      # ベース URL 系は Secrets ではなく environment に置く(値はシークレットでないため)
      { name = "APP_FRONTEND_URL",       value = "https://${var.domain_name}" },
      { name = "APP_BASE_URL",           value = "https://${var.domain_name}" },
    ]

    # シークレット(Secrets Manager から注入)
    # ECS がタスク起動時に値を取得してコンテナの環境変数として渡す
    secrets = [
      {
        name      = "DB_PASSWORD"
        # Secrets Manager の JSON キーを指定する場合は ARN::key:: 形式
        valueFrom = "${var.secret_arn_db_password}:password::"
      },
      {
        name      = "JWT_SECRET"
        valueFrom = "${var.secret_arn_app_secrets}:JWT_SECRET::"
      },
      {
        name      = "STRIPE_SECRET_KEY"
        valueFrom = "${var.secret_arn_app_secrets}:STRIPE_SECRET_KEY::"
      },
    ]

    # ログ設定
    logConfiguration = {
      logDriver = "awslogs"
      options = {
        "awslogs-group"         = aws_cloudwatch_log_group.app.name
        "awslogs-region"        = var.aws_region
        "awslogs-stream-prefix" = "ecs"
      }
    }

    # JVM のコンテナ対応(メモリ上限を超えた OOM Kill を防ぐ)
    # Dockerfile の ENTRYPOINT で "-XX:MaxRAMPercentage=75.0" を付けること
    # 詳細は JVM コンテナサポートのドキュメントを参照
  }])
}

# --- ECS サービス ---

resource "aws_ecs_service" "app" {
  name            = "${var.prefix}-app"
  cluster         = aws_ecs_cluster.main.id
  task_definition = aws_ecs_task_definition.app.arn
  desired_count   = 1   # WebSocket がインメモリブローカーのうちは 1 固定
  launch_type     = "FARGATE"

  # タスクが起動してからヘルスチェックを始めるまでの猶予時間
  # Flyway マイグレーションの実行時間に合わせて長めに設定する
  health_check_grace_period_seconds = 120

  network_configuration {
    subnets          = var.public_subnet_ids
    security_groups  = [var.sg_ecs_id]
    assign_public_ip = true   # パブリックサブネット構成の場合は true(ECR pull に必要)
  }

  load_balancer {
    target_group_arn = aws_lb_target_group.app.arn
    container_name   = "backend"
    container_port   = 8080
  }

  # タスク定義の更新で自動再デプロイしない(手動でデプロイを制御したい場合)
  # 詳細は force_new_deployment の使い方を参照
  lifecycle {
    ignore_changes = [task_definition]
  }
}

月額目安: ECR リポジトリは 500 MB まで無料(超過分 $0.10/GB)/ ALB 約 $20 / ECS Fargate 0.25vCPU + 1GB を常時稼働で約 $15 / ACM 証明書は無料。

初回デプロイ — コンテナを動かして確認する

1. docker build して ECR に push

# ECR にログイン
aws ecr get-login-password --region ap-northeast-1 | \
  docker login --username AWS --password-stdin \
  <AWSアカウントID>.dkr.ecr.ap-northeast-1.amazonaws.com

# イメージをビルド(プロジェクトルートで実行)
docker build -t myapp-backend:latest ./backend

# ECR リポジトリ名は terraform output で確認
ECR_URI=$(terraform -chdir=infra/envs/prod output -raw ecr_repository_url)

# タグ付けして push
docker tag myapp-backend:latest ${ECR_URI}:latest
docker push ${ECR_URI}:latest

2. ECS サービスを強制更新

aws ecs update-service \
  --cluster myapp-prod \
  --service myapp-prod-app \
  --force-new-deployment

3. 動作確認

# ALB の DNS 名を確認
ALB_DNS=$(terraform -chdir=infra/envs/prod output -raw alb_dns_name)

# ヘルスチェックが通るか確認(証明書未検証なので HTTPS では -k が必要な場合あり)
curl http://${ALB_DNS}/actuator/health
# {"status":"UP"} が返れば成功

よくあるハマり 3 パターン

パターン1: タスクが起動してすぐ OOM Kill される

症状: ECS のイベントタブに OutOfMemoryError または exit code 137

原因: JVM がコンテナのメモリ上限を認識せず、ヒープをホストメモリ量で計算して OOM Kill される。

切り分け: ECS サービス → イベントタブ → タスクのイベント内容を確認。CloudWatch Logs でも java.lang.OutOfMemoryError を検索。

解決策: Dockerfile の JVM オプションに -XX:MaxRAMPercentage=75.0 を追加する(JDK 11 以降はコンテナのメモリ上限を自動認識する)。

ENTRYPOINT ["java", \
  "-XX:MaxRAMPercentage=75.0", \
  "-Djava.security.egd=file:/dev/./urandom", \
  "-jar", "/app/app.jar"]

パターン2: タスクが何度も起動・停止を繰り返す

症状: ECS サービスのイベントに「タスクがアンヘルシーで停止された」が繰り返す

原因: health_check_grace_period_seconds が短すぎて、Flyway マイグレーションが終わる前にヘルスチェックが始まって落とされる。または /actuator/health が認証なしでアクセスできていない。

切り分け:

  1. CloudWatch Logs でアプリのログを確認(起動途中で止まっていないか)
  2. ALB のターゲットグループ → ターゲット → ヘルスチェック詳細でエラーコードを確認
  3. Spring Security の設定で /actuator/healthpermitAll になっているか確認

解決策: health_check_grace_period_seconds を 180〜300 秒に延ばす。Spring Security の設定例:

.requestMatchers("/actuator/health").permitAll()

パターン3: タスクが起動するが DB に繋がらない

症状: アプリが起動するが Connection refused / Communications link failure

原因パターン:

  • RDS のセキュリティグループで ECS SG からの 3306 が許可されていない
  • アプリの DB ホスト設定が間違っている(localhost のままなど)
  • Secrets Manager の値がまだ投入されていない

切り分け: CloudWatch Logs で Spring Boot の起動ログを確認。DB_HOSTSPRING_DATASOURCE_URL が期待通りの値になっているかは、タスク定義のコンテナ詳細から環境変数を確認できます(シークレット値は見えませんが、キーが正しいかは確認可能)。

アプリ設定の注入 — 環境変数と secrets の書き分け

タスク定義における 2 種類の値

environment(平文で tfstate・コンソールに見える)
  → URL・ホスト名・ポート・プロファイル名など「見えても問題ない設定値」

secrets(Secrets Manager から取得。コンソールにも見えない)
  → パスワード・API キー・JWT 秘密鍵など「漏れると困る値」

Spring Boot の本番プロファイル設定

第1回の参照記事でも触れましたが、デフォルト値なしの環境変数参照にして、未設定なら起動を失敗させる(fail-fast)ことが本番品質の証明です。

# application-prod.yml
spring:
  datasource:
    url: jdbc:mysql://${DB_HOST}:3306/${DB_NAME}  # デフォルト値なし
    username: ${DB_USERNAME}
    password: ${DB_PASSWORD}                       # Secrets Manager から注入
  data:
    redis:
      host: ${CACHE_HOST}
      port: 6379

app:
  frontend-url: ${APP_FRONTEND_URL}   # デフォルト値なし — 未設定なら起動失敗
  base-url: ${APP_BASE_URL}           # 招待メールのリンク生成等に使う
  jwt:
    secret: ${JWT_SECRET}

${VAR}${VAR:default} の違いを意識するだけで「黙って localhost を向く」事故が防げます。

設定漏れの見つけ方

ECS でタスクが起動したのに機能が壊れている場合、まず CloudWatch Logs の起動ログに No value for required parameter 系のエラーが出ていないか確認します。次に、タスク定義の環境変数一覧を aws ecs describe-task-definition で取得してキーの過不足を確認します。

# タスク定義の環境変数を一覧確認
aws ecs describe-task-definition \
  --task-definition myapp-prod-app \
  --query 'taskDefinition.containerDefinitions[0].environment'

まとめ

今回 Terraform で作ったもの:

リソース 用途
AWS Budgets 消し忘れ課金の保険
VPC / サブネット / IGW / SG ネットワーク基盤
RDS MySQL 8.0 アプリのメイン DB(削除保護・自動バックアップ付き)
ElastiCache Valkey セッション・キャッシュ
Secrets Manager パスワード・API キーの安全な管理
ECR コンテナイメージの置き場
ALB + ACM HTTPS での受付(証明書検証は第3回)
ECS Fargate Spring Boot コンテナの実行基盤

この時点では ALB の DNS 名(http://xxxx.ap-northeast-1.elb.amazonaws.com)で /actuator/health が確認できる状態です。独自ドメインでの HTTPS アクセスと Cloudflare 経由の接続は第3回で行います。

destroy する場合の手順

  1. deletion_protection = truefalse に変更して apply
  2. aws_lb_listenerhttps リソースを削除(証明書未検証の場合は不要なこともある)
  3. terraform destroy
  4. ECR に push したイメージを削除(Terraform は管理外のリソースを消さないため)
  5. Secrets Manager のシークレットは recovery_window_in_days = 7 があるため、即時削除したい場合は --force-delete-without-recovery オプションが必要

次回予告

第3回では:

  • Cloudflare での DNS 設定と ACM 証明書の DNS 検証レコード追加
  • Cloudflare Pages への Nuxt 3 デプロイ
  • /api/** を ALB へプロキシする Workers 設定(同一オリジン化)
  • GitHub Actions での CI/CD パイプライン(OIDC 連携・ECR push・ECS デプロイ)

を扱います。今回作った ALB の DNS 名と ACM の検証レコード情報(terraform output acm_validation_options)を手元に控えておいてください。

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?