Lab 4: Notificaciones SNS + Lambda Failover

Lab 4: Notificaciones SNS + Lambda Failover

En este lab conectamos todos los piezas: cuando el Health Check detecta que el primario cae, SNS envía un email/SMS y Lambda ejecuta el failover cambiando el Routing Control automáticamente.


Flujo completo

Health Check falla
      │
      ▼
CloudWatch Alarm → "ALARM"
      │
      ├──► SNS Topic → Email (notificación instantánea)
      │             → SMS (opcional)
      │
      └──► Lambda Function "failover-handler"
                │
                ├── Cambia Primary RC → OFF
                ├── Cambia Secondary RC → ON
                └── Publica confirmación en SNS

Paso 1: Variables de entorno

export AWS_REGION="us-east-1"
export AWS_ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)

# Recuperar ARNs del Lab 3
export PRIMARY_RC_ARN=$(aws cloudformation describe-stacks \
  --stack-name route53-arc-routing --region $AWS_REGION \
  --query 'Stacks[0].Outputs[?OutputKey==`PrimaryRoutingControlArn`].OutputValue' \
  --output text)

export SECONDARY_RC_ARN=$(aws cloudformation describe-stacks \
  --stack-name route53-arc-routing --region $AWS_REGION \
  --query 'Stacks[0].Outputs[?OutputKey==`SecondaryRoutingControlArn`].OutputValue' \
  --output text)

export ARC_CLUSTER_ARN=$(aws cloudformation describe-stacks \
  --stack-name route53-arc-routing --region $AWS_REGION \
  --query 'Stacks[0].Outputs[?OutputKey==`ARCClusterArn`].OutputValue' \
  --output text)

export CW_ALARM_NAME=$(aws cloudformation describe-stacks \
  --stack-name route53-arc-routing --region $AWS_REGION \
  --query 'Stacks[0].Outputs[?OutputKey==`CloudWatchAlarmName`].OutputValue' \
  --output text)

# IMPORTANTE: reemplaza con tu email real
export NOTIFICATION_EMAIL="tu-email@ejemplo.com"
# OPCIONAL: reemplaza con tu número (formato internacional)
export NOTIFICATION_PHONE="+54911XXXXXXXX"

echo "Primary RC:   $PRIMARY_RC_ARN"
echo "Secondary RC: $SECONDARY_RC_ARN"
echo "CW Alarm:     $CW_ALARM_NAME"
Aviso

Reemplaza tu-email@ejemplo.com con tu email real antes de continuar. Recibirás un email de confirmación de AWS SNS que debes aceptar para que las notificaciones lleguen.


Paso 2: Crear el código Lambda

mkdir -p /tmp/lambda-failover

cat > /tmp/lambda-failover/handler.py << 'LAMBDA_EOF'
"""
roLearning – Route53 ARC Workshop
Lambda: Failover Handler

Triggered by: CloudWatch Alarm (via SNS)
Action: Cambia el Routing Control primario → OFF y secundario → ON
"""

import json
import os
import boto3
import logging

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

# Variables de entorno (inyectadas por CloudFormation)
PRIMARY_RC_ARN    = os.environ.get('PRIMARY_RC_ARN')
SECONDARY_RC_ARN  = os.environ.get('SECONDARY_RC_ARN')
CLUSTER_ENDPOINT  = os.environ.get('CLUSTER_ENDPOINT')
SNS_TOPIC_ARN     = os.environ.get('SNS_TOPIC_ARN')
REGION            = os.environ.get('AWS_REGION', 'us-east-1')


def get_arc_client():
    """Crea cliente de ARC Recovery Cluster con el endpoint del cluster."""
    return boto3.client(
        'route53-recovery-cluster',
        endpoint_url=CLUSTER_ENDPOINT,
        region_name=REGION
    )


def get_current_state(arc_client, routing_control_arn):
    """Obtiene el estado actual de un Routing Control."""
    response = arc_client.get_routing_control_state(
        RoutingControlArn=routing_control_arn
    )
    return response['RoutingControlState']


def execute_failover(arc_client):
    """Ejecuta el failover: primario OFF, secundario ON."""
    logger.info("Ejecutando failover: Primario → OFF, Secundario → ON")

    arc_client.update_routing_control_states(
        UpdateRoutingControlStateEntries=[
            {
                'RoutingControlArn': PRIMARY_RC_ARN,
                'RoutingControlState': 'Off'
            },
            {
                'RoutingControlArn': SECONDARY_RC_ARN,
                'RoutingControlState': 'On'
            }
        ]
    )
    logger.info("✅ Failover ejecutado exitosamente")


def notify_sns(message, subject):
    """Publica un mensaje en el topic SNS."""
    if not SNS_TOPIC_ARN:
        logger.warning("SNS_TOPIC_ARN no configurado, omitiendo notificación")
        return

    sns = boto3.client('sns', region_name=REGION)
    sns.publish(
        TopicArn=SNS_TOPIC_ARN,
        Subject=subject,
        Message=message
    )
    logger.info(f"Notificación SNS enviada: {subject}")


def lambda_handler(event, context):
    """
    Handler principal de Lambda.
    Recibe eventos de CloudWatch Alarm vía SNS.
    """
    logger.info(f"Evento recibido: {json.dumps(event)}")

    # Extraer mensaje de la alarma
    alarm_state = "UNKNOWN"
    alarm_name  = "route53-arc-primary-unhealthy"

    try:
        # El evento viene de SNS → mensaje JSON de CloudWatch
        for record in event.get('Records', []):
            if record.get('EventSource') == 'aws:sns':
                sns_message = json.loads(record['Sns']['Message'])
                alarm_state = sns_message.get('NewStateValue', 'UNKNOWN')
                alarm_name  = sns_message.get('AlarmName', alarm_name)
                logger.info(f"Alarma: {alarm_name} | Estado: {alarm_state}")
    except (KeyError, json.JSONDecodeError) as e:
        logger.warning(f"No se pudo parsear el evento SNS: {e}")
        # Si se invoca manualmente, continuamos de todos modos
        alarm_state = event.get('alarm_state', 'ALARM')

    if alarm_state != 'ALARM':
        logger.info(f"Estado no es ALARM ({alarm_state}), sin acción.")
        return {
            'statusCode': 200,
            'body': f'No action needed. Alarm state: {alarm_state}'
        }

    # Verificar estado actual de los Routing Controls
    arc_client = get_arc_client()

    primary_state   = get_current_state(arc_client, PRIMARY_RC_ARN)
    secondary_state = get_current_state(arc_client, SECONDARY_RC_ARN)

    logger.info(f"Estado actual - Primario: {primary_state} | Secundario: {secondary_state}")

    # Evitar failover redundante
    if primary_state == 'Off' and secondary_state == 'On':
        logger.info("Failover ya está activo. Sin cambios necesarios.")
        return {
            'statusCode': 200,
            'body': 'Failover already active'
        }

    # Ejecutar failover
    execute_failover(arc_client)

    # Notificar resultado
    notify_sns(
        subject="🚨 [FAILOVER ACTIVADO] Sitio primario caído",
        message=(
            f"ALERTA: El sitio web primario no responde.\n\n"
            f"Acción tomada: Failover automático ejecutado.\n"
            f"- Routing Control Primario: OFF\n"
            f"- Routing Control Secundario: ON\n\n"
            f"El tráfico fue redirigido a la página de mantenimiento (CloudFront + S3).\n\n"
            f"Alarma: {alarm_name}\n"
            f"Hora: {context.aws_request_id}\n\n"
            f"Para recuperar el sitio primario, ejecuta el Lab 5 (Failback).\n"
            f"roLearning – Route53 ARC Workshop"
        )
    )

    return {
        'statusCode': 200,
        'body': json.dumps({
            'action': 'FAILOVER_EXECUTED',
            'primary_new_state': 'Off',
            'secondary_new_state': 'On'
        })
    }
LAMBDA_EOF

# Empaquetar en ZIP
cd /tmp/lambda-failover && zip -r /tmp/failover-handler.zip handler.py
echo "✅ Lambda package creado: $(ls -lh /tmp/failover-handler.zip | awk '{print $5}')"

Paso 3: Subir Lambda a S3 y desplegar el stack

# Crear bucket temporal para el código Lambda
LAMBDA_BUCKET="route53-arc-lambda-${AWS_ACCOUNT_ID}"
aws s3 mb s3://$LAMBDA_BUCKET --region $AWS_REGION 2>/dev/null || true

# Subir el ZIP
aws s3 cp /tmp/failover-handler.zip s3://$LAMBDA_BUCKET/failover-handler.zip \
  --region $AWS_REGION

echo "✅ Lambda ZIP subido a s3://$LAMBDA_BUCKET/failover-handler.zip"

Paso 4: Crear el template CloudFormation para SNS + Lambda

cat > /tmp/sns-lambda.yaml << 'TEMPLATE_EOF'
AWSTemplateFormatVersion: '2010-09-09'
Description: 'roLearning - Route53 ARC Workshop - SNS + Lambda Failover'

Parameters:
  NotificationEmail:
    Type: String
    Description: "Email para recibir alertas SNS"

  NotificationPhone:
    Type: String
    Default: ""
    Description: "Número de teléfono para SMS (formato +54911...) - opcional"

  PrimaryRCAarn:
    Type: String
    Description: "ARN del Routing Control primario"

  SecondaryRCAarn:
    Type: String
    Description: "ARN del Routing Control secundario"

  ARCClusterArn:
    Type: String
    Description: "ARN del Cluster ARC"

  CloudWatchAlarmName:
    Type: String
    Description: "Nombre de la alarma CloudWatch del Lab 3"

  LambdaBucket:
    Type: String
    Description: "Bucket S3 con el código Lambda"

Conditions:
  HasPhone: !Not [!Equals [!Ref NotificationPhone, ""]]

Resources:

  # ──────────────────────────────────────────────
  # SNS Topic para alertas
  # ──────────────────────────────────────────────
  AlertTopic:
    Type: AWS::SNS::Topic
    Properties:
      TopicName: route53-arc-alerts
      DisplayName: "Route53 ARC - Alertas de Disponibilidad"
      Tags:
        - Key: Workshop
          Value: route53-arc

  EmailSubscription:
    Type: AWS::SNS::Subscription
    Properties:
      TopicArn: !Ref AlertTopic
      Protocol: email
      Endpoint: !Ref NotificationEmail

  SMSSubscription:
    Type: AWS::SNS::Subscription
    Condition: HasPhone
    Properties:
      TopicArn: !Ref AlertTopic
      Protocol: sms
      Endpoint: !Ref NotificationPhone

  # ──────────────────────────────────────────────
  # IAM Role para Lambda
  # ──────────────────────────────────────────────
  LambdaExecutionRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: route53-arc-lambda-failover-role
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
      Policies:
        - PolicyName: ARCFailoverPolicy
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action:
                  - route53-recovery-cluster:GetRoutingControlState
                  - route53-recovery-cluster:UpdateRoutingControlStates
                Resource: "*"
              - Effect: Allow
                Action:
                  - sns:Publish
                Resource: !Ref AlertTopic
      Tags:
        - Key: Workshop
          Value: route53-arc

  # ──────────────────────────────────────────────
  # Lambda Function – Failover Handler
  # ──────────────────────────────────────────────
  FailoverLambda:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: route53-arc-failover-handler
      Runtime: python3.12
      Handler: handler.lambda_handler
      Role: !GetAtt LambdaExecutionRole.Arn
      Timeout: 60
      MemorySize: 128
      Code:
        S3Bucket: !Ref LambdaBucket
        S3Key: failover-handler.zip
      Environment:
        Variables:
          PRIMARY_RC_ARN:   !Ref PrimaryRCAarn
          SECONDARY_RC_ARN: !Ref SecondaryRCAarn
          CLUSTER_ENDPOINT: !Sub
            - "https://${Endpoint}"
            - Endpoint: !Select
                [2, !Split ["/", !Select [0, !Split [":", !Ref ARCClusterArn]]]]
          SNS_TOPIC_ARN: !Ref AlertTopic
      Tags:
        - Key: Workshop
          Value: route53-arc

  # ──────────────────────────────────────────────
  # Permiso para SNS → Lambda
  # ──────────────────────────────────────────────
  LambdaSNSPermission:
    Type: AWS::Lambda::Permission
    Properties:
      FunctionName: !GetAtt FailoverLambda.Arn
      Action: lambda:InvokeFunction
      Principal: sns.amazonaws.com
      SourceArn: !Ref AlertTopic

  # ──────────────────────────────────────────────
  # Suscripción SNS → Lambda (el trigger)
  # ──────────────────────────────────────────────
  LambdaSNSSubscription:
    Type: AWS::SNS::Subscription
    Properties:
      TopicArn: !Ref AlertTopic
      Protocol: lambda
      Endpoint: !GetAtt FailoverLambda.Arn

  # ──────────────────────────────────────────────
  # Conectar la Alarma CloudWatch al SNS Topic
  # ──────────────────────────────────────────────
  AlarmSNSAction:
    Type: AWS::CloudWatch::Alarm
    Properties:
      AlarmName: !Sub "${CloudWatchAlarmName}-with-sns"
      AlarmDescription: "Sitio primario caído - dispara SNS + Lambda failover"
      Namespace: AWS/Route53
      MetricName: HealthCheckStatus
      Dimensions:
        - Name: HealthCheckId
          Value:
            Fn::ImportValue: route53-arc-hc-id
      Statistic: Minimum
      Period: 60
      EvaluationPeriods: 3
      Threshold: 1
      ComparisonOperator: LessThanThreshold
      TreatMissingData: breaching
      AlarmActions:
        - !Ref AlertTopic
      OKActions:
        - !Ref AlertTopic

Outputs:
  SNSTopicArn:
    Description: "ARN del Topic SNS de alertas"
    Value: !Ref AlertTopic
    Export:
      Name: route53-arc-sns-topic

  LambdaArn:
    Description: "ARN de la Lambda de failover"
    Value: !GetAtt FailoverLambda.Arn
    Export:
      Name: route53-arc-lambda-arn

  LambdaName:
    Description: "Nombre de la Lambda"
    Value: !Ref FailoverLambda
    Export:
      Name: route53-arc-lambda-name
TEMPLATE_EOF

echo "✅ Template SNS+Lambda creado"

Paso 5: Desplegar el stack

# Necesitamos el cluster endpoint
CLUSTER_ENDPOINT=$(aws route53-recovery-control-config describe-cluster \
  --cluster-arn $ARC_CLUSTER_ARN \
  --region $AWS_REGION \
  --query 'Cluster.ClusterEndpoints[0].Endpoint' \
  --output text)

aws cloudformation deploy \
  --template-file /tmp/sns-lambda.yaml \
  --stack-name route53-arc-automation \
  --capabilities CAPABILITY_NAMED_IAM \
  --region $AWS_REGION \
  --parameter-overrides \
    NotificationEmail=$NOTIFICATION_EMAIL \
    PrimaryRCAarn=$PRIMARY_RC_ARN \
    SecondaryRCAarn=$SECONDARY_RC_ARN \
    ARCClusterArn=$ARC_CLUSTER_ARN \
    CloudWatchAlarmName=$CW_ALARM_NAME \
    LambdaBucket=route53-arc-lambda-${AWS_ACCOUNT_ID} \
  --tags Workshop=route53-arc

echo "✅ Stack SNS+Lambda desplegado"

Paso 6: Confirmar suscripción de email

Aviso

Paso obligatorio: Revisa tu email $NOTIFICATION_EMAIL. Habrá llegado un mensaje con asunto “AWS Notification - Subscription Confirmation”. Haz clic en “Confirm subscription” para activar las notificaciones.


✅ Verificación del Lab 4

echo "=== Verificación Lab 4: SNS + Lambda Failover ==="

LAMBDA_NAME=$(aws cloudformation describe-stacks \
  --stack-name route53-arc-automation --region $AWS_REGION \
  --query 'Stacks[0].Outputs[?OutputKey==`LambdaName`].OutputValue' \
  --output text)

SNS_ARN=$(aws cloudformation describe-stacks \
  --stack-name route53-arc-automation --region $AWS_REGION \
  --query 'Stacks[0].Outputs[?OutputKey==`SNSTopicArn`].OutputValue' \
  --output text)

# 1. Lambda existe y está activa
LAMBDA_STATE=$(aws lambda get-function \
  --function-name $LAMBDA_NAME --region $AWS_REGION \
  --query 'Configuration.State' --output text 2>/dev/null)
[ "$LAMBDA_STATE" = "Active" ] \
  && echo "✅ Lambda Function: $LAMBDA_STATE" \
  || echo "❌ Lambda State: $LAMBDA_STATE"

# 2. SNS Topic existe
aws sns get-topic-attributes \
  --topic-arn $SNS_ARN --region $AWS_REGION \
  --query 'Attributes.TopicArn' --output text > /dev/null 2>&1 \
  && echo "✅ SNS Topic: OK" \
  || echo "❌ SNS Topic no encontrado"

# 3. Test manual de la Lambda (sin failover real)
echo ""
echo "Probando Lambda con evento de prueba (sin cambiar Routing Controls)..."
aws lambda invoke \
  --function-name $LAMBDA_NAME \
  --region $AWS_REGION \
  --payload '{"alarm_state": "OK", "test": true}' \
  --cli-binary-format raw-in-base64-out \
  /tmp/lambda-test-output.json

echo "Respuesta Lambda:"
cat /tmp/lambda-test-output.json
echo ""

echo "📌 Guarda estos valores:"
echo "   LAMBDA_NAME = $LAMBDA_NAME"
echo "   SNS_ARN     = $SNS_ARN"

Siguiente paso → Lab 5: Simulación de Incidente