Deploy AWS Fargate Amazon ECS App and Infrastructure Using Terraform

2023.06.20

Hello everyone, This is Aayush Jain in this Blog i will write about container Service and it's deployment using Terraform which is an IaC service

Introduction:

What is AWS Fargate?

AWS Fargate is a serverless compute engine for containers that allows you to run containerized applications without managing the underlying infrastructure.

https://aws.amazon.com/fargate/

What is Terraform?

Terraform is an open-source infrastructure as code (IaC) tool used to define, provision, and manage cloud resources across various providers with a declarative approach.

https://registry.terraform.io/providers/hashicorp/aws/latest/docs

Command Refferences:

Cmd Discription
terraform init This command initializes a Terraform project, downloading provider plugins and setting up the backend for managing infrastructure.
terraform plan This command generates an execution plan, showing a summary of the changes that Terraform will apply to the infrastructure without actually modifying it
terraform apply This command applies the changes specified in the Terraform configuration to provision or modify the infrastructure.
terraform destroy This command destroys all the resources defined in the Terraform configuration, effectively removing the provisioned infrastructure.

What Is ECS?

Prerequisites:

  • AWS Account
  • IDE
  • Terraform And terraform plugins
  • for MacOS:

    brew install terraform

  • Docker
  • for MacOS:

    brew install Docker --cask

  • AWS CLI
  • for MacOS:

    brew install awscli

Architecture Diagram:

Parameters Used:

Networking

VPC

VPC Name CIDR Tenancy Remarks
Production-VPC 10.0.0.0/16 default
  • Subnets
Subnet Name Availability Zone CIDR Route Table Remarks
Private-Subnet-1 ap-northeast-1a 10.0.3.0/24 Private-Route-Table Internet connection via Internet Gateway
Private-Subnet-2 ap-northeast-1c 10.0.4.0/24 Private-Route-Table Internet connection via Internet Gateway
Public-Subnet-1 ap-northeast-1a 10.0.1.0/24 Public-Route-Table Internet connection via NAT Gateway
Public-Subnet-2 ap-northeast-1c 10.0.2.0/24 Public-Route-Table Internet connection via NAT Gateway
  • Internet Gateway
Item Value Remarks
Name Production-IGW
Attached VPC Production-VPC
  • NAT Gateway
Item Value Remarks
Name Production-NAT-GW
Attached VPC Production-VPC
Subnet Public-Subnet-1

Route Tables

  • Private-Route-Table
Item Value Remarks
Name Private-Route-Table
VPC Production-VPC
Subnet Association Private-Subnet-1 Private-Subnet-2
  • Routes for Private Route-Table
recipient target status propagated remarks
10.0.0.0/16 local active no
0.0.0.0/0 nat-xxx active no
  • Public-Route-Table
Item Value Remarks
Name Public-Route-Table
VPC Production-VPC
Subnet Association Public-Subnet-1 Public-Subnet-2
  • Routes for Public Route-Table
recipient target status propagated remarks
10.0.0.0/16 local active no
0.0.0.0/0 igw-xxx active no

ALB

Item Value Remarks
Type ALB
ELB Name DevelopersIO-ECS-Cluster-ALB
Subnet Public-Subnet-1 , Public-Subnet-2
Security Group DevelopersIO-ECS-Cluster-ALB-SG
Listener HTTPS:443
Deletion Protection Disabled
Idle Timeout 60 seconds
HTTP/2 Enabled
Desync Mitigation Mode Defensive
Drop Invalid Header Fields Disabled
Access Logs Disabled
Preserve host header Disabled
Client port preservation Disabled

Listners

Path Target Security Policy SSL Certificate Notes
flask.xxx.xxxxxxx.xxx flask-TG ELBSecurityPolicy-TLS-1-2-2017-01 *.Your_Domain

ALB TargetGroup

Item Configuration Value Notes
Target Group Name flask-TG
Port http:50000
Deregistration Delay 300 seconds
Stickiness Disabled
Targets ip

List of Security Groups

Security Group Name VPC Purpose Notes
flask-SG Production-VPC
DevelopersIO-ECS-Cluster-SG Production-VPC
DevelopersIO-ECS-Cluster-ALB-SG Production-VPC ALB

Security Group Configuration

flask-SG

Inbound Rules

Type Protocol Port Range Source Notes
Custom TCP 5000 10.0.0.0/16 Within VPC
HTTP TCP 80 10.0.0.0/16 Within VPC
  • Outbound rules allow all traffic.

DevelopersIO-ECS-Cluster-ALB-SG

Inbound Rules

Type Protocol Port Range Source Notes
HTTPS TCP 443 0.0.0.0/0 From Anywhere
  • Outbound rules allow all traffic.

IAM

IAM Role Name IAM Policy
fargate_iam_role fargate_iam_policy

ECS

Cluster List

Cluster Name CloudWatch monitoring Notes
Production-Fargate-Cluster Default

Service List

Service Name Cluster Name Notes
flask Production-Fargate-Cluster

Service Configuration

Item Value Notes
Launch Type FARGATE
Task Definition flask
Service Type REPLICA
Number of Tasks 2
Deployment Type ECS
Minimum Health Percentage 100
Maximum Percentage 200
Circuit Breaker Disabled
VPC Production-VPC
Subnet Private-Subnet-1,Private-Subnet-2
Security Group flask-SG
Public IP Turned on
Health Check Grace Period -
Load Balancer DevelopersIO-ECS-Cluster-ALB
Target Group flask-TG
Enable Service Discovery Integration Disabled
AutoScaling Do not adjust the desired count

Task Definition

flask

Item value Notes
Compatibility Launch Type FARGATE
Task Role flask-IAM-Role
Network Mode awsvpc Fixed as awsvpc for FARGATE
Task Execution Role fargate-IAM-Role
Task Memory 1024 Potential for future expansion
Task CPU 512
Container Definitions flask
Service Integration Disabled
Proxy Configuration Disabled
Volumes None

Public ECR

Repository Name
flask-docker

Route53 Records

Record name Type Routing policy Route Traffic to
*.Your_Domain_Name A Simple Alb Domain

Prior to beginning, it is necessary to create an ECR repository and upload our Docker image to it.

Terraform Template:

VPC


resource "aws_ecs_cluster" "production-fargate-cluster" {
  name = "Production-Fargate-Cluster"
}

resource "aws_alb" "ecs_cluster_alb" {
  name            = "${var.ecs_cluster_name}-ALB"
  internal        = false
  security_groups = [aws_security_group.ecs_alb_security_group.id]
  # subnets         = [split(",", join(",", data.terraform_remote_state.infrastructure.outputs.public_subnets))]
  subnets         = tolist([aws_subnet.public-subnet-1.id, aws_subnet.public-subnet-2.id])

  tags = {
    Name = "${var.ecs_cluster_name}-ALB"
  }
}
  • The first code block defines an AWS ECS cluster resource with the name "Production-Fargate-Cluster".
  • The second code block defines an AWS Application Load Balancer resource with the name "${var.ecs_cluster_name}-ALB". It is set to be an external load balancer (internal = false) and associated with the specified security groups and subnets.

resource "aws_alb_listener" "ecs_alb_https_listener" {
  load_balancer_arn = aws_alb.ecs_cluster_alb.arn
  port              = 443
  protocol          = "HTTPS"
  ssl_policy        = "ELBSecurityPolicy-TLS-1-2-2017-01"
  certificate_arn   = aws_acm_certificate.ecs_domain_certificate.arn

  default_action {
    type             = "forward"
    target_group_arn = aws_alb_target_group.ecs_default_target_group.arn
  }

  depends_on = [aws_alb_target_group.ecs_default_target_group]
}
  • This code block defines an AWS ALB listener for HTTPS traffic on port 443.
  • It specifies the load balancer ARN, protocol, SSL policy, and certificate ARN for HTTPS communication.
  • The default action for incoming requests is to forward them to the specified target group ARN.
  • The code block depends on the creation of the target group before it can be successfully created.

resource "aws_alb_target_group" "ecs_default_target_group" {
  name     = "${var.ecs_cluster_name}-TG"
  port     = 80
  protocol = "HTTP"
  vpc_id   = aws_vpc.production-vpc.id

  tags = {
    Name = "${var.ecs_cluster_name}-TG"
  }
}
  • This code block defines an AWS ALB target group for routing incoming requests to the ECS cluster.
  • It specifies the target group name, port, protocol, and the VPC ID where the target group resides.
  • Tags are added to the target group for identification purposes.

resource "aws_route53_record" "ecs_load_balancer_record" {
  name    = "*.${var.ecs_domain_name}"
  type    = "A"
  zone_id = data.aws_route53_zone.ecs_domain.zone_id

  alias {
    evaluate_target_health = false
    name                   = aws_alb.ecs_cluster_alb.dns_name
    zone_id                = aws_alb.ecs_cluster_alb.zone_id
  }
}
  • This code block defines an AWS Route 53 record for mapping a wildcard subdomain to the ALB's DNS name.
  • It specifies the record name, type (A record), and the Route 53 zone ID where the record will be created.
  • The alias is configured to point to the ALB's DNS name, and target health evaluation is

ALB.tf

Certainly! Here's the provided code with comments added to explain each code block:


resource "aws_ecs_cluster" "production-fargate-cluster" {
  name = "Production-Fargate-Cluster"
}
  • This code block defines an AWS ECS cluster resource with the name "Production-Fargate-Cluster".

resource "aws_alb" "ecs_cluster_alb" {
  name            = "${var.ecs_cluster_name}-ALB"
  internal        = false
  security_groups = [aws_security_group.ecs_alb_security_group.id]
  # subnets         = [split(",", join(",", data.terraform_remote_state.infrastructure.outputs.public_subnets))]
  subnets         = tolist([aws_subnet.public-subnet-1.id, aws_subnet.public-subnet-2.id])

  tags = {
    Name = "${var.ecs_cluster_name}-ALB"
  }
}
  • This code block defines an AWS Application Load Balancer resource with the name "${var.ecs_cluster_name}-ALB".
  • It is set to be an external load balancer (internal = false) and associated with the specified security groups and subnets.

resource "aws_alb_listener" "ecs_alb_https_listener" {
  load_balancer_arn = aws_alb.ecs_cluster_alb.arn
  port              = 443
  protocol          = "HTTPS"
  ssl_policy        = "ELBSecurityPolicy-TLS-1-2-2017-01"
  certificate_arn   = aws_acm_certificate.ecs_domain_certificate.arn

  default_action {
    type             = "forward"
    target_group_arn = aws_alb_target_group.ecs_default_target_group.arn
  }

  depends_on = [aws_alb_target_group.ecs_default_target_group]
}
  • This code block defines an AWS ALB listener for HTTPS traffic on port 443.
  • It specifies the load balancer ARN, protocol, SSL policy, and certificate ARN for HTTPS communication.
  • The default action for incoming requests is to forward them to the specified target group ARN.
  • The code block depends on the creation of the target group before it can be successfully created.

resource "aws_alb_target_group" "ecs_default_target_group" {
  name     = "${var.ecs_cluster_name}-TG"
  port     = 80
  protocol = "HTTP"
  vpc_id   = aws_vpc.production-vpc.id

  tags = {
    Name = "${var.ecs_cluster_name}-TG"
  }
}
  • This code block defines an AWS ALB target group for routing incoming requests to the ECS cluster.
  • It specifies the target group name, port, protocol, and the VPC ID where the target group resides.
  • Tags are added to the target group for identification purposes.

resource "aws_route53_record" "ecs_load_balancer_record" {
  name = "*.${var.ecs_domain_name}"
  type = "A"
  zone_id = data.aws_route53_zone.ecs_domain.zone_id

  alias {
    evaluate_target_health  = false
    name                    = aws_alb.ecs_cluster_alb.dns_name
    zone_id                 = aws_alb.ecs_cluster_alb.zone_id
  }
}
  • This code block defines an AWS Route 53 record for mapping a wildcard subdomain to the ALB's DNS name.
  • It specifies the record name, type (A record), and the Route 53 zone ID where the record will be created.
  • The alias is configured to point to the ALB's DNS name.

Security Group


variable "internet_cidr_block" {}

resource "aws_security_group" "ecs_security_group" {
  name        = "${var.ecs_cluster_name}-SG"
  description = "Security group for ECS to allow inbound and outbound communication"
  vpc_id      = "${aws_vpc.production-vpc.id}"

  # Ingress rules
  ingress {
    from_port   = 32768
    protocol    = "TCP"
    to_port     = 65535
    cidr_blocks = [var.internet_cidr_block]
  }

  ingress {
    from_port   = 5000
    protocol    = "TCP"
    to_port     = 5000
    cidr_blocks = [var.internet_cidr_block]
  }
  # Egress rule to allow all outbound traffic
  egress {
    from_port   = 0
    protocol    = "-1"
    to_port     = 0
    cidr_blocks = [var.internet_cidr_block]
  }

  tags = {
    Name = "${var.ecs_cluster_name}-SG"
  }
}

resource "aws_security_group" "ecs_alb_security_group" {
  name        = "${var.ecs_cluster_name}-ALB-SG"
  description = "Security group for ALB to handle traffic for the ECS cluster"
  vpc_id      = "${aws_vpc.production-vpc.id}"

  # Ingress rule for HTTPS traffic
  ingress {
    from_port   = 443
    protocol    = "TCP"
    to_port     = 443
    cidr_blocks = [var.internet_cidr_block]
  }

  # Egress rule to allow all outbound traffic
  egress {
    from_port   = 0
    protocol    = "-1"
    to_port     = 0
    cidr_blocks = [var.internet_cidr_block]
  }
}

taskdefination.tf


variable "ecs_service_name" {}
variable "docker_image_url" {}
variable "memory" {}
variable "docker_container_port" {}
variable "flask_profile" {}
variable "desired_task_number" {}

These lines define the variables that will be used in the Terraform configuration. These variables are placeholders that will be provided with values when executing the Terraform plan.


data "template_file" "ecs_task_definition_template" {
  template = file("task_defination.json")

  vars = {
    task_definition_name  = var.ecs_service_name
    ecs_service_name      = var.ecs_service_name
    docker_image_url      = var.docker_image_url
    memory                = var.memory
    docker_container_port = var.docker_container_port
    flask_profile         = var.flask_profile
    region                = var.region
  }
}

This block specifies a data source that reads a template file called "task_defination.json" and populates it with the variables defined above. The rendered template will be used later in the aws_ecs_task_definition resource.


resource "aws_ecs_task_definition" "apache-task-definition" {
  container_definitions    = data.template_file.ecs_task_definition_template.rendered
  family                   = var.ecs_service_name
  cpu                      = 512
  memory                   = var.memory
  requires_compatibilities = ["FARGATE"]
  network_mode             = "awsvpc"
  execution_role_arn       = aws_iam_role.fargate_iam_role.arn
  task_role_arn            = aws_iam_role.fargate_iam_role.arn
}

This resource block defines an ECS task definition. It specifies the container definitions, task family, CPU and memory allocations, compatibility requirements, network mode, and the execution and task role ARNs.


resource "aws_iam_role" "fargate_iam_role" {
  name = "${var.ecs_service_name}-IAM-Role"

  assume_role_policy = <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Service": ["ecs.amazonaws.com", "ecs-tasks.amazonaws.com"]
      },
      "Action": "sts:AssumeRole"
    }
  ]
}
EOF
}

This resource block creates an IAM role named ${var.ecs_service_name}-IAM-Role which allows ECS services and tasks to assume this role.


resource "aws_iam_role_policy" "fargate_iam_policy" {
  name = "${var.ecs_service_name}-IAM-Role"
  role = aws_iam_role.fargate_iam_role.id

  policy = <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "ecs:*",
        "ecr:*",
        "logs:*",
        "cloudwatch:*",
        "elasticloadbalancing:*"
      ],
      "Resource": "*"
    }
  ]
}
EOF
}

This resource block attaches a policy to the IAM role created above. The policy grants permissions for various ECS, ECR, CloudWatch, and Elastic Load Balancing actions.


resource "aws_ecs_service" "ecs_service" {
  name            = var.ecs_service_name
  task_definition = var.ecs_service_name
  desired_count   = var.desired_task_number
  cluster         = aws_ecs_cluster.production-farg

ate-cluster.name
  launch_type     = "FARGATE"

  network_configuration {
    subnets          = tolist([aws_subnet.private-subnet-1.id, aws_subnet.private-subnet-2.id])
    security_groups  = [aws_security_group.app_security_group.id]
    assign_public_ip = true
  }

  load_balancer {
    container_name   = var.ecs_service_name
    container_port   = var.docker_container_port
    target_group_arn = aws_alb_target_group.ecs_app_target_group.arn
  }
}

This resource block creates an ECS service. It specifies the name, task definition, desired count, cluster, launch type, network configuration, and load balancer settings for the service.


resource "aws_security_group" "app_security_group" {
  name        = "${var.ecs_service_name}-SG"
  description = "Security group for the flask app to communicate in and out"
  vpc_id      = aws_vpc.production-vpc.id

  ingress {
    from_port   = 80
    protocol    = "TCP"
    to_port     = 80
    cidr_blocks = [var.vpc_cidr]
  }

  ingress {
    from_port   = 5000
    protocol    = "TCP"
    to_port     = 5000
    cidr_blocks = [var.vpc_cidr]
  }

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

  tags = {
    Name = "${var.ecs_service_name}-SG"
  }
}

This resource block creates a security group for the flask app. It allows inbound traffic on ports 80 and 5000 from the VPC CIDR range and allows all outbound traffic.


resource "aws_alb_target_group" "ecs_app_target_group" {
  name        = "${var.ecs_service_name}-TG"
  port        = var.docker_container_port
  protocol    = "HTTP"
  vpc_id      = aws_vpc.production-vpc.id
  target_type = "ip"

  health_check {
    path                = "/"
    protocol            = "HTTP"
    matcher             = "200"
    interval            = "60"
    timeout             = "30"
    unhealthy_threshold = "3"
    healthy_threshold   = "3"
  }

  tags = {
    Name = "${var.ecs_service_name}-TG"
  }
}

This resource block creates a target group for the ECS application. It specifies the name, port, protocol, VPC ID, target type, and health check settings for the target group.


resource "aws_alb_listener_rule" "ecs_alb_listener_rule" {
  listener_arn = aws_alb_listener.ecs_alb_https_listener.arn
  priority     = 100

  action {
    type             = "forward"
    target_group_arn = aws_alb_target_group.ecs_app_target_group.arn
  }

  condition {
    host_header {
      values = ["${lower(var.ecs_service_name)}.${var.ecs_domain_name}"]
    }
  }
}

This resource block creates a listener rule for the ALB listener. It specifies the listener ARN, priority, action type, and target group ARN. It also includes a condition to match the host header of the incoming requests.


resource "aws_cloudwatch_log_group" "flaskapp_log_group" {
  name = "${var.ecs_service_name}-LogGroup"
}


This resource block creates a CloudWatch log group for the ECS service. It specifies the name of the log group based on the ECS service name.

domain.tf


variable "ecs_cluster_name" {}
variable "ecs_domain_name" {}

resource "aws_acm_certificate" "ecs_domain_certificate" {
  domain_name       = "*.${var.ecs_domain_name}"
  validation_method = "DNS"

  tags = {
    Name = "${var.ecs_cluster_name}-Certificate"
  }
}

This code block defines a variable for the ECS cluster name and ECS domain name. It also creates an ACM certificate resource for the ECS domain. The certificate's domain name is set to "*.ecs_domain_name" to include all subdomains. DNS validation method is used to validate the certificate, and tags are added to identify the certificate.


data "aws_route53_zone" "ecs_domain" {
  name         = var.ecs_domain_name
  private_zone = false
}

This code block retrieves information about the Route 53 hosted zone for the ECS domain. It uses the domain name variable to find the corresponding zone. The private_zone parameter is set to false to retrieve information for a public hosted zone.


resource "aws_route53_record" "cert_validation" {
  for_each = {
    for ecs in aws_acm_certificate.ecs_domain_certificate.domain_validation_options : ecs.domain_name => {
      name   = ecs.resource_record_name
      record = ecs.resource_record_value
      type   = ecs.resource_record_type
    }
  }

  allow_overwrite = true
  name            = each.value.name
  records         = [each.value.record]
  ttl             = 60
  type            = each.value.type
  zone_id         = data.aws_route53_zone.ecs_domain.zone_id
}

This code block creates Route 53 DNS records for certificate validation. It uses the for_each meta-argument to iterate over the domain validation options of the ACM certificate resource. Each iteration represents a DNS record to be created. The record's name, value, type, TTL, and zone ID are specified based on the validation options.


resource "aws_acm_certificate_validation" "ecs_domain_certificate_validation" {
  certificate_arn         = aws_acm_certificate.ecs_domain_certificate.arn
  validation_record_fqdns = [for record in aws_route53_record.cert_validation : record.fqdn]
}

This code block performs certificate validation by creating a certificate validation resource. It specifies the ARN of the ACM certificate to be validated and the FQDNs of the validation records created in Route 53. The certificate validation process will check if the DNS records are properly configured to prove domain ownership.

Run Book

terraform init -var-file="dev.tfvars"

terraform apply -var-file="dev.tfvars"

Result

open your domain in new browser you should be able to view Docker page

flask.your_domain.xxx

Conclusion:

Deploying a Fargate ECS application and its infrastructure using Terraform provides a reliable and efficient approach to managing containerised applications on AWS. By utilising Terraform's declarative syntax and infrastructure-as-code principles, the process becomes automated, scalable and repeatable. Through the various Terraform resources and configurations, including task definitions, security groups, IAM roles, load balancers and DNS validations, the entire lifecycle of the Fargate ECS application can be seamlessly managed and orchestrated. This approach enables developers and operations teams to easily deploy and manage their applications, ensuring consistent and reliable environments while taking advantage of AWS' powerful Fargate service for container orchestration.