Lab 4: SNS + Lambda Failover
Lab 4: SNS + Lambda Failover
Estructura del módulo
04_automation/
├── main.tf
├── variables.tf
├── outputs.tf
└── lambda/
└── handler.pyPaso 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" # opcionalPaso 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:
- Parsea el mensaje recibido de CloudWatch.
- Se conecta al endpoint del cluster ARC mediante
boto3.- Cambia el estado del Routing Control Primario a
Offy el Secundario aOn.- 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" }
EOF2. 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]
}
EOF3. 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 }
EOFPaso 4: Desplegar
terraform init
terraform apply \
-var="notification_email=$NOTIFICATION_EMAIL" \
-auto-approveAviso
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; echoSiguiente paso → Lab 5: Simulación