Lab 3: Route 53 Health Checks + ARC

Lab 3: Route 53 Health Checks + ARC Routing Controls


Estructura del módulo

03_arc/
├── main.tf       ARC Cluster, Routing Controls, Health Checks, DNS
├── variables.tf
└── outputs.tf

Los outputs de los labs anteriores se leen via terraform_remote_state.


Paso 1: Setup

source ~/route53-arc-tf/global.env
cd ~/route53-arc-tf/03_arc

Dominio: opcional pero recomendado

Este lab funciona en dos modos:

  • Modo demo (sin dominio): Terraform crea una Hosted Zone privada (workshop.internal). El DNS no resuelve desde internet, pero podés ver los registros creados en la consola de Route 53 y los Routing Controls funcionan igual. Es suficiente para entender el concepto.
  • Modo real (con dominio): Si tenés un dominio registrado en Route 53 en esta cuenta, el failover funciona de verdad — el tráfico se redirige automáticamente entre el ALB y CloudFront.

Cómo obtener el Hosted Zone ID de tu dominio

Si tenés un dominio en Route 53, el Hosted Zone ID es el identificador de la zona DNS de ese dominio. Lo obtenés así:

Opción A — Desde la consola:

  1. Ir a Route 53 → Hosted zones
  2. Hacé clic en tu dominio
  3. El Hosted zone ID aparece en el panel derecho (formato: Z1234ABCDEF)

Opción B — Desde la CLI:

# Listar todas las hosted zones de la cuenta
aws route53 list-hosted-zones \
  --query "HostedZones[*].{Nombre:Name, ID:Id, Privada:Config.PrivateZone}" \
  --output table

# Buscar por nombre de dominio específico (reemplazá tudominio.com)
aws route53 list-hosted-zones \
  --query "HostedZones[?Name=='tudominio.com.'].{ID:Id,Name:Name}" \
  --output table

El ID que devuelve tiene el formato /hostedzone/Z1234ABCDEF — solo necesitás la parte Z1234ABCDEF.

# Extraer solo el ID (sin el prefijo /hostedzone/)
HOSTED_ZONE_ID=$(aws route53 list-hosted-zones \
  --query "HostedZones[?Name=='tudominio.com.'].Id" \
  --output text | cut -d'/' -f3)

echo "Hosted Zone ID: $HOSTED_ZONE_ID"

Configurar variables de dominio

# Sin dominio (modo demo)
export USE_DOMAIN="false"

# ── O ──────────────────────────────────────────────────────────────────────────

# Con dominio real en Route 53
export USE_DOMAIN="true"
export DOMAIN_NAME="tudominio.com"        # tu dominio sin punto final
export APP_SUBDOMAIN="app"                # quedará como app.tudominio.com
export HOSTED_ZONE_ID="Z1234ABCDEF"       # ID obtenido arriba
Información

¿Qué registro DNS se crea? Terraform crea dos registros con política de failover:

  • app.tudominio.com → PRIMARY → Alias al ALB (EC2 + nginx)
  • app.tudominio.com → SECONDARY → CNAME al dominio de fallback (Amplify o CloudFront)

Route 53 sirve el PRIMARY mientras el Health Check esté verde. Si falla, sirve el SECONDARY automáticamente.


💡 Concepto clave: ¿Qué es un Routing Control?

Un Routing Control es como un interruptor en el panel de control del cluster ARC. En este lab creamos dos:

  • Primario (ON): Route 53 sirve el tráfico al ALB (EC2 + nginx).
  • Secundario (OFF): Route 53 ignorara ese registro DNS.

Cuando el Primario pasa a OFF y el Secundario a ON, Route 53 redirige el tráfico al sitio de contingencia (Amplify). La Safety Rule garantiza que nunca los dos estén OFF al mismo tiempo — evita un corte total.


Paso 2: Crear los archivos

1. variables.tf

¿Para qué sirve este archivo?
Define los parámetros de entrada del orquestador de ARC (región, modo demo o dominio real, nombre de dominio, subdominio e ID de la Hosted Zone en Route 53).

cat > variables.tf << 'EOF'
variable "aws_region" {
  type    = string
  default = "us-east-1"
}

variable "use_domain" {
  description = "true = usar dominio real | false = modo demo"
  type        = bool
  default     = false
}

variable "domain_name" {
  description = "Dominio base (ej: tudominio.com)"
  type        = string
  default     = "workshop.internal"
}

variable "app_subdomain" {
  description = "Subdominio del workshop (ej: app)"
  type        = string
  default     = "app"
}

variable "hosted_zone_id" {
  description = "ID de la Public Hosted Zone (solo si use_domain=true)"
  type        = string
  default     = ""
}

variable "project_tag" {
  type    = string
  default = "route53-arc"
}
EOF

2. main.tf

¿Para qué sirve este archivo?
Es el núcleo de la solución de alta disponibilidad y conmutación de tráfico:

  • Lectura de estados locales: Importa los outputs del Lab 1 (alb_dns_name, alb_zone_id) y del Lab 2 (fallback_domain) mediante data "terraform_remote_state".
  • Route 53 Health Check & CloudWatch Alarm: Monitorea continuamente la salud del ALB primario en la ruta /health.
  • ARC Cluster & Routing Controls: Crea el cluster redundante de Route 53 ARC con sus dos interruptores (Primario y Secundario).
  • Safety Rule (min-one-active-rule): Regla de protección que garantiza que al menos 1 sitio se mantenga encendido (ATLEAST 1), evitando cortes totales por error humano.
  • Health Checks de ARC & Políticas de Failover DNS: Asocia los Routing Controls a registros de DNS Primario (Alias ALB) y Secundario (CNAME Amplify / CloudFront).
cat > main.tf << 'EOF'
terraform {
  required_version = ">= 1.5"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

provider "aws" {
  region = var.aws_region
  default_tags {
    tags = {
      Workshop  = var.project_tag
      ManagedBy = "Terraform"
    }
  }
}

# ─── Remote state de labs anteriores ──────────────────────────────────────────

data "terraform_remote_state" "primary" {
  backend = "local"
  config = {
    path = "../01_primary/terraform.tfstate"
  }
}

data "terraform_remote_state" "secondary" {
  backend = "local"
  config = {
    path = "../02_secondary/terraform.tfstate"
  }
}

locals {
  alb_dns_name  = data.terraform_remote_state.primary.outputs.alb_dns_name
  alb_zone_id   = data.terraform_remote_state.primary.outputs.alb_zone_id
  # fallback_domain viene como "https://main.xxxx.amplifyapp.com" — extraemos solo el hostname
  fallback_host = replace(data.terraform_remote_state.secondary.outputs.fallback_domain, "https://", "")
}

# ─── Route 53 Health Check sobre el ALB ───────────────────────────────────────

resource "aws_route53_health_check" "primary_alb" {
  fqdn              = local.alb_dns_name
  port              = var.use_domain ? 443 : 80
  type              = var.use_domain ? "HTTPS" : "HTTP"
  resource_path     = "/health"
  failure_threshold = 3
  request_interval  = 30

  tags = {
    Name = "route53-arc-primary-hc"
  }
}

# ─── CloudWatch Alarm sobre el Health Check ───────────────────────────────────

resource "aws_cloudwatch_metric_alarm" "primary_unhealthy" {
  alarm_name          = "route53-arc-primary-unhealthy"
  alarm_description   = "Sitio primario no responde al Health Check de Route 53"
  namespace           = "AWS/Route53"
  metric_name         = "HealthCheckStatus"
  statistic           = "Minimum"
  period              = 60
  evaluation_periods  = 3
  threshold           = 1
  comparison_operator = "LessThanThreshold"
  treat_missing_data  = "breaching"

  dimensions = {
    HealthCheckId = aws_route53_health_check.primary_alb.id
  }
}

# ─── ARC Cluster ──────────────────────────────────────────────────────────────

resource "aws_route53recoverycontrolconfig_cluster" "main" {
  name = "route53-arc-workshop-cluster"
}

resource "aws_route53recoverycontrolconfig_control_panel" "main" {
  name        = "route53-arc-workshop-panel"
  cluster_arn = aws_route53recoverycontrolconfig_cluster.main.arn
}

# ─── Routing Controls ─────────────────────────────────────────────────────────

resource "aws_route53recoverycontrolconfig_routing_control" "primary" {
  name              = "primary-routing-control"
  cluster_arn       = aws_route53recoverycontrolconfig_cluster.main.arn
  control_panel_arn = aws_route53recoverycontrolconfig_control_panel.main.arn
}

resource "aws_route53recoverycontrolconfig_routing_control" "secondary" {
  name              = "secondary-routing-control"
  cluster_arn       = aws_route53recoverycontrolconfig_cluster.main.arn
  control_panel_arn = aws_route53recoverycontrolconfig_control_panel.main.arn
}

# ─── Safety Rule ──────────────────────────────────────────────────────────────

resource "aws_route53recoverycontrolconfig_safety_rule" "min_one_active" {
  name              = "min-one-active-rule"
  control_panel_arn = aws_route53recoverycontrolconfig_control_panel.main.arn
  wait_period_ms    = 5000

  asserted_controls = [
    aws_route53recoverycontrolconfig_routing_control.primary.arn,
    aws_route53recoverycontrolconfig_routing_control.secondary.arn,
  ]

  rule_config {
    inverted  = false
    threshold = 1
    type      = "ATLEAST"
  }
}

# ─── Health Checks tipo RECOVERY_CONTROL ──────────────────────────────────────

resource "aws_route53_health_check" "primary_rc" {
  type              = "RECOVERY_CONTROL"
  routing_control_arn = aws_route53recoverycontrolconfig_routing_control.primary.arn

  tags = { Name = "route53-arc-primary-rc-hc" }
}

resource "aws_route53_health_check" "secondary_rc" {
  type              = "RECOVERY_CONTROL"
  routing_control_arn = aws_route53recoverycontrolconfig_routing_control.secondary.arn

  tags = { Name = "route53-arc-secondary-rc-hc" }
}

# ─── Hosted Zone demo (si NO hay dominio real) ────────────────────────────────

resource "aws_route53_zone" "demo" {
  count   = var.use_domain ? 0 : 1
  name    = var.domain_name
  comment = "Hosted Zone demo – route53-arc-workshop"
}

locals {
  # Usar la HZ real o la demo según configuración
  hosted_zone_id = var.use_domain ? var.hosted_zone_id : aws_route53_zone.demo[0].zone_id
}

# ─── DNS Record PRIMARY ────────────────────────────────────────────────────────

resource "aws_route53_record" "primary" {
  zone_id         = local.hosted_zone_id
  name            = "${var.app_subdomain}.${var.domain_name}"
  type            = "CNAME"
  set_identifier  = "primary"
  health_check_id = aws_route53_health_check.primary_rc.id
  ttl             = 60
  records         = [local.alb_dns_name]
  allow_overwrite = true

  failover_routing_policy {
    type = "PRIMARY"
  }
}

# ─── DNS Record SECONDARY ─────────────────────────────────────────────────────
# NOTA: Usamos CNAME en lugar de Alias A porque Amplify no soporta Alias nativos en
# dominios .amplifyapp.com frente a Route 53, y la política de Failover exige que 
# tanto el primario como secundario sean del mismo tipo (CNAME).
# allow_overwrite soluciona colisiones con el CNAME que Amplify crea automáticamente.

resource "aws_route53_record" "secondary" {
  depends_on      = [aws_route53_record.primary]
  zone_id         = local.hosted_zone_id
  name            = "${var.app_subdomain}.${var.domain_name}"
  type            = "CNAME"
  set_identifier  = "secondary"
  health_check_id = aws_route53_health_check.secondary_rc.id
  ttl             = 60
  records         = [local.fallback_host]
  allow_overwrite = true

  failover_routing_policy {
    type = "SECONDARY"
  }
}
EOF

3. outputs.tf

¿Para qué sirve este archivo?
Exporta el ARN del cluster ARC, sus endpoints HTTP de gestión, los ARNs de los Routing Controls primario y secundario, el ID del Health Check y el nombre de la alarma CloudWatch. Estos outputs son necesarios para automatizar el failover con Lambda en el Lab 4.

cat > outputs.tf << 'EOF'
output "arc_cluster_arn" {
  value = aws_route53recoverycontrolconfig_cluster.main.arn
}

output "arc_cluster_endpoints" {
  value = aws_route53recoverycontrolconfig_cluster.main.cluster_endpoints
}

output "primary_rc_arn" {
  value = aws_route53recoverycontrolconfig_routing_control.primary.arn
}

output "secondary_rc_arn" {
  value = aws_route53recoverycontrolconfig_routing_control.secondary.arn
}

output "health_check_id" {
  value = aws_route53_health_check.primary_alb.id
}

output "cloudwatch_alarm_name" {
  value = aws_cloudwatch_metric_alarm.primary_unhealthy.alarm_name
}

output "app_url" {
  value = "http://${var.app_subdomain}.${var.domain_name}"
}
EOF

Aviso

⚠️ ATENCIÓN SI USASTE DOMINIO PROPIO CON AMPLIFY (LAB 2)
Amplify es tan proactivo que en el Lab 2 te creó un registro CNAME automático en Route 53 apuntando a tu app (app.tudominio.com).
Para que este Lab 3 funcione y Terraform pueda crear las políticas de Failover, debes borrar manualmente ese CNAME simple.

  1. Entrá a la consola de AWS Route 53 -> Hosted Zones
  2. Seleccioná tu dominio.
  3. Marcá el registro de tipo CNAME que se llame app.tudominio.com y dale a Delete record.

Paso 3: Desplegar

terraform init

# Preparar variables
if [ "$USE_DOMAIN" = "true" ]; then
  DOMAIN_VARS="-var=use_domain=true -var=domain_name=$DOMAIN_NAME -var=app_subdomain=$APP_SUBDOMAIN -var=hosted_zone_id=$HOSTED_ZONE_ID"
else
  DOMAIN_VARS="-var=use_domain=false"
fi

terraform plan $DOMAIN_VARS


Aviso

⏱️ El terraform apply puede tardar 3-5 minutos — AWS necesita aprovisionar el ARC Cluster en múltiples regiones. La terminal parecerá congelada durante ese tiempo. No la canceles. Es completamente normal.

terraform apply $DOMAIN_VARS -auto-approve

Paso 4: Activar Routing Controls

export PRIMARY_RC_ARN=$(terraform output -raw primary_rc_arn)
export SECONDARY_RC_ARN=$(terraform output -raw secondary_rc_arn)

# Obtener endpoint del cluster para la región activa
# (el cluster ARC tiene endpoints en múltiples regiones para alta disponibilidad)
CLUSTER_ENDPOINT=$(terraform output -json arc_cluster_endpoints | python3 -c "
import json, sys
eps = json.load(sys.stdin)
target = next((e for e in eps if e['region'] == 'us-east-1'), eps[0])
print(target['endpoint'])
")
ENDPOINT_REGION="us-east-1"

echo "Cluster Endpoint: $CLUSTER_ENDPOINT ($ENDPOINT_REGION)"

# Estado normal: primario ON, secundario OFF
aws route53-recovery-cluster update-routing-control-states \
  --update-routing-control-state-entries \
    "[{\"RoutingControlArn\":\"$PRIMARY_RC_ARN\",\"RoutingControlState\":\"On\"},
      {\"RoutingControlArn\":\"$SECONDARY_RC_ARN\",\"RoutingControlState\":\"Off\"}]" \
  --endpoint-url $CLUSTER_ENDPOINT \
  --region $ENDPOINT_REGION

echo "✅ Primario=ON | Secundario=OFF"

✅ Verificación del Lab 3

echo "=== Verificación Lab 3 ==="

terraform show | grep -q "aws_route53recoverycontrolconfig_cluster.main" \
  && echo "✅ ARC Cluster en state" || echo "❌ No en state"

RC_STATE=$(aws route53-recovery-cluster get-routing-control-state \
  --routing-control-arn $PRIMARY_RC_ARN \
  --endpoint-url $CLUSTER_ENDPOINT --region $ENDPOINT_REGION \
  --query 'RoutingControlState' --output text)
[ "$RC_STATE" = "On" ] \
  && echo "✅ Routing Control Primario: $RC_STATE" \
  || echo "⚠️  RC Primario: $RC_STATE"

echo "📌 PRIMARY_RC_ARN   = $PRIMARY_RC_ARN"
echo "📌 CLUSTER_ENDPOINT = $CLUSTER_ENDPOINT"

Siguiente paso → Lab 4: SNS + Lambda