Aurora Libros está construida, optimizada y endurecida. Falta lo que separa una plataforma que funciona de una plataforma que se puede operar: saber qué está pasando ahora mismo, qué pasó anoche a las 3:14 y por qué el contenedor de la API se reinició cuatro veces el martes.
Contenido
- La regla de stdout/stderr, revisitada
- Los drivers de logging
- Configuración global y por servicio
- Rotación obligatoria: el cálculo del disco lleno
- Logs estructurados en JSON
- Agregación centralizada: el patrón
- Loki, Promtail y Grafana en el perfil
observabilidad - Consultas LogQL útiles
- Métricas:
docker statsy sus límites - La API de métricas del daemon
- cAdvisor, node-exporter y Prometheus
- Las métricas que de verdad se vigilan
- Alertas
- El endpoint de salud rico
- Trazas distribuidas: el siguiente paso
- La regla de stdout/stderr, revisitada
Una aplicación en contenedor no escribe ficheros de log: escribe en la salida estándar y en la de error, y deja que la plataforma haga el resto.
El motivo es concreto: el contenedor es efímero. Un fichero en /var/log/app.log desaparece al recrearlo, obliga a montar un volumen solo para eso, exige rotación propia y no puede consultarse con docker logs. Escribiendo a stdout, el proceso solo produce los eventos; recogerlos, rotarlos y enviarlos a donde toque es responsabilidad del driver de logging.
docker compose exec aurora-api sh -c 'ls -la /proc/1/fd/1 /proc/1/fd/2'
# /proc/1/fd/1 -> pipe:[482913]
# /proc/1/fd/2 -> pipe:[482914]Las salidas del proceso son tuberías hacia el daemon, no ficheros. Al otro extremo, el driver decide su destino.
- Los drivers de logging
| Driver | Destino | docker logs |
Cuándo usarlo |
|---|---|---|---|
json-file |
/var/lib/docker/containers/<id>/*-json.log |
Sí | Por defecto. Válido con rotación configurada |
local |
Formato binario propio, comprimido | Sí | Mejor rendimiento y rotación activada de serie |
journald |
systemd-journald del host |
Sí | Hosts con systemd; integra con journalctl |
syslog |
Servidor syslog local o remoto | No | Infraestructura syslog ya existente |
fluentd |
Demonio Fluentd/Fluent Bit | No | Enrutado flexible hacia varios destinos |
gelf |
Graylog / Logstash (UDP) | No | Pilas Graylog o ELK |
awslogs, gcplogs |
CloudWatch, Cloud Logging | No | Cargas nativas de esa nube |
none |
Se descartan | No | Contenedores ruidosos sin valor de log |
La limitación marcada como "No" es importante y sorprende en el peor momento: con syslog, fluentd, gelf o awslogs, docker logs deja de funcionar. Si el sistema central está caído, te quedas sin ninguna vía para ver qué pasa.
La estrategia habitual evita ese callejón: dejar json-file o local con rotación y recoger los ficheros con un agente externo (Promtail, Fluent Bit, Vector). Así conservas docker logs para el diagnóstico inmediato y tienes agregación central para el histórico.
- Configuración global y por servicio
El valor por defecto para todo el host va en /etc/docker/daemon.json, y lo específico en Compose:
Tras sudo systemctl restart docker, verifícalo con docker info --format '{{.LoggingDriver}}'. Ojo: cambiar el driver solo afecta a los contenedores creados después; los existentes conservan el suyo hasta que se recreen.
services:
aurora-api:
logging:
driver: json-file
options: { max-size: "50m", max-file: "5", tag: "{{.Name}}/{{.ID}}" }
aurora-cache:
logging:
driver: none # Redis con --loglevel notice no aporta nada aquí
- Rotación obligatoria: el cálculo del disco lleno
Sin rotación, json-file crece sin límite. Hagamos el cálculo con Aurora Libros: la API registra una línea por petición, unos 200 bytes en JSON, con 50 peticiones por segundo en hora punta y una media de 15/s a lo largo del día.
Casi 8 GB al mes de un solo servicio, en un fichero que nunca se trunca. Con cuatro servicios y un disco de 40 GB, la plataforma se cae por disco lleno en menos de dos meses. Y el fallo por disco lleno es especialmente desagradable: PostgreSQL deja de aceptar escrituras, Docker no puede crear contenedores y el propio sistema de logs no puede registrar el problema.
sudo du -sh /var/lib/docker/containers/*/ | sort -rh | head -2
docker inspect aurora-libros-aurora-api-1 --format '{{.HostConfig.LogConfig}}'
# 2.1G /var/lib/docker/containers/a91f3c.../
# {json-file map[max-file:5 max-size:50m]}Con max-size: 50m y max-file: 5, el techo por contenedor son 250 MB, y siempre tienes los últimos días. Dos avisos: max-file sin max-size no hace nada, y el driver local trae rotación activada por defecto (100 MB en 5 ficheros), lo que lo convierte en la opción más segura si te olvidas de configurarla.
- Logs estructurados en JSON
Un log en texto libre obliga a escribir expresiones regulares para todo. Un log en JSON se consulta por campos.
// api/src/log.js — registro estructurado mínimo, sin dependencias
const NIVELES = { error: 0, warn: 1, info: 2, debug: 3 };
const nivelActual = NIVELES[process.env.LOG_NIVEL ?? 'info'] ?? 2;
function emitir(nivel, mensaje, extra = {}) {
if (NIVELES[nivel] > nivelActual) return;
const linea = { ts: new Date().toISOString(), nivel, servicio: 'aurora-api',
version: process.env.APP_VERSION ?? '1.3.0', mensaje, ...extra };
process[nivel === 'error' ? 'stderr' : 'stdout'].write(JSON.stringify(linea) + '\n');
}
module.exports = Object.fromEntries(
Object.keys(NIVELES).map(n => [n, (m, e) => emitir(n, m, e)])
);// api/src/server.js — identificador de petición y registro de acceso
const { randomUUID } = require('node:crypto');
const log = require('./log');
app.use((req, res, next) => {
req.id = req.get('x-request-id') ?? randomUUID(); // reutiliza el del frontal si viene
res.set('x-request-id', req.id); // y lo devuelve al cliente
const inicio = process.hrtime.bigint();
res.on('finish', () => {
const ms = Number(process.hrtime.bigint() - inicio) / 1e6;
log.info('peticion', { req_id: req.id, metodo: req.method,
ruta: req.route?.path ?? req.path, estado: res.statusCode, ms: Number(ms.toFixed(1)) });
});
next();
});
app.get('/libros', async (req, res) => {
const cacheado = await cache.get('libros:todos');
log.debug(cacheado ? 'cache hit' : 'cache miss', { req_id: req.id, clave: 'libros:todos' });
if (cacheado) return res.json({ origen: 'cache', ...JSON.parse(cacheado) });
// ... consulta a PostgreSQL, log.debug('consulta bd', {...}) y escritura en la caché
});curl -s -o /dev/null http://localhost:8080/api/libros
docker compose logs aurora-api --tail 2 --no-log-prefix | jq -c{"ts":"2026-08-05T10:14:02.881Z","nivel":"debug","servicio":"aurora-api","version":"1.3.0","mensaje":"cache miss","req_id":"7f3a...","clave":"libros:todos"}
{"ts":"2026-08-05T10:14:02.914Z","nivel":"info","servicio":"aurora-api","version":"1.3.0","mensaje":"peticion","req_id":"7f3a...","metodo":"GET","ruta":"/libros","estado":200,"ms":33.2}El req_id es la pieza clave: se propaga por la cabecera x-request-id, lo devuelve la respuesta y aparece en todas las líneas de esa petición. Cuando un usuario reporta un error a las 10:14 con ese identificador, recuperas la traza completa de su petición con una sola consulta, en lugar de leer diez mil líneas.
- Agregación centralizada: el patrón
docker logs sirve para un contenedor de una máquina. Con varios servicios, varios nodos y contenedores que se recrean, hace falta que los eventos salgan del host y vivan más que el contenedor que los produjo.
flowchart LR A["aurora-api<br/>stdout JSON"] --> D["Driver json-file<br/>/var/lib/docker/containers"] B["aurora-db"] --> D C["aurora-web"] --> D D --> P["Promtail<br/>(lee y etiqueta)"] P --> L["Loki<br/>(índice por etiquetas)"] L --> G["Grafana<br/>consultas LogQL"] M["cAdvisor + node-exporter"] --> PR["Prometheus"] PR --> G PR --> AL["Alertmanager"]
Loki indexa solo las etiquetas (servicio, nivel, host) y comprime el resto, lo que lo hace mucho más barato que una pila que indexa cada palabra. Las alternativas: ELK/OpenSearch (Elasticsearch + Logstash + Kibana), más potente en búsqueda de texto completo y bastante más exigente en recursos; y los servicios gestionados de las nubes.
- Loki, Promtail y Grafana en el perfil
observabilidad
observabilidad# compose.observabilidad.yaml — perfil "observabilidad" de Aurora Libros
services:
loki:
image: grafana/loki:3.3.0
profiles: [observabilidad]
volumes: [loki-datos:/loki]
networks: [observacion]
promtail:
image: grafana/promtail:3.3.0
profiles: [observabilidad]
command: ["-config.file=/etc/promtail/config.yaml"]
volumes:
- ./observabilidad/promtail.yaml:/etc/promtail/config.yaml:ro
- /var/lib/docker/containers:/var/lib/docker/containers:ro
- /var/run/docker.sock:/var/run/docker.sock:ro # solo para leer etiquetas
depends_on: [loki]
networks: [observacion]
prometheus:
image: prom/prometheus:v3.1.0
profiles: [observabilidad]
volumes:
- ./observabilidad/prometheus.yml:/etc/prometheus/prometheus.yml:ro
- ./observabilidad/alertas.yml:/etc/prometheus/alertas.yml:ro
- prom-datos:/prometheus
networks: [observacion, trasera]
cadvisor:
image: gcr.io/cadvisor/cadvisor:v0.52.0
profiles: [observabilidad]
volumes: ["/:/rootfs:ro", "/var/run:/var/run:ro", "/sys:/sys:ro", "/var/lib/docker/:/var/lib/docker:ro"]
devices: ["/dev/kmsg"]
networks: [observacion]
node-exporter:
image: prom/node-exporter:v1.8.2
profiles: [observabilidad]
command: ["--path.rootfs=/host"]
pid: host
volumes: ["/:/host:ro,rslave"]
networks: [observacion]
grafana:
image: grafana/grafana:11.5.0
profiles: [observabilidad]
environment:
GF_SECURITY_ADMIN_PASSWORD__FILE: /run/secrets/grafana_admin
secrets: [grafana_admin]
volumes:
- ./observabilidad/fuentes.yaml:/etc/grafana/provisioning/datasources/fuentes.yaml:ro
- grafana-datos:/var/lib/grafana
ports: ["3001:3000"]
depends_on: [loki, prometheus]
networks: [observacion]
volumes: { loki-datos: {}, prom-datos: {}, grafana-datos: {} }
networks: { observacion: { driver: bridge } }# observabilidad/prometheus.yml
global: { scrape_interval: 15s }
rule_files: ["/etc/prometheus/alertas.yml"]
scrape_configs:
- { job_name: cadvisor, static_configs: [{ targets: ["cadvisor:8080"] }] }
- { job_name: node, static_configs: [{ targets: ["node-exporter:9100"] }] }
- { job_name: docker-daemon, static_configs: [{ targets: ["172.17.0.1:9323"] }] }
- { job_name: aurora-api, metrics_path: /metricas,
static_configs: [{ targets: ["aurora-api:3000"] }] }docker compose -f compose.yaml -f compose.observabilidad.yaml --profile observabilidad up -d
docker compose --profile observabilidad ps --format "{{.Service}}" | tr '\n' ' '
# grafana prometheus cadvisor loki promtail node-exporter aurora-api aurora-db ...Que todo esto viva en un perfil (lección 04-06) es deliberado: docker compose up -d sigue levantando solo la plataforma, y la observabilidad se añade cuando se necesita, sin consumir recursos en el portátil de nadie.
Aviso de seguridad. Promtail monta
docker.socken solo lectura para leer etiquetas, y cAdvisor tiene acceso amplio al host. Son concesiones reales (lección 05-03): en producción, usa un proxy de socket filtrado y valida esta configuración con tu responsable de seguridad.
- Consultas LogQL útiles
# Errores de la API en la última hora
{compose_service="aurora-api"} | json | nivel="error"
# La traza completa de una petición concreta
{compose_service="aurora-api"} | json | req_id="7f3a1c9d-4e28-4a1b-9c33-1f0e7b2d5a64"
# Peticiones lentas: más de 500 ms
{compose_service="aurora-api"} | json | ms > 500 | line_format "{{.ruta}} {{.ms}}ms"
# Tasa de respuestas 5xx por minuto
sum(rate({compose_service="aurora-api"} | json | estado >= 500 [1m]))
# Proporción de aciertos de caché
sum(count_over_time({compose_service="aurora-api"} | json | mensaje="cache hit" [5m]))
/ sum(count_over_time({compose_service="aurora-api"} | json | mensaje=~"cache (hit|miss)" [5m]))La segunda consulta es la que justifica todo el trabajo del apartado 5: con un identificador que te da el usuario, recuperas su petición exacta entre millones de líneas en menos de un segundo. La última convierte una decisión de arquitectura —el patrón cache-aside— en una métrica observable: si la proporción de aciertos cae, algo pasa con Redis o con la invalidación.
- Métricas:
docker stats y sus límites
docker stats y sus límitesNAME CPU % MEM USAGE / LIMIT MEM %
aurora-libros-aurora-api-1 2.14% 84.2MiB / 512MiB 16.45%
aurora-libros-aurora-db-1 0.88% 412.7MiB / 2GiB 20.15%
aurora-libros-aurora-cache-1 0.31% 9.8MiB / 256MiB 3.83%Sus límites, y por eso no basta: es una instantánea sin histórico, no tiene alertas, no agrega varios hosts y hay que estar mirando. Sirve para "qué está pasando ahora"; no para "qué pasó anoche".
- La API de métricas del daemon
El propio daemon expone métricas en formato Prometheus si lo habilitas en daemon.json:
sudo systemctl restart docker
curl -s http://localhost:9323/metrics | grep '^engine_daemon_container_states' | head -3engine_daemon_container_states_containers{state="running"} 6
engine_daemon_container_states_containers{state="paused"} 0
engine_daemon_container_states_containers{state="stopped"} 2Son métricas del daemon, no de cada contenedor: cuántos contenedores hay en cada estado, tiempos de las operaciones, versión del motor. Útiles para vigilar la salud del propio Docker. Y expón ese puerto solo en la red de gestión: no lleva autenticación.
- cAdvisor, node-exporter y Prometheus
cAdvisor lee los cgroups (lección 05-07) y publica métricas por contenedor; node-exporter hace lo propio con el host.
curl -s http://localhost:9090/api/v1/query --data-urlencode \
'query=rate(container_cpu_usage_seconds_total{name=~"aurora.*"}[5m])' | jq -r '.data.result[].metric.name'Grafana consulta ambas fuentes, y los paneles no hay que inventarlos: los dashboards 193 (Docker/cAdvisor) y 1860 (Node Exporter Full) del catálogo público cubren el 90 % de lo necesario.
- Las métricas que de verdad se vigilan
| Métrica | Expresión | Por qué importa |
|---|---|---|
| Memoria frente al límite | container_memory_usage_bytes / container_spec_memory_limit_bytes |
Cerca del 100 % → OOM kill (código 137) |
| CPU frente al límite | rate(container_cpu_usage_seconds_total[5m]) |
Estrangulamiento y latencia alta |
| Reinicios | changes(container_start_time_seconds[1h]) |
Un contenedor que se reinicia solo está fallando |
| Estado de salud | /salud, docker inspect .State.Health |
Distingue "arrancado" de "listo" |
| Latencia p95 | histogram_quantile(0.95, ...) |
La media miente; el percentil 95 no |
| Tasa de errores | rate(peticiones{estado=~"5.."}[5m]) |
La señal más directa de que algo se rompió |
| Disco y conexiones a la BD | node_filesystem_avail_bytes, pg_stat_activity |
Logs que llenan el disco; pool agotado que tumba la API |
Fíjate en el patrón: lo que importa casi nunca es el valor absoluto, sino la relación con el límite que pusiste en la lección 03-07. 400 MB de memoria no dicen nada; 400 MB sobre un límite de 512 MB dicen que falta poco para un OOM.
- Alertas
# observabilidad/alertas.yml
groups:
- name: aurora
rules:
- alert: ContenedorCaido
expr: absent(container_last_seen{name="aurora-libros-aurora-api-1"}) == 1
for: 2m
labels: { severidad: critica, aviso: guardia }
annotations: { resumen: "aurora-api no responde desde hace 2 minutos" }
- alert: MemoriaCercaDelLimite
expr: container_memory_usage_bytes{name=~"aurora.*"}
/ container_spec_memory_limit_bytes{name=~"aurora.*"} > 0.9
for: 5m
labels: { severidad: alta, aviso: equipo }
annotations: { resumen: "{{ $labels.name }} al {{ $value | humanizePercentage }} del límite" }
- alert: TasaDeErroresAlta
expr: sum(rate(aurora_peticiones_total{estado=~"5.."}[5m]))
/ sum(rate(aurora_peticiones_total[5m])) > 0.05
for: 3m
labels: { severidad: critica, aviso: guardia }
annotations: { resumen: "Más del 5 % de las peticiones devuelven 5xx" }
- alert: ReiniciosRepetidos
expr: changes(container_start_time_seconds{name=~"aurora.*"}[15m]) > 3
for: 1m
labels: { severidad: alta, aviso: equipo }Las dos claves de una alerta que no acaba ignorada. La primera es for: sin él, un pico de dos segundos despierta a alguien de madrugada. La segunda es a quién notifica: guardia para lo que exige acción inmediata (servicio caído, errores masivos) y equipo para lo que puede esperar a mañana (memoria alta, reinicios). Una alerta que no lleva a ninguna acción concreta debe borrarse; el peor estado posible de un sistema de alertas es que el equipo las ignore por costumbre.
- El endpoint de salud rico
/salud empezó devolviendo {"estado":"ok"}. Eso solo dice que el proceso arranca. Un endpoint útil comprueba sus dependencias:
app.get('/salud', async (req, res) => {
const inicio = Date.now();
const comprobar = async (nombre, fn) => {
const t = Date.now();
try { await fn(); return { nombre, estado: 'ok', ms: Date.now() - t }; }
catch (e) { return { nombre, estado: 'error', ms: Date.now() - t, detalle: e.message }; }
};
const partes = await Promise.all([
comprobar('bd', () => db.query('SELECT 1')),
comprobar('cache', () => cache.ping())
]);
const sano = partes.every(p => p.estado === 'ok');
if (!sano) log.error('salud degradada', { partes });
res.status(sano ? 200 : 503).json({
estado: sano ? 'ok' : 'degradado', version: process.env.APP_VERSION ?? '1.3.0',
uptime_s: Math.round(process.uptime()), ms: Date.now() - inicio, dependencias: partes
});
});curl -s http://localhost:8080/api/salud | jq -c
docker compose stop aurora-cache && sleep 2
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8080/api/salud
docker compose start aurora-cache{"estado":"ok","version":"1.3.0","uptime_s":1842,"ms":4,"dependencias":[{"nombre":"bd","estado":"ok","ms":2},{"nombre":"cache","estado":"ok","ms":1}]}
503El 503 es lo importante: el HEALTHCHECK del Dockerfile lo detecta, el contenedor pasa a unhealthy, depends_on: service_healthy no arranca lo que dependa de él y el balanceador deja de enviarle tráfico. Con un /salud que solo devolvía ok, nada de eso ocurriría.
Un matiz de diseño: distingue liveness (¿debe reiniciarse este proceso?) de readiness (¿puede recibir tráfico?). Si Redis cae, la API no debe reiniciarse en bucle, solo dejar de anunciarse como lista; la distinción explícita llega con Kubernetes (lección 06-05).
- Trazas distribuidas: el siguiente paso
Logs y métricas responden a "qué pasó" y "cuánto"; las trazas responden a "dónde se fue el tiempo". Una petición a /libros atraviesa nginx, Express, Redis y PostgreSQL, y una traza mide cada tramo por separado. OpenTelemetry es el estándar: con la instrumentación automática de Node basta un paquete y unas variables (OTEL_SERVICE_NAME, OTEL_EXPORTER_OTLP_ENDPOINT: http://tempo:4318, OTEL_TRACES_SAMPLER_ARG: "0.1" para muestrear el 10 %).
El req_id del apartado 5 es, de hecho, un precursor artesanal del trace_id. Si adoptas OpenTelemetry, sustitúyelo por el identificador de traza y tendrás logs, métricas y trazas correlacionados por el mismo campo.
Errores Comunes y Consejos
Escribir logs a ficheros dentro del contenedor. Se pierden al recrearlo y docker logs no los ve. Siempre a stdout/stderr.
No configurar rotación. 8 GB al mes por servicio y una caída por disco lleno que además impide registrar la causa.
Cambiar el driver a fluentd o syslog y perder docker logs. Si el sistema central falla, te quedas ciego. Mantén json-file/local y recoge con un agente.
Registrar datos personales o secretos. Un log con contraseñas o datos de clientes es un incidente de protección de datos. Filtra en el emisor y consúltalo con tu responsable de compliance.
Alertar sobre valores absolutos, o alertar sin for. 400 MB no significa nada sin el límite al lado, y un pico de dos segundos que despierta a alguien de madrugada enseña al equipo a ignorar las alertas.
Un /salud que solo dice ok. No distingue "el proceso vive" de "el servicio funciona". Comprueba las dependencias.
Consejo: monta la observabilidad antes de necesitarla. El día del incidente no hay tiempo para instalar Prometheus, y los datos que más falta hacen son los de las horas anteriores, que ya nadie puede recuperar.
Ejercicios
Ejercicio 1. Calcula el crecimiento de logs de aurora-api con tu propio tráfico: mide el tamaño del fichero de log, genera 500 peticiones, vuelve a medir y extrapola a un mes. Después configura la rotación y demuestra que el techo se respeta.
Ejercicio 2. Añade el req_id y el registro estructurado a aurora-api, genera tráfico y demuestra que puedes reconstruir la traza completa de una petición concreta a partir del identificador que devuelve la cabecera de respuesta.
Ejercicio 3. Levanta el perfil observabilidad, provoca un consumo de memoria cercano al límite en aurora-api y comprueba en Prometheus que la expresión de la alerta se dispara. Explica por qué la alerta usa una proporción y no un valor absoluto.
Soluciones
Solución 1.
id=$(docker compose ps -q aurora-api); f=/var/lib/docker/containers/$id/$id-json.log
antes=$(sudo stat -c %s "$f")
for i in $(seq 1 500); do curl -s -o /dev/null http://localhost:8080/api/libros; done
p=$(( ($(sudo stat -c %s "$f") - antes) / 500 ))
echo "bytes/petición: $p"
echo "a 15 pet/s: $(( p * 15 * 86400 / 1048576 )) MB/día"
echo "al mes: $(( p * 15 * 86400 * 30 / 1073741824 )) GB"Siete gigabytes al mes de un solo servicio, medidos y no estimados. Ahora el límite:
docker compose up -d --force-recreate aurora-api
for i in $(seq 1 60000); do curl -s -o /dev/null http://localhost:8080/api/salud; done
id=$(docker compose ps -q aurora-api)
sudo du -ch /var/lib/docker/containers/$id/*json.log* | tail -4El techo se respeta: tres ficheros, ninguno por encima de 10 MB, 25 MB en total pase lo que pase. Se han perdido las líneas más antiguas, y eso es exactamente lo que se busca: los logs recientes son los que sirven para diagnosticar, y el histórico es responsabilidad de Loki, no del disco del host. El detalle a recordar es que max-size sin max-file limita un solo fichero y max-file sin max-size no limita nada: hacen falta los dos.
Solución 2.
docker compose up -d --build aurora-api
rid=$(curl -s -D - -o /dev/null http://localhost:8080/api/libros | grep -i '^x-request-id' | tr -d '\r' | awk '{print $2}')
echo "petición: $rid"
docker compose logs aurora-api --no-log-prefix --tail 200 | jq -c "select(.req_id==\"$rid\")"petición: 7f3a1c9d-4e28-4a1b-9c33-1f0e7b2d5a64
{"ts":"...T10:14:02.874Z","nivel":"debug","mensaje":"cache miss","req_id":"7f3a1c9d-...","clave":"libros:todos"}
{"ts":"...T10:14:02.901Z","nivel":"debug","mensaje":"consulta bd","req_id":"7f3a1c9d-...","filas":9,"ms":24.8}
{"ts":"...T10:14:02.914Z","nivel":"info","mensaje":"peticion","req_id":"7f3a1c9d-...","metodo":"GET","ruta":"/libros","estado":200,"ms":33.2}Tres líneas que cuentan la historia completa de esa petición: la caché no tenía la clave, se consultó PostgreSQL con 9 filas en 24,8 ms, y la respuesta salió con un 200 en 33,2 ms totales. Con esos datos puedes afirmar que el 75 % del tiempo se fue en la base de datos, sin adivinar nada.
Lo que hace esto operable de verdad es que el identificador sale al exterior en la cabecera x-request-id. El usuario que reporta un problema puede darte ese código —o el frontal puede incluirlo en su mensaje de error—, y tú recuperas su petición exacta entre millones. Y como la cabecera también se acepta en la entrada, si nginx o el frontal ya generan uno, la API lo reutiliza y la traza queda encadenada de extremo a extremo.
Solución 3.
docker compose -f compose.yaml -f compose.observabilidad.yaml --profile observabilidad up -d
Q='container_memory_usage_bytes{name=~"aurora.*"} / container_spec_memory_limit_bytes{name=~"aurora.*"}'
curl -s http://localhost:9090/api/v1/query --data-urlencode "query=$Q" \
| jq -r '.data.result[] | "\(.metric.name) \(.value[1])"' | head -1
# Se fuerza el consumo con un endpoint de prueba que retiene memoria
for i in $(seq 1 40); do curl -s -o /dev/null "http://localhost:8080/api/pruebas/memoria?mb=12"; done
sleep 70
curl -s http://localhost:9090/api/v1/query --data-urlencode "query=$Q > 0.9" \
| jq -r '.data.result[] | "\(.metric.name) \(.value[1])"'
curl -s http://localhost:9090/api/v1/alerts | jq -r '.data.alerts[] | "\(.labels.alertname) \(.state)"'La expresión devuelve resultado (94 % del límite) y la alerta entra en pending. Tras cinco minutos cumpliendo la condición —el for: 5m de la regla— pasaría a firing y llegaría a Alertmanager. Ese retardo es deliberado: un pico de memoria durante una importación puntual no debe generar aviso; una tendencia sostenida al 94 %, sí.
Y la razón de usar una proporción y no un valor absoluto es doble. La primera, de significado: 480 MB es catastrófico para aurora-api (límite de 512 MB, a un paso del OOM killer y del código 137) y absolutamente normal para aurora-db (límite de 2 GB). Una alerta con umbral fijo o inunda de falsos positivos o no detecta nada. La segunda, de mantenimiento: el día que subas el límite de la API a 1 GB, la regla en proporción sigue siendo correcta sin tocarla, mientras que un umbral absoluto habría que recordar cambiarlo. Y ese "recordar" es justo lo que nunca ocurre.
Conclusión
Aurora Libros ya no es una caja opaca en marcha. Sabes por qué una aplicación en contenedor escribe a stdout/stderr y no a ficheros, conoces los ocho drivers de logging y la trampa que esconden la mitad de ellos: con syslog, fluentd o gelf, docker logs deja de funcionar, así que la estrategia sólida es json-file o local con rotación más un agente que recoja. Y has hecho el cálculo que convence a cualquiera: 214 bytes por petición son 7 GB al mes de un solo servicio, y sin max-size y max-file eso acaba en una caída por disco lleno que además impide registrar su propia causa.
Tu API emite ahora logs estructurados en JSON con nivel, servicio, versión y un req_id que viaja por la cabecera x-request-id y permite reconstruir la traza completa de una petición concreta entre millones de líneas. Sobre eso montas la agregación con Loki, Promtail y Grafana en un perfil observabilidad que no estorba en desarrollo, con consultas LogQL que van desde "errores de la última hora" hasta la proporción de aciertos de caché, que convierte una decisión de arquitectura en una métrica observable.
Del lado de las métricas conoces los límites de docker stats, el endpoint Prometheus del daemon, y cAdvisor y node-exporter alimentando a Prometheus y Grafana. Y sabes qué vigilar: memoria y CPU frente al límite —no en absoluto—, reinicios, salud, latencia p95, tasa de errores y disco; con alertas que llevan for y que distinguen a quién despiertan. Cierras con un /salud que comprueba PostgreSQL y Redis y devuelve 503 cuando algo falla, haciendo que el HEALTHCHECK, depends_on y el balanceador reaccionen solos, y con OpenTelemetry apuntado como siguiente paso.
En la lección 05-07, la última del módulo, se cierra el círculo: bajarás al kernel de Linux para ver que un contenedor no existe. Es un proceso normal con tres mecanismos encima —namespaces, cgroups y un sistema de ficheros de capas—, y los vas a tocar uno a uno: los siete namespaces con lsns y nsenter, los ficheros de cgroups v2 de aurora-db comprobando que coinciden exactamente con los límites que pusiste en 03-07, el montaje OverlayFS real con su copy-on-write, las capacidades descodificadas con capsh, y qué hace exactamente runc cuando arranca un contenedor.
Docker: De Principiante a Avanzado
Módulo 1: Introducción a Docker
- ¿Qué es Docker?
- Instalando Docker
- Arquitectura de Docker
- Comandos Básicos de Docker
- Entendiendo las Imágenes de Docker
- Creando tu Primer Contenedor Docker
- El Proyecto del Curso: la Plataforma Aurora Libros
Módulo 2: Trabajando con Imágenes Docker
- Docker Hub y Repositorios
- Construyendo Imágenes Docker
- Conceptos Básicos de Dockerfile
- Instrucciones Avanzadas del Dockerfile
- Gestionando Imágenes Docker
- Etiquetado y Publicación de Imágenes
Módulo 3: Contenedores Docker
- Ejecutando Contenedores
- Ciclo de Vida del Contenedor
- Gestionando Contenedores
- Inspección y Depuración de Contenedores
- Redes en Docker
- Persistencia de Datos con Volúmenes
- Límites de Recursos y Políticas de Reinicio
Módulo 4: Docker Compose
- Introducción a Docker Compose
- Definiendo Servicios en Docker Compose
- Comandos de Docker Compose
- Aplicaciones Multi-Contenedor
- Variables de Entorno en Docker Compose
- Perfiles, Overrides y Múltiples Entornos
- Desarrollo Local con Docker Compose
Módulo 5: Conceptos Avanzados de Docker
- Profundización en Redes Docker
- Opciones de Almacenamiento Docker
- Mejores Prácticas de Seguridad en Docker
- Optimizando Imágenes Docker
- Builds Avanzadas con BuildKit y Buildx
- Registro y Monitoreo en Docker
- El Runtime por Dentro: Namespaces, Cgroups y Capas
Módulo 6: Docker en Producción
- Preparar una Imagen para Producción
- CI/CD con Docker
- Orquestando Contenedores con Docker Swarm
- Introducción a Kubernetes
- Desplegando Contenedores Docker en Kubernetes
- Escalado y Balanceo de Carga
- Estrategias de Despliegue y Rollback
