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

  1. Grafana: qué es y cómo se conecta a Prometheus
  2. Anatomía de un panel y los tipos que de verdad se usan
  3. Variables de panel: un cuadro de mando para los tres entornos
  4. Importar cuadros de mando de la comunidad
  5. El cuadro de mando de Rutas Norte, panel a panel
  6. Provisionar cuadros de mando como código
  7. Reglas de alerta con PrometheusRule y la importancia de for
  8. El catálogo de alertas de Rutas Norte
  9. Alertmanager: rutas, receptores, inhibición y silencios
  10. El criterio: alertar sobre síntomas, no sobre causas
  11. SLO y presupuesto de error de Rutas Norte
  12. Errores comunes y consejos
  13. Ejercicios

  1. 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=grafana
NAME                                      READY   STATUS    RESTARTS   AGE
monitorizacion-grafana-6d84b8c7f9-w2xnk   3/3     Running   0          2d4h

Fí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:3000

Las 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; echo
admin
cambiar-en-produccion

Antes de seguir. Ese valor lo pusimos en el fichero de valores de Helm en 07-03, y en rutas-norte-pro es 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 con admin.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: 80

Verificar 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):

http://monitorizacion-kube-pr-prometheus.monitorizacion.svc.cluster.local:9090

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.

  1. 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.

  1. 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.

  1. 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:

{{ruta}} · {{codigo}}
/api/rutas · 200
/api/reservas · 201
/api/reservas · 422

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.

  1. 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.

  1. 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.

  1. 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:   No

label_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):

Nombre:        namespace
Query:         label_values(kube_pod_info{namespace=~"rutas-norte-$entorno"}, namespace)

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):

Nombre:  intervalo
Tipo:    Interval
Valores: 1m,5m,15m,30m,1h,6h,24h

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.

  1. 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:

  1. ¿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í".
  2. ¿El nombre de la fuente de datos coincide? Al importar, Grafana pide mapear la fuente de datos. Si el cuadro espera una llamada Prometheus y la tuya se llama de otra forma, todos los paneles fallan.
  3. ¿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.
  4. ¿Refleja tu realidad? Un cuadro genérico de Kubernetes no sabe qué es api-reservas ni qué es una reserva confirmada. Sirve para infraestructura; no sustituye al cuadro de mando propio.
  5. ¿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.

  1. 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.

sum(rate(rutasnorte_reservas_confirmadas_total{entorno="$entorno"}[$__rate_interval])) * 60
  • Tipo: Stat, con gráfico de tendencia de fondo (Graph mode: Area).
  • Unidad: short, sufijo res/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:

max(apireservas:latencia_p95:5m{entorno="$entorno"})
  • 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):

sum by (ruta) (rate(rutasnorte_peticiones_total{entorno="$entorno"}[$__rate_interval]))

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"}) / 20

Fila 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])) * 60

informes-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:

kube_pod_container_status_restarts_total{namespace="$namespace"}

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.

  1. 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: true

El 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:

  1. Construye el cuadro de mando en la interfaz de Grafana, que es cómoda.
  2. Dashboard settings → JSON Model → Copiar.
  3. Pégalo en el ConfigMap, dentro de data, con la indentación correcta.
  4. kubectl apply y revisión en un pull request.
  5. 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.

  1. Reglas de alerta con PrometheusRule y la importancia de for

Grafana 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.

  1. 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	inactive

Probar 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

  1. 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: true

Los 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, una TiendaWebCaida en dev silenciaría los avisos de pro.

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-pro entero 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)"'

  1. 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:

  1. ¿Qué debe hacer quien la reciba? Si la respuesta es "mirar si se arregla solo", no es una alerta: es un panel.
  2. ¿Está escrito ese procedimiento? Si no, escríbelo antes de activar la alerta y ponlo en runbook_url.
  3. ¿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.

  1. 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 fallar

Ese 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
  1. Escribe la consulta PromQL de la tasa de aciertos de caché (proporción de aciertos sobre el total).
  2. Escribe una alerta que avise antes de que el problema afecte a los clientes, usando predict_linear.
  3. Escribe una segunda alerta sobre el síntoma, para cuando ya esté ocurriendo, y explica por qué hacen falta las dos.
  4. ¿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-plataforma de Slack.
  • Fuera de ese horario: solo las critica de rutas-norte-pro van 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-reservas de 02:00 a 05:00.
  1. ¿Puede Alertmanager enrutar por franja horaria? Si sí, escribe la configuración.
  2. Escribe el árbol de rutas completo que implementa la política.
  3. 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 limits de cada componente.
  • Severidad critica injustificada: no despierta a nadie que pueda hacer algo útil.
  • Sin runbook_url ni descripción.

Problemas de PodReiniciado:

  • > 0 sobre un contador acumulado: kube_pod_container_status_restarts_total nunca vuelve a bajar. Un pod que se reinició hace tres semanas mantiene la alerta disparada para siempre. Hay que usar increase(...[ventana]).
  • Un reinicio aislado no es un problema con seis réplicas y readiness (07-01).
  • for: 1m demasiado corto y severidad exagerada.

Problemas de MemoriaAlta:

  • for: 30s: ruidosísimo.
  • Umbral en bytes crudos y absoluto: 400 MB es mucho para tienda-web y poquísimo para postgres-reservas. Debe compararse con el limits del contenedor.
  • Sin filtrar container!="": incluye la serie agregada del pod y el contenedor pause, 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: true en 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: 12h para 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 CEST es 00: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-reservas en pro. Un silencio de todo el namespace ocultaría un problema no relacionado en tienda-web durante tres horas.
  • Comentario con ticket, aprobación y responsable: dentro de seis semanas, cualquiera puede reconstruir por qué existía.
  • endsAt obligatorio, 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, pre y pro, 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 que for es el campo que separa un sistema usable del ruido, que predict_linear permite avisar de que el disco de PostgreSQL se llenará antes de que se llene, y que runbook_url no 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

Módulo 2: Componentes Principales de Kubernetes

Módulo 3: Gestión de Configuración y Secretos

Módulo 4: Redes en Kubernetes

Módulo 5: Almacenamiento en Kubernetes

Módulo 6: Conceptos Avanzados de Kubernetes

Módulo 7: Monitoreo y Registro

Módulo 8: Seguridad en Kubernetes

Módulo 9: Escalado y Rendimiento

Módulo 10: Ecosistema y Herramientas de Kubernetes

Módulo 11: Estudios de Caso y Aplicaciones del Mundo Real

Módulo 12: Preparación para la Certificación de Kubernetes

© Copyright 2026. Todos los derechos reservados