Como construir uma aplicação escalável com Terraform e AWS

ezequiel_lopes

Ezequiel Lopes

Posted on July 1, 2024

Como construir uma aplicação escalável com Terraform e AWS

Neste artigo, mostrarei como construir um sistema escalável e resiliente na AWS utilizando Terraform. O sistema será escalável horizontalmente e distribuído em diferentes zonas de disponibilidade. Além disso, terá um health check para verificar a saúde das instâncias.

Resiliência

Um sistema resiliente é capaz de se adaptar a condições anormais e manter seu funcionamento total ou parcial. Para isso, existem várias estratégias tanto para a infraestrutura quanto para o software, como políticas de retry, mensageria, sistema distribuído, health check, redundância, entre outros.

O que acontece se o volume de clientes acessando nosso site for maior do que o planejado? O sistema suportaria esses usuários? O que acontece se a zona de disponibilidade em que nosso sistema está localizado ficar fora do ar? Ou se recebermos um ataque de negação de serviço? Esses eventos não são nossa culpa, mas precisamos ter estratégias para nos proteger quando esse tipo de evento acontecer.

Construiremos uma estratégia que minimiza os riscos, mas não garante resiliência total. Para alcançar resiliência total do seu sistema, prepare-se para criar um servidor no espaço, como mencionado no livro "Designing Data-Intensive Applications".

Terraform

Terraform é uma ferramenta para provisionamento de infraestrutura como código. Facilitando muito todo o processo de criação, edição e deleção de diversos recursos, incluindo redes virtuais, máquinas e sistemas de armazenamento, entre outros. Não se limitando apenas à AWS, com Terraform é possível integrar-se a diversos provedores de nuvem, como Azure, GCP, Oracle e outros, além de ser possível importar projetos existentes neles.

Auto Scaling

Auto Scaling é a capacidade de aumentar ou reduzir o número de workloads de acordo com as necessidades a qualquer momento. A AWS oferece um serviço de Auto Scaling que podemos integrar ao nosso sistema. Permitindo criar mais máquinas com base em métricas como utilização de CPU, memória ou número de requisições. Quando a demanda diminui, as máquinas podem ser removidas, garantindo que os recursos sejam utilizados de forma eficiente e econômica.

Load Balancer

Com várias máquinas trabalhando em conjunto, o Load Balancer tem o papel de decidir para onde cada requisição deve ser direcionada. Existem várias estratégias de balanceamento, desde as mais simples, onde cada requisição é distribuída igualmente entre as máquinas, até as mais complexas, que consideram a carga atual de cada instância.

Bora lá

Nosso sistema funcionará da seguinte maneira:

  • Um load balancer na frente que distribuirá as requisições,
  • Dois security groups, um para o load balancer e outro para as instâncias EC2,
  • Auto Scaling que será responsável por criar as máquinas,
  • Um template que terá as configurações das instâncias.

Primeiramente, vamos executar o comando para iniciar nosso projeto Terraform.

terraform init
Enter fullscreen mode Exit fullscreen mode

Vamos criar o arquivo de variáveis onde definiremos valores como o nome da aplicação, a região que utilizaremos, o tipo da instância, os valores de saúde, entre outros.

// variables.tf
variable "aws_region" {
  type        = string
  description = "AWS region where the resources will be deployed."
  default     = "us-east-2"
}

variable "environment" {
  type        = string
  description = "Name of the deployment environment (e.g., dev, stage, prod)."
  default     = "dev"
}

variable "service_name" {
  type        = string
  description = "Name of the service/application to be deployed."
  default     = "app-go"
}

variable "instance_config" {
  description = "Configuration for the EC2 instances."
  type = object({
    ami      = string
    type     = string
    key_name = optional(string, null)
  })
  default = {
    ami  = "ami-09040d770ffe2224f"
    type = "t2.medium"
  }
}

variable "alb_health_check_config" {
  description = "Configuration for the Application Load Balancer (ALB) health checks."
  nullable    = true
  default     = {}
  type = object({
    enabled             = optional(bool, true)
    healthy_threshold   = optional(number, 3)
    interval            = optional(number, 30)
    matcher             = optional(string, "200")
    path                = optional(string, "/healthz")
    port                = optional(number, 80)
    protocol            = optional(string, "HTTP")
    timeout             = optional(number, 5)
    unhealthy_threshold = optional(number, 3)
  })
}

variable "autoscaling_group_config" {
  description = "Configuration for the Auto Scaling group."
  default     = {}
  type = object({
    desired_capacity          = optional(number, 2)
    min_size                  = optional(number, 1)
    max_size                  = optional(number, 5)
    health_check_grace_period = optional(number, 320) // Grace period for health checks (seconds).
    health_check_type         = optional(string, "ELB")
    force_delete              = optional(bool, false)
  })
}

variable "autoscaling_policy_cpu" {
  description = "Configuration for the CPU utilization auto scaling policy."
  nullable    = true
  default     = {}
  type = object({
    enabled          = optional(bool, true)
    name             = optional(string, "CPU utilization")
    disable_scale_in = optional(bool, false)
    target_value     = optional(number, 40)
  })
}

variable "autoscaling_policy_alb" {
  description = "Configuration for the ALB request rate auto scaling policy."
  nullable    = true
  default     = {}
  type = object({
    enabled          = optional(bool, true)
    name             = optional(string, "Load balancer request per minute")
    disable_scale_in = optional(bool, false)
    target_value     = optional(number, 40)
  })
}
Enter fullscreen mode Exit fullscreen mode

No security group, iremos configurar da seguinte forma: no EC2, iremos permitir apenas tráfego vindo do load balancer na porta 80, e no load balancer também iremos permitir apenas a porta 80 vinda da internet.

// security_groups.tf
resource "aws_security_group" "alb" {
  name        = "${local.namespaced_service_name}-alb"
  description = "Allow HTTP internet traffic"
  vpc_id      = aws_vpc.this.id

  ingress {
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = local.internet_cidr_block
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = local.internet_cidr_block
  }

  tags = {
    "Name" = "${local.namespaced_service_name}-alb"
  }
}

resource "aws_security_group" "autoscaling_group" {
  name        = "${local.namespaced_service_name}-autoscaling-group"
  description = "Allow HTTP traffic only through the load balancer"
  vpc_id      = aws_vpc.this.id

  ingress {
    from_port       = 80
    to_port         = 80
    protocol        = "tcp"
    security_groups = [aws_security_group.alb.id]

  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = local.internet_cidr_block
    description = "Allow all traffic"
  }

  tags = {
    "Name" = "${local.namespaced_service_name}-autoscaling-group"
  }
}
Enter fullscreen mode Exit fullscreen mode

Aqui está a configuração do template que utilizaremos nas instâncias. Temos também um script app-setup.sh de inicialização, que será responsável por configurar e iniciar nosso projeto.

// template.tf
resource "aws_launch_template" "this" {
  name_prefix   = local.namespaced_service_name
  image_id      = var.instance_config.ami
  instance_type = var.instance_config.type
  user_data     = filebase64("app-setup.sh")

  monitoring {
    enabled = true
  }

  network_interfaces {
    associate_public_ip_address = true
    security_groups             = [aws_security_group.autoscaling_group.id]
  }

  tag_specifications {
    resource_type = "instance"
    tags = {
      "Name" = "${local.namespaced_service_name}-server"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

O app-setup.sh contém as configurações necessárias para iniciar a aplicação de exemplo em Golang. Ele instala o Golang, clona o repositório do projeto de exemplo (https://github.com/infezek/app-alb-terraform.git) e inicia a aplicação.

O servidor de exemplo possui duas rotas: a rota / retorna uma mensagem de "ok" e a zona de disponibilidade em que a instância está rodando, enquanto a rota /healthz retorna uma mensagem de "ok" nos primeiros 60 segundos e depois retorna um erro, fazendo com que o health check a identifique e reinicie a instância.

#!/bin/bash
sudo apt update -y
sudo apt upgrade -y

cd /var/tmp/
wget https://dl.google.com/go/go1.22.4.linux-amd64.tar.gz
sudo rm -rf /usr/local/go 
sudo tar -C /usr/local -xzf go1.22.4.linux-amd64.tar.gz

git clone https://github.com/infezek/app-alb-terraform.git alb-app
cd /var/tmp/alb-app
GOCACHE=/tmp/gocache GOOS=linux GOARCH=amd64 /usr/local/go/bin/go build -o /home/ubuntu/serve -buildvcs=false

sudo iptables -t nat -A PREROUTING -p tcp --dport 80 -j REDIRECT --to-ports 8080
sudo cp alb-http.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable alb-http
sudo systemctl restart alb-http
Enter fullscreen mode Exit fullscreen mode

Agora, definiremos o autoscaling, especificando os valores mínimos e máximos das instâncias, juntamente com as políticas de escalonamento. Utilizaremos duas métricas principais: requisições por minuto e a média de uso da CPU das instâncias. Além disso, configuraremos a VPC para o autoscaling.

// autoscaling.tf
resource "aws_autoscaling_group" "this" {
  name                      = local.namespaced_service_name
  desired_capacity          = var.autoscaling_group_config.desired_capacity
  min_size                  = var.autoscaling_group_config.min_size
  max_size                  = var.autoscaling_group_config.max_size
  health_check_grace_period = var.autoscaling_group_config.health_check_grace_period
  health_check_type         = var.autoscaling_group_config.health_check_type
  force_delete              = var.autoscaling_group_config.force_delete

  target_group_arns   = [aws_alb_target_group.http.id]
  vpc_zone_identifier = local.public_subnet_ids

  launch_template {
    id      = aws_launch_template.this.id
    version = aws_launch_template.this.latest_version
  }
}

resource "aws_autoscaling_policy" "rpm_policy" {
  name                   = var.autoscaling_policy_alb.name
  enabled                = var.autoscaling_policy_alb.enabled
  autoscaling_group_name = aws_autoscaling_group.this.name
  policy_type            = "TargetTrackingScaling"

  target_tracking_configuration {
    disable_scale_in = var.autoscaling_policy_alb.disable_scale_in
    target_value     = var.autoscaling_policy_alb.target_value
    predefined_metric_specification {
      predefined_metric_type = "ALBRequestCountPerTarget"
      resource_label         = "${aws_alb.this.arn_suffix}/${aws_alb_target_group.http.arn_suffix}"
    }
  }
}

resource "aws_autoscaling_policy" "cpu_policy" {
  name                   = var.autoscaling_policy_cpu.name
  enabled                = var.autoscaling_policy_cpu.enabled
  autoscaling_group_name = aws_autoscaling_group.this.name
  policy_type            = "TargetTrackingScaling"

  target_tracking_configuration {
    disable_scale_in = var.autoscaling_policy_cpu.disable_scale_in
    target_value     = var.autoscaling_policy_cpu.target_value
    predefined_metric_specification {
      predefined_metric_type = "ASGAverageCPUUtilization"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

No nosso load balancer, definiremos as propriedades de redirecionamento, porta, health check, políticas de erro e outros parâmetros. Neste exemplo, utilizaremos a política de redirecionamento round robin, que distribuirá igualmente as requisições entre as instâncias.

// load_balance.tf
resource "aws_alb" "this" {
  name               = local.namespaced_service_name
  internal           = false
  load_balancer_type = "application"
  security_groups    = [aws_security_group.alb.id]
  subnets            = local.public_subnet_ids

  tags = {
    "Name" = local.namespaced_service_name
  }
}

resource "aws_alb_target_group" "http" {
  name     = local.namespaced_service_name
  port     = 80
  protocol = "HTTP"
  vpc_id   = aws_vpc.this.id

  health_check {
    enabled             = var.alb_health_check_config.enabled
    port                = var.alb_health_check_config.port
    timeout             = var.alb_health_check_config.timeout
    protocol            = var.alb_health_check_config.protocol
    unhealthy_threshold = var.alb_health_check_config.unhealthy_threshold
    interval            = var.alb_health_check_config.interval
    matcher             = var.alb_health_check_config.matcher
    path                = var.alb_health_check_config.path
    healthy_threshold   = var.alb_health_check_config.healthy_threshold
  }
}

resource "aws_alb_listener" "http" {
  load_balancer_arn = aws_alb.this.arn
  port              = 80
  protocol          = "HTTP"

  default_action {
    type             = "forward"
    target_group_arn = aws_alb_target_group.http.arn
  }
}
Enter fullscreen mode Exit fullscreen mode

Por fim, podemos executar o comando plan para verificar quais serão as mudanças e, em seguida, utilizar o comando apply para aplicá-las.

terraform plan
terraform apply
Enter fullscreen mode Exit fullscreen mode

Caso queira desfazer tudo que foi feito, basta executar o comando destroy.

terraform destroy
Enter fullscreen mode Exit fullscreen mode

Considerações finais:

Criar um sistema escalável e resiliente é um trabalho difícil e requer planejamento, uma estratégia sólida e compreensão do que pode acontecer e como lidar com esses cenários.

Distribuir suas instâncias em diferentes regiões garante uma maior resiliência, pois as zonas de disponibilidade possuem requisitos como distância mínima e máxima entre elas e sistemas de rede elétrica e internet diferentes. Isso faz com que uma falha em uma zona não afete outra, tornando muito mais difícil a ocorrência de indisponibilidade em todas as zonas da mesma região.

Outra vantagem desse sistema é que a AWS oferece uma variedade de serviços e maneiras de configuração, como security groups, AZs, regiões, VPCs, EC2, entre outros. Mesmo para uma aplicação pequena, a configuração manual pelo painel pode se tornar repetitiva, um pouco chata e complexa. Ter um sistema de infraestrutura como código (IaC) automatizado facilita o dia a dia e minimiza erros humanos. Além disso, IaC é um passo importante para uma infraestrutura moderna e eficiente.

LinkedIn
Repositório

💖 💪 🙅 🚩
ezequiel_lopes
Ezequiel Lopes

Posted on July 1, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related