Terraform을 활용한 AWS ECS Fargate 인프라 배포

Mango
31 min readFeb 7, 2023

--

요즘은 클라우드 서비스에 인프라를 구성하는 방법으로 테라폼과 같은 IaC 도구를 사용하는것을 권장합니다. 그 중 가장 보편적으로 쓰이며 많은 기능을 제공하는 테라폼으로 일반적인 ECS Fargate의 전반적인 인프라 환경을 구성해 보겠습니다.

시간 관계상 각 테라폼 코드의 상세한 설명은 생략한 점 양해 부탁 드립니다.

전반적인 인프라 구성 예시(실제 구성은 ECR, Route table, CloudWatch, EIP 등의 서비스가 추가로 배포 됩니다)

전반적인 인프라 구성은 위와 같습니다.

하나의 VPC 내에 Public / Private Subnet 으로 나누며 외부의 접근을 제한하기 위해 ECS 클러스터와 같은 서버 인스턴스는 Private Subnet 내부에 구성 합니다.

클라이언트의 요청을 받고 응답하고 VPC 내에 Public Subnet의 리소스가 인터넷에 연결할 수 있도록 Internet Gateway를 구성하고 외부 API 연동을 하는 등 외부의 요청 / 응답(Inbound / Outbound) 처리를 하기 위해 Application Load Balancer, NAT Gateway 를 Public Subnet 내부에 구성합니다.

전체 소스코드

Setup

# main.tf

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

provider "aws" {
access_key = var.access_key
secret_key = var.secret_key
region = var.region
}

인프라를 배포할 프로바이더와 사용가능 버전을 지정하고 각 프로바이더를 사용하기 위한 기본 credentials 정보를 바인딩 합니다.

# variables.tf

variable "access_key" {
type = string
}

variable "secret_key" {
type = string
}

variable "region" {
type = string
default = "ap-northeast-2"
}

variable "account_id" {
type = string
}

variable "app_name" {
type = string
}

variable "elb_account_id" {
type = string
default = "600734575887"
}

variable "domain" {
type = string
}

variable "env_suffix" {
type = string
}

variable "tpl_path" {
type = string
default = "service.config.json.tpl"
}

variable "container_port" {
type = number
default = 80
}

variable "host_port" {
type = number
default = 80
}

variable "az_count" {
type = number
default = 4
}

variable "scaling_max_capacity" {
type = number
default = 3
}

variable "scaling_min_capacity" {
type = number
default = 1
}

variable "cpu_or_memory_limit" {
type = number
default = 70
}

테라폼 동작 시점(plan, apply, destroy…)에 값을 주입할 변수들을 정의 합니다. type만 정의되어 있으면 값을 입력 받아야 하는 required 변수이고 default가 정의되어 있으면 값을 입력받지 않을 경우 default 로 입력받은 값을 사용 합니다.

variables 변수 중 프로바이더의 region이 변경 될 경우 아래 두가지 변수를 변경 해주어야 합니다.

  • region
  • elb_account_id (해당 문서에서 리전에 맞는 account id 확인)

VPC / Subnets

# network.tf

resource "aws_vpc" "cluster_vpc" {
tags = {
Name = "ecs-vpc-${var.env_suffix}"
}
cidr_block = "10.30.0.0/16"
}

data "aws_availability_zones" "available" {

}

resource "aws_subnet" "private" {
vpc_id = aws_vpc.cluster_vpc.id
count = var.az_count
cidr_block = cidrsubnet(aws_vpc.cluster_vpc.cidr_block, 8, count.index)
availability_zone = element(data.aws_availability_zones.available.names, count.index)

tags = {
Name = "ecs-private-subnet-${var.env_suffix}"
}
}

resource "aws_subnet" "public" {
count = var.az_count
cidr_block = cidrsubnet(aws_vpc.cluster_vpc.cidr_block, 8, var.az_count + count.index)
availability_zone = data.aws_availability_zones.available.names[count.index]
vpc_id = aws_vpc.cluster_vpc.id
map_public_ip_on_launch = true

tags = {
Name = "ecs-public-subnet-${var.env_suffix}"
}
}

resource "aws_internet_gateway" "cluster_igw" {
vpc_id = aws_vpc.cluster_vpc.id

tags = {
Name = "ecs-igw-${var.env_suffix}"
}
}

resource "aws_route" "internet_access" {
route_table_id = aws_vpc.cluster_vpc.main_route_table_id
destination_cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.cluster_igw.id
}

resource "aws_nat_gateway" "nat_gateway" {
count = var.az_count
subnet_id = element(aws_subnet.public.*.id, count.index)
allocation_id = element(aws_eip.nat_gateway.*.id, count.index)

tags = {
Name = "NAT gw ${var.env_suffix}"
}
}

resource "aws_eip" "nat_gateway" {
count = var.az_count
vpc = true
depends_on = [aws_internet_gateway.cluster_igw]
}

resource "aws_route_table" "private_route" {
count = var.az_count
vpc_id = aws_vpc.cluster_vpc.id

route {
cidr_block = "0.0.0.0/0"
nat_gateway_id = element(aws_nat_gateway.nat_gateway.*.id, count.index)
}

tags = {
Name = "private-route-table-${var.env_suffix}"
}
}

resource "aws_route_table" "public_route" {
vpc_id = aws_vpc.cluster_vpc.id

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

tags = {
Name = "ecs-route-table-${var.env_suffix}"
}
}

resource "aws_route_table_association" "to-public" {
count = length(aws_subnet.public)
subnet_id = element(aws_subnet.public.*.id, count.index)
route_table_id = element(aws_route_table.public_route.*.id, count.index)
}

resource "aws_route_table_association" "to-private" {
count = length(aws_subnet.private)
subnet_id = element(aws_subnet.private.*.id, count.index)
route_table_id = element(aws_route_table.private_route.*.id, count.index)
}

VPC, Internet Gateway 리소스를 생성하고 Internet Gateway와 라우팅 되어 외부와 네트워크 연결이 가능한 Public Subnet을 생성 합니다. Private Subnet 내부에 있는 인스턴스들이 외부에서 들어오는 요청을 프록싱 해주는 Application Load Balancer와 내부에서 외부로만 네트워크 접근을 가능하게 해주는 NAT Gateway 를 Public Subnet 내부에 구성하고 Elastic IP를 부여 합니다.

VPC
- Internet Gateway
- Public Subnet
- NAT Gateway
- Load Balancer
- Private Subnet

# lb.tf

resource "aws_alb" "staging" {
name = "alb-${var.env_suffix}"
subnets = aws_subnet.public.*.id
load_balancer_type = "application"
security_groups = [aws_security_group.lb.id]
internal = false

access_logs {
bucket = aws_s3_bucket.log_storage.id
prefix = "frontend-alb"
enabled = true
}

tags = {
Environment = var.env_suffix
Application = var.app_name
}
}

resource "aws_lb_listener" "https_forward" {
load_balancer_arn = aws_alb.staging.id
port = 443
protocol = "HTTPS"
certificate_arn = aws_acm_certificate.cert.arn

default_action {
type = "forward"
target_group_arn = aws_lb_target_group.staging.id
}
}

resource "aws_lb_listener" "http_forward" {
load_balancer_arn = aws_alb.staging.arn
port = 80
protocol = "HTTP"

default_action {
type = "redirect"

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

resource "aws_lb_target_group" "staging" {
vpc_id = aws_vpc.cluster_vpc.id
name = "service-alb-tg-${var.env_suffix}"
port = var.host_port
protocol = "HTTP"
target_type = "ip"
deregistration_delay = 30

health_check {
interval = 120
path = "/"
timeout = 60
matcher = "200"
healthy_threshold = 5
unhealthy_threshold = 5
}

lifecycle {
create_before_destroy = true
}
}

Public Subnet 내부에 Application Load Balancer를 구성하고 ACM에 등록 되어있는 인증서를 443 HTTPS 프로토콜에 적용 합니다. 80 포트로 요청이 올 경우 443 포트로 리다이렉션 합니다. aws_lb_target_group은 forward 요청에 관한 프로토콜, 전송 포트, 헬스체크 규칙 등에 관한 설정입니다.

# sg.tf

resource "aws_security_group" "lb" {
vpc_id = aws_vpc.cluster_vpc.id
name = "lb-sg-${var.env_suffix}"

ingress {
from_port = var.host_port
protocol = "tcp"
to_port = var.host_port
cidr_blocks = ["0.0.0.0/0"]
ipv6_cidr_blocks = ["::/0"]
}

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

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

resource "aws_security_group" "ecs_tasks" {
vpc_id = aws_vpc.cluster_vpc.id
name = "ecs-tasks-sg-${var.env_suffix}"

ingress {
from_port = var.host_port
protocol = "tcp"
to_port = var.container_port
cidr_blocks = ["0.0.0.0/0"]
}

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

Load Balancer와 ECS 인스턴스에 관한 보안그룹 규칙 설정 입니다.
Load Balancer는 80, 443 포트 요청만을 허용하고
ECS는 80 포트 요청만 허용합니다.

# logs.tf

resource "aws_s3_bucket" "log_storage" {
bucket = "ecs-access-logs-kemist-${var.env_suffix}"
force_destroy = true
}

resource "aws_cloudwatch_log_group" "service" {
name = "awslogs-service-staging-${var.env_suffix}"

tags = {
Environment = var.env_suffix
Application = var.app_name
}
}

resource "aws_s3_bucket_acl" "lb-logs-acl" {
bucket = aws_s3_bucket.log_storage.id
acl = "private"
}

data "aws_iam_policy_document" "allow-lb" {
statement {
principals {
type = "Service"
identifiers = ["logdelivery.elb.amazonaws.com"]
}

actions = ["s3:PutObject"]

resources = [
"arn:aws:s3:::${aws_s3_bucket.log_storage.bucket}/frontend-alb/AWSLogs/${var.account_id}/*"
]

condition {
test = "StringEquals"
variable = "s3:x-amz-acl"

values = [
"bucket-owner-full-control"
]
}
}
statement {
principals {
type = "Service"
identifiers = ["logdelivery.elasticloadbalancing.amazonaws.com"]
}

actions = ["s3:PutObject"]

resources = [
"arn:aws:s3:::${aws_s3_bucket.log_storage.bucket}/frontend-alb/AWSLogs/${var.account_id}/*"
]

condition {
test = "StringEquals"
variable = "s3:x-amz-acl"

values = [
"bucket-owner-full-control"
]
}
}
statement {
principals {
type = "AWS"
identifiers = ["arn:aws:iam::${var.elb_account_id}:root"]
}

actions = ["s3:PutObject"]

resources = [
"arn:aws:s3:::${aws_s3_bucket.log_storage.bucket}/frontend-alb/AWSLogs/${var.account_id}/*"
]

condition {
test = "StringEquals"
variable = "s3:x-amz-acl"

values = [
"bucket-owner-full-control"
]
}
}
}

resource "aws_s3_bucket_policy" "allow-lb" {
bucket = aws_s3_bucket.log_storage.id
policy = data.aws_iam_policy_document.allow-lb.json
}

resource "aws_s3_bucket_lifecycle_configuration" "lifecycle" {
bucket = aws_s3_bucket.log_storage.id

rule {
id = "log_lifecycle_${var.env_suffix}"
status = "Enabled"

expiration {
days = 10
}
}
}

로드밸런서의 access log를 저장할 S3 버킷과 CloudWatch group을 생성 합니다.

Domains / SSL Certificates

SSL 인증서, Route 53 호스팅 영역 설정에 관해 알아보도록 하겠습니다.

테라폼 코드를 apply 하기 전에 한가지 사전 작업이 필요합니다. 바로 사용할 유효한 도메인을 Route 53 호스팅 영역에 등록하는 작업입니다. 외부에서 구매한 도메인 이거나 Route 53 Registered Domains에 등록 되어있는 도메인을 Route 53 Hosted Zone에 등록 해주시면 됩니다.

variables.tf의 domain값으로 Hosted Zone에 등록한 url을 웹 프로토콜을 제외하고 입력 해주시면 됩니다. (예: mango1135.com)

# acm.tf

resource "aws_acm_certificate" "cert" {
domain_name = var.domain
validation_method = "DNS"
lifecycle {
create_before_destroy = true
}
}

resource "aws_acm_certificate_validation" "cert" {
certificate_arn = aws_acm_certificate.cert.arn
validation_record_fqdns = [aws_route53_record.cert_validation.fqdn]
}

해당 도메인에 대한 SSL 인증서를 발급 받는 테라폼 소스코드 입니다.

# route53.tf

data "aws_route53_zone" "front" {
name = var.domain
private_zone = false
}

resource "aws_route53_record" "front" {
zone_id = data.aws_route53_zone.front.zone_id
name = var.domain
type = "A"

alias {
name = aws_alb.staging.dns_name
zone_id = aws_alb.staging.zone_id
evaluate_target_health = true
}
}

resource "aws_route53_record" "cert_validation" {
name = tolist(aws_acm_certificate.cert.domain_validation_options)[0].resource_record_name
type = tolist(aws_acm_certificate.cert.domain_validation_options)[0].resource_record_type
zone_id = data.aws_route53_zone.front.zone_id
records = [tolist(aws_acm_certificate.cert.domain_validation_options)[0].resource_record_value]
ttl = 60
}

해당 도메인에 접근 하였을 경우 Load Balancer로 이동 하도록 A 레코드를 설정합니다.

ECR / ECS Fargate

ECR 레포지토리를 생성하고 ECS 컨테이너에 이미지를 배포 할 수 있도록 구성합니다.

# ecr.tf

resource "aws_ecr_repository" "repo" {
name = "mango/service_${var.env_suffix}"
image_tag_mutability = "MUTABLE"

image_scanning_configuration {
scan_on_push = false
}
}

resource "aws_ecr_lifecycle_policy" "repo-policy" {
repository = aws_ecr_repository.repo.name

policy = <<EOF
{
"rules": [
{
"rulePriority": 1,
"description": "Keep image deployed with tag latest",
"selection": {
"tagStatus": "tagged",
"tagPrefixList": ["latest"],
"countType": "imageCountMoreThan",
"countNumber": 1
},
"action": {
"type": "expire"
}
},
{
"rulePriority": 2,
"description": "Keep last 2 any images",
"selection": {
"tagStatus": "any",
"countType": "imageCountMoreThan",
"countNumber": 2
},
"action": {
"type": "expire"
}
}
]
}
EOF
}

도커 이미지를 저장할 레포지토리를 생성하고 policy를 설정 합니다.

# ecs.tf

data "aws_iam_policy_document" "ecs_task_execution_role" {
version = "2012-10-17"

statement {
sid = ""
effect = "Allow"
actions = ["sts:AssumeRole"]

principals {
type = "Service"
identifiers = ["ecs-tasks.amazonaws.com"]
}
}
}

resource "aws_iam_role" "ecs_task_execution_role" {
name = "ecs-staging-execution-role-${var.env_suffix}"
assume_role_policy = data.aws_iam_policy_document.ecs_task_execution_role.json
}

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

data "template_file" "service" {
template = file(var.tpl_path)

vars = {
region = var.region
aws_ecr_repository = aws_ecr_repository.repo.repository_url
tag = "latest"
container_port = var.container_port
host_port = var.host_port
app_name = var.app_name
env_suffix = var.env_suffix
}
}

resource "aws_ecs_task_definition" "service" {
family = "service-staging-${var.env_suffix}"
network_mode = "awsvpc"
execution_role_arn = aws_iam_role.ecs_task_execution_role.arn
cpu = 256
memory = 512
requires_compatibilities = ["FARGATE"]
container_definitions = data.template_file.service.rendered

tags = {
Environment = var.env_suffix
Application = var.app_name
}
}

resource "aws_ecs_cluster" "staging" {
name = "service-ecs-cluster-${var.env_suffix}"
}

resource "aws_ecs_service" "staging" {
name = "staging"
cluster = aws_ecs_cluster.staging.id
task_definition = aws_ecs_task_definition.service.arn
desired_count = length(data.aws_availability_zones.available.names)
force_new_deployment = true
launch_type = "FARGATE"

network_configuration {
security_groups = [aws_security_group.ecs_tasks.id]
subnets = aws_subnet.private.*.id
assign_public_ip = true
}

load_balancer {
target_group_arn = aws_lb_target_group.staging.arn
container_name = var.app_name
container_port = var.container_port
}

depends_on = [
aws_lb_listener.https_forward,
aws_lb_listener.http_forward,
aws_iam_role_policy_attachment.ecs_task_execution_role,
]

tags = {
Environment = var.env_suffix
Application = var.app_name
}
}
# service.config.json.tpl

[
{
"name": "${app_name}",
"image": "${aws_ecr_repository}:${tag}",
"essential": true,
"logConfiguration": {
"logDriver": "awslogs",
"options": {
"awslogs-region": "${region}",
"awslogs-stream-prefix": "staging-service",
"awslogs-group": "awslogs-service-staging-${env_suffix}"
}
},
"portMappings": [
{
"containerPort": ${container_port},
"hostPort": ${host_port},
"protocol": "tcp"
}
],
"cpu": 2,
"environment": [
{
"name": "PORT",
"value": "${host_port}"
}
],
"ulimits": [
{
"name": "nofile",
"softLimit": 65536,
"hardLimit": 65536
}
],
"mountPoints": [],
"memory": 512,
"volumesFrom": []
}
]

Private Subnet에 ECS 클러스터를 생성하고 아래와 같은 설정들을 합니다.

  • cpu, memory 등 ECS task 사양 설정
  • 템플릿 파일 지정
  • Security Group, Subnet 지정
  • Load Balancer 지정
# autoscaling.tf

resource "aws_appautoscaling_target" "ecs_target" {
max_capacity = var.scaling_max_capacity
min_capacity = var.scaling_min_capacity
resource_id = "service/${aws_ecs_cluster.staging.name}/${aws_ecs_service.staging.name}"
scalable_dimension = "ecs:service:DesiredCount"
service_namespace = "ecs"
}

resource "aws_appautoscaling_policy" "ecs_policy_memory" {
name = "memory-autoscaling"
policy_type = "TargetTrackingScaling"
resource_id = aws_appautoscaling_target.ecs_target.resource_id
scalable_dimension = aws_appautoscaling_target.ecs_target.scalable_dimension
service_namespace = aws_appautoscaling_target.ecs_target.service_namespace

target_tracking_scaling_policy_configuration {
predefined_metric_specification {
predefined_metric_type = "ECSServiceAverageMemoryUtilization"
}

target_value = var.cpu_or_memory_limit
}
}

resource "aws_appautoscaling_policy" "ecs_policy_cpu" {
name = "cpu-autoscaling"
policy_type = "TargetTrackingScaling"
resource_id = aws_appautoscaling_target.ecs_target.resource_id
scalable_dimension = aws_appautoscaling_target.ecs_target.scalable_dimension
service_namespace = aws_appautoscaling_target.ecs_target.service_namespace

target_tracking_scaling_policy_configuration {
predefined_metric_specification {
predefined_metric_type = "ECSServiceAverageCPUUtilization"
}

target_value = 60
}
}

ECS 클러스터가 CPU / Memory 사용률에 따라 CPU / Memory Limit, 최소 / 최대 테스크 갯수 등을 설정하여 ECS task 증가 or 감소 시키는 정책을 정의하여 요청량에 따라 유동적으로 컨테이너를 사용 할 수 있도록 구성합니다.

Variables

테라폼 코드를 배포(apply, plan, destroy) 하는 시점에 variables 변수들의 값이 설정 되어있는 파일을 지정 할 수 있습니다. 운영환경별 인프라가 배포될 경우 tfvars 파일을 운영환경별로 생성하여 각 워크스페이스에서의 배포에 사용 할 수 있습니다.

terraform (plan / apply / destroy) ––var-file=dev.tfvars

# dev.tfvars

access_key = "<your access_key>"
secret_key = "<your secret_key>"
account_id = "<your aws account id>"
bucket_name = "ecs-access-logs-kemist-dev"
app_name = "kemi_dev"
domain = "dev-api.kemist.io"
env_suffix = "dev"
tpl_path = "service.config.json.tpl"

Conclusions

이번 포스팅을 할 때 기본적인 VPC, Subnets, Load Balancer 등의 인스턴스를 구성하는 포스팅을 따로 빼고 ECS + Fargate 배포에 집중할지 기본적인 전체 인프라 생성을 모두 한번에 설명할지 많은 고민이 있었습니다.

결국 모든 구성을 이 한 포스팅에 담기로 한 이유는 당장 ECS + Fargate 환경을 구성 해보려는 사람들이 엑조디아 조립하듯 자료를 하나하나 찾으러 다닐 필요 없이 이 포스팅 하나만 보며 실행해 봄으로써 기본적인 인프라를 구성하고 ECS + Fargate 환경을 구축해 볼 수 있게 하는게 좋겠다고 판단 하였습니다.

전반적인 코드를 같이 담다 보니 글이 길게 느껴 질 수 있는데 금방 보실 수 있을거라 생각 합니다. 질문 사항이나 제보 주실게 있으실 경우 편하게 댓글 달아주시면 금방 답변 드리도록 하겠습니다!

--

--