본문 바로가기
DevOps

AWS 컨테이너 서비스 3-Tier w/테라폼 & CI/CD 구성하기 - (3) Terraform으로 AWS 아키텍쳐 구성하기 (ECS)

by yingtao 2025. 6. 6.

인프라 아키텍쳐 소개

  • 프론트엔드 : S3에 정적 파일을 저장하고, CloudFront를 통해 전달
  • 백엔드 :  ECS Fargate에서 컨테이너화된 Springboot 애플리케이션을 실행하고 ALB로 트래픽 분산
  • 데이터베이스 : RDS for PostgreSQL 사용

 

주요 특징

  • 루트 도메인은 keembucha.shop
  • Terraform 상태 파일 (terraform.tfstate)을 저장하기 위해 S3 버킷, DynamoDB을 사용했다.
    DynamoDB의 Lock 기능을 통해 여러명이 동시에 Terraform 실행 시 상태 파일 충돌을 방지할 수 있다.
  • VPC 구성은 Public, Private, Database 서브넷으로 분리했다.
    특히 Database 서브넷은 variables.tf에서 해당 CIDR 목록을 선언하지 않으면 count 함수를 통해 동적으로 서브넷 생성을 건너뛰도록 구성했다.
  • RDS Multi-AZ 설정(db_multi_az = true)을 통해 고가용성 확보.
    Primary DB에 장애 발생시 자동으로 Standby DB가 Primary로 승격되는 페일오버 기능을 활용했다.

 

Terraform으로 구성한 AWS 리소스 

  • 네트워크 
    • VPC
    • Subnet : Public, Private, Database 서브넷으로 분리 
    • Route Table : 서브넷 내 라우팅 규칙 정의
    • Internet Gateway : Public 서브넷이 외부 인터넷이랑 통신할 수 있도록
    • NAT Gateway : Private 서브넷이 외부 인터넷으로 아웃바운드 통신할 수 있도록
  • 컴퓨팅
    • ECS Cluster : Fargate 컨테이너 실행하고 관리하는 논리적 공간
    • ECS Task Definition : 컨테이너 이미지, CPU/메모리, 환경 변수 등 컨테이너 실행 명세 정의
    • ECS Service : 정의된 task를 원하는 수량(우선 1로 해놓음)으로 유지하도록
  • 로드밸런싱 및 CDN
    • ALB : L7계층에서 HTTP/HTTPS 트래픽을 백엔드 서비스로 분산
    • CloudFront Distribution (CDN)
    • CloudFront Origin Access Control (OAC) : Cloudfront가 S3에 프라이빗하게 접근하도록
  • 데이터베이스
    • RDS Instance
  • 스토리지
    • S3 Buckets : 프론트엔드 정적 파일 저장 (cloudfront랑 연동), tfstate 파일 저장
  • 네임서버 및 인증서
    • Route53 Hosted Zone : 도메인에 대한 DNS 레코드 관리
    • ACM Certificate : SSL/TLS 인증서 발급 및 관리
  • IAM
    • IAM Roles
    • IAM Policies
  • 기타
    • ECR : ECS 도커 이미지 저장소
    • DynamoDB Table : Terraform 상태 파일 lock 관리를 위한 테이블

 

사전 준비 및 Terraform 설치 확인

  • Terraform 설치 확인
terraform version

  • AWS CLI 설정 확인

IAM 사용자가 Terraform이 생성할 AWS 리소스 (VPC, ECS, RDS, S3 등등..)에 대한 권한이 필요하다.

aws configure list

만약 설정되어있지 않으면 aws configure 명령을 통해 AWS Access Key ID, Secret Access Key, 기본 리전 등을 입력해준다.

  • 작업 디렉토리 생성 및 Terraform 구조 생성

Terraform의 구조는 다음과 같다.

.
├── modules
│   ├── rds
│   │   ├── main.tf
│   │   ├── outputs.tf
│   │   └── variables.tf
│   └── vpc
│       ├── main.tf
│       ├── outputs.tf
│       └── variables.tf
└── project
    ├── 3tier_ecs_fargate
    │   ├── alb.tf
    │   ├── cloudfront.tf
    │   ├── ecr.tf
    │   ├── ecs.tf
    │   ├── env
    │   ├── iam.tf
    │   ├── networking.tf
    │   ├── outputs.tf
    │   ├── providers.tf
    │   ├── rds.tf
    │   ├── s3.tf
    │   ├── terraform.tfvars
    │   └── variables.tf
    └── common
        ├── acm.tf
        ├── iam.tf
        ├── route53.tf
        ├── s3.tf
        ├── terraform.tfstate
        ├── terraform.tfvars
        └── variables.tf
  • modules/ : 여러 프로젝트에서 재사용할 수 있는 Terraform 모듈 정의 
    • modules/vpc/ : VPC, Subnet, Routing Table 등 네트워킹 리소스
    • modules/rds/ : RDS 인스턴스 관련 설정
  • project/common/ : 특정 3-tier 프로젝트에 종속되지 않고, AWS 계정 전체에 공통적으로 사용되는 글로벌 리소스 관리 (Terraform state 저장을 위한 S3 버킷이나 DynamoDB 테이블, ACM 인증서 등)
    • project/common/backend.tf : Terraform State 저장 S3 버킷 및 DynamoDB Lock 테이블
    • project/common/s3.tf : state 외 정적 웹 콘텐츠를 저장하는 S3 버킷
    • project/common/iam.tf : CI/CD 역할이나 ECS 테스크 실행 역할 등 공통 IAM
    • project/common/acm.tf : ACM 인증서
    • project/common/route53.tf : Route 53 관련
  • project/3tier_ecs_fargate/ : 현재 생성하려는 ECS Fargate 기반 3-tier 아키텍쳐에 특화된 모든 리소스를 정의한다. main.tf에서 modules를 호출하고 추가 리소스를 정의한다.
    • 3tier_ecs_fargate/variables.tf : 프로젝트 변수 정의
    • 3tier_ecs_fargate/outposts.tf : 배포 후 확인할 주요 출력 값
    • 3tier_ecs_fargate/env/ : 환경별(dev, prod) 변수 파일
    • 3tier_ecs_fargate/resources/ : 각 서비스(alb, ecr, 등..)별 tf 파일

 

Terraform 코드 설명

  • modules 디렉토리

modules/vpc/main.tf

resource "aws_vpc" "main" {
  cidr_block           = var.cidr_block     # 전체 네트워크 대역 (10.0.0.0/16)
  enable_dns_hostnames = true               
  enable_dns_support   = true

  tags = {
    Name        = var.name
    Environment = var.environment
  }
}

resource "aws_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id
  tags = {
    Name = "${var.name}-igw"
  }
}

# public subnet
resource "aws_subnet" "public" {
  count                   = length(var.public_subnet_cidrs)                     # CIDR 블록 수만큼 반복 생성
  vpc_id                  = aws_vpc.main.id                                     
  cidr_block              = var.public_subnet_cidrs[count.index]                # 각 서브넷에 CIDR 할당
  availability_zone       = var.available_azs[count.index]
  map_public_ip_on_launch = true

  tags = {
    Name        = "${var.name}-public-${var.available_azs[count.index]}"
    Environment = var.environment
  }
}

# private subnet
resource "aws_subnet" "private" {
  count             = length(var.private_subnet_cidrs)
  vpc_id            = aws_vpc.main.id
  cidr_block        = var.private_subnet_cidrs[count.index]
  availability_zone = var.available_azs[count.index]

  tags = {
    Name        = "${var.name}-private-${var.available_azs[count.index]}"
    Environment = var.environment
  }
}

# database subnet (private)
resource "aws_subnet" "database" {
  count             = length(var.database_subnet_cidrs)
  vpc_id            = aws_vpc.main.id
  cidr_block        = var.database_subnet_cidrs[count.index]
  availability_zone = var.available_azs[count.index]

  tags = {
    Name        = "${var.name}-database-${var.available_azs[count.index]}"
    Environment = var.environment
  }
}
# Elastic IP 할당
resource "aws_eip" "nat_gateway_eip" {
  # enable_nat_gateway가 false면 생성x
  # single_nat_gateway = true이면 하나만 만들고, 아니면 AZ별로 여러 개 생성
  count  = var.enable_nat_gateway ? var.single_nat_gateway ? 1 : length(var.public_subnet_cidrs) : 0
  domain = "vpc"

  tags = {
    Name = "${var.name}-nat-eip-${count.index}"
  }
}

# nat gateway
resource "aws_nat_gateway" "main" {
  # AZ 별로 하나씩 생성
  count         = var.enable_nat_gateway ? var.single_nat_gateway ? 1 : length(var.public_subnet_cidrs) : 0
  allocation_id = aws_eip.nat_gateway_eip[count.index].id
  subnet_id     = aws_subnet.public[count.index].id

  tags = {
    Name = "${var.name}-nat-${count.index}"
  }

  depends_on = [aws_internet_gateway.main]
}

# routing table (public)
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
  }

  tags = {
    Name = "${var.name}-public-rt"
  }
}

# 서브넷 - 라우팅 테이블 연결
resource "aws_route_table_association" "public" {
  count          = length(aws_subnet.public)
  subnet_id      = aws_subnet.public[count.index].id
  route_table_id = aws_route_table.public.id
}

# routing table (private)
resource "aws_route_table" "private" {
  vpc_id = aws_vpc.main.id
  route {
    cidr_block     = "0.0.0.0/0"
    nat_gateway_id = aws_nat_gateway.main[0].id
  }

  tags = {
    Name = "${var.name}-private-rt"
  }
}

resource "aws_route_table_association" "private" {
  count          = length(aws_subnet.private)
  subnet_id      = aws_subnet.private[count.index].id
  route_table_id = aws_route_table.private.id
}

# routing table (database)
resource "aws_route_table" "database" {
  vpc_id = aws_vpc.main.id

  tags = {
    Name = "${var.name}-database-rt"
  }
}

resource "aws_route_table_association" "database" {
  count          = length(aws_subnet.database)
  subnet_id      = aws_subnet.database[count.index].id
  route_table_id = aws_route_table.database.id
}

 

modules/vpc/outputs.tf

output "vpc_id" {
  description = "The ID of the VPC"
  value       = aws_vpc.main.id
}
output "public_subnet_ids" {
  description = "List of IDs of public subnets"
  value       = aws_subnet.public[*].id
}
output "private_subnet_ids" {
  description = "List of IDs of private subnets"
  value       = aws_subnet.private[*].id
}
output "database_subnet_ids" {
  description = "List of IDs of database subnets"
  value       = aws_subnet.database[*].id
}

 

modules/vpc/variables.tf

variable "name" {
  description = "Name for the VPC resources"
  type        = string
}
variable "environment" {
  description = "Environment tag"
  type        = string
}
variable "cidr_block" {
  description = "CIDR block for the VPC"
  type        = string
}
variable "public_subnet_cidrs" {
  description = "List of CIDR blocks for public subnets"
  type        = list(string)
}
variable "private_subnet_cidrs" {
  description = "List of CIDR blocks for private subnets"
  type        = list(string)
}
variable "database_subnet_cidrs" {
  description = "List of CIDR blocks for database subnets"
  type        = list(string)
}
variable "available_azs" {
  description = "Availability Zones to use for subnets"
  type        = list(string)
}
variable "enable_nat_gateway" {
  description = "Enable NAT Gateway for private subnets"
  type        = bool
  default     = true
}
variable "single_nat_gateway" {
  description = "Use a single NAT Gateway across all AZs"
  type        = bool
  default     = true
}

variable "tags" {
  description = "Tags to apply to resources"
  type        = map(string)
  default     = {}
}

 

modules/rds/main.tf

resource "aws_db_instance" "main" {
  identifier             = var.db_identifier
  engine                 = var.db_engine
  engine_version         = var.db_engine_version
  instance_class         = var.db_instance_class
  allocated_storage      = var.db_allocated_storage
  storage_type           = "gp3"
  db_name                = var.db_name
  username               = var.db_username
  password               = var.db_password
  vpc_security_group_ids = var.db_security_group_ids
  db_subnet_group_name   = var.db_subnet_group_name
  multi_az               = var.db_multi_az
  publicly_accessible    = false

  tags = {
      Name        = "${var.db_identifier}"
      Environment = var.environment
  }

}

 

modules/rds/outputs.tf

output "db_instance_address" {
  description = "The address of the RDS instance"
  value       = aws_db_instance.main.address
}

output "db_instance_port" {
  description = "The port of the RDS instance"
  value       = aws_db_instance.main.port
}

output "db_instance_arn" {
  description = "The ARN of the RDS instance"
  value       = aws_db_instance.main.arn
}

 

modules/rds/variables.tf

variable "db_identifier" {
  description = "Identifier for the DB instance"
  type        = string
}

variable "db_engine" {
  description = "Databse engine"
  type        = string
}

variable "db_engine_version" {
  description = "Databse engine version"
  type        = string
}

variable "db_instance_class" {
  description = "DB instance class"
  type        = string
}

variable "db_allocated_storage" {
  description = "Allocated storage in DB"
  type        = number
}

variable "db_name" {
  description = "Name of the databse"
  type        = string
}

variable "db_username" {
  description = "Username for the database"
  type        = string
}

variable "db_password" {
  description = "Password for the databse"
  type        = string
  sensitive   = true
}

variable "db_subnet_group_name" {
  description = "Name of the DB Subnet Group"
  type        = string
}

variable "db_multi_az" {
  description = "Deploy DB in Multi AZ"
  type        = bool
  default     = false
}

variable "db_security_group_ids" {
  description = "List of security group IDs for RDS"
  type        = list(string)
}

variable "environment" {
  description = "Environment name"
  type        = string
}

variable "tags" {
  description = "Tags to apply to RDS"
  type        = map(string)
  default     = {}
}

 

  • project/common 디렉토리

common 디렉토리에 있는 서비스들은 애플리케이션 인프라(3tier_ecs_fargate 내 서비스들)보다 먼저 배포해야 한다.

terraform.tfvars

root_domain_name = "keembucha.shop"

 

variables.tf

variable "root_domain_name" {
  description = "The root domain name to create the hosted zone for"
  type        = string
}

 

/common/s3.tf

# S3 버킷 (테라폼 상태 파일 저장소)
resource "aws_s3_bucket" "terraform_state_bucket" {
  bucket = "keembucha-terraform-state-s3-bucket"
  acl    = "private" # 외부 접근 불가
  versioning {
    enabled = true # terraform.tfstate 파일 변경 시 버전 관리 (복구 가능)
  }
  server_side_encryption_configuration { # 서버 측 암호화
    rule {
      apply_server_side_encryption_by_default {
        sse_algorithm = "AES256" # 파일 저장 시 AES256 방식으로 자동 암호화
      }
    }
  }

  tags = {
    Name        = "TerraformStateBucket"
    Environment = "Common"
    Project     = "Global"
  }

}

# DynamoDB Lock table (상태 파일 동시 수정 방지를 위한 lock 관리)
resource "aws_dynamodb_table" "terraform_locks" {
  name         = "terraform-lock-table"
  billing_mode = "PAY_PER_REQUEST"
  hash_key     = "LockID" # PK

  attribute {
    name = "LockID" # 데이터 컬럼 : 이름
    type = "S"      # 데이터 타입 : 문자열
  }

  tags = {
    Name        = "TerraformLockTable"
    Environment = "Common"
    Project     = "Global"
  }
}

output "terraform_state_bucket_name" {
  value = aws_s3_bucket.terraform_state_bucket.id
}
output "terraform_lock_table_name" {
  value = aws_dynamodb_table.terraform_locks.id
}
  1. project/common 디렉토리로 이동해 해당 파일 (S3 버킷, DynamoDB 리소스 포함된 파일) 작성 후 terraform init 수행
  2. terraform apply 명령어를 통해 S3 버킷, DynamoDB 테이블 AWS에 생성해주기
  3. 이후 project/3tier_ecs_fargate 프로젝트의 provider.tf 파일에 { backend "s3" { ... } } 블록 추가해서 Terraform의 원격 백엔드로 지정하기 (아래 후술)

 

/common/route53.tf

resource "aws_route53_zone" "main_hosted_zone" {
  name = var.root_domain_name

  tags = {
    Name        = "${var.root_domain_name}-hosted-zone"
    Environment = "Common"
  }
}

output "main_hosted_zone_id" {
  value = aws_route53_zone.main_hosted_zone.zone_id
}
output "main_hosted_zone_name_servers" {
  value = aws_route53_zone.main_hosted_zone.name_servers
}
  1. 가비아에서 도메인 구입 (keembucha.shop)
  2. 위 파일 작성 후 project/common 디렉토리에서 terraform apply를 통해 Route53 Hosted Zone을 AWS에 배포한다.
    그러면 아래 사진과 같이 네임서버가 출력된다.
  3. 가비아에서 출력된 네임서버로 변경해준다 (1차 ~ 4차)

1차 ~ 4차 에 입력해주기

/common/acm.tf

SSL 인증서를 발급받고, Route 53을 통해 자동으로 DNS 검증까지 수행

# SSL/TLS 인증서 요청
resource "aws_acm_certificate" "main_cert" {
  domain_name       = var.root_domain_name # 인증서 적용 도메인      
  validation_method = "DNS"                # Route53 통한 자동 DNS 검증

  lifecycle {
    create_before_destroy = true # 기존 인증서 삭제 전 새 인증서 만들도록 보장
  }

  tags = {
    Name        = "${var.root_domain_name}-cert"
    Environment = "Common"
    
  }
}

# DNS 레코드 생성
resource "aws_route53_record" "cert_validation" {
  # ACM 인증서 생성 시 AWS가 DNS 검증을 위해 필요한 값들 반환
  for_each = {
    for dvo in aws_acm_certificate.main_cert.domain_validation_options : dvo.domain_name => dvo
  }

  allow_overwrite = true                               # 동일 이름 레코드 존재시 덮어쓰기 허용
  name            = each.value.resource_record_name    # 생성할 Route53 레코드 이름
  records         = [each.value.resource_record_value] # 레코드 실제 값      
  ttl             = 60
  type            = each.value.resource_record_type
  zone_id         = aws_route53_zone.main_hosted_zone.zone_id
}

# 검증
resource "aws_acm_certificate_validation" "cert_validation" {
  certificate_arn         = aws_acm_certificate.main_cert.arn
  validation_record_fqdns = [for record in aws_route53_record.cert_validation : record.fqdn]
}

output "main_cert_arn" {
  value = aws_acm_certificate.main_cert.arn
}
  1. 위 파일 작성 후 Route53 Hosted Zone이 이미 배포된 상태에서 project/common 디렉토리에서 terraform apply를 통해 ACM 인증서 관련 리소스를 배포해준다.
  2. aws_route53_record.cert_validation이 aws_route53_zone.main_hosted_zone.zone_id를 참조해 자동으로 DNS 레코드를 생성하고 aws_acm_certificate_validation.cert_validation이 검증이 완료될때까지 기다린다.
  3. 발급된 인증서의 ARN은 output를 통해 출력되고 이후 3tier_ecs_fargate의 ALB HTTPS 리스너 구성 시 certificate_arn으로 참조돼 HTTPS 통신을 가능하게 해준다.

 

/common/iam.tf

# ECS IAM Role
resource "aws_iam_role" "ecs_service_role" {
  name = "keembucha-ecs-service-role"
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = "sts:AssumeRole"
        Effect = "Allow"
        Principal = {
          Service = "ecs.amazonaws.com"
        }
      }
    ]
  })

  tags = {
    Name        = "ECS-Service-Role"
    Environment = "Common"
  }
}

# ECS IAM Policy
resource "aws_iam_role_policy_attachment" "ecs_service_role_policy" {
  role       = aws_iam_role.ecs_service_role.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"

}

# ACM이 Route53의 DNS 레코드를 수정하는 IAM Role
resource "aws_iam_role" "acm_route53_validation_role" {
  name = "keembucha-acm-route53-validation-role"
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Action = "sts:AssumeRole"
      Effect = "Allow"
      Principal = {
        Service = "acm.amazonaws.com" # ACM이 Role을 assume할 수 있도록 허용
      }
    }]
  })
}

# IAM Policy (ACM이 Route53에서 인증용 DNS를 자동으로 생성하고 확인할 수 있게 만드는 권한)
resource "aws_iam_policy" "acm_route53_validation_policy" {
  name        = "keembucha-acm-route53-validation-policy"
  description = "Policy for ACM to validate certificates via Route 53"
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect = "Allow"
      Action = [
        "route53:ChangeResourceRecordSets", # DNS 레코드 변경
        "route53:ListHostedZones",          # 호스티드존 목록 조회
        "route53:GetChange"                 # 변경 상태 조회
      ]
      Resource = "*" # 모든 Hosted Zone에 적용 
    }]
  })
}

resource "aws_iam_role_policy_attachment" "acm_route53_validation_attachment" {
  role       = aws_iam_role.acm_route53_validation_role.name
  policy_arn = aws_iam_policy.acm_route53_validation_policy.arn

}

 

 

  • project/3tier_ecs_fargate 디렉토리

실제 3-Tier 애플리케이션의 핵심 구성 요소들을 정의한다. 모듈에서 정의된 네트워크(module/vpc), 데이터베이스(module/rds)를 활용했다.

terraform.tfvars

# 환경 이름
env         = "test"
environment = "test"

# AWS 리전
aws_region = "ap-northeast-2"

# 프로젝트 이름
project_name = "myproject"
name         = "myproject"

# VPC 및 서브넷 설정
cidr_block            = "10.0.0.0/16"
public_subnet_cidrs   = ["10.0.101.0/24", "10.0.102.0/24"]
private_subnet_cidrs  = ["10.0.1.0/24", "10.0.2.0/24"]
database_subnet_cidrs = ["10.0.10.0/24", "10.0.11.0/24"]
available_azs         = ["ap-northeast-2a", "ap-northeast-2c"]

# 애플리케이션 포트
app_port = 8080

# RDS 정보
database_name        = "..."
database_username    = "..."
database_password    = "..."
db_allocated_storage = 20
db_engine            = "postgres"
db_engine_version    = "13.6"
db_identifier        = "..."
db_instance_class    = "db.t3.micro"
db_multi_az          = true
db_username          = "..."
db_name              = "..."
db_password          = "********"

# 도메인 및 인증서
root_domain_name    = "example.com"
hosted_zone_id      = "Z0...D3L"
acm_certificate_arn = "arn:aws:acm:...:certificate/..."

# S3 버킷
static_content_bucket_name = "myproject-frontend-s3"
image_content_bucket_name  = "myproject-images-s3"

# ECR
ecr_repo_name = "myproject-backend-repo"

개인정보 부분은 ...로 마스킹 처리하였다. 

 

variables.tf

variable "env" {
  description = "Deployment Environment (e.g. dev, prod)"
  type        = string
}
variable "aws_region" {
  description = "AWS Region for deployment"
  type        = string
  default     = "ap-northeast-2"
}
variable "project_name" {
  description = "Name of the project for tagging resources"
  type        = string
  default     = "keembucha-shop"
}
variable "vpc_cidr_block" {
  description = "CIDR block for the VPC"
  type        = string
  default     = "10.0.0.0/16"
}
variable "public_subnet_cidrs" {
  description = "List of CIDR blocks for public subnets"
  type        = list(string)
  default     = ["10.0.101.0/24", "10.0.102.0/24"]
}
variable "private_subnet_cidrs" {
  description = "List of CIDR blocks for private subnets (for ECS/RDS)"
  type        = list(string)
  default     = ["10.0.1.0/24", "10.0.2.0/24"]
}
variable "database_subnet_cidrs" {
  description = "List of CIDR blocks for database subnets"
  type        = list(string)
  default     = ["10.0.10.0/24", "10.0.11.0/24"]
}
variable "available_azs" {
  description = "Availability Zones to use"
  type        = list(string)
  default     = ["ap-northeast-2a", "ap-northeast-2c"]
}
variable "app_port" {
  description = "Port on which the Spring Boot application listens"
  type        = number
  default     = 8080
}
variable "database_name" {
  description = "Name of the RDS database"
  type        = string
  default     = "keembucha-db"
}
variable "database_username" {
  description = "Username for the RDS database"
  type        = string
}
variable "database_password" {
  description = "Password for the RDS database"
  type        = string
  sensitive   = true
}
variable "root_domain_name" {
  description = "Root domain name for Route 53"
  type        = string
  default     = "keembucha.shop"
}
variable "ecr_repo_name" {
  description = "Name of the ECR repository for backend image"
  type        = string
  default     = "image-backend-repo"
}
variable "static_content_bucket_name" {
  description = "Name for the S3 bucket storing static frontend content"
  type        = string
  default     = "keembucha-shop-frontend-s3-bucket"
}
variable "image_content_bucket_name" {
  description = "Name for the S3 bucket storing dynamic images"
  type        = string
  default     = "keembucha-shop-images-s3-bucket"
}
# project/common/route53.tf에서 생성된 Hosted Zone ID를 참조하기 위한 변수
variable "hosted_zone_id" {
  description = "Route 53 Hosted Zone ID from common project"
  type        = string
}
# project/common/acm.tf에서 생성된 ACM Certificate ARN을 참조하기 위한 변수
variable "acm_certificate_arn" {
  description = "ACM Certificate ARN from common project"
  type        = string
}

variable "db_identifier" {
  description = "The identifier for the RDS instance"
  type        = string
}

variable "db_engine" {
  description = "Database engine"
  type        = string
}

variable "db_engine_version" {
  description = "Database engine version"
  type        = string
}

variable "db_instance_class" {
  description = "The instance type of the RDS instance"
  type        = string
}

variable "db_allocated_storage" {
  description = "The allocated storage in GBs"
  type        = number
}

variable "db_name" {
  description = "The name of the database to create"
  type        = string
}

variable "db_username" {
  description = "Username for the database"
  type        = string
}

variable "db_password" {
  description = "Password for the database"
  type        = string
  sensitive   = true
}

variable "db_multi_az" {
  description = "Enable Multi-AZ deployment"
  type        = bool
}

variable "environment" {
  description = "Environment tag"
  type        = string
}

variable "tags" {
  description = "Tags to apply"
  type        = map(string)
  default     = {}
}

variable "name" {
  type        = string
  description = "Name of the VPC"
}

variable "cidr_block" {
  type        = string
  description = "CIDR block for the VPC"
}

variable "enable_nat_gateway" {
  type        = bool
  description = "Whether to enable NAT Gateway"
  default     = true
}

variable "single_nat_gateway" {
  type        = bool
  description = "Whether to use a single NAT Gateway"
  default     = true
}

 

/3tier_ecs_fargate/alb.tf

ALB는 사용자 요청을 받아 ECS로 트래픽을 분산하는 역할을 수행한다.
HTTP 요청을 HTTPS로 리다이렉트하는 리스너랑 HTTPS 요청을 ECS 컨테이너로 전달하는 리스너를 정의했다.
보안그룹은 ALB가 외부 트래픽을 허용하고, ECS 가 ALB로부터 트래픽을 받도록 규칙을 정의했다.

resource "aws_security_group" "alb_sg" {
  name        = "${var.env}-${var.project_name}-alb-sg"
  description = "Allow HTTP/HTTPS traffic to ALB"
  vpc_id      = module.main_vpc.vpc_id

  # HTTP(80) 요청 모두 허용 (외부에서 ALB로 HTTP 요청 가능)
  ingress {
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  # HTTPS(443) 요청 모두 허용
  ingress {
    from_port   = 443
    to_port     = 443
    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"]
  }

  tags = {
    Project     = var.project_name
    Environment = var.env
  }
}

resource "aws_lb" "main_alb" {
  name               = "${var.env}-${var.project_name}-alb"
  internal           = false         # 퍼블릭 ALB (true일 경우 VPC 내부에서만 접근 가능)
  load_balancer_type = "application" # L7 로드밸런서
  security_groups    = [aws_security_group.alb_sg.id]
  subnets            = module.main_vpc.public_subnet_ids # ALB는 퍼블릭 서브넷에 배치

  tags = {
    Project     = var.project_name
    Environment = var.env
  }
}

# ALB Target group 생성 (대상 : ECS Fargate)
resource "aws_lb_target_group" "ecs_app_tg" {
  name        = "${var.env}-${var.project_name}-ecs-tg"
  port        = var.app_port
  protocol    = "HTTP"
  vpc_id      = module.main_vpc.vpc_id # ALB, Target Group, ECS 서비스는 같은 VPC에 위치
  target_type = "ip"                   # ip 주소 기반 트래픽 전달 (ECS는 IP 기반 지정만 가능)

  health_check {
    path                = "/api/image"
    protocol            = "HTTP"
    healthy_threshold   = 2 # 몇 번 연속으로 성공하면 Healthy로 판단할지
    unhealthy_threshold = 2
    timeout             = 5 # 몇 초 간격으로 헬스체크 실행하는지
    interval            = 30
    matcher             = "200" # 기대하는 HTTP 상태 코드
  }

  tags = {
    Project     = var.project_name
    Environment = var.env
  }
}

resource "aws_lb_listener" "http_listener" {
  load_balancer_arn = aws_lb.main_alb.arn
  port              = 80
  protocol          = "HTTP"

  default_action { # HTTP 요청을 HTTPS로 리다이렉트
    type = "redirect"
    redirect {
      port        = "443"
      protocol    = "HTTPS"
      status_code = "HTTP_301"
    }
  }
}

resource "aws_lb_listener" "https_listener" {
  load_balancer_arn = aws_lb.main_alb.arn
  port              = 443
  protocol          = "HTTPS"
  certificate_arn   = "... common디렉토리에서 acm 인증서 배포하고 출력된 arn 입력해주기 ..."

  default_action { # HTTPS 요쳥을 실제 백엔드(ECS)로 전달
    type             = "forward"
    target_group_arn = aws_lb_target_group.ecs_app_tg.arn
  }

}

 

/3tier_ecs_fargate/cloudfront.tf

CloudFront CDN 및 Origin Access Control (OAC)를 정의했다.
CloudFront는 S3에 저장된 정적 콘텐츠 (프론트엔드)를 캐싱해 유저에게 전달하고, ALB로 향하는 api요청을 라우팅한다. 
OAC는 S3의 버킷에서 public access를 허용하지 않아도 CloudFront가 접근할 수 있도록 설정해주었다.

# OAC(Origin Access Control) 생성
# S3가 퍼블릭하지 않아도 CloudFront가 접근 가능하게끔
resource "aws_cloudfront_origin_access_control" "frontend_oac" {
  name                              = "frontend-oac"
  origin_access_control_origin_type = "s3"
  signing_behavior                  = "always" # 모든 요청에 서명 추가
  signing_protocol                  = "sigv4"
  description                       = "OAC for frontend static S3"
}

# CDN 생성
resource "aws_cloudfront_distribution" "frontend_cdn" {
  enabled             = true # 배포 활성화
  is_ipv6_enabled     = true
  comment             = "${var.env} frontend CDN for ${var.project_name}"
  default_root_object = "index.html" # 기본 경로로 접속할 때 표시할 파일

  # 정적 웹사이트 S3
  origin {
    domain_name              = aws_s3_bucket.frontend_static_content_bucket.bucket_regional_domain_name
    origin_id                = "s3-origin-frontend"
    origin_access_control_id = aws_cloudfront_origin_access_control.frontend_oac.id
  }

  # ALB
  origin {
    domain_name = aws_lb.main_alb.dns_name
    origin_id   = "alb-origin-api"

    custom_origin_config {
      http_port              = 80
      https_port             = 443
      origin_protocol_policy = "https-only"
      origin_ssl_protocols   = ["TLSv1.2"]
    }
  }

  default_cache_behavior {
    target_origin_id       = "s3-origin-frontend"
    viewer_protocol_policy = "redirect-to-https"
    allowed_methods        = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"]
    cached_methods         = ["GET", "HEAD"]
    compress               = true

    forwarded_values {
      query_string = false
      headers      = ["Host", "Authorization", "Content-Type", "User-Agent", "Accept"]
      cookies {
        forward = "all"
      }
    }
  }

  restrictions {
    geo_restriction {
      restriction_type = "none"
    }
  }

  viewer_certificate {
    cloudfront_default_certificate = true # 기본 ACM 이용해 HTTPS 적용
  }

  tags = {
    Project     = var.project_name
    Environment = var.env
  }
}

 

/3tier_ecs_fargate/ecr.tf

백엔드 애플리케이션의 도커 이미지 저장소로 ECR을 사용하였다.
scan_on_push = true 설정으로 이미지가 푸쉬될 떄 자동으로 보안 취약점을 스캔하도록 설정했다.

resource "aws_ecr_repository" "backend_repo" {
  name                 = var.ecr_repo_name
  image_tag_mutability = "MUTABLE" # 기존 이미지 태그 덮어쓰기

  image_scanning_configuration {
    scan_on_push = true # 이미지 push 될 때 자동으로 보안 취약점 스캔
  }

  tags = {
    Project     = var.project_name
    Environment = var.env
  }
}

 

/3tier_ecs_fargate/ecs.tf

# ECS 클러스터 정의
resource "aws_ecs_cluster" "main_cluster" {
  name = "${var.env}-${var.project_name}-cluster"

  setting {
    name  = "containerInsights"
    value = "enabled"
  }

  tags = {
    Project     = var.project_name
    Environment = var.env
  }
}

# IAM 권한 (ECS Task가 AWS 리소스에 접근 가능하게끔)
resource "aws_iam_role" "ecs_task_execution_role" {
  name = "${var.env}-${var.project_name}-ecs-task-exec-role"
  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
    Environment = var.env
  }
}

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

# 로그 그룹 정의
resource "aws_cloudwatch_log_group" "ecs_task_log_group" {
  name              = "/ecs/${var.env}-${var.project_name}-task"
  retention_in_days = 7 # 7일동안 로그 보관

  tags = {
    Project     = var.project_name
    Environment = var.env
  }
}

# ECS task 정의
resource "aws_ecs_task_definition" "backend_task" {
  family                   = "${var.env}-${var.project_name}-backend-task" # 테스크 그룹 이름
  requires_compatibilities = ["FARGATE"]
  network_mode             = "awsvpc" # ENI 기반 IP 할당 (Fargate는 반드시 awsvpc로)
  cpu                      = "256"
  memory                   = "512"
  execution_role_arn       = aws_iam_role.ecs_task_execution_role.arn # 컨테이너가 ECR 이미지 pull, cloudwatch 로그 전송 등에 사용하는 IAM role

  container_definitions = jsonencode([
    {
      name      = "${var.project_name}-backend-container"                    # 컨테이너 이름
      image     = "${aws_ecr_repository.backend_repo.repository_url}:latest" # 가장 최근 태그 도커 이미지 사용
      essential = true # 컨테이너가 죽으면 전체 task가 죽음                                             

      portMappings = [ # ALB이 전달한 트래픽과 컨테이너 매핑
        {
          containerPort = var.app_port
          hostPort      = var.app_port
          protocol      = "tcp"
        }
      ],
      logConfiguration = {
        logDriver = "awslogs"
        options = {
          "awslogs-group"         = aws_cloudwatch_log_group.ecs_task_log_group.name
          "awslogs-region"        = var.aws_region
          "awslogs-stream-prefix" = "ecs"
        }
      },
      environment = [
        {
          name  = "SPRING_DATASOURCE_URL"
          value = "jdbc:postgresql://${module.main_rds.db_instance_address}:${module.main_rds.db_instance_port}/${var.database_name}"
        },
        {
          name  = "SPRING_DATASOURCE_USERNAME"
          value = var.database_username
        },
        {
          name  = "SPRING_DATASOURCE_PASSWORD"
          value = var.database_password
        }
      ]
    }
  ])

  tags = {
    Project     = var.project_name
    Environment = var.env
  }
}

# ECS 보안그룹
resource "aws_security_group" "ecs_service_sg" {
  name        = "${var.env}-${var.project_name}-ecs-service-sg"
  description = "Allow inbound traffic from ALB to ECS containers"
  vpc_id      = module.main_vpc.vpc_id

  ingress { # ALB sg에서 ECS로 들어오는 트래픽 허용
    from_port       = var.app_port
    to_port         = var.app_port
    protocol        = "tcp"
    security_groups = [aws_security_group.alb_sg.id]
  }

  egress { # 외부로 나가는 트래픽은 모두 허용
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = {
    Project     = var.project_name
    Environment = var.env
  }
}

# ECS 서비스 
resource "aws_ecs_service" "backend_service" {
  name            = "${var.env}-${var.project_name}-backend-service"
  cluster         = aws_ecs_cluster.main_cluster.id
  task_definition = aws_ecs_task_definition.backend_task.arn
  desired_count   = 1
  launch_type     = "FARGATE"

  network_configuration { # private subnet에 연결
    subnets          = module.main_vpc.private_subnet_ids
    security_groups  = [aws_security_group.ecs_service_sg.id]
    assign_public_ip = false
  }

  load_balancer { # 어떤 ALB Target group과 연결될지
    target_group_arn = aws_lb_target_group.ecs_app_tg.arn
    container_name   = "${var.project_name}-backend-container"
    container_port   = var.app_port
  }

  deployment_controller { # ECS 자체의 롤링 업데이트 사용
    type = "ECS"
  }

  lifecycle {
    ignore_changes = [task_definition]
  }

}

 

/3tier_ecs_fargate/iam.tf

ECS가 Fargate 관리하고 로드밸런서와 통합하는 등의 작업을 수행하도록 iam role을 설정했다. 

# ECS 서비스용 IAM Role
resource "aws_iam_role" "ecs_service_role" {
  name = "${var.env}-${var.project_name}-ecs-service-role"
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = "sts:AssumeRole"
        Effect = "Allow"
        Principal = {
          Service = "ecs.amazonaws.com"
        }
      },
    ]
  })
  tags = {
    Project     = var.project_name
    Environment = var.env
  }
}

resource "aws_iam_role_policy_attachment" "ecs_service_role_policy" {
  role       = aws_iam_role.ecs_service_role.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonECS_FullAccess"
}

 

/3tier_ecs_fargate/networking.tf

modules/vpc에서 정의한 VPC 모듈을 호출한다. 모듈에 필요한 변수를 정의해 VPC를 생성한다.

module "main_vpc" {
  source = "../../modules/vpc"

  name                  = "${var.env}-${var.project_name}-vpc"
  environment           = var.env
  cidr_block            = var.vpc_cidr_block
  public_subnet_cidrs   = var.public_subnet_cidrs
  private_subnet_cidrs  = var.private_subnet_cidrs
  database_subnet_cidrs = var.database_subnet_cidrs
  available_azs         = var.available_azs

  enable_nat_gateway = true
  single_nat_gateway = true

  tags = {
    Project     = var.project_name
    Environment = var.env
  }

}

 

/3tier_ecs_fargate/rds.tf

modules/rds에서 정의한 모듈을 호출한다.
ECS에서만 데이터베이스에 접근을 허용하는 보안그룹과 서브넷 그룹을 추가로 정의했다.

module "main_rds" {
  source = "../../modules/rds"

  db_identifier         = "${var.env}-${var.project_name}-rds"
  db_name               = var.database_name
  db_username           = var.database_username
  db_password           = var.database_password
  db_instance_class     = "db.t3.micro"
  db_engine             = "postgres"
  db_engine_version     = "17.4"
  db_allocated_storage  = 20
  db_multi_az           = false
  db_security_group_ids = [aws_security_group.rds_sg.id]
  db_subnet_group_name  = aws_db_subnet_group.main.name
  environment           = var.env
  tags = {
    Project     = var.project_name
    Environment = var.env
  }
}

# RDS 보안 그룹
resource "aws_security_group" "rds_sg" {
  name        = "${var.env}-${var.project_name}-rds-sg"
  description = "Allow inbound traffic from ECS Service to RDS"
  vpc_id      = module.main_vpc.vpc_id

  ingress {
    from_port       = 5432
    to_port         = 5432
    protocol        = "tcp"
    security_groups = [aws_security_group.ecs_service_sg.id]
  }

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

  tags = {
    Project     = var.project_name
    Environment = var.env
  }
}

# RDS 서브넷 그룹
resource "aws_db_subnet_group" "main" {
  name       = "${var.env}-${var.project_name}-db-subnet-group"
  subnet_ids = module.main_vpc.database_subnet_ids

  tags = {
    Project     = var.project_name
    Environment = var.env
  }

}

 

/3tier_ecs_fargate/s3.tf

프론트엔드 정적 콘텐츠를 호스팅할 S3 버킷을 생성한다.

# 정적 파일 업로드용 S3 버킷
resource "aws_s3_bucket" "frontend_static_content_bucket" {
  bucket = var.static_content_bucket_name
  tags = {
    Project     = var.project_name
    Environment = var.env
  }
}

resource "aws_s3_bucket_website_configuration" "frontend_static_content_bucket_website" {
  bucket = aws_s3_bucket.frontend_static_content_bucket.id

  index_document {
    suffix = "index.html"
  }

  error_document {
    key = "error.html"
  }
}

# Public read 정책 부여
resource "aws_s3_bucket_policy" "frontend_static_content_bucket_policy" {
  bucket = aws_s3_bucket.frontend_static_content_bucket.id
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid    = "AllowCloudFrontServicePrincipal"
        Effect = "Allow"
        Principal = {
          Service = "cloudfront.amazonaws.com"
        }
        Action   = "s3:GetObject"
        Resource = "${aws_s3_bucket.frontend_static_content_bucket.arn}/*"
        Condition = {
          "StringEquals" = {
            "AWS:SourceArn" = aws_cloudfront_origin_access_control.frontend_oac.arn
          }
        }
      }
    ]
  })
}

 

/3tier_ecs_fargate/outputs.tf

3tier_ecs_fargate에서 배포된 리소스들의 endpoint 등을 출력한다.

output "frontend_cdn_url" {
  description = "CloudFront Distribution Domain Name for frontend"
  value       = aws_cloudfront_distribution.frontend_cdn.domain_name
}

output "alb_dns_name" {
  description = "ALB DNS Name for backend API"
  value       = aws_lb.main_alb.dns_name
}

output "ecr_repository_uri" {
  description = "ECR Repository URI for backend image"
  value       = aws_ecr_repository.backend_repo.repository_url
}

output "database_endpoint" {
  description = "RDS Database Endpoint"
  value       = module.main_rds.db_instance_address
}

 

/3tier_ecs_fargate/providers.tf

테라폼 버전 지정 및 프로바이더를 aws로 지정해준다.
아까 common/s3를 배포한 뒤 출력된 내용을 backend "s3" { ... } 로 입력해준다.

terraform {
  required_version = ">= 1.0.0"

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

  backend "s3" {
    bucket         = "keembucha-terraform-state-s3-bucket"
    key            = "3tier-ecs-fargate/terraform.tfstate" # S3 버킷 내부 경로
    region         = "ap-northeast-2"
    encrypt        = true
    dynamodb_table = "terraform-lock-table"
  }
}

provider "aws" {
  region = var.aws_region
}

 

 

3tier_ecs_fargate 배포

3tier_ecs_fargate 디렉토리에서 아래 명령어를 입력해준다.
Apply Complete! ... 출력이 보이면 성공이다.

terraform plan -var-file=./terraform.tfvars

terraform apply -var-file=./terraform.tfvars

 

AWS 콘솔에 접속해 실제 리소스가 생성됐는지 확인해준다.

S3

 

RDS

 

DynamoDB

CloudFront

 

Route53

 

다음은 애플리케이션을 배포하고 실제로 서비스가 동작하는지 확인할 것이다.