Ronda de Diseño ML · Monitoreo · ejemplos de C01

Monitoreo

Prometheus y Grafana

Para instrumentar un servicio, escribir con la documentación abierta las consultas de un dashboard y de una alerta, y diagnosticar «no data», «target down» o «la alerta nunca sonó». En el C01, Prometheus pasa cada 15 s por cada servicio, le pide sus números y los guarda como series de tiempo; las reglas convierten una consulta en una alerta, Alertmanager decide a quién avisar y Grafana dibuja. Cada sección trae la sintaxis como plantilla con variables, las piezas, un ejemplo real del curso, una figura, un truco para recordarla, la trampa típica y un laboratorio opcional. En la versión web, al final hay un diagnosticador de síntomas, un revisor de reglas de alerta y un quiz de errores.

Cómo recordarlaTrampa típicavariablela cambias túEjemplos en verdeGrafanaInspectQueryun lugar de la web: se abre, no se copia

En las figuras:counter (el odómetro)gauge (el medidor de gasolina)histogram (los cajones)ok, up o resueltapendingfiring, down o una cuenta que mientesilenciada, inhibida o ignorada

0

Números de bolsillo: puertos, intervalos, tipos de métrica y las funciones de PromQL que más usas

¿Qué puertos, intervalos, defaults y funciones conviene saberse de memoria para leer un sistema con Prometheus en segundos?

Sintaxis con variables

curl -s host:puerto/metrics | head -n 20

Lo mismo que lee Prometheus en cada scrape (la visita periódica en que pide los números): texto plano, una línea por muestra (el valor de una serie, la historia de un número, en ese momento). Puertos de bolsillo: gateway 8080, Triton 8002, node_exporter 9100 y el propio Prometheus en 9090.

up{job="job"} == 0

up la escribe Prometheus después de cada scrape: 1 si pudo leer al target (cada URL que visita), 0 si falló. Es la primera consulta cuando un panel dice «No data»: si ni siquiera existe up para ese job, Prometheus no sabe que el target existe.

sum by (label) (rate(contador_total[ventana]))

Un counter (contador que solo sube) se lee con rate(): cuánto sube por segundo, en promedio, dentro de la ventana (el tramo de tiempo hacia atrás, entre corchetes). Primero rate y después sum: así se corrige el reinicio de cada pod. A Prometheus le bastan 2 muestras en la ventana; la regla práctica (la de $__rate_interval en Grafana) pide al menos 4 scrapes: [1m] con 15 s. Las alertas de C01 usan [5m].

histogram_quantile(q, sum by (le) (rate(histograma_bucket[ventana])))

El p99 (el tiempo bajo el que queda el 99 % de los requests) de todo el servicio: primero se suman los buckets (los cajones del histograma) de todos los pods y después se calcula el cuantil. Nunca promedies el p99 de cada pod. Es la expresión de ClassifierHighP99Latency en C01.

Variables

VariableQué esEjemplo
hostdónde corre el target, visto desde donde haces curllocalhost (con kubectl port-forward) o gateway dentro del compose
puertopuerto donde el target sirve /metrics8080 gateway · 8002 Triton · 9100 node_exporter
jobgrupo de targets: el job_name de prometheus.ymlgateway
labellabel por el que partes el resultadostatus, source o le
contadornombre del counter sin el _totalclassifier_requests
ventanacuánto tiempo hacia atrás mira la función5m en las alertas de C01; 1m como mínimo práctico con scrape de 15 s
qcuantil, entre 0 y 10.99
histogramanombre base del histogramaclassifier_request_latency_seconds
x, w, tx,\ w,\ tx: el valor del counter; w: la ventana; t: la hora de cada muestralab de la sección 2: 0.371/s en 2 min, con un reinicio adentro
Δscrape\Delta_{\text{scrape}}cada cuánto lee Prometheus (scrape_interval)15 s en C01; 1 min si no lo escribes
NseriesN_{\text{series}}series que se guardan en cada ronda≈ 205 por pod del gateway (sección 3): 615 con 3 pods
tretencioˊnt_{\text{retención}}cuánto guarda el disco local15 días si nadie la cambia

Fórmulas

rate(x[w])  ≈  xuˊltima−xprimera+∑xantes de cada reiniciotuˊltima−tprimera(y extrapola a los bordes de w)\text{rate}(x[w]) \;\approx\; \frac{x_{\text{última}} - x_{\text{primera}} + \sum x_{\text{antes de cada reinicio}}}{t_{\text{última}} - t_{\text{primera}}} \qquad (\text{y extrapola a los bordes de } w)
increase(x[w])  =  rate(x[w])×w\text{increase}(x[w]) \;=\; \text{rate}(x[w]) \times w
w  ≥  4×Δscrape  =  4×15 s  =  1 min(regla praˊctica; Prometheus pide solo 2 muestras)w \;\ge\; 4 \times \Delta_{\text{scrape}} \;=\; 4 \times 15\ \text{s} \;=\; 1\ \text{min} \qquad (\text{regla práctica; Prometheus pide solo 2 muestras})
disco  ≈  tretencioˊn×NseriesΔscrape×1–2 B  =  15 d×61515 s×1–2 B  ≈  53–106 MB\text{disco} \;\approx\; t_{\text{retención}} \times \frac{N_{\text{series}}}{\Delta_{\text{scrape}}} \times 1\text{–}2\ \text{B} \;=\; 15\ \text{d} \times \frac{615}{15\ \text{s}} \times 1\text{–}2\ \text{B} \;\approx\; 53\text{–}106\ \text{MB}

Las piezas

PiezaQué haceEjemplo
9090Prometheus: su web (consultas, alertas, targets), su API HTTP y sus propias métricas en /metrics.localhost:9090/alerts en el compose de C01
9093Alertmanager: recibe las alertas que disparan y decide a quién avisar.kube-prometheus-stack (el paquete de Helm con Prometheus, Alertmanager y Grafana) lo instala junto a Prometheus
3000Grafana: los dashboards. El primer login es admin / admin; cambia esa contraseña.docker run -p 3000:3000 grafana/grafana:13.2.4
9100 · 8080 · 9400 · 9091Exporters (programas que leen otro sistema y lo publican como métricas) y un buzón: node_exporter (la máquina), kube-state-metrics (los objetos de Kubernetes), dcgm-exporter (GPUs NVIDIA) y el Pushgateway (para jobs batch, trabajos que corren y terminan).C01 lee la GPU desde Triton, sin dcgm-exporter
8002Las métricas nv_* de Triton: counters de requests y tiempos, gauges (valores que suben y bajan) de GPU.nv_inference_queue_duration_us, la señal de KEDA (el autoscaler de Triton)
scrape_interval: 1mDefault de cada cuánto se lee cada target si no lo escribes. El archivo de ejemplo (el que trae la imagen de Docker) pone 15s.C01: 15s en local y en los ServiceMonitors (su config de scrape en EKS)
scrape_timeout: 10sCuánto espera cada scrape antes de darlo por fallido (up = 0). Nunca más que el intervalo.un /metrics que tarda 12 s da up = 0 aunque el proceso viva
evaluation_interval: 1mCada cuánto se evalúan las reglas (las consultas que Prometheus corre solo: alertas y grabaciones). Un grupo de reglas puede fijar su propio interval.C01: 15s; su grupo de grabación, 1m
Retención: 15dCuánto tiempo guarda el disco local. En 3.13 se cambia en prometheus.yml (storage: tsdb: retention: time); el flag --storage.tsdb.retention.time todavía funciona, pero está deprecado (se desaconseja y puede desaparecer).ClassifierLowConfidenceSpike necesita 7 días de historia
Counter → rate()Solo sube y vuelve a 0 al reiniciar: se lee con rate() o increase(), nunca crudo.rate(classifier_requests_total[5m])
Gauge → directoSube y baja: se lee tal cual, o con avg_over_time / max_over_time (promedio o máximo de la ventana).nv_gpu_utilization > 0.85 (GPUSaturated)
Histogram → histogram_quantileCajones acumulados (_bucket con le, «hasta») más _sum y _count: se suman entre pods y después se saca el cuantil.p99 > 0.2 s (ClassifierHighP99Latency)
SummaryEl cuantil ya viene calculado en cada proceso: no se puede sumar entre pods. El de Python ni siquiera lo calcula.evítalo en servicios con réplicas
for · group_waitUna alerta espera en pending (pendiente) lo que dice for antes de pasar a firing (disparada); sin for (0s), dispara en la primera evaluación verdadera. Alertmanager espera group_wait (30 s por defecto) antes del primer aviso.for: 5m en ClassifierHigh5xxRate
CampoDefaultEn C01Por qué importa
scrape_interval1m (Prometheus Operator: 30s)15s en prometheus.yml y en los ServiceMonitorsla resolución: con 1m, la regla práctica pide rate(x[4m]) o más
scrape_timeout10s; si el intervalo es menor, el intervalosin cambiar (10s)un /metrics lento da up = 0 aunque el proceso viva
evaluation_interval1m (Prometheus Operator: 30s)15s; el grupo de grabación usa interval: 1mcada cuánto puede cambiar de estado una alerta
metrics_path · scheme/metrics · http/metrics en los dos jobsotra ruta se escribe en el job, no en targets
Retención (storage.tsdb.retention.time)15dsin cambiar≥ 7 días para el promedio de ClassifierLowConfidenceSpike
for0s: firing en la primera evaluación verdadera5m, 10m, 15m o 30m según la alertafiltra picos cortos; la ventana del rate los estira, así que cuenta las dos
query.lookback-delta5m—una consulta instantánea toma la última muestra de hasta 5 min atrás
rules.alert.resend-delay1m—Prometheus reenvía a Alertmanager cada alerta que sigue en firing
group_wait · group_interval · repeat_interval30s · 5m · 4hsin Alertmanager en localcuándo llega el primer aviso, los siguientes del grupo y el recordatorio
sample_limit · label_limit0 (sin límite)sin cambiarcon límite, un scrape que lo pasa se descarta entero: up = 0
Ventana de rate()no tiene default[5m] en casi todas las alertas, [30m] en la grabación y en la de confianza, [2m] en KEDAPrometheus pide solo 2 muestras; la regla práctica es al menos 4 scrapes (la de $__rate_interval en Grafana)

Para ver los defaults de tu propio Prometheus: /api/v1/status/config devuelve la config cargada con los defaults ya escritos, y /api/v1/status/flags, los flags. Es el lab de esta sección.

Defaults de Prometheus 3.13 y Alertmanager 0.34 contra lo que fija C01. Verificados en la documentación oficial y en el código fuente, en octubre de 2026.

Ejemplo real · El Prometheus del compose de C01: versión, puerto y archivos

YAMLreference/c01/docker-compose.yml (líneas 46–55, el servicio prometheus)
  prometheus:
    image: prom/prometheus:v3.13.3  # tag fijo (nunca latest): 3.13 es la LTS vigente (soporte hasta 2027-07-31); sube de parche dentro de 3.13.x
    ports:
      - "9090:9090"  # UI: http://localhost:9090/alerts muestra las 6 alertas y su estado
    volumes:  # montados en la ruta que usa el CMD por defecto de la imagen: no hace falta `command`
      - ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro
      - ./monitoring/alerts.yml:/etc/prometheus/alerts.yml:ro
    depends_on:
      - gateway
      - triton
  • v3.13.3 es de la línea LTS (soporte largo: un año de arreglos para fallas graves, hasta el 31 de julio de 2027). En octubre de 2026 la más nueva es la 3.15.1, y la 3.13 ya va en 3.13.5 (21 de octubre, con arreglos de seguridad): subir de parche es cambiar el tag, sin tocar la config.
  • 9090:9090: la web y la API. /alerts muestra cada alerta en inactive, pending o firing; /targets (menú Status → Target health) dice si cada scrape funciona y, si no, con qué error.
  • Los dos archivos entran a /etc/prometheus, donde el CMD de la imagen (los flags con que arranca si no le pasas otros) ya busca la config (--config.file=/etc/prometheus/prometheus.yml y --storage.tsdb.path=/prometheus); rule_files: - alerts.yml se resuelve relativo a esa carpeta. Si agregas flags en command:, reemplazas ese CMD entero: repite los dos.
  • No hay servicio de Alertmanager ni bloque alerting: en prometheus.yml: en local las alertas se ven en /alerts, pero nadie recibe un aviso. En EKS lo trae kube-prometheus-stack.
  • La retención no se toca: 15 días, que alcanzan para los 7 que pide ClassifierLowConfidenceSpike. Pero la base vive en un volumen anónimo (lo cuenta el cheat sheet de Docker): tras un down + up empieza de cero, y el promedio «de 7 días» pasa a ser el de lo que haya.

Cómo recordarla

9090 Prometheus, 9093 Alertmanager, 3000 Grafana, 9100 la máquina, 8002 Triton. Un scrape cada 15 s (si no lo escribes, 1 min), 10 s para contestar, 15 días de memoria. Y el orden de lectura: up → rate → sum by → histogram_quantile.

Trampa

Creer que 15 s es el default porque está en todos los ejemplos, incluido el prometheus.yml que trae la imagen de Docker, cuyo comentario dice «Default is every 1 minute». Si tu archivo no escribe scrape_interval, es 1m, y un rate(x[1m]) casi siempre encuentra una sola muestra: devuelve vacío y el panel dice «No data». Con 15 s, [1m] tiene 4 muestras; con 1 min, usa [4m] o más.

Laboratorio (opcional)7 pasos

Qué vas a provocar: Levantar un Prometheus sin escribir config y leerle, por su API, los defaults de esta sección: intervalos, timeout, retención, lookback y el up de su único target.

Necesitas: Docker Desktop corriendo e internet para bajar prom/prometheus:v3.13.3 (la imagen de C01, con versión para Apple Silicon). Python 3 de macOS para leer el JSON.

  1. Paso 1
    docker run -d --name prom-lab -p 9090:9090 prom/prometheus:v3.13.3

    Qué deberías ver: Un id largo; la primera vez baja la imagen. Sin -v ni command arranca con el prometheus.yml de ejemplo que trae la imagen, que se lee a sí mismo en localhost:9090.

  2. Paso 2
    sleep 5; curl -s localhost:9090/-/ready

    Qué deberías ver: Prometheus Server is Ready. Si todavía arranca, Service Unavailable (un 503): espera y repite.

  3. Paso 3
    sleep 15; curl -s localhost:9090/api/v1/targets | python3 -c "import json, sys; t = json.load(sys.stdin)['data']['activeTargets'][0]; print(t['scrapeUrl'], t['scrapeInterval'], t['scrapeTimeout'], t['health'])"

    Qué deberías ver: http://localhost:9090/metrics 15s 10s up. El esquema y la ruta son defaults (http y /metrics), los 15 s los escribe el archivo de ejemplo y los 10 s de timeout son el default: el archivo solo los nombra en un comentario. Si dice unknown, todavía no hubo scrape: repite.

  4. Paso 4
    curl -s localhost:9090/api/v1/status/config | python3 -c "import json, sys; print(json.load(sys.stdin)['data']['yaml'])" | grep -E 'evaluation_interval|scrape_timeout'

    Qué deberías ver: evaluation_interval: 15s (del archivo) y scrape_timeout: 10s dos veces, en global y copiado al job: la config cargada ya trae escritos los defaults.

  5. Paso 5
    curl -s localhost:9090/api/v1/status/runtimeinfo | python3 -m json.tool | grep storageRetention
    curl -s localhost:9090/api/v1/status/flags | python3 -m json.tool | grep -E 'lookback-delta|for-grace-period|resend-delay'

    Qué deberías ver: "storageRetention": "15d" (nadie fijó retención), y después "query.lookback-delta": "5m", "rules.alert.for-grace-period": "10m" y "rules.alert.resend-delay": "1m", en orden alfabético.

  6. Paso 6
    curl -s 'localhost:9090/api/v1/query?query=up'

    Qué deberías ver: Un JSON con resultType vector y un resultado: metric con __name__ up, app prometheus, instance localhost:9090 y job prometheus, y value [la hora, "1"]. job e instance los agrega Prometheus; app viene del labels: del archivo de ejemplo.

  7. Paso 7
    docker rm -f -v prom-lab

    Qué deberías ver: prom-lab. Con -v se borra también el volumen anónimo donde quedó la base.

Cómo arreglarlo y confirmarlo

Si curl no conecta: docker ps (¿sigue corriendo?) y docker logs prom-lab (cuando ya atiende, el log dice msg="Server is ready to receive web requests."). Si el 9090 está ocupado, publica otro puerto del Mac: -p 9091:9090 y curl localhost:9091.

Verificado contra la documentación; no ejecutado aquí.

↳

La cadena: de una línea de código a una alerta en tu teléfono

¿Qué pasa, en orden, entre un REQUESTS.inc() en el gateway y el aviso que despierta a alguien, y cuánto tarda cada eslabón?

Sintaxis con variables

METRICA.labels(label=valor).inc()

El primer eslabón: un número que sube en la memoria del proceso. Cuesta microsegundos y todavía no sale nada por la red.

curl -s host:puerto/metrics | grep metrica

El segundo: el valor acumulado, en texto, esperando a que alguien lo pida. Si aquí no sube, el problema está en tu código, no en Prometheus.

curl -s -G localhost:9090/api/v1/query --data-urlencode 'query=expresion'

Prometheus ya lo guardó como serie y lo consultas con PromQL (su lenguaje de consultas): la misma expresión que usan las reglas, Grafana y KEDA. -G con --data-urlencode codifica las llaves y comillas de la consulta.

curl -s localhost:9090/api/v1/alerts

Las alertas activas: pending (la condición se cumple, pero todavía no durante todo el for) o firing (ya disparó y se manda a Alertmanager).

curl -s localhost:9093/api/v2/alerts

Lo que recibió Alertmanager. Desde aquí agrupa, silencia y avisa a un receiver (el destino: PagerDuty, Slack, email o un webhook).

Variables

VariableQué esEjemplo
METRICAvariable de Python que guarda el counterREQUESTS
label=valorla serie que subestatus="503"
host:puertoel targetlocalhost:8080 con kubectl port-forward
metricanombre expuesto de la métricaclassifier_requests_total
expresionla PromQL que quieres verla expr de ClassifierHigh5xxRate
tcrucet_{\text{cruce}}lo que tarda la expresión en pasar el umbral desde que empiezan los errores30 s con 10 % de errores
wwventana del rate()5 min
eefracción de errores que empezó de golpe10 %: Triton rechaza 1 de cada 10
Δscrape, Δeval\Delta_{\text{scrape}},\ \Delta_{\text{eval}}intervalos de scrape y de evaluación de reglas15 s y 15 s en el compose de C01; en EKS, 15 s (ServiceMonitor) y 30 s (default del Prometheus Operator, si nadie lo cambia)
tfort_{\text{for}}lo que la alerta espera en pending5 min en ClassifierHigh5xxRate
tgrupot_{\text{grupo}}group_wait: lo que Alertmanager espera antes del primer aviso de un grupo nuevo30 s por defecto

Fórmulas

taviso  ≈  tcruce+Δscrape+Δeval⏟≤ 30 s en C01+tfor+tgrupot_{\text{aviso}} \;\approx\; t_{\text{cruce}} + \underbrace{\Delta_{\text{scrape}} + \Delta_{\text{eval}}}_{\le\, 30\ \text{s en C01}} + t_{\text{for}} + t_{\text{grupo}}
tcruce  =  w×umbrale  =  300 s×1 %10 %  =  30 st_{\text{cruce}} \;=\; w \times \frac{\text{umbral}}{e} \;=\; 300\ \text{s} \times \frac{1\ \%}{10\ \%} \;=\; 30\ \text{s}
taviso  ≈  30+30+300+30 s  ≈  6.5 min(ClassifierHigh5xxRate con 10 % de errores)t_{\text{aviso}} \;\approx\; 30 + 30 + 300 + 30\ \text{s} \;\approx\; 6.5\ \text{min} \qquad (\texttt{ClassifierHigh5xxRate}\ \text{con 10 \% de errores})

Las piezas

PiezaQué haceEjemplo
InstrumentaciónLas llamadas en tu código que suben counters y observan histogramas, en memoria.REQUESTS.labels(…).inc() en el finally de classify
/metricsLa foto de esos números en texto, cada vez que alguien la pide.gateway:8080/metrics
ScrapePrometheus lee cada target cada scrape_interval y escribe up (1 o 0).cada 15 s en C01
TSDB: head y WALLa base de series de Prometheus (TSDB, time series database). Las muestras recientes viven en memoria (head) y en un registro en disco (WAL, write-ahead log) que se relee si Prometheus reinicia./prometheus/wal en la imagen de Docker
Bloques de 2 hCada bloque de 2 h pasa de la memoria a un archivo en disco; después se junta con otros en bloques más grandes, y los más viejos que la retención se borran.15 días por defecto
PromQLCalcula sobre las series: tasas, cocientes, sumas, cuantiles.la fracción de 5xx sobre requests válidos
Regla de grabaciónGuarda el resultado de una expresión cara como una serie nueva, en cada evaluación.classifier:low_confidence_share:rate30m
Regla de alertaEvalúa su expr cada evaluation_interval: cada serie que devuelve es una alerta activa.ClassifierHigh5xxRate
pending → firingpending mientras la condición no cumple el for completo; firing cuando sí. Si deja de cumplirse, vuelve a inactive.for: 5m
ALERTSSerie que escribe Prometheus por cada alerta activa, con alertstate pending o firing.ALERTS{alertname="ClassifierHigh5xxRate"}
alerting: alertmanagersA qué Alertmanager manda Prometheus las alertas en firing; las reenvía cada minuto mientras sigan.no está en el prometheus.yml de C01
AlertmanagerAgrupa, quita duplicados, silencia, inhibe (calla una alerta si otra más grave ya avisa) y elige el receiver según los labels (severity).page a quien está de guardia, ticket al equipo de ML
Receiver y runbookEl destino del aviso, y el link con los pasos a seguir (runbook), que viaja en las annotations de la alerta.runbook_url en las 6 alertas de C01
GrafanaUna rama aparte: consulta la misma base con PromQL para dibujar. En C01 no participa del aviso (Grafana también puede evaluar alertas, pero C01 las tiene en Prometheus).dashboard de servicio de C01

tu servicio

1Código2/metrics

Prometheus

3Scrape4TSDB5PromQL6Regla7for8Envío

Alertmanager

9Alertmanager10Aviso

rama aparte

·Grafana

  1. Códigotu servicio

    PythonREQUESTS.labels(status="503", …).inc()

    Suma 1 en la memoria del proceso: microsegundos, sin red.

  2. /metricstu servicio

    terminalcurl -s localhost:8080/metrics | grep 'status="503"'

    El valor acumulado, en texto, cuando alguien lo pide.

  3. ScrapePrometheus

    PromQLup{job="gateway"}

    Cada 15 s Prometheus lee cada pod (10 s de timeout) y escribe up.

  4. TSDBPrometheus

    PromQLprometheus_tsdb_head_series

    La muestra entra a su serie: memoria (head) y WAL en disco; bloques de 2 h; 15 días de retención.

  5. PromQLPrometheus

    PromQLsum(rate(classifier_requests_total{status=~"5.."}[5m])) / sum(rate(classifier_requests_total{status!~"4.."}[5m]))

    Tasas en ventanas de 5 min: un salto a 10 % de errores cruza el 1 % en ≈ 30 s.

  6. ReglaPrometheus

    reglaClassifierHigh5xxRate

    Se evalúa cada 15 s (evaluation_interval). La primera vez que la expr devuelve algo: pending.

  7. forPrometheus

    PromQLALERTS{alertname="ClassifierHigh5xxRate"}

    5 min seguidos en pending → firing. Si la condición se apaga antes, vuelve a inactive y no avisa.

  8. EnvíoPrometheus

    YAMLalerting: alertmanagers

    Prometheus manda las alertas en firing a Alertmanager y las reenvía cada 1 min mientras sigan. En el compose de C01, este eslabón no existe.

  9. AlertmanagerAlertmanager

    terminalcurl -s localhost:9093/api/v2/alerts

    Agrupa por labels, espera group_wait (30 s) por si llegan más alertas del mismo incidente, aplica silencios e inhibiciones y elige el receiver por severity.

  10. AvisoAlertmanager

    YAMLseverity: page → receiver de guardia

    PagerDuty, Slack o email, con summary, description y runbook_url. Con 10 % de errores, ≈ 6.5 min después del primer error en el peor caso.

rama aparteGrafana

PromQLhistogram_quantile(0.99, sum by (le) (rate(classifier_request_latency_seconds_bucket[5m])))

Consulta la misma base cada vez que refresca un panel. Si Grafana se cae, las alertas de Prometheus siguen.

Pythonuna línea de Python de tu servicioterminalun comando: va en la terminalPromQLuna consulta: va en la caja de consultas de Prometheus (o en Grafana)reglael nombre de una regla de alertaYAMLuna clave de la configuración (YAML)

De la línea 244 de main.py al aviso: diez eslabones, con los tiempos de C01 y una rama aparte para Grafana, que lee la misma base y no participa del aviso. En el compose local la cadena se corta en /alerts: no hay Alertmanager.

Ejemplo real · La alerta de 5xx de C01: la expresión, el for y lo que viaja con el aviso

YAMLreference/c01/monitoring/alerts.yml (líneas 41–60)
      # Errores del servidor > 1 % de los requests válidos: los 4xx no cuentan ni en el numerador ni en el
      # denominador (si no, una ráfaga de 4xx diluye los 5xx).
      # Punto ciego: si TODOS los gateways quedan NotReady, el ALB responde 503 sin llegar al
      # gateway y esta métrica no lo ve. Lo cubren TritonNotReady y el HTTPCode_ELB_5XX_Count del ALB.
      - alert: ClassifierHigh5xxRate
        expr: |
          (
            sum(rate(classifier_requests_total{status=~"5.."}[5m]))
              /
            sum(rate(classifier_requests_total{status!~"4.."}[5m]))
          ) > 0.01
          and sum(rate(classifier_requests_total[5m])) > 1
        # La segunda condición exige ≥ 1 req/s: con tráfico casi nulo, 1 error de 20 dispararía un page falso
        for: 5m
        labels:
          severity: page
        annotations:
          summary: "Más de 1 % de 5xx en /v1/classify"
          description: "Tasa de 5xx = {{ $value | humanizePercentage }}. 503 = Triton caído o rechazando; 504 = Triton lento (mira TritonQueueTimeHigh)."
          runbook_url: "https://runbooks.example.com/classifier/high-5xx-rate"
  • Eslabón 1: cada request suma 1 en REQUESTS.labels(status=str(status), source=source, model_version=MODEL_VERSION).inc() (main.py, línea 244, en el finally); los 422 los cuenta aparte el handler de errores de validación (línea 329), porque FastAPI los rechaza antes de llamar a classify. Eslabones 2 y 3: Prometheus lee gateway:8080/metrics cada 15 s, según prometheus.yml.
  • La expr es PromQL sobre esas series: 5xx por segundo ÷ requests válidos por segundo (sin 4xx), en ventanas de 5 min. La segunda condición pide al menos 1 req/s: con 20 requests de madrugada, un solo error sería el 5 %.
  • for: 5m: la alerta pasa a pending en la primera evaluación en que la expr devuelve algo, y a firing cuando lleva 5 min así. Ojo: la ventana de 5 min estira cada pico. Un pico de 2 min con 10 % de errores deja la fracción sobre 1 % durante ≈ 6 min, así que dispara igual; uno de 2 min con 2 % nunca pasa el 1 %.
  • severity: page es solo un label: lo que lo convierte en «despertar a alguien» es la ruta de Alertmanager (sección 8). {{ $value | humanizePercentage }} escribe el valor de la expr en el texto del aviso, y runbook_url dice qué hacer.
  • En el compose de C01 la cadena se corta antes: prometheus.yml no tiene bloque alerting: ni hay Alertmanager, así que la alerta llega a firing en /alerts y ahí se queda. En EKS, kube-prometheus-stack conecta Prometheus con su Alertmanager; las rutas y los receivers los escribe tu equipo.

Cómo recordarla

El código cuenta, Prometheus pregunta y guarda, PromQL calcula, la regla espera (for) y Alertmanager decide a quién avisar; Grafana solo mira. Si un aviso no llegó, recorre la cadena en orden: ¿sube el número en /metrics? ¿up = 1? ¿la expr devuelve algo? ¿pending o firing? ¿llegó a Alertmanager?

Trampa

Esperar el aviso en el instante del primer error. Se suman la ventana del rate, el scrape, la evaluación, el for y el group_wait: con 10 % de errores, ≈ 6.5 min para la alerta de 5xx de C01. Y creer que firing en /alerts significa que alguien se enteró: sin bloque alerting: y sin una ruta en Alertmanager, la alerta dispara y nadie recibe nada. El compose de C01 está así.

Laboratorio (opcional)6 pasos

Qué vas a provocar: Recorrer la cadena entera en tu Mac: un counter en un servicio de juguete, Prometheus que lo lee, una regla que pasa a pending y a firing, Alertmanager y un webhook que hace de tu teléfono.

Necesitas: Docker Desktop con internet para bajar prom/prometheus:v3.13.3 y prom/alertmanager:v0.34.1, y Python 3 de macOS (solo stdlib). Usa los puertos 8000, 5001, 9090 y 9093.

  1. Paso 1
    mkdir -p ~/lab-prom/config ~/lab-prom/am && cd ~/lab-prom
    cat > app.py <<'EOF'
    # app.py: un servicio de juguete con un counter por status (solo stdlib)
    import os, threading, time
    from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
    
    cuenta = {"200": 0, "500": 0}
    
    def trafico():  # 10 requests por segundo; si existe el archivo "fallar", 2 de cada 10 son 500
        i = 0
        while True:
            i += 1
            status = "500" if os.path.exists("fallar") and i % 5 == 0 else "200"
            cuenta[status] += 1  # la línea de código donde empieza la cadena
            time.sleep(0.1)
    
    class Metrics(BaseHTTPRequestHandler):
        def do_GET(self):
            lineas = ["# TYPE demo_requests_total counter"]
            lineas += [f'demo_requests_total{{status="{s}"}} {n}' for s, n in cuenta.items()]
            cuerpo = ("\n".join(lineas) + "\n").encode()
            self.send_response(200)
            self.send_header("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
            self.end_headers()
            self.wfile.write(cuerpo)
    
        def log_message(self, *args):
            pass
    
    threading.Thread(target=trafico, daemon=True).start()
    ThreadingHTTPServer(("", 8000), Metrics).serve_forever()
    EOF
    cat > webhook.py <<'EOF'
    # webhook.py: hace de tu teléfono: imprime cada notificación que manda Alertmanager (solo stdlib)
    import json
    from http.server import BaseHTTPRequestHandler, HTTPServer
    
    class Aviso(BaseHTTPRequestHandler):
        def do_POST(self):
            aviso = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
            for a in aviso["alerts"]:
                print(f'{a["status"].upper()} {a["labels"]["alertname"]} severity={a["labels"].get("severity")}: '
                      f'{a["annotations"].get("summary")} (desde {a["startsAt"][11:19]} UTC)', flush=True)
            self.send_response(200)
            self.end_headers()
    
        def log_message(self, *args):
            pass
    
    HTTPServer(("", 5001), Aviso).serve_forever()
    EOF
    python3 app.py & echo $! > app.pid
    python3 webhook.py & echo $! > webhook.pid
    sleep 1; curl -s localhost:8000/metrics

    Qué deberías ver: # TYPE demo_requests_total counter y dos series: status="200" subiendo (10 por segundo) y status="500" en 0. webhook.py no imprime nada todavía: espera avisos en el puerto 5001. Si macOS pregunta si Python acepta conexiones entrantes, acepta: los contenedores llegan a tu Mac por la red.

  2. Paso 2
    cat > config/prometheus.yml <<'EOF'
    global:
      scrape_interval: 5s
      evaluation_interval: 5s
    rule_files:
      - alerts.yml
    alerting:
      alertmanagers:
        - static_configs:
            - targets: ["am-lab:9093"]
    scrape_configs:
      - job_name: servicio
        static_configs:
          - targets: ["host.docker.internal:8000"]
    EOF
    cat > config/alerts.yml <<'EOF'
    groups:
      - name: lab
        rules:
          - alert: DemoHigh5xxRate
            expr: |
              sum(rate(demo_requests_total{status=~"5.."}[1m]))
                / sum(rate(demo_requests_total[1m])) > 0.05
            for: 1m
            labels:
              severity: page
            annotations:
              summary: "Más de 5 % de 5xx en el servicio de juguete"
    EOF
    cat > am/alertmanager.yml <<'EOF'
    route:
      receiver: webhook
      group_by: [alertname]
      group_wait: 10s
      group_interval: 1m
      repeat_interval: 1h
    receivers:
      - name: webhook
        webhook_configs:
          - url: http://host.docker.internal:5001/
    EOF
    docker run --rm -v "$HOME/lab-prom/config:/cfg:ro" --entrypoint promtool prom/prometheus:v3.13.3 check config /cfg/prometheus.yml
    docker run --rm -v "$HOME/lab-prom/am:/cfg:ro" --entrypoint amtool prom/alertmanager:v0.34.1 check-config /cfg/alertmanager.yml

    Qué deberías ver: promtool: Checking /cfg/prometheus.yml, SUCCESS: 1 rule files found y SUCCESS: /cfg/prometheus.yml is valid prometheus config file syntax; después Checking /cfg/alerts.yml con SUCCESS: 1 rules found. amtool: Checking '/cfg/alertmanager.yml' SUCCESS y la lista de lo que encontró (route, 1 receivers). Los intervalos son de 5 s para no esperar tanto; C01 usa 15 s.

  3. Paso 3
    docker network create lab-prom
    docker run -d --name am-lab --network lab-prom -p 9093:9093 -v "$HOME/lab-prom/am:/etc/alertmanager:ro" prom/alertmanager:v0.34.1
    docker run -d --name prom-lab --network lab-prom -p 9090:9090 -v "$HOME/lab-prom/config:/etc/prometheus:ro" prom/prometheus:v3.13.3
    sleep 15; curl -s -G localhost:9090/api/v1/query --data-urlencode 'query=up' | python3 -c "import json, sys; [print(r['metric']['job'], r['value'][1]) for r in json.load(sys.stdin)['data']['result']]"

    Qué deberías ver: El id de la red y dos ids de contenedor; después, servicio 1: Prometheus (en Docker) ya lee app.py (en tu Mac) por host.docker.internal. En la red lab-prom, Prometheus encuentra a Alertmanager por su nombre, am-lab.

  4. Paso 4
    touch fallar
    for i in $(seq 8); do sleep 15; curl -s localhost:9090/api/v1/alerts | python3 -c "import json, sys; print([(a['labels']['alertname'], a['state']) for a in json.load(sys.stdin)['data']['alerts']] or 'sin alertas')"; done

    Qué deberías ver: Con el archivo fallar, 2 de cada 10 requests son 500. Una línea cada 15 s: sin alertas al principio, [('DemoHigh5xxRate', 'pending')] cuando la fracción pasa del 5 % (a los ≈ 15–25 s) y [('DemoHigh5xxRate', 'firing')] un minuto después (for: 1m). Unos 10 s más tarde (group_wait) aparece en la misma terminal la línea de webhook.py: FIRING DemoHigh5xxRate severity=page: Más de 5 % de 5xx en el servicio de juguete, con la hora de inicio en UTC.

  5. Paso 5
    rm fallar
    sleep 150

    Qué deberías ver: Sin errores nuevos, la ventana de 1 min se vacía: la fracción baja del 5 % a los ≈ 45 s y la alerta vuelve a inactive (sale de /alerts sin pasar por pending). En uno o dos minutos, al siguiente group_interval, webhook.py imprime RESOLVED DemoHigh5xxRate …: el webhook recibe el resuelto porque send_resolved es true por defecto.

  6. Paso 6
    kill $(cat app.pid) $(cat webhook.pid)
    docker rm -f -v prom-lab am-lab
    docker network rm lab-prom
    cd ~ && rm -rf ~/lab-prom

    Qué deberías ver: prom-lab, am-lab y lab-prom: no queda nada.

Cómo arreglarlo y confirmarlo

Si up da 0: ¿corre app.py (curl localhost:8000/metrics)? ¿aceptaste el permiso de red de macOS? Si nunca llega FIRING al webhook, recorre la cadena: Status → Alertmanager discovery en localhost:9090 (¿aparece am-lab?), la web de Alertmanager en localhost:9093 (¿llegó la alerta?) y docker logs am-lab (¿errores al llamar al webhook?).

Verificado contra la documentación; no ejecutado aquí.

1

Qué es el monitoreo y qué hacen Prometheus y Grafana

¿Para qué se monitorea un sistema, qué diferencia hay entre métricas, logs y trazas, y quién hace qué entre Prometheus, Alertmanager y Grafana?

Sintaxis con variables

curl -s servicio:puerto/metrics

Lo que expone un servicio para Prometheus: un endpoint (una URL) con sus números en texto plano. El servicio no le manda nada a nadie: espera a que lo lean.

docker run -d --name prom-lab -p 9090:9090 prom/prometheus:version

Prometheus en tu Mac en un comando. Sin config propia usa la de ejemplo que trae la imagen: se lee a sí mismo cada 15 s. Abre http://localhost:9090 y consulta up.

rate(prometheus_http_requests_total[1m])

Una primera consulta de PromQL (el lenguaje de consultas de Prometheus) sobre las métricas del propio Prometheus: cuántos requests por segundo recibe su servidor web (la UI, la API y su propio /metrics), por ruta (handler) y código (code).

http://localhost:9090 · http://localhost:9093 · http://localhost:3000

Las tres webs: Prometheus (consultas, alertas, targets), Alertmanager (avisos agrupados y silencios) y Grafana (dashboards). Grafana no guarda métricas: le pregunta a Prometheus con PromQL.

Variables

VariableQué esEjemplo
serviciodónde está el targetgateway en el compose de C01
puertopuerto donde sirve /metrics8080
versiontag fijo de la imagenv3.13.3, la de C01
λ\lambdarequests por día del servicio10 M en C01
BlıˊneaB_{\text{línea}}bytes de una línea de log≈ 300 B en C01
NseriesN_{\text{series}}series que guarda Prometheus del gateway≈ 615 con 3 pods (sección 3)
Δscrape\Delta_{\text{scrape}}intervalo de scrape15 s → 5,760 rondas por día

Fórmulas

logs por dıˊa  =  λ×Blıˊnea  =  10 M×300 B  ≈  3 GB\text{logs por día} \;=\; \lambda \times B_{\text{línea}} \;=\; 10\ \text{M} \times 300\ \text{B} \;\approx\; 3\ \text{GB}
muestras por dıˊa  =  Nseries×86,400 sΔscrape  =  615×5,760  ≈  3.5 M(× 1–2 B  ≈  3.5–7 MB)\text{muestras por día} \;=\; N_{\text{series}} \times \frac{86{,}400\ \text{s}}{\Delta_{\text{scrape}}} \;=\; 615 \times 5{,}760 \;\approx\; 3.5\ \text{M} \qquad (\times\, 1\text{–}2\ \text{B} \;\approx\; 3.5\text{–}7\ \text{MB})

Las piezas

PiezaQué haceEjemplo
MonitoreoSaber, sin estar mirando, si el sistema funciona, y que te avise cuando no.page si el p99 pasa de 200 ms durante 10 min
MétricaUn número medido en el tiempo y agregado: cuántos, qué tan rápido, cuántos errores. Barata y fácil de alertar.classifier_requests_total
LogUn evento con detalle, normalmente una línea por request. Responde qué le pasó a ese request.la línea JSON del gateway, ≈ 300 B
Traza (trace)El viaje de un request por varios servicios, partido en tramos con inicio y fin (spans).gateway → Triton, muestreada ≈ 1 % (propuesta del curso)
PrometheusEl servidor que lee métricas (scrape), las guarda como series de tiempo y evalúa reglas. Un binario autónomo, con su disco local.prom/prometheus:v3.13.3 en el compose de C01
Pull (scrape)Prometheus pregunta a cada target cada cierto tiempo; el target solo contesta.GET /metrics cada 15 s
Target · job · instanceTarget: una URL que leer. Job: el grupo de targets con el mismo propósito. Instance: cuál de ellos.job="gateway", instance="10.0.3.17:8080" (ilustrativo)
Serie de tiempo (time series)La historia de un solo número: un nombre más un juego de labels, con una muestra por scrape.classifier_requests_total{status="503", source="web", …}
TSDBLa base de datos de series de Prometheus (time series database), en el disco local: 15 días por defecto, sin réplica./prometheus en la imagen de Docker
PromQLEl lenguaje para consultar series: tasas, sumas, cuantiles. Lo usan los dashboards, las reglas y KEDA.sum(rate(classifier_requests_total[5m]))
Reglas (rules)Consultas que Prometheus evalúa solo, cada cierto tiempo: las de grabación guardan un resultado; las de alerta disparan.1 de grabación y 6 alertas en alerts.yml
AlertmanagerRecibe las alertas, las agrupa, silencia e inhibe, y decide a quién avisar y cómo (page o ticket).lo trae kube-prometheus-stack
GrafanaDibuja dashboards consultando a Prometheus con PromQL. No guarda métricas.servicio, Triton y GPU, modelo y negocio (el curso)
Exporter · PushgatewayExporter: traduce las métricas de algo que no habla Prometheus. Pushgateway: un buzón donde un job batch (un trabajo que corre y termina) deja sus números para que Prometheus los lea.node_exporter · el PSI del job diario de drift (M12)

Pull: Prometheus hace la ronda

Prometheus :9090 pasa por cada target cada 15 s, pide /metrics y espera hasta 10 s. En cada visita anota up:

Prometheus → GET /metrics → targettarget → 200 con el texto

Prometheus abre la conexión y pregunta; el target solo contesta. Si no contesta en 10 s, Prometheus anota up = 0: el silencio también es un dato.

Push: (Pushgateway u OTLP)

Quién abre la conexión: cada proceso manda sus números a un receptor (OTLP es el protocolo de OpenTelemetry para eso)

Cuándo conviene: jobs batch que terminan antes del próximo scrape

El problema: un servicio muerto solo deja de mandar: nadie distingue eso de «no hubo tráfico»

up = 1: contestóup = 0: no contestó, y queda anotadocon push: mandócon push: nada (¿murió o no hubo tráfico?)

En EKS, el label job de cada target del Operator es el nombre de su Service: gateway y triton en C01, igual que en el compose local.

Pull: Prometheus visita cada target cada 15 s y anota lo que encuentra, también el silencio (up = 0). Push: cada servicio manda sus números, y uno muerto simplemente deja de mandar. Pods y puertos de C01 en EKS; el gateway caído es ilustrativo.

Ejemplo real · Quién le pregunta a quién: el ServiceMonitor del gateway de C01

YAMLreference/c01/k8s/monitoring.yaml (líneas 1–20: el encabezado y el ServiceMonitor del gateway)
# Scrape con el Prometheus Operator (kube-prometheus-stack) (caso c01). Sin esto, Prometheus nunca
# lee el gateway ni Triton: la consulta de KEDA vuelve vacía (k8s/keda-triton.yaml la marca como
# error), Triton se queda en 2 réplicas y ninguna alerta evalúa. Falla en silencio.
# Las reglas de monitoring/alerts.yml van aparte, como PrometheusRule (el Operator no lee archivos
# de reglas): el comando está en el README.
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
  name: gateway
  namespace: classifier
  labels:
    release: kube-prometheus-stack  # = serviceMonitorSelector de tu Prometheus (nombre del release de Helm)
spec:
  selector:
    matchLabels:
      app.kubernetes.io/name: gateway  # labels del Service de k8s/gateway.yaml
  endpoints:
    - port: http  # nombre del puerto del Service: 8080/metrics del gateway (no expuesto en el Ingress)
      path: /metrics
      interval: 15s  # igual que en local: suficiente para alertas de 5–30 min
  • Pull en una frase: el gateway no sabe que Prometheus existe. Solo atiende GET /metrics en su puerto 8080; quien decide qué leer y cada cuánto es Prometheus. Este objeto es la forma de decírselo en Kubernetes.
  • «Falla en silencio»: si nadie le dice a Prometheus que lea un servicio, no hay error en ningún lado. No hay series ni up, y una alerta sobre una serie que no existe no dispara: su expresión no devuelve nada.
  • Un ServiceMonitor es un objeto del Prometheus Operator (un programa que corre en el cluster, vigila estos objetos y con ellos arma la config de Prometheus): busca los Services con esas labels y lee el puerto llamado http de cada pod detrás. El label release: kube-prometheus-stack hace que tu Prometheus lo tome en cuenta. La sección 11 lo cuenta entero.
  • El pull también se ve en la red: la NetworkPolicy de Triton (la regla de red de Kubernetes que dice quién puede conectarse; k8s/triton.yaml) solo deja entrar al puerto 8002 desde el namespace monitoring, porque es Prometheus el que abre la conexión. Con push haría falta lo contrario: dejar salir a Triton hacia el receptor.
  • Lo que Prometheus no hace: guardar eventos uno por uno (para eso están los logs) ni dar números exactos al 100 %, como para cobrar por request; su documentación lo dice. Es un sistema de números agregados, pensado para ser lo que consultas durante una caída: cada servidor es independiente y no depende de otra base ni de otro servicio para guardar.

Cómo recordarla

Prometheus es una ronda de enfermería cada 15 s: pasa por cada cuarto (target) y anota los signos vitales; si en un cuarto nadie contesta, anota eso también (up = 0). Alertmanager llama a quien está de guardia; Grafana es el tablero de la pared. Métricas para saber que algo anda mal, logs para saber qué le pasó a un request, trazas para saber dónde se fue el tiempo.

Trampa

Pensar que tu servicio «manda» sus métricas a Prometheus. Con pull, si nadie le dice a Prometheus que lo lea (falta el ServiceMonitor, el puerto se llama distinto, el label release no coincide), no hay error en ningún lado: no hay series, ninguna alerta evalúa y KEDA no escala (en C01, Triton se queda en 2 réplicas). Lo primero que miras es si existe up para ese job.

Laboratorio (opcional)6 pasos

Qué vas a provocar: Ver el pull con tus propios ojos: un servicio que solo contesta GETs, y una «ronda» que pregunta cada 2 s, anota up y nota cuando el servicio muere.

Necesitas: Python 3 y curl, que ya vienen en macOS (solo stdlib). Ni Docker ni internet.

  1. Paso 1
    mkdir -p ~/lab-prom && cd ~/lab-prom
    cat > objetivo.py <<'EOF'
    # objetivo.py: un "servicio" que expone /metrics en el formato de texto de Prometheus (solo stdlib)
    import random, threading, time
    from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
    
    atendidos = 0  # contador: solo sube
    en_vuelo = 0   # gauge: sube y baja
    
    def trabajar():  # simula tráfico: cada 0.1 s llega un request que dura un rato
        global atendidos, en_vuelo
        while True:
            en_vuelo = random.randint(0, 4)
            atendidos += 1
            time.sleep(0.1)
    
    class Metrics(BaseHTTPRequestHandler):
        def do_GET(self):
            if self.path != "/metrics":
                self.send_error(404)
                return
            cuerpo = (
                "# HELP demo_requests_total Requests atendidos desde que arrancó el proceso.\n"
                "# TYPE demo_requests_total counter\n"
                f"demo_requests_total {atendidos}\n"
                "# HELP demo_inflight_requests Requests en curso ahora mismo.\n"
                "# TYPE demo_inflight_requests gauge\n"
                f"demo_inflight_requests {en_vuelo}\n"
            ).encode()
            self.send_response(200)
            self.send_header("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
            self.send_header("Content-Length", str(len(cuerpo)))
            self.end_headers()
            self.wfile.write(cuerpo)
    
        def log_message(self, *args):  # sin una línea por request en la terminal
            pass
    
    threading.Thread(target=trabajar, daemon=True).start()
    print("sirviendo /metrics en el puerto 8000", flush=True)
    ThreadingHTTPServer(("", 8000), Metrics).serve_forever()
    EOF

    Qué deberías ver: Sin salida: el archivo queda escrito.

  2. Paso 2
    python3 objetivo.py & echo $! > objetivo.pid
    sleep 1; curl -s -i localhost:8000/metrics

    Qué deberías ver: sirviendo /metrics en el puerto 8000, y la respuesta: HTTP/1.0 200 OK, Content-Type: text/plain; version=0.0.4; charset=utf-8 y seis líneas: un # HELP y un # TYPE por métrica, demo_requests_total (≈ 10 a esa altura) y demo_inflight_requests (entre 0 y 4).

  3. Paso 3
    cat > ronda.py <<'EOF'
    # ronda.py: lo que hace Prometheus en cada scrape, en miniatura (solo stdlib)
    import sys, time, urllib.request
    
    URL = "http://localhost:8000/metrics"
    INTERVALO, TIMEOUT = 2.0, 1.0  # scrape_interval y scrape_timeout de esta ronda
    vueltas = int(sys.argv[1]) if len(sys.argv) > 1 else 4
    antes = None
    for _ in range(vueltas):
        t = time.time()
        try:  # PULL: el que pregunta es el scraper; el servicio solo contesta
            texto = urllib.request.urlopen(URL, timeout=TIMEOUT).read().decode()
            muestras = {}
            for linea in texto.splitlines():
                if linea and not linea.startswith("#"):  # las líneas # HELP y # TYPE son metadatos
                    nombre, valor = linea.rsplit(" ", 1)
                    muestras[nombre] = float(valor)
            up = 1
        except OSError as e:  # conexión rechazada, timeout, DNS: el scrape falló
            muestras, up = {}, 0
            print(f"  scrape falló: {e.__class__.__name__}")
        linea = f"up=1  demo_requests_total={muestras.get('demo_requests_total', 0):.0f}  demo_inflight_requests={muestras.get('demo_inflight_requests', 0):.0f}" if up else "up=0"
        if up and antes:
            linea += f"  rate≈{(muestras['demo_requests_total'] - antes[1]) / (t - antes[0]):.1f}/s"
        print(time.strftime("%H:%M:%S"), linea, flush=True)
        antes = (t, muestras["demo_requests_total"]) if up else None
        time.sleep(max(0.0, INTERVALO - (time.time() - t)))
    EOF
    python3 ronda.py 4

    Qué deberías ver: Cuatro líneas, una cada 2 s: up=1, el contador subiendo ≈ 19 por ronda y, desde la segunda, rate≈9.5–10/s (10 por segundo, menos lo que tarda cada vuelta). El servicio nunca supo de la ronda: solo contestó GETs.

  4. Paso 4
    kill $(cat objetivo.pid)
    python3 ronda.py 2

    Qué deberías ver: Dos veces scrape falló: URLError y una línea up=0. El servicio murió y la ronda lo anota. Con push, un servicio muerto solo deja de mandar, y nadie distingue eso de que no haya habido tráfico.

  5. Paso 5
    python3 objetivo.py & echo $! > objetivo.pid
    sleep 1; python3 ronda.py 2

    Qué deberías ver: up=1 otra vez, con demo_requests_total en ≈ 10–30: el proceso nuevo empezó de 0. Ese salto hacia abajo es un reinicio del counter; rate() lo corrige (sección 2).

  6. Paso 6
    kill $(cat objetivo.pid); cd ~ && rm -rf ~/lab-prom

    Qué deberías ver: Sin salida (zsh avisa que el proceso terminó).

Cómo arreglarlo y confirmarlo

Si el puerto 8000 está ocupado (Address already in use), cambia 8000 por otro número en los dos archivos. Los valores cambian en cada corrida; la forma (up=1 con el counter subiendo, up=0 al matar el proceso, un counter que vuelve a empezar) no.

Probado de verdad en un Mac. Pasos corridos tal cual en zsh (con un HOME de prueba) en macOS, con Python 3.14.3 y con 3.9.6 (el python3 de Apple): mismas formas de salida (rate≈9.0–10.0/s; up=0 con URLError al matar el proceso; el counter vuelve a ≈ 10 al reiniciarlo).

2

El modelo de datos: nombre, labels, series y muestras; counter, gauge, histogram y summary; nombres y cardinalidad

¿Qué guarda Prometheus exactamente, qué tipo de métrica uso para cada cosa y cómo evito que la cantidad de series explote?

Sintaxis con variables

metrica{label="valor", label2="valor2"} numero

Una línea del formato de texto = una muestra (sample: un número en un instante) de una serie (time series: la historia de un número). La serie es el nombre más el juego exacto de labels (etiquetas clave=valor). Cambia, agrega o quita un label y es otra serie.

# TYPE metrica counter|gauge|histogram|summary

El tipo viaja como comentario: lo usan la librería que mide y quien lee el texto. El servidor guarda todas las series igual, como números con hora (salvo los native histograms): eres tú quien elige rate() para un counter o la lectura directa para un gauge.

rate(contador_total[ventana])                           # counter: cuánto sube por segundo
gauge                                                    # gauge: lo que marca ahora
max_over_time(gauge[ventana])                          # gauge: el pico de la ventana
rate(hist_sum[ventana]) / rate(hist_count[ventana])  # histogram: el promedio

Cada tipo se lee distinto. El promedio de un histograma sale de _sum ÷ _count; los percentiles, de los _bucket (sección 6).

prefijo_que_mide_unidad_total    # counter
prefijo_que_mide_unidad          # gauge o histogram

Nombres: un prefijo del dominio, lo que mide, la unidad base en plural (seconds, bytes; las proporciones de 0 a 1, ratio) y _total si es un counter. Nunca milisegundos en una métrica y segundos en otra.

topk(10, count by (__name__) ({__name__=~"prefijo_.*"}))

¿Qué métrica tiene más series? Cuenta series por nombre. En la web, Status → TSDB status (el estado de la base de series) muestra el ranking de todo Prometheus. Usa siempre un filtro: sin él recorre todas las series.

Variables

VariableQué esEjemplo
metricanombre de la métricaclassifier_requests_total
label, valor · label2, valor2dimensiones y sus valores: la serie lleva todos sus labelsstatus="503", source="web"
numeroel valor: siempre un float de 64 bits17.0
contador, gauge, histnombres base de cada tipoclassifier_requests · nv_gpu_utilization · classifier_request_latency_seconds
ventanacuánto mira hacia atrás5m
prefijo, que_mide, unidadlas partes del nombreclassifier · request_latency · seconds
NseriesN_{\text{series}}series que puede tener una métrica72 para classifier_requests_total por pod, como máximo
LiL_{i}el label i; |L_i| es cuántos valores distintos puede tomarstatus 9, source 4, model_version 1 por pod
b, vb,\ vbuckets con borde finito y combinaciones de labels; el + 4 son +Inf, _sum, _count y _created11 y 4 en la latencia de C01
xˉ\bar{x}promedio de lo observado en la ventanalatencia media por request

Fórmulas

Nseries  ≤  ∏i∣Li∣classifier_requests_total: 9×4×1=36 combinaciones (72 series con _created)N_{\text{series}} \;\le\; \prod_i |L_i| \qquad \texttt{classifier\_requests\_total}:\ 9 \times 4 \times 1 = 36\ \text{combinaciones (72 series con \_created)}
series de un histograma  =  (b+4)×v  =  (11+4)×4  =  60 por pod\text{series de un histograma} \;=\; (b + 4) \times v \;=\; (11 + 4) \times 4 \;=\; 60\ \text{por pod}
xˉ  =  rate(x_sum[5m])rate(x_count[5m])(promedio de lo observado en la ventana)\bar{x} \;=\; \frac{\texttt{rate(x\_sum[5m])}}{\texttt{rate(x\_count[5m])}} \qquad (\text{promedio de lo observado en la ventana})

Las piezas

PiezaQué haceEjemplo
Muestra (sample)Un número float64 (decimal de 64 bits) con su hora en milisegundos. Es lo que deja cada scrape en cada serie.17.0 a las 14:03:15.123
Serie (time series)La historia de un número: nombre + juego exacto de labels. Cambiar un label crea otra serie.classifier_requests_total{status="200", source="web", …}
Nombre de métricaQué se mide. Letras, dígitos, _ y :, sin empezar con un dígito (los : quedan para las reglas de grabación). Desde Prometheus 3.0 acepta UTF-8, pero el formato clásico evita sorpresas.classifier_request_latency_seconds
LabelUna dimensión clave=valor para partir la métrica. Los que empiezan con __ son internos; un valor vacío equivale a no tener el label.source="mobile"
CounterSolo sube; vuelve a 0 si el proceso reinicia. Nombre con _total. Se lee con rate() o increase().classifier_requests_total; nv_inference_count (Triton no le pone _total)
GaugeSube y baja: vale lo que marca ahora. Se lee directo o con *_over_time.nv_gpu_utilization, kube_deployment_status_replicas_available
HistogramCuenta observaciones en cajones acumulados (_bucket con le) y guarda _sum y _count. Se suma entre pods.classifier_request_latency_seconds
le (less or equal)El borde de cada cajón: le="0.2" cuenta todo lo que tardó 0.2 s o menos. El último, le="+Inf", es igual a _count.11 bordes de 0.01 a 2.5 s en C01
SummaryCalcula cuantiles en el proceso (quantile="0.99"). Esos cuantiles no se pueden sumar ni promediar entre pods. El de Python solo da _sum y _count.C01 no usa ninguno
Native histogramHistograma con buckets automáticos, guardado en una sola serie por combinación de labels. Estable desde 3.8, pero hay que activar scrape_native_histograms y que el cliente lo emita: el Histogram de Python emite solo clásicos.C01 usa histogramas clásicos
_createdLa hora en que nació la serie. prometheus_client la agrega a counters, histogramas y summaries como una serie más (se apaga con PROMETHEUS_DISABLE_CREATED_SERIES=True).classifier_requests_created
Unidades baseSegundos, bytes y proporciones de 0 a 1, con la unidad al final del nombre._seconds, _bytes; nv_inference_queue_duration_us (µs) es la excepción de Triton
Métrica _infoUn gauge que siempre vale 1 y lleva datos en sus labels: versión, build.python_info{implementation="CPython", …, version="3.13.16"} 1.0
CardinalidadCuántas series tiene una métrica: hasta el producto de los valores de cada label. Cada serie ocupa memoria e índice en Prometheus. La guía oficial: si puedes, menos de 10 series por métrica (y pocas métricas en todo el sistema que pasen de eso); nunca ids ni valores sin límite.request_id como label: una serie nueva por request

Counter · el odómetro: solo sube

classifier_requests_total (un pod)

ventana 2m01,0002,000112142172202segundosreinicio1,0421,060321

001060000003el proceso nuevo empieza de 0

  • rate(x[2m]) 0.371/scorrige el reinicio
  • increase(x[2m]) 44.6requests en la ventana
  • última − primera −9.72/snegativo: no ve el reinicio

Como cambiar de auto a mitad de viaje: el odómetro nuevo empieza en 0. rate() suma lo que marcaba el viejo antes de caer (1,060) y no da negativo.

Gauge · el medidor de gasolina: sube y baja

requests en vuelo (gauge)

ventana 2m02.55112142172202segundospico 6
  • avg_over_time(x[2m]) 3.62lo que marcó en promedio: la línea punteada
  • max_over_time(x[2m]) 6el pico, en el círculo

Como el medidor de gasolina: se lee lo que marca. rate() sobre esto da 0.158/s, un número sin sentido.

Histogram · cajones acumulados: «hasta»

classifier_request_latency_seconds (1,000 requests simulados, mediana ≈ 40 ms)

_sum 44.7 s_count 1,000= le="+Inf"

Cada cajón cuenta todo lo que cayó por debajo de su borde (le): por eso los números solo suben y +Inf = _count. El p99 cae entre 0.1 y 0.15 s (sección 6).

Summary: No sale en C01: trae el cuantil ya calculado en cada pod (quantile="0.99") y dos pods no se pueden mezclar.

countergaugehistogram: lo nuevo de cada cajónlo que ya traía del cajón anteriorla ventana de rate()

Los tres tipos que usa C01, a escala. Counter: un pod que reinicia entre t = 157 y 172 s; rate() en 2 min da 0.371/s. Gauge: requests en vuelo, que suben y bajan. Histogram: 1,000 latencias simuladas en los buckets de C01, acumulados. El counter y el gauge son los del lab de esta sección.

Ejemplo real · Las seis métricas del gateway de C01: nombres, tipos, labels y buckets

Pythonreference/c01/gateway/app/main.py (líneas 62–88)
# ---------------------------------------------------------------------------- métricas
# Nombres = contrato con monitoring/alerts.yml: si cambias uno aquí, rompes una alerta en silencio.
# Labels acotados: status (≈ 10 códigos) × source (4) × model_version (1–2 durante un canary)
REQUESTS = Counter(  # el cliente de Python agrega el sufijo _total; el nombre expuesto es este mismo
    "classifier_requests_total", "Requests a /v1/classify por código HTTP, caller y modelo",
    ["status", "source", "model_version"],  # status: "200", "503"… · source: mobile/web/internal/other
)
LATENCY = Histogram(  # latencia server-side; los buckets rodean al SLO (0.2 s) para que el p99 sea preciso
    "classifier_request_latency_seconds", "Latencia total de /v1/classify", ["source"],
    buckets=(0.01, 0.025, 0.05, 0.075, 0.1, 0.15, 0.2, 0.3, 0.5, 1.0, 2.5),
)
PREPROCESS = Histogram(  # separar preprocess e inferencia dice a qué capa escalar cuando sube la latencia
    "classifier_preprocess_seconds", "Decode + resize + normalize (CPU)",
    buckets=(0.002, 0.005, 0.01, 0.02, 0.05, 0.1, 0.25, 0.5),
)
INFERENCE = Histogram(  # ida y vuelta a Triton: red + cola de dynamic batching + cómputo en GPU
    "classifier_inference_seconds", "Llamada a Triton (round trip)",
    buckets=(0.005, 0.01, 0.02, 0.035, 0.05, 0.075, 0.1, 0.15, 0.3),
)
TOP1 = Histogram(  # distribución de confianza: la señal de drift más barata que existe (sin labels)
    "classifier_top1_score", "Score top-1 tras softmax", ["model_version"],  # separa canary de estable
    buckets=tuple(round(0.1 * i, 1) for i in range(1, 11)),  # 0.1 … 1.0 → el bucket le="0.3" alimenta una alerta
)
IMAGE_BYTES = Histogram(  # tamaño de entrada: explica cambios de latencia (p. ej. la web empieza a subir 12 MP)
    "classifier_image_bytes", "Bytes de la imagen recibida",
    buckets=(1e4, 5e4, 1e5, 2.5e5, 5e5, 1e6, 2.5e6, 5e6, 1e7),
)
  • Un counter y cinco histogramas, ningún gauge. Los nombres siguen la guía: prefijo classifier_, unidad base al final (_seconds, _bytes) y _total en el counter. classifier_top1_score no lleva unidad: es una proporción de 0 a 1.
  • «Contrato con alerts.yml» (línea 63): las alertas buscan estos nombres exactos. Si renombras uno, la alerta no falla: su expresión deja de encontrar series y nunca dispara.
  • La cardinalidad está en el comentario de la línea 64: status × source × model_version. Para la latencia, (11 + 4) × 4 = 60 series por pod; con el counter y los otros cuatro histogramas, hasta 184 series classifier_* por pod (sección 3).
  • El sufijo: prometheus_client quita _total del nombre que le das y lo vuelve a poner al exponer, así que la serie de nacimiento se llama classifier_requests_created, sin _total (verificado con prometheus-client 0.26.0, la que fija C01).
  • TOP1 lleva model_version para comparar el canary (la versión nueva, que recibe una parte del tráfico) con el estable. Sus bordes salen de round(0.1 * i, 1): 0.1 … 1.0 exactos, y le="0.3" es el cajón que lee la regla de grabación de baja confianza.

Cómo recordarla

Un counter es el odómetro del auto: solo sube, y rate() lo convierte en velocidad. Un gauge es el medidor de gasolina: lo lees tal cual. Un histogram son cajones acumulados (le = «hasta»), y un summary es un p99 ya cocinado en cada pod, que no se mezcla con el de otro.

Trampa

Pasar un gauge por rate() o mostrar un counter crudo. rate() toma cada bajada de un gauge por un reinicio y suma basura (en el lab, 0.158 «requests en vuelo por segundo»); un counter crudo en un panel solo sube y cae a 0 en cada deploy. Y un label con valores sin límite (request_id, user_id): cada valor nuevo es una serie nueva que Prometheus indexa y guarda.

Laboratorio (opcional)5 pasos

Qué vas a provocar: Calcular rate() e increase() a mano con el algoritmo de Prometheus y ver tres cosas: cómo corrige un reinicio, por qué la ventana necesita varias muestras y por qué un gauge no se pasa por rate().

Necesitas: Python 3 de macOS (solo stdlib). Ni Docker ni internet.

  1. Paso 1
    mkdir -p ~/lab-prom && cd ~/lab-prom
    cat > rate.py <<'EOF'
    # rate.py: rate() e increase() como los calcula Prometheus, a partir de muestras (solo stdlib)
    
    def en_ventana(serie, t, w):
        return [(ts, v) for ts, v in serie if t - w < ts <= t]  # (t - w, t]: abierta a la izquierda
    
    def rate(serie, t, w, contador=True):
        m = en_ventana(serie, t, w)
        if len(m) < 2:
            return None  # con menos de 2 muestras no hay resultado: el panel dice "No data"
        (t0, v0), (t1, v1) = m[0], m[-1]
        delta = v1 - v0
        if contador:  # si el valor bajó, el proceso reinició: suma lo que valía antes de caer
            delta += sum(antes for (_, antes), (_, ahora) in zip(m, m[1:]) if ahora < antes)
        medido = t1 - t0
        paso = medido / (len(m) - 1)
        ini, fin = t0 - (t - w), t - t1  # lo que falta medir antes de la 1.a muestra y después de la última
        if ini >= 1.1 * paso:
            ini = paso / 2  # la serie empezó dentro de la ventana: extrapola solo medio paso
        if contador and delta > 0 and v0 >= 0:
            ini = min(ini, medido * v0 / delta)  # nunca extrapola un contador por debajo de 0
        if fin >= 1.1 * paso:
            fin = paso / 2
        return delta * (medido + ini + fin) / medido / w  # por segundo; increase = rate * w
    
    # Un contador leído cada 15 s (t = 7, 22 … 217): sube 6 por scrape (0.4 req/s). El pod reinicia
    # entre t = 157 y t = 172: vuelve a 0 y para t = 172 ya atendió 3 requests.
    x, valor = [], 1000
    for i in range(15):
        t = 7 + 15 * i
        valor = 3 if t == 172 else valor + (6 if i else 0)
        x.append((t, valor))
    print("muestras (t, valor):", x[-8:])
    
    T = 217
    for w in (15, 30, 60, 120):
        r = rate(x, T, w)
        print(f"rate(x[{w}s]):", "sin datos (menos de 2 muestras)" if r is None else f"{r:.3f}/s")
    m = en_ventana(x, T, 120)
    print(f"(último - primero) / tiempo en 2m: {(m[-1][1] - m[0][1]) / (m[-1][0] - m[0][0]):.2f}/s  <- el reinicio da negativo")
    print(f"increase(x[2m]): {rate(x, T, 120) * 120:.1f} requests")
    
    sin_202 = [p for p in x if p[0] != 202]  # se perdió un scrape (timeout, deploy)
    for w in (30, 60):
        r = rate(sin_202, T, w)
        print(f"sin el scrape de t=202, rate(x[{w}s]):", "sin datos" if r is None else f"{r:.3f}/s")
    
    # Un gauge (requests en vuelo) que sube y baja: rate() toma cada bajada por un reinicio
    g = [(7 + 15 * i, v) for i, v in enumerate([3, 5, 2, 6, 4, 3, 5, 2, 4, 3, 6, 2, 3, 5, 4])]
    vals = [v for _, v in en_ventana(g, T, 120)]
    print(f"rate(gauge[2m]): {rate(g, T, 120):.3f}/s  <- no significa nada")
    print(f"avg_over_time(gauge[2m]): {sum(vals) / len(vals):.2f}   max_over_time(gauge[2m]): {max(vals)}")
    EOF

    Qué deberías ver: Sin salida: el archivo queda escrito.

  2. Paso 2
    python3 rate.py | head -n 5

    Qué deberías ver: Las últimas 8 muestras, con el reinicio entre (157, 1060) y (172, 3). rate(x[15s]): sin datos (una sola muestra en la ventana); [30s]: 0.400/s; [60s]: 0.350/s (la ventana empieza justo después del reinicio: Prometheus extrapola hasta el 0 del counter y da 21 / 60 s, lo que atendió el proceso nuevo); [120s]: 0.371/s.

  3. Paso 3
    python3 rate.py | sed -n '6,9p'

    Qué deberías ver: Restar la última menos la primera da -9.72/s: negativo, por el reinicio. increase(x[2m]) da 44.6 requests. Si se pierde el scrape de t = 202, [30s] queda sin datos y [60s] sigue dando 0.350/s. Por eso la regla práctica pide al menos 4 scrapes por ventana: a Prometheus le bastan 2 muestras, pero así aguanta scrapes perdidos.

  4. Paso 4
    python3 rate.py | tail -n 2

    Qué deberías ver: rate(gauge[2m]): 0.158/s, un número sin sentido. avg_over_time da 3.62 y max_over_time da 6: así se lee un gauge.

  5. Paso 5
    cd ~ && rm -rf ~/lab-prom

    Qué deberías ver: Sin salida.

Cómo arreglarlo y confirmarlo

rate() necesita al menos 2 muestras en la ventana, y la ventana es abierta a la izquierda: con scrapes cada 15 s, [1m] tiene 4 y sigue dando resultado aunque se pierdan dos. Los números son exactos con cualquier Python 3: no hay azar.

Probado de verdad en un Mac. Pasos corridos tal cual en zsh (con un HOME de prueba) en macOS, con Python 3.9.6, 3.13 y 3.14.3: salidas idénticas, byte a byte. El cálculo sigue extrapolatedRate de promql/functions.go (rama release-3.13): corrección de reinicios, ventana (t − w, t], extrapolación hasta el borde si la muestra está a menos de 1.1 × el paso medio (si no, medio paso) y tope en el 0 del counter.

3

Instrumentar tu código y usar exporters: prometheus_client, el gateway de C01, Triton, node_exporter, kube-state-metrics y cAdvisor

¿Cómo hago que mi servicio exponga métricas, dónde van las llamadas en el código y qué métricas me dan otros sin escribir nada?

Sintaxis con variables

from prometheus_client import Counter, Gauge, Histogram
CONTADOR = Counter("prefijo_cosa_total", "ayuda", ["label"])
HISTO = Histogram("prefijo_cosa_seconds", "ayuda", buckets=(bordes))
GAUGE = Gauge("prefijo_cosa", "ayuda")

Se definen una vez, al importar el módulo, y quedan en el registro global (REGISTRY); definirlas dos veces da DuplicateTimeseries («Duplicated timeseries in CollectorRegistry», un ValueError). Sin buckets (los bordes de los cajones), el histograma usa los default (de 5 ms a 10 s), pensados para «requests web típicos», no para tu SLO (el objetivo de servicio: p99 ≤ 200 ms en C01).

t0 = time.perf_counter()
status = "500"
try:
    ...  # el trabajo
    status = "200"
finally:
    CONTADOR.labels(label=status).inc()
    HISTO.observe(time.perf_counter() - t0)

Las llamadas van en el finally: así cuentan también los errores. .labels() elige la serie, .inc() suma 1 y .observe() mete el valor en su bucket. Un gauge usa .set(), .inc()/.dec() o el decorador track_inprogress(), que sube al entrar y baja al salir.

@app.get("/metrics", include_in_schema=False)
def metrics() -> Response:
    return Response(generate_latest(), media_type=CONTENT_TYPE_LATEST)

start_http_server(puerto)  # en un script o un worker sin web

Exponer: en FastAPI, una ruta propia como la de C01 (montar make_asgi_app() en /metrics responde 307, una redirección, hacia /metrics/). En un script, start_http_server abre un servidor de /metrics en un hilo aparte.

PROMETHEUS_MULTIPROC_DIR=carpeta uvicorn app --workers n

Con varios procesos por pod (uvicorn con --workers N, o gunicorn) cada uno tiene su propio registro: hace falta el modo multiproceso (una carpeta compartida, vaciada en cada arranque, y un MultiProcessCollector en la ruta de /metrics). C01 corre 1 worker por pod y escala con réplicas.

curl -s localhost:puerto/metrics | docker run --rm -i --entrypoint promtool prom/prometheus:version check metrics

promtool, la herramienta de línea de comandos de Prometheus, revisa con check metrics el texto que le llega por stdin (por el |) y marca problemas de estilo, como counter metrics should have "_total" suffix o no help text. Viene dentro de la imagen de Prometheus: no instalas nada.

Variables

VariableQué esEjemplo
CONTADOR, HISTO, GAUGEvariables de Python con cada métricaREQUESTS y LATENCY en C01 (no tiene gauges)
prefijo, cosalas partes del nombreclassifier · request_latency
ayudatexto de # HELPLatencia total de /v1/classify
labeldimensión con valores de una lista cerradastatus
bordesbordes de los buckets, varios cerca del SLO0.01, 0.025 … 2.5 (11 en C01)
puertopuerto donde escucha /metrics8080 en el gateway; 8000 en el lab
carpetacarpeta del modo multiproceso, vacía al arrancar/tmp/prom-multiproc
n, appprocesos del servidor y la app que corre1 worker en C01 · app.main:app
versiontag de la imagen de Prometheusv3.13.3
NpodN_{\text{pod}}series que expone un pod del gateway en régimen200: 184 classifier_* + 16 del cliente, en Linux
npodsn_{\text{pods}}réplicas del gateway (las mueve el HPA, el autoscaler de Kubernetes)de 3 a 12
+ 5\text{+ 5}series que Prometheus agrega por targetup y las cuatro scrape_*

Fórmulas

Npod  =  2×36⏟requests+15×4⏟latencia+12+13+14+13⏟preproceso, inferencia, top-1, bytes+16⏟cliente  =  200N_{\text{pod}} \;=\; \underbrace{2 \times 36}_{\text{requests}} + \underbrace{15 \times 4}_{\text{latencia}} + \underbrace{12 + 13 + 14 + 13}_{\text{preproceso, inferencia, top-1, bytes}} + \underbrace{16}_{\text{cliente}} \;=\; 200
Nservicio  =  npods×(Npod+5)  =  3×205=615    a    12×205=2,460N_{\text{servicio}} \;=\; n_{\text{pods}} \times (N_{\text{pod}} + 5) \;=\; 3 \times 205 = 615 \;\;\text{a}\;\; 12 \times 205 = 2{,}460
request_id como label:15 seriesrequest×10 M requestsdıˊa  =  150 M series nuevas al dıˊa\texttt{request\_id}\ \text{como label:}\quad 15\ \tfrac{\text{series}}{\text{request}} \times 10\ \text{M}\ \tfrac{\text{requests}}{\text{día}} \;=\; 150\ \text{M series nuevas al día}

Las piezas

PiezaQué haceEjemplo
prometheus_clientLa librería oficial de Python: define métricas, las guarda en memoria y las escribe en el formato de texto.0.26.0 en C01, la última en octubre de 2026
REGISTRY · generate_latest()El registro global donde vive cada métrica; generate_latest() lo vuelca entero como texto.Response(generate_latest(), media_type=CONTENT_TYPE_LATEST)
.labels(…)Elige (o crea) la serie de esa combinación. Una serie con labels no existe hasta su primer uso: llámala sin .inc() al arrancar si quieres verla en 0.REQUESTS.labels(status="500", …)
Counter: .inc()Suma 1, o lo que le pases. count_exceptions() cuenta las excepciones de un bloque.REQUESTS…inc() en el finally de classify
Histogram: .observe() · .time()Mete una observación en su bucket y la suma a _sum y _count; .time() mide un bloque en segundos.LATENCY.labels(source=source).observe(elapsed)
Gauge: .set() · track_inprogress()Fija o mueve un valor que sube y baja; track_inprogress() cuenta lo que está en curso.C01 lleva state.inflight, pero no lo exporta
Colectores por defectoprocess_* (CPU, RAM, archivos abiertos; solo en Linux), python_gc_* y python_info, sin escribir nada.16 series en un pod de C01; 10 en tu Mac
start_http_server(puerto)Un servidor de /metrics en un hilo aparte, para scripts y workers sin web propia.start_http_server(8000) en el lab
Modo multiprocesoPara varios procesos por pod: PROMETHEUS_MULTIPROC_DIR y MultiProcessCollector. Sin él, cada scrape cae en un proceso distinto.C01 lo evita con --workers 1
ExporterUn programa aparte que lee algo que no habla Prometheus y lo traduce a /metrics.node_exporter en :9100 lee /proc y /sys de cada nodo
Métricas propias del softwareProgramas que ya exponen /metrics por su cuenta: no hace falta exporter.Triton en :8002 (nv_*); el propio Prometheus en :9090
kube-state-metricsLee la API de Kubernetes y publica el estado de los objetos: réplicas, fases, reinicios.kube_deployment_status_replicas_available → TritonNotReady
cAdvisor (dentro del kubelet)El uso real de CPU, memoria y throttling (los frenos cuando un contenedor agota su cuota de CPU) de cada contenedor, que el kubelet (el agente de Kubernetes en cada nodo) sirve en /metrics/cadvisor.container_memory_working_set_bytes
PushgatewayUn buzón para jobs batch que terminan antes del próximo scrape: empujan su resultado y Prometheus lee el buzón. No es para servicios.el PSI del job diario de drift (M12)

histogramclassifier_request_latency_seconds

se abre por el label source, que tiene 4 valores:

  • source="mobile"

    15 series

  • source="web"

    15 series

  • source="internal"

    15 series

  • source="other"

    15 series

4 × 15 = 60 series de una sola métrica, en un solo pod

_bucket{le="0.01"} … _bucket{le="2.5"} (11)_bucket{le="+Inf"}_sum_count_created

Series por métrica en un pod (a escala, máximo 72)

  1. classifier_requests_totalcounter

    status (9) × source (4) × model_version (1) · 2 por combinación

    72

  2. classifier_request_latency_secondshistogram

    source (4) · 15 por combinación

    60

  3. classifier_preprocess_secondshistogram

    sin labels · 12 por combinación

    12

  4. classifier_inference_secondshistogram

    sin labels · 13 por combinación

    13

  5. classifier_top1_scorehistogram

    model_version (1) · 14 por combinación

    14

  6. classifier_image_byteshistogram

    sin labels · 13 por combinación

    13

  7. process_*, python_gc_*, python_infodel cliente

    sin labels

    16

Un pod del gateway, un cuadrito por serie: 200 + 5 = 205

3 a 12 pods: de 615 a 2,460 series

Un label sin techo: request_id

2 series por request en un counter (_total y _created), 15 en un histograma: ≈ 1,600 nuevas en los primeros 10 s del lab

Medido: lab: 144 series classifier_* con 4 status × 4 source; 154 en total en macOS (sin las 6 process_*)

counterhistogramdel clientelas agrega Prometheus

Los 9 status son los que main.py puede anotar: 200, 400, 413, 415, 422, 499, 500, 503 y 504. Un label de lista cerrada multiplica; un id no tiene techo.

Cuántas series expone un pod del gateway de C01, contadas con las definiciones exactas de main.py y prometheus-client 0.26.0. Cada métrica se abre en una serie por combinación de labels; un histograma, en 15 por combinación (11 buckets + +Inf + _sum + _count + _created).

Ejemplo real · main.py: la ruta /metrics y las dos llamadas del finally

Pythonreference/c01/gateway/app/main.py (líneas 30, 119–127 y 240–246; # … marca líneas omitidas)
from prometheus_client import CONTENT_TYPE_LATEST, Counter, Histogram, generate_latest  # métricas Prometheus
# …
@app.get("/metrics", include_in_schema=False)
def metrics() -> Response:
    """Endpoint que scrapea Prometheus. Con 1 proceso por contenedor basta el registro por defecto.

    Verificado: app.mount("/metrics", make_asgi_app()) (lo que sugiere la doc) responde 307 →
    /metrics/ en Starlette; Prometheus sigue el redirect, pero cada scrape serían dos requests.
    Con gunicorn/uvicorn --workers N necesitarías el modo multiproceso de prometheus_client.
    """
    return Response(generate_latest(), media_type=CONTENT_TYPE_LATEST)  # formato de texto de Prometheus
# …
    finally:
        elapsed = time.perf_counter() - t0
        source = record["source"] if record["source"] in SOURCES else "other"  # cardinalidad acotada
        # Alimenta la alerta de 5xx y los paneles por caller y por modelo (canary vs estable)
        REQUESTS.labels(status=str(status), source=source, model_version=MODEL_VERSION).inc()
        if not 400 <= status < 500:  # los 4xx son rechazos rápidos: incluirlos "mejoraría" el p99 falsamente
            LATENCY.labels(source=source).observe(elapsed)
  • La ruta es una función normal (def, no async def): FastAPI la corre en su threadpool (un grupo de hilos aparte, para no frenar al resto). Devuelve el registro entero con CONTENT_TYPE_LATEST, que en prometheus-client 0.26.0 es text/plain; version=1.0.0; charset=utf-8.
  • Por qué no make_asgi_app(): montado en /metrics, Starlette responde 307 hacia /metrics/ y cada scrape serían dos requests. Lo dice el docstring y se repitió con Starlette 1.7.0 (la de C01, que es quien redirige) y FastAPI 0.143.0: 307 con location: …/metrics/.
  • El finally de classify corre siempre: el counter cuenta 200, 4xx y 5xx. Los 422 son la excepción: FastAPI los rechaza antes de llamar a classify, y los cuenta aparte el handler (la función que atiende ese error) de RequestValidationError, en la línea 329. status va como texto (str(status)): los valores de un label siempre son strings.
  • source pasa por una lista cerrada antes de ser label: un header libre (x-client-source) crearía una serie por cada valor que invente un cliente. Los 4xx no entran al histograma de latencia: un rechazo de 1 ms bajaría el p99 sin que nadie esté mejor.
  • Lo que el gateway no exporta: state.inflight (requests en vuelo) vive en la app pero no es un gauge, aunque el curso lo lista como señal de saturación del gateway. Para verlo en un panel haría falta un Gauge que se mueva junto con state.inflight.

Cómo recordarla

Define al importar, cuenta en el finally, expón en /metrics. Labels de una lista cerrada, buckets alrededor del SLO y un proceso por pod. Lo que no es tuyo (la máquina, Kubernetes, Triton) ya trae su exporter o su propio /metrics.

Trampa

Correr el servicio con varios workers (--workers 4) sin el modo multiproceso. Cada worker tiene sus propios counters y cada scrape cae en uno distinto: la serie salta, por ejemplo, 1,200 → 310 → 1,215, y rate() lee cada bajada como un reinicio y suma de más. Por eso C01 corre --workers 1 y escala con réplicas. La otra: buscar la serie de los 500 antes del primer 500; una serie con labels no existe hasta su primer uso, y el panel dice «No data» en vez de 0.

Laboratorio (opcional)7 pasos

Qué vas a provocar: Correr las seis métricas del gateway de C01 con tráfico simulado, contar sus series contra la figura y ver qué hace un label sin límite.

Necesitas: Python 3.13 e internet para el pip install (prometheus-client 0.26.0). Ni Docker ni GPU.

  1. Paso 1
    mkdir -p ~/lab-prom && cd ~/lab-prom
    python3.13 -m venv .venv
    .venv/bin/pip install prometheus-client==0.26.0

    Qué deberías ver: Termina con Successfully installed prometheus-client-0.26.0, la versión que fija C01 (no tiene dependencias).

  2. Paso 2
    cat > metricas_c01.py <<'EOF'
    # metricas_c01.py: las seis métricas del gateway de C01 (copiadas de main.py) con tráfico simulado
    import random, sys, time
    from prometheus_client import Counter, Histogram, start_http_server
    
    REQUESTS = Counter(
        "classifier_requests_total", "Requests a /v1/classify por código HTTP, caller y modelo",
        ["status", "source", "model_version"],
    )
    LATENCY = Histogram(
        "classifier_request_latency_seconds", "Latencia total de /v1/classify", ["source"],
        buckets=(0.01, 0.025, 0.05, 0.075, 0.1, 0.15, 0.2, 0.3, 0.5, 1.0, 2.5),
    )
    PREPROCESS = Histogram(
        "classifier_preprocess_seconds", "Decode + resize + normalize (CPU)",
        buckets=(0.002, 0.005, 0.01, 0.02, 0.05, 0.1, 0.25, 0.5),
    )
    INFERENCE = Histogram(
        "classifier_inference_seconds", "Llamada a Triton (round trip)",
        buckets=(0.005, 0.01, 0.02, 0.035, 0.05, 0.075, 0.1, 0.15, 0.3),
    )
    TOP1 = Histogram(
        "classifier_top1_score", "Score top-1 tras softmax", ["model_version"],
        buckets=tuple(round(0.1 * i, 1) for i in range(1, 11)),
    )
    IMAGE_BYTES = Histogram(
        "classifier_image_bytes", "Bytes de la imagen recibida",
        buckets=(1e4, 5e4, 1e5, 2.5e5, 5e5, 1e6, 2.5e6, 5e6, 1e7),
    )
    MODEL_VERSION = "2026-09-01-resnet50-fp16"
    MALO = "--malo" in sys.argv
    if MALO:  # lo que NO hay que hacer: un id como label
        POR_REQUEST = Counter("classifier_requests_by_id_total", "Requests por id", ["request_id"])
    
    start_http_server(8000)  # /metrics en el puerto 8000, en un hilo aparte
    print("sirviendo /metrics en el puerto 8000", flush=True)
    n = 0
    while True:  # un request simulado cada 10 ms, con el mismo patrón que el finally de classify
        n += 1
        status = random.choices(["200", "503", "504", "422"], weights=[90, 4, 4, 2])[0]
        source = random.choice(["mobile", "web", "internal", "other"])
        elapsed = random.lognormvariate(-3.2, 0.5)  # ≈ 40 ms de mediana
        if status == "200":
            PREPROCESS.observe(elapsed * 0.4)
            INFERENCE.observe(elapsed * 0.3)
            TOP1.labels(model_version=MODEL_VERSION).observe(random.betavariate(5, 2))
            IMAGE_BYTES.observe(random.randint(100_000, 300_000))
        REQUESTS.labels(status=status, source=source, model_version=MODEL_VERSION).inc()
        if not 400 <= int(status) < 500:  # como en C01: los 4xx no entran al histograma de latencia
            LATENCY.labels(source=source).observe(elapsed)
        if MALO:
            POR_REQUEST.labels(request_id=f"{n:08x}").inc()  # una serie nueva por request
        time.sleep(0.01)
    EOF

    Qué deberías ver: Sin salida: el archivo queda escrito. Las seis definiciones son copia exacta de main.py; el tráfico es simulado.

  3. Paso 3
    .venv/bin/python metricas_c01.py & echo $! > metricas.pid
    sleep 15; curl -s localhost:8000/metrics | grep 'source="web",status="200"'

    Qué deberías ver: sirviendo /metrics en el puerto 8000 y dos líneas: classifier_requests_total{model_version="2026-09-01-resnet50-fp16",source="web",status="200"} con cientos, y su gemela classifier_requests_created con la hora de nacimiento en segundos (≈ 1.79e+09). Los labels salen en orden alfabético.

  4. Paso 4
    curl -s localhost:8000/metrics | grep -E 'latency_seconds_(bucket\{le="(0.2|\+Inf)",source="web"\}|count\{source="web"\})'

    Qué deberías ver: Tres líneas: el bucket le="0.2" igual o casi igual al total (con ≈ 40 ms de mediana, casi todo cabe bajo 0.2 s), le="+Inf" y _count con el mismo número: los buckets son acumulados.

  5. Paso 5
    curl -s localhost:8000/metrics | grep -c '^classifier_'
    curl -s localhost:8000/metrics | grep -c '^[a-z]'

    Qué deberías ver: 144 y 154. Las 144: 32 de requests (4 status × 4 source × 2), 60 de latencia y 52 de los otros cuatro histogramas (12 + 13 + 14 + 13). Las 10 de diferencia son python_gc_* y python_info; en un pod de Linux se suman las 6 process_*.

  6. Paso 6
    kill $(cat metricas.pid)
    .venv/bin/python metricas_c01.py --malo & echo $! > metricas.pid
    sleep 10; curl -s localhost:8000/metrics | grep -c '^classifier_requests_by_id'
    sleep 10; curl -s localhost:8000/metrics | grep -c '^classifier_requests_by_id'

    Qué deberías ver: Unas 1,600 y unas 3,200: dos series nuevas por request (_total y _created), sin techo. El /metrics pasa de ≈ 14 KB a ≈ 240 KB en 20 s; en Prometheus, cada una sería una serie más en memoria y en el índice.

  7. Paso 7
    kill $(cat metricas.pid); cd ~ && rm -rf ~/lab-prom

    Qué deberías ver: Sin salida (zsh avisa que el proceso terminó).

Cómo arreglarlo y confirmarlo

Si en el paso 5 ves menos de 144, espera unos segundos más: alguna combinación rara (un 422 de internal) todavía no salió, y una serie con labels no existe hasta su primer uso. Los conteos de requests cambian en cada corrida; la cantidad de series no.

Probado de verdad en un Mac. Pasos 2 a 7 corridos tal cual en zsh (con un HOME de prueba) en macOS, con un venv que ya tenía prometheus-client 0.26.0 y Python 3.13.16; el paso 1 (venv y pip) no se corrió: 144 y 154 series a los 15 s; con --malo, 1,622 y 3,264 series a los 10 y 20 s, y el /metrics creció de 13.6 KB a 240 KB.

4

Configurar el scrape: prometheus.yml, jobs, targets, relabeling básico, up y la página de targets

¿Cómo le dices a Prometheus qué leer, cada cuánto y con qué labels, y cómo sabes si lo está logrando?

Sintaxis con variables

global:
  scrape_interval: intervalo
  evaluation_interval: intervalo
rule_files:
  - reglas.yml
scrape_configs:
  - job_name: job
    metrics_path: ruta
    static_configs:
      - targets: ["host:puerto"]
        labels:
          label: valor

global pone los valores por defecto de todos los jobs (cada job puede cambiarlos). Cada job (grupo de targets con el mismo propósito) lleva un nombre único, que termina como label job en cada serie. targets son host:puerto, sin http:// ni ruta: la ruta va en metrics_path (default /metrics).

    relabel_configs:
      - source_labels: [label_origen]
        regex: "regex"
        action: drop
      - target_label: label
        replacement: valor

Relabeling: reescribir los labels de cada target antes del scrape. keep y drop deciden si el target se lee; sin action es replace, que escribe target_label. El regex se ancla entero (default (.*)). Los labels que empiezan con __ (__address__, los __meta_* del descubrimiento) se borran al terminar.

    metric_relabel_configs:
      - source_labels: [__name__]
        regex: "patron"
        action: drop

metric_relabel_configs corre después del scrape, sobre cada muestra y antes de guardarla: sirve para tirar series caras. No toca up ni las otras series que genera Prometheus.

docker run --rm -v "$PWD":/cfg:ro --entrypoint promtool prom/prometheus:version check config /cfg/prometheus.yml

promtool (el validador que viene en la imagen) revisa la sintaxis, que existan los archivos que nombra y que las reglas de rule_files sean válidas. Úsalo antes de cada cambio: un error aquí es un Prometheus que no arranca o una recarga que falla.

docker kill -s HUP contenedor
curl -X POST localhost:9090/-/reload   # solo con --web.enable-lifecycle

Recargar sin reiniciar: la señal SIGHUP (el aviso de «relee tu config»), o un POST a /-/reload si Prometheus arrancó con --web.enable-lifecycle. Si la config nueva tiene un error, sigue la anterior y prometheus_config_last_reload_successful queda en 0.

Variables

VariableQué esEjemplo
intervalocada cuánto lee y evalúa15s en C01
reglasarchivo de reglas, relativo a prometheus.ymlalerts
jobnombre único del grupo de targetsgateway
rutaruta de las métricas en el target/metrics (el default)
host:puertodónde está el target, visto desde Prometheusgateway:8080 en el compose; host.docker.internal:8000 hacia tu Mac
label: valorlabel fijo para todas las series de esos targetsenv: lab
label_origenlabel que miras para decidir; en Kubernetes, un __meta_*__address__ · __meta_kubernetes_namespace
regexexpresión regular, anclada al inicio y al final.*:8001
patronnombres de métricas que no quieres guardargo_.*
versiontag de la imagen de Prometheusv3.13.3 (la de C01)
contenedornombre del contenedor de Prometheusprom-lab
Δtimeout, Δscrape\Delta_{\text{timeout}},\ \Delta_{\text{scrape}}scrape_timeout y scrape_interval10 s y 15 s en C01
NseriesN_{\text{series}}series que expone cada target, más las 5 que agrega Prometheus205 por pod del gateway

Fórmulas

Δtimeout  ≤  Δscrapesi no lo escribes: Δtimeout=min⁡(10 s, Δscrape)\Delta_{\text{timeout}} \;\le\; \Delta_{\text{scrape}} \qquad \text{si no lo escribes: } \Delta_{\text{timeout}} = \min(10\ \text{s},\ \Delta_{\text{scrape}})
muestrass  =  ∑targetsNseriesΔscrape  =  3×20515 s  =  41 muestras/s(el gateway de C01 con 3 pods)\frac{\text{muestras}}{\text{s}} \;=\; \sum_{\text{targets}} \frac{N_{\text{series}}}{\Delta_{\text{scrape}}} \;=\; \frac{3 \times 205}{15\ \text{s}} \;=\; 41\ \text{muestras/s}\quad(\text{el gateway de C01 con 3 pods})

Las piezas

PiezaQué haceEjemplo
globalValores por defecto para todos los jobs (intervalos y timeout), más los external_labels de este Prometheus.scrape_interval: 15s en C01
external_labelsLabels que se pegan a lo que sale de este Prometheus: alertas, federación (otro Prometheus que lee de este) y remote write (copias a otra base). No aparecen en tus consultas locales.env: local en C01
job_nameNombre único del job; termina como label job en cada serie.job="gateway"
static_configs.targetsLista fija de host:puerto. Con http:// adelante, la config ni carga; si el job tiene relabel_configs, carga, pero ese target nunca se crea.["gateway:8080"]
instanceLabel que dice qué target fue: por defecto, el __address__ (host:puerto).instance="gateway:8080"
metrics_path · schemeRuta y protocolo del scrape: /metrics y http por defecto.C01 escribe metrics_path: /metrics aunque es el default
scrape_timeoutCuánto espera cada scrape; no puede pasar el intervalo.10 s por defecto
Descubrimiento (service discovery)Que Prometheus encuentre los targets solo, sin lista fija. En Kubernetes, kubernetes_sd_configs con role pod, service, endpoints, endpointslice, node o ingress.en EKS lo arma el Prometheus Operator desde los ServiceMonitors (sección 11)
relabel_configsReglas que reescriben los labels de cada target antes del scrape: keep, drop, replace, labelmap (copia labels con otro nombre)…tirar un namespace entero, renombrar instance
__address__ · __meta_*Labels temporales del target: la dirección y lo que sabe el descubrimiento. Se borran después del relabeling.__meta_kubernetes_pod_name
metric_relabel_configsReglas que corren después del scrape, sobre cada muestra: tiran o renombran series antes de guardarlas.drop de go_.* en el lab
honor_labelsSi el target trae un label que Prometheus también pone (job, instance): con false (default) el del target pasa a llamarse exported_job; con true gana el del target.true para el Pushgateway
up y scrape_*Series que escribe Prometheus en cada scrape: up, scrape_duration_seconds, scrape_samples_scraped, scrape_samples_post_metric_relabeling y scrape_series_added.up{job="triton"} == 0
Status → Target healthLa página /targets: cada target con su estado (UP o DOWN), labels, último scrape, duración y error. Status → Service discovery muestra los labels de antes del relabeling y los targets descartados.connection refused · context deadline exceeded

antes del scrape

1Descubrimiento2relabel_configs3Target listo

la visita

4Scrape

después del scrape

5metric_relabel_configs6Límites7up y scrape_*8TSDB

  1. Descubrimientoantes del scrape

    YAMLstatic_configs · kubernetes_sd_configs

    Una lista fija (local) o la API de Kubernetes (EKS). Cada target nace con __address__ (host:puerto), job y, con descubrimiento, labels __meta_*.

  2. relabel_configsantes del scrape

    YAMLaction: keep · drop · replace · labelmap

    Decide si el target se lee y con qué labels. Lo que empieza con __ se borra al terminar; instance = __address__ si nadie lo fijó.

  3. Target listoantes del scrape

    terminalcurl -s localhost:9090/api/v1/targets

    discoveredLabels (antes) y labels (después del relabeling). Los descartados quedan como dropped en Status → Service discovery.

  4. Scrapela visita

    HTTPGET http://gateway:8080/metrics

    Cada scrape_interval (15 s en C01), con scrape_timeout (10 s). El formato se negocia con el header Accept.

  5. metric_relabel_configsdespués del scrape

    YAMLaction: drop sobre __name__

    Corre sobre cada muestra leída: tira series caras antes de guardarlas. No toca up.

  6. Límitesdespués del scrape

    YAMLsample_limit · label_limit

    Si una ronda los pasa, se descarta entera y up = 0. Default 0: sin límite.

  7. up y scrape_*después del scrape

    PromQLup{job="gateway"}

    Al cerrar la ronda, Prometheus escribe up (1 si salió bien; 0 si el scrape falló o pasó un límite), scrape_duration_seconds, scrape_samples_scraped…

  8. TSDBdespués del scrape

    PromQLprometheus_tsdb_head_series

    Cada muestra entra a su serie (nombre + labels). Una combinación nueva de labels es una serie nueva en memoria.

YAMLuna clave de la configuración (YAML)terminalun comando: va en la terminalHTTPel request que hace PrometheusPromQLuna consulta: va en la caja de consultas de Prometheus (o en Grafana)

La vida de un target en cada ronda, del descubrimiento a la muestra guardada. Hay dos lugares para reescribir labels: relabel_configs, antes del scrape, y metric_relabel_configs, después.

Ejemplo real · El prometheus.yml de C01: dos jobs, cada 15 s

YAMLreference/c01/monitoring/prometheus.yml (completo)
# Configuración de Prometheus para correr el sistema en local con docker compose (caso c01).
# En Kubernetes NO se escribe a mano: el Prometheus Operator descubre los pods con
# ServiceMonitor/PodMonitor (o kubernetes_sd_configs), y en AWS puedes usar Amazon Managed
# Prometheus. alerts.yml se reutiliza tal cual como spec de un PrometheusRule (ver README).

global:
  scrape_interval: 15s  # cada cuánto lee /metrics: suficiente para alertas de 5–30 min, barato en series
  evaluation_interval: 15s  # cada cuánto evalúa reglas y alertas
  external_labels:
    env: local  # en producción, env/cluster distinguen de dónde viene cada alerta

rule_files:
  - alerts.yml  # ruta relativa a este archivo: reglas de grabación + las 6 alertas del caso

scrape_configs:
  - job_name: gateway  # el label job="gateway" aparece en cada serie del gateway
    metrics_path: /metrics  # endpoint propio (sin redirect; ver main.py)
    static_configs:
      - targets: ["gateway:8080"]  # nombre del servicio en docker-compose.yml, puerto del gateway

  - job_name: triton  # métricas nv_* (cola, cómputo, batches, GPU) que expone Triton
    static_configs:
      - targets: ["triton:8002"]  # 8002 = --metrics-port de Triton
  • Un archivo para el compose local: cada target es servicio:puerto en la red de Docker (gateway:8080, triton:8002). Desde el contenedor de Prometheus, localhost sería el propio Prometheus.
  • Dos jobs, dos valores del label job: gateway y triton. El job de Triton no escribe metrics_path y usa el default, /metrics.
  • external_labels no aparece en tus consultas: se pega a lo que sale de este Prometheus (alertas a Alertmanager, federación, remote write). En producción, env y cluster dicen de dónde viene cada alerta.
  • En EKS este archivo no existe (lo dice su comentario): el Operator arma el scrape desde k8s/monitoring.yaml. Cada ServiceMonitor se vuelve un job (scrape pool) llamado serviceMonitor/classifier/gateway/0, pero el label job de las series es el nombre del Service: gateway y triton, los mismos que en local. El Operator agrega además namespace, service, pod, container y endpoint (el nombre del puerto).
  • Los ServiceMonitors repiten interval: 15s: sin él usarían el intervalo de su Prometheus, que en el Prometheus Operator es 30 s por defecto. Con 15 s, las ventanas de 2 a 30 min de KEDA y de las alertas tienen de 8 a 120 muestras.

Cómo recordarla

Un job es una ronda con su lista de cuartos (targets): relabel_configs decide en qué cuartos entrar y con qué nombre anotarlos, metric_relabel_configs qué anotar, y up es la línea que dice «entré y alguien contestó».

Trampa

Prometheus en un contenedor con targets: ["localhost:8080"]: localhost es el propio contenedor de Prometheus, y el target queda DOWN con connection refused. Usa el nombre del servicio en compose (gateway:8080) o host.docker.internal para tu Mac. Y escribir http://gateway:8080 en targets: la config ni carga ("http://gateway:8080" is not a valid hostname), y si el job tiene relabel_configs, carga pero ese target nunca se crea. El esquema va en scheme y la ruta en metrics_path.

Laboratorio (opcional)8 pasos

Qué vas a provocar: Escribir un prometheus.yml propio: un job vivo con un label y un relabel, uno muerto, un metric_relabel que tira series, y dos recargas, una rota y una buena.

Necesitas: Docker Desktop con internet para bajar prom/prometheus:v3.13.3, y Python 3 de macOS (solo stdlib). Los sed -i '' son la sintaxis de macOS.

  1. Paso 1
    mkdir -p ~/lab-prom/config && cd ~/lab-prom
    cat > mini.py <<'EOF'
    # mini.py: un target mínimo con /metrics (solo stdlib)
    import time
    from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
    
    T0 = time.time()
    
    class Metrics(BaseHTTPRequestHandler):
        def do_GET(self):
            n = int((time.time() - T0) * 10)  # "requests atendidos": 10 por segundo
            cuerpo = f"# TYPE demo_requests_total counter\ndemo_requests_total {n}\n".encode()
            self.send_response(200 if self.path == "/metrics" else 404)
            self.send_header("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
            self.end_headers()
            self.wfile.write(cuerpo)
    
        def log_message(self, *args):
            pass
    
    ThreadingHTTPServer(("", 8000), Metrics).serve_forever()
    EOF
    python3 mini.py & echo $! > mini.pid
    sleep 1; curl -s localhost:8000/metrics

    Qué deberías ver: # TYPE demo_requests_total counter y demo_requests_total ≈ 10 (sube 10 por segundo). Si macOS pregunta si Python acepta conexiones entrantes, acepta: el contenedor llega a tu Mac por la red.

  2. Paso 2
    cat > config/prometheus.yml <<'EOF'
    global:
      scrape_interval: 15s
    scrape_configs:
      - job_name: servicio
        static_configs:
          - targets: ["host.docker.internal:8000"]
            labels:
              env: lab
        relabel_configs:
          - target_label: instance
            replacement: mi-mac
      - job_name: fantasma
        static_configs:
          - targets: ["host.docker.internal:8001"]
      - job_name: prometheus
        static_configs:
          - targets: ["localhost:9090"]
        metric_relabel_configs:
          - source_labels: [__name__]
            regex: "go_.*"
            action: drop
    EOF
    docker run --rm -v "$HOME/lab-prom/config:/cfg:ro" --entrypoint promtool prom/prometheus:v3.13.3 check config /cfg/prometheus.yml

    Qué deberías ver: Checking /cfg/prometheus.yml y SUCCESS: /cfg/prometheus.yml is valid prometheus config file syntax.

  3. Paso 3
    docker run -d --name prom-lab -p 9090:9090 -v "$HOME/lab-prom/config:/etc/prometheus:ro" prom/prometheus:v3.13.3
    sleep 20

    Qué deberías ver: Un id largo. Montas la carpeta, no el archivo suelto, en la ruta que usa el CMD de la imagen: así las ediciones de los pasos 6 y 7 se ven adentro.

  4. Paso 4
    curl -s -G localhost:9090/api/v1/query --data-urlencode 'query=up' | python3 -c "import json, sys; [print(r['metric'], r['value'][1]) for r in json.load(sys.stdin)['data']['result']]"

    Qué deberías ver: Tres líneas, en cualquier orden: servicio con instance mi-mac y env lab, en 1; fantasma con instance host.docker.internal:8001, en 0; prometheus con instance localhost:9090, en 1. En http://localhost:9090/targets, fantasma está DOWN con un error que termina en connect: connection refused.

  5. Paso 5
    curl -s -G localhost:9090/api/v1/query --data-urlencode 'query=scrape_samples_scraped{job="prometheus"} - scrape_samples_post_metric_relabeling{job="prometheus"}' | python3 -c "import json, sys; print(json.load(sys.stdin)['data']['result'][0]['value'][1])"

    Qué deberías ver: Un número de decenas: las series go_* que Prometheus leyó de sí mismo y metric_relabel_configs tiró antes de guardarlas. Una consulta a {__name__=~"go_.*"} vuelve vacía.

  6. Paso 6
    sed -i '' 's#"host.docker.internal:8001"#"http://host.docker.internal:8001"#' config/prometheus.yml
    docker kill -s HUP prom-lab; sleep 2
    docker logs prom-lab 2>&1 | grep 'Error reloading config' | tail -n 1
    curl -s localhost:9090/metrics | grep '^prometheus_config_last_reload_successful'

    Qué deberías ver: Una línea con msg="Error reloading config" cuyo err termina en is not a valid hostname (el target con http:// adelante), y prometheus_config_last_reload_successful 0, leído directo del /metrics del propio Prometheus. Los targets siguen como antes: una recarga fallida no tira la config vieja.

  7. Paso 7
    sed -i '' 's#"http://host.docker.internal:8001"#"host.docker.internal:8001"#; s/15s/5s/' config/prometheus.yml
    docker kill -s HUP prom-lab; sleep 10
    curl -s localhost:9090/api/v1/targets | python3 -c "import json, sys; [print(t['labels']['job'], t['scrapeInterval'], t['health']) for t in json.load(sys.stdin)['data']['activeTargets']]"

    Qué deberías ver: servicio 5s up, fantasma 5s down y prometheus 5s up: la recarga funcionó sin reiniciar el contenedor, y prometheus_config_last_reload_successful vuelve a 1. Si alguno dice unknown, todavía no hubo scrape con la config nueva: repite.

  8. Paso 8
    kill $(cat mini.pid); docker rm -f -v prom-lab; cd ~ && rm -rf ~/lab-prom

    Qué deberías ver: prom-lab, y la carpeta borrada.

Cómo arreglarlo y confirmarlo

Si servicio queda DOWN: ¿corre mini.py (curl localhost:8000/metrics)? ¿aceptaste el permiso de red de macOS? Si un cambio no se ve tras el HUP: mira prometheus_config_last_reload_successful y el log. Y valida con promtool antes de recargar.

Verificado contra la documentación; no ejecutado aquí.

5

PromQL básico: selectores, rangos, rate y agregaciones

¿Cómo le preguntas a Prometheus «cuántos requests por segundo», «qué fracción falla» o «qué pod va peor», sin que la respuesta te engañe?

Sintaxis con variables

métrica{source="valor", status=~"regex"}

Un selector elige series (cada serie es la historia de un número en el tiempo, con sus labels): el nombre de la métrica y, entre llaves, matchers (filtros por label, las etiquetas clave-valor de cada serie). = igual, != distinto, =~ cumple la regex (expresión regular), !~ no la cumple. La regex tiene que calzar con el valor completo: status=~"5.." toma 500 y 503, no 1500. El resultado es un instant vector (vector instantáneo): el último valor de cada serie.

métrica{…}[ventana]

Con [5m] pides todas las muestras (cada lectura guardada: una hora y un valor) de los últimos 5 minutos de cada serie: un range vector (vector de rango). No se grafica directo: es lo que reciben rate() y las funciones *_over_time. La ventana deja afuera el borde viejo e incluye el de ahora.

rate(counter[ventana])
increase(counter[ventana])
irate(counter[ventana])

Para counters (contadores que solo suben, como el odómetro de un auto). rate da la velocidad: cuánto sube por segundo, en promedio, dentro de la ventana. increase da cuánto subió en toda la ventana. irate mira solo las dos últimas muestras. Los tres corrigen los reinicios (el proceso arranca de nuevo y el contador vuelve a 0). Prometheus solo exige 2 muestras dentro de la ventana; la regla práctica es una ventana de al menos 4 scrapes (4 lecturas de Prometheus): 1m con los 15 s de C01. Es la regla que usa Grafana en su ventana automática, $__rate_interval.

sum by (label) (rate(counter[ventana]))
topk(k, sum by (label) (rate(counter[ventana])))

Agregar junta muchas series en una. sum by (source) suma y deja solo el label source; without (pod) quita los labels que nombras y deja el resto. topk(3, …) se queda con las 3 series más altas, con sus labels. Siempre rate primero y sum después: si sumas los counters crudos, el reinicio de un pod hace bajar la suma y rate lo toma como un reinicio del total: sale un pico falso.

sum(rate(errores[ventana])) / sum(rate(total[ventana]))

Una proporción (ratio). Al dividir, Prometheus empareja las series que tienen exactamente los mismos labels; las que no tienen pareja desaparecen, sin error. Por eso se agrega igual de los dos lados. on(…) o ignoring(…) eligen con qué labels emparejar, y group_left deja que varias series de la izquierda usen la misma de la derecha.

expresión > umbral
expresión > bool umbral
rate(counter[ventana] offset duración)

Una comparación filtra: quedan solo las series que la cumplen; en una regla de alerta, cada serie que queda es una alerta. Con bool devuelve 1 o 0 para todas, sin filtrar. offset 1w corre la consulta una semana hacia atrás (para comparar con el mismo día de la semana pasada) y va pegado al selector, dentro del rate(…).

Variables

VariableQué esEjemplo
métricanombre de la métricaclassifier_requests_total
valorvalor exacto de un label (los valores son texto)"mobile"
regexexpresión regular que tiene que calzar con todo el valor"5.." (de 500 a 599)
ventanalargo del rango: número y unidad (ms, s, m, h, d, w, y)5m en las alertas de C01; 2m en la query de KEDA (el que escala Triton)
counterun contador: suele terminar en _total, _count, _sum o _bucket (varios de Triton no llevan sufijo)classifier_requests_total, nv_inference_request_success
labellabel que queda después de agregarsource, model_version, le
kcuántas series dejar3
errores, totalselectores del numerador y del denominadorclassifier_requests_total{status=~"5.."} y classifier_requests_total{status!~"4.."}
expresión, umbrallo que comparas y el número contra el que comparas… > 0.01 (1 %)
duracióncuánto hacia atrás1d, 1w
x1, xnx_{1},\ x_{n}primer y último valor del counter dentro de la ventanafigura: 1,320 y 480
t1, tnt_{1},\ t_{n}segundo de la primera y de la última muestrafigura: 45 y 150
Σ reinicios\text{Σ reinicios}lo que llevaba el contador justo antes de cada reiniciofigura: 1,680
wwla ventana, en segundos120 con [2m]
≈≈Prometheus extrapola hasta los bordes de la ventana; si las muestras son regulares y la cubren, da exactamente Δx / (t_n − t_1)figura: 8.0

Fórmulas

Δx=xn−x1+∑reiniciosxantes del reinicio\Delta x = x_n - x_1 + \sum_{\text{reinicios}} x_{\text{antes del reinicio}}
rate(x[w])≈Δxtn−t1increase(x[w])=rate(x[w])×w\texttt{rate}(x[w]) \approx \frac{\Delta x}{t_n - t_1} \qquad \texttt{increase}(x[w]) = \texttt{rate}(x[w]) \times w
irate(x[w])=xn−xn−1tn−tn−1\texttt{irate}(x[w]) = \frac{x_n - x_{n-1}}{t_n - t_{n-1}}
w≥4×15 s=1 min (regla praˊctica)figura: 480−1320+1680150 s−45 s=840105 s=8 req/sw \ge 4 \times 15\,\text{s} = 1\,\text{min}\ (\text{regla práctica}) \qquad \text{figura: } \frac{480 - 1320 + 1680}{150\,\text{s} - 45\,\text{s}} = \frac{840}{105\,\text{s}} = 8\ \text{req/s}

Las piezas

PiezaQué haceEjemplo
Selector y matchersElige series por nombre y labels: = igual, != distinto, =~ cumple la regex, !~ no la cumple. La regex se ancla a todo el valor.classifier_requests_total{status!~"4.."}
Label vacíoUn matcher que acepta "" también elige las series que no tienen ese label. Un selector sin nombre necesita al menos un matcher que no acepte "".classifier_requests_total{model_version=""}: las que no lo tienen
Instant vectorUn valor por serie: el último que hay hasta 5 min atrás (el lookback). Una serie sin muestras en esos 5 min no aparece.nv_gpu_utilization
Range vectorTodas las muestras de la ventana, por serie. No se grafica: va dentro de una función.classifier_requests_total[5m]: unas 20 muestras a 15 s
rate()Velocidad media de un counter en la ventana, por segundo. Corrige reinicios y estira la cuenta hasta los bordes de la ventana (extrapola). La de las alertas y los paneles.rate(classifier_requests_total[5m]) → req/s
increase()Cuánto subió en la ventana: rate × segundos de la ventana. Puede dar decimales aunque cuentes de a uno.increase(classifier_requests_total[1h])
irate()Velocidad entre las dos últimas muestras. Nerviosa: para graficar counters muy rápidos, no para alertas.irate(nv_inference_count[1m])
*_over_time()Resumen de un gauge (un valor que sube y baja) dentro de la ventana: promedio, máximo, mínimo.max_over_time(nv_gpu_utilization[10m])
sum · avg · max · min · countAgregan muchas series en una (o en una por grupo). Promediar proporciones o percentiles no sirve: suma numeradores y denominadores.sum by (source) (rate(classifier_requests_total[5m]))
by (…) · without (…)by deja solo esos labels; without quita esos y deja el resto. Se puede escribir antes o después del paréntesis.sum by (le) (…) en un histograma
topk(k, …) · bottomkLas k series más altas o más bajas, con sus labels intactos.topk(3, sum by (source) (rate(classifier_requests_total[5m])))
+ − * / (aritmética)Con un número, operan sobre cada serie. Entre dos vectores, emparejan series con exactamente los mismos labels y lo que no tiene pareja se cae; on (…) e ignoring (…) eligen los labels y group_left permite muchos a uno. El resultado pierde el nombre de la métrica.… / 1000: de µs a ms · errores / total: una proporción
> < == != >= <=Filtran: queda lo que cumple. Con bool devuelven 0 o 1 para todo.… > 0.01: la condición de una alerta
and · or · unlessOperan con conjuntos de series: and deja lo de la izquierda que tiene pareja a la derecha; unless, lo de la izquierda que no la tiene; or deja todo lo de la izquierda y suma lo de la derecha que no tiene pareja.… > 0.01 and sum(rate(…[5m])) > 1 (C01)
offsetCorre una consulta hacia el pasado. Va pegado al selector.rate(classifier_requests_total[5m] offset 1w)
clamp_min(v, m)Ningún valor queda por debajo de m: evita dividir por 0.clamp_min(…, 1e-9) en la query de KEDA
absent(v)Devuelve 1 cuando v no tiene ninguna serie, y nada cuando existe: avisa si una métrica desaparece.absent(up{job="gateway"})
la ventana [2m]: (30, 150] s · 8 muestras05001,0001,5002,0002,5000306090120150180segundosvalor del counterevaluamos en t = 150reinicio+1,680restar: −8 req/s ✗rate() = 8 req/s ✓ventana [2m]05001,0001,5002,0002,500060120180segundosvalort = 150reinicio+1,680−8 req/srate() = 8 req/s
  1. Restar el primero al último: no ve el reinicio

    (480 − 1320) / (150 − 45)

    −8 req/s

  2. rate(): suma lo que el counter llevaba antes del reinicio

    (480 − 1320 + 1680) / (150 − 45) = 840 / 105

    8 req/s

  3. increase(): rate × los segundos de la ventana

    8 req/s × 120 s

    960 requests

  4. irate() en t = 105: solo las dos últimas muestras

    120 / 15: bajó, así que toma el último valor solo

    8 req/s

classifier_requests_total de un pod, una muestra cada 15 slo que ve rate(): el viejo + el nuevola resta que mientela ventana: abierta a la izquierda

Un pod atiende 8 req/s y Prometheus lo lee cada 15 s. Justo después de la lectura de t = 90 s el pod se reinicia y su counter vuelve a 0. Restar el primer valor de la ventana al último da −8 req/s; rate() suma lo que el contador llevaba antes del reinicio y da 8. Cifras calculadas con la regla de Prometheus, la misma que el lab de la sección 2 hace a mano.

Ejemplo real · La query de KEDA: espera media en la cola de Triton, en ms

YAMLreference/c01/k8s/keda-triton.yaml (líneas 53–59)
        # Espera media en cola en ms (misma fórmula que la alerta TritonQueueTimeHigh).
        # clamp_min evita 0/0 = NaN sin tráfico; si todo falla (0 éxitos) el valor se dispara y escala.
        query: |
          sum(rate(nv_inference_queue_duration_us{model="classifier"}[2m]))
            /
          clamp_min(sum(rate(nv_inference_request_success{model="classifier"}[2m])), 1e-9)
            / 1000
  • Se lee de adentro hacia afuera. nv_inference_queue_duration_us{model="classifier"} es un counter: los µs acumulados en la cola desde que arrancó Triton. Triton no le pone _total, pero sube como un odómetro (la hoja de Triton separa sus counters de sus gauges). Por el nombre, Prometheus 3 muestra un aviso de nivel info al hacerle rate (metric might not be a counter…): aquí no importa.
  • rate(…[2m]): cuántos µs de cola se acumulan por segundo en los últimos 2 minutos (8 muestras a 15 s). sum(…) junta los pods de Triton en un solo número. Primero rate, después sum.
  • Dividir dos rates da un promedio: µs de cola por segundo ÷ requests exitosos por segundo = µs de espera por request, y / 1000 lo pasa a ms. Es la cuenta de la alerta TritonQueueTimeHigh, que usa [5m] y avisa sobre 20 ms; KEDA usa 2 minutos para reaccionar antes, con objetivo de 10 ms.
  • clamp_min(…, 1e-9): sin tráfico el denominador es 0 y 0/0 da NaN (not a number). Con el piso, 0 / 1e-9 = 0: sin cola, no escala. Y si todo falla (0 éxitos pero tiempo en cola), el valor se dispara y KEDA escala.
  • Si la query vuelve vacía (Prometheus no lee Triton), no hay número: con ignoreNullValues: "false" KEDA lo marca como error en vez de tomarlo como 0 (hoja de Kubernetes, autoscaling). En C01, la señal de tráfico del gateway es la misma cuenta sobre un counter: rate(classifier_requests_total[5m]), por caller (quién llama: el label source).

Cómo recordarla

selector → [ventana] → rate → sum by → comparación, de adentro hacia afuera. Un counter es el odómetro del auto: el número suelto no dice nada, su velocidad (rate) sí. Un gauge es el indicador de gasolina: se lee directo, o con avg_over_time. Ventana de al menos 4 scrapes (la regla práctica de Grafana; Prometheus solo exige 2 muestras); rate antes de sum.

Trampa

escribir status="5.." con =. Busca el texto literal «5..»: no hay error, la consulta vuelve vacía y una alerta así nunca dispara. Para una regex va =~. Otra que tampoco avisa: dividir dos lados con labels distintos, como sum by (source) (…) / sum(…). Sin pareja, el resultado es vacío; usa / ignoring(source) group_left sum(…) o agrega igual en los dos lados.

Laboratorio (opcional)5 pasos

Qué vas a provocar: Hacerle a un Prometheus de verdad las consultas de esta sección, sobre sus propias métricas, y ver tres errores: = con una regex, rate sin ventana y una división sin pareja.

Necesitas: Docker Desktop corriendo, Python 3 (solo stdlib) e internet para bajar prom/prometheus:v3.13.3 (una vez).

  1. Paso 1
    mkdir -p ~/lab-prom && cd ~/lab-prom
    docker run -d --name prom-lab -p 9090:9090 prom/prometheus:v3.13.3
    cat > q.py <<'EOF'
    # q.py: manda una consulta PromQL a la API de Prometheus y resume la respuesta (solo stdlib)
    import json, sys, urllib.error, urllib.parse, urllib.request
    
    def tamano(s):                                   # para ordenar: el valor, o cuántas muestras si es un rango
        return float(s["value"][1]) if "value" in s else len(s["values"])
    
    url = "http://localhost:9090/api/v1/query?" + urllib.parse.urlencode({"query": sys.argv[1]})
    try:
        r = json.load(urllib.request.urlopen(url))
    except urllib.error.HTTPError as e:              # 400: Prometheus no pudo leer la consulta
        r = json.load(e)
    if r["status"] != "success":
        sys.exit("error: " + r["error"])
    d = r["data"]
    print(d["resultType"], "·", len(d["result"]), "serie(s)")
    for s in sorted(d["result"], key=tamano, reverse=True)[:4]:   # solo las 4 de valor más alto
        labels = {k: v for k, v in s["metric"].items() if k != "__name__"}
        valor = s["value"][1] if "value" in s else f'{len(s["values"])} muestras'
        print(" ", s["metric"].get("__name__", ""), labels, "=", valor)
    EOF
    sleep 20; python3 q.py up

    Qué deberías ver: Un id largo y después vector · 1 serie(s): up con app prometheus, instance localhost:9090 y job prometheus, = 1. La imagen trae una config de ejemplo que se lee a sí misma cada 15 s; q.py manda la consulta a /api/v1/query, cuenta las series y muestra las 4 de valor más alto.

  2. Paso 2
    sleep 15
    python3 q.py 'prometheus_http_requests_total{handler="/api/v1/query"}'
    python3 q.py 'prometheus_http_requests_total{handler="/api/v1/.*"}'
    python3 q.py 'prometheus_http_requests_total{handler=~"/api/v1/.*"}'

    Qué deberías ver: El sleep le da tiempo a Prometheus de leerse a sí mismo y ver tus consultas. La primera encuentra una serie, con code 200: cuenta tus propias consultas a /api/v1/query. La segunda da vector · 0 serie(s), sin ningún error: = busca el texto literal «/api/v1/.*». La tercera encuentra decenas de series, una por cada ruta de /api/v1/ (Prometheus crea en 0 la serie de cada ruta al arrancar), con /api/v1/query arriba: =~ toma la regex, anclada a todo el valor.

  3. Paso 3
    python3 q.py 'rate(prometheus_http_requests_total)'
    python3 q.py 'prometheus_http_requests_total{handler="/metrics"}[1m]'
    python3 q.py 'sum by (handler) (rate(prometheus_http_requests_total[1m]))'

    Qué deberías ver: La primera sale con un error que termina en expected type range vector in call to function "rate", got instant vector: rate necesita una [ventana]. La segunda es un range vector: matrix · 1 serie(s) con unas 4 muestras (un scrape cada 15 s en una ventana de 1 min; menos si Prometheus lleva menos de un minuto arriba). La tercera da una serie por handler (decenas, casi todas en 0), ya sin code ni instance. Arriba sale /metrics, cerca de 0.0667 (un scrape cada 15 s), junto a /api/v1/query si hiciste consultas en el último minuto.

  4. Paso 4
    python3 q.py 'topk(2, sum by (handler) (rate(prometheus_http_requests_total[5m])))'
    python3 q.py 'sum by (handler) (rate(prometheus_http_requests_total[5m])) / sum(rate(prometheus_http_requests_total[5m]))'
    python3 q.py 'sum by (handler) (rate(prometheus_http_requests_total[5m])) / ignoring(handler) group_left sum(rate(prometheus_http_requests_total[5m]))'
    python3 q.py 'sum(rate(prometheus_http_requests_total[1m] offset 1m))'

    Qué deberías ver: topk(2, …): las dos rutas con más tráfico, con su label handler. La división sin pareja da vector · 0 serie(s), otra vez sin error: a la izquierda cada serie tiene handler y a la derecha no hay labels. Con ignoring(handler) group_left sale la fracción del tráfico de cada handler (todas juntas suman 1). La última es la tasa total de hace un minuto: offset va pegado al selector, dentro del rate. Si sale vacía, Prometheus lleva menos de 2 minutos arriba y esa ventana todavía no tiene 2 muestras: repítela en un minuto.

  5. Paso 5
    docker rm -f -v prom-lab
    cd ~ && rm -rf ~/lab-prom

    Qué deberías ver: prom-lab, y la carpeta borrada.

Cómo arreglarlo y confirmarlo

Si q.py dice Connection refused, Prometheus todavía está arrancando: espera unos segundos. Las mismas consultas se pueden pegar en http://localhost:9090/query; en Prometheus 3, la pestaña Explain de esa página muestra cada parte de la consulta por separado y cómo se emparejan los dos lados de una división.

Verificado contra la documentación; no ejecutado aquí.

6

Histogramas y percentiles: buckets, le y histogram_quantile

¿Cómo sacas el p99 de todos los pods juntos, qué tan exacto sale y por qué nunca se promedian los p99?

Sintaxis con variables

nombre_bucket{le="borde"}   # observaciones ≤ borde, acumuladas desde que arrancó el proceso
nombre_bucket{le="+Inf"}      # todas
nombre_count                  # cuántas (igual al bucket +Inf)
nombre_sum                    # la suma de los valores observados

Un histograma (histogram) no guarda cada latencia: cuenta cuántas cayeron bajo cada borde (bucket). El label le significa «menor o igual». Los buckets son acumulados: le="0.2" incluye todo lo de le="0.1". Cada línea es un counter.

histogram_quantile(φ, sum by (le) (rate(nombre_bucket[ventana])))

El percentil de todo el servicio, en tres pasos: rate (solo lo de la ventana, no desde que arrancó cada pod), sum by (le) (junta los pods y conserva le) y recién ahí el cuantil. Con φ = 0.99 sale el p99: el tiempo bajo el que queda el 99 % de los requests.

histogram_quantile(φ, sum by (le, label) (rate(nombre_bucket[ventana])))

Un p99 por grupo: agrega el label al by, sin quitar le. En C01, la latencia se parte por source; el score top-1 (otro histograma) se parte por model_version durante un canary, cuando una versión nueva recibe una parte del tráfico.

sum(rate(nombre_sum[ventana])) / sum(rate(nombre_count[ventana]))

La media: suma de los valores ÷ cantidad. Sirve, pero esconde la cola (los requests más lentos); el p99 la muestra.

sum(rate(nombre_bucket{le="borde"}[ventana])) / sum(rate(nombre_count[ventana]))

La fracción de observaciones ≤ borde, sin interpolar: exacta si el borde es uno de tus buckets. Así cuenta C01 las predicciones con score ≤ 0.3, y así se mide un SLO de latencia (la meta de servicio: qué fracción queda bajo 0.2 s).

histogram_quantile(φ, sum(rate(nombre[ventana])))   # native histogram: sin _bucket ni le

Los native histograms (buckets exponenciales automáticos, todo en una sola serie) no tienen _bucket ni le. Son estables desde Prometheus 3.8, pero Prometheus solo los lee si lo activas (scrape_native_histograms: true) y el cliente tiene que emitirlos: lo hacen las librerías oficiales de Go, Java y Rust. C01 (Python) usa histogramas clásicos.

Variables

VariableQué esEjemplo
nombrenombre base del histogramaclassifier_request_latency_seconds
bordeun borde de bucket, en la unidad de la métrica"0.2" (200 ms)
φel cuantil, entre 0 y 10.99 (p99), 0.5 (mediana)
ventanaventana del rate5m
labellabel que quieres conservar además de lesource
q^\hat{q}cuantil estimado por histogram_quantilelab: 275 ms
r, Nr,\ Nposición buscada y total de observaciones (el bucket +Inf)lab: 99 de 100
ℓ, u\ell,\ ubordes inferior y superior del bucket donde cae r0.2 y 0.3 s
cℓ, cuc_{\ell},\ c_{u}conteos acumulados en esos dos bordeslab: 96 y 100
qrealq_{\text{real}}el percentil exacto, que Prometheus no conoce: siempre dentro del bucketlab: 260 ms
xˉ\bar{x}la media: crecimiento de _sum ÷ crecimiento de _count en la ventanacon los datos del lab: 3.16 s / 50 = 63 ms en pod-b
b, vb,\ vbuckets finitos y combinaciones de labels; el + 4 son +Inf, _sum, _count y _created11 y 4 (los valores de source)

Fórmulas

q^=ℓ+(u−ℓ) r−cℓcu−cℓr=φ×N\hat{q} = \ell + (u - \ell)\,\frac{r - c_{\ell}}{c_{u} - c_{\ell}} \qquad r = \varphi \times N
lab: r=0.99×100=99  ⇒  q^=0.2+0.1×99−96100−96=0.275 s\text{lab: } r = 0.99 \times 100 = 99 \;\Rightarrow\; \hat{q} = 0.2 + 0.1 \times \frac{99 - 96}{100 - 96} = 0.275\ \text{s}
ℓ<qreal≤u(lab: 0.2<0.26≤0.3)\ell < q_{\text{real}} \le u \qquad (\text{lab: } 0.2 < 0.26 \le 0.3)
xˉ=Δ _sumΔ _count\bar{x} = \frac{\Delta\,\texttt{\_sum}}{\Delta\,\texttt{\_count}}
series=(b+4)×v=(11+4)×4=60 por pod\text{series} = (b + 4) \times v = (11 + 4) \times 4 = 60\ \text{por pod}

Las piezas

PiezaQué haceEjemplo
_bucket{le="…"}Un counter por borde: cuántas observaciones fueron ≤ ese borde. Acumulado.classifier_request_latency_seconds_bucket{le="0.2"}
le="+Inf"El último bucket: cuenta todo, igual que _count. Sin él, histogram_quantile da NaN.el cliente lo agrega solo
_sum · _countLa suma de los valores y la cantidad de observaciones: su cociente es la media.rate(_sum) / rate(_count)
_createdLa hora en que nació cada serie; prometheus_client la agrega por cada combinación de labels.se apaga con PROMETHEUS_DISABLE_CREATED_SERIES=True
buckets=(…)Los bordes los decides en el código: densos cerca del SLO. Cambiarlos después rompe la comparación con la historia.C01: 0.15, 0.2 y 0.3 s alrededor de 200 ms
Buckets por defectoSin buckets=, prometheus_client usa .005, .01, .025, .05, .075, .1, .25, .5, .75, 1, 2.5, 5, 7.5 y 10 s: de 0.1 salta a 0.25.un p99 cerca de 200 ms queda en un bucket de 150 ms de ancho
histogram_quantile(φ, v)Estima el cuantil φ con una línea recta dentro del bucket donde cae: supone que las observaciones están repartidas parejo dentro del bucket.0.99 → p99
sum by (le)Junta los buckets de todos los pods conservando le. Agregues como agregues (by o without), le tiene que sobrevivir.obligatorio antes de histogram_quantile
Cae en +InfSi el cuantil cae en el último bucket, devuelve el borde finito más alto: no sabe cuánto más.C01 nunca dirá más de 2.5 s
Sin tráficoCon 0 observaciones en la ventana sale NaN (not a number): la comparación da falso y la alerta calla.rate = 0 en todos los buckets
SummaryCalcula cuantiles en el cliente: no se pueden sumar entre pods. El de Python ni los calcula: solo _sum y _count.avg(x{quantile="0.99"}) no significa nada
Native histogramBuckets exponenciales automáticos en una sola serie: más precisos y baratos. Estables desde Prometheus 3.8, pero hay que activar su lectura.scrape_native_histograms: true
Heatmap de GrafanaDibuja los buckets en el tiempo: ves toda la distribución, no un solo percentil.sección 9

Los buckets acumulados de los dos pods, sumados por le (100 requests)

pod-apod-br = 0.99 × 100: el request 99

histogram_quantile: una recta dentro del bucket

el bucket donde cae r96r = 99100200 ms250 ms300 msℓ: 96 hasta 200 msu: 100 hasta 300 ms▲ real 260 msestimado 275 ms

¿Cumple el SLO de 200 ms? A escala, de 0 a 300 ms

  • p99 de pod-a68.8 ms
  • p99 de pod-b287.5 ms
  • promedio de los dos p99178.1 ms ✗
  • p99 real de los 100260 ms
  • histogram_quantile (la recta)275 ms

El promedio dice 178.1 ms («cumple»); el p99 de los 100 requests es 260 ms. Sumar los buckets y sacar el cuantil una vez no miente.

100 requests de dos pods (los del lab): las barras son los buckets acumulados después de sum by (le). r = 0.99 × 100 = 99 cae entre le="0.2" (96) y le="0.3" (100); la línea recta da 275 ms. El p99 real es 260 ms: siempre dentro de ese bucket. Promediar el p99 de cada pod (68.8 y 287.5 ms) daría 178 ms, bajo el SLO de 200 ms.

Ejemplo real · La alerta de p99 de C01: buckets sumados entre pods, después el cuantil

YAMLreference/c01/monitoring/alerts.yml (líneas 26–39)
      # SLO de latencia: p99 server-side ≤ 200 ms. Histograma agregado entre pods ANTES del quantile
      # (sum by le): promediar p99 de cada pod da un número matemáticamente sin sentido.
      - alert: ClassifierHighP99Latency
        expr: |
          histogram_quantile(0.99,
            sum by (le) (rate(classifier_request_latency_seconds_bucket[5m]))
          ) > 0.2
        for: 10m  # 10 min sostenido: ignora picos de un GC o de un deploy
        labels:
          severity: page
        annotations:
          summary: "p99 del clasificador sobre 200 ms"
          description: "p99 = {{ $value | humanizeDuration }} (SLO 200 ms). Revisa classifier_preprocess_seconds vs classifier_inference_seconds para saber si es CPU o GPU."
          runbook_url: "https://runbooks.example.com/classifier/high-p99-latency"
  • Los bordes salen de main.py: buckets=(0.01, 0.025, 0.05, 0.075, 0.1, 0.15, 0.2, 0.3, 0.5, 1.0, 2.5). Hay bordes en 0.15, 0.2 y 0.3 s: si el p99 anda cerca del SLO (200 ms), cae en un bucket de 50 o 100 ms de ancho y la estimación es fina.
  • rate(…_bucket[5m]): cada bucket es un counter, y rate deja solo lo de los últimos 5 minutos. Sin rate, el p99 sería el de todo lo que pasó desde que arrancó cada pod (en el lab, 497.8 ms contra 49.8 ms de los últimos 5 minutos).
  • sum by (le) suma el mismo bucket de todos los pods (y de todos los valores de source) y conserva le. El comentario lo dice: promediar el p99 de cada pod «da un número matemáticamente sin sentido». En el lab, dos pods con p99 de 68.8 y 287.5 ms promedian 178 ms («cumplimos»), pero sumando buckets el p99 es 275 ms (el real, 260 ms).
  • > 0.2 está en segundos: las métricas de Prometheus van en unidades base. Si el p99 pasa de 2.5 s, histogram_quantile devuelve 2.5 (el último borde finito): la alerta igual dispara, pero el número no dice cuánto más. humanizeDuration lo escribe legible: 0.2134 → 213.4ms.
  • Sin tráfico en la ventana, el p99 es NaN y la alerta no dispara: una caída del tráfico se detecta con otra señal (por ejemplo, la tasa de requests contra la de la semana pasada, con offset 1w).

Cómo recordarla

rate, sum by (le), histogram_quantile, en ese orden y sin saltarte ninguno. Un histograma es como medir la estatura de un grupo con marcas en la puerta: «¿cuántos miden hasta 1.50 m, hasta 1.60, hasta 1.70?». Para el p99 buscas la marca donde ya pasaste el 99 % y estimas con una regla recta entre esa marca y la anterior: el resultado siempre queda entre las dos.

Trampa

agregar sin le. histogram_quantile(0.99, sum(rate(x_bucket[5m]))) junta todos los bordes en una sola serie sin le, histogram_quantile la descarta y el resultado sale vacío. En la UI de Prometheus 3 ves un aviso (bucket label "le" is missing or has a malformed value), pero una regla no muestra avisos: la alerta calla para siempre. La otra ya la viste en la hoja del gateway: promediar los p99 de cada pod (en el lab, 178 ms «cumple» y el real es 260 ms).

Laboratorio (opcional)3 pasos

Qué vas a provocar: Sacar el p99 de dos pods a mano, como histogram_quantile, ver por qué el promedio de los p99 engaña y los cuatro casos en que el número le miente a una alerta.

Necesitas: Python 3, sin paquetes extra. Ni Docker ni Prometheus.

  1. Paso 1
    mkdir -p ~/lab-prom && cd ~/lab-prom
    cat > hq.py <<'EOF'
    # hq.py: el p99 de dos pods con histogram_quantile, como lo calcula Prometheus (solo stdlib)
    import math
    
    BORDES = (0.01, 0.025, 0.05, 0.075, 0.1, 0.15, 0.2, 0.3, 0.5, 1.0, 2.5, math.inf)  # los de main.py, más +Inf
    PODS = {"pod-a": [0.03] * 48 + [0.06] * 2,                # 50 requests, todos rápidos
            "pod-b": [0.04] * 44 + [0.18] * 2 + [0.26] * 4}   # 50 requests, 4 lentos
    
    def acumulado(latencias):                        # lo que exporta /metrics: _bucket{le} cuenta las <= le
        return {b: float(sum(1 for x in latencias if x <= b)) for b in BORDES}
    
    def histogram_quantile(q, acum):                 # la regla de Prometheus para histogramas clásicos
        bordes = sorted(acum)
        if len(bordes) < 2 or bordes[-1] != math.inf or acum[math.inf] == 0:
            return math.nan                          # sin +Inf o sin observaciones: NaN
        r = q * acum[math.inf]                       # la posición buscada: 0.99 × total
        for i, b in enumerate(bordes):
            if acum[b] >= r:                         # el primer bucket que llega a r
                if b == math.inf:
                    return bordes[-2]                # cayó en +Inf: devuelve el último borde finito
                ini, antes = (bordes[i - 1], acum[bordes[i - 1]]) if i else (0.0, 0.0)
                return ini + (b - ini) * (r - antes) / (acum[b] - antes)  # línea recta dentro del bucket
    
    if __name__ == "__main__":
        pods = {p: acumulado(lat) for p, lat in PODS.items()}
        for p, acum in pods.items():
            print(f"{p}: " + " ".join(f"{'+Inf' if b == math.inf else b}:{c:.0f}" for b, c in acum.items()))
        p99 = {p: histogram_quantile(0.99, a) for p, a in pods.items()}
        print("p99 de cada pod:", ", ".join(f"{p} {v * 1000:.1f} ms" for p, v in p99.items()))
        print(f"promedio de los dos p99: {sum(p99.values()) / 2 * 1000:.1f} ms   <- no significa nada")
        suma = {b: pods["pod-a"][b] + pods["pod-b"][b] for b in BORDES}   # sum by (le)
        print("sum by (le):", " ".join(f"{'+Inf' if b == math.inf else b}:{c:.0f}" for b, c in suma.items()))
        print(f"r = 0.99 x {suma[math.inf]:.0f} = {0.99 * suma[math.inf]:.0f}: cae entre le=0.2 ({suma[0.2]:.0f}) y le=0.3 ({suma[0.3]:.0f})")
        for q in (0.5, 0.9, 0.99):
            print(f"histogram_quantile({q}, sum by (le)) = {histogram_quantile(q, suma) * 1000:.1f} ms")
        reales = sorted(PODS["pod-a"] + PODS["pod-b"])
        print(f"p99 real (el request 99 de 100, ordenados): {reales[98] * 1000:.0f} ms")
    EOF
    python3 hq.py

    Qué deberías ver: Los buckets acumulados de cada pod (pod-b: 0, 0, 44, 44, 44, 44, 46, 50…: le=0.3 ya incluye todo). p99 de pod-a: 68.8 ms; de pod-b: 287.5 ms; su promedio, 178.1 ms. sum by (le) suma bucket por bucket (… 0.2:96 0.3:100 …) y r = 99 cae entre esos dos. histogram_quantile da 38.6 ms (p50), 49.5 ms (p90) y 275.0 ms (p99); el p99 real es 260 ms, dentro del bucket (0.2, 0.3]. El promedio de los p99 lo escondía.

  2. Paso 2
    cat > casos.py <<'EOF'
    # casos.py: cuatro casos en los que histogram_quantile engaña a una alerta (solo stdlib)
    import math
    from hq import BORDES, acumulado, histogram_quantile
    
    def ms(v):
        return "NaN" if math.isnan(v) else f"{v * 1000:.1f} ms"
    
    # 1. Cuatro requests tardan 4 s: el p99 real es 4000 ms
    lentos = acumulado([0.03] * 96 + [4.0] * 4)
    print("1. cae en +Inf:", ms(histogram_quantile(0.99, lentos)), "(el real es 4000 ms)")
    # 2. Sin tráfico en la ventana: rate() da 0 en todos los buckets
    print("2. sin tráfico:", ms(histogram_quantile(0.99, {b: 0.0 for b in BORDES})), "-> NaN > 0.2 es falso: no dispara")
    # 3. sum(...) sin by (le): queda una sola serie, sin label le; histogram_quantile la
    #    descarta, así que no le queda nada que calcular
    series = [({}, sum(lentos.values()))]             # sum(rate(..._bucket[5m])): {} = sin labels
    con_le = [s for s in series if "le" in s[0]]
    print("3. sin le:", con_le, "(vacío): la alerta nunca dispara")
    # 4. Sin rate(): los buckets cuentan desde que arrancó el pod
    hora_lenta = acumulado([0.4] * 3600)              # la primera hora: 3,600 requests de 400 ms
    ultimos_5m = acumulado([0.03] * 300)              # los últimos 5 minutos: 300 requests de 30 ms
    desde_inicio = {b: hora_lenta[b] + ultimos_5m[b] for b in BORDES}
    print("4. sin rate(), desde que arrancó:", ms(histogram_quantile(0.99, desde_inicio)))
    print("   con rate(...[5m]), lo de ahora:", ms(histogram_quantile(0.99, ultimos_5m)))
    EOF
    python3 casos.py

    Qué deberías ver: 1. cae en +Inf: 2500.0 ms (el real es 4000 ms). 2. sin tráfico: NaN. 3. sin le: [] (vacío). 4. sin rate(), desde que arrancó: 497.8 ms; con rate(...[5m]), lo de ahora: 49.8 ms. Los cuatro dejan una alerta callada o equivocada sin dar ningún error.

  3. Paso 3
    cd ~ && rm -rf ~/lab-prom

    Qué deberías ver: Sin salida.

Cómo arreglarlo y confirmarlo

Cambia PODS en hq.py (por ejemplo, 6 requests de 0.26 s en pod-b) y vuelve a correrlo: el p99 estimado se mueve dentro de su bucket y el promedio de los p99 sigue sin significar nada. Para verlo con un Prometheus de verdad, el lab de Grafana (sección 9) consulta el histograma del propio Prometheus.

Probado de verdad en un Mac. Ejecutado en macOS con Python 3.9.6, 3.13.15 y 3.14.3: salidas idénticas. histogram_quantile sigue la regla documentada para histogramas clásicos: interpolación lineal dentro del bucket, 0 como borde inferior del primero, el último borde finito si cae en +Inf y NaN sin observaciones.

7

Recording rules y alerting rules: expr, for, labels y annotations

¿Cómo conviertes una consulta en una alerta que avisa a tiempo y sin falsas alarmas, y en una serie precalculada que se consulta barato?

Sintaxis con variables

groups:
  - name: grupo
    interval: intervalo
    rules:
      - record: nivel:métrica:operaciones
        expr: consulta

Una regla de grabación (recording rule) corre la consulta cada interval y guarda el resultado como una serie nueva con ese nombre: las alertas y los paneles leen esa serie, que sale barata. Sin interval usa el evaluation_interval global: 1m si nadie lo fija, 15 s en el prometheus.yml local de C01 y 30 s en EKS (el Kubernetes de AWS donde corre C01), porque es el default del Prometheus Operator, el programa que instala y configura Prometheus en el cluster. Las reglas de un grupo corren en orden, una tras otra.

      - alert: NombreDeAlerta
        expr: consulta > umbral
        for: espera
        keep_firing_for: retención
        labels:
          severity: severidad
        annotations:
          summary: "resumen"
          description: "detalle: {{ $value }}"
          runbook_url: "url"

Una regla de alerta (alerting rule) crea una alerta por cada serie que devuelve la expr. for: cuánto tiene que sostenerse antes de disparar; mientras, la alerta está pending (pendiente). Sin for, dispara en la primera evaluación en que la expr devuelve algo. keep_firing_for: cuánto sigue firing (disparada) después de que la condición se apaga, para que no parpadee; sin él, se resuelve en la primera evaluación que ya no cumple. labels deciden a quién le llega en Alertmanager (el que reparte los avisos, sección 8); annotations son el texto para la persona.

promtool check rules archivo.yml
promtool test rules pruebas.yml

check valida el YAML, el PromQL y las plantillas; sale con código 0 si todo está bien. test corre pruebas unitarias: series inventadas y las alertas que esperas a cierta hora. promtool viene en la imagen prom/prometheus.

apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
  name: nombre
  namespace: namespace
  labels:
    release: release
spec:
  groups:
    - name: grupo
      rules: […]

En Kubernetes con el Prometheus Operator (el controlador que configura Prometheus a partir de objetos del cluster), el mismo YAML va bajo spec de un PrometheusRule. La label release tiene que coincidir con el ruleSelector (el filtro de labels con que tu Prometheus elige sus reglas); en kube-prometheus-stack es el nombre con que instalaste el chart de Helm. Si no coincide, el Operator no lo carga y nadie avisa.

ALERTS{alertname="NombreDeAlerta", alertstate="estado"}

Prometheus escribe una serie ALERTS (vale 1) por cada alerta pending o firing: la puedes consultar y graficar. La página /alerts (puerto 9090) muestra cada regla como inactive, pending o firing.

Variables

VariableQué esEjemplo
gruponombre del grupo, único en el archivoclassifier-recording
intervalocada cuánto corre el grupo1m
nivel:métrica:operacioneslos labels que quedan, la métrica de origen y lo que se le hizo (la última operación primero)classifier:low_confidence_share:rate30m
consultala expresión PromQLhistogram_quantile(0.99, sum by (le) (rate(…[5m])))
NombreDeAlertaen CamelCase; nombra el síntomaClassifierHighP99Latency
umbralen la unidad base de la métrica0.2 (segundos)
esperael for10m
retenciónel keep_firing_for5m (C01 no lo usa)
severidadel valor con el que rutea tu Alertmanager. En C01: page despierta a alguien, warning se mira en horario laboral, ticket se investiga esa semanapage, warning o ticket
estadoestado de la alerta en la serie ALERTSpending o firing
resumen, detalle, urlqué pasa, el valor y por dónde empezar, y los pasossummary, description y runbook_url de C01
archivo, pruebasel archivo de reglas y el de pruebasalerts.yml, prueba.yml
nombre, namespace, releasenombre, namespace y label release del PrometheusRuleclassifier, classifier, kube-prometheus-stack
tcrucet_{\text{cruce}}cuándo la expr pasa el umbral; depende también de la ventana del rateunos minutos después de que empieza el problema
Δteval\Delta t_{\text{eval}}cada cuánto se evalúa el grupo15 s en el compose de C01; 30 s en EKS (default del Operator)
group_wait\texttt{group\_wait}espera de Alertmanager antes de la primera notificación de un grupo (sección 8)30 s por defecto
43.2 min\text{43.2 min}presupuesto de errores: lo que puede fallar en un mes sin romper el SLO (la meta) de 99.9 % a 30 días(1 − 0.999) × 43,200 min

Fórmulas

tpage≈tcruce+for+Δteval+group_wait=tcruce+10 min+30 s+30 s (EKS)t_{\text{page}} \approx t_{\text{cruce}} + \texttt{for} + \Delta t_{\text{eval}} + \texttt{group\_wait} = t_{\text{cruce}} + 10\,\text{min} + 30\,\text{s} + 30\,\text{s}\ (\text{EKS})
apagoˊn total: presupuesto gastado antes del page≈for43.2 min=5 min43.2 min≈11.6 %\text{apagón total: presupuesto gastado antes del page} \approx \frac{\texttt{for}}{43.2\ \text{min}} = \frac{5\ \text{min}}{43.2\ \text{min}} \approx 11.6\,\%
00=NaN  ⇒  avg_over_time(…[7d])=NaN durante 7 dıˊas\frac{0}{0} = \text{NaN} \;\Rightarrow\; \texttt{avg\_over\_time}(\ldots[7d]) = \text{NaN durante 7 días}
7 d÷1 min=10,080 muestras de la serie grabada por evaluacioˊn7\,\text{d} \div 1\,\text{min} = 10{,}080\ \text{muestras de la serie grabada por evaluación}

Las piezas

PiezaQué haceEjemplo
groups[].nameNombre del grupo, único en el archivo. Sus reglas corren en orden, con la misma hora de evaluación.classifier-recording y classifier-alerts
intervalCada cuánto corre el grupo. Si falta, el evaluation_interval global: 1m si nadie lo fija, 30 s con el Prometheus Operator.1m en classifier-recording
recordNombre de la serie que se graba: nivel:métrica:operaciones (con rate, la métrica pierde su _total). Se graba rate y no increase: la documentación lo pide para que todo quede por segundo.classifier:low_confidence_share:rate30m
alertNombre de la alerta: llega a Alertmanager como el label alertname.ClassifierHighP99Latency
exprLa consulta. En una alerta, cada serie del resultado es una alerta: por eso termina en una comparación que filtra.histogram_quantile(…) > 0.2
forCuánto tiene que sostenerse; mientras, pending. Si una evaluación sale bajo el umbral, vuelve a inactive y el conteo empieza de cero. Sin for, dispara en la primera evaluación.for: 10m
for + ventana del rateLa ventana y el for se suman: la ventana estira cada pico. Con [5m], un pico de 2 min al 10 % de 5xx deja la proporción sobre 1 % unos 6 min, así que una alerta con for: 5m dispara igual.ClassifierHigh5xxRate: [5m] y for: 5m
keep_firing_forCuánto sigue firing después de que la condición se apaga: evita resolver y redisparar en el borde.keep_firing_for: 5m
labelsLabels que se agregan a la alerta; Alertmanager rutea con ellos.severity: page
annotationsTexto para quien recibe la alerta: summary corto, description con valores y runbook_url, el link al runbook (la guía de pasos para resolverla).las tres en cada alerta de C01
$value · $labelsEn las plantillas: el valor de la expr y los labels de la serie que disparó. Después de un sum sin by, $labels ya no trae pod ni instance.{{ $labels.gpu_uuid }} en GPUSaturated
humanize*Funciones que formatean en las plantillas: humanizeDuration (0.2134 → 213.4ms), humanizePercentage (0.0123 → 1.23%).description de ClassifierHighP99Latency
Guarda de tráficoUna condición extra con and que exige tráfico mínimo: con 20 requests de madrugada, un error ya es el 5 %.and sum(rate(classifier_requests_total[5m])) > 1
ALERTSSerie que Prometheus crea por cada alerta pending o firing.ALERTS{alertstate="firing"}
promtoolcheck rules valida; test rules prueba con series inventadas antes del deploy.promtool check rules alerts.yml
PrometheusRuleEl mismo YAML como objeto de Kubernetes; el Operator lo carga si su label calza con el ruleSelector.release: kube-prometheus-stack
RecargaPrometheus vuelve a leer las reglas cuando recibe la señal SIGHUP (un aviso del sistema operativo) o un POST a /-/reload, si arrancó con --web.enable-lifecycle. Si un archivo tiene un error, se queda con las reglas anteriores.kill -HUP al proceso de Prometheus

ClassifierHighP99Latency · for: 10m · se evalúa cada 15 s en el compose y 30 s en EKS

12340.10.150.20.250.3umbral 0.2 sp99 (s)inactivependingfiringresolvedfor: 10 min seguidos051015202530minutos12340.10.150.20.250.3umbral 0.2 sp99 (s)pendingfiringfor: 10 min seguidos0102030minutos
  1. inactive0–3 min
  2. pending3–4 minprimera evaluación sobre 0.2 s
  3. inactive4–5 minuna evaluación bajo 0.2 s: el for empieza de cero
  4. pending5–15 min10 min seguidos sobre 0.2 s
  5. firing15–27 minPrometheus la manda a Alertmanager y la reenvía cada 1 min
  6. resolved27–30 minresolved: Alertmanager avisa la resolución si el receiver tiene send_resolved
  1. 12 mindeploy
  2. 215.5 minpage: firing + group_wait de 30 s
  3. 325 minrollback
  4. 427 minresolved (con keep_firing_for: 5m seguiría firing hasta el minuto 32)

ok · up · inactivependingfiring

ClassifierHighP99Latency (for: 10m) durante un deploy que sube el p99. Un punto por minuto, valores ilustrativos; Prometheus evalúa cada 15 s en el compose de C01 y cada 30 s en EKS. Una sola evaluación bajo 0.2 s reinicia el for; el page sale 10 min después de la última entrada a pending, más los 30 s de group_wait de Alertmanager.

Ejemplo real · Una regla de grabación y la alerta que la lee: la señal de calidad de C01

YAMLreference/c01/monitoring/alerts.yml (líneas 8–21 y 62–76; # … marca las omitidas)
groups:
  # ------------------------------------------------------------------ reglas de grabación
  - name: classifier-recording
    interval: 1m  # precalcula cada minuto: la alerta de confianza compara contra 7 días de esta serie
    rules:
      # Fracción de predicciones con score top-1 < 0.3 (el bucket le="0.3" del histograma).
      # Se graba porque avg_over_time(...[7d]) sobre una expresión cruda sería carísimo en cada evaluación.
      # `> 0` en el denominador: sin tráfico en 30 min no se graba nada. Sin él, 0/0 = NaN quedaría grabado
      # y avg_over_time(...[7d]) daría NaN durante 7 días: la alerta de abajo nunca dispararía.
      - record: classifier:low_confidence_share:rate30m
        expr: |
          sum(rate(classifier_top1_score_bucket{le="0.3"}[30m]))
            /
          (sum(rate(classifier_top1_score_count[30m])) > 0)
  # …
      # Señal de calidad SIN labels: si se duplica la fracción de predicciones de baja confianza,
      # algo cambió en la entrada (nueva cámara, nuevo tipo de producto, bug de preprocesamiento).
      - alert: ClassifierLowConfidenceSpike
        expr: |
          classifier:low_confidence_share:rate30m
            > 2 * avg_over_time(classifier:low_confidence_share:rate30m[7d])
          and sum(rate(classifier_top1_score_count[30m])) > 1
        # Necesita 7 días de historia de la regla de grabación (retención de Prometheus ≥ 7 d)
        for: 30m
        labels:
          severity: ticket
        annotations:
          summary: "Se duplicó la fracción de predicciones con score top-1 < 0.3"
          description: "Fracción actual = {{ $value | humanizePercentage }} (> 2× su promedio de 7 días). Compara por source (mobile/web) en los logs y revisa deploys recientes del gateway."
          runbook_url: "https://runbooks.example.com/classifier/low-confidence-spike"
  • El grupo classifier-recording corre cada minuto (interval: 1m) y graba classifier:low_confidence_share:rate30m: de las predicciones de los últimos 30 minutos, la fracción con score top-1 ≤ 0.3 (el bucket le="0.3" ÷ el total). El nombre dice el nivel (classifier), la métrica y la operación con su ventana.
  • (… > 0) en el denominador: sin tráfico, la comparación descarta el 0 y no se graba nada. Sin ese filtro, 0/0 = NaN quedaría grabado y el avg_over_time(…[7d]) de la alerta daría NaN durante 7 días: nunca dispararía.
  • La alerta compara la fracción actual con el doble de su promedio de 7 días. Sobre la serie grabada son 10,080 muestras (una por minuto); sobre la expresión cruda habría que recalcular dos rates de 30 minutos en cada punto de esos 7 días, en cada evaluación. Necesita 7 días de historia: la retención por defecto de Prometheus es 15 d, y la de kube-prometheus-stack, 10 d.
  • and sum(rate(classifier_top1_score_count[30m])) > 1 es la guarda de tráfico: de madrugada, con pocas predicciones, la fracción salta sola. for: 30m y severity: ticket: la calidad del modelo se revisa en horario laboral; nadie se despierta por esto.
  • Las annotations llevan el valor (humanizePercentage: 0.065 → 6.5%), qué mirar primero y el runbook. En el compose, Prometheus lee este archivo por rule_files de prometheus.yml; en EKS, el README lo envuelve en un PrometheusRule con release: kube-prometheus-stack. C01 lo validó con un parser de PromQL, no con promtool: el lab lo hace.

Cómo recordarla

expr dice qué, for dice cuánto tiempo, labels dicen a quién, annotations dicen qué hacer. Es la indicación médica junto a la cama: «si la temperatura pasa de 38 °C durante 10 minutos, llama a quien esté de guardia». La ronda de enfermería (el scrape) anota cada 15 s; una sola medición alta no basta (for), y la gravedad (severity) decide si es una llamada urgente (page) o una nota para la mañana (ticket).

Trampa

una alerta sobre una serie que no existe nunca dispara. Si Prometheus deja de leer el gateway, o alguien renombra una métrica en main.py, histogram_quantile(…) > 0.2 devuelve vacío: ni pending ni error, solo silencio. Por eso main.py dice que sus nombres son un contrato con alerts.yml, y por eso existen absent() y las alertas sobre up. Prueba las reglas con promtool test rules, que falla si la alerta esperada no sale.

Laboratorio (opcional)4 pasos

Qué vas a provocar: Validar las reglas reales de C01 con promtool y escribir una prueba unitaria que demuestre el for de TritonNotReady: pending a los 8 minutos, firing a los 10.

Necesitas: Docker Desktop corriendo, internet para bajar prom/prometheus:v3.13.3 (una vez) y el repo del curso (el paso 1 copia alerts.yml desde su raíz). Usa el sed de macOS (BSD): en Linux es sed -i sin las comillas vacías.

  1. Paso 1
    mkdir -p ~/lab-prom
    cp apps/ml-system-design/reference/c01/monitoring/alerts.yml ~/lab-prom/
    cd ~/lab-prom
    docker run --rm -v "$PWD":/w -w /w --entrypoint promtool prom/prometheus:v3.13.3 check rules alerts.yml

    Qué deberías ver: Checking alerts.yml y debajo SUCCESS: 7 rules found (1 de grabación y 6 alertas). El cp se corre desde la raíz del repo del curso. El README de C01 dice que nunca se corrió promtool: este es ese chequeo.

  2. Paso 2
    cat > prueba.yml <<'EOF'
    # prueba.yml: TritonNotReady (for: 5m) con una réplica menos desde el minuto 4
    rule_files:
      - alerts.yml
    evaluation_interval: 1m
    tests:
      - interval: 1m
        input_series:
          # 2 réplicas de 0 a 3 min; 1 réplica de 4 a 14 min
          - series: 'kube_deployment_status_replicas_available{namespace="classifier", deployment="triton"}'
            values: '2x3 1x10'
        alert_rule_test:
          - eval_time: 8m  # 4 min abajo: pending, todavía no firing
            alertname: TritonNotReady
            exp_alerts: []
          - eval_time: 10m  # ya pasó el for de 5 min (desde el minuto 4)
            alertname: TritonNotReady
            exp_alerts:
              - exp_labels:
                  severity: page
                  namespace: classifier
                  deployment: triton
                exp_annotations:
                  summary: "Menos de 2 pods de Triton listos"
                  description: "Réplicas disponibles = 1. Causas típicas: engine que no carga (versión de TensorRT), nodo GPU que no escala, OOM de GPU."
                  runbook_url: "https://runbooks.example.com/classifier/triton-not-ready"
    EOF
    docker run --rm -v "$PWD":/w -w /w --entrypoint promtool prom/prometheus:v3.13.3 test rules prueba.yml

    Qué deberías ver: SUCCESS. A los 8 minutos la serie lleva 4 minutos con menos de 2 réplicas: la alerta está pending, y una pending no cuenta en exp_alerts. A los 10 ya pasaron los 5 minutos del for (desde el minuto 4): firing, con los labels de la serie (namespace, deployment), severity: page y la description con $value = 1.

  3. Paso 3
    sed -i '' '101s/for: 5m/for: 10m/' alerts.yml
    docker run --rm -v "$PWD":/w -w /w --entrypoint promtool prom/prometheus:v3.13.3 test rules prueba.yml

    Qué deberías ver: FAILED, con alertname: TritonNotReady, time: 10m, la alerta que esperabas en exp y got:[] (ninguna): con for: 10m, a esa hora sigue pending. La línea 101 es el for de TritonNotReady. promtool sale con código 1: en CI, el PR no pasa.

  4. Paso 4
    cd ~ && rm -rf ~/lab-prom

    Qué deberías ver: Sin salida.

Cómo arreglarlo y confirmarlo

Si check rules dice FAILED, el mensaje trae la línea y lo que no pudo leer (YAML, PromQL o plantilla). Antes de cambiar un umbral o un for, agrega su prueba; en input_series también puedes escribir los buckets de un histograma, uno por le.

Verificado contra la documentación; no ejecutado aquí.

8

Alertmanager: ruteo, agrupación, inhibición, silencios y receivers

Cuando una regla pasa a firing, ¿cómo llega a la persona correcta, una sola vez, y no a las 3 a. m. por algo que puede esperar?

Sintaxis con variables

route:
  receiver: receiver_por_defecto
  group_by: [label, …]
  group_wait: espera
  group_interval: intervalo
  repeat_interval: repetición
  routes:
    - matchers: ['label="valor"']
      receiver: receiver
      continue: false

El árbol de ruteo: cada alerta entra por la raíz, que acepta todas, y baja al primer hijo cuyos matchers (filtros por label: =, !=, =~, !~) cumple. Con continue: true sigue probando los hermanos. Si no calza ningún hijo, la maneja el nodo donde está. Los hijos heredan lo que no definen. Por defecto: group_wait 30s, group_interval 5m, repeat_interval 4h.

receivers:
  - name: receiver
    pagerduty_configs:
      - routing_key_file: archivo
  - name: otro_receiver
    slack_configs:
      - api_url_file: archivo
        channel: 'canal'
        send_resolved: true

Un receiver es un destino con nombre: PagerDuty u Opsgenie (servicios que hacen sonar el teléfono de quien está de guardia, el on-call: eso es un page), Slack, Teams, email, un webhook (un POST a una URL tuya) y otros. Los secretos van en archivos (*_file), no en el YAML. send_resolved avisa también cuando la alerta se resuelve: true por defecto en PagerDuty y webhook, false en Slack y email.

inhibit_rules:
  - source_matchers: ['label="valor"']
    target_matchers: ['label=~"regex"']
    equal: [label, …]

Inhibición (inhibition): mientras haya una alerta activa que cumple source_matchers, se callan las que cumplen target_matchers y tienen los mismos valores en los labels de equal. Un label que falta cuenta como vacío: si falta en las dos, son iguales.

amtool silence add label=valor --duration=duración --comment="motivo" --alertmanager.url=http://localhost:9093
amtool silence query --alertmanager.url=http://localhost:9093
amtool silence expire id --alertmanager.url=http://localhost:9093

Un silencio (silence) calla por un tiempo las alertas que calzan con unos matchers: un mantenimiento, un falso positivo ya conocido. La alerta sigue firing en Prometheus; solo no se notifica. amtool exige un comentario y la duración por defecto es 1h. También se crean en la UI, en el puerto 9093.

amtool check-config alertmanager.yml
amtool config routes test --config.file=alertmanager.yml --tree label=valor …

Valida el archivo y te dice a qué receiver iría una alerta con esos labels, sin mandar nada: la prueba unitaria del ruteo.

alerting:
  alertmanagers:
    - static_configs:
        - targets: ['host:9093']

Esto va en el prometheus.yml y conecta Prometheus con Alertmanager. El prometheus.yml local de C01 no lo trae: en el compose las alertas se ven en :9090/alerts, pero no le llegan a nadie. En kube-prometheus-stack (el chart de Helm que instala todo esto en Kubernetes) lo configura el Operator.

Variables

VariableQué esEjemplo
receiver_por_defectoreceiver de la raíz: lo que no calce con ningún hijoequipo-slack
label, valor, regexlabel de la alerta y el valor o la regex que pide el matcherseverity, "page", "TritonQueueTimeHigh|GPUSaturated"
esperael group_wait30s
intervaloel group_interval5m
repeticiónel repeat_interval4h (1h en la ruta de page)
receiver, otro_receivernombres de destinos definidos en receiversoncall-pagerduty, equipo-slack
archivoarchivo con el secreto, montado desde un Secret/etc/alertmanager/secrets/pagerduty-routing-key
canalcanal de Slack#classifier
duración, motivocuánto dura el silencio y por qué2h, "mantenimiento del nodo GPU"
idel id que devuelve silence addun UUID
alertmanager.ymlel archivo de configuraciónalertmanager.yml
hostdónde escucha Alertmanageralertmanager (compose) o el Service del Operator

Fórmulas

tprimera notificacioˊn=tllega la primera alerta del grupo+group_wait (30 s)t_{\text{primera notificación}} = t_{\text{llega la primera alerta del grupo}} + \texttt{group\_wait}\ (30\,\text{s})
Δtentre avisos de un grupo≥group_interval=5 minΔtrecordatorio=repeat_interval=4 h\Delta t_{\text{entre avisos de un grupo}} \ge \texttt{group\_interval} = 5\,\text{min} \qquad \Delta t_{\text{recordatorio}} = \texttt{repeat\_interval} = 4\,\text{h}
Nnotificaciones=Ngrupos≠Nalertas:4 GPUs saturadas con group_by: [alertname, env]⇒1N_{\text{notificaciones}} = N_{\text{grupos}} \ne N_{\text{alertas}}: \quad 4\ \text{GPUs saturadas con } \texttt{group\_by: [alertname, env]} \Rightarrow 1

Las piezas

PiezaQué haceEjemplo
route (raíz)Entrada de todas las alertas: no lleva matchers y tiene el receiver de reserva.receiver: equipo-slack
routes · matchersHijos que eligen alertas por labels. Gana el primero que calza.severity="page" → oncall-pagerduty
continueSi es true, la alerta sigue por los hermanos y puede llegar a varios receivers. Default false.page al on-call y copia a Slack
group_byLabels que definen un grupo: las alertas con los mismos valores salen en una sola notificación. ['...'] agrupa por todos los labels (cada alerta va sola).[alertname, env]
group_waitEspera antes de la primera notificación de un grupo nuevo, para juntar las que llegan casi a la vez. Default 30s.30s
group_intervalEspera antes de avisar cambios en un grupo ya notificado (alertas nuevas o resueltas). Default 5m.5m
repeat_intervalCada cuánto se repite el aviso si nada cambió. Default 4h; kube-prometheus-stack trae 12h.1h en la ruta de page
receiversDestinos con nombre: pagerduty_configs, slack_configs, email_configs, webhook_configs, opsgenie_configs, msteams_configs y más.oncall-pagerduty
send_resolvedAvisa también cuando la alerta se resuelve. Default true en PagerDuty y webhook, false en Slack y email.send_resolved: true en Slack
inhibit_rulesMientras una alerta fuente esté activa, calla a las objetivo que coinciden en los labels de equal.TritonNotReady calla a TritonQueueTimeHigh
SilencioCalla por un tiempo las alertas que calzan con unos matchers, con autor y comentario. Se crea en la UI (:9093) o con amtool.amtool silence add alertname=GPUSaturated --duration=2h --comment="nodo en mantenimiento"
time_intervalsHorarios con nombre. Una ruta con active_time_intervals solo notifica en ese horario; con mute_time_intervals, calla en él.warnings de lunes a viernes, 9 a 18
Deduplicación y HAAlertas idénticas (mismos labels) se notifican una vez. En alta disponibilidad, Prometheus manda a todos los Alertmanagers, sin balanceador, y ellos se coordinan (puerto 9094).2 réplicas de Prometheus, 1 solo page
external_labelsLabels que Prometheus pega a cada alerta que manda: dicen de qué entorno o cluster viene.env: local en el prometheus.yml de C01
WatchdogAlerta que siempre está firing (expr: vector(1)) para probar que toda la cadena funciona. kube-prometheus-stack la trae y la manda al receiver 'null'; si la mandas a un servicio externo, ese servicio avisa cuando deja de llegar.severity: none
WebhookUn POST con JSON (version "4": receiver, status, alerts…) a tu URL: el receiver más fácil de probar.webhook_configs: - url: http://host.docker.internal:5001/page

route (la raíz): entran todas las alertas

si ningún hijo calza → equipo-slack

group_by [alertname, env]group_wait 30sgroup_interval 5mrepeat_interval 4h

  1. 1severity="page"

    → oncall-pagerduty

    repeat_interval: 1h

    • ClassifierHighP99Latency
    • ClassifierHigh5xxRate
    • TritonNotReady
  2. 2severity="warning"

    → equipo-slack

    active_time_intervals: horario-laboral

    • TritonQueueTimeHigh
    • GPUSaturated
  3. 3severity="ticket"

    → ml-tickets

    • ClassifierLowConfidenceSpike

Inhibición: mientras esté activa una alerta con alertname="TritonNotReady", se callan las que cumplen alertname=~"TritonQueueTimeHigh|GPUSaturated" y tienen el mismo env.

Un incidente (15:10): se cae el nodo de una de las 2 GPUs: queda 1 pod de Triton con todo el tráfico de la hora pico

  1. 15:15TritonNotReady page firing (for: 5m) page a oncall-pagerduty a las 15:15:30 (group_wait 30 s)
  2. 15:20TritonQueueTimeHigh warning firing (for: 10m) inhibida: no se notifica
  3. 15:25GPUSaturated warning firing (for: 15m) inhibida: no se notifica

3 alertas en firing → 1 notificación

page: despierta a alguienwarning: en horario laboralticket: esta semanainhibida: no se notifica

El árbol de la configuración de ejemplo, y lo que pasa cuando se cae el nodo de una de las dos GPUs en hora pico (horas ilustrativas): disparan tres alertas, pero llega un solo page. La cola y la GPU quedan inhibidas por TritonNotReady.

Ejemplo real · Las severidades de C01 convertidas en rutas, una inhibición y un horario

YAMLilustrativo: arma en YAML la columna «quién la recibe» de C01 monitoreo y la tabla de severidades de M12; C01 no trae alertmanager.yml
# alertmanager.yml ILUSTRATIVO: la columna «quién la recibe» de C01, en YAML (C01 no trae este archivo)
route:
  receiver: equipo-slack  # lo que no calce con ningún hijo va al canal del equipo
  group_by: [alertname, env]  # una notificación por alerta y entorno, no una por pod o por GPU
  group_wait: 30s
  group_interval: 5m
  repeat_interval: 4h
  routes:
    - matchers: ['severity="page"']  # p99, 5xx, Triton no listo: quien usa el servicio ya lo nota
      receiver: oncall-pagerduty
      repeat_interval: 1h
    - matchers: ['severity="warning"']  # cola de Triton, GPU: anticipan un page
      receiver: equipo-slack
      active_time_intervals: [horario-laboral]
    - matchers: ['severity="ticket"']  # calidad del modelo: se analiza con datos, no a las 3 a. m.
      receiver: ml-tickets
inhibit_rules:
  - source_matchers: ['alertname="TritonNotReady"']  # con Triton caído, la cola y la GPU son consecuencia
    target_matchers: ['alertname=~"TritonQueueTimeHigh|GPUSaturated"']
    equal: [env]
time_intervals:
  - name: horario-laboral
    time_intervals:
      - weekdays: ['monday:friday']
        times:
          - start_time: '09:00'
            end_time: '18:00'
        location: America/Mexico_City
receivers:
  - name: oncall-pagerduty
    pagerduty_configs:
      - routing_key_file: /etc/alertmanager/secrets/pagerduty-routing-key
  - name: equipo-slack
    slack_configs:
      - api_url_file: /etc/alertmanager/secrets/slack-webhook-url
        channel: '#classifier'
        send_resolved: true
  - name: ml-tickets
    webhook_configs:
      - url: http://ticket-bridge.monitoring.svc:8080/alertmanager
  • Las tres rutas salen de C01: page al on-call del servicio (quien está de guardia), warning al canal del equipo y ticket al equipo de ML. La raíz no tiene matchers (acepta todas) y su receiver es el de reserva para lo que no calce.
  • group_by: [alertname, env]: si se saturan las 4 GPUs a la vez, llega una notificación con las cuatro, no cuatro. group_wait: 30s espera a las hermanas antes del primer aviso; group_interval: 5m espacia los avisos de cambios; repeat_interval repite si nada cambió (4 h, 1 h en la ruta de page).
  • La inhibición: mientras TritonNotReady esté activa, se callan TritonQueueTimeHigh y GPUSaturated del mismo env. Con un pod de Triton caído, la cola y la GPU son consecuencia, y el on-call ya tiene el page de la causa. env sale de external_labels del prometheus.yml (env: local). No pongas en equal un label que la expr pierde al agregar: TritonQueueTimeHigh hace sum sin by y no tiene namespace, así que con equal: [namespace] nunca se inhibiría.
  • active_time_intervals deja que la ruta de warning avise solo de lunes a viernes, de 9 a 18 (hora de Ciudad de México). Los secretos van en archivos (routing_key_file, api_url_file), montados desde un Secret: nunca en el YAML que va a git.
  • Para que algo llegue, Prometheus necesita el bloque alerting.alertmanagers. El Alertmanager de kube-prometheus-stack viene con group_by: ['namespace'], repeat_interval: 12h, todo al receiver 'null' (que descarta) e inhibiciones por severidad: una critical calla a las warning e info con el mismo alertname y namespace, y una warning, a las info. Hay que agregarle tus receivers y tus rutas.

Cómo recordarla

Prometheus decide que algo está mal; Alertmanager decide a quién, cuándo y cuántas veces avisar. Es la central de enfermería de un hospital: junta los avisos de un mismo paciente en una sola llamada (agrupar), no llama por la presión si ya llamó por el paro (inhibir), no llama por la cama en mantenimiento (silenciar) y elige a quién llamar según la gravedad (rutear).

Trampa

creer que un silencio o una inhibición apaga la alerta. Prometheus la sigue evaluando y la ves firing en :9090/alerts; Alertmanager solo no la notifica. Un silencio largo «por si acaso» tapa la próxima falla real: dale una duración corta y un comentario. Y en Slack send_resolved es false por defecto: nadie se entera de que se resolvió.

Laboratorio (opcional)7 pasos

Qué vas a provocar: Correr Alertmanager con un webhook propio, mandarle alertas a mano como lo haría Prometheus y ver el ruteo, la inhibición y un silencio.

Necesitas: Docker Desktop corriendo (host.docker.internal lleva del contenedor a tu Mac), Python 3 e internet para bajar prom/alertmanager:v0.34.1 (una vez). Sin Prometheus: las alertas se mandan con curl, en el formato en que las manda Prometheus.

  1. Paso 1
    mkdir -p ~/lab-prom && cd ~/lab-prom
    cat > webhook.py <<'EOF'
    # webhook.py: un receiver de Alertmanager que solo imprime lo que le llega (solo stdlib)
    import json
    from http.server import BaseHTTPRequestHandler, HTTPServer
    
    class Receptor(BaseHTTPRequestHandler):
        def do_POST(self):
            cuerpo = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
            nombres = ", ".join(a["labels"]["alertname"] for a in cuerpo["alerts"])
            print(f"{self.path} receiver={cuerpo['receiver']} status={cuerpo['status']} alertas=[{nombres}]", flush=True)
            self.send_response(200)
            self.end_headers()
    
        def log_message(self, *args):                 # sin el log de acceso de http.server
            pass
    
    print("escuchando en :5001", flush=True)
    HTTPServer(("0.0.0.0", 5001), Receptor).serve_forever()
    EOF
    python3 webhook.py &

    Qué deberías ver: escuchando en :5001. Queda corriendo en segundo plano en esta terminal: lo que reciba se imprime aquí.

  2. Paso 2
    cat > alertmanager.yml <<'EOF'
    # alertmanager.yml del lab: los tres receivers son el mismo webhook.py, con rutas distintas
    route:
      receiver: equipo          # lo que no calce con ningún hijo (aquí, las warning)
      group_by: [alertname, env]
      group_wait: 10s           # corto para el lab (default 30s)
      group_interval: 30s       # (default 5m)
      repeat_interval: 1h
      routes:
        - matchers: ['severity="page"']
          receiver: oncall
        - matchers: ['severity="ticket"']
          receiver: ml-tickets
    inhibit_rules:
      - source_matchers: ['alertname="TritonNotReady"']
        target_matchers: ['alertname=~"TritonQueueTimeHigh|GPUSaturated"']
        equal: [env]
    receivers:
      - name: oncall
        webhook_configs:
          - url: http://host.docker.internal:5001/page
      - name: equipo
        webhook_configs:
          - url: http://host.docker.internal:5001/equipo
      - name: ml-tickets
        webhook_configs:
          - url: http://host.docker.internal:5001/ticket
    EOF
    docker run --rm -v "$PWD":/w --entrypoint amtool prom/alertmanager:v0.34.1 check-config /w/alertmanager.yml

    Qué deberías ver: Checking '/w/alertmanager.yml' con SUCCESS y, debajo de Found:, route, 1 inhibit rules y 3 receivers.

  3. Paso 3
    docker run --rm -v "$PWD":/w --entrypoint amtool prom/alertmanager:v0.34.1 config routes test --config.file=/w/alertmanager.yml severity=page alertname=TritonNotReady
    docker run --rm -v "$PWD":/w --entrypoint amtool prom/alertmanager:v0.34.1 config routes test --config.file=/w/alertmanager.yml severity=warning alertname=GPUSaturated

    Qué deberías ver: oncall y después equipo: la de warning no calza con ningún hijo y la maneja la raíz. Con --tree, además, el camino que siguió.

  4. Paso 4
    docker run -d --name am-lab -p 9093:9093 -v "$PWD":/etc/alertmanager prom/alertmanager:v0.34.1
    sleep 3
    curl -s -XPOST localhost:9093/api/v2/alerts -H 'Content-Type: application/json' -d '[
      {"labels": {"alertname": "TritonNotReady", "severity": "page", "env": "local"}},
      {"labels": {"alertname": "TritonQueueTimeHigh", "severity": "warning", "env": "local"}}]'

    Qué deberías ver: A los ≈ 10 s (el group_wait del lab) webhook.py imprime una sola línea: /page receiver=oncall status=firing alertas=[TritonNotReady]. La de la cola no llega a /equipo: está inhibida.

  5. Paso 5
    docker exec am-lab amtool alert query --inhibited --alertmanager.url=http://localhost:9093

    Qué deberías ver: Una tabla con TritonQueueTimeHigh: sigue activa en Alertmanager, solo que inhibida.

  6. Paso 6
    docker exec am-lab amtool silence add alertname=ClassifierLowConfidenceSpike --duration=15m --author=lab --comment="lab: probar silencios" --alertmanager.url=http://localhost:9093
    curl -s -XPOST localhost:9093/api/v2/alerts -H 'Content-Type: application/json' -d '[
      {"labels": {"alertname": "ClassifierLowConfidenceSpike", "severity": "ticket", "env": "local"}}]'
    sleep 15
    docker exec am-lab amtool alert query --silenced --alertmanager.url=http://localhost:9093

    Qué deberías ver: silence add imprime el id del silencio (un UUID). Después de 15 s no llegó nada a /ticket y la consulta --silenced muestra la alerta. Unos 5 minutos después del primer curl llega /page receiver=oncall status=resolved: una alerta mandada sin endsAt se resuelve sola a los 5 min (resolve_timeout), y el webhook avisa la resolución porque su send_resolved es true por defecto.

  7. Paso 7
    docker rm -f -v am-lab
    kill %1
    cd ~ && rm -rf ~/lab-prom

    Qué deberías ver: am-lab, y zsh avisa que terminó el job de webhook.py.

Cómo arreglarlo y confirmarlo

Si webhook.py no imprime nada, docker logs am-lab muestra el error de la notificación (connection refused si webhook.py no está corriendo). Si en el paso 4 llegan dos líneas, revisa que las dos alertas tengan el mismo env: equal compara ese label.

Verificado contra la documentación; no ejecutado aquí.

9

Grafana: data sources, dashboards, paneles, variables y dashboards como código

¿Cómo conviertes las consultas en un tablero que quien está de guardia entiende en 10 segundos, y cómo evitas que ese tablero se pierda o se rompa?

Sintaxis con variables

apiVersion: 1
datasources:
  - name: nombre
    type: prometheus
    uid: uid
    access: proxy
    url: http://host:9090
    isDefault: true
    jsonData:
      timeInterval: scrape_interval

Un data source (fuente de datos) le dice a Grafana dónde está Prometheus. Grafana no guarda métricas: le manda PromQL a Prometheus y dibuja la respuesta. Este archivo va en /etc/grafana/provisioning/datasources y se carga al arrancar. timeInterval es el «Scrape interval» del data source: ponle el de tu Prometheus (15s en C01) para que $__rate_interval salga bien.

rate(counter[$__rate_interval])
histogram_quantile(0.99, sum by (le) (rate(nombre_bucket[$__rate_interval])))

$__rate_interval es una variable de Grafana: una ventana que se ajusta al zoom y que nunca baja de 4 scrapes, así rate siempre tiene muestras. En las reglas de Prometheus (alertas y grabaciones) no existe: ahí va una ventana fija, como [5m].

Variable: variable · Query type: Label values · Label: label · Metric: métrica
sum by (model_version) (rate(classifier_requests_total{model_version=~"$model_version"}[$__rate_interval]))

Una variable es un menú arriba del dashboard (model_version, source) que se reemplaza en todas las consultas. Con selección múltiple o «All», Grafana une los valores con |: usa =~, no =.

Visualización: Time seriesStatGaugeHeatmapTable
Unit: smspercentunitreqps
Thresholds: Base verde · valor rojo

Cada panel es una o más consultas, una visualización, una unidad (con Unit s, Grafana muestra 0.2134 como 213 ms) y umbrales (thresholds: colores a partir de un valor). Los umbrales pintan; no le avisan a nadie. En Legend, {{source}} pone el valor de ese label como nombre de cada línea.

apiVersion: 1
providers:
  - name: nombre
    type: file
    folder: carpeta
    allowUiUpdates: false
    options:
      path: ruta_de_los_json

Dashboards como código: el JSON del dashboard (su modelo completo: paneles, consultas, variables) vive en git y Grafana lo carga de una carpeta. Con allowUiUpdates: false no se puede guardar un cambio desde la UI: el cambio entra por git. En kube-prometheus-stack (el chart de Helm que instala Prometheus, Alertmanager y Grafana en Kubernetes), un ConfigMap con la label grafana_dashboard: "1" hace lo mismo: Grafana lo encuentra y lo carga.

Variables

VariableQué esEjemplo
nombrenombre del data source o del providerPrometheus
uidid fijo del data source: los dashboards lo referencianprom-lab
hostdónde escucha Prometheus, visto desde Grafanaprometheus (compose), prometheus-operated.monitoring.svc (EKS)
scrape_intervalel intervalo de scrape real de tu Prometheus15s
counter, nombre_bucketun counter y los buckets de un histogramaclassifier_requests_total, classifier_request_latency_seconds_bucket
variable, label, métricanombre de la variable, label cuyos valores ofrece y métrica de donde los sacamodel_version, model_version, classifier_requests_total
valorvalor donde empieza el color del umbral0.2 (s) en el p99, 0.01 en la tasa de 5xx
carpetacarpeta de Grafana donde aparece el dashboardClasificador
ruta_de_los_jsoncarpeta del contenedor con los archivos JSON/var/lib/grafana/dashboards
ssel Scrape interval configurado en el data source15 s
$__interval\texttt{\$\_\_interval}el paso entre puntos de un panel: crece con el rango de tiempo y baja con el ancho del panelmás grande con 7 días que con 1 hora
$__rate_interval\texttt{\$\_\_rate\_interval}la ventana para rate en un panelnunca menos de 4s = 60 s

Fórmulas

$__rate_interval=max⁡($__interval+s,  4s)\texttt{\$\_\_rate\_interval} = \max(\texttt{\$\_\_interval} + s,\; 4s)
s=15 s:$__interval=30 s⇒60 s$__interval=2 min⇒135 ss = 15\,\text{s}: \quad \texttt{\$\_\_interval} = 30\,\text{s} \Rightarrow 60\,\text{s} \qquad \texttt{\$\_\_interval} = 2\,\text{min} \Rightarrow 135\,\text{s}
$__interval≈rango de tiempo del dashboardpuntos del panel(redondeado y nunca menor que s)\texttt{\$\_\_interval} \approx \frac{\text{rango de tiempo del dashboard}}{\text{puntos del panel}} \quad (\text{redondeado y nunca menor que } s)

Las piezas

PiezaQué haceEjemplo
Data sourceLa conexión a Prometheus: tipo y URL. Grafana consulta; no guarda métricas.http://prometheus-operated.monitoring.svc:9090
DashboardUna página de paneles con un rango de tiempo y un refresco comunes. Por dentro es un JSON.Servicio, Triton y GPU, Modelo, Negocio (C01)
RowUna fila plegable que agrupa paneles.Ahora · Latencia · Triton y GPU · Modelo
Time seriesLíneas en el tiempo: tasas, latencias, cola. La visualización por defecto.p50 y p99 del gateway
StatUn número grande con color por umbral (por defecto, el último valor).tasa de 5xx de ahora
Gauge · Bar gaugeEl valor contra un mínimo y un máximo que fijas.pods de Triton listos, de 0 a 4
HeatmapLa distribución en el tiempo: cada columna son los buckets de un histograma. La consulta va con Format: Heatmap.buckets de classifier_request_latency_seconds
TableUna fila por serie, con sus valores y labels.top 5 de callers (quién llama: el label source) por req/s
VariableUn menú del dashboard que se reemplaza en las consultas; tipo Query → Label values.$model_version: estable contra canary (la versión nueva a prueba)
$__rate_intervalLa ventana para rate que se ajusta al zoom y nunca baja de 4 scrapes.rate(classifier_requests_total[$__rate_interval])
UnitCómo se muestra el número: s, ms, percentunit (0.0–1.0 como %), reqps.percentunit en la tasa de 5xx
ThresholdsColores a partir de un valor; por defecto, base verde y 80 rojo. En Time series se pueden dibujar como líneas. No mandan alertas.línea roja en 0.2 s
LegendEl nombre de cada serie; {{label}} usa el valor de ese label.{{gpu_uuid}} en la utilización por GPU
AnnotationsMarcas verticales que salen de una consulta: deploys, reinicios, alertas.changes(process_start_time_seconds{job="gateway"}[5m]) > 0
ProvisioningData sources y dashboards desde archivos al arrancar: viven en git, no en un clic./etc/grafana/provisioning
ExplorePantalla para consultas sueltas, sin armar un panel: el lugar para probar una query.Builder o Code
Alertas de GrafanaGrafana también puede evaluar alertas. Elige un solo lugar para las reglas (en C01, Prometheus), versionado en git.alerts.yml de C01

A escala: 24 columnas de ancho; cada fila de la grilla, una unidad de alto.

  1. 1Requests/sStat · unit reqps

    PromQLsum(rate(classifier_requests_total{source=~"$source"}[$__rate_interval]))

  2. 25xx sobre válidosStat · unit percentunitbase: verde0.01: rojo

    PromQLsum(rate(classifier_requests_total{status=~"5..", source=~"$source"}[$__rate_interval])) / sum(rate(classifier_requests_total{status!~"4..", source=~"$source"}[$__rate_interval]))

  3. 3p99Stat · unit sbase: verde0.2: rojo

    PromQLhistogram_quantile(0.99, sum by (le) (rate(classifier_request_latency_seconds_bucket{source=~"$source"}[$__rate_interval])))

  4. 4Pods de Triton listosGauge · de 0 a 4base: rojo2: verde

    PromQLkube_deployment_status_replicas_available{namespace="classifier", deployment="triton"}

  5. 5p50 y p99 del gatewayTime series · unit s0.2: rojo, como línea

    PromQLhistogram_quantile(0.5, sum by (le) (rate(classifier_request_latency_seconds_bucket{source=~"$source"}[$__rate_interval])))

    PromQLhistogram_quantile(0.99, sum by (le) (rate(classifier_request_latency_seconds_bucket{source=~"$source"}[$__rate_interval])))

  6. 6p99 por tramo: preprocess contra inferenciaTime series · unit s

    PromQLhistogram_quantile(0.99, sum by (le) (rate(classifier_preprocess_seconds_bucket[$__rate_interval])))

    PromQLhistogram_quantile(0.99, sum by (le) (rate(classifier_inference_seconds_bucket[$__rate_interval])))

  7. 7Distribución de la latenciaHeatmap · Format: Heatmap

    PromQLsum by (le) (rate(classifier_request_latency_seconds_bucket{source=~"$source"}[$__rate_interval]))

  8. 8Cola contra cómputo (ms)Time series · unit ms20: rojo, como línea

    PromQLsum(rate(nv_inference_queue_duration_us{model="classifier"}[$__rate_interval])) / sum(rate(nv_inference_request_success{model="classifier"}[$__rate_interval])) / 1000

    PromQLsum(rate(nv_inference_compute_infer_duration_us{model="classifier"}[$__rate_interval])) / sum(rate(nv_inference_request_success{model="classifier"}[$__rate_interval])) / 1000

  9. 9Batch promedioStat

    PromQLsum(rate(nv_inference_count{model="classifier"}[$__rate_interval])) / sum(rate(nv_inference_exec_count{model="classifier"}[$__rate_interval]))

  10. 10Réplicas de Triton contra el máximo de KEDATime series

    PromQLkube_horizontalpodautoscaler_status_current_replicas{horizontalpodautoscaler="keda-hpa-triton"}

    PromQLkube_horizontalpodautoscaler_spec_max_replicas{horizontalpodautoscaler="keda-hpa-triton"}

  11. 11Utilización por GPUTime series · unit percentunit · Legend {{gpu_uuid}}0.85: rojo, como línea

    PromQLnv_gpu_utilization

  12. 12Baja confianza por model_versionTime series · unit percentunit · Legend {{model_version}}

    PromQLsum by (model_version) (rate(classifier_top1_score_bucket{le="0.3", model_version=~"$model_version"}[$__rate_interval])) / sum by (model_version) (rate(classifier_top1_score_count{model_version=~"$model_version"}[$__rate_interval]))

  13. 13Score top-1Heatmap · Format: Heatmap

    PromQLsum by (le) (rate(classifier_top1_score_bucket{model_version=~"$model_version"}[$__rate_interval]))

Variables: los menús de arriba

  • $source · Label values: source de classifier_requests_total · selección múltiple · con All
  • $model_version · Label values: model_version de classifier_requests_total · selección múltiple · con All

Anotaciones: marcas verticales en todos los paneles

  • Reinicios del gateway

    PromQLchanges(process_start_time_seconds{job="gateway"}[5m]) > 0

  • Alertas firing

    PromQLALERTS{alertstate="firing"}

umbral verdeumbral rojo (pinta; no avisa)

Un dashboard de servicio para el on-call de C01 (quien está de guardia), en la grilla de 24 columnas de Grafana (x, y, w y h en celdas): lo que importa ahora arriba, después la latencia por tramo, Triton y el modelo. Las consultas son reales sobre las métricas de C01; el layout es una propuesta, porque el repo no trae dashboards.

Ejemplo real · Las tres consultas de M12 que se vuelven paneles de C01

PromQLjs/content/m12-monitoreo.js, sección «Prometheus, Grafana y alertas por SLO»: p99.promql, error-ratio.promql y batch-promedio.promql, tal cual
# p99 del servicio en ventanas de 5 min: primero sumar buckets entre pods, después el cuantil
histogram_quantile(0.99,
  sum by (le) (rate(classifier_request_latency_seconds_bucket[5m])))

# Fracción de 5xx sobre requests válidos (los 4xx no cuentan en ningún lado)
sum(rate(classifier_requests_total{status=~"5.."}[5m]))
  / sum(rate(classifier_requests_total{status!~"4.."}[5m]))

# Batch promedio en Triton: inferencias por ejecución (cerca de max_batch_size = sin margen)
sum(rate(nv_inference_count{model="classifier"}[5m]))
  / sum(rate(nv_inference_exec_count{model="classifier"}[5m]))
  • C01 describe cuatro dashboards: Servicio (tasa, errores y latencia por tier, la capa: ALB, gateway y Triton, y por caller, con el desglose preprocess / cola / cómputo y marcadores de deploy), Triton y GPU (cola contra cómputo, batch promedio, utilización y memoria por GPU, réplicas contra el máximo de KEDA), Modelo (score top-1, baja confianza, top-20 de categorías, todo partido por model_version) y Negocio (diario). El repo no trae sus JSON: estas consultas son el punto de partida.
  • p99 → un Time series del dashboard Servicio, con Unit s y el umbral 0.2 dibujado como línea. En un panel conviene [$__rate_interval] en vez de [5m]: se ajusta al zoom. Para el valor de ahora, un Stat con la misma consulta.
  • Fracción de 5xx → Unit percentunit (0.012 se ve como 1.20%) y un umbral rojo en 0.01, el de la alerta. Partida por caller: sum by (source) arriba y abajo de la división.
  • Batch promedio → dashboard Triton y GPU. Si se acerca a max_batch_size, el dynamic batching (Triton juntando requests en un solo batch) ya no tiene margen (hoja de Triton). Va junto a la cola (la query de KEDA de la sección 5) y a nv_gpu_utilization con Legend {{gpu_uuid}}: una línea por GPU.
  • El top-20 de categorías no sale de Prometheus: el gateway no tiene un label de categoría (serían 500 valores, cada uno con sus propias series: demasiada cardinalidad). Sale de los logs de predicción, en otro data source; un mismo dashboard puede mezclar paneles de los dos.

Cómo recordarla

Grafana no mide nada: pregunta y dibuja. Es el tablero del auto: muestra lo que miden los sensores (Prometheus guarda, Grafana pinta). Cada panel es una consulta, una visualización, una unidad y unos umbrales; las variables cambian el filtro de todo el tablero a la vez, y el JSON del dashboard va a git como cualquier código.

Trampa

dejar rate(x[1m]) fijo en un panel. Con un rango de 7 días, Grafana pide un punto cada varios minutos, pero cada punto mira solo el último minuto: lo que pasó entre punto y punto no se ve. $__rate_interval crece con el paso, pero depende del Scrape interval del data source: si dice 15s y tu Prometheus lee cada 60 s, la ventana sale corta y el panel queda vacío. Y un umbral rojo no avisa a nadie: las alertas viven en las reglas de Prometheus.

Laboratorio (opcional)7 pasos

Qué vas a provocar: Levantar Prometheus y Grafana con el data source por provisioning, armar un dashboard con un p99, un Stat y un Heatmap sobre las métricas del propio Prometheus, y agregar una variable.

Necesitas: Docker Desktop corriendo, un navegador e internet para bajar prom/prometheus:v3.13.3 y grafana/grafana:13.2.4 (una vez).

  1. Paso 1
    mkdir -p ~/lab-prom/datasources && cd ~/lab-prom
    docker network create lab-prom
    docker run -d --name prom-lab --network lab-prom -p 9090:9090 prom/prometheus:v3.13.3

    Qué deberías ver: Un id de red y uno de contenedor. En http://localhost:9090/targets el job prometheus aparece UP: la configuración de ejemplo de esa imagen se lee a sí misma cada 15 s.

  2. Paso 2
    cat > datasources/prometheus.yml <<'EOF'
    apiVersion: 1
    datasources:
      - name: Prometheus
        type: prometheus
        uid: prom-lab
        access: proxy
        url: http://prom-lab:9090
        isDefault: true
        jsonData:
          timeInterval: 15s
    EOF
    docker run -d --name grafana-lab --network lab-prom -p 3000:3000 \
      -v "$PWD/datasources":/etc/grafana/provisioning/datasources grafana/grafana:13.2.4

    Qué deberías ver: Abre http://localhost:3000 con admin / admin (te pide cambiar la contraseña). En Connections → Data sources está Prometheus, marcado como provisionado; su botón de prueba da un aviso verde de que pudo consultar la API de Prometheus. La URL usa el nombre del contenedor (prom-lab): desde Grafana, localhost sería Grafana mismo.

  3. Paso 3
    curl -s localhost:9090/api/v1/query --data-urlencode 'query=sum by (handler) (rate(prometheus_http_requests_total[1m]))' | head -c 400; echo

    Qué deberías ver: Un JSON con status success y un resultado por handler: Prometheus contesta la misma PromQL que Grafana le va a mandar. Ahora, en Grafana, abre Explore en modo Code y pega sum by (handler) (rate(prometheus_http_requests_total[$__rate_interval])) y después histogram_quantile(0.99, sum by (le) (rate(prometheus_http_request_duration_seconds_bucket[$__rate_interval]))): una línea por handler (decenas, casi todas planas en 0: Prometheus crea la serie de cada ruta al arrancar; recarga su UI unas veces para crear tráfico) y el p99 de la API de Prometheus en segundos, el mismo patrón que la alerta de C01.

  4. Paso 4
    open http://localhost:3000/dashboard/new

    Qué deberías ver: Un dashboard nuevo en el navegador. Agrega tres visualizaciones: un Time series con el p99 del paso anterior (Unit: seconds (s); Thresholds: 0.2 en rojo, mostrado como línea), un Stat con sum(rate(prometheus_http_requests_total[$__rate_interval])) (Unit: requests/sec (rps)) y un Heatmap con sum by (le) (rate(prometheus_http_request_duration_seconds_bucket[$__rate_interval])), con Format: Heatmap en las opciones de la consulta. Guárdalo.

  5. Paso 5
    curl -s localhost:9090/api/v1/label/handler/values | head -c 300; echo

    Qué deberías ver: La lista de valores de handler: es lo que Grafana le pide a Prometheus para llenar una variable de tipo Label values. En el dashboard, agrega la variable handler (Query type: Label values, Label: handler, Metric: prometheus_http_requests_total, con Multi-value e Include All) y cambia la consulta del Stat a sum(rate(prometheus_http_requests_total{handler=~"$handler"}[$__rate_interval])). Al elegir dos handlers, el panel suma solo esos; con = en vez de =~, elegir dos lo deja vacío, porque Grafana los une con |.

  6. Paso 6
    pbpaste > ~/lab-prom/dashboard.json
    head -c 300 ~/lab-prom/dashboard.json

    Qué deberías ver: Antes, en la configuración del dashboard, abre JSON Model y copia todo. El archivo es el dashboard entero (paneles, consultas, variables): versionado en git, se carga por provisioning o por la API, y deja de depender de un clic.

  7. Paso 7
    docker rm -f -v prom-lab grafana-lab && docker network rm lab-prom
    cd ~ && rm -rf ~/lab-prom

    Qué deberías ver: prom-lab, grafana-lab y lab-prom: lo que borró.

Cómo arreglarlo y confirmarlo

Si la prueba del data source falla con connection refused, revisa que los dos contenedores estén en la red lab-prom y que la URL diga prom-lab, no localhost. Si el p99 sale vacío con una imagen de Prometheus más nueva: desde v3.14 su configuración de ejemplo activa scrape_native_histograms y la serie _bucket ya no existe; usa histogram_quantile(0.99, sum(rate(prometheus_http_request_duration_seconds[$__rate_interval]))).

Verificado contra la documentación; no ejecutado aquí.

10

SLOs: golden signals, RED y USE, error budget y alertas por burn rate

¿Qué mides para saber si el servicio cumple, cuánto puede fallar en un mes y cuándo una alerta tiene que despertar a alguien?

Sintaxis con variables

- record: servicio:error_ratio:rateventana
  expr: |
    sum(rate(contador{status=~"5.."}[ventana]))
      /
    sum(rate(contador{status!~"4.."}[ventana]))

El SLI (service level indicator: la fracción de eventos buenos sobre los válidos) se alerta al revés, por la fracción mala. Aquí, 5xx (errores del servidor) sobre los requests sin 4xx (errores del cliente, como una imagen inválida): los 4xx no cuentan ni arriba ni abajo. Va en una recording rule (una regla que corre la query en cada evaluación, cada 15 s en el compose del C01, y guarda el resultado como una serie nueva), una por ventana.

- record: servicio:slow_ratio:rateventana
  expr: |
    1 - (
      sum(rate(histograma_bucket{le="umbral"}[ventana]))
        /
      sum(rate(histograma_count[ventana]))
    )

El SLI de latencia sale de un solo bucket (el cajón del histograma que cuenta los requests que tardaron hasta ese borde): lo que no cayó en le="umbral" tardó más. El umbral tiene que ser un borde exacto del histograma; el gateway del C01 tiene 0.2 y 0.3.

- alert: Alerta
  expr: |
    servicio:error_ratio:ratelarga > (factor * presupuesto)
    and
    servicio:error_ratio:ratecorta > (factor * presupuesto)
  labels:
    severity: severidad

Suena si el error budget (el presupuesto de errores: lo que el SLO deja fallar) se quema factor veces más rápido de lo permitido en la ventana larga y en la corta. Las reglas multi-ventana del SRE Workbook (el libro de Google sobre cómo operar servicios confiables) no llevan for (la espera antes de disparar): las dos ventanas ya filtran los picos, y el libro desaconseja sumar esperas.

1 - (
  sum(increase(contador{status=~"5.."}[30d]))
    /
  sum(increase(contador{status!~"4.."}[30d]))
) / presupuesto

Cuánto queda del presupuesto del mes: 1 = intacto, 0 = gastado, negativo = SLO roto. Va arriba en el dashboard de quien está de guardia (el on-call). Necesita 30 días guardados: con la retención por defecto (15 días; 10 en kube-prometheus-stack) mide solo los que hay. Es cara: con scrape cada 15 s, cada serie aporta 172,800 muestras. Igual corre (tarda, pero corre), porque Prometheus las carga de a una serie; una subquery de 30 días (rate(…[5m])[30d:15s]: la query de adentro, recalculada cada 15 s) las junta todas a la vez y, con unas 300 series, pasa el tope de 50 millones (--query.max-samples): query processing would load too many samples. Lo barato: grabar la fracción con recording rules.

Variables

VariableQué esEjemplo
servicioprefijo de las series grabadas: el nivel del nombre nivel:métrica:operacionesclassifier
contadorel counter de requests, con el label statusclassifier_requests_total
ventana, larga, cortaventanas de rate(); la larga confirma, la corta se apaga rápido5m, 30m, 1h, 6h · larga 1h con corta 5m
histogramanombre base del histograma de latenciaclassifier_request_latency_seconds
umbralborde del bucket que separa rápido de lento, en segundos0.2 (p99 ≤ 200 ms) · 0.3 (el SLI de requisitos)
factorburn rate que dispara la alerta14.4, 6 o 1
presupuesto1 − SLO, como fracción0.001 (errores) · 0.01 (latencia: 99 % bajo 200 ms)
Alerta, severidadnombre de la alerta y a quién le llega: page despierta a quien está de guardia; ticket se revisa en horario laboralClassifierErrorBudgetFastBurn · page
SLO\text{SLO}la meta del SLI en una ventana de tiempo99.9 % en 30 días
burn rate\text{burn rate}qué tan rápido gastas el presupuesto; 1 = se acaba justo al final del mes5 % de errores = 50×
ggfracción del presupuesto del mes que aceptas gastar antes del aviso2 %, 5 % o 10 %
PPel período del SLO720 h (30 días)
LLla ventana larga de la alerta1 h, 6 h o 3 días
eela tasa de error real durante el incidente5 % (el deploy malo de M15)
tagotart_{\text{agotar}}en cuánto se acaba el presupuesto a ese ritmo50 h a 14.4×
tdetectart_{\text{detectar}}cuánto tarda la ventana larga en cruzar el umbral≈ 17 min con 5 % de errores

Fórmulas

presupuesto=1−SLO=1−0.999=0.001  ⇒  0.001×300 M=300,000 requests al mes\text{presupuesto} = 1 - \text{SLO} = 1 - 0.999 = 0.001 \;\Rightarrow\; 0.001 \times 300\ \text{M} = 300{,}000\ \text{requests al mes}
burn rate=tasa de error en la ventana1−SLOtagotar=720 hburn rate\text{burn rate} = \frac{\text{tasa de error en la ventana}}{1 - \text{SLO}} \qquad t_{\text{agotar}} = \frac{720\ \text{h}}{\text{burn rate}}
factor=g×PL=0.02×720 h1 h=14.4umbral=14.4×0.001=1.44 %\text{factor} = \frac{g \times P}{L} = \frac{0.02 \times 720\ \text{h}}{1\ \text{h}} = 14.4 \qquad \text{umbral} = 14.4 \times 0.001 = 1.44\,\%
tdetectar=1−SLOe×L×factor=0.0010.05×60 min×14.4≈17 mint_{\text{detectar}} = \frac{1 - \text{SLO}}{e} \times L \times \text{factor} = \frac{0.001}{0.05} \times 60\ \text{min} \times 14.4 \approx 17\ \text{min}

Las piezas

PiezaQué haceEjemplo
Golden signalsLas cuatro señales del libro de SRE de Google: latencia, tráfico, errores y saturación (qué tan lleno está el servicio).un panel por señal y por tier
REDRate, Errors, Duration: requests por segundo, errores y latencia. La versión corta para servicios que responden requests.gateway: req/s, 5xx, p99
USEUtilization, Saturation, Errors: cuánto se usa, cuánto trabajo queda esperando y cuánto falla cada recurso (CPU, memoria, GPU, disco).GPU: utilización y cola de Triton
SLILa medida: eventos buenos sobre eventos válidos. Para alertar se usa la fracción mala.requests sin 5xx y en ≤ 300 ms ÷ requests sin 4xx
SLOLa meta interna del SLI en una ventana. Sale de requisitos, no de lo que el sistema logra hoy.99.9 % en 30 días · p99 ≤ 200 ms
SLAUn contrato con créditos o multas si no se cumple. Más laxo que el SLO, para tener margen.el C01 no tiene: sus callers son internos
Error budgetLo que el SLO deja fallar: 1 − SLO. Se gasta en deploys y experimentos; si se acaba, se congelan los lanzamientos riesgosos.0.1 % = 300,000 requests al mes
Burn rateTasa de error ÷ presupuesto. A 1× el presupuesto dura justo el mes; a 14.4×, 50 h.5 % de errores = 50×
Ventana larga y cortaLa larga confirma que el gasto es real; la corta (1/12 de la larga) hace que la alerta se apague minutos después del arreglo.1 h y 5 min · 6 h y 30 min · 3 días y 6 h
14.4× · 6× · 1×Los tres pares del SRE Workbook para 99.9 %: 2 % del presupuesto en 1 h (page), 5 % en 6 h (page) y 10 % en 3 días (ticket).el C01 manda el de 6× a ticket: existe el selector manual
Recording rule por ventanaUna serie grabada por ventana: cada alerta compara dos números ya calculados en vez de recalcular horas de datos.classifier:error_ratio:rate1h
Mínimo de tráficoCon pocos requests, un solo error es un porcentaje enorme. Exige un caudal mínimo antes de alertar.and sum(rate(classifier_requests_total[5m])) > 1
Política de error budgetUn acuerdo previo con producto: qué se hace con más de 50 %, entre 10 y 50 % y con menos de 10 % de presupuesto.bajo 10 %: solo cambios de confiabilidad
Sloth · PyrraHerramientas que generan todas estas reglas desde una definición corta del SLO.un YAML con el objetivo y el SLI

SLO 99.9 % en 30 díaspresupuesto 0.1 %de 300,000,000 requests al mes: 300,000 pueden fallar

0 %25 %50 %75 %100 %051015202530días del mespresupuesto que queda1× · 30 días0 %25 %50 %75 %100 %051015202530días del mesqueda1× · 30 días
  • 1×: dura justo los 30 días
  • 6×: se acaba en 5 días
  • 14.4×: se acaba en 50 h

Las alertas de dos ventanas: la larga confirma, la corta (1/12) se apaga rápido

  1. 14.4×pagesuena si el error pasa 1.44 % en 1h y en 5m

    gasta el 2 % del presupuesto del mes antes de sonar

  2. 6×page (ticket en el C01)suena si el error pasa 0.6 % en 6h y en 30m

    gasta el 5 % del presupuesto del mes antes de sonar

  3. 1×ticketsuena si el error pasa 0.1 % en 3d y en 6h

    gasta el 10 % del presupuesto del mes antes de sonar

Un deploy malo, minuto a minuto (a escala, de 0 a 170 min)

  1. el deploy malo: 5 % de errores (50×)

    120 min · gasta 13.9 %

  2. 14.4× (1 h y 5 min)

    suena a los 18 min · 2.1 % gastado

  3. 6× (6 h y 30 min)

    suena a los 44 min · 5.1 % gastado

  4. 1× (3 d y 6 h)

    suena a los 87 min · 10.1 % gastado

  5. umbral del C01 (1 % durante 5 min)

    suena a los 7 min

  6. la de 14.4× se apaga después del arreglo

    con ventana corta: +4 min · solo con 1 h: +43 min

1×: dura el mes6×14.4× o más: pageel umbral fijo del C01el arreglo

El presupuesto del mes del C01 (300,000 requests fallidos) gastado a tres ritmos, y un deploy malo con 5 % de errores durante 2 h: la alerta de 14.4× suena a los 18 min y se apaga 4 min después del arreglo gracias a la ventana corta; solo con la de 1 h, se apagaría 43 min después del arreglo. Números del lab de esta sección.

Ejemplo real · C01: las alertas por burn rate del curso para el SLO de 99.9 %

YAMLM12 · Prometheus, Grafana y alertas por SLO (slo-rules.yml del curso, extracto; la línea # … resume las tres reglas omitidas)
# Parte de errores del SLO de c01: 99.9 % a 30 días (su SLI también exige ≤ 300 ms) → presupuesto 0.1 %
groups:
  - name: classifier-slo-recording
    rules:
      # Una regla por ventana: la alerta compara ventanas ya calculadas (barato y legible)
      - record: classifier:error_ratio:rate5m
        expr: |
          sum(rate(classifier_requests_total{status=~"5.."}[5m]))
            / sum(rate(classifier_requests_total{status!~"4.."}[5m]))
      # … classifier:error_ratio:rate30m, :rate1h y :rate6h: la misma expr con [30m], [1h] y [6h]
  - name: classifier-slo-alerts
    rules:
      # 14.4× durante 1 h = 2 % del presupuesto mensual; la ventana de 5 min hace que se apague rápido
      - alert: ClassifierErrorBudgetFastBurn
        expr: |
          classifier:error_ratio:rate1h > (14.4 * 0.001)
          and classifier:error_ratio:rate5m > (14.4 * 0.001)
        labels:
          severity: page
        annotations:
          summary: "Quemando el presupuesto de errores 14.4× más rápido de lo permitido"
          description: "Error ratio 1 h = {{ $value | humanizePercentage }} (umbral 1.44 %)."
          runbook_url: "https://runbooks.example.com/classifier/error-budget-burn"
      # 6× durante 6 h = 5 % del presupuesto; el SRE Workbook lo manda a page, c01 a ticket (hay fallback)
      - alert: ClassifierErrorBudgetSlowBurn
        expr: |
          classifier:error_ratio:rate6h > (6 * 0.001)
          and classifier:error_ratio:rate30m > (6 * 0.001)
        labels:
          severity: ticket
        annotations:
          summary: "Quemando el presupuesto de errores 6× más rápido de lo permitido"
          runbook_url: "https://runbooks.example.com/classifier/error-budget-burn"
  • Una regla de grabación por ventana (5m, 30m, 1h y 6h): cada alerta compara dos series ya calculadas. Sin ellas, cada evaluación recalcularía rate() sobre 6 h de datos crudos de todos los pods.
  • 14.4 * 0.001 es 1.44 %: más de 1.44 % de 5xx en la última hora y en los últimos 5 min. Con el deploy malo de M15 (5 % de errores) suena a los 17–18 min, con ≈ 2 % del presupuesto gastado, y se apaga 4 min después del arreglo. Lo calculas en el lab.
  • La de 6× va a ticket y no a page: el C01 no está en el camino crítico (quien publica puede elegir la categoría a mano). El SRE Workbook la manda a page.
  • Es solo la parte de errores. El SLI de requisitos también exige ≤ 300 ms: la fracción lenta sale del bucket le="0.3" con la segunda plantilla. Sumar las dos fracciones puede contar dos veces un 5xx que además tardó más de 300 ms; como el gateway se da 250 ms por request (su deadline: el tiempo máximo que se permite), casi no pasa.
  • Comparada con ClassifierHigh5xxRate de alerts.yml (más de 1 % durante 5 min): ese umbral fijo es un burn rate de 10×. Con 5 % de errores suena antes (a los 7 min, contra 18); en una caída total, no: la de 14.4× suena al primer minuto y el for: 5m retrasa la fija hasta el minuto 6. Y la fija nunca suena con un 0.8 % sostenido, que agota el mes en 90 h; la de 6× lo atrapa a las 4.5 h.
  • Sin tráfico, la fracción es 0/0 = NaN y la comparación da falso: no dispara. Con muy poco tráfico, en cambio, un error mueve mucho la fracción; el extracto del curso no trae el mínimo de requests que sí usa ClassifierHigh5xxRate.

Cómo recordarla

14.4 en 1 h, 6 en 6 h, 1 en 3 días: el 2 %, el 5 % y el 10 % del presupuesto del mes. Cada ventana larga lleva una corta de 1/12 (5 min, 30 min, 6 h): la larga dice que el gasto es real; la corta, que sigue pasando.

Trampa

creer que una alerta de umbral fijo ya es el SLO. ClassifierHigh5xxRate (más de 1 % durante 5 min) es un burn rate de 10×: un 0.8 % sostenido nunca la dispara y se come el presupuesto del mes en menos de 4 días. Y con poco tráfico pasa lo contrario: un solo error ya es un porcentaje enorme, así que exige un mínimo de requests.

Laboratorio (opcional)5 pasos

Qué vas a provocar: Calcular el presupuesto del C01 y los tres factores del SRE Workbook, y ver minuto a minuto cuándo dispara y cuándo se apaga cada alerta en tres incidentes distintos.

Necesitas: Python 3 sin paquetes extra (el python3 de macOS sirve). Ni Docker ni cluster.

  1. Paso 1
    mkdir -p ~/lab-prom && cd ~/lab-prom
    cat > burn.py <<'EOF'
    # burn.py: el presupuesto de errores del C01 y las alertas por burn rate del SRE Workbook (solo stdlib)
    import sys
    
    SLO = 0.999                  # 99.9 % de requests válidos sin 5xx, en 30 días (requisitos del C01)
    PRESUPUESTO = 1 - SLO        # la fracción que puede fallar: 0.1 %
    PERIODO_H = 30 * 24          # 720 h
    REQ_MIN = 10_000_000 / 1440  # 10 M requests por día, parejos: ≈ 6,944 por minuto
    MES = REQ_MIN * 60 * PERIODO_H * PRESUPUESTO   # requests que pueden fallar en el mes
    
    if len(sys.argv) == 1:  # sin argumentos: la tabla del SRE Workbook
        print(f"presupuesto: {PRESUPUESTO:.1%} → {MES:,.0f} requests fallidos al mes")
        print("severidad  gasta  larga  corta  factor  umbral  se acaba en")
        for sev, gasto, larga_h, corta in (("page", 0.02, 1, "5m"), ("page", 0.05, 6, "30m"), ("ticket", 0.10, 72, "6h")):
            factor = gasto * PERIODO_H / larga_h   # qué tan rápido hay que quemar para gastar eso en la ventana larga
            print(f"{sev:9}  {gasto:4.0%}  {larga_h:4}h  {corta:>5}  {factor:6.1f}  {factor * PRESUPUESTO:6.2%}  {PERIODO_H / factor:6.0f} h")
        sys.exit()
    
    tasa, dura = float(sys.argv[1]), int(sys.argv[2])   # tasa de error constante durante "dura" minutos; después, 0
    fin = dura + 3 * 1440
    malo = [tasa if m < dura else 0.0 for m in range(fin)]          # una fracción de error por minuto
    acum = [0.0]
    for x in malo:
        acum.append(acum[-1] + x)
    def frac(t, ventana):  # fracción de error de los últimos "ventana" minutos hasta el minuto t (como rate(...[ventana]))
        ini = max(0, t + 1 - ventana)
        return (acum[t + 1] - acum[ini]) / ventana   # antes del minuto 0 no hubo errores
    def primero(cond, desde=0):
        return next((t for t in range(desde, fin) if cond(t)), None)
    def gastado(t):  # fracción del presupuesto del mes gastada hasta el minuto t
        return REQ_MIN * acum[t + 1] / MES
    
    print(f"incidente: {tasa:.1%} de errores durante {dura} min = burn rate {tasa / PRESUPUESTO:g}×; "
          f"a ese ritmo el mes se acaba en {PERIODO_H / (tasa / PRESUPUESTO):g} h")
    reglas = (("14.4× (1 h y 5 min)", 60, 5, 14.4), ("6× (6 h y 30 min)", 360, 30, 6), ("1× (3 d y 6 h)", 4320, 360, 1))
    for nombre, larga, corta, f in reglas:
        u = f * PRESUPUESTO
        t = primero(lambda t: frac(t, larga) > u and frac(t, corta) > u)
        print(f"  {nombre:20} " + ("nunca dispara" if t is None or t >= dura else f"dispara a los {t + 1} min, con {gastado(t):.1%} del mes gastado"))
    # La alerta de umbral del C01: 5xx > 1 % en rate(...[5m]) sostenido 5 min (for: 5m)
    t = primero(lambda t: t >= 5 and all(frac(s, 5) > 0.01 for s in range(t - 5, t + 1)))
    print(f"  {'umbral C01 (1 %, 5m)':20} " + ("nunca dispara" if t is None or t >= dura else f"dispara a los {t + 1} min"))
    if tasa / PRESUPUESTO > 14.4:  # cuándo se apaga la de 14.4× después del arreglo
        u = 14.4 * PRESUPUESTO
        y = primero(lambda t: not (frac(t, 60) > u and frac(t, 5) > u), dura)
        solo = primero(lambda t: not frac(t, 60) > u, dura)
        print(f"  tras el arreglo (minuto {dura}): se apaga a los {y - dura + 1} min gracias a la ventana corta; solo con la de 1 h, a los {solo - dura + 1}")
    EOF
    python3 burn.py

    Qué deberías ver: presupuesto: 0.1% → 300,000 requests fallidos al mes, y la tabla: page 2% 1h 5m 14.4 1.44% 50 h; page 5% 6h 30m 6.0 0.60% 120 h; ticket 10% 72h 6h 1.0 0.10% 720 h. El factor es el gasto aceptado × 720 h ÷ la ventana larga.

  2. Paso 2
    python3 burn.py 0.05 120

    Qué deberías ver: Un deploy malo: 5% de errores durante 120 min = burn rate 50×. La de 14.4× dispara a los 18 min con 2.1% del mes gastado; la de 6×, a los 44 min; la de 1×, a los 87 min; el umbral del C01, a los 7 min. Tras el arreglo, la de 14.4× se apaga a los 4 min gracias a la ventana corta; solo con la de 1 h, a los 43.

  3. Paso 3
    python3 burn.py 0.008 2880

    Qué deberías ver: 0.8% de errores durante 2 días = 8×: el mes se acaba en 90 h. La de 14.4× y el umbral del C01 nunca disparan; la de 6× dispara a los 271 min (4.5 h) con 5.0% gastado y la de 1×, a los 541 min.

  4. Paso 4
    python3 burn.py 1 30

    Qué deberías ver: Una caída total de 30 min (1000×): la de 14.4× dispara al primer minuto con 2.3% gastado, el umbral del C01 a los 6 min, y la de 14.4× se apaga 5 min después del arreglo (solo con la de 1 h, a los 60).

  5. Paso 5
    cd ~ && rm -rf ~/lab-prom

    Qué deberías ver: Sin salida.

Cómo arreglarlo y confirmarlo

Prueba otros incidentes, por ejemplo python3 burn.py 0.02 60 (20×) o python3 burn.py 0.003 4320 (3×). El modelo supone tráfico parejo y una evaluación por minuto; Prometheus evalúa cada 15 s, así que en la realidad los minutos se corren un poco.

Probado de verdad en un Mac. Ejecutado en macOS con Python 3.14.3, 3.13.16 y 3.9.6: salidas idénticas. Los factores, las ventanas cortas de 1/12 y la fórmula de detección salen del capítulo Alerting on SLOs del SRE Workbook (tabla de parámetros para 99.9 %); el umbral de 1 % durante 5 min es el de ClassifierHigh5xxRate en monitoring/alerts.yml.

11

En Kubernetes: kube-prometheus-stack, el Operator, ServiceMonitor, PrometheusRule y KEDA

En un cluster, ¿cómo encuentra Prometheus tus pods, quién mide los nodos, los contenedores y los objetos, y cómo lee KEDA una query para escalar?

Sintaxis con variables

helm install release oci://ghcr.io/prometheus-community/charts/kube-prometheus-stack \
  --version version -n ns --create-namespace

kube-prometheus-stack es un chart de Helm (un paquete de manifests con valores por defecto) que instala el Prometheus Operator (un controlador que convierte objetos de Kubernetes en configuración de Prometheus) con sus CRDs (las definiciones de los tipos nuevos: ServiceMonitor, PodMonitor, PrometheusRule…), Prometheus, Alertmanager, Grafana, kube-state-metrics, node_exporter, reglas y dashboards. Fija --version.

apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
  name: nombre
  namespace: ns_app
  labels:
    release: release
spec:
  selector:
    matchLabels:
      app.kubernetes.io/name: app
  endpoints:
    - port: puerto
      path: /metrics
      interval: intervalo

Un ServiceMonitor le dice al Operator: lee /metrics de los pods que están detrás de los Services con estas labels, en el puerto con este nombre. La label release tiene que coincidir con el serviceMonitorSelector de tu Prometheus (el filtro con el que elige ServiceMonitors); si no, se ignora sin avisar.

apiVersion: monitoring.coreos.com/v1
kind: PodMonitor
metadata:
  name: nombre
  namespace: ns_app
  labels:
    release: release
spec:
  selector:
    matchLabels:
      app.kubernetes.io/name: app
  podMetricsEndpoints:
    - port: puerto_pod
      interval: intervalo

Un PodMonitor elige pods directo, sin Service: sirve para workers o jobs que nadie llama. port es el nombre del puerto del contenedor; si el pod no declara ese nombre, no aparece ningún target.

apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
  name: nombre
  namespace: ns_app
  labels:
    release: release
spec:
  groups:
    - name: grupo
      rules:
        - alert: Alerta
          expr: expr
          for: for
          labels:
            severity: severidad

Las reglas van como PrometheusRule: el Operator no lee archivos sueltos. El spec es el mismo YAML de groups de alerts.yml, también con la label release (para el ruleSelector, el filtro de reglas). Un webhook del chart (un chequeo que el API server hace antes de guardar el objeto) rechaza las reglas que no parsean: Helm muestra Rules are not valid; kubectl apply, "prometheusrules" is invalid y la regla que falló.

kubectl get servicemonitors,podmonitors,prometheusrules -A --show-labels
kubectl get prometheus -n ns -o jsonpath='{.items[0].spec.serviceMonitorSelector}'
kubectl port-forward -n ns svc/prometheus-operated 9090

Primero, ¿existe el objeto y trae la label? Después, ¿qué selector usa tu Prometheus? prometheus-operated es el Service que crea el Operator: el mismo que consulta KEDA en el C01.

Variables

VariableQué esEjemplo
releasenombre del release de Helm; el chart lo usa como label para elegir objetoskube-prometheus-stack
versionversión fija del chart92.11.0 (trae el Operator v0.94.1)
nsnamespace de Prometheusmonitoring (el C01 lo da por hecho)
nombre, ns_appnombre y namespace del ServiceMonitor, PodMonitor o PrometheusRulegateway, triton · classifier
applabel que comparten el Service (o los pods) que quieres medirgateway
puertoel NOMBRE del puerto del Service, no el númerohttp (8080) · metrics (8002)
puerto_podel nombre del puerto del contenedormetrics
intervalocada cuánto se lee; sin él, el de Prometheus (30 s con el Operator)15s
grupo, Alerta, expr, for, severidadlo mismo que en alerts.ymlclassifier-alerts · TritonNotReady · … · 5m · page
Ready\text{Ready}réplicas listas del Deployment2
valor de la query\text{valor de la query}lo que devuelve la query de KEDA: un solo número25 (ms de espera en la cola)
threshold\text{threshold}el objetivo del trigger10 (ms)
ventana\text{ventana}el rango de rate() dentro de la query: Prometheus necesita 2 muestras adentro; la regla práctica de Grafana ($__rate_interval) pide 4 intervalos[2m] en KEDA (8 muestras), [5m] en las alertas (20)
interval\text{interval}intervalo de scrape del target15 s (los ServiceMonitors del C01)

Fórmulas

deseadas=⌈Ready×valor de la querythreshold⌉=⌈2×2510⌉=5  →  4 (maxReplicaCount)\text{deseadas} = \left\lceil \text{Ready} \times \frac{\text{valor de la query}}{\text{threshold}} \right\rceil = \left\lceil 2 \times \frac{25}{10} \right\rceil = 5 \;\rightarrow\; 4\ (\text{maxReplicaCount})
muestras≈ventanainterval≥2 (mıˊnimo de Prometheus)ventana≥4×interval (regla de Grafana)[2m]≥4×15 s=1 min\text{muestras} \approx \frac{\text{ventana}}{\text{interval}} \ge 2\ (\text{mínimo de Prometheus}) \qquad \text{ventana} \ge 4 \times \text{interval}\ (\text{regla de Grafana}) \qquad [2\text{m}] \ge 4 \times 15\ \text{s} = 1\ \text{min}

Las piezas

PiezaQué haceEjemplo
kube-prometheus-stackChart de Helm con el Operator, Prometheus, Alertmanager, Grafana, kube-state-metrics y node_exporter, más reglas y dashboards por defecto.chart 92.11.0 (Operator v0.94.1)
Prometheus OperatorControlador que vigila ServiceMonitors, PodMonitors y PrometheusRules y reescribe la config de Prometheus. No le escribes prometheus.yml a mano.deploy kube-prometheus-stack-operator
Prometheus (el objeto)Recurso que describe tu Prometheus: réplicas, retención y selectores (qué objetos toma). El Operator crea el StatefulSet (el objeto que mantiene pods con nombre fijo y disco propio) y el Service prometheus-operated. En el chart, la retención es de 10 días (sin el chart, Prometheus guarda 15): alcanza para los 7 días que pide ClassifierLowConfidenceSpike, pero no para un presupuesto de 30 días.pod prometheus-kube-prometheus-stack-prometheus-0
serviceMonitorSelectorQué ServiceMonitors toma ese Prometheus. El chart pone release con el nombre del release: un ServiceMonitor sin esa label no existe para él.{"matchLabels":{"release":"kube-prometheus-stack"}}
serviceMonitorNamespaceSelectorEn qué namespaces los busca. Vacío ({}) = todos, el default del chart; sin definir = solo el namespace de Prometheus.{} en kube-prometheus-stack
ServiceMonitorElige Services por label y lee los pods que tienen detrás. El job queda con el nombre del Service; el scrape pool, serviceMonitor/namespace/nombre/número de endpoint.serviceMonitor/classifier/gateway/0
PodMonitorElige pods sin pasar por un Service.workers que leen de una cola
PrometheusRuleReglas de grabación y alertas como objeto. El Operator las monta en Prometheus; el webhook del chart rechaza las que no parsean.classifier: 1 regla de grabación + 6 alertas
config-reloaderContenedor al lado de Prometheus que lo recarga cuando cambia la config generada, sin reiniciar el pod.un ServiceMonitor nuevo aparece en uno o dos minutos
kube-state-metricsLee el API server y publica el estado de los objetos: réplicas, fases, reinicios y motivos.kube_deployment_status_replicas_available → TritonNotReady
cAdvisor (dentro del kubelet)El uso real de cada contenedor: CPU y memoria, y throttling (las pausas que impone un límite de CPU) solo si el contenedor tiene ese límite. El chart lo lee en /metrics/cadvisor de cada nodo. El gateway del C01 corre sin límite de CPU: no tiene serie de throttling.container_cpu_usage_seconds_total del gateway
node_exporterUn pod por nodo (un DaemonSet) en el puerto 9100 con la máquina: CPU, memoria, disco y red.node_memory_MemAvailable_bytes
metrics-serverNo es Prometheus: publica CPU y memoria para kubectl top y para el HPA por CPU.el HPA del gateway
KEDA (trigger prometheus)Corre tu query en Prometheus y le pasa el número al HPA que crea (keda-hpa- más el nombre). La query tiene que devolver una sola serie.serverAddress: http://prometheus-operated.monitoring.svc.cluster.local:9090
ignoreNullValuesCon true (el default), una query vacía, NaN o +Inf vale 0 en silencio; con "false", es un error visible en los eventos del ScaledObject."false" en el C01
TargetDown · WatchdogDos alertas que trae el chart: TargetDown (más del 10 % de los targets de un job caídos durante 10 min) y Watchdog (siempre encendida, para probar que la cadena de alertas funciona).Watchdog va al receiver null (descarta todo) por defecto

1Lo que escribes: objetos en el cluster

  • ServiceMonitor

    classifier/gateway

    release: kube-prometheus-stack

    Service gateway · puerto http (8080) · /metrics cada 15 s

  • ServiceMonitor

    classifier/triton

    release: kube-prometheus-stack

    Service triton · puerto metrics (8002) cada 15 s

  • PrometheusRule

    classifier/classifier

    release: kube-prometheus-stack

    1 regla de grabación + 6 alertas (alerts.yml)

  • ServiceMonitor

    classifier/sin-label

    (sin release)

    ignorado: no genera ningún job

el Operator los vigila

2El Prometheus Operator

Vigila: ServiceMonitor, PodMonitor, PrometheusRule

Elige con: serviceMonitorSelector: release=kube-prometheus-stack · serviceMonitorNamespaceSelector: {} (todos)

genera la config

3Lo que escribe el Operator

  • un Secret con prometheus.yaml.gz (la config generada)
  • un ConfigMap …-rulefiles-0 con las reglas

Un job por endpoint: serviceMonitor/classifier/gateway/0 serviceMonitor/classifier/triton/0

recarga: config-reloader, sin reiniciar

4Prometheus · prometheus-kube-prometheus-stack-prometheus-0

Service: prometheus-operated:9090

lee cada target (scrape)

5Los targets: los pods detrás de cada Service

  • 10.0.1.21:8080job="gateway"UP
  • 10.0.2.14:8080job="gateway"UP
  • 10.0.1.33:8080job="gateway"UP
  • 10.0.1.12:8002job="triton"UP
  • 10.0.2.40:8002job="triton"UP

le preguntan con PromQL

6Quién lo consulta

  • Alertmanagerrecibe las alertas que disparan
  • Grafanadibuja los dashboards
  • KEDAcorre la query de la cola cada 15 s para keda-hpa-triton

UPignorado: sin la label release, el Operator ni lo mira

El Operator convierte objetos en config: toma los ServiceMonitors y PrometheusRules con la label release, genera un job por endpoint y el config-reloader recarga Prometheus. Los targets son los pods detrás de cada Service. IPs ilustrativas.

Quién mide qué en un cluster

FuenteQué mideEjemploEn el C01
Tu app (prometheus_client)lo que solo tu código sabe: requests, latencia por tramo, scoresclassifier_requests_totalel gateway, en :8080/metrics; los requests en vuelo que el curso cuenta como saturación no salen ahí: main.py no los exporta como gauge
Tritoncola, cómputo y batches por modelo, y la GPUnv_inference_queue_duration_uspuerto 8002; la query de KEDA y TritonQueueTimeHigh
kube-state-metricsel estado de los objetos de Kubernetes, leído del API serverkube_deployment_status_replicas_availableTritonNotReady (no existe en el compose local)
cAdvisor (kubelet)cuánto usa de verdad cada contenedorcontainer_memory_working_set_bytesla CPU del gateway: hoy, su única señal de saturación exportada (sin límite de CPU no hay throttling)
node_exporterla máquina: CPU, memoria, disco y red del nodonode_filesystem_avail_bytessin alerta propia en el C01
DCGM exporterla GPU vista por NVIDIA: utilización, memoria, temperatura, erroresDCGM_FI_DEV_GPU_UTILlo instala el GPU Operator; el C01 usa nv_gpu_utilization de Triton
metrics-serverCPU y memoria al instante para kubectl top y el HPA; no guarda historia—el HPA del gateway al 60 % de su request

La pregunta elige la fuente: ¿el Deployment tiene sus réplicas listas? kube-state-metrics. ¿El contenedor se acerca a su límite de memoria? cAdvisor. ¿Se llena el disco del nodo? node_exporter. ¿Fallan los requests? Tu app.

Ejemplo real · C01: los dos ServiceMonitors que conectan el gateway y Triton con Prometheus

YAMLreference/c01/k8s/monitoring.yaml (completo)
# Scrape con el Prometheus Operator (kube-prometheus-stack) (caso c01). Sin esto, Prometheus nunca
# lee el gateway ni Triton: la consulta de KEDA vuelve vacía (k8s/keda-triton.yaml la marca como
# error), Triton se queda en 2 réplicas y ninguna alerta evalúa. Falla en silencio.
# Las reglas de monitoring/alerts.yml van aparte, como PrometheusRule (el Operator no lee archivos
# de reglas): el comando está en el README.
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
  name: gateway
  namespace: classifier
  labels:
    release: kube-prometheus-stack  # = serviceMonitorSelector de tu Prometheus (nombre del release de Helm)
spec:
  selector:
    matchLabels:
      app.kubernetes.io/name: gateway  # labels del Service de k8s/gateway.yaml
  endpoints:
    - port: http  # nombre del puerto del Service: 8080/metrics del gateway (no expuesto en el Ingress)
      path: /metrics
      interval: 15s  # igual que en local: suficiente para alertas de 5–30 min
---
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
  name: triton
  namespace: classifier
  labels:
    release: kube-prometheus-stack
spec:
  selector:
    matchLabels:
      app.kubernetes.io/name: triton  # labels del Service de k8s/triton.yaml
  endpoints:
    - port: metrics  # 8002: métricas nv_* (las que consulta KEDA); la NetworkPolicy solo deja entrar a "monitoring"
      interval: 15s
  • release: kube-prometheus-stack es la label que busca el serviceMonitorSelector que arma el chart (release igual al nombre del release de Helm). Si instalas el chart con otro nombre, cambia esta label: si no, el ServiceMonitor no existe para Prometheus, ni UP ni DOWN.
  • port: http y port: metrics son nombres de puertos de los Services de gateway.yaml y triton.yaml, no números. El job queda con el nombre del Service, así que up{job="gateway"} funciona igual que en el compose local.
  • interval: 15s pisa el default del Operator (30 s) y queda igual que prometheus.yml en local: la ventana de 2 min de KEDA y las de 5 min de las alertas tienen 8 y 20 muestras por serie. Ojo: las reglas sin interval propio se evalúan cada 30 s en el cluster (el default del Operator), no cada 15 s como en local.
  • La NetworkPolicy de triton.yaml solo deja leer el puerto 8002 desde el namespace monitoring (si el plugin de red del cluster, el CNI, aplica NetworkPolicies, como pide ese archivo). Si Prometheus vive en otro namespace, Triton sale DOWN con context deadline exceeded: el ejemplo de la sección 12.
  • Las reglas no van aquí: el README del C01 envuelve alerts.yml en un PrometheusRule con la misma label release. Sin ese paso, las 6 alertas no existen en el cluster.
  • KEDA lee este mismo Prometheus por prometheus-operated.monitoring.svc.cluster.local:9090. Si falta el ServiceMonitor de Triton, su query vuelve vacía y, con ignoreNullValues: "false", el ScaledObject muestra prometheus metrics 'prometheus' target may be lost, the result is empty en sus eventos.

Cómo recordarla

no escribes prometheus.yml: escribes objetos. ServiceMonitor = qué leer (por Service), PodMonitor = qué leer (por pod), PrometheusRule = qué evaluar; la label release es la llave. kube-state-metrics dice cómo están los objetos; cAdvisor, cuánto gasta cada contenedor; node_exporter, cómo está la máquina.

Trampa

un ServiceMonitor que nadie elige no da error. Sin la label release, con un selector que copia las labels del pod en vez de las del Service, o con un port que el Service no tiene, el target simplemente no aparece: ni UP ni DOWN. En el C01 eso rompe dos cosas a la vez: las alertas se quedan sin datos y KEDA no ve la cola.

Laboratorio (opcional)11 pasos

Qué vas a provocar: Instalar kube-prometheus-stack en un cluster local, ver cómo un ServiceMonitor sin la label release se ignora en silencio, cargar una alerta como PrometheusRule y comprobar que una alerta sobre up == 0 se apaga cuando el servicio desaparece del todo.

Necesitas: Un cluster local (Kubernetes de Docker Desktop o kind) con memoria de sobra en la VM de Docker, helm 3 o 4 (brew install helm) y jq (los macOS recientes lo traen en /usr/bin/jq; si no, brew install jq). La primera vez baja las imágenes del Operator, Prometheus, Alertmanager, Grafana y la app de ejemplo.

  1. Paso 1
    kubectl config current-context
    helm install kube-prometheus-stack oci://ghcr.io/prometheus-community/charts/kube-prometheus-stack \
      --version 92.11.0 -n monitoring --create-namespace \
      --set prometheus-node-exporter.hostRootFsMount.enabled=false --wait --timeout 15m
    kubectl get pods -n monitoring

    Qué deberías ver: El contexto de tu cluster local (docker-desktop o kind-…), STATUS: deployed y pods Running: kube-prometheus-stack-operator, prometheus-kube-prometheus-stack-prometheus-0, alertmanager-kube-prometheus-stack-alertmanager-0, kube-prometheus-stack-grafana, kube-prometheus-stack-kube-state-metrics y un kube-prometheus-stack-prometheus-node-exporter por nodo. La opción de node_exporter evita un error al montar el disco raíz que puede aparecer en Docker Desktop.

  2. Paso 2
    kubectl get prometheus -n monitoring -o jsonpath='{.items[0].spec.serviceMonitorSelector}{"\n"}{.items[0].spec.serviceMonitorNamespaceSelector}{"\n"}'

    Qué deberías ver: {"matchLabels":{"release":"kube-prometheus-stack"}} y {}: este Prometheus toma los ServiceMonitors que traen esa label, en cualquier namespace.

  3. Paso 3
    kubectl create namespace lab-prom
    kubectl apply -n lab-prom -f - <<'EOF'
    apiVersion: apps/v1
    kind: Deployment
    metadata:
      name: web
    spec:
      replicas: 2
      selector:
        matchLabels:
          app.kubernetes.io/name: web
      template:
        metadata:
          labels:
            app.kubernetes.io/name: web
        spec:
          containers:
            - name: web
              image: quay.io/brancz/prometheus-example-app:v0.6.0
              ports:
                - name: http
                  containerPort: 8080
    ---
    apiVersion: v1
    kind: Service
    metadata:
      name: web
      labels:
        app.kubernetes.io/name: web
    spec:
      selector:
        app.kubernetes.io/name: web
      ports:
        - name: http
          port: 8080
          targetPort: http
    ---
    apiVersion: monitoring.coreos.com/v1
    kind: ServiceMonitor
    metadata:
      name: web
    spec:
      selector:
        matchLabels:
          app.kubernetes.io/name: web
      endpoints:
        - port: http
          interval: 15s
    EOF

    Qué deberías ver: namespace/lab-prom created, deployment.apps/web created, service/web created y servicemonitor.monitoring.coreos.com/web created. La app de ejemplo del Prometheus Operator sirve / y expone /metrics en el 8080. El ServiceMonitor no trae la label release.

  4. Paso 4
    kubectl port-forward -n monitoring svc/prometheus-operated 9090 >/dev/null 2>&1 & PF=$!
    sleep 5; curl -s 'localhost:9090/api/v1/targets?state=active' | jq -r '.data.activeTargets[].scrapePool' | sort -u

    Qué deberías ver: Una lista de pools serviceMonitor/monitoring/… (los del chart) y ninguno serviceMonitor/lab-prom/web/0. Tu ServiceMonitor existe, pero nadie lo elige: no hay error en ningún lado.

  5. Paso 5
    curl -s 'localhost:9090/api/v1/targets?state=active' | jq -r '.data.activeTargets[] | select(.health=="down") | [.scrapePool, .lastError] | @tsv'

    Qué deberías ver: En un cluster local suelen salir DOWN los de kube-controller-manager, kube-scheduler, kube-etcd y kube-proxy, con connection refused: esos componentes escuchan sus métricas solo en 127.0.0.1 de su nodo. No es tu app; el chart permite apagar esos ServiceMonitors (kubeControllerManager.enabled=false y similares).

  6. Paso 6
    kubectl label servicemonitor web -n lab-prom release=kube-prometheus-stack
    sleep 90; curl -s 'localhost:9090/api/v1/targets?state=active' | jq -r '.data.activeTargets[] | select(.scrapePool=="serviceMonitor/lab-prom/web/0") | [.labels.job, .labels.instance, .health] | @tsv'

    Qué deberías ver: servicemonitor.monitoring.coreos.com/web labeled y, después, dos líneas: web, la IP de cada pod con :8080, up. El Operator regeneró la config y el config-reloader recargó Prometheus. Si sale vacío, espera otro minuto y repite el curl.

  7. Paso 7
    curl -s localhost:9090/api/v1/query --data-urlencode 'query=http_requests_total{job="web"}' | jq '.data.result | length'
    kubectl run carga -n lab-prom --rm -i --restart=Never --image=busybox:1.38 -- sh -c 'for i in $(seq 50); do wget -q -O /dev/null http://web:8080/; done; echo listo'
    sleep 30; curl -s localhost:9090/api/v1/query --data-urlencode 'query=sum by (code) (http_requests_total{job="web"})' | jq -r '.data.result[] | [.metric.code, .value[1]] | @tsv'

    Qué deberías ver: Primero 0: el counter tiene labels y todavía no recibió ningún request, así que no existe ni una serie (vacío, no cero). Después, listo y el pod carga se borra solo. Al final, 200 y 50: los 50 requests, sumados entre los dos pods.

  8. Paso 8
    kubectl apply -n lab-prom -f - <<'EOF'
    apiVersion: monitoring.coreos.com/v1
    kind: PrometheusRule
    metadata:
      name: web
      labels:
        release: kube-prometheus-stack
    spec:
      groups:
        - name: lab-web
          rules:
            - alert: WebCaido
              expr: up{job="web"} == 0
              for: 1m
              labels:
                severity: page
              annotations:
                summary: "{{ $labels.instance }} no responde"
    EOF
    sleep 90; curl -s 'localhost:9090/api/v1/rules?type=alert' | jq -r '.data.groups[] | select(.name=="lab-web") | .rules[] | [.name, .state, .health] | @tsv'

    Qué deberías ver: prometheusrule.monitoring.coreos.com/web created y, en Rule health, WebCaido inactive ok: la regla está cargada y sana, y no hay nada caído.

  9. Paso 9
    kubectl patch service web -n lab-prom --type=json -p '[{"op":"replace","path":"/spec/ports/0/targetPort","value":9999}]'
    sleep 150; curl -s localhost:9090/api/v1/alerts | jq -r '.data.alerts[] | select(.labels.alertname=="WebCaido") | [.labels.instance, .state] | @tsv'

    Qué deberías ver: service/web patched y, después, dos alertas WebCaido firing, una por pod, con instance en el puerto 9999: el Service ahora apunta a un puerto donde nadie escucha, el scrape falla con connection refused y up vale 0 durante más de 1 min (el for).

  10. Paso 10
    kubectl scale deploy web -n lab-prom --replicas=0
    sleep 60; curl -s localhost:9090/api/v1/alerts | jq '[.data.alerts[] | select(.labels.alertname=="WebCaido")] | length'
    curl -s localhost:9090/api/v1/query --data-urlencode 'query=absent(up{job="web"})' | jq -r '.data.result[].value[1]'

    Qué deberías ver: deployment.apps/web scaled, 0 alertas y después 1. Sin pods no hay targets ni serie up: la alerta up == 0 se apagó justo cuando el servicio está caído del todo. absent(up{job="web"}) devuelve 1: esa es la expresión que sí avisaría.

  11. Paso 11
    kill $PF
    kubectl delete namespace lab-prom
    helm uninstall kube-prometheus-stack -n monitoring
    kubectl delete namespace monitoring
    kubectl get crd -o name | grep monitoring.coreos.com

    Qué deberías ver: release "kube-prometheus-stack" uninstalled y los namespaces borrados. La última lista sigue mostrando los CRDs (servicemonitors, prometheusrules…): Helm no los borra. Si no vas a volver a instalar el chart: kubectl get crd -o name | grep monitoring.coreos.com | xargs kubectl delete.

Cómo arreglarlo y confirmarlo

Un ServiceMonitor que no aparece: compara sus labels con el serviceMonitorSelector del paso 2 y su selector con las labels del Service. Una alerta que se apaga cuando todo cae: agrega una con absent() o alerta del lado de quien espera la señal. En el C01, k8s/monitoring.yaml ya trae la label correcta.

Verificado contra la documentación; no ejecutado aquí. Verificado contra el chart kube-prometheus-stack 92.11.0 (values.yaml y plantillas: serviceMonitorSelector con release, namespaceSelector {}, nombres de los recursos, hostRootFsMount de node_exporter), la API del Prometheus Operator v0.94.2 (ServiceMonitor, PodMonitor, PrometheusRule, el Service prometheus-operated y el scrape pool serviceMonitor/namespace/nombre/índice), el README del chart (los CRDs no se borran al desinstalar) y el código de prometheus-example-app (puerto 8080, http_requests_total con code y method); la imagen v0.6.0 es multi-arquitectura.

12

Diagnóstico: el método y la tabla de estados

No hay datos, un target está DOWN o la alerta nunca sonó: ¿qué miras primero, qué después y qué te dice cada estado?

Sintaxis con variables

curl -s 'http://prometheus/api/v1/targets?state=active' \
  | jq -r '.data.activeTargets[] | [.labels.job, .labels.instance, .health, .lastError] | @tsv'

¿Prometheus lee el target? health es up, down o unknown (todavía no lo leyó) y lastError dice por qué falló. Es lo mismo que Status → Target health en la UI. Si el target ni aparece, el problema está antes: la config o el ServiceMonitor.

curl -s http://host:puerto/metrics | grep '^metrica'

¿El proceso emite la métrica? Lo que no está aquí no puede estar en Prometheus. Una serie con labels nace con su primera observación: si nadie recibió un 503, no hay línea de 503. En Kubernetes, llega con kubectl port-forward.

curl -s http://prometheus/api/v1/query --data-urlencode 'query=consulta' | jq '.data.result, .warnings, .infos'

¿La query devuelve algo? Una lista vacía no es un 0: no hay serie con ese nombre y esas labels en los últimos 5 min (el lookback: cuánto mira hacia atrás una query para encontrar la última muestra). Y lee warnings e infos: la query pudo correr con una sospecha.

curl -s 'http://prometheus/api/v1/rules?type=alert' \
  | jq -r '.data.groups[].rules[] | [.name, .state, .health, .lastError] | @tsv'
amtool --alertmanager.url=http://alertmanager alert query alertname=alerta

¿La regla evalúa y en qué estado está? health ok o err (con lastError) y state inactive, pending o firing. Después, ¿la recibió Alertmanager? Sin flags, amtool lista solo las activas (las que sí notifica); -s e -i muestran solo las silenciadas y las inhibidas, y -a -s -i, todas, con su estado.

Variables

VariableQué esEjemplo
prometheushost y puerto de Prometheuslocalhost:9090 (compose o port-forward)
host, puertoel target, visto desde donde corres curl (Prometheus lo lee como gateway:8080 o triton:8002)localhost:8080 · localhost:8002 (puertos publicados del compose o port-forward)
metricanombre de la métrica (o su comienzo)classifier_requests_total
consultala query que no devuelve lo esperadoup{job="triton"}
alertmanagerhost y puerto de Alertmanagerlocalhost:9093
alertanombre de la alertaClassifierHigh5xxRate
tavisot_{\text{aviso}}del primer error a la notificación≈ 6 min para ClassifierHigh5xxRate en una caída total
tcrucet_{\text{cruce}}hasta que la expresión es verdadera: un scrape y la ventana≈ 15–30 s con 100 % de errores (rate necesita dos muestras de la serie de 5xx); ≈ 17 min con 5 % en la de 14.4×
tfort_{\text{for}}el for de la regla: el tiempo en pending5 min
tevalt_{\text{eval}}hasta la siguiente evaluación de la regla (evaluation_interval: 1 min si no lo fijas)≤ 15 s con el prometheus.yml del C01; ≤ 30 s con el Operator (EKS)
tgrupot_{\text{grupo}}group_wait de Alertmanager: espera antes de la primera notificación de un grupo30 s (default)
ssintervalo de scrape que Grafana supone para el data source15 s (el default de Grafana y el del C01)
uuel umbral de la alerta0.01 (1 % de 5xx)

Fórmulas

taviso≈tcruce+tfor+teval+tgrupo≈15 s+5 min+15 s+30 s≈6 mint_{\text{aviso}} \approx t_{\text{cruce}} + t_{\text{for}} + t_{\text{eval}} + t_{\text{grupo}} \approx 15\ \text{s} + 5\ \text{min} + 15\ \text{s} + 30\ \text{s} \approx 6\ \text{min}
$__rate_interval=max⁡($__interval+s,  4s)\text{\$\_\_rate\_interval} = \max\left(\text{\$\_\_interval} + s,\; 4s\right)
00=NaNx0=+∞NaN>u  ⇒  falso: la alerta no dispara\frac{0}{0} = \text{NaN} \qquad \frac{x}{0} = +\infty \qquad \text{NaN} > u \;\Rightarrow\; \text{falso: la alerta no dispara}

Las piezas

PiezaQué haceEjemplo
up = 1El último scrape funcionó: Prometheus leyó /metrics completo.up{job="gateway"} 1
up = 0 (DOWN)El scrape falló. La causa está en lastError: red, puerto, path, timeout o Content-Type.context deadline exceeded
sin serie upNo hay target: la config, el ServiceMonitor, su selector o el nombre del puerto no lo generan.un ServiceMonitor sin la label release
health: unknownTarget recién descubierto, todavía sin scrape.los primeros segundos
resultado vacíoNo hay serie con ese nombre y esas labels en los últimos 5 min. No es lo mismo que 0.la serie de 503 cuando no hubo ningún 503
NaNDividiste 0 entre 0, o histogram_quantile no tuvo observaciones en la ventana.la fracción de 5xx sin tráfico
+InfDividiste algo positivo entre 0.espera en cola ÷ 0 éxitos, sin clamp_min
caída en un counterEl proceso reinició y el counter volvió a 0 (un counter reset). rate() lo corrige si va antes del sum.un pod reemplazado en un deploy
hueco en la gráficaScrapes que fallaron: Prometheus marca la serie como stale (vencida) y no inventa valores.un /metrics que tardó más que scrape_timeout
regla con health errLa regla no se puede evaluar; lastError trae el mensaje. Mientras falle, no dispara.many-to-many matching not allowed
alerta pendingLa condición es verdadera, pero no cumplió el for. Si se apaga un instante, el for empieza de cero.for: 10m en ClassifierHighP99Latency
alerta firingCumplió el for: Prometheus la manda a Alertmanager y la reenvía mientras siga activa.ALERTS{alertstate="firing"}
silenciada · inhibidaAlertmanager la tiene, pero no notifica: un silencio la tapa u otra alerta la inhibe.amtool alert query -s -i
No data (Grafana)La query del panel no devolvió series: nombre, labels, rango de tiempo, variables o data source.Inspect → Query del panel

tu servicio

1Tu código

Prometheus

2Scrape3TSDB4PromQL5Regla6Envío

Alertmanager

7Alertmanager8Receptor

rama aparte

·Grafana

  1. Tu códigotu servicio

    ¿El proceso emite la métrica, con esos labels?

    terminalcurl -s localhost:8080/metrics | grep '^classifier_requests_total'

    Sin línea: el nombre es otro, o esa combinación de labels todavía no tuvo ninguna observación.

  2. ScrapePrometheus

    ¿Prometheus lo lee?

    PromQLup{job="gateway"}

    1 = bien; 0 = DOWN, lee lastError; nada = no hay target (config o ServiceMonitor).

  3. TSDBPrometheus

    ¿La serie llegó y sigue viva?

    PromQLcount by (job) (classifier_requests_total)

    Si un deploy cambió el nombre o el target cayó, la serie quedó stale y desaparece de las queries.

  4. PromQLPrometheus

    ¿La query hace lo que crees?

    PromQLsum(rate(classifier_requests_total{status=~"5.."}[5m]))

    Vacío no es 0; NaN es 0/0; lee warnings e infos.

  5. ReglaPrometheus

    ¿Está cargada, sana y en qué estado?

    PromQLALERTS{alertname="ClassifierHigh5xxRate"}

    Rule health: ok o err. Pending espera el for; firing ya salió hacia Alertmanager.

  6. EnvíoPrometheus

    ¿Prometheus conoce y alcanza un Alertmanager?

    PromQLprometheus_notifications_alertmanagers_discovered

    0 = no hay a quién mandar (el compose del C01). Los fallos suman en prometheus_notifications_errors_total.

  7. AlertmanagerAlertmanager

    ¿La recibió, la silenció, a qué receiver la mandó?

    terminalamtool --alertmanager.url=http://localhost:9093 alert query -a -s -i

    Estado active = notifica; suppressed = silenciada o inhibida, no notifica. La ruta decide el receiver.

  8. ReceptorAlertmanager

    ¿El envío final funcionó?

    terminalkubectl logs -n monitoring alertmanager-kube-prometheus-stack-alertmanager-0 -c alertmanager | grep 'Notify for alerts failed'

    Un webhook vencido o sin red de salida: Alertmanager reintenta y lo deja en el log.

rama aparteGrafana

¿El panel pregunta lo mismo que tú?

GrafanaInspectQuery

Data source, variables, rango de tiempo y $__rate_interval.

El primer eslabón que falla es la causa.

terminalun comando: va en la terminalPromQLuna consulta: va en la caja de consultas de Prometheus (o en Grafana)un lugarde la web: se abre, no se copia

El método: recorre la cadena en orden, de tu código al receptor. El primer eslabón que falla es la causa; lo que viene después son consecuencias. Grafana es una rama aparte: lee de Prometheus como tú.

Ejemplo real · C01: Triton DOWN por la red, no por Triton

bash · zshComandos reales con los nombres de reference/c01/k8s/triton.yaml (NetworkPolicy triton-ingress), monitoring.yaml y keda-triton.yaml; las salidas son ilustrativas, con la forma de Prometheus 3.13 y KEDA 2.21
# Síntoma: el panel de la cola de Triton dice No data y KEDA no pasa de 2 réplicas.
# Este Prometheus (kube-prometheus-stack) quedó instalado en el namespace "observability".
kubectl port-forward -n observability svc/prometheus-operated 9090 &
curl -s 'localhost:9090/api/v1/targets?state=active' \
  | jq -r '.data.activeTargets[] | select(.labels.namespace=="classifier") | [.labels.job, .health, .lastError] | @tsv'
# gateway  up
# gateway  up
# gateway  up
# triton   down  Get "http://10.0.1.12:8002/metrics": context deadline exceeded
# triton   down  Get "http://10.0.2.40:8002/metrics": context deadline exceeded

# El target existe (el ServiceMonitor está bien), pero no contesta a tiempo: ¿el proceso o la red?
kubectl port-forward -n classifier svc/triton 8002 &
curl -s localhost:8002/metrics | grep -c '^nv_inference_queue_duration_us'
# 1          ← por el port-forward sí contesta: el proceso está bien
kubectl get networkpolicy triton-ingress -n classifier -o jsonpath='{.spec.ingress[1].from}'
# [{"namespaceSelector":{"matchLabels":{"kubernetes.io/metadata.name":"monitoring"}}}]

# Causa: solo el namespace "monitoring" puede leer el 8002. KEDA tampoco llega a Prometheus:
kubectl describe scaledobject triton -n classifier | grep KEDAScalerFailed
# Warning  KEDAScalerFailed  …  …: lookup prometheus-operated.monitoring.svc.cluster.local …: no such host
  • Lee los tres estados: el gateway sale UP (se lee bien), Triton sale DOWN con un error (el target existe y el scrape falla) y ningún target falta. Si faltara, el problema sería de selección (sección 11); aquí el ServiceMonitor está bien.
  • context deadline exceeded = nadie contestó antes de scrape_timeout (10 s por defecto). Una NetworkPolicy descarta los paquetes sin responder: por eso es un timeout y no un connection refused.
  • El port-forward entra al pod a través del kubelet y no pasa por la NetworkPolicy: si así contesta y Prometheus no, el problema es la red, no el proceso.
  • El namespace monitoring es parte del contrato del C01: lo usan la NetworkPolicy de triton.yaml y el serverAddress de keda-triton.yaml. O instalas Prometheus ahí, o cambias los dos archivos en git.
  • Mientras tanto, TritonQueueTimeHigh y GPUSaturated no pueden disparar (sus series de Triton no existen: la expresión vuelve vacía) y KEDA no escala: un error de monitoreo deja ciegos a la vez a las alertas y al autoscaling. Una alerta sobre up == 0 (o la TargetDown del chart, a los 10 min) lo habría avisado, si Alertmanager tiene a quién mandarla.

Cómo recordarla

código → scrape → serie → query → regla → envío → Alertmanager → receptor. Sigue la cadena en orden y lee el estado exacto: DOWN (falla el scrape), ausente (no hay target), vacío (no hay serie), NaN (dividiste 0 entre 0), pending (esperando el for).

Trampa

leer vacío como cero y sin alerta como todo bien. Cuando un target cae, Prometheus marca sus series como stale (vencidas) en el scrape siguiente: una consulta directa vuelve vacía enseguida, y una con rate(…[5m]), cuando su ventana se queda sin muestras. Las alertas sobre esas series se apagan justo cuando todo está caído. Por eso hacen falta up == 0, absent() y una alerta que siempre suene (el Watchdog del chart) mandada a un servicio de afuera que avise cuando deja de llegar.

Laboratorio (opcional)8 pasos

Qué vas a provocar: Provocar en un Prometheus local tres targets DOWN con tres errores distintos, una alerta que dispara y otra que nunca puede disparar (un typo en el nombre de la métrica), y arreglarla con un reload.

Necesitas: Docker Desktop y jq (los macOS recientes lo traen en /usr/bin/jq; si no, brew install jq). Baja prom/prometheus:v3.13.3, la versión del compose del C01.

  1. Paso 1
    mkdir -p ~/lab-prom && cd ~/lab-prom
    cat > prometheus.yml <<'EOF'
    global:
      scrape_interval: 15s
      evaluation_interval: 15s
    rule_files:
      - reglas.yml
    scrape_configs:
      - job_name: prometheus      # Prometheus se lee a sí mismo: UP
        static_configs:
          - targets: ["localhost:9090"]
      - job_name: puerto-cerrado  # nadie escucha en el 9999
        static_configs:
          - targets: ["localhost:9999"]
      - job_name: ruta-mal        # el puerto contesta, pero no hay nada en /metricas
        metrics_path: /metricas
        static_configs:
          - targets: ["localhost:9090"]
      - job_name: nombre-mal      # este nombre no existe en la red del contenedor
        static_configs:
          - targets: ["gateway:8080"]
    EOF
    cat > reglas.yml <<'EOF'
    groups:
      - name: lab
        rules:
          - alert: TargetCaido
            expr: up == 0
            for: 1m
            labels:
              severity: page
            annotations:
              summary: "{{ $labels.job }} ({{ $labels.instance }}) no responde"
          - alert: NuncaDispara
            expr: sum(rate(prometheus_http_requests_totl[5m])) > 0
            for: 1m
            labels:
              severity: ticket
            annotations:
              summary: "el nombre de la métrica tiene un typo: nunca hay datos"
    EOF
    docker run --rm --entrypoint promtool -v "$PWD":/etc/prometheus:ro prom/prometheus:v3.13.3 check config /etc/prometheus/prometheus.yml

    Qué deberías ver: Checking /etc/prometheus/prometheus.yml, SUCCESS: 1 rule files found, SUCCESS: … is valid prometheus config file syntax y, para reglas.yml, SUCCESS: 2 rules found. promtool no ve el typo: prometheus_http_requests_totl es un nombre válido que simplemente no existe.

  2. Paso 2
    docker run -d --name lab-prom -p 9090:9090 -v "$PWD":/etc/prometheus:ro prom/prometheus:v3.13.3
    sleep 20; curl -s localhost:9090/-/ready

    Qué deberías ver: El id del contenedor y Prometheus Server is Ready. Monta la carpeta entera (no los archivos sueltos) para que los cambios del paso 6 se vean adentro.

  3. Paso 3
    curl -s 'localhost:9090/api/v1/targets?state=active' | jq -r '.data.activeTargets[] | [.labels.job, .health, .lastError] | @tsv'

    Qué deberías ver: prometheus up; puerto-cerrado down con Get "http://localhost:9999/metrics": dial tcp …:9999: connect: connection refused; ruta-mal down con server returned HTTP status 404 Not Found; nombre-mal down con lookup gateway on …: no such host (la IP del DNS depende de tu Docker). Si alguno dice unknown, espera 15 s y repite.

  4. Paso 4
    sleep 60; curl -s localhost:9090/api/v1/alerts | jq -r '.data.alerts[] | [.labels.alertname, .labels.job, .state] | @tsv'

    Qué deberías ver: Tres líneas TargetCaido, una por target caído, en firing (o pending si todavía no cumplió 1 min: espera y repite). NuncaDispara no aparece: su expresión no devuelve nada, así que no hay nada que comparar.

  5. Paso 5
    curl -s 'localhost:9090/api/v1/rules?type=alert' | jq -r '.data.groups[].rules[] | [.name, .state, .health] | @tsv'
    curl -s localhost:9090/api/v1/query --data-urlencode 'query=absent(prometheus_http_requests_totl)' | jq -r '.data.result[].value[1]'
    curl -s localhost:9090/api/v1/query --data-urlencode 'query=count by (__name__) ({__name__=~"prometheus_http_requests.*"})' | jq -r '.data.result[].metric.__name__'

    Qué deberías ver: TargetCaido firing ok y NuncaDispara inactive ok: la regla está sana, el problema no es de sintaxis. absent() devuelve 1 (esa serie no existe) y la búsqueda muestra el nombre real, prometheus_http_requests_total.

  6. Paso 6
    sed -i '' 's/_totl\[/_total[/' reglas.yml
    curl -s -X POST localhost:9090/-/reload; echo
    docker kill --signal=HUP lab-prom && sleep 2 && docker logs lab-prom 2>&1 | grep 'Completed loading of configuration file' | tail -1

    Qué deberías ver: Lifecycle API is not enabled.: el reload por HTTP necesita --web.enable-lifecycle. La señal SIGHUP sí recarga: la última línea del log dice Completed loading of configuration file.

  7. Paso 7
    sleep 90; curl -s localhost:9090/api/v1/alerts | jq -r '.data.alerts[] | [.labels.alertname, .state] | @tsv' | sort | uniq -c

    Qué deberías ver: 3 TargetCaido firing y 1 NuncaDispara firing: con el nombre correcto, la expresión ve los requests HTTP que recibe el propio Prometheus (cada scrape a sí mismo cuenta) y la alerta cumple su for. Si sigue pending, espera 15 s y repite.

  8. Paso 8
    docker rm -f -v lab-prom && cd ~ && rm -rf ~/lab-prom

    Qué deberías ver: lab-prom: se borran el contenedor y su volumen de datos, y después la carpeta.

Cómo arreglarlo y confirmarlo

Cada target DOWN se arregla donde dice su error: el puerto correcto, el metrics_path correcto, un nombre que resuelva en esa red. Una alerta que no puede disparar se detecta probando su expr en Query antes de subirla (o con absent()), y se recarga con SIGHUP, reiniciando o, si lo activaste, con POST /-/reload.

Verificado contra la documentación; no ejecutado aquí. Las salidas salen del código de Prometheus v3.13.3: el Dockerfile (usuario nobody, promtool en la imagen, CMD con /etc/prometheus/prometheus.yml), promtool check config, el error server returned HTTP status del scrape, Prometheus Server is Ready., Lifecycle API is not enabled. y el log Completed loading of configuration file; los errores de red son los de Go.

13

Errores que debes reconocer rápido

Lees el mensaje: ¿quién lo dijo (el scrape, PromQL, Alertmanager, Grafana, tu código o el cluster), qué significa, qué corres primero y dónde está la explicación?

Sintaxis con variables

Get "url": error_de_red

Lo dijo el scrape: Prometheus como cliente HTTP de tu target. Aparece en Status → Target health y en lastError, y up vale 0.

invalid parameter "query": fila:columna: parse error: detalle

Lo dijo el parser de PromQL (la parte que lee la query antes de correrla; errorType bad_data): tu query no llegó a correr. Fila y columna señalan dónde se perdió. Un error del motor, en cambio, sale con errorType execution.

PromQL info: detalle
PromQL warning: detalle

Una anotación (desde Prometheus 2.48): la query sí corrió, pero algo es sospechoso. La UI la muestra sobre el resultado; la API, en los campos infos y warnings.

time=hora level=nivel source=archivo.go:linea msg="mensaje" err="detalle"

Una línea de log de Prometheus 3 o de Alertmanager: la escribe el servidor (config, disco, envío de alertas). Léela con docker logs o kubectl logs.

modulo.Excepcion: mensaje

Un traceback de Python: lo dijo prometheus_client, dentro de tu proceso, al crear o usar una métrica. Pasa en cada arranque o en cada request que toca esa línea.

Variables

VariableQué esEjemplo
urllo que Prometheus intentó leerhttp://10.0.1.12:8002/metrics
error_de_redel error de red de Gocontext deadline exceeded · connect: connection refused · no such host
fila:columnadónde se trabó el parser, contando desde 11:6
detalleel porqué, en una fraseunclosed left parenthesis
hora, nivelcuándo y qué tan grave2026-10-23T10:00:00.000Z · ERROR, WARN o INFO
archivo.go:lineade qué parte del código saliómain.go:745
mensajequé pasóError loading config (--config.file=/etc/prometheus/prometheus.yml)
modulo.Excepcionla clase de la excepciónprometheus_client.registry.DuplicateTimeseries

Las piezas

PiezaQué haceEjemplo
Target health · lastErrorEl scrape: Prometheus no pudo leer /metrics. up vale 0 para ese target.server returned HTTP status 404 Not Found
parse error · bad_dataEl parser de PromQL: la query no corrió.expected type range vector in call to function "rate"
errorType executionEl motor: la query parseó, pero no se pudo calcular.many-to-many matching not allowed
errorType timeoutLa query pasó --query.timeout (2 min por defecto).query timed out in expression evaluation
PromQL info · PromQL warningAnotaciones: corrió, pero algo es sospechoso.metric might not be a counter
promtool check config · check rulesEl validador: encuentra YAML, campos y PromQL inválidos antes de cargarlos.could not parse expression
Log de PrometheusEl servidor: config, almacenamiento, reglas y envío a Alertmanager.msg="Error sending alerts"
amtool · log de AlertmanagerLa config de Alertmanager y las notificaciones que fallan.undefined receiver "pagerduty-ml" used in route
Panel o data source de GrafanaLa query del panel o la conexión; el error de Prometheus llega tal cual.There was an error returned querying the Prometheus API
Traceback de Pythonprometheus_client en tu proceso.Duplicated timeseries in CollectorRegistry
kubectl · API serverEl cluster no conoce el tipo (faltan los CRDs) o un webhook rechazó el objeto.no matches for kind "ServiceMonitor"
Eventos del ScaledObjectKEDA no pudo leer su métrica (KEDAScalerFailed).target may be lost, the result is empty
Eventos del ServiceMonitorEl Operator rechazó el objeto (InvalidConfiguration): no genera config para él.was rejected due to invalid configuration

Quiz · un mensaje a la vez

Mensaje 1 de 26

server returned HTTP status 404 Not Found
Quiz: el mensaje tal como lo ves; responde quién lo dijo, qué significa, qué corres primero y en qué sección está.

Los 26 mensajes

26 de 26

  1. server returned HTTP status 404 Not Found

    Prometheus llegó al target, pero en ese path no hay métricas: metrics_path (por defecto /metrics) no coincide con la ruta de tu app. La red y el proceso están bien; up vale 0 igual. Un redirect (un 307 a /metrics/) no da este error, porque Prometheus lo sigue, pero cada scrape son dos requests: por eso el gateway del C01 sirve /metrics directo.

    Primero: curl -s -o /dev/null -w '%{http_code}\n' localhost:8080/metrics

    → Sección 4 · Configurar el scrape: prometheus.yml, jobs, targets, relabeling básico, up y la página de targets

  2. Get "http://10.0.1.12:8002/metrics": context deadline exceeded

    Nadie terminó de contestar antes de scrape_timeout (10 s por defecto, nunca mayor que el intervalo). O la red descarta los paquetes (una NetworkPolicy o un security group, el firewall de AWS; Triton en el C01 si Prometheus no vive en el namespace monitoring), o el proceso está tan cargado que /metrics tarda más que eso.

    Primero: kubectl get networkpolicy -n classifier · curl -s localhost:9090/api/v1/query --data-urlencode 'query=scrape_duration_seconds{job="triton"}' | jq -r '.data.result[].value[1]'

    → Sección 4 · Configurar el scrape: prometheus.yml, jobs, targets, relabeling básico, up y la página de targets

  3. dial tcp 10.0.1.21:8080: connect: connection refused

    La máquina existe, pero nadie escucha en ese puerto: el proceso no arrancó, se cayó, o el puerto del target es otro. El rechazo es inmediato: a diferencia del timeout, no hay nada que esperar. En Kubernetes, revisa el puerto del Service que nombra el ServiceMonitor.

    Primero: kubectl get svc gateway -n classifier -o jsonpath='{.spec.ports}'

    → Sección 4 · Configurar el scrape: prometheus.yml, jobs, targets, relabeling básico, up y la página de targets

  4. dial tcp: lookup gateway on 127.0.0.11:53: no such host

    El nombre del target no resuelve en el DNS de donde corre Prometheus. 127.0.0.11 es el DNS interno de Docker en las redes de compose: Prometheus está en una, pero gateway no (otro proyecto de compose, o el servicio se llama distinto). Desde tu Mac o desde un contenedor lanzado aparte tampoco resuelve. En Kubernetes: un Service o un namespace mal escrito.

    Primero: docker network inspect classifier-c01_default --format '{{range .Containers}}{{.Name}} {{end}}'

    → Sección 4 · Configurar el scrape: prometheus.yml, jobs, targets, relabeling básico, up y la página de targets

  5. received unsupported Content-Type "application/json" and no fallback_scrape_protocol specified for target

    Desde Prometheus 3.0 el scrape falla si el Content-Type no es de métricas (en 2.x se leía como texto igual). El caso típico en FastAPI: una ruta /metrics que hace return generate_latest() sin Response, y FastAPI lo manda como JSON. Sírvelo con Response(generate_latest(), media_type=CONTENT_TYPE_LATEST), como main.py del C01. fallback_scrape_protocol es para exporters viejos con un header malo, no para esto.

    Primero: curl -sI localhost:8080/metrics | grep -i content-type

    → Sección 4 · Configurar el scrape: prometheus.yml, jobs, targets, relabeling básico, up y la página de targets

  6. line 12: field scrape_config not found in type config.plain

    El YAML trae un campo que Prometheus no conoce: aquí falta la s de scrape_configs (config.plain es el tipo interno de la config). En la línea de arriba viene parsing YAML file …: yaml: unmarshal errors. Al arrancar, Prometheus sale con Error loading config; en un reload, se queda con la config anterior y prometheus_config_last_reload_successful vale 0.

    Primero: docker run --rm --entrypoint promtool -v "$PWD/monitoring":/etc/prometheus:ro prom/prometheus:v3.13.3 check config /etc/prometheus/prometheus.yml

    → Sección 4 · Configurar el scrape: prometheus.yml, jobs, targets, relabeling básico, up y la página de targets

  7. Lifecycle API is not enabled.

    Pediste un reload por HTTP (POST /-/reload) y Prometheus corre sin --web.enable-lifecycle, que viene apagado. La config nueva no se cargó. Mándale SIGHUP, reinícialo o arráncalo con esa flag. Con el Operator no hace falta: el config-reloader recarga solo.

    Primero: docker compose kill -s SIGHUP prometheus · docker compose logs prometheus | grep 'Completed loading of configuration file'

    → Sección 4 · Configurar el scrape: prometheus.yml, jobs, targets, relabeling básico, up y la página de targets

  8. found duplicate series for the match group {pod="…"} on the right hand-side of the operation: […];many-to-many matching not allowed: matching labels must be unique on one side

    Una operación entre dos vectores (una división, una suma) encontró varias series del mismo lado para la misma combinación de labels del on(...). Casi siempre falta agregar un lado: sum by (pod) arriba y abajo. El mensaje dice qué lado tiene los repetidos y muestra dos de ellos.

    Primero: curl -s localhost:9090/api/v1/query --data-urlencode 'query=count by (pod) (rate(classifier_requests_total[5m]))' | jq -r '.data.result[] | [.metric.pod, .value[1]] | @tsv'

    → Sección 5 · PromQL básico: selectores, rangos, rate y agregaciones

  9. vector cannot contain metrics with the same labelset

    El resultado quedó con dos series de labels idénticas. El caso típico: rate() sobre varias métricas a la vez ({__name__=~"…"}) borra el nombre, y dos series que solo se distinguían por él chocan. Consulta cada métrica por separado, o agrega por un label que las distinga.

    Primero: curl -s localhost:9090/api/v1/query --data-urlencode 'query=count by (__name__) ({__name__=~"nv_inference_(count|exec_count)"})' | jq -r '.data.result[].metric.__name__'

    → Sección 5 · PromQL básico: selectores, rangos, rate y agregaciones

  10. invalid parameter "query": 1:6: parse error: expected type range vector in call to function "rate", got instant vector

    rate(), increase() y las funciones _over_time necesitan una ventana: rate(x[5m]), no rate(x). Es un error de sintaxis (errorType bad_data): la query no llegó a correr. 1:6 es fila y columna dentro de la query.

    Primero: curl -s localhost:9090/api/v1/query --data-urlencode 'query=rate(classifier_requests_total[5m])' | jq -r .status

    → Sección 5 · PromQL básico: selectores, rangos, rate y agregaciones

  11. PromQL info: metric might not be a counter, name does not end in _total/_sum/_count/_bucket: "nv_inference_queue_duration_us"

    Una anotación, no un error: la query corrió. Prometheus avisa que hiciste rate() o increase() sobre algo cuyo nombre no parece de counter. Sobre un gauge (nv_gpu_utilization) es un bug: el rate de un gauge no significa nada. Con Triton es una falsa alarma: sus counters no terminan en _total. Existe desde Prometheus 2.48.

    Primero: curl -s localhost:8002/metrics | grep '^# TYPE nv_inference_queue_duration_us'

    → Sección 5 · PromQL básico: selectores, rangos, rate y agregaciones

  12. query processing would load too many samples into memory in query execution

    La query tuvo que tener en memoria, a la vez, más muestras que --query.max-samples (50 millones por defecto). Una ventana larga dentro de rate() o increase() no llega sola: Prometheus la carga de a una serie (172,800 muestras para [30d] con scrape cada 15 s), así que tarda pero corre. Lo que choca es juntarlo todo: una subquery de 30 días (rate(…[5m])[30d:15s] guarda cada paso de cada serie: con unas 300 series ya pasa el tope), una ventana cruda pedida entera a la API (x[30d]) o un rango largo con miles de series. Para el presupuesto del mes, graba sum(rate(…[5m])) del numerador y del denominador y divide sus sum_over_time(…[30d]).

    Primero: curl -s localhost:9090/api/v1/query --data-urlencode 'query=count(last_over_time(classifier_requests_total[30d]))' | jq -r '.data.result[0].value[1]'

    → Sección 10 · SLOs: golden signals, RED y USE, error budget y alertas por burn rate

  13. PromQL warning: bucket label "le" is missing or has a malformed value of ""

    histogram_quantile recibió series sin le: lo perdiste al agregar (sum by (source) sin le) o le pasaste _sum o _count en vez de _bucket. El resultado sale vacío. El le tiene que sobrevivir a la agregación: sum by (le), o sum by (le, source) si quieres un p99 por source. Es una anotación desde Prometheus 2.48.

    Primero: curl -s localhost:9090/api/v1/query --data-urlencode 'query=histogram_quantile(0.99, sum by (le) (rate(classifier_request_latency_seconds_bucket[5m])))' | jq '.data.result, .warnings'

    → Sección 6 · Histogramas y percentiles: buckets, le y histogram_quantile

  14. group "classifier-alerts", rule 1, "ClassifierHighP99Latency": could not parse expression: …

    promtool (o Prometheus al cargar las reglas) no pudo parsear la expr de esa regla. Antes de group vienen la línea y la columna del expr en el YAML; después de could not parse expression, el error de PromQL con fila y columna dentro de la expr. Prometheus no carga ese archivo: al arrancar sale con error y en un reload sigue con las reglas anteriores.

    Primero: docker run --rm --entrypoint promtool -v "$PWD/monitoring":/etc/prometheus:ro prom/prometheus:v3.13.3 check rules /etc/prometheus/alerts.yml

    → Sección 7 · Recording rules y alerting rules: expr, for, labels y annotations

  15. undefined receiver "pagerduty-ml" used in route

    Una ruta de Alertmanager manda a un receiver que no está en la lista receivers: un typo, o lo borraste. Alertmanager no arranca con esa config, y en un reload sigue con la anterior. amtool check-config lo atrapa antes de aplicar.

    Primero: amtool check-config alertmanager.yml

    → Sección 8 · Alertmanager: ruteo, agrupación, inhibición, silencios y receivers

  16. msg="Notify for alerts failed" … err="slack-ml/slack[0]: notify retry canceled after 4 attempts: …"

    Alertmanager tiene la alerta y la ruteó, pero el envío al receptor falló: la URL o el token del webhook (la dirección que recibe el aviso), la red de salida o un rechazo del servicio. Reintenta hasta que vence el plazo y lo deja en su log; alertmanager_notifications_failed_total sube. Lo que sigue a attempts: es el último error (la respuesta del receptor o el de la red). Si el receptor contesta con un error que reintentar no arregla (por ejemplo un 4xx), el texto es notify retry canceled due to unrecoverable error after 1 attempts.

    Primero: kubectl logs -n monitoring alertmanager-kube-prometheus-stack-alertmanager-0 -c alertmanager | grep 'Notify for alerts failed'

    → Sección 8 · Alertmanager: ruteo, agrupación, inhibición, silencios y receivers

  17. msg="Error sending alerts" … err="Post \"http://alertmanager:9093/api/v2/alerts\": dial tcp …: connect: connection refused"

    Lo dice Prometheus: no pudo entregar las alertas a Alertmanager (la dirección o el puerto de alerting.alertmanagers están mal, o Alertmanager está caído). Las alertas salen firing en /alerts, pero nadie las recibe; prometheus_notifications_errors_total sube. Hasta Prometheus 3.0 el texto era Error sending alert, en singular.

    Primero: curl -s localhost:9090/api/v1/alertmanagers | jq '.data.activeAlertmanagers'

    → Sección 8 · Alertmanager: ruteo, agrupación, inhibición, silencios y receivers

  18. There was an error returned querying the Prometheus API

    Grafana no pudo hablar con Prometheus: la URL, el protocolo o el puerto del data source, o Prometheus no se alcanza desde donde corre Grafana. El clásico: Grafana en un contenedor con http://localhost:9090, y ahí localhost es el propio Grafana. Usa el nombre del servicio (http://prometheus:9090 en compose) o el Service del cluster.

    Primero: curl -s localhost:9090/-/ready · GrafanaConnectionsData sourcesPrometheusSave & test

    → Sección 9 · Grafana: data sources, dashboards, paneles, variables y dashboards como código

  19. Datasource ${DS_PROMETHEUS} was not found

    El dashboard viene de un JSON exportado para compartir afuera: trae la entrada ${DS_PROMETHEUS}, que solo se llena al importarlo a mano. Por provisioning (dashboards como código) nadie la llena. Exporta sin esa opción, o reemplaza la referencia por el uid (el id fijo) de tu data source.

    Primero: grep -rn 'DS_PROMETHEUS' dashboards/

    → Sección 9 · Grafana: data sources, dashboards, paneles, variables y dashboards como código

  20. prometheus_client.registry.DuplicateTimeseries: Duplicated timeseries in CollectorRegistry: {'classifier_requests_total', …}

    Registraste dos veces una métrica con el mismo nombre en el registro global de prometheus_client: el módulo se importó dos veces (con nombres distintos, o un test que lo recarga) o re-ejecutaste la celda del notebook. Un counter reserva tres nombres (sin sufijo, _total y _created). Desde prometheus-client 0.26.0 (la del C01) la excepción es DuplicateTimeseries, una subclase de ValueError; antes salía ValueError: con el mismo texto. Las métricas se crean una vez, al nivel del módulo.

    Primero: grep -rnE "Counter\(|Histogram\(" app/

    → Sección 3 · Instrumentar tu código y usar exporters: prometheus_client, el gateway de C01, Triton, node_exporter, kube-state-metrics y cAdvisor

  21. ValueError: counter metric is missing label values

    Llamaste .inc() directo sobre una métrica que declaró labels: primero va .labels(status=…, source=…). Si los nombres de .labels() no son los declarados, el error es Incorrect label names. Revienta en el request, no al arrancar: un test que pase por ese camino lo atrapa.

    Primero: grep -rnE "\.(inc|observe)\(" app/

    → Sección 3 · Instrumentar tu código y usar exporters: prometheus_client, el gateway de C01, Triton, node_exporter, kube-state-metrics y cAdvisor

  22. no matches for kind "ServiceMonitor" in version "monitoring.coreos.com/v1"

    El cluster no conoce el tipo ServiceMonitor: faltan los CRDs (las definiciones de tipos nuevos) del Prometheus Operator, porque kube-prometheus-stack no está instalado o se instaló después. kubectl termina con ensure CRDs are installed first. Aplica monitoring.yaml después del chart.

    Primero: kubectl get crd servicemonitors.monitoring.coreos.com

    → Sección 11 · En Kubernetes: kube-prometheus-stack, el Operator, ServiceMonitor, PrometheusRule y KEDA

  23. prometheus metrics 'prometheus' target may be lost, the result is empty

    Lo dice KEDA con ignoreNullValues: "false", como en el C01: la query de su trigger no devolvió ninguna serie. Casi siempre Prometheus no scrapea a Triton (falta el ServiceMonitor o su label release) o la query pide un label que no existe. Con el default (true), KEDA lo tomaría como 0 y nada avisaría. Sale como evento KEDAScalerFailed del ScaledObject.

    Primero: kubectl describe scaledobject triton -n classifier

    → Sección 11 · En Kubernetes: kube-prometheus-stack, el Operator, ServiceMonitor, PrometheusRule y KEDA

  24. prometheus query rate(nv_inference_queue_duration_us{model="classifier"}[2m]) returned multiple elements

    La query de KEDA devolvió más de una serie, y KEDA necesita un solo número. Pasa al quitar el sum() de afuera: queda una serie por pod de Triton. Agrega con sum() (o max()) hasta que devuelva una sola serie.

    Primero: curl -s localhost:9090/api/v1/query --data-urlencode 'query=rate(nv_inference_queue_duration_us{model="classifier"}[2m])' | jq '.data.result | length'

    → Sección 11 · En Kubernetes: kube-prometheus-stack, el Operator, ServiceMonitor, PrometheusRule y KEDA

  25. admission webhook "prometheusrulevalidate.monitoring.coreos.com" denied the request: Rules are not valid

    El webhook de validación (un chequeo que el API server consulta antes de guardar el objeto) que instala kube-prometheus-stack rechazó tu PrometheusRule: alguna regla no parsea. Así lo muestra Helm; kubectl apply muestra en cambio "prometheusrules" is invalid y debajo la regla que falló (group …, rule …: could not parse expression). El objeto no se creó ni cambió y siguen las reglas anteriores; el log del Operator repite el detalle (Invalid rule).

    Primero: docker run --rm --entrypoint promtool -v "$PWD/monitoring":/etc/prometheus:ro prom/prometheus:v3.13.3 check rules /etc/prometheus/alerts.yml · kubectl logs -n monitoring deploy/kube-prometheus-stack-operator | grep 'Invalid rule'

    → Sección 11 · En Kubernetes: kube-prometheus-stack, el Operator, ServiceMonitor, PrometheusRule y KEDA

  26. nv_inference_queue_duration_us metric names should not contain abbreviated units

    promtool check metrics revisa los nombres contra las convenciones de Prometheus: la unidad va completa y en base (_seconds, no _us ni _ms). Para la misma métrica marca también counter metrics should have "_total" suffix: los counters terminan en _total. Triton no sigue ninguna de las dos y funciona igual; en tu código, síguelas: con _seconds nadie confunde µs con ms, y _total dice counter de un vistazo.

    Primero: curl -s localhost:8002/metrics | docker run --rm -i --entrypoint promtool prom/prometheus:v3.13.3 check metrics

    → Sección 2 · El modelo de datos: nombre, labels, series y muestras; counter, gauge, histogram y summary; nombres y cardinalidad

Ejemplo real · Cómo leer un many-to-many: la fracción de 5xx por pod

textoForma exacta del mensaje en Prometheus 3.13 (promql/engine.go); las labels son las que agrega un ServiceMonitor del C01; el pod y la IP son ilustrativos
$ curl -s localhost:9090/api/v1/query --data-urlencode 'query=
    sum by (pod) (rate(classifier_requests_total{status=~"5.."}[5m]))
      / on (pod)
    rate(classifier_requests_total[5m])' | jq -r '.errorType, .error'
execution
found duplicate series for the match group {pod="gateway-5f6b9c7d48-7tq2m"} on the right hand-side of the operation: [{container="gateway", endpoint="http", instance="10.0.1.21:8080", job="gateway", model_version="2026-09-01-resnet50-fp16", namespace="classifier", pod="gateway-5f6b9c7d48-7tq2m", service="gateway", source="web", status="200"}, {container="gateway", endpoint="http", instance="10.0.1.21:8080", job="gateway", model_version="2026-09-01-resnet50-fp16", namespace="classifier", pod="gateway-5f6b9c7d48-7tq2m", service="gateway", source="mobile", status="200"}];many-to-many matching not allowed: matching labels must be unique on one side
  • Léelo por partes: match group {pod=…} son las labels de tu on (pod); right hand-side dice de qué lado están los repetidos; la lista muestra dos series de ese lado que solo difieren en source.
  • La causa: el lado derecho no está agregado. Para cada pod hay una serie por cada combinación de status, source y model_version, y PromQL no sabe con cuál dividir.
  • Arreglo: agrega los dos lados igual, sum by (pod) (…) / sum by (pod) (…). group_left es para otra cosa: unir muchas series con una sola, por ejemplo para copiar labels de kube_pod_info.
  • Sale con errorType execution: la query parseó bien y falló al calcularse. Un error de sintaxis habría sido bad_data.
  • En una regla, el mismo error no se ve en la página de alertas: la regla queda con health err y el texto en lastError (Status → Rule health), y la alerta nunca dispara.

Cómo recordarla

quién habla dice dónde mirar. Get "…" en lastError: el scrape (red, puerto, path). parse error o bad_data: tu PromQL no corrió. PromQL info o warning: corrió, pero es sospechosa. Un log level=ERROR: el servidor. Un traceback: tu instrumentación.

Trampa

buscar el error donde no está. El de un target no sale en tu app ni en la query: está en Target health. El de una regla que no evalúa tampoco grita: queda en Rule health. Y algunos textos dependen de la versión: en Prometheus 2.x un Content-Type raro se toleraba; desde 3.0 el scrape falla con received unsupported Content-Type, y un le="1" se guarda como le="1.0".

Laboratorio (opcional)5 pasos

Qué vas a provocar: Reconocer los 26 mensajes en 5 minutos y provocar con prometheus_client los tres errores de instrumentación más comunes, más una serie que todavía no existe.

Necesitas: Una terminal de macOS (la ronda usa sort -R) y Python 3 para el venv. Ni Docker ni cluster.

  1. Paso 1
    ronda() { printf '%s\n' 'server returned HTTP status 404 Not Found' 'Get "http://10.0.1.12:8002/metrics": context deadline exceeded' 'dial tcp 10.0.1.21:8080: connect: connection refused' 'dial tcp: lookup gateway on 127.0.0.11:53: no such host' 'received unsupported Content-Type "application/json" and no fallback_scrape_protocol specified for target' 'field scrape_config not found in type config.plain' 'Lifecycle API is not enabled.' 'many-to-many matching not allowed: matching labels must be unique on one side' 'vector cannot contain metrics with the same labelset' 'expected type range vector in call to function "rate", got instant vector' 'PromQL info: metric might not be a counter, name does not end in _total/_sum/_count/_bucket' 'query processing would load too many samples into memory in query execution' 'PromQL warning: bucket label "le" is missing or has a malformed value' 'group "classifier-alerts", rule 1, "ClassifierHighP99Latency": could not parse expression' 'undefined receiver "pagerduty-ml" used in route' 'msg="Notify for alerts failed" … notify retry canceled after 4 attempts' 'msg="Error sending alerts" … /api/v2/alerts: connect: connection refused' 'There was an error returned querying the Prometheus API' 'Datasource ${DS_PROMETHEUS} was not found' 'Duplicated timeseries in CollectorRegistry' 'ValueError: counter metric is missing label values' 'no matches for kind "ServiceMonitor" in version "monitoring.coreos.com/v1"' "prometheus metrics 'prometheus' target may be lost, the result is empty" 'prometheus query … returned multiple elements' 'admission webhook "prometheusrulevalidate.monitoring.coreos.com" denied the request: Rules are not valid' 'nv_inference_queue_duration_us metric names should not contain abbreviated units' | sort -R | head -5; }; ronda

    Qué deberías ver: Cinco mensajes al azar. Para cada uno, en menos de 30 s y en voz alta: quién lo dijo (el scrape, PromQL, Alertmanager, Grafana, tu código, kubectl o KEDA), la causa probable y el primer comando. Después compáralo con la lista.

  2. Paso 2
    ronda

    Qué deberías ver: Otra ronda de cinco. Repite hasta completar 5 minutos; la meta es 5 de 5 sin mirar.

  3. Paso 3
    mkdir -p ~/lab-prom && cd ~/lab-prom
    python3 -m venv .venv && .venv/bin/pip install -q prometheus-client==0.26.0
    cat > cliente.py <<'EOF'
    # cliente.py: los errores de prometheus_client que vas a ver al instrumentar (y una serie que todavía no existe)
    from prometheus_client import CollectorRegistry, Counter, generate_latest
    
    reg = CollectorRegistry()  # un registro propio: así el script se puede correr las veces que quieras
    REQUESTS = Counter("classifier_requests_total", "Requests por código", ["status", "source"], registry=reg)
    
    def prueba(nombre, f):
        try:
            f()
            print(f"{nombre}: sin error")
        except ValueError as e:  # DuplicateTimeseries también es un ValueError
            clase = type(e).__name__ if type(e) is ValueError else f"{type(e).__module__}.{type(e).__name__}"
            print(f"{nombre}: {clase}: {e}")
    
    prueba("1 crear dos veces", lambda: Counter("classifier_requests_total", "otra vez", registry=reg))
    prueba("2 inc sin labels", lambda: REQUESTS.inc())
    prueba("3 label mal escrito", lambda: REQUESTS.labels(estado="200", source="web").inc())
    print("4 /metrics antes del primer request:")
    print(generate_latest(reg).decode().strip())
    REQUESTS.labels(status="200", source="web").inc()
    print("5 /metrics después de uno:")
    print(generate_latest(reg).decode().strip())
    EOF
    .venv/bin/python cliente.py

    Qué deberías ver: 1 crear dos veces: prometheus_client.registry.DuplicateTimeseries: Duplicated timeseries in CollectorRegistry: con los tres nombres que reserva el counter; 2 inc sin labels: ValueError: counter metric is missing label values; 3 label mal escrito: ValueError: Incorrect label names. En 4, /metrics solo trae # HELP y # TYPE: sin requests no hay serie. En 5 aparecen classifier_requests_total{source="web",status="200"} 1.0 y su _created (la hora en que nació la serie).

  4. Paso 4
    .venv/bin/python cliente.py | head -1

    Qué deberías ver: La misma línea 1 con los tres nombres en otro orden: Python muestra el conjunto en un orden que cambia de corrida en corrida. Busca el nombre, no la posición.

  5. Paso 5
    cd ~ && rm -rf ~/lab-prom

    Qué deberías ver: Sin salida.

Cómo arreglarlo y confirmarlo

Los que fallaste en la ronda: abre la sección de su tema (el enlace de la lista) y haz su laboratorio. Para no repetir los errores del cliente: métricas creadas una vez al nivel del módulo, siempre .labels(...) antes de .inc() y un test que pase por cada camino que las usa.

Probado de verdad en un Mac. Ejecutado aquí: la ronda en zsh y cliente.py con prometheus-client 0.26.0 y Python 3.13.16 de un venv que ya existía (el pip install no se corrió). Los textos de los demás mensajes salen del código de Prometheus v3.13.3, Alertmanager v0.34.1, KEDA v2.21.0, prometheus-operator v0.94.2, Grafana v13.2.4 (y su guía de troubleshooting) y la API de Kubernetes.

⚙

Herramientas

Del síntoma al primer comando (o a la pantalla que hay que abrir), y una revisión rápida de tus reglas de alerta antes de cargarlas.

Diagnosticador

Elige lo que ves. Te dice qué mirar primero y las causas típicas, cada una con cómo comprobarla y cómo arreglarla. Los comandos se copian; un lugar de la web, como PrometheusStatusTarget health, se abre en Prometheus o en Grafana.

Elige un síntoma para ver las causas.

Revisor de reglas de alerta

Pega un archivo de reglas de Prometheus (el YAML con groups: que carga rule_files) o un PrometheusRule (las mismas reglas bajo spec.groups). Revisa 18 reglas típicas en tu navegador: alertas sin for, sin severity o sin el texto para quien la recibe, rate() sobre un gauge, ventanas de menos de 4 scrapes, histogram_quantile sin le o promediado, umbrales en milisegundos, alertas atadas a un pod, divisiones sin proteger y nombres fuera de la convención. Cada hallazgo dice el grupo, la regla y la línea.

No reemplaza a promtool check rules: no valida el PromQL ni las plantillas. Mira lo que promtool deja pasar y después duele.

Lee el YAML con js-yaml (de cdnjs): después de la primera visita queda guardado y funciona sin conexión.