Lab 4: SNS + Lambda Failover

Lab 4: SNS + Lambda Failover


Estructura del módulo

04_automation/
├── main.tf
├── variables.tf
├── outputs.tf
└── lambda/
    └── handler.py

Paso 1: Setup

source ~/route53-arc-tf/global.env
cd ~/route53-arc-tf/04_automation
mkdir -p lambda

# EDITÁ con tu email real
export NOTIFICATION_EMAIL="tu-email@ejemplo.com"
# export NOTIFICATION_PHONE="+54911XXXXXXXX"  # opcional

Paso 2: Código Lambda

lambda/handler.py

¿Para qué sirve este archivo?
Es el código de la función Python que ejecutará la conmutación automática de tráfico (failover). Cuando se activa por una notificación de alerta SNS:

  1. Parsea el mensaje recibido de CloudWatch.
  2. Se conecta al endpoint del cluster ARC mediante boto3.
  3. Cambia el estado del Routing Control Primario a Off y el Secundario a On.
  4. Envía un correo electrónico/SMS notificando al equipo de ingeniería que el failover fue ejecutado.
cat > lambda/handler.py << 'EOF'
"""
AWS Route 53 ARC Workshop (Terraform)
Lambda: Failover Handler
"""
import json, os, boto3, logging

logger = logging.getLogger()
logger.setLevel(logging.INFO)

PRIMARY_RC_ARN   = os.environ["PRIMARY_RC_ARN"]
SECONDARY_RC_ARN = os.environ["SECONDARY_RC_ARN"]
CLUSTER_ENDPOINT = os.environ["CLUSTER_ENDPOINT"]
SNS_TOPIC_ARN    = os.environ["SNS_TOPIC_ARN"]
REGION           = os.environ.get("AWS_REGION", "us-east-1")

def arc_client():
    return boto3.client("route53-recovery-cluster",
                        endpoint_url=CLUSTER_ENDPOINT, region_name=REGION)

def get_state(client, arn):
    return client.get_routing_control_state(
        RoutingControlArn=arn)["RoutingControlState"]

def execute_failover(client):
    client.update_routing_control_states(
        UpdateRoutingControlStateEntries=[
            {"RoutingControlArn": PRIMARY_RC_ARN,   "RoutingControlState": "Off"},
            {"RoutingControlArn": SECONDARY_RC_ARN, "RoutingControlState": "On"},
        ]
    )
    logger.info("Failover ejecutado")

def notify(subject, message):
    boto3.client("sns", region_name=REGION).publish(
        TopicArn=SNS_TOPIC_ARN, Subject=subject, Message=message)

def lambda_handler(event, context):
    logger.info(json.dumps(event))
    alarm_state = "UNKNOWN"
    try:
        for r in event.get("Records", []):
            if r.get("EventSource") == "aws:sns":
                alarm_state = json.loads(r["Sns"]["Message"]).get("NewStateValue", "UNKNOWN")
    except Exception as e:
        logger.warning(f"Parse error: {e}")
        alarm_state = event.get("alarm_state", "ALARM")

    if alarm_state != "ALARM":
        return {"statusCode": 200, "body": f"No action. State: {alarm_state}"}

    arc = arc_client()
    if get_state(arc, PRIMARY_RC_ARN) == "Off":
        logger.info("Ya en failover, sin cambios.")
        return {"statusCode": 200, "body": "Already failed over"}

    execute_failover(arc)
    notify(
        subject="🚨 [FAILOVER ACTIVADO] Sitio primario caído",
        message=(
            "ALERTA: Failover automático ejecutado.\n"
            "- Routing Control Primario: OFF\n"
            "- Routing Control Secundario: ON\n\n"
            "Tráfico redirigido a CloudFront + S3.\n"
            "Para restaurar ejecutá el Lab 6 (Failback).\n"
            "AWS Route 53 ARC Workshop (Terraform)"
        ),
    )
    return {"statusCode": 200, "body": "FAILOVER_EXECUTED"}
EOF

cd lambda && zip -q ../failover.zip handler.py && cd ..
echo "✅ Lambda ZIP: $(ls -lh failover.zip | awk '{print $5}')"

Paso 3: Archivos Terraform

1. variables.tf

¿Para qué sirve este archivo?
Define los parámetros de entrada para la automatización (región AWS, email de destino de alertas y teléfono opcional para SMS).

cat > variables.tf << 'EOF'
variable "aws_region"         { type = string; default = "us-east-1" }
variable "notification_email" { type = string }
variable "notification_phone" { type = string; default = "" }
variable "project_tag"        { type = string; default = "route53-arc" }
EOF

2. main.tf

¿Para qué sirve este archivo?
Orquesta los recursos de notificación y conmutación automática:

  • Remote State de Lab 3: Importa los ARNs de los Routing Controls y la Alarma base vía data "terraform_remote_state".
  • Tópico SNS & Suscripciones: Despliega el canal de comunicación y suscribe tu email/SMS.
  • Bucket S3 para código: Almacena temporalmente el ZIP del script Python para la función Lambda (force_destroy = true).
  • Rol e IAM Policy de Lambda: Concede permisos para consultar y modificar el estado del cluster ARC (route53-recovery-cluster) y publicar en SNS.
  • Recurso Lambda & Trigger SNS: Despliega la función en Python 3.12, configura sus variables de entorno y conecta la suscripción SNS a la invocación de Lambda.
  • Alarma CloudWatch conectada: Asocia la acción de alarma del Health Check para publicar directamente en el tópico SNS.
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" } }
}

data "aws_caller_identity" "current" {}

# Leer outputs del Lab 3
data "terraform_remote_state" "arc" {
  backend = "local"
  config  = {
    path = "../03_arc/terraform.tfstate"
  }
}

locals {
  primary_rc_arn   = data.terraform_remote_state.arc.outputs.primary_rc_arn
  secondary_rc_arn = data.terraform_remote_state.arc.outputs.secondary_rc_arn
  # Primer endpoint del cluster
  cluster_endpoint = data.terraform_remote_state.arc.outputs.arc_cluster_endpoints[0]["Endpoint"]
  hc_id            = data.terraform_remote_state.arc.outputs.health_check_id
  cw_alarm_base    = data.terraform_remote_state.arc.outputs.cloudwatch_alarm_name
}

# ── SNS Topic ──────────────────────────────────────────────────────────────────
resource "aws_sns_topic" "alerts" {
  name         = "route53-arc-alerts"
  display_name = "Route53 ARC – Alertas"
}

resource "aws_sns_topic_subscription" "email" {
  topic_arn = aws_sns_topic.alerts.arn
  protocol  = "email"
  endpoint  = var.notification_email
}

resource "aws_sns_topic_subscription" "sms" {
  count     = var.notification_phone != "" ? 1 : 0
  topic_arn = aws_sns_topic.alerts.arn
  protocol  = "sms"
  endpoint  = var.notification_phone
}

# ── S3 para código Lambda ──────────────────────────────────────────────────────
resource "aws_s3_bucket" "lambda_code" {
  bucket        = "route53-arc-lambda-${data.aws_caller_identity.current.account_id}"
  force_destroy = true
}

resource "aws_s3_object" "failover_zip" {
  bucket = aws_s3_bucket.lambda_code.id
  key    = "failover-handler.zip"
  source = "${path.module}/failover.zip"
  etag   = filemd5("${path.module}/failover.zip")
}

# ── IAM Role Lambda ────────────────────────────────────────────────────────────
resource "aws_iam_role" "lambda" {
  name = "route53-arc-lambda-failover-role"
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{ Effect = "Allow"
      Principal = { Service = "lambda.amazonaws.com" }
      Action    = "sts:AssumeRole"
    }]
  })
}

resource "aws_iam_role_policy_attachment" "basic" {
  role       = aws_iam_role.lambda.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}

resource "aws_iam_role_policy" "arc_sns" {
  name = "arc-sns-policy"
  role = aws_iam_role.lambda.id
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      { Effect = "Allow"
        Action = ["route53-recovery-cluster:GetRoutingControlState",
                  "route53-recovery-cluster:UpdateRoutingControlStates"]
        Resource = "*" },
      { Effect = "Allow"
        Action   = "sns:Publish"
        Resource = aws_sns_topic.alerts.arn }
    ]
  })
}

# ── Lambda Function ────────────────────────────────────────────────────────────
resource "aws_lambda_function" "failover" {
  function_name = "route53-arc-failover-handler"
  runtime       = "python3.12"
  handler       = "handler.lambda_handler"
  role          = aws_iam_role.lambda.arn
  timeout       = 60
  memory_size   = 128

  s3_bucket = aws_s3_bucket.lambda_code.id
  s3_key    = aws_s3_object.failover_zip.key

  environment {
    variables = {
      PRIMARY_RC_ARN   = local.primary_rc_arn
      SECONDARY_RC_ARN = local.secondary_rc_arn
      CLUSTER_ENDPOINT = local.cluster_endpoint
      SNS_TOPIC_ARN    = aws_sns_topic.alerts.arn
    }
  }
}

resource "aws_lambda_permission" "sns_invoke" {
  statement_id  = "AllowSNSInvoke"
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.failover.function_name
  principal     = "sns.amazonaws.com"
  source_arn    = aws_sns_topic.alerts.arn
}

resource "aws_sns_topic_subscription" "lambda_sub" {
  topic_arn = aws_sns_topic.alerts.arn
  protocol  = "lambda"
  endpoint  = aws_lambda_function.failover.arn
}

# ── CloudWatch Alarm con acción SNS ───────────────────────────────────────────
resource "aws_cloudwatch_metric_alarm" "with_action" {
  alarm_name          = "${local.cw_alarm_base}-with-sns"
  alarm_description   = "Sitio primario caído – dispara SNS + Lambda failover"
  namespace           = "AWS/Route53"
  metric_name         = "HealthCheckStatus"
  statistic           = "Minimum"
  period              = 60
  evaluation_periods  = 3
  threshold           = 1
  comparison_operator = "LessThanThreshold"
  treat_missing_data  = "breaching"
  dimensions          = { HealthCheckId = local.hc_id }
  alarm_actions       = [aws_sns_topic.alerts.arn]
  ok_actions          = [aws_sns_topic.alerts.arn]
}
EOF

3. outputs.tf

¿Para qué sirve este archivo?
Exporta el ARN del tópico SNS, el nombre de la función Lambda y el nombre de la alarma CloudWatch para usarlos en el laboratorio de simulación (Lab 5).

cat > outputs.tf << 'EOF'
output "sns_topic_arn" { value = aws_sns_topic.alerts.arn }
output "lambda_name"   { value = aws_lambda_function.failover.function_name }
output "alarm_name"    { value = aws_cloudwatch_metric_alarm.with_action.alarm_name }
EOF

Paso 4: Desplegar

terraform init

terraform apply \
  -var="notification_email=$NOTIFICATION_EMAIL" \
  -auto-approve
Aviso

Revisá tu email → “AWS Notification - Subscription Confirmation” → clic en Confirm subscription.


✅ Verificación del Lab 4

echo "=== Verificación Lab 4 ==="
LAMBDA_NAME=$(terraform output -raw lambda_name)

aws lambda get-function --function-name $LAMBDA_NAME --region $AWS_REGION \
  --query 'Configuration.State' --output text \
  | grep -q Active && echo "✅ Lambda activa" || echo "❌ Lambda no activa"

aws lambda invoke \
  --function-name $LAMBDA_NAME \
  --payload '{"alarm_state":"OK"}' \
  --cli-binary-format raw-in-base64-out \
  /tmp/lambda-test.json --region $AWS_REGION
echo "Respuesta:"; cat /tmp/lambda-test.json; echo

Siguiente paso → Lab 5: Simulación