El problema
Convertí el diseño de alertas en un módulo de Terraform que se reutiliza en cada entorno. Crea el topic SNS, la suscripción por email y las alarmas de un jumpbox fijo y de un servidor web detrás de un ASG.
El porqué de cada decisión (qué alarmas, severidades, naming, InstanceId frente a ASG) está en Alertas preventivas en AWS como código con Terraform. Aquí va solo el cómo.
Lo monté en una cuenta de laboratorio. Los nombres, IDs y correos son de ejemplo.
El módulo no sabe nada del entorno: el root le pasa los IDs y el email, y el mismo código sirve para dev, staging y prod.
La estructura
Separé las alarmas por tipo de recurso. Así cada diff del plan dice a qué recurso toca.
# árbol del repositorio
terraform/
main/
main.tf
variables.tf
modules/
monitoring_ec2_alerts/
variables.tf
sns.tf
alarms_bastion.tf
alarms_api_asg.tf
outputs.tf
El topic y la suscripción
El topic y su suscripción por email son lo primero que creo, porque sin canal ninguna alarma avisa a nadie.
# modules/monitoring_ec2_alerts/sns.tf
resource "aws_sns_topic" "infra_alerts" {
name = var.topic_name
}
resource "aws_sns_topic_subscription" "infra_alerts_email" {
topic_arn = aws_sns_topic.infra_alerts.arn
protocol = "email"
endpoint = var.alert_email_endpoint
}
# modules/monitoring_ec2_alerts/variables.tf
variable "topic_name" {
type = string
default = "demo-infra-alerts"
}
variable "alert_email_endpoint" {
type = string
}
Terraform crea la suscripción por email, pero no puede confirmarla. Hasta que alguien pulsa el enlace del correo, las alarmas saltan y no llega nada.
Alarmas para una instancia fija
Para el jumpbox uso la dimensión InstanceId. Esta es la alarma de CPU en warning:
# modules/monitoring_ec2_alerts/alarms_bastion.tf
resource "aws_cloudwatch_metric_alarm" "bastion_cpu_warning" {
alarm_name = "demo-ec2-jumpbox-cpu-warning"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 1
datapoints_to_alarm = 1
metric_name = "CPUUtilization"
namespace = "AWS/EC2"
period = 300
statistic = "Average"
threshold = 75
treat_missing_data = "notBreaching"
dimensions = {
InstanceId = var.bastion_instance_id
}
alarm_actions = [aws_sns_topic.infra_alerts.arn]
ok_actions = [aws_sns_topic.infra_alerts.arn]
}
Dos detalles. period = 300 encaja con la ventana de 5 minutos y con las métricas de Basic Monitoring. Y ok_actions envía también el aviso de vuelta a OK, así sé cuándo se ha recuperado.
La alarma critical es la misma con threshold = 85, evaluation_periods = 2 y datapoints_to_alarm = 2: dos bloques de 5 minutos seguidos.
Alarmas para lo que vive detrás de un ASG
Para el servidor web cambio la dimensión. La alarma de CPU es idéntica a la anterior salvo el nombre y este bloque:
# modules/monitoring_ec2_alerts/alarms_api_asg.tf
alarm_name = "demo-ec2-web-cpu-warning"
dimensions = {
AutoScalingGroupName = var.api_asg_name
}
StatusCheckFailed sí cambia más: estadística Maximum, periodo de un minuto y umbral cero.
# modules/monitoring_ec2_alerts/alarms_api_asg.tf
resource "aws_cloudwatch_metric_alarm" "api_statuscheckfailed_critical" {
alarm_name = "demo-ec2-web-statuscheckfailed-critical"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 1
datapoints_to_alarm = 1
metric_name = "StatusCheckFailed"
namespace = "AWS/EC2"
period = 60
statistic = "Maximum"
threshold = 0
dimensions = {
AutoScalingGroupName = var.api_asg_name
}
alarm_actions = [aws_sns_topic.infra_alerts.arn]
}
Conectar el módulo en el root
El root le pasa al módulo lo que solo él conoce. Ningún ID va escrito dentro del módulo.
# terraform/main/main.tf
module "monitoring_ec2_alerts" {
source = "../modules/monitoring_ec2_alerts"
alert_email_endpoint = var.infra_alerts_email
bastion_instance_id = module.bastion.instance_id
api_asg_name = module.api.asg_name
}
# terraform/main/variables.tf
variable "infra_alerts_email" {
description = "Email endpoint for infrastructure alerts SNS subscription"
type = string
}
En Terraform Cloud, infra_alerts_email va como Terraform variable, porque es un input del código. Las credenciales AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY y AWS_DEFAULT_REGION van como Environment variables. Mezclarlas da warnings y configuraciones ambiguas.
Aplicar y comprobar
Antes de cada apply paso por el mismo orden:
# terminal, en terraform/main
terraform fmt -recursive
terraform validate
terraform plan
terraform apply
Después compruebo tres cosas con la CLI: que la suscripción está confirmada, que el canal entrega y que las alarmas existen con el nombre esperado.
# terminal
TOPIC_ARN="arn:aws:sns:eu-central-1:123456789012:demo-infra-alerts"
aws sns list-subscriptions-by-topic --topic-arn "$TOPIC_ARN" \
--query "Subscriptions[].SubscriptionArn"
aws sns publish --topic-arn "$TOPIC_ARN" \
--subject "Prueba de canal" --message "Mensaje de prueba"
aws cloudwatch describe-alarms --alarm-name-prefix "demo-ec2-" \
--query "MetricAlarms[].{Name:AlarmName,State:StateValue}" --output table
Si la suscripción aparece como PendingConfirmation, el correo de confirmación sigue sin pulsar. Con el canal validado, toca forzar la alarma con carga real: lo cuento en Alarmas de CloudWatch para EC2, probadas con carga real.
Cómo ampliarlo
Con el módulo validado, el siguiente paso es cubrir más servicios con el mismo patrón:
- RDS: CPU, conexiones y almacenamiento libre.
- ElastiCache: memoria, evictions y CPU.
- ALB: latencia y respuestas 5xx.
- Memoria y disco de EC2, que necesitan el CloudWatch Agent.
Cada uno en su fichero, con el mismo naming y el mismo topic.
Lo que aprendí
Terraform crea la suscripción por email, pero no la confirma. Desde entonces, list-subscriptions-by-topic va en la lista de comprobaciones de cada apply, antes que cualquier alarma.
Si tienes dudas o lo estás montando tú, cuéntamelo.