Configuramos AutoScaling en ECS con Terraform

Configuramos AutoScaling en ECS con Terraform

En este artículo, voy a explicar cómo configurar un cluster ECS (Amazon Elastic Container Service) con Autoscaling utilizando Terraform en 2024, aprovechando las últimas prácticas, como el uso de Launch Templates en lugar de Launch Configurations.

Vamos a estar configurando AutoScaling tanto a nivel de servicios (Aumento/disminución de task según carga) como a nivel de Cluster (Aumento/disminución de Instancias según carga).

¿Qué es ECS y por qué usar Terraform?

ECS es un servicio de administración de contenedores altamente escalable y altamente disponible creado por AWS. Permite ejecutar contenedores Docker de manera sencilla y eficiente. Es una alternativa con una curva de aprendizaje menor a Kubernetes, por este motivo se suele utilizar para proyectos que recién están comenzando.

Terraform, por otro lado, es una herramienta de infraestructura como código que permite definir y gestionar la infraestructura en la nube de manera predecible y reproducible. En este artículo que escribí vas a poder encontrar más detalles.

¿Qué son los Capacity Provider en ECS?

Un Capacity Provider es un concepto clave en ECS que permite definir cómo y dónde se ejecutarán las tareas de contenedor. Puede estar asociado a un cluster ECS y define las estrategias de aprovisionamiento de recursos para las tareas. Esto puede incluir el uso de instancias EC2 o servicios Fargate.

¿Qué es un ASG en un cluster ECS?

Un ASG (Auto Scaling Group) es un grupo de instancias EC2 que escala automáticamente según la demanda. Está asociado a un cluster ECS para proporcionar la capacidad de cómputo necesaria para ejecutar las tareas del contenedor.

Como interactúan los Capacity Provider con los ASG

El Capacity Provider utiliza el ASG para aprovisionar y escalar automáticamente las instancias EC2 según la demanda de tareas de contenedor. Cuando se requiere más capacidad, el Capacity Provider solicita al ASG que agregue instancias al cluster ECS. Del mismo modo, cuando la carga disminuye, el Capacity Provider puede escalar hacia abajo, eliminando instancias innecesarias del ASG.

Escalado de tareas dentro de un servicio vs. Escalado de instancias en un cluster

El escalado de tareas dentro de un servicio ECS implica ajustar dinámicamente el número de tareas de contenedor en función de la demanda. Esto se puede lograr configurando un objetivo de escalado automático para el servicio.

Por otro lado, el escalado de instancias en un cluster ECS implica ajustar el número de instancias EC2 disponibles para ejecutar tareas de contenedor. Esto se logra utilizando grupos de escalado automático que agregan o eliminan instancias según sea necesario.

Beneficios económicos y de eficiencia

Escalar el cluster ECS según el tráfico de las aplicaciones permite optimizar los costos al proporcionar solo la capacidad de cómputo necesaria en cada momento. Esto evita el subaprovisionamiento, reduciendo los costos operativos, y el sobreaprovisionamiento, evitando gastos innecesarios.

Además, la capacidad de configurar fácilmente el apagado de clusters en ambientes de baja demanda fuera del horario de oficina contribuye aún más a la eficiencia operativa y a la optimización de costos.

Configurando el Cluster ECS con Terraform

Son varios los recursos que vamos a tener que crear en Terraform, les dejo un link a mi repo donde pueden ver todos los archivos con los recursos.

1- Creamos el proyecto y el archivo main.tf:

mkdir ecs-autoscaling-demo; cd ecs-autoscaling-demo; touch main.tf

Agregamos el siguiente texto al archivo main.tf:

terraform {
  required_providers {
    aws = { source = "hashicorp/aws", version = "5.17.0" }
  }
}

provider "aws" {
  profile = "default"
  region  = "us-east-1"
}

2-Creamos la VPC:

# --- VPC ---

data "aws_availability_zones" "available" { state = "available" }

locals {
  azs_count = 2
  azs_names = data.aws_availability_zones.available.names
}

resource "aws_vpc" "main" {
  cidr_block           = "10.10.0.0/16"
  enable_dns_hostnames = true
  enable_dns_support   = true
  tags                 = { Name = "demo-vpc" }
}

resource "aws_subnet" "public" {
  count                   = local.azs_count
  vpc_id                  = aws_vpc.main.id
  availability_zone       = local.azs_names[count.index]
  cidr_block              = cidrsubnet(aws_vpc.main.cidr_block, 8, 10 + count.index)
  map_public_ip_on_launch = true
  tags                    = { Name = "demo-public-${local.azs_names[count.index]}" }
}

3-Creamos la Internet Gateway:

# --- Internet Gateway ---

resource "aws_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id
  tags   = { Name = "demo-igw" }
}

resource "aws_eip" "main" {
  count      = local.azs_count
  depends_on = [aws_internet_gateway.main]
  tags       = { Name = "demo-eip-${local.azs_names[count.index]}" }
}

4-Creamos la Ruta Publica:

# --- Public Route Table ---

resource "aws_route_table" "public" {
  vpc_id = aws_vpc.main.id
  tags   = { Name = "demo-rt-public" }

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

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

5-Creamos el Cluster ECS:

# --- ECS Cluster ---

resource "aws_ecs_cluster" "main" {
  name = "demo-cluster"
}

6-Creamos el Rol de IAM para los nodos:

# --- ECS Node Role ---

data "aws_iam_policy_document" "ecs_node_doc" {
  statement {
    actions = ["sts:AssumeRole"]
    effect  = "Allow"

    principals {
      type        = "Service"
      identifiers = ["ec2.amazonaws.com"]
    }
  }
}

resource "aws_iam_role" "ecs_node_role" {
  name_prefix        = "demo-ecs-node-role"
  assume_role_policy = data.aws_iam_policy_document.ecs_node_doc.json
}

resource "aws_iam_role_policy_attachment" "ecs_node_role_policy" {
  role       = aws_iam_role.ecs_node_role.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonEC2ContainerServiceforEC2Role"
}

resource "aws_iam_instance_profile" "ecs_node" {
  name_prefix = "demo-ecs-node-profile"
  path        = "/ecs/instance/"
  role        = aws_iam_role.ecs_node_role.name
}

7-Creamos el Security Group para los nodos:

# --- SG Nodos ECS ---

resource "aws_security_group" "ecs_node_sg" {
  name_prefix = "demo-ecs-node-sg-"
  vpc_id      = aws_vpc.main.id

  egress {
    from_port   = 0
    to_port     = 65535
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

7-Creamos el Launch Template:

# --- ECS Launch Template ---

data "aws_ssm_parameter" "ecs_node_ami" {
  name = "/aws/service/ecs/optimized-ami/amazon-linux-2/recommended/image_id"
}

resource "aws_launch_template" "ecs_ec2" {
  name_prefix            = "demo-ecs-ec2-"
  image_id               = data.aws_ssm_parameter.ecs_node_ami.value
  instance_type          = "t2.micro"
  vpc_security_group_ids = [aws_security_group.ecs_node_sg.id]

  iam_instance_profile { arn = aws_iam_instance_profile.ecs_node.arn }
  monitoring { enabled = true }

  user_data = base64encode(<<-EOF
      #!/bin/bash
      echo ECS_CLUSTER=${aws_ecs_cluster.main.name} >> /etc/ecs/ecs.config;
    EOF
  )
}

8-Creamos el AutoScalingGroup(ASG):

# --- ECS ASG ---

resource "aws_autoscaling_group" "ecs" {
  name_prefix               = "demo-ecs-asg-"
  vpc_zone_identifier       = aws_subnet.public[*].id
  min_size                  = 2
  max_size                  = 8
  health_check_grace_period = 0
  health_check_type         = "EC2"
  protect_from_scale_in     = false

  launch_template {
    id      = aws_launch_template.ecs_ec2.id
    version = "$Latest"
  }

  tag {
    key                 = "Name"
    value               = "demo-ecs-cluster"
    propagate_at_launch = true
  }

  tag {
    key                 = "AmazonECSManaged"
    value               = ""
    propagate_at_launch = true
  }
}

8-Creamos el Capacity Provider para el cluster ECS:

# --- ECS Capacity Provider ---

resource "aws_ecs_capacity_provider" "main" {
  name = "demo-ecs-ec2"

  auto_scaling_group_provider {
    auto_scaling_group_arn         = aws_autoscaling_group.ecs.arn
    managed_termination_protection = "DISABLED"  # Esto determina si el grupo de Auto Scaling tiene protección de terminación administrada

    managed_scaling {
      maximum_scaling_step_size = 4   # Para que vaya agregando instancias al cluster de 4 a la vez (pra escalar mas rapido)
      minimum_scaling_step_size = 1   # Para que vaya quitando instancias del cluster de 1 a la vez
      status                    = "ENABLED"
      target_capacity           = 100 # La utilización de la capacidad objetivo como porcentaje para el proveedor de capacidad
    }
  }
}

resource "aws_ecs_cluster_capacity_providers" "main" {
  cluster_name       = aws_ecs_cluster.main.name
  capacity_providers = [aws_ecs_capacity_provider.main.name]

  default_capacity_provider_strategy {
    capacity_provider = aws_ecs_capacity_provider.main.name
    base              = 1
    weight            = 100
  }
}

9-Creamos el repositorio en ECR:

# --- ECR ---

resource "aws_ecr_repository" "app" {
  name                 = "demo-app"
  image_tag_mutability = "MUTABLE"
  force_delete         = true

  image_scanning_configuration {
    scan_on_push = true
  }
}

output "demo_app_repo_url" {
  value = aws_ecr_repository.app.repository_url
}

10-Subimos nuestra imagen de Docker a ECR:

# Get AWS repo url from Terraform outputs
export REPO=$(terraform output --raw demo_app_repo_url)
# Login to AWS ECR
aws ecr get-login-password | docker login --username AWS --password-stdin $REPO

# Pull docker image & push to our ECR
docker pull --platform linux/amd64 strm/helloworld-http:latest
docker tag strm/helloworld-http:latest $REPO:latest
docker push $REPO:latest

11-Creamos el Rol de IAM para las Task de nuestro servicio de ECS:

# --- ECS Task Role ---

data "aws_iam_policy_document" "ecs_task_doc" {
  statement {
    actions = ["sts:AssumeRole"]
    effect  = "Allow"

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

resource "aws_iam_role" "ecs_task_role" {
  name_prefix        = "demo-ecs-task-role"
  assume_role_policy = data.aws_iam_policy_document.ecs_task_doc.json
}

resource "aws_iam_role" "ecs_exec_role" {
  name_prefix        = "demo-ecs-exec-role"
  assume_role_policy = data.aws_iam_policy_document.ecs_task_doc.json
}

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

12-Creamos el log group para nuestro servicio de ECS en CloudWatch:

# --- Cloud Watch Logs ---

resource "aws_cloudwatch_log_group" "ecs" {
  name              = "/ecs/demo"
  retention_in_days = 14
}

13-Creamos la task definition para nuestro servicio:

# --- ECS Task Definition ---

resource "aws_ecs_task_definition" "app" {
  family             = "demo-app"
  task_role_arn      = aws_iam_role.ecs_task_role.arn
  execution_role_arn = aws_iam_role.ecs_exec_role.arn
  network_mode       = "awsvpc"
  cpu                = 256
  memory             = 256

  container_definitions = jsonencode([{
    name         = "app",
    image        = "${aws_ecr_repository.app.repository_url}:latest",
    essential    = true,
    portMappings = [{ containerPort = 80, hostPort = 80 }],

    environment = [
      { name = "EXAMPLE", value = "example" }
    ]

    logConfiguration = {
      logDriver = "awslogs",
      options = {
        "awslogs-region"        = "us-east-1",
        "awslogs-group"         = aws_cloudwatch_log_group.ecs.name,
        "awslogs-stream-prefix" = "app"
      }
    },
  }])
}

14-Creamos el servicio en ECS:

# --- ECS Service ---

resource "aws_security_group" "ecs_task" {
  name_prefix = "ecs-task-sg-"
  description = "Allow all traffic within the VPC"
  vpc_id      = aws_vpc.main.id

  ingress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = [aws_vpc.main.cidr_block]
  }

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

resource "aws_ecs_service" "app" {
  name            = "app"
  cluster         = aws_ecs_cluster.main.id
  task_definition = aws_ecs_task_definition.app.arn
  desired_count   = 2

  network_configuration {
    security_groups = [aws_security_group.ecs_task.id]
    subnets         = aws_subnet.public[*].id
  }

  capacity_provider_strategy {
    capacity_provider = aws_ecs_capacity_provider.main.name
    base              = 1
    weight            = 100
  }

  ordered_placement_strategy {
    type  = "spread"
    field = "attribute:ecs.availability-zone"
  }

  lifecycle {
    ignore_changes = [desired_count]
  }


  depends_on = [aws_lb_target_group.app]

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

15-Creamos el Application Load Balancer:

# --- ALB ---

resource "aws_security_group" "http" {
  name_prefix = "http-sg-"
  description = "Allow all HTTP/HTTPS traffic from public"
  vpc_id      = aws_vpc.main.id

  dynamic "ingress" {
    for_each = [80, 443]
    content {
      protocol    = "tcp"
      from_port   = ingress.value
      to_port     = ingress.value
      cidr_blocks = ["0.0.0.0/0"]
    }
  }

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

resource "aws_lb" "main" {
  name               = "demo-alb"
  load_balancer_type = "application"
  subnets            = aws_subnet.public[*].id
  security_groups    = [aws_security_group.http.id]
}

resource "aws_lb_target_group" "app" {
  name_prefix = "app-"
  vpc_id      = aws_vpc.main.id
  protocol    = "HTTP"
  port        = 80
  target_type = "ip"

  health_check {
    enabled             = true
    path                = "/"
    port                = 80
    matcher             = 200
    interval            = 10
    timeout             = 5
    healthy_threshold   = 2
    unhealthy_threshold = 3
  }
}

resource "aws_lb_listener" "http" {
  load_balancer_arn = aws_lb.main.id
  port              = 80
  protocol          = "HTTP"

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

output "alb_url" {
  value = aws_lb.main.dns_name
}

16-Editamos el recurso creado anteriormente de Servicio ECS para conectarlo al ALB:

# --- ECS Service ---

resource "aws_ecs_service" "app" {
  # ESTO ES LO QUE TENEMOS QUE AGREGAR:

  depends_on = [aws_lb_target_group.app]

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

17-Por último configuramos el AutoScaling de nuestro servicio de ECS:

# --- ECS Service Auto Scaling ---

resource "aws_appautoscaling_target" "ecs_target" {
  service_namespace  = "ecs"
  scalable_dimension = "ecs:service:DesiredCount"
  resource_id        = "service/${aws_ecs_cluster.main.name}/${aws_ecs_service.app.name}"
  min_capacity       = 2
  max_capacity       = 5
}

resource "aws_appautoscaling_policy" "ecs_target_cpu" {
  name               = "application-scaling-policy-cpu"
  policy_type        = "TargetTrackingScaling"
  service_namespace  = aws_appautoscaling_target.ecs_target.service_namespace
  resource_id        = aws_appautoscaling_target.ecs_target.resource_id
  scalable_dimension = aws_appautoscaling_target.ecs_target.scalable_dimension

  target_tracking_scaling_policy_configuration {
    predefined_metric_specification {
      predefined_metric_type = "ECSServiceAverageCPUUtilization"
    }

    target_value       = 80
    scale_in_cooldown  = 300
    scale_out_cooldown = 300
  }
}

resource "aws_appautoscaling_policy" "ecs_target_memory" {
  name               = "application-scaling-policy-memory"
  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       = 80
    scale_in_cooldown  = 300
    scale_out_cooldown = 300
  }
}

Probando AutoScaling en ECS

Para probar el ASG tenemos que cambiar las Desired Task en nuestro servicio.

Como podemos ver, el servicio inicia con 2 task, y nuestro cluster con 2 Instancias:

Ahora necesitamos cambiar las desired task a un número mayor, para que el Capacity Provider genere más instancias para aprovisionar las nuevas task:

Luego de unos minutos vamos a ver que nuestro Cluster ahora tiene 8 instancias en total para soportar la carga de las 7 tasks:

Pasados unos minutos nuestro servicio va a detectar que hay muchas task corriendo cuando no hay una carga que lo amerite y cambiara el desired task de nuestro al valor por defecto. Al reducir las task, el Capacity Provider detecta que hay muchas instancias corriendo, cuando no lo amerita la cantidad de task. Por este motivo va a empezar a matar instancias hasta lograr el equilibrio.

Por último podemos configurar a mano el desired task de nuestro servicio en 0, y notaremos que en nuestro cluster mueren todas las instancias.

Esto es útil para configurar el apagado de nuestro cluster mediante una lambda, como explico en el siguiente artículo.

En resumen, configurar un cluster ECS con Terraform en 2024 utilizando Launch Templates y aprovechando el escalado automático proporciona una infraestructura altamente escalable, eficiente y económica para ejecutar aplicaciones en contenedores en la nube de AWS.