Al final de la lección anterior, Rutas Norte tenía por fin memoria: Prometheus guarda treinta días de métricas de todos los componentes y podemos preguntarle cualquier cosa en PromQL. Pero se quedó un problema abierto: todo eso vive en una interfaz austera donde hay que escribir consultas a mano, y nadie está mirándola a las tres de la madrugada. Un dato que nadie ve y del que nadie recibe aviso no ha resuelto nada.
Esta lección da ese salto: de la consulta suelta al cuadro de mando que cuenta una historia de un vistazo, y de ahí a la alerta que despierta a alguien porque de verdad hace falta. Veremos Grafana para visualizar, PrometheusRule para definir cuándo algo va mal, y Alertmanager para decidir a quién se avisa, cuándo y con qué agrupación. Y sobre todo veremos el criterio que separa un sistema de alertas útil de un generador de ruido que todo el mundo acaba silenciando: alertar sobre síntomas que percibe el cliente, no sobre causas.
Contenido
- Grafana: qué es y cómo se conecta a Prometheus
- Anatomía de un panel y los tipos que de verdad se usan
- Variables de panel: un cuadro de mando para los tres entornos
- Importar cuadros de mando de la comunidad
- El cuadro de mando de Rutas Norte, panel a panel
- Provisionar cuadros de mando como código
- Reglas de alerta con
PrometheusRuley la importancia defor - El catálogo de alertas de Rutas Norte
- Alertmanager: rutas, receptores, inhibición y silencios
- El criterio: alertar sobre síntomas, no sobre causas
- SLO y presupuesto de error de Rutas Norte
- Errores comunes y consejos
- Ejercicios
- Grafana: qué es y cómo se conecta a Prometheus
Grafana es una herramienta de visualización que se conecta a fuentes de datos —Prometheus, Elasticsearch, PostgreSQL, decenas más— y dibuja cuadros de mando. No almacena métricas: solo consulta y pinta. Si Prometheus se cae, Grafana se queda en blanco.
Esa separación de responsabilidades es deliberada y muy sana: Prometheus se especializa en recoger, guardar y evaluar; Grafana en mostrar. Cada uno hace bien una cosa.
Cuando en 07-03 instalamos el kube-prometheus-stack, Grafana vino incluida y con la fuente de datos ya configurada. El chart crea automáticamente la conexión al Service de Prometheus, así que no hay que introducir ninguna URL a mano.
# Comprobar que Grafana está corriendo
kubectl -n monitorizacion get pods -l app.kubernetes.io/name=grafanaFíjate en el 3/3: además del contenedor de Grafana hay dos sidecars, uno que vigila los ConfigMaps de cuadros de mando y otro los de fuentes de datos. El primero será clave en el apartado 6.
Acceso
kubectl -n monitorizacion port-forward svc/monitorizacion-grafana 3000:80
# Abrir http://localhost:3000Las credenciales iniciales están en un Secret creado por el chart:
kubectl -n monitorizacion get secret monitorizacion-grafana \
-o jsonpath='{.data.admin-user}' | base64 -d; echo
kubectl -n monitorizacion get secret monitorizacion-grafana \
-o jsonpath='{.data.admin-password}' | base64 -d; echoAntes de seguir. Ese valor lo pusimos en el fichero de valores de Helm en 07-03, y en
rutas-norte-proes inaceptable por dos motivos: está en texto plano en un fichero versionado en Git, y es una contraseña débil. En producción hay que (a) generar la contraseña como Secret externo y referenciarla conadmin.existingSecret, y (b) mejor aún, delegar la autenticación en el proveedor de identidad de la empresa (OAuth o LDAP), desactivando el usuario local. Grafana da acceso de lectura a todas las métricas de la plataforma, incluidas las de negocio.
En rutas-norte-pro publicaríamos Grafana con un Ingress y TLS gestionado por cert-manager, exactamente como hicimos en 04-04 y 04-05:
# k8s/entornos/pro/ingress-grafana.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: grafana
namespace: monitorizacion
annotations:
cert-manager.io/cluster-issuer: letsencrypt-produccion
nginx.ingress.kubernetes.io/auth-type: basic # capa extra
nginx.ingress.kubernetes.io/auth-secret: grafana-basic-auth
spec:
ingressClassName: nginx
tls:
- hosts: [metricas.rutasnorte.example]
secretName: grafana-tls
rules:
- host: metricas.rutasnorte.example
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: monitorizacion-grafana
port:
number: 80Verificar la fuente de datos
En la interfaz: Connections → Data sources → Prometheus. Debe aparecer configurada y con el botón "Save & test" devolviendo verde. La URL interna que usa es el DNS del Service (04-03):
Un detalle de configuración que conviene ajustar: el campo Scrape interval debe coincidir con el interval de tus ServiceMonitor (30 s en nuestro caso). Grafana lo usa para calcular la variable $__rate_interval, que veremos enseguida.
- Anatomía de un panel y los tipos que de verdad se usan
Un panel de Grafana tiene cuatro partes, y entenderlas evita el 90 % de los cuadros de mando ilegibles.
- La consulta
Una o varias expresiones PromQL. Grafana ofrece dos modos: el constructor visual (útil para aprender) y el modo código (el que usarás en cuanto sepas PromQL).
Dos variables especiales que Grafana sustituye automáticamente:
| Variable | Qué es | Cuándo usarla |
|---|---|---|
$__rate_interval |
Ventana adaptada al zoom y al intervalo de escaneo | Siempre dentro de rate() |
$__interval |
Resolución del gráfico según el ancho en píxeles | En increase() sobre ventanas variables |
Usa siempre rate(metrica[$__rate_interval]) en lugar de rate(metrica[5m]). Con la ventana fija, al hacer zoom a 7 días el gráfico se vuelve ruidoso o produce huecos; $__rate_interval se adapta y garantiza que siempre haya al menos cuatro muestras en la ventana.
- La leyenda con plantillas de etiquetas
Por defecto, Grafana muestra la leyenda con todas las etiquetas de la serie:
{__name__="rutasnorte_peticiones_total", codigo="200", componente="api-reservas", entorno="pro", instance="10.244.2.17:9090", job="api-reservas", metodo="GET", namespace="rutas-norte-pro", pod="api-reservas-7d9f8c4b5-x2klm", ruta="/api/rutas", version="2.8.1"}Ilegible. Con una plantilla en el campo Legend:
Regla práctica: la leyenda debe caber en una línea y contener solo lo que distingue a esa serie de las demás del panel.
- Unidades y umbrales
En Standard options → Unit. Este ajuste es más importante de lo que parece:
| Métrica | Unidad correcta | Sin ella se ve |
|---|---|---|
| Latencia en segundos | seconds (s) |
0.412 en vez de 412 ms |
| Tasa de error (0-1) | Percent (0.0-1.0) |
0.023 en vez de 2,3 % |
| Memoria en bytes | bytes (IEC) |
536870912 en vez de 512 MiB |
| Peticiones por segundo | requests/sec (rps) |
Un número desnudo sin contexto |
Los umbrales (Thresholds) colorean el panel según el valor. Para la tasa de error de api-reservas: verde hasta 0,01, amarillo de 0,01 a 0,05, rojo por encima. Un cuadro de mando bien pintado permite detectar el problema desde el otro lado de la oficina, sin leer un número.
- El tipo de visualización
De las decenas que ofrece Grafana, en la práctica se usan cuatro:
| Tipo | Cuándo usarlo | Ejemplo en Rutas Norte |
|---|---|---|
| Time series | Evolución de un valor en el tiempo. El 70 % de los paneles | Peticiones/s, latencia p95, memoria |
| Stat | Un número grande, el estado actual de un indicador | Reservas confirmadas en la última hora |
| Table | Comparar el estado de varias entidades a la vez | Estado de todos los pods, con reinicios y edad |
| Heatmap | Distribución completa de un histograma en el tiempo | Distribución de latencias, no solo el p95 |
Sobre el mapa de calor: es el tipo más infrautilizado y el que más información da sobre latencia. Un p95 de 400 ms puede ocultar dos poblaciones muy distintas (una masa a 20 ms y una cola a 3 s) o una distribución uniforme. El mapa de calor las distingue de un vistazo, y se alimenta directamente de las cubetas del histograma:
sum by (le) (rate(rutasnorte_duracion_peticion_segundos_bucket{entorno="$entorno"}[$__rate_interval]))Con el formato de la consulta puesto en Heatmap y la opción Format: Heatmap activada.
Tipos que conviene evitar: los indicadores de aguja (gauge) ocupan mucho y dicen poco, y las tartas son casi siempre una mala elección para datos temporales.
- Variables de panel: un cuadro de mando para los tres entornos
Sin variables, tendrías que duplicar el cuadro de mando tres veces: uno para rutas-norte-dev, otro para pre y otro para pro. Tres copias que se desincronizan a la primera modificación.
Las variables convierten el cuadro de mando en una plantilla con desplegables en la parte superior.
Definirlas
En Dashboard settings → Variables.
Variable entorno (tipo Query):
Nombre: entorno
Tipo: Query
Data source: Prometheus
Query: label_values(rutasnorte_peticiones_total, entorno)
Sort: Alphabetical (asc)
Multi-value: Nolabel_values(metrica, etiqueta) es una función específica de Grafana que devuelve todos los valores distintos que tiene esa etiqueta. El desplegable se rellena solo con dev, pre y pro, y si mañana añadimos un cuarto entorno aparecerá automáticamente.
Variable namespace (dependiente de la anterior):
Variable componente (multivalor):
Nombre: componente
Query: label_values(kube_pod_info{namespace="$namespace"}, created_by_name)
Multi-value: Sí
Include All: Sí
All value: .*Con Multi-value puedes seleccionar varios a la vez; con Include All, la opción "All".
Variable intervalo (tipo Interval, para ajustar la ventana de las tasas):
Usarlas en las consultas
# Con una variable simple
sum(rate(rutasnorte_peticiones_total{entorno="$entorno"}[$__rate_interval]))
# Con una variable multivalor: OBLIGATORIO usar =~ y el formato regex
sum by (pod) (rate(container_cpu_usage_seconds_total{
namespace="$namespace",
pod=~"$componente.*"
}[$__rate_interval]))Detalle que causa muchos quebraderos de cabeza: con variables multivalor, Grafana sustituye $componente por (api-reservas|tienda-web). Eso solo funciona con el operador =~, nunca con =. Si usas =, la consulta no devuelve nada y no hay ningún mensaje de error que lo explique.
Para forzar el formato explícitamente: ${componente:regex} o ${componente:pipe}.
El resultado
Un único cuadro de mando, versionado una sola vez, que sirve para los tres entornos. Cambias el desplegable de pro a pre y ves los mismos paneles con los datos del otro entorno. Y si añadimos un componente nuevo con las etiquetas de la convención de Rutas Norte, aparece solo en el desplegable.
- Importar cuadros de mando de la comunidad
Grafana tiene un catálogo público con miles de cuadros de mando. Se importan por su identificador numérico: Dashboards → New → Import → pegar el ID.
Los que de verdad interesan para un clúster de Kubernetes:
| ID | Cuadro de mando | Qué muestra |
|---|---|---|
| 315 | Kubernetes cluster monitoring | Visión general del clúster: nodos, pods, red |
| 1860 | Node Exporter Full | Todo lo que expone node-exporter, muy completo |
| 6417 | Kubernetes Cluster (Prometheus) | Recursos por namespace y por workload |
| 9628 | PostgreSQL Database | Para el exportador de postgres-reservas |
| 11835 | Redis Dashboard | Para redis-cache |
| 7645 | NGINX Ingress Controller | Tráfico del Ingress de 04-04 |
Además, el kube-prometheus-stack ya trae instalados una veintena de cuadros de mando muy buenos (uso por namespace, por pod, por nodo, estado del plano de control). Antes de importar nada, mira lo que ya tienes.
Por qué conviene revisarlos antes de fiarse
Un cuadro de mando de la comunidad es código escrito por un desconocido para su clúster, no para el tuyo. Comprobaciones obligatorias:
- ¿Las métricas existen en tu Prometheus? Muchos cuadros usan nombres de versiones antiguas de kube-state-metrics o de cAdvisor. Un panel vacío no siempre significa "todo bien": puede significar "esta métrica no existe aquí".
- ¿El nombre de la fuente de datos coincide? Al importar, Grafana pide mapear la fuente de datos. Si el cuadro espera una llamada
Prometheusy la tuya se llama de otra forma, todos los paneles fallan. - ¿Las consultas son razonables para tu escala? Un panel con
rate(...[1m])sobre 50 000 series puede tardar 20 segundos y castigar a Prometheus cada vez que alguien abre la página. - ¿Refleja tu realidad? Un cuadro genérico de Kubernetes no sabe qué es
api-reservasni qué es una reserva confirmada. Sirve para infraestructura; no sustituye al cuadro de mando propio. - ¿Se ha actualizado recientemente? Un cuadro de 2019 usará métricas que ya no existen.
Estrategia recomendada para Rutas Norte:
- Cuadros de la comunidad para infraestructura genérica (nodos, PostgreSQL, Redis, Ingress). No aportamos nada reinventándolos.
- Cuadro propio para la plataforma: los cuatro indicadores dorados de nuestros componentes y las métricas de negocio. Nadie de la comunidad puede escribir eso por nosotros.
- El cuadro de mando de Rutas Norte, panel a panel
Diseñamos el cuadro de mando principal. Estructura: una fila de resumen arriba y una fila por componente debajo, todas plegables.
flowchart TB
subgraph DASH["Cuadro de mando: Plataforma Rutas Norte — [entorno] [namespace]"]
subgraph F0["Fila 0: Resumen ejecutivo"]
A["Reservas/min<br/>Stat"]
B["Tasa de error<br/>Stat"]
C["Latencia p95<br/>Stat"]
D["Pods no listos<br/>Stat"]
end
subgraph F1["Fila 1: api-reservas"]
E["Tráfico"] --- F["Errores"] --- G["Latencia p50/p95/p99"] --- H["Saturación"]
end
subgraph F2["Fila 2: postgres-reservas"]
I["Conexiones"] --- J["Transacciones/s"] --- K["Tamaño en disco"] --- L["Interbloqueos"]
end
subgraph F3["Fila 3: resto de componentes"]
M["tienda-web"] --- N["redis-cache"] --- O["worker-notificaciones"] --- P["informes-ocupacion"]
end
end
Fila 0 — Resumen ejecutivo
Cuatro paneles Stat que responden en un vistazo a "¿va bien la plataforma?".
Panel 1: Reservas confirmadas por minuto. La métrica del negocio.
- Tipo: Stat, con gráfico de tendencia de fondo (Graph mode: Area).
- Unidad:
short, sufijores/min. - Umbrales: rojo por debajo de 1, amarillo hasta 5, verde por encima.
- Por qué es el primer panel del cuadro: si esto cae a cero, da exactamente igual que los pods estén
Running. La plataforma existe para vender billetes.
Panel 2: Tasa de error.
sum(rate(rutasnorte_peticiones_total{entorno="$entorno", codigo=~"5.."}[$__rate_interval]))
/
sum(rate(rutasnorte_peticiones_total{entorno="$entorno"}[$__rate_interval]))- Unidad:
Percent (0.0-1.0). - Umbrales: verde < 0,5 %, amarillo < 2 %, rojo ≥ 2 %.
Panel 3: Latencia p95 global. Usamos la regla de grabación que creamos en 07-03:
- Unidad:
seconds (s). Umbrales: verde < 0,3 s, amarillo < 1 s, rojo ≥ 1 s.
Panel 4: Pods no listos. Directamente de kube-state-metrics:
sum(kube_deployment_status_replicas_unavailable{namespace="$namespace"})
+
sum(kube_statefulset_status_replicas_current{namespace="$namespace"}
- kube_statefulset_status_replicas_ready{namespace="$namespace"})- Umbrales: verde 0, rojo ≥ 1. Cualquier valor distinto de cero es una anomalía.
Fila 1 — api-reservas: los cuatro indicadores dorados
Tráfico (Time series):
Leyenda: {{ruta}}. Unidad: reqps.
Errores (Time series, con dos consultas superpuestas):
# A: tasa de error de nuestra plataforma
sum(rate(rutasnorte_peticiones_total{entorno="$entorno", codigo=~"5.."}[$__rate_interval]))
/ sum(rate(rutasnorte_peticiones_total{entorno="$entorno"}[$__rate_interval]))
# B: tasa de fallo de la pasarela externa
sum(rate(rutasnorte_pasarela_pagos_llamadas_total{entorno="$entorno", resultado=~"error|timeout"}[$__rate_interval]))
/ sum(rate(rutasnorte_pasarela_pagos_llamadas_total{entorno="$entorno"}[$__rate_interval]))Superponer ambas es una decisión de diseño deliberada: permite ver de un golpe si nuestros errores coinciden con los del proveedor externo. Esa correlación visual ahorra veinte minutos de investigación en un incidente.
Latencia (Time series, tres percentiles):
histogram_quantile(0.50, sum by (le) (rate(rutasnorte_duracion_peticion_segundos_bucket{entorno="$entorno"}[$__rate_interval])))
histogram_quantile(0.95, sum by (le) (rate(rutasnorte_duracion_peticion_segundos_bucket{entorno="$entorno"}[$__rate_interval])))
histogram_quantile(0.99, sum by (le) (rate(rutasnorte_duracion_peticion_segundos_bucket{entorno="$entorno"}[$__rate_interval])))Leyendas: p50, p95, p99. Ver los tres juntos es lo que distingue "todo va lento" (los tres suben) de "hay una cola de peticiones patológicas" (solo sube el p99).
Saturación (Time series, dos ejes):
# CPU consumida frente al límite
sum by (pod) (rate(container_cpu_usage_seconds_total{namespace="$namespace", pod=~"api-reservas-.*", container="api"}[$__rate_interval]))
/ sum by (pod) (kube_pod_container_resource_limits{namespace="$namespace", pod=~"api-reservas-.*", resource="cpu"})
# Fracción de periodos estrangulados: la confirmación del throttling
sum by (pod) (rate(container_cpu_cfs_throttled_periods_total{namespace="$namespace", pod=~"api-reservas-.*"}[$__rate_interval]))
/ sum by (pod) (rate(container_cpu_cfs_periods_total{namespace="$namespace", pod=~"api-reservas-.*"}[$__rate_interval]))
# El pool de conexiones: el fenómeno de 07-01, ahora visible
max(rutasnorte_conexiones_pool_activas{entorno="$entorno"}) / 20Fila 2 — postgres-reservas
Todo del sidecar exportador de 06-04, ya conectado en 07-03:
# Conexiones respecto al máximo
pg_stat_database_numbackends{datname="reservas"} / pg_settings_max_connections
# Transacciones por segundo (confirmadas y revertidas)
rate(pg_stat_database_xact_commit{datname="reservas"}[$__rate_interval])
rate(pg_stat_database_xact_rollback{datname="reservas"}[$__rate_interval])
# Tamaño de la base de datos, con proyección
pg_database_size_bytes{datname="reservas"}
# Espacio libre en el PVC (viene de kubelet, no del exportador)
kubelet_volume_stats_available_bytes{persistentvolumeclaim="datos-postgres-reservas-0"}
/ kubelet_volume_stats_capacity_bytes{persistentvolumeclaim="datos-postgres-reservas-0"}
# Interbloqueos: cero es lo normal, cualquier cosa distinta merece mirarse
increase(pg_stat_database_deadlocks{datname="reservas"}[$__interval])Fila 3 — Resto de componentes
worker-notificaciones — el panel más importante es la profundidad de la cola:
max(rutasnorte_cola_pendientes{entorno="$entorno"})
max(rutasnorte_cola_espera_segundos{entorno="$entorno"})
sum(rate(rutasnorte_correos_enviados_total{entorno="$entorno", resultado="ok"}[$__rate_interval])) * 60informes-ocupacion — el CronJob de 06-03. No tiene indicadores dorados clásicos:
# Segundos desde la última ejecución con éxito
time() - kube_job_status_completion_time{job_name=~"informes-ocupacion.*"}
# Duración de la última ejecución
kube_job_status_completion_time{job_name=~"informes-ocupacion.*"}
- kube_job_status_start_time{job_name=~"informes-ocupacion.*"}Panel de estado general (Table), muy útil como resumen:
Con transformaciones para mostrar pod, contenedor, reinicios y edad en columnas ordenables.
Un consejo de diseño
Un cuadro de mando debe responder a una pregunta, no mostrarlo todo. El nuestro responde a "¿está la plataforma sirviendo bien a los clientes y, si no, dónde está el problema?". Cuadros con cuarenta paneles no los mira nadie; ocho paneles bien elegidos se miran todos los días.
- Provisionar cuadros de mando como código
Si construyes el cuadro de mando pinchando en la interfaz, vive en la base de datos SQLite interna de Grafana. Y esa base de datos, en un pod sin volumen persistente, desaparece cuando el pod se recrea. Es un desastre que le pasa a todo el mundo una vez.
Además, los cuadros de mando son configuración: deben estar en Git, revisarse en un pull request y desplegarse igual que el resto de manifiestos.
El mecanismo del sidecar
El chart despliega junto a Grafana un contenedor sidecar que vigila todos los ConfigMaps del clúster buscando una etiqueta concreta. Cuando encuentra uno, escribe su contenido en el directorio de cuadros de mando de Grafana, que lo carga automáticamente.
# Fragmento de los valores del chart (07-03) que activa el mecanismo
grafana:
sidecar:
dashboards:
enabled: true
label: grafana_dashboard # <-- la etiqueta que busca
labelValue: "1"
searchNamespace: ALL # busca en todos los namespaces
folderAnnotation: grafana_folder
provider:
foldersFromFilesStructure: trueEl ConfigMap del cuadro de mando
# k8s/base/monitorizacion/dashboard-rutas-norte.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: dashboard-rutas-norte
namespace: monitorizacion
labels:
grafana_dashboard: "1" # el sidecar lo detecta por esta etiqueta
app.kubernetes.io/part-of: rutas-norte
annotations:
grafana_folder: "Rutas Norte" # carpeta donde aparecerá
data:
rutas-norte-general.json: |
{
"title": "Plataforma Rutas Norte — General",
"uid": "rutasnorte-general",
"tags": ["rutas-norte", "produccion"],
"timezone": "Europe/Madrid",
"refresh": "30s",
"time": { "from": "now-6h", "to": "now" },
"templating": {
"list": [
{
"name": "entorno",
"type": "query",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"query": "label_values(rutasnorte_peticiones_total, entorno)",
"current": { "text": "pro", "value": "pro" },
"sort": 1
},
{
"name": "namespace",
"type": "query",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"query": "label_values(kube_pod_info{namespace=~\"rutas-norte-$entorno\"}, namespace)",
"sort": 1
}
]
},
"panels": [
{
"id": 1,
"title": "Reservas confirmadas por minuto",
"type": "stat",
"gridPos": { "h": 5, "w": 6, "x": 0, "y": 0 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"targets": [
{
"expr": "sum(rate(rutasnorte_reservas_confirmadas_total{entorno=\"$entorno\"}[$__rate_interval])) * 60",
"legendFormat": "reservas/min"
}
],
"fieldConfig": {
"defaults": {
"unit": "short",
"decimals": 1,
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "red", "value": null },
{ "color": "yellow","value": 1 },
{ "color": "green", "value": 5 }
]
}
}
},
"options": { "graphMode": "area", "colorMode": "background" }
},
{
"id": 2,
"title": "Tasa de error 5xx",
"type": "stat",
"gridPos": { "h": 5, "w": 6, "x": 6, "y": 0 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"targets": [
{
"expr": "sum(rate(rutasnorte_peticiones_total{entorno=\"$entorno\", codigo=~\"5..\"}[$__rate_interval])) / sum(rate(rutasnorte_peticiones_total{entorno=\"$entorno\"}[$__rate_interval]))",
"legendFormat": "tasa error"
}
],
"fieldConfig": {
"defaults": {
"unit": "percentunit",
"decimals": 2,
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 0.005 },
{ "color": "red", "value": 0.02 }
]
}
}
}
},
{
"id": 3,
"title": "Latencia de api-reservas",
"type": "timeseries",
"gridPos": { "h": 9, "w": 12, "x": 0, "y": 5 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"targets": [
{
"expr": "histogram_quantile(0.50, sum by (le) (rate(rutasnorte_duracion_peticion_segundos_bucket{entorno=\"$entorno\"}[$__rate_interval])))",
"legendFormat": "p50"
},
{
"expr": "histogram_quantile(0.95, sum by (le) (rate(rutasnorte_duracion_peticion_segundos_bucket{entorno=\"$entorno\"}[$__rate_interval])))",
"legendFormat": "p95"
},
{
"expr": "histogram_quantile(0.99, sum by (le) (rate(rutasnorte_duracion_peticion_segundos_bucket{entorno=\"$entorno\"}[$__rate_interval])))",
"legendFormat": "p99"
}
],
"fieldConfig": {
"defaults": { "unit": "s", "custom": { "fillOpacity": 10 } }
}
}
]
}El flujo de trabajo recomendado
No escribas ese JSON a mano. El procedimiento práctico:
- Construye el cuadro de mando en la interfaz de Grafana, que es cómoda.
- Dashboard settings → JSON Model → Copiar.
- Pégalo en el ConfigMap, dentro de
data, con la indentación correcta. kubectl applyy revisión en un pull request.- En Grafana, marca ese cuadro de mando como de solo lectura para evitar que alguien lo edite en la interfaz y pierda los cambios en el siguiente despliegue.
kubectl apply -f k8s/base/monitorizacion/dashboard-rutas-norte.yaml
# El sidecar lo detecta en segundos; verificarlo en su log
kubectl -n monitorizacion logs deploy/monitorizacion-grafana -c grafana-sc-dashboard --tail=10{"time": "2026-08-06T11:42:03", "msg": "Working on configmap monitorizacion/dashboard-rutas-norte"}
{"time": "2026-08-06T11:42:03", "msg": "Writing /tmp/dashboards/rutas-norte-general.json"}Cuando lleguemos a 10-04 (Kustomize) y 10-05 (GitOps), este ConfigMap será un artefacto más del repositorio, desplegado automáticamente al hacer merge.
- Reglas de alerta con
PrometheusRule y la importancia de for
PrometheusRule y la importancia de forGrafana tiene su propio motor de alertas, pero en un entorno con el Prometheus Operator lo natural es definir las alertas en Prometheus mediante el recurso PrometheusRule. Ventajas: viven en Git junto a la aplicación, las evalúa Prometheus (que es donde están los datos) y las enruta Alertmanager.
Anatomía de una regla
- alert: ApiReservasTasaErrorAlta
expr: |
sum(rate(rutasnorte_peticiones_total{entorno="pro", codigo=~"5.."}[5m]))
/
sum(rate(rutasnorte_peticiones_total{entorno="pro"}[5m]))
> 0.05
for: 5m
labels:
severity: critica
componente: api-reservas
equipo: plataforma
annotations:
summary: "api-reservas devuelve más de un 5% de errores"
description: >
La tasa de errores 5xx de api-reservas en producción es del
{{ $value | humanizePercentage }} durante los últimos 5 minutos.
Los clientes no pueden completar reservas.
runbook_url: "https://runbooks.rutasnorte.example/api-reservas-errores-5xx"
dashboard_url: "https://metricas.rutasnorte.example/d/rutasnorte-general"| Campo | Función |
|---|---|
alert |
Nombre de la alerta. En PascalCase, descriptivo, sin espacios |
expr |
Expresión PromQL. La alerta está "activa" para cada serie que devuelva |
for |
Cuánto tiempo debe cumplirse de forma continuada antes de disparar |
labels |
Metadatos para el enrutado en Alertmanager. Aquí va la severidad |
annotations |
Texto para el humano que la recibe. No afectan al enrutado |
La importancia de for
Este es el campo que separa un sistema de alertas usable de uno insoportable.
Sin for, la alerta dispara en cuanto la expresión se cumple una sola vez. Un pico de 30 segundos por un despliegue, un reinicio puntual o un escaneo perdido genera un aviso. Multiplicado por veinte alertas y tres entornos, es un canal de Slack que nadie lee.
Con for: 5m, Prometheus observa la expresión en cada ciclo de evaluación (por defecto cada 30 s) y solo dispara si se ha cumplido en todas las evaluaciones de esos 5 minutos. Un solo ciclo en el que la condición no se cumpla reinicia el contador.
Estados de una alerta:
stateDiagram-v2
[*] --> Inactive: la expresión no se cumple
Inactive --> Pending: la expresión se cumple
Pending --> Inactive: deja de cumplirse antes de agotar for
Pending --> Firing: se cumple durante todo el periodo for
Firing --> Inactive: deja de cumplirse (se envía la resolución)
Solo en Firing se envía nada a Alertmanager. Pending es visible en la interfaz de Prometheus (Alerts), lo cual es útil para depurar.
Criterio para elegir for:
| Tipo de alerta | for recomendado |
Razón |
|---|---|---|
Caída total (up == 0) |
2m |
Rápida, pero tolera un reinicio o un despliegue |
| Tasa de error | 5m |
Un pico corto no debe despertar a nadie |
| Latencia alta | 10m |
Muy ruidosa si es más corta |
| Disco llenándose | 15m |
Es una tendencia, no un evento |
| Certificado caducando | 1h |
No hay ninguna prisa |
Pod en CrashLoopBackOff |
10m |
Tolera un arranque lento legítimo |
Anotaciones y plantillas
Las anotaciones admiten plantillas Go con acceso al valor y a las etiquetas:
| Expresión | Resultado |
|---|---|
{{ $value }} |
0.0734829 |
{{ $value | humanizePercentage }} |
7.35% |
{{ $value | humanize }} |
73.5m |
{{ $value | humanizeDuration }} |
1h 12m 30s |
{{ $labels.pod }} |
api-reservas-7d9f8c4b5-x2klm |
{{ $labels.namespace }} |
rutas-norte-pro |
El runbook_url no es opcional. Una alerta que llega a las tres de la madrugada a alguien que no escribió el código y contiene solo "ApiReservasTasaErrorAlta" es inútil. Con un enlace a un procedimiento escrito —qué comprobar, en qué orden, qué hacer, a quién escalar— la alerta es accionable. Los runbooks completos son materia de 11-06, pero el enlace se pone desde el primer día.
- El catálogo de alertas de Rutas Norte
# k8s/base/monitorizacion/alertas-rutas-norte.yaml
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: alertas-rutas-norte
namespace: monitorizacion
labels:
app.kubernetes.io/part-of: rutas-norte
prometheus: monitorizacion
spec:
groups:
# =====================================================================
# GRUPO 1: SÍNTOMAS. Lo que el cliente percibe. Máxima prioridad.
# =====================================================================
- name: rutasnorte.sintomas
interval: 30s
rules:
- alert: PlataformaSinVentas
expr: |
sum(rate(rutasnorte_reservas_confirmadas_total{entorno="pro"}[10m])) == 0
and
sum(rate(rutasnorte_peticiones_total{entorno="pro"}[10m])) > 0.5
for: 10m
labels:
severity: critica
equipo: plataforma
annotations:
summary: "Rutas Norte no ha confirmado NINGUNA reserva en 10 minutos"
description: >
Hay tráfico entrante ({{ $value | humanize }} peticiones/s) pero
cero reservas confirmadas. La plataforma no está vendiendo.
Esta es la alerta más importante del sistema.
runbook_url: "https://runbooks.rutasnorte.example/sin-ventas"
# Justificación: la condición "hay tráfico" evita que dispare de
# madrugada, cuando es normal no vender nada durante diez minutos.
- alert: ApiReservasTasaErrorAlta
expr: |
sum(rate(rutasnorte_peticiones_total{entorno="pro", codigo=~"5.."}[5m]))
/ sum(rate(rutasnorte_peticiones_total{entorno="pro"}[5m])) > 0.05
for: 5m
labels:
severity: critica
componente: api-reservas
equipo: plataforma
annotations:
summary: "api-reservas devuelve más de un 5% de errores"
description: >
Tasa de error del {{ $value | humanizePercentage }} durante
5 minutos. Los clientes no pueden completar sus compras.
runbook_url: "https://runbooks.rutasnorte.example/api-errores-5xx"
- alert: ApiReservasLatenciaAlta
expr: apireservas:latencia_p95:5m{entorno="pro"} > 1
for: 10m
labels:
severity: aviso
componente: api-reservas
equipo: plataforma
annotations:
summary: "El p95 de latencia de api-reservas supera 1 segundo"
description: >
El percentil 95 está en {{ $value | humanizeDuration }} en la
ruta {{ $labels.ruta }}. El SLO de Rutas Norte son 500 ms.
runbook_url: "https://runbooks.rutasnorte.example/api-latencia"
- alert: TiendaWebCaida
expr: |
sum(kube_deployment_status_replicas_available{
namespace="rutas-norte-pro", deployment="tienda-web"}) == 0
for: 2m
labels:
severity: critica
componente: tienda-web
equipo: plataforma
annotations:
summary: "No queda ninguna réplica de tienda-web disponible"
description: "www.rutasnorte.example está caída para todos los clientes."
runbook_url: "https://runbooks.rutasnorte.example/tienda-web-caida"
# =====================================================================
# GRUPO 2: CAUSAS Y CAPACIDAD. Avisan antes de que haya síntoma.
# =====================================================================
- name: rutasnorte.capacidad
interval: 60s
rules:
- alert: DiscoPostgresSeLlenara
expr: |
predict_linear(
kubelet_volume_stats_available_bytes{
persistentvolumeclaim="datos-postgres-reservas-0"}[6h],
4 * 3600
) < 0
for: 30m
labels:
severity: critica
componente: postgres-reservas
equipo: plataforma
annotations:
summary: "El disco de postgres-reservas se llenará en menos de 4 horas"
description: >
Según la tendencia de las últimas 6 horas, el volumen
datos-postgres-reservas-0 se quedará sin espacio en menos de
4 horas. Quedan {{ $value | humanize1024 }}B libres.
Ampliar el PVC (procedimiento de 05-05) ANTES de que ocurra:
una base de datos con el disco lleno deja de aceptar escrituras.
runbook_url: "https://runbooks.rutasnorte.example/ampliar-pvc-postgres"
# predict_linear ajusta una recta de regresión sobre la ventana [6h]
# y extrapola 4*3600 segundos hacia adelante. Si el resultado es
# negativo, significa que la recta cruza el cero antes de 4 horas.
# Es la diferencia entre avisar de un problema y avisar del futuro.
- alert: PostgresConexionesAgotandose
expr: |
pg_stat_database_numbackends{datname="reservas"}
/ pg_settings_max_connections > 0.85
for: 10m
labels:
severity: aviso
componente: postgres-reservas
equipo: plataforma
annotations:
summary: "postgres-reservas al {{ $value | humanizePercentage }} de conexiones"
description: >
Cuando se agoten, api-reservas fallará la readiness y saldrá
de los Endpoints. Revisar consultas lentas y el pool de la API.
runbook_url: "https://runbooks.rutasnorte.example/postgres-conexiones"
- alert: CertificadoCaducaPronto
expr: |
(certmanager_certificate_expiration_timestamp_seconds - time()) / 86400 < 15
for: 1h
labels:
severity: aviso
equipo: plataforma
annotations:
summary: "El certificado {{ $labels.name }} caduca en {{ $value | humanize }} días"
description: >
cert-manager debería renovarlo automáticamente (04-05). Que no
lo haya hecho indica un problema con el emisor o con el reto ACME.
runbook_url: "https://runbooks.rutasnorte.example/certificados"
- alert: ContenedorConThrottlingSevero
expr: |
sum by (namespace, pod, container) (
rate(container_cpu_cfs_throttled_periods_total{namespace=~"rutas-norte-.*"}[5m]))
/ sum by (namespace, pod, container) (
rate(container_cpu_cfs_periods_total{namespace=~"rutas-norte-.*"}[5m]))
> 0.30
for: 15m
labels:
severity: aviso
equipo: plataforma
annotations:
summary: "{{ $labels.container }} estrangulado el {{ $value | humanizePercentage }} del tiempo"
description: >
El contenedor está alcanzando su limits.cpu de forma sostenida.
Revisar la recalibración de recursos de 07-02.
runbook_url: "https://runbooks.rutasnorte.example/throttling-cpu"
# =====================================================================
# GRUPO 3: SALUD DE LOS OBJETOS. Fuente: kube-state-metrics.
# =====================================================================
- name: rutasnorte.workloads
interval: 60s
rules:
- alert: PodEnCrashLoop
expr: |
increase(kube_pod_container_status_restarts_total{
namespace=~"rutas-norte-.*"}[15m]) > 3
for: 10m
labels:
severity: aviso
equipo: plataforma
annotations:
summary: "{{ $labels.pod }} se ha reiniciado más de 3 veces en 15 min"
description: >
Contenedor {{ $labels.container }} en {{ $labels.namespace }}.
Causas frecuentes: OOMKilled, fallo de arranque, o una
livenessProbe demasiado agresiva (07-01).
Recoger evidencias con 'kubectl logs --previous' ANTES de tocar nada.
runbook_url: "https://runbooks.rutasnorte.example/crashloop"
- alert: CronJobInformesNoEjecutado
expr: |
time() - max(kube_job_status_completion_time{
job_name=~"informes-ocupacion.*"}) > 100000
for: 30m
labels:
severity: aviso
componente: informes-ocupacion
equipo: datos
annotations:
summary: "El CronJob informes-ocupacion no se ha ejecutado con éxito"
description: >
Han pasado {{ $value | humanizeDuration }} desde la última
ejecución correcta. El CronJob nocturno debería ejecutarse cada
24 horas. Sin informes, el departamento comercial se queda ciego.
runbook_url: "https://runbooks.rutasnorte.example/cronjob-informes"
# 100000 segundos ≈ 27,8 h: un margen sobre las 24 h del cron que
# tolera un retraso puntual sin generar falsos positivos.
- alert: JobFallido
expr: kube_job_status_failed{namespace=~"rutas-norte-.*"} > 0
for: 5m
labels:
severity: aviso
equipo: plataforma
annotations:
summary: "El Job {{ $labels.job_name }} ha fallado"
runbook_url: "https://runbooks.rutasnorte.example/job-fallido"
- alert: ColaNotificacionesCreciendo
expr: |
max(rutasnorte_cola_espera_segundos{entorno="pro"}) > 900
for: 10m
labels:
severity: aviso
componente: worker-notificaciones
equipo: plataforma
annotations:
summary: "Correos de confirmación con más de 15 minutos de retraso"
description: >
El mensaje más antiguo lleva {{ $value | humanizeDuration }}
en cola. Hay clientes que han pagado y no han recibido nada.
runbook_url: "https://runbooks.rutasnorte.example/cola-notificaciones"
# =====================================================================
# GRUPO 4: META. Alertas sobre el propio sistema de monitorización.
# =====================================================================
- name: rutasnorte.meta
interval: 60s
rules:
- alert: ObjetivoPrometheusCaido
expr: up{namespace=~"rutas-norte-.*|monitorizacion"} == 0
for: 5m
labels:
severity: aviso
equipo: plataforma
annotations:
summary: "Prometheus no puede escanear {{ $labels.job }}"
description: >
Objetivo {{ $labels.instance }} inalcanzable. Revisar
NetworkPolicies, el endpoint /metrics y la readiness del pod.
runbook_url: "https://runbooks.rutasnorte.example/objetivo-caido"
- alert: HPASinMetricas
expr: |
kube_horizontalpodautoscaler_status_condition{
condition="ScalingActive", status="false"} == 1
for: 5m
labels:
severity: critica
equipo: plataforma
annotations:
summary: "El HPA {{ $labels.horizontalpodautoscaler }} no puede escalar"
description: >
El autoescalado está inactivo, probablemente porque
metrics-server no responde (07-02). Durante un pico de tráfico
la plataforma NO escalará y nadie se dará cuenta.
runbook_url: "https://runbooks.rutasnorte.example/hpa-inactivo"Esta última alerta es la medida preventiva que prometimos en 07-02: el fallo silencioso del HPA ya no puede pasar desapercibido.
Aplicar y verificar:
kubectl apply -f k8s/base/monitorizacion/alertas-rutas-norte.yaml
# ¿El operador ha cargado las reglas?
kubectl -n monitorizacion get prometheusrule alertas-rutas-norte
# ¿Prometheus las está evaluando?
curl -s localhost:9090/api/v1/rules | jq -r '
.data.groups[] | select(.name | startswith("rutasnorte")) |
.rules[] | "\(.name)\t\(.state // "recording")"'PlataformaSinVentas inactive
ApiReservasTasaErrorAlta inactive
ApiReservasLatenciaAlta pending
TiendaWebCaida inactive
DiscoPostgresSeLlenara inactiveProbar una alerta antes de confiar en ella es imprescindible. Una forma segura en rutas-norte-dev:
# Provocar deliberadamente que un objetivo caiga
kubectl -n rutas-norte-dev scale deployment api-reservas --replicas=0
# Esperar el for y comprobar que la alerta pasa a firing
- Alertmanager: rutas, receptores, inhibición y silencios
Prometheus decide qué está mal. Alertmanager decide a quién se lo cuenta, cuándo y cómo.
El problema que resuelve
Imagina que rutas-norte-worker-2 se cae a las 03:14. Prometheus dispara, en cuestión de segundos:
- 6 alertas
PodEnCrashLoop(los pods que vivían ahí). - 4 alertas
ObjetivoPrometheusCaido. - 1
TiendaWebCaida. - 1
ApiReservasTasaErrorAlta. - 1
NodeNotReady(de las reglas que trae el stack).
Sin Alertmanager, la persona de guardia recibe trece notificaciones en dos minutos y tiene que reconstruir mentalmente que todas son el mismo problema. Con Alertmanager bien configurado recibe una, agrupada, con el nodo caído destacado y las derivadas silenciadas.
El árbol de rutas
# k8s/base/monitorizacion/alertmanager-config.yaml
apiVersion: v1
kind: Secret
metadata:
name: alertmanager-monitorizacion-kube-pr-alertmanager
namespace: monitorizacion
stringData:
alertmanager.yaml: |
global:
resolve_timeout: 5m
smtp_smarthost: 'smtp.rutasnorte.example:587'
smtp_from: '[email protected]'
# -----------------------------------------------------------------
# ÁRBOL DE RUTAS: se evalúa de arriba abajo; la primera coincidencia
# gana, salvo que se marque continue: true.
# -----------------------------------------------------------------
route:
receiver: 'equipo-plataforma-slack' # receptor por defecto
# Qué alertas se agrupan en una misma notificación.
# Agrupar por alertname + namespace significa: "todas las
# PodEnCrashLoop de rutas-norte-pro llegan en un único mensaje".
group_by: ['alertname', 'namespace', 'componente']
# Tras la PRIMERA alerta de un grupo nuevo, esperar 30 s por si
# llegan más y mandarlas juntas. Es lo que convierte 13 mensajes
# en 1 cuando cae un nodo.
group_wait: 30s
# Si llegan alertas NUEVAS a un grupo ya notificado, esperar 5 min
# antes de mandar la actualización.
group_interval: 5m
# Si la alerta sigue activa, repetir el aviso cada 4 horas.
# Ni tan corto que sature ni tan largo que se olvide.
repeat_interval: 4h
routes:
# 1. Alertas de prueba y de desarrollo: a un canal aparte, sin
# despertar a nadie. Evita que el ruido de dev llegue a la guardia.
- matchers:
- namespace =~ "rutas-norte-(dev|pre)"
receiver: 'canal-desarrollo'
group_wait: 5m
repeat_interval: 24h
# 2. Alertas críticas de producción: PagerDuty (despierta a alguien)
# Y ADEMÁS Slack, gracias a continue: true.
- matchers:
- severity = "critica"
- namespace =~ "rutas-norte-pro|monitorizacion"
receiver: 'guardia-pagerduty'
group_wait: 10s # las críticas, con menos espera
repeat_interval: 1h # y se recuerdan más a menudo
continue: true
- matchers:
- severity = "critica"
receiver: 'equipo-plataforma-slack'
# 3. Alertas del equipo de datos: a su propio canal.
- matchers:
- equipo = "datos"
receiver: 'equipo-datos-slack'
repeat_interval: 12h
# -----------------------------------------------------------------
# INHIBICIÓN: una alerta grave silencia las derivadas.
# -----------------------------------------------------------------
inhibit_rules:
# Si la tienda web está caída del todo, no hace falta avisar
# también de que su latencia es alta.
- source_matchers:
- alertname = "TiendaWebCaida"
target_matchers:
- severity = "aviso"
- componente = "tienda-web"
equal: ['namespace']
# Si el nodo está caído, no avisar de cada pod que hay en él.
- source_matchers:
- alertname = "NodeNotReady"
target_matchers:
- alertname =~ "PodEnCrashLoop|ObjetivoPrometheusCaido"
equal: ['node']
# Regla general: si hay una crítica del mismo componente,
# los avisos de ese componente se callan.
- source_matchers:
- severity = "critica"
target_matchers:
- severity = "aviso"
equal: ['componente', 'namespace']
# -----------------------------------------------------------------
# RECEPTORES
# -----------------------------------------------------------------
receivers:
- name: 'equipo-plataforma-slack'
slack_configs:
- api_url_file: /etc/alertmanager/secrets/slack/url
channel: '#alertas-plataforma'
send_resolved: true
title: '{{ if eq .Status "firing" }}🔴{{ else }}✅{{ end }} {{ .CommonLabels.alertname }}'
text: |
{{ range .Alerts }}
*{{ .Annotations.summary }}*
{{ .Annotations.description }}
Entorno: `{{ .Labels.namespace }}` · Severidad: `{{ .Labels.severity }}`
<{{ .Annotations.runbook_url }}|📖 Runbook> · <{{ .Annotations.dashboard_url }}|📊 Cuadro de mando>
{{ end }}
- name: 'guardia-pagerduty'
pagerduty_configs:
- routing_key_file: /etc/alertmanager/secrets/pagerduty/key
description: '{{ .CommonAnnotations.summary }}'
severity: 'critical'
details:
runbook: '{{ .CommonAnnotations.runbook_url }}'
namespace: '{{ .CommonLabels.namespace }}'
- name: 'canal-desarrollo'
slack_configs:
- api_url_file: /etc/alertmanager/secrets/slack/url
channel: '#alertas-desarrollo'
send_resolved: false
- name: 'equipo-datos-slack'
slack_configs:
- api_url_file: /etc/alertmanager/secrets/slack/url
channel: '#alertas-datos'
send_resolved: true
- name: 'correo-responsables'
email_configs:
- to: '[email protected]'
send_resolved: trueLos cuatro tiempos
| Parámetro | Qué controla | Valor típico | Si es demasiado corto | Si es demasiado largo |
|---|---|---|---|---|
group_wait |
Espera antes del primer aviso de un grupo | 30 s (10 s crítica) | Llegan mensajes sueltos, no agrupados | Se retrasa la detección |
group_interval |
Espera antes de avisar de alertas nuevas del grupo | 5 m | Spam en incidentes que evolucionan | Te enteras tarde de que empeora |
repeat_interval |
Cada cuánto se recuerda una alerta activa | 4 h (1 h crítica) | Fatiga y silenciado masivo | Se olvida un problema abierto |
resolve_timeout |
Cuánto esperar sin datos antes de darla por resuelta | 5 m | Falsas resoluciones | Alertas fantasma |
Inhibición
La inhibición es lo que convierte trece mensajes en uno. La sintaxis tiene tres partes:
source_matchers: la alerta que silencia.target_matchers: las alertas que se silencian.equal: las etiquetas que deben coincidir entre ambas. Sin esto, unaTiendaWebCaidaendevsilenciaría los avisos depro.
El campo equal es el que más se olvida y el que hace que la inhibición sea segura.
Silencios durante un mantenimiento
Un silencio es una supresión temporal creada por una persona, normalmente antes de una intervención planificada: ampliar el PVC de PostgreSQL, migrar un nodo, hacer un despliegue grande.
Desde la interfaz de Alertmanager (Silences → New Silence), o por API:
kubectl -n monitorizacion port-forward svc/monitorizacion-kube-pr-alertmanager 9093:9093 &
# Silenciar todas las alertas de postgres-reservas durante 2 horas
curl -s -X POST http://localhost:9093/api/v2/silences \
-H 'Content-Type: application/json' \
-d '{
"matchers": [
{"name": "componente", "value": "postgres-reservas", "isRegex": false},
{"name": "namespace", "value": "rutas-norte-pro", "isRegex": false}
],
"startsAt": "2026-08-07T02:00:00Z",
"endsAt": "2026-08-07T04:00:00Z",
"createdBy": "joan.costa",
"comment": "Ampliación programada del PVC de postgres-reservas (ticket OPS-1842)"
}'Buenas prácticas con los silencios:
- Siempre con fecha de fin. Un silencio indefinido es una alerta borrada de facto, y todo el mundo se olvida de él.
- Siempre con comentario y ticket. Dentro de seis semanas nadie recordará por qué existe.
- Lo más específico posible. Silenciar
namespace=rutas-norte-proentero durante una intervención en la base de datos te deja ciego ante un problema no relacionado. - Revisar los silencios activos periódicamente. Un silencio olvidado ha ocultado más de un incidente grave.
# Listar silencios activos
curl -s http://localhost:9093/api/v2/silences | \
jq -r '.[] | select(.status.state=="active") |
"\(.id)\t\(.comment)\thasta \(.endsAt)"'
- El criterio: alertar sobre síntomas, no sobre causas
Todo lo anterior es mecánica. Esto es criterio, y es lo que determina si el sistema sirve.
El problema de la fatiga de alertas
Un equipo que recibe cuarenta notificaciones al día deja de leerlas en dos semanas. Cuando llega la que de verdad importa, se pierde entre el ruido. Un sistema con demasiadas alertas es peor que uno sin ninguna, porque genera una falsa sensación de cobertura.
Regla 1: alertar sobre síntomas, no sobre causas
Un síntoma es algo que el cliente percibe. Una causa es algo del sistema que puede o no traducirse en síntoma.
| Causa (mala alerta) | Síntoma (buena alerta) | Por qué |
|---|---|---|
"El pod api-reservas-x2klm se reinició" |
"La tasa de error de api-reservas supera el 5 %" |
Con 6 réplicas y readiness, un reinicio no afecta a nadie |
| "La CPU del nodo está al 85 %" | "El p95 de latencia supera 1 s" | Un nodo al 85 % puede ser perfectamente sano |
| "Hay 4 réplicas en vez de 6" | "La tienda web está caída" | 4 réplicas pueden bastar de madrugada |
"La memoria de redis-cache está al 70 %" |
"La tasa de aciertos de caché ha caído" | El 70 % puede ser el estado normal |
La prueba definitiva: si esta alerta dispara a las tres de la madrugada y nadie hace nada, ¿pasa algo malo? Si la respuesta es no, no debería despertar a nadie.
Esto no significa que las causas no se monitoricen: se visualizan en el cuadro de mando y se consultan durante el diagnóstico. Simplemente no despiertan a nadie.
Excepción legítima: las alertas de capacidad predictivas. DiscoPostgresSeLlenara es una causa, no un síntoma. Se justifica porque avisa con horas de antelación de un síntoma catastrófico e irreversible que aún se puede evitar. Ese es el criterio para hacer excepciones: antelación suficiente para actuar y consecuencia grave si no se actúa.
Regla 2: cada alerta debe ser accionable
Antes de crear una alerta, responde por escrito a tres preguntas:
- ¿Qué debe hacer quien la reciba? Si la respuesta es "mirar si se arregla solo", no es una alerta: es un panel.
- ¿Está escrito ese procedimiento? Si no, escríbelo antes de activar la alerta y ponlo en
runbook_url. - ¿Puede actuar la persona de guardia, o hay que escalar siempre? Si siempre hay que escalar, enruta directamente al equipo que puede actuar.
Regla 3: revisar las alertas después de cada incidente
Después de cada incidente, dos preguntas:
- ¿Nos avisó alguna alerta? Si no, falta una. Créala.
- ¿Nos avisaron alertas que no aportaron nada? Si sí, sobra. Bórrala o sube su umbral.
Sin esta revisión periódica, el catálogo solo crece y acaba siendo ruido.
Niveles de severidad de Rutas Norte
| Severidad | Significado | Canal | Ejemplo |
|---|---|---|---|
critica |
Afecta a clientes ahora. Requiere acción inmediata, de día o de noche | PagerDuty + Slack | PlataformaSinVentas |
aviso |
Va a afectar si no se actúa. Se atiende en horario laboral | Slack | PostgresConexionesAgotandose |
info |
Contexto. No requiere acción | Solo en el cuadro de mando | Un despliegue ha terminado |
Regla dura: si una alerta critica no justifica una llamada de teléfono a las cuatro de la madrugada, no es crítica.
- SLO y presupuesto de error de Rutas Norte
Los SLO (Service Level Objectives) formalizan la pregunta "¿qué significa que la plataforma funciona bien?" con un número acordado con el negocio.
Definir los SLO
Un SLO tiene tres partes: un indicador (SLI, qué se mide), un objetivo (qué valor debe alcanzar) y una ventana (en cuánto tiempo se evalúa).
SLO de Rutas Norte, acordados con la dirección:
| Servicio | Indicador (SLI) | Objetivo (SLO) | Ventana |
|---|---|---|---|
api-reservas disponibilidad |
% de peticiones sin error 5xx | 99,5 % | 30 días |
api-reservas latencia |
% de peticiones bajo 500 ms | 99,0 % | 30 días |
tienda-web disponibilidad |
% de peticiones sin error 5xx | 99,9 % | 30 días |
| Correos de confirmación | % entregados en menos de 5 min | 99,0 % | 30 días |
Un matiz importante: 99,5 % no es 100 %, y eso es deliberado. Perseguir el 100 % es infinitamente caro y frena cualquier cambio. El SLO reconoce que un porcentaje de fallo es aceptable.
El presupuesto de error
El presupuesto de error es el complemento del SLO: el fallo que te puedes permitir.
SLO de disponibilidad de api-reservas: 99,5 % en 30 días
Presupuesto de error = 100 % − 99,5 % = 0,5 %
En tiempo: 30 días × 24 h × 0,5 % = 3 h 36 min de indisponibilidad al mes
En peticiones: con ~4 millones de peticiones/mes → 20 000 peticiones pueden fallarEse presupuesto es una herramienta de decisión, no un dato curioso:
- Si queda presupuesto, el equipo puede desplegar, experimentar y asumir riesgos. La velocidad de cambio no está limitada.
- Si el presupuesto está agotado, se congelan los cambios que no sean correcciones de fiabilidad hasta el siguiente periodo. Se deja de añadir funcionalidad y se arregla lo que falla.
Es un mecanismo objetivo que sustituye la discusión eterna entre "hay que sacar la funcionalidad nueva" y "hay que estabilizar la plataforma".
Medir el presupuesto en PromQL
- name: rutasnorte.slo
interval: 60s
rules:
# Ratio de éxito en la ventana de 30 días
- record: apireservas:slo_disponibilidad:30d
expr: |
1 - (
sum(increase(rutasnorte_peticiones_total{entorno="pro", codigo=~"5.."}[30d]))
/
sum(increase(rutasnorte_peticiones_total{entorno="pro"}[30d]))
)
# Presupuesto de error CONSUMIDO, en tanto por uno.
# 0 = intacto, 1 = agotado, >1 = SLO incumplido.
- record: apireservas:presupuesto_error_consumido:30d
expr: |
(
sum(increase(rutasnorte_peticiones_total{entorno="pro", codigo=~"5.."}[30d]))
/
sum(increase(rutasnorte_peticiones_total{entorno="pro"}[30d]))
) / 0.005
# SLO de latencia: fracción de peticiones bajo 500 ms
- record: apireservas:slo_latencia:30d
expr: |
sum(increase(rutasnorte_duracion_peticion_segundos_bucket{entorno="pro", le="0.5"}[30d]))
/
sum(increase(rutasnorte_duracion_peticion_segundos_count{entorno="pro"}[30d]))Alertas por consumo del presupuesto
En lugar de alertar sobre un umbral fijo de errores, se alerta sobre la velocidad a la que se consume el presupuesto (burn rate). Es más inteligente: tolera un pico corto pero detecta rápido una hemorragia.
- alert: PresupuestoErrorConsumidoRapido
expr: |
(
sum(rate(rutasnorte_peticiones_total{entorno="pro", codigo=~"5.."}[1h]))
/ sum(rate(rutasnorte_peticiones_total{entorno="pro"}[1h]))
) > (14.4 * 0.005)
for: 5m
labels:
severity: critica
equipo: plataforma
annotations:
summary: "Se está consumiendo el presupuesto de error 14 veces más rápido de lo sostenible"
description: >
A este ritmo, el presupuesto de 30 días se agotará en unas 2 días.
Tasa de error actual: {{ $value | humanizePercentage }}.
runbook_url: "https://runbooks.rutasnorte.example/presupuesto-error"
- alert: PresupuestoErrorCasiAgotado
expr: apireservas:presupuesto_error_consumido:30d > 0.90
for: 30m
labels:
severity: aviso
equipo: plataforma
annotations:
summary: "Queda menos del 10% del presupuesto de error del mes"
description: >
Consumido el {{ $value | humanizePercentage }} del presupuesto.
Considerar congelar los despliegues no relacionados con fiabilidad.El factor 14,4 no es arbitrario: es el ritmo al que se consumiría el presupuesto completo de 30 días en unas 2 días. Los factores habituales son 14,4 (1 h, crítica), 6 (6 h, crítica) y 1 (3 d, aviso), combinándose para detectar tanto hemorragias rápidas como desangrados lentos.
Un panel Stat con apireservas:presupuesto_error_consumido:30d, en tanto por ciento y con umbrales en 50/80/100, es probablemente el panel más útil del cuadro de mando para hablar con el negocio.
Errores Comunes y Consejos
1. Cuadros de mando que no persisten. Construidos en la interfaz, sin ConfigMap ni volumen persistente, desaparecen al recrearse el pod. Provisiona siempre como código.
2. Usar rate(metrica[5m]) en vez de rate(metrica[$__rate_interval]). Con ventana fija, al hacer zoom a 30 días el gráfico se llena de huecos o de ruido.
3. Variable multivalor con = en vez de =~. Grafana la sustituye por (a|b|c), que solo funciona con el operador de expresión regular. El panel queda vacío sin ningún error visible.
4. Alertas sin for. El error que más rápido convierte un sistema de alertas en ruido. Todo pico de 30 segundos genera una notificación.
5. Alertas sin runbook_url. A las tres de la madrugada, un nombre de alerta sin procedimiento asociado obliga a improvisar. Escribe el runbook antes de activar la alerta.
6. Alertar sobre causas en lugar de síntomas. "El pod se reinició" no es un problema si hay seis réplicas y readiness. "Los clientes no pueden comprar" sí lo es.
7. Olvidar equal en las reglas de inhibición. Sin él, una alerta de dev puede silenciar los avisos de pro. Un error silencioso y peligroso.
8. Silencios sin fecha de fin. Es borrar una alerta y olvidarlo. Revisa periódicamente los silencios activos.
9. Umbrales copiados de otro sistema. Un p95 de 1 s puede ser excelente para un informe y catastrófico para un autocompletado. Los umbrales salen de tus datos y de tu SLO.
10. Demasiadas alertas críticas. Si todo es crítico, nada lo es. Reserva critica para lo que justifica una llamada de madrugada.
11. No probar las alertas. Una alerta con una consulta mal escrita nunca dispara y da falsa tranquilidad. Provoca la condición deliberadamente en rutas-norte-dev y verifica que llega la notificación al canal correcto.
12. Contraseña de Grafana en Git. El valor adminPassword del fichero de Helm es texto plano versionado. Usa un Secret externo o autenticación delegada.
Ejercicios
Ejercicio 1 — Corregir un catálogo de alertas defectuoso
Un compañero ha escrito estas alertas. Identifica los problemas de cada una y reescribe el PrometheusRule corregido.
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: alertas-equipo
namespace: monitorizacion
spec:
groups:
- name: alertas
rules:
- alert: CPUAlta
expr: |
sum by (pod) (rate(container_cpu_usage_seconds_total{namespace="rutas-norte-pro"}[5m])) > 0.5
labels:
severity: critica
annotations:
summary: "CPU alta"
- alert: PodReiniciado
expr: kube_pod_container_status_restarts_total{namespace="rutas-norte-pro"} > 0
for: 1m
labels:
severity: critica
annotations:
summary: "Un pod se ha reiniciado"
- alert: MemoriaAlta
expr: container_memory_working_set_bytes{namespace="rutas-norte-pro"} > 400000000
for: 30s
labels:
severity: critica
annotations:
summary: "Memoria alta en {{ $labels.pod }}"Ejercicio 2 — Diseñar la alerta predictiva de redis-cache
redis-cache tiene maxmemory configurado en 1 GiB con política allkeys-lru. Cuando se llena, empieza a expulsar claves y la tasa de aciertos se desploma, lo que hace que api-reservas consulte PostgreSQL mucho más y todo vaya lento.
El exportador de Redis expone:
redis_memory_used_bytes
redis_memory_max_bytes
redis_keyspace_hits_total
redis_keyspace_misses_total
redis_evicted_keys_total- Escribe la consulta PromQL de la tasa de aciertos de caché (proporción de aciertos sobre el total).
- Escribe una alerta que avise antes de que el problema afecte a los clientes, usando
predict_linear. - Escribe una segunda alerta sobre el síntoma, para cuando ya esté ocurriendo, y explica por qué hacen falta las dos.
- ¿Qué reglas de inhibición añadirías entre ellas?
Ejercicio 3 — Configurar el enrutado para un incidente nocturno
Rutas Norte define esta política de guardia:
- De 08:00 a 20:00, laborables: todas las alertas van al canal
#alertas-plataformade Slack. - Fuera de ese horario: solo las
criticaderutas-norte-provan a PagerDuty; el resto espera a la mañana siguiente. - Las alertas del equipo de datos (
equipo: datos) nunca van a PagerDuty. - El sábado del puente de mayo hay una migración programada de
postgres-reservasde 02:00 a 05:00.
- ¿Puede Alertmanager enrutar por franja horaria? Si sí, escribe la configuración.
- Escribe el árbol de rutas completo que implementa la política.
- Escribe la orden que crea el silencio para la migración programada, con las buenas prácticas del apartado 9.
Soluciones
Solución 1
Problemas de CPUAlta:
- Sin
for: dispara en cuanto un pod supera 0,5 núcleos un instante. Un arranque de JVM o una compactación puntual generan notificación. - Alerta sobre una causa, no un síntoma. 0,5 núcleos puede ser perfectamente normal para
postgres-reservas. Nadie sabe qué hacer al recibirla. - Umbral absoluto sin contexto. 0,5 núcleos significa cosas distintas según el
limitsde cada componente. - Severidad
criticainjustificada: no despierta a nadie que pueda hacer algo útil. - Sin
runbook_urlni descripción.
Problemas de PodReiniciado:
> 0sobre un contador acumulado:kube_pod_container_status_restarts_totalnunca vuelve a bajar. Un pod que se reinició hace tres semanas mantiene la alerta disparada para siempre. Hay que usarincrease(...[ventana]).- Un reinicio aislado no es un problema con seis réplicas y readiness (07-01).
for: 1mdemasiado corto y severidad exagerada.
Problemas de MemoriaAlta:
for: 30s: ruidosísimo.- Umbral en bytes crudos y absoluto: 400 MB es mucho para
tienda-weby poquísimo parapostgres-reservas. Debe compararse con ellimitsdel contenedor. - Sin filtrar
container!="": incluye la serie agregada del pod y el contenedorpause, duplicando las alertas. - Alerta sobre causa. Lo que importa es si el contenedor va a ser
OOMKilled, no un número de bytes.
Versión corregida:
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: alertas-equipo
namespace: monitorizacion
labels:
app.kubernetes.io/part-of: rutas-norte
spec:
groups:
- name: rutasnorte.recursos
interval: 60s
rules:
# Sustituye a CPUAlta: alerta sobre el efecto real (throttling),
# relativo al límite del propio contenedor, no absoluto.
- alert: ContenedorEstrangulado
expr: |
sum by (namespace, pod, container) (
rate(container_cpu_cfs_throttled_periods_total{namespace="rutas-norte-pro"}[5m]))
/ sum by (namespace, pod, container) (
rate(container_cpu_cfs_periods_total{namespace="rutas-norte-pro"}[5m]))
> 0.30
for: 15m
labels:
severity: aviso
equipo: plataforma
annotations:
summary: "{{ $labels.container }} estrangulado el {{ $value | humanizePercentage }} del tiempo"
description: >
El contenedor {{ $labels.container }} del pod {{ $labels.pod }}
alcanza su limits.cpu de forma sostenida y eso degrada la latencia.
Revisar la recalibración de recursos de 07-02.
runbook_url: "https://runbooks.rutasnorte.example/throttling-cpu"
# Sustituye a PodReiniciado: usa increase() sobre una ventana,
# exige varios reinicios y baja la severidad.
- alert: PodEnCrashLoop
expr: |
increase(kube_pod_container_status_restarts_total{
namespace="rutas-norte-pro"}[15m]) > 3
for: 10m
labels:
severity: aviso
equipo: plataforma
annotations:
summary: "{{ $labels.pod }} reiniciado más de 3 veces en 15 minutos"
description: >
Contenedor {{ $labels.container }}. Causas frecuentes: OOMKilled,
fallo de configuración o livenessProbe demasiado agresiva (07-01).
Recoger 'kubectl logs --previous' ANTES de tocar nada.
runbook_url: "https://runbooks.rutasnorte.example/crashloop"
# Sustituye a MemoriaAlta: relativa al límite, con for razonable
# y filtrando la serie agregada del pod.
- alert: MemoriaCercaDelLimite
expr: |
container_memory_working_set_bytes{namespace="rutas-norte-pro", container!=""}
/ on (namespace, pod, container)
kube_pod_container_resource_limits{namespace="rutas-norte-pro", resource="memory"}
> 0.90
for: 15m
labels:
severity: aviso
equipo: plataforma
annotations:
summary: "{{ $labels.container }} al {{ $value | humanizePercentage }} de su límite de memoria"
description: >
El contenedor está cerca de su limits.memory y será OOMKilled
si lo supera. Revisar si hay una fuga o si el límite es corto.
runbook_url: "https://runbooks.rutasnorte.example/memoria-limite"
# Alerta sobre el SÍNTOMA real: el contenedor YA fue matado.
- alert: ContenedorOOMKilled
expr: |
increase(kube_pod_container_status_last_terminated_reason{
namespace="rutas-norte-pro", reason="OOMKilled"}[15m]) > 0
for: 1m
labels:
severity: critica
equipo: plataforma
annotations:
summary: "{{ $labels.container }} ha sido matado por falta de memoria"
description: >
El kernel ha matado el contenedor por superar su limits.memory
(código de salida 137). Ampliar el límite o corregir la fuga.
runbook_url: "https://runbooks.rutasnorte.example/oomkilled"Cambios de fondo, más allá de la sintaxis: se ha pasado de tres alertas sobre causas absolutas a alertas relativas al límite del propio contenedor, con ventanas razonables, severidades proporcionadas y runbooks. La única critica es la que refleja un hecho consumado y accionable.
Solución 2
1. Tasa de aciertos de caché.
sum(rate(redis_keyspace_hits_total{entorno="pro"}[5m]))
/
(
sum(rate(redis_keyspace_hits_total{entorno="pro"}[5m]))
+
sum(rate(redis_keyspace_misses_total{entorno="pro"}[5m]))
)Se usan tasas y no valores absolutos porque los contadores acumulados desde el arranque del pod diluyen cualquier degradación reciente.
2. Alerta predictiva (la causa, con antelación).
- alert: RedisCacheSeLlenara
expr: |
predict_linear(
(redis_memory_max_bytes{entorno="pro"} - redis_memory_used_bytes{entorno="pro"})[2h:],
2 * 3600
) < 0
for: 20m
labels:
severity: aviso
componente: redis-cache
equipo: plataforma
annotations:
summary: "redis-cache se llenará en menos de 2 horas"
description: >
Según la tendencia de las últimas 2 horas, redis-cache alcanzará su
maxmemory de 1 GiB en menos de 2 horas y empezará a expulsar claves.
Memoria libre actual: {{ $value | humanize1024 }}B.
Acción: ampliar maxmemory o revisar el TTL de las claves de disponibilidad.
runbook_url: "https://runbooks.rutasnorte.example/redis-memoria"Nota sobre la sintaxis: predict_linear necesita un vector de rango. Al aplicarlo sobre una resta de dos gauges hay que usar un subquery ([2h:]), porque la expresión resultante no es una serie simple. Es un detalle que confunde mucho.
Alternativa más simple, sobre una sola métrica:
predict_linear(redis_memory_used_bytes{entorno="pro"}[2h], 2*3600)
> avg(redis_memory_max_bytes{entorno="pro"})3. Alerta sobre el síntoma.
- alert: RedisTasaAciertosDegradada
expr: |
(
sum(rate(redis_keyspace_hits_total{entorno="pro"}[10m]))
/ (sum(rate(redis_keyspace_hits_total{entorno="pro"}[10m]))
+ sum(rate(redis_keyspace_misses_total{entorno="pro"}[10m])))
) < 0.80
for: 15m
labels:
severity: aviso
componente: redis-cache
equipo: plataforma
annotations:
summary: "La tasa de aciertos de redis-cache ha caído al {{ $value | humanizePercentage }}"
description: >
Lo normal es un 95%. Con una tasa baja, api-reservas consulta
postgres-reservas mucho más de lo previsto y la latencia sube.
Comprobar si redis-cache está expulsando claves por falta de memoria.
runbook_url: "https://runbooks.rutasnorte.example/redis-aciertos"
- alert: RedisExpulsandoClaves
expr: sum(rate(redis_evicted_keys_total{entorno="pro"}[5m])) > 10
for: 10m
labels:
severity: aviso
componente: redis-cache
equipo: plataforma
annotations:
summary: "redis-cache expulsa {{ $value | humanize }} claves/s por falta de memoria"
runbook_url: "https://runbooks.rutasnorte.example/redis-memoria"Por qué hacen falta las dos. Cumplen funciones distintas en el tiempo:
- La predictiva avisa con dos horas de margen, cuando todavía se puede actuar sin prisa y sin impacto para el cliente. Es una alerta de capacidad.
- La de síntoma cubre el caso en que la predicción falle: un cambio brusco de patrón (por ejemplo, un despliegue que cachea objetos mucho mayores) puede llenar Redis en minutos sin que ninguna tendencia lo anticipe. La regresión lineal solo predice bien lo que se comporta linealmente.
Confiar solo en la predictiva es asumir que el futuro se parece al pasado. Confiar solo en el síntoma es renunciar a prevenir.
4. Reglas de inhibición.
inhibit_rules:
# Si Redis ya está expulsando claves, la predicción de que se llenará
# ya no aporta nada: el hecho ha ocurrido.
- source_matchers:
- alertname = "RedisExpulsandoClaves"
target_matchers:
- alertname = "RedisCacheSeLlenara"
equal: ['componente', 'namespace']
# Si la latencia de api-reservas está disparada (síntoma que sufre el
# cliente), los avisos de redis-cache son la causa: no hacen falta dos
# notificaciones separadas del mismo incidente.
- source_matchers:
- alertname = "ApiReservasLatenciaAlta"
- severity = "critica"
target_matchers:
- componente = "redis-cache"
- severity = "aviso"
equal: ['namespace']La primera regla es un ejemplo perfecto del criterio de inhibición: cuando la predicción se cumple, la predicción sobra.
Solución 3
1. ¿Puede Alertmanager enrutar por franja horaria?
Sí. Desde Alertmanager 0.22 existen los intervalos de tiempo (time_intervals), que se referencian en las rutas con active_time_intervals (la ruta solo aplica dentro del intervalo) o mute_time_intervals (la ruta se silencia dentro del intervalo).
time_intervals:
- name: horario-laboral
time_intervals:
- weekdays: ['monday:friday']
times:
- start_time: '08:00'
end_time: '20:00'
location: 'Europe/Madrid'
- name: fuera-de-horario
time_intervals:
- weekdays: ['monday:friday']
times:
- start_time: '20:00'
end_time: '24:00'
- start_time: '00:00'
end_time: '08:00'
location: 'Europe/Madrid'
- weekdays: ['saturday', 'sunday']
location: 'Europe/Madrid'El campo location es imprescindible: sin él, Alertmanager usa UTC y en verano la guardia empezaría dos horas antes de lo previsto.
2. Árbol de rutas completo.
route:
receiver: 'equipo-plataforma-slack'
group_by: ['alertname', 'namespace', 'componente']
group_wait: 30s
group_interval: 5m
repeat_interval: 4h
routes:
# ------------------------------------------------------------------
# 1. Equipo de datos: SIEMPRE a su canal, NUNCA a PagerDuty.
# Va primero porque la primera coincidencia gana.
# ------------------------------------------------------------------
- matchers:
- equipo = "datos"
receiver: 'equipo-datos-slack'
repeat_interval: 12h
# ------------------------------------------------------------------
# 2. dev y pre: nunca despiertan a nadie.
# ------------------------------------------------------------------
- matchers:
- namespace =~ "rutas-norte-(dev|pre)"
receiver: 'canal-desarrollo'
group_wait: 5m
repeat_interval: 24h
# ------------------------------------------------------------------
# 3. FUERA DE HORARIO: solo las críticas de producción despiertan.
# ------------------------------------------------------------------
- matchers:
- severity = "critica"
- namespace =~ "rutas-norte-pro|monitorizacion"
active_time_intervals: ['fuera-de-horario']
receiver: 'guardia-pagerduty'
group_wait: 10s
repeat_interval: 1h
continue: true # que también quede registro en Slack
# 3b. El resto, fuera de horario, solo a Slack: se lee por la mañana.
- matchers:
- namespace =~ "rutas-norte-pro|monitorizacion"
active_time_intervals: ['fuera-de-horario']
receiver: 'equipo-plataforma-slack'
group_wait: 5m
repeat_interval: 12h # sin insistir de madrugada
# ------------------------------------------------------------------
# 4. HORARIO LABORAL: todo a Slack, con las críticas más ágiles.
# ------------------------------------------------------------------
- matchers:
- severity = "critica"
active_time_intervals: ['horario-laboral']
receiver: 'equipo-plataforma-slack'
group_wait: 10s
repeat_interval: 1h
- active_time_intervals: ['horario-laboral']
receiver: 'equipo-plataforma-slack'Puntos a destacar del diseño:
- El orden importa: la ruta del equipo de datos va primero para que sus alertas críticas no acaben en PagerDuty por la ruta 3.
continue: trueen la ruta 3 permite que la alerta llegue a los dos sitios: PagerDuty despierta a alguien y Slack deja constancia para la revisión del día siguiente.- Las alertas no críticas fuera de horario tienen
repeat_interval: 12hpara no llenar el canal de madrugada.
Consideración de robustez: este enrutado por horario asume que la guardia es siempre la misma persona. En equipos con rotación, lo habitual es delegar la lógica de turnos en la propia herramienta de guardia (PagerDuty, Opsgenie), que gestiona calendarios, escalados y sustituciones mucho mejor que Alertmanager. Aquí la reglas horarias sirven sobre todo para decidir qué merece despertar a alguien, no a quién.
3. Silencio para la migración programada.
kubectl -n monitorizacion port-forward svc/monitorizacion-kube-pr-alertmanager 9093:9093 &
curl -s -X POST http://localhost:9093/api/v2/silences \
-H 'Content-Type: application/json' \
-d '{
"matchers": [
{"name": "componente", "value": "postgres-reservas", "isRegex": false},
{"name": "namespace", "value": "rutas-norte-pro", "isRegex": false}
],
"startsAt": "2026-05-02T00:00:00Z",
"endsAt": "2026-05-02T03:15:00Z",
"createdBy": "[email protected]",
"comment": "Migración programada de postgres-reservas 02:00-05:00 CEST. Ticket OPS-1842. Aprobada en el comité de cambios del 28/04. Responsable de guardia: Marta Ruiz."
}' | jq -r '.silenceID'Buenas prácticas aplicadas y su porqué:
- Horas en UTC.
02:00-05:00 CESTes00:00-03:00 UTC. Es el error más frecuente al crear silencios y deja alertas sin silenciar justo durante la ventana. - 15 minutos de margen sobre la ventana prevista (hasta las 03:15 UTC), porque las migraciones se alargan.
- Matchers específicos: solo
postgres-reservasenpro. Un silencio de todo el namespace ocultaría un problema no relacionado entienda-webdurante tres horas. - Comentario con ticket, aprobación y responsable: dentro de seis semanas, cualquiera puede reconstruir por qué existía.
endsAtobligatorio, nunca indefinido.
Y lo que no se debe silenciar durante la migración:
# Comprobar QUÉ alertas quedan cubiertas por el silencio antes de aplicarlo:
# las de la plataforma completa (PlataformaSinVentas, TiendaWebCaida) NO
# llevan la etiqueta componente=postgres-reservas, así que siguen activas.
# Eso es deliberado: si la migración deja la plataforma sin vender, hay que
# enterarse inmediatamente aunque la causa sea la propia migración.
curl -s http://localhost:9093/api/v2/alerts | \
jq -r '.[] | select(.status.silencedBy | length > 0) | .labels.alertname'Verificación tras la ventana:
# Confirmar que el silencio ha expirado y no queda ninguno olvidado
curl -s http://localhost:9093/api/v2/silences | \
jq -r '.[] | select(.status.state=="active") |
"\(.id)\t\(.createdBy)\t\(.comment)\thasta \(.endsAt)"'Este último comando debería ejecutarse como parte de la revisión semanal del equipo: un silencio olvidado es una alerta que ya no existe.
Conclusión
Rutas Norte ya no solo recuerda: ahora muestra y avisa. En esta lección hemos:
- Conectado Grafana a Prometheus y aprendido la anatomía de un panel: la consulta con
$__rate_interval, la leyenda con plantillas de etiquetas, las unidades y los umbrales que hacen legible un número, y los cuatro tipos que de verdad se usan, incluido el mapa de calor para ver la distribución completa de latencias. - Usado variables de panel para tener un único cuadro de mando que sirve para
dev,preypro, evitando tres copias que se desincronizan. - Construido el cuadro de mando de Rutas Norte panel a panel: una fila de resumen ejecutivo encabezada por las reservas confirmadas por minuto —porque si eso cae a cero da igual que todos los pods estén
Running— y una fila por componente con los cuatro indicadores dorados. - Provisionado los cuadros de mando como código en ConfigMaps con la etiqueta
grafana_dashboard, para que sobrevivan a la recreación del pod y se revisen en un pull request. - Escrito el catálogo de alertas con
PrometheusRule, entendiendo quefores el campo que separa un sistema usable del ruido, quepredict_linearpermite avisar de que el disco de PostgreSQL se llenará antes de que se llene, y querunbook_urlno es opcional. - Configurado Alertmanager: el árbol de rutas con sus cuatro tiempos, los receptores, la inhibición que convierte trece notificaciones en una cuando cae un nodo, y los silencios con fecha de fin y ticket para los mantenimientos programados.
- Y por encima de la mecánica, el criterio: alertar sobre síntomas que percibe el cliente, no sobre causas; que cada alerta sea accionable; y usar los SLO y el presupuesto de error como herramienta objetiva para decidir cuándo hay que dejar de añadir funcionalidad y ponerse a estabilizar.
Pero hay una pregunta que ni las métricas ni los cuadros de mando pueden responder. Cuando ApiReservasTasaErrorAlta dispara a las 03:14, sabemos que el 7 % de las peticiones fallan, sabemos en qué ruta y con qué latencia. Lo que no sabemos es por qué. La excepción concreta, el mensaje de la base de datos, la traza que señala la línea de código: eso no vive en una métrica. Vive en los logs, que hoy están repartidos por los nodos, se pierden cuando un pod se recrea y no hay forma de buscar entre ellos.
En 07-05 montaremos la pila completa de registro centralizado que anunciamos en 06-02 cuando desplegamos el recolector como DaemonSet: Elasticsearch, Fluentd y Kibana. Veremos cómo pasar de kubectl logs a una búsqueda que correlaciona todos los componentes, por qué los logs estructurados en JSON son la decisión que más mejora todo el sistema, y —muy importante para una plataforma que guarda el DNI y el teléfono de sus clientes— qué nunca debe acabar escrito en un log.
Curso de Kubernetes
Módulo 1: Introducción a Kubernetes
- ¿Qué es Kubernetes?
- Arquitectura de Kubernetes
- Conceptos y Terminología Clave
- Configuración de un Clúster de Kubernetes
- La CLI de Kubernetes: kubectl
- Objetos, Manifiestos YAML y el Modelo Declarativo
- El Proyecto del Curso: la Plataforma Rutas Norte
Módulo 2: Componentes Principales de Kubernetes
- Pods
- ReplicaSets
- Deployments
- Actualizaciones, Rollbacks y Estrategias de Despliegue
- Servicios
- Namespaces
- Etiquetas, Selectores y Anotaciones
Módulo 3: Gestión de Configuración y Secretos
- ConfigMaps
- Secrets
- Variables de Entorno
- Cuotas y Límites de Recursos
- LimitRanges y Clases de Calidad de Servicio (QoS)
- ServiceAccounts y Acceso a la API desde los Pods
Módulo 4: Redes en Kubernetes
- Redes de Clúster
- Tipos de Servicios
- DNS Interno y Descubrimiento de Servicios
- Controladores de Ingress
- TLS y Gestión de Certificados con cert-manager
- Políticas de Red
Módulo 5: Almacenamiento en Kubernetes
- Volúmenes
- Volúmenes Persistentes
- Reclamaciones de Volúmenes Persistentes
- Clases de Almacenamiento
- Aprovisionamiento Dinámico, Expansión y Snapshots
- Copias de Seguridad y Restauración de Datos
Módulo 6: Conceptos Avanzados de Kubernetes
- StatefulSets
- DaemonSets
- Trabajos y CronJobs
- Init Containers, Sidecars y Patrones Multi-Contenedor
- Planificación: Afinidad, Taints y Tolerations
- Definiciones de Recursos Personalizados (CRDs)
- Operadores y el Patrón Controlador
Módulo 7: Monitoreo y Registro
- Verificaciones de Salud y Sondas
- Servidor de Métricas y kubectl top
- Monitoreo con Prometheus
- Visualización y Alertas con Grafana y Alertmanager
- Registro Centralizado con Elasticsearch, Fluentd y Kibana (EFK)
- Depuración de Aplicaciones y Eventos del Clúster
Módulo 8: Seguridad en Kubernetes
- Control de Acceso Basado en Roles (RBAC)
- Contextos de Seguridad y Endurecimiento del Contenedor
- Políticas de Seguridad de Pods y Pod Security Standards
- Seguridad de Red
- Seguridad de Imágenes
- Auditoría, Escaneo y Gestión de Vulnerabilidades
Módulo 9: Escalado y Rendimiento
- Autoescalado Horizontal de Pods
- Autoescalado Vertical de Pods
- Autoescalado de Clúster
- Escalado por Eventos y Métricas Personalizadas con KEDA
- Alta Disponibilidad: PodDisruptionBudgets y Topología
- Ajuste de Rendimiento
Módulo 10: Ecosistema y Herramientas de Kubernetes
- Minikube y Entornos Locales con kind
- Kubeadm
- Helm
- Kustomize
- GitOps con Argo CD y Flux
- Kubernetes Gestionado: EKS, AKS y GKE
Módulo 11: Estudios de Caso y Aplicaciones del Mundo Real
- Despliegue de una Aplicación Web
- Ejecución de Aplicaciones con Estado
- CI/CD con Kubernetes
- Estrategias de Despliegue: Blue-Green y Canary
- Gestión Multi-Clúster
- Operación en Producción: Incidencias, Runbooks y Costes
