Como construir uma aplicação escalável com Terraform e AWS
Ezequiel Lopes
Posted on July 1, 2024
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
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)
})
}
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"
}
}
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"
}
}
}
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
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"
}
}
}
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
}
}
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
Caso queira desfazer tudo que foi feito, basta executar o comando destroy
.
terraform destroy
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.
Posted on July 1, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.