AWS: Aplicación 3-Tier (arquitectura de 3 capas) utilizando Terraform

AWS: Aplicación 3-Tier (arquitectura de 3 capas) utilizando Terraform

Utilizar una arquitectura de 3 capas o niveles (3-tier) con alta disponibilidad, da múltiples beneficios a la hora de desplegar nuestro proyecto en producción:

  • Escalabilidad: Facilidad en el escalado hacia arriba o abajo, acorde a la demanda de nuestra app. Sin la necesidad de realizarlo manualmente.
  • Disponibilidad: Utilizamos varias Zonas de Disponibilidad (AZ) en AWS para desplegar nuestros recursos. Si una AZ se cae, no lo hará nuestro servicio ya que desvía el trafico hacia las AZs activas (Podemos hacer una arquitectura multi-AZ o multi-Región inclusive).
  • División de recursos: Cada capa es un proceso separado que funciona con sus respectivos recursos.

Obviamente su utilización va a depender del propósito de nuestra aplicación y el gestionamiento de los administradores.

Las 3 capas o niveles son los siguientes:

  • Nivel de Presentación: Es la capa que interactúa con el usuario final. Es el FrontEnd.
  • Nivel de Aplicación: Es el Backend: se encarga de procesar los datos y es el intermediario entre el FrontEnd y la Base de Datos.
  • Nivel de Datos: Aquí se almacena la Base de Datos.

Arquitectura

La arquitectura y los servicios que se utilizarán en este proyecto son los siguientes:

  • 2 Auto Scaling groups
  • 1 External Load Balancer
  • 1 Internal Load Balancer
  • 2 Targets groups
  • 2 LB Listeners
  • 4 políticas de Auto Scaling Group
  • 4 Alarmas de CloudWatch
  • 1 DynamoDB table
  • 1 VPC
  • 2 Subnets públicas
  • 2 Subnets privadas
  • 2 NAT Gateways
  • 1 Internet Gateway

Repositorio en GitHub

El siguiente link contiene el código completo del proyecto, así como los archivos de Terraform para su despliegue en AWS: https://github.com/brunodangelo/terraform_aws_3tier_project

Solo debes agregar las credenciales de tu cuenta de AWS en el archivo llamada “userdataback.sh” de la carpeta “scripts”.

Capa de Presentación: Frontend

En este nivel se deben crear los siguientes recursos (entre otros):

  • 2 subnets públicas que contienen las instancias con la web del frontend.
  • 1 Load Balancer externo que balancea la carga y expone un único punto de acceso a las instancias.
  • 1 Auto Scaling Group con las instancias a desplegar, así como su políticas de auto escalado y launch templates.

Primeramente creamos las subnets públicas:

resource "aws_subnet" "public-subnet" {
  count = length(var.availability_zones)
  vpc_id = aws_vpc.vpc.id
  cidr_block = var.cidr_block_public_subnets[count.index]
  availability_zone = var.availability_zones[count.index]
  map_public_ip_on_launch = true

  tags = {
    Name = "Public subnet ${count.index + 1}"
    Owner = "Bruno"
    Env = "dev"
  }
}

La variable “count” representa la cantidad de zonas de disponibilidad de que tenemos, en este caso es 2. Este valor lo podemos modificar en el main.tf dentro del módulo.

Luego la asociamos a una Route Table:

resource "aws_route_table" "rt_publics_subnets" {
  vpc_id = aws_vpc.vpc.id

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

  tags = {
    Name = "RT publics subnets"
    Owner = "Bruno"
  }
}
resource "aws_route_table_association" "rt_publics_subnets_association" {
  count = length(var.availability_zones)
  route_table_id = aws_route_table.rt_publics_subnets.id
  subnet_id = aws_subnet.public-subnet[count.index].id
}

Seguidamente podemos ya comenzar a definir las instacias EC2 del frontend, creando el auto scaling group:

resource "aws_autoscaling_group" "asg_front" {
  desired_capacity   = 1
  max_size           = var.max_amount_ec2
  min_size           = 1
  vpc_zone_identifier = var.public_subnets_id
  health_check_grace_period = 300
  health_check_type         = "EC2"

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

Debemos establecer el Launch template con el que iniciará cada instancia en el Auto Scaling Group, ya sea cuando ocurra un error o deba lanzar una nueva por las políticas establecidas:

resource "aws_launch_template" "launch_template_front" {
  name_prefix   = "lt-front"
  image_id      = "ami-05b10e08d247fb927"
  instance_type = "t2.micro"
  vpc_security_group_ids = [aws_security_group.sg_ec2_public.id]
  user_data = base64encode(templatefile("./scripts/userdatafront.sh",{url_internal_lb=aws_lb.back_lb.dns_name}))
  lifecycle {
    create_before_destroy = true
  }
}

En este caso levantaremos un Linux en una instancia t2.micro y ejecutamos al inicio un script de bash que instala los paquetes y dependencias del código del frontend (llamado “userdatafront.sh”). Obsérvese también que disponemos de una variable que se le pasa a este script de Bash desde Terraform, que contiene la url del backend.

El security group de las instancias sería el siguiente. Solo recibe el tráfico que proviene del Load Balancer externo que definiremos más adelante:

resource "aws_security_group" "sg_ec2_public" {
  name = "SG public subnets"
  description = "Security group de las instancias ec2 desplegadas en subredes publicas"
  vpc_id = var.vpc_id

  ingress {
    protocol = "TCP"
    from_port = 80
    to_port = 80
    security_groups = [ aws_security_group.sg_external_lb.id ]
  }

  ingress {
    protocol = "TCP"
    from_port = 22
    to_port = 22
    cidr_blocks = [ "0.0.0.0/0" ]
  }

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

  tags = {
    Owner = "Bruno"
  }
}

Por último en esta capa, debemos crear el Load Balancer que estará expuesto a Internet y sus asociaciones con los recursos previamente creados:

resource "aws_lb" "front_lb" {
  name               = "front-lb"
  internal           = false
  load_balancer_type = "application"
  security_groups    = [aws_security_group.sg_external_lb.id]
  subnets            = var.public_subnets_id

  depends_on = [ aws_lb.back_lb ]

  tags = {
    Owner = "Bruno"
    Env = "dev"
  }
}

Aquí debemos hacer una aclaración: Se incluye un “depends_on” con el Load Balancer del Backend porque desde el Frontend tenemos que tener previamente la url asignada al Back para luego introducirla en el código del front (Por eso se tiene que crear primero).

Asignamos a un target group y un listener:

resource "aws_lb_target_group" "tg_front" {
  name        = "tg-front"
  port        = 80
  protocol    = "HTTP"
  vpc_id      = var.vpc_id
}
resource "aws_lb_listener" "listener_front" {
  load_balancer_arn = aws_lb.front_lb.arn
  port              = "80"
  protocol          = "HTTP"

  default_action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.tg_front.arn
  }
}

También tenemos que asociar el target group con el Autoscaling Group, para que las nuevas instancias que se vayan creando se asocien a este último y al Load Balancer:

resource "aws_autoscaling_attachment" "tg_front_attachment" {
  autoscaling_group_name = aws_autoscaling_group.asg_front.id
  lb_target_group_arn    = aws_lb_target_group.tg_front.arn
}

El security group del Load Balancer externo queda establecido de la siguiente manera:

resource "aws_security_group" "sg_external_lb" {
  name = "SG external load balancer"
  description = "Security group del Load Balancer externo"
  vpc_id = var.vpc_id

  ingress {
    protocol = "TCP"
    from_port = 80
    to_port = 80
    cidr_blocks = ["0.0.0.0/0"]
  }

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

  tags = {
    Owner = "Bruno"
  }
}

Opcionalmente podemos incluir una política de auto escalado, para poder administrar el tráfico ante subidas y bajadas en la carga. En caso de que existan más usuarios utilizando la app se agregan mas instancias, y en caso de que tengamos capacidad ociosa se puedan eliminar para reducir el costo.

En este caso lo haremos con el uso del CPU:

Escalado hacia arriba:

resource "aws_autoscaling_policy" "front_scale_up" {
  name                   = "front_scale_up"
  autoscaling_group_name = aws_autoscaling_group.asg_front.name
  adjustment_type        = "ChangeInCapacity"
  scaling_adjustment     = 1
  cooldown               = 120
}

resource "aws_cloudwatch_metric_alarm" "front_scale_up" {
  alarm_description   = "Se monitorea el CPU de las instancias del Frontend"
  alarm_actions       = [aws_autoscaling_policy.front_scale_up.arn]
  alarm_name          = "alarm_front_scale_up"
  comparison_operator = "GreaterThanOrEqualToThreshold"
  namespace           = "AWS/EC2"
  metric_name         = "CPUUtilization"
  threshold           = "70"
  evaluation_periods  = "2"
  period              = "120"
  statistic           = "Average"

  dimensions = {
    AutoScalingGroupName = aws_autoscaling_group.asg_front.name
  }
}

Escalado hacia abajo:

resource "aws_autoscaling_policy" "front_scale_down" {
  name                   = "front_scale_down"
  autoscaling_group_name = aws_autoscaling_group.asg_front.name
  adjustment_type        = "ChangeInCapacity"
  scaling_adjustment     = -1
  cooldown               = 120
}

resource "aws_cloudwatch_metric_alarm" "front_scale_down" {
  alarm_description   = "Se monitorea el CPU de las instancias del Frontend"
  alarm_actions       = [aws_autoscaling_policy.front_scale_down.arn]
  alarm_name          = "alarm_front_scale_down"
  comparison_operator = "LessThanOrEqualToThreshold"
  namespace           = "AWS/EC2"
  metric_name         = "CPUUtilization"
  threshold           = "30"
  evaluation_periods  = "2"
  period              = "120"
  statistic           = "Average"

  dimensions = {
    AutoScalingGroupName = aws_autoscaling_group.asg_front.name
  }
}

Capa de Aplicación: Backend

En este nivel vamos a crear los siguientes recursos:

  • 2 Subnets privadas que alojan los servidores del backend de la aplicación.
  • 1 Auto Scaling Group que administra el escalado automático de la aplicación según el tráfico.
  • 1 Load Balancer Interno que balancea el trafico y expone un único punto de acceso a las instancias privadas.
  • 2 NAT Gateways para asociar a las subnets privadas y que estas puedan acceder a internet.

Comenzamos creando las Subnets:

resource "aws_subnet" "private-subnet" {
  count = length(var.availability_zones)
  vpc_id = aws_vpc.vpc.id
  cidr_block = var.cidr_block_private_subnets[count.index]
  availability_zone = var.availability_zones[count.index]

  tags = {
    Name = "Private subnet ${count.index + 1}"
    Owner = "Bruno"
  }
}

Al ser instancias privadas, por defecto no van a ser accesibles ni poder acceder a Internet. Por ello vamos a crear 2 NAT Gateways (para cada subnet) para lograr este último cometido, ya que deben clonar el repositorio donde almacenamos el código fuente de la app. Adicionalmente debemos disponer de IP elásticas para tal fin.

resource "aws_eip" "eips" {
  count = length(var.availability_zones)
  domain = "vpc"
}

resource "aws_nat_gateway" "nat_gateways" {
  count = length(var.availability_zones)
  subnet_id = aws_subnet.public-subnet[count.index].id
  allocation_id = aws_eip.eips[count.index].id
  tags = {
    Name = "NAT Gateway para ${var.availability_zones[count.index]}"
    Owner = "Bruno"
  }
}

Para finalizar la etapa de redes de este nivel, creamos la Route Table:

resource "aws_route_table" "rt_private_subnet" {
  count = length(var.availability_zones)
  vpc_id = aws_vpc.vpc.id

  route {
    cidr_block = "0.0.0.0/0"
    nat_gateway_id = aws_nat_gateway.nat_gateways[count.index].id
  }

  tags = {
    Name = "RT private subnet 1a"
    Owner = "Bruno"
  }
}

Y la asociamos:

resource "aws_route_table_association" "rt_private_subnets_associations" {
  count = length(var.availability_zones)
  route_table_id = aws_route_table.rt_private_subnet[count.index].id
  subnet_id = aws_subnet.private-subnet[count.index].id
}

Luego procedemos a crear el Auto Scaling Group:

resource "aws_autoscaling_group" "asg_back" {
  desired_capacity   = 1
  max_size           = var.max_amount_ec2
  min_size           = 1
  vpc_zone_identifier = var.private_subnets_id
  health_check_grace_period = 300
  health_check_type         = "EC2"

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

Junto con el Launch Template, que iniciara un script de Bash que instala las dependencias y corre la aplicación en cada instancia:

resource "aws_launch_template" "launch_template_back" {
  name_prefix   = "lt-back"
  image_id      = "ami-05b10e08d247fb927"
  instance_type = "t2.micro"
  vpc_security_group_ids = [aws_security_group.sg_ec2_private.id]
  user_data = filebase64("./scripts/userdataback.sh")

  lifecycle {
    create_before_destroy = true
  }
}

El Security group para estas instancias privadas es el siguiente. Solo habilitamos el trafico de ingreso que proviene del Load Balancer Interno:

resource "aws_security_group" "sg_ec2_private" {
  name = "SG private subnets"
  description = "Security group de las instancias ec2 en las subredes privadas del back"
  vpc_id = var.vpc_id

  ingress {
    protocol = "TCP"
    to_port = 3000
    from_port = 3000
    security_groups = [ aws_security_group.sg_internal_lb.id ]
  }

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

  tags = {
    Owner = "Bruno"
  }
}

Seguidamente creamos el Load Balancer Interno, que se encarga de realizar el balanceo de carga y exponer en un solo punto todas las instancias que creará el Auto Scaling group:

resource "aws_security_group" "sg_internal_lb" {
  name = "SG internal load balancer"
  description = "Security group del Load Balancer interno"
  vpc_id = var.vpc_id

  ingress {
    protocol = "TCP"
    from_port = 80
    to_port = 80
    security_groups = [ aws_security_group.sg_ec2_public.id ]
  }

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

  tags = {
    Owner = "Bruno"
  }
}

Creamos el Target Group y el Listener. En el caso particular de esta aplicación, se encuentra corriendo en el puerto 3000:

resource "aws_lb_target_group" "tg_back" {
  name        = "tg-back"
  port        = 3000
  protocol    = "HTTP"
  vpc_id      = var.vpc_id

  health_check {
    enabled = true
    path     = "/health"
  }
}
resource "aws_lb_listener" "listener_back" { 
  load_balancer_arn = aws_lb.back_lb.arn
  port              = 80
  protocol          = "HTTP"

  default_action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.tg_back.arn
  }
}

El security group del Load Balancer Interno queda definido de la siguiente manera:

resource "aws_security_group" "sg_internal_lb" {
  name = "SG internal load balancer"
  description = "Security group del Load Balancer interno"
  vpc_id = var.vpc_id

  ingress {
    protocol = "TCP"
    from_port = 80
    to_port = 80
    security_groups = [ aws_security_group.sg_ec2_public.id ]
  }

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

  tags = {
    Owner = "Bruno"
  }
}

Seguidamente, realizamos las asociaciones finales:

resource "aws_autoscaling_attachment" "tg_back_attachment" {
  autoscaling_group_name = aws_autoscaling_group.asg_back.name
  lb_target_group_arn    = aws_lb_target_group.tg_back.arn
}

Por último, creamos las políticas de auto escalado para garantizar un comportamiento eficiente de la aplicación acorde al tráfico que recibe. En este caso tomamos como métrica el porcentaje de uso de CPU.

Escalado hacia arriba:

resource "aws_autoscaling_policy" "back_scale_up" {
  name                   = "back_scale_up"
  autoscaling_group_name = aws_autoscaling_group.asg_back.name
  adjustment_type        = "ChangeInCapacity"
  scaling_adjustment     = 1
  cooldown               = 120
}

resource "aws_cloudwatch_metric_alarm" "back_scale_up" {
  alarm_description   = "Se monitorea el CPU de las instancias del Backend"
  alarm_actions       = [aws_autoscaling_policy.back_scale_up.arn]
  alarm_name          = "alarm_back_scale_up"
  comparison_operator = "GreaterThanOrEqualToThreshold"
  namespace           = "AWS/EC2"
  metric_name         = "CPUUtilization"
  threshold           = "75"
  evaluation_periods  = "2"
  period              = "120"
  statistic           = "Average"

  dimensions = {
    AutoScalingGroupName = aws_autoscaling_group.asg_back.name
  }
}

Escalado hacia abajo:

resource "aws_autoscaling_policy" "back_scale_down" {
  name                   = "back_scale_down"
  autoscaling_group_name = aws_autoscaling_group.asg_back.name
  adjustment_type        = "ChangeInCapacity"
  scaling_adjustment     = -1
  cooldown               = 120
}

resource "aws_cloudwatch_metric_alarm" "back_scale_down" {
  alarm_description   = "Se monitorea el CPU de las instancias del Backend"
  alarm_actions       = [aws_autoscaling_policy.back_scale_down.arn]
  alarm_name          = "alarm_back_scale_down"
  comparison_operator = "LessThanOrEqualToThreshold"
  namespace           = "AWS/EC2"
  metric_name         = "CPUUtilization"
  threshold           = "50"
  evaluation_periods  = "2"
  period              = "120"
  statistic           = "Average"

  dimensions = {
    AutoScalingGroupName = aws_autoscaling_group.asg_back.name
  }
}

Capa de Datos: Base de Datos

En este último niveles vamos a utilizar a DynamoDB.

Las tablas globales de DynamoDB son una base de datos totalmente gestionada, sin servidor, multirregional y multiactiva. Según AWS, las tablas globales ofrecen una disponibilidad del 99,999 %.

Cuando creamos la tabla, AWS automáticamente replica los datos en las múltiples Zonas de Disponibilidad dentro de la región que especificamos. Esto permite tener mayor tolerancia a fallos y asegura que los datos se mantengan accesibles si una AZ completa se cae.

resource "aws_dynamodb_table" "dynamodb_table" {
  name             = "tabla-bruno"
  hash_key         = "key"
  billing_mode     = "PAY_PER_REQUEST"

  attribute {
    name = "key"
    type = "S"
  }

  replica = {
    region_name = "us-east-1"
  }
}

Proxy-Inverso

Como se puede ver en la arquitectura del proyecto, entre el Frontend y el Backend tenemos un Load Balancer Interno. Este solamente recibe tráfico de las instancias públicas del Frontend (véase los security groups).

Las instancias públicas de la Capa de Presentación, podrían acceder a las instancias privadas de la Capa de Aplicación con tener solamente la dirección dns del Load Balancer Interno. De hecho, si nos conectamos mediante ssh a las instancias públicas del front y probamos la conectividad (mediante un ping a la dirección dns) con las instancias privadas de back vamos a tener éxito.

Sin embargo, en la práctica, el usuario esta accediendo desde su navegador a la URL del Load Balancer Externo (expuesto a Internet) y si realiza peticiones al Backend (mediante la url del load balancer interno, inclusive a través del front) no podrá acceder, ya que el origen de este acceso no son desde las instancias del front sino el mismo usuario con su IP (que no está dentro de los ingresos permitidos en el Security Group).

Por lo descrito en el párrafo anterior, se utiliza en este caso un proxy inverso (o reverse-proxy) para reenviar las solicitudes de los clientes/usuarios a los servidores web (instancias privadas de la Capa de Aplicación) y devolver las respuestas, a través de las instancias públicas que si poseen acceso.

En este proyecto utilizamos nginx, por lo que en su archivo de configuración “nginx.conf” definimos este proxy-inverso con la dirección de dns del Load Balancer Interno, para que cumpla su función. Esto esta automatizado en el script llamado “userdatafront.sh”.

location /api/ {
            proxy_pass http://${url_internal_lb}:80/;
}

Última actualización el 01-04-2025 por Bruno D’Angelo

Deja un comentario

Tu email no será publicado.