Ronda de Diseño ML · Monitoreo · ejemplos de C01
MonitoreoPara 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
¿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"} == 0up 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
| Variable | Qué es | Ejemplo |
|---|---|---|
| host | dónde corre el target, visto desde donde haces curl | localhost (con kubectl port-forward) o gateway dentro del compose |
| puerto | puerto donde el target sirve /metrics | 8080 gateway · 8002 Triton · 9100 node_exporter |
| job | grupo de targets: el job_name de prometheus.yml | gateway |
| label | label por el que partes el resultado | status, source o le |
| contador | nombre del counter sin el _total | classifier_requests |
| ventana | cuánto tiempo hacia atrás mira la función | 5m en las alertas de C01; 1m como mínimo práctico con scrape de 15 s |
| q | cuantil, entre 0 y 1 | 0.99 |
| histograma | nombre base del histograma | classifier_request_latency_seconds |
| x: el valor del counter; w: la ventana; t: la hora de cada muestra | lab de la sección 2: 0.371/s en 2 min, con un reinicio adentro | |
| cada cuánto lee Prometheus (scrape_interval) | 15 s en C01; 1 min si no lo escribes | |
| series que se guardan en cada ronda | ≈ 205 por pod del gateway (sección 3): 615 con 3 pods | |
| cuánto guarda el disco local | 15 días si nadie la cambia |
Fórmulas
| Pieza | Qué hace | Ejemplo |
|---|---|---|
| 9090 | Prometheus: su web (consultas, alertas, targets), su API HTTP y sus propias métricas en /metrics. | localhost:9090/alerts en el compose de C01 |
| 9093 | Alertmanager: 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 |
| 3000 | Grafana: 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 · 9091 | Exporters (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 |
| 8002 | Las 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: 1m | Default 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: 10s | Cuá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: 1m | Cada 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: 15d | Cuá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 → directo | Sube 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_quantile | Cajones 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) |
| Summary | El 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_wait | Una 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 |
| Campo | Default | En C01 | Por qué importa |
|---|---|---|---|
| scrape_interval | 1m (Prometheus Operator: 30s) | 15s en prometheus.yml y en los ServiceMonitors | la resolución: con 1m, la regla práctica pide rate(x[4m]) o más |
| scrape_timeout | 10s; si el intervalo es menor, el intervalo | sin cambiar (10s) | un /metrics lento da up = 0 aunque el proceso viva |
| evaluation_interval | 1m (Prometheus Operator: 30s) | 15s; el grupo de grabación usa interval: 1m | cada cuánto puede cambiar de estado una alerta |
| metrics_path · scheme | /metrics · http | /metrics en los dos jobs | otra ruta se escribe en el job, no en targets |
| Retención (storage.tsdb.retention.time) | 15d | sin cambiar | ≥ 7 días para el promedio de ClassifierLowConfidenceSpike |
| for | 0s: firing en la primera evaluación verdadera | 5m, 10m, 15m o 30m según la alerta | filtra picos cortos; la ventana del rate los estira, así que cuenta las dos |
| query.lookback-delta | 5m | — | una consulta instantánea toma la última muestra de hasta 5 min atrás |
| rules.alert.resend-delay | 1m | — | Prometheus reenvía a Alertmanager cada alerta que sigue en firing |
| group_wait · group_interval · repeat_interval | 30s · 5m · 4h | sin Alertmanager en local | cuándo llega el primer aviso, los siguientes del grupo y el recordatorio |
| sample_limit · label_limit | 0 (sin límite) | sin cambiar | con 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 KEDA | Prometheus 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.
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
- tritonv3.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./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.alerting: en prometheus.yml: en local las alertas se ven en /alerts, pero nadie recibe un aviso. En EKS lo trae kube-prometheus-stack.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.
En el curso: M12 · Prometheus, Grafana y alertas por SLO C01 · Monitoreo, alertas y reentrenamiento Herramientas · Monitoreo: sistema y modelo Docker y kubectl · docker compose Hoja · Formulario de estimaciones
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.
docker run -d --name prom-lab -p 9090:9090 prom/prometheus:v3.13.3Qué 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.
sleep 5; curl -s localhost:9090/-/readyQué deberías ver: Prometheus Server is Ready. Si todavía arranca, Service Unavailable (un 503): espera y repite.
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.
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.
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.
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.
docker rm -f -v prom-labQué 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í.
¿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 metricaEl 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/alertsLas 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/alertsLo que recibió Alertmanager. Desde aquí agrupa, silencia y avisa a un receiver (el destino: PagerDuty, Slack, email o un webhook).
Variables
| Variable | Qué es | Ejemplo |
|---|---|---|
| METRICA | variable de Python que guarda el counter | REQUESTS |
| label=valor | la serie que sube | status="503" |
| host:puerto | el target | localhost:8080 con kubectl port-forward |
| metrica | nombre expuesto de la métrica | classifier_requests_total |
| expresion | la PromQL que quieres ver | la expr de ClassifierHigh5xxRate |
| lo que tarda la expresión en pasar el umbral desde que empiezan los errores | 30 s con 10 % de errores | |
| ventana del rate() | 5 min | |
| fracción de errores que empezó de golpe | 10 %: Triton rechaza 1 de cada 10 | |
| intervalos de scrape y de evaluación de reglas | 15 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) | |
| lo que la alerta espera en pending | 5 min en ClassifierHigh5xxRate | |
| group_wait: lo que Alertmanager espera antes del primer aviso de un grupo nuevo | 30 s por defecto |
Fórmulas
| Pieza | Qué hace | Ejemplo |
|---|---|---|
| Instrumentación | Las llamadas en tu código que suben counters y observan histogramas, en memoria. | REQUESTS.labels(…).inc() en el finally de classify |
| /metrics | La foto de esos números en texto, cada vez que alguien la pide. | gateway:8080/metrics |
| Scrape | Prometheus lee cada target cada scrape_interval y escribe up (1 o 0). | cada 15 s en C01 |
| TSDB: head y WAL | La 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 h | Cada 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 |
| PromQL | Calcula sobre las series: tasas, cocientes, sumas, cuantiles. | la fracción de 5xx sobre requests válidos |
| Regla de grabación | Guarda el resultado de una expresión cara como una serie nueva, en cada evaluación. | classifier:low_confidence_share:rate30m |
| Regla de alerta | Evalúa su expr cada evaluation_interval: cada serie que devuelve es una alerta activa. | ClassifierHigh5xxRate |
| pending → firing | pending mientras la condición no cumple el for completo; firing cuando sí. Si deja de cumplirse, vuelve a inactive. | for: 5m |
| ALERTS | Serie que escribe Prometheus por cada alerta activa, con alertstate pending o firing. | ALERTS{alertname="ClassifierHigh5xxRate"} |
| alerting: alertmanagers | A qué Alertmanager manda Prometheus las alertas en firing; las reenvía cada minuto mientras sigan. | no está en el prometheus.yml de C01 |
| Alertmanager | Agrupa, 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 runbook | El 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 |
| Grafana | Una 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
Códigotu servicio
REQUESTS.labels(status="503", …).inc()
Suma 1 en la memoria del proceso: microsegundos, sin red.
/metricstu servicio
curl -s localhost:8080/metrics | grep 'status="503"'
El valor acumulado, en texto, cuando alguien lo pide.
ScrapePrometheus
PromQLup{job="gateway"}
Cada 15 s Prometheus lee cada pod (10 s de timeout) y escribe up.
TSDBPrometheus
prometheus_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.
PromQLPrometheus
sum(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.
ReglaPrometheus
reglaClassifierHigh5xxRate
Se evalúa cada 15 s (evaluation_interval). La primera vez que la expr devuelve algo: pending.
forPrometheus
ALERTS{alertname="ClassifierHigh5xxRate"}
5 min seguidos en pending → firing. Si la condición se apaga antes, vuelve a inactive y no avisa.
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.
AlertmanagerAlertmanager
curl -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.
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
histogram_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)
# 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"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.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.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í.
En el curso: C01 · Monitoreo, alertas y reentrenamiento M12 · Prometheus, Grafana y alertas por SLO M12 · Dashboards, runbooks y on-call C01 · Archivos de referencia Hoja · Del dato a producción · la estación de monitoreo
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.
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/metricsQué 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.
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.ymlQué 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.
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.
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')"; doneQué 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.
rm fallar
sleep 150Qué 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.
kill $(cat app.pid) $(cat webhook.pid)
docker rm -f -v prom-lab am-lab
docker network rm lab-prom
cd ~ && rm -rf ~/lab-promQué 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í.
¿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/metricsLo 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
| Variable | Qué es | Ejemplo |
|---|---|---|
| servicio | dónde está el target | gateway en el compose de C01 |
| puerto | puerto donde sirve /metrics | 8080 |
| version | tag fijo de la imagen | v3.13.3, la de C01 |
| requests por día del servicio | 10 M en C01 | |
| bytes de una línea de log | ≈ 300 B en C01 | |
| series que guarda Prometheus del gateway | ≈ 615 con 3 pods (sección 3) | |
| intervalo de scrape | 15 s → 5,760 rondas por día |
Fórmulas
| Pieza | Qué hace | Ejemplo |
|---|---|---|
| Monitoreo | Saber, 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étrica | Un 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 |
| Log | Un 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) |
| Prometheus | El 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 · instance | Target: 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", …} |
| TSDB | La 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 |
| PromQL | El 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 |
| Alertmanager | Recibe las alertas, las agrupa, silencia e inhibe, y decide a quién avisar y cómo (page o ticket). | lo trae kube-prometheus-stack |
| Grafana | Dibuja dashboards consultando a Prometheus con PromQL. No guarda métricas. | servicio, Triton y GPU, modelo y negocio (el curso) |
| Exporter · Pushgateway | Exporter: 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:
cada ronda, en segundos
015304560gateway · 3 pods:8080/metricsjob="gateway"
triton · 2 pods:8002/metricsjob="triton"
kube-state-metrics:8080/metrics
node_exporter · 1 por nodo:9100/metrics
un pod del gateway que acaba de morir:8080/metricsjob="gateway"
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.
# 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 minGET /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.up, y una alerta sobre una serie que no existe no dispara: su expresión no devuelve nada.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.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.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.
En el curso: M12 · Por qué un modelo falla en silencio M12 · Métricas de sistema: golden signals con detalles de ML M12 · Prometheus, Grafana y alertas por SLO Herramientas · Monitoreo: sistema y modelo
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.
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()
EOFQué deberías ver: Sin salida: el archivo queda escrito.
python3 objetivo.py & echo $! > objetivo.pid
sleep 1; curl -s -i localhost:8000/metricsQué 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).
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 4Qué 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.
kill $(cat objetivo.pid)
python3 ronda.py 2Qué 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.
python3 objetivo.py & echo $! > objetivo.pid
sleep 1; python3 ronda.py 2Qué 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).
kill $(cat objetivo.pid); cd ~ && rm -rf ~/lab-promQué 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).
¿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"} numeroUna 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
| Variable | Qué es | Ejemplo |
|---|---|---|
| metrica | nombre de la métrica | classifier_requests_total |
| label, valor · label2, valor2 | dimensiones y sus valores: la serie lleva todos sus labels | status="503", source="web" |
| numero | el valor: siempre un float de 64 bits | 17.0 |
| contador, gauge, hist | nombres base de cada tipo | classifier_requests · nv_gpu_utilization · classifier_request_latency_seconds |
| ventana | cuánto mira hacia atrás | 5m |
| prefijo, que_mide, unidad | las partes del nombre | classifier · request_latency · seconds |
| series que puede tener una métrica | 72 para classifier_requests_total por pod, como máximo | |
| el label i; |L_i| es cuántos valores distintos puede tomar | status 9, source 4, model_version 1 por pod | |
| buckets con borde finito y combinaciones de labels; el + 4 son +Inf, _sum, _count y _created | 11 y 4 en la latencia de C01 | |
| promedio de lo observado en la ventana | latencia media por request |
Fórmulas
| Pieza | Qué hace | Ejemplo |
|---|---|---|
| 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étrica | Qué 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 |
| Label | Una 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" |
| Counter | Solo 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) |
| Gauge | Sube y baja: vale lo que marca ahora. Se lee directo o con *_over_time. | nv_gpu_utilization, kube_deployment_status_replicas_available |
| Histogram | Cuenta 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 |
| Summary | Calcula 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 histogram | Histograma 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 |
| _created | La 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 base | Segundos, 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 _info | Un gauge que siempre vale 1 y lleva datos en sus labels: versión, build. | python_info{implementation="CPython", …, version="3.13.16"} 1.0 |
| Cardinalidad | Cuá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)
001060000003el proceso nuevo empieza de 0
rate(x[2m]) 0.371/scorrige el reinicioincrease(x[2m]) 44.6requests en la ventanaComo 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)
avg_over_time(x[2m]) 3.62lo que marcó en promedio: la línea punteadamax_over_time(x[2m]) 6el pico, en el círculoComo 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()
# ---------------------------------------------------------------------------- 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),
)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.classifier_* por pod (sección 3).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.
En el curso: M12 · Prometheus, Grafana y alertas por SLO M12 · Métricas de sistema: golden signals con detalles de ML M3 · p50, p95, p99: por qué el promedio miente C01 · Monitoreo, alertas y reentrenamiento Hoja · El gateway · observabilidad
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.
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)}")
EOFQué deberías ver: Sin salida: el archivo queda escrito.
python3 rate.py | head -n 5Qué 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.
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.
python3 rate.py | tail -n 2Qué 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.
cd ~ && rm -rf ~/lab-promQué 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.
¿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 webExponer: 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 nCon 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
| Variable | Qué es | Ejemplo |
|---|---|---|
| CONTADOR, HISTO, GAUGE | variables de Python con cada métrica | REQUESTS y LATENCY en C01 (no tiene gauges) |
| prefijo, cosa | las partes del nombre | classifier · request_latency |
| ayuda | texto de # HELP | Latencia total de /v1/classify |
| label | dimensión con valores de una lista cerrada | status |
| bordes | bordes de los buckets, varios cerca del SLO | 0.01, 0.025 … 2.5 (11 en C01) |
| puerto | puerto donde escucha /metrics | 8080 en el gateway; 8000 en el lab |
| carpeta | carpeta del modo multiproceso, vacía al arrancar | /tmp/prom-multiproc |
| n, app | procesos del servidor y la app que corre | 1 worker en C01 · app.main:app |
| version | tag de la imagen de Prometheus | v3.13.3 |
| series que expone un pod del gateway en régimen | 200: 184 classifier_* + 16 del cliente, en Linux | |
| réplicas del gateway (las mueve el HPA, el autoscaler de Kubernetes) | de 3 a 12 | |
| series que Prometheus agrega por target | up y las cuatro scrape_* |
Fórmulas
| Pieza | Qué hace | Ejemplo |
|---|---|---|
| prometheus_client | La 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 defecto | process_* (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 multiproceso | Para varios procesos por pod: PROMETHEUS_MULTIPROC_DIR y MultiProcessCollector. Sin él, cada scrape cae en un proceso distinto. | C01 lo evita con --workers 1 |
| Exporter | Un 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 software | Programas que ya exponen /metrics por su cuenta: no hace falta exporter. | Triton en :8002 (nv_*); el propio Prometheus en :9090 |
| kube-state-metrics | Lee 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 |
| Pushgateway | Un 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)
classifier_requests_totalcounter
status (9) × source (4) × model_version (1) · 2 por combinación
72
classifier_request_latency_secondshistogram
source (4) · 15 por combinación
60
classifier_preprocess_secondshistogram
sin labels · 12 por combinación
12
classifier_inference_secondshistogram
sin labels · 13 por combinación
13
classifier_top1_scorehistogram
model_version (1) · 14 por combinación
14
classifier_image_byteshistogram
sin labels · 13 por combinación
13
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.
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)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.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/.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.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.
En el curso: M12 · Prometheus, Grafana y alertas por SLO M12 · Métricas de sistema: golden signals con detalles de ML M7 · FastAPI + PyTorch: el punto de partida C01 · Archivos de referencia Hoja · El gateway · observabilidad Hoja · De PyTorch a Triton · medir
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.
mkdir -p ~/lab-prom && cd ~/lab-prom
python3.13 -m venv .venv
.venv/bin/pip install prometheus-client==0.26.0Qué deberías ver: Termina con Successfully installed prometheus-client-0.26.0, la versión que fija C01 (no tiene dependencias).
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)
EOFQué deberías ver: Sin salida: el archivo queda escrito. Las seis definiciones son copia exacta de main.py; el tráfico es simulado.
.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.
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.
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_*.
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.
kill $(cat metricas.pid); cd ~ && rm -rf ~/lab-promQué 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.
¿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: valorglobal 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: valorRelabeling: 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: dropmetric_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
| Variable | Qué es | Ejemplo |
|---|---|---|
| intervalo | cada cuánto lee y evalúa | 15s en C01 |
| reglas | archivo de reglas, relativo a prometheus.yml | alerts |
| job | nombre único del grupo de targets | gateway |
| ruta | ruta de las métricas en el target | /metrics (el default) |
| host:puerto | dónde está el target, visto desde Prometheus | gateway:8080 en el compose; host.docker.internal:8000 hacia tu Mac |
| label: valor | label fijo para todas las series de esos targets | env: lab |
| label_origen | label que miras para decidir; en Kubernetes, un __meta_* | __address__ · __meta_kubernetes_namespace |
| regex | expresión regular, anclada al inicio y al final | .*:8001 |
| patron | nombres de métricas que no quieres guardar | go_.* |
| version | tag de la imagen de Prometheus | v3.13.3 (la de C01) |
| contenedor | nombre del contenedor de Prometheus | prom-lab |
| scrape_timeout y scrape_interval | 10 s y 15 s en C01 | |
| series que expone cada target, más las 5 que agrega Prometheus | 205 por pod del gateway |
Fórmulas
| Pieza | Qué hace | Ejemplo |
|---|---|---|
| global | Valores por defecto para todos los jobs (intervalos y timeout), más los external_labels de este Prometheus. | scrape_interval: 15s en C01 |
| external_labels | Labels 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_name | Nombre único del job; termina como label job en cada serie. | job="gateway" |
| static_configs.targets | Lista 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"] |
| instance | Label que dice qué target fue: por defecto, el __address__ (host:puerto). | instance="gateway:8080" |
| metrics_path · scheme | Ruta y protocolo del scrape: /metrics y http por defecto. | C01 escribe metrics_path: /metrics aunque es el default |
| scrape_timeout | Cuá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_configs | Reglas 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_configs | Reglas que corren después del scrape, sobre cada muestra: tiran o renombran series antes de guardarlas. | drop de go_.* en el lab |
| honor_labels | Si 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 health | La 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
Descubrimientoantes del scrape
static_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_*.
relabel_configsantes del scrape
action: 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ó.
Target listoantes del scrape
curl -s localhost:9090/api/v1/targets
discoveredLabels (antes) y labels (después del relabeling). Los descartados quedan como dropped en Status → Service discovery.
Scrapela visita
GET http://gateway:8080/metrics
Cada scrape_interval (15 s en C01), con scrape_timeout (10 s). El formato se negocia con el header Accept.
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.
Límitesdespués del scrape
sample_limit · label_limit
Si una ronda los pasa, se descarta entera y up = 0. Default 0: sin límite.
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…
TSDBdespués del scrape
prometheus_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)
# 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 Tritonservicio:puerto en la red de Docker (gateway:8080, triton:8002). Desde el contenedor de Prometheus, localhost sería el propio Prometheus.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.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).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.
En el curso: M12 · Prometheus, Grafana y alertas por SLO C01 · Archivos de referencia M5 · docker compose para desarrollo local Docker y kubectl · docker compose Hoja · Docker · redes
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.
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/metricsQué 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.
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.ymlQué deberías ver: Checking /cfg/prometheus.yml y SUCCESS: /cfg/prometheus.yml is valid prometheus config file syntax.
docker run -d --name prom-lab -p 9090:9090 -v "$HOME/lab-prom/config:/etc/prometheus:ro" prom/prometheus:v3.13.3
sleep 20Qué 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.
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.
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.
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.
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.
kill $(cat mini.pid); docker rm -f -v prom-lab; cd ~ && rm -rf ~/lab-promQué 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í.
¿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
| Variable | Qué es | Ejemplo |
|---|---|---|
| métrica | nombre de la métrica | classifier_requests_total |
| valor | valor exacto de un label (los valores son texto) | "mobile" |
| regex | expresión regular que tiene que calzar con todo el valor | "5.." (de 500 a 599) |
| ventana | largo 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) |
| counter | un contador: suele terminar en _total, _count, _sum o _bucket (varios de Triton no llevan sufijo) | classifier_requests_total, nv_inference_request_success |
| label | label que queda después de agregar | source, model_version, le |
| k | cuántas series dejar | 3 |
| errores, total | selectores del numerador y del denominador | classifier_requests_total{status=~"5.."} y classifier_requests_total{status!~"4.."} |
| expresión, umbral | lo que comparas y el número contra el que comparas | … > 0.01 (1 %) |
| duración | cuánto hacia atrás | 1d, 1w |
| primer y último valor del counter dentro de la ventana | figura: 1,320 y 480 | |
| segundo de la primera y de la última muestra | figura: 45 y 150 | |
| lo que llevaba el contador justo antes de cada reinicio | figura: 1,680 | |
| la ventana, en segundos | 120 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
| Pieza | Qué hace | Ejemplo |
|---|---|---|
| Selector y matchers | Elige 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ío | Un 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 vector | Un 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 vector | Todas 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 · count | Agregan 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, …) · bottomk | Las 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 · unless | Operan 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) |
| offset | Corre 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"}) |
Restar el primero al último: no ve el reinicio
(480 − 1320) / (150 − 45)
−8 req/s
rate(): suma lo que el counter llevaba antes del reinicio
(480 − 1320 + 1680) / (150 − 45) = 840 / 105
8 req/s
increase(): rate × los segundos de la ventana
8 req/s × 120 s
960 requests
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
# 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)
/ 1000nv_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./ 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.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.
En el curso: M12 · Prometheus, Grafana y alertas por SLO C01 · Monitoreo, alertas y reentrenamiento M6 · KEDA: escalar por eventos y hasta cero M12 · Métricas de sistema: golden signals con detalles de ML Hoja · Kubernetes · autoscaling
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).
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 upQué 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.
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.
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.
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.
docker rm -f -v prom-lab
cd ~ && rm -rf ~/lab-promQué 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í.
¿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 observadosUn 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
| Variable | Qué es | Ejemplo |
|---|---|---|
| nombre | nombre base del histograma | classifier_request_latency_seconds |
| borde | un borde de bucket, en la unidad de la métrica | "0.2" (200 ms) |
| φ | el cuantil, entre 0 y 1 | 0.99 (p99), 0.5 (mediana) |
| ventana | ventana del rate | 5m |
| label | label que quieres conservar además de le | source |
| cuantil estimado por histogram_quantile | lab: 275 ms | |
| posición buscada y total de observaciones (el bucket +Inf) | lab: 99 de 100 | |
| bordes inferior y superior del bucket donde cae r | 0.2 y 0.3 s | |
| conteos acumulados en esos dos bordes | lab: 96 y 100 | |
| el percentil exacto, que Prometheus no conoce: siempre dentro del bucket | lab: 260 ms | |
| la media: crecimiento de _sum ÷ crecimiento de _count en la ventana | con los datos del lab: 3.16 s / 50 = 63 ms en pod-b | |
| buckets finitos y combinaciones de labels; el + 4 son +Inf, _sum, _count y _created | 11 y 4 (los valores de source) |
Fórmulas
| Pieza | Qué hace | Ejemplo |
|---|---|---|
| _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 · _count | La suma de los valores y la cantidad de observaciones: su cociente es la media. | rate(_sum) / rate(_count) |
| _created | La 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 defecto | Sin 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 +Inf | Si 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áfico | Con 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 |
| Summary | Calcula 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 histogram | Buckets 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 Grafana | Dibuja 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
¿Cumple el SLO de 200 ms? A escala, de 0 a 300 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.
# 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"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.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).
En el curso: M12 · Métricas de sistema: golden signals con detalles de ML M3 · p50, p95, p99: por qué el promedio miente C01 · Monitoreo, alertas y reentrenamiento M12 · Prometheus, Grafana y alertas por SLO Hoja · El gateway · observabilidad
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.
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.pyQué 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.
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.pyQué 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.
cd ~ && rm -rf ~/lab-promQué 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.
¿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: consultaUna 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
| Variable | Qué es | Ejemplo |
|---|---|---|
| grupo | nombre del grupo, único en el archivo | classifier-recording |
| intervalo | cada cuánto corre el grupo | 1m |
| nivel:métrica:operaciones | los labels que quedan, la métrica de origen y lo que se le hizo (la última operación primero) | classifier:low_confidence_share:rate30m |
| consulta | la expresión PromQL | histogram_quantile(0.99, sum by (le) (rate(…[5m]))) |
| NombreDeAlerta | en CamelCase; nombra el síntoma | ClassifierHighP99Latency |
| umbral | en la unidad base de la métrica | 0.2 (segundos) |
| espera | el for | 10m |
| retención | el keep_firing_for | 5m (C01 no lo usa) |
| severidad | el valor con el que rutea tu Alertmanager. En C01: page despierta a alguien, warning se mira en horario laboral, ticket se investiga esa semana | page, warning o ticket |
| estado | estado de la alerta en la serie ALERTS | pending o firing |
| resumen, detalle, url | qué pasa, el valor y por dónde empezar, y los pasos | summary, description y runbook_url de C01 |
| archivo, pruebas | el archivo de reglas y el de pruebas | alerts.yml, prueba.yml |
| nombre, namespace, release | nombre, namespace y label release del PrometheusRule | classifier, classifier, kube-prometheus-stack |
| cuándo la expr pasa el umbral; depende también de la ventana del rate | unos minutos después de que empieza el problema | |
| cada cuánto se evalúa el grupo | 15 s en el compose de C01; 30 s en EKS (default del Operator) | |
| espera de Alertmanager antes de la primera notificación de un grupo (sección 8) | 30 s por defecto | |
| 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
| Pieza | Qué hace | Ejemplo |
|---|---|---|
| groups[].name | Nombre del grupo, único en el archivo. Sus reglas corren en orden, con la misma hora de evaluación. | classifier-recording y classifier-alerts |
| interval | Cada 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 |
| record | Nombre 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 |
| alert | Nombre de la alerta: llega a Alertmanager como el label alertname. | ClassifierHighP99Latency |
| expr | La consulta. En una alerta, cada serie del resultado es una alerta: por eso termina en una comparación que filtra. | histogram_quantile(…) > 0.2 |
| for | Cuá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 rate | La 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_for | Cuánto sigue firing después de que la condición se apaga: evita resolver y redisparar en el borde. | keep_firing_for: 5m |
| labels | Labels que se agregan a la alerta; Alertmanager rutea con ellos. | severity: page |
| annotations | Texto 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 · $labels | En 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áfico | Una 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 |
| ALERTS | Serie que Prometheus crea por cada alerta pending o firing. | ALERTS{alertstate="firing"} |
| promtool | check rules valida; test rules prueba con series inventadas antes del deploy. | promtool check rules alerts.yml |
| PrometheusRule | El mismo YAML como objeto de Kubernetes; el Operator lo carga si su label calza con el ruleSelector. | release: kube-prometheus-stack |
| Recarga | Prometheus 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
ok · up · inactivependingfiring
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"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.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.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.
En el curso: C01 · Monitoreo, alertas y reentrenamiento C01 · Archivos de referencia M12 · Prometheus, Grafana y alertas por SLO M12 · Dashboards, runbooks y on-call
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.
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.ymlQué 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.
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.ymlQué 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.
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.ymlQué 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.
cd ~ && rm -rf ~/lab-promQué 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í.
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: falseEl á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: trueUn 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
| Variable | Qué es | Ejemplo |
|---|---|---|
| receiver_por_defecto | receiver de la raíz: lo que no calce con ningún hijo | equipo-slack |
| label, valor, regex | label de la alerta y el valor o la regex que pide el matcher | severity, "page", "TritonQueueTimeHigh|GPUSaturated" |
| espera | el group_wait | 30s |
| intervalo | el group_interval | 5m |
| repetición | el repeat_interval | 4h (1h en la ruta de page) |
| receiver, otro_receiver | nombres de destinos definidos en receivers | oncall-pagerduty, equipo-slack |
| archivo | archivo con el secreto, montado desde un Secret | /etc/alertmanager/secrets/pagerduty-routing-key |
| canal | canal de Slack | #classifier |
| duración, motivo | cuánto dura el silencio y por qué | 2h, "mantenimiento del nodo GPU" |
| id | el id que devuelve silence add | un UUID |
| alertmanager.yml | el archivo de configuración | alertmanager.yml |
| host | dónde escucha Alertmanager | alertmanager (compose) o el Service del Operator |
Fórmulas
| Pieza | Qué hace | Ejemplo |
|---|---|---|
| route (raíz) | Entrada de todas las alertas: no lleva matchers y tiene el receiver de reserva. | receiver: equipo-slack |
| routes · matchers | Hijos que eligen alertas por labels. Gana el primero que calza. | severity="page" → oncall-pagerduty |
| continue | Si 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_by | Labels 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_wait | Espera antes de la primera notificación de un grupo nuevo, para juntar las que llegan casi a la vez. Default 30s. | 30s |
| group_interval | Espera antes de avisar cambios en un grupo ya notificado (alertas nuevas o resueltas). Default 5m. | 5m |
| repeat_interval | Cada cuánto se repite el aviso si nada cambió. Default 4h; kube-prometheus-stack trae 12h. | 1h en la ruta de page |
| receivers | Destinos con nombre: pagerduty_configs, slack_configs, email_configs, webhook_configs, opsgenie_configs, msteams_configs y más. | oncall-pagerduty |
| send_resolved | Avisa también cuando la alerta se resuelve. Default true en PagerDuty y webhook, false en Slack y email. | send_resolved: true en Slack |
| inhibit_rules | Mientras una alerta fuente esté activa, calla a las objetivo que coinciden en los labels de equal. | TritonNotReady calla a TritonQueueTimeHigh |
| Silencio | Calla 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_intervals | Horarios 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 HA | Alertas 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_labels | Labels que Prometheus pega a cada alerta que manda: dicen de qué entorno o cluster viene. | env: local en el prometheus.yml de C01 |
| Watchdog | Alerta 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 |
| Webhook | Un 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
1severity="page"
→ oncall-pagerduty
repeat_interval: 1h
2severity="warning"
→ equipo-slack
active_time_intervals: horario-laboral
3severity="ticket"
→ ml-tickets
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
3 alertas en firing → 1 notificación
page: despierta a alguienwarning: en horario laboralticket: esta semanainhibida: no se notifica
# 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/alertmanagergroup_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).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.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ó.
En el curso: M12 · Prometheus, Grafana y alertas por SLO M12 · Dashboards, runbooks y on-call C01 · Monitoreo, alertas y reentrenamiento Herramientas · Monitoreo: sistema y modelo
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.
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í.
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.ymlQué deberías ver: Checking '/w/alertmanager.yml' con SUCCESS y, debajo de Found:, route, 1 inhibit rules y 3 receivers.
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=GPUSaturatedQué 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ó.
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.
docker exec am-lab amtool alert query --inhibited --alertmanager.url=http://localhost:9093Qué deberías ver: Una tabla con TritonQueueTimeHigh: sigue activa en Alertmanager, solo que inhibida.
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:9093Qué 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.
docker rm -f -v am-lab
kill %1
cd ~ && rm -rf ~/lab-promQué 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í.
¿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_intervalUn 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 series|Stat|Gauge|Heatmap|Table Unit: s|ms|percentunit|reqps 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_jsonDashboards 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
| Variable | Qué es | Ejemplo |
|---|---|---|
| nombre | nombre del data source o del provider | Prometheus |
| uid | id fijo del data source: los dashboards lo referencian | prom-lab |
| host | dónde escucha Prometheus, visto desde Grafana | prometheus (compose), prometheus-operated.monitoring.svc (EKS) |
| scrape_interval | el intervalo de scrape real de tu Prometheus | 15s |
| counter, nombre_bucket | un counter y los buckets de un histograma | classifier_requests_total, classifier_request_latency_seconds_bucket |
| variable, label, métrica | nombre de la variable, label cuyos valores ofrece y métrica de donde los saca | model_version, model_version, classifier_requests_total |
| valor | valor donde empieza el color del umbral | 0.2 (s) en el p99, 0.01 en la tasa de 5xx |
| carpeta | carpeta de Grafana donde aparece el dashboard | Clasificador |
| ruta_de_los_json | carpeta del contenedor con los archivos JSON | /var/lib/grafana/dashboards |
| el Scrape interval configurado en el data source | 15 s | |
| el paso entre puntos de un panel: crece con el rango de tiempo y baja con el ancho del panel | más grande con 7 días que con 1 hora | |
| la ventana para rate en un panel | nunca menos de 4s = 60 s |
Fórmulas
| Pieza | Qué hace | Ejemplo |
|---|---|---|
| Data source | La conexión a Prometheus: tipo y URL. Grafana consulta; no guarda métricas. | http://prometheus-operated.monitoring.svc:9090 |
| Dashboard | Una 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) |
| Row | Una fila plegable que agrupa paneles. | Ahora · Latencia · Triton y GPU · Modelo |
| Time series | Líneas en el tiempo: tasas, latencias, cola. La visualización por defecto. | p50 y p99 del gateway |
| Stat | Un número grande con color por umbral (por defecto, el último valor). | tasa de 5xx de ahora |
| Gauge · Bar gauge | El valor contra un mínimo y un máximo que fijas. | pods de Triton listos, de 0 a 4 |
| Heatmap | La 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 |
| Table | Una fila por serie, con sus valores y labels. | top 5 de callers (quién llama: el label source) por req/s |
| Variable | Un 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_interval | La ventana para rate que se ajusta al zoom y nunca baja de 4 scrapes. | rate(classifier_requests_total[$__rate_interval]) |
| Unit | Cómo se muestra el número: s, ms, percentunit (0.0–1.0 como %), reqps. | percentunit en la tasa de 5xx |
| Thresholds | Colores 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 |
| Legend | El nombre de cada serie; {{label}} usa el valor de ese label. | {{gpu_uuid}} en la utilización por GPU |
| Annotations | Marcas verticales que salen de una consulta: deploys, reinicios, alertas. | changes(process_start_time_seconds{job="gateway"}[5m]) > 0 |
| Provisioning | Data sources y dashboards desde archivos al arrancar: viven en git, no en un clic. | /etc/grafana/provisioning |
| Explore | Pantalla para consultas sueltas, sin armar un panel: el lugar para probar una query. | Builder o Code |
| Alertas de Grafana | Grafana también puede evaluar alertas. Elige un solo lugar para las reglas (en C01, Prometheus), versionado en git. | alerts.yml de C01 |
Ahora
Latencia
Triton y GPU
Modelo
A escala: 24 columnas de ancho; cada fila de la grilla, una unidad de alto.
1Requests/sStat · unit reqps
sum(rate(classifier_requests_total{source=~"$source"}[$__rate_interval]))
25xx sobre válidosStat · unit percentunitbase: verde0.01: rojo
sum(rate(classifier_requests_total{status=~"5..", source=~"$source"}[$__rate_interval])) / sum(rate(classifier_requests_total{status!~"4..", source=~"$source"}[$__rate_interval]))
3p99Stat · unit sbase: verde0.2: rojo
histogram_quantile(0.99, sum by (le) (rate(classifier_request_latency_seconds_bucket{source=~"$source"}[$__rate_interval])))
4Pods de Triton listosGauge · de 0 a 4base: rojo2: verde
kube_deployment_status_replicas_available{namespace="classifier", deployment="triton"}
5p50 y p99 del gatewayTime series · unit s0.2: rojo, como línea
histogram_quantile(0.5, sum by (le) (rate(classifier_request_latency_seconds_bucket{source=~"$source"}[$__rate_interval])))
histogram_quantile(0.99, sum by (le) (rate(classifier_request_latency_seconds_bucket{source=~"$source"}[$__rate_interval])))
6p99 por tramo: preprocess contra inferenciaTime series · unit s
histogram_quantile(0.99, sum by (le) (rate(classifier_preprocess_seconds_bucket[$__rate_interval])))
histogram_quantile(0.99, sum by (le) (rate(classifier_inference_seconds_bucket[$__rate_interval])))
7Distribución de la latenciaHeatmap · Format: Heatmap
sum by (le) (rate(classifier_request_latency_seconds_bucket{source=~"$source"}[$__rate_interval]))
8Cola contra cómputo (ms)Time series · unit ms20: rojo, como línea
sum(rate(nv_inference_queue_duration_us{model="classifier"}[$__rate_interval])) / sum(rate(nv_inference_request_success{model="classifier"}[$__rate_interval])) / 1000
sum(rate(nv_inference_compute_infer_duration_us{model="classifier"}[$__rate_interval])) / sum(rate(nv_inference_request_success{model="classifier"}[$__rate_interval])) / 1000
9Batch promedioStat
sum(rate(nv_inference_count{model="classifier"}[$__rate_interval])) / sum(rate(nv_inference_exec_count{model="classifier"}[$__rate_interval]))
10Réplicas de Triton contra el máximo de KEDATime series
kube_horizontalpodautoscaler_status_current_replicas{horizontalpodautoscaler="keda-hpa-triton"}
kube_horizontalpodautoscaler_spec_max_replicas{horizontalpodautoscaler="keda-hpa-triton"}
11Utilización por GPUTime series · unit percentunit · Legend {{gpu_uuid}}0.85: rojo, como línea
PromQLnv_gpu_utilization
12Baja confianza por model_versionTime series · unit percentunit · Legend {{model_version}}
sum 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]))
13Score top-1Heatmap · Format: Heatmap
sum 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 AllAnotaciones: marcas verticales en todos los paneles
changes(process_start_time_seconds{job="gateway"}[5m]) > 0
ALERTS{alertstate="firing"}
umbral verdeumbral rojo (pinta; no avisa)
# 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]))model_version) y Negocio (diario). El repo no trae sus JSON: estas consultas son el punto de partida.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.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.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.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.
En el curso: M12 · Prometheus, Grafana y alertas por SLO M12 · Dashboards, runbooks y on-call C01 · Monitoreo, alertas y reentrenamiento Herramientas · Monitoreo: sistema y modelo Hoja · De PyTorch a Triton · batching
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).
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.3Qué 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.
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.4Qué 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.
curl -s localhost:9090/api/v1/query --data-urlencode 'query=sum by (handler) (rate(prometheus_http_requests_total[1m]))' | head -c 400; echoQué 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.
open http://localhost:3000/dashboard/newQué 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.
curl -s localhost:9090/api/v1/label/handler/values | head -c 300; echoQué 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 |.
pbpaste > ~/lab-prom/dashboard.json
head -c 300 ~/lab-prom/dashboard.jsonQué 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.
docker rm -f -v prom-lab grafana-lab && docker network rm lab-prom
cd ~ && rm -rf ~/lab-promQué 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í.
¿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: severidadSuena 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]))
) / presupuestoCuá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
| Variable | Qué es | Ejemplo |
|---|---|---|
| servicio | prefijo de las series grabadas: el nivel del nombre nivel:métrica:operaciones | classifier |
| contador | el counter de requests, con el label status | classifier_requests_total |
| ventana, larga, corta | ventanas de rate(); la larga confirma, la corta se apaga rápido | 5m, 30m, 1h, 6h · larga 1h con corta 5m |
| histograma | nombre base del histograma de latencia | classifier_request_latency_seconds |
| umbral | borde del bucket que separa rápido de lento, en segundos | 0.2 (p99 ≤ 200 ms) · 0.3 (el SLI de requisitos) |
| factor | burn rate que dispara la alerta | 14.4, 6 o 1 |
| presupuesto | 1 − SLO, como fracción | 0.001 (errores) · 0.01 (latencia: 99 % bajo 200 ms) |
| Alerta, severidad | nombre de la alerta y a quién le llega: page despierta a quien está de guardia; ticket se revisa en horario laboral | ClassifierErrorBudgetFastBurn · page |
| la meta del SLI en una ventana de tiempo | 99.9 % en 30 días | |
| qué tan rápido gastas el presupuesto; 1 = se acaba justo al final del mes | 5 % de errores = 50× | |
| fracción del presupuesto del mes que aceptas gastar antes del aviso | 2 %, 5 % o 10 % | |
| el período del SLO | 720 h (30 días) | |
| la ventana larga de la alerta | 1 h, 6 h o 3 días | |
| la tasa de error real durante el incidente | 5 % (el deploy malo de M15) | |
| en cuánto se acaba el presupuesto a ese ritmo | 50 h a 14.4× | |
| cuánto tarda la ventana larga en cruzar el umbral | ≈ 17 min con 5 % de errores |
Fórmulas
| Pieza | Qué hace | Ejemplo |
|---|---|---|
| Golden signals | Las 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 |
| RED | Rate, Errors, Duration: requests por segundo, errores y latencia. La versión corta para servicios que responden requests. | gateway: req/s, 5xx, p99 |
| USE | Utilization, 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 |
| SLI | La 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 |
| SLO | La 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 |
| SLA | Un 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 budget | Lo 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 rate | Tasa de error ÷ presupuesto. A 1× el presupuesto dura justo el mes; a 14.4×, 50 h. | 5 % de errores = 50× |
| Ventana larga y corta | La 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 ventana | Una 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áfico | Con 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 budget | Un 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 · Pyrra | Herramientas 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
Las alertas de dos ventanas: la larga confirma, la corta (1/12) se apaga rápido
14.4×pagesuena si el error pasa 1.44 % en 1h y en 5m
gasta el 2 % del presupuesto del mes antes de sonar
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
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)
el deploy malo: 5 % de errores (50×)
120 min · gasta 13.9 %
14.4× (1 h y 5 min)
suena a los 18 min · 2.1 % gastado
6× (6 h y 30 min)
suena a los 44 min · 5.1 % gastado
1× (3 d y 6 h)
suena a los 87 min · 10.1 % gastado
umbral del C01 (1 % durante 5 min)
suena a los 7 min
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
# 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"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.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.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.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.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.
En el curso: M12 · Prometheus, Grafana y alertas por SLO M15 · SLOs, error budgets y disponibilidad compuesta C01 · Requisitos C01 · Monitoreo, alertas y reentrenamiento M12 · Métricas de sistema: golden signals con detalles de ML M12 · Dashboards, runbooks y on-call
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.
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.pyQué 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.
python3 burn.py 0.05 120Qué 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.
python3 burn.py 0.008 2880Qué 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.
python3 burn.py 1 30Qué 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).
cd ~ && rm -rf ~/lab-promQué 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.
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: intervaloUn 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: intervaloUn 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: severidadLas 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
| Variable | Qué es | Ejemplo |
|---|---|---|
| release | nombre del release de Helm; el chart lo usa como label para elegir objetos | kube-prometheus-stack |
| version | versión fija del chart | 92.11.0 (trae el Operator v0.94.1) |
| ns | namespace de Prometheus | monitoring (el C01 lo da por hecho) |
| nombre, ns_app | nombre y namespace del ServiceMonitor, PodMonitor o PrometheusRule | gateway, triton · classifier |
| app | label que comparten el Service (o los pods) que quieres medir | gateway |
| puerto | el NOMBRE del puerto del Service, no el número | http (8080) · metrics (8002) |
| puerto_pod | el nombre del puerto del contenedor | metrics |
| intervalo | cada cuánto se lee; sin él, el de Prometheus (30 s con el Operator) | 15s |
| grupo, Alerta, expr, for, severidad | lo mismo que en alerts.yml | classifier-alerts · TritonNotReady · … · 5m · page |
| réplicas listas del Deployment | 2 | |
| lo que devuelve la query de KEDA: un solo número | 25 (ms de espera en la cola) | |
| el objetivo del trigger | 10 (ms) | |
| 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) | |
| intervalo de scrape del target | 15 s (los ServiceMonitors del C01) |
Fórmulas
| Pieza | Qué hace | Ejemplo |
|---|---|---|
| kube-prometheus-stack | Chart 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 Operator | Controlador 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 |
| serviceMonitorSelector | Qué 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"}} |
| serviceMonitorNamespaceSelector | En qué namespaces los busca. Vacío ({}) = todos, el default del chart; sin definir = solo el namespace de Prometheus. | {} en kube-prometheus-stack |
| ServiceMonitor | Elige 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 |
| PodMonitor | Elige pods sin pasar por un Service. | workers que leen de una cola |
| PrometheusRule | Reglas 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-reloader | Contenedor 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-metrics | Lee 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_exporter | Un pod por nodo (un DaemonSet) en el puerto 9100 con la máquina: CPU, memoria, disco y red. | node_memory_MemAvailable_bytes |
| metrics-server | No 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 |
| ignoreNullValues | Con 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 · Watchdog | Dos 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 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"UP10.0.2.14:8080job="gateway"UP10.0.1.33:8080job="gateway"UP10.0.1.12:8002job="triton"UP10.0.2.40:8002job="triton"UPle preguntan con PromQL
6Quién lo consulta
UPignorado: sin la label release, el Operator ni lo mira
| Fuente | Qué mide | Ejemplo | En el C01 |
|---|---|---|---|
| Tu app (prometheus_client) | lo que solo tu código sabe: requests, latencia por tramo, scores | classifier_requests_total | el 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 |
| Triton | cola, cómputo y batches por modelo, y la GPU | nv_inference_queue_duration_us | puerto 8002; la query de KEDA y TritonQueueTimeHigh |
| kube-state-metrics | el estado de los objetos de Kubernetes, leído del API server | kube_deployment_status_replicas_available | TritonNotReady (no existe en el compose local) |
| cAdvisor (kubelet) | cuánto usa de verdad cada contenedor | container_memory_working_set_bytes | la CPU del gateway: hoy, su única señal de saturación exportada (sin límite de CPU no hay throttling) |
| node_exporter | la máquina: CPU, memoria, disco y red del nodo | node_filesystem_avail_bytes | sin alerta propia en el C01 |
| DCGM exporter | la GPU vista por NVIDIA: utilización, memoria, temperatura, errores | DCGM_FI_DEV_GPU_UTIL | lo instala el GPU Operator; el C01 usa nv_gpu_utilization de Triton |
| metrics-server | CPU 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.
# 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: 15srelease: 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.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.alerts.yml en un PrometheusRule con la misma label release. Sin ese paso, las 6 alertas no existen en el cluster.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.
En el curso: M12 · Prometheus, Grafana y alertas por SLO M6 · KEDA: escalar por eventos y hasta cero M6 · HPA: por qué escalar por CPU no sirve para un servicio de GPU Docker y kubectl · Autoscaling y capacidad C01 · Monitoreo, alertas y reentrenamiento C01 · Archivos de referencia Hoja · Kubernetes · autoscaling
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.
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 monitoringQué 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.
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.
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
EOFQué 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.
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 -uQué 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.
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).
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.
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.
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.
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).
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.
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.comQué 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.
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
| Variable | Qué es | Ejemplo |
|---|---|---|
| prometheus | host y puerto de Prometheus | localhost:9090 (compose o port-forward) |
| host, puerto | el 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) |
| metrica | nombre de la métrica (o su comienzo) | classifier_requests_total |
| consulta | la query que no devuelve lo esperado | up{job="triton"} |
| alertmanager | host y puerto de Alertmanager | localhost:9093 |
| alerta | nombre de la alerta | ClassifierHigh5xxRate |
| del primer error a la notificación | ≈ 6 min para ClassifierHigh5xxRate en una caída total | |
| 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× | |
| el for de la regla: el tiempo en pending | 5 min | |
| 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) | |
| group_wait de Alertmanager: espera antes de la primera notificación de un grupo | 30 s (default) | |
| intervalo de scrape que Grafana supone para el data source | 15 s (el default de Grafana y el del C01) | |
| el umbral de la alerta | 0.01 (1 % de 5xx) |
Fórmulas
| Pieza | Qué hace | Ejemplo |
|---|---|---|
| up = 1 | El ú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 up | No hay target: la config, el ServiceMonitor, su selector o el nombre del puerto no lo generan. | un ServiceMonitor sin la label release |
| health: unknown | Target recién descubierto, todavía sin scrape. | los primeros segundos |
| resultado vacío | No 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 |
| NaN | Dividiste 0 entre 0, o histogram_quantile no tuvo observaciones en la ventana. | la fracción de 5xx sin tráfico |
| +Inf | Dividiste algo positivo entre 0. | espera en cola ÷ 0 éxitos, sin clamp_min |
| caída en un counter | El 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áfica | Scrapes 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 err | La regla no se puede evaluar; lastError trae el mensaje. Mientras falle, no dispara. | many-to-many matching not allowed |
| alerta pending | La condición es verdadera, pero no cumplió el for. Si se apaga un instante, el for empieza de cero. | for: 10m en ClassifierHighP99Latency |
| alerta firing | Cumplió el for: Prometheus la manda a Alertmanager y la reenvía mientras siga activa. | ALERTS{alertstate="firing"} |
| silenciada · inhibida | Alertmanager 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
Tu códigotu servicio
¿El proceso emite la métrica, con esos labels?
curl -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.
ScrapePrometheus
¿Prometheus lo lee?
PromQLup{job="gateway"}
1 = bien; 0 = DOWN, lee lastError; nada = no hay target (config o ServiceMonitor).
TSDBPrometheus
¿La serie llegó y sigue viva?
count by (job) (classifier_requests_total)
Si un deploy cambió el nombre o el target cayó, la serie quedó stale y desaparece de las queries.
PromQLPrometheus
¿La query hace lo que crees?
sum(rate(classifier_requests_total{status=~"5.."}[5m]))
Vacío no es 0; NaN es 0/0; lee warnings e infos.
ReglaPrometheus
¿Está cargada, sana y en qué estado?
ALERTS{alertname="ClassifierHigh5xxRate"}
Rule health: ok o err. Pending espera el for; firing ya salió hacia Alertmanager.
EnvíoPrometheus
¿Prometheus conoce y alcanza un Alertmanager?
prometheus_notifications_alertmanagers_discovered
0 = no hay a quién mandar (el compose del C01). Los fallos suman en prometheus_notifications_errors_total.
AlertmanagerAlertmanager
¿La recibió, la silenció, a qué receiver la mandó?
amtool --alertmanager.url=http://localhost:9093 alert query -a -s -i
Estado active = notifica; suppressed = silenciada o inhibida, no notifica. La ruta decide el receiver.
ReceptorAlertmanager
¿El envío final funcionó?
kubectl 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
# 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 hostcontext 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.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.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.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.
En el curso: M12 · Prometheus, Grafana y alertas por SLO M12 · Dashboards, runbooks y on-call C01 · Monitoreo, alertas y reentrenamiento C01 · Modos de falla y degradación Docker y kubectl · Depurar pods: síntoma → comandos → causa típica Docker y kubectl · Autoscaling y capacidad Hoja · Kubernetes · diagnóstico
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.
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.ymlQué 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.
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/-/readyQué 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.
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.
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.
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.
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 -1Qué 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.
sleep 90; curl -s localhost:9090/api/v1/alerts | jq -r '.data.alerts[] | [.labels.alertname, .state] | @tsv' | sort | uniq -cQué 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.
docker rm -f -v lab-prom && cd ~ && rm -rf ~/lab-promQué 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.
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
| Variable | Qué es | Ejemplo |
|---|---|---|
| url | lo que Prometheus intentó leer | http://10.0.1.12:8002/metrics |
| error_de_red | el error de red de Go | context deadline exceeded · connect: connection refused · no such host |
| fila:columna | dónde se trabó el parser, contando desde 1 | 1:6 |
| detalle | el porqué, en una frase | unclosed left parenthesis |
| hora, nivel | cuándo y qué tan grave | 2026-10-23T10:00:00.000Z · ERROR, WARN o INFO |
| archivo.go:linea | de qué parte del código salió | main.go:745 |
| mensaje | qué pasó | Error loading config (--config.file=/etc/prometheus/prometheus.yml) |
| modulo.Excepcion | la clase de la excepción | prometheus_client.registry.DuplicateTimeseries |
| Pieza | Qué hace | Ejemplo |
|---|---|---|
| Target health · lastError | El scrape: Prometheus no pudo leer /metrics. up vale 0 para ese target. | server returned HTTP status 404 Not Found |
| parse error · bad_data | El parser de PromQL: la query no corrió. | expected type range vector in call to function "rate" |
| errorType execution | El motor: la query parseó, pero no se pudo calcular. | many-to-many matching not allowed |
| errorType timeout | La query pasó --query.timeout (2 min por defecto). | query timed out in expression evaluation |
| PromQL info · PromQL warning | Anotaciones: corrió, pero algo es sospechoso. | metric might not be a counter |
| promtool check config · check rules | El validador: encuentra YAML, campos y PromQL inválidos antes de cargarlos. | could not parse expression |
| Log de Prometheus | El servidor: config, almacenamiento, reglas y envío a Alertmanager. | msg="Error sending alerts" |
| amtool · log de Alertmanager | La config de Alertmanager y las notificaciones que fallan. | undefined receiver "pagerduty-ml" used in route |
| Panel o data source de Grafana | La 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 Python | prometheus_client en tu proceso. | Duplicated timeseries in CollectorRegistry |
| kubectl · API server | El cluster no conoce el tipo (faltan los CRDs) o un webhook rechazó el objeto. | no matches for kind "ServiceMonitor" |
| Eventos del ScaledObject | KEDA no pudo leer su métrica (KEDAScalerFailed). | target may be lost, the result is empty |
| Eventos del ServiceMonitor | El 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
26 de 26
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
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]'
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}'
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}}'
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
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
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'
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
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
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
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
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
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
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
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
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
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
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
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
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/
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/
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
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
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'
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'
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
Ningún mensaje tiene ese texto. Prueba con una palabra del mensaje (por ejemplo deadline o receiver).
$ 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 sidematch 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.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.execution: la query parseó bien y falló al calcularse. Un error de sintaxis habría sido bad_data.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".
En el curso: M12 · Prometheus, Grafana y alertas por SLO M12 · Métricas de sistema: golden signals con detalles de ML C01 · Monitoreo, alertas y reentrenamiento Docker y kubectl · Depurar pods: síntoma → comandos → causa típica Herramientas · Monitoreo: sistema y modelo
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.
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; }; rondaQué 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.
rondaQué deberías ver: Otra ronda de cinco. Repite hasta completar 5 minutos; la meta es 5 de 5 sin mirar.
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.pyQué 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).
.venv/bin/python cliente.py | head -1Qué 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.
cd ~ && rm -rf ~/lab-promQué 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.
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.
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.
Un target sale DOWN (up = 0) en Status → Target health.
Corre primero
curl -s 'localhost:9090/api/v1/targets?state=active' | jq -r '.data.activeTargets[] | select(.health=="down") | [.labels.job, .scrapeUrl, .lastError] | @tsv'
Nadie escucha en ese host y puerto: el proceso no corre, o el puerto del target es otro (connection refused). Ojo con el 9100: es el de node_exporter, y el ejemplo de M12 abre ahí su /metrics; en una máquina con los dos, el segundo en arrancar falla con Address already in use.
Compruébalo: kubectl get svc gateway -n classifier -o jsonpath='{.spec.ports}' · docker compose ps · lsof -nP -iTCP:9100 -sTCP:LISTEN
Arreglo: Apunta al puerto correcto: en Kubernetes, el port del ServiceMonitor es el nombre de un puerto del Service; en compose, el puerto interno del contenedor (gateway:8080), no el publicado en tu Mac. A tu app, dale un puerto propio, no el 9100.
La red descarta los paquetes o el target tarda más que scrape_timeout (context deadline exceeded). En el C01, la NetworkPolicy de Triton solo deja leer el 8002 desde el namespace monitoring.
Compruébalo: kubectl get networkpolicy -n classifier -o yaml · curl -s localhost:9090/api/v1/query --data-urlencode 'query=scrape_duration_seconds{job="triton"}' | jq -r '.data.result[].value[1]'
Arreglo: Prometheus en el namespace que permite la policy (o la policy apuntando al suyo). Si /metrics tarda de verdad, baja la cardinalidad o sube scrape_timeout sin pasar el intervalo.
El path o el formato no sirven: 404 (metrics_path), o un Content-Type que Prometheus 3 no acepta (una ruta que devuelve JSON o HTML).
Compruébalo: curl -si localhost:8080/metrics | head -5
Arreglo: El metrics_path correcto y la respuesta con CONTENT_TYPE_LATEST, como el gateway del C01: Response(generate_latest(), media_type=CONTENT_TYPE_LATEST).
El nombre no resuelve (no such host): Prometheus no está en la misma red de compose, o el Service o el namespace están mal escritos.
Compruébalo: docker network inspect classifier-c01_default --format '{{range .Containers}}{{.Name}} {{end}}'
Arreglo: Corre Prometheus dentro del mismo compose (como el C01) o usa el nombre DNS completo del Service en Kubernetes.
Mi servicio no aparece en Target health: ni UP ni DOWN.
Corre primero
kubectl get servicemonitors -A --show-labels
El ServiceMonitor no trae la label que pide el serviceMonitorSelector (release: kube-prometheus-stack en el chart): el Operator lo ignora sin avisar.
Compruébalo: kubectl get prometheus -n monitoring -o jsonpath='{.items[0].spec.serviceMonitorSelector}'
Arreglo: Agrega la label (en git: k8s/monitoring.yaml ya la trae) y espera uno o dos minutos a que el config-reloader recargue.
El selector del ServiceMonitor apunta a labels que el Service no tiene (por ejemplo, las del pod), o endpoints[].port nombra un puerto que el Service no tiene.
Compruébalo: kubectl get svc -n classifier --show-labels · kubectl get svc gateway -n classifier -o jsonpath='{.spec.ports[*].name}'
Arreglo: matchLabels con las labels del Service y port con el nombre de su puerto (http, metrics).
El namespace del ServiceMonitor queda fuera del serviceMonitorNamespaceSelector (sin definir = solo el namespace de Prometheus), o el Operator lo rechazó por un campo inválido.
Compruébalo: kubectl get prometheus -n monitoring -o jsonpath='{.items[0].spec.serviceMonitorNamespaceSelector}' · kubectl describe servicemonitor gateway -n classifier
Arreglo: serviceMonitorNamespaceSelector: {} para todos los namespaces (el default del chart); si hay un evento InvalidConfiguration, corrige el campo que nombra.
Fuera de Kubernetes: el job no está en la config cargada (YAML mal indentado o faltó el reload).
Compruébalo: curl -s localhost:9090/api/v1/status/config | jq -r .data.yaml | grep job_name
Arreglo: promtool check config antes de cargar; recarga con SIGHUP o reinicia el contenedor.
La query no devuelve nada (Empty query result), aunque el target está UP.
Corre primero
curl -s localhost:8080/metrics | grep '^classifier_requests_total'
La serie todavía no existe: una métrica con labels nace con su primera observación. Sin ningún 503 no hay serie de 503, y un vacío no es un 0.
Compruébalo: curl -s localhost:8080/metrics | grep 'status="503"'
Arreglo: Para paneles y cocientes, completa con or vector(0) después de agregar; para avisar que algo falta, absent().
El nombre o un label no coinciden: un typo, = con un patrón (status="5.." busca ese texto literal; el patrón va con =~) o un le entero (desde Prometheus 3, le="1" se guarda como le="1.0").
Compruébalo: curl -s localhost:9090/api/v1/query --data-urlencode 'query=count by (__name__) ({__name__=~"classifier_.*"})' | jq -r '.data.result[].metric.__name__'
Arreglo: Copia el nombre y los valores exactos de /metrics; =~ para patrones; le="1.0" en Prometheus 3.
La ventana es muy corta para rate(): necesita al menos dos muestras adentro. Con scrape cada 15 s, [15s] casi nunca las tiene (vacío casi siempre) y [20s] las tiene a ratos.
Compruébalo: curl -s localhost:9090/api/v1/query --data-urlencode 'query=count_over_time(up{job="gateway"}[1m])' | jq -r '.data.result[].value[1]'
Arreglo: Prometheus solo pide dos muestras, pero un scrape fallido te deja con una: usa la regla práctica de Grafana ($__rate_interval), al menos 4 veces el intervalo de scrape (1 min con 15 s).
La serie dejó de llegar: el target cayó (sus series quedaron stale), un deploy cambió el nombre o el pod se reemplazó y hoy la serie tiene otras labels.
Compruébalo: curl -s localhost:9090/api/v1/query --data-urlencode 'query=last_over_time(classifier_requests_total[1h])' | jq '.data.result | length'
Arreglo: Si last_over_time encuentra series y la query instantánea no, la serie murió: busca qué cambió a esa hora (deploy, target DOWN) y filtra por labels estables (job, namespace), no por pod.
Un panel de Grafana dice No data, pero en Prometheus la query funciona.
Mira primero
GrafanaInspectQuery del panel: la query ya interpolada y el rango que Grafana mandó de verdad
El data source apunta a otro lado: dentro de un contenedor, http://localhost:9090 es el propio Grafana.
Compruébalo: GrafanaConnectionsData sourcesPrometheusSave & test
Arreglo: La URL con el nombre del servicio (http://prometheus:9090 en compose) o el Service del cluster.
Una variable del dashboard ($pod, $source) quedó en un valor que ya no existe (un pod reemplazado) o vacía.
Compruébalo: GrafanaInspectQuery (la query con las variables ya reemplazadas)
Arreglo: Refresca las variables al cargar el dashboard, ofrece All y filtra por labels estables (job, namespace), no por nombre de pod.
La ventana del rate quedó más corta que dos scrapes: el data source supone otro intervalo (Grafana usa 15 s por defecto) o pusiste una ventana fija chica.
Compruébalo: GrafanaConnectionsData sourcesPrometheusInterval behaviorScrape interval
Arreglo: El scrape interval del data source igual al de Prometheus y $__rate_interval en los paneles; en alertas y reglas, ventanas fijas como [5m].
El rango de tiempo del dashboard no tiene datos (antes del deploy, o en el futuro) o el panel usa otro data source que el que probaste.
Compruébalo: GrafanaExplore con la misma query, el mismo data source y el mismo rango
Arreglo: Ajusta el rango o el data source del panel.
Un panel o una regla da NaN (o +Inf).
Corre primero
curl -s localhost:9090/api/v1/query --data-urlencode 'query=sum(rate(classifier_requests_total[5m]))' | jq -r '.data.result[].value[1]'
Dividiste 0 entre 0: una fracción de errores en un rato sin tráfico. Una comparación con NaN da falso, así que la alerta tampoco dispara.
Compruébalo: curl -s localhost:9090/api/v1/query --data-urlencode 'query=sum(rate(classifier_requests_total{status!~"4.."}[5m]))' | jq -r '.data.result[].value[1]'
Arreglo: Protege el denominador con > 0 (como la regla de grabación del C01) o con clamp_min(…, 1e-9) si necesitas un número (como la query de KEDA).
histogram_quantile sin observaciones en la ventana: con todos los buckets en 0 devuelve NaN.
Compruébalo: curl -s localhost:9090/api/v1/query --data-urlencode 'query=sum(rate(classifier_request_latency_seconds_count[5m]))' | jq -r '.data.result[].value[1]'
Arreglo: Es normal sin tráfico. En un panel, muéstralo como sin datos; en una alerta, exige un mínimo de requests.
Un NaN grabado contamina un promedio largo: avg_over_time(…[7d]) da NaN mientras ese NaN siga en la ventana (la trampa que el C01 evita con el > 0).
Compruébalo: curl -s localhost:9090/api/v1/query --data-urlencode 'query=classifier:low_confidence_share:rate30m != classifier:low_confidence_share:rate30m' | jq '.data.result | length'
Arreglo: Arregla la regla de grabación (> 0 en el denominador) y espera a que el NaN salga de la ventana de 7 días. NaN es la única cosa distinta de sí misma: por eso esa query la encuentra.
+Inf: dividiste algo positivo entre 0, por ejemplo la espera acumulada en cola entre 0 éxitos.
Compruébalo: curl -s localhost:9090/api/v1/query --data-urlencode 'query=sum(rate(nv_inference_request_success{model="classifier"}[2m]))' | jq -r '.data.result[].value[1]'
Arreglo: clamp_min en el denominador, como la query de KEDA del C01: un valor enorme escala. Un +Inf, con ignoreNullValues en false, es un error visible; con el default (true), vale 0 y no escala.
rate() o increase() dan picos, saltos imposibles o decimales raros.
Corre primero
curl -s localhost:9090/api/v1/query --data-urlencode 'query=resets(classifier_requests_total[1h])' | jq -r '.data.result[] | [.metric.pod, .value[1]] | @tsv'
Agregaste antes del rate: rate(sum(x)[5m:]) es una subquery (la suma, recalculada paso a paso y pasada a rate como ventana). Cuando un pod reinicia, la suma baja, rate lo toma como un reinicio del total y sale un pico falso.
Compruébalo: GrafanaInspectQuery del panel, o la expr de la regla
Arreglo: Primero rate por serie y después sum: sum(rate(x[5m])).
Varios procesos (--workers N de uvicorn o gunicorn) exponen cada uno su counter sin el modo multiproceso: cada scrape lee a un proceso distinto y el counter sube y baja.
Compruébalo: curl -s localhost:8080/metrics | grep '^classifier_requests_total' (repítelo: si un número baja entre lecturas, son procesos distintos)
Arreglo: Un proceso por contenedor (como el C01) o el modo multiproceso de prometheus_client (PROMETHEUS_MULTIPROC_DIR).
rate() sobre un gauge: el resultado no significa nada. Prometheus lo avisa con PromQL info: metric might not be a counter.
Compruébalo: curl -s localhost:8002/metrics | grep '^# TYPE nv_gpu_utilization'
Arreglo: Los gauges se leen directo o con avg_over_time; rate e increase, solo con counters.
Decimales en increase() (2.9 requests): es la extrapolación a los bordes de la ventana, no un bug.
Compruébalo: curl -s localhost:9090/api/v1/query --data-urlencode 'query=increase(classifier_requests_total[5m])' | jq -r '.data.result[0].value[1]'
Arreglo: Normal. Para conteos de un período largo, ventanas largas o recording rules; nunca esperes enteros exactos.
Prometheus usa cada vez más memoria, se pone lento o lo mata OOMKilled.
Corre primero
curl -s localhost:9090/api/v1/status/tsdb | jq '.data.seriesCountByMetricName[:5], .data.labelValueCountByLabelName[:5]'
Un label sin límite (request_id, user_id, una ruta con ids, el texto de un error): cada valor distinto es una serie nueva.
Compruébalo: curl -s localhost:8080/metrics | docker run --rm -i --entrypoint promtool prom/prometheus:v3.13.3 check metrics --extended --lint=none
Arreglo: Labels de lista cerrada (como source en el C01: mobile, web, internal u other); los ids van a los logs y a los exemplars (un id de traza pegado a algunas observaciones del histograma). Mientras arreglas el código, descarta esa métrica con metric_relabel_configs.
Histogramas con muchas combinaciones de labels: cada combinación son (buckets + 4) series.
Compruébalo: curl -s localhost:9090/api/v1/query --data-urlencode 'query=count by (__name__) ({__name__=~".+_bucket"})' | jq -r '.data.result[] | [.metric.__name__, .value[1]] | @tsv'
Arreglo: Pocos labels en los histogramas (el de latencia del C01 solo lleva source) y los buckets justos alrededor del SLO.
Rotación de series (churn): cada deploy crea pods con nombres nuevos y todas sus series nacen de nuevo; las viejas siguen un rato en memoria.
Compruébalo: curl -s localhost:9090/api/v1/query --data-urlencode 'query=rate(prometheus_tsdb_head_series_created_total[1h])' | jq -r '.data.result[].value[1]'
Arreglo: Dimensiona la memoria para el pico que deja un deploy y agrega por job en las reglas, para no arrastrar el label pod donde no hace falta.
Un exporter que expone demasiado: miles de series por target (cAdvisor o kube-state-metrics con todo encendido, un exporter de terceros).
Compruébalo: curl -s localhost:9090/api/v1/query --data-urlencode 'query=topk(5, sum by (job) (scrape_samples_scraped))' | jq -r '.data.result[] | [.metric.job, .value[1]] | @tsv'
Arreglo: metric_relabel_configs (metricRelabelings en un ServiceMonitor) para descartar lo que nadie usa, y sample_limit como freno.
La alerta nunca disparó, aunque el problema existió.
Corre primero
curl -s 'localhost:9090/api/v1/rules?type=alert' | jq -r '.data.groups[].rules[] | [.name, .state, .health, .lastError] | @tsv'
La regla no está cargada: rule_files apunta a un archivo que no existe (Prometheus arranca igual, sin esas reglas) o, en Kubernetes, el PrometheusRule no trae la label release.
Compruébalo: PrometheusStatusRule health · kubectl get prometheusrules -A --show-labels
Arreglo: promtool check config (avisa si un archivo de rule_files no existe) y la label release en el PrometheusRule.
La expresión no devuelve nada: un nombre o un label que no existe (en el compose no hay kube_deployment_status_replicas_available, así que TritonNotReady nunca evalúa) o el target estaba caído y sus series desaparecieron.
Compruébalo: curl -s localhost:9090/api/v1/query --data-urlencode 'query=absent(kube_deployment_status_replicas_available{deployment="triton"})' | jq -r '.data.result[].value[1]'
Arreglo: Prueba cada expr en Query antes de subirla; para lo que puede desaparecer, una alerta aparte con absent() o up == 0.
La condición nunca se sostuvo el for completo: un instante en falso (un hueco, un rate ruidoso) devuelve la alerta de pending a inactive y el for empieza de cero.
Compruébalo: curl -s localhost:9090/api/v1/query --data-urlencode 'query=ALERTS{alertname="ClassifierHighP99Latency"}' | jq '.data.result' (y grafícalo en el rango del incidente)
Arreglo: Una ventana más larga o una recording rule que suavice, y un for acorde. En alertas, rate y no irate.
La regla falla al evaluarse (health err): many-to-many, labels duplicadas o un timeout. Mientras falle, no dispara.
Compruébalo: curl -s localhost:9090/api/v1/query --data-urlencode 'query=increase(prometheus_rule_evaluation_failures_total[1h]) > 0' | jq -r '.data.result[].metric.rule_group'
Arreglo: Lee lastError en Rule health, corrige la expr y pruébala con promtool test rules antes de volver a subirla.
La alerta dispara de más: pages que se cierran con un no era nada.
Corre primero
curl -s 'localhost:9090/api/v1/rules?type=alert' | jq -r '.data.groups[].rules[] | [.name, .duration, .labels.severity, .query] | @tsv'
Sin for (no tiene default: sin él, la alerta dispara en la primera evaluación en que la condición es verdadera) o con uno muy corto: un pico de segundos, un GC o un deploy, ya es un page.
Compruébalo: La columna duration del primer comando (el for, en segundos)
Arreglo: Un for de varios minutos (el C01 usa de 5 a 30 min) o, mejor, burn rate con dos ventanas.
Poco tráfico: de madrugada, 1 error en 20 requests ya es un 5 %.
Compruébalo: curl -s localhost:9090/api/v1/query --data-urlencode 'query=sum(rate(classifier_requests_total[5m]))' | jq -r '.data.result[].value[1]'
Arreglo: Un caudal mínimo dentro de la expr (el C01: and sum(rate(classifier_requests_total[5m])) > 1).
Alertas por causa y no por síntoma (CPU al 90 %, una GPU ocupada) mandadas a page: suenan sin que nadie que use el servicio note nada.
Compruébalo: La columna severity del primer comando
Arreglo: Page solo para síntomas (latencia, errores, réplicas listas); las causas, a warning o ticket, como GPUSaturated en el C01.
Una alerta por cada pod o instancia del mismo problema: diez pages por un solo incidente.
Compruébalo: curl -s localhost:9090/api/v1/alerts | jq -r '.data.alerts[].labels.alertname' | sort | uniq -c
Arreglo: Agrega en la expr (sum by …) y agrupa en Alertmanager con group_by: [alertname, namespace].
La alerta está firing en Prometheus, pero no llega nada al teléfono ni al canal.
Corre primero
curl -s localhost:9090/api/v1/alertmanagers | jq '.data.activeAlertmanagers'
Prometheus no conoce ningún Alertmanager: falta alerting.alertmanagers. Es el caso del compose del C01: las 6 alertas se ven en /alerts y no avisan a nadie.
Compruébalo: curl -s localhost:9090/api/v1/query --data-urlencode 'query=prometheus_notifications_alertmanagers_discovered' | jq -r '.data.result[].value[1]'
Arreglo: Agrega alerting.alertmanagers con el host y el puerto 9093 de tu Alertmanager; kube-prometheus-stack ya lo trae conectado.
Prometheus no logra entregarlas (Error sending alerts en su log): la dirección, el puerto o la red.
Compruébalo: curl -s localhost:9090/api/v1/query --data-urlencode 'query=rate(prometheus_notifications_errors_total[5m])' | jq -r '.data.result[] | [.metric.alertmanager, .value[1]] | @tsv'
Arreglo: Corrige la dirección y confirma que Alertmanager responde: curl -s localhost:9093/-/ready.
Alertmanager la recibió pero la manda a un receiver sin destino (la config por defecto de kube-prometheus-stack enruta todo a null), o un silencio o una inhibición la tapan.
Compruébalo: amtool --alertmanager.url=http://localhost:9093 alert query -a -s -i alertname=ClassifierHighP99Latency · amtool config routes test --config.file=alertmanager.yml alertname=ClassifierHighP99Latency severity=page
Arreglo: Rutas por severity hacia receivers reales (page al on-call, ticket al equipo de ML); revisa los silencios con amtool silence query.
El envío al receptor falla (Notify for alerts failed en el log de Alertmanager): una URL de webhook (la dirección que recibe el aviso) que ya no sirve, un token vencido o la red de salida.
Compruébalo: kubectl logs -n monitoring alertmanager-kube-prometheus-stack-alertmanager-0 -c alertmanager | grep 'Notify for alerts failed'
Arreglo: Corrige el receiver y prueba la cadena entera con una alerta que siempre suena (el Watchdog del chart) mandada a un receptor real.
Llegan notificaciones duplicadas, demasiado seguidas o una por pod.
Corre primero
amtool --alertmanager.url=http://localhost:9093 config show
La ruta no agrupa lo que debería: con un group_by muy fino (o que incluye pod o instance), cada pod es un grupo y una notificación.
Compruébalo: amtool --alertmanager.url=http://localhost:9093 config show | grep -B2 -A6 'group_by'
Arreglo: group_by: [alertname, namespace] (o por servicio): un mensaje por incidente.
repeat_interval corto: Alertmanager reenvía mientras la alerta siga activa (4 h por defecto; 12 h en la config del chart).
Compruébalo: amtool --alertmanager.url=http://localhost:9093 config show | grep repeat_interval
Arreglo: Súbelo y arregla la causa; para algo pendiente, un ticket, no un page cada hora.
Dos Prometheus en alta disponibilidad mandan la misma alerta con labels distintas (por ejemplo, una label de réplica): para Alertmanager son dos alertas y no las deduplica.
Compruébalo: curl -s localhost:9090/api/v1/status/config | jq -r .data.yaml | grep -A4 external_labels
Arreglo: Las mismas labels en las alertas de las dos réplicas (quita la de réplica con alert_relabel_configs; el Operator ya lo hace) y las dos apuntando a todos los Alertmanagers.
La alerta se apaga y se prende (flapping): cada vuelta es un resolved y un firing nuevos.
Compruébalo: curl -s localhost:9090/api/v1/query --data-urlencode 'query=ALERTS{alertname="ClassifierHigh5xxRate"}' | jq '.data.result' (grafícalo en las últimas horas)
Arreglo: keep_firing_for (sigue firing un rato después de que la condición se apaga), ventanas más largas o burn rate con dos ventanas.
KEDA no escala Triton: el ScaledObject sale READY False o se queda en 2 réplicas con la cola alta.
Corre primero
kubectl describe scaledobject triton -n classifier
La query vuelve vacía: Prometheus no scrapea a Triton (falta el ServiceMonitor o su label release). Con ignoreNullValues: "false" sale target may be lost, the result is empty; con el default, un 0 silencioso.
Compruébalo: curl -s localhost:9090/api/v1/query --data-urlencode 'query=up{job="triton"}' | jq '.data.result'
Arreglo: kubectl apply -f k8s/monitoring.yaml y espera a que el target de Triton salga UP.
KEDA no llega a Prometheus: el serverAddress apunta a prometheus-operated.monitoring y Prometheus vive en otro namespace (no such host), o algo corta la red.
Compruébalo: kubectl get svc -A | grep prometheus-operated · kubectl logs -n keda deploy/keda-operator | grep -i prometheus
Arreglo: El serverAddress con el namespace real, o Prometheus en monitoring, como espera el C01.
La query devuelve varias series (returned multiple elements): falta el sum() de afuera.
Compruébalo: curl -s localhost:9090/api/v1/query --data-urlencode 'query=rate(nv_inference_queue_duration_us{model="classifier"}[2m])' | jq '.data.result | length'
Arreglo: Envuelve la query en sum() (o max()) hasta que devuelva una sola serie.
La métrica llega, pero el HPA no puede subir: ya está en maxReplicaCount (4) o el pod nuevo queda Pending sin GPU.
Compruébalo: kubectl get hpa keda-hpa-triton -n classifier · kubectl get pods -n classifier --field-selector=status.phase=Pending
Arreglo: Eso ya no es monitoreo: sigue la cadena de autoscaling de la hoja de Kubernetes (cuota, nodos GPU, Karpenter).
El dashboard tarda, o la query falla por timeout o por demasiadas muestras.
Corre primero
curl -s localhost:9090/api/v1/query --data-urlencode 'query=sum(increase(classifier_requests_total[30d]))' --data-urlencode 'stats=all' | jq '.data.stats.timings.evalTotalTime, .data.stats.samples.totalQueryableSamples, .data.stats.samples.peakSamples'
Una query que junta demasiadas muestras a la vez: una subquery larga (rate(…[5m])[30d:15s] guarda cada paso de cada serie) o una ventana cruda pedida entera (x[30d] en la API). Pasa --query.max-samples (50 millones) y falla. Una ventana larga dentro de rate() sola no llega al tope, porque se carga de a una serie, pero tarda: con scrape cada 15 s, [30d] son 172,800 muestras por serie.
Compruébalo: El primer comando: peakSamples contra 50,000,000; totalQueryableSamples y evalTotalTime dicen cuántas muestras leyó y cuántos segundos tardó
Arreglo: Recording rules que precalculen cada minuto, y consulta la serie grabada: sum_over_time de la serie grabada en vez de una subquery larga.
Un selector muy amplio: regex sobre __name__ o sin filtrar por job o namespace, así que la query toca miles de series.
Compruébalo: curl -s localhost:9090/api/v1/query --data-urlencode 'query=count({__name__=~"classifier_.*"})' | jq -r '.data.result[0].value[1]'
Arreglo: Filtra por el nombre exacto y por job antes de agregar.
Un panel de rango con un paso muy chico: demasiados puntos por serie (el límite es 11,000).
Compruébalo: GrafanaInspectQuery del panel (mira el step y el intervalo)
Arreglo: Un Min interval en el panel o un rango más corto; Grafana ajusta el paso solo si no lo fijas.
Prometheus mismo está saturado: demasiadas series en memoria o muchas reglas pesadas.
Compruébalo: curl -s localhost:9090/api/v1/query --data-urlencode 'query=prometheus_tsdb_head_series' | jq -r '.data.result[].value[1]'
Arreglo: Baja la cardinalidad y reparte las reglas pesadas en grupos con un interval más largo.
Prometheus no arranca, se reinicia en bucle o ignora el cambio de config.
Corre primero
docker compose logs prometheus | grep -E 'level=(ERROR|WARN)' | tail -5
La config no parsea: un campo mal escrito (field … not found in type config.plain), la sangría, o un rule_files que no existe.
Compruébalo: docker run --rm --entrypoint promtool -v "$PWD/monitoring":/etc/prometheus:ro prom/prometheus:v3.13.3 check config /etc/prometheus/prometheus.yml
Arreglo: Corrige lo que dice promtool y vuelve a arrancar o recargar.
El reload falló y Prometheus siguió con la config vieja: lo pediste por HTTP sin --web.enable-lifecycle (Lifecycle API is not enabled.) o la config nueva tenía errores.
Compruébalo: curl -s localhost:9090/api/v1/query --data-urlencode 'query=prometheus_config_last_reload_successful' | jq -r '.data.result[].value[1]' · curl -s localhost:9090/api/v1/query --data-urlencode 'query=time() - prometheus_config_last_reload_success_timestamp_seconds' | jq -r '.data.result[].value[1]'
Arreglo: SIGHUP (docker compose kill -s SIGHUP prometheus) o un reinicio. Un 0 en la primera dice que la config nueva tenía errores; si la segunda (segundos desde la última carga buena) es mayor que el tiempo desde tu cambio, la config nueva nunca se cargó.
No puede escribir en su directorio de datos: un volumen del host sin permisos para el usuario nobody de la imagen (failed to initialize active query tracker … permission denied).
Compruébalo: docker compose logs prometheus | grep 'permission denied'
Arreglo: Un volumen con nombre de Docker, o el directorio con un dueño que el usuario de la imagen pueda escribir.
En Kubernetes lo mata OOMKilled: demasiadas series para su límite de memoria. Al reiniciar relee el WAL (el registro de lo recién escrito) y tarda en estar Ready.
Compruébalo: kubectl describe pod prometheus-kube-prometheus-stack-prometheus-0 -n monitoring | grep -A3 'Last State'
Arreglo: Baja la cardinalidad (el síntoma de memoria) y sube el límite con margen para el pico.
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.