Curso de especialización · Curso 2026-27
Mantenimiento del sistema de contenedores
Apuntes de la asignatura
Víctor Sellés, con apoyo de Claude (Anthropic)
Versión v1.2-13-g98ac1e8 · 98ac1e8 · https://victor-educ.github.io/apuntes-5169/
Texto e imágenes propias con licencia CC BY-SA 4.0. Las imágenes de terceros llevan su atribución al pie.
Índice
Mantenimiento del sistema de contenedores¶
Aquí están los apuntes de toda la asignatura, unidad por unidad, con las actividades de cada sesión y las prácticas evaluables. Es el mismo material que se trabaja en clase, ampliado con lo que no cabe en dos horas y con enlaces a la documentación oficial.
De qué va la asignatura¶
En Despliegue de plataformas de contenedores se monta la plataforma: hipervisor, red, cortafuegos, código de infraestructura y pipeline. Esta asignatura trabaja sobre lo que aquella va construyendo y se ocupa de que el servicio que corre encima siga vivo, seguro y bajo control durante meses: vigilarlo, saber cuándo algo va mal antes de que lo digan los usuarios, probarlo, actualizarlo sin romper nada, tener copias que de verdad se puedan restaurar y, cuando llegue el momento, retirarlo sin dejar rastro. La monitorización es de esta asignatura: la pila de mon01 (Prometheus, Alertmanager, Grafana y Loki) se monta aquí desde octubre, y la asignatura de despliegue trabaja sobre ella en marzo para vigilar también la propia plataforma.
Las dos asignaturas se cursan a la vez (esta los martes y jueves, la de despliegue los miércoles y viernes) y comparten laboratorio: la VPC dev de Proxmox, el servicio del curso en app01 y la pila de monitorización en mon01. Lo que aquí se llama "contenedor de referencia" es ese servicio.
Estos son los conceptos que vertebran el curso:
- Observabilidad. Los cuatro flujos que salen de un contenedor (métricas de recursos, métricas de aplicación, logs y eventos), cómo se sacan fuera con cAdvisor, exporters, Promtail y Loki, y cómo se comprueba que llegan íntegros.
- Alarmas. De la métrica al umbral, del umbral a la regla, de la regla a Alertmanager y de ahí a una incidencia con dueño. Recording rules, agrupación, inhibición, silencios y verificación de cada alarma.
- Seguridad de la monitorización. Cada exporter es un puerto más. Auditar qué escucha, cerrar lo que sobra, cifrar y autenticar el tráfico de métricas y logs.
- Indicadores y pruebas. Fichas de métricas, SLI y SLO, catálogo de alarmas con runbooks, y pruebas funcionales, de carga, de estrés y de seguridad con pytest, k6 y ZAP.
- Explotación del sistema en la empresa. Revisión sistemática de logs, detección de fuerza bruta con fail2ban, análisis de reinicios y crashdumps, línea base de rendimiento.
- Copias de seguridad en la empresa. Qué se copia, con qué frecuencia, a dónde, con qué retención y, sobre todo, la restauración de prueba que demuestra que la copia sirve.
- Actualización y vulnerabilidades. Versiones y digests, Renovate, SBOM con Syft, escaneo con Trivy y Grype, decisiones justificadas por CVE y actualizaciones trazables de pre a producción.
- Terminación segura. Dar de baja un servicio liberando infraestructura, destruyendo copias y logs de forma irrecuperable, borrando datos sensibles y desconfigurando la monitorización.
flowchart LR
UT1["<b>UT1</b><br><small>Observabilidad</small>"]:::pieza
UT2["<b>UT2</b><br><small>Alarmas</small>"]:::pieza
UT3["<b>UT3</b><br><small>Seguridad de la monitorización</small>"]:::pieza
UT4["<b>UT4</b><br><small>KPI y pruebas</small>"]:::pieza
UT7["<b>UT7</b><br><small>Actualización y vulnerabilidades</small>"]:::pieza
UT8["<b>UT8</b><br><small>Terminación segura</small>"]:::pieza
FE["<b>Formación en empresa</b><br><small>UT5 logs y rendimiento · UT6 copias</small>"]:::infra
UT1 --> UT2 --> UT3 --> UT4 --> UT7 --> UT8
UT4 -.-> FE
classDef act fill:#ea580c22,stroke:#ea580c,stroke-width:1.5px
classDef pieza fill:#64748b22,stroke:#64748b,stroke-width:1.5px
classDef dato fill:#2563eb22,stroke:#2563eb,stroke-width:1.5px
classDef infra fill:#a1a1aa14,stroke:#a1a1aa,stroke-width:1.5px
classDef ok fill:#16a34a22,stroke:#16a34a,stroke-width:1.5px
classDef riesgo fill:#dc262622,stroke:#dc2626,stroke-width:1.5px
Primero se mira, luego se avisa, luego se protege lo que mira, y solo entonces se mide, se actualiza y se retira.
Qué hay en esta web¶
-
Los apuntes de las ocho unidades. Cada una empieza con un par de párrafos sobre lo que se busca y sigue con las sesiones en orden: en cada una, la teoría que se explica ese día y, a continuación, su hoja de práctica. La práctica evaluable cierra la unidad con su rúbrica.
-
Las 41 sesiones del curso con fecha, unidad y lo que se hace en cada una. La vista de calendario muestra el detalle al pasar el ratón y lleva a la actividad con un clic.
-
Resultados de aprendizaje y criterios de evaluación con la unidad donde se trabaja cada uno, cómo se calcula la nota, las herramientas y la metodología.
-
El entorno compartido con la asignatura de despliegue, qué se añade en esta (la pila de monitorización de mon01 con Loki y Promtail, restic, herramientas de escaneo), convenciones y cómo recuperarlo.
-
Docker, PromQL y LogQL, Alertmanager, nftables, k6 y newman, fail2ban, restic, Trivy y Syft, y el borrado seguro.
-
Los términos de la asignatura en una o dos frases, con la unidad donde se explican a fondo.
-
Los apartados de cada unidad que van más allá de lo que se hace en clase y los enlaces para seguir por cuenta propia, ordenados por unidad.
-
Documentación oficial de cada herramienta, libros, sitios donde practicar y créditos de las imágenes.
-
Despliegue de plataformas de contenedores
La asignatura hermana. Ahí está cómo se construye, en paralelo a esta, el laboratorio sobre el que se trabaja aquí: Proxmox, la VPC, el cortafuegos, OpenTofu y Jenkins.
Unidades¶
| UT | Título | Horas | Dónde | RA · CE |
|---|---|---|---|---|
| UT1 | Observabilidad de contenedores: métricas, logs y eventos | 14 | Centro | RA1 a |
| UT2 | Umbrales, agregación y gestión de alarmas | 16 | Centro | RA1 b–e |
| UT3 | Seguridad de las comunicaciones de monitorización | 6 | Centro | RA1 f, g |
| UT4 | Indicadores, KPI y pruebas del servicio | 16 | Centro | RA2 a–f |
| UT5 | Explotación de logs, accesos y rendimiento | 14 | Empresa | RA3 a–d |
| UT6 | Copias de seguridad y restauración | 14 | Empresa | RA4 a, b, c |
| UT7 | Actualización y gestión de vulnerabilidades | 14 | Centro | RA4 d–i |
| UT8 | Terminación segura del contenedor | 10 | Centro | RA5 a–d |
Las unidades del centro van seguidas de octubre a marzo, y la teoría y los exámenes terminan antes de Pascua: la UT8 tiene su última sesión el 18 de marzo y su práctica evaluable, el acta de baja, se entrega por Aules antes del examen. Los exámenes de evaluación van después de la UT3 (primera evaluación, 10 de diciembre de 2026) y después de la UT8 (segunda evaluación, 23 de marzo de 2027), y sus dos sesiones están contadas dentro de las 41 sesiones y las 82 h que el módulo tiene en el centro. Después de Pascua (del 25 de marzo al 2 de abril) el 6 de abril queda sin clase, como margen del calendario, y la última sesión, la del 8 de abril, se dedica a un proyecto conjunto con el resto de asignaturas del curso, que se plantea aparte.
Cómo usar estos apuntes¶
- Conviene leer la sesión antes de clase. Cada unidad está ordenada por sesiones, con la teoría de ese día seguida de su hoja de práctica. En clase la explicación es corta y el laboratorio largo.
- Los comandos están pensados para copiarlos en el laboratorio. Si algo no funciona igual en la versión instalada, el primer sitio donde mirar es la sección "Errores frecuentes" de la unidad.
- Las hojas de práctica numeradas (A1.1, A1.2...) se hacen en la sesión que se indica y no se entregan: su último apartado, «Para practicar», dice qué conviene apuntar, responder y guardar, y la práctica evaluable de la unidad parte muchas veces de ello. Lo que va más allá de lo que se hace en clase está apartado en Para ampliar, para no cargar las unidades. La práctica evaluable cierra la unidad y es lo que se entrega por Aules.
- Conviene documentar sobre la marcha: una captura con fecha, la salida de un comando, el fichero de configuración. Al final de la unidad eso es la práctica.
Antes de empezar¶
Se da por hecho lo mismo que en la asignatura de despliegue: terminal de Linux, redes básicas, Docker y Git. No hace falta tener el laboratorio montado para empezar, porque no existe todavía: la asignatura arranca el 1 de octubre con Docker en el puesto del alumno, con el servicio del curso (nginx, API y PostgreSQL en un solo compose) y una pila mínima de monitorización en localhost. El laboratorio va llegando por fases desde la asignatura de despliegue: app01 y mon01 el 14 de octubre, y la VPC dev y el cortafuegos en noviembre, y cada unidad indica sobre qué se trabaja ese día. La página de laboratorio detalla qué hay disponible en cada momento.
Sobre estos apuntes¶
Este material lo ha escrito Víctor Sellés para la asignatura con el apoyo de Claude; la nota completa sobre cómo se ha elaborado está en la página de presentación. Los errores se pueden comunicar en clase o abrir como issue en el repositorio. El texto se publica con licencia CC BY-SA 4.0; las imágenes de terceros llevan su atribución al pie. La versión publicada aparece en el pie de cada página.
Unidades
UT1 · Observabilidad de contenedores: métricas, logs y eventos¶
Esta es la primera unidad del módulo y trabaja sobre el servicio del curso, que se da el primer día en Aules como repositorio servicio: una API en Python con Flask, servida con gunicorn, detrás de nginx y con PostgreSQL, todo en un solo Docker Compose. El laboratorio llega por fases, y esta unidad se hace sobre un entorno provisional: hasta el 13 de octubre no existe ninguna VM y todo corre con Docker en el puesto del alumno; el 14 de octubre la asignatura de despliegue clona en la red del aula app01 (el servicio) y mon01 (la pila de monitorización), y las dos no pasan a su red definitiva, detrás de un cortafuegos, hasta el 27 de noviembre. La pila de monitorización que se monta aquí, a partir de la mínima que se da en clase, es la de todo el curso. Aquí no se despliega nada nuevo del servicio: se trata de sacar de él todo lo que cuenta sobre su estado (métricas de recursos, métricas de aplicación, logs y eventos), llevarlo al sistema de monitorización y demostrar con pruebas que llega entero, a tiempo y se guarda. Ese "demostrar" es la mitad de la unidad y el criterio de evaluación lo dice explícitamente: integrar y verificar comunicación, integridad y almacenamiento. Sin datos fiables no hay alarma que funcione, y las alarmas son lo que construye la unidad siguiente, la UT2, a partir del 29 de octubre.
Al servicio del curso, esté en el puesto o en app01, se le llama durante todo el módulo el contenedor de referencia, y se mantiene vivo todo el curso.
Sesión 1 · Presentación y el contenedor de referencia¶
Mantener un servicio en contenedores empieza por poder contestar en todo momento a dos preguntas: si funciona y, cuando falla, por qué. Para eso no basta con entrar en la máquina a mirar: hay que saber qué información da un contenedor sobre sí mismo, de dónde sale cada una y adónde hay que llevarla para guardarla. Esta primera sesión, después de la presentación de la asignatura, dibuja ese mapa completo y lo comprueba con el servicio del curso: se pone en marcha, se tira la base de datos y se observa en tres terminales cómo reacciona cada tipo de información. Sin el mapa, lo que aparece en las terminales son piezas sueltas; con él, es el índice de lo que se monta en el resto de la unidad.
Qué expone un contenedor¶
Qué es la observabilidad. Un servicio es observable cuando, desde fuera y sin tocarlo, se puede saber en qué estado está y por qué. Es lo contrario de lo que pasa cuando algo falla y la única forma de averiguarlo es entrar en la máquina, probar cosas y reiniciar a ver si se arregla. La observabilidad no se consigue con una herramienta, sino recogiendo de forma ordenada todo lo que el servicio cuenta de sí mismo.
Qué cuenta un contenedor. Un contenedor en ejecución no es más que un proceso (o varios) del host, aislado del resto por dos mecanismos del kernel: los namespaces, que deciden qué ve, y los cgroups, que deciden cuánto puede gastar y llevan la cuenta. Todo lo que interesa saber de él sale por uno de estos cuatro caminos:
| Flujo | Quién lo produce | Cómo se lee en local | A dónde va |
|---|---|---|---|
| Métricas de recursos | El kernel, vía cgroups | docker stats, /sys/fs/cgroup, cAdvisor |
Prometheus |
| Métricas de aplicación | La propia aplicación, en /metrics (formato de exposición de Prometheus) |
curl app01:29102/metrics |
Prometheus |
| Logs | stdout y stderr del proceso principal, capturados por el driver de logs de Docker | docker logs -f app |
Loki (vía Promtail) |
| Eventos | El demonio Docker: start, die, oom, health_status... | docker events |
Loki (vía un contenedor auxiliar y Promtail) |
Por qué cuatro y no uno. Cada flujo responde a una pregunta distinta. Las métricas de recursos dicen cuánto gasta el contenedor; las de aplicación, si el servicio hace bien su trabajo (cuántas peticiones, cuántos errores, cuánto tardan); los logs, qué ha pasado exactamente en cada momento; y los eventos, qué le ha pasado al contenedor desde fuera (si ha arrancado, si ha muerto y por qué). Ninguno sustituye a los demás: una avería se entiende cruzándolos.
Por qué dos sistemas para guardarlos. Los dos primeros son números: se miden cada pocos segundos y se guardan como series temporales, una lista de parejas hora y valor, en Prometheus, una base de datos hecha para eso. Los otros dos son texto con su hora: no se miden, se recogen línea a línea, y se guardan en Loki, una base de datos de logs. Grafana no guarda nada: es la web que consulta los dos y dibuja el resultado. Las tres piezas están en la pila mínima de monitorización que se da hoy en Aules, y se explican a fondo en las sesiones en que se usan por primera vez.
flowchart LR
subgraph app01["Host del servicio · el puesto hasta el 13 oct, app01 después"]
api["<b>API</b><br><small>/metrics :29102 · stdout JSON</small>"]:::pieza
cad["<b>cAdvisor</b><br><small>:28081</small>"]:::pieza
pgx["<b>postgres_exporter</b><br><small>:29187</small>"]:::pieza
ev["<b>docker-events</b><br><small>contenedor auxiliar</small>"]:::pieza
pt["<b>Promtail</b><br><small>:29080</small>"]:::pieza
dk[("<b>dockerd</b><br><small>json-file</small>")]:::infra
cg[("<b>cgroups v2</b>")]:::infra
cg --> cad
api --> dk
ev --> dk
dk --> pt
end
subgraph mon01["Monitorización · el puesto hasta el 13 oct, mon01 después"]
prom["<b>Prometheus</b><br><small>:29090</small>"]:::act
loki["<b>Loki</b><br><small>:23100</small>"]:::act
graf["<b>Grafana</b><br><small>:23000</small>"]:::dato
prom --> graf
loki --> graf
end
prom -.->|scrape| api
prom -.->|scrape| cad
prom -.->|scrape| pgx
pt -->|push HTTP| loki
classDef act fill:#ea580c22,stroke:#ea580c,stroke-width:1.5px
classDef pieza fill:#64748b22,stroke:#64748b,stroke-width:1.5px
classDef dato fill:#2563eb22,stroke:#2563eb,stroke-width:1.5px
classDef infra fill:#a1a1aa14,stroke:#a1a1aa,stroke-width:1.5px
classDef ok fill:#16a34a22,stroke:#16a34a,stroke-width:1.5px
classDef riesgo fill:#dc262622,stroke:#dc2626,stroke-width:1.5px
Las métricas las va a buscar Prometheus; los logs los empuja Promtail. Esa diferencia de sentido decide qué puerto hay que abrir en cada máquina.
El esquema es el mismo antes y después del 14 de octubre; lo único que cambia es dónde está cada caja. Hasta el 13 de octubre las dos están en el mismo host, el puesto del alumno, y las flechas son llamadas a localhost o al nombre del servicio dentro de la red de compose; desde el traslado, el 15 de octubre, la de arriba es app01 y la de abajo mon01, y las flechas cruzan la red del aula.
Conviene fijarse en la dirección de las flechas: las métricas las va a buscar Prometheus (pull), y los logs los empuja Promtail, un agente que corre junto al servicio (push). Esa diferencia decide qué se pierde cuando se corta la red. Se comprueba al final de la unidad, el 22 de octubre, cortándola a propósito.
Por qué los puertos del curso empiezan por 2. Cada pieza del esquema escucha en un puerto, y los que traen de fábrica son también los de muchos otros programas: el 80 lo usan XAMPP o IIS, el 3000 los servidores de desarrollo de Node, el 8080 Tomcat y el 9090 Cockpit, la consola web de Fedora y Red Hat. Si uno de ellos está en marcha en el puesto, docker compose up falla con address already in use. Para no depender de lo que tenga instalado cada equipo, el curso saca los puertos del servicio y de la monitorización de una franja que casi ningún programa usa, la del 20000 al 29999, con una regla que se recuerda sola: el puerto de fábrica más 20000. Prometheus, que de fábrica escucha en el 9090, va en el 29090; Grafana, en el 23000; la API, en el 28080; y el nginx del servicio, en el 20080. Cada servicio se configura para escuchar en ese número dentro de su contenedor y se publica en el mismo, así que tiene un solo número, el mismo en el compose.yml, en el navegador y en Prometheus. La franja queda por encima de los puertos de las herramientas habituales y por debajo de los que el sistema operativo reparte a las conexiones salientes, que empiezan en el 32768 en Linux y en el 49152 en Windows y macOS. La tabla completa está en las convenciones del laboratorio.
A1.1 Presentación y el contenedor de referencia (sesión 1)¶
Los primeros 25 minutos son la presentación de la asignatura (evaluación, laboratorio compartido con Despliegue y calendario); después, 20 minutos para el mapa de los cuatro flujos, y el resto es esta hoja.
Objetivo Tener el servicio del curso en marcha, que es el que vas a vigilar todo el año, y ver con tus propios ojos los flujos del mapa antes de montar ninguna herramienta: cómo reaccionan el consumo, los logs y los eventos cuando se cae la base de datos y cuando vuelve.
Antes de empezar
- Docker Engine sobre Linux en tu puesto. Hoy no existe ninguna VM del laboratorio: todo se hace en localhost. Si el puesto es Windows o macOS, trabaja en una VM Debian en VirtualBox, porque en la sesión 2, el 6 de octubre, se leen ficheros de cgroups que solo existen en un host Linux.
- De Aules, el servicio del curso (la API Flask en
api/y uncompose.ymlconnginx,appydb) y la pila mínima de monitorización (Prometheus, Alertmanager y Grafana), que hoy no se usa. - Lo explicado antes: Qué expone un contenedor.
Pasos
Primero dejas preparado el sitio donde vas a trabajar todo el curso, con cada pieza en su repositorio, y pones el servicio en marcha. Después abres una ventana a cada flujo del mapa con las herramientas que Docker trae de serie, y al final provocas la primera avería del curso para ver qué cuenta cada ventana. Fíjate en lo que no puedes hacer con estas herramientas, como volver atrás en el tiempo o ver otra máquina: es lo que resuelve el resto de la unidad.
-
Prepara el sitio de trabajo y arranca el servicio. Deja el servicio en
/opt/servicioy la pila de monitorización en/opt/monitoring, cada una como repositorio Git con un commit de partida: así nacen hoyservicioymonitoring, que crecerán todo el curso (el remoto llega el 17 de noviembre). Crea también/opt/agentes, vacía, para la sesión 2. Despliega el servicio y comprueba que responde enhttp://localhost:20080/health.Pista: las contraseñas van en un
.envcopiado de.env.example, que no se sube al repositorio. Cámbialas antes del primerup: PostgreSQL fija la suya la primera vez que arranca. -
Abre una ventana a cada flujo. En tres terminales, deja a la vista el consumo de los contenedores, los logs de la API y los eventos del demonio de Docker, con las herramientas de la columna «Cómo se lee en local» de Qué expone un contenedor. Desde un cuarto terminal, genera tráfico continuo contra
/healthy observa qué cambia en cada ventana con cada petición.Pista:
curl -s -o /dev/null -w '%{http_code}\n'imprime solo el código de respuesta. Los contenedores se llamanservicio-app-1,servicio-db-1yservicio-nginx-1. -
Provoca la primera avería. Para la base de datos y cronometra qué aparece en cada ventana y cuántos segundos tarda en aparecer desde la parada; después arráncala y observa cómo se recupera el servicio. Lo que apuntes es la primera versión de lo que la monitorización tendrá que detectar sola.
Comprobación git log tiene un commit en /opt/servicio y otro en /opt/monitoring; el bucle de curl devuelve 200 con la base de datos arriba y 500 (o 503) con ella parada; en el terminal de eventos aparece el die de db con exitCode 0 y después el start.
Para practicar Apunta en tests/evidence/ut1/a1.1.md del repositorio servicio una tabla con tres filas (métricas, logs, eventos) y tres columnas (qué se vio al parar la BD, cuántos segundos tardó, qué se vio al arrancarla).
Si te sobra tiempo Repite el bucle con /items, que consulta la base de datos: ¿la API devuelve error o se queda colgada?
Sesión 2 · Métricas de recursos¶
En la A1.1 se vio con docker stats cuánto consumía cada contenedor mientras se paraba la base de datos. Esa terminal sirve para mirar ahora mismo, pero no para mantener un servicio: no guarda lo que pasó anoche, solo enseña la máquina en la que se ejecuta y no puede avisar a nadie. Esta sesión convierte ese mismo dato en una métrica de verdad, que se guarda con su hora, se consulta desde otra máquina y puede disparar alarmas. Para eso hacen falta cuatro piezas en cadena, y la teoría las recorre en ese orden: de dónde saca el kernel los números (los cgroups), quién los publica (cAdvisor), quién los guarda (Prometheus) y quién los dibuja (Grafana). La hoja monta la cadena entera para la CPU y la memoria de los contenedores del servicio.
Métricas de recursos: cgroups v2 y cAdvisor¶
flowchart TB
K["<b>Kernel · cgroups v2</b><br><small>lleva la cuenta de lo que gasta cada contenedor</small>"]:::infra
C["<b>cAdvisor</b><br><small>lee esa cuenta cada pocos segundos</small>"]:::pieza
E["<b>/metrics</b><br><small>la publica como texto en formato Prometheus</small>"]:::dato
P["<b>Prometheus</b><br><small>la recoge y la guarda con su hora</small>"]:::act
G(["<b>Grafana</b><br><small>la dibuja</small>"]):::ok
K --> C --> E --> P --> G
classDef act fill:#ea580c22,stroke:#ea580c,stroke-width:1.5px
classDef pieza fill:#64748b22,stroke:#64748b,stroke-width:1.5px
classDef dato fill:#2563eb22,stroke:#2563eb,stroke-width:1.5px
classDef infra fill:#a1a1aa14,stroke:#a1a1aa,stroke-width:1.5px
classDef ok fill:#16a34a22,stroke:#16a34a,stroke-width:1.5px
classDef riesgo fill:#dc262622,stroke:#dc2626,stroke-width:1.5px
Nadie inventa los números: el kernel ya los está contando. El resto de la cadena solo los lee, los guarda y los enseña.
De dónde salen los números¶
El problema que resuelven los cgroups. En una máquina con muchos procesos, nada impide que uno solo se coma toda la memoria o toda la CPU y deje sin recursos a los demás. Para evitarlo, el kernel de Linux necesita poder agrupar procesos y, para cada grupo, ponerle un límite y llevar la cuenta de lo que gasta. Eso son los cgroups (control groups, grupos de control): un mecanismo del kernel que empezaron a desarrollar ingenieros de Google en 2006 para repartir sus servidores entre muchas tareas a la vez, y que forma parte de Linux desde 2008.
Qué tienen que ver con los contenedores. Un contenedor no es una máquina virtual: es un proceso normal del host al que el kernel le aplica dos mecanismos a la vez. Los namespaces deciden qué ve el proceso (sus propios procesos, su red, su sistema de ficheros) y los cgroups deciden cuánto puede gastar. Cuando Docker arranca un contenedor, crea un cgroup y mete dentro su proceso principal; las opciones --memory y --cpus (o mem_limit y cpus en compose) no son más que valores que Docker escribe en los ficheros de ese cgroup.
Por qué ahí están las métricas. Para hacer cumplir un límite, el kernel tiene que medir el consumo continuamente. Esa contabilidad existe para cada contenedor, tenga límite o no, y se puede leer desde el host: está en un sistema de ficheros especial, /sys/fs/cgroup, donde cada cgroup es un directorio y cada fichero es un contador o un límite. Para medir un contenedor no hay que instalar nada dentro de él; basta con saber leer esos ficheros.
Hay dos versiones de esa interfaz. La primera tenía un árbol de directorios por cada recurso; la v2, estable desde 2016, tiene uno solo, y es la que usa Debian desde su versión 11, así que es la del laboratorio. Con Docker gestionado por systemd, cada contenedor aparece como un directorio docker-<id>.scope dentro de system.slice, y de todos sus ficheros el curso usa estos:
| Fichero | Qué contiene | Para qué sirve aquí |
|---|---|---|
memory.current |
La memoria que tiene cargada el grupo ahora, caché de ficheros incluida | Punto de partida de la memoria del contenedor |
memory.max |
El límite de memoria, o max si no tiene |
Saber cuánto le queda antes de que el kernel actúe |
memory.stat |
El desglose de esa memoria: anon (la del proceso), file (caché de ficheros), inactive_file... |
Calcular el working set, que se explica en el apartado siguiente |
cpu.stat |
usage_usec: microsegundos de CPU gastados desde que arrancó |
La CPU del contenedor |
io.stat |
Bytes leídos y escritos en cada disco | La actividad de disco |
pids.current |
Procesos e hilos vivos | Detectar un contenedor que no para de crear procesos |
Así se leen para la API. La ruta del cgroup se saca del propio proceso y no se escribe a mano, porque cambia según cómo esté configurado Docker:
PID=$(docker inspect -f '{{.State.Pid}}' servicio-app-1)
CG=/sys/fs/cgroup$(cut -d: -f3 /proc/$PID/cgroup) # /sys/fs/cgroup/system.slice/docker-<id>.scope
cat $CG/memory.current $CG/memory.max
grep usage_usec $CG/cpu.stat
grep -E '^(anon|file|inactive_file) ' $CG/memory.stat
docker stats hace exactamente esto cada segundo y lo formatea. Cuando una herramienta da un número que no cuadra, estos ficheros son la fuente de verdad contra la que se compara.
Memory usage frente a working set¶
Por qué la memoria merece un apartado propio. De todos los recursos, la memoria es el que mata contenedores. Si a uno le falta CPU, va más lento; si llega a su límite de memoria, el kernel mata uno de sus procesos (es el OOM killer, de out of memory) y el contenedor muere. Por eso hay que tener claro qué número vigilar, y no es el más obvio.
De qué está hecha la memoria de un contenedor. Linux aprovecha la memoria libre para guardar en caché los ficheros que se leen y se escriben (la caché de páginas), porque leer de memoria es mucho más rápido que leer de disco. Esa caché se le apunta al cgroup que la provocó: si la API escribe logs o lee ficheros, su memory.current crece aunque el proceso no haya pedido ni un byte más. Pero esa caché no es imprescindible y, cuando falta memoria, el kernel la suelta. Por eso un memory.current que sube y sube en un contenedor que escribe logs parece una fuga de memoria que en realidad no existe.
El número que importa. El working set es la memoria total menos la caché de ficheros que el kernel considera inactiva (memory.current menos inactive_file): lo que el kernel no puede soltar sin perjudicar al proceso. Cuando un contenedor llega a su límite, el kernel primero recupera caché y, si con eso no basta, entra el OOM killer. Así que el working set es la mejor aproximación a cuánto le queda de verdad al contenedor. Es la cifra que enseña docker stats y la que usa Kubernetes para decidir qué contenedor sacar de un nodo que se queda sin memoria.
flowchart TB
subgraph U["container_memory_usage_bytes · memory.current"]
direction TB
RSS["<b>Memoria del proceso</b><br><small>anon / rss</small>"]:::dato
CACHE["<b>Caché de páginas</b><br><small>ficheros leídos o escritos<br>el kernel la suelta sin dolor</small>"]:::infra
end
WS["<b>working set</b><br><small>usage − inactive_file<br>lo que el kernel NO puede reclamar</small>"]:::ok
OOM["<b>Si llega al límite, OOM killer</b><br><small>después de recuperar toda la caché que pueda</small>"]:::riesgo
U -- "quitando la caché reclamable" --> WS --> OOM
classDef act fill:#ea580c22,stroke:#ea580c,stroke-width:1.5px
classDef pieza fill:#64748b22,stroke:#64748b,stroke-width:1.5px
classDef dato fill:#2563eb22,stroke:#2563eb,stroke-width:1.5px
classDef infra fill:#a1a1aa14,stroke:#a1a1aa,stroke-width:1.5px
classDef ok fill:#16a34a22,stroke:#16a34a,stroke-width:1.5px
classDef riesgo fill:#dc262622,stroke:#dc2626,stroke-width:1.5px
Para alarmas y para comparar contra el límite, working set; la memoria total solo sirve para entender de qué está hecha.
cAdvisor, que se presenta más abajo, publica las dos cifras: container_memory_usage_bytes (la total) y container_memory_working_set_bytes. Con la segunda y el límite se calcula qué parte de su memoria ha gastado cada servicio:
# porcentaje del límite que usa cada servicio de compose
container_memory_working_set_bytes{container_label_com_docker_compose_service!=""}
/ container_spec_memory_limit_bytes * 100
Si un contenedor no tiene límite, cAdvisor publica container_spec_memory_limit_bytes a 0 (con cgroups v2, "sin límite" se traduce así) y la división da +Inf. No es un fallo de la consulta, es un aviso: a ese contenedor le falta un límite, y sin límite no hay forma de saber cuánto le queda.
Prometheus: guardar la métrica con su hora¶
Qué falta todavía. Leer un fichero da el valor de ahora. Mantener un servicio exige el valor de cada momento: cuánta memoria tenía la API a las tres de la madrugada, si la CPU lleva una semana subiendo, si el pico de ayer coincidió con un despliegue. Eso es una serie temporal, una sucesión de parejas (hora, valor) guardadas una detrás de otra. Prometheus es una base de datos hecha para guardar y consultar series temporales; nació en 2012 en una empresa de música en streaming para vigilar sus propios servicios, y hoy es la herramienta de referencia para las métricas de contenedores (fue el segundo proyecto que adoptó la CNCF, la fundación que custodia Kubernetes).
Cómo consigue los datos. Prometheus no espera a que nadie le mande nada: cada cierto tiempo (el scrape interval, 15 s en el curso) hace una petición HTTP a la ruta /metrics de cada programa que vigila, que se llama target, y guarda lo que recibe con la hora de ese momento. Los targets del mismo tipo se agrupan en un job. A esa petición periódica se le llama scrape, y es la razón de que quien quiera ser vigilado tenga que publicar sus datos en esa ruta. Lo que devuelve /metrics es texto plano, una línea por valor:
container_memory_working_set_bytes{name="servicio-app-1",image="servicio-app"} 5.0659328e+07
container_cpu_usage_seconds_total{name="servicio-app-1",cpu="total"} 184.39
Métrica, etiquetas y serie. Cada línea lleva un nombre de métrica, unas etiquetas entre llaves que dicen de qué se está hablando (qué contenedor, qué servicio, qué máquina) y un valor. Cada combinación distinta de nombre y etiquetas es una serie distinta, con su propio histórico. Las etiquetas son lo que permite preguntar después por "la memoria de todos los contenedores del servicio app" o por "la CPU de todo lo que corre en app01", y elegirlas bien es la mitad del trabajo, como se ve en Etiquetas y cardinalidad.
Contadores y medidores. La memoria es un medidor (gauge): sube y baja, y su valor se lee tal cual. La CPU es un contador (counter): acumula los segundos de CPU gastados desde que arrancó el contenedor y solo crece, así que su valor suelto no dice nada. Lo que interesa es cuánto sube por segundo, y eso lo calcula la función rate() de PromQL, el lenguaje de consulta de Prometheus. Hay dos tipos más, pensados para medir cuánto tardan las peticiones, que se ven en la sesión 3, el 13 de octubre, al instrumentar la API.
cAdvisor en compose¶
Por qué hace falta un intermediario. Prometheus solo sabe leer /metrics por HTTP, y los cgroups son ficheros del host. Hace falta un programa que lea los cgroups de todos los contenedores y los publique en ese formato: cAdvisor (Container Advisor), un agente que Google publicó en 2014 y que hoy va integrado en Kubernetes. cAdvisor descubre los contenedores de la máquina, lee sus ficheros de cgroup cada pocos segundos y los expone como métricas container_*. No calcula nada nuevo: son los ficheros de De dónde salen los números, con nombre y etiquetas.
Por qué en un compose aparte. cAdvisor no forma parte del servicio, lo vigila. Si estuviera en el mismo compose, cada docker compose down del servicio tumbaría también al agente que tiene que contar lo que le pasa. Por eso va en un compose de agentes, /opt/agentes/compose.yml, al que en las sesiones siguientes se añaden Promtail y el recolector de eventos. El fichero es el mismo en el puesto del alumno (octubre) y en app01 (desde el 15 de octubre); solo cambia la máquina donde se ejecuta.
Qué tiene que ver del host. Desde dentro de su contenedor, cAdvisor necesita ver tres cosas de fuera: el árbol de cgroups (/sys), el socket de Docker (/var/run/docker.sock, el fichero por el que se habla con el demonio, para saber qué cgroup es de qué contenedor y leer sus etiquetas) y el directorio de Docker (para el disco). Por eso monta esas rutas del host, todas en solo lectura:
# /opt/agentes/compose.yml, en el host del servicio
services:
cadvisor:
image: gcr.io/cadvisor/cadvisor:v0.52.1
container_name: cadvisor
privileged: true
devices:
- /dev/kmsg
volumes:
- /:/rootfs:ro
- /var/run:/var/run:ro
- /sys:/sys:ro
- /var/lib/docker/:/var/lib/docker:ro
- /dev/disk/:/dev/disk:ro
command:
- --docker_only=true
- --housekeeping_interval=10s
- --port=28081
- --store_container_labels=false
- --whitelisted_container_labels=com.docker.compose.service,com.docker.compose.project
- --disable_metrics=percpu,sched,tcp,udp,hugetlb,referenced_memory,cpu_topology,resctrl
ports:
- "28081:28081"
restart: unless-stopped
Del resto del fichero, cuatro decisiones:
privilegedy/dev/kmsgle dejan leer los avisos del kernel. Sin ellos funciona, pero no se entera de que el OOM killer ha matado a nadie ycontainer_oom_events_totalno sube nunca.--docker_onlydeja fuera los cgroups que no son contenedores (los servicios del propio host), que no aportan nada y multiplican las series.--housekeeping_interval=10shace que mida cada 10 s en lugar de cada segundo. Con el valor por defecto, cAdvisor puede llegar a gastar un 5 % de la CPU del host solo en medirse a sí mismo y a los demás.--port=28081: cAdvisor escucha en el puerto que le diga esta opción, y se publica en ese mismo número. Por la regla de los puertos del curso le tocaría el 28080 (su 8080 de fábrica más 20000), pero ese es el de la API en el mismo host. Dentro de la red de Docker se le llama igual,cadvisor:28081.
Las dos opciones de etiquetas (--store_container_labels y --whitelisted_container_labels) son las que explica el apartado siguiente.
Etiquetas y cardinalidad¶
Qué etiquetas trae cada serie. cAdvisor pone a cada serie id (la ruta del cgroup), name (el nombre del contenedor, por ejemplo servicio-app-1) e image. Además puede copiar las etiquetas de Docker del contenedor como etiquetas container_label_<clave>, con los puntos cambiados por guiones bajos. Compose pone a cada contenedor bastantes etiquetas de Docker: el proyecto, el servicio, el número de réplica, la ruta del fichero y un hash de la configuración (com.docker.compose.config-hash) que cambia cada vez que un compose up modifica el servicio.
El problema: la cardinalidad. Como cada combinación distinta de etiquetas es una serie, una etiqueta que cambia de valor en cada despliegue crea series nuevas en cada despliegue, y las viejas se quedan huérfanas. Prometheus mantiene en memoria todas las series recientes, así que cada serie de más es memoria y disco de más en mon01 y consultas más lentas. Al número de series distintas se le llama cardinalidad, y que crezca sin control es el problema número uno de una base de datos de series temporales.
flowchart TB
subgraph MAL["Dejando pasar config-hash"]
direction LR
D1["<b>compose up</b><br><small>lunes</small>"]:::act --> S1["<b>serie nueva</b>"]:::riesgo
D2["<b>compose up</b><br><small>martes</small>"]:::act --> S2["<b>serie nueva</b>"]:::riesgo
D3["<b>compose up</b><br><small>miércoles</small>"]:::act --> S3["<b>serie nueva</b>"]:::riesgo
S1 --- H["<b>Las anteriores quedan huérfanas</b><br><small>ocupan, ensucian y ralentizan las consultas</small>"]:::riesgo
S2 --- H
S3 --- H
end
subgraph BIEN["Dejando pasar solo service y project"]
direction LR
DD["<b>compose up</b><br><small>cualquier día</small>"]:::act --> SS(["<b>La misma serie</b><br><small>service=app · estable entre despliegues</small>"]):::ok
end
MAL ~~~ BIEN
classDef act fill:#ea580c22,stroke:#ea580c,stroke-width:1.5px
classDef pieza fill:#64748b22,stroke:#64748b,stroke-width:1.5px
classDef dato fill:#2563eb22,stroke:#2563eb,stroke-width:1.5px
classDef infra fill:#a1a1aa14,stroke:#a1a1aa,stroke-width:1.5px
classDef ok fill:#16a34a22,stroke:#16a34a,stroke-width:1.5px
classDef riesgo fill:#dc262622,stroke:#dc2626,stroke-width:1.5px
Una etiqueta que cambia en cada despliegue multiplica las series. Es el enemigo número uno de una base de datos de series temporales.
La solución del curso, en dos sitios. En cAdvisor, el compose de arriba desactiva la copia de etiquetas y solo deja pasar dos: el servicio y el proyecto de compose. De las dos, la que importa para todo el módulo es la del servicio, container_label_com_docker_compose_service, porque es estable entre despliegues (siempre app, db o nginx), mientras que name cambia si se escala o se renombra un contenedor. En Prometheus, el job que recoge cAdvisor hace tres cosas más con lo que recibe, en este orden: copia esa etiqueta a una más manejable, service; borra todas las container_label_*, que ya no hacen falta; y se queda solo con las métricas que el curso usa.
# prometheus.yml (fragmento)
scrape_configs:
- job_name: cadvisor
scrape_interval: 15s
static_configs:
- targets: ["cadvisor:28081"] # nombre del servicio en la red de compose
labels: { host: puesto }
metric_relabel_configs:
- source_labels: [container_label_com_docker_compose_service]
target_label: service
- regex: container_label_.*
action: labeldrop # después de la copia: ya no hace falta ninguna
- source_labels: [__name__]
regex: container_(cpu_usage_seconds_total|memory_working_set_bytes|memory_usage_bytes|spec_memory_limit_bytes|network_(receive|transmit)_bytes_total|fs_(reads|writes)_bytes_total|oom_events_total|last_seen)
action: keep
metric_relabel_configs es la parte del job que reescribe cada serie justo después del scrape y antes de guardarla. El último bloque, keep, es opcional pero recomendable en un laboratorio con 2 o 3 GB de RAM en mon01: cAdvisor publica más de cien métricas por contenedor y el curso usa diez. Entre ellas está container_last_seen, que dice cuándo se vio por última vez cada contenedor y sirve para detectar los que han desaparecido.
El target depende de dónde esté cada pieza. En el entorno provisional, Prometheus y cAdvisor corren en el mismo puesto y comparten una red de Docker, así que basta el nombre del servicio (cadvisor:28081, con la etiqueta host: puesto). Desde el 15 de octubre, con Prometheus en mon01 y cAdvisor en app01, el target pasa a ser la IP de app01 con el mismo puerto, que es también el publicado (<app01>:28081), y la etiqueta, host: app01. El resto del bloque no cambia.
Las consultas del panel¶
Grafana no guarda nada. Grafana es la web que dibuja: se conecta a Prometheus como fuente de datos, le manda una consulta en PromQL por cada panel y pinta lo que recibe. Si un panel sale vacío, el fallo está en la consulta o en Prometheus, nunca en un dato que Grafana haya perdido.
Qué hace cada consulta. Las tres que se usan en la sesión se leen de dentro afuera:
# CPU en núcleos por servicio (1 = un núcleo entero)
sum by (service) (rate(container_cpu_usage_seconds_total{service!=""}[2m]))
# memoria working set por servicio
sum by (service) (container_memory_working_set_bytes{service!=""})
# tráfico de red recibido, bytes/s
sum by (service) (rate(container_network_receive_bytes_total{service!=""}[2m]))
{service!=""}se queda con las series de contenedores del servicio y descarta el resto.rate(...[2m])convierte un contador en una velocidad: cuánto ha subido por segundo, de media, en los dos últimos minutos. Aplicado a los segundos de CPU, da los núcleos que se están usando.sum by (service)suma todas las series que tienen el mismoservice. Hoy cada servicio tiene un solo contenedor, pero si mañanaapptuviera tres réplicas, la consulta seguiría dando una línea por servicio.
La ventana de rate(). Con un scrape cada 15 s, en una ventana de [2m] caen 8 muestras, que bastan para una media estable. Con [30s] caen dos, y la gráfica va a saltos o se queda en blanco. La regla práctica: la ventana de rate(), al menos cuatro veces el intervalo de scrape.
A1.2 Métricas de recursos (sesión 2)¶
Objetivo Que la CPU y la memoria de cada contenedor del servicio dejen de ser una foto de docker stats y pasen a ser series guardadas en Prometheus, con una etiqueta service estable, y dibujadas en Grafana. Al terminar tienes la cadena entera de la teoría funcionando y sabes de dónde sale cada número que pinta el panel.
Antes de empezar
- Sigue sin haber VM: todo en tu puesto, con el servicio de la A1.1 en
/opt/servicioy la pila mínima en/opt/monitoring. Los agentes van en un tercer compose,/opt/agentes, que hoy estrenas. - Lo explicado antes: de dónde salen los números, memory usage frente a working set, Prometheus, cAdvisor en compose, etiquetas y cardinalidad y las consultas del panel.
Pasos
Vas a montar la cadena de la teoría de abajo arriba y a comprobar cada eslabón antes de pasar al siguiente: primero lees los números en su origen, después los publica cAdvisor, luego los recoge Prometheus y al final los dibuja Grafana. Así, si al final algo no cuadra, sabes en qué eslabón buscar.
-
Lee la memoria de la API en su origen. Antes de montar ninguna herramienta, comprueba que entiendes el número que va a pintar el panel. Localiza el cgroup del contenedor de la API, lee su memoria total y el desglose de
memory.stat, y calcula el working set como se explica en Memory usage frente a working set. Compáralo con la columna de memoria dedocker stats: si los dos cuadran, ya sabes qué mide cada herramienta. Apunta el número, porque al final tendrás que reconocerlo en Grafana. -
Pon las tres pilas en la misma red. Cada compose crea su propia red, y los contenedores de una no ven los de otra. Prometheus, que está en la pila de monitorización, tiene que llegar a cAdvisor, que va a estar en la de agentes, por su nombre. Por eso las tres pilas van a compartir una red de Docker llamada
obs, creada fuera de compose para que ningúndownla borre.Pista: la red se crea una vez con
docker network create, y cada compose la declara al final como su red por defecto, sin tocar ningún servicio: -
Publica los números con cAdvisor. Despliega cAdvisor en
/opt/agentescon el compose de cAdvisor en compose. Antes de conectar Prometheus, comprueba que el agente hace su trabajo: en su/metrics, publicado en el 28081, tienen que aparecer los contenedores del servicio con la etiqueta de su servicio de compose, que toma los valoresapp,dbynginx. Si esa etiqueta no está aquí, no estará después en ningún panel. -
Haz que Prometheus lo recoja, con las etiquetas limpias. Añade a Prometheus el job
cadvisorde Etiquetas y cardinalidad, con el targetcadvisor:28081, y comprueba en su página de targets que está en UP. Antes de dar el job por bueno, mide para qué sirve su bloquekeep: cuenta las métricas distintas y las series del job con el bloque desactivado, actívalo y vuelve a contar. La diferencia es memoria y disco que mon01 se ahorra durante todo el curso.Pista:
count(count by (__name__)({job="cadvisor"}))cuenta métricas distintas, ycount({job="cadvisor"}), series. -
Dibuja CPU y memoria por servicio y cierra el círculo. Crea en Grafana un dashboard con dos paneles, CPU en núcleos y working set por servicio, con las consultas de Las consultas del panel, y añade al de memoria el porcentaje sobre el límite. Genera tráfico contra el servicio un par de minutos: la CPU de
apptiene que subir con la carga, y su working set tiene que parecerse al número que apuntaste en el paso 1. Si los dos cuadran, la cadena entera funciona.Pista: la pila de Aules ya trae Prometheus como fuente de datos, en
http://prometheus:29090y no enlocalhost, que dentro del contenedor de Grafana es el propio Grafana. Si el porcentaje sale+Inf,appno tiene límite de memoria: pónselo conmem_limit: 512m, el que fija la documentación del servicio.
Comprobación El working set del paso 1 se queda a unos pocos MiB de docker stats; cadvisor en UP en Targets; las series llevan service con los valores app, db y nginx, y ninguna container_label_*; los dos paneles pintan una línea por servicio y el número de series tras el keep es varias veces menor.
Para practicar Guarda en tests/evidence/ut1/a1.2.md la comparación de memoria del paso 1, la captura de los dos paneles y los cuatro números (métricas distintas y series, antes y después del keep): la práctica evaluable parte de él. El compose de agentes y el fragmento de prometheus.yml van además al repositorio monitoring.
Si te sobra tiempo Cambia la ventana del rate() a [30s] en el panel de CPU y observa cómo se rompe la gráfica; después vuelve a [2m].
Sesión 3 · Instrumentar la aplicación¶
Prometheus ya guarda cuánto consume cada contenedor. Pero un contenedor puede estar tranquilo, al 2 % de CPU y con la memoria estable, mientras devuelve error a todo el que le pide algo: el kernel no sabe nada de peticiones ni de errores. Esta sesión añade el segundo de los cuatro flujos de Qué expone un contenedor, las métricas de aplicación, que responden a las tres preguntas de quien mantiene una API: cuántas peticiones llegan, cuántas fallan y cuánto tardan. Se construye en tres pasos: primero, qué tipos de métrica hay y cuál sirve para cada pregunta; después, cómo se añaden al código de la API; y por último, qué se hace con el software que no se puede tocar, como PostgreSQL.
Métricas de aplicación¶
Por qué hay que tocar el código. Las métricas de recursos las calcula el kernel desde fuera, sin que el contenedor se entere. Las de aplicación no se pueden ver desde fuera: solo la propia aplicación sabe que ha recibido una petición a
/items, que ha tardado 80 ms y que ha devuelto un 500. Por eso se instrumenta: se le añaden unas líneas de código que cuentan esos sucesos y los publican en /metrics, la misma ruta y el mismo formato de texto que ya se vio con cAdvisor, para que Prometheus las recoja con su scrape cada 15 s.
De dónde sale el código. No hace falta escribir el formato a mano: el proyecto Prometheus mantiene librerías cliente oficiales para los lenguajes más usados (Go, Java, Python, Ruby...). La aplicación crea sus métricas con la librería, las actualiza cuando pasa algo (una petición atendida, un error) y la librería se encarga de servirlas. Los valores viven en la memoria del proceso: cada scrape lee los que hay en ese momento.
Tipos de métrica¶
Cada pregunta se responde con un tipo de métrica distinto. Los dos primeros ya aparecieron con la CPU y la memoria de los contenedores.
- Counter (contador): un número que solo sube. Sirve para contar sucesos: peticiones atendidas, errores, bytes enviados. Su valor crudo no se consulta nunca, porque depende de cuándo arrancó el proceso; se consulta su velocidad con
rate()(por segundo) oincrease()(en total dentro de una ventana). La ventaja de contar en lugar de medir es que no se pierde nada: si a Prometheus se le escapa un scrape, el siguiente trae el total acumulado y la velocidad sale igual. Si el proceso se reinicia, el contador vuelve a cero yrate()lo detecta y lo corrige. - Gauge (medidor): un valor que sube y baja, y que se consulta tal cual: peticiones en curso, conexiones abiertas, tamaño de una cola, la fecha de la última copia de seguridad.
- Histogram (histograma): sirve para responder a "cuánto tardan". Explicado en el párrafo siguiente.
- Summary (resumen): calcula los percentiles dentro de la propia aplicación. Es más preciso para una sola instancia, pero sus percentiles no se pueden sumar entre varias réplicas de la API, así que en la práctica se usa casi siempre el histograma.
Por qué la latencia necesita un histograma. La primera idea para saber cuánto tarda una API es la media, y la media engaña: si 95 peticiones tardan 20 ms y 5 tardan 3 s, la media sale 169 ms y no describe a nadie, ni a los usuarios contentos ni a los que se cansaron de esperar. Lo que se usa es el percentil: el p95 es el tiempo por debajo del cual quedan el 95 % de las peticiones, o dicho al revés, lo que sufre el 5 % más lento. Para calcularlo, el histograma no guarda cada duración, sino cuántas peticiones han caído por debajo de cada umbral fijo, que se llama bucket (cubo): cuántas tardaron menos de 0,1 s, cuántas menos de 0,25 s, cuántas menos de 0,5 s... Cada cubo es un contador con una etiqueta le (less or equal, menor o igual), y los cubos son acumulativos: el de 0,5 s incluye las del de 0,25 s. Con esos contadores Prometheus estima cualquier percentil con la función histogram_quantile(), y como son contadores, se pueden sumar entre réplicas.
flowchart TB
Q{"<b>¿Qué se quiere medir?</b>"}:::act
C["<b>Counter</b><br><small>solo sube · peticiones, errores, bytes</small>"]:::dato
G["<b>Gauge</b><br><small>sube y baja · conexiones, cola, temperatura</small>"]:::dato
H["<b>Histogram</b><br><small>cubos acumulativos · percentiles en el servidor</small>"]:::dato
S["<b>Summary</b><br><small>percentiles en el cliente · no se agrega</small>"]:::riesgo
Q -- "algo que solo crece" --> C
Q -- "un valor de ahora mismo" --> G
Q -- "latencias" --> H
Q -. "casi nunca" .-> S
C -- "nunca el valor crudo" --> R(["<b>rate() o increase()</b>"]):::ok
H --> P(["<b>histogram_quantile()</b><br><small>y se puede sumar entre réplicas</small>"]):::ok
classDef act fill:#ea580c22,stroke:#ea580c,stroke-width:1.5px
classDef pieza fill:#64748b22,stroke:#64748b,stroke-width:1.5px
classDef dato fill:#2563eb22,stroke:#2563eb,stroke-width:1.5px
classDef infra fill:#a1a1aa14,stroke:#a1a1aa,stroke-width:1.5px
classDef ok fill:#16a34a22,stroke:#16a34a,stroke-width:1.5px
classDef riesgo fill:#dc262622,stroke:#dc2626,stroke-width:1.5px
El contador crudo depende de cuándo arrancó el proceso, así que no se consulta nunca: se consulta su velocidad.
Python con prometheus_client¶
Dónde se engancha el código. La API del curso está hecha con Flask, el microframework web de Python. Flask permite registrar funciones que se ejecutan antes y después de cada petición (before_request y after_request), y ese es el sitio natural para medir: antes se apunta la hora, y después se calcula cuánto ha tardado y se cuenta la petición con su ruta y su código de respuesta. Así se mide cualquier ruta de la API sin tocar ninguna de ellas. Este es el módulo que usa el curso, con la librería cliente oficial de Python, prometheus_client:
# metrics.py
import time
from flask import Flask, request, g
from prometheus_client import Counter, Histogram, Gauge, start_http_server
REQUESTS = Counter(
"app_http_requests_total",
"Peticiones HTTP atendidas",
["method", "route", "status"],
)
LATENCY = Histogram(
"app_http_request_duration_seconds",
"Duración de la petición en segundos",
["method", "route"],
buckets=(0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5),
)
INFLIGHT = Gauge("app_http_requests_in_flight", "Peticiones en curso")
DB_UP = Gauge("app_db_reachable", "1 si la última consulta a PostgreSQL funcionó")
def instrument(app: Flask) -> None:
@app.before_request
def _start():
g.t0 = time.perf_counter()
INFLIGHT.inc()
@app.after_request
def _end(resp):
route = request.url_rule.rule if request.url_rule else "unmatched"
LATENCY.labels(request.method, route).observe(time.perf_counter() - g.t0)
REQUESTS.labels(request.method, route, str(resp.status_code)).inc()
INFLIGHT.dec()
return resp
start_http_server(29102) # sirve /metrics en 0.0.0.0:29102
Las decisiones que hay detrás del módulo:
- Un puerto propio para
/metrics.start_http_server(29102)abre un servidor aparte solo para las métricas, en lugar de servirlas en el puerto de la API, el 28080. Así el scrape no pasa por nginx, no cuenta como tráfico de usuarios y no queda expuesto al público junto con la API. - La plantilla de la ruta, no la URL. La etiqueta
routelleva la ruta tal como está definida en Flask (/items/<int:id>) y nunca la URL real (/items/48213). Con la URL real, cada id distinto sería una serie nueva: es el mismo problema de cardinalidad que con las etiquetas de cAdvisor, provocado esta vez por el propio código. Por la misma razón,statuslleva el código y no el mensaje. - Los cubos se eligen pensando en el servicio. Para una API que debería responder en menos de 250 ms, la lista de arriba tiene cubos de sobra alrededor de esa cifra. Para un proceso por lotes que tarda minutos no serviría: todas las peticiones caerían en el último cubo y el percentil no diría nada.
- Las peticiones en curso como gauge.
INFLIGHTsube al empezar una petición y baja al terminar. Si crece y no baja, la API se está atascando: es una señal que avisa de un atasco antes de que empiecen los errores.
Por qué un solo worker. La API la ejecuta gunicorn, el servidor que se usa para poner en producción aplicaciones web de Python. gunicorn puede arrancar varios procesos (workers) que atienden peticiones en paralelo, pero cada proceso tiene su propia memoria y, por tanto, sus propios contadores: cada scrape lo atendería uno distinto y Prometheus vería valores que suben y bajan sin sentido. En el curso gunicorn arranca con un solo worker. Cuando hacen falta varios, la librería tiene un modo multiproceso que junta los contadores de todos (la variable de entorno PROMETHEUS_MULTIPROC_DIR y el MultiProcessCollector, en la documentación de prometheus_client).
Nombres y etiquetas: convenciones¶
Por qué importan los nombres. Prometheus deja poner cualquier nombre, pero si cada aplicación los pone a su manera, nadie sabe qué buscar, las consultas se vuelven ilegibles y los dashboards que comparte la comunidad dejan de funcionar. Por eso el propio proyecto publica unas convenciones, las de Metric and label naming:
| Regla | Bien | Mal |
|---|---|---|
| Prefijo con el nombre de la aplicación o del subsistema | app_http_requests_total |
requests |
| Unidad base en el nombre, sin prefijos SI | _seconds, _bytes, _ratio |
_ms, _kb, _percent |
Los counters terminan en _total |
app_errors_total |
app_error_count |
| Una métrica mide una cosa; las dimensiones van en etiquetas | app_http_requests_total{status="500"} |
app_http_500_total |
| Etiquetas de cardinalidad acotada | method, status, route plantilla |
user_id, request_id, URL completa |
| snake_case en minúsculas | app_db_reachable |
appDbReachable |
La cuenta de la cardinalidad. Antes de añadir una etiqueta hay que hacer siempre la misma cuenta: el número de series de una métrica es el producto del número de valores de cada etiqueta. method (5 valores) × route (20) × status (8) son 800 series para el contador de peticiones, y es asumible. Si se añadiera client_ip, con miles de valores, se pasaría del millón.
Las dos consultas de la sesión¶
Con la API instrumentada, las preguntas del principio se responden con dos consultas en PromQL:
# p95 de latencia en segundos, de todas las rutas juntas
histogram_quantile(0.95, sum by (le) (rate(app_http_request_duration_seconds_bucket[5m])))
# porcentaje de respuestas 5xx
sum(rate(app_http_requests_total{status=~"5.."}[5m])) / sum(rate(app_http_requests_total[5m])) * 100
- El p95.
rate(...[5m])convierte cada cubo del histograma en una velocidad: cuántas peticiones por segundo han caído por debajo de cada umbral en los últimos cinco minutos.sum by (le)junta las rutas y conserva solo la etiqueta del cubo, que es la que necesitahistogram_quantile()para estimar el percentil. El resultado sale en segundos, porque el histograma mide en segundos. - El porcentaje de errores. Divide la velocidad de las respuestas 5xx (
status=~"5.."es una expresión regular: cualquier código que empiece por 5) entre la velocidad de todas las respuestas. Los 4xx no cuentan: un 404 es un error del cliente, que ha pedido algo que no existe, y no dice nada de la salud del servicio. Un 500 o un 503 sí: el servicio no ha sabido responder.
Exporters: lo que no se puede instrumentar¶
El problema. La API se ha podido instrumentar porque su código es del curso. PostgreSQL, nginx o el propio sistema operativo del host no se pueden modificar para añadirles una librería de Prometheus, pero también hay que vigilarlos. La solución es el patrón exporter: un programa auxiliar que pregunta al software por la vía que este ya ofrece (una consulta SQL, una página de estado, unos ficheros del sistema) y traduce lo que obtiene al formato de /metrics. Corre al lado de lo que vigila, normalmente como un contenedor más del mismo compose.
postgres_exporter (puerto 29187) es el exporter de PostgreSQL. PostgreSQL ya lleva sus propias estadísticas en unas vistas internas (pg_stat_database, pg_stat_activity, pg_locks...), y el exporter las consulta y las publica. Para hacerlo necesita entrar en la base de datos, y aquí se aplica el mínimo privilegio: nunca se le da el usuario postgres, que lo puede todo, sino uno propio, monitor, con el rol pg_monitor, que PostgreSQL trae desde su versión 10 justamente para dar permiso de lectura de estadísticas y nada más.
CREATE USER monitor WITH PASSWORD 'cámbiame';
GRANT pg_monitor TO monitor;
GRANT CONNECT ON DATABASE servicio TO monitor;
GRANT SELECT ON items TO monitor; -- solo para la consulta propia de queries.yaml
La contraseña de monitor no se escribe en el compose: va en un secret de compose, un fichero que Docker monta dentro del contenedor en /run/secrets/ y que no aparece en docker inspect ni en el repositorio. Con --web.listen-address=:29187, el exporter escucha en el puerto de la franja en lugar de en su 9187 de fábrica, y se publica en el mismo número.
# junto al servicio db, en el mismo compose del servicio
postgres_exporter:
image: quay.io/prometheuscommunity/postgres-exporter:latest
command: ["--extend.query-path=/etc/queries.yaml", "--web.listen-address=:29187"]
environment:
DATA_SOURCE_URI: "db:5432/servicio?sslmode=disable"
DATA_SOURCE_USER: monitor
DATA_SOURCE_PASS_FILE: /run/secrets/pgx_pass
volumes:
- ./queries.yaml:/etc/queries.yaml:ro
secrets: [pgx_pass]
ports: ["29187:29187"]
depends_on: [db]
# y al final del fichero
secrets:
pgx_pass:
file: ./pgx_pass.txt # la contraseña de monitor; va en .gitignore
Qué se mira de PostgreSQL. pg_up vale 1 si el exporter consigue conectar y 0 si no, así que es la primera métrica que dice si la base de datos responde. Además, pg_stat_database_numbackends (las conexiones abiertas, para compararlas con el máximo permitido), pg_stat_database_xact_commit y xact_rollback (transacciones confirmadas y deshechas, con rate()), pg_stat_database_blks_hit frente a blks_read (cuántas lecturas se sirven desde la caché de PostgreSQL y cuántas tienen que ir a disco), pg_locks_count y pg_database_size_bytes.
Medir lo que importa al negocio. Las estadísticas internas dicen si PostgreSQL está sano, no si la aplicación hace lo que debe. Para eso el exporter admite consultas propias, en un fichero YAML pasado con --extend.query-path: cualquier cosa que se pueda preguntar con SQL se convierte en una métrica. La de abajo cuenta las filas de items, la tabla de la API. Cada métrica se llama como la clave del fichero más el nombre de la columna, así que la clave empieza por pg_ para que se agrupe con las demás. Ese mecanismo está marcado como obsoleto en las versiones actuales del exporter, aunque sigue funcionando; si desaparece, la alternativa mantenida es sql_exporter, que hace lo mismo para cualquier base de datos.
# queries.yaml: sale como pg_items_filas (un gauge, así que sin _total)
pg_items:
query: "SELECT count(*) AS filas FROM items"
metrics:
- filas: { usage: GAUGE, description: "Filas de la tabla items de la API" }
Los otros dos exporters del curso. node_exporter (puerto 29100) es el exporter del propio host: CPU, memoria, disco y red de la máquina entera, leídos de /proc y /sys. Completa a cAdvisor, que solo ve los contenedores: un disco lleno o una VM sin memoria no se ven desde un contenedor. Viene instalado en la plantilla de la que salen app01 y mon01 (paquete prometheus-node-exporter, que la plantilla ya deja escuchando en el 29100), así que no hay que desplegarlo. nginx-prometheus-exporter (de fábrica, en el 9113) hace lo mismo con nginx a partir de su página de estado, stub_status, pero da poco: conexiones activas y en espera, peticiones totales y si nginx responde, sin códigos de respuesta ni latencias por ruta. Por eso en el curso lo que se sabe de nginx sale de su log de acceso. Cómo se activa stub_status y se arranca el exporter está en Para ampliar. Ni node_exporter ni el log de nginx se usan todavía: los dos entran en la monitorización el 15 de octubre, con el traslado a las VM.
A1.3 Instrumentar la aplicación (sesión 3)¶
Objetivo Que el servicio responda a las tres preguntas que las métricas de recursos no saben contestar (cuántas peticiones llegan, cuántas fallan y cuánto tardan) y que PostgreSQL publique sus estadísticas sin tocar su código. Al terminar, Prometheus recoge la API y la base de datos, y sabes calcular el p95 de latencia y el porcentaje de errores y comprobar que cuentan lo que de verdad ha pasado.
Antes de empezar
- Última sesión sin VM: todo sigue en tu puesto, con las tres pilas de la A1.2 en la red
obs. - La API es Python con Flask, servida con gunicorn y un solo worker. PostgreSQL es el servicio
dbdel mismo compose: se entra condocker compose exec db psql -U postgres -d servicio. - Lo explicado antes: tipos de métrica, Python con prometheus_client, nombres y etiquetas, las dos consultas de la sesión y exporters.
Pasos
Vas a hacer que hablen dos piezas que hasta hoy estaban calladas: primero la API, a la que añades código que cuenta sus peticiones, y después PostgreSQL, que no se puede tocar y por eso necesita un exporter al lado. Cuando las dos publiquen sus métricas, las conectas a Prometheus con las mismas etiquetas que ya usa cAdvisor y, al final, provocas tráfico bueno y malo para comprobar que las consultas cuentan la historia que tú sabes que ha pasado.
-
Haz que la API cuente sus peticiones. Añade a la API el módulo de Python con prometheus_client, con los mismos nombres de métrica y etiquetas, y engánchalo al crear la aplicación. La API tiene que servir sus métricas en su propio puerto, el 29102, publicado en el host. Antes de seguir, mira ese
/metrics: tienen que aparecer el contador de peticiones y el histograma de duración, conrouteen forma de plantilla y no con la URL de cada petición.Pista:
prometheus_clienttiene que estar enapi/requirements.txt, y eso obliga a reconstruir la imagen de la API. Deja gunicorn con un solo worker. -
Dale a PostgreSQL un exporter que solo pueda leer. PostgreSQL no se puede instrumentar, así que pon a su lado postgres_exporter, en el mismo compose del servicio y publicado en el 29187. Primero crea en la base de datos el usuario
monitorcon permisos solo de lectura, y después configura el exporter con ese usuario y con la consulta propia que cuenta las filas deitems, que es la prueba de que se puede medir cualquier cosa que se pueda preguntar con SQL. El exporter funciona si publicapg_upcon valor 1.Pista: el SQL, el servicio con su bloque
secretsyqueries.yamlestán completos en Exporters.pgx_pass.txtes una contraseña: va al.gitignore. -
Conéctalos a Prometheus con las etiquetas del curso. Añade a Prometheus los jobs
appypostgres, con la etiquetahost: puestoy conservice: appyservice: dbrespectivamente, y comprueba que los dos están en UP. Son las mismas etiquetas que ya llevan las series de cAdvisor, y eso es lo que te permitirá cruzar en un mismo panel, o en una misma alarma, lo que consume un servicio con lo que hace. -
Provoca tráfico y comprueba que las métricas cuentan lo que ha pasado. Genera un par de minutos de tráfico variado: peticiones correctas, alguna a una ruta que no existe y medio minuto con la base de datos parada. Después calcula en Prometheus el p95 de latencia y el porcentaje de respuestas 5xx con las dos consultas de la sesión y compara lo que dicen con lo que tú hiciste: tienes que poder señalar en el resultado cada cosa que provocaste, y explicar por qué las peticiones a la ruta inexistente no suben el porcentaje de errores.
Comprobación app y postgres en UP en Targets; app_http_requests_total lleva route en forma de plantilla (/items/<int:id>, nunca /items/48213); pg_up vale 1 y la métrica personalizada aparece con prefijo pg_; el p95 sale en segundos y el porcentaje de 5xx sube solo mientras la base de datos está parada.
Para practicar Guarda en tests/evidence/ut1/a1.3.md el código de la instrumentación, queries.yaml, los jobs de prometheus.yml y las dos consultas con su resultado: lo retoma la práctica evaluable. Haz además un commit propio del código en el repositorio servicio, que el 15 de octubre viaja con la carpeta a app01.
Si te sobra tiempo Añade el gauge app_db_reachable (1 si la última consulta a PostgreSQL funcionó) y comprueba que baja a 0 al parar la base de datos.
Sesión 4 · Logs estructurados¶
Prometheus ya sabe cuánto consume cada contenedor y cuántas peticiones fallan. Pero una métrica dice que algo pasa, no por qué: el porcentaje de errores sube, y para saber qué error es, en qué petición y con qué mensaje, hay que leer los logs. Hoy se monta el tercero de los cuatro flujos: los logs de la API y de nginx salen de app01 y llegan a mon01, escritos de forma que una máquina pueda filtrarlos y seguir una petición de un servicio a otro.
Es también el día del traslado. Ayer la asignatura de Despliegue clonó las dos VM del laboratorio, app01 y mon01, y desde hoy el servicio y su monitorización viven en máquinas distintas, como en cualquier empresa: si se cae la máquina del servicio, la que la vigila sigue en pie para contarlo. La hoja empieza llevando las tres pilas del puesto a las VM y después construye el camino de los logs en el orden de los apartados de abajo: limitar los ficheros de log de Docker, hacer que la API y nginx escriban JSON con un identificador de petición, guardar los logs en Loki y enviarlos con Promtail.
Logs¶
Por qué no basta con docker logs. docker logs enseña lo que ha escrito un contenedor, pero tiene los mismos límites que docker stats: hay que entrar en la máquina, solo se ve un contenedor cada vez y, si el contenedor se borra y se vuelve a crear, sus logs desaparecen con él. Para mantener un servicio hacen falta los logs de todos los contenedores en un mismo sitio, fuera de la máquina que los produce, guardados unos días y con forma de buscar en ellos.
Por qué un sistema distinto de Prometheus. Los logs son texto, no números: no se pueden muestrear cada 15 s ni guardar como series, hay que guardar cada línea. Por eso tienen su propio camino, con tres piezas: Docker recoge lo que escribe cada contenedor, Promtail lo lee, le pone etiquetas y lo envía, y Loki lo guarda y lo consulta. Los apartados siguen ese orden, más uno intermedio que no es una herramienta sino una forma de escribir: conseguir que la aplicación escriba logs que una máquina sepa leer.
El driver de logs de Docker y por qué limitarlo¶
Qué es el driver. Un programa dentro de un contenedor no escribe sus logs en ningún fichero: los escribe por su salida estándar y su salida de error (stdout y stderr), como cualquier programa de terminal. Docker captura esas dos salidas y se las entrega a un driver de logs, el componente que decide dónde se guardan. Es lo que hace que docker logs funcione. El driver por defecto es json-file: escribe cada línea como un objeto JSON ({"log":"...","stream":"stdout","time":"2026-10-13T09:12:44.120Z"}) en /var/lib/docker/containers/<id>/<id>-json.log. Sin configuración adicional ese fichero no rota nunca. Una API que escriba 200 líneas por segundo de 300 bytes genera 5 GB al día.
La causa más común de \"se ha caído el servidor\"
En dos semanas app01 se queda sin disco, Docker no puede escribir y el contenedor se para o se cuelga.
No hay alarma de aplicación que avise de esto: el servicio está bien hasta que el disco se acaba. Conviene limitar
el driver desde el primer despliegue, no cuando pase.
flowchart LR
APP["<b>Contenedor</b><br><small>stdout y stderr</small>"]:::pieza
DRV["<b>Driver json-file</b><br><small>una línea JSON por mensaje</small>"]:::pieza
FIC["<b><id>-json.log</b><br><small>sin configurar, no rota nunca</small>"]:::riesgo
LLENO(["<b>Disco lleno</b><br><small>Docker no puede escribir · el contenedor se para</small>"]):::riesgo
LIM["<b>max-size y max-file</b><br><small>en /etc/docker/daemon.json</small>"]:::act
OK(["<b>Ocupación acotada</b>"]):::ok
APP --> DRV --> FIC --> LLENO
DRV -- "con límite" --> LIM --> OK
classDef act fill:#ea580c22,stroke:#ea580c,stroke-width:1.5px
classDef pieza fill:#64748b22,stroke:#64748b,stroke-width:1.5px
classDef dato fill:#2563eb22,stroke:#2563eb,stroke-width:1.5px
classDef infra fill:#a1a1aa14,stroke:#a1a1aa,stroke-width:1.5px
classDef ok fill:#16a34a22,stroke:#16a34a,stroke-width:1.5px
classDef riesgo fill:#dc262622,stroke:#dc2626,stroke-width:1.5px
El log es el único recurso del contenedor que crece sin que nadie lo pida. Acotarlo es una línea de configuración.
El límite se pone en /etc/docker/daemon.json, con los mismos valores en app01 y en mon01 (JSON no admite comentarios, así que el fichero lleva solo esto):
Tras cambiarlo, systemctl restart docker, y ojo: solo aplica a contenedores creados después. Los existentes conservan su driver y sus opciones hasta que se recrean (docker compose up -d --force-recreate). Con esos valores el techo por contenedor son 250 MB. La rotación es por tamaño, no por tiempo: si un contenedor apenas escribe, sus logs de hace meses siguen ahí.
Hay otros drivers (local, journald, el plugin loki y none), pero ninguno cambia la recomendación del curso: json-file con límites y un agente que lea los ficheros, añada contexto, reintente y no toque el demonio. Qué hace cada uno y por qué aquí no sirve está en Para ampliar.
Logs estructurados: JSON con request_id¶
El problema del texto libre. docker logs funciona con cualquier cosa que la aplicación escriba, pero un log que dice Error procesando pedido 4821 para cliente 77 solo lo entiende una persona que lo lea entero. Una máquina no sabe que 4821 es un pedido ni que esa línea es un error, así que no puede contar cuántos errores hubo en una hora ni filtrar los de una ruta. Para filtrar en Loki por nivel, contar errores por ruta o seguir una petición desde nginx hasta la base de datos, la aplicación tiene que escribir una línea JSON por evento con campos fijos: ts (fecha en formato RFC 3339, como 2026-10-13T09:12:44.120Z, con zona horaria y milisegundos), level, msg, logger, y los que aporten contexto: request_id, method, route, status, duration_s (en segundos, la misma unidad base que las métricas), user (id, nunca nombre ni email).
Por qué cada línea lleva un request_id. Una petición de un usuario pasa por dos servicios, nginx y la API, y cada uno escribe sus propias líneas. Sin algo que las una, encontrar en los logs de la API la línea que corresponde a un error que vio nginx es buscar por la hora y adivinar. El request_id es lo que permite esa correlación. Lo genera nginx con la variable $request_id (32 caracteres hexadecimales, uno por petición), lo pasa a la API en una cabecera, la API lo incluye en todas sus líneas de log y lo devuelve al cliente en la respuesta. Cuando un usuario dice "me ha dado error a las 10:14", se busca ese id y aparece la petición completa. Hasta el 27 de noviembre nginx y la API son dos contenedores del mismo compose en app01, y la correlación es entre dos servicios. Ese día el proxy pasa a web01, y el mecanismo sigue siendo el mismo, solo que entre dos hosts.
# nginx del servicio: en el fichero de /etc/nginx/conf.d/, que se incluye dentro del bloque http
proxy_set_header X-Request-ID $request_id;
add_header X-Request-ID $request_id always;
log_format json escape=json '{"ts":"$time_iso8601","level":"info","msg":"access",'
'"request_id":"$request_id","method":"$request_method","uri":"$request_uri",'
'"status":$status,"bytes":$body_bytes_sent,"duration_s":$request_time,'
'"upstream":"$upstream_addr","client":"$remote_addr"}';
access_log /dev/stdout json;
En un contenedor el log de acceso va a stdout, no a un fichero: así lo recoge el driver de logs de Docker y lo lee el mismo Promtail que lee la API, sin montar ningún volumen de logs. En la imagen oficial de nginx /var/log/nginx/access.log ya es un enlace a stdout; al cambiar el formato hay que escribir la ruta /dev/stdout de forma explícita.
En la API, con Python, un formateador JSON en la librería logging estándar y un filtro que inyecte el id que llega en la cabecera (si no llega, se genera uno):
import json, logging, uuid, sys
from flask import request, g
class JsonFormatter(logging.Formatter):
def format(self, record):
d = {
"ts": self.formatTime(record, "%Y-%m-%dT%H:%M:%S") + f".{int(record.msecs):03d}Z",
"level": record.levelname.lower(),
"logger": record.name,
"msg": record.getMessage(),
"request_id": getattr(g, "request_id", None) if request else None,
}
if record.exc_info:
d["exc"] = self.formatException(record.exc_info)
return json.dumps(d, ensure_ascii=False)
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(JsonFormatter())
logging.basicConfig(level=logging.INFO, handlers=[handler])
@app.before_request
def _rid():
g.request_id = request.headers.get("X-Request-ID", uuid.uuid4().hex)
@app.after_request
def _rid_out(resp):
resp.headers["X-Request-ID"] = g.request_id
return resp
El formateador convierte cada mensaje de la librería logging en una línea JSON, y los dos hooks de Flask, los mismos que usa la instrumentación de la API, recogen el id al empezar la petición y lo devuelven en la respuesta. Tres normas para que esto funcione después en Loki: una línea por evento (nada de trazas multilínea sueltas; van dentro del campo exc), siempre a stdout (no a un fichero dentro del contenedor, que nadie va a leer y que llena el disco), y el mismo nombre de campo en todos los servicios (request_id, no requestId en uno y rid en otro).
Promtail¶
Por qué hace falta un agente. Los logs se escriben en app01 y se guardan en mon01: alguien tiene que leerlos en una máquina y llevarlos a la otra. Ese alguien es un agente, un programa pequeño que corre al lado de los contenedores. Al revés que las métricas, que las va a buscar Prometheus, los logs los empuja el agente: un log es una sucesión de líneas que no se puede perder, y es el agente quien sabe hasta dónde ha leído y quién tiene que reintentar si mon01 no contesta.
Promtail es el agente de Grafana Labs para enviar logs a Loki. Grafana lo ha declarado al final de su vida en favor de Grafana Alloy, pero se usa igualmente porque su configuración es YAML corto, se entiende en una sesión y sus conceptos se trasladan uno a uno a Alloy. Qué cambia con Alloy y cómo se pasa de uno a otro está en Para ampliar.
Promtail hace cuatro cosas en orden: descubre de dónde leer, etiqueta cada origen con relabel (reglas que copian, renombran o descartan etiquetas), procesa cada línea con un pipeline y envía lotes a Loki por HTTP, guardando en un fichero de posiciones hasta dónde ha leído.
# /opt/agentes/promtail.yml en app01
server:
http_listen_port: 29080
positions:
filename: /var/lib/promtail/positions.yaml
clients:
- url: http://<mon01>:23100/loki/api/v1/push # la IP de mon01 en vmbr0
external_labels:
host: app01
env: dev
batchwait: 1s
batchsize: 1048576
backoff_config:
min_period: 500ms
max_period: 5m
max_retries: 10
timeout: 10s
scrape_configs:
- job_name: docker
docker_sd_configs:
- host: unix:///var/run/docker.sock
refresh_interval: 5s
relabel_configs:
- source_labels: [__meta_docker_container_name]
regex: '/(.*)'
target_label: container
- source_labels: [__meta_docker_container_label_com_docker_compose_service]
target_label: service
pipeline_stages:
- match:
selector: '{service="app"}'
stages:
- json:
expressions:
level: level
ts: ts
request_id: request_id
- timestamp:
source: ts
format: RFC3339Nano
- labels:
level:
- structured_metadata:
request_id:
- match:
selector: '{service="app"} !~ "^\\{"'
stages:
- static_labels:
malformed: "true"
Por partes:
docker_sd_configs. En lugar de leer los ficheros *-json.log (que también se puede, con static_configs y __path__: /var/lib/docker/containers/*/*.log), Promtail pregunta al demonio por el socket qué contenedores hay y lee sus logs por la API de Docker. Ventaja: obtiene el nombre y las etiquetas del contenedor sin tener que parsear rutas, y sigue a contenedores nuevos a los 5 s de arrancar. Necesita acceso al socket, así que el contenedor de Promtail lo monta en solo lectura y corre como root o con el gid del grupo docker.
relabel_configs. Las meta-etiquetas __meta_docker_* desaparecen al final del relabel; solo sobrevive lo que se copie a una etiqueta sin guiones bajos iniciales. Con estas dos reglas, más las external_labels del cliente, cada flujo queda como {host="app01", env="dev", container="servicio-app-1", service="app"}, y el pipeline le añade level. Son las etiquetas de todo el curso: host, service, container y level, siempre en minúsculas, más env para distinguir entornos. Las etiquetas en Loki son índice, y cada combinación distinta de valores es un stream con sus propios chunks (los bloques comprimidos en que Loki guarda el texto): unas pocas etiquetas con pocos valores cada una está bien; meter request_id o la ruta como etiqueta destroza Loki igual que destroza Prometheus. Para campos de cardinalidad alta que conviene filtrar rápido, Loki 3 tiene structured metadata (lo que hace la etapa structured_metadata): se guarda junto a la línea, no en el índice, y se consulta con | request_id="...".
pipeline_stages. Se ejecutan por línea, en orden. json extrae campos a variables internas; timestamp usa el ts de la aplicación como marca de tiempo de la entrada en vez de la hora en que Promtail la leyó (importa cuando hay retraso: si Promtail estuvo 3 minutos sin poder enviar, las líneas se guardan con su hora real, no con la de reenvío); labels promociona level a etiqueta (5 valores posibles, aceptable y muy útil para {service="app", level="error"}). El segundo match marca las líneas que no son JSON (una traza de arranque, un print olvidado) para encontrarlas y corregirlas. Con format: RFC3339Nano Go acepta también RFC 3339 sin nanosegundos; si la API escribe otra cosa, el formato se expresa con la fecha de referencia de Go (2006-01-02 15:04:05.000).
positions. El fichero positions.yaml guarda, por origen, el último offset (o el último timestamp leído, con docker_sd) enviado con éxito. Si Promtail se reinicia, continúa desde ahí. Si el fichero está en /tmp dentro del contenedor y no en un volumen, cada reinicio de Promtail reenvía todo lo que Docker conserve del contenedor (hasta 250 MB con los límites de arriba), y Loki no deduplica: los count_over_time de la verificación salen multiplicados. Volumen persistente siempre.
clients. batchwait y batchsize deciden cuándo sale un lote: al segundo o al llegar a 1 MB, lo que ocurra antes. backoff_config es lo que salva en un corte de red: si el push falla, reintenta esperando 0,5 s, luego 1, 2, 4, 8... hasta 5 minutos entre intentos, y hasta 10 intentos por lote. La suma de las esperas con esos valores son unos 8,5 minutos: un corte de 3 minutos no pierde nada; uno más largo que esa suma sí, y se ve en promtail_dropped_entries_total. Ese es el límite de un corte: lo pone la suma de reintentos. max-size y max-file limitan otra cosa, cuánto puede reenviar Promtail tras un reinicio si el fichero de posiciones se queda atrás.
# en /opt/agentes/compose.yml
promtail:
image: grafana/promtail:3.5.3
container_name: promtail
command: -config.file=/etc/promtail/promtail.yml
volumes:
- ./promtail.yml:/etc/promtail/promtail.yml:ro
- /var/run/docker.sock:/var/run/docker.sock:ro
- promtail-positions:/var/lib/promtail
ports:
- "29080:29080"
restart: unless-stopped
volumes:
promtail-positions:
Promtail expone sus propias métricas en el puerto de http_listen_port, en :29080/metrics, y hay que hacerles scrape desde mon01: promtail_sent_entries_total, promtail_dropped_entries_total, promtail_request_duration_seconds_count{status_code!="204"} (pushes fallidos) y promtail_targets_active_total. Quien vigila al vigilante.
sequenceDiagram
participant D as dockerd (app01)
participant P as Promtail
participant L as Loki (mon01)
D->>P: líneas nuevas del contenedor app (API /containers/{id}/logs?follow)
P->>P: relabel + pipeline (json, timestamp, labels)
P->>P: acumula lote (1 s o 1 MB)
P->>L: POST /loki/api/v1/push (snappy + protobuf)
alt Loki responde 204
L-->>P: 204 No Content
P->>P: avanza positions.yaml
else Loki no responde o 5xx
L--xP: timeout / error
P->>P: espera backoff (0,5 s, 1, 2, ... 5 min) y reintenta hasta 10 veces
P->>P: si agota reintentos: promtail_dropped_entries_total +N
end
Promtail sí tiene lote y reintentos, al revés que el scrape de Prometheus: solo avanza positions.yaml cuando Loki confirma el envío.
Loki¶
Qué es y por qué este y no otro. Loki es la base de datos de logs de Grafana Labs, y se suele describir como "Prometheus para logs": mismo modelo de etiquetas, misma filosofía de indexar poco. La alternativa clásica, Elasticsearch, indexa cada palabra de cada línea: busca muy rápido cualquier texto, pero necesita mucha más memoria y disco de los que tiene mon01. Loki, en cambio, solo indexa las etiquetas del stream y el rango de tiempo; el texto se guarda comprimido en chunks y se busca por fuerza bruta dentro de los chunks que las etiquetas seleccionan. Por eso es barato y por eso las consultas sin etiquetas ({host=~".+"} |= "error" en 7 días) son lentas: hay que descomprimir todo.
En producción Loki se reparte en varios procesos, cada uno con su papel; aquí se ejecuta entero en uno (-target=all, el modo "monolítico") con el disco local de mon01. Ese reparto interno en componentes está descrito en Para ampliar. De la configuración conviene fijarse en limits_config (cuánto acepta y cuánto guarda) y en compactor (quién borra lo viejo); el resto es fijo:
# /opt/monitoring/loki/loki.yml en mon01
auth_enabled: false
server:
http_listen_port: 23100
common:
path_prefix: /loki
storage:
filesystem:
chunks_directory: /loki/chunks
rules_directory: /loki/rules
replication_factor: 1
ring:
kvstore:
store: inmemory
schema_config:
configs:
- from: 2026-10-01
store: tsdb
object_store: filesystem
schema: v13
index:
prefix: index_
period: 24h
limits_config:
retention_period: 168h
reject_old_samples: true
reject_old_samples_max_age: 168h
allow_structured_metadata: true
ingestion_rate_mb: 8
ingestion_burst_size_mb: 16
max_global_streams_per_user: 5000
compactor:
working_directory: /loki/compactor
delete_request_store: filesystem
retention_enabled: true
compaction_interval: 10m
retention_delete_delay: 2h
La retención de 7 días (168h) se aplica cada 10 minutos por el compactor, y con la demora de 2 h. Con el servicio del curso, Loki ocupa entre 2 y 5 GB de disco con esa retención. En una empresa son habituales retenciones de 30 a 90 días por normativa, y ahí el disco local ya no vale: se pasa a un object store (un almacén de ficheros por HTTP, como MinIO o S3) cambiando object_store y nada más. reject_old_samples_max_age es lo que rechaza líneas con reloj atrasado.
LogQL (el lenguaje de consulta de Loki, primo de PromQL) para comprobar que las cosas llegan, que es lo que hace falta en esta unidad:
{host="app01"} # todo lo que envía app01, ¿llegan las etiquetas?
{service="app", level="error"} # errores de la API, con la etiqueta level de Promtail
{service="app"} | request_id="3f9a..." # una petición completa (structured metadata)
{service=~"app|nginx"} |= "3f9a1c..." # la misma petición en los dos servicios
count_over_time({service="app"}[5m]) # líneas en 5 min, para comparar con el origen
sum by (level) (rate({service="app"}[1m])) # líneas/s por nivel
{service="app", malformed="true"} # lo que no era JSON
Desde línea de comandos, logcli (se instala en el puesto o en mon01 y habla con Loki por HTTP):
export LOKI_ADDR=http://<mon01>:23100
logcli labels # qué etiquetas existen
logcli labels service # valores de una etiqueta
logcli query --since=5m '{service="app"}' --limit 20
logcli query --since=5m 'count_over_time({service="app"}[5m])'
logcli series '{host="app01"}' # streams que tiene Loki de app01: cardinalidad
En Grafana se añade Loki como fuente de datos (http://loki:23100 si está en el mismo compose que Grafana, o http://<mon01>:23100) y en Explore (la pantalla de consultas libres de Grafana) se elige la fuente y se escriben las mismas consultas. Explore con la fuente Prometheus y la fuente Loki en pantalla partida, el mismo rango de tiempo, es la forma más rápida de correlacionar un pico de latencia con los errores de ese momento.
A1.4 Logs estructurados (sesión 4)¶
Objetivo El laboratorio trasladado a sus dos máquinas, el servicio en app01 y la monitorización en mon01, y los logs de la API y de nginx llegando a Loki como JSON, con las etiquetas host, service, container y level y con un request_id común. Al terminar puedes coger cualquier petición y ver en Grafana Explore lo que dejó escrito en los dos servicios por los que pasó, que es lo primero que se hace cuando un usuario dice "me ha dado un error".
Antes de empezar
- Desde hoy se trabaja en app01 y mon01, que Despliegue clonó ayer de la plantilla 9000: en vmbr0 con IP por DHCP, con el usuario
ops(tu clave pública ysudosin contraseña), Docker con compose y node_exporter en el 29100. mon01 tiene 3 GB de RAM. Llegan sin ningún servicio. - web01 y db01 no se usan todavía: el proxy y la base de datos siguen siendo los servicios
nginxydbdel compose, ahora en app01. Entre las dos VM no hay cortafuegos. - Pon nombre a las dos VM en el
/etc/hostsde tu puesto y de mon01. En lo que sigue,<app01>y<mon01>son su IP, o su nombre allí donde vale ese fichero: en la shell y en el navegador sí; en lo que leen los contenedores (targetsde Prometheus,clientsde Promtail) no, porque un contenedor no lee el/etc/hostsde su host. - Lo explicado antes: el driver de logs y por qué limitarlo, logs JSON con request_id, Promtail y Loki.
Pasos
La hoja tiene dos partes. En la primera trasladas lo que ya funciona en tu puesto a las dos VM y compruebas que las métricas que ya recogías siguen llegando, ahora cruzando la red. En la segunda construyes el camino de los logs en el orden en que viaja una línea: primero haces que nginx y la API escriban lo que hace falta, después montas el destino, Loki, y al final el agente que los une, Promtail. Montar Loki antes que Promtail tiene una razón: así, cuando arranques el agente, sus errores te dirán si el problema está en él y no en un destino que todavía no existe.
-
Lleva el laboratorio a sus dos máquinas. Copia las tres pilas del puesto a las VM, con su historia de Git: el servicio y los agentes a app01, la monitorización a mon01. Desde hoy esas son las copias de trabajo y las del puesto dejan de usarse. Repasa antes en los tres compose que cada puerto publicado es el de la franja y el mismo en que escucha el servicio dentro de su contenedor, porque desde hoy Prometheus va a buscar cada target a otra máquina por ese número. Y antes de levantar nada en app01, limita sus ficheros de log como se explica en El driver de logs de Docker (50 MB y 5 ficheros): es el momento, porque el límite solo se aplica a los contenedores que se crean después.
Pista: la red
obshay que crearla otra vez en cada VM, y la base de datos de app01 nace vacía, así que el usuariomonitorde la A1.3 se vuelve a crear. El número de cada servicio está en las convenciones del laboratorio; si se publica sin su opción de escucha, el servicio sigue en su puerto de fábrica y no contesta. -
Haz que Prometheus encuentre cada pieza en su nuevo sitio. Hasta ayer Prometheus llamaba a cada target por su nombre dentro de la red de Docker del puesto; desde hoy los targets están en otra máquina, así que se llaman por la IP de app01 y el puerto publicado, que es el mismo número de antes. Cambia en los jobs
app,postgresycadvisorel nombre por esa IP y su etiqueta ahost: app01, para que cada serie diga de qué máquina viene. Añade además el jobnode, con el node_exporter de las dos VM, que da lo que cAdvisor no ve (el disco o la memoria de la máquina entera), y los depromtailyloki, que vigilan la propia cadena de logs. Al terminar,app,postgres,cadvisory los dos denodetienen que estar en UP;lokiypromtailseguirán en DOWN hasta los pasos 4 y 5.Pista: el
metric_relabel_configsde cAdvisor de la A1.2 se conserva sin tocar. El puerto de cada pieza está en la tabla de puertos del laboratorio, y Loki se llama por su nombre porque comparte compose con Prometheus. -
Haz que una petición deje el mismo rastro en nginx y en la API. nginx tiene que generar un
request_idpara cada petición, pasárselo a la API y escribir su log de acceso en JSON por stdout. La API tiene que escribir sus logs en JSON con ese mismo identificador. Las piezas de los dos lados están en Logs estructurados: JSON con request_id. Cuando esté, una petición tiene que devolver la cabeceraX-Request-IDy el mismo valor tiene que aparecer en el log de los dos contenedores.Pista: en nginx,
log_formatyaccess_logvan fuera del bloqueserver, yproxy_set_headeryadd_header, dentro. Para que cada petición deje una línea en la API, añade uninfo("request done")en elafter_request. -
Monta el destino: Loki en mon01. Añade Loki al compose de monitorización con el
loki.ymlde Loki, publicado en el 23100, y sus datos en un volumen llamadoloki, para que los logs sobrevivan a que se recree el contenedor. Con el proyectomonitoring, el volumen queda comomonitoring_loki, que es el nombre que mira la verificación de la sesión 6. Loki está listo cuando su ruta/readyrespondeready.Pista: imagen
grafana/loki:3.5.3, arrancada con-config.file=apuntando al fichero montado. Tarda unos 15 s en estar listo. -
Une los dos extremos con Promtail. Despliega Promtail en el compose de agentes de app01 con la configuración de Promtail, enviando a la IP de mon01. Promtail descubre solo los contenedores de la máquina, nginx incluido, así que no hay que decirle uno por uno qué leer. Sus propios logs dicen si funciona: no tiene que haber errores al leer el socket de Docker ni respuestas 4xx de Loki.
Pista: para leer el socket de Docker necesita
user: rooten el servicio, ogroup_addcon el gid del grupodocker. -
Sigue una petición de punta a punta. En Grafana, añade Loki como fuente de datos. Haz una petición al servicio, toma el
request_idde la cabecera de la respuesta y búscalo en Explore: tienen que salir sus dos líneas, la de nginx y la de la API. Es la prueba de que funciona la cadena entera, desde lo que escribe cada contenedor hasta la consulta en mon01.
Comprobación En Targets de mon01, app, postgres, cadvisor y node en UP, y las series container_* siguen llevando service; docker inspect muestra los límites del driver en app01; {host="app01"} devuelve líneas con container y service, las de la API también con level, y aparecen tanto service="app" como service="nginx" (las de nginx llegan enteras, todavía sin parsear: eso es lo primero de la sesión 5); la consulta por request_id devuelve dos líneas (nginx y API) con la misma marca de tiempo salvo milisegundos; {service="app", malformed="true"} está vacío o solo tiene líneas de arranque.
Para practicar Guarda en tests/evidence/ut1/a1.4.md la captura de Targets en mon01, promtail.yml completo, la configuración de nginx, una captura de Explore con la petición correlada en los dos servicios y la salida de docker inspect con los límites del driver: lo retoma la práctica evaluable. Haz commit de prometheus.yml, loki.yml y una copia de promtail.yml en el repositorio monitoring, y de la configuración de nginx y el formateador en servicio.
Si te sobra tiempo Pon el mismo daemon.json en mon01, reinicia Docker y recrea su pila. Después localiza en Loki las líneas del servicio db al parar y arrancar la base de datos, y comprueba qué etiqueta service les pone Promtail.
Sesión 5 · Eventos¶
Cuando un contenedor muere, las tres señales que ya llegan a mon01 se dan cuenta, pero ninguna sabe por qué. Las métricas dejan un hueco, los logs se cortan a mitad de una frase y el porcentaje de errores sube, pero ninguna dice si al contenedor lo mató el kernel por falta de memoria, si terminó por un error suyo o si alguien lo paró a mano. Eso lo sabe quien gestiona los contenedores, el demonio de Docker, y lo cuenta en forma de eventos: el cuarto y último de los cuatro flujos. Es una sesión con poca teoría, porque los eventos reutilizan el camino de los logs, el de Promtail a Loki, y casi todo el tiempo es laboratorio: provocar las tres muertes típicas de un contenedor y comprobar que cada una deja su rastro.
Eventos del demonio Docker¶
Qué es un evento. El demonio de Docker (dockerd) es el servicio del host que crea, arranca y para los contenedores; los comandos docker y docker compose solo le hacen peticiones. Cada vez que cambia el estado de un contenedor, una imagen, un volumen o una red, el demonio emite un evento: una línea que dice qué ha pasado, a quién y cuándo. Para el mantenimiento importan los de contenedor, y sobre todo estos:
| Evento | Qué significa | Dato útil en Actor.Attributes |
|---|---|---|
die |
El proceso principal ha terminado | exitCode (0 normal, 137 SIGKILL, 143 SIGTERM, 1 error de la app) |
oom |
El kernel ha matado un proceso del contenedor por exceder memory.max |
Siempre seguido de un die con 137 |
health_status: unhealthy |
El healthcheck ha fallado retries veces seguidas |
Nombre del contenedor |
restart |
Docker lo ha reiniciado por la política restart |
Cuántas veces, comparando con start |
kill, stop |
Alguien (o compose) lo ha parado | signal |
Cómo leer el exitCode. Cuando un proceso termina por una señal (el aviso que el sistema le manda para que pare), su código de salida es 128 más el número de la señal: 137 es 128 + 9, SIGKILL, que mata sin dejar terminar (es la que usa el OOM killer, y la que manda Docker si un stop no ha bastado); 143 es 128 + 15, SIGTERM, la petición educada de parar que manda un docker stop. Un 1, o cualquier otro número bajo, es la propia aplicación diciendo que ha fallado.
Qué es un healthcheck. Que un contenedor esté en marcha no significa que funcione: el proceso puede seguir vivo y no responder. Un healthcheck es una orden que Docker ejecuta dentro del contenedor cada cierto tiempo (por ejemplo, pedir /health a la API); si falla un número seguido de veces (retries), Docker lo marca como unhealthy y emite el evento. El compose del servicio ya trae uno para app.
Los eventos se ven en directo con docker events, y se pueden filtrar por tipo o por acción:
docker events --since 30m --filter type=container --format '{{json .}}'
docker events --filter event=die --filter event=oom --filter event=health_status
Cómo llegan a Loki sin una pieza nueva. Ni Promtail ni Prometheus tienen una fuente de datos de eventos, pero no hace falta: la forma más simple de recogerlos es un contenedor auxiliar que ejecute docker events con salida JSON. Su stdout pasa por el driver de logs como cualquier otro y Promtail lo envía a Loki con service="docker-events". Un contenedor, cero código:
# en /opt/agentes/compose.yml
docker-events:
image: docker:28-cli
container_name: docker-events
command: docker events --filter type=container --format '{{json .}}'
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
restart: unless-stopped
Cada línea es un JSON con status, Action, Actor.ID, Actor.Attributes.name, Actor.Attributes.exitCode, Actor.Attributes.com.docker.compose.service, y time. Un match en el pipeline de Promtail para {service="docker-events"} con json extrae Action a etiqueta action y el nombre del contenedor afectado a structured metadata. En Loki: {service="docker-events", action=~"die|oom"}. La alternativa es un exporter que convierta eventos en métricas (hay varios en GitHub, ninguno oficial); tiene la ventaja de poder alertar con PromQL en lugar de LogQL, pero se pierde el detalle (el exitCode, quién lo paró). Quedarse con Loki no impide alertar: en noviembre, en la unidad de alarmas (UT2), se escriben alarmas sobre estos eventos, como la de un contenedor que muere por falta de memoria.
Dos avisos. El contenedor auxiliar solo captura eventos mientras está en marcha: si se reinicia el demonio, se pierden los de ese intervalo (poco importa: el start de todos los contenedores es lo primero que aparece). Y docker events desde un contenedor con el socket montado tiene acceso total al demonio; el :ro del montaje no limita la API, solo el fichero. Acotarlo exige un proxy de socket que solo deje pasar las llamadas de lectura, y eso queda fuera del curso: por eso el socket se monta solo en los agentes (cAdvisor, Promtail y docker-events) y nunca en un contenedor del servicio.
A1.5 Eventos (sesión 5)¶
Objetivo Que los eventos del demonio Docker de app01 lleguen a Loki con la etiqueta action, y haber comprobado que las tres formas típicas de morir de un contenedor (que lo maten, que se quede sin memoria y que deje de responder) dejan cada una su rastro: el evento que dice qué pasó, y la métrica y el log que dicen cómo. Con eso queda completo el mapa de los cuatro flujos, y lo dibujas al final.
Antes de empezar
- A1.4 funcionando: Explore devuelve
{host="app01"}. - Lo explicado antes: Eventos del demonio Docker.
Pasos
Primero pones a funcionar el cuarto flujo, que reutiliza el camino de los logs. Después haces de avería: provocas a propósito las tres muertes de la teoría, apuntando la hora de cada una, porque es la forma de saber qué buscar y de comprobar que el sistema lo cuenta bien. Por último, cruzas cada evento con las otras dos señales, que es lo que hace quien atiende una avería real, y dibujas el mapa entero ahora que tiene sus cuatro flujos.
-
Lleva los eventos a Loki. Añade al compose de agentes de app01 el contenedor
docker-eventsde Eventos del demonio Docker: como su salida pasa por el mismo camino que cualquier log, Promtail ya lo envía. Para poder filtrar, Promtail tiene que sacar de cada evento suActioncomo etiquetaaction, y el nombre del contenedor afectado y suexitCodecomo structured metadata. Aprovecha para que el JSON de nginx, que todavía llega sin procesar, se procese igual que el de la API y gane su etiquetalevel.Pista: son dos
matchnuevos en el pipeline depromtail.yml, con la misma forma que el de{service="app"}en Promtail. Promtail no relee su fichero solo: después de cambiarlo, reinícialo. -
Provoca las tres muertes de un contenedor. Mata la API de golpe, déjala sin memoria y haz que su healthcheck falle, una cosa cada vez y apuntando la hora de cada una. Después localiza cada evento en Loki por su
actiony apunta elexitCodede cadadie. Al terminar, deja el compose de la API como estaba.Pista: el
oomsale con unmem_limitmuy bajo (unos 32 MB) y unas decenas de peticiones a/itemsen paralelo; elunhealthy, con un healthcheck que pida una ruta que no existe. -
Cruza cada evento con la métrica y el log. Un evento solo dice que algo pasó; la métrica y el log dicen cómo y por qué. Para cada una de las tres muertes, mira en Prometheus y en Explore qué se ve en ese mismo minuto, con la hora que apuntaste, y resúmelo en una tabla de tres filas, una por evento, y tres columnas: «evento en Loki», «qué hace la métrica» y «qué deja en los logs». Es el ejercicio que harás en cada avería real del curso.
-
Dibuja el mapa completo. Con los cuatro flujos funcionando, dibuja el esquema del flujo de datos que pide el informe de la práctica evaluable: los contenedores del servicio y los agentes de app01, qué señal sale de cada uno, por qué puerto y en qué sentido (Prometheus va a buscar las métricas; Promtail empuja los logs y los eventos). Guárdalo en
tests/evidence/ut1/flujo.md, en Mermaid o como foto de un dibujo a mano. Tenerlo hecho hoy te ahorra media sesión el día del informe.
Comprobación Loki devuelve al menos una línea de cada evento con la hora que apuntaste; el oom va seguido de un die con exitCode 137; docker inspect dice unhealthy cuando el evento aparece en Loki; {service="nginx"} trae la etiqueta level; la tabla del paso 3 tiene sus tres filas con una métrica y un log por evento, y el esquema de flujo.md recoge los cuatro flujos con su puerto y su sentido.
Para practicar Guarda en tests/evidence/ut1/a1.5.md las tres consultas con su resultado, la lista de exitCode observados con su causa y la tabla de las tres señales del paso 3. Deja el esquema en tests/evidence/ut1/flujo.md y los dos match nuevos en el promtail.yml del repositorio monitoring.
Si te sobra tiempo Mide cuánto tarda un evento en llegar: apunta la hora al lanzar un docker restart y compárala con la marca de tiempo de la línea start en Loki. Ese retardo es el del camino entero: del demonio de Docker a Loki, pasando por Promtail.
Sesión 6 · Integridad y almacenamiento¶
Con los eventos, los cuatro flujos llegan ya a mon01, y es tentador darlo por terminado porque las gráficas se mueven y en Explore salen líneas. Pero "se ve algo" no es lo mismo que "llega todo, a tiempo y se guarda": un sistema de monitorización que pierde la mitad de los logs sin avisar es peor que no tenerlo, porque da una falsa tranquilidad. El criterio de evaluación de la unidad lo pide explícitamente, y esta sesión es la que lo demuestra. Primero se repasa punto por punto qué puede fallar en la cadena y con qué comando se comprueba; después, por qué la hora de las máquinas importa tanto como los datos; y por último, qué pasa con todo lo anterior cuando se corta la red entre app01 y mon01, que es la prueba que de verdad separa una configuración que funciona de una que aguanta.
Verificar la integración¶
Por qué hay que verificar. Integrar no es configurar y olvidarse. Cada punto de la cadena puede fallar en silencio, sin que ninguna gráfica se quede en blanco: el cortafuegos descarta, Promtail lee pero no envía, Loki recibe pero rechaza las líneas por la hora, Prometheus guarda pero la retención borra antes de tiempo. Verificar es ejecutar un comando en cada punto y saber qué significa lo que sale.
Qué se comprueba. La tabla sigue el camino de los datos, en el mismo orden en que viajan. Comunicación: que cada máquina llega a la otra por el puerto que toca, en el sentido que toca. Recepción: que lo enviado se acepta. Integridad: que llega todo, con lo que se cuenta en el origen y lo que se cuenta en el destino. Marcas de tiempo: que la hora de cada dato es la de verdad. Almacenamiento: que se guarda el tiempo que se dice. Y pérdidas: si algo se ha perdido, cuánto y por qué. Es la tabla que hay que completar entera en la A1.6 y la base del informe de la práctica evaluable.
| Comprobación | Comando | Lo esperado | Si no cuadra |
|---|---|---|---|
| Comunicación app01 → mon01 (logs) | Desde app01: nc -zv <mon01> 23100 |
succeeded |
Hasta el 27 de noviembre no hay cortafuegos entre las dos VM: si falla, Loki está parado (docker compose ps en mon01) o el puerto no está publicado |
| Comunicación mon01 → app01 (métricas) | Desde mon01: curl -s http://<app01>:29102/metrics \| head -5 y lo mismo a :29187, :28081, :29080 y :29100 |
Texto # HELP ... |
Connection refused: no está publicado, o el servicio no escucha en ese número dentro de su contenedor; se queda colgado: IP equivocada o VM apagada (desde el 27 de noviembre, con el cortafuegos, será la primera sospecha) |
| Recepción de métricas | http://<mon01>:29090/targets o curl -s localhost:29090/api/v1/targets \| jq '.data.activeTargets[] \| {job, health, lastError}' |
Todos up |
lastError dice el motivo; scrape_samples_scraped en PromQL dice cuántas series trae cada target |
| Recepción de logs | logcli query --since=2m '{host="app01"}' --limit 5 |
Líneas recientes con container y service |
Vacío: mirar docker logs promtail (errores 4xx de Loki) y promtail_targets_active_total |
| Integridad de logs | En app01: docker logs --since 5m servicio-app-1 \| wc -l; en Loki en el mismo momento: count_over_time({container="servicio-app-1"}[5m]) |
Iguales o diferencia de un puñado por el desfase de segundos entre los dos comandos | Loki muy por debajo: pérdidas (promtail_dropped_entries_total) o rechazos (loki_discarded_samples_total por motivo); Loki por encima: duplicados por posiciones perdidas |
| Integridad de métricas | En app01: curl -s localhost:29102/metrics \| grep 'app_http_requests_total{method="GET",route="/health",status="200"}'; en Prometheus, la misma serie |
El valor de Prometheus es el del último scrape (hasta 15 s más viejo), nunca mayor | Mayor o muy distinto: se está mirando otra instancia o etiquetas cambiadas por relabel |
| Marcas de tiempo | En cada host: chronyc tracking \| grep 'System time' |
Desfase inferior a 100 ms | Ver la sección de chrony; Loki y las correlaciones en Grafana lo notan a partir de segundos |
| Almacenamiento Prometheus | prometheus_tsdb_head_series, prometheus_tsdb_storage_blocks_bytes, y consultar cualquier serie con offset 24h |
Hay datos de hace 24 h; series estables (no crecen sin parar) | Series crecen: cardinalidad (etiqueta con demasiados valores); sin datos antiguos: retención demasiado corta o el volumen no persiste |
| Almacenamiento Loki | logcli query --since=24h --limit 1 '{host="app01"}' y du -sh /var/lib/docker/volumes/monitoring_loki/_data en mon01 |
Hay líneas de ayer; el tamaño crece y se estabiliza al llegar a la retención | Sin líneas: el compactor ha borrado o el volumen no persiste |
| Pérdidas | promtail_dropped_entries_total, loki_discarded_samples_total y count_over_time({service="app"}[1h]) antes y después de un corte |
Todos a cero, o su crecimiento coincide con un corte documentado | Suben sin corte: revisar límites de ingestión de Loki (ingestion_rate_mb) y reloj |
Un detalle de la integridad de logs: docker logs --since 5m y la consulta de Loki no se ejecutan en el mismo instante ni miden exactamente la misma ventana. Para una prueba limpia se genera un número conocido de líneas (for i in $(seq 1 500); do curl -s -o /dev/null http://<app01>:20080/health; done desde el puesto) con la API en silencio, se esperan diez segundos y se cuenta en las dos partes con una ventana holgada. Deben salir 500 y 500. Si la API escribe dos líneas por petición (una del middleware y otra del handler), 1000 y 1000.
Relojes: chrony¶
Por qué importa la hora. Todo lo que se hace con la monitorización consiste en poner cosas una al lado de otra en el tiempo: el pico de latencia y los errores de ese minuto, el evento oom y la caída de la memoria. Si los relojes de las máquinas no coinciden, esas coincidencias se desplazan y engañan. Prometheus pone la hora a las muestras con el reloj de mon01, así que las métricas solo dependen de un reloj. Los logs no: la hora la pone la aplicación (o Docker, si la aplicación no la escribe) con el reloj del host de app01, y Loki la compara con el suyo. Los contenedores no tienen reloj propio, usan el del kernel del host, así que basta con sincronizar las VM. Con un desfase de 30 s la correlación en Grafana entre un pico de latencia y sus errores queda desplazada y confunde; con más de reject_old_samples_max_age Loki rechaza las líneas; y con la hora en el futuro más de 10 minutos también (creation_grace_period).
Cómo se mantiene en hora una máquina. El reloj de un ordenador se adelanta o se atrasa unos segundos al día, y el de una VM más, porque se para cuando la VM se suspende. La solución es NTP (Network Time Protocol), el protocolo con el que una máquina pregunta la hora a un servidor de referencia y corrige la suya. Debian 13 trae systemd-timesyncd, un cliente NTP sencillo que vale para un portátil pero no da diagnóstico. Conviene instalar chrony, un cliente NTP más completo y con diagnóstico, en las dos VM que existen en octubre, app01 y mon01. En el entorno provisional no hay OPNsense, el cortafuegos del laboratorio, así que la fuente es la de Internet y basta con la línea pool. Desde el 27 de noviembre, con las VM ya detrás del cortafuegos y sin salida a Internet por NTP, la fuente pasa a ser el .1 de la zona de cada VM, que es el propio OPNsense sirviendo la hora:
sudo apt install chrony
sudo tee /etc/chrony/sources.d/lab.sources <<EOT
# server <el .1 de la zona de la VM> iburst prefer # desde el 27 de noviembre, detrás de OPNsense
pool 2.debian.pool.ntp.org iburst
EOT
sudo systemctl restart chrony
chronyc sources -v # ^* delante de la fuente elegida
chronyc tracking # System time : 0.000213 seconds fast of NTP time
Las VM en Proxmox pierden tiempo al suspenderse o al migrarse; chrony por defecto solo corrige a saltos (makestep) en los tres primeros ajustes tras arrancar, y después ajusta la velocidad del reloj poco a poco (un desfase de 10 s tarda horas en corregirse). Para el laboratorio, donde se apagan y se encienden VM constantemente, conviene añadir makestep 1 -1 en /etc/chrony/conf.d/lab.conf: corrige de golpe siempre que el desfase pase de 1 s. En producción se tiene apagado por lo que hace a las bases de datos un salto de reloj hacia atrás.
Qué pasa cuando se pierde la red¶
Por qué cortar la red a propósito. En producción la red se corta: se reinicia un switch, se toca una regla del cortafuegos, una VM se migra de nodo. La pregunta no es si pasará, sino qué se pierde cuando pase, y la única forma de saberlo es provocarlo en el laboratorio y medirlo. Es la prueba de esta sesión y de la práctica evaluable. Para cortar basta con copiar las órdenes de abajo, que usan nftables, el cortafuegos del kernel de Linux. Ese cortafuegos se explica a fondo en la sesión 17, el 1 de diciembre, cuando se cierra app01 con reglas propias.
En app01 se corta todo el tráfico con mon01 durante tres minutos:
sudo nft add table inet corte
sudo nft add chain inet corte out '{ type filter hook output priority 0; }'
sudo nft add chain inet corte in '{ type filter hook input priority 0; }'
sudo nft add rule inet corte out ip daddr <mon01> drop
sudo nft add rule inet corte in ip saddr <mon01> drop
date; sleep 180; date
sudo nft delete table inet corte
Lo que hay que observar y documentar, con capturas:
- Métricas por scrape: a los 15 s, en Targets todos los de app01 pasan a DOWN con
context deadline exceeded;up{host="app01"}vale 0. Al restaurar, vuelven a UP en el siguiente scrape. En Grafana queda un hueco de tres minutos en todas las gráficas de app01. No se recupera: esos datos no existen en ningún sitio. Los contadores de la API han seguido subiendo, así queincrease(app_http_requests_total[10m])sobre un rango que incluye el corte da el total correcto, solo falta el detalle de esos minutos. - Métricas por remote_write (solo si se ha montado el Prometheus local de «Si te sobra tiempo»): la cola del emisor crece durante el corte y se vacía en segundos al restaurar, y en el central aparecen las muestras con su hora original. Se recupera sin hueco.
- Logs:
docker logs promtailmuestra los reintentos (error sending batch, will retry, con el tiempo de espera creciente).promtail_sent_entries_totaldeja de crecer,promtail_dropped_entries_totalsigue a cero. Al restaurar, en menos de un minuto (el siguiente reintento) llegan a Loki todas las líneas del corte con sus marcas de tiempo reales. Se comprueba concount_over_timesobre la ventana del corte. Se recupera mientras el corte dure menos que la suma de esperas de los reintentos. - Eventos: igual que los logs, porque van por el mismo camino.
Repetida la prueba con max_retries: 3, o con un corte más largo que la suma de esperas de los reintentos (unos 8,5 minutos con la configuración de Promtail), aparecen pérdidas: promtail_dropped_entries_total sube y el conteo en Loki queda por debajo del de docker logs. Ese es el número que hay que poner en el informe: cuánto aguanta la configuración propia, calculado con la suma de esperas y comprobado con el corte.
Enviar las métricas a un sistema remoto: scrape y remote_write¶
Material de consulta
Esto no se explica en clase: lo necesitas para la hoja de práctica de esta sesión.
Hasta aquí se ha dado por hecho que las métricas llegan a mon01. Este apartado explica cómo viajan, porque hay dos formas con comportamientos distintos cuando la red falla: en la primera Prometheus va a buscarlas, en la segunda se las envían. La primera es la del curso y la que explica el hueco que deja el corte de red de esta sesión; la segunda solo se monta si sobra tiempo.
El camino normal es el scrape: Prometheus en mon01 tiene una lista de targets y cada scrape_interval (15 s en el laboratorio del curso) hace un GET a /metrics de cada uno, parsea el texto y guarda cada muestra con la hora del reloj de mon01. El target no sabe nada de Prometheus, solo responde a un GET. Esto tiene consecuencias que conviene tener claras:
sequenceDiagram
participant P as Prometheus (mon01)
participant T as API app01 :29102
loop cada 15 s
P->>T: GET /metrics (timeout 10 s)
alt 200 OK
T-->>P: texto de exposición (app_http_requests_total{...} 18342 ...)
P->>P: parsea, añade job/instance, guarda con t = ahora
P->>P: up{instance="app01:29102"} = 1
else sin respuesta
P->>P: up = 0, scrape_duration_seconds, no hay muestras
P->>P: marca de "stale" en las series del target
end
end
Un scrape es una petición HTTP con tiempo límite, sin cola y sin reintento: si falla, up vale 0 y en la serie queda el hueco.
Un scrape que falla no se recupera: no hay cola, el target no guarda nada, y en la gráfica queda un hueco. Los contadores lo toleran bien (el siguiente scrape trae el valor acumulado y rate() lo reparte), pero un pico de un gauge que ocurrió en esos segundos se pierde para siempre. up es la métrica más importante de Prometheus: vale 1 si el último scrape del target tuvo éxito y 0 si no, y es lo primero que se mira en http://<mon01>:29090/targets.
Los jobs de esta unidad en mon01 son los del paso 2 de la A1.4: todos apuntan a la IP de app01 con sus puertos publicados, salvo Loki, que se resuelve por su nombre porque comparte el compose de Prometheus, y el node_exporter de la propia mon01. Las dos VM están en la red del aula sin cortafuegos entre ellas, así que no hay que abrir nada. Si un target sale context deadline exceeded en lugar de connection refused, los paquetes se están descartando sin respuesta: la VM está apagada o la IP es otra. connection refused es que el puerto no está escuchando en la máquina. Esto cambia el 27 de noviembre, cuando Despliegue pase las dos VM detrás de OPNsense: desde ese día cada uno de esos puertos necesita una regla que deje pasar a mon01, y ante un context deadline exceeded lo primero que hay que mirar es el cortafuegos.
remote_write es la alternativa cuando el scrape no puede hacerse: el target está detrás de NAT (una red privada cuyas direcciones mon01 no puede alcanzar directamente) o se busca tolerancia a cortes. Se despliega un Prometheus pequeño junto al servicio, en app01, que hace el scrape en local y empuja las muestras al central; guarda un WAL (diario de escrituras) en disco y, si el central no responde, reintenta y al volver envía lo acumulado. En el laboratorio la práctica se hace con scrape directo, que es lo que tiene cualquier empresa con una red plana, y el remote_write solo se monta si sobra tiempo en la hoja de esta sesión, para comparar el comportamiento ante el corte. La configuración del Prometheus local, el receptor del central y las métricas que dicen si su cola va bien están en Para ampliar.
A1.6 Integridad y almacenamiento (sesión 6)¶
Objetivo Pasar de "parece que funciona" a "está demostrado que funciona": la tabla de verificación rellena con comandos y salidas reales, y medido qué pasa con métricas y logs cuando se corta la red, primero con la configuración normal de Promtail y después con una que aguanta menos. Al terminar sabes qué se recupera, qué se pierde y cuánto corte aguanta tu configuración.
Antes de empezar
- Todos los flujos funcionando (A1.2 a A1.5) y
sudoen app01 y mon01, las dos únicas VM que usa hoy esta asignatura. logclien mon01 o en el puesto (binario de la release de Loki), conLOKI_ADDRapuntando a mon01.- Lo explicado antes: Verificar la integración, relojes: chrony y qué pasa cuando se pierde la red.
Pasos
El orden importa. Los relojes van primero, porque sin ellos ninguna comparación de horas de las que vienen después es fiable. Después recorres la tabla con todo en orden, para tener una foto de cómo es el sistema cuando funciona. Y solo entonces lo rompes: dos cortes de red, uno con la configuración que vas a dejar puesta y otro con una peor a propósito, para ver la diferencia entre aguantar un corte y perder datos.
-
Pon en hora las dos máquinas. Instala chrony en app01 y en mon01 como en Relojes: chrony, todavía sin OPNsense (solo la línea
pool) y conmakestep 1 -1para que corrija de golpe los saltos de las VM. Guarda la salida dechronyc trackingde las dos: el desfase tiene que quedar por debajo de 100 ms.Pista: instala a la vez
netcat-openbsdyjq, que los usa la tabla del paso 2. -
Haz la foto del sistema sano. Recorre la tabla de Verificar la integración fila a fila y anota, para cada una, el comando exacto, la salida y si cuadra con lo esperado. Para la integridad de logs no basta con contar "lo que haya": haz la prueba limpia que se describe debajo de la tabla, con un número conocido de peticiones, para que los dos conteos se puedan comparar.
-
Corta la red y mira qué se recupera. Con tráfico en marcha, corta la red entre app01 y mon01 durante tres minutos con los comandos de Qué pasa cuando se pierde la red. Mientras dura el corte y al restaurarlo, observa lo que dice la teoría que tiene que pasar y compruébalo: cuánto tardan los targets de app01 en caer y en volver, qué escribe Promtail mientras no puede enviar, cuándo llegan a Loki las líneas del corte y si el hueco de las gráficas se rellena. La diferencia entre métricas y logs es la del mapa de los cuatro flujos: unas se van a buscar y las otras se empujan con reintentos.
Pista: antes del corte apunta
promtail_dropped_entries_total,loki_discarded_samples_totalycount_over_time({service="app"}[1h]), para compararlos después y contrastar el conteo de Loki con el dedocker logs. -
Rómpelo de verdad, mide la pérdida y déjalo bien. Repite el corte con los reintentos de Promtail reducidos a
max_retries: 3: esta vez el corte dura más que lo que Promtail espera antes de rendirse, y tienen que perderse líneas. Mide cuántas, y calcula cuánto corte aguanta la configuración normal. Al terminar, vuelve amax_retries: 10: con el valor bajo, cualquier corte corto de las unidades siguientes perdería logs.Pista: lo que aguanta la configuración normal es la suma de las esperas de sus reintentos, calculada en Promtail.
Comprobación Las diez filas de la tabla con resultado; desfase de reloj inferior a 100 ms en app01 y mon01; tras el primer corte los conteos de logs coinciden y dropped_entries no ha subido, mientras que las gráficas por scrape tienen un hueco que no se rellena; tras el segundo hay pérdida de logs medible, y promtail.yml vuelve a tener max_retries: 10.
Para practicar Guarda en tests/evidence/ut1/a1.6.md la tabla rellena (comando, salida, cuadra o no), las salidas de chronyc tracking y, por cada corte, horas de inicio y fin, gráficas de up y de CPU, conteos y valor de promtail_dropped_entries_total. Apunta también cuánto corte aguanta la configuración normal. Ese fichero es la base del informe de la práctica evaluable.
Si te sobra tiempo Monta en app01 el Prometheus local de Para ampliar (el central necesita --web.enable-remote-write-receiver), repite el corte de 3 minutos y compara: por remote_write no hay hueco.
Sesión 7 · Práctica evaluable¶
Este informe es lo que demuestra que la monitorización del servicio funciona: lo que leería otra persona para fiarse de ella sin haber estado delante. Por eso no se trata de contar lo que has hecho cada día, sino de enseñar el sistema tal como está y las pruebas de que hace lo que dice. Ordénalo como viajan los datos: primero el mapa (qué sale de cada contenedor y hacia dónde), después la configuración que lo hace posible, luego las pruebas de que llega todo y a tiempo, y al final qué pasa cuando algo falla.
Entrega un informe (máximo 5 páginas, PDF, en tests/evidence/ut1/ del repositorio servicio) sobre el contenedor de referencia integrado en mon01. Lo que se documenta es el entorno que existe hoy: dos VM en la red del aula, app01 con el servicio del curso y sus agentes y mon01 con Prometheus, Loki y Grafana. web01 y db01 no se usan todavía, así que el proxy es el servicio nginx y la base de datos el servicio db, los dos en el compose de app01. El informe se monta con lo que ya está en tests/evidence/ut1/ (de a1.2.md a a1.6.md y flujo.md): no pide ninguna prueba nueva. Tiene que contener:
- Esquema del flujo de datos (métricas, logs, eventos) desde app01 hasta mon01, con puertos y sentido de cada flujo (de la A1.5, paso 4). En app01 conviven los contenedores del compose del servicio (nginx, API, PostgreSQL y postgres_exporter) y los agentes (cAdvisor, Promtail y docker-events), así que el esquema tiene que distinguir qué sale de cada contenedor.
- Configuración: el compose del servicio y el de agentes,
promtail.yml, los jobs deprometheus.yml,daemon.jsony el fragmento de la instrumentación de la API (de la A1.2, paso 3; de la A1.3, pasos 1 y 2; de la A1.4, pasos 1, 2 y 5; de la A1.5, paso 1). - Pruebas de comunicación, recepción, integridad y almacenamiento con evidencias (comandos y salidas, capturas de Targets y de Explore), siguiendo la tabla de verificación (de la A1.6, paso 2).
- Resultado de los dos cortes de red: qué se recupera, qué se pierde, qué métrica lo demuestra y cuánto aguanta la configuración normal (de la A1.6, pasos 3 y 4).
- Salida de
chronyc trackingde app01 y de mon01 (de la A1.6, paso 1).
| Criterio (RA1 a) | Peso |
|---|---|
| Métricas de recursos y de aplicación llegan a Prometheus | 30 % |
| Logs y eventos llegan a Loki con etiquetas correctas | 30 % |
| Integridad de datos comprobada (conteos coinciden, relojes sincronizados) | 20 % |
| Almacenamiento en mon01 verificado (retención de Prometheus y Loki) y comportamiento ante pérdida de red documentado | 20 % |
Se valora que el informe se pueda reproducir: quien lo lea con acceso al laboratorio tiene que poder ejecutar los mismos comandos y obtener lo mismo. Las capturas sin el comando que las generó no cuentan como evidencia.
Errores frecuentes en el laboratorio¶
A1.1 · La API no arranca: password authentication failed for user "postgres", y nginx devuelve 504. PostgreSQL fija la contraseña la primera vez que arranca, en el volumen de datos, y después ignora .env: pasa si se arrancó sin .env o se cambió la contraseña tras el primer up. Arreglo: docker compose down -v && docker compose up -d en /opt/servicio (la API vuelve a crear sus datos de ejemplo). Con Grafana pasa lo mismo con la contraseña de admin. Si docker compose up dice required variable DB_PASSWORD is missing a value, falta copiar .env.example a .env.
cAdvisor arranca pero no muestra contenedores, o todas las series llevan name="". Le falta el montaje de /var/run (no encuentra el socket para relacionar cgroups con contenedores) o /sys. Con cgroups v2 además necesita privileged. Conviene revisar docker logs cadvisor: suele decir Failed to start container manager o unable to get docker info.
El target de cAdvisor da un 404, o no responde aunque el contenedor está en marcha. El 28080 de app01 es la API, que no sirve /metrics en ese puerto y responde 404; cAdvisor está en el 28081, porque el 28080 que le daría la regla ya es de la API. Si el puerto es el bueno y aun así no hay respuesta (Empty reply from server o Connection reset by peer con curl en app01, connection refused desde mon01), se ha publicado el puerto de la franja sin la opción que pone al servicio a escuchar en él (--port en cAdvisor, --web.listen-address en los exporters), y dentro del contenedor sigue en su puerto de fábrica. docker compose ps enseña lo publicado, no dónde escucha el proceso: eso lo dicen sus logs al arrancar.
Los valores de app_http_requests_total bajan y suben entre scrapes. gunicorn con varios workers y cada uno con su registro. Activar el modo multiproceso de prometheus_client o servir /metrics desde un único proceso.
Promtail: permission denied al abrir /var/run/docker.sock. El contenedor de Promtail corre con un usuario sin acceso al socket. Se añade user: root en el compose (aceptable en un agente de solo lectura) o group_add con el gid del grupo docker del host (getent group docker).
Loki responde 400 entry too far behind o timestamp too old. La primera: dentro de un mismo stream llegan líneas más antiguas que las ya aceptadas fuera de la ventana de desorden (2 h por defecto); suele ser un reenvío tras perder el fichero de posiciones. La segunda: la línea tiene más de reject_old_samples_max_age, normalmente por un reloj atrasado en app01 o por reenviar logs de hace días tras cambiar de configuración. loki_discarded_samples_total lleva la cuenta por motivo.
Loki responde 429 Ingestion rate limit exceeded o Maximum active stream limit exceeded. Se está enviando más de ingestion_rate_mb (subirlo si el disco lo permite, o bajar el nivel de log de la API), o hay en las etiquetas algo de cardinalidad alta (request_id, la ruta). logcli series '{host="app01"}' | wc -l dice cuántos streams hay; con el servicio del curso deberían ser unas decenas, no miles.
Los conteos de Loki salen el doble que los de docker logs. Promtail ha reenviado todo tras un reinicio porque el fichero de posiciones estaba en /tmp dentro del contenedor. Volumen persistente para /var/lib/promtail.
max-size está puesto en daemon.json y el fichero json del contenedor sigue en 4 GB. La opción solo se aplica a contenedores creados después del cambio. docker compose up -d --force-recreate y se comprueba con docker inspect -f '{{.HostConfig.LogConfig}}' servicio-app-1.
A1.5 · El oom no salta, o la API ni siquiera arranca. El límite de memoria no cuadra con lo que consume la API en ese puesto. Si con 32 MB no salta, se baja a 16 MB; si la API no llega a arrancar, se sube a 48 MB. Las peticiones a /items tienen que ir en paralelo (con & y un wait al final), porque de una en una la memoria se libera entre petición y petición.
docker logs no muestra nada de la API. La aplicación escribe a un fichero dentro del contenedor o el framework tiene el log por defecto a nivel WARNING. Todo a stdout y nivel INFO.
En Grafana, Explore con Loki dice "no logs found" pero logcli en mon01 sí devuelve. Rango de tiempo del navegador (últimos 5 minutos con un desfase de reloj del portátil) o fuente de datos configurada con localhost:23100 desde el contenedor de Grafana, donde localhost es el propio Grafana. Se usa el nombre del servicio de compose (http://loki:23100).
Targets en DOWN con context deadline exceeded justo después de tocar OPNsense. Regla de firewall sin la dirección o el puerto correcto, o creada en la interfaz equivocada. Se comprueba con nc -zv desde mon01 y se mira el live log del firewall filtrando por la IP de mon01.
Las gráficas de CPU de cAdvisor muestran valores absurdos (4000 %). Se está usando container_cpu_usage_seconds_total sin rate(), o rate() con una ventana menor que el intervalo de scrape.
Los enlaces para ampliar y los apartados que van más allá de lo que se hace en clase están en Para ampliar.
UT2 · Umbrales, agregación y gestión de alarmas¶
La UT1 dejó mon01 recogiendo los cuatro flujos del contenedor de referencia: en Prometheus, las métricas de recursos de cAdvisor y node_exporter, las de la propia API y las de postgres_exporter; en Loki, los logs y los eventos del demonio Docker. La A1.6 demostró además que llegan enteros y a tiempo. Pero todo eso es todavía un almacén: solo sirve si alguien está mirando el dashboard en el momento en que algo falla, y a las tres de la madrugada no mira nadie. Esta unidad convierte esos datos en alarmas: condiciones que se evalúan solas, que avisan a quien tiene que actuar, cuando toca y sin repetirse sin necesidad, y que dejan rastro en un gestor de incidencias.
La unidad monta la cadena en el orden en que la recorre una alarma: qué condiciones merece la pena vigilar en las métricas y en los logs, cómo se calculan una sola vez los indicadores sobre los que se vigila, cómo se convierten las condiciones en reglas que se evalúan solas, quién decide a quién se avisa y cuándo, y dónde queda registrada cada alarma. La última sesión antes de la práctica evaluable prueba la cadena entera de punta a punta.
Sesión 8 · Umbrales sobre contadores¶
Hasta ahora, para enterarse de que el servicio iba mal había que estar mirando: las terminales de la A1.1, los paneles de Grafana. Una alarma empieza por lo contrario: una condición escrita de antemano que el sistema pueda comprobar sin nadie delante. Esta sesión escribe las primeras, las que salen de las métricas de Prometheus, y la teoría sigue el orden en que se construyen. Primero, el mapa de la cadena que monta la unidad y el criterio para decidir qué merece ser alarma, porque un aviso que no exige actuar es ruido. Después, las herramientas de PromQL para escribir la condición: casi todo lo que interesa vigilar sale de contadores que solo crecen, y antes de compararlos con un número hay que convertirlos en velocidades, dividir unos entre otros y saber qué significa que una consulta salga vacía. Por último, los seis umbrales del contenedor de referencia y cuánto tiempo tiene que durar una condición antes de avisar. La hoja mide el valor normal de cada indicador con tráfico real y fija cada umbral con la documentación del servicio delante.
De la métrica a la alarma¶
Qué es una alarma. Una alarma es una condición sobre los datos que, mantenida durante un tiempo, exige que alguien haga algo. Las cuatro palabras importantes son condición, tiempo, alguien y algo, y si falta cualquiera no es una alarma. Sin una condición escrita, es un panel que alguien tiene que estar mirando; sin un tiempo mínimo, un pico de diez segundos despierta a alguien para nada; sin alguien a quien avisar, no se entera nadie; y sin algo que hacer, el aviso solo molesta.
Por qué es una cadena de piezas. Entre un número que sube en Prometheus y un mensaje en el móvil hay seis eslabones, y la unidad dedica una sesión a casi cada uno. No es un programa único porque cada pieza hace un trabajo distinto: quien calcula (Prometheus, el ruler de Loki) no necesita saber a quién se avisa, y quien avisa (Alertmanager, un programa aparte del mismo proyecto Prometheus) no necesita saber calcular. Gracias a ese reparto se puede cambiar el canal de guardia sin tocar una sola regla, o añadir una fuente de alertas nueva, como Loki, sin tocar los avisos.
flowchart TB
M["<b>Métrica o log</b><br><small>Prometheus / Loki</small>"]:::dato
I["<b>Indicador</b><br><small>recording rule</small>"]:::dato
U["<b>Umbral</b><br><small>lo decide la documentación del servicio</small>"]:::act
R["<b>Regla de alerta</b><br><small>expr + for + labels</small>"]:::pieza
AM["<b>Alertmanager</b><br><small>agrupa, enruta, inhibe</small>"]:::pieza
N(["<b>Notificación</b><br><small>correo, Telegram</small>"]):::ok
W["<b>Webhook</b>"]:::pieza
T(["<b>Incidencia en Gitea</b><br><small>se abre y se cierra sola</small>"]):::ok
M --> I --> U --> R --> AM
AM --> N
AM --> W --> T
classDef act fill:#ea580c22,stroke:#ea580c,stroke-width:1.5px
classDef pieza fill:#64748b22,stroke:#64748b,stroke-width:1.5px
classDef dato fill:#2563eb22,stroke:#2563eb,stroke-width:1.5px
classDef infra fill:#a1a1aa14,stroke:#a1a1aa,stroke-width:1.5px
classDef ok fill:#16a34a22,stroke:#16a34a,stroke-width:1.5px
classDef riesgo fill:#dc262622,stroke:#dc2626,stroke-width:1.5px
El único paso que no es técnico es el umbral, y es el que más se discute: lo decide el servicio, no la herramienta.
| Eslabón | Qué es | Dónde se escribe (repositorio alerting) |
Cuándo |
|---|---|---|---|
| Métrica o log | Lo que ya se recoge del contenedor de referencia | En Prometheus y Loki, sin cambios | Ya está |
| Indicador | Una fórmula sobre las métricas: el ratio de errores, el percentil de latencia | prometheus/rules.yml, como recording rules |
5 nov |
| Umbral | El número que separa lo normal de lo que no, con su justificación | docs/umbrales.md y docs/cadenas.md |
29 oct y 3 nov |
| Regla de alerta | Indicador, umbral y duración juntos, con las etiquetas que la clasifican | prometheus/alerts.yml y loki/loki-alerts.yml |
10 nov |
| Alertmanager | Recibe las alertas disparadas y decide a quién avisar, cuándo y cuántas veces | alertmanager/alertmanager.yml |
12 nov |
| Notificación e incidencia | El aviso a una persona (correo, Telegram) y, en paralelo, la incidencia en Gitea | Los receptores de alertmanager.yml y el receptor tickets/ |
12 y 17 nov |
El umbral no se inventa: sale de la documentación del servicio o de un acuerdo de nivel de servicio (lo que el servicio se compromete a cumplir con quien lo usa). Alertmanager forma parte de la pila mínima desde la A1.1, pero con una configuración de partida que no avisa a nadie. Y la incidencia la abre un webhook (una llamada HTTP que un programa hace a otro para avisarle de que ha pasado algo), que deja la alarma guardada en un sitio que no depende de Alertmanager.
Alertar por síntomas, no por causas¶
El problema: qué merece una alarma. Con PromQL se le puede poner un umbral a cualquier cosa que recoja Prometheus: la CPU, la memoria, las conexiones a la base de datos, el disco, los errores. Si se le pone a todo, cada día salta algo, casi nunca importa y el equipo aprende a no hacer caso. Hace falta un criterio para elegir.
El criterio: síntomas, no causas. El capítulo de monitorización del libro de SRE de Google (SRE, Site Reliability Engineering, es como Google llama a operar servicios en producción; sre.google/sre-book/monitoring-distributed-systems, gratuito) fija el principio que se sigue aquí: las alarmas que despiertan a alguien deben responder a síntomas que sufre el usuario (la API devuelve errores, tarda demasiado, no responde), no a causas posibles (la CPU está al 90 %, hay muchas conexiones a la base de datos, el disco crece). El razonamiento es sencillo. Las causas son infinitas y cada una tiene un umbral discutible; los síntomas son pocos y todos tienen la misma consecuencia: alguien no puede usar el servicio. Una CPU al 95 % que sirve todas las peticiones en 80 ms no es un problema. Una CPU al 30 % con un 5 % de errores 500 sí lo es.
Qué se hace con las causas. No se tiran. Sirven para dos cosas: como avisos que se miran en horario laboral, antes de que se conviertan en un síntoma, y como paneles que se consultan cuando una alarma de síntoma ha despertado a alguien y hay que buscar el porqué. La diferencia la marca la severidad que lleva cada alarma: critical es lo que exige actuar ya y va al canal de guardia, el de la persona que en ese turno atiende los avisos aunque sea de noche; warning puede esperar al día siguiente. La tabla de umbrales de esta sesión lo aplica: errores, latencia y servicio caído son critical; memoria, reinicios y conexiones, warning.
La prueba de cada alarma. El mismo capítulo da la regla para decidir si una alarma merece existir: cada vez que suena, alguien tiene que actuar de forma inteligente, tiene que hacerlo ahora, y eso no puede automatizarse. Si falla cualquiera de las tres condiciones, la alarma es ruido: si la respuesta es siempre la misma, la puede dar un script; si puede esperar, no tiene por qué despertar a nadie. El capítulo lo resume en unas pocas preguntas que conviene hacerse ante cada alarma nueva: si alguna vez se podrá ignorar sabiendo que no pasa nada, si de verdad indica que los usuarios están afectados, si se puede hacer algo al recibirla y si el mismo problema ya está avisando a alguien por otro lado.
Fatiga de alertas¶
Qué pasa si se alerta de todo. La fatiga de alertas es lo que pasa cuando el canal de notificaciones recibe tantos avisos que el equipo deja de leerlos. Es un fallo de ingeniería, no de disciplina: si en el canal de Telegram de guardia entran cuarenta mensajes al día y treinta y ocho no requieren acción, la gente aprende a ignorar el canal, y la alarma número treinta y nueve, la que importa, se pierde con las demás. No es un problema exclusivo de la informática: está muy estudiado en los hospitales, con las alarmas de los monitores de cuidados intensivos, que de tanto sonar dejan de oírse.
Por qué condiciona los umbrales. Es la razón de que en esta unidad ningún umbral se ponga bajo "por si acaso": cada aviso de más gasta la atención que va a necesitar el que importa. Lo que distingue a un equipo que funciona es que mide la fatiga y la corrige. Prometheus y Alertmanager exponen series con las que se cuentan las horas que ha estado activa cada alarma, las notificaciones por semana y persona de guardia o el porcentaje de avisos que acaban en una acción; las consultas y los cuatro indicadores habituales están en Para ampliar. Cuando una alarma sale mal en esos números se hace una de tres cosas: subir el umbral, alargar el for (el tiempo que la condición tiene que durar antes de avisar, que se explica al final de esta sesión), o bajarla de critical a warning y sacarla del canal de guardia.
Umbrales sobre contadores¶
Qué es un umbral en PromQL. Una condición de alarma es una consulta con una comparación al final. En PromQL la comparación no devuelve verdadero o falso: funciona como un filtro. De todas las series que calcula la consulta, deja pasar solo las que cumplen la comparación, con su valor; si no pasa ninguna, el resultado está vacío y no hay nada de qué avisar. Se ve bien con
up, la métrica que vale 1 cuando el último scrape de un target funcionó y 0 cuando no:
up{job="node"} # una serie por máquina: 1 si su node_exporter responde, 0 si no
up{job="node"} == 0 # solo las máquinas que no responden; con todas en pie, ninguna
Por eso, en una regla de alerta, cada serie que pasa el filtro es una alerta.
El problema de los contadores. La mayor parte de lo que hay que vigilar en el servicio (peticiones, errores, segundos de CPU) llega a Prometheus como contador, el tipo de métrica que solo sube (Tipos de métrica). Un umbral sobre un contador nunca se escribe sobre su valor: app_http_requests_total vale 4 831 220 y mañana valdrá más, y ese número solo dice cuánto tiempo lleva arrancado el proceso. Lo que se compara con un umbral es su velocidad, cuánto sube por segundo, o cuánto ha subido en una ventana de tiempo. Las métricas que no son contadores, como la memoria o las conexiones abiertas, son medidores (gauges): suben y bajan, y esas sí se comparan tal cual.
Los apartados siguen el orden en que se escribe una condición: convertir el contador en una velocidad con la ventana adecuada, dividir unas series entre otras para obtener un ratio, entender por qué una consulta puede salir vacía y, con eso, fijar los seis umbrales del servicio y cuánto tiempo tiene que cumplirse cada uno.
rate, increase y la ventana¶
De dónde se parte. rate() no es nuevo: convirtió los segundos de CPU en núcleos para el panel de recursos (Las consultas del panel) y sirvió en la A1.3 para el p95 y el porcentaje de errores. Para alertar se usa igual, pero hace falta saber dos cosas más: qué hace exactamente con los reinicios y con los bordes de la ventana, y qué ventana conviene cuando el resultado puede despertar a alguien.
Qué calculan. rate(c[5m]) es la velocidad media por segundo del contador c en los últimos cinco minutos. increase(c[5m]) es exactamente rate(c[5m]) * 300: lo que ha subido en total en esa ventana. Las dos corrigen los reinicios: si el contenedor arranca de nuevo y el contador vuelve a cero, lo detectan y no lo toman por una bajada. Y las dos extrapolan: las muestras casi nunca caen justo en los bordes de la ventana, así que Prometheus estima cuánto habría subido en la ventana completa prolongando la tendencia hasta los bordes. Por eso increase puede devolver 2,7 aunque el contador suba de uno en uno: para compararlo con un umbral da igual, pero no sirve para contar sucesos sueltos.
La ventana importa más que la función. Con un scrape_interval (cada cuánto Prometheus recoge muestras) de 15 s, rate(c[1m]) solo tiene cuatro muestras y va a dar picos y agujeros; rate(c[5m]) tiene veinte y da una curva suave. La regla de las gráficas sigue valiendo, la ventana al menos cuatro veces el intervalo de scrape, y para alertas se añade otra: entre 2 y 10 minutos. Con menos, y poco tráfico, un par de peticiones fallidas pesan tanto que parecen una avería; con más, la media se suaviza tanto que la alarma llega tarde. El curso usa [5m] en todos los indicadores. irate toma solo las dos últimas muestras y sirve para gráficas de detalle, nunca para alertar.
Una tercera función para los reinicios. Para saber cuántas veces se ha reiniciado la API no hay ningún contador. Lo que hay es una marca de tiempo: la librería que instrumenta la API publica, además de las métricas de la A1.3, unas cuantas del propio proceso, y una de ellas es process_start_time_seconds, la hora a la que arrancó, en segundos desde 1970. Es un gauge, y no sube de uno en uno: salta a una hora nueva cada vez que la API vuelve a arrancar. Como la serie es siempre la misma, la del job app, Prometheus ve cada salto. increase sobre ella no significa nada; la función correcta es changes(v[1h]), que cuenta cuántas veces ha cambiado el valor en la ventana, es decir, cuántos arranques ha habido en la última hora:
Por qué no la de cAdvisor. cAdvisor publica una métrica de nombre parecido, container_start_time_seconds, y es la primera que se busca, pero no sirve para esto. Para un contenedor de Docker, cAdvisor la rellena con la fecha en que se creó el contenedor, que no cambia cuando se reinicia; y si el contenedor se recrea, cambia su id y la serie es otra. En los dos casos changes() da 0 aunque la API se haya caído diez veces. Es el ejemplo de por qué una consulta de alarma no se da por buena hasta que se la ve reaccionar al fallo, que es lo que hace la hoja.
Cómo se sabe qué tipo es cada métrica. Usar increase sobre un gauge es un error frecuente y silencioso: la consulta no falla, simplemente devuelve un número que no significa nada. Antes de escoger la función se mira el tipo en el propio /metrics, donde cada métrica va precedida de una línea # TYPE; en el de la API, por ejemplo:
Si es counter, se usa rate o increase; si es gauge, el valor tal cual o, para una marca de tiempo como esta, changes.
Operadores con etiquetas¶
Por qué casi todo es una división. Un umbral fijo sobre un valor suelto rara vez sirve: 50 errores por minuto son un desastre con 60 peticiones por minuto y una minucia con 60 000. Por eso casi todos los indicadores son una proporción entre dos series: errores entre peticiones, memoria usada entre memoria límite, conexiones abiertas entre conexiones permitidas. Una proporción vale lo mismo con poco o con mucho tráfico, y su umbral no hay que retocarlo cada vez que el servicio crece.
Cómo divide PromQL. Una división entre dos consultas no divide números, divide series: para cada serie de la izquierda, PromQL busca en la derecha la que tenga exactamente las mismas etiquetas (sin contar el nombre de la métrica) y divide sus valores. Las series que no encuentran pareja desaparecen del resultado sin dar ningún error. Por eso una división con las etiquetas mal emparejadas no falla: devuelve vacío ("no data" en Grafana), y una alarma escrita sobre ella no saltará nunca. Hay tres situaciones habituales:
- Las etiquetas coinciden tras agregar.
sum by (service)(rate(errores)) / sum by (service)(rate(total)): las dos partes se quedan solo conservice, así que se emparejan sin más. Es el caso limpio, y es el del ratio de errores. - Una etiqueta sobra o no coincide en un lado.
a / ignoring(id) bempareja sin mirarid, ya / on(service) bempareja solo porservice. Con cAdvisor el primer sospechoso esid: dos métricas suyas, como las del ratio de memoria, casi siempre coinciden en todo, peroid, la ruta del cgroup, es la etiqueta más larga y la primera que hay que ignorar cuando una división de cAdvisor sale vacía. - Un lado tiene varias series y el otro una (muchos a uno).
pg_stat_activity_count / on(service) group_left pg_settings_max_connections: a la izquierda hay varias series, por base de datos y estado de la conexión, y a la derecha una sola, el máximo de conexiones.group_leftdice que el lado izquierdo es el que tiene varias series y que la del derecho se reutiliza para cada una. Sin él, PromQL se niega a emparejar una serie con varias y la consulta da error. La consulta de conexiones de la tabla suma antes la izquierda por servicio, así que allí queda una serie a cada lado y elgroup_leftno hace falta, aunque tampoco estorba.
Una consulta vacía no es un cero¶
Por qué importa. Una alarma salta cuando su consulta devuelve alguna serie. Si la consulta sale vacía, la alarma no salta, y desde fuera no se distingue "todo va bien" de "la consulta no encuentra nada que medir". En el curso hay cuatro situaciones en las que una consulta sale vacía, y solo la primera significa que todo va bien:
- La comparación ha filtrado todo. Es el caso bueno:
up{job="node"} == 0sale vacía porque todas las máquinas responden. - La serie todavía no existe. La librería de la API crea la serie de cada combinación de etiquetas la primera vez que la usa: hasta que la API no devuelve su primer error 5xx desde que arrancó, no hay ninguna serie con
status5xx. Mientras tanto, la parte de arriba del ratio de errores sale vacía, y la división también, en lugar de dar 0. - La serie ha dejado de existir. Si un target desaparece de la configuración de Prometheus, o deja de listarlo el descubrimiento que lo generaba, su serie
updesaparece con él, yup{job="app"} == 0no devuelve nada aunque la API lleve horas sin responder. Para eso estáabsent():absent(up{job="app"})devuelve una serie con valor 1 precisamente cuando no existe ninguna serie que coincida, y nada cuando sí existe. Por eso la condición de servicio caído esup{job="app"} == 0 or absent(up{job="app"}), que cubre los dos casos.absent_over_time(serie[10m])hace lo mismo para "no ha habido ninguna muestra en 10 minutos". - La métrica no se guarda. El bloque
keepdel job de cAdvisor solo deja pasar diez métricas. Una consulta sobre cualquier otra métrica de cAdvisor sale vacía para siempre, sin error: hay que añadirla antes a esa lista.
La forma de saber en cuál se está es siempre la misma: ejecutar la consulta sin la comparación y, si sigue vacía, cada parte de la división por separado, hasta encontrar la que no devuelve nada.
Tabla de umbrales del contenedor de referencia¶
De dónde salen los números. Con las herramientas anteriores ya se pueden escribir las seis condiciones del servicio. Los umbrales orientativos salen de la documentación del servicio del curso: límite de memoria de 512 MiB para app, objetivo de latencia p95 de 500 ms (el p95 es el tiempo por debajo del cual quedan el 95 % de las peticiones), disponibilidad del 99 %, que equivale a admitir un 1 % de errores, y el max_connections de PostgreSQL, que en el servicio db es el valor por defecto, 100.
| Indicador | Consulta | Umbral orientativo | Severidad |
|---|---|---|---|
| Tasa de errores | sum(rate(app_http_requests_total{status=~"5.."}[5m])) / sum(rate(app_http_requests_total[5m])) |
> 1 % durante 5 min | critical |
| Latencia p95 | histogram_quantile(0.95, sum by (le)(rate(app_http_request_duration_seconds_bucket[5m]))) |
> 0,5 s durante 10 min | critical |
| Servicio ausente | up{job="app"} == 0 or absent(up{job="app"}) |
2 min | critical |
| Memoria del contenedor | container_memory_working_set_bytes{service="app"} / container_spec_memory_limit_bytes{service="app"} |
> 90 % durante 5 min | warning |
| Reinicios | changes(process_start_time_seconds{job="app"}[1h]) |
> 3 en 1 h | warning |
| Conexiones a PostgreSQL | sum by (service)(pg_stat_activity_count) / on(service) group_left pg_settings_max_connections |
> 80 % | warning |
Por qué cada umbral. Los dos primeros traducen la documentación tal cual: más de un 1 % de errores sostenido es incumplir la disponibilidad prometida, y un p95 por encima de 500 ms, el objetivo de latencia. El de servicio ausente no tiene número: basta con que la API no conteste durante dos minutos de scrapes seguidos. Los tres restantes son causas, y por eso son warning, pero causas que anuncian un síntoma cercano. La memoria se mide con el working set, el número que de verdad acerca al contenedor a su límite, y si llega al límite el kernel mata su proceso; como el umbral es un porcentaje, vale igual con el mem_limit que tenga app en el compose de app01. Más de tres arranques en una hora es un contenedor que se cae y vuelve una y otra vez, algo que la política restart del compose del servicio disimula. Y al llegar a max_connections, PostgreSQL rechaza las conexiones nuevas y la API empieza a devolver errores.
Con qué etiquetas se eligen las series. En las consultas sobre cAdvisor los contenedores se eligen con service="app", la etiqueta que el job de cAdvisor copia del servicio de compose, y no con name, que lleva el nombre largo del contenedor (servicio-app-1) y cambia si se escala o se renombra. En las de la API y de PostgreSQL se usan job o service, las etiquetas que pone el scrape.
De valores orientativos a definitivos. Los valores de partida vienen de la documentación; los definitivos se ajustan con una o dos semanas de datos reales. Un umbral del 1 % de errores es absurdo si el servicio lleva un 0,8 % permanente por un endpoint de salud mal configurado: primero se arregla eso, luego se pone el umbral. Para ver la distribución histórica de un indicador antes de fijarlo, quantile_over_time(0.99, app:errors:ratio5m[7d]) da el valor que solo se supera el 1 % del tiempo. app:errors:ratio5m es el ratio de errores guardado como métrica propia, que se crea el 5 de noviembre con las recording rules.
Cómo elegir el for¶
El problema: los picos. Una condición puede cumplirse un momento por pura casualidad: un par de peticiones lentas, un scrape que llega tarde. Si cada vez que se cumple se avisara a alguien, la fatiga de alertas estaría garantizada. El for es el filtro contra esos picos: el tiempo que la condición tiene que mantenerse cierta, sin interrupción, antes de que la alerta se notifique. Prometheus evalúa cada regla de alerta cada 15 s (el evaluation_interval de la pila mínima); mientras la condición se cumple pero todavía no ha pasado el for, la alerta está en espera (pending), y solo cuando lo cumple pasa a disparada (firing) y se notifica. Estas consultas se convierten en reglas el 10 de noviembre, y ese día se ven todos los estados de una alerta.
flowchart LR
M["<b>Métrica cruda</b>"]:::dato
R["<b>rate(...[5m])</b><br><small>suaviza el pico: 5 min de retraso</small>"]:::pieza
C{"<b>¿La condición<br>sigue siendo cierta?</b>"}:::act
PEN["<b>pending</b><br><small>durante todo el for</small>"]:::pieza
FIR(["<b>firing</b><br><small>ahora sí se notifica</small>"]):::riesgo
NADA(["<b>Se olvida</b><br><small>era un pico</small>"]):::ok
M --> R --> C
C -- "sí, sin interrupción" --> PEN --> FIR
C -- "deja de serlo" --> NADA
classDef act fill:#ea580c22,stroke:#ea580c,stroke-width:1.5px
classDef pieza fill:#64748b22,stroke:#64748b,stroke-width:1.5px
classDef dato fill:#2563eb22,stroke:#2563eb,stroke-width:1.5px
classDef infra fill:#a1a1aa14,stroke:#a1a1aa,stroke-width:1.5px
classDef ok fill:#16a34a22,stroke:#16a34a,stroke-width:1.5px
classDef riesgo fill:#dc262622,stroke:#dc2626,stroke-width:1.5px
Los dos retrasos se suman: con [5m] y for: 5m el aviso llega diez minutos tarde. La pregunta es si el usuario aguanta diez minutos de errores.
Tres criterios para elegirlo:
- Los retrasos se suman. La ventana de
rateya suaviza: con[5m], la media tarda hasta cinco minutos en reflejar del todo un cambio. Unfor: 5mañade otros cinco minutos de espera encima, así que el aviso puede llegar hasta diez minutos después de que empiecen los errores. La pregunta que decide es si el usuario aguanta diez minutos así. - Cero para lo que ya ha pasado. Un OOM kill (el kernel ha matado el contenedor por pasarse de memoria) es un suceso, no una tendencia: esperar no aporta información.
for: 0m, o simplemente no ponerlo. - Proporcional al coste de una falsa alarma. Si la notificación despierta a alguien,
forlargo; si abre una incidencia que se mirará mañana, corto.
El caso contrario: alarmas que parpadean. Prometheus tiene además keep_firing_for (desde la versión 2.42), que mantiene la alerta disparada un tiempo después de que la condición deje de cumplirse. Sirve para el problema opuesto al de for: una alarma que oscila alrededor del umbral (lo que se llama flapping) y manda una pareja de notificaciones, disparo y resolución, cada pocos minutos. Con keep_firing_for: 10m se queda encendida hasta que lleve diez minutos limpia.
A2.1 Umbrales sobre contadores (sesión 8)¶
Objetivo Las seis condiciones que la unidad va a convertir en alarmas, escritas en PromQL, probadas con tráfico real y con un umbral que se puede defender: el valor normal medido en tu servicio y la cifra de la documentación de la que sale. Al terminar tienes docs/umbrales.md, la base de las reglas de alerta del 10 de noviembre y lo primero que mira la práctica evaluable en el criterio b.
Antes de empezar
app01ymon01encendidas, con todos los targets dehttp://mon01:29090en UP.- Los nombres
app01ymon01en el/etc/hostsde tu puesto y de mon01, como los dejó la A1.4 (hasta el 27 de noviembre nadie más los resuelve); si el DHCP les ha cambiado la IP, corrígela. - La documentación del servicio (la da el profesor): límite de memoria, latencia p95, disponibilidad y
max_connections. - Explicado al principio: De la métrica a la alarma y Alertar por síntomas, para el criterio; rate, increase y la ventana, Operadores con etiquetas y Una consulta vacía no es un cero, para las consultas; la tabla de umbrales y Cómo elegir el for, para los números.
Pasos
Primero preparas el terreno: el repositorio donde va a vivir todo lo de la unidad y un tráfico de fondo constante, sin el cual los ratios no tienen nada que dividir. Después mides cuánto vale cada indicador en un día normal, provocas fallos para ver que las consultas reaccionan y, solo al final, fijas los umbrales. El orden importa: un umbral fijado sin conocer el valor normal acaba siendo una alarma permanente o una que no suena nunca.
-
Crea el repositorio de la unidad. Todo lo que se escribe en esta unidad (umbrales, cadenas, reglas, la configuración de Alertmanager y el receptor de incidencias) va al repositorio
alerting, en/opt/alertingde mon01, y es lo que entregas en la práctica evaluable. Créalo hoy consecrets/en el.gitignoredesde el primer commit, porque ahí irán el 12 y el 17 de noviembre el token del bot de Telegram y el de Gitea, y con unREADMEque anote que todavía no tiene remoto: lo recibe en la A2.6. -
Pon tráfico de fondo. Deja en tu puesto, toda la sesión, un bucle de peticiones contra
http://app01:20080/itemsque meta de vez en cuando una ruta inexistente. Sin tráfico, la velocidad de las peticiones es cero, los ratios dividen entre cero y no hay valor normal que medir; las rutas inexistentes sirven para comprobar que un 404 no cuenta como error del servicio. Las hojas siguientes cuentan con este bucle.Pista: con
curl -sel bucle falla en silencio, así que comprueba antes que la API da 200. -
Mide el valor normal de cada indicador. Ejecuta en Prometheus las seis consultas de la tabla de umbrales y apunta el valor que se mantiene con el tráfico de fondo: es el "valor normal", la referencia contra la que fijarás cada umbral. Si una consulta sale vacía, averigua por qué antes de seguir, con Una consulta vacía no es un cero delante: una consulta vacía no tiene valor normal y una alarma escrita sobre ella no dispararía nunca.
Pista: la de servicio ausente lleva ya su condición y, con la API respondiendo, vacía es lo correcto; su número se lee en
up{job="app"}. -
Comprueba que reaccionan al fallo. Un valor estable no demuestra nada hasta que lo ves moverse cuando el servicio falla. Reinicia
appcuatro veces, con medio minuto entre una y otra, y la de reinicios tiene que marcar 4; después paradbun par de minutos, hasta que el ratio de errores suba hacia 1, y vuelve a arrancarla. Fíjate también en qué hacen las demás con cada fallo, porque no todas reaccionan igual.Pista: el medio minuto es para que Prometheus llegue a hacer scrape de cada arranque; con menos, alguno puede escapársele.
-
Fija los umbrales y justifícalos. Con el valor normal y la documentación delante, decide el umbral, el
fory la severidad de cada indicador, con los criterios de Cómo elegir el for y de Alertar por síntomas. Escríbelo endocs/umbrales.md, con la tablaIndicador | Consulta | Valor normal | Umbral | for | Severidad | De dónde sale(la página o el parámetro de la documentación), y haz commit en el repositorio. Es el documento del que saldrán las reglas de alerta.Pista: un umbral por debajo del valor normal es una alarma permanente.
Comprobación Las seis consultas devuelven una serie con el tráfico de fondo y reaccionan al paso 4; docs/umbrales.md está comprometido en el repositorio con las siete columnas rellenas.
Para practicar Guarda docs/umbrales.md en el repositorio alerting.
Sesión 9 · Cadenas en logs y eventos¶
Los umbrales de la A2.1 vigilan números, y hay fallos que no aparecen en ningún número porque la aplicación no los cuenta: una excepción concreta, un tiempo de espera agotado al hablar con la base de datos, un contenedor que el kernel ha matado por falta de memoria. Esos fallos sí dejan rastro en texto, en los logs de la API y en los eventos del demonio Docker que ya llegan a Loki, y hoy se escriben las condiciones sobre ese texto. Hasta ahora LogQL, el lenguaje de consulta de Loki, solo se ha usado para comprobar que los logs llegaban; ahora hace falta encontrar una línea concreta entre miles y convertir su aparición en un número, porque una alarma es una comparación con un umbral. La teoría sigue el orden en que se escribe una de estas consultas: elegir de qué flujos se lee, filtrar las líneas, extraer los campos de cada una y contar las que quedan. El último apartado presenta el ruler de Loki, el componente que evalúa estas consultas solo, igual que Prometheus las suyas.
Cadenas en logs y eventos¶
Qué es una cadena aquí. Una cadena es un texto fijo cuya aparición en un log significa algo concreto: un mensaje de error de la API, un código interno, el nombre de un evento de Docker como oom o health_status: unhealthy. No se inventan: salen del código de la aplicación o de su documentación, porque solo ahí está escrito qué mensaje se escribe en cada situación. En la pila del curso ese texto está en Loki y se consulta con LogQL, un lenguaje inspirado en PromQL: las mismas llaves para elegir por etiquetas, funciones de rango parecidas.
Selectores de stream y filtros de línea¶
Lo barato y lo caro. Loki solo indexa las etiquetas, como se vio al montarlo. Cada combinación distinta de etiquetas es un stream, un flujo de líneas que se guarda junto en bloques comprimidos, y el texto de las líneas no tiene índice. De ahí sale la forma de toda consulta LogQL, en dos partes. La primera es el selector de stream, entre llaves, que elige qué flujos se leen por sus etiquetas, igual que en Prometheus; es lo único que usa el índice, y por eso es barato. La segunda es una tubería de filtros y parsers que se aplican línea a línea a lo que el selector ha dejado; eso obliga a descomprimir y recorrer cada línea, y es lo caro. Por eso el selector debe ser lo más estrecho posible.
{service="app"} # todo el log de la API
{service="app", level="error"} # solo sus líneas de error
{service="app"} |= "timeout" # líneas que contienen timeout
{service="app"} |= "timeout" != "/health" # ...pero no las de /health
{service="app"} |~ "timeout|refused" # expresión regular (RE2)
{service=~"app|nginx"} !~ "GET /static/.*" # regex negada
Los filtros de línea. Son cuatro: |= (contiene), != (no contiene), |~ (cumple una expresión regular) y !~ (no la cumple). Las expresiones regulares usan la sintaxis RE2, la de Go. Se encadenan y se evalúan en orden, así que conviene poner primero el que descarte más líneas.
Qué etiquetas hay. Las del selector son las que pone Promtail en app01: host, service, container y level, y en los eventos de Docker, además, action. Si se cambiaron, cambian aquí; logcli labels dice las que existen de verdad. Un detalle que rompe muchas reglas: los filtros de línea distinguen mayúsculas. La API escribe el nivel en minúsculas ("level":"error"), así que |= "ERROR" no encuentra nada. Para el nivel se usa la etiqueta level, que Promtail ya sube al índice, y para un texto del que no se sabe cómo está escrito, una expresión regular que empiece por (?i), que no distingue mayúsculas.
Parsers: json y logfmt¶
Para qué sirve un parser. Un filtro de línea solo sabe si un texto aparece o no. Cuando la línea es JSON se puede hacer más: filtrar por el valor de un campo concreto (que el mensaje empiece por timeout, que una petición haya tardado más de 2 segundos) o quedarse solo con algunos campos. Un parser lee cada línea y convierte sus campos en etiquetas temporales, que existen solo mientras dura la consulta y no se guardan en el índice, así que no crean cardinalidad. La API del curso escribe JSON desde la A1.4, con los campos ts, level, logger, msg y request_id, y el nginx del servicio, con otros como status, uri y duration_s (en segundos). Para ellos el parser es | json. Los ejemplos usan una línea de error de la API, como {"level":"error","msg":"timeout al consultar la base de datos","request_id":"3f9a1c..."}, y el log de acceso de nginx, que lleva además status y duration_s:
{service="app", level="error"} | json | msg=~"timeout.*"
{service="nginx"} | json | duration_s > 2
{service="app", level="error"} | json | line_format "{{.request_id}} {{.msg}}"
Conviene fijarse en tres cosas. msg=~"timeout.*" después del parser es un filtro de etiqueta (comparación exacta o con expresión regular) sobre un campo extraído, no un filtro de línea, y los campos numéricos se pueden comparar como números. level no hace falta extraerlo: ya es etiqueta del stream, y filtrar por él en el selector es más barato. Y line_format reescribe la línea con los campos extraídos, con una plantilla de huecos entre dobles llaves, útil para que la notificación lleve solo lo que importa.
Los otros formatos. En el laboratorio todo lo que llega a Loki es JSON (la API, nginx desde la A1.4 y los eventos de Docker), así que | json basta. Fuera de él aparecen otros dos casos. | logfmt hace lo mismo con el formato clave=valor clave2="valor con espacios" que usan Docker, Loki, Grafana y buena parte del software escrito en Go: el demonio Docker, si llega a Loki a través de journald (el registro del sistema de systemd), escribe así. Y para formatos propios, como el log de acceso de nginx en su formato de fábrica, está | pattern "<ip> - - <_> \"<method> <path> <_>\" <status> <_>", que extrae campos por su posición sin escribir una expresión regular.
El coste. | json sobre un stream de 2 000 líneas por segundo se nota. Conviene estrechar primero con el selector (level="error") o con un filtro de línea barato (|= "timeout", que solo busca una subcadena) y parsear después solo las líneas que sobreviven.
Métricas sobre logs¶
Del texto al número. Una consulta como las anteriores devuelve líneas, y una línea no se puede comparar con un umbral. LogQL convierte una consulta de líneas en una métrica envolviéndola en una función de rango, igual que rate() envuelve un contador en PromQL: la ventana va entre corchetes al final de la consulta de líneas, y el resultado es un número por stream. Con eso ya se puede escribir una condición:
sum(count_over_time({service="app", level="error"} [5m])) > 10
sum(count_over_time({service="app", level="error"} | json | msg=~"timeout.*" [5m])) > 0
sum by (host)(count_over_time({service="docker-events", action="oom"} [10m])) > 0
sum(rate({service="nginx"} | json | status >= 500 [5m]))
Qué función para qué. count_over_time cuenta líneas en la ventana, y es la de casi todas las alarmas de cadenas, porque la pregunta es "¿ha aparecido?" o "¿ha aparecido más de N veces?". rate divide esa cuenta entre los segundos de la ventana. bytes_over_time y bytes_rate miden volumen, útil para detectar un contenedor que se pone a escribir sin control. El sum de fuera junta los streams, igual que en PromQL; con sum by (host) se conserva la máquina, que es la etiqueta que dice de qué host es cada alarma.
Latencias sin instrumentar. Si se ha extraído un campo numérico con un parser, | unwrap duration_s lo convierte en el valor de la muestra, y entonces quantile_over_time(0.95, {service="nginx"} | json | unwrap duration_s [5m]) da un p95 de latencia calculado desde los logs de nginx, sin instrumentar nada. Es más caro que un histograma de Prometheus y menos preciso, pero funciona con aplicaciones que no exponen métricas, que es la mayoría de las heredadas que se encuentran en una empresa.
Dónde se guardan las cadenas. Las cadenas a vigilar se mantienen en un fichero versionado junto a las reglas, con una entrada por cadena que diga de dónde sale y qué significa que aparezca, porque dentro de seis meses nadie recordará por qué se vigila "E1042". En el laboratorio ese fichero es docs/cadenas.md, y lo escribe la hoja de hoy.
Alertas en el ruler de Loki¶
El problema. Las consultas de arriba se ejecutan a mano, en Grafana o con logcli. Para que sean alarmas, alguien tiene que evaluarlas cada minuto y avisar cuando se cumplen, como hará Prometheus con las suyas. Loki lleva para eso un componente propio, el ruler: evalúa reglas escritas con la misma estructura que las de Prometheus, pero con expresiones LogQL, y manda las que se cumplen al mismo Alertmanager. Así las alarmas sobre texto entran en la misma cadena que las de métricas, con las mismas rutas, silencios e incidencias, sin pasar por Grafana. La hoja de hoy no lo usa: el ruler se pone en marcha el 10 de noviembre, en la A2.4, junto con las reglas de alerta de Prometheus.
Cómo se activa. Con un bloque ruler en loki.yml:
ruler:
storage:
type: local
local:
directory: /loki/rules
rule_path: /tmp/loki-rules-scratch
alertmanager_url: http://alertmanager:29093
enable_api: true
evaluation_interval: 1m
Cada opción responde a una pregunta:
storagedice de dónde lee las reglas: del disco local, en/loki/rules.rule_pathes un directorio de trabajo donde el ruler deja copias temporales de las reglas mientras las evalúa; no hay que tocarlo.alertmanager_urldice a quién manda las alertas: al Alertmanager de la misma pila, por su nombre de servicio en la red de compose.enable_apipublica la API con la que se consultan las reglas cargadas, para comprobar que el ruler las ha leído.evaluation_intervales cada cuánto evalúa las reglas.
Dónde van las reglas. Loki está pensado para servir a varios clientes a la vez (inquilinos) y guarda las reglas de cada uno en un subdirectorio con su nombre. Con la autenticación multiinquilino desactivada (auth_enabled: false, como en el laboratorio) solo hay un inquilino y se llama fake, así que las reglas van en /loki/rules/fake/. El fichero loki-alerts.yml de ese directorio tiene la misma forma que uno de Prometheus, con dos cosas que conviene ver: las expresiones son LogQL, y cada regla lleva las cinco etiquetas de categorización de todas las alarmas del curso (severity, team, service, origen y env):
groups:
- name: app_logs
interval: 1m
rules:
- alert: AppLogErrorsBurst
expr: sum(count_over_time({service="app", level="error"} [5m])) > 10
for: 2m
labels:
severity: warning
team: dev
service: app
origen: loki
env: dev
annotations:
summary: "Más de 10 líneas de error en 5 min en la API"
runbook: "https://wiki.lab/runbooks/app-log-errors"
- alert: AppDbTimeout
expr: sum(count_over_time({service="app", level="error"} | json | msg=~"timeout.*" [5m])) > 0
for: 0m
labels:
severity: critical
team: ops
service: app
origen: loki
env: dev
annotations:
summary: "La API registra timeouts contra la base de datos"
- alert: ContainerOOM
expr: sum by (host)(count_over_time({service="docker-events", action="oom"} [10m])) > 0
labels:
severity: critical
team: ops
service: app
origen: docker-events
env: dev
annotations:
summary: "OOM kill en {{ $labels.host }}"
Lo que dicen las tres reglas. AppLogErrorsBurst avisa de una racha de errores en el log de la API, más de diez en cinco minutos, con un for corto. AppDbTimeout vigila una cadena concreta, y basta con que aparezca una vez. ContainerOOM no lleva for porque un OOM ya ha pasado, como se vio en Cómo elegir el for, y agrupa por host para que la alarma diga en qué máquina ha sido. La cadena de AppDbTimeout es un ejemplo: la buena es la que diga el código de la API, y sale de la A2.2.
Cómo se comprueba. El fichero se valida antes de cargarlo con lokitool rules lint. lokitool es la utilidad de línea de comandos de Loki para trabajar con reglas; no viene en la imagen de Loki, sino como binario aparte en la misma release que logcli, y se instala igual. Sin más opciones, lint además reescribe el fichero con su propio formato (ordena las claves y cambia la sangría); con -n (prueba en seco) solo comprueba y no toca nada. Las reglas se aplican reiniciando el contenedor de Loki, y las activas se ven en GET /loki/api/v1/rules y en /prometheus/api/v1/alerts, que Loki expone imitando la API de Prometheus. El ruler también admite record: para recording rules sobre logs, cuyo resultado puede escribirse en Prometheus con remote_write (el mecanismo con el que Prometheus acepta series enviadas desde fuera); aquí no se usa, pero es la forma de tener un contador de errores de log en Prometheus sin instrumentar la aplicación.
A2.2 Cadenas en logs y eventos (sesión 9)¶
Objetivo Las condiciones sobre texto que completan las de la A2.1: tres cadenas de error de la API, sacadas de su código, y los dos eventos de Docker que delatan a un contenedor en apuros (oom y unhealthy), cada una con una consulta LogQL probada con logcli contra líneas reales. Al terminar tienes docs/cadenas.md, con el origen de cada cadena, del que saldrán las reglas del ruler de Loki en la A2.4.
Antes de empezar
- Loki con los logs de
appy los eventos Docker,logclien tu puesto conLOKI_ADDR=http://mon01:23100y el bucle de tráfico de la A2.1 corriendo. - El código de la API (
/opt/servicio/api/en app01) o su documentación. - Explicado al principio: Cadenas en logs y eventos, hasta Métricas sobre logs. El ruler no hace falta hoy.
Pasos
Vas del origen a la métrica. Primero buscas en el código qué mensajes puede escribir la API, porque una cadena que no sale del código es una suposición. Después escribes la consulta que encuentra cada uno y la pruebas provocando el fallo, porque una consulta que no ha devuelto ninguna línea no está probada. Haces lo mismo con los eventos de Docker y, al final, conviertes las consultas en números, que es lo que necesita una alarma.
-
Busca las cadenas en su origen. Saca del código de la API o de su documentación tres mensajes de error distintos, con su fichero y su línea. Son los textos exactos que escribirá el log cuando pase cada cosa, y anotar de dónde salen es lo que permitirá a quien venga después saber por qué se vigilan, como se explica en Métricas sobre logs.
-
Encuentra cada mensaje en Loki. Escribe una consulta LogQL por mensaje, al menos una con
| jsony filtro por un campo, y pruébala conlogcli. Empieza por el selector más estrecho que puedas, como en Selectores de stream y filtros de línea, y deja el parser para el final. Una consulta que no devuelve ninguna línea no está probada: provoca el error y repite.Pista: saca el selector de
logcli labels, no de memoria. Condbparada treinta segundos, la API escribe errores. -
Provoca los dos eventos de Docker y encuéntralos. Un evento no se espera a que pase: se provoca. En app01, provoca un
oomy ununhealthycon dos contenedores de usar y tirar, encuéntralos en Loki con las consultas de Eventos del demonio Docker y borra después el contenedor enfermo, que si no seguiría ahí.Pista:
docker run --rm -m 32m python:3-slim python -c "x = bytearray(200 * 1024 * 1024)"muere por falta de memoria; elunhealthysale de--health-cmd "exit 1"con un intervalo corto. Suactionlleva el estado dentro (health_status: unhealthy): se filtra con una expresión regular. -
Convierte las consultas en números. Una alarma compara un número con un umbral, así que una consulta de la API y las dos de eventos tienen que pasar a consultas métricas, como en Métricas sobre logs, que den más de cero cuando aparece la cadena. Son las que irán al ruler en la A2.4.
-
Documenta cada cadena. Escribe
docs/cadenas.mdcon una entrada por cadena: la consulta, de dónde sale (fichero y línea, o el evento de Docker) y qué significa que aparezca. Haz commit en el repositorio.
Comprobación Cada consulta de docs/cadenas.md devuelve al menos una línea con logcli; las de eventos encuentran el oom y el unhealthy provocados; las tres métricas dan más de cero.
Para practicar Guarda docs/cadenas.md en el repositorio alerting: lo pide la práctica evaluable.
Sesión 10 · Recording rules¶
Las seis consultas de la tabla de umbrales funcionan, pero en cuanto se usan de verdad aparecen tres problemas. Son largas y se copian en cada panel y en cada alerta, y en cuanto una copia cambia un detalle (una ventana de 2 minutos donde las demás tienen 5) el panel y la alarma dejan de decir lo mismo. Algunas son caras, como el percentil de latencia, y se recalculan enteras cada vez que alguien abre el dashboard. Y el indicador que importa, el ratio de errores, no existe como métrica en ningún sitio: hay que reconstruirlo cada vez a partir de dos contadores. Esta sesión resuelve los tres a la vez con las recording rules, que calculan cada indicador una sola vez y lo guardan como una métrica nueva con nombre propio. La teoría va en orden: por qué precalcular, cómo se nombran las métricas que resultan y el fichero que las define. La hoja carga ese fichero en Prometheus y cambia el dashboard de la A1.2 para que use las métricas nuevas. Son también las que usarán las reglas de alerta, que se escriben el 10 de noviembre.
Agregación y correlación: recording rules¶
Qué es. Una recording rule es una regla que Prometheus evalúa solo, cada cierto tiempo: ejecuta una expresión PromQL y guarda el resultado como una serie nueva, con su propio nombre, en su base de datos. A partir de ese momento es una métrica más: se consulta, se grafica y se alerta sobre ella igual que sobre up, pero ya viene calculada. Las reglas son parte del propio Prometheus, así que no hace falta ningún componente nuevo, solo un fichero que Prometheus lea al arrancar o al recargar. Al terminar este apartado queda un rules.yml con los indicadores del contenedor de referencia, listos para las reglas de alerta.
Por qué precalcular¶
Tres razones, en orden de importancia para este trabajo:
Correlar. El indicador "ratio de errores" no existe en ninguna métrica: existe en la relación entre dos contadores. La recording rule lo materializa, y desde ese momento tiene nombre e histórico y se consulta como cualquier otra serie. Lo mismo con el porcentaje de memoria usada o las conexiones respecto al máximo. Es literalmente lo que pide el criterio c de la unidad: agregar y correlar contadores para obtener indicadores nuevos.
Agregar. Pasar de una serie por instancia a una serie por servicio. Si la API tuviera tres réplicas, cada una con sus contadores, la alarma no debería mirar cada réplica, sino el servicio: que una réplica tenga un 3 % de errores cuando el balanceador (el proxy que reparte las peticiones entre réplicas) ya ha dejado de mandarle tráfico no es un incidente. Con una sola réplica, como en el laboratorio, el sum by (service) no cambia el resultado, pero deja la regla preparada para cuando haya más.
Aligerar. histogram_quantile sobre un histograma con una docena de cubos y tres réplicas, calculado cada vez que un panel de Grafana se refresca y cada vez que una regla se evalúa, es un coste que se multiplica. Precalculado una vez cada 30 s, cuesta lo mismo pintar diez paneles que uno.
Hay una cuarta razón menos evidente: coherencia. Si el panel calcula el ratio de errores con una fórmula y la alerta con otra ligeramente distinta (ventana de 5 minutos en una, de 2 en la otra), habrá alarmas disparadas con paneles en verde, y nadie se fiará de ninguno de los dos. Con la recording rule, ambos usan la misma serie.
Convención de nombres¶
Por qué una convención. Una métrica grabada se va a usar en paneles, en alertas y en las unidades siguientes, y su nombre tiene que decir, sin abrir el fichero, qué se ha calculado y sobre qué. Prometheus recomienda la forma nivel:métrica:operaciones, con dos puntos como separador. Los dos puntos están reservados para las reglas grabadas: los exporters y las librerías de instrumentación no deben usarlos, así que un nombre con dos puntos nunca choca con una métrica exportada y se reconoce a simple vista como calculada.
niveles el nivel de agregación: de qué se habla después de agregar. La guía oficial pone ahí el nombre de las etiquetas que quedan (service:...); el laboratorio lo simplifica y pone el servicio al que se refiere,appodb, porque todas sus reglas agregan por servicio y así el nombre dice además de cuál.métricaes el nombre de la métrica base sin el sufijo_total, o el del indicador:requests,errors,latency_p95.operacioneses lo que se le ha hecho, incluida la ventana:rate5m,ratio5m,sum.
Así, app:errors:ratio5m se lee como "en el servicio app, ratio de errores, ventana de 5 minutos". Cuando se encadenan reglas (una que usa el resultado de otra), el nombre de la segunda hereda el nivel y añade operaciones. La guía oficial, con más ejemplos, está en prometheus.io/docs/practices/rules.
El fichero rules.yml¶
Qué lleva. Cuatro indicadores de la tabla de umbrales (errores, latencia, memoria y conexiones) convertidos en métricas grabadas, más app:requests:rate5m, que no tiene umbral pero sirve de auxiliar para los paneles de tráfico. La ausencia y los reinicios no se graban, porque su consulta no calcula ningún indicador: compara una serie con un valor y nada más. El fichero se organiza en grupos (groups), cada uno con un nombre, un intervalo de evaluación y su lista de reglas; cada regla es una pareja de record (el nombre de la métrica nueva) y expr (la consulta que la calcula):
groups:
- name: app_rules
interval: 30s
rules:
- record: app:requests:rate5m
expr: sum by (service)(rate(app_http_requests_total[5m]))
- record: app:errors:ratio5m
expr: |
sum by (service)(rate(app_http_requests_total{status=~"5.."}[5m]))
/
sum by (service)(rate(app_http_requests_total[5m]))
- record: app:latency_p95:5m
expr: histogram_quantile(0.95, sum by (service, le)(rate(app_http_request_duration_seconds_bucket[5m])))
- record: app:memory:ratio
expr: |
container_memory_working_set_bytes{service="app"}
/ ignoring(id)
container_spec_memory_limit_bytes{service="app"}
- record: db:connections:ratio
expr: sum by (service)(pg_stat_activity_count) / on(service) group_left pg_settings_max_connections
Conviene fijarse en tres cosas, que aplican lo visto con los umbrales: el nombre de cada regla sigue la convención, el sum by (service) agrega por servicio, y la memoria y las conexiones llevan los ignoring y group_left de Operadores con etiquetas. El interval: 30s hace que el grupo se evalúe cada 30 segundos en lugar de cada 15, el evaluation_interval general: con ventanas de 5 minutos el indicador apenas cambia en 15 segundos, y así se calcula la mitad de veces.
El orden dentro de un grupo. Todas las reglas de un grupo se evalúan en secuencia, cada interval, así que dentro de un grupo una regla puede usar el resultado de la anterior en la misma pasada. Entre grupos no hay orden garantizado.
Cómo se carga. El fichero se referencia desde prometheus.yml con rule_files: [ "rules.yml", "alerts.yml" ], y antes de cada recarga se valida con promtool check rules rules.yml (promtool es la utilidad de línea de comandos que acompaña a Prometheus y viene en su misma imagen). La recarga en caliente es un POST a /-/reload, que solo funciona si Prometheus arrancó con --web.enable-lifecycle (la pila mínima del curso ya lo lleva), o una señal HUP al proceso. Un error de sintaxis con recarga en caliente no tumba Prometheus: se queda con la configuración anterior y lo registra en el log, así que después de cada cambio conviene mirar prometheus_config_last_reload_successful, que vale 0 si la última recarga falló.
La norma del repositorio. Las alertas sobre indicadores calculados (ratios, percentiles, porcentajes) usan su métrica grabada, no la consulta cruda: así la alarma y el panel no pueden dejar de coincidir. Es la norma del repositorio alerting y se revisa en la práctica evaluable. Las de ausencia (AppDown, HostDown) y la de reinicios (AppRestarting) se quedan sobre la serie cruda a propósito, porque no calculan ningún indicador.
A2.3 Recording rules (sesión 10)¶
Objetivo Que los indicadores del servicio existan en Prometheus como métricas con nombre propio, calculadas una sola vez, y que el dashboard las use. Al terminar tienes un rules.yml cargado en Prometheus con las cinco métricas grabadas del ejemplo, visibles en el explorador y usadas en el dashboard de Grafana, y las reglas de alerta tienen ya sobre qué escribirse. Es la evidencia del criterio c.
Antes de empezar
- Las consultas de A2.1 funcionando;
prometheus.ymlycompose.ymlde mon01; el dashboard de la A1.2 en Grafana. - Explicado al principio: Por qué precalcular, Convención de nombres y El fichero rules.yml.
Pasos
Escribes las reglas, haces que Prometheus las cargue y, antes de usarlas en ningún sitio, compruebas que cada métrica grabada da lo mismo que la consulta de la que sale: una regla mal escrita da un número creíble y equivocado, y en un panel no se nota. Solo entonces cambias el dashboard para que las use.
-
Escribe las reglas. Crea
prometheus/rules.ymlen el repositorioalertingcon las cinco reglas de El fichero rules.yml, ajustando etiquetas (name,job,service) y consultas a las tuyas de la A2.1. Las reglas son tus consultas de la A2.1 con nombre propio: si allí tuviste que corregir una etiqueta para que no saliera vacía, la corrección va también aquí. -
Haz que Prometheus las cargue desde el repositorio. Las reglas viven en
alertingy Prometheus en la pila de/opt/monitoring, así que el fichero se monta en el contenedor, de solo lectura, en/etc/prometheus/rules.yml, y se referencia enrule_files. La pila mínima ya arranca Prometheus con--web.enable-lifecycle, así que acepta recargas en caliente. Valida el fichero conpromtoolantes de cada recarga: una recarga con errores no tumba Prometheus, pero tampoco carga nada.Pista:
promtoolviene en la imagen de Prometheus. Un montaje nuevo obliga a recrear el contenedor, recargar no basta; y una recarga fallida no avisa: miraprometheus_config_last_reload_successfuldespués de cada una. -
Comprueba que cada métrica grabada dice lo mismo que su consulta. Para cada una de las cinco, ejecuta la métrica grabada y la consulta cruda de la que sale y compara los valores. Si no coinciden, la regla no calcula lo que crees, y todo lo que se construya encima heredará el error.
Pista: nacen en la primera evaluación del grupo; espera un minuto.
-
Pásale el dashboard a las métricas grabadas. En el dashboard de la A1.2, cambia la consulta del porcentaje del límite de memoria por
app:memory:ratioy añade dos paneles, uno conapp:errors:ratio5my otro conapp:latency_p95:5m: así el panel y las alarmas leerán la misma serie, como pide la coherencia. Exporta el dashboard agrafana/dashboard.jsony haz commit derules.yml, el dashboard y una copia deprometheus.yml.
Comprobación promtool check rules responde SUCCESS; las cinco métricas aparecen en Graph con valor; el panel de memoria pinta lo mismo que antes y los dos nuevos tienen datos.
Para practicar Guarda prometheus/rules.yml y grafana/dashboard.json en el repositorio alerting.
Sesión 11 · Reglas de alerta¶
Cada alarma tiene ya su condición, su umbral, su for y, si es un indicador calculado, su métrica grabada. Pero todavía no avisa de nada: alguien tiene que ejecutar la consulta y mirar el resultado. Hoy esas condiciones pasan a ser reglas de alerta que Prometheus y el ruler de Loki evalúan solos, cada pocos segundos, y que mandan a Alertmanager las que se cumplen. Lo nuevo no es la condición, que ya está escrita, sino lo que viaja con cada alerta: unas etiquetas que dicen qué es, de quién es y cuánto urge, y que Alertmanager usará para decidir a quién avisar, y unas anotaciones con el texto que leerá la persona avisada. La teoría va en ese orden: el fichero de reglas, sus etiquetas y anotaciones, los estados por los que pasa una alerta, que explican por qué el aviso no llega en el mismo instante en que empieza el problema, y cómo se prueba una regla sin esperar a que falle nada. Las reglas de Loki se escriben con lo que ya explicó el apartado Alertas en el ruler de Loki.
Reglas de alerta¶
Qué es una regla de alerta. Tiene la misma estructura que una recording rule, pero en lugar de guardar el resultado de la consulta lo usa como condición: en cada evaluación, cada serie que devuelve la expresión es una alerta, y si la sigue devolviendo durante el for, Prometheus la manda a Alertmanager con sus etiquetas y anotaciones. Si la consulta devuelve tres series (tres contenedores por encima del umbral), son tres alertas distintas, cada una con sus etiquetas, y Alertmanager las agrupará después. La pila mínima ya tiene en prometheus.yml el bloque alerting, que le dice a Prometheus dónde está Alertmanager, así que desde que se carga la primera regla las alertas empiezan a llegarle.
El fichero. El alerts.yml del laboratorio traduce la tabla de umbrales a seis reglas, más HostDown, que avisa si una máquina deja de contestar al job node, el del node_exporter de app01 y mon01. Las expr de los indicadores calculados usan las métricas grabadas de rules.yml; las de ausencia y reinicios van sobre la serie cruda, porque no calculan nada. Los umbrales y los for son los orientativos de la tabla de umbrales, y en la hoja se cambian por los de cada docs/umbrales.md. Y las cinco etiquetas de labels se repiten en todas, por lo que explica el apartado siguiente:
groups:
- name: app_alerts
rules:
- alert: AppHighErrorRate
expr: app:errors:ratio5m > 0.01
for: 5m
labels:
severity: critical
team: ops
service: app
origen: prometheus
env: dev
annotations:
summary: "Errores 5xx al {{ $value | humanizePercentage }} en {{ $labels.service }}"
description: "El ratio de errores lleva 5 min por encima del 1 % (SLA). Revisa la API y el servicio db en app01."
runbook: "https://wiki.lab/runbooks/app-errors"
- alert: AppHighLatency
expr: app:latency_p95:5m > 0.5
for: 10m
labels: { severity: critical, team: ops, service: app, origen: prometheus, env: dev }
annotations:
summary: "p95 de {{ $value | humanizeDuration }} en {{ $labels.service }}"
runbook: "https://wiki.lab/runbooks/app-latency"
- alert: AppDown
expr: up{job="app"} == 0 or absent(up{job="app"})
for: 2m
labels: { severity: critical, team: ops, service: app, origen: prometheus, env: dev }
annotations:
summary: "El exporter de app no responde desde hace 2 min"
- alert: HostDown
expr: up{job="node"} == 0
for: 2m
labels: { severity: critical, team: ops, service: node, origen: prometheus, env: dev }
annotations:
summary: "{{ $labels.host }} no responde al node_exporter desde hace 2 min"
- alert: AppMemoryHigh
expr: app:memory:ratio > 0.9
for: 5m
labels: { severity: warning, team: dev, service: app, origen: cadvisor, env: dev }
annotations:
summary: "Memoria al {{ $value | humanizePercentage }} del límite en {{ $labels.name }}"
- alert: AppRestarting
expr: changes(process_start_time_seconds{job="app"}[1h]) > 3
for: 0m
labels: { severity: warning, team: dev, service: app, origen: prometheus, env: dev }
annotations:
summary: "app reiniciado {{ $value }} veces en 1 h"
- alert: DbConnectionsHigh
expr: db:connections:ratio > 0.8
for: 5m
labels: { severity: warning, team: ops, service: db, origen: prometheus, env: dev }
annotations:
summary: "PostgreSQL al {{ $value | humanizePercentage }} de max_connections"
Cargado este fichero y recargado Prometheus, las siete reglas aparecen en su página Alerts en verde (inactive: la condición no se cumple). Si alguna sale en amarillo o rojo nada más cargar, la condición ya se cumple con el servicio sano, y conviene revisar el umbral antes de seguir.
El catálogo cerrado. Con las tres reglas del ruler de Loki (AppLogErrorsBurst, AppDbTimeout y ContainerOOM), estas siete forman el catálogo de alarmas de la unidad. Es una lista cerrada de diez nombres: el resto del curso las cita así, y una alarma que no esté en la lista no existe en el repositorio alerting. Solo crece una vez, el 12 de enero, cuando el catálogo de alarmas de la UT4 le añade cinco.
Etiquetas y anotaciones¶
Por qué las etiquetas se deciden en la regla. Las etiquetas viajan con la alerta y son lo único que Alertmanager mira para agrupar, enrutar e inhibir, y el receptor de incidencias las usa además para clasificar cada incidencia. Lo que no esté en las etiquetas de la regla no lo puede saber ningún eslabón posterior de la cadena. Por eso el conjunto se fija aquí, en el origen, y es el mismo en todas las reglas:
| Etiqueta | Valores | Para qué la usa la cadena |
|---|---|---|
severity |
critical, warning, info | Elige el canal: Telegram para critical, correo para el resto |
team |
ops, dev | Las de dev llegan además al correo del equipo de desarrollo |
service |
app, db, web, y node para las alarmas de la máquina |
Agrupa las alertas del mismo servicio y calla las de un servicio caído |
origen |
prometheus, cadvisor, loki, docker-events | Dice de dónde sale el dato, para clasificar la incidencia |
env |
dev, pre | Separa entornos: la inhibición de AppDown solo calla las alertas de su mismo entorno |
Es la categorización que pide el criterio e de la unidad, decidida en el origen. A esas cinco etiquetas Prometheus añade las de la propia serie que ha cumplido la condición (instance, job, host, name, y service si viene de la recording rule, aunque la de la regla manda) y alertname, el nombre de la regla.
Las anotaciones, para la persona. Las anotaciones no se usan para enrutar: son el texto que lee quien recibe el aviso. Se escriben con plantillas de Go (Prometheus y Alertmanager están escritos en Go y usan su sistema de plantillas): texto con huecos entre dobles llaves. {{ $labels.service }} inserta una etiqueta, {{ $value }} el valor de la expresión en el momento de disparar, y las funciones humanize (1234567 pasa a 1.235M), humanizePercentage (0.0234 pasa a 2.34%), humanizeDuration (0.5 pasa a 500ms) y humanizeTimestamp hacen el valor legible. La convención del laboratorio: summary de una línea con el qué y el dónde, que es lo que cabe en la pantalla de un móvil; description con el contexto que no cabe en un mensaje de Telegram; y runbook con la URL del runbook, el procedimiento escrito de qué hacer cuando salta la alarma. En el laboratorio esa URL apunta de momento a una wiki de ejemplo: los runbooks se escriben el 12 de enero, en la unidad de indicadores y pruebas (UT4), y entonces cada regla pasa a enlazar su documento.
Estados de una alerta¶
Por qué el aviso no llega al instante. Entre que empieza un problema y que llega el mensaje pasan varios minutos, y no por lentitud: cada estado que atraviesa la alerta es un filtro puesto a propósito contra las falsas alarmas.
stateDiagram-v2
[*] --> inactive
inactive --> pending : la expresión devuelve series
pending --> inactive : deja de cumplirse antes de for
pending --> firing : se cumple durante for
firing --> firing : sigue cumpliéndose (repeat_interval)
firing --> resolved : deja de cumplirse (o tras keep_firing_for)
resolved --> inactive : Alertmanager envía send_resolved
inactive --> [*]
Los dos retardos que explican por qué el aviso no llega cuando se espera: el for vive entre pending y firing, y el send_resolved entre resolved e inactive.
Lo que se ve en Prometheus. Los tres primeros estados están en la página Alerts: inactive (verde, la condición no se cumple), pending (amarillo, se cumple pero todavía no ha pasado el for, y se ve cuánto lleva) y firing (rojo, ya se ha mandado a Alertmanager). Prometheus manda la alerta en cuanto entra en firing y la reenvía cada poco mientras siga así; cuando la condición deja de cumplirse, la manda una vez más con su hora de fin (endsAt) fijada en ese momento, y eso es lo que Alertmanager interpreta como resolved.
Si Prometheus se cae. Cada envío lleva una caducidad, unos minutos por delante, que Prometheus renueva en cada reenvío. Si Prometheus muere sin despedirse, nadie la renueva, y Alertmanager da la alerta por resuelta cuando caduca. El parámetro resolve_timeout de Alertmanager (5 minutos por defecto) hace lo mismo con las alertas que llegan sin caducidad, como las de prueba que se crean a mano.
Probar las reglas con promtool¶
El problema. Una regla de alerta es código, y el código se prueba. Pero esperar a que el servicio falle de verdad para ver si una regla dispara es lento (con for: 5m, como mínimo cinco minutos por prueba) y obliga a romper algo cada vez que se cambia un umbral. promtool, la misma utilidad que valida la sintaxis con promtool check rules, tiene otra orden para esto: promtool test rules toma series inventadas, simula el paso del tiempo y comprueba qué alertas tienen que estar disparadas en cada instante. Una prueba tarda un segundo y se queda en el repositorio: si alguien cambia el for o el texto de una anotación, la prueba falla antes de que el cambio llegue a Prometheus.
Cómo es una prueba. Un fichero YAML que dice qué reglas cargar, qué series inventar y qué se espera de ellas. Este comprueba que AppHighErrorRate todavía no ha disparado a los 2 minutos de un 5 % constante de errores, porque su for es de 5, y que a los 6 sí, con sus etiquetas y anotaciones exactas:
rule_files:
- ../alerts.yml
evaluation_interval: 15s
tests:
- interval: 15s
input_series:
- series: 'app:errors:ratio5m{service="app"}'
values: '0.05x40'
alert_rule_test:
- eval_time: 2m
alertname: AppHighErrorRate
exp_alerts: []
- eval_time: 6m
alertname: AppHighErrorRate
exp_alerts:
- exp_labels: { service: app, severity: critical, team: ops, origen: prometheus, env: dev }
exp_annotations:
summary: "Errores 5xx al 5% en app"
description: "El ratio de errores lleva 5 min por encima del 1 % (SLA). Revisa la API y el servicio db en app01."
runbook: "https://wiki.lab/runbooks/app-errors"
input_series define la serie inventada: '0.05x40' es el valor 0,05 repetido 41 veces, una por cada interval de 15 s, así que cubre diez minutos. alert_rule_test pide, en cada eval_time, la lista de alertas disparadas (firing) con ese nombre: vacía a los 2 minutos, porque la alerta aún está en pending, y con una alerta a los 6. Las etiquetas esperadas son las de la serie más las de la regla, sin alertname, y las anotaciones tienen que coincidir letra a letra con las que produce la plantilla: humanizePercentage convierte 0.05 en 5%.
A2.4 Reglas de alerta (sesión 11)¶
Objetivo Que las diez alarmas del catálogo se evalúen solas: siete en el alerts.yml de Prometheus y tres en el loki-alerts.yml del ruler de Loki, todas con las cinco etiquetas de categorización. Al terminar tienes además una prueba de promtool test rules que pasa y una alerta real observada pasando por pending y firing, con sus horas: la primera vez que ves la cadena funcionar sola, y la medida de cuánto tarda en avisar.
Antes de empezar
- Las métricas grabadas de A2.3 evaluándose;
docs/umbrales.mdydocs/cadenas.md;loki.ymlde mon01; el bucle de tráfico de A2.1 corriendo en tu puesto. lokitoolen mon01: es un binario de la misma release de Loki quelogcli, y se instala igual.- Explicado al principio: Reglas de alerta entero, con Probar las reglas con promtool para el paso 3; para Loki, Alertas en el ruler de Loki.
Pasos
Empiezas por las reglas de Prometheus, porque una de ellas sirve para medir: en cuanto están cargadas provocas su alarma, y como tarda en disparar, mientras esperas escribes la prueba de promtool y cargas las reglas de Loki. Al final lo guardas todo, con las horas que has medido.
-
Carga las reglas de Prometheus. Crea
prometheus/alerts.ymlcon las siete reglas de Reglas de alerta, pero con los umbrales y losforde tudocs/umbrales.md, que son los que justificaste con tu servicio. Cárgalo en Prometheus igual querules.yml, en/etc/prometheus/alerts.yml. Las siete tienen que salir en Alerts comoinactive: con el servicio sano, ninguna condición debería cumplirse.Pista: un volumen nuevo obliga a recrear el contenedor; recargar no basta. Una regla en
pendingsin haber provocado nada tiene el umbral por debajo del valor normal. -
Provoca una alarma real y mide sus tiempos. Provoca ya
AppHighErrorRate, porque tarda: paradbcon el bucle de tráfico corriendo y apunta las horas de la parada, dependingy defiringmientras sigues con los pasos 3 y 4. Con la alerta enfiring, arrancadby apunta cuándo desaparece. Son los retrasos de los que habla Cómo elegir el for: compáralos con la ventana y elforde la regla. -
Prueba la regla sin esperar. Crea
prometheus/tests/alerts_test.ymlcon la prueba de Probar las reglas con promtool y pásala conpromtool test rules. Tiene que probar tu regla, no la del ejemplo: si cambiaste elforo el texto de las anotaciones deAppHighErrorRate, la prueba lo refleja.Pista: las anotaciones y el
foresperados tienen que coincidir letra a letra con los tuyos.promtoolse lanza condocker runsobre la imagen de Prometheus de la pila; la orden está en la chuleta. -
Activa el ruler de Loki con las tres reglas de texto. Añade a
loki.ymlel bloque de Alertas en el ruler de Loki y cárgaleloki/loki-alerts.ymlcon las tres reglas del mismo apartado, con la cadena deAppDbTimeoutcambiada por una de tudocs/cadenas.md. Valida el fichero conlokitool rules lintantes de reiniciar Loki, para no descubrir un error de sintaxis después, buscándolo en su log.Pista: dentro del contenedor tiene que quedar en
/loki/rules/fake/loki-alerts.yml; en otro sitio se ignora sin error. Usalintcon-n, o te reescribirá el fichero. -
Guárdalo con las horas medidas. Haz commit de
prometheus/yloki/, con las horas del paso 2 apuntadas endocs/umbrales.md, junto a la regla a la que corresponden.
Comprobación promtool check rules y promtool test rules terminan en SUCCESS; Alerts muestra las siete reglas de Prometheus; lokitool rules lint limpio y curl http://mon01:23100/loki/api/v1/rules devuelve tus tres reglas; tienes anotado pending → firing → resuelta con horas.
Para practicar Guarda prometheus/alerts.yml, prometheus/tests/alerts_test.yml y loki/loki-alerts.yml en el repositorio alerting. Para la sesión del 12 de noviembre, la de Alertmanager, trae creado un bot de Telegram con @BotFather, metido en un grupo de pruebas y con el chat_id del grupo apuntado: cómo se hace está en Receptores y plantillas.
Sesión 12 · Alertmanager¶
Prometheus y el ruler de Loki ya mandan a Alertmanager cada alerta que dispara. Pero la configuración de partida de la pila mínima lo manda todo a un receptor vacío, llamado nadie, así que no se entera nadie. Y si cada alerta se convirtiera sin más en un mensaje, el resultado sería casi peor: una máquina caída con diez alertas daría diez mensajes, repetidos cada pocos minutos, a todo el mundo y a cualquier hora. Alertmanager existe para decidir: con qué otras alertas va cada una, a quién se le manda, cuándo y cuántas veces, y qué se calla. Es el tramo más largo de la unidad, y la teoría sigue el camino que hace una alerta dentro de Alertmanager: el árbol de rutas que elige a quién avisar, los tres tiempos que deciden cuándo, la inhibición y los silencios que callan lo que sobra, y los receptores y plantillas que dan forma al mensaje, con Mailpit para probar el correo sin mandar nada a nadie. Al final, cómo se prueba todo eso sin provocar fallos de verdad. La hoja monta las tres rutas, comprueba una inhibición y un silencio, y hace llegar a Telegram dos alarmas del mismo grupo en un solo mensaje.
Alertmanager¶
Prometheus evalúa; Alertmanager decide. Alertmanager es un programa aparte del proyecto Prometheus. Recibe alertas de uno o varios Prometheus, del ruler de Loki y de Grafana si se configura (Grafana trae su propio motor de alertas; cuándo conviene y por qué en el laboratorio no se usa está en Para ampliar), y hace cinco cosas con ellas: deduplica (dos Prometheus idénticos, montados en paralelo para que la caída de uno no deje el servicio sin vigilancia, mandan la misma alerta y sale un solo aviso), agrupa, enruta, inhibe y silencia. Luego notifica por los receptores configurados y repite mientras la alerta siga activa. Tenerlo aparte de Prometheus es lo que permite que varias fuentes compartan las mismas rutas, los mismos silencios y los mismos canales.
flowchart TB
IN["<b>Llega una alerta</b><br><small>de Prometheus o del ruler de Loki</small>"]:::dato
RU["<b>Árbol de rutas</b><br><small>a qué receptores va, según sus etiquetas</small>"]:::pieza
GR["<b>Grupo</b><br><small>con qué otras alertas viaja: group_by</small>"]:::pieza
TI["<b>Tiempos</b><br><small>group_wait · group_interval · repeat_interval</small>"]:::act
IS{"<b>¿Inhibida o silenciada?</b>"}:::act
CA(["<b>Se calla</b><br><small>se ve en la interfaz, no llega a nadie</small>"]):::infra
NO(["<b>Notificación</b><br><small>correo, Telegram, webhook</small>"]):::ok
IN --> RU --> GR --> TI --> IS
IS -- "sí" --> CA
IS -- "no" --> NO
classDef act fill:#ea580c22,stroke:#ea580c,stroke-width:1.5px
classDef pieza fill:#64748b22,stroke:#64748b,stroke-width:1.5px
classDef dato fill:#2563eb22,stroke:#2563eb,stroke-width:1.5px
classDef infra fill:#a1a1aa14,stroke:#a1a1aa,stroke-width:1.5px
classDef ok fill:#16a34a22,stroke:#16a34a,stroke-width:1.5px
classDef riesgo fill:#dc262622,stroke:#dc2626,stroke-width:1.5px
El camino de una alerta dentro de Alertmanager, en el orden de los apartados que siguen. La inhibición y el silencio no borran la alerta: solo impiden que se notifique.
Toda la configuración está en un fichero, alertmanager.yml. Se valida con amtool check-config alertmanager.yml (amtool es a Alertmanager lo que promtool a Prometheus, y viene en su misma imagen) y se recarga en caliente con un POST a /-/reload.
El árbol de rutas¶
El problema: no todas las alertas van al mismo sitio. Una alerta critical tiene que llegar al móvil de quien está de guardia; una warning puede esperar en el correo hasta mañana; y las del equipo de desarrollo tienen que llegarle a ese equipo. Alertmanager lo decide con un árbol de rutas: una ruta raíz con rutas hijas, cada una con unas condiciones sobre las etiquetas de la alerta (matchers) y un receptor, el destino al que se mandan las alertas que las cumplen.
El fichero del laboratorio. Este es el alertmanager.yml que se monta en la hoja de esta sesión. Tiene cinco bloques, y cada uno responde a una pregunta:
global: los valores comunes a todo el fichero; aquí, el servidor de correo (Mailpit) y elresolve_timeout.templates: dónde están las plantillas propias de los mensajes.route: el árbol de rutas, con los parámetros de agrupación de la raíz y tres rutas hijas.inhibit_rules: qué alertas se callan mientras otra está activa.receivers: los destinos a los que apuntan las rutas, cada uno con sus integraciones (correo, Telegram).
global:
resolve_timeout: 5m
smtp_smarthost: mailpit:1025
smtp_from: alertmanager@lab
smtp_require_tls: false
templates:
- /etc/alertmanager/templates/*.tmpl
route:
receiver: mail
group_by: [alertname, service]
group_wait: 30s
group_interval: 5m
repeat_interval: 4h
routes:
- matchers: [severity="critical"]
receiver: critical
continue: true
- matchers: [severity="warning"]
receiver: mail
- matchers: [team="dev"]
receiver: dev-chat
group_by: [alertname, service, env]
inhibit_rules:
- source_matchers: [alertname="HostDown"]
target_matchers: [severity=~"warning|critical"]
equal: [host]
- source_matchers: [alertname="AppDown"]
target_matchers: [service="app"]
equal: [env]
receivers:
- name: mail
email_configs:
- to: ops@lab
send_resolved: true
- name: critical
telegram_configs:
- bot_token_file: /etc/alertmanager/secrets/tg_token
chat_id: -100123456789
parse_mode: HTML
message: '{{ template "lab.telegram" . }}'
send_resolved: true
- name: dev-chat
email_configs:
- to: dev@lab
send_resolved: true
El diseño sigue a la severidad: lo critical va a Telegram, que suena en el móvil; lo warning, al correo del equipo de operaciones (ops@lab); y lo del equipo de desarrollo, a su propio correo (dev@lab). Conviene fijarse en el continue: true de la ruta critical, en el orden de las rutas y en que el token del bot va en un fichero (bot_token_file) y no en el YAML: la configuración se sube al repositorio y el secreto no. Alertmanager admite más piezas que este laboratorio no usa (rutas que se callan por horario, Slack, una ruta propia hacia el gestor de incidencias); están en Para ampliar.
flowchart TD
ROOT["<b>route raíz</b><br><small>receiver: mail · group_by: alertname, service</small>"]:::act
C{"<b>severity=critical</b>"}:::dato
CR["<b>receiver: critical</b><br><small>Telegram · continue: true</small>"]:::riesgo
W{"<b>severity=warning</b>"}:::dato
MW["<b>receiver: mail</b><br><small>correo a ops@lab</small>"]:::pieza
D{"<b>team=dev</b>"}:::dato
DEV["<b>receiver: dev-chat</b><br><small>correo a dev@lab</small>"]:::pieza
MAIL["<b>receiver: mail</b><br><small>la raíz</small>"]:::pieza
ROOT --> C
C -- sí --> CR --> W
C -- no --> W
W -- sí --> MW
W -- no --> D
D -- sí --> DEV
D -- no --> MAIL
classDef act fill:#ea580c22,stroke:#ea580c,stroke-width:1.5px
classDef pieza fill:#64748b22,stroke:#64748b,stroke-width:1.5px
classDef dato fill:#2563eb22,stroke:#2563eb,stroke-width:1.5px
classDef infra fill:#a1a1aa14,stroke:#a1a1aa,stroke-width:1.5px
classDef ok fill:#16a34a22,stroke:#16a34a,stroke-width:1.5px
classDef riesgo fill:#dc262622,stroke:#dc2626,stroke-width:1.5px
El continue: true de la rama crítica es la clave: sin él, una alerta grave del equipo de desarrollo se quedaría en Telegram y no llegaría a dev-chat.
Cómo se recorre. El árbol se recorre desde la raíz, probando las rutas hijas en orden, y en cuanto una coincide se detiene ahí y usa su receptor. Salvo que la ruta tenga continue: true: entonces, además de usar su receptor, sigue probando las hermanas siguientes. En el ejemplo, una alerta severity=critical, team=dev va a critical (Telegram) y también a dev-chat; una severity=warning se queda en mail sea del equipo que sea, porque la ruta warning va antes que la de dev y no lleva continue; y lo que no coincide con ninguna hija cae en el receptor de la raíz, que también es mail. Los parámetros de agrupación y repetición se heredan de la raíz y cada ruta puede sobrescribirlos, como hace dev-chat con group_by. amtool config routes test --config.file=alertmanager.yml severity=critical team=dev dice a qué receptores llegaría un conjunto de etiquetas sin tener que provocar nada.
Los matchers. Admiten =, !=, =~ y !~, como los selectores de PromQL, y una lista de varios se combina con AND: tienen que cumplirse todos. Para descartar un tipo de alerta se usa una ruta hacia un receptor sin integraciones, por convención "null" (con comillas, porque YAML interpretaría null como valor nulo): la alerta existe y se ve en la interfaz, pero no notifica.
group_wait, group_interval y repeat_interval¶
El problema: con quién, cuándo y cuántas veces. Las rutas deciden a quién; quedan dos preguntas. La primera, con quién viaja cada alerta: si se cae una máquina con diez contenedores, llegan diez alertas casi a la vez, y lo útil es un mensaje con las diez, no diez mensajes. Eso es el grupo: group_by dice qué etiquetas tienen que compartir las alertas para viajar juntas, y con [alertname, service] van juntas todas las alertas de la misma regla y el mismo servicio. La segunda, cuándo y cuántas veces: un aviso que se manda una vez y se olvida se pierde, y uno que se repite cada minuto es ruido. De eso se encargan tres tiempos, los que más confusión generan de toda la configuración, y se entienden mejor sobre una línea temporal.
Una línea temporal. El supuesto es group_by: [alertname, service], group_wait: 30s, group_interval: 5m, repeat_interval: 4h, y que app01 se queda sin base de datos a las 10:00:00. En el laboratorio AppHighErrorRate agrega por servicio y da una sola alerta; el supuesto imagina tres réplicas de la API con una alerta cada una, que es lo que la A2.5 imita con alertas sintéticas que solo se distinguen en instance.
flowchart LR
T0["<b>10:00:00</b><br><small>llega la primera alerta del grupo</small>"]:::dato
GW["<b>group_wait · 30 s</b><br><small>espera por si llegan hermanas<br>y las manda juntas</small>"]:::act
N1(["<b>10:00:30 · primer aviso</b>"]):::ok
GI["<b>group_interval · 5 min</b><br><small>solo si hay novedades en el grupo</small>"]:::act
N2(["<b>Aviso con lo nuevo</b>"]):::ok
RI["<b>repeat_interval · 4 h</b><br><small>si todo sigue igual, recuerda</small>"]:::act
N3(["<b>4 h después · recordatorio</b>"]):::ok
T0 --> GW --> N1 --> GI --> N2
N1 --> RI --> N3
classDef act fill:#ea580c22,stroke:#ea580c,stroke-width:1.5px
classDef pieza fill:#64748b22,stroke:#64748b,stroke-width:1.5px
classDef dato fill:#2563eb22,stroke:#2563eb,stroke-width:1.5px
classDef infra fill:#a1a1aa14,stroke:#a1a1aa,stroke-width:1.5px
classDef ok fill:#16a34a22,stroke:#16a34a,stroke-width:1.5px
classDef riesgo fill:#dc262622,stroke:#dc2626,stroke-width:1.5px
group_wait agrupa, group_interval avisa de lo que cambia y repeat_interval recuerda lo que no se ha arreglado. Son tres relojes distintos y por eso se confunden.
| Instante | Qué pasa |
|---|---|
| 10:00:00 | Llega AppHighErrorRate{service=app}. No existe el grupo (AppHighErrorRate, app); se crea y arranca group_wait. |
| 10:00:20 | Llega AppHighErrorRate{service=app, instance=app01:29102} (otra réplica, misma alerta y servicio). Entra en el mismo grupo. No se notifica todavía. |
| 10:00:30 | Vence group_wait. Se envía una notificación con las dos alertas del grupo. |
| 10:02:00 | Llega una tercera réplica al grupo. No se notifica: el grupo está en su group_interval. |
| 10:05:30 | Vence group_interval. Como el grupo ha cambiado (hay una alerta nueva), se envía una notificación actualizada con las tres. |
| 10:10:30 | Vence otro group_interval. Nada ha cambiado, no se envía nada. |
| 14:05:30 | Se cumplen 4 h desde la última notificación (la de las 10:05:30, no la primera) y el grupo no ha cambiado: vence repeat_interval y se reenvía, por si nadie lo ha atendido. |
| 14:20:00 | Se arregla: las tres alertas dejan de cumplirse y llegan con su endsAt. |
| 14:20:30 | Siguiente vencimiento de group_interval: se envía la notificación de resolución, si el receptor tiene send_resolved: true. |
Qué es cada uno. group_wait es la espera inicial para juntar alertas que llegan a la vez (diez contenedores muertos en un host caído, en un solo correo). group_interval es cada cuánto se revisa un grupo ya notificado para mandar los cambios (alertas nuevas o resueltas). repeat_interval es cada cuánto se insiste con un grupo que no ha cambiado; como solo se comprueba en los vencimientos de group_interval, conviene que sea múltiplo de él. Los extremos se pagan: un group_wait de 0 s da inmediatez a cambio de una notificación por alerta, y un repeat_interval de 1 h con diez alarmas activas son 240 mensajes al día.
Inhibición¶
El problema: las consecuencias. Cuando una máquina se cae, deja de responder todo lo que corre en ella: la API, sus métricas, sus contenedores. Cada regla afectada dispara su propia alerta, y quien está de guardia recibe una ráfaga de avisos que dicen lo mismo con distintas palabras, mientras el único que importa, que la máquina está caída, se pierde entre ellos. Una regla de inhibición lo evita diciendo: mientras esté activa una alerta que cumpla source_matchers, no notifiques las que cumplan target_matchers y compartan los valores de las etiquetas de equal.
Las dos del laboratorio. El caso canónico es la primera del ejemplo: si HostDown{host="app01"} está en firing, las alertas de lo que corre en app01 (la API que no responde, la memoria de sus contenedores, un OOM) son consecuencia, no información, y se callan. Siguen existiendo (se ven en la interfaz marcadas como inhibidas) pero no llegan a ningún receptor ni al webhook. La segunda hace lo mismo un nivel más abajo: con AppDown activa, se callan las demás alertas de service="app" del mismo entorno.
Por qué host y no instance. La etiqueta de equal tiene que existir con el mismo valor en la alerta fuente y en las inhibidas. Por eso el ejemplo usa host, que llevan todos los jobs del scrape y que Promtail pone en todos los flujos de log, y no instance: cada exporter tiene su puerto, y el instance del node_exporter (...:29100) nunca coincide con el de la API (...:29102). Las alertas que agregan con sum by (service) pierden host y quedan fuera, pero con la máquina caída tampoco tienen datos con los que disparar.
Sin equal, la inhibición silencia de más
equal acota a qué alertas alcanza la inhibición. Sin él, un host caído en el CPD de Madrid silenciaría
las alertas de Sevilla, y nadie se enteraría de la segunda avería hasta que llamara un cliente. Cuidado
también con las inhibiciones circulares: si A inhibe a B y B inhibe a A, Alertmanager lo resuelve de
forma poco intuitiva.
flowchart LR
HD["<b>HostDown</b><br><small>host=app01 · en firing</small>"]:::riesgo
EQ{"<b>equal: [host]</b><br><small>¿mismo host?</small>"}:::act
HIJAS["<b>Alertas de app01</b><br><small>API caída, memoria, OOM</small>"]:::pieza
CALLA(["<b>Inhibidas</b><br><small>se ven en la interfaz, no llegan a nadie</small>"]):::ok
OTRA["<b>Alertas de mon01</b><br><small>otro host</small>"]:::pieza
PASA(["<b>Siguen notificando</b>"]):::ok
HD --> EQ
EQ -- "sí" --> HIJAS --> CALLA
EQ -- "no" --> OTRA --> PASA
classDef act fill:#ea580c22,stroke:#ea580c,stroke-width:1.5px
classDef pieza fill:#64748b22,stroke:#64748b,stroke-width:1.5px
classDef dato fill:#2563eb22,stroke:#2563eb,stroke-width:1.5px
classDef infra fill:#a1a1aa14,stroke:#a1a1aa,stroke-width:1.5px
classDef ok fill:#16a34a22,stroke:#16a34a,stroke-width:1.5px
classDef riesgo fill:#dc262622,stroke:#dc2626,stroke-width:1.5px
Si el host está caído, sus alertas hijas son consecuencia, no información. Lo que no puede pasar es que callen también las de otro sitio, y eso es lo que decide equal.
Silencios¶
El problema: el fallo que se sabe que va a pasar. Hay fallos que se provocan a propósito: una actualización de PostgreSQL, un reinicio programado, una prueba del laboratorio. Durante ese rato las alarmas van a saltar, y avisar de algo que se está haciendo a propósito es ruido. Un silencio es una inhibición temporal creada a mano: unos matchers (qué alertas calla), un principio y un fin, y un comentario obligatorio que diga por qué, para que quien lo encuentre sepa quién lo puso y si sigue teniendo sentido. Se crean desde la interfaz web de Alertmanager (puerto 29093) o con amtool:
amtool --alertmanager.url=http://mon01:29093 silence add \
service=db env=dev --duration=2h \
--author=ops --comment="Mantenimiento PostgreSQL 17.x, ticket #142"
amtool --alertmanager.url=http://mon01:29093 silence query
amtool --alertmanager.url=http://mon01:29093 silence expire <id>
amtool --alertmanager.url=http://mon01:29093 alert query severity=critical
Qué sobrevive a un reinicio. Los silencios se guardan en el directorio de datos de Alertmanager (--storage.path), así que sobreviven a un reinicio del contenedor si ese directorio está en un volumen. Lo que no sobrevive es la memoria de las alertas: Alertmanager no tiene historial, y lo que ya no está en firing desaparece. Esa es la razón del webhook y las incidencias, que se montan el 17 de noviembre.
Receptores y plantillas¶
Los canales del laboratorio. Alertmanager trae integraciones para correo, varias aplicaciones de chat, servicios de guardia y un webhook genérico; la lista completa está en Para ampliar. En el laboratorio se usan tres, una por cada necesidad:
- Correo, para lo que puede esperar, contra Mailpit (un SMTP de pruebas, explicado más abajo):
smtp_smarthost: mailpit:1025,smtp_require_tls: false. En producción sería el servidor de correo de la empresa, consmtp_auth_username/smtp_auth_password_filey TLS. - Telegram, para lo que tiene que sonar en el móvil. Se crea el bot con @BotFather (un bot de Telegram que da de alta bots y entrega su token), se mete el bot en un grupo, se escribe algo en el grupo y se consulta
https://api.telegram.org/bot<TOKEN>/getUpdates, que devuelve elchat_id(negativo para grupos; el de un supergrupo empieza por -100). El token va en un fichero (bot_token_file) y ese fichero, fuera del repositorio. - Webhook, para todo lo demás: un
POSTcon JSON a la URL que se indique. Es la integración universal: cualquier cosa que no esté en la lista se hace con esto, y es la que lleva cada alarma al gestor de incidencias.
Todas admiten send_resolved, que decide si se avisa también cuando la alerta se resuelve, y el valor por defecto cambia según la integración: en el correo es false; en Telegram y en el webhook, true. Conviene ponerlo explícito siempre: una alarma cuya resolución no se notifica obliga a mirar el panel para saber si sigue viva.
Por qué una plantilla propia. El texto de la notificación se controla con las mismas plantillas de Go que las anotaciones. Las que trae Alertmanager por defecto salen largas y difíciles de leer en Telegram; una plantilla propia en /etc/alertmanager/templates/lab.tmpl deja en cada mensaje solo lo que hace falta para decidir:
{{ define "lab.title" }}[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .GroupLabels.alertname }} ({{ .GroupLabels.service }}){{ end }}
{{ define "lab.telegram" }}
<b>{{ template "lab.title" . }}</b>
{{ range .Alerts -}}
<b>{{ .Status | toUpper }} · {{ .Labels.severity }}</b> {{ .Annotations.summary }}
Desde: {{ .StartsAt.Format "02/01 15:04" }} · Origen: {{ .Labels.origen }}
{{ if .Annotations.runbook }}<a href="{{ .Annotations.runbook }}">Runbook</a>{{ end }}
{{ end }}
{{ end }}
y en el receptor, la línea message: '{{ template "lab.telegram" . }}' que ya lleva el ejemplo. define da nombre a un trozo de plantilla y template lo usa en otro sitio. Los objetos disponibles (.Status, .Alerts, .Alerts.Firing, .GroupLabels, .CommonLabels, .CommonAnnotations, .ExternalURL) están en la referencia de notificaciones. El estado en mayúsculas al principio de cada línea permite distinguir FIRING de RESOLVED en el móvil antes de leer nada más.
Mailpit como SMTP de pruebas¶
Por qué un servidor falso. Para probar las notificaciones por correo hace falta un servidor de correo, y no conviene usar uno de verdad: cada prueba mandaría mensajes reales, a direcciones que no existen o a gente que no los espera. Mailpit es un servidor SMTP falso: acepta cualquier correo en el puerto 1025, no lo entrega a nadie y lo muestra en una interfaz web, con búsqueda y vista del HTML. Sustituye a MailHog, que lleva años sin mantenimiento. En el compose de mon01 solo se publica la web, y en el puerto del curso: MP_UI_BIND_ADDR la pone a escuchar en el 28025, el mismo número con el que se publica (la regla de puertos está en Convenciones). El SMTP se queda en su 1025 de fábrica y sin publicar, porque solo lo usa Alertmanager, que llega a él por la red del compose con el nombre mailpit. MP_MAX_MESSAGES limita cuántos mensajes guarda.
mailpit:
image: axllent/mailpit:v1.22.3
ports: ["28025:28025"]
environment:
MP_UI_BIND_ADDR: "0.0.0.0:28025"
MP_MAX_MESSAGES: 500
Con eso se pueden probar plantillas de correo y comprobar send_resolved sin molestar a nadie ni depender del servidor de la empresa. Tiene además una API (/api/v1/messages) que permite comprobar desde un script que ha llegado un correo con cierto asunto, lo que convierte la verificación de alarmas en algo automatizable.
Alertas sintéticas¶
El problema: probar sin romper. Las rutas, la agrupación, la inhibición y las plantillas se configuran hoy, y comprobarlas con fallos de verdad sería lento (cada alarma real tarda minutos en disparar) y obligaría a romper el servicio una vez por cada prueba. amtool config routes test dice a qué receptores iría una alerta, pero no manda nada. Para ver llegar un mensaje se usan alertas sintéticas: alertas inventadas, con las etiquetas que se quiera, que se meten a mano en Alertmanager sin pasar por ninguna regla:
amtool --alertmanager.url=http://mon01:29093 alert add alertname=PruebaAviso severity=warning service=app \
--annotation=summary="Prueba de la ruta warning"
Cómo se comportan. Para Alertmanager son alertas como cualquier otra: pasan por las rutas, se agrupan, se inhiben y se silencian igual, y llegan a los receptores con la plantilla. Por eso sirven también para probar una inhibición: una HostDown sintética con host=app01 calla a otra sintética con el mismo host. Tienen dos diferencias con las reales. No aparecen en la página Alerts de Prometheus, porque no salen de ninguna regla, sino solo en la interfaz de Alertmanager. Y como llegan sin caducidad, se resuelven solas al pasar resolve_timeout (5 minutos), salvo que se les dé un fin con --end.
A2.5 Alertmanager (sesión 12)¶
Objetivo Que las alertas de la A2.4 lleguen a quien tienen que llegar, juntas cuando son del mismo problema y calladas cuando sobran. Al terminar tienes Alertmanager con tres rutas, una inhibición comprobada y un silencio, notificando por Mailpit y por Telegram con plantilla propia, y has visto llegar dos alarmas del mismo grupo en una sola notificación.
Antes de empezar
- Las reglas de la A2.4 cargadas y, de la misma hoja, el bot de Telegram en un grupo de pruebas, con el
chat_iddel grupo (negativo) y el token ensecrets/tg_token. Sin bot, pon de momento en el receptorcriticalunemail_configsaguardia@laben lugar deltelegram_configs; la captura que guardas al final será la de Mailpit. - Explicado al principio: todo el apartado Alertmanager, de El árbol de rutas a Alertas sintéticas.
Pasos
Primero preparas la pila para que Alertmanager lea su configuración del repositorio y no pierda los silencios al reiniciarse. Después cargas la configuración y compruebas las rutas sobre el papel, sin mandar nada. Por último lo pruebas de verdad con alertas sintéticas, que llegan en segundos y no obligan a romper el servicio; el silencio va antes porque una de las pruebas tiene que esquivarlo.
-
Prepara la pila. Añade Mailpit a la pila de mon01 con el bloque de Mailpit como SMTP de pruebas. Haz que el servicio
alertmanagerlea su configuración del repositorioalerting(alertmanager/en/etc/alertmanagerysecrets/en/etc/alertmanager/secrets, de solo lectura) y guarde sus datos en un volumenalertmanager_data, en/alertmanagercon--storage.path: sin volumen, cada reinicio borraría los silencios.Pista:
--storage.pathva en elcommandde Alertmanager, junto a las opciones que ya tiene. Si al tocarlo se pierde--web.listen-address=:29093, Alertmanager vuelve a su 9093 de fábrica y Prometheus deja de llegarle. -
Carga la configuración. Crea
alertmanager/alertmanager.ymlcon el de El árbol de rutas y elchat_idde tu grupo, yalertmanager/templates/lab.tmplcon la plantilla de Receptores y plantillas; el webhook llega en la A2.6. Valídalo conamtool check-config, levanta Mailpit y Alertmanager y comprueba que el bloquealertingdeprometheus.ymlapunta aalertmanager:29093, que es por donde le llegan las alertas de Prometheus.Pista:
amtoolviene en la imagen de Alertmanager:docker compose run --rm --entrypoint amtool alertmanagerlo usa sin el servicio en marcha. -
Comprueba las rutas sin mandar nada. Con
amtool config routes test, mira a qué receptores llega una alertaseverity=warning team=devy decide si es lo que quieres: la pregunta es si el equipo de desarrollo tiene que enterarse de sus propios avisos. Si no te convence, cambia el orden de las rutas o sucontinue, y apunta la decisión y su porqué en el README. -
Pon un silencio. Crea un silencio de una hora sobre
service=db, con autor y comentario, como si fueras a hacer un mantenimiento de la base de datos, y déjalo puesto para el paso siguiente. -
Prueba con alertas sintéticas. Comprueba, con alertas sintéticas, que:
- dos
PruebaGrupocriticaldeservice=appque solo se distinguen eninstancellegan a Telegram en una sola notificación, y después su resolución; - una
PruebaAvisowarningllega a Mailpit y no a Telegram; - con una
HostDownsintética dehost=app01activa, unaPruebaInhibidadel mismohostqueda inhibida.
Pista:
amtool alert addnecesita--alertmanager.url; la orden está en la chuleta. Usaservice=app, porque el silencio tapa las dedb. Las sintéticas se resuelven a los 5 minutos deresolve_timeout, y el aviso llega en el siguientegroup_interval. - dos
-
Guárdalo sin secretos. Haz commit de
alertmanager/enalertingy delcompose.ymlde mon01 enmonitoring, sinsecrets/.
Comprobación amtool check-config sin errores; en Mailpit hay un correo de PruebaAviso y ninguno de PruebaInhibida, que alert query -i da como inhibida; en Telegram, un [FIRING:2] con dos alertas y su [RESOLVED]; el silencio se ve en la interfaz; no hay secretos en el repositorio.
Para practicar Guarda alertmanager/ y el compose.yml de mon01 en sus repositorios, y una captura de Telegram con la notificación agrupada en docs/capturas/ de alerting.
Si te sobra tiempo Cambia en la ruta critical el agrupamiento a group_by: [service], recarga Alertmanager y lanza dos sintéticas critical con alertname distinto y el mismo service=app: llegan en un solo mensaje, cuando con [alertname, service] serían dos. Deja el group_by que prefieras, justificado en el README.
Sesión 13 · Integración con incidencias¶
Cada alarma llega ya a una persona, pero en cuanto se resuelve no queda nada de ella: Alertmanager no guarda historial, y un mensaje de Telegram se pierde entre los demás del grupo. Así no se puede contestar a preguntas que en mantenimiento son de todos los días: cuándo empezó y cuándo terminó el último corte, quién lo atendió y qué se hizo, cuántas veces ha saltado esta alarma este mes. Los criterios d y e de la unidad lo piden expresamente: alarmas almacenadas fuera de Alertmanager y clasificadas por fecha, origen, criticidad y servicio. La solución es la de cualquier empresa, un gestor de incidencias, donde cada alarma se convierte en una incidencia con su historia. La teoría va en el orden en que viaja la información: qué datos de cada alarma sirven para clasificarla, qué envía exactamente Alertmanager cuando avisa por webhook y el pequeño programa que traduce ese aviso en incidencias que se abren y se cierran solas. El mismo Gitea que guarda las incidencias hace de remoto de los repositorios hasta febrero, y la hoja deja dados de alta los tres.
Categorizar, notificar y tratar¶
Qué es tratar una alarma. Una incidencia es el registro de un problema desde que aparece hasta que se cierra: cuándo empezó, qué era, quién lo cogió, qué se hizo y cuándo terminó. Notificar es avisar a alguien; tratar es que ese aviso se convierta en trabajo con dueño y con rastro. Para que eso funcione, cada alarma que sale de Alertmanager tiene que llegar ya clasificada: quién la atiende, con qué prioridad, de dónde viene. Y esa clasificación no se hace a mano: viene en las etiquetas que se pusieron en la regla y en los campos que añade Alertmanager.
| Parámetro | Valores | De dónde sale |
|---|---|---|
| Fecha de creación | startsAt (fecha en formato estándar RFC 3339, en hora universal UTC) |
Alertmanager, del primer envío de Prometheus |
| Fecha de resolución | endsAt |
Alertmanager, cuando llega el resolved |
| Origen | prometheus, cadvisor, loki, docker-events | Etiqueta origen de la regla |
| Criticidad | critical, warning, info | Etiqueta severity |
| Servicio / entorno | app, db, web, node; dev, pre | Etiquetas service, env |
| Equipo | ops, dev | Etiqueta team |
| Estado | firing, resolved (más silenced e inhibited en la interfaz) | Campo status |
| Identidad | fingerprint (hash de las etiquetas) |
Alertmanager; es lo que permite emparejar el firing con su resolved |
Cada fila acaba en el título, el cuerpo o las fechas de una incidencia. Es también lo que se rellena para cada alarma en la tabla de verificación del 19 de noviembre.
El JSON del webhook¶
Qué llega. Cuando una ruta apunta a un receptor con webhook_configs, Alertmanager no manda un mensaje para una persona: manda los datos del grupo de alertas a una URL, para que otro programa haga con ellos lo que quiera. Es un POST con Content-Type: application/json y este cuerpo (recortado solo en las URL):
{
"version": "4",
"groupKey": "{}/{severity=\"critical\"}:{alertname=\"AppHighErrorRate\", service=\"app\"}",
"truncatedAlerts": 0,
"status": "firing",
"receiver": "critical",
"groupLabels": { "alertname": "AppHighErrorRate", "service": "app" },
"commonLabels": {
"alertname": "AppHighErrorRate", "env": "dev", "origen": "prometheus",
"service": "app", "severity": "critical", "team": "ops"
},
"commonAnnotations": {
"summary": "Errores 5xx al 2.4% en app",
"runbook": "https://wiki.lab/runbooks/app-errors"
},
"externalURL": "http://mon01:29093",
"alerts": [
{
"status": "firing",
"labels": {
"alertname": "AppHighErrorRate", "env": "dev", "origen": "prometheus",
"service": "app", "severity": "critical", "team": "ops"
},
"annotations": {
"summary": "Errores 5xx al 2.4% en app",
"description": "El ratio de errores lleva 5 min por encima del 1 % (SLA). Revisa la API y el servicio db en app01.",
"runbook": "https://wiki.lab/runbooks/app-errors"
},
"startsAt": "2026-11-12T09:41:30.512Z",
"endsAt": "0001-01-01T00:00:00Z",
"generatorURL": "http://mon01:29090/graph?g0.expr=app%3Aerrors%3Aratio5m+%3E+0.01",
"fingerprint": "5c2f8a1e9b0d4c77"
}
]
}
Cómo se lee. El sobre describe el grupo: su estado, el receptor que lo manda, las etiquetas del group_by (groupLabels) y las que comparten todas sus alertas (commonLabels). Dentro, alerts lleva cada alerta con sus etiquetas, sus anotaciones, sus fechas y su fingerprint.
Tres detalles que importan al escribir el receptor. status del sobre es firing si alguna alerta del grupo lo está, y resolved solo cuando todas lo están; por eso hay que mirar el status de cada elemento de alerts, no el del sobre. endsAt vale la fecha cero de Go (0001-01-01T00:00:00Z) mientras la alerta está activa. Y fingerprint es estable para el mismo conjunto de etiquetas, así que es el identificador natural para saber qué incidencia cerrar cuando llegue el resolved. truncatedAlerts es mayor que cero si el grupo tenía más alertas de las que caben (max_alerts en el receptor).
Receptor Flask que abre y cierra incidencias en Gitea¶
Por qué hace falta un programa en medio. Alertmanager sabe hacer un POST con su JSON, y Gitea tiene una API para crear y cerrar incidencias, pero no hablan el mismo idioma: Gitea espera un título y un cuerpo, y alguien tiene que decidir qué alerta corresponde a qué incidencia. Hace falta un traductor, y en el laboratorio es un script corto en Python con Flask (una librería mínima para montar un servicio web en pocas líneas, la misma con la que está hecha la API del servicio) que corre como un contenedor más de la pila, con el nombre tickets.
Dónde van las incidencias. Gitea es una forja de Git ligera que se instala en un servidor propio: repositorios, incidencias (issues) y una API para manejarlos, como un GitHub casero. Hoy corre en un contenedor de mon01, al lado de la pila. La máquina de plataforma que lo alojará de verdad, gitea01, no existe hasta el 12 de febrero, cuando Despliegue lleva allí Gitea en su unidad de integración continua, y ese día solo habrá que cambiar GITEA_URL.
Qué hace el receptor. Recibe el POST, crea una issue por cada alerta en firing que no tenga ya una abierta y, cuando la alerta llega en resolved, le añade un comentario y la cierra. La relación entre fingerprint y número de issue se guarda en un JSON en disco para que sobreviva a un reinicio.
import json, os, requests
from flask import Flask, request
GITEA = os.environ.get("GITEA_URL", "http://gitea:23001")
REPO = os.environ.get("GITEA_REPO", "ops/incidencias")
HDR = {"Authorization": "token " + os.environ["GITEA_TOKEN"]}
API = f"{GITEA}/api/v1/repos/{REPO}/issues"
STATE_FILE = os.environ.get("STATE_FILE", "/data/issues.json")
app = Flask(__name__)
state = json.load(open(STATE_FILE)) if os.path.exists(STATE_FILE) else {}
def save():
json.dump(state, open(STATE_FILE, "w"))
def abrir(a):
l, an = a["labels"], a.get("annotations", {})
titulo = f"[{l.get('severity','?')}][{l.get('service','?')}] {l['alertname']}: {an.get('summary','')}"
cuerpo = (f"**Inicio:** {a['startsAt']}\n**Origen:** {l.get('origen','?')} · "
f"**Entorno:** {l.get('env','?')} · **Equipo:** {l.get('team','?')}\n\n"
f"{an.get('description','')}\n\nRunbook: {an.get('runbook','-')}\n"
f"Consulta: {a.get('generatorURL','')}\n\n`fingerprint: {a['fingerprint']}`")
r = requests.post(API, json={"title": titulo, "body": cuerpo}, headers=HDR, timeout=10)
r.raise_for_status()
state[a["fingerprint"]] = r.json()["number"]
def cerrar(a):
num = state.pop(a["fingerprint"], None)
if num is None:
return
requests.post(f"{API}/{num}/comments", headers=HDR, timeout=10,
json={"body": f"Resuelta automáticamente. Fin: {a['endsAt']}"})
requests.patch(f"{API}/{num}", json={"state": "closed"}, headers=HDR, timeout=10)
@app.post("/alertmanager")
def alertmanager():
for a in request.get_json(force=True)["alerts"]:
if a["status"] == "firing" and a["fingerprint"] not in state:
abrir(a)
elif a["status"] == "resolved":
cerrar(a)
save()
return "", 204
if __name__ == "__main__":
app.run(host="0.0.0.0", port=25000)
Leído de arriba abajo:
- La configuración sale de variables de entorno. La URL de Gitea, el repositorio y, sobre todo, el token: nunca en el código, porque el código se sube al repositorio y el token no.
- El estado vive en disco.
statees un diccionariofingerprint → número de issue, cargado al arrancar y guardado después de cada aviso. Sirve para dos cosas: no abrir dos issues para la misma alerta cuando llega repetida o por dos receptores, y saber qué issue cerrar cuando llega elresolved. abrircompone el título con la severidad, el servicio y el nombre de la alarma, y el cuerpo con la categorización completa, y crea la issue.cerrarbusca la issue de esa alerta, le añade un comentario con la hora de fin y la cierra.- La ruta
/alertmanagerrecorrealertsmirando elstatusde cada una, no el del sobre, y responde 204 (correcto, sin contenido): una respuesta 2xx le dice a Alertmanager que el aviso ha llegado, y si el receptor no contesta o falla con un error 5xx, Alertmanager lo reintenta. app.runarranca el receptor en el 25000, su puerto del curso, y en0.0.0.0, todas las interfaces del contenedor: con el127.0.0.1que Flask usa por defecto, Alertmanager no le llegaría desde su propio contenedor.
El token de Gitea se genera en Configuración → Aplicaciones → Generar token con permiso de escritura sobre issues y se pasa por variable de entorno. Las etiquetas de Gitea (las de colores) se asignan por identificador numérico en la API ("labels": [3, 7]), no por nombre, así que el script mete la categorización en el título entre corchetes, que es buscable y no requiere mantener un mapa; si hacen falta etiquetas reales, se consulta GET /api/v1/repos/{owner}/{repo}/labels una vez al arrancar y se construye el diccionario nombre → id.
Lo que se gana. Con esto la alarma queda almacenada fuera de Alertmanager: cuándo empezó, cuándo terminó, qué era, quién comentó qué. Y de paso quedan los datos para medir la fatiga de alertas, porque Alertmanager solo guarda las alertas activas y olvida el pasado. Cómo cambia el receptor con otros gestores de incidencias (GitLab, Zammad, GLPI) o con una herramienta de automatización visual sin código está en Para ampliar.
A2.6 Integración con incidencias (sesión 13)¶
Objetivo Que cada alarma deje rastro fuera de Alertmanager: una issue en ops/incidencias de Gitea que se abre sola, categorizada, cuando la alerta dispara, y se cierra sola, con comentario, cuando se resuelve. Al terminar tienes ese receptor webhook en mon01 y, de paso, los repositorios alerting, servicio y monitoring con su remoto en el mismo Gitea, donde vivirán hasta febrero.
Antes de empezar
- Alertmanager de A2.5 notificando, el bucle de tráfico de A2.1 corriendo en tu puesto y sitio en mon01 para un contenedor más (Gitea pide unos 300 MB de memoria).
- Explicado al principio: Categorizar, notificar y tratar y El JSON del webhook. Para el código, Receptor Flask que abre y cierra incidencias en Gitea.
Pasos
Construyes la cadena desde el final: primero Gitea, que es donde tienen que acabar las incidencias; después el receptor, que pruebas solo, con ficheros JSON, antes de conectarlo a nada; y cuando abre y cierra bien, lo conectas a Alertmanager y lo pruebas con alarmas reales. Probar cada tramo por separado es lo que te permite saber, si algo falla, dónde está el fallo. Al final, el mismo Gitea pasa a ser el remoto de tus repositorios.
-
Levanta Gitea, donde acabarán las incidencias. Como
gitea01no existe hasta el 12 de febrero, Gitea va de momento en la pila de mon01: imagengitea/gitea:1.24, datos en un volumengitea_data(queda comomonitoring_gitea_data), escuchando y publicado en el 23001 conGITEA__server__HTTP_PORT=23001(el 23000 que le daría la regla de puertos es el de Grafana) yGITEA__server__ROOT_URL=http://gitea.lab:23001/, la dirección que Gitea pone en sus enlaces. Añadegitea.laba la línea de mon01 del/etc/hostsde tu puesto, de mon01, de app01 y del puesto de administración, porque todos van a empujar a este Gitea. Instálalo con SQLite y crea tu usuario administrador, la organizaciónopsy, dentro, cuatro repositorios vacíos:incidencias,alerting,servicioymonitoring. -
Despliega el receptor. Pon el receptor como servicio
ticketsde la misma pila, para que Alertmanager le llegue por la red del compose: en/opt/alerting/tickets, el script de Receptor Flask tal cual comoapp.pyy unDockerfilesobrepython:3-slimconflaskyrequests. En el compose, sus variables van ensecrets/tickets.env(permisos 600, porque lleva el token de Gitea) y su estado en un volumentickets_data, en/data, para que la relación entre alertas e issues sobreviva a un reinicio. Publica su 25000 solo en127.0.0.1: Alertmanager llega por la red del compose, y desde fuera solo hace falta para el paso 3.Pista:
GITEA_URLeshttp://gitea:23001: el mismo puerto que en el navegador, pero congitea, el nombre del servicio en la red del compose, porquegitea.label contenedor no lo resuelve. -
Prueba el receptor sin Alertmanager. Antes de conectarlo a nada, comprueba que hace bien su trabajo con avisos que controlas tú. Desde mon01, guarda el cuerpo de El JSON del webhook en
/opt/alerting/tickets/tests/firing.json, saca de élresolved.jsony envíalos uno tras otro, con un reinicio del receptor en medio. La issue tiene que abrirse enops/incidenciascon el primero y cerrarse con comentario con el segundo; el reinicio demuestra que el receptor no olvida qué issue es de qué alerta.Pista:
resolved.jsones el mismo cuerpo con"resolved"en lugar de"firing"y unendsAtreal: sale con unsed. -
Conéctalo a Alertmanager. Añade a los tres receptores de
alertmanager.ymlunwebhook_configshaciahttp://tickets:25000/alertmanagerconsend_resolved: true, valida y recarga. Va en los tres para que ninguna alarma se quede sin incidencia, sea cual sea su ruta; y una alerta que pasa por dos receptores no duplica su issue, porque sufingerprintya la tiene. -
Pruébalo con alarmas reales de dos orígenes. Provoca a la vez dos alarmas de orígenes distintos parando
dbcon el bucle de tráfico corriendo: tienen que abrirse una issue deAppHighErrorRate(Prometheus) y otra deAppLogErrorsBurst(Loki, conorigen: loki), la prueba de que las dos fuentes de alertas acaban en el mismo sitio. Arrancadby comprueba que las dos se cierran solas al siguientegroup_intervaldespués de resolverse. Apunta las horas; mientras esperas el cierre, haz el paso 6. -
Dale un remoto a cada repositorio. Haz commit de
tickets/yalertmanager/enalerting, con los enlaces a las issues de prueba en el README en lugar de la nota del remoto pendiente, y delcompose.ymlenmonitoring. Después da a cada copia de trabajo (alertingymonitoringen mon01,servicioen app01) su remotohttp://gitea.lab:23001/ops/<repo>.gity haz el primer push: desde hoy el trabajo de las tres pilas está también fuera de las VM.Pista: antes del primer push de
alerting,git check-ignore secrets/tickets.envtiene que imprimir la ruta; si no, el token se sube con el resto.
Comprobación firing.json crea una issue y resolved.json la cierra aunque el receptor se reinicie entre los dos; las alarmas del paso 5 tienen cada una su issue categorizada, abierta al firing y cerrada con comentario; los tres repositorios tienen su último commit en Gitea y secrets/ no está en alerting.
Para practicar Deja tickets/ completo en el repositorio alerting y apunta en su README los enlaces a las issues de prueba.
Sesión 14 · Verificación completa¶
Cada pieza de la cadena se ha probado ya por separado: las consultas con tráfico real en la A2.1, las reglas con promtool y una alarma real en la A2.4, Alertmanager con alertas sintéticas en la A2.5 y el receptor con ficheros JSON en la A2.6. Lo que falta es seguir alarmas reales de punta a punta, del fallo a la incidencia cerrada, midiendo cuánto tarda cada tramo. Es lo que hizo la A1.6 con los datos, ahora con las alarmas: no basta con que cada pieza funcione, hay que demostrar que funcionan juntas y en un tiempo aceptable. Es una sesión de práctica: los cinco primeros minutos explican el procedimiento con el que se da por buena una alarma, y el resto es laboratorio, con cuatro alarmas que recorren cada una un camino distinto de la cadena. Al terminar queda la tabla de verificación rellena, con la categoría de cada alarma, los tiempos medidos y los cambios que motivan, que es la base del informe de la práctica evaluable.
Procedimiento de verificación de una alarma¶
Por qué un procedimiento. Una alarma escrita no es todavía una alarma de la que fiarse: falta ver que dispara, que llega a quien tiene que llegar, que deja su incidencia y que se cierra sola. Sin esta prueba una alarma no se considera en producción. Se repite para cada alarma nueva y para cada cambio de umbral, for o ruta, siempre con los mismos pasos, para que los resultados se puedan comparar, y el resultado se apunta en el informe.
| Paso | Qué se hace | Qué se comprueba | Dónde |
|---|---|---|---|
| 1 | Se anota la hora y se provoca la condición (parar db con el bucle de tráfico corriendo, parar un exporter, un contenedor que se queda sin memoria) |
El indicador cruza el umbral en la gráfica | Prometheus Graph o Grafana |
| 2 | Se espera | La alerta pasa a pending con el reloj del for |
Prometheus Alerts |
| 3 | Se espera el for |
Pasa a firing; se anota el tiempo desde el paso 1 |
Prometheus Alerts y Alertmanager (29093) |
| 4 | Se espera group_wait |
Llega la notificación a cada canal de la ruta, con el texto de la plantilla correcto | Mailpit (28025) y Telegram |
| 5 | Se mira Gitea | Existe la issue con título, etiquetas y fecha correctos | Gitea, repositorio ops/incidencias |
| 6 | Se resuelve la causa | El indicador vuelve por debajo del umbral | Prometheus Graph |
| 7 | Se espera group_interval |
Llega la notificación de resolución (send_resolved); se anota el tiempo desde el paso 6 |
Los mismos canales |
| 8 | Se mira Gitea | La issue está cerrada con el comentario de resolución | Gitea |
| 9 | Se registra | Alarma, su categoría, cómo se provoca, tiempo hasta firing, canales, issue, tiempo hasta resolved, issue cerrada | Informe de verificación |
Qué se hace con los tiempos. Los tiempos del paso 3 y del 7 son los que luego se comparan con lo que el servicio tolera. Si el usuario nota los errores a los 30 s y la alarma tarda 10 min en sonar, o se baja el for o la ventana, o se acepta que esa alarma es de diagnóstico, no de aviso. Y si una alarma ha saltado por un pico sin importancia, es al revés: se sube el umbral o se alarga el for, como se vio en Fatiga de alertas.
A2.7 Verificación completa (sesión 14)¶
Objetivo La prueba de que la cadena entera funciona con fallos reales: la tabla de verificación rellena para cuatro alarmas de orígenes distintos (HostDown, AppHighErrorRate, AppLogErrorsBurst de Loki y ContainerOOM de eventos Docker), con su categoría, los tiempos medidos y los cambios que motivan. Cada una recorre un camino distinto de la cadena, así que entre las cuatro la cubren entera, y la tabla es a la vez el informe de verificación y la tabla de categorización de la práctica evaluable.
Antes de empezar
- Toda la cadena de A2.4 a A2.6 en marcha (compruébalo con una alerta sintética de
amtool alert add) y el bucle de tráfico de A2.1 corriendo en tu puesto. - Explicado al principio: Procedimiento de verificación de una alarma.
Pasos
Las provocaciones tardan: entre el for, la agrupación y la resolución, una alarma puede llevarse un cuarto de hora de punta a punta. Por eso preparas primero la tabla, lanzas juntas las provocaciones que no se estorban y dejas para el final la que inhibe a las demás. Mientras esperas, vas rellenando filas, y con los tiempos delante decides qué hay que cambiar.
-
Prepara la tabla. Crea
docs/verificacion.mdcon la tabla vacía: alarma, origen, criticidad, servicio, cómo se provoca, hora y tiempo hasta firing, canales, issue creada, hora y tiempo hasta resolved, issue cerrada, cambios. Origen, criticidad y servicio salen de las etiquetas de la regla, y con ellas esta tabla es también la de categorización de la práctica evaluable. -
Lanza primero las provocaciones lentas. Provoca al empezar
ContainerOOMy la parada dedb(que disparaAppHighErrorRateyAppLogErrorsBurst), anotando la hora de cada una, y vuelve a arrancardben cuanto estén abiertas sus dos issues. Pueden ir a la vez porque son tres grupos distintos en Alertmanager, uno poralertname, y no se pisan.Pista: el
oomsale del mismo contenedor de usar y tirar de la A2.2, yContainerOOMse resuelve sola cuando el evento sale de su ventana de 10 min. -
Deja para el final la que inhibe. Cuando esas tres issues estén cerradas, y no antes, provoca
HostDownparando el node_exporter de app01, y arráncalo en cuanto llegue el aviso. Va la última por la inhibición: mientras está activa, calla las alertas de app01 que llevanhost.Pista: en app01 es el servicio de systemd
prometheus-node-exporter. SiHostDowncoincidiera con las otras, elresolvedde una alarma inhibida podría no llegar nunca a su issue. -
Rellena las filas. Completa la fila de cada alarma siguiendo el procedimiento; las horas de firing y resolved están en el
startsAty elendsAtde su issue. Si alguna no ha cerrado al acabar la sesión, completa su fila al principio de la sesión 15. -
Ajusta lo que piden los tiempos. Rellena Cambios y aplícalos en
alerts.yml: si el tiempo hasta firing es mayor de lo que el servicio tolera, quéforo ventana bajas; si ha saltado por un pico sin importancia, qué subes. Es el último paso del ciclo que empezó en la A2.1: el umbral de la documentación, corregido con lo que has medido. Valida, recarga y haz commit dedocs/verificacion.mdy de las reglas que hayas tocado.
Comprobación Cuatro filas completas en docs/verificacion.md, cada una con su categoría y su issue abierta y cerrada en Gitea a las horas de la tabla; los cambios aplicados y promtool check rules sin errores.
Para practicar Guarda docs/verificacion.md en el repositorio alerting: es la base del informe de la práctica evaluable.
Si te sobra tiempo Añade la fila de AppRestarting: cuatro reinicios de app con medio minuto entre ellos, como en la A2.1 (paso 4). Se resuelve sola cuando los reinicios salen de la ventana de una hora, así que conviene lanzarla al principio de la sesión.
Sesión 15 · Práctica evaluable¶
La práctica no pide nada que no hayas hecho en las hojas: se trata de cerrar y ordenar lo que ya está en el repositorio. Entrega el repositorio alerting, comprimido y subido por Aules, con rules.yml, alerts.yml, loki-alerts.yml, alertmanager.yml (sin secretos: tokens en ficheros ignorados), el receptor webhook con su Compose y el README que has ido completando en las hojas. En docs/ van umbrales.md (A2.1), cadenas.md (A2.2) y verificacion.md (A2.7), cuya tabla es a la vez el informe de verificación y la tabla de categorización: cada alarma con su origen, criticidad y servicio y las fechas de creación y cierre de su issue. Si alguna fila de la A2.7 quedó sin cerrar, se completa al principio de esta sesión.
Trabaja en este orden. Cierra primero lo pendiente, las filas de la A2.7 sin terminar. Repasa después el repositorio contra la lista de abajo, que es lo que mira la corrección, y empieza por las validaciones: un fichero que no pasa promtool o amtool no se ha cargado, y nada de lo que depende de él cuenta como probado. Comprueba al final que no hay secretos ni en el repositorio ni en su historial antes de comprimirlo.
-
promtool check rulesyamtool check-configsin errores (A2.4, paso 1, y A2.5, paso 2) - Las alertas sobre indicadores calculados (ratios, percentiles, porcentajes) usan su métrica grabada (A2.4, paso 1)
- Todas las reglas llevan las cinco etiquetas de categorización (A2.4, pasos 1 y 4)
- Activación y recuperación probadas para las cuatro alarmas de la A2.7, con issue abierta y cerrada (A2.7, pasos 2 a 4)
| Criterio | RA1 | Peso |
|---|---|---|
| Umbrales de contadores y cadenas de eventos definidos desde la documentación (A2.1 y A2.2) | b | 25 % |
| Recording rules de agregación y correlación que generan indicadores nuevos (A2.3) | c | 20 % |
| Eventos integrados en el gestor de alarmas con activación y recuperación probadas y almacenadas fuera (A2.4 a A2.7) | d | 30 % |
| Categorización por fecha, origen, criticidad y servicio, con notificación y tratamiento (A2.6 y tabla de la A2.7) | e | 25 % |
Errores frecuentes en el laboratorio¶
La alerta nunca sale de pending. Casi siempre es porque la expresión devuelve series de forma intermitente: con rate(x[1m]) y scrape de 15 s, hay evaluaciones en las que la ventana no tiene muestras suficientes y la serie desaparece, lo que reinicia el contador de for. Conviene ampliar la ventana a [5m] o revisar que evaluation_interval no sea mayor que el scrape.
increase devuelve 2,7 reinicios, o la consulta de reinicios da 0 aunque la API se haya reiniciado. Lo primero es la extrapolación de la ventana aplicada a una métrica que no es un contador: process_start_time_seconds es un gauge con la hora de arranque, y para marcas de tiempo se usa changes(); si hace falta un entero para un contador real, round(increase(...)) o resets(). Lo segundo pasa si la consulta usa container_start_time_seconds de cAdvisor, que guarda la hora de creación del contenedor y no cambia al reiniciarlo, o si los reinicios van tan seguidos que Prometheus no llega a hacer scrape de alguno.
La división devuelve "no data". Las etiquetas de numerador y denominador no coinciden. Se ejecuta cada lado por separado, se comparan las etiquetas y se añade ignoring(...), on(...) o el sum by que falte. Con cAdvisor el culpable habitual es id o image.
El exporter cae y no salta nada. up == 0 no dispara si la serie ha desaparecido del todo (por ejemplo, porque el target lo generaba un descubrimiento que ya no lo lista). Se añade or absent(up{job="..."}).
Las reglas nuevas no aparecen después de recargar Prometheus. Una recarga con errores no lo tumba: sigue con la configuración anterior y prometheus_config_last_reload_successful vale 0. El log de Prometheus (docker compose logs --tail 20 prometheus, en /opt/monitoring) dice qué fichero y qué línea fallan; pasar promtool check rules antes de cada recarga lo evita.
Alertmanager rechaza el fichero: receiver "null" not found. Se ha escrito receiver: null sin comillas y YAML lo ha convertido en valor nulo. Va entre comillas en los dos sitios.
Llegan notificaciones por duplicado. O la ruta tiene continue: true y dos receptores comparten canal, o hay dos Prometheus con external_labels distintas mandando la "misma" alerta (para Alertmanager son distintas si difieren en cualquier etiqueta). amtool alert query muestra las etiquetas completas.
El correo no llega a Mailpit. smtp_require_tls está a true por defecto y Mailpit no habla TLS de serie. Se pone a false en global. Si aparece dial tcp: lookup mailpit, el contenedor de Alertmanager no está en la misma red de Compose que Mailpit.
La web de Mailpit o la de Gitea no responde en su puerto. Desde el puesto, el navegador dice que se ha rechazado la conexión; desde la propia mon01, curl da Empty reply from server o Connection reset by peer. El contenedor está publicado en la franja, pero dentro sigue escuchando en su puerto de fábrica, y Docker no tiene a quién entregar la conexión. Falta la opción que lo cambia: MP_UI_BIND_ADDR en Mailpit y GITEA__server__HTTP_PORT en Gitea. Las primeras líneas de docker compose logs de cada uno dicen en qué puerto ha arrancado.
Telegram devuelve Bad Request: chat not found. El bot no está en el grupo, o el chat_id es el de un usuario y no el del grupo (que es negativo). Se manda un mensaje al grupo con el bot dentro y se consulta https://api.telegram.org/bot<TOKEN>/getUpdates.
El ruler de Loki no carga las reglas. El directorio es /loki/rules/<tenant>/ y con auth_enabled: false el tenant es fake. Un fichero directamente en /loki/rules/ se ignora sin error. Y la validación: lokitool rules lint, con -n para que no reescriba el fichero.
El webhook crea una issue por cada repetición. El script no comprueba si el fingerprint ya tiene issue, o el fichero de estado está en un directorio que no persiste entre reinicios del contenedor. Se monta /data como volumen.
El receptor de incidencias responde 500 y no aparece ninguna issue. Casi siempre es el token sin permiso de escritura sobre issues o GITEA_REPO mal escrito; los logs del servicio tickets lo dicen. Si el error es de conexión, GITEA_URL apunta a gitea.lab:23001, un nombre que el contenedor no resuelve porque no lee el /etc/hosts de mon01: va http://gitea:23001, con el mismo puerto y el nombre del servicio.
La inhibición no inhibe. Falta equal o los valores de la etiqueta no coinciden literalmente (instance="app01:29100" en la fuente y instance="app01:29102" en el objetivo son distintos). Se usa una etiqueta común como host, que llevan todos los jobs, o se inhibe por env. amtool alert query -i dice si una alerta está inhibida.
Los enlaces para ampliar y los apartados que van más allá de lo que se hace en clase están en Para ampliar.
UT3 · Seguridad de las comunicaciones de monitorización¶
Las dos primeras unidades conectaron el servicio del curso a la pila de mon01 (node_exporter, cAdvisor, postgres_exporter, el /metrics de la API, Promtail y Loki) y le pusieron alarmas encima. Todo eso se montó pensando en que funcionase y en que Prometheus llegase a todo, y funciona en claro: cada exporter es un servidor HTTP sin autenticación, Promtail manda los logs a Loki por HTTP y Grafana está en el 23000 con la contraseña del primer día. Y la monitorización es justo la pieza que más conviene proteger, porque es la que llega a todas las máquinas y la que sabe todo de ellas: quien entra en ella tiene el mapa de la infraestructura y, si alcanza las alarmas, puede apagarlas antes de atacar.
Esta unidad mira la pila como la miraría alguien que ha entrado en la red y la protege en el orden en que se hace en una empresa. Primero se audita: qué puertos hay abiertos, quién puede llegar a ellos y qué se lleva si llega, con una evidencia fechada de cada cosa; es el criterio f. Después se cierra lo que sobra por capas (la forma de publicar de Docker, el firewall de cada host y OPNsense), se cifra y se autentica lo que queda, y se documenta como una política que se pueda comprobar; es el criterio g. Con esta unidad se cierra la primera evaluación, que tiene su examen el 10 de diciembre. Todo se apoya en la unidad de seguridad por capas de Despliegue, que se cursa a la vez, del 20 de noviembre al 9 de diciembre: allí se montan OPNsense, sus zonas y la CA del curso que se usan aquí.
Sesión 16 · Auditoría inicial¶
Durante dos unidades la pila se ha montado para que funcione, y nadie ha preguntado todavía quién más puede llegar a ella. Antes de cerrar nada hay que saberlo, por dos motivos: lo que se cree abierto (lo que dicen los compose) no siempre es lo que está abierto de verdad, y la práctica evaluable pide demostrar con fecha cómo estaba la pila antes de protegerla y cómo queda después. La teoría sigue el orden en que se razona una auditoría: primero qué regala un /metrics abierto, para saber por qué importa; después la tabla de puertos con quién debe llegar a cada uno, que es lo esperado; y al final las cuatro medidas que dicen lo que hay de verdad (el inventario desde dentro, el escaneo desde fuera, qué protocolo habla cada puerto y qué tráfico circula) y la matriz que compara lo esperado con lo medido. La hoja hace esa auditoría sobre app01 y mon01 y deja preparados los certificados y las reglas de OPNsense que harán falta después del traslado. Hoy es el último día en que la pila se puede medir tal como se construyó: mañana, 27 de noviembre, la A3.3 de Despliegue traslada app01 y mon01 a la VPC, detrás de OPNsense.
Superficie de exposición de la monitorización¶
Qué es la superficie de exposición. Es el conjunto de puntos por los que alguien de fuera puede interactuar con un sistema: cada puerto que escucha, cada servicio que responde, cada API que acepta peticiones. Cuanto más grande, más sitios por donde atacar, y por eso la primera regla de cualquier protección es reducirla: lo que no escucha no se puede atacar.
Por qué la de la monitorización es tan grande. Cada exporter es un servidor HTTP más en cada máquina. Cuando se montó la pila, el objetivo era que Prometheus llegase a todo (el scrape: la lectura periódica de cada /metrics), y la forma rápida de conseguirlo fue dejar node_exporter escuchando en todas las interfaces y publicar puertos en el host: 28081:28081, 29102:29102, 29080:29080. Ni los exporters ni Prometheus traen activados de fábrica el cifrado ni la contraseña, y al montarla no se activaron. El resultado es que la monitorización abre más puertos que la propia aplicación. El servicio del curso, bien desplegado, expone un único 443 en web01; la monitorización, sin control, expone cuatro puertos en app01, dos en db01 y siete en mon01 (contando Mailpit y la Gitea provisional), ninguno con contraseña.
Qué se ve en un /metrics abierto¶
Las métricas no son «solo números». La idea no aguanta un curl. Esto es lo que devuelve un node_exporter recién instalado a cualquiera que llegue al 29100:
node_uname_info{domainname="(none)",machine="x86_64",nodename="app01",release="6.12.22-amd64",sysname="Linux",version="#1 SMP PREEMPT_DYNAMIC Debian 6.12.22-1"} 1
node_os_info{id="debian",name="Debian GNU/Linux",version_id="13"} 1
node_network_address_info{address="10.10.2.10",device="ens18",netmask="24"} 1
node_filesystem_size_bytes{device="/dev/sda1",mountpoint="/var/lib/docker"} 2.1e+10
node_systemd_unit_state{name="ssh.service",state="active"} 1
node_systemd_unit_state{name="postgresql.service",state="inactive"} 1
node_boot_time_seconds 1.763979e+09
Lo que regala node_exporter. Con esas siete líneas un atacante sabe qué versión exacta del kernel y de la distribución corre la máquina, y con eso qué CVE (vulnerabilidades publicadas con identificador) le afectan; sabe en qué red está y con qué dirección, qué discos tiene montados, qué servicios están activos y cuánto lleva sin reiniciarse (node_boot_time_seconds), que suele ser cuánto lleva sin aplicar los parches del kernel, porque esos necesitan reiniciar. Es justo la información que se busca al preparar un ataque, servida sin pedir nada a cambio.
flowchart LR
C["<b>curl :29100/metrics</b><br><small>sin credencial ninguna</small>"]:::act
V["<b>Lo que sale</b>"]:::dato
V1["<b>Versión del kernel y la distribución</b><br><small>qué CVE le afectan</small>"]:::riesgo
V2["<b>Sistemas de ficheros y puntos de montaje</b><br><small>el mapa del disco</small>"]:::riesgo
V3["<b>Interfaces y direcciones</b><br><small>la topología de la red</small>"]:::riesgo
V4["<b>Servicios y horas de arranque</b><br><small>cuándo se reinició y qué corre</small>"]:::riesgo
C --> V --> V1 & V2 & V3 & V4
classDef act fill:#ea580c22,stroke:#ea580c,stroke-width:1.5px
classDef pieza fill:#64748b22,stroke:#64748b,stroke-width:1.5px
classDef dato fill:#2563eb22,stroke:#2563eb,stroke-width:1.5px
classDef infra fill:#a1a1aa14,stroke:#a1a1aa,stroke-width:1.5px
classDef ok fill:#16a34a22,stroke:#16a34a,stroke-width:1.5px
classDef riesgo fill:#dc262622,stroke:#dc2626,stroke-width:1.5px
No hace falta entrar en la máquina para saber cómo atacarla: basta con que el exporter esté abierto.
Lo que regalan los demás exporters. cAdvisor pone en sus series la imagen y el nombre de cada contenedor (las etiquetas image y name, por ejemplo en container_last_seen), así que se ve qué versión de la aplicación corre (app:1.4.2). Con su configuración de fábrica copia además todas las etiquetas de Docker de cada contenedor como container_label_*, y si alguien puso en una etiqueta del compose una URL con credenciales, está ahí; la configuración del curso solo deja pasar dos, pero el /metrics sigue diciendo qué corre y en qué versión. postgres_exporter publica los nombres de las bases de datos y las conexiones por usuario. El /metrics de la API del curso enumera las rutas que han recibido alguna petición (app_http_requests_total{route="/items/<int:id>"}), incluidas las que no están enlazadas desde ningún sitio.
Lo que regalan los servidores de la pila. Prometheus, Alertmanager y Loki filtran todavía más: la API de Prometheus da en /api/v1/targets el mapa de todo lo que se monitoriza y en /api/v1/status/config la configuración cargada, Alertmanager acepta silencios sin autenticación (un atacante silencia la alarma y después ataca) y Loki sin autenticación deja leer todos los logs e inyectar líneas falsas. El detalle de cada API está en Para ampliar.
No es teórico: hay recuentos públicos de cientos de miles de exporters abiertos a Internet (casos reales).
Puertos del entorno del curso y quién debe llegar¶
La referencia contra la que se audita. Auditar es comparar lo que hay con lo que debería haber, y para eso primero hay que escribir lo que debería haber. Esta tabla es esa referencia: cada puerto de la pila, en qué máquina corre y quién tiene motivo para llegar a él. El criterio es el de mínimo privilegio, cada puerto solo para quien lo necesita para su trabajo, y de la tabla salen tanto la columna «esperado» de la matriz de la auditoría como las reglas de los cortafuegos.
| Componente | Puerto | Dónde corre | Quién debe llegar |
|---|---|---|---|
| node_exporter | 29100 | app01, db01, mon01 (viene en la plantilla) | Solo mon01 |
| cAdvisor | 28081 | app01 | Solo mon01 |
| /metrics de la app | 29102 | app01 | Solo mon01 |
| Promtail | 29080 | app01 | Solo mon01, que lee sus métricas |
| postgres_exporter | 29187 | db01 | Solo mon01 |
| Loki | 23100 | mon01 | El Promtail de app01 y logcli desde el puesto de administración, con certificado de cliente; Grafana y Prometheus por dentro |
| Grafana | 23000 dentro de mon01, 443 tras nginx | mon01 | Usuarios desde la red de gestión |
| Prometheus | 29090 | mon01 | Grafana y Alertmanager por dentro; administradores por túnel SSH |
| Alertmanager | 29093 | mon01 | Prometheus por dentro; administradores por túnel SSH |
| Mailpit | 28025 | mon01 | Administradores por túnel SSH |
| Gitea provisional | 23001 | mon01, hasta el 12 de febrero | La red de gestión y app01 |
Cada servicio escucha en su contenedor en el mismo número con el que se publica, según la convención de puertos del curso, así que el número de la tabla es a la vez el que se escanea, el que va a la matriz y el que usan los demás contenedores por la red de Docker.
Todo lo demás, cerrado. Y de estos, los administradores entran por la red de gestión (10.10.0.0/24) con usuario nominal (una cuenta por persona, nunca compartida, para que cada acción tenga autor), nunca desde front ni desde back. Los servidores de mon01 se hablan por nombre en la red de Docker, así que en la 10.10.0.20 solo se publica lo que llega de otra máquina: un nginx que hace de puerta de Grafana (443) y de Loki (23100), y la Gitea provisional (23001). Prometheus, Alertmanager y Mailpit quedan en 127.0.0.1, detrás de un túnel SSH (un puerto del puesto que, por dentro de una conexión SSH, llega a mon01). De fábrica no piden credenciales, y el túnel les pone delante la autenticación y el cifrado de SSH sin tocar nada en ellos.
Por qué el scrape cruza el cortafuegos. Un detalle del laboratorio condiciona toda la unidad: app01 y db01 no tienen pata en la red de gestión. Cada uno lleva una sola tarjeta, la de su zona (app01 la 10.10.2.10 en back, db01 la 10.10.3.10 en data), y mon01 vive en gestión con la 10.10.0.20. Eso significa que el scrape cruza siempre el cortafuegos, y que la protección no puede consistir en esconder el puerto en una interfaz que solo ve gestión: hay que permitir el paso justo en OPNsense y cerrar el resto en cada host.
Las reglas de OPNsense. Salen directamente de la tabla anterior. Son las filas 11 a 13 de la matriz de reglas del laboratorio, y se escriben como las reglas por zona que explicó Despliegue el 25 de noviembre: cada regla en la pestaña de la interfaz por la que entra el tráfico (las reglas y su orden de evaluación) y con alias, nombres que agrupan direcciones o puertos para que la regla se lea sola (los aliases). srv_app y srv_db ya existen, y solo se añaden el host srv_mon (10.10.0.20) y los alias de puertos p_mon_app y p_mon_db.
| Fila | Pestaña | Acción | Origen | Destino | Puertos | Log | Descripción |
|---|---|---|---|---|---|---|---|
| 11 | MGMT | Pass | srv_mon |
srv_app |
p_mon_app: 29100, 28081, 29102, 29080 |
sí | 11 Scrape de mon01 a app01 |
| 12 | MGMT | Pass | srv_mon |
srv_db |
p_mon_db: 29100, 29187 |
sí | 12 Scrape de mon01 a db01 |
| 13 | DMZINT | Pass | srv_app |
srv_mon |
23100 | sí | 13 Promtail de app01 hacia Loki |
Cualquier otro origen cae en la denegación por defecto, la fila 99. app01 llega además al 23001 de la Gitea provisional por la fila 6, que es de Despliegue. Sin las filas 11 a 13 los targets de app01 y db01 se quedan en DOWN en la VPC y los logs de app01 no llegan a Loki. La A3.1 las deja puestas el día antes del traslado, y así el 1 de diciembre basta con cambiar los targets a las direcciones nuevas.
Auditar: saber qué hay antes de tocar nada¶
Qué es auditar aquí. Medir lo que hay de verdad, compararlo con lo que debería haber y dejar una prueba fechada de cada medida. Es el criterio f, y se hace antes de proteger nada por dos razones: lo que se cambia ya no se puede medir como estaba, y la evaluable pide la matriz antes y después, porque la comparación es lo que demuestra que la protección funciona.
Por qué cuatro medidas y no una. Cada herramienta ve una parte. El inventario desde dentro dice lo que el host cree que expone; el escaneo desde fuera, lo que de verdad se alcanza a través de la red; la prueba de protocolo, lo que se lleva quien llega; y la captura de tráfico, quién usa cada puerto y qué viaja por él. Los problemas se esconden justo en las diferencias entre una medida y otra: un puerto que ss no lista y que se ve desde fuera, o un exporter que no está en ningún compose y responde igual. Las cuatro se hacen sobre app01 y mon01, y la matriz las junta: son los cinco apartados que siguen.
Repaso
nmap y tcpdump, y cómo se leen sus salidas, se explicaron en Despliegue el 6 de noviembre, en Cómo se prueba una red. Aquí solo se repasa lo que cambia al aplicarlos a los exporters.
Inventario desde dentro¶
Qué escucha cada host. ss -tlnup lista los sockets TCP y UDP en escucha (-t, -u, -l), con números en lugar de nombres (-n) y con el proceso que los abrió (-p; el proceso solo se ve con root):
sudo ss -tlnup
# Netid State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
# tcp LISTEN 0 4096 *:29100 *:* users:(("prometheus-node",pid=812,fd=3))
# tcp LISTEN 0 4096 0.0.0.0:28081 0.0.0.0:* users:(("docker-proxy",pid=1544,fd=4))
# tcp LISTEN 0 4096 0.0.0.0:29102 0.0.0.0:* users:(("docker-proxy",pid=1590,fd=4))
# tcp LISTEN 0 4096 0.0.0.0:29080 0.0.0.0:* users:(("docker-proxy",pid=1602,fd=4))
# tcp LISTEN 0 128 0.0.0.0:22 0.0.0.0:* users:(("sshd",pid=610,fd=3))
La dirección es lo que importa. La columna de dirección local dice desde dónde se puede llegar: 0.0.0.0 o * es todas las interfaces, incluidos los bridges de Docker; 127.0.0.1, solo desde la propia máquina; 10.10.2.10, solo por la tarjeta de la zona. Un exporter en 0.0.0.0 no es un agujero por sí mismo, pero quiere decir que todo depende de que un cortafuegos lo cierre.
Lo que publica Docker no siempre sale en ss. Los puertos que aparecen con el proceso docker-proxy los publica Docker. Ese proceso es solo un apoyo: Docker publica en realidad con reglas de NAT en el kernel, y si se desactiva el proxy ("userland-proxy": false en daemon.json) el puerto sigue abierto pero desaparece de ss. Por eso el inventario no está completo sin preguntar a Docker qué publica:
docker ps --format '{{.Names}}\t{{.Ports}}'
# servicio-app-1 0.0.0.0:29102->29102/tcp, :::29102->29102/tcp
# cadvisor 8080/tcp, 0.0.0.0:28081->28081/tcp, :::28081->28081/tcp
# promtail 0.0.0.0:29080->29080/tcp, :::29080->29080/tcp
# servicio-db-1 5432/tcp
0.0.0.0:29102->29102/tcp es un puerto publicado para todo el mundo, en IPv4 y en IPv6; el primer número es el del host, que es el que se escanea desde fuera, y el segundo, el del contenedor, que en el curso es siempre el mismo. 5432/tcp sin flecha se expone pero no se publica: solo se alcanza desde la red de Docker. El 8080/tcp de cAdvisor, también sin flecha, es otra cosa: el puerto que su imagen declara de fábrica (su EXPOSE), que sigue en la lista aunque el contenedor escuche en el 28081; ahí no escucha nada. node_exporter no sale en esta lista porque no es un contenedor sino un servicio del sistema, que viene en la plantilla de las VM: de él solo informa ss. Cómo son esas reglas de NAT, y por qué el cortafuegos del host no ve los puertos que publica Docker, se explica el 1 de diciembre, en Por qué Docker se salta el firewall del host.
Escaneo desde fuera¶
Por qué desde fuera, y desde dónde. El inventario dice lo que el host cree que expone; el escaneo, lo que de verdad se alcanza desde otra máquina, con todo lo que hay por medio (cortafuegos, rutas, el NAT de Docker). Como lo que se alcanza depende de desde dónde se mire, se escanea desde varios orígenes, y los de la unidad son fijos porque cada uno representa a un tipo de cliente:
- mon01, el cliente legítimo: tiene que ver los exporters.
- El puesto de administración (10.10.0.50), una máquina de gestión que no es la monitorización: no tiene por qué leer métricas, aunque esté en la red de confianza.
- web01, la máquina más expuesta, en la DMZ externa: es la primera que caería si alguien entra desde Internet, y no debe ver nada de la monitorización.
- app01 hacia mon01, el sentido contrario: los logs van de app01 a mon01, así que app01 tiene que llegar a Loki y a la Gitea provisional, y a nada más.
El 26 de noviembre, con web01 ya detrás de OPNsense, el origen «de fuera» es el puesto de administración, por su tarjeta del aula.
Las opciones de nmap. Interesan dos escaneos: -sS (SYN, con root: manda solo el primer paquete de cada conexión y no la completa, así que es rápido) y -sV, que identifica el servicio de cada puerto abierto y dice si habla TLS. --reason añade a cada puerto el paquete que decidió su estado, que es justo lo que hay que interpretar; -p- recorre los 65 535 puertos en lugar de los mil más habituales, entre los que no está ninguno de los que usa el curso del 20000 al 29999 (sin -p, nmap no encuentra ni un exporter), y -T4 acorta las esperas, algo razonable en una red de laboratorio sin pérdidas. Detrás de un cortafuegos hace falta además -Pn: antes de escanear, nmap comprueba si el host está vivo con un ping y un par de sondas TCP, y si el cortafuegos las tira todas lo da por caído y no escanea nada (avisa con Host seems down); -Pn se salta esa comprobación.
sudo nmap -sS -p- --reason -T4 <app01> <mon01> # desde el puesto de administración, todos los puertos
sudo nmap -sS -p 22,28081,29080,29100,29102 --reason <app01> # desde mon01, solo los esperados
Cómo se lee cada estado. Es lo que se pide en la práctica y lo que la gente confunde:
| Estado nmap | Qué ha pasado en la red | Qué significa aquí |
|---|---|---|
open |
Llegó un SYN-ACK | Hay un servicio escuchando y nada lo filtra |
closed |
Llegó un RST | El paquete llegó al host pero nadie escucha en ese puerto; un firewall que rechaza con RST en vez de tirar (la acción Reject) también da esto |
filtered |
No llegó nada, o llegó un ICMP unreachable | Un firewall con drop lo ha tirado por el camino (OPNsense o nftables) |
Hoy, desde el puesto de administración, el 29100 de app01 sale open; al final tiene que salir filtered, y desde mon01 seguir open. La salida se guarda con -oN escaneo-desde-admin-$(date +%F).txt: la fecha del nombre y la hora de la cabecera son la evidencia. Si todo está filtrado, el escaneo tarda minutos, porque nmap espera el timeout de cada puerto: eso ya indica un drop. web01 ya tiene nmap; desde app01, que no lo tiene, se usa nc -zv -w 3 <IP> <puerto>: succeeded es open, refused es closed y el tiempo agotado, filtered.
Protocolos y autenticación¶
Por qué no basta con el escaneo. open dice que alguien responde, no qué entrega ni si pregunta quién llama. La tercera medida pide cada /metrics como lo pediría cualquiera, sin credenciales, y mira el diálogo entero con curl -v:
Qué se lee en la salida. Las líneas que empiezan por > son lo que envía curl, y las que empiezan por <, lo que contesta el servidor. Si la conexión fuese cifrada, antes de la petición aparecería el handshake TLS (el saludo inicial en el que cliente y servidor acuerdan cómo cifrar), con una línea SSL connection using TLSv1.3; si el exporter pidiese credenciales, la respuesta sería 401 Unauthorized. Un HTTP/1.1 200 OK seguido de las métricas, desde el puesto de administración, son tres fallos a la vez: alcanzable desde donde no toca, en claro y sin autenticación. Cada uno se arregla en un sitio distinto, y ese es el orden de lo que queda de unidad: el cortafuegos decide quién llega, TLS cifra lo que viaja y la autenticación decide a quién se contesta.
Tráfico real con tcpdump¶
Lo que no ve ninguna de las anteriores. El escaneo y curl prueban qué pasa cuando alguien abre una conexión hacia un puerto; no dicen quién la abre de verdad ni qué viaja por ella. Para los logs eso es lo que importa, porque ahí la conexión la abre app01: Promtail empuja sus lotes hacia el 23100 de mon01, y la única forma de ver qué viaja es capturarlo. En app01:
sudo tcpdump -i ens18 -nn -A port 23100 -c 20
# 10:42:17.301 IP 10.10.2.10.48122 > 10.10.0.20.23100: Flags [P.], seq ..., length 1834
# 10:42:17.303 IP 10.10.0.20.23100 > 10.10.2.10.48122: Flags [P.], seq ..., length 89
Qué se busca. La salida de ejemplo es con las direcciones de la VPC; el 26 de noviembre salen las del aula. Las opciones son las de siempre (-i la interfaz, -nn sin traducir direcciones ni puertos a nombres, -c 20 para parar a los veinte paquetes) más -A, que imprime el contenido de cada paquete como texto. Se busca que el único destino sea mon01 y que inicie app01: un puerto de origen efímero (el que el sistema asigna al cliente, distinto en cada conexión) y el 23100 de destino. Antes de proteger se lee la petición POST /loki/api/v1/push con sus cabeceras y trozos de las líneas de log, porque Promtail las comprime, pero comprimir no es cifrar. Cuando el envío vaya cifrado, al final de la unidad, no se leerá nada, y esa pareja de capturas, la de hoy y la del final, es la evidencia de cifrado de la práctica evaluable.
La matriz de exposición¶
Para qué una matriz. Los ficheros de nmap, curl y tcpdump son la prueba, pero nadie los lee uno a uno. La matriz los ordena para que se entiendan de un vistazo: una fila por cada origen y destino, con lo que se esperaba (de la tabla de puertos), lo que se obtuvo (de las medidas) y el fichero que lo demuestra. Estas son las filas mínimas, las mismas el 26 de noviembre, con las direcciones del aula, y el 3 de diciembre, con las de la VPC:
| Origen | Destino | Puertos | Esperado | Obtenido (antes) | Obtenido (después) | Evidencia |
|---|---|---|---|---|---|---|
| mon01 | app01 | 29100, 28081, 29102, 29080 | open | open | escaneo-app01-desde-mon01-2026-11-26.txt | |
| puesto de administración | app01 | 29100, 28081, 29102, 29080 | filtered | open | escaneo-desde-admin-2026-11-26.txt | |
| puesto de administración | mon01 | 23000, 28025, 29090, 29093, 29100 | filtered | open | ídem | |
| puesto de administración | mon01 | 443 | open | closed (aún no hay nginx) | ídem | |
| puesto de administración | mon01 | 23001, 23100 | open (23100 solo con certificado de cliente) | open | ídem | |
| app01 | mon01 | 23100 | open y cifrado | open y en claro | tcpdump-app01-23100-2026-11-26.txt | |
| app01 | mon01 | 23000, 28025, 29090, 29093 | filtered | open | nc-mon01-desde-app01-2026-11-26.txt | |
| web01 | app01 y mon01 | todos los de la tabla de puertos | filtered | no se mide: web01 ya está detrás de OPNsense |
Las filas donde esperado y obtenido no coinciden se marcan en rojo: son la lista de trabajo del resto de la unidad, y en la práctica evaluable se rellena «obtenido (después)». Al final de la unidad, la misma matriz con las dos columnas llenas es la demostración de que el trabajo está hecho.
flowchart LR
O["<b>Cualquier otro origen</b><br><small>el aula, el puesto de administración, web01</small>"]:::infra
M["<b>mon01</b>"]:::ok
A["<b>app01</b><br><small>29100 · 28081 · 29102 · 29080</small>"]:::pieza
M -->|"antes: abierto y en claro<br>después: TLS y basic auth"| A
O -.->|"antes: abierto<br>después: filtered"| A
classDef act fill:#ea580c22,stroke:#ea580c,stroke-width:1.5px
classDef pieza fill:#64748b22,stroke:#64748b,stroke-width:1.5px
classDef dato fill:#2563eb22,stroke:#2563eb,stroke-width:1.5px
classDef infra fill:#a1a1aa14,stroke:#a1a1aa,stroke-width:1.5px
classDef ok fill:#16a34a22,stroke:#16a34a,stroke-width:1.5px
classDef riesgo fill:#dc262622,stroke:#dc2626,stroke-width:1.5px
El objetivo de la unidad en una imagen: que solo quede una flecha, y además autenticada.
A3.1 Auditoría inicial (sesión 16)¶
Objetivo Tener la matriz de exposición de la pila de monitorización tal como está hoy, con una evidencia fechada por fila y en rojo las filas que no deberían estar así. Es la mitad «antes» de la práctica evaluable, y solo se puede medir hoy: mañana app01 y mon01 se van a la VPC y la pila deja de estar como se construyó. De paso dejas preparados los certificados y las reglas de OPNsense que la unidad necesita después del traslado, para que el resto de la unidad solo tenga que instalarlos.
Antes de empezar
- app01 y mon01 con la pila de monitorización y alarmas funcionando (todos los targets en UP), todavía en la red del aula y sin cortafuegos: eso es lo que la matriz tiene que demostrar con fecha. Hoy se usan sus direcciones del aula; el traslado a la VPC es mañana, en la A3.3 de Despliegue.
- El puesto de administración, que tiene tarjeta en el aula además de la 10.10.0.50, hace hoy de origen «de fuera»: web01 ya está detrás de OPNsense y no llega al aula.
- La consola de OPNsense, con los alias
srv_appysrv_db, y la CA del curso (Lab 5166 CA) en~/ca/del puesto de administración, las dos tal como las dejó ayer la A3.2 de Despliegue. Si la CA no existe, créala con aquel apartado antes del paso 5. - Lo explicado antes: qué se ve en un /metrics abierto, la tabla de puertos con las filas 11 a 13 y las cuatro medidas de la auditoría con su matriz, con el repaso de nmap y tcpdump. Para el paso 5, Certificados con la CA del curso, que se explica el 1 de diciembre pero hoy solo hace falta para firmar.
Pasos
Vas a hacer dos cosas. Primero, en los cuatro primeros pasos, la auditoría, de dentro afuera y en el orden de la teoría: lo que cada host dice que escucha, lo que se alcanza desde otras máquinas y lo que contesta cada puerto, lo que viaja por la red y, al final, la matriz que lo compara todo con lo esperado. Después, en los dos últimos, preparas el traslado de mañana: los certificados y las reglas de OPNsense no se pueden probar hoy, pero dejarlos hechos es lo que permite que la clase del 1 de diciembre se dedique entera a cerrar y a cifrar.
-
Haz el inventario de app01 y de mon01. Antes de mirar desde fuera, apunta lo que cada máquina dice que expone, con las dos órdenes de Inventario desde dentro, en
inventario-<host>-<fecha>.txt. Por cada puerto, anota en qué dirección escucha y si lo publica Docker: son los dos datos que deciden después dónde va la regla que lo cierra.Pista: instala antes
tcpdump,netcat-openbsdynftablesen app01, que mañana pierde la salida a Internet, ynmapen mon01 y en el puesto de administración. De app01, la matriz solo recoge lo que sigue allí tras el traslado: la base de datos, nginx y postgres_exporter se van mañana. -
Escanea desde fuera y pide las métricas sin credenciales. Comprueba qué se alcanza de verdad desde los dos orígenes de hoy, como en Escaneo desde fuera: desde mon01, los puertos esperados de app01 (
escaneo-app01-desde-mon01-<fecha>.txt), que tienen que salir abiertos porque es el cliente legítimo; desde el puesto de administración, todos los de app01 y mon01 (escaneo-desde-admin-<fecha>.txt), que es lo que vería cualquiera del aula. Después, desde el puesto, guarda encurl-app01-desde-admin-<fecha>.txtlo que responden los cuatro exporters de app01 a una petición sin credenciales, como en Protocolos y autenticación: demuestra que, además de alcanzables, hablan en claro y contestan a cualquiera. -
Mira qué viaja por la red desde app01. Guarda en
tcpdump-app01-23100-<fecha>.txtveinte paquetes del tráfico de Promtail hacia Loki con el contenido legible, como en Tráfico real con tcpdump: es la mitad «antes» de la pareja de capturas que demuestra el cifrado. Guarda también ennc-mon01-desde-app01-<fecha>.txtsi app01 llega a los puertos 23000, 28025, 29090 y 29093 de mon01: es el sentido contrario, lo que alcanzaría quien comprometiese la API.Pista: comprueba el nombre de la interfaz con
ip -br linkantes de capturar.ncescribe su resultado en la salida de error: redirígela al fichero. -
Junta las medidas en la matriz. Reúne las evidencias en
audit/antes/del repositoriomonitoringde mon01 (/opt/monitoring) y creaaudit/matriz.mdcon las ocho filas de La matriz de exposición: «obtenido (antes)» con lo medido y las direcciones de hoy, «después» vacía, y en rojo (negrita o una columna «estado») las filas donde esperado y obtenido no coinciden, que son la lista de trabajo del resto de la unidad. Haz commit: su fecha es una prueba más de cuándo se midió. -
Emite de una vez los certificados de la unidad. El resto de la unidad cifra el scrape y los logs, y cada servicio que va a hablar TLS necesita su certificado firmado por la CA del curso. Emítelos hoy todos, como en Certificados con la CA del curso, y déjalos con sus claves y una copia de
ca.crten~/tls-monitorizacion/del puesto de administración, fuera de cualquier repositorio: después solo habrá que copiarlos. Son ocho. Siete son de servidor, con su SAN:app01-nodeyapp01-nginx(DNS:app01.dev.lab,IP:10.10.2.10),db01-nodeydb01-postgres(DNS:db01.dev.lab,IP:10.10.3.10),mon01-nodeymon01-loki(DNS:mon01.lab,IP:10.10.0.20) ymon01-nginx(lo mismo másDNS:grafana.lab). El octavo,promtail-app01, es de cliente y no lleva SAN. Comprueba uno de cada clase conopenssl x509 -noout -ext subjectAltName,extendedKeyUsage.Pista: con la función de aquel apartado, cada certificado de servidor es una línea; el de cliente lleva
-extensions client_certy ningún SAN. Si repites una firma,index.txtla apunta dos veces: emite solo los que falten. -
Deja en OPNsense las reglas del traslado. Crea las filas 11 a 13 de la tabla del apartado, con sus alias nuevos (
srv_mon,p_mon_appyp_mon_db) y el número de fila al principio de la descripción, para que el log y la matriz de reglas se crucen sin buscar. Hoy no se pueden probar, porque app01 y mon01 todavía no pasan por OPNsense, pero desde el traslado de mañana son las que dejan pasar el scrape y los logs. Mañana añade también las tres filas, con su justificación, a lamatriz-reglas.mdque empiezas en la A3.3 de Despliegue.
Comprobación Un fichero de inventario por host, el escaneo desde mon01 y el del puesto de administración con la fecha en el nombre y en la cabecera, el fichero de curl con 200 OK y métricas, el de nc desde app01 con los cuatro puertos succeeded, y una captura de tcpdump donde se lee POST /loki/api/v1/push en claro. En la matriz, las filas del puesto de administración y de app01 hacia puertos de monitorización están en rojo y la de mon01 no. En ~/tls-monitorizacion/ hay ocho certificados con su clave y ca.crt, y los dos comprobados dicen lo que tienen que decir: el de servidor lleva su SAN y serverAuth, y el de cliente, clientAuth. En OPNsense están los tres alias y las filas 11 a 13.
Para practicar Antes de salir, haz commit de audit/antes/ y audit/matriz.md en el repositorio monitoring: la práctica evaluable de la unidad parte de esta matriz, que es su mitad «antes». Los certificados y sus claves no van a ningún repositorio: déjalos en ~/tls-monitorizacion/ del puesto, y de ahí los copiarás a cada host.
Si te sobra tiempo Consulta http://<mon01>:29090/api/v1/targets y /api/v1/status/config desde el puesto de administración y anota qué se lee de la infraestructura sin ninguna credencial.
Sesión 17 · Red, firewall y TLS¶
La matriz de la A3.1 tiene filas en rojo de tres clases: puertos que se alcanzan desde donde no toca, tráfico que viaja en claro y servicios que contestan a cualquiera. Desde el 27 de noviembre app01 y mon01 están en la VPC, detrás de OPNsense, y eso permite por fin arreglarlas con las dos capas que quedan: reducir, para que solo llegue quien debe, y proteger, para que lo que llega vaya cifrado y con credenciales. Hacen falta las dos, porque una sin la otra no protege nada: un puerto cerrado sigue hablando en claro con quien sí puede llegar, y un puerto cifrado sigue expuesto a quien no debería verlo. La teoría va en el orden en que se aplican. Primero, por qué un cortafuegos en un host con Docker no filtra los puertos que publica Docker, porque sin eso las reglas de la hoja parecen no funcionar; después, la reducción por capas (publicar solo donde hace falta, el nftables de cada host y la segunda capa en OPNsense); luego, qué resuelve TLS y qué resuelve la autenticación, los certificados de la CA del curso y cómo se activan los dos en un exporter; y al final, cómo se demuestra que todo eso funciona. La hoja lo aplica a app01, que tiene a la vez un exporter que escucha como servicio del host y tres puertos que publica Docker: al acabar, app01 queda cerrado por su propio nftables además de por OPNsense, de forma que sobrevive a un reinicio, y el scrape de su node_exporter va cifrado y con contraseña, con nmap -sV respondiendo ssl/http. Los ficheros van completos aquí y la hoja solo tiene que adaptarlos. El cierre de mon01 y el mTLS entre Promtail y Loki se montan en la práctica evaluable, el 3 de diciembre, con su material de consulta allí.
Por qué Docker se salta el firewall del host¶
El síntoma. Es el error más común al poner un firewall en una máquina con Docker, y conviene entender el mecanismo porque aparece en cualquier host que combine las dos cosas. Alguien pone reglas en nftables o en ufw (un frontal simplificado de iptables) para cerrar el 28080, comprueba con nft list ruleset que están, y desde otra máquina el 28080 sigue abierto. La regla no está mal: es que el paquete nunca pasa por ella.
Las piezas del cortafuegos del kernel. El filtro de paquetes de Linux se llama Netfilter y vive en el kernel; iptables y nftables son las dos herramientas con las que se le escriben reglas (nftables es la actual y la que trae Debian; iptables, la histórica, que todavía usan muchos programas, Docker entre ellos). Para seguir el mecanismo hacen falta tres piezas: las tablas, que agrupan las reglas según lo que hacen (nat para reescribir direcciones, filter para aceptar o tirar); las cadenas, los puntos del camino del paquete donde se aplican (PREROUTING al entrar, INPUT si va al propio host, FORWARD si el host lo reenvía a otro sitio); y el DNAT (NAT de destino), la reescritura de la dirección o el puerto de destino de un paquete, que es lo que hace el port forward de un router.
Cómo publica Docker un puerto. Un contenedor tiene su propia dirección en una red bridge de Docker (por ejemplo, la 172.18.0.3), que solo existe dentro del host. Para que un puerto del host llegue a él, Docker hace lo mismo que el port forward de OPNsense, pero dentro del propio host. Con -p 28080:28080 no abre un socket y reenvía (eso solo lo hace docker-proxy como apoyo para el tráfico local): escribe una regla DNAT en la cadena PREROUTING de la tabla nat que cambia el destino 10.10.2.10:28080 por 172.18.0.3:28080 (el contenedor escucha en el mismo número), y reglas en la cadena FORWARD de la tabla filter que aceptan ese tráfico hacia el bridge.
Por qué la regla del host no lo ve. Un paquete que llega de fuera con destino al contenedor entra por PREROUTING y se le cambia el destino; como el nuevo destino ya no es una dirección del host, el kernel lo enruta: pasa por FORWARD, no por INPUT. Las reglas de INPUT, que es donde todo el mundo pone las de «este host solo acepta X», no lo ven jamás. Con -p 29100:29100 de un node_exporter en contenedor pasaría exactamente lo mismo.
flowchart TB
IN["<b>Paquete a 10.10.2.10:28080</b>"]:::dato
PRE["<b>nat PREROUTING</b><br><small>DNAT de Docker: a 172.18.0.3:28080</small>"]:::pieza
DEC{"<b>¿destino local?</b>"}:::act
FWD["<b>filter FORWARD</b>"]:::pieza
INPUT["<b>filter INPUT</b><br><small>las reglas del host</small>"]:::riesgo
DU["<b>DOCKER-USER</b><br><small>aquí sí valen las reglas del host</small>"]:::ok
DK["<b>DOCKER</b><br><small>las de Docker: accept</small>"]:::pieza
CT["<b>Contenedor</b>"]:::pieza
IN --> PRE --> DEC
DEC -->|"no, es 172.18.0.3"| FWD --> DU --> DK --> CT
DEC -->|sí| INPUT
classDef act fill:#ea580c22,stroke:#ea580c,stroke-width:1.5px
classDef pieza fill:#64748b22,stroke:#64748b,stroke-width:1.5px
classDef dato fill:#2563eb22,stroke:#2563eb,stroke-width:1.5px
classDef infra fill:#a1a1aa14,stroke:#a1a1aa,stroke-width:1.5px
classDef ok fill:#16a34a22,stroke:#16a34a,stroke-width:1.5px
classDef riesgo fill:#dc262622,stroke:#dc2626,stroke-width:1.5px
El tráfico a un contenedor publicado nunca pasa por INPUT: por eso una regla en el firewall del host no lo filtra y hay que ponerla en DOCKER-USER.
La cadena que Docker deja para el administrador. Docker sabe que esto es un problema y por eso crea la cadena DOCKER-USER al principio de FORWARD: es la única cadena que Docker promete no tocar, y todo el tráfico hacia contenedores pasa por ella antes que por las reglas de Docker. En Debian 13 con Docker Engine 28 las reglas de Docker se escriben con la API de iptables, que por debajo es iptables-nft, así que acaban como tablas de nftables (ip filter, ip nat) que conviven con la tabla propia del host (inet fw, la del fichero de más abajo). Un matiz de nftables que aquí importa: cuando hay varias tablas con cadenas base en el mismo hook (el mismo punto del camino del paquete), el kernel las evalúa todas. Un accept en la cadena de Docker no salva al paquete de un drop en la propia; un drop en cualquiera es definitivo. Por eso una cadena forward en la tabla propia con reglas de drop explícitas funciona aunque Docker acepte el tráfico en la suya.
Las tres soluciones. En el laboratorio se usan las tres, según el caso:
- Publicar en una IP concreta.
-p 10.10.2.10:29102:29102(o"10.10.2.10:29102:29102"en el compose) hace que la regla DNAT solo se aplique a paquetes cuyo destino original es esa IP. En una máquina con varias tarjetas esto basta para esconder un puerto de las zonas que no deben verlo. En el laboratorio del curso no basta, y conviene tenerlo claro desde el principio: app01 tiene una sola tarjeta, la de la zona back, así que publicar en la 10.10.2.10 no lo esconde de nadie que ya esté en back. Lo que sí consigue es dejar de publicar en0.0.0.0, es decir, retirar el puerto delocalhosty de los bridges de Docker, donde cualquier contenedor del host lo alcanzaba. Lo mismo vale para los exporters que corren como servicio del sistema (node_exporter con--web.listen-address=10.10.2.10:29100), que no tienen nada que ver con Docker pero sufren el mismo problema de "escuchar en todas partes". Se puede fijar como valor por defecto en/etc/docker/daemon.jsoncon"ip": "10.10.2.10", y entonces cualquier-p 28080:28080que a alguien se le olvide restringir se publica ahí y no en todas las interfaces. Quién llega de verdad al puerto lo decide la solución 3 y OPNsense. - No publicar: red interna. Si Prometheus corre en un contenedor de la misma máquina, no hace falta publicar nada: los dos contenedores comparten una red Docker y se hablan por nombre. Es lo que se hace en mon01 con Prometheus, Alertmanager, Loki y Grafana. Para app01, donde Prometheus está en otra máquina, la red Docker no cruza hosts, así que se combina con la solución 1 o la 3 (apartado siguiente).
- Reglas en DOCKER-USER o en la cadena forward de nftables. Es la solución que protege aunque alguien publique mal un puerto, y por eso es la que exige la política. Con iptables:
El --ctorigdstport es necesario porque en DOCKER-USER el paquete ya ha pasado por el DNAT y su puerto de destino es el del contenedor, que no siempre coincide con el publicado; conntrack (el seguimiento de conexiones del kernel, el mismo que hace funcionar los cortafuegos con estado) recuerda el destino original. En el curso los dos números coinciden, porque cada servicio escucha en el mismo con el que se publica, pero la regla no debe depender de eso: si alguien publica un contenedor que dentro escucha en otro número, una regla sobre el puerto de destino ya no lo filtra. Con nftables la misma idea va en la cadena forward de la tabla propia y aparece en el fichero completo más abajo.
ufw y Docker
ufw es un frontal de iptables que solo escribe en INPUT, así que con Docker no sirve para nada respecto a los puertos publicados, y hay años de hilos en foros de gente sorprendida. Si aparece ufw en una máquina con Docker, lo prudente es asumir que los puertos publicados están abiertos y comprobarlo con nmap desde fuera.
Reducir: red Docker dedicada y firewall por host¶
El trabajo de este apartado es cerrar puertos. El objetivo es que, al terminar, un escaneo desde cualquier sitio que no sea mon01 devuelva filtered para todos los puertos de la monitorización, y que ese resultado se deba a dos capas independientes: la forma de publicar los puertos y el firewall de cada host, por un lado, y OPNsense en el centro de la VPC, por otro. Si una de las dos falla o alguien la desconfigura, la otra sigue cerrando. Es la defensa en profundidad del modelo de capas, aplicada a la monitorización.
Publicar solo donde hace falta¶
Lo que se arregla en el compose. La red dedicada ya existe: es obs, la red de Docker que comparten los composes del servicio, de los agentes y de la monitorización, y por la que los contenedores se llaman por nombre. Lo que falta es acotar lo que se publica hacia fuera. En app01 hay que publicar, porque Prometheus está en otra máquina, así que se aplica la solución 1 cambiando solo las líneas ports de los dos composes:
# /opt/servicio/compose.yml, servicio app
ports:
- "10.10.2.10:28080:28080" # la API, la que consume web01
- "10.10.2.10:29102:29102" # /metrics, solo para mon01
# /opt/agentes/compose.yml: cadvisor con "10.10.2.10:28081:28081" y promtail con "10.10.2.10:29080:29080"
Lo que se retira es la publicación en 0.0.0.0 (localhost y los bridges de Docker), no el puerto de servicio: el 28080 sigue ahí para web01. Que desde otras zonas no se llegue no lo arregla este cambio, sino el nftables del host y OPNsense. En mon01, en cambio, se aplica la solución 2: la pila habla por nombre dentro de obs, y en la 10.10.0.20 solo publican nginx (443 y 23100) y la Gitea provisional. Se monta en la práctica evaluable, con el fichero completo en Cerrar mon01.
nftables por host: el fichero completo¶
Por qué cada host lleva el suyo. Cada host lleva su propio firewall aunque OPNsense ya filtre entre zonas, por dos motivos: OPNsense no ve el tráfico dentro de una misma subred (una máquina comprometida en back llegaría a app01 sin pasar por él), y dos capas independientes son lo que pide la defensa en profundidad. En Debian el cortafuegos del host se escribe con nftables: el fichero va en /etc/nftables.conf, que es el que carga nftables.service al arrancar, y se activa con systemctl enable --now nftables.
Cómo se lee un fichero de nftables. A diferencia de iptables, en nftables no hay tablas ni cadenas hechas de antemano: se declaran. Una tabla agrupa todo lo propio, y la familia inet hace que valga a la vez para IPv4 y para IPv6. Dentro, cada cadena base se engancha a un punto del camino del paquete (hook input, forward u output, los mismos del apartado anterior) con una prioridad, y lleva una política: lo que se hace con el paquete que llega al final sin que ninguna regla haya decidido. Un conjunto (set) es una lista con nombre que las reglas usan con @, para escribir los puertos una sola vez. Y las reglas que empiezan por ct consultan conntrack: ct state established,related accept deja pasar todo lo que pertenece a una conexión ya aceptada, así que solo hay que escribir reglas para el primer paquete de cada una.
Este es el de app01 completo; conviene fijarse en las dos cadenas: input es para lo que escucha el propio host (node_exporter, SSH) y forward para los puertos que publica Docker, que no pasan por input:
#!/usr/sbin/nft -f
table inet fw
flush table inet fw
table inet fw {
set mon_ports {
type inet_service
elements = { 29100, 28081, 29102, 29080 }
}
chain input {
type filter hook input priority filter; policy drop;
iif lo accept
ct state established,related accept
ct state invalid drop
ip protocol icmp accept
ip6 nexthdr icmpv6 accept
# gestión: SSH solo desde la red de gestión
ip saddr 10.10.0.0/24 tcp dport 22 accept
# monitorización: el exporter que escucha como servicio (node_exporter), solo desde mon01
ip saddr 10.10.0.20 tcp dport @mon_ports accept
tcp dport @mon_ports log prefix "mon-denegado: " drop
}
chain forward {
type filter hook forward priority filter; policy accept;
# puertos publicados por Docker: mismo criterio, con el puerto de antes del DNAT
iifname "ens18" ip saddr != 10.10.0.20 meta l4proto tcp ct original proto-dst @mon_ports log prefix "mon-docker-denegado: " drop
# el 28080 de la API no necesita regla: lo publica Docker, pasa por aquí con
# política accept, y OPNsense solo deja llegar a él desde web01 (fila 3)
}
chain output {
type filter hook output priority filter; policy accept;
}
}
Las decisiones del fichero:
- Las dos primeras líneas crean la tabla si no existe y la vacían, así que el fichero se puede recargar con
nft -f /etc/nftables.confsin duplicar reglas. La primera hace falta porque no se puede vaciar una tabla que todavía no existe: sin ella, la primera carga del fichero fallaría. Las dos tocan solo la tabla propia: unflush ruleset, que es como empieza el/etc/nftables.confque trae Debian, borraría también las tablas de Docker, que no las regenera hasta que se reinicia el servicio, y dejaría los contenedores publicados sin NAT. inputcon políticadrop: el host solo acepta lo que la matriz justifica. Primero va lo que no se discute (el tráfico local porlo, las respuestas de conexiones ya aceptadas y el ICMP, sin el que no funcionan ni el ping de diagnóstico ni IPv6), y después SSH solo desde la red de gestión y los puertos de monitorización solo desde mon01.- El
log ... dropexplícito, aunque la política ya tiraría esos paquetes, deja rastro enjournalctl -kde quién lo intenta, con un prefijo fácil de buscar. forwardcon políticaacceptydropexplícitos solo para lo que importa. Con políticadropcortaría también el tráfico de los contenedores hacia otras máquinas (Promtail hacia Loki, la API hacia la base de datos), que pasa porforward. Funciona porque, como se vio en el apartado anterior, undropen la tabla propia es definitivo aunque la tabla de Docker acepte.ct original proto-dstes el equivalente nftables del--ctorigdstport: el puerto de destino tal como llegó, antes del DNAT.
El de db01 es el mismo con dos cambios: el conjunto pasa a { 29100, 29187 } y se añade ip saddr 10.10.2.10 tcp dport 5432 accept, porque allí PostgreSQL es un servicio del host y sí pasa por input; la cadena forward sobra, porque db01 no tiene Docker. El de mon01 tiene una trampa: casi todo lo que se protege allí lo publica Docker (el nginx que da paso a Grafana y a Loki, y la Gitea provisional), así que una cadena input sola no filtra nada de eso y hacen falta las dos. Su fichero está en Cerrar mon01, con el material de la práctica evaluable del 3 de diciembre, porque se escribe a la vez que se retira el 23000 de Grafana.
Segunda capa en OPNsense¶
Qué añade. Las filas 11 a 13 que la A3.1 dejó en OPNsense son la segunda capa: desde el cortafuegos central dicen lo mismo que cada host. Si alguien compromete app01 y vacía su nftables, OPNsense sigue cerrando; si alguien abre de más en OPNsense, sigue cerrando el host. Con las dos, un escaneo desde web01 hacia app01:29100 da filtered por dos motivos independientes, y en el registro en vivo de OPNsense (Firewall, Log Files, Live View) aparece el bloqueo con la regla que lo decidió; una captura de esa vista es la evidencia de la segunda capa.
Cómo se prueba cada capa por separado. Ver que la capa del host funciona sola exige llegar a app01 sin que OPNsense corte antes, y eso solo se consigue con una fila temporal, que es una de las tareas de «si te sobra tiempo» de la A3.2. En mon01 es más fácil, porque el puesto de administración está en su misma subred y OPNsense no se interpone: es la prueba que hace la evaluable.
Proteger: cifrado y autenticación¶
Lo que el cortafuegos no arregla. Con el cortafuegos, alguien de front ya no llega a los exporters. Pero el cortafuegos decide por la dirección de origen, y eso deja dos huecos. El primero: el scrape sigue viajando en claro, de gestión a back a través de OPNsense, y cualquiera que pueda capturar en ese camino (una máquina comprometida en el mismo segmento, el propio cortafuegos) lee las métricas y los logs. El segundo: la regla confía en una dirección, no en un programa, y todo lo que corre en mon01 sale con la 10.10.0.20, incluidos sus contenedores (la Gitea provisional, por ejemplo); si uno de ellos cae en malas manos, pasa el filtro como si fuera Prometheus. Para cerrar esos dos huecos hacen falta otras dos piezas: cifrar el canal y que cada extremo demuestre quién es.
Qué resuelve TLS y qué resuelve la autenticación¶
Qué es TLS. TLS (Transport Layer Security, seguridad de la capa de transporte) es el protocolo que cifra una conexión y permite al cliente comprobar con quién está hablando. Es el sucesor de SSL, cuyo nombre sigue apareciendo en mensajes de error y en directivas de nginx, y es la S de HTTPS: el mismo HTTP de siempre, dentro de un canal cifrado. Al abrir la conexión, en el handshake, el servidor presenta su certificado: un documento que dice «esta clave pública es de app01.dev.lab y de la 10.10.2.10» y que lleva la firma de una autoridad de certificación (CA). El cliente comprueba esa firma con el certificado de la CA, que tiene guardado y en el que confía, y comprueba que el nombre o la dirección a la que se ha conectado está en el certificado. Si todo cuadra, los dos acuerdan una clave para esa conexión y a partir de ahí todo viaja cifrado.
Lo que resuelve. Dos cosas: la confidencialidad (quien capture el tráfico no lee nada, y si lo altera se nota) y la identidad del servidor (Prometheus sabe que habla con el exporter de app01 y no con otra máquina que se haga pasar por él).
Lo que no resuelve. Quién es el cliente. Un servidor TLS normal acepta la conexión de cualquiera: cifra la conversación, pero no pregunta con quién la tiene. Para eso hace falta autenticación, y en el curso se usa una forma distinta en cada sentido del tráfico, aunque técnicamente las dos valdrían en los dos:
- Basic auth en los exporters, a los que Prometheus va a buscar las métricas. El cliente manda en cada petición una cabecera
Authorizationcon su usuario y su contraseña, solo codificados en base64, que se deshace sin ninguna clave: basic auth sin TLS es entregar la contraseña en claro, y por eso van siempre juntos. Encaja porque cada exporter tiene un solo cliente legítimo, Prometheus, y la librería de los exporters la trae hecha. - mTLS (TLS mutuo) delante de Loki, al que los hosts empujan sus logs. El cliente también presenta un certificado firmado por la CA, y el servidor lo comprueba igual que el cliente comprueba el suyo. Encaja porque Loki no trae autenticación propia, y el nginx que se le pone delante comprueba certificados sin guardar ninguna contraseña: le basta con el certificado de la CA del curso.
Por qué TLS 1.3 y no otra versión. La 1.3, de 2018, es la actual, y quitó los algoritmos antiguos que daban problemas en las anteriores. Todos los clientes del curso la hablan (Prometheus, Promtail, curl y los navegadores), así que no hay motivo para aceptar nada anterior, y por eso cada servidor de la unidad la exige como mínimo.
Certificados con la CA del curso¶
Qué hace falta para hablar TLS. Cada servicio que va a hablar TLS necesita un certificado con su nombre y su dirección, firmado por una CA en la que confíen sus clientes. El curso tiene la suya, la que se creó en la A3.2 de Despliegue (Lab 5166 CA), que vive en ~/ca/ del puesto de administración con su ca.cnf, index.txt, serial y crlnumber; ca.key no sale nunca de ahí. Una CA propia y no un certificado autofirmado por servicio, porque así cada cliente confía en un solo fichero, ca.crt, y con él en todo lo que la CA firme; y no Let's Encrypt, porque solo firma nombres públicos que puede comprobar desde Internet, y app01.dev.lab no lo es.
Cómo se emite uno. Se firma con openssl ca, que apunta cada certificado en index.txt y así permite revocarlo después. Por cada servicio que va a hablar TLS se emite un certificado de servidor con su nombre y su IP en el SAN (Subject Alternative Name, la lista de nombres e IP para los que vale), porque Prometheus y Promtail validan el SAN y no el CN (el campo clásico de nombre). Son dos órdenes: openssl req crea la clave y la petición de firma (CSR) con el nombre y el SAN, y openssl ca la firma con la extensión de servidor. Como en la A3.1 se emiten ocho seguidos y entre uno y otro solo cambian el nombre y el SAN, las dos órdenes van en una función:
# en ~/ca del puesto de administración; los certificados se dejan en $T
T=~/tls-monitorizacion && mkdir -p $T
srv() { # srv <nombre> <SAN>
openssl req -new -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes \
-keyout $T/$1.key -out $T/$1.csr -subj "/CN=$1" -addext "subjectAltName=$2"
openssl ca -batch -notext -config ca.cnf -extensions server_cert -in $T/$1.csr -out $T/$1.crt
}
srv app01-node DNS:app01.dev.lab,IP:10.10.2.10 # el del node_exporter de app01
-batch hace que openssl ca firme sin preguntar, y -notext deja en el .crt solo el certificado, sin su versión en texto. La sección server_cert de ca.cnf marca el certificado como de servidor (serverAuth), y copy_extensions = copy hace que el SAN de la petición pase al certificado. Los de cliente (el de Promtail y el de logcli del puesto de administración) se firman con -extensions client_cert, que pone clientAuth, y no llevan SAN, porque nadie se conecta a ellos. El CN es el nombre del fichero y no el del host porque app01 y mon01 llevan varios certificados cada una, y así index.txt dice cuál es cuál.
Cómo se usan en el curso. Se emiten todos de una vez en la A3.1, así que después solo queda copiarlos a cada host con scp, con propietario el usuario que los lee y permisos 600 en la clave. Un certificado de un año en un laboratorio está bien; en una empresa lo normal es una CA interna que emite por ACME (el protocolo automático que usa Let's Encrypt), por ejemplo step-ca, con certificados de días que se renuevan solos.
TLS y basic auth en los exporters¶
Una sola configuración para todos los exporters. node_exporter, postgres_exporter y el resto de exporters oficiales comparten la librería de su servidor web (exporter-toolkit, del propio proyecto Prometheus), y por eso todos admiten la misma opción, --web.config.file, que apunta a un fichero con dos bloques: el TLS del servidor y los usuarios de basic auth:
# /etc/node_exporter/web.yml
tls_server_config:
cert_file: /etc/node_exporter/app01-node.crt
key_file: /etc/node_exporter/app01-node.key
min_version: TLS13
basic_auth_users:
prometheus: "$2y$10$Wb3l0jwVfM1xC5eYq7p0Ee2q1HkzFq4t1g1hA5m9Xn3uQ2Xv3q5Ki"
Por qué la contraseña va en bcrypt. El fichero no guarda la contraseña sino su hash bcrypt, un hash lento hecho a propósito para contraseñas: quien lea el fichero (en una copia de seguridad, en un repositorio) no obtiene la contraseña, y adivinarla probando millones cuesta muchísimo. Se genera con htpasswd -nBC 10 prometheus (paquete apache2-utils), que pide la contraseña y escribe usuario:hash; al fichero va solo el hash. El 10 es el coste: cada punto más duplica el tiempo de cálculo, y 10 es el valor habitual, lento para quien prueba contraseñas a ciegas y asumible para el exporter, que tiene que comprobarla cuando Prometheus se presenta.
Dónde se activa. En el paquete de Debian los argumentos del exporter van en /etc/default/prometheus-node-exporter, en la línea ARGS, y el servicio corre con el usuario prometheus, que es quien tiene que poder leer la clave:
El puerto ya estaba en esa línea desde la plantilla (--web.listen-address=:29100); lo que se añade es la IP delante y --web.config.file.
Qué cambia en Prometheus. En Prometheus, el esquema (http o https), la CA y las credenciales son de cada job y no de cada target, así que app01 pasa a un job propio, node-tls, y mon01 y db01 siguen en node por HTTP. La alarma HostDown (en prometheus/alerts.yml del repositorio alerting), que mira up{job="node"}, pasa a mirar up{job=~"node|node-tls"} para seguir cubriendo app01. El job nuevo:
- job_name: node-tls
scheme: https
tls_config:
ca_file: /etc/prometheus/tls/ca.crt
basic_auth:
username: prometheus
password_file: /etc/prometheus/secrets/node_exporter.pass
static_configs:
- targets: ["10.10.2.10:29100"]
labels: { host: app01 }
password_file en lugar de password para que la contraseña no esté en un prometheus.yml que va a un repositorio Git. server_name no hace falta porque el certificado lleva la IP del target en el SAN. Los ficheros que Prometheus lee se montan en su contenedor con un compose.override.yml al lado del compose de mon01, que Docker Compose lee solo sin tener que nombrarlo y que permite añadir volúmenes sin tocar el compose original; tls/ y secrets/ van en el .gitignore del repositorio monitoring, y el fichero de la contraseña, con propietario 65534 (el usuario con el que corre Prometheus en su imagen) y permisos 600:
# /opt/monitoring/compose.override.yml
services:
prometheus:
volumes:
- ./tls:/etc/prometheus/tls:ro
- ./secrets:/etc/prometheus/secrets:ro
sequenceDiagram
participant P as Prometheus (mon01)
participant N as node_exporter (app01:29100)
P->>N: ClientHello (TLS 1.3)
N->>P: ServerHello + certificado firmado por Lab 5166 CA
P->>P: valida cadena contra ca.crt y la IP del SAN
P->>N: GET /metrics + Authorization: Basic (cifrado)
N->>N: bcrypt.compare(contraseña, hash) del usuario prometheus
alt credenciales válidas
N->>P: 200 OK + métricas (cifradas)
else sin cabecera o hash no coincide
N->>P: 401 Unauthorized
end
Dos comprobaciones distintas y en este orden: el certificado dice quién es el exporter, y la contraseña dice quién es Prometheus.
cAdvisor y la API, con un sidecar. cAdvisor y el 29102 de la API no usan exporter-toolkit y no tienen TLS ni autenticación propias. La salida habitual es ponerles delante un nginx sidecar (un contenedor auxiliar al lado del servicio) en la red obs, que termine TLS, pida basic auth y sea el único que publica esos dos puertos; mientras no está, los protegen la cadena forward y OPNsense. Su configuración es un bloque server de nginx por puerto, con TLS y auth_basic:
server {
listen 28081 ssl;
server_name app01.dev.lab;
ssl_certificate /etc/nginx/tls/app01-nginx.crt;
ssl_certificate_key /etc/nginx/tls/app01-nginx.key;
ssl_protocols TLSv1.3;
auth_basic "monitorizacion";
auth_basic_user_file /etc/nginx/htpasswd;
location / { proxy_pass http://cadvisor:28081; }
}
# El segundo bloque es igual cambiando dos líneas: listen 29102 ssl; y proxy_pass http://app:29102;
Cada bloque escucha en el mismo número que el servicio al que protege, y no chocan porque cada uno está en su contenedor: en el host, el sidecar publica "10.10.2.10:28081:28081" y "10.10.2.10:29102:29102", que cAdvisor y la API dejan de publicar. Así los targets de Prometheus conservan su puerto y solo pasan a https, con un job como node-tls. El sidecar tiene la ventaja de que no toca el código del servicio; se monta, si sobra tiempo, en la práctica evaluable. El ejemplo completo de hoy es el node_exporter de app01, que es el caso que después se repite igual en los demás.
Verificar que de verdad está protegido¶
Material de consulta
Esto no se explica en clase: lo necesitan la hoja de hoy, en sus dos últimos pasos, y la práctica evaluable del 3 de diciembre, para cerrar la auditoría.
Por qué se prueba. Lo que vale para la práctica no es el fichero de configuración sino la prueba de que hace lo que dice: un web.yml perfecto que el servicio no ha cargado no protege nada. Las comprobaciones quitan piezas de una en una, para que cada fallo salga por el motivo correcto: primero sin la CA (el cliente no puede comprobar quién es el servidor), después con la CA y sin credenciales (el servidor no sabe quién es el cliente) y por último con las dos. Estas son, con el resultado esperado; las cuatro primeras se lanzan desde mon01 (con ca.crt en el directorio de trabajo), la quinta desde el puesto de administración con el certificado de cliente de logcli y la sexta desde una shell en mon01:
# 1. sin CA: el certificado no se puede validar
curl https://10.10.2.10:29100/metrics
# curl: (60) SSL certificate problem: unable to get local issuer certificate
# 2. con CA pero sin credenciales
curl --cacert ca.crt https://10.10.2.10:29100/metrics
# 401 Unauthorized
# 3. con CA y credenciales: las métricas
curl --cacert ca.crt -u prometheus https://10.10.2.10:29100/metrics | head -3
# 4. el handshake visto por openssl: versión, cifrado, cadena y verificación
openssl s_client -connect 10.10.2.10:29100 -CAfile ca.crt </dev/null
# Protocol: TLSv1.3 / Cipher: TLS_AES_128_GCM_SHA256
# Verify return code: 0 (ok)
# 5. Loki tras nginx: sin certificado de cliente la petición no pasa de nginx
curl --cacert ca.crt https://10.10.0.20:23100/ready
# 400 No required SSL certificate was sent
curl --cacert ca.crt --cert logcli.crt --key logcli.key https://10.10.0.20:23100/ready
# ready
# 6. Prometheus sigue viendo los targets UP
curl -s localhost:29090/api/v1/targets | jq -r '.data.activeTargets[] | "\(.labels.job) \(.scrapeUrl) \(.health)"'
Cómo se lee la cuarta. openssl s_client hace el handshake a mano y lo enseña entero: la versión negociada, el cifrado, la cadena de certificados y, al final, Verify return code: 0 (ok), que quiere decir que la cadena llega hasta la CA de ca.crt. Cualquier otro código dice por qué no.
La prueba en la red. La evidencia de cifrado en la red es tcpdump sobre el puerto del exporter durante un scrape. Antes, con -A, se leía GET /metrics HTTP/1.1 y las métricas; ahora no se lee nada. Si se añade -X, que vuelca cada paquete también en hexadecimal, los datos del primer paquete que manda el cliente empiezan por 16 03 01: es la cabecera del ClientHello, el primer mensaje del handshake TLS, que por compatibilidad dice versión 1.0 aunque después se negocie la 1.3. Con el 23100, mientras Promtail envía, pasa lo mismo, y esa es la pareja de capturas que se entrega. nmap -sV -p 29100 10.10.2.10 desde mon01 lo resume en una línea: 29100/tcp open ssl/http. Ese ssl/ es la diferencia entre la matriz de antes y la de después.
A3.2 Red, firewall y TLS (sesión 17)¶
Objetivo Que los puertos de monitorización de app01 salgan filtered desde fuera de mon01 y sigan open desde mon01, que ese cierre se deba a dos capas (el nftables del propio host y OPNsense) y que el scrape de su node_exporter viaje ya cifrado y con contraseña. Al terminar, app01 es el primer host cerrado y con un exporter cifrado, y lo que has hecho en él es la receta que después se repite en db01 y en mon01.
Antes de empezar
- La matriz "antes" de la A3.1 con sus filas en rojo: es la lista de trabajo.
- app01 (10.10.2.10) y mon01 (10.10.0.20) en la VPC dev desde la A3.3 de Despliegue, con una sola tarjeta cada una, y db01 (10.10.3.10) con PostgreSQL, su postgres_exporter y el node_exporter de la plantilla. A app01 se entra con salto por el puesto de administración:
ssh -J ops@<IP de aula de admin01> ops@10.10.2.10. Si el traslado no se hizo, es lo primero, con la receta de aquella hoja. - Las filas 11 a 13 de OPNsense, del paso 6 de la A3.1, y los certificados de
~/tls-monitorizacion/, del paso 5 de la A3.1 (si faltan, emite al menosapp01-node). En el puesto de administración,htpasswd(paqueteapache2-utils). - La pila se abre ya con el primer túnel de Cómo se llega a la pila, con
-L 23000:127.0.0.1:23000añadido para Grafana, que sigue en el 23000 hasta la evaluable. Ese túnel deja además una shell en mon01. - Hoy solo se cierra app01: mon01 se cierra en la evaluable, a la vez que se pone nginx delante de Grafana y de Loki.
- Lo explicado antes: por qué Docker se salta el firewall del host, con sus tres soluciones; publicar solo donde hace falta y el fichero nftables; qué resuelve TLS y qué resuelve la autenticación y TLS y basic auth en los exporters. Para los pasos 4 y 5, el material de consulta Verificar que de verdad está protegido.
Pasos
Vas a cerrar app01 y a cifrar su node_exporter, en el orden en que cada cosa se puede comprobar. Primero recuperas la monitorización en las direcciones nuevas, porque hasta que todo esté en UP no sabrías si un target caído es culpa tuya o del traslado. Después cierras app01 (reducir) y compruebas que lo legítimo sigue pasando; luego cifras su node_exporter y haces que Prometheus lo lea así (proteger); y al final lo mides desde fuera. Entre cerrar y cifrar, el target de app01 puede pasar un rato en DOWN: por eso el paso 2 empieza con un silencio.
-
Recupera la monitorización en la VPC. Desde el traslado, Prometheus y Promtail siguen apuntando a las direcciones del aula, que ya no son las de app01 y mon01. Lleva a las de la VPC los targets de Prometheus (app01 en la 10.10.2.10 y mon01 en la 10.10.0.20) y el envío de Promtail a Loki. Con la base de datos en db01, el target del job
postgrespasa a10.10.3.10:29187y el jobnodegana10.10.3.10:29100, los dos conhost: db01. Todos los targets tienen que quedar en UP: es el punto de partida contra el que se mide todo lo que cierres después, y la prueba de que las filas 11 a 13 de la A3.1 hacen su trabajo.Pista: Promtail no relee su fichero solo: después de cambiarlo, reinícialo.
-
Cierra app01 sin cortar lo legítimo. Silencia antes en Alertmanager
host=app01hasta el final de la clase, como en Silencios: desde aquí hasta el paso 4 su target puede quedar en DOWN, yHostDownabriría una incidencia. Después haz que su node_exporter escuche solo en la 10.10.2.10 y deja activo y persistente el fichero nftables de app01, que lo cierra a todo lo que no sea mon01. Comprueba al final que lo que cruza la cadenaforwardsigue pasando: la API responde enhttps://api.dev.lab/healthdesde tu puesto del aula y Promtail envía sin errores. Un cortafuegos que corta el servicio no está terminado.Pista: node_exporter no es un contenedor: su dirección va en la línea
ARGSde/etc/default/prometheus-node-exporter, dentro del--web.listen-addressque ya trae de la plantilla: de momento solo le pones la IP delante del puerto. Valida el fichero connft -c -fy actívalo entrando por el puesto de administración: la regla de SSH solo admite la red de gestión. -
Cifra el node_exporter y ponle contraseña. Activa TLS y basic auth en el node_exporter de app01 con el certificado
app01-node, elweb.ymly la líneaARGScompleta de TLS y basic auth en los exporters. El hash es el de una contraseña para el usuarioprometheus; guarda también la contraseña, que Prometheus necesita en el paso 4. Desde ese momento el exporter ya no contesta por HTTP, y el target de app01 queda en DOWN hasta que Prometheus aprenda a hablarle cifrado.Pista: la clave, en 600 y del usuario
prometheus, que es con el que corre el servicio. -
Haz que Prometheus lo lea cifrado y con contraseña. El target de app01 pasa del job
nodeal jobnode-tlsdel apartado, conca.crten/opt/monitoring/tls/, la contraseña ensecrets/node_exporter.passy los dos directorios montados con sucompose.override.yml. En el repositorioalerting,HostDownpasa aup{job=~"node|node-tls"} == 0, para que la alarma siga cubriendo app01. Termina con las tres primeras comprobaciones de Verificar que de verdad está protegido: sin CA, sin credenciales y con las dos, cada una tiene que fallar o funcionar por el motivo que dice el apartado.Pista: un volumen nuevo no se aplica recargando: hay que recrear el contenedor de Prometheus, que de paso relee la configuración y las reglas.
tls/ysecrets/van al.gitignore. -
Mide el resultado desde fuera. Deja en
/opt/monitoring/audit/despues/dos escaneos con fecha que demuestran las dos mitades del trabajo: desde el puesto de administración, los cuatro puertos de monitorización de app01 (escaneo-app01-desde-admin-<fecha>.txt), que tienen que salirfiltered; y desde mon01, unnmap -sVcontra su 29100 (nmap-sv-app01-<fecha>.txt), que tiene que decirssl/http. Los demás orígenes, el Live View de OPNsense y el reinicio se miden sobre el estado final, en la práctica evaluable.Pista: desde el puesto, nmap da app01 por caído y no escanea nada: detrás de un cortafuegos hace falta
-Pn, como se explica en Escaneo desde fuera.
Comprobación Prometheus tiene todos los targets en UP, incluidos los dos de db01, y el de node-tls con un scrapeUrl que empieza por https://. En app01, ss muestra el 29100 solo en la 10.10.2.10, nft list ruleset enseña la tabla inet fw con sus dos cadenas y systemctl is-enabled nftables dice enabled. curl sin CA da error 60, con CA y sin credenciales 401, y con las dos devuelve métricas; nmap -sV responde ssl/http en el 29100. Desde el puesto de administración, los cuatro puertos de app01 salen filtered. La API sigue respondiendo en https://api.dev.lab y en Explore de Grafana hay líneas de app01 del último minuto.
Para practicar Guarda con commit en monitoring la copia del nftables.conf de app01 en seguridad/nftables/app01.conf, el web.yml en seguridad/ con el hash sustituido por <bcrypt>, prometheus.yml con el job node-tls, compose.override.yml y los dos escaneos de hoy en audit/despues/, y en alerting, el cambio de HostDown: lo pide la práctica evaluable. Deja vacía la columna "obtenido (después)" de la matriz, que allí se rellena sobre el estado final.
Si te sobra tiempo Por este orden. Retira de 0.0.0.0 los tres puertos que publica Docker en app01, cambiando las líneas ports como en Publicar solo donde hace falta: el firewall ya los cierra desde fuera, pero en 0.0.0.0 siguen al alcance de cualquier contenedor del host. Comprueba la capa del host sola: abre en la pestaña DMZEXT de OPNsense una fila temporal (Pass, srv_web a srv_app, puertos p_mon_app, descripción TEMPORAL alta 01-12 baja 01-12 prueba de nftables), repite desde web01 el escaneo de app01 del paso 5 (sigue filtered, ahora solo por nftables), comprueba en app01 que sudo journalctl -k | grep mon- muestra líneas mon-denegado: y mon-docker-denegado: con la IP de web01, y borra la fila. Y cierra db01, que es repetir el paso 2 con otras direcciones: --web.listen-address=10.10.3.10:29100 en /etc/default/prometheus-node-exporter, --web.listen-address=10.10.3.10:29187 en /etc/default/prometheus-postgres-exporter, y el nftables.conf de app01 con los dos cambios del apartado. Si db01 no trae el paquete nftables, instálalo con una fila temporal, como en la A3.3 de Despliegue.
Sesión 18 · Práctica evaluable¶
Tras la A3.2, app01 está cerrado y su node_exporter va cifrado, pero el centro de la pila sigue abierto: en mon01, Grafana está en el 23000 en HTTP y con la contraseña del primer día, Prometheus, Alertmanager y Mailpit contestan sin pedir nada a cualquiera de la red de gestión, y Loki acepta logs de quien sea. mon01 se cierra la última porque cerrarla cambia cómo llega todo el mundo a la pila, y se hace aquí a propósito: su nftables.conf se escribe a la vez que el compose, ya con el 443 de nginx en lugar del 23000 de Grafana, para no escribirlo dos veces. La sesión termina la unidad en este orden: se cierra mon01 (publicación acotada, un nginx que hace de única puerta de Grafana y de Loki, y su propio cortafuegos); como desde ese momento Loki exige certificado, lo siguiente es dárselo a Promtail para que vuelva a enviar; después se repite la auditoría sobre el estado final, y con lo medido se completan la matriz y el documento de política. Los diez minutos de explicación se dedican al documento de política, que es lo que convierte la configuración en algo que se puede comprobar: qué son las cinco reglas y cómo se comprueba cada una. Los cuatro apartados que vienen a continuación son material de consulta, con los ficheros que hay que copiar; después van el documento de política y el enunciado, con el reparto del tiempo, los entregables y los criterios de calificación.
La unidad cierra la primera evaluación
El 10 de diciembre hay examen de la primera evaluación, y entran completas esta unidad y las dos anteriores. De esta unidad se pregunta lo que se explica en clase: qué se ve en un /metrics abierto, cómo se lee cada estado de nmap, por qué un puerto publicado por Docker no pasa por la cadena input y dónde va entonces la regla, y qué resuelve TLS frente a qué resuelve la autenticación. La práctica evaluable de esta sesión se entrega por Aules antes del examen: el trabajo de laboratorio queda hecho el 3 de diciembre, y la matriz y el documento de política se pueden terminar hasta la víspera.
Cerrar mon01: publicación y cortafuegos¶
Material de consulta
Esto no se explica en clase: lo necesita el primer bloque de la práctica evaluable de hoy.
Qué se cambia y por qué. En mon01 se aplica la segunda solución de por qué Docker se salta el firewall del host, no publicar y usar la red interna: los servicios de la pila se hablan por nombre en la red obs, y hacia fuera solo se publica lo que tiene que llegar desde otra máquina. Cada servicio cae en uno de tres casos:
- Lo que solo usan los administradores, de vez en cuando, y no pide credenciales de fábrica (Prometheus, Alertmanager y Mailpit) se publica en
127.0.0.1. Desde fuera no existe, y se llega por un túnel SSH, que le pone delante la clave SSH y el cifrado sin configurar nada en él. - Lo que tiene que llegar desde otras máquinas (Grafana, para los usuarios, y Loki, para Promtail) deja de publicarse y pasa detrás de un nginx que termina el TLS de los dos y, en el caso de Loki, exige además certificado de cliente. Es el patrón del proxy inverso de web01, y deja en un solo sitio los certificados de mon01.
- La Gitea provisional se publica solo en la 10.10.0.20 hasta que se vaya a gitea01.
Son dos cambios en /opt/monitoring/compose.yml: las líneas ports de los servicios que ya existen y un servicio nuevo, nginx, que hace de puerta de Grafana y de Loki. Las opciones de escucha de cada servicio (--web.listen-address=:29090 y las demás) no se tocan: dentro de su contenedor tiene que seguir escuchando en todas las interfaces, porque por ahí entra tanto lo que publica Docker como lo que llega de los demás contenedores de obs.
| Servicio | Línea ports que queda |
Por qué |
|---|---|---|
prometheus |
["127.0.0.1:29090:29090"] |
Solo por túnel SSH |
alertmanager |
["127.0.0.1:29093:29093"] |
Solo por túnel SSH |
mailpit |
["127.0.0.1:28025:28025"] |
Solo por túnel SSH |
grafana |
ninguna: se borra | Se entra por nginx, en el 443 |
loki |
ninguna: se borra | Se entra por nginx, en el 23100, con certificado de cliente |
gitea |
["10.10.0.20:23001:23001"] |
La red de gestión y app01, hasta el 12 de febrero |
nginx, nuevo |
["10.10.0.20:443:443", "10.10.0.20:23100:23100"] |
La única puerta de Grafana y de Loki |
# en /opt/monitoring/compose.yml, dentro de services
nginx:
image: nginx:1.28
ports: ["10.10.0.20:443:443", "10.10.0.20:23100:23100"]
volumes:
- ./nginx/mon01.conf:/etc/nginx/conf.d/default.conf:ro
- ./tls:/etc/nginx/tls:ro
depends_on: [grafana, loki]
restart: unless-stopped
nginx/mon01.conf lleva dos bloques server, el de Grafana y el de Loki. En tls/ se juntan ca.crt, que está desde la A3.2, y los certificados mon01-nginx y mon01-loki de la A3.1, con las claves de root y en 600: el directorio también está montado en Prometheus, que no tiene por qué leerlas. Al levantar, docker ps enseña siete flechas y ninguna en 0.0.0.0: cuatro en 127.0.0.1 (las tres de la tabla y el receptor de incidencias, que ya estaba ahí) y tres en la 10.10.0.20.
El cortafuegos de mon01. Tiene las mismas dos cadenas que el de app01, con otros puertos. En input queda lo que escucha el propio host (SSH y el node_exporter, al que Prometheus llega desde su contenedor, por la red de Docker); en forward, lo que publica Docker, con un accept por cada origen legítimo antes del drop final. La primera regla de forward deja pasar las respuestas de las conexiones que abren los propios contenedores: sin ella, la respuesta de Telegram a Alertmanager, que vuelve del 443, caería en el drop del final, porque su conexión también tiene el 443 como destino original.
#!/usr/sbin/nft -f
table inet fw
flush table inet fw
table inet fw {
set mon_pub {
type inet_service
elements = { 443, 23100, 23001 }
}
chain input {
type filter hook input priority filter; policy drop;
iif lo accept
ct state established,related accept
ct state invalid drop
ip protocol icmp accept
ip6 nexthdr icmpv6 accept
# gestión: SSH solo desde la red de gestión
ip saddr 10.10.0.0/24 tcp dport 22 accept
# node_exporter: Prometheus llega desde su contenedor
ip saddr { 10.10.0.20, 172.16.0.0/12 } tcp dport 29100 accept
tcp dport 29100 log prefix "mon-denegado: " drop
}
chain forward {
type filter hook forward priority filter; policy accept;
ct state established,related accept
# Loki, tras nginx: el Promtail de app01 y logcli desde gestión
iifname "ens18" ip saddr { 10.10.2.10, 10.10.0.0/24 } meta l4proto tcp ct original proto-dst 23100 accept
# Grafana, tras nginx: solo la red de gestión
iifname "ens18" ip saddr 10.10.0.0/24 meta l4proto tcp ct original proto-dst 443 accept
# Gitea provisional: la red de gestión y app01, hasta el 12 de febrero
iifname "ens18" ip saddr { 10.10.0.0/24, 10.10.2.10 } meta l4proto tcp ct original proto-dst 23001 accept
# cualquier otro origen hacia lo que publica la pila
iifname "ens18" meta l4proto tcp ct original proto-dst @mon_pub log prefix "mon-docker-denegado: " drop
}
chain output {
type filter hook output priority filter; policy accept;
}
}
Lo que se publica en 127.0.0.1 no necesita regla: no se alcanza desde fuera, y el tráfico local entra por lo. El 12 de febrero, cuando Gitea pase a gitea01, sobran su línea ports, el 23001 del conjunto y su accept.
Cómo se llega a la pila desde hoy¶
Por qué cambia el acceso. Con mon01 cerrado, las interfaces que se consultaban por la IP del aula pasan a estar detrás del puesto de administración, que es la máquina de gestión con tarjeta en el aula. Se llega con túneles SSH: ssh -L puerto_local:destino:puerto abre un puerto en la máquina desde la que se lanza y lleva lo que entra por él, por dentro de la conexión SSH, hasta el destino tal como se ve desde el otro extremo. Por eso, en un túnel hacia mon01, 127.0.0.1 es el de mon01 y no el del puesto. Con -J, la conexión salta antes por el puesto de administración, que llega a la vez al aula y a la VPC:
- Prometheus, Alertmanager y Mailpit, desde el puesto del aula, con un túnel SSH que salta por el puesto de administración:
ssh -L 29090:127.0.0.1:29090 -L 29093:127.0.0.1:29093 -L 28025:127.0.0.1:28025 -J ops@<IP de aula de admin01> ops@10.10.0.20, y despuéshttp://localhost:29090en el navegador. Desde una shell en mon01,curl http://localhost:29090/..., yamtoolcondocker compose exec alertmanager amtool .... - Grafana, desde el puesto de administración, en
https://grafana.lab/. Desde el puesto del aula, conssh -L 28443:10.10.0.20:443 ops@<IP de aula de admin01>, la línea127.0.0.1 grafana.laben el fichero hosts del puesto y la CA del curso importada, enhttps://grafana.lab:28443. - Loki, con
logclidesde el puesto de administración y su certificado de cliente:LOKI_ADDR=https://10.10.0.20:23100,LOKI_CA_CERT_PATH,LOKI_CLIENT_CERT_PATHyLOKI_CLIENT_KEY_PATHapuntando aca.crt,logcli.crtylogcli.key.
mTLS entre Promtail y Loki¶
Material de consulta
Esto no se explica en clase: lo necesitan el primer y el segundo bloque de la práctica evaluable de hoy.
El problema. En el sentido de los logs la relación se invierte: son los hosts los que hablan con mon01, y Loki tiene que saber que quien le envía logs es un Promtail legítimo y no cualquiera con acceso al 23100, que podría inyectar líneas falsas. Loki no trae autenticación propia. El auth_enabled de su configuración, pese al nombre, no comprueba quién llama: activa el modo de varios inquilinos, que separa los datos de cada cliente según una cabecera que pone el propio cliente. Su documentación da por hecho que la autenticación la pone un proxy delante.
La solución del curso. En el laboratorio la pone el mismo nginx que protege Grafana: escucha en el 23100 de la 10.10.0.20, presenta el certificado mon01-loki, exige un certificado de cliente firmado por la CA del curso y solo entonces reenvía a loki:23100 por la red interna. Que nginx y Loki usen el mismo número no es un choque: cada uno escucha en su contenedor, y en el host solo publica nginx. Grafana y Prometheus siguen hablando con Loki por dentro, sin pasar por nginx, y no hay que tocarlos. Loki sabe también exigir el certificado él mismo (http_tls_config con RequireAndVerifyClientCert), pero entonces todo el que le habla, incluidos Grafana y Prometheus, necesitaría certificado de cliente.
flowchart TB
subgraph N["Métricas · mon01 va a buscarlas"]
direction LR
P["<b>Prometheus</b><br><small>mon01</small>"]:::act
E["<b>Exporter</b><br><small>en el host · TLS + basic auth</small>"]:::pieza
P -- "scrape" --> E
end
subgraph M["Logs · el host los empuja · mTLS"]
direction LR
PT["<b>Promtail</b><br><small>presenta su certificado de cliente</small>"]:::act
L["<b>nginx delante de Loki</b><br><small>presenta el suyo y exige el del cliente</small>"]:::pieza
CA(["<b>La misma CA firma los dos</b><br><small>cada uno sabe con quién habla</small>"]):::ok
PT -- "push" --> L
L --- CA
PT --- CA
end
N ~~~ M
classDef act fill:#ea580c22,stroke:#ea580c,stroke-width:1.5px
classDef pieza fill:#64748b22,stroke:#64748b,stroke-width:1.5px
classDef dato fill:#2563eb22,stroke:#2563eb,stroke-width:1.5px
classDef infra fill:#a1a1aa14,stroke:#a1a1aa,stroke-width:1.5px
classDef ok fill:#16a34a22,stroke:#16a34a,stroke-width:1.5px
classDef riesgo fill:#dc262622,stroke:#dc2626,stroke-width:1.5px
En los dos sentidos el servidor se identifica con su certificado; lo que cambia es el cliente: Prometheus, que va a buscar, con usuario y contraseña, y Promtail, que empuja, con su propio certificado.
El bloque de nginx. Además del certificado del servidor, lleva las dos directivas que hacen el TLS mutuo: ssl_client_certificate, la CA con la que se comprueban los certificados de los clientes, y ssl_verify_client on, que hace obligatorio presentar uno:
# /opt/monitoring/nginx/mon01.conf, segundo bloque: Loki
server {
listen 23100 ssl;
server_name mon01.lab;
ssl_certificate /etc/nginx/tls/mon01-loki.crt;
ssl_certificate_key /etc/nginx/tls/mon01-loki.key;
ssl_protocols TLSv1.3;
ssl_client_certificate /etc/nginx/tls/ca.crt; # la CA con la que se validan los clientes
ssl_verify_client on; # sin certificado de cliente válido, no pasa
client_max_body_size 10m; # los lotes de Promtail llegan a 1 MB
location / { proxy_pass http://loki:23100; }
}
Qué cambia en Promtail. En app01, Promtail cambia la URL a https y presenta su certificado. Los tres ficheros van en /opt/agentes/tls/ (la clave en 600) y se montan en el contenedor con una línea más en los volumes del servicio promtail de /opt/agentes/compose.yml, - ./tls:/etc/promtail/tls:ro:
# /opt/agentes/promtail.yml en app01: el bloque clients
clients:
- url: https://10.10.0.20:23100/loki/api/v1/push
tls_config:
ca_file: /etc/promtail/tls/ca.crt
cert_file: /etc/promtail/tls/promtail-app01.crt
key_file: /etc/promtail/tls/promtail-app01.key
# external_labels, batchwait, batchsize, backoff_config y timeout, igual que estaban
server_name no hace falta porque el certificado de Loki lleva la 10.10.0.20 en el SAN. Con ssl_verify_client on, una petición sin certificado de cliente recibe de nginx un 400 No required SSL certificate was sent y no llega a Loki; con un certificado que no firmó la CA del curso, o que no es de cliente, 400 The SSL certificate error. El certificado de logcli para consultar desde el puesto de administración se emite igual que el de Promtail, desde ~/ca:
cd ~/ca && T=~/tls-monitorizacion
openssl req -new -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes \
-keyout $T/logcli.key -out $T/logcli.csr -subj "/CN=logcli"
openssl ca -batch -notext -config ca.cnf -extensions client_cert -in $T/logcli.csr -out $T/logcli.crt
Promtail está al final de su vida, como se vio al instalarlo, y su sustituto es Grafana Alloy; la configuración TLS de Alloy es equivalente (loki.write con bloque tls_config), así que lo que se aprende aquí se traslada tal cual.
Grafana detrás de nginx con TLS y roles¶
Material de consulta
Esto no se explica en clase: lo necesita el primer bloque de la práctica evaluable de hoy.
Por qué detrás de nginx. Grafana es lo único de la pila que usan personas a diario, y hasta hoy escuchaba en 10.10.0.20:23000 en HTTP: la contraseña de cada usuario viajaba en claro al iniciar sesión. Grafana sabe servir HTTPS por sí mismo, pero el curso termina el TLS en el nginx de Cerrar mon01, que ya está ahí por Loki, para tener en un solo sitio los certificados de mon01. nginx publica 10.10.0.20:443 y reenvía a grafana:23000 por la red interna:
# /opt/monitoring/nginx/mon01.conf, primer bloque: Grafana
server {
listen 443 ssl;
http2 on;
server_name grafana.lab mon01.lab;
ssl_certificate /etc/nginx/tls/mon01-nginx.crt;
ssl_certificate_key /etc/nginx/tls/mon01-nginx.key;
ssl_protocols TLSv1.3;
location / {
proxy_pass http://grafana:23000;
proxy_set_header Host $http_host; # con el puerto: el túnel del 28443 lo necesita
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# Grafana Live usa WebSocket
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
Las cabeceras son las del proxy inverso de web01, con dos detalles: Host lleva $http_host, que conserva el puerto, y las tres últimas líneas dejan pasar el WebSocket (una conexión que se queda abierta para que el servidor mande datos sin que se los pidan) con el que Grafana Live actualiza los paneles en directo.
Lo que se fija en Grafana. En la configuración de Grafana, por variables de entorno en el servicio grafana del compose (cada GF_<SECCIÓN>_<CLAVE> equivale a una clave de una sección de grafana.ini), se fija lo que importa para la política. Varias ya valen eso de fábrica; se escriben igualmente para que la decisión quede en el repositorio y no dependa de los valores por defecto de la versión que se instale:
environment:
GF_SERVER_HTTP_PORT: "23000"
GF_SERVER_ROOT_URL: https://grafana.lab/
GF_SECURITY_COOKIE_SECURE: "true"
GF_AUTH_ANONYMOUS_ENABLED: "false"
GF_USERS_ALLOW_SIGN_UP: "false"
GF_USERS_AUTO_ASSIGN_ORG_ROLE: Viewer
GF_AUTH_BASIC_ENABLED: "false"
GF_SERVER_HTTP_PORTya venía en la pila mínima de Aules y no es de la política: es el 23000 al que reenvía nginx. Si se pierde al copiar el bloque, Grafana vuelve a su puerto de fábrica y nginx responde502 Bad Gateway.GF_SERVER_ROOT_URLes la dirección pública de Grafana, con la que construye sus enlaces y redirecciones: si no dicehttps://, redirige a HTTP.GF_SECURITY_COOKIE_SECUREhace que la cookie de sesión solo se envíe por HTTPS, así que nunca viaja en claro.GF_AUTH_ANONYMOUS_ENABLEDyGF_USERS_ALLOW_SIGN_UPimpiden entrar sin usuario y que alguien se cree uno a sí mismo: los usuarios los da de alta un administrador.GF_USERS_AUTO_ASSIGN_ORG_ROLE: Viewergarantiza que un usuario nuevo no pueda tocar nada hasta que alguien le suba el rol.GF_AUTH_BASIC_ENABLEDdesactivado evita que la API de Grafana acepte usuario y contraseña en cada petición: para automatizar están los tokens de cuenta de servicio, que se pueden revocar.
Roles y usuarios. Los roles de Grafana son cuatro por organización: Admin (todo), Editor (crea y modifica dashboards y alertas), Viewer (solo mira) y, desde Grafana 10, No basic role combinado con permisos por recurso. La regla de la política es sencilla: cada persona con su usuario nominal, Viewer por defecto, Editor para quien mantiene dashboards y un solo Admin, el admin de fábrica, que no usa nadie a diario. La contraseña del admin no se cambia con variables de entorno, que solo cuentan la primera vez que arranca Grafana, sino con docker compose exec grafana grafana cli admin reset-admin-password '<nueva>'. Prometheus, Alertmanager y Mailpit quedan en 127.0.0.1: cómo se llega a ellos está en Cómo se llega a la pila desde hoy.
Datos: redacción, retención y permisos¶
Material de consulta
Esto no se explica en clase: lo necesita el documento de política de la práctica evaluable, que dice dónde se redactan los secretos y dónde se fijan la retención y los permisos, y su «si sobra tiempo», que añade las etapas replace al Promtail de app01.
El contenido también se protege. Cifrar el canal no sirve de nada si lo que viaja ya está mal. Los logs de la API del curso llegan a Loki tal como salen de la aplicación, y las aplicaciones registran cosas que no deberían: parámetros de la petición con contraseñas, cabeceras Authorization, tokens en URLs de callback. Una vez en Loki, los lee cualquiera que pueda consultar los logs, durante todos los días que diga la retención.
Redactar: tachar el secreto antes de guardarlo. Redactar, en este sentido (del inglés redact), es tachar o sustituir en un texto lo que no debe guardarse. La primera línea de defensa es la aplicación: registrar de forma estructurada (JSON con campos elegidos), nunca volcar la petición entera, y tener una lista de campos que el logger sustituye antes de escribir. Como no siempre se controla el código, la segunda línea está en Promtail, que puede reescribir cada línea antes de enviarla con la etapa replace. Las tres etapas van al principio de pipeline_stages del job docker de /opt/agentes/promtail.yml, antes de los dos match que ya tiene:
pipeline_stages:
- replace:
expression: '(?i)password=(\S+)'
replace: '***'
- replace:
expression: 'Authorization: (?:Basic|Bearer) (\S+)'
replace: '***'
- replace:
expression: '\b\d{4}(\d{8})\d{4}\b'
replace: '********'
En la etapa replace, si la expresión tiene grupos de captura, se sustituye solo el contenido de cada grupo (no la línea entera), de modo que password=hunter2 queda como password=*** y la clave del campo se conserva para poder seguir buscando. Por eso el tercer ejemplo captura solo el centro de un número de 16 dígitos (una tarjeta): se tachan los ocho de en medio y quedan los cuatro primeros y los cuatro últimos, que bastan para reconocerla. Se comprueba con promtail --dry-run --config.file=... sobre un fichero de muestra antes de desplegar, y después con una consulta en Grafana: {service="app"} |= "password=" no debe devolver nada que no lleve asteriscos. La redacción en Promtail tiene un límite: actúa sobre lo que ya salió de la aplicación, así que ese secreto estuvo unos milisegundos en journald o en el fichero JSON del driver de Docker. Por eso la política pide las dos cosas.
La retención también es una decisión de seguridad. Cuanto más tiempo se guarda, más hay que robar y más cuesta cumplir una petición de borrado. En Loki ya está fijada en loki.yml, con retention_period: 168h y el compactor activo (sin el compactor, la retención no se aplica y el disco crece hasta llenarse). En Prometheus se fija con --storage.tsdb.retention.time en el command de su servicio; si no se pone, guarda 15 días, su valor por defecto. La política dice el valor que haya configurado de verdad el día que se escribe.
Los permisos de los datos. Los datos viven en volúmenes con nombre (monitoring_loki y los de Prometheus y Grafana), dentro de /var/lib/docker/volumes, que solo lee root: la base de datos de Grafana, con las credenciales de los datasources, y los chunks de Loki, con los logs de todos, quedan a salvo de un usuario sin privilegios de mon01. Lo que hay que vigilar es quién está en el grupo docker, porque pertenecer a él equivale a ser root (puede arrancar un contenedor que monte el disco entero del host), y que tls/ y secrets/ sigan con las claves en 600.
La política de protección entre contenedor y monitorización¶
Para qué sirve el documento. El criterio g no pide solo configurar sino documentar. La configuración dice cómo está la pila hoy; la política dice cómo tiene que estar siempre y cómo se comprueba, de modo que cualquiera (un compañero, un auditor, el tutor de empresa) puede verificarla sin preguntar, y un cambio que la incumpla se detecta en la siguiente comprobación. Por eso cada regla lleva su prueba: una regla que no se puede comprobar es una declaración de intenciones.
Cómo es. El documento que se entrega es corto (una o dos páginas), vive en el repositorio monitoring junto a la configuración, y dice lo que se cumple y cómo se comprueba. El del curso tiene cinco reglas, que recogen las capas de la unidad: quién llega a la pila (la 1 y la 3), cómo viaja lo que llega (la 2), quién la administra (la 4) y cómo se mantiene todo eso en el tiempo (la 5):
- Los exporters solo son alcanzables desde mon01. Publicados en la IP de la zona del host o en red interna, con regla en nftables del host (cadenas
inputyforward) y en OPNsense. Comprobación: escaneo desde el puesto de administración y desde web01 con resultadofiltered, y desde mon01open. - Todo tráfico de monitorización va cifrado y autenticado. TLS 1.3 con certificados de la CA del curso; basic auth en exporters; certificado de cliente para enviar a Loki; Grafana tras nginx con TLS y usuarios nominales. Comprobación: curl sin CA, sin credenciales y sin certificado de cliente fallan; tcpdump no muestra HTTP en claro.
- Desde las zonas de servicio solo se llega a lo imprescindible. web01 no tiene ninguna regla hacia mon01, y app01 solo llega a Loki (23100, con certificado) y a la Gitea provisional (23001, hasta el 12 de febrero). Comprobación: escaneo desde web01 a mon01 con todos los puertos
filtered, y desde app01 con todofilteredsalvo esos dos. - Los administradores acceden por la red de gestión, con usuario nominal. Grafana sin acceso anónimo ni registro; Prometheus, Alertmanager y Mailpit solo en
127.0.0.1, por túnel SSH. Comprobación: lista de usuarios de Grafana con roles,docker psde mon01. - Cualquier puerto nuevo requiere actualizar la matriz y las reglas. Un exporter nuevo entra por un cambio en el repositorio que toca a la vez el compose,
nftables.conf, las reglas de OPNsense y la matriz, y se prueba con el mismo escaneo. Sin eso no se despliega.
Junto a cada regla van la fecha de la última comprobación y el nombre del fichero de evidencia. La regla 5 es el procedimiento de cambios de la 5166 aplicado a la monitorización, y el conjunto es lo que un auditor externo (o el tutor de empresa) va a pedir en la primera reunión.
Enunciado¶
Toma la pila de monitorización tal como la dejó la A3.2 y déjala conforme a la política de protección de esta unidad, con evidencias de cada paso. Se entrega por Aules antes del examen del 10 de diciembre, como un directorio monitoring/seguridad/ en el repositorio monitoring con un README.md que enlace cada evidencia.
Los tres primeros bloques son trabajo en las máquinas y ocupan los cien minutos de hoy. Conviene hacerlos en este orden, porque cada uno deja hecho lo que necesita el siguiente: el bloque 1 cierra mon01 y, al poner nginx delante de Loki, deja a Promtail sin poder enviar; el bloque 2 se lo arregla con su certificado; y el bloque 3 mide el estado final, que solo existe cuando los dos anteriores están hechos. La matriz y el documento de política son transcribir y redactar con lo que ya está en el repositorio: se empiezan si sobra rato y se terminan antes del examen.
-
Cerrar mon01: publicación, nginx y nftables, 40 minutos. Al terminar, la pila de mon01 solo se ve desde fuera por tres puertos (Grafana y Loki detrás de nginx, con TLS, y la Gitea provisional), y el resto solo se alcanza por túnel. Todo está en Cerrar mon01 y en los dos apartados que lo siguen.
- Publica la pila de mon01 como dice la tabla de aquel apartado, sin nada en
0.0.0.0, con el servicionginxy sus dos bloquesservercomo única puerta de Grafana y de Loki, y Grafana con las variablesGF_*. Los certificadosmon01-nginxymon01-lokivan conca.crten/opt/monitoring/tls/. Desde ese momento el Promtail de app01 no puede enviar, porque el 23100 ya pide certificado: lo arregla el bloque 2. - Cierra mon01 con su
nftables.conf, validado y persistente, como el de app01 en la A3.2. - Cambia la contraseña del
adminde Grafana, entra desde tu puesto del aula enhttps://grafana.lab:28443con el túnel de Cómo se llega a la pila y crea tu usuario nominal con rol Editor. Con él, comprueba que siguen ahí tus dashboards y que Explore lee Loki.
Pista: la contraseña del
adminno se cambia con variables de entorno, que solo cuentan en el primer arranque de Grafana, sino congrafana cli. - Publica la pila de mon01 como dice la tabla de aquel apartado, sin nada en
-
Certificado de cliente en Promtail, 20 minutos. Desde el bloque 1, el 23100 solo abre a quien presenta un certificado firmado por la CA del curso, y Promtail todavía no lo hace. Haz que el Promtail de app01 vuelva a enviar a Loki presentando su certificado
promtail-app01, con el bloqueclientsy el volumen de mTLS entre Promtail y Loki, hasta que en Explore vuelvan las líneas de app01. En el puesto de administración, emite el certificado de cliente delogclidel mismo apartado: lo usa la quinta comprobación del bloque 3, y con él se consulta Loki desde hoy.Pista: un volumen nuevo no se aplica con
restart: hay que recrear el contenedor de Promtail. -
Auditoría final, 35 minutos. Repite las medidas de la A3.1 sobre el estado final, ahora también desde web01: son la columna «después» de la matriz y la prueba de que la política se cumple. Todo a
monitoring/audit/despues/, con la fecha en el nombre.- Las seis comprobaciones de Verificar que de verdad está protegido, con su salida. La sexta, todos los targets en UP, es la evidencia de que mon01 sigue llegando a los exporters.
- Los escaneos del estado final, con los mismos doce puertos desde cada origen (
22,443,23000,23001,23100,28025,28081,29080,29090,29093,29100,29102): app01 y mon01 desde el puesto de administración (escaneo-desde-admin-<fecha>.txt) y desde web01 (escaneo-desde-web01-<fecha>.txt), y desde app01, connc, los puertos 443, 23000, 23001, 23100, 28025, 29090 y 29093 de mon01 (nc-mon01-desde-app01-<fecha>.txt). - La captura de tcpdump del 23100 en app01 (
tcpdump-app01-23100-<fecha>.txt), igual que en la A3.1: es la mitad "después" de la pareja. - La captura del Live View de OPNsense filtrada por el 29100 durante el escaneo desde web01: la evidencia de la segunda capa.
- Reinicia app01 y mon01 a la vez y comprueba al volver que en las dos sigue cargada la tabla
inet fw, quedocker psde mon01 tiene las mismas siete flechas y que los targets siguen en UP: es la prueba de que la configuración es persistente.
- Matriz. Las mismas ocho filas de la A3.1, ni una menos, con la columna "obtenido (después)" y el fichero de evidencia en cada una: los ficheros ya están, así que es transcribir. Las filas que sigan en rojo se explican en el documento de política.
- Documento de política. Las cinco reglas de arriba adaptadas a tu entorno: el esqueleto está escrito, y lo que añades es qué parte está hecha y con qué fichero se demuestra. Lo que se haya quedado sin cifrar (cAdvisor, el 29102 de la API, Promtail y los exporters de db01) o sin cerrar (db01, si no se hizo en la A3.2) se escribe como pendiente con fecha prevista, y con lo de Datos: redacción, retención y permisos se dice dónde se redactan los secretos de los logs y dónde están fijadas la retención y los permisos. Una política que dice lo que falta vale mucho más que una que declara lo que no está hecho.
Si sobra tiempo, cierra lo que queda, que es repetir recetas ya escritas: el sidecar nginx de cAdvisor y del 29102 en app01 (si en la A3.2 no retiraste esos puertos de 0.0.0.0, ese es su primer paso), db01 con su nftables.conf y el TLS de sus dos exporters, el node_exporter de mon01 con su web.yml, y las tres etapas replace de Datos en el Promtail de app01.
Entregables:
- Matriz de exposición antes y después, con las mismas ocho filas y la evidencia de cada una (de la A3.1, paso 4, y del bloque 4).
-
nftables.confde app01 y de mon01 (de la A3.2, paso 2, y del bloque 1), el de db01 si se hizo, y captura de las filas 11 a 13 de OPNsense con sus alias (de la A3.1, paso 6). - Configuración de TLS y autenticación: el
web.ymlde app01 con el hash sustituido por<bcrypt>y el jobnode-tls(de la A3.2, pasos 3 y 4),nginx/mon01.confy las variablesGF_*(del bloque 1) y el bloqueclientsde Promtail (del bloque 2). - Escaneos y pruebas de conexión con fecha desde el puesto de administración, web01 y app01, antes (de la A3.1, pasos 2 y 3) y después (de la A3.2, paso 5, y del bloque 3), que demuestran que solo mon01 llega a los exporters.
- Captura de tcpdump del 23100 antes (legible) y después (TLS), y salidas de las seis comprobaciones de curl y openssl (de la A3.1, paso 3, y del bloque 3).
- Documento de política adaptado a tu entorno, con las cinco reglas, su comprobación y la fecha de la última verificación (del bloque 5).
| Criterio | RA1 | Peso |
|---|---|---|
| Auditoría de comunicaciones: solo protocolos y puertos requeridos habilitados, con evidencias | f | 50 % |
| Reglas de protección configuradas (red, firewall, TLS y autenticación) y documentadas | g | 50 % |
Para el criterio f se valora que la matriz sea completa (todos los orígenes relevantes, no solo mon01), que los estados de nmap estén bien interpretados y que cada fila tenga evidencia con fecha. Para el criterio g se valora que las reglas estén en las dos capas, que la configuración sea persistente (sobrevive a reiniciar host y contenedores) y que el documento de política se corresponda con lo que de verdad hay configurado; una política que dice que Loki pide certificado con un 23100 que acepta peticiones sin él se corrige sobre la mitad de ese criterio. Del criterio g, el cierre de app01 y el TLS de su node_exporter vienen hechos de la A3.2 y aquí solo se documentan; lo que se configura hoy, y se evalúa a la vez que se hace, es el cierre de mon01 (publicación, nginx y su nftables.conf), el certificado de cliente de Promtail y Grafana tras nginx con usuarios nominales.
Errores frecuentes en el laboratorio¶
El puerto sigue abierto después de poner la regla en nftables. Casi siempre es un puerto publicado por Docker y la regla está en input. Se ve con docker ps (si tiene flecha con 0.0.0.0, es esto) y la regla va en forward con ct original proto-dst, o se publica en la IP concreta.
Después de nft flush ruleset los contenedores no tienen red. El flush ruleset ha borrado las tablas ip nat e ip filter de Docker. systemctl restart docker las regenera. En el fichero se usa flush table inet fw y nunca flush ruleset.
Al activar nftables en app01, la API deja de responder en api.dev.lab o Promtail no puede enviar. La cadena forward tiene política drop, y corta también el tráfico de los contenedores hacia otras máquinas, que pasa por ella. Va con política accept y drop explícitos solo para @mon_ports, como en el fichero de app01.
Prometheus marca el target como DOWN con x509: certificate signed by unknown authority. Prometheus no encuentra ca.crt (ruta dentro del contenedor distinta de la del host, o falta el compose.override.yml) o el certificado se emitió con otra CA. docker compose exec prometheus ls -l /etc/prometheus/tls/ca.crt y openssl verify -CAfile ca.crt app01-node.crt.
x509: certificate is valid for app01.dev.lab, not 10.10.2.10. El target es una IP y el certificado no la lleva en el SAN. Hay que añadir server_name al tls_config o reemitir el certificado con IP:10.10.2.10.
El target da 401 Unauthorized con la contraseña correcta. El hash bcrypt tiene un $ que se ha comido el shell o YAML. En el web.yml va entre comillas dobles; si se generó con echo sin comillas simples, se han perdido caracteres. Se regenera con htpasswd -nBC 10 y se pega el hash tal cual.
nmap dice Host seems down desde el puesto de administración o desde web01. Antes de escanear, nmap comprueba si el host responde a un ping y a un par de sondas TCP, y detrás de OPNsense y del nftables del host no vuelve ninguna. No quiere decir que no haya nada: con -Pn escanea sin esa comprobación.
Promtail registra server returned HTTP status 400 Bad Request contra Loki. nginx no ha aceptado su certificado de cliente: no lo presenta (falta el tls_config o el volumen de tls/), lo firmó otra CA o se emitió sin clientAuth. docker compose logs nginx en mon01 dice cuál de las tres, y openssl x509 -in promtail-app01.crt -noout -ext extendedKeyUsage comprueba la última.
nginx responde 502 Bad Gateway delante de Grafana o de Loki. No hay nadie escuchando donde reenvía: o en el proxy_pass se ha colado el puerto de fábrica, el de cualquier ejemplo de Internet, en lugar de grafana:23000 o loki:23100, o el servicio ha perdido su opción de escucha (GF_SERVER_HTTP_PORT en Grafana, http_listen_port en loki.yml) y ha vuelto al de fábrica. docker compose logs nginx lo dice con connect() failed (111: Connection refused) while connecting to upstream.
Grafana deja de ver Loki al cerrar mon01. El datasource apunta a la IP de mon01 o a https://, y entonces pasa por nginx, que le pide certificado. Tiene que seguir en http://loki:23100: Grafana le habla por dentro.
Grafana redirige a http://, pierde la sesión o responde origin not allowed al entrar por nginx. Falta GF_SERVER_ROOT_URL con https:// o la cabecera X-Forwarded-Proto: sin ellas Grafana cree que está en HTTP y las cookies marcadas secure no se envían. El origin not allowed al entrar por el túnel del 28443 es Host $host en lugar de Host $http_host, que pierde el puerto.
nmap desde web01 tarda minutos y no acaba. Es lo esperado con todo filtrado: cada puerto espera su timeout. Para las evidencias se escanean solo los puertos de la matriz, los doce del bloque 3, y un -p- se guarda una sola vez.
HostDown de app01 saltando tras el cambio a TLS. Se cambió el exporter a TLS antes que el job de Prometheus, o al revés, y pasaron más de los 2 minutos del for entre un cambio y otro. Las dos cosas se cambian en la misma ventana y se recrea Prometheus con docker compose up -d prometheus; si hay que hacerlo por partes, conviene poner antes un silencio de 30 minutos en Alertmanager.
Los enlaces para ampliar y los apartados que van más allá de lo que se hace en clase están en Para ampliar.
UT4 · Indicadores, KPI y pruebas del servicio¶
Las tres primeras unidades pusieron el contenedor de referencia a exponer métricas, logs y eventos, convirtieron algunas de esas métricas en alarmas con Alertmanager y protegieron la pila de monitorización. Ahora toca ordenar todo eso para que sirva a alguien ajeno al trabajo: documentar qué mide cada contador, definir con fórmula y umbral los indicadores que de verdad describen el servicio, escribir el runbook de cada alarma (la guía de qué hacer cuando salta) y, sobre todo, probar el servicio de forma repetible (funcional, carga, estrés, seguridad) y dejar evidencia de cada prueba. Lo que salga de aquí es el dossier de operación que pide cualquier empresa antes de pasar un servicio a producción. Esta unidad abre la segunda evaluación, y buena parte de lo que se hace aquí entra en su examen, el 23 de marzo de 2027.
La unidad tiene dos mitades y un cierre. La primera mira el servicio tal como funciona hoy y escribe lo necesario para operarlo: qué significa cada métrica, qué números resumen su salud y dónde está la raya entre bien y mal, y qué hacer cuando una alarma la cruza. La segunda lo somete a pruebas para saber cómo se comportará con lo que todavía no ha pasado: si cada función responde como debe, si aguanta la carga prevista y lo que venga detrás, y si deja expuesto algo que no debería. El cierre junta las dos mitades: documenta las pruebas para que sirvan de evidencia, fija quién revisa los indicadores y cada cuánto, y reúne el dossier en la práctica evaluable.
Sesión 20 · Fichas de métricas¶
Después de tres unidades, Prometheus recoge varios cientos de métricas distintas: las de cAdvisor, node_exporter y postgres_exporter, las de la propia pila de monitorización y las que se programaron en la API. Cada una se añadió por un motivo, pero ese motivo no está escrito en ningún sitio: quien llegue nuevo al equipo, o quien reciba una alarma de madrugada, no sabe cuáles importan, en qué unidad van ni de dónde salen cuando dan un valor raro. Esta sesión pone orden en dos pasos. Primero se decide qué merece la pena documentar, con tres listas clásicas de lo que hay que vigilar en un servicio (las señales doradas, USE y RED); después se describe cada métrica elegida en una ficha de ocho campos y se clasifica como capacidad, rendimiento o calidad, que es lo que pide el criterio a del RA2. La hoja crea el repositorio operacion, donde va a vivir toda la documentación de operación de la unidad, y escribe en él las primeras diez fichas.
Elegir qué medir antes de documentarlo¶
No se puede documentar todo. El primer impulso es documentar todo lo que llega a Prometheus, y no sirve: solo cAdvisor publica más de cien métricas por contenedor (el keep que se le puso por la cardinalidad deja pasar diez), y un documento con cientos de fichas no lo lee nadie. Lo que hace falta es saber qué preguntas hay que poder responder sobre el servicio: las métricas que las responden son las que merecen ficha, indicador y, si hace falta, alarma. Para eso hay tres listas clásicas, que dicen casi lo mismo desde puntos de vista distintos.
Las cuatro señales doradas¶
De dónde salen. Vienen del capítulo de monitorización del libro de SRE de Google, el mismo del que sale el principio de alertar por síntomas. Allí se llaman golden signals y se presentan como el mínimo que hay que medir de cualquier servicio que atiende a usuarios:
flowchart TB
S["<b>Un servicio de cara al usuario</b>"]:::pieza
L["<b>Latencia</b><br><small>separando la de los errores:<br>un 500 en 2 ms disfraza la media</small>"]:::dato
T["<b>Tráfico</b><br><small>cuánto le piden</small>"]:::dato
E["<b>Errores</b><br><small>cuántos fallan</small>"]:::dato
SAT["<b>Saturación</b><br><small>cuán lleno está</small>"]:::dato
S --> L & T & E & SAT
classDef act fill:#ea580c22,stroke:#ea580c,stroke-width:1.5px
classDef pieza fill:#64748b22,stroke:#64748b,stroke-width:1.5px
classDef dato fill:#2563eb22,stroke:#2563eb,stroke-width:1.5px
classDef infra fill:#a1a1aa14,stroke:#a1a1aa,stroke-width:1.5px
classDef ok fill:#16a34a22,stroke:#16a34a,stroke-width:1.5px
classDef riesgo fill:#dc262622,stroke:#dc2626,stroke-width:1.5px
Las cuatro señales doradas son el mínimo. Si solo se pueden instrumentar cuatro cosas de un servicio, que sean estas.
- Latencia: cuánto tarda en responder una petición. Conviene separar la latencia de las respuestas correctas de la de los errores, porque un 500 que se devuelve en 2 ms baja la media y disfraza el problema.
- Tráfico: cuánta demanda recibe el servicio. En una API, peticiones por segundo; en una base de datos, transacciones o sesiones; en un proxy, bytes y conexiones.
- Errores: proporción de peticiones que fallan, de forma explícita (5xx), implícita (200 con contenido incorrecto) o por política (más de 1 s de respuesta cuenta como fallo).
- Saturación: cómo de lleno está el recurso que antes se va a agotar. En el contenedor
appes la CPU, acotada por el límitecpusdel compose; endb01suelen ser las conexiones o el disco.
Para qué sirven en el curso. Con estas cuatro señales del servicio del curso ya hay un panel útil, y las alarmas de síntoma caen de forma natural: latencia alta, errores altos, tráfico anómalo (cero tráfico también es síntoma) y saturación.
USE para recursos, RED para servicios¶
Dos listas para dos tipos de cosa. Las señales doradas piensan en un servicio que atiende peticiones, pero por debajo hay recursos (CPU, memoria, disco, red) que se agotan de otra manera. Para ellos se usa el método USE, que nació para diagnosticar el rendimiento de servidores: para cada recurso se miran su utilización (utilization, el porcentaje de tiempo que está ocupado), su saturación (saturation, el trabajo que espera en cola) y sus errores (errors). Su equivalente para servicios es el método RED, pensado después para los microservicios: la tasa de peticiones (rate, por segundo), los errores (errors, peticiones fallidas por segundo) y la duración (duration, la distribución de la latencia). Son la misma idea que las señales doradas vista desde dos lados: USE mira hacia dentro (la infraestructura y su capacidad) y RED mira hacia el usuario (la calidad y el rendimiento que recibe).
Para qué sirven en el curso. En el laboratorio la correspondencia es directa. RED de la API sale del contador y del histograma que se le programaron (app_http_requests_total y app_http_request_duration_seconds_bucket). USE del contenedor sale de cAdvisor (container_cpu_usage_seconds_total, container_memory_working_set_bytes, container_fs_*, container_network_*), y USE del host, de node_exporter (node_*). USE de PostgreSQL sale de postgres_exporter (pg_stat_activity_count como saturación de conexiones, pg_stat_database_* como tasa y errores). En la A4.1 sirven de lista de comprobación: cuando falten métricas de una categoría, recorrer USE y RED las hace aparecer.
Documentar las métricas¶
Qué es una ficha de métrica. Elegidas las métricas, cada una se describe en una ficha: qué mide, quién la produce, en qué unidad, con qué etiquetas y para qué se usa. Es la respuesta escrita a lo que se pregunta quien la ve por primera vez en un panel, y su valor está en que obliga a responder preguntas que de otro modo nadie se hace: ¿es un gauge o un counter?, ¿va en segundos o en milisegundos?, ¿quién la mantiene si cambia el código? Por eso el criterio a del RA2 pide documentar cada métrica con su contador, su referencia y su categoría. El curso usa una ficha de ocho campos; esta es la del histograma de latencia de la API:
| Campo | Ejemplo |
|---|---|
| Nombre | app_http_request_duration_seconds_bucket |
| Tipo | histogram |
| Unidad | segundos |
| Etiquetas | method, route, le |
| Qué mide | Tiempo de respuesta de la API por petición |
| Referencia | Código: api/metrics.py; documentación del cliente Prometheus para Python |
| Categoría | Rendimiento |
| Uso | Indicador de latencia p95; alerta AppHighLatency |
Tres campos marcan la diferencia entre una ficha útil y una de relleno:
- Tipo. Dice cómo se comporta el valor, según los tipos de métrica de Prometheus: un counter solo sube (como un cuentakilómetros), un gauge sube y baja (como un termómetro) y un histogram reparte cada observación en cubos según su valor, con la etiqueta
lecomo límite de cada cubo. El tipo decide qué funciones de PromQL tienen sentido:rate()solo sobre counters,histogram_quantile()solo sobre los_bucketde un histogram, y sobre un gauge, funciones comoavg_over_time()(su media en una ventana) opredict_linear()(su tendencia). El tipo no se adivina mirando los valores: cada/metricslo declara en una línea# TYPE, y la descripción, en una# HELP. Prometheus guarda las dos cosas y las sirve en su API, en/api/v1/metadata. - Unidad. Va en la unidad base (segundos, bytes), como manda la convención de nombres de Prometheus, y la conversión a milisegundos o GiB se hace al pintar, en Grafana. Así nadie tiene que adivinar si un 300 son milisegundos o segundos.
- Referencia. Es lo que permite ir al origen cuando la métrica se comporta raro: el fichero y la función del código propio, o la página del exporter y su versión, porque los exporters cambian nombres y significados de una versión a otra.
Las tres categorías con ejemplos del laboratorio¶
Por qué tres categorías. Cada categoría responde a una pregunta distinta sobre el servicio, y cada pregunta se vigila a su manera, con umbrales que se fijan con su propia lógica. Un servicio bien documentado tiene métricas de las tres; si al terminar la A4.1 solo hay de capacidad, es que se está mirando el contenedor y no el servicio. Las tablas que siguen son el punto de partida de la hoja: métricas reales del laboratorio, ya clasificadas.
Capacidad responde a "¿cuándo se llenará?" y son casi siempre gauges o cocientes entre un uso y un límite:
| Métrica | Tipo | Origen | Qué mide |
|---|---|---|---|
container_memory_working_set_bytes{service="app"} |
gauge | cAdvisor | Memoria que el kernel no puede reclamar sin perjudicar al proceso; es la que cuenta para el OOM killer (el mecanismo del kernel que mata un proceso cuando se agota la memoria) |
container_spec_memory_limit_bytes{service="app"} |
gauge | cAdvisor | Límite mem_limit de compose; sin límite, cAdvisor publica 0 |
node_filesystem_avail_bytes{mountpoint="/data"} |
gauge | node_exporter en db01 | Bytes disponibles para usuarios no root (distinto de _free_bytes, que incluye la reserva del 5 %) en /data, el disco de datos donde vive el clúster de PostgreSQL |
pg_stat_activity_count{datname="servicio"} |
gauge | postgres_exporter | Conexiones abiertas por estado (state="active", "idle") |
pg_settings_max_connections |
gauge | postgres_exporter | Valor de max_connections en postgresql.conf |
Por qué /data. /data no es la raíz de db01: es un disco aparte montado ahí, donde vive el clúster de PostgreSQL desde que lo separa la A3.3 de Despliegue el 27 de noviembre. Medir la capacidad de la base de datos sobre / mezclaría los datos con los logs del sistema, y llenar el sistema de ficheros a propósito, como hace la prueba cruzada de la A4.3, dejaría la máquina sin PostgreSQL, sin journald y sin SSH. Dos de los nueve indicadores de la A4.2 y dos de las alarmas de la A4.3 miden ese punto de montaje: si node_filesystem_avail_bytes{mountpoint="/data"} no devuelve nada en Prometheus, lo que falta es el disco, no la consulta.
Rendimiento responde a "¿cuánto tarda y cuánto procesa?" y suelen ser histogramas o counters derivados en el tiempo:
| Métrica | Tipo | Origen | Qué mide |
|---|---|---|---|
app_http_request_duration_seconds_bucket |
histogram | API, api/metrics.py |
Distribución de la latencia por method y route |
app_http_requests_total |
counter | API | Peticiones atendidas por method, route y status; su rate() es el throughput (las peticiones atendidas por segundo) |
pg_stat_database_xact_commit_total |
counter | postgres_exporter | Transacciones confirmadas; su rate() es la carga real de la BD |
container_cpu_usage_seconds_total{service="app"} |
counter | cAdvisor | Segundos de CPU consumidos; en rendimiento cuando se compara con la latencia, en capacidad cuando se divide por la cuota |
Calidad responde a "¿el resultado es correcto?" y es la parte que más se olvida, porque hay que construirla:
| Métrica | Tipo | Origen | Qué mide |
|---|---|---|---|
app_http_requests_total{status=~"5.."} |
counter | API | Respuestas con error de servidor |
app_http_requests_total{status=~"4.."} |
counter | API | Respuestas de error del cliente (400, 401, 404, 422): no son culpa del servicio, pero un pico dice que algo ha cambiado en quien lo llama |
up{job="app"} y pg_up |
gauge | Prometheus y postgres_exporter | 1 si el último scrape de la API funcionó y si el exporter consigue conectar con PostgreSQL; base de las alarmas AppDown y PgDown |
pg_stat_database_deadlocks_total |
counter | postgres_exporter | Interbloqueos; cada uno es una transacción abortada |
app_items_invalid |
gauge | habría que programarla en la API | Items guardados con datos imposibles, como un precio negativo; ejemplo de métrica de negocio que no existe hasta que alguien la escribe |
up y pg_up dicen si el servicio está vivo visto desde Prometheus. La disponibilidad vista desde fuera, como la ve un cliente, pediría una sonda externa que llame a /health (el blackbox_exporter), y el laboratorio no la monta.
La métrica que no existe. La última fila es deliberada. Ninguna herramienta da la calidad del dato: si un item guardado con precio negativo es un fallo del servicio, alguien tiene que escribir la consulta que lo cuenta y exponerla como gauge. En una empresa, eso es lo que separa vigilar el servidor de vigilar el servicio.
A4.1 Fichas de métricas (sesión 20)¶
Objetivo Al terminar tienes el repositorio operacion creado y publicado en la Gitea del laboratorio, con un metricas.md de al menos diez fichas de métrica, tres o más por categoría, cada una con su referencia al código o al exporter. Es la primera pieza del dossier de operación: los indicadores de la A4.2 y las alarmas de la A4.3 se construyen sobre estas métricas, y la columna Uso de cada ficha se completa con ellos.
Antes de empezar
- Los túneles de Cómo se llega a la pila abiertos y una shell en el puesto de administración, donde nace hoy
operacion. api/metrics.pydel repositorioservicio, para las referencias, y tu usuario de la Gitea de mon01.- Lo explicado al principio de la sesión: las señales doradas, USE y RED y la ficha de métrica con sus categorías.
Pasos
Vas de lo que hay a lo que se documenta: primero preparas el sitio donde vive la documentación, después sacas de Prometheus lo que cada métrica dice de sí misma, con eso escribes las fichas y al final compruebas que las tres categorías están cubiertas. Escribir la ficha a partir de lo que declara Prometheus, y no de memoria, es lo que hace que el tipo y las etiquetas sean ciertos.
-
Crea el repositorio
operacion. Toda la documentación de operación de la unidad (fichas, indicadores, catálogo de alarmas y revisiones) va a un repositorio propio, separado del código del servicio y de las reglas dealerting, porque la lee y la mantiene quien opera el servicio, no quien lo programa. Créalo en el puesto de administración, con la ramamainy el remotohttp://gitea.lab:23001/ops/operacion.git.Pista: Gitea no crea el repositorio con el primer
push. Créalo antes, vacío, desde la web o con unPOSTa/api/v1/orgs/ops/repos. -
Saca de Prometheus lo que dice cada métrica de sí misma. Para las métricas
app_,container_,pg_ynode_filesystem, consigue su tipo y su descripción tal como los declara quien las produce, y comprueba con una consulta que cada una llega con las etiquetas que crees. Es la materia prima de los campos Tipo, Etiquetas y Qué mide: si el tipo de la ficha no coincide con el declarado, las funciones que se le apliquen después estarán mal.Pista: Prometheus sirve el
# TYPEy el# HELPde todo lo que recoge en/api/v1/metadata. De cAdvisor solo llegan las diez métricas delkeepde su job: si necesitas otra, amplía elkeepy recarga. -
Escribe las fichas. En
metricas.md, diez fichas o más con los ocho campos de Documentar las métricas, empezando por las de las tres tablas de categorías, menosapp_items_invalid, que no existe. Cuida sobre todo la Referencia, que es lo que permite ir al origen cuando una métrica da un valor raro: el fichero y la función para la API, y la página y la versión para un exporter. -
Comprueba el reparto y publica. Revisa que hay tres fichas o más por categoría; si falta alguna, recorre USE y RED como en el apartado. Cierra
metricas.mdcon una tabla resumen (nombre, categoría y en qué indicador o alarma se usa; la columna Uso se completa en la A4.2) y publicaoperacionen la Gitea.Pista: la que suele quedarse corta es calidad. Los códigos de estado de la API,
upypg_updan tres sin programar nada.
Comprobación Diez fichas o más con las ocho filas rellenas; tres o más por categoría; todas devuelven datos en Prometheus; el tipo coincide con el de /api/v1/metadata; git ls-remote origin muestra la rama main de operacion.
Para practicar Empuja metricas.md del repositorio operacion a la Gitea del laboratorio: forma parte del dossier de la práctica evaluable.
Sesión 21 · Indicadores¶
La A4.1 dejó escrito qué mide cada métrica, pero una métrica suelta todavía no dice si el servicio va bien. Que la API lleve 4.831.220 peticiones atendidas y 312 errores no responde a lo que preguntan quien opera el servicio y quien lo paga: si cumple lo que promete y cuánto margen le queda. Para eso las métricas se combinan en indicadores con significado propio, y cada indicador lleva un umbral que separa lo normal de lo que no. La teoría va en ese orden: primero el vocabulario con el que se expresa una promesa de servicio (SLI, SLO, SLA y presupuesto de error), después los nueve indicadores del servicio del curso, con la fórmula de cada uno y su trampa, y al final cómo se fija un umbral sin inventárselo. La hoja los graba como recording rules, igual que las que ya hay en alerting, los pinta en un panel de KPI y justifica cada umbral con el histórico de Prometheus.
Indicadores: fórmulas y umbrales¶
Qué es un indicador. Un indicador o KPI (key performance indicator, indicador clave) combina contadores en un valor con significado para alguien que no conoce el código. "El contenedor lleva 1.234.567 segundos de CPU" no dice nada; "la API va al 62 % de su límite de CPU" sí. Los primeros ya existen: son recording rules como el ratio de errores o el p95 de latencia. Lo que falta es decidir cuáles describen el servicio entero y con qué valor se da por bueno cada uno.
De dónde sale el vocabulario. Los contratos de servicio con penalizaciones existen desde hace décadas, pero la forma de hablar de indicadores que se usa hoy la popularizó el mismo libro de SRE de Google del que salen las señales doradas. Distingue cuatro piezas:
- SLI (service level indicator): la medida, expresada normalmente como cociente de eventos buenos entre eventos totales. Disponibilidad = peticiones correctas / peticiones totales.
- SLO (service level objective): el valor comprometido internamente para ese SLI en una ventana. Disponibilidad ≥ 99,5 % en 30 días.
- SLA (service level agreement): el contrato con el cliente, con consecuencias (penalizaciones) si se incumple. Siempre menos exigente que el SLO, para que el equipo tenga margen de reaccionar antes de que la empresa pague.
- Presupuesto de error (error budget): lo que se puede fallar sin incumplir el SLO. Es la herramienta que convierte el SLO en decisiones: si queda presupuesto, se despliega; si no queda, se congela el despliegue y se arregla la fiabilidad.
El SLO del curso. La documentación del servicio con la que se fijaron los umbrales de las alarmas daba un objetivo de disponibilidad del 99 %, y con él se puso la alarma de errores en el 1 %: es la cifra que la descripción de AppHighErrorRate llama SLA, lo que el servicio promete hacia fuera. El SLO interno se fija más exigente, en el 99,5 % en 30 días, para que el equipo se entere y reaccione antes de romper esa promesa, que es justo la relación entre SLO y SLA de la lista. Es el SLO que usan los ejemplos de la unidad.
El presupuesto de error con números¶
Fallar un poco está permitido. Ningún servicio está disponible el 100 % del tiempo, y perseguirlo saldría carísimo. El SLO dice cuánto se puede fallar, y ese margen se gasta como un presupuesto. Con un SLO de disponibilidad del 99,5 % en 30 días, el presupuesto es el 0,5 % de la ventana:
flowchart LR
SLO["<b>SLO 99,5 % en 30 días</b>"]:::dato
PRE["<b>Presupuesto de error</b><br><small>el 0,5 % de la ventana</small>"]:::pieza
GAS["<b>Cada minuto caído gasta presupuesto</b>"]:::act
Q{"<b>¿Queda presupuesto?</b>"}:::act
SI(["<b>Se puede desplegar</b><br><small>el riesgo cabe</small>"]):::ok
NO(["<b>Se congelan los cambios</b><br><small>toca arreglar fiabilidad</small>"]):::riesgo
SLO --> PRE --> GAS --> Q
Q -- sí --> SI
Q -- no --> NO
classDef act fill:#ea580c22,stroke:#ea580c,stroke-width:1.5px
classDef pieza fill:#64748b22,stroke:#64748b,stroke-width:1.5px
classDef dato fill:#2563eb22,stroke:#2563eb,stroke-width:1.5px
classDef infra fill:#a1a1aa14,stroke:#a1a1aa,stroke-width:1.5px
classDef ok fill:#16a34a22,stroke:#16a34a,stroke-width:1.5px
classDef riesgo fill:#dc262622,stroke:#dc2626,stroke-width:1.5px
El presupuesto de error convierte «¿desplegamos el viernes?» en una pregunta con respuesta numérica en lugar de una discusión de opiniones.
Si el servicio recibe de media 20 peticiones/s, en 30 días son 51,84 millones de peticiones y el presupuesto son 259.200 peticiones fallidas. Da igual si esas 3,6 h son una caída completa o un 1 % de errores repartido durante 15 días: el presupuesto se consume igual, y esa es la gracia del modelo. Un SLO del 99,9 % deja 43 min al mes; uno del 99,99 %, 4,3 min. Cada nueve más cuesta un orden de magnitud en infraestructura y en guardias, así que el SLO se negocia con quien paga, no se pone el máximo por defecto.
A qué ritmo se gasta: el burn rate. El ritmo al que se gasta el presupuesto es el burn rate. Un burn rate de 1 significa que, al ritmo actual de errores, el presupuesto se agota justo al final de la ventana de 30 días. Con SLO 99,5 %:
burn rate = tasa de errores observada / (1 - SLO)
= tasa de errores observada / 0,005
Tasa de errores del 0,5 % → burn rate 1 → presupuesto agotado en 30 días
Tasa de errores del 3 % → burn rate 6 → presupuesto agotado en 5 días
Tasa de errores del 7,2 % → burn rate 14,4 → presupuesto agotado en 50 horas
Por eso las alarmas basadas en SLO no se disparan por "errores > 1 %" a secas, sino por burn rate en dos ventanas a la vez (por ejemplo, burn rate 14,4 sostenido en la última hora y también en los últimos 5 minutos): la ventana larga evita el ruido y la corta comprueba que el problema sigue activo. El 14,4 no es un número mágico: es el ritmo al que en una hora se gasta el 2 % del presupuesto del mes, porque la ventana de 30 días tiene 720 horas y 0,02 × 720 = 14,4. En PromQL, sobre las reglas grabadas de la tasa de errores:
# 2 % del presupuesto mensual consumido en 1 h y el problema sigue vivo
(app:errors:ratio1h > (14.4 * 0.005)) and (app:errors:ratio5m > (14.4 * 0.005))
Hace falta grabar app:errors:ratio1h además del ratio5m que ya existe. El capítulo "Alerting on SLOs" del SRE Workbook (enlazado en Para ampliar) tiene la tabla completa de ventanas y burn rates; no hace falta memorizarla, sí entender por qué existe.
Los nueve indicadores del servicio¶
Por qué estos nueve. Salen de cruzar las señales doradas con las tres categorías de métrica: la latencia, el tráfico y los errores del lado de la API (rendimiento y calidad), y la saturación de cada recurso que se puede agotar (capacidad): la CPU y la memoria del contenedor, y el disco y las conexiones de la base de datos. Esta tabla es el núcleo de la unidad: cada indicador lleva la fórmula que lo calcula, su categoría y dos umbrales, aviso y crítico. El p95 es el percentil 95: el valor que el 95 % de las peticiones no supera.
| Indicador | Fórmula (PromQL) | Categoría | Umbral aviso | Umbral crítico |
|---|---|---|---|---|
| Disponibilidad | sum(rate(app_http_requests_total{status!~"5.."}[30d])) / sum(rate(app_http_requests_total[30d])) |
Calidad | < 99,7 % | < 99,5 % |
| Latencia p95 | histogram_quantile(0.95, sum by(le)(rate(app_http_request_duration_seconds_bucket[5m]))) |
Rendimiento | > 300 ms | > 500 ms |
| Throughput | sum(rate(app_http_requests_total[5m])) |
Rendimiento | informativo | ninguno |
| Tasa de errores | app:errors:ratio5m |
Calidad | > 0,5 % | > 1 % |
| Saturación CPU | sum by (service)(rate(container_cpu_usage_seconds_total{service="app"}[5m])) / sum by (service)(container_spec_cpu_quota{service="app"} / container_spec_cpu_period{service="app"}) |
Capacidad | > 70 % | > 90 % |
| Memoria | container_memory_working_set_bytes{service="app"} / container_spec_memory_limit_bytes{service="app"} |
Capacidad | > 80 % | > 90 % |
| Disco BD | node_filesystem_avail_bytes{mountpoint="/data"} / node_filesystem_size_bytes{mountpoint="/data"} |
Capacidad | < 20 % | < 10 % |
| Días hasta disco lleno | predict_linear(node_filesystem_avail_bytes{mountpoint="/data"}[7d], 30*86400) < 0 |
Capacidad | 30 días | 7 días |
| Conexiones BD | sum by (service)(pg_stat_activity_count) / on(service) group_left pg_settings_max_connections |
Capacidad | > 70 % | > 85 % |
Cada fórmula tiene un porqué y al menos una trampa. Conviene repasarlas una a una: en la A4.2 se implementan como recording rules y entran en el examen.
Disponibilidad. El cociente excluye del numerador los 5xx pero mantiene los 4xx, porque un 404 o un 401 es una respuesta correcta a una petición incorrecta y no es culpa del servicio. La trampa es la ventana: rate(...[30d]) obliga a Prometheus a leer, cada vez que se evalúa, treinta días de muestras de todas las series de app_http_requests_total, una por cada combinación de método, ruta y código (con un scrape cada 15 s, son 172.800 muestras por serie), y eso tarda segundos. Lo correcto es grabar app:requests:rate5m y app:errors:rate5m como reglas y calcular la disponibilidad mensual sobre ellas con sum_over_time, que suma todas las muestras de una serie dentro de la ventana: como las dos reglas se evalúan al mismo ritmo, el cociente de sus sumas es, a efectos prácticos, el cociente entre los errores y las peticiones del mes. Cualquier ventana de 30 días exige además que Prometheus guarde más de 30 días: en mon01 la retención es de 45 días por esta razón, y la A4.2 lo comprueba antes de empezar.
Latencia p95. histogram_quantile no calcula el percentil real, porque el histograma no guarda cada duración: busca el cubo en el que cae el 95 % acumulado y hace una interpolación lineal dentro de ese cubo. Con los cubos de api/metrics.py (0,005, 0,01, 0,025, 0,05, 0,1, 0,25, 0,5, 1, 2,5 y 5 s), si el p95 real está en 320 ms el resultado será cualquier valor entre 250 y 500 ms, según cómo se repartan las peticiones dentro de ese cubo. De ahí sale cómo se eligen los cubos: conviene que haya uno justo en cada umbral, porque entonces la comparación es exacta (el p95 estimado pasa de 0,5 s exactamente cuando más del 5 % de las peticiones tarda más de 0,5 s). Los cubos actuales lo cumplen para el crítico de 500 ms, pero no para el aviso de 300 ms; una lista pensada para estos umbrales sería 0,1, 0,2, 0,3, 0,4, 0,5, 0,75, 1, 2 y 5. La segunda trampa es el sum by(le): si se agrega quitando le, la función no tiene con qué trabajar y devuelve NaN. Y si el p95 queda por encima del último cubo finito, el resultado es el límite de ese cubo (5 s), no un valor mayor. Por último, conviene leer bien el número: con 20 peticiones/s, un p95 de 400 ms significa que 60 peticiones por minuto tardan más que eso.
Throughput. No tiene umbral porque no es bueno ni malo en sí, pero es contexto imprescindible: un p95 de 800 ms con 200 peticiones/s no es el mismo problema que con 2 peticiones/s. Sí vale la pena una alarma de "tráfico cero durante 10 min en horario laboral", que casi siempre significa que el proxy de web01 ha dejado de enviar peticiones.
Tasa de errores. Se usa la regla grabada app:errors:ratio5m y no la consulta cruda, para que el panel, la alerta y el informe mensual coincidan al céntimo. El umbral crítico del 1 % es el doble del presupuesto de error (0,5 %), es decir, burn rate 2, y el aviso del 0,5 % es burn rate 1. Tiene dos trampas. Con poco tráfico el cociente es ruidoso: 1 error entre 50 peticiones ya es un 2 %, así que, si el servicio tiene horas de tráfico bajo, conviene añadir a la expresión de la alerta una condición de tráfico mínimo (and app:requests:rate5m > 1). Y si la API no ha devuelto ni un 5xx desde que arrancó, la serie de errores no existe todavía, porque la librería de la API crea cada combinación de etiquetas la primera vez que la usa: el cociente sale vacío en lugar de 0, y en el panel se ve "No data". Se arregla en la propia regla, completando el numerador con or y la tasa total multiplicada por 0, que vale 0 cuando no hay errores. Va en app:errors:ratio5m y en app:errors:rate5m, que es el numerador de la disponibilidad:
# numerador que vale 0, y no vacío, cuando no ha habido ningún 5xx
sum by (service)(rate(app_http_requests_total{status=~"5.."}[5m]))
or sum by (service)(rate(app_http_requests_total[5m])) * 0
Saturación de CPU. cAdvisor publica el límite de CPU del contenedor como dos números: la cuota (container_spec_cpu_quota, microsegundos de CPU que el contenedor puede gastar en cada periodo) y el periodo (container_spec_cpu_period, normalmente 100.000 µs). cpus: "1.5" en compose se traduce en cuota 150.000 y periodo 100.000, y su cociente devuelve el 1,5. La fórmula divide los segundos de CPU gastados por segundo (1,5 si el contenedor usa un núcleo y medio) entre ese límite. Las dos sumas by (service) no son decoración: container_cpu_usage_seconds_total lleva una etiqueta cpu (con valor total) que la cuota no tiene, y sin agregar las dos partes la división no empareja ninguna serie y sale vacía, el problema de etiquetas de los operadores. Sin límite de CPU, cAdvisor ni siquiera publica la cuota y el indicador tampoco devuelve nada: solo tiene sentido si el servicio tiene cpus fijado, que es una de las razones por las que se fija. Superar el 100 % es imposible de forma sostenida, porque el kernel frena al contenedor en cuanto agota su cuota (es el throttling, que cuenta container_cpu_cfs_throttled_periods_total), y esa métrica es mejor señal de saturación que el porcentaje. Una particularidad del servicio del curso: gunicorn corre con un solo worker, que atiende las peticiones de una en una, en un solo proceso, y no puede usar más de un núcleo. Con cpus por encima de 1 el indicador nunca llegaría a sus umbrales (con cpus: "1.5" no pasa del 67 %), así que en el laboratorio conviene un límite de 1 o menos.
Memoria. Se usa el working set y no usage, por lo que se vio al compararlos: usage incluye la caché de páginas, que el kernel puede soltar sin afectar al proceso. Cuando el contenedor llega a su límite, el kernel recupera primero esa caché y, si no basta, entra el OOM killer y mata uno de sus procesos; por eso el 90 % del límite en working set es un aviso real de que un pico más puede tumbarlo. Sin mem_limit en compose, cAdvisor publica el límite como 0 y el indicador da +Inf: no es un fallo de la fórmula, es que el contenedor no tiene límite contra el que medirse.
Disco de la base de datos. Se usa avail y no free: ext4 reserva por defecto un 5 % del disco para root, y PostgreSQL no corre como root. En el disco de 5 GB de db01 esa reserva son unos 250 MB: cuando free todavía marca 250 MB, PostgreSQL ya no puede escribir. El umbral crítico del 10 % es agresivo a propósito: con el disco lleno PostgreSQL deja de aceptar escrituras y, si el WAL (el diario donde PostgreSQL apunta cada cambio antes de aplicarlo a las tablas) no puede crecer, se para entero.
Días hasta disco lleno. predict_linear ajusta una recta por mínimos cuadrados sobre los últimos 7 días de la serie y la extrapola 30 días (30 × 86.400 s): dice cuántos bytes libres quedarán si se sigue al mismo ritmo, y si el valor previsto es negativo, el disco se llena antes de un mes. La ventana de 7 días absorbe los ciclos semanales (las copias del domingo, el VACUUM de cada noche, que es la limpieza interna de PostgreSQL); una ventana de 1 h daría avisos con cada carga masiva. Para pintar directamente los días que quedan en un panel se puede dividir lo libre entre lo que se gasta por segundo, que es la pendiente de esa misma recta (deriv), y pasarlo a días: node_filesystem_avail_bytes{mountpoint="/data"} / -deriv(node_filesystem_avail_bytes{mountpoint="/data"}[7d]) / 86400. Da un valor sin sentido (negativo) cuando el disco está vaciándose: en Grafana se limita el eje a valores positivos. La trampa de cualquier predicción lineal es que un cambio brusco, como un borrado grande o una ampliación del disco, la deja ciega durante una semana.
Conexiones de la base de datos. pg_stat_activity_count viene desglosado por estado y base de datos, de ahí el sum by (service), y el group_left empareja esa suma con la única serie de max_connections, como en la regla db:connections:ratio que ya existe. El límite es max_connections, 100 por defecto. Los umbrales del 70 y el 85 % son bajos porque el agotamiento de conexiones es brusco: una API con un pool de conexiones grande (un conjunto de conexiones abiertas que reutiliza entre peticiones) o con muchos workers puede pasar de 40 a 100 conexiones en segundos cuando la base de datos se ralentiza, y a partir de ahí todo son FATAL: too many clients. En el servicio del curso el riesgo hoy es pequeño, porque su único worker abre una conexión por petición y atiende una petición cada vez, pero el indicador se vigila igual: es el primero que se dispara el día que la API pase a varios workers.
Cómo fijar umbrales¶
Cada categoría, su lógica. Un umbral no se elige porque suene bien: tiene que responder a qué pasa cuando se cruza, y eso depende de la categoría.
- Capacidad: por margen restante, es decir, por el tiempo disponible para reaccionar. El aviso tiene que llegar con días de margen si la acción es comprar disco o pedir una VM más grande, y con minutos si la acción es un
docker compose restart. Por eso "días hasta disco lleno" avisa a 30 días y "memoria" al 80 %. - Rendimiento: por el SLO más un margen. Si el SLO de latencia es p95 < 500 ms, el aviso va en 300 ms para dar tiempo a mirar antes de incumplir.
- Calidad: por el presupuesto de error, con burn rate. El crítico es burn rate 2 o superior; el aviso, burn rate 1 sostenido. En la disponibilidad mensual, el aviso del 99,7 % salta cuando ya se ha gastado el 60 % del presupuesto, con tiempo de frenar los despliegues antes de llegar al 99,5 %.
Ningún umbral sin mirar los datos. Antes de fijar un umbral se mira la distribución real del indicador. Un umbral de CPU al 70 % cuando el servicio vive al 75 % en horas punta es una alarma permanente, y una alarma permanente es una alarma ignorada: es la fatiga de alertas. En la A4.2 se mira el histórico que ya guarda Prometheus de las sesiones anteriores con las funciones _over_time sobre la ventana que interese: min_over_time y max_over_time dan los extremos, y quantile_over_time(0.5, ...), el valor habitual. Y quantile_over_time(0.99, ...) da el valor que solo se supera el 1 % del tiempo.
A4.2 Indicadores (sesión 21)¶
Objetivo Al terminar, los nueve indicadores del servicio están grabados como recording rules en el Prometheus de mon01, un panel de KPI en Grafana los muestra con sus umbrales como colores, y indicadores.md en operacion da la fórmula, la categoría y los umbrales de cada uno, con los datos que justifican cada umbral. Es el criterio b del RA2, y la base de las alarmas de la A4.3 y de la revisión periódica de la A4.7.
Antes de empezar
alertingen mon01, con las recording rules de la A2.3 enprometheus/rules.yml.- Prometheus guardando 45 días, que es lo que pide la disponibilidad mensual: compruébalo en
http://localhost:29090/flags(storage.tsdb.retention.time) y, si no, ajústalo en elcommandde Prometheus. cpus(de 1 o menos, por lo que explica la saturación de CPU en los nueve indicadores) ymem_limiten el servicioappde app01. Elkeepde cAdvisor no deja pasarcontainer_spec_cpu_quota,container_spec_cpu_periodnicontainer_cpu_cfs_throttled_periods_total, que usa la A4.5: añádelas como en la A4.1. Sin todo eso, la CPU no devuelve nada y la memoria da+Inf.- db01 con su disco de datos en
/data, node_exporter y postgres_exporter (de la A3.3 de Despliegue), recogidos por Prometheus: de ahí salen tres indicadores. - El bucle de tráfico de la A2.1 contra
https://api.dev.lab/itemsen el puesto de administración, para que los cocientes no denNaN. Por su-s, falla en silencio sicurlno confía en la CA del curso: compruébalo antes e instala~/ca/ca.crten el sistema si hace falta. - Lo explicado al principio de la sesión: SLI, SLO y presupuesto de error, los nueve indicadores y cómo fijar umbrales.
Pasos
Construyes los indicadores de dentro afuera: primero los cálculos, como reglas que Prometheus evalúa solo; después el panel que los enseña; y solo con el panel y su histórico delante decides los umbrales, porque un umbral sin datos es una opinión. El presupuesto de error va detrás porque necesita el tráfico real de los últimos días, y la exportación cierra la hoja para que el panel quede versionado con lo demás.
-
Graba los indicadores que faltan. Cinco de los nueve ya existen (
app:requests:rate5m,app:errors:ratio5m,app:latency_p95:5m,app:memory:ratioydb:connections:ratio). Graba enprometheus/rules.yml, en un grupoapp-kpiconinterval: 30s, las seis reglas que faltan para los nueve indicadores, con los nombres que usan la A4.3 y la A4.7:app:errors:rate5m,app:errors:ratio1h,app:availability:30d,app:cpu:ratio5m,db:disk_avail:ratioydb:disk_full:predict30d. Grabarlas hace que el panel, las alarmas y los informes lean la misma serie. Valida, recarga y comprueba enhttp://localhost:29090/rulesque se evalúan sin errores.Pista: la disponibilidad es
1 -el cociente de lossum_over_time(...[30d])deapp:errors:rate5myapp:requests:rate5m, y la predicción se graba sin el< 0. Si los errores salen vacíos, no es la fórmula: es la segunda trampa de la tasa de errores. -
Monta el panel de KPI. Crea en Grafana el dashboard "KPI app", con un panel Stat por indicador sobre su regla grabada, en su unidad y con los umbrales como colores de fondo. Es la vista que se abre en la revisión diaria y la que capturas como evidencia en las pruebas de la A4.5 y la A4.6, así que cada panel tiene que leerse sin explicación: un número, su unidad y un color.
Pista: haz uno y duplícalo: solo cambian la consulta, la unidad (
percent (0.0-1.0),seconds,bytes) y los umbrales. -
Decide los umbrales con datos. Saca del histórico de Prometheus el mínimo, el máximo y el valor habitual de cada indicador, como en Cómo fijar umbrales, y con eso escribe
indicadores.mdenoperacion: indicador, regla, categoría y umbrales, y una línea por umbral con lo que has visto y por qué ese valor. Un umbral por debajo del valor habitual sería una alarma permanente, y este paso es el que lo evita.Pista: una recording rule no tiene datos de antes de cargarse, así que el histórico de las de hoy se mira con la expresión completa de la tabla.
-
Calcula el presupuesto de error. Con tu SLO y el tráfico medio de los dos últimos días, calcula el presupuesto de error mensual en minutos y en peticiones, como en El presupuesto de error con números, y añádelo a
indicadores.mdcon las cuentas a la vista. Es la cifra contra la que la revisión semanal de la A4.7 compara lo gastado. -
Versiona el panel y publica. Exporta el dashboard a
grafana/kpi.jsondeoperaciony empujaalertingyoperacion. Un panel que solo vive en la base de datos de Grafana se pierde con ella; en el repositorio queda junto a los indicadores que explica.Pista: el JSON se descarga en el puesto del aula, y
operacionestá en el puesto de administración.
Comprobación promtool check rules sin errores; los nueve indicadores devuelven valor (ninguno NaN ni +Inf); el panel los muestra con colores según umbral; cada umbral tiene su justificación con datos.
Para practicar Empuja a la Gitea del laboratorio prometheus/rules.yml en alerting, e indicadores.md y grafana/kpi.json en operacion: forman parte del dossier de la práctica evaluable.
Si te sobra tiempo Añade a alerts.yml la alerta de burn rate en dos ventanas del apartado y fuérzala parando PostgreSQL en db01 un par de minutos.
Sesión 22 · Catálogo de alarmas¶
Desde noviembre hay diez alarmas que saltan solas y llegan por correo, por Telegram y como incidencia de Gitea, y la A4.2 acaba de fijar umbrales para indicadores que todavía no avisan a nadie. Falta lo que pasa después de que suene el aviso: quien lo recibe tiene que saber qué significa, qué puede estar fallando y qué hacer, y hoy eso solo lo sabe quien escribió la regla. La anotación runbook de algunas reglas apunta a una wiki de ejemplo que no existe. Esta sesión escribe lo que falta en tres capas: una ficha por alarma con su origen, su posible fallo y su impacto, que juntas forman el catálogo; un runbook con los pasos de análisis, resolución y escalado para las alarmas que más lo necesitan; y el enlace desde cada regla hasta su documento. La teoría da el esquema de la ficha con un ejemplo, las propiedades que distinguen un runbook bueno y dos ejemplos más; la hoja añade las cinco alarmas de los indicadores nuevos, escribe el catálogo entero y cinco runbooks, y deja cada regla apuntando al suyo.
Catálogo de alarmas y runbooks¶
Para quién se escribe. Esta documentación no es para quien escribió la alarma, sino para quien la recibe sin contexto, a las 3 de la mañana, medio dormido y con un cliente esperando. Esa persona necesita respuesta a seis preguntas, que son los seis campos de la ficha: de dónde sale el aviso (Origen), qué puede estar fallando (Posible fallo), a quién afecta (Impacto), cómo se averigua cuál de las causas es (Análisis), qué se hace en cada caso (Resolución) y a quién se pasa el problema si no se arregla, con su plazo (Escalado). El catálogo es la tabla de todas las alarmas con los tres primeros campos; el runbook es la ficha completa, con los pasos, y se escribe para las alarmas que más lo necesitan. Entre los dos cubren lo que pide el criterio c del RA2: origen, fallo, impacto y pasos de resolución. La ficha de AppHighErrorRate, que sirve de modelo:
| Campo | AppHighErrorRate |
|---|---|
| Origen | app:errors:ratio5m > 1 % durante 5 min |
| Posible fallo | BD no responde; despliegue con bug; dependencia externa caída |
| Impacto | Usuarios reciben errores 5xx; afecta al SLO de disponibilidad |
| Análisis | 1. Panel "KPI app" en Grafana: ¿coincide con un despliegue? 2. En app01: docker compose -f /opt/servicio/compose.yml logs --since 10m app \| grep -ci error 3. En app01: nc -zv 10.10.3.10 5432 4. Loki: {service="app", level="error"} |
| Resolución | Si es despliegue: rollback (docker compose up -d con la imagen de la versión anterior). Si es BD: ver alarma PgDown. Si es externo: activar el modo degradado (seguir sirviendo lo que no depende de lo caído) |
| Escalado | Desarrollador de guardia si persiste más de 15 min |
Qué hace bueno a un runbook¶
La prueba. Un runbook bueno se reconoce porque alguien que no conoce el servicio puede seguirlo sin preguntar. Eso se traduce en cinco propiedades:
- Los pasos de análisis van del más rápido y frecuente al más lento y raro, y cada uno dice qué significa el resultado ("si
ncfalla, el problema es de red o de PostgreSQL: salta a PgDown"). - Los comandos son exactos, copiables, con el host desde el que se ejecutan. "Mirar los logs" no vale;
ssh ops@10.10.2.10 docker compose -f /opt/servicio/compose.yml logs --since 10m app | grep -ci errorsí. - Las acciones de resolución están ordenadas por reversibilidad: primero lo que no rompe nada (reiniciar un contenedor), luego lo que tiene coste (rollback), y lo destructivo (borrar datos, ampliar disco en caliente) solo con escalado.
- Hay un criterio de escalado con tiempo y persona o rol, no "avisar a alguien".
- Se enlaza desde la anotación
runbookde la regla de alerta (como en la A2.4), y cambiar la alarma obliga a revisar su runbook en el mismo turno: en el laboratorio las reglas viven enalertingy los runbooks enoperacion, así que el pull request que toca la regla (la propuesta de cambio, hecha desde una rama, que otra persona revisa antes de aceptarla) enlaza el cambio del runbook.
Un runbook se mantiene. Cada vez que una alarma salta y el runbook no resuelve el caso, el cierre de la incidencia incluye actualizar el runbook. Un runbook que no cambia en un año es un runbook que nadie usa.
Dos runbooks más¶
Dos fichas más, escritas con el mismo esquema, para ver cómo cambia el contenido según la alarma: una de fallo brusco (la base de datos deja de responder) y una de aviso con margen (el disco se llenará en una semana).
| Campo | PgDown |
|---|---|
| Origen | pg_up == 0 durante 1 min (postgres_exporter no consigue conectar) |
| Posible fallo | El servicio postgresql de db01 parado o reiniciándose (falta de memoria, disco lleno, corrupción); max_connections agotadas y el exporter no entra; red entre mon01 y la subred data cortada por una regla de OPNsense |
| Impacto | Toda escritura y la mayoría de lecturas de la API fallan con 5xx; salta AppHighErrorRate a los 5 min. Inhibida por HostDown de db01 |
| Análisis | 1. Desde app01, que es la única máquina con paso al 5432: nc -zv 10.10.3.10 5432 (si falla, red o proceso caído). 2. En db01: systemctl status postgresql@17-main y journalctl -k \| grep -i 'out of memory'. 3. df -h /data (disco lleno es la causa más frecuente en el laboratorio). 4. sudo tail -50 /var/log/postgresql/postgresql-17-main.log buscando FATAL, PANIC o could not write. 5. Si el servicio está arriba: sudo -u postgres psql -c "select count(*) from pg_stat_activity" para descartar conexiones agotadas |
| Resolución | Proceso muerto por falta de memoria: sudo systemctl restart postgresql@17-main y subir la memoria de la VM en el mismo turno. Disco lleno: liberar espacio en /data (volcados locales antiguos, WAL archivado ya copiado) y reiniciar; nunca borrar dentro de /data/postgresql. Conexiones agotadas: pg_terminate_backend de las sesiones idle más antiguas y revisar el pool de la API. Corrupción (PANIC en los logs): no tocar, escalar y preparar restauración de la copia |
| Escalado | Inmediato al responsable de datos si hay PANIC o si no arranca en 10 min; en horario, aviso al equipo de desarrollo si la causa es el pool |
| Campo | DiskWillFillIn7d |
|---|---|
| Origen | predict_linear(node_filesystem_avail_bytes{mountpoint="/data"}[7d], 7*86400) < 0 durante 1 h |
| Posible fallo | Crecimiento normal de datos sin plan de capacidad; logs de PostgreSQL o de la aplicación sin rotación; copias locales (pg_dump) acumuladas en el mismo volumen; tabla sin VACUUM con bloat (espacio que siguen ocupando filas ya borradas) |
| Impacto | Ninguno inmediato. Si no se actúa, en menos de una semana PostgreSQL deja de escribir y salta PgDown |
| Análisis | 1. Panel "KPI app", indicador de días hasta disco lleno: pendiente de la gráfica de 30 días y fecha estimada. 2. du -xsh /data/* \| sort -h \| tail para ver qué crece. 3. sudo -u postgres psql -c "select relname, pg_size_pretty(pg_total_relation_size(oid)) from pg_class order by pg_total_relation_size(oid) desc limit 10". 4. ls -la /data/copias (¿hay volcados que ya están copiados fuera de la máquina?). 5. Si el crecimiento está fuera de /data, du -xsh /var/log/* en db01: casi siempre son los logs de PostgreSQL sin rotar |
| Resolución | Volcados locales ya replicados: borrar los de más de 7 días. Logs: revisar la rotación de /var/log/postgresql en /etc/logrotate.d/postgresql-common. Bloat: VACUUM (VERBOSE) de la tabla en ventana de baja carga. Crecimiento legítimo: abrir tarea de ampliación del disco virtual en Proxmox (qm resize) y resize2fs, con copia previa |
| Escalado | Si la fecha estimada es inferior a 3 días, tratar como crítico y avisar al responsable de infraestructura en el día |
El catálogo, vivo. El catálogo es la tabla de todas las alarmas, versionada junto a sus runbooks y enlazada desde la anotación runbook de cada regla. La A4.3 pide el catálogo entero y cinco runbooks completos. En una empresa, un servicio mediano acumula decenas de alarmas, y parte del mantenimiento es retirar las que nunca saltan o las que saltan sin que haya nada que hacer: es una de las cosas que miran las revisiones periódicas.
A4.3 Catálogo de alarmas (sesión 22)¶
Objetivo Al terminar, operacion tiene en alarmas/ el catálogo de todas las alarmas del laboratorio y un runbook completo para cinco de ellas, y cada regla de alerta enlaza desde su anotación runbook a su runbook o al catálogo. Es el criterio c del RA2: que quien reciba un aviso llegue con un clic a lo que tiene que hacer.
Antes de empezar
alertingen mon01, con las diez alarmas de la A2.4 (siete enprometheus/alerts.ymly tres enloki/loki-alerts.yml) y las reglas grabadas de la A4.2.operacionen el puesto de administración y los túneles de Cómo se llega a la pila.- Lo explicado al principio de la sesión: la ficha de alarma, qué hace bueno a un runbook y los dos ejemplos completos.
Pasos
Primero completas las alarmas, para que el catálogo nazca con todas; después escribes el catálogo, que es corto y da la vista de conjunto; luego los cinco runbooks, que es donde está el trabajo; y al final enlazas cada regla con su documento, que va el último porque la URL no existe hasta que el fichero está escrito y empujado.
-
Convierte los indicadores nuevos en alarmas. Añade a
prometheus/alerts.ymllas cinco alarmas nuevas, con las etiquetas de la A2.4 y los umbrales de tuindicadores.md. Son las que vigilan los indicadores que grabaste en la A4.2; sin ellas, el catálogo estaría incompleto desde el primer día:AppLowAvailability, crítica:app:availability:30dpor debajo del SLO durante 15 min.AppCpuSaturated, crítica:app:cpu:ratio5mpor encima del umbral crítico durante 10 min.DbDiskLow, aviso:db:disk_avail:ratiopor debajo del umbral de aviso durante 5 min.PgDown, crítica, yDiskWillFillIn7d, aviso: el Origen de sus fichas en Dos runbooks más.
Pista:
AppCpuSaturatedllevateam: devyorigen: cadvisor, comoAppMemoryHigh; las otras cuatro,team: opsyorigen: prometheus. -
Escribe el catálogo. En
alarmas/README.mddeoperacion, una fila por alarma, las diez que ya había y las cinco de hoy, con Alarma, Origen (laexprcon sufor), Posible fallo e Impacto (una frase cada uno) y Runbook, que se rellena en el paso 4. Es la vista de conjunto: quien la lea tiene que saber de un vistazo qué vigila el laboratorio y qué pasa cuando salta cada cosa. -
Escribe los cinco runbooks. Uno por fichero en
alarmas/<Alarma>.md, con las seis filas del esquema y las cinco propiedades de Qué hace bueno a un runbook:AppHighErrorRate,PgDownyDiskWillFillIn7da partir de los ejemplos del apartado, y otras dos de las diez que ya había, a tu elección. En los tres primeros, revisa que hosts, rutas y nombres son los de tu laboratorio; en los dos tuyos, ejecuta una vez los comandos de Análisis, porque un comando que nadie ha probado es el que falla a las 3 de la mañana. Los de Resolución no se ejecutan. -
Enlaza cada regla con su documento. Pon en la anotación
runbookde cada regla dealerts.ymlyloki-alerts.ymlla URL de su documento: las cinco del paso 3, a su fichero, y el resto, al catálogo, con URL comohttp://gitea.lab:23001/ops/operacion/src/branch/main/alarmas/AppHighErrorRate.md. Así el aviso que llega por Telegram, y la incidencia que abre en Gitea, llevan el enlace dentro. Rellena la columna Runbook, empuja los dos repositorios y recarga.Pista: usa
gitea.laby no la IP de mon01: el 12 de febrero Gitea se muda a gitea01, y ese día solo cambia el principio de la URL, dehttp://gitea.lab:23001ahttps://gitea.lab. Cambia también la URL esperada enprometheus/tests/alerts_test.ymlde la A2.4, opromtool test rulesdejará de pasar.
Comprobación promtool check rules sin errores con las cinco alarmas nuevas; el catálogo tiene una fila por cada regla de alerts.yml y loki-alerts.yml; cinco runbooks con las seis filas; cada regla tiene anotación runbook con una URL que responde.
Para practicar Empuja a la Gitea del laboratorio alarmas/ en operacion, y alerts.yml y loki-alerts.yml en alerting: forman parte del dossier de la práctica evaluable.
Si te sobra tiempo Prueba cruzada: dale uno de tus runbooks a un compañero que no lo haya escrito y provoca su alarma (parando PostgreSQL en db01 para PgDown, o con sudo fallocate -l 4G /data/relleno para DbDiskLow, que con el disco de 5 GB de db01 deja un 13 % disponible sin llenarlo; bórralo en cuanto salte). Cada pregunta que te haga el compañero es una línea que falta en la ficha.
Sesión 23 · Pruebas funcionales¶
Hasta aquí la unidad ha mirado el servicio tal como funciona hoy, con el tráfico que tiene. Pero en mantenimiento la pregunta más frecuente es otra: si se cambia algo (una versión nueva, una dependencia actualizada, otra imagen base), ¿seguirá funcionando? Eso no lo dice ningún panel, porque el problema todavía no ha pasado; lo dicen las pruebas, que provocan a propósito las situaciones que interesan y comprueban el resultado. Esta sesión abre la segunda mitad de la unidad con el mapa de los tipos de prueba (qué pregunta responde cada uno y en qué momento se lanza) y empieza por el primero, las pruebas funcionales: son las más rápidas, y no tiene sentido medir cuánto tarda una función que no hace lo que debe. La hoja escribe la suite funcional de la API con pytest, comprueba que de verdad detecta un fallo y deja escrito cómo la ejecutará Jenkins cuando exista.
Pruebas del servicio¶
Monitorizar y probar. Monitorizar dice cómo se comporta el servicio con el tráfico que hay; probar dice cómo se comportará con el tráfico que habrá, o con el que no debería haber. Cada tipo de prueba responde a una pregunta distinta y se lanza en un momento distinto de la vida de una versión, desde que alguien sube un cambio hasta que llega a producción:
| Tipo | Qué comprueba | Herramientas | Cuándo |
|---|---|---|---|
| Funcionales | Que cada función hace lo que debe (y falla como debe) | pytest, Postman/newman, curl con asserts | Cada cambio |
| Calidad de servicio | Que cumple los SLO con carga normal | k6 con umbrales (thresholds) |
Cada versión |
| Rendimiento | Latencia y throughput bajo carga creciente | k6, JMeter, Locust | Cada versión, cambios de infraestructura |
| Estrés | Dónde y cómo se rompe; cómo se recupera | k6 con rampas hasta fallo | Antes de producción, tras cambios de capacidad |
| Seguridad | Vulnerabilidades en la aplicación y sus componentes | OWASP ZAP (baseline), trivy image, nikto |
Cada versión |
flowchart TB
C["<b>Commit en Gitea</b>"]:::act
B["<b>Build de imagen</b>"]:::pieza
F["<b>Funcionales</b><br><small>pytest / newman · cada commit</small>"]:::pieza
S["<b>Seguridad</b><br><small>trivy image + ZAP baseline</small>"]:::pieza
D["<b>Despliegue en pre</b>"]:::pieza
Q["<b>Calidad de servicio</b><br><small>k6 con thresholds · cada versión</small>"]:::pieza
P["<b>Rendimiento</b><br><small>rampas 10 / 50 / 100</small>"]:::pieza
E["<b>Estrés</b><br><small>hasta el fallo · antes de prod</small>"]:::pieza
R["<b>Informe de versión</b>"]:::dato
Prod(["<b>Despliegue en producción</b>"]):::ok
C --> B --> F --> S --> D --> Q --> P --> E --> R --> Prod
classDef act fill:#ea580c22,stroke:#ea580c,stroke-width:1.5px
classDef pieza fill:#64748b22,stroke:#64748b,stroke-width:1.5px
classDef dato fill:#2563eb22,stroke:#2563eb,stroke-width:1.5px
classDef infra fill:#a1a1aa14,stroke:#a1a1aa,stroke-width:1.5px
classDef ok fill:#16a34a22,stroke:#16a34a,stroke-width:1.5px
classDef riesgo fill:#dc262622,stroke:#dc2626,stroke-width:1.5px
Cada prueba tiene su frecuencia: las funcionales en cada commit, las de carga en cada versión, las de estrés solo antes de producción o al cambiar la capacidad.
Por qué cada una va en su momento. Las pruebas funcionales son rápidas (segundos) y se lanzan en cada commit. Las de calidad y rendimiento necesitan un entorno desplegado y varios minutos, así que van por versión. El estrés puede tumbar el entorno, por lo que no se automatiza en cada versión: se lanza a mano, en un entorno de preproducción (en esta unidad, dev), antes de la primera puesta en producción y cuando cambia la capacidad (más CPU, más workers, otra VM). La seguridad va en cada versión porque una imagen base nueva puede traer CVE nuevas (vulnerabilidades conocidas, publicadas con un identificador) sin que el código cambie.
Quién las lanza en el curso. La cadena del diagrama es un pipeline: la secuencia de etapas automáticas que construye, prueba y despliega cada versión. En el laboratorio lo ejecutará Jenkins, el servidor de integración continua (el que construye y prueba solo cada cambio que se sube). Hasta que exista, las pruebas de esta unidad se lanzan a mano, casi todas desde el puesto de administración, con los mismos comandos que después usará el pipeline. Jenkins llega al laboratorio el 5 de febrero, cuando lo instala Despliegue en su unidad de integración continua (UT6).
Pruebas funcionales con pytest¶
Qué es una prueba funcional. Una prueba funcional contra la API es una petición HTTP y una serie de afirmaciones sobre la respuesta: el código de estado, las cabeceras y el contenido que tienen que llegar. Escritas en un fichero, se repiten igual cada vez y responden en segundos a la pregunta de si la versión hace lo que debe.
Las herramientas. En Python se escriben con pytest, el ejecutor de pruebas más extendido del lenguaje, y requests, la librería con la que se hacen peticiones HTTP. pytest busca en los ficheros test_*.py las funciones cuyo nombre empieza por test, ejecuta cada una y da por fallida la que lanza una excepción; assert es la forma de lanzarla cuando algo no es lo esperado, y pytest enseña entonces los valores que no cuadraban. Lo que una prueba necesita preparado de antes (aquí, un token de sesión) se escribe como fixture: una función que pytest ejecuta antes de cada prueba que la pide (basta con poner su nombre como parámetro) y cuyo resultado le pasa en ese parámetro. Con scope="session", se ejecuta una sola vez para toda la suite.
Así queda la suite de partida. El fixture token inicia sesión con el usuario de prueba test de la API, cuya contraseña llega en una variable de entorno, y cada función prueba una sola cosa; los errores de la API llegan en JSON con la forma {"error": "..."}:
# tests/functional/test_items.py
import os
import requests
import pytest
BASE = os.environ.get("API_URL", "https://api.dev.lab")
@pytest.fixture(scope="session")
def token():
r = requests.post(f"{BASE}/login", json={"user": "test", "password": os.environ["API_TEST_PASS"]}, timeout=5)
assert r.status_code == 200
return r.json()["token"]
def test_health():
assert requests.get(f"{BASE}/health", timeout=5).status_code == 200
def test_list_items_ok(token):
r = requests.get(f"{BASE}/items", headers={"Authorization": f"Bearer {token}"}, timeout=5)
assert r.status_code == 200
assert r.headers["Content-Type"].startswith("application/json")
assert isinstance(r.json(), list)
def test_create_item_requires_auth():
r = requests.post(f"{BASE}/items", json={"name": "x", "price": 1}, timeout=5)
assert r.status_code == 401
assert "error" in r.json()
def test_create_item_validates_price(token):
r = requests.post(f"{BASE}/items", json={"name": "x", "price": -3},
headers={"Authorization": f"Bearer {token}"}, timeout=5)
assert r.status_code == 422
assert "error" in r.json()
def test_unknown_item_is_404(token):
r = requests.get(f"{BASE}/items/999999", headers={"Authorization": f"Bearer {token}"}, timeout=5)
assert r.status_code == 404
Qué cubre. Conviene fijarse en el reparto: dos casos correctos, uno de autenticación, uno de validación y uno de recurso inexistente. Los ocho casos que pide la A4.4 cubren los tres grupos (correctos, errores esperados, validación). Una suite que solo comprueba status == 200 no prueba que el servicio falle bien, y fallar bien es la mitad del trabajo de una API: un 401 claro en lugar de un 500, un 422 que dice qué está mal en lugar de guardar un dato imposible. La contraseña de prueba va en variable de entorno (en Jenkins, una credencial), nunca en el fichero.
El informe y el código de salida. Un pipeline no lee la pantalla: de cada prueba necesita un código de salida que diga si ha ido bien y un informe que se pueda archivar. pytest da las dos cosas: sale con 0 si todo pasa y con 1 si algo falla, y con --junitxml escribe un informe JUnit, un XML con un resultado por caso. El formato lo popularizó JUnit, el marco de pruebas de Java, y es el que entienden Jenkins y casi cualquier herramienta de integración continua. newman, el ejecutor de Postman, que es la otra herramienta habitual para probar APIs, produce el mismo informe. Esta es la ejecución con cada uno:
export REQUESTS_CA_BUNDLE=~/ca/ca.crt
API_URL=https://api.dev.lab python3 -m pytest tests/functional -v --junitxml=reports/pytest.xml
REQUESTS_CA_BUNDLE apunta a la CA del curso porque requests instalado con pip no lee el almacén de certificados del sistema, sino el suyo propio (el paquete certifi), que no conoce esa CA: sin la variable, el fixture token muere con SSLError antes del primer caso. Se pone siempre, también en Jenkins, para que la suite no dependa de cómo se instaló.
Postman es cómodo para diseñar la petición con la interfaz gráfica, y newman es su ejecutor de línea de comandos. Una colección exportada es un JSON con, por cada petición, su método, sus cabeceras, su cuerpo y su URL; lo propio de las pruebas es el bloque event de cada petición, con las afirmaciones escritas en JavaScript con pm.test, equivalentes a los assert de pytest:
"event": [{
"listen": "test",
"script": { "exec": [
"pm.test('status 200', () => pm.response.to.have.status(200));",
"pm.test('devuelve una lista', () => pm.expect(pm.response.json()).to.be.an('array'));"
] }
}]
La colección entera de la que sale este fragmento está en Para ampliar. Se ejecuta con la imagen oficial, sin instalar nada más que Docker; NODE_EXTRA_CA_CERTS es el equivalente de REQUESTS_CA_BUNDLE para newman:
Cuál elegir. El criterio es sencillo: si el equipo ya documenta la API en Postman, newman; si hay que preparar datos antes de cada caso (crear un usuario, un item), pytest, porque los fixtures lo hacen limpio y en Postman acaba siendo JavaScript incrustado en un JSON. En el laboratorio la suite se escribe con pytest.
La suite en Jenkins¶
Por qué se escribe ya. Jenkins no existe en el laboratorio hasta el 5 de febrero, pero la suite tiene que acabar ejecutándose ahí. Si se deja para entonces, nadie recordará con qué variables se lanzaba ni qué certificado necesitaba. Por eso se escribe hoy el fichero con el que Jenkins la ejecutará, y se comprueba que su comando es exactamente el que se ha lanzado a mano.
Cómo se lee un Jenkinsfile. Jenkins lee la definición del pipeline de un fichero del propio repositorio, el Jenkinsfile. En su forma declarativa se escribe por bloques: agent dice en qué máquina se ejecuta (any, en cualquiera disponible), environment fija variables para todas las etapas, stages lista las etapas con sus pasos (steps) y post dice qué se hace al terminar. Este es el mínimo para la suite:
pipeline {
agent any
environment {
API_URL = 'https://api.dev.lab'
API_TEST_PASS = credentials('api-test-pass')
REQUESTS_CA_BUNDLE = '/usr/local/share/ca-certificates/lab5166.crt' // la CA del curso en el agente
}
stages {
stage('Funcionales') {
steps { sh 'python3 -m pytest tests/functional -v --junitxml=reports/pytest.xml' }
}
}
post { always { junit 'reports/*.xml' } }
}
Tres detalles hacen que funcione igual que a mano. credentials('api-test-pass') saca la contraseña del almacén de credenciales de Jenkins, donde habrá que crearla como Secret text, en lugar de escribirla en el fichero. El sh es el mismo comando que se lanza en el puesto de administración, y la CA del curso tiene que estar en el agente, en la ruta que dice REQUESTS_CA_BUNDLE. Y junit va en post { always } para publicar el informe aunque la etapa haya fallado, que es justo cuando más falta hace. Para que Jenkins use el fichero hará falta además un job de tipo Pipeline que apunte a él; hoy basta con dejar escrito cuál.
A4.4 Pruebas funcionales (sesión 23)¶
Objetivo Al terminar tienes una suite de ocho casos funcionales con pytest sobre la API de dev, que genera un informe JUnit y sale con código distinto de cero cuando falla un caso, y el Jenkinsfile que la ejecutará en el pipeline, escrito y comprobado contra el comando real, a la espera de que exista Jenkins. Es la primera prueba del dossier y la que más se repite: se vuelve a pasar contra cada versión nueva.
Antes de empezar
- Una shell en el puesto de administración, que llega a
https://api.dev.laby confía en la CA del curso desde la A4.2. - Un clon de
servicioen~/serviciodel puesto de administración, donde se escriben las pruebas y se guardan sus evidencias. La copia de app01 sigue siendo la del despliegue. - pytest y requests (
python3-pytestypython3-requests), y la contraseña del usuariotestde la API enAPI_TEST_PASS, nunca en el repositorio. - Nada de Jenkins hasta el 5 de febrero: hoy la suite se lanza a mano y el pipeline se deja escrito.
- Lo explicado al principio de la sesión: los tipos de prueba, pytest y la suite en Jenkins.
Pasos
Primero escribes los casos, después demuestras que la suite sirve para algo (pasa cuando todo está bien y avisa cuando algo se rompe) y al final la dejas preparada para el pipeline. El orden importa: un Jenkinsfile que lanza una suite que nunca ha fallado delante de ti no garantiza nada.
-
Escribe los ocho casos. En
tests/functional/test_items.py, parte de la suite de pytest, que trae cinco, y llega a ocho repartidos en los tres grupos: correctos (salud, listar, crear y leer lo creado), errores esperados (401 sin token, 401 con contraseña equivocada, 404 con id inexistente) y validación (422 con precio negativo y con un cuerpo que no es JSON). Los dos últimos grupos son los que demuestran que la API falla bien.Pista: crear devuelve 201 con el
idnuevo en el JSON. La API trata un cuerpo que no es JSON como vacío, y lo rechaza la validación. -
Comprueba que la suite detecta un fallo. Ejecútala contra dev con el comando de pytest, que deja el informe JUnit en
reports/, y copia el de esa ejecución atests/evidence/1.4.2/pytest.xml, la carpeta de la versión que corre en app01 (en estos apuntes, la 1.4.2). Después rompe un caso y comprueba las dos señales que mirará el pipeline: el código de salida pasa a 1 y el XML trae un<failure>. Una suite que no ha fallado nunca delante de ti no ha demostrado nada. Deshaz el cambio. -
Deja la suite lista para Jenkins. Guarda en
ci/Jenkinsfile.pruebasel Jenkinsfile de La suite en Jenkins, con elshidéntico al comando que has lanzado a mano, y enci/README.mdlo que habrá que hacer cuando haya Jenkins: la credencialapi-test-passcomo Secret text, la CA del curso en el agente y el job Pipeline "app-pruebas-funcionales" apuntando a este fichero. Así, en febrero el trabajo será crear tres cosas en Jenkins, no recordar cómo se lanzaba la suite. Empujatests/yci/.
Comprobación Ocho casos que pasan contra dev, repartidos en los tres grupos; con un caso roto, código de salida 1 y <failure> en el XML; ci/Jenkinsfile.pruebas y ci/README.md escritos, con el comando idéntico al que has ejecutado.
Para practicar Empuja a la Gitea del laboratorio tests/functional/, ci/ y tests/evidence/1.4.2/pytest.xml del repositorio servicio: en la A4.7 le pondrás su ficha de caso.
Sesión 24 · Calidad de servicio y rendimiento¶
La suite de la A4.4 dice que cada función responde bien, pero lo comprueba con una petición detrás de otra, sin prisa. El SLO del servicio habla de otra cosa: de que el 95 % de las peticiones tarde menos de 500 ms y falle menos del 1 %, con mucha gente usando el servicio a la vez. Para saber si se cumple antes de que lo descubran los usuarios hay que fabricar esa gente: un programa que lance peticiones en paralelo, como harían muchos clientes, y mida lo que tarda cada una. Esta sesión lo hace con k6, en tres pasos: primero, qué distingue una prueba de carga, una de rendimiento y una de estrés, que se hacen con la misma herramienta pero buscan cosas distintas; después, cómo se describe en k6 la carga y cómo se le dan los SLO como umbrales; y por último, cómo se leen sus resultados junto a Grafana, porque k6 solo ve lo que ve el cliente. La hoja lanza la prueba con rampas de 10, 50 y 100 usuarios y responde a partir de cuántos se incumple el SLO y qué lo limita.
Carga, rendimiento y estrés no son lo mismo¶
Misma herramienta, tres preguntas. Las tres usan k6 y a menudo el mismo script, pero buscan cosas distintas y se leen de forma distinta:
flowchart LR
S["<b>El mismo script de k6</b>"]:::dato
C["<b>Carga</b><br><small>la carga esperada · 50 usuarios</small>"]:::pieza
R["<b>Rendimiento</b><br><small>subiendo hasta encontrar el techo</small>"]:::pieza
E["<b>Estrés</b><br><small>más allá del techo, a propósito</small>"]:::pieza
CQ(["<b>¿Cumple los SLO?</b><br><small>sí o no · es la que va en el pipeline</small>"]):::ok
RQ(["<b>¿Dónde está el límite?</b><br><small>un número para dimensionar</small>"]):::ok
EQ(["<b>¿Cómo se rompe y cómo vuelve?</b><br><small>¿degrada o se cae?</small>"]):::riesgo
S --> C --> CQ
S --> R --> RQ
S --> E --> EQ
classDef act fill:#ea580c22,stroke:#ea580c,stroke-width:1.5px
classDef pieza fill:#64748b22,stroke:#64748b,stroke-width:1.5px
classDef dato fill:#2563eb22,stroke:#2563eb,stroke-width:1.5px
classDef infra fill:#a1a1aa14,stroke:#a1a1aa,stroke-width:1.5px
classDef ok fill:#16a34a22,stroke:#16a34a,stroke-width:1.5px
classDef riesgo fill:#dc262622,stroke:#dc2626,stroke-width:1.5px
Se confunden porque comparten herramienta. Lo que cambia es la pregunta, y por eso el resultado se lee distinto.
- Carga o calidad de servicio. Somete al servicio a la carga esperada (los 50 usuarios del script básico del apartado siguiente) y responde a una pregunta binaria: ¿cumple los SLO o no? Es la que va en el pipeline y la que verificará cada actualización. Su resultado es el veredicto de los
thresholds. - Rendimiento. Sube la carga por escalones (10, 50 y 100 usuarios en la A4.5) y busca la curva: cómo crece la latencia con la carga y a partir de qué punto se incumple el SLO. Su resultado es un número ("el SLO se incumple a partir de 80 usuarios") y una gráfica. Sirve para planificar capacidad y para comparar versiones: si la 1.4.2 aguanta 80 y la 1.5.0 aguanta 60, alguien tiene que explicar por qué.
- Estrés. Sube hasta romper y luego baja. Busca tres datos: el punto de rotura (errores > 10 % o timeouts), el modo de fallo (¿la API devuelve 503 limpios o se cuelga? ¿muere PostgreSQL por conexiones o la API por memoria?) y el tiempo de recuperación al retirar la carga. Un servicio que se rompe a 300 usuarios pero se recupera solo en 20 s es mejor que uno que aguanta 400 y luego necesita un reinicio manual. Para el estrés no se usan
thresholdsque aborten: interesa ver el fallo entero. El script de estrés,estres.js, se escribe en la A4.6 a partir del básico del apartado siguiente, cambiando la rampa.
Calidad de servicio y rendimiento con k6¶
Qué es k6. k6 es un generador de carga libre que hoy mantiene Grafana Labs, la empresa de Grafana y Loki. Los escenarios se escriben en JavaScript, pero el programa es un binario único que no necesita nada más instalado. Tiene dos propiedades que lo hacen útil aquí: sale con un código distinto de cero cuando no se cumple un umbral, lo que lo convierte en herramienta de pipeline y no solo de laboratorio, y sabe mandar sus métricas a Prometheus. En el laboratorio se instala en el puesto de administración, desde donde se lanzan las pruebas de carga: si el generador corriera en una máquina vigilada, su propio consumo falsearía las métricas que se quieren medir.
El script básico. Un script de k6 tiene dos partes: options, que describe cuánta carga se genera y qué se exige, y la función por defecto, que es lo que hace cada usuario simulado:
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
stages: [
{ duration: '2m', target: 50 },
{ duration: '5m', target: 50 },
{ duration: '2m', target: 0 },
],
thresholds: {
http_req_duration: ['p(95)<500'],
http_req_failed: ['rate<0.01'],
},
};
export default function () {
const r = http.get('https://api.dev.lab/items');
check(r, { 'status 200': (r) => r.status === 200 });
sleep(1);
}
Cada pieza tiene su función:
options.stagesdefine la rampa en usuarios virtuales (VU): 2 min subiendo de 0 a 50, 5 min estables en 50 y 2 min bajando. Un VU es un bucle que ejecuta la función por defecto una y otra vez. Elsleep(1)es la pausa entre peticiones, como la de una persona que lee la respuesta antes de pedir otra cosa; sin él, cada VU lanzaría peticiones sin respiro. Consleep(1)y una respuesta de 100 ms, cada VU genera algo menos de 1 petición/s, así que 50 VU son unas 45 peticiones/s. Para controlar el ritmo en peticiones por segundo y no en usuarios, el ejecutorconstant-arrival-rateque viene más abajo es el adecuado.thresholdsson los SLO del servicio expresados sobre las métricas internas de k6.http_req_durationes lo que tarda cada petición desde que k6 empieza a enviarla hasta que recibe el último byte de la respuesta, sin contar la resolución DNS ni el establecimiento de la conexión;http_req_failedes la proporción de respuestas que k6 considera fallidas (4xx y 5xx por defecto). Si un umbral no se cumple al terminar, k6 lo marca en rojo y sale con código 99.checkes una afirmación que no detiene la prueba: cuenta cuántas veces se cumplió. Se refleja en la métricachecksy se puede poner umbral sobre ella (checks: ['rate>0.99']). Uncheckque falla no cuenta comohttp_req_failed; son dos cosas distintas y conviene tener umbral en ambas.
Un script más cercano a un servicio real. El que se usa en la A4.5 separa escenarios, etiqueta peticiones y aborta si el SLO se incumple de forma clara, para no gastar el resto de la prueba en un resultado que ya se conoce:
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
scenarios: {
navegacion: {
executor: 'ramping-vus',
startVUs: 0,
stages: [
{ duration: '1m', target: 10 },
{ duration: '3m', target: 10 },
{ duration: '1m', target: 50 },
{ duration: '3m', target: 50 },
{ duration: '1m', target: 100 },
{ duration: '3m', target: 100 },
{ duration: '1m', target: 0 },
],
gracefulRampDown: '30s',
},
escrituras: {
executor: 'constant-arrival-rate',
rate: 5, timeUnit: '1s',
duration: '13m',
preAllocatedVUs: 10, maxVUs: 30,
exec: 'crear',
},
},
thresholds: {
'http_req_duration{name:items}': ['p(95)<500', 'p(99)<1000'],
'http_req_duration{name:crear}': [{ threshold: 'p(95)<800', abortOnFail: true, delayAbortEval: '1m' }],
http_req_failed: ['rate<0.01'],
checks: ['rate>0.99'],
},
tags: { version: __ENV.APP_VERSION || 'desconocida' },
};
const BASE = __ENV.API_URL || 'https://api.dev.lab';
const params = { headers: { Authorization: `Bearer ${__ENV.API_TOKEN}` } };
export default function () {
const r = http.get(`${BASE}/items`, { ...params, tags: { name: 'items' } });
check(r, {
'status 200': (r) => r.status === 200,
'lista no vacía': (r) => r.json().length > 0,
});
sleep(1);
}
export function crear() {
const r = http.post(`${BASE}/items`, JSON.stringify({ name: 'k6', price: 9.5 }),
{ ...params, headers: { ...params.headers, 'Content-Type': 'application/json' }, tags: { name: 'crear' } });
check(r, { 'creado': (r) => r.status === 201 });
}
Lo que aporta cada novedad:
- Escenarios.
scenariospermite mezclar en la misma prueba una carga de lectura que crece por escalones (ejecutorramping-vus) y una carga de escritura constante de 5 peticiones/s (constant-arrival-rate), que es lo que se parece a un servicio real. Como el ritmo de escritura es fijo, k6 necesita VU de reserva por si las respuestas se alargan: empieza con 10 (preAllocatedVUs) y puede llegar a 30 (maxVUs).gracefulRampDownda 30 s a las iteraciones en curso para terminar cuando la rampa baja, en lugar de cortarlas. - Etiquetas por petición. Las
tags: { name }hacen que los umbrales se puedan fijar por tipo de petición, porque un SLO global esconde que leer va bien y crear va mal. - Abortar a tiempo.
abortOnFailcondelayAbortEvalcorta la prueba si el p95 de escritura supera 800 ms una vez pasado el primer minuto, para no abortar por el arranque en frío. - La versión en todas las métricas. La etiqueta global
versionse propaga a todas las métricas y, cuando se mandan a Prometheus, permite comparar dos versiones en el mismo panel.
Ejecución y salidas. La prueba se lanza con sus variables delante, y el resumen se guarda en la carpeta de evidencias de la versión:
API_URL=https://api.dev.lab API_TOKEN=... APP_VERSION=1.4.2 \
k6 run --summary-export=tests/evidence/1.4.2/k6-resumen.json tests/k6/carga.js
echo "código de salida: $?"
--summary-export escribe un JSON pequeño con los agregados por métrica y umbral, que es el que se adjunta al informe y el que un script puede consultar con jq, el filtro de línea de comandos para JSON: jq '.metrics.http_req_duration["p(95)"]'. Ojo con la forma de ese fichero, que no es la de los datos de handleSummary() que aparecen en buena parte de la documentación de k6: cada métrica lleva sus valores directamente, sin un nivel values, y cada umbral vale true cuando ha fallado. Existe también --out json, que escribe una línea por cada muestra: con 100 VU durante 13 min son cientos de MB que nadie lee, así que en el laboratorio no se guarda.
Las métricas de k6 en Prometheus. Para tener las métricas de k6 al lado de las del servicio en Grafana, que es lo que de verdad interesa, k6 puede escribir directamente en Prometheus por remote write: el envío de series por HTTP a otro sistema, esta vez con Prometheus como receptor:
K6_PROMETHEUS_RW_SERVER_URL=http://localhost:29090/api/v1/write \
K6_PROMETHEUS_RW_TREND_STATS="p(95),p(99)" \
k6 run --out experimental-prometheus-rw tests/k6/carga.js
Para que funcione, el Prometheus de mon01 debe arrancar con --web.enable-remote-write-receiver, y como solo escucha en el 127.0.0.1 de mon01, k6 le escribe a través de un túnel abierto en la máquina que lanza la prueba (ssh -fNL 29090:127.0.0.1:29090 ops@10.10.0.20). El nombre del output ha cambiado entre versiones de k6: si la instalada no reconoce experimental-prometheus-rw, su documentación da el actual. Las métricas llegan con el prefijo k6_ y las etiquetas del escenario. De cada duración, k6 manda las estadísticas que pide K6_PROMETHEUS_RW_TREND_STATS (por defecto, solo el p(99)); con p(95) en la lista aparece k6_http_req_duration_p95, que se puede pintar al lado de app:latency_p95:5m, y el total de peticiones llega como k6_http_reqs_total. En Grafana se importa el panel oficial de k6 para Prometheus o se hace uno propio con esas dos.
Leer los resultados junto a Grafana¶
Dos puntos de vista. El resumen de k6 dice qué vio el cliente; Grafana dice qué le pasó al servidor mientras tanto. Y no miden lo mismo: el http_req_duration de k6 cuenta el camino entero, mientras que el histograma de la API solo mide el tiempo que la petición pasa dentro de Flask, entre el before_request y el after_request con los que se instrumentó. Lo que queda fuera de ese tramo es lo que separa los dos p95:
flowchart TB
K["<b>k6</b><br><small>puesto de administración</small>"]:::act
W["<b>nginx de web01</b><br><small>TLS y proxy</small>"]:::pieza
C["<b>Cola de entrada de la API</b><br><small>espera a que el único worker de gunicorn quede libre</small>"]:::riesgo
F["<b>Flask y su consulta a PostgreSQL</b><br><small>lo único que mide el histograma de la API</small>"]:::dato
K --> W --> C --> F
classDef act fill:#ea580c22,stroke:#ea580c,stroke-width:1.5px
classDef pieza fill:#64748b22,stroke:#64748b,stroke-width:1.5px
classDef dato fill:#2563eb22,stroke:#2563eb,stroke-width:1.5px
classDef infra fill:#a1a1aa14,stroke:#a1a1aa,stroke-width:1.5px
classDef ok fill:#16a34a22,stroke:#16a34a,stroke-width:1.5px
classDef riesgo fill:#dc262622,stroke:#dc2626,stroke-width:1.5px
k6 cuenta el camino entero, ida y vuelta; app:latency_p95:5m, solo la última caja. La diferencia entre los dos es el proxy, la red, la cola y la transferencia de la respuesta.
Qué mirar y en qué orden. Durante cada rampa conviene tener abierto el panel del servicio con la ventana de tiempo de la prueba y mirar, en este orden:
- Latencia p95 del servidor (
app:latency_p95:5m) frente alp(95)de k6. Si k6 ve 800 ms y el servidor ve 200 ms, el tiempo se va fuera de Flask: en el proxy de web01 y la red, en la transferencia de la respuesta (la de/itemses la lista completa de artículos) o en la cola de entrada de la API. Esta última es la sospechosa habitual en el laboratorio: gunicorn corre con un solo worker que atiende las peticiones de una en una, y mientras está ocupado las demás esperan sin que el histograma las cuente. Hay que tener en cuenta, además, que la regla calcula el p95 de los últimos 5 minutos, así que al final de un escalón de 3 todavía arrastra parte del anterior. - Saturación de CPU del contenedor
appycontainer_cpu_cfs_throttled_periods_total. Si la latencia sube justo cuando aparece el throttling, la cuota decpuses el límite y la solución es cambiarla, no optimizar código. Si la CPU se queda baja mientras la latencia de k6 se dispara, el límite no es la CPU, sino la cola del punto 1. - Conexiones de PostgreSQL. Si se pegan a
max_connectionsantes de que suba la latencia, la API abre más conexiones de las que la base de datos admite, por un pool demasiado grande o por demasiados workers. - Memoria working set. Una subida lineal que no baja al retirar la carga es una fuga; el estrés es la mejor manera de encontrarlas.
- Tasa de errores y logs filtrados por el intervalo: Loki para los de la API y el
error.logde nginx en web01 para los del proxy, que no llegan a Loki. El primer error que aparece suele decir qué se rompió (too many clients,upstream timed out,OOMKilled).
La captura como evidencia. La captura del panel es evidencia obligatoria en la A4.5 y la A4.6, y tiene que llevar el rango de tiempo visible, para que quien la lea después sepa a qué ejecución corresponde. Mejor todavía: una anotación en Grafana al inicio y al final de cada prueba (se pueden crear desde el propio script de k6 con una llamada a la API de anotaciones de Grafana en las funciones setup() y teardown()).
A4.5 Calidad de servicio y rendimiento (sesión 24)¶
Objetivo Al terminar tienes un script k6 con tus SLO como umbrales, ejecutado con rampas de 10, 50 y 100 usuarios contra dev, las capturas de Grafana de cada escalón y una respuesta escrita, con datos, a dos preguntas: a partir de cuántos usuarios se incumple el SLO y qué recurso lo limita. Es la prueba de calidad de servicio que irá en el pipeline y la de rendimiento con la que se compararán versiones.
Antes de empezar
- k6 y
jqen el puesto de administración. k6 es un binario único, de su página de versiones. - El clon de
servicioyAPI_TEST_PASS, como en la A4.4. appcon los límites de la A4.2, y el panel de KPI abierto en el navegador del puesto del aula.- Lo explicado al principio de la sesión: carga, rendimiento y estrés, k6, options, stages, thresholds y checks y leer los resultados junto a Grafana.
Pasos
Primero preparas lo que la prueba necesita para ir sola (el token y el script); después la lanzas entera y capturas el panel en cada escalón, porque lo que le pasa al servidor solo se puede mirar mientras ocurre; y al final cruzas lo que vio k6 con lo que vio Grafana para responder. La prueba dura trece minutos: lanzarla pronto deja tiempo para leerla.
-
Prepara el script y su token. Escribe
tests/token.sh, que imprime un token de/loginpara el usuariotest, y copia atests/k6/carga.jsel script completo de k6 con los umbrales de tus SLO. El token va en un script aparte porque también lo necesitan el estrés de la A4.6 y la etapa de Jenkins de la A4.7.Pista: que
token.shuseAPI_URLcuando esté definida, porque la etapa de Jenkins de la A4.7 lo llamará contra pre. -
Lanza la prueba y captura cada escalón. Exporta
API_TOKENcon la salida detoken.shy lanza la prueba completa como en el apartado, conAPP_VERSION=1.4.2y el resumen entests/evidence/1.4.2/k6-resumen.json. Al final de cada escalón (minutos 4, 8 y 12) captura el panel de KPI con el rango de tiempo visible:grafana-carga-10.png,grafana-carga-50.pngygrafana-carga-100.png. Son la mitad de la respuesta del paso 3: lo que le pasaba al servidor mientras k6 medía.Pista: las capturas se quedan en el puesto del aula; llévalas después a
tests/evidence/1.4.2/del clon. -
Responde con los datos. Escribe
tests/evidence/1.4.2/rendimiento.mdcon tres respuestas: a partir de cuántos usuarios se incumple el SLO de p95, qué recurso limita (en el orden de Leer los resultados junto a Grafana) y por qué difieren el p95 de k6 y el del servidor. Cada respuesta se apoya en una cifra del resumen o en una captura. Empujatests/.Pista: el p95 de lectura del resumen está en
.metrics["http_req_duration{name:items}"]["p(95)"].
Comprobación k6-resumen.json con los umbrales evaluados (código de salida 0 si se cumplen, 99 si no); tres capturas con el rango visible; rendimiento.md con las tres respuestas.
Para practicar Empuja a la Gitea del laboratorio, en servicio, tests/token.sh, tests/k6/carga.js y, en tests/evidence/1.4.2/, el resumen, las capturas y rendimiento.md: en la A4.7 le pondrás sus fichas de caso. La A4.6 usa la imagen de ZAP, que pesa unos 1,5 GB: si puedes, déjala descargada en mon01 al terminar (docker pull ghcr.io/zaproxy/zaproxy:stable).
Si te sobra tiempo Activa el remote write en el Prometheus de mon01 y repite un escalón con --out experimental-prometheus-rw, por el túnel del apartado, para ver k6_http_req_duration_p95 al lado de app:latency_p95:5m.
Sesión 25 · Estrés y seguridad¶
La A4.5 dejó medido a partir de cuántos usuarios se incumple el SLO, pero no qué pasa más allá: si el servicio se degrada poco a poco o se cae de golpe, qué pieza se rompe primero y si vuelve sola cuando baja la carga. Eso lo responde la prueba de estrés, que ya se distinguió de la de carga y la de rendimiento y cuyo script sale del básico de k6 cambiando la rampa, así que hoy no necesita teoría nueva. Lo que queda por probar es otra cosa: que un servicio rápido y correcto no deje nada expuesto. La teoría de hoy es la de las dos pruebas de seguridad que caben en un pipeline, ZAP, que mira lo que la API responde desde fuera, y Trivy, que mira lo que lleva dentro la imagen, con sus códigos de salida y qué informe se archiva de cada una. Es la sesión en la que Trivy se explica a fondo. La hoja empieza por el estrés, que es lo que más tarda y lo que puede dejar el entorno tocado, y sigue con ZAP y con Trivy.
Seguridad: ZAP baseline y trivy image¶
Dos sitios donde mirar. Un servicio puede ser rápido y correcto y aun así dejar puertas abiertas, y hay dos sitios donde buscarlas. Uno es lo que el servicio responde por HTTP: cabeceras de seguridad que faltan, cookies mal marcadas, versiones a la vista. Eso se ve desde fuera, como lo vería un atacante, y lo mira ZAP. El otro es lo que va dentro de la imagen: paquetes del sistema y librerías con vulnerabilidades conocidas, que desde fuera no se ven pero están ahí. Eso lo mira Trivy. Las dos pruebas tienen algo en común que las hace adecuadas para esta unidad: corren solas, en minutos y sin intervención humana, así que caben en un pipeline. El análisis activo, el que intenta atacar de verdad (las pruebas de intrusión o el escaneo activo de ZAP), queda fuera del curso.
ZAP en modo baseline. ZAP (Zed Attack Proxy) es un escáner libre de aplicaciones web que nació como proyecto de la fundación OWASP, dedicada a la seguridad de aplicaciones web. En modo baseline recorre el sitio con su araña (el rastreador que sigue los enlaces de cada página) durante un minuto y aplica solo las reglas pasivas: no envía ataques, solo observa las respuestas, así que es seguro contra un entorno de pruebas e incluso contra producción. Detecta cabeceras de seguridad ausentes, cookies sin Secure o HttpOnly, información de versión en la cabecera Server, formularios sin protección CSRF (falsificación de peticiones desde otro sitio) o contenido mixto (recursos que se piden por HTTP desde una página HTTPS).
Dónde corre. ZAP se distribuye como imagen de contenedor, así que necesita una máquina con Docker. En el laboratorio corre en mon01, que tiene Docker y resuelve api.dev.lab con el DNS de OPNsense; --network host hace que el contenedor use ese mismo DNS. Las opciones -c e -I se explican justo debajo:
mkdir -p zap && chmod 777 zap
docker run --rm --network host -v "$PWD/zap:/zap/wrk:rw" ghcr.io/zaproxy/zaproxy:stable \
zap-baseline.py -t https://api.dev.lab -r zap.html -J zap.json -c zap-rules.conf -I
echo "código de salida: $?"
El volumen en /zap/wrk es imprescindible: sin él, el informe se escribe dentro del contenedor y desaparece, y la carpeta tiene que admitir escritura del usuario zap del contenedor, de ahí el chmod.
Los códigos de salida, y qué debe parar una versión. ZAP sale con 0 si no hay hallazgos, con 1 si hay alguno marcado como FAIL, con 2 si solo hay WARN y con 3 si no ha podido ejecutarse (casi siempre porque no llega a la URL). Por defecto todas las alertas son WARN, así que basta un aviso cualquiera para que salga con 2, y un pipeline, que da por fallido cualquier código distinto de cero, pararía la versión por cualquier aviso. Por eso se combinan dos cosas. El fichero de reglas que se pasa con -c, zap-rules.conf, decide por el id de cada regla cuáles son FAIL, cuáles WARN y cuáles se ignoran. Y la opción -I hace que los WARN no cuenten como fallo. Con las dos, el código de salida solo es 1 cuando aparece algo que de verdad debe parar la versión:
10020 FAIL (Anti-clickjacking Header)
10021 FAIL (X-Content-Type-Options Header Missing)
10038 FAIL (Content Security Policy (CSP) Header Not Set)
10054 WARN (Cookie without SameSite Attribute)
10096 IGNORE (Timestamp Disclosure)
Cómo se lee el informe. Al leer zap.html, cada alerta trae su riesgo (High, Medium, Low, Informational), la confianza, las URL afectadas y una solución genérica. En el laboratorio, los hallazgos de cabeceras se resuelven en la configuración de nginx en web01 (add_header X-Content-Type-Options nosniff always;), no en la aplicación, y esa es la primera lección: buena parte de lo que ZAP encuentra se arregla en el proxy.
Trivy: lo que va dentro de la imagen. Trivy es un escáner libre de vulnerabilidades que mantiene la empresa Aqua Security. No ataca nada: abre la imagen, hace inventario de lo que lleva instalado (los paquetes del sistema operativo base y las librerías de la aplicación, los de requirements.txt que quedaron dentro) y compara cada versión con una base de datos de vulnerabilidades conocidas, que descarga la primera vez que se ejecuta y actualiza cuando se queda vieja. Por defecto busca además secretos incrustados, como contraseñas o claves que se quedaron en alguna capa. Cada hallazgo es una CVE con su severidad (LOW, MEDIUM, HIGH o CRITICAL). Es un binario único y en el laboratorio se instala en el puesto de administración, donde se queda para el resto del curso. Todavía no hay registry: la imagen es la app:<versión> que app01 construye desde api/Dockerfile, así que viaja al puesto como fichero con docker save y Trivy la lee con --input:
ssh ops@10.10.2.10 docker save app:1.4.2 > /tmp/app.tar
trivy image --input /tmp/app.tar --format json -o tests/evidence/1.4.2/trivy.json # informe completo: el que se archiva
trivy image --input /tmp/app.tar --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 # tabla de trabajo y puerta
echo "código de salida: $?"
Dos comandos, dos propósitos. El JSON que se archiva va sin filtros: es el inventario completo de lo que traía la imagen ese día, y es lo que se compara cuando llega una versión nueva. Los filtros son para trabajar: --severity se queda con lo grave, --ignore-unfixed descarta las CVE que no tienen corrección publicada (no se puede hacer nada con ellas salvo cambiar de imagen base, así que en la lista de trabajo solo estorban) y --exit-code 1 hace que Trivy falle si queda algún hallazgo tras los filtros. Eso es lo que necesita una puerta del pipeline: un paso que, si falla, impide que la versión avance.
Cómo se lee la tabla. Las columnas que importan son Library, Vulnerability (el id CVE), Severity, Installed Version y Fixed Version: la última dice si basta con reconstruir la imagen (la corrección está en el repositorio de Debian) o si hay que subir la versión de una dependencia en el código. El tamaño de la imagen base pesa mucho en el resultado: la imagen completa python:3.12 trae muchos más paquetes que python:3.12-slim, y con ellos muchos más hallazgos. Por eso la API del curso se construye sobre la slim. De dónde sale la severidad de cada CVE se explica el 4 de febrero, en la unidad de actualización y gestión de vulnerabilidades (UT7).
A4.6 Estrés y seguridad (sesión 25)¶
Objetivo Al terminar sabes cómo se rompe el servicio y cómo vuelve (punto de rotura, modo de fallo y tiempo de recuperación, anotados con su evidencia), y tienes los informes de ZAP baseline y de Trivy sobre la imagen del servicio con la lista de hallazgos y dónde se corrige cada uno. Son las dos pruebas que faltaban para el criterio d del RA2, y las que más dicen de si la versión está lista para producción.
Antes de empezar
- Un snapshot
antes-estresde app01 (120) y db01 (130), por la regla de Cuando algo se rompe. - k6,
tests/token.shyAPI_TOKENcomo en la A4.5. - La imagen de ZAP en mon01. Si no la dejaste descargada, lanza su
docker pullal empezar: son 1,5 GB y bajan mientras haces el estrés. - Trivy en el puesto de administración, donde se queda para el resto del curso. Su primera ejecución descarga la base de vulnerabilidades.
- Panel de KPI y Loki (
{service="app"}) abiertos en Grafana. - Lo explicado: carga, rendimiento y estrés, en la sesión 24, y hoy ZAP baseline y trivy image.
Pasos
El estrés va primero porque es lo que más tarda y lo único que puede dejar el entorno tocado: por eso el snapshot, y por eso lo lanzas cuando todavía queda tiempo para volver atrás. Mientras sube la carga observas la rotura, y mientras baja, la recuperación. Después, con el entorno ya tranquilo, pasas las dos pruebas de seguridad, primero la que mira desde fuera (ZAP) y luego la que mira dentro de la imagen (Trivy), y al final reúnes sus hallazgos en una sola lista de trabajo.
-
Prepara y lanza el estrés. Escribe
tests/k6/estres.jsa partir del script básico de k6, con la autenticación decarga.js: escalones de 2 min a 50, 100 y 200 usuarios, bajada a 0 en 3 min yhttp_req_failed: ['rate<0.10']como umbral solo informativo, porque aquí no se quiere abortar sino ver el fallo entero. Lánzalo con el resumen entests/evidence/1.4.2/k6-estres.json. -
Observa la rotura. Durante la subida, anota el punto de rotura (minuto y usuarios en que los errores pasan del 10 % o aparecen timeouts), el modo de fallo (503 limpios o cuelgue) y la primera línea de error, con su hora. La primera línea de error suele nombrar la pieza que ha cedido, y es la que irá al informe.
Pista: el
upstream timed outdel proxy no llega a Loki: está en/var/log/nginx/error.logde web01. -
Mide la recuperación. Durante la bajada, mide cuánto tardan la latencia y los errores en volver a lo normal; si algo no vuelve solo, anótalo y vuelve al snapshot. Este dato es el que distingue un servicio que se degrada de uno que hay que rescatar a mano. Captura el panel con toda la prueba (
grafana-estres.png) y escribetests/evidence/1.4.2/estres.mdcon los tres datos y lo que demuestra cada uno.Pista: mira los errores con una ventana de
[1m]: la regla de 5 minutos tarda en bajar. -
Mira la API desde fuera con ZAP. Pasa ZAP baseline contra
https://api.dev.labdesde mon01, como en ZAP baseline y trivy image, contests/zap-rules.conf(las tres reglas de cabeceras del apartado en FAIL), y traezap.htmlyzap.jsonatests/evidence/1.4.2/zap/. Es lo que vería cualquiera que llegue a la API.Pista:
zap-rules.conftiene que estar dentro de la carpeta de mon01 que montas en/zap/wrk. -
Mira dentro de la imagen con Trivy. Escanea con Trivy la imagen que corre en app01, como en el apartado: el informe completo en
tests/evidence/1.4.2/trivy.json, que es el inventario contra el que se comparará la versión siguiente, y la tabla de trabajo con la puerta, anotando su código de salida.Pista: la etiqueta exacta (en estos apuntes,
app:1.4.2) la dadocker compose imagesen app01. -
Reúne los hallazgos. Escribe
tests/evidence/1.4.2/seguridad.md: hallazgo, herramienta, severidad y dónde se corrige (proxy nginx de web01, imagen base, dependencia o código), con los FAIL y WARN de ZAP y los HIGH y CRITICAL con corrección de Trivy, agrupados por paquete si pasan de diez. Decir dónde se corrige cada uno es lo que convierte dos informes en una lista de trabajo. Empujatests/.
Comprobación estres.md con los tres datos y su evidencia; grafana-estres.png con el rango visible; zap.html y zap.json en la carpeta; trivy.json sin filtrar y el código de salida de la puerta anotado; seguridad.md con los hallazgos y dónde se corrige cada uno.
Para practicar Empuja a la Gitea del laboratorio, en servicio, tests/k6/estres.js, tests/zap-rules.conf y, en tests/evidence/1.4.2/, k6-estres.json, grafana-estres.png, estres.md, zap/, trivy.json y seguridad.md.
Si te sobra tiempo Corrige uno de los hallazgos de cabeceras en el nginx de web01 (add_header X-Content-Type-Options nosniff always; en el bloque server del 443) y vuelve a pasar ZAP: la alerta tiene que desaparecer del informe.
Sesión 26 · Documentación de pruebas y seguimiento¶
Las A4.4 a A4.6 han dejado tests/evidence/1.4.2/ lleno de ficheros: un XML de pytest, dos resúmenes de k6, capturas y los informes de ZAP y de Trivy. Para quien los generó tienen sentido; para cualquier otro son ficheros sueltos que no dicen qué se probó, contra qué versión, con qué resultado ni, sobre todo, si la versión puede pasar a producción. Y los indicadores están en un panel que nadie está obligado a mirar mientras no salte una alarma. Esta sesión resuelve las dos cosas, que son las que piden los criterios e y f del RA2. Primero, cómo se documentan las pruebas: una ficha por caso, una carpeta de evidencias con sitio fijo, un informe por versión con un veredicto único y la etapa de Jenkins que repetirá ese veredicto en cada versión. Después, el ciclo de revisión periódica de los indicadores, con su plantilla. La hoja aplica las dos partes a la versión 1.4.2 y a las dos últimas semanas del servicio.
Documentar las pruebas¶
La ficha de caso. Una prueba que no se documenta no existe a efectos de auditoría, y tampoco sirve para comparar versiones: cuando llegue la siguiente, habrá que demostrar qué pasaba la anterior y qué pasa la nueva. Por cada ejecución relevante se rellena una ficha de caso de prueba. Su identificador lleva el tipo en las letras (PF funcional, PC calidad de servicio, PR rendimiento, PE estrés y PS seguridad) y un número, para poder citarla desde el informe:
| Campo | Contenido |
|---|---|
| Id y nombre | PC-01 Calidad de servicio con 50 usuarios |
| Versión probada | app:1.4.2, commit abc1234 |
| Entorno | dev |
| Fecha y ejecutor | 2027-01-19 · a mano desde el puesto de administración (ops) |
| Procedimiento | k6 run --summary-export=... tests/k6/carga.js con API_URL=https://api.dev.lab, APP_VERSION=1.4.2 |
| Resultado esperado | p95 < 500 ms, errores < 1 % |
| Resultado obtenido | p95 = 412 ms, errores 0,2 % |
| Veredicto | OK |
| Evidencias | k6-resumen.json, grafana-carga-50.png |
La ficha es corta a propósito. Lo que la hace verificable es que el procedimiento permite repetir la prueba exactamente y que las evidencias están donde dice que están. La versión es la etiqueta de la imagen que corre en app01, y el commit, el del repositorio servicio desplegado, que identifica el código exacto aunque una etiqueta se reutilice.
La carpeta de evidencias. Cada prueba deja sus ficheros en un sitio fijo del repositorio del servicio, una carpeta por versión:
tests/
├── functional/ # pytest
├── k6/ # carga.js, estres.js
├── token.sh
├── zap-rules.conf
└── evidence/
└── 1.4.2/
├── INFORME.md # informe de pruebas de la versión
├── casos/ # PF-01, PC-01, PR-01, PE-01 y PS-01
├── pytest.xml
├── k6-resumen.json, k6-estres.json
├── grafana-carga-10.png, grafana-carga-50.png, grafana-carga-100.png
├── rendimiento.md, estres.md, grafana-estres.png
├── zap/zap.html, zap/zap.json
├── trivy.json
└── seguridad.md
Las evidencias se guardan en el repositorio o en un almacén con la misma retención que las versiones: mientras la versión 1.4.2 pueda desplegarse, sus pruebas siguen disponibles. Si el repositorio Git empieza a pesar, lo grande se mueve a un bucket con la misma política y el INFORME.md enlaza a la ruta externa.
El informe de versión. El informe de pruebas de una versión es un documento de una página que enlaza todos sus casos y da un veredicto único:
# Informe de pruebas · app 1.4.2 · commit abc1234 · dev · 2027-01-26
| Bloque | Casos | OK | KO | Veredicto |
|---|---|---|---|---|
| Funcionales (pytest, 8 casos) | PF-01 | 8 | 0 | OK |
| Calidad de servicio (k6, 50 VU) | PC-01 | 1 | 0 | OK |
| Rendimiento (rampas 10, 50, 100) | PR-01 | informativo | | SLO se incumple a partir de 85 VU |
| Estrés | PE-01 | informativo | | rotura a 160 VU (timeouts del proxy); recuperación 40 s |
| Seguridad (ZAP y trivy) | PS-01 | 1 | 1 | KO: CVE-2026-XXXXX HIGH en libssl, corregida en 3.5.2-1 |
Veredicto de la versión: NO APTA para producción hasta reconstruir con base actualizada (PS-01).
Firmado: ops · Revisado: ...
El veredicto es único a propósito: quien decide el paso a producción no tiene que interpretar cinco tablas, y un solo bloque en KO basta para parar la versión.
La puerta de pruebas en el pipeline de Jenkins¶
Qué es una puerta. Como se adelantó con Trivy, una puerta es una etapa del pipeline que decide si la versión sigue adelante: si alguna de sus pruebas falla, el pipeline se para y la versión no llega al entorno siguiente. Así nadie tiene que acordarse de mirar los resultados antes de desplegar.
Dónde va en el curso. Hoy no hay ningún Jenkins donde lanzar nada, así que lo que se escribe aquí es la etapa que irá en el pipeline justo detrás del despliegue en pre: solo da la versión por buena si todos los códigos de salida son cero, y archiva las evidencias con el número de build (cada ejecución del pipeline lleva el suyo). En pre no hay proxy ni certificado, así que la API se prueba en el 28080 de app01-pre. Es trabajo preparado, no ejecutado: su comprobación de hoy es que cada sh sea exactamente el comando que ya se ha lanzado a mano, con el mismo código de salida. El pipeline de jenkins01 lo monta la asignatura de despliegue en febrero, en su unidad de integración continua (UT6), y su etapa Deploy, que lleva cada versión a app01-pre, es del 26 de febrero.
stage('Pruebas en pre') {
environment {
API_URL = 'http://10.20.2.10:28080' // app01-pre, sin proxy ni certificado
API_TEST_PASS = credentials('api-test-pass')
APP_VERSION = '1.4.2' // en febrero, la versión que despliega el pipeline
}
steps {
sh 'python3 -m pytest tests/functional -v --junitxml=reports/pytest.xml'
sh 'docker run --rm -v "$WORKSPACE:/zap/wrk:rw" ghcr.io/zaproxy/zaproxy:stable zap-baseline.py -t "$API_URL" -r reports/zap.html -J reports/zap.json -c tests/zap-rules.conf -I'
sh 'API_TOKEN=$(tests/token.sh) k6 run --summary-export=reports/k6-resumen.json tests/k6/carga.js'
}
post {
always {
junit 'reports/*.xml'
archiveArtifacts artifacts: 'reports/**', fingerprint: true
}
}
}
Qué hace cada pieza. La puerta la hacen los propios códigos de salida: si falla una prueba, pytest sale con un código distinto de cero, el sh falla, la etapa se para ahí (ZAP y k6 ya no se lanzan) y la ejecución queda en rojo, que es lo que debe pasar con una versión que no cumple. El paso junit, en post { always }, se ejecuta igual aunque la etapa haya fallado: recoge el informe, pinta qué casos han fallado y la tendencia de casos OK/KO entre builds. archiveArtifacts guarda las evidencias unidas al número de build, que es lo que después se copia a tests/evidence/<versión>/ (o se enlaza desde el informe, si se prefiere que las evidencias vivan en Jenkins). Si alguna vez interesara que una prueba roja dejara la ejecución en amarillo (UNSTABLE) y siguiera, haría falta un || true detrás del pytest para que el sh no fallara, y la opción skipStagesAfterUnstable() para que las etapas siguientes no se ejecutaran; en una puerta de paso a producción, el rojo es lo correcto. ZAP monta el espacio de trabajo entero para leer tests/zap-rules.conf y escribir en reports/, y lleva -I para que solo los FAIL de ese fichero paren la versión. El estrés no está en la etapa: se lanza a mano con otro job parametrizado, porque tumbar el entorno cada vez que alguien hace un commit no es aceptable para el resto del equipo que también lo usa.
El orden importa
Trivy no está en esta etapa porque va antes: entre construir la imagen y subirla al registry, en una etapa de escaneo propia. Es la prueba más barata (segundos) y la que más veces falla, y no tiene sentido desplegar ni gastar minutos de carga en una imagen que no va a pasar a producción por una CVE crítica.
Seguimiento periódico¶
Lo que una alarma no dice. Los indicadores se revisan en un ciclo fijo, no solo cuando salta una alarma. Una alarma avisa de que algo ha pasado el umbral; la revisión detecta lo que se acerca al umbral despacio, lo que el umbral no cubre y los umbrales que están mal puestos, que son cosas que una alarma nunca dice. Es lo que pide el criterio f del RA2: revisar los indicadores frente a sus umbrales con una periodicidad y dejar constancia.
flowchart TD
D["<b>Diario · 10 min</b><br><small>disponibilidad, errores, latencia, alarmas activas</small>"]:::act
S["<b>Semanal · 45 min</b><br><small>tendencias de capacidad, presupuesto de error,<br>alarmas repetidas</small>"]:::act
M["<b>Mensual · 2 h</b><br><small>cumplimiento de SLO, umbrales, certificados, versiones</small>"]:::act
R["<b>Registro</b><br><small>fecha, quién, hallazgos, acciones</small>"]:::dato
A{"<b>¿Acción?</b>"}:::dato
U["<b>Pull request en alerting</b>"]:::ok
T["<b>Tarea en el gestor</b>"]:::ok
D --> S --> M --> R --> A
A -- ajustar umbral --> U --> D
A -- ampliar capacidad --> T --> D
A -- nada --> D
classDef act fill:#ea580c22,stroke:#ea580c,stroke-width:1.5px
classDef pieza fill:#64748b22,stroke:#64748b,stroke-width:1.5px
classDef dato fill:#2563eb22,stroke:#2563eb,stroke-width:1.5px
classDef infra fill:#a1a1aa14,stroke:#a1a1aa,stroke-width:1.5px
classDef ok fill:#16a34a22,stroke:#16a34a,stroke-width:1.5px
classDef riesgo fill:#dc262622,stroke:#dc2626,stroke-width:1.5px
El ciclo se cierra siempre en el registro: una revisión que no deja rastro escrito no se puede auditar ni mejorar.
Tres ritmos, tres preguntas. Cada revisión mira a una distancia distinta:
- Diario: panel de estado (disponibilidad, errores, latencia, alarmas activas). Es un vistazo de 10 minutos al empezar el día y su valor es detectar lo que pasó de noche sin llegar a alarma: un pico de errores a las 3:00 que duró 4 minutos y no llegó a los 5 del
for. - Semanal: tendencias de capacidad (disco, memoria, conexiones), presupuesto de error consumido en lo que va de mes, alarmas repetidas. Una alarma que ha saltado cuatro veces en la semana y se ha cerrado sola las cuatro es o un umbral mal puesto o un problema real intermitente; en ambos casos hay que decidir algo.
- Mensual: cumplimiento de SLO con el dato cerrado del mes, revisión de cada umbral contra la distribución real de ese mes (¿sigue teniendo sentido el umbral del 70 % de CPU tras el crecimiento de tráfico?), caducidad de certificados (
openssl x509 -noout -enddate -in <certificado>sobre los que firmó la CA del curso), y versiones desplegadas frente a las publicadas.
El registro. Cada revisión deja un registro breve. La plantilla de las semanales va en revisiones/AAAA-Wnn.md del repositorio operacion, con el año y el número de semana ISO en el nombre (2027-W03, por ejemplo):
# Revisión semanal · 2027-W03 · 2027-01-22 · ops
## Estado
- Disponibilidad 30 d: 99,71 % (SLO 99,5 %). Presupuesto consumido: 58 %, casi todo en la incidencia del día 19; queda un 42 %.
- p95 semana: 340 ms (aviso 300). Subida desde 280 la semana anterior; coincide con despliegue 1.4.2 el día 19.
- Disco /data: 71 % usado, predict_linear a 30 d: 84 %. Sin riesgo.
- Conexiones BD: pico 3 de 100 el martes 10:15.
## Alarmas de la semana
| Alarma | Veces | Duración media | Causa | ¿Acción? |
|---|---|---|---|---|
| AppHighErrorRate | 1 | 8 min | despliegue 1.4.2, rollback | no (funcionó el runbook) |
| AppHighLatency | 4 | 12 min | picos de 10:00 | sí: for 10m → 15m |
## Hallazgos
1. AppHighLatency salta cada mañana con la carga de las 10:00 y se cierra sola. Ruido.
2. El presupuesto de error va rápido por la incidencia del día 19; no hay margen para otro despliegue fallido este mes.
## Acciones
- [ ] PR ops/alerting#42: AppHighLatency for 10m → 15m. Responsable ops. Antes del 26.
- [ ] Congelar despliegues no urgentes hasta el cierre del mes (queda menos del 50 % del presupuesto). Comunicado a dev.
La parte de acciones con responsable y fecha es la que distingue una revisión de una lectura del panel. Y conviene fijarse en la primera acción: el ajuste de umbral es un pull request sobre el repositorio alerting, revisado por otra persona antes de aceptarse, con lo que el histórico de por qué un umbral vale lo que vale queda en Git y no en la memoria de quien lo cambió.
De dónde salen las cifras. Las del apartado Estado salen de consultas a la API de Prometheus. Una función de shell de una línea basta para pedirlas desde mon01, donde Prometheus escucha en localhost:
q() { curl -sG http://localhost:29090/api/v1/query --data-urlencode "query=$1" | jq -r '.data.result[0].value[1] // "n/a"'; }
q 'quantile_over_time(0.95, app:latency_p95:5m[7d])' # p95 de la semana
q 'sum(changes(ALERTS_FOR_STATE[7d]))' # alarmas disparadas en 7 días
La primera da el valor que el p95 de cinco minutos no superó el 95 % del tiempo de la semana. La segunda usa ALERTS_FOR_STATE, una serie interna de Prometheus que existe mientras una alerta está en pending o firing y vale la hora en que se activó: contar sus cambios es una forma barata de estimar cuántas veces ha saltado algo, pero solo aproximada, porque la primera activación de cada alerta no cuenta como cambio y sí cuentan las que se quedaron en pending. La cuenta exacta, con fechas y duraciones, está en las incidencias de ops/incidencias: el receptor de incidencias abre una por cada alerta que pasa a firing y la cierra al resolverse, y de ahí sale la tabla de alarmas de la plantilla. Los informes automáticos (el PDF programado de Grafana, que la edición libre no trae, y un script que cada lunes exporta los KPI a Markdown y los confirma en Git) están en Para ampliar; en el laboratorio las capturas de la A4.5 y la A4.6 se hacen a mano, con el rango de tiempo visible.
A4.7 Documentación de pruebas y seguimiento (sesión 26)¶
Objetivo Al terminar, las pruebas de las A4.4 a A4.6 son evidencia que otro puede verificar: cinco fichas de caso con sus evidencias, una por tipo de prueba, y el informe de la versión con un veredicto único. La puerta de pruebas queda escrita para el pipeline y comprobada a mano con un fallo provocado, y el ciclo de revisión de los indicadores, diseñado y estrenado con una revisión semanal que termina en un pull request de ajuste de umbral. Documentar las pruebas y montar el ciclo comparten sesión porque se complementan: lo primero deja las evidencias en su sitio, lo segundo decide quién las mira y cuándo.
Antes de empezar
- Las evidencias de la A4.4 a la A4.6 en
tests/evidence/1.4.2/del clon deservicio, yoperacion, en el puesto de administración. ci/Jenkinsfile.pruebasyci/README.mdde la A4.4.- El histórico de Prometheus (las pruebas de las sesiones anteriores cuentan como tráfico), la shell de mon01 del túnel, con
jq, y las incidencias deops/incidenciasen la Gitea. - Lo explicado al principio de la sesión: la ficha de caso y el informe de versión, la puerta de pruebas en Jenkins y el ciclo de revisión.
Pasos
La hoja tiene dos mitades. En la primera (pasos 1 a 3) conviertes la carpeta de evidencias en documentación: primero las fichas de cada prueba, después el informe que las resume con un veredicto y al final la puerta que repetirá ese veredicto en cada versión. En la segunda (pasos 4 a 6) montas el seguimiento: el diseño del ciclo, una revisión real con los datos de las dos últimas semanas y la acción que sale de ella. Cada paso usa lo que deja el anterior.
-
Escribe las fichas de caso. Rellena en
tests/evidence/1.4.2/casos/la ficha de caso del apartado para cinco pruebas, una por tipo: PF-01 (funcionales), PC-01 (calidad de servicio: el escalón de 50 usuarios), PR-01 (rendimiento: las tres rampas), PE-01 (estrés) y PS-01 (seguridad), con el comando exacto en Procedimiento. La ficha es lo que hace repetible una prueba: alguien que no estuvo tiene que poder lanzarla igual y encontrar sus evidencias donde dice. -
Escribe el informe y su veredicto. Escribe
tests/evidence/1.4.2/INFORME.mdcon la tabla por bloque del apartado y el veredicto único de la versión, firmado. Es lo que lee quien decide si la versión pasa a producción, y lo que se comparará con el de la versión siguiente. Un hallazgo en FAIL de ZAP o de Trivy hace la versión NO APTA aunque el resto pase. -
Prepara la puerta y pruébala con un fallo. Añade a
ci/Jenkinsfile.pruebasla etapaPruebas en predel apartado, y aci/README.mdlo pendiente para febrero: la credencial, pytest y k6 en el agente, quién lanza la etapa y la opciónskipStagesAfterUnstable(). Como Jenkins todavía no existe, comprueba la lógica de la puerta a mano: con un umbral imposible encarga.js, encadena con&&enci/puerta-pruebas.shpytest, una rampa corta de k6 y unechode que la versión pasa, y guarda la salida enci/puerta-fallo.txt. La cadena tiene que pararse en k6 con código 99, antes delecho, que es lo mismo que hará Jenkins con una versión que no cumpla el SLO. Deshaz el umbral.Pista:
p(95)<1es imposible y--vus 10 --duration 1mbasta como rampa. Exporta antes las variables de la A4.4, o la cadena se parará en pytest. -
Diseña el ciclo de revisión. Escribe
revisiones/README.mdenoperacioncon el ciclo de Seguimiento periódico adaptado a tu servicio: las tres revisiones con su duración, tus indicadores y tus alarmas en lugar de los ejemplos, y quién hace cada una. Tres líneas por revisión bastan: es el compromiso de quién mira qué y cada cuánto, sin el que los indicadores solo se miran cuando ya ha saltado algo. -
Haz la revisión semanal. Revisa las dos últimas semanas en
revisiones/AAAA-Wnn.mdcon la plantilla del apartado: las cifras de Estado, con la funciónqen mon01, y las alarmas, de las incidencias que el receptor ha ido abriendo enops/incidencias. Cada hallazgo acaba en una acción con responsable y fecha o en un "no procede" razonado: una revisión sin acciones es solo una lectura del panel.Pista: el pico semanal de conexiones es una subconsulta:
max_over_time(sum(pg_stat_activity_count)[7d:5m]). -
Propón el ajuste de umbral. Elige un umbral que los datos de la revisión digan que está mal y propón el cambio en
alertingcon un pull request desde una rama, con la regla validada y las cifras que lo justifican en el mensaje del commit. Así el porqué de cada umbral queda en el historial de Git. El pull request se queda abierto hasta que alguien lo revise.
Comprobación Cinco fichas, una por tipo de prueba, cuyas evidencias existen en la carpeta; INFORME.md con veredicto y con todas las pruebas en su tabla; ci/Jenkinsfile.pruebas y ci/README.md completos, y ci/puerta-fallo.txt con la cadena detenida en k6; revisiones/README.md y un registro semanal con acciones; el pull request de alerting con el umbral ajustado y su justificación.
Para practicar Empuja a la Gitea del laboratorio tests/evidence/1.4.2/ y ci/ en servicio, revisiones/ en operacion, y en alerting, la rama con su pull request: es el grueso del dossier de la práctica evaluable.
Sesión 27 · Práctica evaluable¶
Las hojas de la unidad han ido dejando cada pieza del dossier de operación en su repositorio: las fichas, los indicadores, el catálogo y las revisiones en operacion, las reglas en alerting, y las pruebas con sus evidencias en servicio. Lo que falta es que alguien de fuera pueda recorrerlo sin saber dónde está cada cosa, y comprobar que no queda ningún hueco. La sesión se dedica a eso: el arranque es para aclarar dudas del enunciado y el resto, para trabajar sobre lo ya hecho. No hay teoría nueva: lo que haga falta está en los apartados de las sesiones anteriores.
Práctica evaluable UT4 (sesión 27, 28 de enero de 2027). Entrega el dossier de operación del servicio: fichas de métricas, tabla de indicadores con fórmulas y umbrales, catálogo de alarmas con runbooks, informe de pruebas de la versión con casos y evidencias, y el registro de la revisión periódica. No se pide nada que no salga de las hojas: la sesión es para revisar, completar lo que falte y escribir en la raíz de operacion un README que enlace cada parte, también las que viven en servicio y alerting. Ese README es la primera página que abre quien evalúa el dossier o quien hereda el servicio: recorre la lista de entregables en orden y abre cada enlace como lo haría esa persona, porque lo que no se encuentra desde ahí, a efectos prácticos, no está.
Entregables:
-
operacion/metricas.md: mínimo diez fichas, tres o más por categoría (de la A4.1, pasos 3 y 4). -
operacion/indicadores.md,operacion/grafana/kpi.jsonyalerting/prometheus/rules.yml: nueve indicadores con fórmula, categoría, umbrales y justificación, con las recording rules cargadas en el Prometheus de mon01 (de la A4.2, pasos 1 a 5). -
operacion/alarmas/: catálogo con todas las alarmas y cinco runbooks completos, enlazados desde la anotaciónrunbookdealerting(de la A4.3, pasos 2 a 4). -
servicio/tests/: la suite funcional, los scripts de k6 y el fichero de reglas de ZAP (de las A4.4, A4.5 y A4.6), ytests/evidence/<versión>/con las evidencias de cada prueba, las cinco fichas de caso y elINFORME.md(de la A4.7, pasos 1 y 2). -
servicio/ci/:Jenkinsfile.pruebascon la etapa de pruebas,README.mdcon lo que queda pendiente de crear en Jenkins ypuerta-fallo.txt(de la A4.4, paso 3, y la A4.7, paso 3). -
operacion/revisiones/: diseño del ciclo y un registro de revisión semanal con acciones, y el pull request dealertingcon el umbral ajustado y su justificación (de la A4.7, pasos 4 a 6).
| Criterio | RA2 | Peso |
|---|---|---|
| Métricas documentadas con contador, referencia y las tres categorías (A4.1) | a | 15 % |
| Indicadores implementados con fórmulas y umbrales por tipo (A4.2) | b | 20 % |
| Catálogo de alarmas con origen, fallo, impacto y pasos de resolución (A4.3) | c | 15 % |
| Pruebas funcionales, de calidad de servicio, rendimiento, seguridad y estrés ejecutadas (A4.4 a A4.6) | d | 25 % |
| Pruebas documentadas con evidencias, indicadores y registros (A4.7) | e | 15 % |
| Indicadores monitorizados y revisados periódicamente frente a umbrales (A4.7) | f | 10 % |
Este dossier es el punto de partida de la unidad de actualización y gestión de vulnerabilidades (UT7), en febrero: cuando se actualicen la aplicación y PostgreSQL, la verificación consistirá en volver a pasar exactamente estas pruebas y comparar el informe de la versión nueva con el de la 1.4.2.
Errores frecuentes en el laboratorio¶
histogram_quantile devuelve NaN o una línea plana. Casi siempre falta le en el sum by(), o el rate está sobre app_http_request_duration_seconds_count en vez de _bucket. Si devuelve siempre 5, el límite del último cubo finito de api/metrics.py, es que más del 5 % de las peticiones tarda más que eso: o la API va muy lenta, o los cubos no cubren el rango en que se mueve y hay que revisarlos.
La disponibilidad a 30 días tarda 20 s o da timeout. rate(app_http_requests_total[30d]) sobre la métrica cruda, que tiene una serie por cada método, ruta y código. Se graban las tasas a 5 min como reglas y se calcula la mensual con sum_over_time sobre ellas; el timeout de la fuente de datos de Grafana solo se sube si no hay más remedio.
La tasa de errores o la disponibilidad salen vacías ("No data") con la API respondiendo. La API no ha devuelto ningún 5xx desde que arrancó, así que la serie de errores todavía no existe y el cociente se queda sin numerador. Se completa el numerador con or y la tasa total multiplicada por 0, como en la tasa de errores de los nueve indicadores.
Una regla grabada nueva no enseña nada del pasado. Una recording rule empieza a guardar su serie cuando se carga, y Prometheus no la rellena hacia atrás: el día que se crea app:cpu:ratio5m, su gráfica de ayer está vacía aunque la CPU sí se midiera. Para mirar el histórico de una regla recién creada se usa la expresión completa, que sí se calcula sobre los datos crudos guardados.
pytest falla en todos los casos con SSLError. requests instalado con pip no lee el almacén de certificados del sistema, sino su propio paquete certifi, que no conoce la CA del curso: el fixture que pide el token muere antes del primer caso. Se exporta REQUESTS_CA_BUNDLE con la ruta de ca.crt, y lo mismo en Jenkins; el equivalente para newman es NODE_EXTRA_CA_CERTS.
pg_up o node_filesystem_avail_bytes{mountpoint="/data"} no devuelven nada en Prometheus. Falta el target de db01 en prometheus.yml, que se añade en la A3.2, o su regla en OPNsense, la fila 12. Sin ellos se quedan sin datos tres de los nueve indicadores y las alarmas PgDown, DbDiskLow y DiskWillFillIn7d.
La saturación de CPU no devuelve nada o la memoria da +Inf. El servicio no tiene cpus o mem_limit en /opt/servicio/compose.yml. Sin límite de CPU, cAdvisor no publica container_spec_cpu_quota y la división se queda sin serie con la que emparejar; sin límite de memoria, publica el límite como 0 y la división da +Inf. No es un fallo de la fórmula: es que el contenedor no tiene límites y por tanto no puede saturarse "respecto a" nada. Hay que poner límites; hacen falta igualmente para el estrés. Si los límites están y la CPU sigue sin devolver nada, falta el sum by (service) en los dos lados de la división: la etiqueta cpu del consumo no está en la cuota.
La saturación de CPU se queda lejos del 100 % aunque la API no dé más de sí. El límite cpus es mayor que 1 y la API corre con un solo worker de gunicorn, que no puede usar más de un núcleo. Con cpus de 1 o menos, el indicador llega al 100 % cuando la CPU es de verdad el límite.
k6 termina con código 99 pero todos los check están en verde. Los check y los thresholds son independientes. El umbral que falla suele ser http_req_duration, que incluye el tiempo de las peticiones con error, o http_req_failed por redirecciones 3xx que k6 no sigue si redirects: 0. El resumen final marca con una cruz el umbral incumplido; conviene leerlo antes de tocar el script.
jq devuelve null al leer el resumen de k6. El fichero de --summary-export no tiene la forma de los datos de handleSummary() que aparecen en buena parte de la documentación de k6: cada métrica lleva sus valores directamente, sin values (.metrics.http_req_duration["p(95)"]), la tasa de http_req_failed está en .value y cada umbral vale true cuando ha fallado.
k6 alcanza 100 VU pero el throughput no pasa de 30 peticiones/s y la API está al 20 % de CPU. El cuello no es la CPU. En el servicio del curso, lo primero que hay que sospechar es la cola del único worker de gunicorn: atiende las peticiones de una en una y, mientras espera a PostgreSQL, la CPU está parada; un p95 de k6 muy por encima del de app:latency_p95:5m lo confirma. Si no es eso, el cuello está en el camino o en el generador: un worker_connections bajo en el nginx de web01, o la propia máquina que lanza k6. El puesto de administración tiene 1 GB, y si top muestra la CPU o la memoria de k6 al límite mientras corre la prueba, lo que se está midiendo es el generador y no la API.
ZAP no escribe el informe. Falta el volumen en /zap/wrk o la carpeta no tiene permisos de escritura para el usuario zap (uid 1000) del contenedor. chmod 777 en la carpeta es aceptable en el laboratorio; en la empresa se crea la carpeta con el uid correcto.
ZAP baseline devuelve 3 al instante. No llega a la URL. Casi siempre es el nombre: api.dev.lab se resuelve en mon01 pero no dentro del contenedor, que no usa el DNS del host si no lleva --network host. Se prueba con --network host o con --add-host api.dev.lab:10.10.1.10. El certificado de la CA del curso no es el problema: ZAP no valida el certificado del servidor.
ZAP devuelve 2 y para la cadena aunque no haya ningún FAIL. Sin -I, cualquier aviso (WARN) da código 2, y un pipeline lo trata como un fallo. Se añade -I y se deja en FAIL, en zap-rules.conf, solo lo que de verdad debe parar la versión.
trivy image app:1.4.2 en el puesto de administración no encuentra la imagen. La imagen vive en el Docker de app01 y el puesto no tiene Docker ni registry hasta el 19 de febrero. Se lleva como fichero con ssh ops@10.10.2.10 docker save app:1.4.2 > /tmp/app.tar y se escanea con trivy image --input /tmp/app.tar.
Trivy tarda minutos o falla al descargar la base de datos. La primera ejecución descarga la base de vulnerabilidades por la salida a Internet de la red de gestión, y tarda. Conviene conservar ~/.cache/trivy en la máquina desde la que se lanza (y, cuando exista el pipeline, en el agente de Jenkins) o levantar un trivy server y usar --server.
predict_linear da un valor absurdo la semana después de ampliar el disco. La recta se ajusta sobre 7 días que incluyen el salto de la ampliación, así que predice que el disco se vacía. No es un fallo: es la ventana. Durante esa semana conviene mirar el indicador de porcentaje.
La suite de pytest pasa a mano y falla en Jenkins con 401. La credencial api-test-pass guarda la contraseña de otro entorno, o alguien ha sustituido el fixture por un token guardado, que deja de valer en cuanto cambia API_SECRET, el secreto con el que la API firma los tokens (y que no tiene por qué ser el mismo en cada entorno). Las pruebas funcionales obtienen su propio token en el fixture de sesión con el usuario de prueba, como en el apartado, y no reutilizan uno guardado.
El pipeline se pone en amarillo (inestable) pero sigue con las etapas siguientes. junit marca el build como UNSTABLE si hay casos KO, y por defecto las etapas siguientes se ejecutan con UNSTABLE. Se añade skipStagesAfterUnstable() en options o se comprueba currentBuild.result antes de la etapa que no debe correr.
Los enlaces para ampliar y los apartados que van más allá de lo que se hace en clase están en Para ampliar.
UT5 · Explotación de logs, accesos y rendimiento¶
Hasta aquí todo el trabajo se ha hecho sobre el laboratorio: la pila de observabilidad de mon01, las alarmas, la monitorización de seguridad, los KPI y las pruebas de carga, la actualización y la gestión de vulnerabilidades, y la terminación segura del entorno de pre. Las alarmas avisan de lo que alguien previó; esta unidad trata de lo que solo se ve mirando el sistema cada día. El caso típico: un jueves por la tarde la API empieza a devolver algún 502 y, como funciona casi siempre, nadie mira nada. El lunes resulta que el contenedor lleva cuatro días reiniciándose cada veinte minutos por falta de memoria, que una IP de fuera ha probado ochenta usuarios distintos por SSH y que el disco está al 94 % porque nadie configuró la rotación de los logs. Nada era grave el jueves; el lunes son tres incidencias a la vez. Los cuatro bloques de la unidad son las cuatro miradas que lo habrían evitado, una por criterio de evaluación: los logs, los accesos, los fallos y reinicios, y el rendimiento. Esta unidad y la siguiente, la de copias de seguridad, se cursan en la empresa, entre el 19 de abril y el 9 de junio de 2027, sobre un sistema real que asigne el tutor de empresa. No hay sesiones numeradas ni laboratorio compartido: hay cuatro actividades (A5.1 a A5.4), una actividad de cierre y una ficha de evidencias que firma el tutor. Este documento es la guía para hacer ese trabajo con criterio sin nadie al lado, y al mismo tiempo la lista de lo que hay que traer de vuelta.
Una regla desde el principio: todo dato de la empresa que aparezca en las evidencias se anonimiza. Nombres de host, IPs públicas, nombres de usuario, dominios, rutas con nombre de cliente. No es un formalismo: un log de accesos con IP y usuario contiene datos personales, como se vio en qué hay que conservar por obligación legal, y la evidencia sale de la empresa. Se sustituyen por equivalentes (web01, 203.0.113.45, usuario_a, empresa.example) antes de pegar nada en el informe. Las redes 192.0.2.0/24, 198.51.100.0/24 y 203.0.113.0/24 están reservadas por la RFC 5737 justo para esto, para documentación, y el dominio example por la RFC 2606: como nunca se asignan a nadie, una evidencia anonimizada no señala por error a la máquina real de otro. Ante la duda de si algo se puede incluir, se pregunta al tutor antes de incluirlo, no después.
Cómo trabajar en la empresa¶
El método, no la herramienta. Cada empresa tiene sus herramientas: Loki y Grafana como en mon01, Elastic, Graylog, Datadog o ficheros en /var/log y un grep. Lo que se evalúa es el método: qué se busca, cómo se justifica y qué se hace con lo que se encuentra. Por eso cada apartado da primero el método y después los comandos para el caso más habitual (Linux con Docker, journald y nginx), con su equivalente en Loki cuando existe. Si en la empresa hay otra cosa, se adapta el comando y se anota en la evidencia cuál se ha usado.
Leer sí; cambiar, solo con permiso. El trabajo se hace sobre sistemas en producción o cerca de ella, que mantiene el equipo de la empresa y de los que ese equipo responde. Todo lo que sea lectura (leer logs, consultar métricas, listar IPs bloqueadas) queda cubierto por la autorización general del tutor, aunque fail2ban-client, nft o el journal del kernel piden sudo incluso para consultar, y ese acceso se pide aparte. Todo lo que cambie algo (activar una jail, que es una regla de bloqueo de fail2ban; cambiar un límite de memoria; reiniciar un servicio) se acuerda antes con el tutor, se hace en el horario acordado y se apunta. La razón es práctica: cuando algo falla, lo primero que se pregunta es qué ha cambiado, y un cambio que nadie conoce hace perder horas buscando la causa donde no está. Por eso una evidencia de un cambio que el tutor no conocía no vale, aunque el resultado sea correcto.
La rutina que se construye. Las cuatro actividades no son independientes: juntas forman una rutina de revisión, diaria y semanal, que al final de la estancia se entrega escrita como procedimiento (actividad de cierre). Este es su esqueleto:
flowchart LR
D["<b>Diario · 10 min</b>"]:::act
D1["<b>Errores de las últimas 24 h</b>"]:::pieza
D2["<b>Accesos fallidos y baneos</b>"]:::pieza
D3["<b>Reinicios y OOM</b>"]:::pieza
D4["<b>Rendimiento frente a la línea base</b>"]:::pieza
S["<b>Semanal · 45 min</b>"]:::act
S1["<b>Tendencias de 7 días</b>"]:::pieza
S2["<b>Incidencias abiertas y cerradas</b>"]:::pieza
S3["<b>Espacio en disco y rotación</b>"]:::pieza
S4["<b>Revisar ignoreip y listas de baneo</b>"]:::pieza
I(["<b>Incidencia si procede</b>"]):::ok
R(["<b>Informe semanal al tutor</b>"]):::ok
D --> D1 & D2 & D3 & D4
S --> S1 & S2 & S3 & S4
D1 --> I
D2 --> I
D3 --> I
S1 --> R
classDef act fill:#ea580c22,stroke:#ea580c,stroke-width:1.5px
classDef pieza fill:#64748b22,stroke:#64748b,stroke-width:1.5px
classDef dato fill:#2563eb22,stroke:#2563eb,stroke-width:1.5px
classDef infra fill:#a1a1aa14,stroke:#a1a1aa,stroke-width:1.5px
classDef ok fill:#16a34a22,stroke:#16a34a,stroke-width:1.5px
classDef riesgo fill:#dc262622,stroke:#dc2626,stroke-width:1.5px
La rutina diaria son diez minutos y cuatro miradas. Lo que la hace útil es hacerla siempre, no hacerla bien un día.
Cada mirada diaria es uno de los bloques de la unidad y responde a una pregunta: qué ha fallado (los logs, bloque 1), quién ha intentado entrar (los accesos, bloque 2), qué se ha reiniciado o se ha quedado sin memoria (bloque 3) y si el sistema va como siempre (el rendimiento frente a la línea base, bloque 4). Lo que encuentran, si lo merece, se abre como incidencia. Las miradas semanales cubren lo que no cambia de un día para otro y solo se ve con perspectiva: la tendencia de siete días, que va al informe semanal al tutor, el estado de las incidencias, el disco con su rotación de logs y las listas de bloqueo, que se quedan viejas si nadie las revisa.
Conviene guardar desde el primer día lo que se ejecuta: un fichero de texto con fecha, comando y una línea de resultado. Ahorra la mitad del trabajo del cierre, porque el procedimiento final tiene que llevar los comandos que se usaron de verdad, no los que se recuerdan.
Dónde se guarda el trabajo. El Gitea del laboratorio (gitea.lab) es una máquina del centro y desde la red de la empresa no se alcanza, así que el primer día se crea un repositorio local con git init en el equipo propio, con una carpeta ut5/ dentro, y ahí van las evidencias según se producen; es lo mismo que se hizo con el repositorio alerting, que vivió en local hasta que hubo un Gitea al que empujar. Al volver al centro se añade el remoto (git remote add origin y git push -u) y el conjunto se entrega por Aules, como el resto de prácticas evaluables. Regla que no admite excepción: nada entra en ese repositorio sin anonimizar antes, porque una vez en el historial de git borrarlo ya no es borrar un fichero.
La UT4 de Despliegue va en las mismas semanas
La UT4 de Despliegue, nube pública, también se cursa en la empresa entre el 19 de abril y el 9 de junio. Muchas veces los sistemas serán los mismos: la plataforma donde allí se despliega es donde aquí se revisan logs, accesos y rendimiento. Conviene acordar con el tutor un único sistema para las dos asignaturas y aprovechar las evidencias que sirvan a ambas: una captura de Grafana anonimizada vale en las dos.
Bloque 1 · Revisión de logs (CE 3a)¶
Las alarmas vigilan lo que alguien previó: una cadena conocida, un umbral. Lo nuevo (el error que nadie había visto, el aviso que anuncia el fallo de la semana siguiente, el servicio que ha dejado de escribir) solo aparece si alguien lee los logs, y leerlos sin método es imposible, porque un servicio corriente escribe miles de líneas al día. Este bloque monta ese método en el orden en que se usa. Primero, por dónde llega cada log al disco, que es lo que dice con qué se lee y quién lo borra; después, qué se busca en él; luego, los comandos que convierten miles de líneas en un recuento por tipo que se lee en diez minutos, y un script que lo hace solo; a continuación, la retención, porque la revisión de mañana necesita que los logs de hoy sigan ahí sin llenar el disco; y al final, cómo se cuenta lo encontrado para que otra persona pueda actuar. Va primero porque es la base de los otros tres bloques: los accesos, los fallos y la lentitud también se leen, en parte, en los logs. La hoja A5.1 aplica todo esto durante cinco días laborables a un servicio de la empresa.
Revisar los archivos de registro (CE 3a)¶
Por qué cada día y no cuando algo falla. Los logs se leen de forma periódica y sistemática. La diferencia entre un administrador que "mira los logs cuando pasa algo" y uno que los revisa cada mañana es que el segundo detecta el problema una semana antes, cuando todavía es una línea rara y no una caída. Además, la revisión alimenta a las alarmas: un error nuevo que aparece tres mañanas seguidas es candidato a regla en el ruler de Loki, porque las cadenas que se vigilan no se inventan, salen de lo que la aplicación escribe (métricas sobre logs).
Dónde están en la empresa. En el laboratorio se montó Loki para tener en mon01 los logs de app01. En la empresa el sitio lo indica el tutor: puede ser una plataforma central como aquella o los propios hosts, y en ese caso hay que saber quién escribe cada log, que es lo que explica el apartado siguiente.
Los tres caminos de un log¶
Cada log llega al disco por un camino. En un host Linux con contenedores, lo que escribe un programa acaba en uno de tres sitios según quién lo recoja, y cada sitio tiene su forma de leerse y su forma de borrarse:
| Quién escribe | Quién lo recoge y dónde lo deja | Cómo se lee | Quién lo borra |
|---|---|---|---|
| Un contenedor, por stdout y stderr | El driver de logs de Docker; con json-file, un fichero por contenedor en /var/lib/docker/containers/ |
docker logs o docker compose logs |
El propio driver, si tiene max-size y max-file |
| Un servicio de systemd, y el kernel | journald, el registro central de systemd, en /var/log/journal/ |
journalctl |
journald, según journald.conf |
| Un programa que escribe su propio fichero (nginx instalado en el host, una aplicación que escribe en un volumen) o rsyslog | El propio programa, en /var/log/ o en la ruta que tenga configurada |
less, grep, zcat |
logrotate, según /etc/logrotate.d/ |
rsyslog es el servicio clásico que reparte los mensajes del sistema en ficheros de /var/log (syslog, auth.log); en Debian 13 no se instala por defecto, y por eso esos ficheros pueden no existir. Una plataforma central como Loki no es un cuarto camino: es una copia que un agente saca de los otros tres y guarda en otra máquina, con su propia retención. La API del laboratorio, por ejemplo, va por el primero: escribe a stdout, Docker lo guarda en su fichero JSON y Promtail se lo pide a Docker para enviarlo a Loki (el driver de logs de Docker). nginx va por el primero si corre en un contenedor y por el tercero si está instalado como paquete del host.
Por qué importa saberlo. El camino decide tres cosas de la revisión: el comando con que se lee, el permiso que hace falta y quién se encarga de que no llene el disco. Los ficheros de /var/log y el journal se leen con los grupos adm o systemd-journal; docker logs necesita hablar con el demonio de Docker, y eso es el grupo docker, que equivale a ser root (por eso muchas empresas prefieren dar sudo para comandos concretos). Y cada camino tiene su propio mecanismo de retención, que se revisa en Retención: logrotate y journald.
El formato de la línea. Cada aplicación escribe a su manera: texto libre (ERROR db timeout), JSON con el nivel en minúsculas como la API del curso ("level":"error"), o el formato combinado de nginx, con la IP, la petición y el código HTTP en posiciones fijas. El nivel (level) es la gravedad que la aplicación da a cada línea, normalmente debug, info, warning, error y critical o fatal; el journal usa además las ocho prioridades de syslog, el estándar clásico de registro de Unix, de 0 (emerg) a 7 (debug). Antes de escribir ningún filtro se miran unas líneas del log, porque un grep ERROR sobre un log que escribe "level":"error" no encuentra nada, y parece que el servicio está limpio.
Qué buscar¶
Lo que se busca cabe en cuatro grupos, que van del pasado al futuro: lo que ya ha fallado, lo que ha fallado y se ha recuperado solo, lo que ha cambiado y lo que va a fallar.
- Errores. Niveles ERROR y FATAL, y excepciones no controladas: en Java o Python se reconocen por la traza de pila (varias líneas que empiezan por
at ...oFile "..."), en Go porpanic:seguido degoroutine. Una excepción no controlada que se repite cada pocos minutos es un bug que alguien tiene que mirar aunque el servicio siga respondiendo. - Salidas inesperadas. Reinicios del servicio,
timeout,connection refused,connection reset by peer,out of memory,too many open files,no space left on device, códigos HTTP 5xx en el proxy. Cada uno apunta a una capa distinta:connection refusedes que no había nadie escuchando (el backend estaba caído o reiniciando) ytimeout, que había alguien pero no respondió a tiempo. Los dos casos se distinguen también desde el proxy: nginx devuelve502en el primero y504en el segundo, así que el código del proxy ya dice cuál de los dos fue. - Cambios de volumen. El doble de líneas de lo habitual suele ser un bucle de reintentos o un escáner; el silencio total es peor, porque significa que el servicio no está escribiendo (colgado, disco lleno, o el agente de logs muerto). Loki lo mide con
count_over_timey Prometheus con la métricapromtail_sent_entries_totalo su equivalente en Alloy (Promtail y Alloy son los agentes que recogen los logs del host y los envían a Loki); sin nada de eso,wc -lsobre el fichero de hoy comparado con el de ayer. - Avisos que anuncian un fallo futuro.
deprecated, certificados que caducan (certificate will expire),disk usage above 85%, reconexiones a la base de datos, o las consultas lentas que PostgreSQL apunta comoduration: ... mssi tiene activadolog_min_duration_statement.
Los dos primeros grupos se cuentan por tipo, el tercero se compara con el día anterior y el cuarto se apunta aunque hoy no haya pasado nada; así se rellena cada fila del registro de la A5.1.
Comandos para la revisión diaria¶
Agrupar, no leer. La revisión no consiste en leer las líneas una a una, sino en contarlas: cuántos errores de cada tipo ha habido en las últimas 24 horas y cuántas líneas en total, para compararlo con el día anterior. Trescientas líneas del mismo error son un problema, no trescientos. Por eso todos los comandos de abajo tienen la misma forma: una ventana de tiempo, un filtro y un recuento.
La ventana de tiempo. Con Docker es el parámetro que más se olvida. docker logs app sin más vuelca todo lo que el contenedor ha escrito desde que existe, y en un contenedor con semanas de vida eso son cientos de miles de líneas.
# Errores de las últimas 24 h en un contenedor
docker logs --since 24h app 2>&1 | grep -E "ERROR|FATAL|Exception|panic:" | tail -50
# Cuántos por tipo (agrupa por la parte del mensaje tras el nivel)
docker logs --since 24h app 2>&1 | grep -oE "(ERROR|FATAL) [A-Za-z._]+" | sort | uniq -c | sort -rn | head
# Servicio de systemd: solo prioridad err o superior desde ayer
journalctl -u nginx --since yesterday -p err --no-pager
# Todo el host, prioridad crit o superior, con explicación de cada mensaje
journalctl -p crit -x --since "-24h"
# Ficheros clásicos: nginx, 5xx de ayer
zcat -f /var/log/nginx/access.log* | awk -v d="$(LC_ALL=C date -d '-1 day' +%d/%b/%Y)" '$4 ~ d && $9 ~ /^5/' | wc -l
Tres detalles de estos comandos que no se ven a simple vista. journalctl -p acepta el nombre o el número de la prioridad syslog, y filtra esa y todas las más graves: -p err equivale a -p 0..3. Docker escribe stdout y stderr por separado y docker logs los devuelve en los dos descriptores, de ahí el 2>&1 antes del grep; sin él, los errores que la aplicación escribe por stderr no pasan por el filtro. Y en el recuento de nginx, zcat -f lee también los ficheros ya rotados: logrotate rota de madrugada, y en la revisión de la mañana las líneas de ayer ya no están en access.log sino en access.log.1. LC_ALL=C hace que el mes salga en inglés (May), como lo escribe nginx.
Lo mismo en Loki. Si la empresa tiene Loki, las mismas búsquedas se hacen con LogQL, el lenguaje de consultas de Loki (selectores de stream y filtros de línea): el selector entre llaves elige el flujo de logs y lo que va detrás lo filtra o lo cuenta. La ventaja no es la sintaxis, es que la consulta se guarda en un panel de Grafana y no depende de recordar el comando. Los ejemplos usan las etiquetas del laboratorio (service y level); en la empresa se cambian por las que ponga su agente de logs.
{service="app", level="error"}
sum by (level) (count_over_time({service="app", level=~"error|critical"}[24h]))
# Volumen de líneas por hora, para ver el "doble de lo habitual" o el silencio
sum(count_over_time({service="app"}[1h]))
# 5xx en nginx en la última hora, con el log en formato combinado (con log JSON, | json)
count_over_time({service="nginx"} | pattern `<ip> - - [<_>] "<method> <uri> <_>" <status> <_>` | status >= 500 [1h])
El panel de la revisión. En Grafana 12 estas consultas van a un dashboard "Revisión diaria" con cuatro paneles (errores por nivel, volumen por hora, 5xx, accesos fallidos) y una variable $service, el desplegable de la cabecera del dashboard que cambia el servicio en todas las consultas a la vez. Es el panel que se abre cada mañana, y una captura suya con fecha vale como evidencia de la revisión. Conviene guardar además las consultas (con la estrella del historial de consultas de Explore, o en la biblioteca de consultas si la versión de Grafana la tiene), para que el siguiente que llegue las herede.
Un script diario que resuma errores¶
Para qué un script. La rutina falla el día en que nadie se acuerda de hacerla. Cuando no hay plataforma central, o como complemento a ella, un script en cron (el planificador de tareas de Linux) que resuma los errores por tipo y lo envíe por correo o a un canal de chat es lo que la mantiene viva esos días. Este es un ejemplo mínimo, pensado para llevarlo a la empresa y adaptarlo; lo importante es que agrupa, no que vuelque.
#!/usr/bin/env bash
# /usr/local/sbin/resumen-logs.sh : resumen diario de errores por contenedor
set -euo pipefail
DESDE="24h"
DEST="ops@empresa.example"
TMP=$(mktemp)
{
echo "Resumen de errores $(hostname) $(date +%F) (últimas $DESDE)"
echo
for c in $(docker ps --format '{{.Names}}'); do
n=$(docker logs --since "$DESDE" "$c" 2>&1 | grep -cE "ERROR|FATAL|panic:" || true)
echo "== $c: $n líneas de error"
if [ "$n" -gt 0 ]; then
docker logs --since "$DESDE" "$c" 2>&1 \
| grep -E "ERROR|FATAL|panic:" \
| sed -E 's/^[0-9T:.-]+Z? //; s/[0-9a-f]{8,}/<id>/g; s/[0-9]{2,}/<n>/g' \
| sort | uniq -c | sort -rn | head -10
fi
echo
done
echo "== Reinicios de contenedores en $DESDE"
docker events --since "$DESDE" --until 0s --filter event=die --filter event=oom \
--format '{{.Time}} {{.Actor.Attributes.name}} {{.Action}} exit={{.Actor.Attributes.exitCode}}' 2>/dev/null || true
echo
echo "== journal, prioridad err o superior"
journalctl -p err --since "-$DESDE" --no-pager -q | tail -20
} > "$TMP"
mail -s "Resumen de errores $(hostname) $(date +%F)" "$DEST" < "$TMP"
rm -f "$TMP"
El sed del medio es el truco que hace útil el resumen: sustituye identificadores y números por marcadores para que "timeout en petición 48213" y "timeout en petición 48214" cuenten como el mismo error. Sin eso, el uniq -c no agrupa nada. docker events --until 0s es la forma de que el comando termine en lugar de quedarse escuchando. Se programa en /etc/cron.d/resumen-logs a las 07:30 (30 7 * * * root /usr/local/sbin/resumen-logs.sh), antes de que llegue nadie, o como timer de systemd (la alternativa de systemd a cron, que deja el resultado de cada ejecución en el journal) si la empresa los prefiere. La última línea da por hecho que el servidor sabe enviar correo: mail solo entrega si hay un agente de correo local o un relé configurado. Si no lo hay, esa línea se cambia por lo que use la empresa para avisar, o el resumen se deja en un fichero con fecha.
Retención: logrotate y journald¶
Por qué la retención es parte de la revisión. Un sistema en el que nadie ha pensado en la retención acaba con el disco lleno, y un disco lleno en un host de contenedores es una caída completa (Docker no puede escribir su estado, PostgreSQL deja de aceptar escrituras). Y al revés: si se guarda demasiado poco, la revisión del lunes no encuentra lo que pasó el sábado. Cada uno de los tres caminos de un log tiene su propio mecanismo, y los tres se revisan la primera semana en la empresa.
Cuánto se guarda no lo decide la técnica. El tamaño lo limita el disco, pero el tiempo depende de para qué se usan los logs: investigar un incidente semanas después, cumplir la política de la empresa o la normativa, porque un log con IP o usuario es un dato personal. Los plazos orientativos están en qué hay que conservar por obligación legal; en la empresa se pregunta cuál es su política antes de proponer ningún valor.
Los logs de Docker. Con el driver json-file (el que hay por defecto), los logs de los contenedores no rotan si nadie lo configura. Es lo mismo que se configuró en app01 y mon01 (el driver de logs y por qué limitarlo): lo normal es fijarlo en /etc/docker/daemon.json para todos los contenedores:
Eso limita cada contenedor a 250 MB de logs. Solo afecta a contenedores creados después del cambio; los que ya existen hay que recrearlos (docker compose up -d --force-recreate). Por eso mirar daemon.json no basta para saber si un contenedor rota: lo que cuenta es la configuración con la que se creó cada uno, y se ve con docker inspect --format '{{json .HostConfig.LogConfig}}' <contenedor>. Para ver cuánto ocupa ahora mismo: du -sh /var/lib/docker/containers/*/*-json.log | sort -rh | head. Alternativa: driver journald, que mete los logs del contenedor en el journal del host y hereda su retención; entonces se leen con journalctl CONTAINER_NAME=app.
El journal. journald tiene su propia retención en /etc/systemd/journald.conf: SystemMaxUse=2G limita el total en disco, MaxRetentionSec=1month la antigüedad, y Storage=persistent asegura que sobreviva al reinicio (con auto, si no existe /var/log/journal se queda en RAM y se pierde al reiniciar; en Debian 13 el directorio existe por defecto). Para ver y recortar:
journalctl --disk-usage
journalctl --vacuum-size=1G # deja como mucho 1 GB
journalctl --vacuum-time=30d # elimina lo anterior a 30 días
journalctl --verify # comprueba la integridad de los ficheros
vacuum solo borra ficheros de archivo, nunca el activo; si el uso no baja, primero journalctl --rotate y después el vacuum.
Los ficheros clásicos. logrotate se encarga de los ficheros clásicos (/var/log/nginx/*.log, /var/log/auth.log, los de la aplicación si escribe a fichero). No es un servicio que esté siempre en marcha: lo lanza una vez al día un timer de systemd (cron, en sistemas antiguos), y por eso la rotación ocurre de madrugada. Cada servicio deja su configuración en /etc/logrotate.d/, y un ejemplo típico para una aplicación en /srv/app/logs:
Cada línea es una decisión:
dailyyrotate 14: un fichero por día y catorce días guardados. Dos semanas bastan para comparar una semana con la anterior; si la política de la empresa pide más, se cambia aquí.compressydelaycompress: los ficheros viejos se comprimen, menos el más reciente, que se queda en claro hasta la rotación siguiente. Así el de ayer se lee sinzcat, y no se pierden las líneas que el programa todavía escriba en él unos segundos después de rotarlo.missingokynotifempty: no da error si no hay ficheros, y no rota uno vacío.copytruncate: copia el fichero y vacía el original en lugar de renombrarlo. Es lo que hace falta cuando el proceso mantiene el fichero abierto y no sabe reabrirlo al recibir una señalSIGHUP, que es lo que les pasa a la mayoría de aplicaciones dentro de contenedores que escriben a un volumen. El precio es que las líneas escritas entre la copia y el vaciado se pierden.
Con logrotate -d /etc/logrotate.d/app se simula sin tocar nada, y logrotate -f fuerza una rotación para comprobar que funciona. Documentación: la página de manual de logrotate en man7.org y la de journald.conf.
Cómo se redacta una incidencia útil¶
Encontrar no basta. Todo fallo encontrado se reporta. Reportar no es mandar un mensaje diciendo "he visto errores en app"; es abrir una incidencia (el registro de un problema en la herramienta de seguimiento de la empresa, con su responsable, su estado y su historia) con lo que alguien necesita para decidir si es urgente y por dónde empezar. En el laboratorio, el receptor de alertas las abría automáticamente en Gitea (categorizar, notificar y tratar); aquí se abren a mano, y por eso hay que saber qué poner. Los ingredientes son siempre los mismos y conviene tener la plantilla a mano en la herramienta que use la empresa (Jira, GitLab issues, un Gitea como el del laboratorio, o un correo estructurado).
Título: [servicio] resumen de una línea del síntoma
Fecha y hora de detección: 2027-05-04 08:10 (revisión diaria)
Servicio / host: api (contenedor app en app01)
Mensaje exacto (una línea, tal cual aparece):
ERROR sqlalchemy.pool: QueuePool limit of size 5 overflow 10 reached, connection timed out
Frecuencia: 143 apariciones entre 02:00 y 02:40; ninguna fuera de esa ventana
Impacto observado: 37 respuestas 502 en nginx en el mismo intervalo; sin quejas de usuarios
Contexto: coincide con la copia de seguridad de la BD (02:00) según el cron de db01
Fragmento de log (10 líneas alrededor, anonimizado): adjunto
Hipótesis (opcional, marcada como tal): el pool se agota porque las consultas
se ralentizan durante el pg_dump
Gravedad propuesta: media (recurrente, sin pérdida de servicio visible)
Qué pesa más de cada campo. El mensaje exacto es lo más valioso: es lo que se busca en Google, en la documentación y en el histórico de incidencias. Se copia literal, sin parafrasear. La frecuencia y la ventana temporal son lo segundo, porque convierten "hay errores" en "hay errores a las dos de la mañana", que ya es media investigación hecha. El impacto dice si corre prisa: no es lo mismo un error que solo queda en el log que uno que llega al usuario como un 502. La hipótesis va aparte y marcada, para que nadie la confunda con un hecho. Y la gravedad se propone, no se decide: la fija quien atiende la incidencia, con la escala que use la empresa.
A5.1 Revisión de logs (CE 3a)¶
Objetivo Al terminar tienes un registro de cinco días laborables de revisión diaria de los logs de un servicio y una incidencia abierta con la plantilla de la unidad. Es la primera de las cuatro miradas de la rutina, y la que más días necesita: cinco revisiones a la misma hora son lo que demuestra que la revisión es periódica y no una mirada suelta, y la incidencia, que lo encontrado llega a quien lo tiene que arreglar.
Antes de empezar
- Autorización del tutor: qué servicio se revisa, dónde están sus logs y con qué usuario se leen. Es solo lectura, pero ese usuario tiene que poder leerlos de verdad: grupo
admosystemd-journal, grupodockerosudo, según el camino del log. - Un fichero
a5.1-logs.mden tu carpeta de trabajo para el registro del paso 2. - Leídos Los tres caminos de un log, Qué buscar, Comandos para la revisión diaria, Retención: logrotate y journald y Cómo se redacta una incidencia útil.
Pasos
Vas a pasar de no conocer el log del servicio a revisarlo cada mañana en diez minutos. Primero preparas las consultas un solo día, mirando cómo escribe el servicio; después las repites cinco días laborables a la misma hora, que es lo que convierte una mirada en una rutina y te da con qué comparar cada día; de lo que encuentres sale la incidencia. La retención va al final porque es una comprobación de una sola vez, pero puedes hacerla cualquier día de la semana.
-
Conoce el log y prepara tus consultas. El primer día, averigua por cuál de los tres caminos escribe el servicio y mira unas líneas para conocer su formato: si es texto libre, JSON o el formato de nginx, y cómo marca el nivel. Con eso, adapta al servicio los comandos (o las consultas de LogQL) de Comandos para la revisión diaria: errores de 24 h agrupados por tipo y volumen de líneas. Son las dos consultas que vas a repetir cada día, así que guárdalas tal cual en tu fichero de trabajo.
Pista: con un log JSON que escribe
"level":"error", ungrep ERRORno encuentra nada. En un fichero, las líneas de ayer ya están en el que logrotate rotó de madrugada:zcat -flee los dos. -
Revisa cinco días laborables, siempre a la misma hora. Cada día, lanza las dos consultas y añade una fila al registro con fecha, servicio, intervalo revisado, comando o consulta exactos, errores agrupados por tipo con su recuento e incidencia, si la hay. La misma hora hace que cada fila cubra las mismas 24 h y que las filas se puedan comparar entre sí. Compara además el volumen con el del día anterior: que se doble o que se quede en silencio también es un hallazgo, como se explica en Qué buscar.
-
Convierte un hallazgo en incidencia. Elige uno de los hallazgos del registro y ábrelo con la plantilla de Cómo se redacta una incidencia útil, en la herramienta de la empresa si el tutor lo autoriza o, si no, en un fichero con ese formato. La prueba de que está bien escrita es que otra persona podría atenderla sin preguntarte nada: el mensaje exacto, cuántas veces y en qué ventana, y qué impacto ha tenido.
Pista: el mensaje va literal, sin parafrasear. En la herramienta de la empresa la incidencia lleva los datos reales; la copia que va a
a5.1-logs.md, anonimizada. -
Comprueba que los logs se conservan lo justo. Mira si los logs de Docker, el journal y los ficheros clásicos tienen retención, como se explica en Retención: logrotate y journald: un log que no rota acaba llenando el disco, y uno que se borra demasiado pronto deja la revisión sin datos. Si falta, anótalo como hallazgo y propónselo al tutor con el valor que sugieres y por qué; no cambies nada sin su visto bueno.
Pista: en Docker no basta con leer
daemon.json, que solo vale para los contenedores creados después: mira lo que tiene cada contenedor.
Comprobación La tabla tiene cinco filas con fecha, cada fila cita el comando exacto que se ejecutó, y la incidencia contiene un mensaje de log copiado literal con su recuento y su ventana temporal. Ningún host, IP pública ni usuario real aparece en el fichero.
Entrega a5.1-logs.md en la carpeta ut5/ del repositorio de la unidad, con el registro de los cinco días y la incidencia anonimizada. Pide al tutor que firme la fila A5.1 de la ficha.
Si te sobra tiempo Adapta el script resumen-logs.sh de Un script diario que resuma errores a los contenedores de la empresa y propón al tutor programarlo en cron; adjunta la salida de una ejecución manual como evidencia extra.
Bloque 2 · Accesos y fuerza bruta (CE 3b)¶
Cualquier sistema que se alcance desde Internet recibe intentos de entrar desde el primer minuto, y casi todos son ruido: programas que prueban contraseñas por defecto contra todo lo que responde. Lo difícil es distinguir de ese ruido un ataque dirigido, y asegurarse de que lo que tendría que pararlo lo para de verdad. El bloque lo construye en ese orden: dónde queda registrado cada acceso, qué patrón distingue una fuerza bruta de un password spraying, cómo se convierte ese patrón en una alerta y cómo se bloquea de forma automática, con fail2ban como herramienta principal y CrowdSec como alternativa. Viene después de la revisión de logs porque es la misma habilidad aplicada a un tipo de línea concreto, las de autenticación, y añade una acción: bloquear. La hoja A5.2 clasifica 24 h de accesos de un sistema de la empresa, revisa su mecanismo de bloqueo y termina comprobando sobre el firewall que un bloqueo real funciona; la regla de Loki queda para el tiempo que sobre.
Monitorizar los accesos (CE 3b)¶
El ruido de fondo. Un servidor con SSH en el puerto 22 abierto a Internet ve entre cientos y varios miles de intentos fallidos al día sin que nadie lo esté atacando en particular. Son botnets, redes de máquinas infectadas que alguien controla a distancia y que prueban credenciales por defecto contra todo lo que responde. Ese ruido no se puede evitar; lo que se puede es dejarlo sin efecto: con claves en lugar de contraseñas no hay nada que adivinar, y un bloqueo automático reduce su volumen.
Lo que se busca debajo. Monitorizar los accesos es distinguir ese ruido de fondo de un ataque dirigido (alguien que prueba usuarios que existen de verdad en la empresa, o que vuelve con paciencia cada pocos minutos), y comprobar que el mecanismo de bloqueo hace su trabajo. Lo segundo importa tanto como lo primero: un bloqueo instalado que no bloquea da una seguridad falsa, que es peor que saber que no hay ninguno.
Dónde se registran¶
Cada capa autentica por su cuenta. Un sistema no tiene una sola puerta: SSH para quien lo administra, el proxy y la aplicación para los usuarios, el panel del hipervisor, la VPN. Cada una deja sus intentos en su propio log, y quien ataca prueba la que responda. La revisión empieza por la que está expuesta a Internet:
| Origen | Dónde mirar | Qué contiene |
|---|---|---|
| SSH | /var/log/auth.log (Debian con rsyslog) o journalctl -u ssh |
Intentos fallidos, logins correctos, usuario inválido, clave aceptada |
| sudo, su y PAM (los módulos de autenticación de Linux) | mismo fichero o journal, _COMM=sudo |
Quién escaló privilegios y qué ejecutó |
| Proxy inverso (nginx) | /var/log/nginx/access.log y error.log |
Intentos a rutas de administración, 401/403, escáneres |
| Aplicación | su log (stdout del contenedor) | Login fallido, usuario bloqueado, cambio de contraseña, tokens rechazados |
| Panel del hipervisor | Proxmox: /var/log/pve/tasks/, journalctl -u pvedaemon, -u pveproxy |
Accesos al panel web, acciones sobre VM |
| VPN (red privada virtual) | WireGuard no registra handshakes por defecto; OpenVPN en su log; OPNsense en Sistema > Registro | Conexiones, orígenes, fallos de autenticación |
| Firewall | OPNsense, nft monitor, journalctl -k con reglas log |
Conexiones rechazadas, escaneos de puertos |
SSH en Debian 13: por unidad, no por proceso. Sin rsyslog instalado (los tres caminos de un log), auth.log no existe y todo está en el journal; journalctl -u ssh --since today es el equivalente. El filtro va por unidad de systemd y no por nombre de proceso a propósito: desde OpenSSH 9.8 (Debian 13 trae una versión posterior), el demonio que escucha (sshd) delega cada conexión en un proceso hijo, sshd-session, y es ese hijo el que escribe Failed password, Invalid user y Accepted publickey. Por eso journalctl _COMM=sshd devuelve poco más que los arranques del servicio y parece que nadie ha intentado entrar, mientras que -u ssh recoge los tres procesos (sshd, sshd-session y sshd-auth) porque todos cuelgan de la misma unidad, y funciona igual en Debian 12 y en Debian 13. Si la empresa centraliza en Loki con Alloy o Promtail, el job suele llamarse auth o syslog y ahí van todas las búsquedas de abajo con |=.
Qué se busca: patrones de fuerza bruta y password spraying¶
Dos ataques, dos firmas. Los dos intentan adivinar una contraseña, pero reparten los intentos al revés, y por eso se cuentan de forma distinta.
Fuerza bruta clásica: una misma IP prueba muchas contraseñas contra uno o pocos usuarios, deprisa. En auth.log se ve así (anonimizado):
May 4 03:12:41 web01 sshd[18422]: Failed password for root from 203.0.113.45 port 51204 ssh2
May 4 03:12:44 web01 sshd[18422]: Failed password for root from 203.0.113.45 port 51204 ssh2
May 4 03:12:47 web01 sshd[18422]: Failed password for root from 203.0.113.45 port 51204 ssh2
May 4 03:12:49 web01 sshd[18422]: Failed password for root from 203.0.113.45 port 51204 ssh2
May 4 03:12:50 web01 sshd[18422]: Failed password for root from 203.0.113.45 port 51204 ssh2
May 4 03:12:50 web01 sshd[18422]: Disconnecting authenticating user root 203.0.113.45 port 51204: Too many authentication failures [preauth]
May 4 03:12:53 web01 sshd[18431]: Invalid user admin from 203.0.113.45 port 51290
May 4 03:12:55 web01 sshd[18431]: Failed password for invalid user admin from 203.0.113.45 port 51290 ssh2
Con OpenSSH 9.8 o posterior, el proceso que firma estas líneas es sshd-session en lugar de sshd; el resto es igual.
Password spraying: una IP (o varias coordinadas) prueba una o dos contraseñas muy comunes contra muchos usuarios distintos, despacio. Está pensado justo para esquivar los dos bloqueos habituales: el que bloquea una cuenta tras varios fallos (le llegan pocos intentos por cuenta) y el que bloquea una IP que falla mucho en poco tiempo (los intentos van repartidos). Se reconoce por la variedad de usuarios, no por el volumen:
May 4 04:01:10 web01 sshd[19002]: Invalid user oracle from 198.51.100.7 port 40122
May 4 04:03:52 web01 sshd[19017]: Invalid user postgres from 198.51.100.7 port 40310
May 4 04:06:31 web01 sshd[19033]: Invalid user jenkins from 198.51.100.7 port 40488
May 4 04:09:14 web01 sshd[19051]: Invalid user deploy from 198.51.100.7 port 40615
May 4 04:11:58 web01 sshd[19068]: Invalid user ubuntu from 198.51.100.7 port 40790
Un intento cada tres minutos no dispara un findtime = 10m con maxretry = 5 (los dos parámetros con los que fail2ban decide cuándo bloquear, que se explican más abajo: cuántos fallos y en qué ventana de tiempo). Por eso la detección de spraying se hace contando usuarios distintos por IP en ventanas largas, no intentos.
Cómo se cuenta cada uno. Los comandos siguen la idea de agrupar del bloque 1, cada uno con su firma: la fuerza bruta se cuenta como intentos fallidos por IP, y el spraying, como usuarios distintos por IP. Los logins correctos se revisan aparte, porque un ataque que ha funcionado ya no aparece como fallo:
# Fuerza bruta: intentos fallidos por IP, hoy
journalctl -u ssh --since today --no-pager \
| grep -oE "Failed password for (invalid user )?\S+ from \S+" \
| awk '{print $NF}' | sort | uniq -c | sort -rn | head
# Spraying: usuarios distintos por IP en las últimas 24 h
journalctl -u ssh --since -24h --no-pager \
| grep -oE "Invalid user \S+ from \S+" \
| awk '{print $5, $3}' | sort -u | awk '{print $1}' | uniq -c | sort -rn | head
# Logins correctos, para ver quién ha entrado y desde dónde (y cuándo)
journalctl -u ssh --since -7d --no-pager | grep "Accepted"
# nginx: IPs que más 401/403/404 generan (escáneres y rutas de administración)
# El fichero actual y el de ayer, que logrotate ya ha rotado: así se cubren 24 h
zcat -f /var/log/nginx/access.log /var/log/nginx/access.log.1 \
| awk '$9 ~ /^(401|403|404)$/ {print $1}' | sort | uniq -c | sort -rn | head
zcat -f /var/log/nginx/access.log /var/log/nginx/access.log.1 \
| awk '$7 ~ /(wp-login|wp-admin|\.env|phpmyadmin|\/admin|\.git)/ {print $1, $7}' | sort | uniq -c | sort -rn | head
Lo que no es fuerza bruta pero se mira en el mismo pase. Accesos correctos fuera del horario habitual (un Accepted publickey a las 03:40 de una cuenta que siempre entra de 8 a 18) y orígenes geográficos raros. Para lo segundo, geoiplookup (paquete geoip-bin) o la base de datos GeoLite2 de MaxMind (gratuita con registro) dan el país de una IP; en Loki, Alloy tiene una etapa geoip que añade geoip_country_code como etiqueta, y con eso el panel de Grafana puede mostrar un mapa de orígenes. Un país nuevo en la lista de logins correctos es motivo de llamada, no de incidencia: se pregunta a la persona de esa cuenta si era ella (un viaje, una VPN) antes de tratarlo como un problema de seguridad.
Detección en Loki y alerta¶
De la consulta a la alerta. Contar a mano sirve para la revisión de la mañana; para enterarse a las tres de la madrugada hace falta una alerta. En Loki 3.x las reglas de alerta van en el ruler, el componente que evalúa consultas cada cierto tiempo igual que hace Prometheus con sus reglas (alertas en el ruler de Loki; en el laboratorio, las reglas iban en /loki/rules/fake/). Tienen la misma sintaxis que las de Prometheus y, como ellas, disparan a Alertmanager. Estas dos son las dos firmas del apartado anterior:
groups:
- name: accesos
rules:
- alert: FuerzaBrutaSSH
expr: |
sum by (host) (count_over_time({job="auth"} |= "Failed password" [5m])) > 20
for: 0m
labels: { severity: warning }
annotations:
summary: "Más de 20 fallos SSH en 5 min en {{ $labels.host }}"
- alert: PasswordSprayingSSH
expr: |
count by (host, ip) (
sum by (host, ip, user) (
count_over_time({job="auth"}
| regexp `Invalid user (?P<user>\S+) from (?P<ip>\S+)` [1h])
)) > 8
for: 0m
labels: { severity: warning }
annotations:
summary: "IP {{ $labels.ip }} ha probado más de 8 usuarios distintos en 1 h"
La primera es la de fuerza bruta: muchos fallos de contraseña en poco tiempo, más de 20 en 5 minutos en un mismo host. La segunda es la de spraying: extrae usuario e IP con regexp, agrupa por los tres, y cuenta cuántos usuarios distintos tiene cada IP. Un umbral de 8 usuarios distintos en una hora no lo alcanza nadie legítimo. Ojo con count_over_time sin agregación en logs con muchas etiquetas: devuelve una serie por stream y la alerta se dispara por cada una. La agregación sum by es la que la deja en una por host.
fail2ban a fondo¶

El problema que resuelve. Ver la fuerza bruta en el log no la para, y nadie puede vigilar el log de SSH las 24 horas para bloquear IPs a mano. fail2ban automatiza esa reacción: es un servicio escrito en Python, que está en los repositorios de las distribuciones habituales, y que lee los logs, cuenta los fallos de cada IP y, cuando pasan de un límite, la bloquea en el firewall durante un tiempo.
Cómo decide. Lee ficheros de log (o el journal) y les aplica expresiones regulares, los filtros. Cuando una IP supera maxretry fallos dentro de findtime, ejecuta una acción de bloqueo durante bantime, y al acabar ese tiempo la desbloquea sola. Cada combinación de filtro, acción y parámetros es una jail. La configuración de fábrica está en /etc/fail2ban/jail.conf y no se toca, porque una actualización del paquete puede sobrescribirla; la propia va en /etc/fail2ban/jail.local o en ficheros bajo /etc/fail2ban/jail.d/, que se cargan encima.
Una configuración completa. El jail.local de abajo es uno completo para un host con SSH y nginx. Las tres decisiones que más problemas evitan o causan son ignoreip, backend y bantime.increment, y son las que hay que entender antes de copiarlo:
# /etc/fail2ban/jail.local
[DEFAULT]
# Redes que nunca se bloquean: loopback, oficina, monitorización, bastión
ignoreip = 127.0.0.1/8 ::1 10.10.0.0/24 203.0.113.0/28
bantime = 1h
findtime = 10m
maxretry = 5
# Bantime incremental: cada reincidencia alarga el baneo, hasta 4 semanas
bantime.increment = true
bantime.factor = 2
bantime.maxtime = 4w
banaction = nftables-multiport
banaction_allports = nftables-allports
[sshd]
enabled = true
mode = aggressive
# Debian 13: sin rsyslog no hay auth.log, y el backend systemd lee el journal
backend = systemd
# el journalmatch de fábrica solo casa _COMM=sshd y no ve a sshd-session
journalmatch = _SYSTEMD_UNIT=ssh.service
[nginx-http-auth]
enabled = true
logpath = /var/log/nginx/error.log
[nginx-botsearch]
enabled = true
logpath = /var/log/nginx/access.log
maxretry = 2
[recidive]
enabled = true
bantime = 1w
findtime = 1d
maxretry = 3
Las decisiones de ese fichero, una por una:
ignoreipes obligatorio pensarlo antes de activar nada: un bloqueo sobre la propia IP de administración o sobre la sonda de monitorización es un problema autoinfligido que en una empresa se nota. Ahí van la red de gestión, la IP pública de la oficina y la del bastión, la máquina intermedia desde la que se administra (en el laboratorio, el puesto de administración).backend = systemdva solo en la jailsshd, no en[DEFAULT]. En un sistema sin rsyslog los fallos de SSH solo están en el journal, y este backend es el que lo lee; pero con él fail2ban ignoralogpathy busca solo en el journal, así que puesto en[DEFAULT]dejaría ciegas a las jails que leen ficheros, como las de nginx yrecidive.journalmatchevita el fallo más silencioso de todos en Debian 13: el filtro de fábrica busca los mensajes del procesosshd, y en esa versión los escribesshd-session, así que la jail arranca, se queda a cero fallos y no banea nunca. Filtrando por unidad,_SYSTEMD_UNIT=ssh.service, entran los mensajes de los tres procesos, y la configuración vale también en Debian 12.bantime.incrementes la forma moderna de castigar a los reincidentes: una IP que vuelve después de su primer baneo recibe uno más largo, y cada nueva reincidencia lo alarga otra vez, hastabantime.maxtime;bantime.factordecide lo deprisa que crece. fail2ban guarda el historial en su base de datos SQLite (/var/lib/fail2ban/fail2ban.sqlite3), así que sobrevive a reinicios.- La jail
recidivees el mecanismo antiguo para lo mismo: lee el propio log de fail2ban y banea en todos los puertos (banaction_allports) a quien ha sido baneado tres veces en un día. Se pueden usar las dos. banaction = nftables-multiportdice cómo se aplica el bloqueo: con reglas de nftables en los puertos de cada jail. Dónde acaban esas reglas se explica más abajo.mode = aggressiveamplía el filtrosshd: a las contraseñas fallidas y los usuarios que no existen, que ya cuenta el modo normal, les suma las conexiones que se cortan antes de intentar autenticarse, típicas de los escáneres.
Un filtro propio para la aplicación. Los filtros de fábrica cubren SSH, nginx y otros servicios conocidos, pero no los logins de una aplicación propia. Si la aplicación escribe WARN auth: login failed for user=usuario_a ip=203.0.113.45 cuando alguien falla la contraseña, el filtro es una regex con el marcador <HOST> (o <ADDR> en versiones recientes) en el sitio de la IP, que es lo que fail2ban usa para saber a quién apuntarle el fallo:
# /etc/fail2ban/filter.d/app-login.conf
[Definition]
failregex = ^.*auth: login failed for user=\S+ ip=<HOST>\s*$
ignoreregex =
# /etc/fail2ban/jail.d/app-login.conf
[app-login]
enabled = true
filter = app-login
logpath = /srv/app/logs/app.log
port = http,https
maxretry = 8
findtime = 15m
bantime = 2h
El detalle de los contenedores. fail2ban corre en el host y necesita leer el log de la aplicación. Si la aplicación escribe a stdout, el fichero está en /var/lib/docker/containers/<id>/<id>-json.log (cada línea envuelta en JSON, la regex tiene que contemplarlo) y cambia de nombre al recrear el contenedor; es más limpio que la aplicación escriba a un volumen montado, o usar backend = systemd con el driver de logs journald y journalmatch = CONTAINER_NAME=app en la jail. Y la IP que ve la aplicación detrás de nginx es la del proxy, no la del cliente, salvo que nginx pase X-Forwarded-For o X-Real-IP (cabeceras HTTP en las que el proxy pone la IP original del cliente) y la aplicación lo registre. Sin eso, el filtro banearía a nginx.
Probar antes de activar. Un filtro que no casa con nada no da ningún error: la jail arranca y se queda a cero. Por eso se prueba antes con fail2ban-regex, que lo pasa por un log real y dice cuántas líneas casan y cuáles no:
fail2ban-regex /srv/app/logs/app.log /etc/fail2ban/filter.d/app-login.conf --print-all-missed | tail -20
fail2ban-regex "$(journalctl -u ssh --since -1h -o short-iso | tail -200)" sshd
Operar. El día a día se hace con fail2ban-client, que habla con el servicio en marcha:
fail2ban-client status # jails activas
fail2ban-client status sshd # fallos, baneos actuales e IPs
fail2ban-client set sshd unbanip 203.0.113.45
fail2ban-client set sshd banip 198.51.100.7 # baneo manual
fail2ban-client get sshd bantime
fail2ban-client reload # recarga sin perder baneos
fail2ban-client banned # todas las IPs baneadas por jail
Dónde se aplica el bloqueo. fail2ban no bloquea nada por sí mismo: decide, y el bloqueo lo aplica la acción en el firewall. Con banaction = nftables-multiport, crea una tabla inet f2b-table (el nombre exacto depende de la versión) con un set por jail y una regla que descarta el tráfico de las IPs del set en los puertos de la jail. Se comprueba directamente con nftables (el firewall del kernel), que es donde de verdad se ve si el bloqueo existe:
Si en el host ya hay un firewall nftables propio, como el que lleva app01 en el laboratorio con su tabla inet fw (nftables por host), fail2ban añade su tabla aparte y las dos conviven; el orden de evaluación entre tablas depende de la prioridad de las cadenas, y la de fail2ban usa priority -1 para ir antes que filter (0).
El hueco de Docker. La cadena de fail2ban engancha en input, que es por donde pasa lo que va al propio host: SSH o un nginx instalado como paquete. El tráfico a los puertos que publica un contenedor no pasa por ahí, sino por forward, por la razón que explica por qué Docker se salta el firewall del host: Docker le cambia el destino y el kernel lo reenvía al contenedor. Un baneo de fail2ban, por tanto, no corta el acceso a un servicio publicado por Docker. Para ese caso fail2ban tiene la acción iptables-multiport con chain = DOCKER-USER, o se banea en forward con una acción nftables personalizada. Por eso, en un host con Docker, conviene bloquear en el proxy o en el cortafuegos del entorno, o usar la acción sobre DOCKER-USER.
Comprobar un bloqueo de verdad. Es lo que pide la actividad A5.2, porque cada eslabón puede fallar por separado: que el filtro cuente, que la acción cree la regla y que la regla corte. Desde una máquina que no esté en ignoreip (una VM de pruebas, el móvil con datos), se falla la contraseña SSH seis veces, se ve la IP en fail2ban-client status sshd, se ve la regla en nftables, se comprueba que la conexión ahora da Connection refused o se queda en timeout, y se desbanea. Capturas de esos cuatro momentos son la evidencia. Documentación oficial: https://github.com/fail2ban/fail2ban/wiki y las páginas de manual jail.conf(5).
CrowdSec como alternativa¶
Qué cambia respecto a fail2ban. CrowdSec hace lo mismo que fail2ban con dos diferencias de diseño. La primera es que separa la detección (el agente, que lee logs con "parsers" y "scenarios" en YAML descargados desde su hub) del bloqueo (los "bouncers": un componente para nftables, otro para nginx, otro para OPNsense, otro para Traefik, que consultan la API local del agente y aplican las decisiones). Así el agente puede estar en un host y el bouncer en el firewall. La segunda es que es colaborativo: cada instalación que reporta ataques a la red de CrowdSec recibe a cambio una lista de IPs maliciosas vistas por la comunidad, de modo que un atacante queda bloqueado antes de llegar al sistema propio. Esa lista es opcional y en algunas empresas no gusta enviar datos fuera; se puede desactivar (crowdsec en modo sin API central) y sigue funcionando como fail2ban.
Operar. Todo se maneja con cscli, y las órdenes tienen el mismo papel que las de fail2ban-client: qué lee, qué ha decidido bloquear y qué bouncers lo aplican.
cscli metrics # qué logs lee y qué escenarios disparan
cscli decisions list # IPs bloqueadas, motivo, duración
cscli decisions add --ip 203.0.113.45 --duration 4h --reason "prueba manual"
cscli decisions delete --ip 203.0.113.45
cscli alerts list # histórico de detecciones
cscli hub list # colecciones instaladas (nginx, sshd, linux)
cscli bouncers list # bouncers registrados y cuándo consultaron
Cuál elegir. La elección va por tamaño. Con uno o dos hosts, fail2ban basta: es más sencillo de operar y permite escribir filtros propios con expresiones regulares a medida. Con varios hosts, o cuando interesa bloquear en un solo punto, CrowdSec, porque separa el agente del bouncer y OPNsense tiene plugin oficial; la lista comunitaria es un argumento añadido a su favor donde se acepte enviar datos fuera. En la empresa, lo que haya. Documentación: https://docs.crowdsec.net/.
A5.2 Accesos y fuerza bruta (CE 3b)¶
Objetivo Clasificar con argumentos los intentos de acceso de al menos 24 h de log de un sistema, revisar o configurar el mecanismo de bloqueo y demostrar un bloqueo real de extremo a extremo. Es la diferencia entre creer que el sistema está protegido y saberlo: una jail que lleva meses a cero fallos parece igual de tranquila si no hay ataques que si no está leyendo nada.
Antes de empezar
- Autorización del tutor en dos niveles: lectura de los logs de acceso (general) y, aparte, permiso explícito para la prueba de bloqueo del paso 4, con la IP de pruebas, el día y la hora acordados. Si no autoriza la prueba, anótalo: la actividad termina en el paso 3.
sudopara el cliente del mecanismo de bloqueo (fail2ban-client,cscli) y paranft, que necesitan los pasos 3 y 4. Si la empresa no lo concede, acuerda con el tutor que ejecute él esos comandos mientras se los dictas, y anota en la entrega quién los ejecutó.- Saber qué mecanismo usa la empresa (fail2ban, CrowdSec, bloqueo en OPNsense u otro) y dónde está su configuración.
- Leídos Dónde se registran, Qué se busca: patrones de fuerza bruta y password spraying y fail2ban a fondo o CrowdSec como alternativa, según el caso.
Pasos
Vas de la lectura a la acción, y de lo que no toca nada a lo que sí: primero sacas los números del log, después los interpretas, luego revisas la configuración que debería reaccionar a ellos y, solo al final y con permiso, provocas un bloqueo para ver que la cadena entera funciona. Si el tutor no autoriza la prueba, la actividad termina en el paso 3 y lo anotas.
-
Saca los números de 24 h de accesos. Localiza el log de accesos del sistema elegido (SSH, proxy inverso o aplicación) con la tabla de Dónde se registran, y cuenta, con los comandos de Qué se busca, los intentos fallidos por IP y los usuarios distintos por IP de las últimas 24 h, y los logins correctos de la semana con su hora y su origen. Son los tres datos que separan las dos firmas de ataque del acceso legítimo.
Pista: en Debian 13, filtra el journal por unidad (
-u ssh) y no por proceso: los fallos los escribesshd-session, y con_COMM=sshdparece que nadie ha intentado entrar. -
Clasifica lo que ves y justifícalo. Con esos números, decide si lo que hay es ruido de fondo, fuerza bruta, password spraying o nada, y explica por qué con el criterio de Qué se busca: el volumen por IP en un caso, la variedad de usuarios en el otro. Guarda un extracto anonimizado de diez a veinte líneas que muestre el patrón, porque la clasificación se tiene que poder comprobar leyéndolo.
-
Revisa la configuración que debería reaccionar. Anota las jails activas (o los escenarios y bouncers, con CrowdSec),
ignoreipo la lista blanca equivalente, los tiempos y el backend, y compáralos con las decisiones de fail2ban a fondo. Comprueba que la lista blanca incluye la red de gestión, la oficina y la sonda de monitorización, y que cada jail lee de verdad: si el paso 1 encontró fallos y la jail no ha contado ninguno, no está mirando donde debe.Pista: una jail
sshdactiva que lleva días a cero fallos suele estar leyendo donde no toca: las dos causas están en Errores frecuentes. -
Demuestra un bloqueo de extremo a extremo. En el momento acordado y desde una IP que no esté en
ignoreip, falla el acceso SSH hasta superarmaxretry. Recoge una captura de cada momento, porque cada eslabón puede fallar por separado (comprobar un bloqueo de verdad): la IP baneada, su regla en el firewall, la conexión rechazada desde la máquina de pruebas y el desbaneo al terminar, que deja el sistema como estaba.Pista: el nombre de la tabla y del set de nftables cambia con la versión de fail2ban; búscalo con
nft list tablesantes de listar el set. Si el servidor solo admite claves, prueba con un usuario que no existe: también cuenta como fallo.
Comprobación El extracto de log tiene el patrón marcado y una explicación de por qué es esa categoría; la configuración muestra jails activas, ignoreip, tiempos y backend; las capturas del bloqueo demuestran que la IP dejó de poder conectar y que al final quedó desbaneada.
Entrega a5.2-accesos.md en ut5/ del repositorio, con el extracto clasificado, la configuración anonimizada y la prueba del bloqueo (salida de fail2ban-client status, cscli decisions list o equivalente, y la regla del firewall). Firma del tutor en la fila A5.2.
Si te sobra tiempo Si hay una aplicación propia que registra los logins fallidos y no tiene filtro, escribe uno como el de fail2ban a fondo y pruébalo con fail2ban-regex sin activarlo. Si la empresa tiene Loki, escribe la regla PasswordSprayingSSH del apartado Detección en Loki y alerta adaptada a su job y comprueba en Explore que la consulta devuelve algo con el log real.
Bloque 3 · Fallos y reinicios (CE 3c)¶
Los dos bloques anteriores enseñan a ver que algo falla; este, a averiguar por qué. El caso más habitual con contenedores es el reinicio: la política de reinicio vuelve a levantar el contenedor en unos segundos, el servicio parece sano y nadie investiga la causa, que sigue ahí. El bloque sigue un reinicio hasta su causa y su corrección, en el orden en que se investiga: primero el método completo, que fija qué se recoge antes de pensar; después el código de salida, que dice quién mató al proceso; luego la causa más frecuente, la memoria y el OOM killer; a continuación los volcados de memoria y los registros de error de cada runtime, para cuando el proceso muere por un fallo propio; y al final la plantilla que lo junta todo en una página. Usa lo de los bloques anteriores: los cinco minutos de log previos al fallo son parte de la investigación. La hoja A5.3 lo aplica a un fallo real de la empresa o, si no ha habido ninguno, a uno reproducido en pruebas.
Fallos y reinicios: crashdumps y registros de error (CE 3c)¶
Un reinicio no es un fallo resuelto. Un contenedor que se reinicia solo y vuelve a funcionar es el fallo más fácil de ignorar y el que más caro sale ignorar. Con restart: unless-stopped en el Compose (la política que hace que Docker lo vuelva a arrancar cuando muere), el servicio está caído unos segundos, el proxy devuelve unos 502, y nadie se entera hasta que el reinicio pasa a ser cada diez minutos. El análisis siempre sigue el mismo camino:
flowchart TD
A["<b>Síntoma</b><br><small>reinicio, 5xx, alerta</small>"]:::dato
B["<b>Código de salida y OOMKilled</b>"]:::act
C["<b>Logs de los 5 minutos previos</b>"]:::act
D["<b>Métricas del momento</b><br><small>memoria, CPU, conexiones</small>"]:::act
E{"<b>¿Hay dump?</b>"}:::dato
F["<b>Analizar el dump</b><br><small>gdb, py-spy, jmap</small>"]:::pieza
G["<b>Habilitar dumps</b><br><small>para la próxima vez</small>"]:::pieza
H["<b>Hipótesis</b>"]:::dato
I["<b>Reproducir en pruebas</b>"]:::act
J{"<b>¿Se reproduce?</b>"}:::dato
K["<b>Corregir el origen</b>"]:::ok
L["<b>Verificar</b><br><small>que no se repite en N días</small>"]:::ok
M(["<b>Cerrar la incidencia con informe</b>"]):::ok
A --> B --> C --> D --> E
E -- sí --> F --> H
E -- no --> G --> H
H --> I --> J
J -- no --> H
J -- sí --> K --> L --> M
classDef act fill:#ea580c22,stroke:#ea580c,stroke-width:1.5px
classDef pieza fill:#64748b22,stroke:#64748b,stroke-width:1.5px
classDef dato fill:#2563eb22,stroke:#2563eb,stroke-width:1.5px
classDef infra fill:#a1a1aa14,stroke:#a1a1aa,stroke-width:1.5px
classDef ok fill:#16a34a22,stroke:#16a34a,stroke-width:1.5px
classDef riesgo fill:#dc262622,stroke:#dc2626,stroke-width:1.5px
El bucle hipótesis → reproducir → hipótesis es el trabajo real. Sin reproducir, corregir es adivinar.
Por qué ese orden. Los tres primeros pasos recogen datos que desaparecen solos: el código de salida se pierde si el contenedor se recrea, los logs de antes del fallo rotan y las métricas de ese minuto quedan enterradas entre las de la semana. Por eso se recogen antes de tener ninguna idea sobre la causa, y no al revés, que es buscar solo lo que confirma la primera sospecha. El volcado de memoria (dump), si existe, dice en qué punto del código murió el proceso; si no existe, se deja preparado para la próxima vez. Solo entonces se formula una hipótesis, y no se da por buena hasta reproducir el fallo en un entorno de pruebas: lo que no se ha reproducido no se puede comprobar que esté corregido. Los apartados siguientes explican cada caja en ese orden.
Códigos de salida¶
De dónde sale el número. Cuando un proceso termina, deja al sistema un número entre 0 y 255: 0 si todo fue bien y otro valor si no, a elección del programa. Si en lugar de terminar lo mata una señal (el aviso que el kernel u otro proceso le manda: SIGTERM le pide que pare, SIGKILL lo mata sin que pueda hacer nada, SIGSEGV le avisa de que ha tocado memoria que no es suya), no llega a elegir ningún número, y por la convención de las shells de Unix, que Docker sigue, se informa como 128 más el número de la señal. Docker guarda el código del proceso principal del contenedor, y es lo primero que se mira, porque acota el problema a una de tres familias: lo mató el kernel, lo mató Docker, o se murió solo.
flowchart LR
E["<b>Código de salida</b><br><small>docker inspect · State.ExitCode</small>"]:::dato
K["<b>Lo mató el kernel</b><br><small>137 · OOM killer</small>"]:::riesgo
D["<b>Lo mató Docker</b><br><small>143 · SIGTERM de un stop</small>"]:::pieza
S["<b>Se murió solo</b><br><small>1, 2, 127… lo dice la aplicación</small>"]:::pieza
K --> QK(["<b>Memoria</b><br><small>límite, fuga o vecino ruidoso</small>"]):::act
D --> QD(["<b>Alguien o algo lo paró</b><br><small>despliegue, reinicio, orquestador</small>"]):::act
S --> QS(["<b>Los logs de la aplicación</b>"]):::act
E --> K & D & S
classDef act fill:#ea580c22,stroke:#ea580c,stroke-width:1.5px
classDef pieza fill:#64748b22,stroke:#64748b,stroke-width:1.5px
classDef dato fill:#2563eb22,stroke:#2563eb,stroke-width:1.5px
classDef infra fill:#a1a1aa14,stroke:#a1a1aa,stroke-width:1.5px
classDef ok fill:#16a34a22,stroke:#16a34a,stroke-width:1.5px
classDef riesgo fill:#dc262622,stroke:#dc2626,stroke-width:1.5px
El código acota antes de leer nada: cada familia lleva a un sitio distinto donde mirar, y ahorra media hora de logs.
| Código | Qué significa | Causa habitual |
|---|---|---|
| 0 | Salida normal | El proceso principal terminó (un script que acaba, un CMD mal pensado) |
| 1 | Error genérico de la aplicación | Excepción no controlada, fichero de configuración que falta, variable de entorno vacía |
| 2 | Uso incorrecto | Argumentos erróneos al binario, típico en shell |
| 125 | Fallo del propio docker run |
Opción inválida, imagen inexistente |
| 126 | El comando no es ejecutable | Permisos del entrypoint, falta el bit x |
| 127 | Comando no encontrado | Ruta errónea en CMD, binario que no está en la imagen |
| 134 | 128 + 6 (SIGABRT) | abort(): aserción fallida, error de memoria detectado por la propia biblioteca (glibc) |
| 137 | 128 + 9 (SIGKILL) | OOM killer (comprobar OOMKilled), o docker stop que agotó el periodo de gracia, o alguien con kill -9 |
| 139 | 128 + 11 (SIGSEGV) | Fallo de segmentación: bug en código nativo, biblioteca incompatible, corrupción de memoria |
| 143 | 128 + 15 (SIGTERM) | Parada pedida desde fuera: docker stop, compose down, reinicio del host; el proceso no falló, lo pararon |
| 255 | Fuera de rango | Muchos programas lo usan como "error no especificado"; SSH lo devuelve al fallar la conexión |
Cómo se lee. Código mayor de 128 es señal (código menos 128 da el número de señal, kill -l N da el nombre). 137 sin OOMKilled=true es que a alguien se le acabó la paciencia con un docker stop (10 s de gracia por defecto, stop_grace_period en Compose) o que lo mató un kill -9; 137 con OOMKilled=true es memoria, y OOMKilled es la marca que pone Docker cuando el que mató al proceso fue el OOM killer. Con 143 el proceso no ha fallado: alguien le pidió parar, y lo que hay que averiguar es quién; un docker events o el journal de dockerd lo dicen.
Dónde se leen. El código, la marca de OOM, el número de reinicios y la hora del fallo están en docker inspect; la historia de los últimos días, en los eventos del demonio:
docker inspect app --format '{{.RestartCount}} {{.State.ExitCode}} {{.State.OOMKilled}} {{.State.FinishedAt}} {{.State.Error}}'
docker events --since 24h --until 0s --filter container=app --filter event=die --filter event=oom --filter event=restart
docker compose ps -a # muestra "Exited (137) 2 hours ago"
RestartCount se pone a cero cuando el contenedor se recrea, así que un 0 no es garantía de nada si alguien hizo compose up esta mañana.
Las políticas de reinicio. Son las que deciden si un fallo se queda en un contenedor parado, que se ve, o en un reinicio, que se esconde. Las de Docker: no, on-failure[:N] (solo si sale con código distinto de 0, como mucho N veces), always y unless-stopped (como always, pero si se paró a mano no arranca con el demonio). En producción lo normal es unless-stopped; on-failure:5 tiene sentido en trabajos por lotes que no deben reintentar sin fin. Docker aplica un retardo creciente entre reinicios (100 ms, 200 ms, 400 ms...) que se reinicia si el contenedor aguanta 10 s; un contenedor en bucle de arranque se reconoce por RestartCount alto y docker ps mostrando "Restarting (1) 3 seconds ago".
OOM: el kernel y los cgroups¶
Los mismos cgroups de las métricas. Los cgroups son el mecanismo del kernel con el que Docker acota la CPU y la memoria de cada contenedor, y de ellos salen también sus métricas (de dónde salen los números). Cuando un contenedor tiene mem_limit (Compose) o --memory, Docker lo traduce a memory.max en el cgroup v2 del contenedor. Si el uso llega al límite y el kernel no puede recuperar suficiente caché, invoca al OOM killer dentro de ese cgroup, que mata el proceso que más memoria consume (lo normal, el principal), y el contenedor sale con 137.
Por qué todo contenedor lleva límite. Si no hay límite, el contenedor puede consumir toda la RAM del host y el OOM killer global mata lo que le parezca, que puede ser otro contenedor o PostgreSQL. Con límite, el daño se queda en el contenedor que se ha pasado: mejor que muera él solo que el host entero.
Dónde queda rastro. El OOM deja tres huellas: el mensaje del kernel, el contador del propio cgroup y la marca OOMKilled de Docker que ya se vio en el apartado anterior. La ruta del cgroup se saca del proceso, porque cambia según cómo esté configurado Docker en cada empresa:
# Mensajes del OOM killer, con marca de tiempo legible
dmesg -T | grep -iE "oom|killed process" | tail
journalctl -k --since -24h | grep -iE "oom-kill|out of memory"
# Ejemplo de lo que aparece:
# Memory cgroup out of memory: Killed process 21877 (python3) total-vm:1893204kB, anon-rss:1044120kB ...
# oom-kill:constraint=CONSTRAINT_MEMCG,...,task=python3,pid=21877,uid=1000
# Límite y uso del cgroup del contenedor (cgroup v2)
PID=$(docker inspect app --format '{{.State.Pid}}')
CG=/sys/fs/cgroup$(cut -d: -f3 /proc/$PID/cgroup)
cat $CG/memory.max $CG/memory.current
cat $CG/memory.events # oom_kill N
head -20 $CG/memory.stat
docker stats --no-stream app
Cómo se leen. memory.events lleva un contador oom_kill con los procesos que el OOM killer ha matado dentro del cgroup, y es la forma más limpia de saber si hubo OOM aunque el mensaje del kernel ya no esté. Tiene un límite: el cgroup desaparece cuando el contenedor se para, así que el contador cuenta desde el último arranque. Donde más sirve es en el caso que no deja reinicio: si el kernel mata un proceso hijo (un worker que el proceso principal vuelve a lanzar), el contenedor sigue vivo, RestartCount no se mueve, y solo oom_kill y el journal del kernel lo cuentan. anon-rss en el mensaje del kernel es la memoria realmente usada por el proceso; total-vm es la reservada y no dice mucho.
El mismo dato en Prometheus. container_memory_working_set_bytes de cAdvisor (el exporter que publica las métricas de cada contenedor) es la memoria que el kernel no puede soltar, la que hay que comparar con el límite (memory usage frente a working set), y container_oom_events_total cuenta los OOM. Un panel con el working set y el límite container_spec_memory_limit_bytes superpuestos muestra la fuga como una rampa que sube hasta tocar la línea y cae en vertical.
Fuga o pico. Una fuga de memoria y un pico de carga se ven distinto en ese panel: la fuga es una rampa constante, día tras día, hasta el OOM; el pico es un escalón que coincide con un evento (la copia de seguridad, un informe pesado, un ataque). La corrección es diferente: la fuga se arregla en el código (o se mitiga con reinicios programados mientras se arregla), el pico con más límite o con menos carga.
Core dumps en contenedores¶
Qué es y para qué sirve. Un core dump (volcado de memoria) es la imagen de la memoria del proceso en el momento de morir por una señal como SIGSEGV o SIGABRT. Con él y el mismo binario, un depurador dice en qué función y por qué camino del código murió el proceso, en lugar de adivinarlo por los síntomas. Es lo que hace falta cuando el código de salida es 134 o 139: el proceso no lo mató nadie de fuera, se murió por un fallo propio.
Quién lo escribe y dónde. El kernel escribe el volcado donde diga core_pattern, uno de sus parámetros. Si empieza por |, se lo pasa a un programa: systemd-coredump (en Debian es un paquete aparte), que los guarda y los lista con coredumpctl, o apport en Ubuntu. Si es una ruta, escribe un fichero; con el valor de fábrica, core, en el directorio de trabajo del proceso. Además, cada proceso tiene un tamaño máximo de volcado, el ulimit -c, y con 0 no se escribe ningún fichero.
Los dos obstáculos de un contenedor. core_pattern es del kernel y por tanto del host, no del contenedor: un |/usr/lib/systemd/systemd-coredump ... del host se ejecuta en los espacios de nombres del host (los namespaces, que deciden qué ve cada proceso) y a veces no puede leer el binario del contenedor. Y el ulimit -c del contenedor suele ser 0. La salida habitual es un patrón de fichero en un directorio que comparten el host y el contenedor:
# En el host
cat /proc/sys/kernel/core_pattern
# Si empieza por | lo recoge un programa (systemd-coredump, apport); si no, escribe un fichero
coredumpctl list
coredumpctl info 21877
coredumpctl debug 21877 # abre gdb sobre el dump
# Alternativa para contenedores: patrón de fichero en un directorio compartido
echo '/var/crash/core.%e.%p.%t' > /proc/sys/kernel/core_pattern
sysctl -w fs.suid_dumpable=2
# docker-compose.yml: permitir dumps y montar el directorio
services:
app:
ulimits:
core: -1
volumes:
- /var/crash:/var/crash
Por qué la misma ruta dentro y fuera. El directorio /var/crash tiene que existir dentro del contenedor en la misma ruta que indica core_pattern, porque el kernel escribe la ruta interpretada desde el espacio de nombres de montaje del proceso que murió. Y hay que vigilar el tamaño: un dump de una JVM con 4 GB de heap ocupa 4 GB.
Antes de habilitarlo en un sistema ajeno. Hay dos cosas que pesar antes de habilitar esto en un sistema que no sea el propio. core_pattern y fs.suid_dumpable son parámetros del kernel del host: el cambio afecta a todos los contenedores y a los procesos del propio host, no solo al servicio que se está investigando. Y un volcado es una copia literal de la memoria del proceso, con lo que hubiera dentro en claro: contraseñas, tokens de sesión y datos personales. Por eso el volcado se trata como un dato a proteger (directorio con permisos restrictivos, retención corta, borrado en cuanto deja de hacer falta) y, fuera de un laboratorio propio, habilitar los volcados es un cambio que se autoriza, se anota y se revierte, no una configuración que se deja puesta por si acaso.
Cómo se analiza, según el runtime. Hay una herramienta por lenguaje: gdb es el depurador clásico de binarios nativos (C, C++, Go, Rust) y es el que lee el dump; py-spy inspecciona un proceso Python vivo sin pararlo; jcmd, jmap y jstack son las utilidades de la JVM para volcar memoria e hilos. Conviene fijarse en dos detalles del bloque: gdb se ejecuta dentro de la misma imagen que murió, y Java no necesita el dump del kernel porque genera el suyo.
# C/C++/Go/Rust: gdb básico sobre el dump, con el mismo binario y bibliotecas
docker run --rm -it -v /var/crash:/var/crash --entrypoint bash app-imagen:1.4.2
gdb /usr/local/bin/servidor /var/crash/core.servidor.17.1746340361
(gdb) bt # traza de pila del hilo que falló
(gdb) info threads # todos los hilos
(gdb) thread apply all bt # traza de todos
(gdb) frame 3 # saltar a un marco concreto
(gdb) info locals # variables locales de ese marco
# Python: py-spy no necesita dump, inspecciona el proceso vivo (colgado, no muerto)
pipx install py-spy # en Debian 12 o posterior, pip no instala fuera de un entorno virtual
py-spy dump --pid $(docker inspect app --format '{{.State.Pid}}')
py-spy top --pid <pid> # qué funciones consumen CPU ahora mismo
# Un proceso Python que ha muerto por segfault viene de una extensión en C: gdb + python-gdb.py
# Java: heap dump para fugas, thread dump para cuelgues
docker exec app jcmd 1 GC.heap_info
docker exec app jmap -dump:live,format=b,file=/var/crash/heap.hprof 1
docker exec app jstack 1 > /var/crash/threads.txt
# O que la JVM lo haga sola al morir por OOM:
# JAVA_TOOL_OPTIONS="-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/var/crash"
Para gdb el detalle importante es que el binario y las bibliotecas tienen que ser exactamente los del contenedor que murió; por eso se abre gdb dentro de la misma imagen (y versión). gdb casi nunca viene en la imagen: se instala en ese contenedor temporal, que se tira al salir. Sin símbolos de depuración la traza da direcciones y nombres de función, que suele bastar para saber en qué biblioteca ha reventado. jcmd, jmap y jstack vienen con el JDK (el kit de desarrollo de Java), así que en una imagen que solo lleva el JRE (el entorno de ejecución) no están. Los .hprof de Java se analizan con Eclipse MAT o VisualVM en un equipo de trabajo, no en el servidor. El PID 1 en los docker exec es el proceso principal del contenedor; si la aplicación arranca a través de un script, el PID real se busca con docker top app.
Registros de error de la aplicación¶
No todo fallo deja volcado. Aparte de lo que escriben por stdout, muchos runtimes dejan su propio informe cuando mueren, y es lo primero que se busca después del código de salida:
- La JVM escribe
hs_err_pid<N>.logen el directorio de trabajo cuando muere por un error fatal, con la traza, los hilos y el estado de la memoria. Es lo primero que se busca en un 134 de Java. - Node deja la traza en stderr y, arrancado con
--report-on-fatalerror, genera además un informe de diagnóstico en JSON. - PostgreSQL escribe en
/var/log/postgresql/postgresql-17-main.log(o en la salida del contenedor, con la imagen oficial). Ahí aparecen elFATAL: sorry, too many clients alreadyde un pool de conexiones mayor de lo que admite la base de datos o elserver process (PID ...) was terminated by signal 9de un proceso de PostgreSQL que mató el OOM killer, y son los que explican los errores que la aplicación registra en ese mismo minuto. - El navegador: los errores de un front están en el cliente y no en el servidor. Sin un servicio tipo Sentry o GlitchTip (plataformas que reciben los errores del navegador y del servidor y los agrupan) que los recoja, solo se ven en la consola del usuario, y la evidencia es la captura que este envíe.
El método y la plantilla de informe¶
Primero se recopila, después se piensa. Es el orden del diagrama del principio del apartado. Se reúnen el dump o el log de error, las líneas de log de los cinco minutos previos al fallo (docker logs --until "2027-05-04T02:41:00" --since "2027-05-04T02:36:00" app) y las métricas de ese instante (memoria, CPU, conexiones, latencia; en Grafana, el intervalo de tiempo fijado a esos diez minutos y una captura). Con eso se formula una hipótesis, se intenta reproducir en un entorno previo de la empresa (nunca en producción, porque reproducir un fallo es provocarlo), se corrige el origen y no el síntoma (subir el límite de memoria para una fuga es un parche, arreglar la fuga es la corrección; poner restart: always a algo que muere cada hora no es corregir nada), y se verifica durante días que el reinicio no se repite. Todo va a la incidencia.
Una página, nueve puntos. El informe de A5.3 es una página y sigue la estructura de abajo. La página obliga a separar lo que importa de lo que no; los nueve puntos siguen el orden del diagrama, de modo que quien lo lee puede comprobar cada conclusión con el dato que la sostiene y ver qué queda pendiente:
1. Síntoma y detección: qué se vio, cuándo, cómo se detectó (alerta, revisión, aviso)
2. Datos del fallo: contenedor, imagen y versión, código de salida, OOMKilled,
RestartCount, mensaje del kernel o del runtime (literal)
3. Contexto: log de los 5 min previos (extracto anonimizado), métricas del momento
(captura o tabla: memoria usada/límite, CPU, conexiones, latencia p95)
4. Análisis del dump o del registro de error: traza, hilo, función, o "no había dump,
se ha habilitado para la próxima"
5. Hipótesis (una principal, alternativas descartadas y por qué)
6. Reproducción: dónde, cómo, resultado
7. Corrección: qué se cambió (fichero, valor antes y después), dónde (pruebas o
el sistema real) y quién lo aprobó
8. Verificación: métrica o comando que demuestra que no se repite, y durante cuánto tiempo
9. Acciones pendientes: lo que queda para el equipo de desarrollo o para más adelante
A5.3 Fallos y reinicios (CE 3c)¶
Objetivo Un informe de una página, con los nueve puntos de la unidad, sobre un reinicio o fallo real (o reproducido en pruebas) que llegue hasta la corrección y su verificación. Lo que cuenta no es que el fallo sea espectacular, sino el recorrido: datos recogidos antes de opinar, una hipótesis que se comprueba reproduciendo, y una corrección del origen que se verifica con un número.
Antes de empezar
- Autorización del tutor en dos niveles: lectura (
docker inspect,docker events, el journal del kernel y los dumps que ya existan) y, aparte y explícita, cualquier cambio en la máquina de la empresa: instalar gdb o py-spy, inspeccionar un proceso vivo, habilitar volcados de memoria (paso 3) o aplicar la corrección. Si no ha habido ningún fallo en el periodo, acuerda con él un entorno de pruebas (nunca producción) donde reproducir uno, y quién aprueba la corrección si la hay. - Un contenedor de pruebas propio con la misma imagen y versión del servicio, en tu equipo o en ese entorno de pruebas. Ahí se hace lo que toca el kernel del host, que en el sistema de la empresa casi nunca se autoriza.
- Acceso a las métricas del momento del fallo (Grafana o
sar, que se explica en el bloque 4, en Cómo se toma y se guarda la línea base). - Leídos Fallos y reinicios, con su diagrama, Códigos de salida, OOM: el kernel y los cgroups, Core dumps en contenedores, Registros de error de la aplicación y El método y la plantilla de informe.
Pasos
Sigues el diagrama del método de arriba abajo: primero fijas los datos del fallo, que se pierden si esperas; después reúnes el contexto; luego buscas la causa dentro del proceso, con el volcado o el registro de error; con todo eso formulas la hipótesis, la compruebas reproduciendo y corriges; y al final mides que no se repite. Cada paso rellena uno o dos puntos de la plantilla, así que el informe se va escribiendo a la vez que investigas.
-
Fija los datos del fallo. Identifica el fallo con los comandos de Códigos de salida y anota el código de salida (con su señal, si pasa de 128),
OOMKilled,RestartCount, la hora del fallo, la imagen y su versión. Va primero porque son los datos que antes desaparecen: basta uncompose uppara que el contenedor sea otro yRestartCountvuelva a cero. Si en el periodo no ha habido ningún fallo, reprodúcelo en el entorno de pruebas acordado.Pista: lo más sencillo de reproducir es un OOM: un contenedor con
mem_limit: 64my unpython3 -cque vaya llenando una lista con bloques de 1 MB. -
Reúne el contexto del momento. Recoge el log de los cinco minutos previos, el mensaje del kernel o el contador
oom_killsi hubo OOM (OOM: el kernel y los cgroups), y la memoria, la CPU y las conexiones de esos diez minutos en una captura de Grafana o una tabla desar. El código de salida dice quién mató al proceso; el contexto dice qué estaba pasando cuando lo mataron, y de ahí sale la hipótesis.Pista: la hora del fallo es el
FinishedAtdedocker inspect, ydocker logsacepta--sincey--untilcon fecha y hora. -
Busca la causa dentro del proceso. Analiza el dump o el registro de error según el runtime, con Core dumps en contenedores y Registros de error de la aplicación. Si no había dump, comprueba cómo se generaría: en la máquina de la empresa solo con autorización escrita del tutor (anota antes el
core_patternpara restaurarlo y acuerda la retención de/var/crash) y, si no la hay, que es lo normal, en tu contenedor de pruebas. El informe recoge entonces la propuesta para el host (core_pattern,ulimitsdel servicio, espacio en/var/crashy retención), que para el punto 4 de la plantilla vale igual que un volcado real.Pista: para provocar el volcado, la señal va a un proceso hijo (
sh -c 'sleep 60 & kill -SEGV $!'dentro del contenedor); si la recibe el PID 1,/var/crashse queda vacío aunque la configuración esté bien. -
Formula la hipótesis, reprodúcela y corrige el origen. Escribe una hipótesis principal y las alternativas que descartas, con el motivo: descartar también es un resultado, porque dice dónde ya no hay que mirar. Reproduce en pruebas y corrige allí el origen, no el síntoma, como se explica en El método y la plantilla de informe, anotando fichero y valor antes y después. Llevar la corrección al sistema de la empresa lo decide el tutor: si no lo aprueba, el informe la deja como propuesta en las acciones pendientes.
-
Mide que no se repite. Verifica la corrección donde se aplicó: en el sistema de la empresa, durante los días que queden de estancia; en pruebas, repitiendo la reproducción con la corrección puesta. Indica cuántos días o cuántas repeticiones, porque una verificación sin duración no demuestra nada: un fallo que salía cada tres días puede no salir en dos.
Pista:
RestartCountestable,oom_killsin subir o el panel sin nuevas caídas son verificaciones que se pueden medir.
Comprobación El informe cabe en una página, sigue los nueve puntos en orden, incluye el mensaje del kernel o del runtime literal y termina con una verificación medible y con las acciones pendientes.
Entrega a5.3-fallos.md en ut5/ del repositorio. Firma del tutor en la fila A5.3.
Si te sobra tiempo Añade al panel de revisión diaria un gráfico con container_memory_working_set_bytes y container_spec_memory_limit_bytes superpuestos para el contenedor analizado, y una alerta sobre container_oom_events_total.
Bloque 4 · Rendimiento (CE 3d)¶
"Va lento" es la queja más frecuente y la más vaga, y sin una referencia no hay forma de contestarla: un 60 % de CPU puede ser lo de todos los días o el principio de un problema. Este bloque construye esa referencia y la usa, en este orden: primero la línea base, los valores normales del sistema en una semana corriente, y cómo se guarda para poder compararla; después el método USE, que recorre cada recurso con las mismas tres preguntas para no dejarse ninguno; luego el caso de las máquinas virtuales, donde la causa puede estar fuera de la máquina que se mira; y al final cómo se pasa del diagnóstico a una propuesta con su coste. Es el último bloque porque necesita los otros (una carga anormal puede ser un ataque del bloque 2, una memoria que sube, la fuga del bloque 3), pero la grabación de datos empieza el primer día de estancia: si nadie la está haciendo ya, la semana de línea base no se recupera hacia atrás. La hoja A5.4 compara esa semana con un periodo de carga y termina en una propuesta.
Rendimiento del equipo (CE 3d)¶
Un número solo no dice nada. El rendimiento no se evalúa con un número, se evalúa contra una línea base: los mismos indicadores en una semana normal. "CPU al 60 %" no dice nada; "CPU al 60 % cuando la línea base de ese día y esa hora es 25 %" sí. Sin línea base, la mitad de las alertas de rendimiento son falsas y la otra mitad llegan tarde. Es la misma regla que se siguió para fijar los umbrales de los KPI, ninguno sin mirar antes la distribución real (cómo fijar umbrales), aplicada a un sistema que no se conoce: primero hay que medir qué es lo normal.
Cómo se toma y se guarda la línea base¶
Una forma, no un promedio. La línea base no es un promedio plano: tiene forma (el pico de las 9:00, el valle de la noche, la copia de las 02:00), y lo que se compara es la forma. Por eso se toma durante una semana entera de días normales, y un valor se compara con el de la misma hora del mismo día de la semana anterior.
Con Prometheus, ya está tomada. Si node_exporter y cAdvisor llevan semanas en marcha, la línea base son las últimas dos o cuatro semanas que guarda Prometheus, y es la toma más útil porque no hay que esperar a nada. En Grafana se superpone añadiendo al panel una segunda consulta igual con offset 1w, el modificador de PromQL que evalúa la consulta como si fuera una semana antes: la serie de hace siete días, pintada en gris debajo de la actual. La opción time shift del panel no superpone, sino que desplaza el panel entero; sirve para tener al lado un panel gemelo con la semana anterior.
# Uso de CPU del host ahora frente a hace una semana
100 * (1 - avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])))
100 * (1 - avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[5m] offset 1w)))
# Percentil 95 de la última semana, como número de referencia
quantile_over_time(0.95, (100 * (1 - avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[5m]))))[7d:5m])
Cómo se leen estas consultas. node_cpu_seconds_total es un contador, un número que solo sube (tipos de métrica), con los segundos que cada núcleo ha pasado en cada modo. rate() sobre el modo idle da la fracción del tiempo en que ha estado parado, avg by (instance) hace la media de todos los núcleos de cada máquina, y uno menos eso es la fracción ocupada. quantile_over_time recorre los valores de la última semana (la subconsulta [7d:5m] los calcula cada cinco minutos) y devuelve su percentil 95: el número de referencia que no supera el 95 % de las muestras.
Sin Prometheus, con sar. Si no hay Prometheus, la línea base se toma con sar, el grabador de estadísticas del sistema del paquete sysstat, que graba cada 10 minutos en /var/log/sysstat/ y guarda tantos días como diga HISTORY en /etc/sysstat/sysstat, que en Debian es 7: sar -u -f /var/log/sysstat/sa04 da la CPU de todo el día 4, sar -r la memoria, sar -d el disco, sar -n DEV la red. Conviene activarlo la primera semana en la empresa si no está (ENABLED="true" en /etc/default/sysstat) y subir a la vez HISTORY a 28, el máximo con el que los ficheros siguen llamándose saDD: la semana de datos es el requisito de A5.4, no se puede recuperar hacia atrás y con 7 días el primero puede borrarse antes de procesarlo.
Cómo se guarda. Se guarda como tabla, con lo mínimo que hace falta para comparar después: por recurso, valor medio y p95 (el valor que el 95 % de las muestras no supera, para que un pico aislado no distorsione) en horario laboral, la media fuera de él y la hora que define la forma (pico de la mañana, batch nocturno). Y se guarda con fecha y con la versión de la aplicación desplegada en ese momento, porque una línea base tomada con la versión 1.4 no sirve para juzgar la 1.6 si el despliegue cambió el consumo.
| Recurso | Métrica | Media laboral | p95 laboral | Media nocturna | Hora del pico |
|---|---|---|---|---|---|
| CPU host | % uso | 22 | 48 | 6 | 09:15 |
| CPU contenedor app | % de un núcleo | 35 | 80 | 4 | 09:15 |
| Memoria app | working set / límite | 410 MB / 1 GB | 520 MB | 380 MB | 17:40 |
| Disco vdb (datos) | %util, await ms | 12, 1.8 | 35, 4.1 | 45 (02:00) | 02:10 |
| Red ens18 | Mbit/s entrada/salida | 3 / 11 | 9 / 30 | 0.4 / 1 | 12:30 |
| Peticiones | req/s, p95 latencia ms | 18, 120 | 45, 260 | 2, 90 | 09:15 |
USE por recurso: comandos y métricas¶
Tres preguntas por recurso. El método USE (Utilization, Saturation, Errors), que sirvió para elegir qué medir al definir los KPI (USE para recursos, RED para servicios), es aquí una lista de comprobación para diagnosticar: para cada recurso, cuánto se usa, cuánta cola hay esperando por él, y cuántos errores da. Recorrer la tabla entera evita el error más común de un diagnóstico de rendimiento, que es mirar la CPU, verla tranquila y concluir que todo va bien cuando el cuello estaba en el disco. Para cada recurso, la tabla da el comando, la métrica de Prometheus equivalente y la señal de cada pregunta, para poder usar en la empresa lo uno o lo otro.
| Recurso | Comando | Métrica Prometheus | Utilización | Saturación | Errores / señal de problema |
|---|---|---|---|---|---|
| CPU | top, mpstat -P ALL 1, docker stats |
node_cpu_seconds_total, container_cpu_usage_seconds_total, container_cpu_cfs_throttled_periods_total |
% no idle > 80 % sostenido | load average > número de núcleos (node_load1), columna r de vmstat alta, throttling de CFS en contenedores con límite de CPU |
%steal alto en VM (ver abajo) |
| Memoria | free -h, vmstat 1 (columnas si/so) |
node_memory_MemAvailable_bytes, container_memory_working_set_bytes frente a container_spec_memory_limit_bytes |
working set cerca del límite | swap activo (si/so > 0 de forma continua), node_vmstat_pswpin |
OOM kills (container_oom_events_total) |
| Disco | iostat -xz 1, df -h, df -i |
node_disk_io_time_seconds_total, node_disk_read_time_seconds_total, node_filesystem_avail_bytes |
%util > 80 % |
await > 10 ms en SSD o > 30 ms en disco rotacional, aqu-sz creciente |
errores en dmesg (I/O error, reset), espacio < 15 %, inodos < 10 % |
| Red | ss -s, ss -tan state established \| wc -l, iftop -i ens18, ip -s link |
node_network_receive_bytes_total, node_network_transmit_drop_total, node_netstat_Tcp_RetransSegs |
Mbit/s frente a la capacidad del enlace | cola de conexiones (ss -ltn columna Recv-Q en escucha), SYN en ss -s |
retransmisiones, drop y errors en ip -s link, overruns |
| Procesos | ps aux --sort=-%mem \| head, pidstat -r -u 5, docker top app |
container_memory_working_set_bytes por contenedor, process_resident_memory_bytes de la propia aplicación |
un proceso con % desproporcionado | número de hilos creciente (ps -o nlwp), descriptores de fichero (ls /proc/<pid>/fd \| wc -l) frente a ulimit -n |
un proceso que crece sin parar (fuga), zombis (Z en ps) |
El throttling de CFS. Cuando un contenedor tiene límite de CPU (cpus: en Compose), el planificador del kernel, CFS, le da una cuota de tiempo de CPU en cada periodo de 100 ms; si la gasta antes de que acabe el periodo, lo frena hasta el siguiente. Eso es el throttling, y container_cpu_cfs_throttled_periods_total cuenta los periodos en que ha ocurrido. Es saturación aunque la CPU del host esté tranquila: el contenedor va lento porque su límite no le deja usar más.
vmstat e iostat. Son los dos comandos que merecen explicación aparte. vmstat 1 5 da cinco muestras de un segundo; las columnas que importan son r (procesos esperando CPU; si es mayor que los núcleos de forma continua, hay saturación de CPU), b (bloqueados en E/S), si/so (swap in/out en KB/s; cualquier valor sostenido distinto de cero es un problema) y wa (% de CPU esperando E/S; alto quiere decir que el disco es el cuello de botella, no la CPU). iostat -xz 1 con -x da las columnas extendidas y -z oculta los discos sin actividad; %util es el porcentaje de tiempo con al menos una petición en curso, await la latencia media de cada petición en milisegundos, y aqu-sz la longitud media de la cola. Las versiones recientes de iostat dan la latencia partida en r_await (lecturas) y w_await (escrituras); sar -d la da junta como await. En un SSD virtual de Proxmox, await por encima de 10 ms de forma continua indica que el almacenamiento del host está saturado, y eso no se arregla desde la VM.
El steal en máquinas virtuales¶
La única columna que apunta fuera. %st en top o mpstat es el tiempo en que la VM quería ejecutar y el hipervisor le dio la CPU a otra. Es la única columna que apunta fuera de la máquina que se está mirando. Un steal por encima del 5 al 10 % sostenido significa que el host de Proxmox (o el de la nube) está sobresuscrito: hay más vCPU asignadas de las que el host puede atender a la vez. Desde dentro de la VM la aplicación va lenta sin que ninguna métrica interna lo explique (CPU al 40 %, latencia el doble). En Prometheus es rate(node_cpu_seconds_total{mode="steal"}[5m]), y una alerta razonable es steal > 10 % durante 15 minutos. La acción no es de la VM: es hablar con quien administra el hipervisor (en la empresa, el equipo de sistemas; en el laboratorio, mirar en el nodo de Proxmox qué otra VM se está comiendo los núcleos).
Señales de problema y acciones¶
Del diagnóstico a la propuesta. La comparación con la línea base termina en una propuesta, y una propuesta dice qué se cambia, no solo qué se ha visto. Las propuestas tipo, para no quedarse en "la CPU está alta":
- CPU del contenedor frenada por CFS al agotar su cuota
cpus:(el throttling de CFS;throttled_periodscreciente con uso por debajo del 100 % del host): subircpus:en el Compose o quitar el límite si el host tiene margen. Caso muy frecuente con límites puestos "por si acaso" a 0,5 CPU. - Memoria del contenedor con rampa hasta el límite y OOM: fuga; pedir corrección a desarrollo y, mientras, reinicio programado en la ventana de menor carga, con
restarty monitorización del contador de OOM. Si no es rampa sino escalón, subir el límite y justificarlo con el nuevo consumo. - Disco con
awaitalto ywaalto en la ventana de la copia de seguridad: mover la copia, limitar su E/S conionice -c3opg_dumpcon--jobsmenor, o pedir almacenamiento más rápido para la BD. - Disco lleno o cerca: revisar la retención de logs (Retención: logrotate y journald, en el bloque 1),
docker system dfydocker system prunede imágenes huérfanas, dumps olvidados en/var/crash(Core dumps en contenedores). - Red con retransmisiones: mirar primero el enlace físico o virtual (
ip -s link, errores en la interfaz), después la MTU (el tamaño máximo de paquete de la interfaz) si hay túneles (WireGuard o VXLAN restan cabecera; 1420 o 1450 son valores habituales), después el firewall que pueda estar descartando. - Latencia p95 alta con todos los recursos del host tranquilos: el cuello está fuera (base de datos, servicio externo, DNS) o en el pool de conexiones de la aplicación; se ve en el log de la aplicación y en las métricas de la BD (
pg_stat_activity, consultas lentas), no entop. - Carga muy por encima de la línea base sin ninguna causa interna: mirar los accesos, como en el bloque 2. Un escáner o un ataque a la API se ve antes en
req/spor IP que en la CPU.
Cada propuesta, con su coste. Quien decide si se aplica necesita saber qué cuesta y qué se gana. Se documenta con una tabla antes/después (línea base, periodo de carga, diferencia, umbral) o con una captura del panel con la línea base superpuesta, y se propone la acción con su coste: ajustar límites (gratis, inmediato), escalar (dinero o recursos del hipervisor), optimizar una consulta (tiempo de desarrollo), limpiar disco (gratis, pero recurrente si no se arregla la retención).
A5.4 Rendimiento (CE 3d)¶
Objetivo Una línea base de CPU, memoria, disco y red de una semana, comparada con un periodo de carga, y una propuesta de acción justificada con su coste y su efecto esperado. Es lo que permite contestar a "va lento" con datos: qué ha cambiado respecto a lo normal, en qué recurso, y qué cuesta arreglarlo.
Antes de empezar
- Autorización del tutor para consultar Prometheus y Grafana o, si no los hay, para activar
sysstaten el equipo, que es un cambio y por tanto se acuerda. Empieza el primer día de estancia: la semana de datos no se recupera hacia atrás. - Acordar con el tutor cuál será el periodo de carga: cierre de mes, campaña, una prueba de carga como las que hiciste con k6 o el día de más tráfico de la semana siguiente.
- Leídos Rendimiento del equipo, Cómo se toma y se guarda la línea base, USE por recurso: comandos y métricas, El steal en máquinas virtuales si el sistema es una VM, y Señales de problema y acciones.
Pasos
Aquí el orden lo marca el calendario: lo primero es poner a grabar, porque si nadie lo está haciendo ya, la semana de datos no se recupera hacia atrás, y mientras se graba, haces las otras actividades. Con la semana completa construyes la referencia; después mides el periodo de carga con las mismas medidas, pones una frente a la otra, y la propuesta sale de la diferencia.
-
Pon a grabar desde el primer día. Asegúrate de que se graban datos de CPU, memoria, disco y red, con Prometheus (
node_exportery cAdvisor en UP) o consar, y anota la versión de la aplicación desplegada. Sin la versión, la línea base no sirve para juzgar la siguiente, porque un despliegue puede cambiar el consumo (Cómo se toma y se guarda la línea base).Pista: en Debian,
sarguarda solo 7 días: subeHISTORYa 28 antes de empezar a contar, o el primer día puede borrarse antes de que lo proceses. -
Construye la referencia. Tras la semana, rellena la tabla de línea base de Cómo se toma y se guarda la línea base, una fila por recurso (CPU, memoria, disco y red), con fecha y versión de la aplicación. La media y el p95 en horario laboral, la media nocturna y la hora del pico son lo que da forma a la semana: con ellos, un valor del periodo de carga se puede llamar normal o anormal.
Pista:
sarno da percentiles:sadf -dexporta cada día en CSV y el p95 se calcula en una hoja de cálculo. Con Prometheus, la consulta del p95 está en ese apartado. -
Mide el periodo de carga, recurso a recurso. En el periodo de carga, toma las mismas medidas y responde para cada recurso las tres preguntas de USE con los comandos o las métricas de USE por recurso. Las tres preguntas en todos los recursos, aunque alguno parezca tranquilo: así no se te escapa un cuello de botella que no está donde lo esperabas.
Pista: si el sistema es una VM, mira también
%st, la única columna que apunta fuera de la máquina (El steal en máquinas virtuales). -
Pon la carga frente a lo normal. Compara la línea base con el periodo de carga en una tabla antes/después (línea base, carga, diferencia, umbral) o en una captura del panel con la serie de hace una semana superpuesta con
offset 1w. La diferencia es la evidencia de lo que cambia con la carga, y el recurso donde más cambia es por donde va la propuesta. -
Propón una acción con su coste. Redacta la propuesta a partir de Señales de problema y acciones: qué se cambia, qué cuesta (gratis, recursos, dinero o tiempo de desarrollo) y qué métrica esperas que cambie y cuánto. El valor esperado es lo que permitirá comprobar después si la acción funcionó.
Comprobación La línea base cubre cinco días laborables representativos (sin festivos ni vacaciones de medio equipo) y lleva fecha y versión; cada recurso tiene sus tres preguntas USE respondidas; la propuesta nombra una métrica concreta y un valor esperado.
Entrega a5.4-rendimiento.md en ut5/ del repositorio, con la tabla o la captura y la propuesta. Firma del tutor en la fila A5.4.
Si te sobra tiempo Propón al tutor una alerta sobre la desviación respecto a la línea base (por ejemplo, CPU por encima del doble del valor de hace una semana durante 30 minutos) en lugar de un umbral fijo.
Práctica evaluable¶
En esta unidad no hay una práctica de laboratorio aparte: la práctica evaluable es el conjunto de las cuatro evidencias más el procedimiento de cierre, con la ficha firmada por el tutor de empresa, porque lo que se evalúa es el trabajo hecho sobre un sistema real y no un ejercicio montado para la ocasión. Se entrega por Aules la carpeta ut5/ del repositorio de la unidad (un fichero Markdown o PDF por actividad, más rutina.md y la ficha escaneada) antes del 11 de junio de 2027, dos días después de terminar la estancia, y ese mismo día se empuja el repositorio a Gitea, que hasta la vuelta al centro no se alcanza. Todo anonimizado; una evidencia con datos identificables de la empresa se devuelve sin corregir hasta que se anonimice.
Entregables:
-
a5.1-logs.md: registro de cinco días (A5.1, paso 2) e incidencia reportada (A5.1, paso 3) -
a5.2-accesos.md: extracto de log clasificado (A5.2, paso 2), configuración revisada (A5.2, paso 3) y prueba de bloqueo (A5.2, paso 4, o la negativa del tutor anotada) -
a5.3-fallos.md: informe de análisis de una página (A5.3, pasos 1 a 5) -
a5.4-rendimiento.md: tabla de línea base (A5.4, paso 2), comparación (A5.4, paso 4) y propuesta (A5.4, paso 5) -
rutina.md: procedimiento diario y semanal de dos páginas (actividad de cierre) -
ficha-evidencias.pdf: firmada por el tutor
| Criterio | CE | Peso | Qué se valora |
|---|---|---|---|
| A5.1 Revisión de logs | 3a | 20 % | Registro completo y constante; incidencia con mensaje literal, frecuencia, impacto y fragmento; comandos o consultas adecuados al sistema |
| A5.2 Accesos y fuerza bruta | 3b | 20 % | Patrón correctamente identificado y explicado; configuración revisada (ignoreip, tiempos, backend); bloqueo comprobado de extremo a extremo, si el tutor autoriza la prueba |
| A5.3 Fallos y reinicios | 3c | 20 % | Datos completos del fallo; hipótesis razonada y alternativas descartadas; corrección del origen, en pruebas o en el sistema real, y verificación |
| A5.4 Rendimiento | 3d | 20 % | Línea base representativa y bien guardada; USE aplicado; propuesta justificada con coste y efecto esperado |
| Procedimiento de cierre | a, b, c, d | 15 % | Revisado por otra persona y sin dudas pendientes; comandos reales; criterios de normalidad y de escalado |
| Forma y anonimización | 5 % | Evidencias legibles, con fecha, sin datos identificables; ficha firmada |
Las observaciones del tutor de empresa en la ficha se tienen en cuenta en cada apartado. Una actividad sin firma del tutor no puntúa.
Actividad de cierre¶
Objetivo Un procedimiento de dos páginas, "Rutina de revisión diaria y semanal de logs, accesos, fallos y rendimiento en la empresa", que pueda seguir alguien que llegue nuevo al puesto. Es la prueba de que lo hecho en las cuatro actividades se puede transmitir: si otra persona puede hacer tu revisión con tu documento, la rutina sobrevive a tu marcha.
Antes de empezar
- Las cuatro actividades hechas y el fichero con fecha, comando y resultado que has ido guardando desde el primer día.
- Visto bueno del tutor sobre qué comandos y rutas pueden aparecer (anonimizados) en el procedimiento.
- El diagrama de rutina de Cómo trabajar en la empresa como esqueleto.
Pasos
Partes del esqueleto que ya tienes, el diagrama de la rutina, y lo rellenas con lo que has hecho de verdad estas semanas; después le añades lo que necesita quien empieza de cero, y al final lo pruebas con un lector real, que es la única forma de saber si se entiende sin ti.
- Rellena el esqueleto. Para cada caja del diagrama (cuatro diarias, cuatro semanales) escribe una fila: qué se mira, herramienta, comando o consulta literal, tiempo estimado, qué se considera normal y qué dispara una incidencia. Los comandos salen del fichero que has ido guardando desde el primer día, y lo normal, de la línea base de la A5.4.
- Añade lo que necesita quien llega nuevo. Pon al principio los accesos necesarios (usuario, hosts, paneles) y al final la plantilla de incidencia y a quién se escala. Son las dos cosas que no se ven en ningún comando, y sin ellas el procedimiento no se puede empezar ni terminar.
- Pruébalo con otra persona. Pide al tutor o a un compañero que lo lea con el sistema delante y marque lo que no sabría ejecutar sin preguntarte; corrígelo. Cada pregunta que te haga es un hueco del documento, y por eso el criterio pide que no quede ninguna pendiente.
Comprobación Dos páginas, todos los comandos son los que has usado de verdad, y las dudas de quien lo ha revisado están resueltas en el texto.
Entrega rutina.md en ut5/ del repositorio. Firma del tutor en la fila Cierre.
Ficha de evidencias¶
La ficha la rellena el tutor de empresa conforme se van entregando las evidencias, y se entrega firmada al final de la estancia junto con el conjunto de evidencias.
| Actividad | CE | Fecha | Evidencia adjunta | Observaciones del tutor | Firma |
|---|---|---|---|---|---|
| A5.1 Logs | 3a | ||||
| A5.2 Accesos | 3b | ||||
| A5.3 Fallos | 3c | ||||
| A5.4 Rendimiento | 3d | ||||
| Cierre | (todos) |
Errores frecuentes en el laboratorio¶
Aunque esta unidad se hace en la empresa, estos son los tropiezos que se repiten y que conviene conocer antes de llegar.
docker logssin--sincetarda minutos y llena el terminal. Siempre con ventana de tiempo, y con--tail 200para un vistazo rápido.grep ERRORno encuentra nada porque la aplicación escribe en JSON con"level":"error"en minúsculas. Conviene mirar primero cinco líneas del log para ver el formato y usargrep -iojq:docker logs --since 1h app | jq -r 'select(.level=="error") | .msg'.journalctl _COMM=sshdno devuelve nada en Debian 13 y parece que no hay ni un intento de acceso. Desde OpenSSH 9.8 cada conexión la atiende un hijo llamadosshd-session, y es él quien escribeFailed passwordeInvalid user. Se filtra por unidad,journalctl -u ssh, que recogesshd,sshd-sessionysshd-authy funciona igual en Debian 12.- fail2ban activo pero
status sshdda cero fallos. Dos causas, y conviene descartar las dos: el backend lee unauth.logque no existe porque el sistema no tiene rsyslog (se ve confail2ban-client get sshd logpath, que apunta a un fichero que no existe, o en/var/log/fail2ban.log; se arregla conbackend = systemden la jailsshddejail.localy reiniciando fail2ban), o eljournalmatchde fábrica del filtrosshdsolo casa_COMM=sshdy en Debian 13 no ve asshd-session(journalmatch = _SYSTEMD_UNIT=ssh.serviceen la jail). - Las jails de nginx y
recidivese quedan a cero fallos después de ponerbackend = systemden[DEFAULT]. Con ese backend fail2ban ignoralogpathy busca en el journal, donde nginx no escribe. El backendsystemdva solo en las jails que leen el journal, como la de SSH. - Baneada la IP de la propia oficina o de la sonda de monitorización.
ignoreipantes deenabled = true, y si ya ha pasado,fail2ban-client set sshd unbanip. - fail2ban banea en
INPUTpero el puerto publicado por Docker sigue accesible: el tráfico a contenedores pasa porDOCKER-USER/forward, no porinput. Bloquear en el proxy, en OPNsense, o usar la acción conchain = DOCKER-USER. - El filtro propio banea al proxy nginx (10.10.1.10) en lugar de al cliente: la aplicación registra la IP del proxy. Configurar
X-Forwarded-Foren nginx y que la aplicación lo registre (con cuidado: solo confiar en esa cabecera cuando viene del proxy). OOMKilled=falsecon código 137: no era memoria, era undocker stopsin tiempo de gracia suficiente. La aplicación no manejaSIGTERMy Docker la mata a los 10 s; arreglar la aplicación o subirstop_grace_period.- No hay core dump aunque
ulimit -ces ilimitado en el contenedor. Tres causas: elcore_patterndel host apunta a un directorio que no existe dentro del contenedor, o asystemd-coredumpque no encuentra el binario (montar/var/crashen la misma ruta y usar un patrón de fichero); o la señal se mandó a mano al PID 1 del contenedor, que la ignora si no tiene manejador para ella (probar con un proceso hijo:sh -c 'sleep 60 & kill -SEGV $!'). RestartCounta cero después de uncompose up -dque recreó el contenedor. Los reinicios anteriores están endocker eventso en el journal de dockerd (journalctl -u docker | grep -i oom), no eninspect.- Línea base tomada un festivo o en la semana de vacaciones de medio equipo. La línea base es una semana representativa; si no lo es, se anota y se toma otra.
iostatmuestra%utilal 100 % en un volumen virtual y se concluye que el disco está saturado, cuando en dispositivos con colas paralelas (NVMe, almacenamiento en red)%utildeja de ser fiable a partir de cierto punto. En esos casos mandaawait.- La captura de Grafana en la evidencia lleva el nombre real de la empresa en el título del dashboard o en la etiqueta
instance. Revisar cada captura antes de adjuntarla; recortar o pixelar lo que haga falta.
Los enlaces para ampliar y los apartados que van más allá de lo que pide la unidad están en Para ampliar.
UT6 · Copias de seguridad y restauración¶
Hasta la incorporación a la empresa todo el módulo ha transcurrido en el laboratorio del centro, sobre el servicio del curso (nginx en web01, API en app01, PostgreSQL en db01), y casi todo lo hecho ha servido para enterarse a tiempo de lo que le pasa: métricas, logs, alarmas y pruebas. Hay fallos que ninguna alarma evita, como un disco que muere, un DELETE sin WHERE o un ransomware (un programa que cifra los datos y pide un rescate por la clave), y frente a ellos solo queda una copia que se pueda restaurar. En el centro la copia fue una pieza puntual: en febrero, la copia previa con restic (la herramienta de copias del curso) era el plan B de la actualización en pre y se lanzó a mano, y en marzo, al dar de baja pre, sus copias en MinIO (el almacenamiento de objetos del laboratorio) fueron justo lo que hubo que eliminar de forma controlada. Esta unidad, que se cursa durante la formación en empresa, convierte esa pieza en una tarea de mantenimiento sobre un servicio real de la empresa de acogida. Va en tres bloques, en el orden en que una copia demuestra que sirve: que se haga sola cada día (bloque 1), que esté fuera del alcance del host y cumpla la política de la empresa (bloque 2) y que se pueda restaurar en el tiempo comprometido (bloque 3).
Estas páginas son guía de referencia y lista de evidencias. Las reglas de trabajo son las de la UT5 (Cómo trabajar en la empresa), y aquí pesan más que en ninguna otra unidad, porque casi todo toca producción: un volcado lee la base de datos entera, la prueba de borrado escribe en el destino de las copias y una restauración saca datos reales del sitio donde están protegidos. Por eso cada hoja empieza pidiendo una autorización concreta del tutor, y nada que cambie un sistema de la empresa se hace sin ella. Tampoco se sacan datos de la empresa: lo que se entrega son configuraciones, listados y capturas anonimizadas (nombres de host, rutas y buckets pueden cambiarse por genéricos; el contenido de un dump nunca sale de allí). Las evidencias van a una carpeta ut6/ del mismo repositorio local de la estancia, junto a la ut5/; cómo se lleva ese repositorio lo explica el mismo apartado.
Lo que hubo en el laboratorio
En el laboratorio hubo un repositorio restic contra el MinIO del curso, con la convención s3:http://10.10.0.30:29000/backups, la credencial restic y la contraseña en /etc/restic/pass. Se inicializó el 11 de febrero en la A7.4, que lo usó como plan B de la actualización. Desde la red de la empresa no se alcanza (como el Gitea del centro), así que no sirve de destino: queda solo como referencia para comparar. La empresa tendrá su propia herramienta, su propio destino y sus propias políticas, y parte del trabajo es entender por qué son distintas.
Bloque 1 · Copias programadas (CE 4a)¶
La copia de la A7.4 se hizo a mano, una vez y porque tocaba actualizar. Un servicio en producción no puede depender de que alguien se acuerde: necesita una copia cada noche, consistente aunque la base de datos esté escribiendo, que deje rastro y que avise cuando falte. Este bloque la construye en el orden en que se toman las decisiones. Primero, qué se copia y cómo se saca cada pieza sin corromperla. Después, los números con los que la empresa juzga una copia: la regla 3-2-1, que dice cuántas copias hacen falta y dónde, y el RPO y el RTO, que dicen cuántos datos se pueden perder y cuánto puede tardar el servicio en volver. Luego, el vocabulario de tipos de copia, que es el que usan el tutor y la documentación. Por último, la herramienta (restic), el script que la orquesta con su timer y las cuatro capas que comprueban que se ha ejecutado. La hoja A6.1 lo aplica a una aplicación de la empresa, y la copia que deja es la que audita el bloque 2 y restaura el bloque 3.
Qué se copia y qué no¶
El criterio. La tentación es copiarlo todo, el disco entero del servidor si hace falta. Sale una copia enorme, lenta de hacer y de restaurar, que además puede llevar la base de datos a medio escribir y, por tanto, inservible, como explica el apartado siguiente. El criterio útil es el contrario: se copia lo que no se puede reconstruir a partir de otra cosa que ya está guardada. En un servicio en contenedores, las imágenes están en el registry y el código en Git; copiarlos otra vez es gastar espacio y, peor, dar una sensación de seguridad falsa, porque una copia de cientos de gigas parece completa aunque le falte lo único que no se puede rehacer. Lo que sí hay que copiar cabe en tres cajones:
- Datos: la base de datos y los volúmenes de ficheros que suben los usuarios (adjuntos, imágenes, documentos generados). Es lo único que no existe en ningún otro sitio.
- Configuración: el
compose.yml, el.env(cifrado, nunca en claro), certificados y claves TLS, la configuración del proxy y de la monitorización (reglas de alerta, dashboards exportados). Aunque parte esté en Git, lo que corre en producción puede no coincidir con lo último del repositorio, y los secretos nunca están en él. - Metadatos del despliegue: qué versión de imagen estaba en producción cuando se hizo la copia. La aplicación y sus datos van emparejados: si entre dos versiones hubo migraciones de esquema (cambios en la estructura de las tablas que aplica la versión nueva), un dump de la base de datos de la versión 2.4 no arranca con la imagen 2.6. Basta con un
docker compose imagesvolcado a un fichero junto al dump, y al restaurar se levanta la versión que dice ese fichero.
De los tres cajones, el delicado es la base de datos, porque es el único que cambia mientras se copia.
Dump lógico frente a copia del volumen¶
Por qué una base de datos no se copia como un fichero. Una base de datos escribe en disco de forma continua. Si se copia el directorio del volumen mientras PostgreSQL está en marcha, se obtienen ficheros de distintos instantes: un fichero de datos que ya tiene la transacción y un WAL (el registro de transacciones, write-ahead log) que todavía no. Ese conjunto puede no arrancar, o arrancar con datos corruptos que no se descubren hasta meses después. Hay dos maneras limpias de resolverlo: pedirle los datos al propio motor, o copiar los ficheros cuando nadie los está tocando.
Dump lógico. El propio motor exporta su contenido. Se llama lógico porque guarda lo que hay en la base (tablas, filas, definiciones de índices) y no los ficheros con los que el motor lo guarda en disco, que es lo que hace una copia física. pg_dump lee toda la base dentro de una sola transacción, así que el resultado es la foto de un instante aunque otras conexiones sigan escribiendo, y no les impide leer ni escribir mientras tanto. Además es portable: se carga en una versión más nueva de PostgreSQL o en una máquina de otra arquitectura. Para PostgreSQL:
# Una base, formato custom (comprimido, permite restauración selectiva y en paralelo)
docker compose exec -T db pg_dump -U app -Fc app > /var/backups/app.dump
# Roles y contraseñas del clúster, que pg_dump NO incluye
docker compose exec -T db pg_dumpall -U postgres --globals-only > /var/backups/globals.sql
Tres decisiones de estas dos órdenes:
-Tdesactiva la asignación de TTY (terminal interactivo) en elexec. Sin él, la salida que se redirige con>se corrompe, y es la causa más habitual de un dump vacío.-Fcproduce el formato custom, comprimido, que se restaura conpg_restore: admite-j 4para cargar en paralelo y-t tablapara sacar una sola tabla. El SQL plano, en cambio, se carga conpsqlde principio a fin y sin elegir qué.pg_dumpall --globals-onlyexiste porquepg_dumpvuelca una sola base, y los roles y sus contraseñas no pertenecen a ninguna: son del clúster entero. Sin ellos, la carga del dump falla en cada propietario y cada permiso con unrole ... does not exist.pg_dumpallsin--globals-onlyvuelca todo el clúster en SQL plano, útil para migraciones, poco práctico como copia diaria de una base de 40 GB.
Con otros motores la idea es la misma y cambia la orden:
| Motor | Volcado | Lo que hay que saber |
|---|---|---|
| MySQL o MariaDB | mysqldump --single-transaction --routines --triggers --events app > app.sql |
--single-transaction abre una transacción con lectura consistente para que el volcado corresponda a un instante, sin bloquear las tablas InnoDB (el motor de almacenamiento habitual de MySQL). Si hay tablas del motor antiguo MyISAM no sirve y toca --lock-tables |
| MongoDB | mongodump --archive --gzip |
Sin nombre de fichero, --archive escribe en la salida estándar; se carga con mongorestore --archive --gzip |
Copia del volumen con el servicio parado o congelado. El dump tiene un límite: cuanto más grande es la base, más tarda en sacarse y mucho más en cargarse, porque restaurarlo es volver a insertar todas las filas y reconstruir todos los índices. Si la base es enorme y el dump no cabe en la ventana de copia (el rato de poca actividad en que se puede copiar), o su carga tarda más de lo que el servicio puede estar parado, se copian los ficheros, pero de forma que sean coherentes: se para el contenedor y se copia el directorio, o se hace una copia física con pg_basebackup, que la saca con el servidor en marcha y admite PITR (recuperación a un instante concreto) si además se archiva el WAL. Para el resto de volúmenes (ficheros subidos) la copia directa es lo normal, porque cada fichero se sostiene solo y no depende de que otro esté al día:
# Copiar un volumen con nombre sin conocer su ruta en el host
docker run --rm -v app_uploads:/data:ro -v /var/backups:/backup alpine \
tar czf /backup/uploads.tgz -C /data .
Con restic no hace falta el tar: se le pasa la ruta del volumen directamente, y en cada copia solo lee los ficheros que han cambiado desde la anterior.
| Método | Consistencia | Portabilidad | Tamaño | Cuándo |
|---|---|---|---|---|
pg_dump -Fc |
Transaccional | Entre versiones mayores | Comprimido | Copia diaria estándar |
pg_basebackup + WAL |
Física, con PITR | Misma versión mayor | Igual que el clúster | Bases grandes, RPO de minutos |
| Copia del volumen en caliente | Ninguna | Misma versión | Igual que el clúster | Nunca para bases de datos |
| Copia del volumen parado | Total | Misma versión | Igual que el clúster | Ventana de parada aceptable |
La copia de la A7.4 y la de la A6.1 son la primera fila. Las otras dos filas válidas son para cuando el tamaño de la base no deja otra salida, y la de PITR, además, para cuando no se pueden perder más que unos minutos de datos.
Ficheros, configuración y secretos¶
Ficheros y configuración. Los ficheros subidos por usuarios se copian tal cual; el único cuidado es que no cambien a mitad de copia (un fichero de 2 GB que se está subiendo). restic detecta la modificación y avisa, y se puede repetir esa ruta. La configuración va toda al mismo snapshot que los datos (snapshot es cada copia que guarda restic, con su fecha), para que una restauración devuelva un conjunto coherente: la configuración de ese día con los datos de ese día.
Los secretos, aparte. El .env con la contraseña de la base de datos, las claves de API y los certificados privados tienen que estar en la copia (sin ellos no se restaura nada), pero no pueden viajar en claro a un bucket que gestiona otro equipo. Dos opciones válidas: cifrarlos antes con age o sops (dos herramientas de cifrado de ficheros) y copiar el fichero cifrado, o confiar en el cifrado del repositorio restic (todo lo que entra se cifra y se autentica antes de salir del host; el destino solo ve ficheros cifrados). Lo habitual es combinar las dos, y la razón es quién tiene cada clave. La contraseña del repositorio la tienen el host que copia y quien administra las copias, y con ella se lee todo, .env incluido; si además los secretos van cifrados con una clave que custodia otra persona, esa contraseña ya no basta para llegar a las credenciales de producción.
age en tres órdenes. age cifra con una clave pública y descifra con la privada que le corresponde, así que el host puede cifrar cada noche sin tener la clave que descifra. El par de claves lo genera quien vaya a custodiar la privada, fuera del host que copia; en el host solo queda la pública (la línea age1... que imprime age-keygen), en /etc/restic/age.pub:
age-keygen -o copias.key # privada: nunca junto a la copia
age -R /etc/restic/age.pub -o /opt/app/.env.enc /opt/app/.env # cifrar, en el host, antes de cada copia
age -d -i copias.key .env.enc > .env # descifrar, al restaurar
sops hace lo mismo cifrando campo a campo un YAML o un JSON; dónde encaja cada una lo explica la gestión de secretos de Despliegue.
Regla 3-2-1, RPO y RTO¶
Qué falta. Saber qué se copia no dice cuántas copias hacen falta, cada cuánto ni dónde, y sin una vara de medir cualquier copia parece suficiente hasta el día en que hay que usarla. La regla 3-2-1 responde a cuántas y dónde; el RPO y el RTO, a cada cuánto y en cuánto tiempo hay que poder volver. Con ellos se justifica todo lo que viene después: la frecuencia, el destino y el método de restauración.
La regla 3-2-1. Es el mínimo defendible, y cada número cubre un fallo distinto: tres copias de los datos (la de producción y dos más), para que perder una no deje una sola de la que depender; en dos soportes distintos (disco local y almacenamiento de objetos, por ejemplo), para que un defecto de un tipo de soporte no se lleve las dos; y una fuera del sitio (otro centro de datos, otra región de la nube, una cinta en una caja fuerte), para que un incendio o un robo en el edificio no acabe con todas. La variante 3-2-1-1-0, que se ha extendido con el ransomware, añade una copia offline o inmutable (que nadie con credenciales del host pueda borrar, porque el ransomware busca las copias para borrarlas antes de cifrar) y cero errores en la verificación (las copias se comprueban y restauran, no se dan por buenas).
flowchart TB
P["<b>Producción</b><br><small>copia 1</small>"]:::pieza
L["<b>Disco local</b><br><small>copia 2 · soporte A</small>"]:::pieza
O["<b>Objetos remotos</b><br><small>copia 3 · soporte B · fuera del sitio</small>"]:::pieza
I["<b>+1 · inmutable u offline</b><br><small>que nadie con credenciales del host borre</small>"]:::ok
V["<b>+0 · cero errores</b><br><small>se verifica y se restaura de verdad</small>"]:::ok
RAN["<b>Ransomware con las credenciales del host</b><br><small>borra todo lo que esas credenciales alcancen</small>"]:::riesgo
P --> L --> O --> I --> V
RAN -.-> L
RAN -.-> O
RAN -. "aquí no llega" .-x I
classDef act fill:#ea580c22,stroke:#ea580c,stroke-width:1.5px
classDef pieza fill:#64748b22,stroke:#64748b,stroke-width:1.5px
classDef dato fill:#2563eb22,stroke:#2563eb,stroke-width:1.5px
classDef infra fill:#a1a1aa14,stroke:#a1a1aa,stroke-width:1.5px
classDef ok fill:#16a34a22,stroke:#16a34a,stroke-width:1.5px
classDef riesgo fill:#dc262622,stroke:#dc2626,stroke-width:1.5px
El «+1» y el «+0» son los que se han vuelto imprescindibles: una copia que el atacante puede borrar no es una copia, y una que nunca se ha restaurado no se sabe si lo es.
RPO y RTO. Son los dos parámetros que fija la empresa, no el técnico, porque responden a una pregunta de negocio: cuánto cuesta perder datos o tener el servicio parado, frente a cuánto cuesta evitarlo. Copiar cada hora o tener una réplica preparada cuesta dinero, y solo quien responde del servicio puede decidir si lo vale:
- RPO (Recovery Point Objective): cuántos datos se pueden perder, medido en tiempo. Fija la frecuencia de copia. Si el RPO es 24 h, una copia diaria a las 02:30 vale. Si es 1 h, o se copia cada hora o se archiva el WAL de PostgreSQL de forma continua.
- RTO (Recovery Time Objective): cuánto puede tardar el servicio en volver. Fija el método de restauración y dónde está la copia. Un RTO de 4 h permite descargar 30 GB de un bucket remoto y hacer un
pg_restore; un RTO de 15 min exige una réplica en caliente o un snapshot local.
Con números: una tienda con 200 pedidos al día y copia diaria a las 02:30 tiene un RPO real de hasta 24 h: si el disco muere a las 02:00, justo antes de la copia, se pierden los pedidos de casi un día entero. Si la empresa dice que solo puede perder una hora de pedidos, la copia diaria no cumple, por muy bien que funcione. Y si el RTO es de 2 h pero la restauración de prueba tardó 3 h 40 min, el plan está mal aunque la copia sea perfecta. Por eso la A6.1 empieza pidiendo estos dos números y la A6.3 termina midiendo el segundo.
Tipos de copia y snapshots¶
El problema que resuelven. Copiarlo todo cada noche es lo más sencillo de restaurar, pero con una base de cientos de gigas no cabe en la ventana ni en el destino. Las herramientas clásicas lo resolvieron copiando solo lo que cambia, y de ahí salen los tipos de copia que aparecen en cualquier documentación y en la conversación con el tutor. Se definen aquí para reconocerlos, para entender por qué con restic o borg la distinción casi deja de importar y, al final, por qué un snapshot del hipervisor no cuenta como copia.
- Completa: todo, cada vez. Simple de restaurar (un solo conjunto), cara en espacio y tiempo.
- Incremental: solo lo que cambió desde la última copia (completa o incremental). Barata; restaurar exige la completa más toda la cadena, y un eslabón corrupto rompe lo que viene después.
- Diferencial: lo que cambió desde la última completa. Restaurar necesita dos conjuntos; cada diferencial crece hasta la siguiente completa.
- Deduplicada: la que hacen restic y borg. Los ficheros se trocean en bloques de tamaño variable, cada bloque se identifica por su hash (una huella que solo cambia si cambia el contenido) y solo se almacenan los bloques que no estaban ya guardados. Los cortes los decide el propio contenido (content-defined chunking) y no una posición fija: si se insertan unas líneas al principio de un fichero, cambian los bloques de alrededor y el resto se reconoce igual, cosa que con bloques de tamaño fijo no pasaría, porque se desplazarían todos. Cada snapshot es lógicamente completo (se restaura solo, sin cadenas), pero cuesta lo que un incremental. Es la razón por la que la distinción clásica ha dejado de importar en la práctica.
Los snapshots del hipervisor no son copias. Aparte están los snapshots del hipervisor (los de Proxmox, o vzdump, su herramienta de copias de VM, en modo snapshot) y los del sistema de ficheros (ZFS, LVM). Son instantáneas del disco de la VM entera: rápidos, cómodos para volver atrás antes de una actualización, y engañosos como copia de seguridad. Un snapshot en el mismo almacenamiento que la VM desaparece con él; capta la base de datos en caliente con los mismos problemas de consistencia que copiar el volumen (aunque el guest agent de QEMU con fsfreeze, que congela un instante el sistema de ficheros del invitado, mitiga parte); y restaurar un fichero suelto obliga a levantar la VM entera. La combinación habitual en empresa es ambas cosas: copia de VM con Proxmox Backup Server (el servidor de copias de Proxmox) para recuperar el host en bloque, y copia de aplicación con restic para recuperar datos con granularidad.
restic a fondo¶
Qué resuelve. Con un pg_dump y un tar ya se puede hacer una copia, pero quedan por resolver casi todas las exigencias de los apartados anteriores: cifrar antes de que los datos salgan del host, subir solo lo que ha cambiado, saber qué copia es de qué día y de qué máquina, borrar las antiguas con una regla y no a mano, y comprobar que lo guardado se puede leer. restic hace todo eso con un solo programa, y por eso es la herramienta del curso: con ella se hace casi todo lo que sigue (copiar, retener, verificar y restaurar).
Qué es. restic (versión 0.18 en el laboratorio) es un programa libre de un solo binario, escrito en Go, que crea repositorios deduplicados y cifrados en local, SFTP, S3 y compatibles (MinIO, Backblaze B2), Azure, Google Cloud Storage o su propio rest-server, un servidor HTTP hecho solo para guardar repositorios de restic. Todo lo que se configura son variables de entorno y flags.
Cómo guarda las copias. Un repositorio es un directorio, o un prefijo dentro de un bucket, con un fichero config y cinco carpetas. Saber qué hay en cada una explica casi todas las órdenes que vienen después:
| Carpeta | Qué guarda | Por qué importa al operar |
|---|---|---|
data/ |
Los bloques de la deduplicación (blobs) y la estructura de directorios de cada copia, empaquetados en ficheros grandes (packs) | Es casi todo el tamaño del repositorio |
index/ |
En qué pack está cada bloque | restic lo consulta en cada operación; la caché local lo guarda para no descargarlo cada vez |
snapshots/ |
Un fichero pequeño por copia: fecha, host, rutas, tags y el punto de entrada a sus ficheros | Olvidar una copia (forget) es borrar solo este fichero |
keys/ |
La clave maestra del repositorio, cifrada con cada una de sus contraseñas | Sin una contraseña que abra alguno de estos ficheros, el repositorio es ilegible |
locks/ |
Un fichero por cada operación en curso | Es lo que impide que un prune borre bloques que una copia en marcha está usando |
Todo lo que entra se cifra y se autentica en el host antes de subir, así que en el destino no se ve ni un nombre de fichero de la aplicación. De esta estructura salen tres reglas: la contraseña es el repositorio (perderla es perderlo entero, y quien la tiene y llega al destino lo lee todo); olvidar copias no libera espacio por sí solo, porque los bloques siguen en data/ hasta que prune reescribe los packs; y como cada operación deja su fichero en locks/ mientras dura, una que muere a medias deja un bloqueo que hay que retirar.
Cómo se usa. Con los valores del laboratorio del curso queda así; en la empresa cambian el destino y la credencial:
export RESTIC_REPOSITORY=s3:http://10.10.0.30:29000/backups
export RESTIC_PASSWORD_FILE=/etc/restic/pass # permisos 600, del usuario que lanza la copia
export AWS_ACCESS_KEY_ID=restic # la credencial de MinIO para copias
export AWS_SECRET_ACCESS_KEY='...' # mejor en /etc/restic/env con EnvironmentFile=
export RESTIC_CACHE_DIR=/var/cache/restic # índices locales; acelera todo
export RESTIC_COMPRESSION=auto # off | auto | max
restic init # una vez; crea la clave del repo
restic backup /var/backups /opt/app/compose.yml /var/lib/docker/volumes/app_uploads/_data \
--tag app --tag daily --exclude-file /etc/restic/excludes --one-file-system
restic snapshots # lista
restic snapshots --latest 1 --tag app --json # el último con tag app, para scripts
--one-file-system impide que la copia se meta en otro sistema de ficheros montado bajo una de las rutas (un disco de red, por ejemplo), y /etc/restic/excludes es un fichero con un patrón por línea de lo que no se copia. No hay que memorizar las opciones de la lista siguiente, sino saber que existen para reconocerlas en el script de la empresa; son lo que separa una prueba de una copia en producción:
- Tags y host: cada snapshot lleva el nombre del host y los tags que se le pongan. Las políticas de retención se aplican por grupos, y por defecto un grupo es un host con unas rutas concretas (
--group-by host,paths): si la misma máquina copia dos aplicaciones con rutas distintas, cada una tiene su propia rotación. La trampa está en cambiar las rutas de una copia que ya existe: las copias nuevas forman otro grupo, y las del antiguo se quedan para siempre, porque la política siempre conserva las últimas de cada grupo. Cuando un tag identifica la aplicación, comoappen esta unidad, se agrupa por él (--group-by host,tags) y la trampa desaparece. - Exclusiones:
--exclude '*.tmp',--exclude-file,--exclude-caches(respeta los ficherosCACHEDIR.TAG) y--exclude-if-present .nobackup. Excluirnode_modules, cachés y logs rotados reduce el snapshot de una aplicación típica a una fracción. - Volcado por stdin: en lugar de escribir el dump a disco y copiarlo,
pg_dump ... | restic backup --stdin --stdin-filename app.dump --tag app, que es como se hizo la copia de la A7.4. Ahorra el espacio intermedio, pero restic no puede saber si lo que le llega está completo: si elpg_dumpmuere a medias, guarda lo recibido como una copia buena. Con--stdin-from-command(restic backup --stdin-from-command --stdin-filename app.dump -- pg_dump ...) es restic quien lanza la orden y, si esta termina con error, no crea el snapshot. - Retención:
restic forget --keep-daily 7 --keep-weekly 4 --keep-monthly 6 --keep-yearly 2 --prune.--keep-daily 7conserva, de los 7 últimos días que tienen copia, la última de cada día;--keep-weekly,--keep-monthlyy--keep-yearlyhacen lo mismo con semanas, meses y años, y una misma copia puede contar a la vez como diaria, semanal y mensual.forgetborra los snapshots; sin--prunelos datos siguen ocupando espacio hasta que se ejecuterestic prune, que reempaqueta y elimina los bloques que ya no usa ningún snapshot.prunees lento y toma un bloqueo exclusivo, así que se programa aparte (semanal) y no en cada copia.--max-unused 5%limita cuánto reempaqueta. - Verificación:
restic checkvalida la estructura del repositorio con los índices;--read-datadescarga y verifica cada blob (caro con 500 GB en un bucket remoto);--read-data-subset=5%o--read-data-subset=1/10lo hace por partes, de forma que en 10 ejecuciones queda leído entero. - Restauración:
restic restore latest --target /restorerestaura el último snapshot;--include /var/backups/app.dumpsaca solo esa ruta;--hosty--tagseleccionan el snapshot cuando hay varios.restic ls latestyrestic find app.dumplocalizan ficheros sin restaurar;restic diff id1 id2muestra qué cambió entre dos snapshots. - Montaje:
restic mount /mnt/resticexpone el repositorio por FUSE (un sistema de ficheros en espacio de usuario: el repositorio aparece como un directorio normal), con un directorio por snapshot (/mnt/restic/snapshots/latest/...). Es la forma más rápida de recuperar un fichero suelto o de inspeccionar qué hay dentro sin restaurar nada. Necesita el paquetefuse3. - Rendimiento: la primera copia va limitada por CPU (hash y cifrado) y por la subida; las siguientes solo leen los ficheros cuyos metadatos han cambiado (por defecto compara las fechas de modificación y de cambio, el tamaño y el inodo). La caché local en
RESTIC_CACHE_DIRes lo que más se nota: sin ella cada operación descarga los índices del bucket. El afinado fino (concurrencia de lectura, tamaño de los paquetes en S3, límite de ancho de banda en la ventana nocturna) está en Para ampliar. - Bloqueos: si una copia muere a medias (reinicio del host), queda un lock y la siguiente falla con "repository is already locked".
restic unlocklo limpia; antes de hacerlo, asegurarse de que no hay otro proceso restic vivo. - Contraseña:
restic key addyrestic key removegestionan varias claves para el mismo repositorio; cambiarla según la política de la empresa es añadir la nueva, verificar que abre el repo y quitar la antigua. Perderla equivale a perder el repositorio entero; en la empresa conviene preguntar dónde está custodiada.
borg como alternativa¶
BorgBackup es el otro programa de copias deduplicadas y cifradas que se encuentra en producción: hace lo mismo que restic con otros nombres de comando (borg create, borg prune, borg check, borg mount). Su destino típico no es un bucket sino un NAS al que se llega por SSH, con borg serve al otro extremo. Ese servidor admite el modo --append-only, que deja al cliente añadir copias pero no borrar las que ya hay: la protección más directa contra un ransomware que entre por el host copiado. El criterio de elección suele venir dado por el destino: si es un NAS por SSH, borg; si es almacenamiento de objetos, restic. La equivalencia de comandos entre las dos herramientas, las opciones de cifrado y compresión y borgmatic están en Para ampliar.
Programar la copia¶
Qué falta. Con lo anterior, una copia se puede hacer a mano. Para que se haga sola cada noche hacen falta dos piezas más: un script que ejecute los pasos siempre en el mismo orden y se pare en cuanto uno falle, y un programador que lo lance a la hora que pide el RPO. En el laboratorio no hubo copia programada: la de la A7.4 se lanzó a mano y, como PostgreSQL vivía en su propia VM, el volcado se pidió por SSH a esa máquina en vez de con docker compose exec. Si en la empresa la base tampoco está en el compose, las dos líneas del volcado del script cambian de la misma forma. El recorrido completo, que este apartado y el siguiente explican pieza a pieza:
flowchart TB
T["<b>systemd timer</b><br><small>02:30</small>"]:::act
S["<b>backup-app.sh</b>"]:::act
D["<b>pg_dump -Fc + globals</b>"]:::pieza
R["<b>restic backup --tag app</b>"]:::pieza
M[("<b>MinIO / S3</b><br><small>bucket backups</small>")]:::dato
F["<b>restic forget</b><br><small>retención</small>"]:::pieza
C["<b>restic check 5%</b>"]:::pieza
P["<b>backup_app.prom</b><br><small>marca de tiempo</small>"]:::dato
N["<b>node_exporter</b><br><small>textfile</small>"]:::pieza
PR["<b>Prometheus</b>"]:::pieza
A(["<b>Alertmanager</b><br><small>si no hay copia en 26 h</small>"]):::riesgo
O(["<b>unit en failed</b><br><small>sin marca nueva: acaba saltando la alarma</small>"]):::riesgo
T --> S --> D --> R --> M
R --> F --> C
C -->|todo OK| P --> N --> PR -->|sin copia en 26 h| A
S -.->|exit != 0| O
classDef act fill:#ea580c22,stroke:#ea580c,stroke-width:1.5px
classDef pieza fill:#64748b22,stroke:#64748b,stroke-width:1.5px
classDef dato fill:#2563eb22,stroke:#2563eb,stroke-width:1.5px
classDef infra fill:#a1a1aa14,stroke:#a1a1aa,stroke-width:1.5px
classDef ok fill:#16a34a22,stroke:#16a34a,stroke-width:1.5px
classDef riesgo fill:#dc262622,stroke:#dc2626,stroke-width:1.5px
La copia no termina cuando se escribe: termina cuando alguien puede demostrar que se escribió. De ahí la marca de tiempo y la alarma.
El script. Hace de orquestador: cifra el .env, apunta las versiones de imagen, vuelca la base de datos y los roles, lanza restic, aplica la retención y verifica, y al terminar escribe una métrica que node_exporter (el agente de métricas del host) recoge, como se explica en Verificar que se ha ejecutado. Es el script de referencia de la unidad, el que se adapta en la A6.1:
#!/bin/bash
# /usr/local/bin/backup-app.sh
set -euo pipefail
export RESTIC_REPOSITORY=s3:https://<destino>/backups
export RESTIC_PASSWORD_FILE=/etc/restic/pass
export RESTIC_CACHE_DIR=/var/cache/restic
# AWS_ACCESS_KEY_ID y AWS_SECRET_ACCESS_KEY llegan por EnvironmentFile= de la unit
TS_FILE=/var/lib/prometheus/node-exporter/backup_app.prom # el directorio de textfile del node_exporter
START=$(date +%s)
cd /opt/app
age -R /etc/restic/age.pub -o /opt/app/.env.enc /opt/app/.env # el .env viaja cifrado
docker compose images --format json > /var/backups/app-images.json
docker compose exec -T db pg_dump -U app -Fc app > /var/backups/app.dump
docker compose exec -T db pg_dumpall -U postgres --globals-only > /var/backups/globals.sql
restic backup /var/backups /opt/app/compose.yml /opt/app/.env.enc \
/var/lib/docker/volumes/app_uploads/_data \
--tag app --exclude-file /etc/restic/excludes --one-file-system
restic forget --keep-daily 7 --keep-weekly 4 --keep-monthly 6 --tag app --group-by host,tags
restic check --read-data-subset=5%
# Métrica para Prometheus: solo se escribe si todo lo anterior ha ido bien (set -e)
# La etiqueta no se llama job: al leerla, Prometheus la renombraría a exported_job
cat > "$TS_FILE.$$" <<EOF
# HELP backup_last_success_timestamp_seconds Epoch de la última copia correcta
# TYPE backup_last_success_timestamp_seconds gauge
backup_last_success_timestamp_seconds{backup="app"} $(date +%s)
backup_last_duration_seconds{backup="app"} $(( $(date +%s) - START ))
EOF
mv "$TS_FILE.$$" "$TS_FILE"
Cuatro decisiones de este script, en el orden en que aparecen:
set -euo pipefailconvierte cualquier fallo intermedio en el final del script:-elo detiene en cuanto una orden falla,-utrata como error una variable sin definir (una ruta mal escrita no se convierte en una ruta vacía) ypipefailhace que una tubería falle si falla cualquiera de sus partes, no solo la última. El código de salida distinto de cero que resulta es lo que systemd va a mirar.- El orden. Los volcados van antes que restic porque restic copia los ficheros que ellos dejan en
/var/backups. La retención va después de una copia correcta: si la de hoy falla, el script se detiene antes delforgety no se borra ninguna copia antigua el mismo día en que no hay una nueva. Elforgetagrupa por tags, como se explica en restic a fondo, para que añadir una ruta al script no deje copias viejas sin rotar. Elpruneno está aquí a propósito: va en un timer semanal separado, porque es lento y bloquea el repositorio. Y si en el bloque 2 la credencial del host pierde el permiso de borrado, elforgetsale también de aquí (Destinos y políticas). - La métrica, solo al final. Con
set -e, llegar a la última línea significa que todo lo anterior ha ido bien, así que la hora que se escribe es la de la última copia correcta. Se escribe en un fichero temporal y se renombra conmv, que dentro del mismo sistema de ficheros es atómico: node_exporter nunca lee un fichero a medio escribir y, como solo lee los que acaban en.prom, ignora el temporal (backup_app.prom.<PID>). - La etiqueta
backup, y nojob. Prometheus ya pone a cada serie la etiquetajobdel scrape que la recoge; si la métrica trae una propia con ese nombre, la renombra aexported_job, y las reglas de alerta no la encontrarían donde la buscan.
systemd timers¶
Por qué un timer. Un timer de systemd es una unit que lanza otra unit a una hora o cada cierto tiempo. Frente a cron, que es lo que hay en muchas empresas, tiene tres ventajas que importan en una copia: si el host estaba apagado a la hora prevista, la lanza al arrancar; el log va al journal sin gestionar nada; y cuando el script falla, la unit queda en estado failed, que se puede consultar. Hacen falta dos ficheros: una unit de servicio oneshot (se ejecuta, termina y no queda residente) y el timer que la dispara.
# /etc/systemd/system/backup-app.service
[Unit]
Description=Copia de seguridad de app
After=docker.service network-online.target
Wants=network-online.target
[Service]
Type=oneshot
EnvironmentFile=/etc/restic/env
ExecStart=/usr/local/bin/backup-app.sh
Nice=10
IOSchedulingClass=best-effort
IOSchedulingPriority=7
De la unit de servicio, tres decisiones:
After=docker.service network-online.targetyWants=network-online.target: la copia necesita Docker para el volcado y la red para llegar al destino.After=solo ordena el arranque; sinWants=, el objetivonetwork-online.target, que es el que espera a que la red esté configurada, puede no activarse nunca y no habría nada que esperar.EnvironmentFile=/etc/restic/envcarga las credenciales del bucket. Una unit no hereda el entorno de la sesión de quien la instala, y así los secretos tampoco quedan escritos en el script, que puede acabar en un repositorio.Nice=10y las dos líneas deIOSchedulingbajan la prioridad de la copia en CPU y en disco, para que ceda ante el servicio si coinciden.
# /etc/systemd/system/backup-app.timer
[Unit]
Description=Lanza backup-app.service cada noche
[Timer]
OnCalendar=*-*-* 02:30:00
Persistent=true
RandomizedDelaySec=15m
Unit=backup-app.service
[Install]
WantedBy=timers.target
Del timer:
OnCalendaradmite expresiones comoMon..Fri 02:30,*-*-01 04:00(el día 1 de cada mes) ohourly;systemd-analyze calendar '*-*-* 02:30:00'indica cuándo será la próxima ejecución.Persistent=trueguarda en disco la última ejecución y, si el host estaba apagado a las 02:30, lanza la copia en cuanto arranca (cron no hace esto).RandomizedDelaySecretrasa cada ejecución un tiempo al azar dentro de ese margen, para repartir la carga cuando varios hosts comparten el mismo destino a la misma hora.
Si la empresa ya tiene una unit que avisa por correo o webhook, una línea OnFailure= en [Unit] la engancha para que salte cuando la copia termine con error; aquí el aviso lo da la alerta por ausencia de Verificar que se ha ejecutado, que cubre también el caso en que la copia ni siquiera arranca. Se activa con systemctl enable --now backup-app.timer (el timer, no el servicio: es el timer el que tiene que sobrevivir a un reinicio); systemctl list-timers muestra la próxima y la última ejecución; journalctl -u backup-app.service -n 50 es el log, sin gestionar rotación ni redirecciones.
cron y contenedor programador¶
cron. La línea equivalente en /etc/cron.d/backup-app es 30 2 * * * root /usr/local/bin/backup-app.sh >> /var/log/backup-app.log 2>&1. Funciona en cualquier sitio y es lo que hay en muchas empresas; sus carencias frente al timer son las del apartado anterior: no recupera ejecuciones perdidas, el log hay que gestionarlo a mano y el fallo solo se ve si alguien lee el correo de root. Si la empresa usa cron, se respeta, porque cambiar de programador es una decisión del equipo y no de la copia: la alerta por ausencia compensa la falta de aviso. Con cron no hay EnvironmentFile=, así que el script tiene que cargar él mismo /etc/restic/env exportando sus variables: set -a; . /etc/restic/env; set +a.
Contenedor programador. Cuando el host es ajeno (una plataforma gestionada donde solo se dispone de Docker), el programador va en un contenedor: ofelia, un programador de tareas pensado para Docker, lee etiquetas de los otros contenedores (ofelia.job-exec.backup.schedule: "0 30 2 * * *", ofelia.job-exec.backup.command: "pg_dump ...") y ejecuta comandos dentro de ellos; o un sidecar (contenedor auxiliar que acompaña al principal) Alpine con crond que monta el socket de Docker. Ambos tienen el mismo problema: el contenedor programador necesita acceso al socket o a los volúmenes, y eso es un privilegio grande que hay que documentar, porque quien habla con el socket de Docker puede arrancar un contenedor con el disco del host montado, y eso equivale a ser root.
Verificar que se ha ejecutado¶
El problema. El script y el timer hacen la copia; falta saber que se ha hecho. Una copia programada que nadie mira deja de funcionar en silencio: caduca la credencial del bucket, se llena /var/backups, cambia la contraseña de la base de datos, y nadie lo descubre hasta que hace falta restaurar. La verificación tiene cuatro capas, de la más barata a la más fiable, y cada una ve algo que la anterior no ve:
- Código de salida. El
set -edel script yType=oneshothacen que systemd marque la unit comofailed.systemctl is-failed backup-app.servicelo consulta. - Log.
journalctl -u backup-app.service --since yesterday. restic imprime al final de cada copia cuántos ficheros nuevos, cambiados y sin cambios, y cuántos datos se han añadido. Un "Added to the repository: 0 B" tres días seguidos en una aplicación viva es sospechoso aunque el código de salida sea 0. - El repositorio.
restic snapshots --latest 1 --tag appdevuelve fecha, host, tags y rutas del último snapshot. Comprobar que la fecha es de hoy y que el tamaño (restic stats latest) está en el rango esperado. Es lo que mira quien audita. - Alerta por ausencia (dead man's switch). Las tres anteriores fallan si el host que copia está apagado, si el timer se deshabilitó o si el script no llega ni a arrancar. La única forma de detectarlo es que otro sistema espere una señal y avise cuando no llega.
Con Prometheus. Si la empresa tiene Prometheus, como la pila de mon01 del curso, la señal es la métrica que escribe el script. Prometheus no puede hacer scrape a un script, que se ejecuta unos minutos y termina; por eso el script deja la métrica en un fichero, y node_exporter, que sí está siempre en marcha, la publica junto a las del host. Es el textfile collector de node_exporter, que lee los ficheros .prom de un directorio. El paquete prometheus-node-exporter de Debian, el de las VM del laboratorio, lo lee de /var/lib/prometheus/node-exporter; otra instalación lo indica con --collector.textfile.directory=<ruta> en su línea de arranque, y sin ese flag no lee ninguno. Las dos reglas, con la sintaxis de las reglas de alerta de Prometheus:
groups:
- name: backups
rules:
- alert: BackupMissing
expr: time() - backup_last_success_timestamp_seconds > 26 * 3600
for: 10m
labels: {severity: critical}
annotations:
summary: "Sin copia correcta de {{ $labels.backup }} en más de 26 h"
- alert: BackupMetricAbsent
expr: absent(backup_last_success_timestamp_seconds{backup="app"})
for: 30m
labels: {severity: warning}
La primera compara la hora actual con la de la última copia correcta. Las 26 h dejan margen a RandomizedDelaySec y a una copia que tarde más de lo habitual sin generar ruido cada noche. La segunda regla cubre el caso en que la métrica desaparece (node_exporter caído, fichero borrado): sin absent(), la primera expresión no devuelve nada y no salta. Cómo se enrutan después en Alertmanager está en El árbol de rutas; aquí solo hay que añadirlas.
Sin Prometheus. Si la empresa no tiene Prometheus, o el host que copia está fuera de su red de monitorización, el mismo patrón se hace con un servicio externo tipo healthchecks.io, que hace de "otro sistema que espera la señal": se crea un check con periodo 24 h y gracia 2 h, y el script hace curl -fsS -m 10 --retry 3 https://hc-ping.com/<uuid> al terminar bien (y .../<uuid>/fail en el trap ERR, el manejador que bash ejecuta cuando falla un comando). Si no llega el ping, avisa por correo, Slack o webhook. Healthchecks se puede autoalojar en un contenedor si la política de la empresa no permite servicios externos.
A6.1 Copias programadas (CE 4a)¶
Objetivo Que una aplicación real de la empresa deje de depender de que alguien se acuerde de copiarla. Al terminar, la aplicación elegida tiene una copia programada que se ejecuta sola a la hora que pide su RPO, deja rastro de que se ha ejecutado y avisa cuando falta; es la copia que auditas en la A6.2 y restauras en la A6.3.
Antes de empezar
- Autorización escrita del tutor para trabajar sobre la aplicación elegida (o revisar su copia actual) y acuerdo sobre qué puedes ejecutar en producción.
- Acceso como root al host de la aplicación, con
resticyageinstalados, o el tutor delante si no te lo dan. - Un destino aprobado por la empresa: bucket o NAS o, si no hay nada, un MinIO temporal en contenedor en un host de pruebas, montado con la receta del laboratorio de Despliegue. El MinIO del laboratorio no se alcanza desde la empresa.
- La clave pública de
ageque te da quien custodia la privada (Ficheros, configuración y secretos). - Leído: Qué se copia y qué no, Regla 3-2-1, RPO y RTO, restic a fondo, Programar la copia y Verificar que se ha ejecutado.
Pasos
Vas a construir la copia en el mismo orden que la teoría y a probar cada pieza a mano antes de automatizarla: primero decides qué se copia y con qué objetivos, después compruebas que el volcado y la copia funcionan cuando los lanzas tú, y solo entonces los metes en el script y el timer y añades la alerta. Así, si algo falla de madrugada, sabes que cada pieza funcionaba suelta y que el problema está en lo que añadió la automatización: el entorno, los permisos o la hora.
-
Haz el inventario y fija los objetivos. Con el tutor, apunta en una tabla cada componente de la aplicación (base de datos, volúmenes de ficheros, configuración, secretos y versión de imagen en producción), si se copia o no y por qué, con el criterio de Qué se copia y qué no. Apunta también el RPO y el RTO que fije el responsable del servicio, porque deciden la hora del timer del paso 5 y la vara con la que se mide la restauración de la A6.3. Si nadie los ha fijado, propón 24 h y 4 h y deja constancia de que es una propuesta.
Pista: los volúmenes en uso salen del
compose.ymlo dedocker inspect, no dedocker volume ls, que lista también los abandonados. -
Contrasta lo que ya hay. Si la empresa ya tiene copias, compara su script con lo explicado en este bloque (dump consistente,
-Ten elexec, secretos cifrados,set -e, retención escrita) y apunta cada diferencia en una lista de mejoras. No se trata de sustituir lo que funciona, sino de saber qué riesgos tiene antes de tocarlo: esa lista es lo que propones al tutor. Si no hay copias, los pasos siguientes las montan desde cero. -
Saca a mano un volcado consistente. Vuelca la base de datos y los roles del clúster en
/var/backups/app.dumpy/var/backups/globals.sql, como en Dump lógico frente a copia del volumen, a la hora que acuerdes con el tutor, porque un volcado lee la base entera de producción. Comprueba que no están vacíos: es la pieza que decide si la copia sirve, y si sale mal, el resto del sistema guardará cada noche, cifrado y verificado, un fichero inútil.Pista: un dump de 0 bytes con código de salida 0 suele ser el
-Tque falta endocker compose exec. -
Haz a mano la primera copia con restic. Copia con el tag
applas rutas del script de Programar la copia, con la contraseña del repositorio en/etc/restic/passy el.envcifrado antes conage, porque en claro no entra nunca. Lanzarla a mano antes de automatizarla separa los problemas: si falla aquí, es el destino o la credencial; si después solo falla desde el timer, es el entorno de systemd. Si el repositorio es nuevo, su contraseña va al gestor de contraseñas de la empresa en cuanto se genera, porque sin ella las copias son ilegibles.Pista: además de las
RESTIC_*, restic necesita la credencial del bucket (AWS_ACCESS_KEY_IDyAWS_SECRET_ACCESS_KEY); sin ella,restic initfalla con ununable to open config fileengañoso. -
Automatízala. Adapta el
backup-app.shde Programar la copia a la aplicación y a la retención acordada, y prográmalo a la hora que pide el RPO con el timer de systemd timers, o con cron si es lo que usa la empresa. Lanza una ejecución a mano y revisa su log; con el timer, lánzala por la unit y no ejecutando el script, porque es la única forma de comprobar antes de la primera noche que el script, lanzado por systemd, ve las mismas credenciales que tú tenías en la terminal.Pista:
TS_FILEtiene que estar en el directorio que lee el node_exporter del host: su--collector.textfile.directory, que en el paquete de Debian ya es el del script. Si no lee ninguno, activarlo lo decide el tutor; si no lo autoriza, la alerta va por healthchecks.io. -
Comprueba que la alerta salta. Configura la alerta por ausencia de Verificar que se ha ejecutado, con sus dos reglas si hay Prometheus o con healthchecks.io si no. Una alerta que nunca se ha visto saltar no demuestra nada, así que provócala con el tutor delante sin esperar un día, captura cada alerta disparada y deja todo como estaba.
Pista: con Prometheus, umbral de
BackupMissinga> 300yfor: 1men las dos reglas;BackupMetricAbsentsalta al apartar el.prom. Con healthchecks.io, periodo de 5 min y gracia de 1 min.
Comprobación
systemctl list-timersmuestra el timer con próxima ejecución, yjournalctl -u backup-app.servicetermina con el resumen de restic (ficheros nuevos, cambiados, datos añadidos) y sin errores.restic snapshots --latest 1 --tag appdevuelve un snapshot de hoy con las rutas esperadas;restic ls latest | grep app.dumplo localiza dentro.- El dump tiene tamaño coherente con la base (
ls -la /var/backups), no 0 bytes. - La alerta ha saltado en la prueba de ausencia y ha vuelto a estado normal tras una copia correcta.
Entrega En ut6/a61/ del repositorio de la estancia: la tabla de inventario del paso 1 y la lista de mejoras del paso 2 si había copias previas, el script o la configuración anonimizados, unit y timer (o la línea de cron), salida de restic snapshots (o equivalente), la regla o el check de alerta y la captura de la alerta disparada. Fila A6.1 de la ficha de evidencias firmada por el tutor.
Bloque 2 · Exportación y políticas (CE 4b)¶
La copia del bloque 1 se hace sola y avisa si falta, pero eso no garantiza que sobreviva a lo que tiene que sobrevivir. Si está en el mismo servidor, se pierde con él; si el host que copia puede borrarla, un ransomware que entre en él la borra antes de cifrar; y si nadie la poda, o la poda sin criterio, el destino se llena o guarda menos de lo que la empresa cree. Este bloque comprueba esas tres cosas contra una política escrita, en el orden en que se audita una copia: dónde está el destino y qué garantías da, si el host que copia puede destruirla, y si la rotación y la limpieza hacen lo que dice la política. Va después del bloque 1 porque necesita una copia funcionando desde hace unos días: sin varios snapshots no hay rotación que contar. La hoja A6.2 lo demuestra con listados, y el punto de retención de restic a fondo, del bloque 1, se vuelve a usar aquí.
Destinos y políticas¶
El problema. La copia local en /var/backups del propio host no es una copia: se pierde con el servidor, con el ransomware que cifra el disco y con el rm -rf equivocado. El destino externo es la parte del 3-2-1 que más se descuida, y elegirlo es decidir de qué desastres protege la copia.
Dónde puede estar. Los destinos habituales, con lo que aporta y lo que cuesta cada uno:
| Destino | Acceso desde restic | Puntos a favor | Puntos en contra |
|---|---|---|---|
| S3 / MinIO (propio o en otro CPD, centro de datos) | s3: nativo |
Versionado, Object Lock, políticas IAM (permisos por credencial) por bucket | Coste por GB y por petición en nube; latencia en prune |
| NAS por SFTP o NFS | sftp: o ruta local |
Barato, rápido en LAN | Mismo edificio; permisos difíciles de hacer append-only |
| Nube pública (B2, S3, Azure Blob) | nativo | Fuera del sitio sin esfuerzo | Egress (pago por descargar) al restaurar; cifrado en cliente obligatorio; ubicación |
| Cinta LTO (cinta magnética) | Vía intermedio (restic no escribe en cinta) | Offline de verdad, décadas de retención | Lento, requiere robot o disciplina manual |
| Disco externo rotado | ruta local | Trivial, offline | Depende de que alguien lo cambie; se olvida |
Con restic, lo habitual es el almacenamiento de objetos, propio en otro centro de datos o en una nube pública: queda fuera del sitio sin que nadie tenga que mover nada, y es el que permite impedir el borrado desde el propio destino, que es lo que más pesa en las políticas de abajo. La cinta y el disco rotado aportan la copia offline de la variante 3-2-1-1-0, a cambio de depender de que alguien los cambie.
Qué tiene que cumplir. Las políticas que hay que respetar y comprobar, y que son el contenido de la A6.2, son de tres tipos: cómo se almacena la copia, cuántas se conservan (rotación) y cómo se libera lo que sobra (limpieza).
Almacenamiento. Tres requisitos, y cada uno con su forma de comprobarlo:
| Requisito | Qué se exige | Cómo se comprueba |
|---|---|---|
| Cifrado | Cifrado en cliente, antes de que los datos salgan del host. El repositorio restic ya lo hace; en nube pública es obligatorio, no opcional. | restic cat config solo abre con la contraseña, y en el destino se ven blobs, no nombres de fichero de la aplicación. |
| Ubicación | Si el servicio trata datos personales, el RGPD (el reglamento europeo de protección de datos: su artículo 32, seguridad del tratamiento, y su capítulo V, transferencias internacionales) condiciona dónde puede estar la copia: un bucket en eu-west-1 o eu-south-2 (Irlanda y España) vale; uno en us-east-1 exige garantías adicionales que la empresa probablemente no quiere gestionar. |
Región del bucket (mc admin info, con mc, el cliente de línea de comandos de MinIO, o la consola de la nube) frente a la que permite la política escrita. |
| Protección contra borrado | El host que copia no debe poder destruir las copias. En S3 o MinIO se combinan el versionado del bucket (mc version enable minio/backups), que conserva las versiones anteriores de un objeto sobrescrito o borrado, y Object Lock en modo compliance con periodo de retención (mc retention set --default COMPLIANCE 30d minio/backups), que impide que nadie, ni el administrador, borre antes de que venza. La credencial del script lleva s3:PutObject, s3:GetObject y s3:ListBucket pero no s3:DeleteObject, salvo en el prefijo locks/ del repositorio (el porqué está debajo de la tabla); con el servidor REST de restic el equivalente es rest-server --append-only. |
mc version info minio/backups, mc retention info minio/backups y la política de la credencial; la prueba práctica es subir un objeto de usar y tirar, fuera de locks/, con la credencial del script e intentar borrarlo, que tiene que responder AccessDenied. Nunca se prueba con un forget --prune contra el repositorio real: si la protección está mal puesta, que es lo que se está comprobando, el comando funciona y destruye copias. |
En las órdenes de la tabla, minio es el alias con el que se ha dado de alta el destino en mc; en el laboratorio era lab.
Cómo funciona la protección contra borrado. Son tres capas porque cada una tapa el hueco de otra. Quitarle el permiso de borrado a la credencial que vive en el host impide que borre, pero no que sobrescriba un objeto con basura; el versionado hace que sobrescribir o borrar deje guardada la versión anterior; y Object Lock en modo compliance impide que esas versiones se eliminen antes de su fecha, ni siquiera con la cuenta de administración del destino. Con las tres, un atacante que entre en el host de producción no puede borrar la copia, que es el primer paso del ransomware moderno.
Lo que cambia en el script. Una credencial que no puede borrar tampoco puede ejecutar forget: olvidar un snapshot es borrar su fichero de snapshots/, y la documentación de restic advierte que forget y prune necesitan permiso completo de lectura, escritura y borrado, y que se lancen desde un cliente aparte y bien protegido. Por eso, cuando se aplica esta protección, el forget sale del script de la A6.1 y pasa, junto al prune y con la misma política, a otro host con otras credenciales; el host de producción se queda en volcar, copiar y verificar. La excepción es locks/: restic crea un fichero de bloqueo al empezar cada operación y lo borra al terminar, así que la credencial del script conserva el permiso de borrado solo en ese prefijo, donde no hay ninguna copia.
Rotación. La política escrita dice cuántas diarias, semanales, mensuales y anuales se conservan; los flags --keep-* tienen que coincidir con ella, y el listado del destino también. Se comprueba con restic snapshots --group-by tags y contando, sin olvidar que una misma copia puede contar a la vez como diaria, semanal y mensual.
Limpieza. prune programado y ejecutándose (con lock exclusivo, en horario que no coincida con la copia); espacio ocupado dentro de lo previsto (restic stats --mode raw-data, mc du minio/backups); y copias caducadas realmente eliminadas, lo que con versionado y Object Lock exige además una regla de ciclo de vida en el bucket (mc ilm rule add --expire-delete-marker --noncurrent-expire-days 30 minio/backups) para que las versiones no actuales se borren cuando venza la retención.
Cómo se comprueba. Siempre igual: listar el destino (restic snapshots, aws s3 ls --recursive, mc ls --versions, listado de la NAS) y compararlo línea a línea con la política. Lo que no coincide va a la tabla de cumplimiento con una fecha de corrección.
Las credenciales del bucket no son secretos de segunda
Con AWS_SECRET_ACCESS_KEY y RESTIC_PASSWORD en el mismo fichero, quien lea /etc/restic/env tiene las copias enteras de la empresa. Ese fichero va con chmod 600, propietario root, fuera de cualquier repositorio Git y, si la empresa tiene Vault (un gestor de secretos) o similar, se sirve desde allí en tiempo de ejecución.
A6.2 Exportación y políticas (CE 4b)¶
Objetivo Pasar de "tenemos copias" a poder demostrar que sobreviven a perder el host y a un ataque desde él. Al terminar, has demostrado con listados que las copias están fuera del host, que el host que copia no puede borrarlas, y que la rotación y la limpieza que hay coinciden con la política escrita (de la empresa o la que propones tú), y lo que no coincide está en una tabla de cumplimiento con su corrección.
Antes de empezar
- Autorización del tutor para lanzar comandos de solo lectura contra el destino; los cambios (versionado, Object Lock, ciclo de vida), solo con su visto bueno y, mejor, ejecutados por él.
- La copia de la A6.1 funcionando desde hace unos días, para que haya varios snapshots que contar.
- Credenciales de lectura al destino y, si es una nube pública, leído lo que explica la unidad de nube pública de Despliegue sobre cuentas, regiones y coste de descarga.
- Leído: Destinos y políticas y el apartado de retención de restic a fondo.
Pasos
Es una auditoría, y como toda auditoría va de la norma a los datos y de los datos a la conclusión: primero consigues la política escrita, que es la vara de medir; después sacas los listados del destino; luego compruebas punto por punto el almacenamiento, la rotación y la limpieza; y al final lo resumes en la tabla de cumplimiento. Casi todo es de solo lectura. Lo único que escribe en el destino es la prueba de borrado del paso 3, y por eso lleva su propia advertencia.
-
Consigue la política escrita. Pide la política de copias de la empresa: sin ella no hay contra qué comparar, y cada comprobación acabaría siendo una opinión. Si no existe, redáctala en media página a partir del RPO de la A6.1 y del espacio disponible: cifrado, ubicación, protección contra borrado, retención y limpieza. Márcala como propuesta y que el tutor la lea antes de comprobar nada, para que la auditoría mida contra algo aceptado.
-
Lista el destino. Saca los snapshots agrupados por tags, los objetos del bucket con sus versiones y el espacio ocupado, con las órdenes de Destinos y políticas, y guarda la salida anonimizada. Esos listados son la evidencia de que la copia está fuera del host, y los datos con los que trabajan los pasos 4 y 5.
Pista: con una credencial de solo lectura, restic no puede crear su fichero en
locks/;--no-lockle deja listar sin crearlo. -
Comprueba el almacenamiento. Recorre las tres filas de la tabla de Destinos y políticas (cifrado, ubicación y protección contra borrado) anotando comando y resultado. La protección no se da por buena leyendo la configuración, porque lo que importa es qué pasaría en un ataque: se demuestra intentando borrar, con la credencial del script, un objeto de usar y tirar que subes tú. Tiene que responder
AccessDeniedy el objeto, seguir ahí; anótalo para que lo retire quien administre el bucket.Pista: configura en
mcun alias aparte con la credencial del script, para no probar sin querer con la de administración. Con una NAS y borg, lo que se busca es--append-onlyen elauthorized_keysdel usuario de copias.El repositorio real no se usa para probar borrados
Si la protección está mal puesta, que es lo que vienes a comprobar, un
restic forget --prunecontra el repositorio de la empresa borra copias de verdad. Si el destino no deja escribir fuera de él, prueba contra un repositorio restic de usar y tirar en otro prefijo. -
Cuenta la rotación. Cuenta en el listado del paso 2 cuántas diarias, semanales y mensuales hay de verdad y compáralo con los
--keep-*que aplica la limpieza (los del script o, si ya se ha separado, los del host que hace elforget) y con la política: las tres cosas tienen que coincidir. Una diferencia significa que la empresa cree tener copias que no tiene, o que guarda y paga copias que nadie ha pedido. -
Comprueba la limpieza. Localiza el
prune(en otro host o con otras credenciales que las del script) y su última ejecución, compara el espacio ocupado con el previsto y, si el bucket tiene versionado, comprueba que hay una regla de ciclo de vida para las versiones no actuales. Es la otra mitad de la rotación: sinprune, lo que olvidaforgetsigue ocupando, y con versionado pero sin ciclo de vida, lo que borraprunetampoco se libera. -
Resume en la tabla de cumplimiento. Rellena una fila por punto de la política: requisito, cómo se comprobó, resultado, acción correctora con fecha si procede. La tabla es lo que se queda la empresa: lo que cumple y lo que falta, con una fecha para cada corrección. Lo que no cumple se propone al tutor y, si lo autoriza, se corrige y se vuelve a comprobar.
Comprobación
- El listado del destino muestra los snapshots de la A6.1 y el recuento por tipo coincide con la política y con los flags
--keep-*. - El borrado del objeto (o del repositorio) de usar y tirar con la credencial del script ha fallado con
AccessDenied, y las copias reales siguen intactas:restic snapshotsdevuelve las mismas de antes de la prueba. - La tabla de cumplimiento no tiene filas vacías y cada "no cumple" tiene acción y fecha.
Entrega En ut6/a62/ del repositorio de la estancia: la política del paso 1 (de la empresa o tu propuesta, indicando cuál), los listados del destino del paso 2 (anonimizados) y la tabla de cumplimiento del paso 6. Fila A6.2 de la ficha de evidencias firmada por el tutor.
Bloque 3 · Restauración de prueba (CE 4c)¶
Los dos bloques anteriores demuestran que la copia se hace, que está fuera del host y que se conserva como dice la política, pero no que sirva. Una copia puede estar completa y no restaurarse: falta la clave para descifrar el .env, los roles no se copiaron, la versión de imagen no casa con los datos o la restauración tarda el triple de lo que permite el RTO. Todo eso solo aparece al restaurar, y es preferible que aparezca un martes cualquiera en un entorno de pruebas que el día del desastre. Por eso este bloque va al final y recoge lo anterior: se restaura la copia que monta el bloque 1, desde el destino que audita el bloque 2, con el procedimiento de ocho pasos que sigue la hoja A6.3; y lo aprendido en los tres bloques se escribe en el plan de copias, el documento que se entrega al cierre.
Restaurar: el mantenimiento preventivo¶
Por qué es mantenimiento preventivo. Una copia que no se ha restaurado nunca no es una copia; es una esperanza. La restauración periódica (mensual como mínimo, y siempre antes de una actualización mayor) es la tarea de mantenimiento preventivo de esta unidad, y la que cubre el CE 4c. Se llama preventiva por lo mismo que cambiar una pieza antes de que se rompa: se hace sin que nada haya fallado, para encontrar los fallos de la copia mientras arreglarlos todavía no corre prisa.
El procedimiento. Paso a paso, con el cronómetro en marcha desde el primer comando, porque lo que se quiere saber no es solo si se restaura, sino cuánto tarda cada fase frente al RTO:
- Elegir la copia. Normalmente la última:
restic snapshots --latest 1 --tag app. Anotar el ID corto. - Preparar la plataforma de pruebas. Una VM limpia o un
docker composecon proyecto distinto (-p app_restore) en un host que no sea producción, porque restaurar encima del servicio real sustituiría los datos de hoy por los de la copia; el proyecto distinto hace que compose ponga otros nombres a contenedores, redes y volúmenes, para que no choquen con nada que ya exista. Comprobar que tiene Docker, restic, espacio en disco (el doble del tamaño del dump) y acceso al destino. - Restaurar.
restic restore <id> --target /restore. Anotar cuánto tarda; en un bucket remoto esta suele ser la fase larga. - Colocar la configuración y levantar la base de datos vacía con la versión de imagen que dice
/restore/var/backups/app-images.json. Elcompose.ymly el.envdescifrado van en su sitio antes del primerup: la copia solo guarda el.env.enc, y sin el fichero en claro el contenedor de la base arranca sin contraseña ni nombre de base. Después se carga:psql -U postgres -f /restore/var/backups/globals.sqlpara los roles, ypg_restore -U app -d app -j 4 /restore/var/backups/app.dump. La ruta del dump va como argumento y no por redirección, porquepg_restorecon-jabre el fichero varias veces y no puede hacerlo sobre la entrada estándar. Los errores de "role already exists" al cargar globals son normales si la imagen ya creó el rol. - Restaurar los volúmenes de ficheros copiando
/restore/var/lib/docker/volumes/app_uploads/_dataal volumen nuevo con un contenedor auxiliar, como en la copia pero al revés, y ajustando el propietario al UID que use la imagen. - Levantar el servicio y verificar: la aplicación arranca; los datos están (
SELECT count(*) FROM pedidos, el último registro por fecha, un fichero subido que se abre); pruebas funcionales básicas (login, una búsqueda, generar un informe). Comparar los recuentos con los de producción a la hora de la copia si es posible. - Parar el cronómetro y comparar con el RTO. Si ha tardado más, el plan tiene un problema y es preferible descubrirlo hoy.
- Registrar: fecha, snapshot usado, quién, tiempo por fase, resultado, incidencias encontradas y qué se corrigió. Destruir el entorno de pruebas (contiene datos reales de la empresa) y borrar
/restore.
flowchart TD
A["<b>Elegir snapshot</b><br><small>restic snapshots --latest 1</small>"]:::act
B["<b>Plataforma de pruebas limpia</b>"]:::pieza
C["<b>restic restore --target /restore</b>"]:::act
D["<b>pg_restore + volúmenes + configuración</b>"]:::act
E["<b>Levantar con la imagen anotada</b>"]:::act
F{"<b>¿Datos y pruebas funcionales OK?</b>"}:::dato
G["<b>Medir el tiempo</b><br><small>y compararlo con el RTO</small>"]:::ok
H["<b>Incidencia</b><br><small>corregir la copia o el procedimiento</small>"]:::riesgo
I(["<b>Registrar y destruir el entorno</b>"]):::ok
A --> B --> C --> D --> E --> F
F -- sí --> G --> I
F -- no --> H --> A
I -.->|mes siguiente| A
classDef act fill:#ea580c22,stroke:#ea580c,stroke-width:1.5px
classDef pieza fill:#64748b22,stroke:#64748b,stroke-width:1.5px
classDef dato fill:#2563eb22,stroke:#2563eb,stroke-width:1.5px
classDef infra fill:#a1a1aa14,stroke:#a1a1aa,stroke-width:1.5px
classDef ok fill:#16a34a22,stroke:#16a34a,stroke-width:1.5px
classDef riesgo fill:#dc262622,stroke:#dc2626,stroke-width:1.5px
Una copia que no se ha restaurado nunca es una suposición. Por eso la restauración se programa como mantenimiento preventivo, con su fecha.
Tres tareas complementarias que también forman parte del mantenimiento:
restic check --read-datacompleto con periodicidad (trimestral en repositorios grandes, mensual en pequeños), además del 5% de cada noche. Detecta corrupción silenciosa en el almacenamiento, que los subconjuntos pueden tardar meses en encontrar.- Rotación de la contraseña del repositorio según la política (
restic key add, probar,restic key remove <id antiguo>), con la nueva custodiada donde diga la empresa. - Restauración de un fichero suelto, que es lo que de verdad piden los usuarios ("he borrado el informe de marzo"). Con restic:
restic find informe-marzo.pdfpara localizar en qué snapshots está, yrestic restore <id> --target /tmp/r --include '/var/lib/docker/volumes/app_uploads/_data/informes/informe-marzo.pdf'; orestic mounty copiar concp. Medir este tiempo también: es un RTO distinto, y suele ser el que más veces se ejerce.
Restauración a un punto anterior
Cuando el problema no es un disco roto sino un borrado lógico descubierto días después ("alguien vació la tabla de clientes el martes"), la copia que interesa no es la última sino la anterior al daño. restic snapshots con la fecha y restic diff entre dos snapshots ayudan a localizar el momento exacto; con pg_restore -t clientes se recupera solo esa tabla en una base auxiliar y se traspasan las filas.
El plan de copias como documento¶
Para quién se escribe. Lo que la empresa necesita al final no es el script, sino un documento que otra persona pueda seguir a las tres de la mañana con el servicio caído. Quien monta las copias no suele ser quien las restaura: el día que hacen falta puede estar de vacaciones o haber terminado ya su estancia en la empresa. Por eso el documento lleva comandos literales y rutas reales, y no da por sabido nada de lo que hizo quien lo escribió. Dos páginas, con estos apartados en este orden:
- Alcance: qué servicio, qué componentes (base de datos, volúmenes, configuración) y qué queda fuera y por qué (imágenes en el registry, código en Git).
- Objetivos: RPO y RTO acordados con quien responde del servicio, y los medidos en la última restauración de prueba.
- Qué y cuándo: método por componente (dump
-Fc, copia de volumen), programación (timer, hora, ventana), herramienta y versión. - Dónde: destino principal y externo, cifrado, ubicación geográfica, quién custodia las claves.
- Retención y limpieza: política de rotación (diarias, semanales, mensuales), Object Lock o equivalente,
pruney ciclo de vida. - Verificación: qué se mira a diario (alerta de ausencia, log), qué mensualmente (restauración de prueba,
check), quién lo hace. - Procedimiento de restauración: los ocho pasos anteriores adaptados, con comandos literales y rutas reales, y el de fichero suelto.
- Registro: tabla con las restauraciones de prueba realizadas (fecha, snapshot, tiempo, resultado).
A6.3 Restauración de prueba (CE 4c)¶
Objetivo Demostrar que la copia de la A6.1 sirve para lo que se hizo: devolver el servicio. Al terminar, has levantado el servicio en una plataforma de pruebas desde la última copia, con los datos verificados, un tiempo medido por fase comparado con el RTO, y el entorno de pruebas destruido. Es la única prueba que pone a examen a la vez la copia, el destino, las claves y el procedimiento.
Antes de empezar
- Autorización del tutor para restaurar datos reales en pruebas, con acuerdo escrito de que el entorno se destruye al terminar.
- Una VM o un host de pruebas que no sea producción, con Docker, restic (o la herramienta de la empresa), acceso de lectura al destino y espacio libre de al menos el doble del último snapshot.
- El
.envdescifrado o la clave con la que descifrarlo, en manos de quien la custodie. - Un cronómetro y una plantilla de registro con una fila por fase: inicio, fin, duración e incidencia.
- Leído: Restaurar: el mantenimiento preventivo entero, incluida la restauración de un fichero suelto.
Pasos
Vas a seguir el procedimiento de Restaurar: el mantenimiento preventivo en el orden en que se reconstruiría el servicio de verdad: primero traes los datos, después levantas la base de datos y la cargas, luego el resto del servicio, y solo con todo arriba verificas. El cronómetro corre desde el primer paso y se anota por fases, porque el total dice si cumples el RTO, pero son las fases las que dicen dónde se va el tiempo. Al final destruyes lo restaurado, porque son datos reales de la empresa fuera de producción.
-
Trae la copia a la plataforma de pruebas. Elige la copia (normalmente la última con el tag
app), arranca el cronómetro y restáurala en/restorede la plataforma de pruebas, anotando cuánto tarda. Con un destino remoto suele ser la fase más larga, y la que más depende del destino que auditaste en la A6.2.Pista: lo restaurado conserva las rutas originales: en
/restore/var/backupsestán el dump, los roles yapp-images.json, con la versión de imagen de la copia. -
Reconstruye la base de datos y los ficheros. En un proyecto de compose aparte,
app_restore, levanta solo la base de datos con esa versión de imagen, carga roles y dump, y lleva los ficheros a su volumen con el propietario que use la imagen. La base va sola y primero porque la aplicación, si arrancara contra una base vacía, podría crear su propio esquema y estorbar a la carga. Anota la duración de cada fase.Pista: el
.envdescifrado va en su sitio antes del primerup.pg_restore -jno lee de la entrada estándar, así que copia antes el dump al contenedor, ydocker compose createcrea el volumen de ficheros sin arrancar la aplicación. -
Levanta el servicio y demuestra que los datos están. Levanta el resto del servicio y verifica los datos (recuentos de las tablas principales, último registro, un fichero subido que se abre) y las pruebas funcionales que acuerdes con el tutor, comparando con producción si es posible: que la aplicación arranque no basta, porque una base restaurada a medias también arranca. Para el cronómetro y compara el total con el RTO de la A6.1; si lo superas, va al registro como incidencia.
-
Recupera un fichero suelto. Recupera un fichero que acuerdes con el tutor, con su propio tiempo, como en Restaurar: el mantenimiento preventivo. Es la restauración que más se pide en el día a día, y se mide aparte porque su tiempo es otro RTO, distinto del de la caída completa.
-
Registra y destruye el entorno. Completa el registro (fecha, snapshot, quién, comandos, tiempo por fase y total, resultado, incidencias y correcciones) y destruye el entorno: el proyecto
app_restorecon sus volúmenes,/restorey lo recuperado del fichero suelto. El registro es lo que alimenta el apartado de registro del plan de copias, y la destrucción no es opcional, porque el entorno guarda datos reales fuera de los controles de producción. El tutor confirma la destrucción en la ficha. -
Propón cada cuánto repetirlo. Redacta la propuesta de periodicidad para la empresa (mensual, trimestral, siempre antes de una actualización mayor), justificada con el tiempo medido y con lo que cambia el servicio entre copias. Una restauración de prueba caduca: cada cambio de versión, de esquema o de destino puede romper la siguiente, y la propuesta dice cada cuánto hay que volver a demostrar que funciona.
Comprobación
- La aplicación restaurada arranca y las pruebas funcionales pasan; los recuentos coinciden o la diferencia se explica por la hora de la copia.
- El registro tiene un tiempo por cada fase y el total, con la comparación con el RTO escrita.
- El fichero suelto se ha recuperado y su tiempo está anotado aparte.
docker compose -p app_restore psno devuelve nada y/restoreno existe.
Entrega En ut6/a63/ del repositorio de la estancia: el registro de la restauración del paso 5 (comandos ejecutados, tiempos por fase y del fichero suelto, resultado, incidencias, capturas anonimizadas de la aplicación restaurada) y la propuesta de periodicidad del paso 6. Fila A6.3 de la ficha de evidencias firmada por el tutor.
Si te sobra tiempo Ensaya la restauración a un punto anterior: localiza con restic snapshots y restic diff la copia previa a un cambio conocido y recupera una sola tabla con pg_restore -t <tabla> en una base auxiliar.
Práctica evaluable¶
En esta unidad la práctica evaluable es el conjunto de las tres actividades más el plan de cierre, valoradas con la ficha de evidencias firmada por el tutor de empresa y revisadas por el profesor. No pide nada que no salga de un paso de las hojas. Los pesos:
| Criterio | Peso | Qué se valora |
|---|---|---|
| A6.1 Copias programadas (CE 4a) | 25 % | Selección justificada de qué se copia (paso 1), consistencia del dump (paso 3), programación correcta (paso 5), verificación con alerta de ausencia funcionando (paso 6) |
| A6.2 Exportación y políticas (CE 4b) | 25 % | Copia en medio externo (paso 2), protección contra borrado (paso 3), rotación y limpieza comprobadas contra la política (pasos 4 y 5), tabla de cumplimiento completa y honesta (paso 6) |
| A6.3 Restauración de prueba (CE 4c) | 30 % | Procedimiento seguido en plataforma de pruebas con verificación de datos (pasos 1 a 3), tiempos medidos por fase (pasos 1 a 3), fichero suelto (paso 4), registro completo y entorno destruido (paso 5) |
| Plan de copias y restauración | 15 % | Dos páginas ejecutables por un tercero, que recogen lo producido en las tres hojas: RPO y RTO acordados y medidos, comandos literales, registro de pruebas |
| Ficha de evidencias y anonimización | 5 % | Ficha completa y firmada, evidencias sin datos de la empresa |
Se entrega por Aules la carpeta ut6/ del repositorio de la estancia antes del 11 de junio de 2027, junto con la ut5/ y con las mismas reglas de anonimización. Tiene que contener:
-
a61/: tabla de inventario (A6.1, paso 1) y lista de mejoras si había copias previas (paso 2), script, unit y timer o línea de cron (paso 5), listado de snapshots (pasos 4 y 5) y regla o check de alerta con la captura de la alerta disparada (paso 6). -
a62/: política (A6.2, paso 1), listados del destino (paso 2) y tabla de cumplimiento (paso 6). -
a63/: registro de la restauración con tiempos por fase y del fichero suelto (A6.3, pasos 1 a 5) y propuesta de periodicidad (paso 6). -
plan-copias-servicio-X.pdf(dos páginas, de la actividad de cierre). -
ficha-evidencias.pdffirmada.
Actividad de cierre¶
Documento de dos páginas, "Plan de copias y restauración del servicio X", con los ocho apartados de El plan de copias como documento. Es lo que se queda en la empresa cuando termina la estancia: sin él, lo montado en las tres hojas solo lo sabe manejar quien lo montó. No se escribe desde cero: cada apartado resume una evidencia que ya está en ut6/. El alcance sale del inventario de la A6.1 (paso 1); los objetivos, del RPO y el RTO acordados (A6.1, paso 1) y del tiempo medido (A6.3, paso 3); qué y cuándo, del script y el timer (A6.1, paso 5); dónde, retención y limpieza, de la política y la tabla de cumplimiento de la A6.2; la verificación, de la alerta (A6.1, paso 6) y de la propuesta de periodicidad (A6.3, paso 6); y el procedimiento de restauración y el registro, del registro de la A6.3. Se entrega al tutor de empresa y al profesor.
Ficha de evidencias¶
| Actividad | CE | Fecha | Evidencia adjunta | Observaciones del tutor | Firma |
|---|---|---|---|---|---|
| A6.1 Copias programadas | 4a | ||||
| A6.2 Exportación y políticas | 4b | ||||
| A6.3 Restauración | 4c | ||||
| Cierre: plan de copias | 4a, 4b, 4c |
Errores frecuentes en el laboratorio¶
pg_dumpvacío o de 0 bytes y código de salida 0. Casi siempre es el-Tque falta endocker compose exec, oset -o pipefailausente cuando se encadena congzip. Se ve mirando el tamaño del fichero (ls -la /var/backups) antes de dar la copia por buena; el script debería comprobar[ -s "$DUMP" ].Fatal: unable to open config file ... repository does not exist. La variableRESTIC_REPOSITORYno llega al script (systemd no hereda el entorno de la sesión interactiva). O se exporta en el propio script o va enEnvironmentFile=.systemctl show backup-app.service -p Environmentdice qué ve la unit. El mismo mensaje sale con el repositorio bien puesto cuando faltanAWS_ACCESS_KEY_IDyAWS_SECRET_ACCESS_KEY: restic llega al bucket pero no puede autenticarse, y no distingue las dos situaciones.repository is already locked. Copia anterior interrumpida (reinicio,kill).restic unlocktras confirmar conps aux | grep resticque no hay otro proceso.- Copias de 0 B añadidos durante días. La ruta copiada es un volumen que ya no usa la aplicación (se recreó con otro nombre tras un
docker compose down -v).docker volume lsydocker inspectdel contenedor para ver el volumen real. - La alerta
BackupMissingno salta aunque no haya copia. La métrica desapareció (node_exporter reiniciado sin el directorio de textfile, fichero.promborrado). Es el caso que cubreabsent(); si no está la segunda regla, no hay aviso. La consultabackup_last_success_timestamp_secondsen Prometheus lo confirma sin depender de cómo esté protegido el exporter (si no devuelve nada, la métrica no llega), y unls -ladel directorio de textfile en el host que copia dice si el fichero existe. forgetoforget --prunefallan conAccessDenieddesde el host de producción. Es lo esperado cuando la credencial del script va sins3:DeleteObject, como debe ser: la retención y elprunese ejecutan desde el host de administración con otras credenciales, y elforgetsale del script (Destinos y políticas). En el laboratorio no pasaba porque la credencialresticera de lectura y escritura: el mismo host copiaba y limpiaba.- El bucket no deja de crecer aunque
forgetborra snapshots. Versionado activado sin regla de ciclo de vida: los objetos borrados porprunequedan como versiones no actuales.mc ls --versionslo muestra;mc ilm rule addlo arregla. pg_restorefalla con "role app does not exist" o con errores de extensiones. No se restauraron los globals (pg_dumpall --globals-only) o la imagen de PostgreSQL de pruebas no tiene la extensión (pg_trgm,postgis). Va en los metadatos del despliegue.- La restauración arranca pero la aplicación falla al abrir un fichero subido. Se restauró la base pero no el volumen de ficheros, o se restauró en otra ruta y los permisos (UID del contenedor) no coinciden.
chown -R 1000:1000sobre el volumen restaurado, con el UID que use la imagen. - El timer no se ejecutó y
Persistent=trueestá puesto. El timer no estabaenabled, solostarted; al reiniciar el host no se cargó.systemctl enable --nowysystemctl list-timers --allpara ver los inactivos.
Los enlaces para ampliar y los apartados que van más allá de lo que pide la unidad están en Para ampliar.
UT7 · Actualización y gestión de vulnerabilidades¶
Hasta aquí el módulo ha tratado de mirar el servicio y de medirlo y probarlo. Todo eso da por hecho que el servicio no cambia. En esta unidad cambia: aparecen versiones nuevas de la imagen base, de las librerías y de la propia aplicación, aparecen vulnerabilidades en librerías que nadie sabía que llevaba el contenedor, y hay que decidir qué se actualiza, cuándo y cómo, sin romper nada y dejando rastro. Las pruebas de la UT4 se reutilizan tal cual como red de seguridad de cada actualización, y el plan B es una copia con restic contra el MinIO del laboratorio. La práctica evaluable cierra la unidad el 23 de febrero. Justo después, desde el 25 de febrero, la UT8 da de baja el entorno pre que esta unidad usa tanto.
La unidad sigue el orden en que se trabaja en cualquier equipo de Sistemas, y cada sesión contesta a la pregunta que deja abierta la anterior. Primero, qué hay desplegado exactamente y cómo enterarse de que sale algo nuevo. Después, si alguna de esas piezas tiene un fallo de seguridad publicado y qué se hace con cada uno. Con la decisión tomada, cómo se actualiza sin perder nada y qué se hace cuando la actualización falla. Y al final, cómo queda escrito todo para poder reconstruirlo meses después, y cómo se automatiza el escaneo para que ninguna imagen se lo salte.
Sesión 28 · Inventario de versiones y seguimiento automático¶
En la UT4 se probó a fondo una versión concreta del servicio, la 1.4.2, y su informe de Trivy quedó archivado. Desde hoy esa versión empieza a envejecer: Docker Hub publica bases nuevas, las librerías sacan correcciones y la aplicación tendrá su 1.4.3. Antes de cambiar nada hay que resolver dos cosas, y en este orden. La primera, saber con exactitud qué hay desplegado y conseguir que no cambie por su cuenta: para eso la teoría explica cómo se nombra una imagen sin ambigüedad (etiqueta y digest), qué promete un número de versión y qué variante de imagen conviene. La segunda, enterarse de que sale algo nuevo sin tener que mirarlo a mano: Renovate, por qué no Watchtower y la política escrita que dice quién decide qué. La hoja hace las dos cosas sobre el servicio del curso: primero el inventario y la base fijada, después la vigilancia automática.
Versiones, etiquetas y digests¶
El problema: el nombre no basta. Antes de actualizar nada hay que poder decir con exactitud qué hay desplegado. Sin eso no se puede saber si un fallo publicado afecta al servicio, ni repetir en producción exactamente lo que se probó en pre (el entorno de preproducción, donde se ensaya antes de tocar producción), ni volver a lo que había si la actualización sale mal. Con contenedores, la línea image: del compose no siempre lo dice, y este apartado explica por qué y cómo se resuelve.
Qué es una imagen por dentro. Una imagen está hecha de capas, los cambios en el sistema de ficheros que va añadiendo cada instrucción del Dockerfile, y de un fichero de configuración con el comando de arranque, las variables y el usuario. El registry, el servidor del que se descargan las imágenes (Docker Hub para las públicas; en el laboratorio, desde el 19 de febrero, registry.lab:5000), describe cada imagen con un manifiesto: un documento JSON pequeño que lista la configuración y cada capa con su hash SHA-256. Como el manifiesto contiene el hash de todo lo demás, su propio hash identifica la imagen entera: si cambia un solo byte de cualquier capa, cambia también. Ese hash del manifiesto es el digest (sha256:…).
Etiqueta frente a digest. Una imagen se nombra por repositorio:etiqueta (postgres:17.6) y se identifica de forma inequívoca por su digest (postgres@sha256:…). La diferencia importa más de lo que parece: la etiqueta es un puntero que el mantenedor puede mover cuando quiera, el digest es el contenido. Cuando los mantenedores de la imagen oficial reconstruyen 17.6 porque Debian ha publicado un parche de libssl, la etiqueta no cambia y el digest sí. Un docker compose pull en producción un lunes puede traer una imagen distinta de la que se probó el viernes aunque el compose diga lo mismo.
flowchart LR
T1["<b>postgres:16</b><br><small>etiqueta flotante</small>"]:::riesgo
T2["<b>postgres:16.4</b><br><small>etiqueta fija… hasta que la reescriben</small>"]:::pieza
T3["<b>postgres@sha256:…</b><br><small>digest: el contenido exacto</small>"]:::ok
HOY["<b>Hoy</b>"]:::infra
MES["<b>Dentro de un mes</b>"]:::infra
D1["<b>Otra imagen</b><br><small>y nadie lo ha decidido</small>"]:::riesgo
D2(["<b>La misma, siempre</b>"]):::ok
HOY --> T1 & T2 & T3
T1 --> MES --> D1
T3 --> D2
classDef act fill:#ea580c22,stroke:#ea580c,stroke-width:1.5px
classDef pieza fill:#64748b22,stroke:#64748b,stroke-width:1.5px
classDef dato fill:#2563eb22,stroke:#2563eb,stroke-width:1.5px
classDef infra fill:#a1a1aa14,stroke:#a1a1aa,stroke-width:1.5px
classDef ok fill:#16a34a22,stroke:#16a34a,stroke-width:1.5px
classDef riesgo fill:#dc262622,stroke:#dc2626,stroke-width:1.5px
El nombre del compose no identifica el software: identifica una etiqueta que alguien puede mover. Solo el digest dice qué hay desplegado de verdad.
Tres niveles de precisión. De menos a más:
| Referencia | Qué significa | Dónde tiene sentido |
|---|---|---|
postgres:latest, postgres:17 |
La última versión que haya en ese momento. Impredecible: un pull puede saltar de 17.5 a 17.6 o, peor, de 17 a 18 con latest. |
Pruebas rápidas en un portátil. Nunca en un compose que se despliegue. |
postgres:17.6, postgres:17.6-bookworm |
Versión concreta. Identifica qué software lleva, pero la imagen se puede reconstruir por debajo (parches del SO base). | Mínimo aceptable en pre y producción. Es lo que se lee y lo que se escribe en el CHANGELOG. |
postgres:17.6@sha256:3f1a… |
Contenido exacto. Reproducible al 100 %: lo que se ha probado es lo que se despliega. | Pipelines y producción. La etiqueta se deja al lado para que un humano sepa qué es. |
Cómo se pasa de uno a otro. Las órdenes que dan cada dato (jq filtra JSON en la línea de comandos):
# Digest de una imagen descargada de un registry
docker images --digests python
# Digest y arquitecturas de una etiqueta en el registry, sin descargarla (consulta el registry)
docker manifest inspect python:3.13-slim-bookworm | jq '.manifests[] | {digest, platform}'
# Imagen exacta de la que salió un contenedor que está corriendo: su ID
# (el contenedor se busca por su servicio del compose, sin escribir su nombre)
docker inspect --format '{{.Image}}' $(docker compose -f /opt/servicio/compose.yml ps -q app)
# Reescribir el compose con todas las imágenes resueltas a digest (también consulta el registry)
docker compose config --resolve-image-digests > compose.pinned.yml
Las imágenes construidas en local no tienen digest. RepoDigests, el campo de docker image inspect que guarda los digests de una imagen, solo se rellena cuando la imagen se ha descargado de un registry o se ha subido a uno. Una imagen construida en local, como app:1.4.2 en app01 mientras no existe el registry del laboratorio, lo tiene vacío. Se identifica por su ID (docker image inspect --format '{{.Id}}' app:1.4.2), otro hash que Docker calcula en la propia máquina. El ID se conserva al llevar la imagen a otra máquina con docker save y docker load, y por eso es lo que se compara cuando una imagen viaja como fichero: si el ID coincide en los dos lados, es la misma imagen.
Imágenes para varias arquitecturas. Ojo con docker manifest inspect: una imagen publicada para varias arquitecturas tiene un índice, un manifiesto de manifiestos con uno por plataforma (linux/amd64, linux/arm64), y la orden devuelve el índice con el digest de cada plataforma. Para fijar en el compose se usa el digest del índice (el que aparece en RepoDigests), y Docker escoge la plataforma correcta al hacer pull. Si se fija a mano el digest de amd64 y algún día el servicio se mueve a una máquina ARM, el pull falla con un mensaje poco claro.
Para qué se usa en el curso. La forma de trabajo de las empresas que lo hacen bien: la etiqueta para leer, el digest para desplegar. En el servicio del curso, la base de la imagen viene de Docker Hub y lleva las dos cosas; la imagen de la aplicación se construye en app01 y, hasta que exista el registry, lleva una versión fija sin digest:
# compose.yml
services:
app:
image: app:1.4.2 # desde el 19 de febrero, registry.lab:5000/app:1.4.2@sha256:...
Fijar tiene un precio: una versión fijada no recibe ningún parche hasta que alguien cambia el digest. Quién lo cambia se explica en Seguir la aparición de versiones: no una persona a mano, sino Renovate, un robot que revisa el repositorio y propone el cambio cada vez que sale una versión nueva.
Versionado semántico¶
Qué problema resuelve. De un número de versión depende cuánto trabajo da una actualización: leer dos líneas y aplicarla, o leer una guía de migración y reservar una ventana. El versionado semántico es la convención que hace que el número lo diga. Es una especificación pública y corta (semver.org) en la que el proyecto promete qué puede romperse según qué parte del número cambie, MAYOR.MENOR.PARCHE:
- Parche (1.4.2 → 1.4.3): corrección de errores compatible. Se puede aplicar sin leer casi nada, y en la política del curso es el único tipo de cambio que se fusiona solo.
- Menor (1.4.3 → 1.5.0): funcionalidad nueva compatible hacia atrás. Conviene leer las notas por si hay opciones nuevas o algo marcado como obsoleto (deprecated), que es el aviso de que en la siguiente mayor desaparece.
- Mayor (1.5.0 → 2.0.0): cambios incompatibles. Hay que leer la guía de migración entera, probar en pre con datos reales y planificar ventana. Aquí es donde se rompen formatos de configuración, se renombran variables de entorno y cambian los esquemas de base de datos.
Cada proyecto la cumple a su manera. La promesa la hace cada proyecto, y no todos numeran igual. PostgreSQL usa dos números: el primero es la mayor (17 → 18, requiere pg_upgrade o volcado y restauración porque cambia el formato en disco) y el segundo es la "menor" (17.5 → 17.6), que en realidad se comporta como un parche: solo correcciones, mismo formato de datos, se actualiza parando y arrancando con la imagen nueva. nginx alterna ramas: las versiones con segundo número par (1.28.x) son la rama estable, que solo recibe correcciones, y las impares (1.29.x), la mainline, por donde entran las novedades. Python publica una menor al año (3.13, 3.14) y correcciones cada dos meses, más o menos, pero sus menores pueden retirar funciones que antes había marcado como obsoletas: el paso de 3.13 a 3.14 se lee como si fuera una mayor.
Para qué se usa en el curso. Renovate, que se presenta más abajo, compara los números para clasificar cada actualización en parche, menor o mayor, y el renovate.json decide por esa clase qué se fusiona solo y qué espera a una persona. Un proyecto que no sigue la convención engaña a esa clasificación, y por eso a sus paquetes se les dice a mano cómo leer las versiones (versioning, en Errores frecuentes).
Políticas de etiquetas de las imágenes oficiales¶
Qué son las imágenes oficiales. Son las de Docker Hub que no llevan delante el nombre de un usuario o de una organización: python, postgres, nginx. Las mantiene un equipo de Docker junto con cada proyecto, a partir de un repositorio público (docker-library/official-images), y se reconstruyen cuando se actualiza la distribución de debajo, que es justo lo que mueve el digest de una etiqueta que no ha cambiado.
Por qué hay tantas etiquetas para una misma versión. Cada versión se publica en varias variantes, según la distribución base y lo que traen instalado, y cada variante con varios alias (17, 17.6, 17.6-bookworm). Escoger la variante decide cuántos paquetes entran en la imagen sin que nadie los haya pedido, y cada paquete es un candidato a vulnerabilidad: por eso escogerla bien es también una forma de evitar vulnerabilidades. Lo que significa cada sufijo:
| Variante | Base | Tamaño orientativo | Cuándo |
|---|---|---|---|
python:3.13 (sin sufijo) |
Debian completa, con compiladores y cabeceras | ~1 GB | Solo para construir. Nunca como imagen final. |
python:3.13-slim |
Debian con lo mínimo para ejecutar | ~150 MB | La opción por defecto para la aplicación del curso. |
postgres:17.6-bookworm, -trixie |
El sufijo nombra la versión de Debian de debajo (12 o 13) | Conviene fijarla siempre: sin sufijo, el proyecto puede cambiar de Debian al lanzar una versión nueva. | |
nginx:1.28.0-alpine, postgres:17.6-alpine |
Alpine, con musl (otra biblioteca C, no la glibc de Debian) y apk como gestor de paquetes |
20 a 80 MB | Muchos menos paquetes y menos CVE (fallos de seguridad publicados). Cuidado con programas que dependen de glibc o de su resolución DNS. |
Por debajo de alpine hay bases todavía más pequeñas, sin shell ni gestor de paquetes dentro, que dejan el recuento de hallazgos casi a cero a cambio de no poder entrar a depurar con docker exec; se describen en Para ampliar.
Para qué se usa en el curso. Una regla práctica: para el escáner, cada paquete que no está no puede ser vulnerable. Pasar la API del curso de python:3.13 a python:3.13-slim quita de un plumazo cientos de hallazgos que venían de gcc, perl o imagemagick que nadie usaba. Ese cambio lo hace la A7.3, el 9 de febrero, y por eso la A7.1 fija antes el sufijo de Debian: así el cambio de variante no trae además, sin que nadie lo decida, un cambio de distribución.
Seguir la aparición de versiones¶
El problema contrario. Con las versiones fijadas, nada cambia por su cuenta, que es lo que se buscaba. Pero tampoco se actualiza nada: la base fijada hoy seguirá sin los parches de Debian dentro de seis meses si nadie se entera de que han salido. Hay tres formas de enterarse: que un robot lo detecte y proponga el cambio (Renovate), que un programa lo detecte y lo aplique por su cuenta (Watchtower), o que una persona lo mire a mano de vez en cuando. El curso usa la primera. Este apartado explica cómo funciona, por qué la segunda no vale para un servicio que se mantiene y qué política escrita convierte cada propuesta en una decisión.
Renovate a fondo¶
Qué hace. Renovate lee el repositorio, detecta dependencias (imágenes en compose.yml y en FROM de los Dockerfile, paquetes en requirements.txt o package-lock.json, módulos de Ansible, versiones de acciones de CI) y abre un pull request (PR: una propuesta de cambio que alguien revisa antes de fusionarla en la rama principal) por cada actualización disponible, con las notas de la versión pegadas en la descripción. Una persona revisa, el pipeline prueba, alguien fusiona. Automatiza el aviso, no la decisión.
Cómo funciona una pasada. Renovate no es un servicio que se quede escuchando. Cada ejecución clona el repositorio, busca dependencias con un detector por tipo de fichero (un manager: docker-compose, dockerfile, pip_requirements...), pregunta al origen de cada una qué versiones existen (Docker Hub para las imágenes, PyPI para los paquetes de Python), compara y abre o actualiza un PR por cada cambio, cada uno en su propia rama (renovate/...). Después termina. En una empresa se programa para que pase cada pocas horas; en el laboratorio se lanza a mano.
Dónde corre y con qué cuenta. Funciona en GitHub, GitLab y Gitea, que es lo que hay en el laboratorio, y se ejecuta como contenedor con el token de un usuario técnico, una cuenta de Gitea solo para el robot. Así sus PR se reconocen a simple vista, sus permisos se limitan a los repositorios que mantiene y su token se puede revocar sin tocar la cuenta de nadie:
docker run --rm \
-e RENOVATE_PLATFORM=gitea \
-e RENOVATE_ENDPOINT=http://gitea.lab:23001/api/v1 \
-e RENOVATE_TOKEN=$TOKEN_BOT \
-e RENOVATE_REPOSITORIES=ops/servicio \
-e LOG_LEVEL=info \
renovate/renovate:41
La configuración vive en el propio repositorio, en renovate.json, y viaja con el código como cualquier otro fichero. Conviene fijarse en extends, que carga una configuración base ya hecha, y en packageRules, que decide qué se fusiona solo y qué no:
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended", ":pinDigests"],
"timezone": "Europe/Madrid",
"schedule": ["after 7am and before 9am every weekday"],
"labels": ["actualizacion"],
"prConcurrentLimit": 5,
"enabledManagers": ["docker-compose", "dockerfile", "pip_requirements"],
"ignoreDeps": ["app"],
"packageRules": [
{
"description": "Parches y digests: fusión automática si el pipeline pasa",
"matchUpdateTypes": ["patch", "digest"],
"automerge": true,
"automergeType": "pr"
},
{
"description": "Versiones menores de la imagen base: un solo PR semanal",
"matchPackageNames": ["python"],
"matchUpdateTypes": ["minor"],
"groupName": "imagen base",
"schedule": ["before 8am on monday"]
},
{
"description": "Mayores: nunca automático, etiqueta para que se vea",
"matchUpdateTypes": ["major"],
"labels": ["actualizacion", "mayor", "requiere-ventana"],
"automerge": false
}
],
"vulnerabilityAlerts": {
"labels": ["seguridad"],
"schedule": ["at any time"]
}
}
Lo que hace cada bloque:
config:recommendedes la base razonable que mantiene el propio proyecto. Entre otras cosas, abre en el repositorio una incidencia fija, el Dependency Dashboard, con la lista de todo lo pendiente; no es un aviso de fallo.:pinDigestsconviertepython:3.13-bookwormenpython:3.13-bookworm@sha256:…la primera vez y a partir de ahí abre un PR cada vez que el digest cambia por debajo, aunque la etiqueta no se mueva. Es la manera de detectar que la imagen se ha reconstruido con un parche de Debian.enabledManagerslimita los detectores a los tres tipos de fichero que tiene el servicio (compose, Dockerfile yrequirements.txt);prConcurrentLimit, a cinco los PR abiertos a la vez, para que la primera pasada no inunde el repositorio; ylabelspone a sus PR la etiquetaactualizacion, que es por la que luego se buscan.schedulelimita cuándo abre PR (no un viernes a las siete de la tarde).automergesolo parapatchydigest, y solo si el pipeline está en verde: Renovate no fusiona nada sin pruebas que hayan pasado, así que, mientras el repositorio no tenga pipeline, no fusiona nada solo. Una menor o una mayor la fusiona una persona después de leer las notas.groupNamejunta varias actualizaciones en un PR para no tener quince abiertos el lunes.ignoreDepssaca de la vigilancia la imagenapp: hasta el 19 de febrero es local, Renovate la buscaría en Docker Hub y cada pasada acabaría con un error de búsqueda.vulnerabilityAlertsconfigura los PR que corrigen una vulnerabilidad conocida: se abren en cuanto se detectan, sin esperar a su horario, y con la etiquetaseguridad. Ojo: Renovate solo lee esos avisos de GitHub. En Gitea el bloque no tiene efecto, y la alternativa,osvVulnerabilityAlerts(que consulta la base abierta de vulnerabilidades OSV), es todavía experimental y solo cubre las dependencias directas del lenguaje. En el curso, las vulnerabilidades no se vigilan con Renovate, sino con escáneres como Trivy.
Desde el 19 de febrero la imagen del servicio vive en el registry propio (registry.lab:5000/app). Entonces se quita de ignoreDeps y se le dan a Renovate credenciales de lectura del registry en hostRules, por variable de entorno y nunca en el repositorio (RENOVATE_HOST_RULES). Sin ellas ignora esa imagen en silencio y parece cubierta cuando no lo está; con LOG_LEVEL=debug se ve skipping con el motivo.
Dependabot es el equivalente integrado en GitHub, que se configura en .github/dependabot.yml del propio repositorio; cubre menos gestores que Renovate y solo corre allí, así que en clase se usa Renovate, pero sus propuestas aparecen en cualquier proyecto alojado en GitHub. El fichero de ejemplo y la comparación con Renovate están en Para ampliar.
Watchtower y por qué no en producción¶
Qué hace. Watchtower es un contenedor que vigila a los demás del host: con acceso al socket de Docker, hace pull de sus imágenes cada cierto tiempo y los recrea en cuanto hay una nueva. Automatiza a la vez el aviso, la decisión y el despliegue; Renovate solo automatiza lo primero y deja lo demás en manos de una persona.
Por qué no en un servicio que se mantiene. Actualiza sin probar, sin copia previa, sin ventana, sin registro y sin más vuelta atrás que buscar a mano la etiqueta anterior, de modo que un cambio de configuración incompatible tira el servicio a las tres de la mañana. Para un laboratorio personal vale; en el entorno dev del curso tampoco se usa, porque dev es donde se depura y nada debería cambiar solo.
Sin Renovate, un job programado en Jenkins puede hacer lo mismo a mano: comparar el digest desplegado con el publicado y abrir una incidencia cuando no coinciden. Sirve cuando no se puede instalar nada más; el ejemplo está en Para ampliar.
La política escrita¶
El problema. Renovate dice que hay algo nuevo; no dice quién lo mira, cuándo ni con qué prisa. Sin una regla escrita, cada actualización se decide sobre la marcha según quién esté de guardia, y la misma versión se aplicaría deprisa un día y se olvidaría otro. La política de actualización es esa regla: media página en el repositorio que cualquiera puede leer antes de decidir. Tiene que responder a seis preguntas, y estas son las respuestas del servicio del curso:
- Frecuencia de revisión: Renovate abre PR a diario; una persona revisa los abiertos cada lunes.
- Quién aprueba: parches y digests, el pipeline; menores, quien esté de mantenimiento; mayores, el responsable del servicio y aviso al equipo de desarrollo.
- Ventana de mantenimiento, el rato pactado en que se admiten cortes: martes de 7:00 a 8:00, fuera de ella solo emergencias.
- Qué salta la cola: CVE CRITICAL con parche disponible o cualquier entrada en KEV, la lista de vulnerabilidades que se están explotando; se aplica en pre el mismo día y en producción en la siguiente ventana, o antes si hay explotación activa. La gravedad de un CVE y la lista KEV se explican el 4 de febrero, en CVE, CVSS, EPSS y KEV.
- Qué espera al ciclo: todo lo demás, agrupado cada dos semanas.
- Cuánto tiempo se da a una actualización que falla antes de volver atrás: 40 minutos en pre, 20 en producción.
Cada respuesta evita una discusión en el peor momento: a mitad de una ventana nadie tiene que negociar si se vuelve atrás, porque ya está escrito.
A7.1 Inventario de versiones y seguimiento automático (sesión 28)¶
Objetivo Saber con exactitud qué corre en app01 y dejar de depender de que nadie mueva una etiqueta: la imagen base del Dockerfile fijada con versión y digest, el inventario en docs/inventario.md y la política en docs/politica-actualizacion.md. Y, sobre esa base fija, un vigilante: Renovate con al menos un PR abierto en el Gitea del laboratorio. Al terminar, cualquier versión nueva de lo que usa el servicio te llega como una propuesta que se puede revisar, y el inventario es la referencia que escanea la A7.2.
Antes de empezar
- app01 con el servicio arrancado y la copia de trabajo de
servicioen/opt/servicio. No sale a Internet desde el 27 de noviembre, ni le hace falta hoy: solo se miran imágenes que ya tiene. - El repositorio
ops/servicioengitea.lab(hoy, el contenedor de mon01 en el 23001). Usa siempre ese nombre, nunca la dirección: el 12 de febrero Gitea se muda agitea01y solo cambian el host override y el endpoint. - Trabajo que se puede traer hecho: en Gitea, el usuario técnico
renovate-botcon permiso de escritura enops/servicioy su token en el gestor de contraseñas; en mon01, la imagenrenovate/renovate:41(o la mayor vigente, nuncalatest), que pesa varios cientos de MB. - Explicado antes: Versiones, etiquetas y digests, Versionado semántico, Políticas de etiquetas de las imágenes oficiales, Renovate a fondo y La política escrita.
Pasos
La hoja tiene dos mitades, y el orden importa. En la primera averiguas qué corre de verdad en app01 y fijas la base del Dockerfile, para que deje de depender de una etiqueta que otro puede mover. En la segunda pones a Renovate a vigilar esa base ya fijada y escribes la política que dice qué se hace con lo que proponga. Fijar antes de lanzar Renovate tiene su porqué: con la base sin fijar, lo primero que propondría para ella sería fijarla; fijada con el digest de otoño, lo que propone es ya una actualización de verdad, la de la base que Docker Hub ha reconstruido desde entonces.
-
Averigua qué corre de verdad. Haz el inventario en
docs/inventario.md, en la copia de trabajo de app01: una fila por cada imagen que corre en la máquina (servicio y agentes) y otra para la base delFROMdeapi/Dockerfile, con componente, etiqueta, digest o ID, fecha de creación y si es flotante. Los paquetes de Python del contenedorapp, con su versión, van aparte eninventario-app.txt. Es la foto de partida de toda la unidad: la A7.2 escanea estas mismas imágenes y la A7.6 recupera de aquí el digest de otoño de la base.Pista:
app:1.4.2se construyó en app01 y no tiene digest de registry: se identifica por su ID. Flotante eslatest, solo la mayor (python:3) o sin sufijo de Debian. -
Fija la base y llévala a
mainpor un PR. Cambia elFROMdeapi/Dockerfilepara que lleve versión, sufijo de Debian y digest, como en Versiones, etiquetas y digests: desde ese momento, construir la imagen hoy o dentro de un mes da el mismo resultado. El cambio entra por el repositorio y no solo en la máquina: súbelo junto con el inventario en la ramainventario, abre el PR y fusiónalo tú mismo, porque Renovate leemainy tiene que encontrar allí la base ya fijada.Pista: el digest sale del
RepoDigestsde la base, que app01 tiene porque con ella construyó la aplicación; el sufijo (bookwormotrixie), del/etc/os-releasedel contenedorapp. -
Pon a Renovate a vigilar. Sube a la raíz de
mainelrenovate.jsonde Renovate a fondo, con"schedule": ["at any time"]mientras dura la práctica, porque fuera de su franja Renovate no abre nada. Lanza una pasada desde mon01, que sí sale a Internet y llega a Docker Hub y a PyPI, con el token derenovate-botyLOG_LEVEL=debug, y guarda la salida enrenovate.log: es la prueba de qué dependencias ha encontrado y de por qué se ha saltado alguna. Como la base se descargó en otoño, Docker Hub tiene ya otro digest para esa etiqueta, y entre los PR que abra tiene que estar el que lo actualiza. Guarda la captura del PR que abra con la etiquetaactualizacion.Pista: a la orden del apartado le falta
--network host, para que el contenedor resuelvagitea.labcomo lo hace mon01. -
Escribe qué se hace con lo que proponga. Renovate solo avisa; lo que decide es la política. Escribe en
maindocs/politica-actualizacion.mdcon los seis puntos de La política escrita como encabezados y, en cada uno, una o dos líneas decididas para tu servicio. La terminas en la práctica evaluable, el 23 de febrero.
Comprobación Las líneas image: y FROM de compose.yml y api/Dockerfile ya no llevan etiquetas flotantes; docker image inspect python:3.13-<debian>@sha256:<digest> encuentra la imagen en app01, así que el digest está bien copiado; en Gitea hay un PR de renovate-bot con la etiqueta actualizacion y las notas de la versión; en renovate.log la base del Dockerfile aparece entre las dependencias detectadas y app no da error de búsqueda.
Para practicar Deja en main el PR fusionado con docs/inventario.md y el Dockerfile fijado, renovate.json y docs/politica-actualizacion.md, y guarda la captura del PR de Renovate y renovate.log en la carpeta de la práctica: la evaluable parte de ahí.
Si te sobra tiempo Fija también con digest las imágenes de /opt/agentes/compose.yml que el paso 1 marcó como flotantes, y añade un packageRule con versioning explícito para alguna imagen que no siga semver.
Sesión 29 · Escaneo¶
El inventario de la A7.1 dejó escrito qué versión lleva cada pieza del servicio. Sirve para hacerse la segunda pregunta de la unidad: ¿alguna de esas piezas tiene un fallo de seguridad publicado? A mano no se puede contestar, porque una imagen lleva cientos de paquetes y cada semana se publican cientos de fallos nuevos: hay que cruzar las dos listas de forma automática. La teoría sigue el orden en que se construye ese cruce. Primero, el vocabulario con el que se nombra y se pesa un fallo (CVE, CVSS, EPSS y KEV); después, el inventario completo de una imagen (el SBOM); luego, cómo lo cruza un escáner con una base de datos de vulnerabilidades, y al final qué hace cada escáner y con qué opciones. De Trivy, que ya se usó en la UT4, solo se ve lo nuevo. La hoja aplica todo eso a la imagen del servicio y a la de un agente y termina en una tabla de hallazgos graves. Qué se hace con cada uno se decide en la sesión siguiente, el 9 de febrero.
Vulnerabilidades en los componentes¶
Dónde puede estar el fallo. Un contenedor de la API del curso lleva, de fuera hacia dentro: la imagen base (Debian y sus paquetes: libc, openssl, zlib), el runtime (Python y su biblioteca estándar), las librerías de la aplicación (flask, gunicorn, el driver de PostgreSQL y las que arrastran) y el propio código. Cualquiera de las cuatro capas puede tener una vulnerabilidad publicada, y el escáner no distingue entre "está en la imagen" y "se ejecuta". Este apartado presenta el vocabulario con el que se describen esos fallos, cómo se inventaría y se escanea una imagen y, sobre todo, cómo se lee el resultado con calma: la mayoría de los hallazgos no requieren hacer nada hoy, y decidir cuáles sí es el trabajo.
CVE, CVSS, EPSS y KEV¶
Un nombre común para cada fallo. Cuando se descubre un fallo, lo cuentan a la vez el proyecto afectado, cada distribución que lo empaqueta y cada fabricante de escáneres. Sin un nombre común no habría forma de saber que cuatro avisos hablan del mismo problema. Ese nombre es el CVE (Common Vulnerabilities and Exposures), un identificador público, CVE-AÑO-NÚMERO, en uso desde 1999. Lo asigna una autoridad acreditada, normalmente el propio proyecto o un fabricante autorizado, a un fallo concreto. No dice nada de su gravedad, solo lo nombra.
Cuánto de grave es: CVSS. El CVSS (Common Vulnerability Scoring System) puntúa de 0 a 10 la gravedad técnica de un fallo a partir de un vector, una cadena que describe cómo se explota y qué permite. Un ejemplo con la versión 3.1, que es la que todavía aparece en la mayoría de fichas:
Desglosado:
| Métrica | Valor | Significado |
|---|---|---|
| AV (Attack Vector) | N (Network) | Se explota por red, sin estar en la máquina. |
| AC (Attack Complexity) | L (Low) | No hace falta ninguna condición especial. |
| PR (Privileges Required) | N (None) | Sin cuenta ni credenciales. |
| UI (User Interaction) | N (None) | Nadie tiene que pinchar nada. |
| S (Scope) | U (Unchanged) | El daño se queda en el componente vulnerable. |
| C / I / A | H / H / H | Pérdida total de confidencialidad, integridad y disponibilidad. |
Es el peor caso: ejecución remota de código sin autenticación. Si el mismo fallo requiriera usuario local (AV:L) y privilegios (PR:L), la puntuación bajaría a la zona de 7. CVSS 4.0 cambia la notación (separa el impacto sobre el sistema vulnerable, VC/VI/VA, del impacto sobre otros sistemas, SC/SI/SA, y añade métricas de ataque como AT), y el mismo perfil queda en 9.3. La escala es la misma: 0,1 a 3,9 LOW, 4,0 a 6,9 MEDIUM, 7,0 a 8,9 HIGH, 9,0 a 10 CRITICAL. Los niveles que enseña Trivy usan esta escala, aunque para los paquetes del sistema Trivy prefiere la valoración que publica la propia distribución: por eso un mismo CVE puede salir con una severidad en el escáner y con otra en la ficha de NVD, la base oficial de CVE de Estados Unidos.
Gravedad no es urgencia: EPSS y KEV. El problema de CVSS es que mide gravedad teórica, no probabilidad. Un 9.8 en una librería que solo se usa para leer imágenes TIFF que la API nunca recibe es menos urgente que un 7.5 en el parser de HTTP del proxy. Lo que corre prisa es lo que alguien ya sabe aprovechar: un fallo para el que circula un exploit, un programa o una técnica publicada que lo explota. Para medirlo hay dos complementos:
- EPSS (Exploit Prediction Scoring System, de FIRST, el foro internacional de equipos de respuesta a incidentes): probabilidad, entre 0 y 1, de que el CVE se explote en los próximos 30 días, calculada a diario con datos reales de ataques. La mayoría de CVE tienen EPSS por debajo del 1 %; los que pasan del 10 % son una minoría y es donde hay que mirar primero.
- KEV (Known Exploited Vulnerabilities, de CISA, la agencia de ciberseguridad de Estados Unidos): la lista de los que se están explotando ya, con pruebas. Binario: está o no está.
El orden que se aplica en el curso. Está en KEV, EPSS alto, CRITICAL o HIGH con parche disponible y componente alcanzable desde fuera, el resto por ciclo. Y todo ello después de la pregunta que ningún escáner responde: ¿este componente se ejecuta en el contenedor?
Ejecutarse y ser alcanzable. Ninguna de esas cifras sabe nada del servicio concreto. Un paquete puede estar en la imagen y no ejecutarse nunca (un curl que nadie llama), ejecutarse solo con datos internos, o estar en el camino de una petición que llega de fuera: el servidor HTTP, el parser de JSON, la librería de TLS. Solo en el último caso el fallo es alcanzable desde fuera: un atacante puede dispararlo sin haber entrado antes en la máquina. Eso lo decide quien conoce el servicio, no el escáner, y por eso es una de las dos columnas que la A7.2 rellena a mano.
SBOM: el inventario¶
Por qué no basta el inventario de versiones. El inventario de la A7.1 dice qué imágenes corren y qué paquetes de Python lleva la API, pero una imagen trae además cientos de paquetes del sistema que nadie ha pedido uno a uno. Y cuando dentro de unos meses salga un fallo en uno de ellos, la pregunta será qué versiones desplegadas lo llevaban, sin tener que reconstruirlas para mirarlo.
Qué es. Un SBOM (Software Bill of Materials, la lista de materiales del software) es la lista de todo lo que hay dentro de una imagen: paquetes del SO con versión, librerías del lenguaje con versión, ficheros binarios detectados y el origen de cada uno, en un formato que lee una máquina. La idea viene de la industria, donde cada producto lleva su lista de materiales, y en software se generalizó a partir de 2021, cuando el gobierno de Estados Unidos la incluyó entre lo que pide a sus proveedores; el Reglamento europeo de Ciberresiliencia, aprobado en 2024, la incluye también entre las obligaciones de los fabricantes. Hay dos formatos estándar: CycloneDX (de OWASP, más orientado a seguridad) y SPDX (de la Linux Foundation, más orientado a licencias). Cualquiera de los dos lo entienden todos los escáneres.
Cómo se genera. Syft, una herramienta de línea de comandos de Anchore (la misma casa que Grype), abre la imagen y lee lo que dejó apuntado cada gestor de paquetes:
# De un fichero de docker save: la imagen del servicio, que hasta el 19 de febrero no está en ningún registry
syft docker-archive:app-1.4.2.tar -o cyclonedx-json > sbom-app-1.4.2.cdx.json
# De un registry, sin descargarla con Docker
syft registry:python:3.13-bookworm -o spdx-json > sbom-python-3.13.spdx.json
# Cuántos componentes y de qué tipo
jq '.components | group_by(.type) | map({type: .[0].type, n: length})' sbom-app-1.4.2.cdx.json
Para qué sirve. El SBOM se guarda con cada versión, junto al informe de escaneo, en el repositorio o como artefacto del pipeline. Su valor está en el futuro: cuando dentro de cuatro meses salga un CVE en libxml2, no hace falta reescanear las veinte versiones desplegadas por ahí, basta con buscar en los SBOM archivados cuáles la llevaban y en qué versión. Cuando salió el fallo de xz (CVE-2024-3094), las empresas con SBOM contestaron en minutos.
Cómo trabaja un escáner¶
Un inventario más una base de datos. Trivy, Grype y los demás hacen dos cosas seguidas. Primero el inventario, lo mismo que Syft: leen la base de datos del gestor de paquetes de la imagen (la de dpkg en Debian, la de apk en Alpine) y los metadatos de las librerías del lenguaje, y sacan la lista de paquetes con su versión. Después el cruce: comparan cada paquete y versión con una base de datos de vulnerabilidades que dice, para cada CVE, qué paquetes afecta, en qué rangos de versiones y en qué versión se corrigió. Esa base no la inventa el escáner: la compone con los avisos de cada distribución (el rastreador de seguridad de Debian, por ejemplo), con NVD y con las bases de avisos de cada lenguaje, y la descarga de Internet antes de escanear.
Lo que se deduce de eso. Casi todo lo que sorprende de un escáner sale de esas dos mitades:
- Sin base de datos al día no hay cruce: un escáner sin acceso a Internet, o con la base de hace un mes, no ve los CVE nuevos. Por eso en el curso se escanea en el puesto de administración, que sale a Internet, y no en app01.
- Si la imagen no tiene la base de datos de su gestor de paquetes (una imagen construida desde
scratch, o una a la que se le ha borrado para ahorrar espacio), el escáner no ve los paquetes del sistema, y el informe sale casi vacío sin que la imagen esté limpia. - Cada escáner compone su base a su manera y empareja los nombres de paquete con sus propias reglas. Por eso Trivy y Grype nunca dan la misma lista, y un hallazgo que solo ve uno de los dos puede ser un falso positivo (el escáner atribuye el CVE a un paquete o a una versión que no lo tiene) o un fallo que el otro se pierde.
- La versión corregida y el estado del parche (
fixed,will_not_fix...) los da la distribución, y dicen si basta con reconstruir la imagen sobre una base al día. Cómo se leen se explica el 9 de febrero, al decidir qué se hace con cada hallazgo. - Como el inventario y el cruce son pasos separados, se puede escanear un SBOM archivado sin tener la imagen: el cruce se repite con la base de datos de hoy.
Escanear con Trivy¶
Repaso
trivy image con --severity, --ignore-unfixed, --format json y --exit-code 1 se explicó en la sesión 25, y la A4.6 lo dejó instalado en el puesto de administración. Aquí solo se añade lo nuevo: escanear una imagen que no está en ningún registry, los otros modos de Trivy y qué informe se archiva.
Qué cambia respecto a la UT4. Hasta el 19 de febrero la imagen del servicio vive solo en el Docker de app01. Se saca a un fichero y se escanea en el puesto de administración, que tiene Trivy y salida a Internet para su base de datos. Y además de la imagen, Trivy tiene otros modos que miran el repositorio: con ellos se cubren las tres cosas que pide el CE e (código, binarios y librerías):
# Imagen, desde un fichero de docker save: paquetes del SO y dependencias del lenguaje
ssh ops@10.10.2.10 docker save app:1.4.2 > app-1.4.2.tar
trivy image --input app-1.4.2.tar --severity HIGH,CRITICAL
# Sistema de ficheros: el código fuente, sus ficheros de dependencias y los secretos olvidados
trivy fs --scanners vuln,secret .
# Configuración: Dockerfile, compose, manifiestos; busca malas prácticas, no CVE
trivy config .
# Desde un SBOM ya generado, sin volver a leer la imagen
trivy sbom sbom-app-1.4.2.cdx.json
# Para archivar: el informe completo, sin filtros
trivy image --input app-1.4.2.tar --format json -o trivy-app-1.4.2.json
trivy image --input app-1.4.2.tar --format sarif -o trivy-app-1.4.2.sarif
Lo nuevo de esas órdenes:
--inputlee la imagen de un fichero dedocker save: la máquina que escanea no necesita Docker ni acceso a ningún registry.- El informe que se archiva va sin
--severityni--ignore-unfixed. La lista de trabajo se filtra; el archivo no, porque un hallazgo sin parche hoy puede tenerlo mañana y hay que poder buscarlo en el informe de esta versión. --format sarifescribe SARIF (un formato normalizado de resultados de análisis de código), que es el que entienden Gitea, GitLab y GitHub para pintar los hallazgos junto al código.--scanners vuln,secrethace quetrivy fsbusque, además de vulnerabilidades en los ficheros de dependencias, secretos: contraseñas, tokens o claves que alguien dejó escritos en un fichero del repositorio.
trivy config avisa además de malas prácticas de despliegue: contenedor que corre como root, ADD con URL, puerto 22 expuesto.
Grype, Docker Scout y los escáneres del lenguaje¶
Por qué un segundo escáner. Grype (de los mismos autores que Syft) escanea imágenes o SBOM contra otra base de datos. Da resultados parecidos a los de Trivy pero no idénticos, por lo que se explica en Cómo trabaja un escáner, y esa diferencia es útil: un hallazgo que aparece en los dos es más fiable, y uno que solo aparece en uno hay que mirarlo con más cuidado.
grype sbom:sbom-app-1.4.2.cdx.json --only-fixed --fail-on high
grype docker-archive:app-1.4.2.tar -o table
Por qué además un escáner del lenguaje. Los escáneres de imagen ven lo que quedó instalado en la imagen ya construida. Los del lenguaje leen el fichero de dependencias del repositorio, antes de construir nada, resuelven el árbol de dependencias igual que lo haría el instalador y lo comparan con la base de avisos de su ecosistema. Así avisan de un problema antes de que llegue a ninguna imagen. En el curso la API es Python, así que el que se usa es pip-audit, contra la base de datos de avisos de PyPI:
Los demás son la misma idea para otros lenguajes o para otro sitio:
| Herramienta | Qué lee | Cuándo |
|---|---|---|
npm audit --audit-level=high |
package-lock.json |
Proyectos Node |
osv-scanner scan --lockfile <fichero> |
Ficheros de dependencias de casi cualquier lenguaje, contra OSV (la base abierta de avisos por paquete) | Repositorios con varios lenguajes |
docker scout cves <imagen> |
Imágenes, desde Docker Desktop o Docker Hub | Cómodo en un portátil con Docker Desktop, que lo trae; pide iniciar sesión con una cuenta de Docker |
Dos filtros a distinta altura. pip-audit mira las dependencias declaradas antes de construir, y Trivy la imagen ya construida. En el curso los dos se pasan en la A7.2.
A7.2 Escaneo (sesión 29)¶
Objetivo Saber qué fallos publicados lleva lo que corre en app01 y cuáles importan de verdad: el SBOM y los informes JSON de Trivy y Grype de la imagen del servicio y de la de un agente, el escaneo del código con pip-audit y trivy fs, y una tabla de hallazgos HIGH y CRITICAL con las columnas de exploit conocido y alcanzable rellenas, todo en seguridad/ del repositorio. Es la materia prima de la A7.3, que decide qué se hace con cada fila.
Antes de empezar
- Se trabaja en el puesto de administración, que tiene Trivy desde la A4.6, salida a Internet para las bases de datos de vulnerabilidades y tu clon de
servicio. Entra conssh -Apara que desde él llegues a app01. - Trabajo que se puede traer hecho: Syft y Grype instalados con los instaladores oficiales de Anchore, pip-audit con
pipx, y las bases de datos al día (trivy image --download-db-onlyygrype db update). - Explicado antes: CVE, CVSS, EPSS y KEV, SBOM: el inventario, Cómo trabaja un escáner, Escanear con Trivy y Grype, Docker Scout y los escáneres del lenguaje.
Pasos
Vas del inventario al juicio, en ese orden. Primero sacas las imágenes de app01 y haces su SBOM, la lista sobre la que trabajan los escáneres. Después pasas los escáneres: dos sobre las imágenes, porque cada uno ve cosas que el otro no, y otros dos sobre el código, porque el CE pide código, binarios y librerías. Con todo eso reúnes los hallazgos graves en una tabla de trabajo y le añades lo que ningún escáner sabe: si hay exploit conocido y si el fallo es alcanzable desde fuera. Al final contrastas los dos escáneres, que es la comprobación de que la tabla no depende de uno solo.
-
Saca las dos imágenes y haz su inventario. Trae al puesto la imagen del servicio (
app-1.4.2.tar) y la de cAdvisor de tu inventario (cadvisor.tar). cAdvisor es de un tercero y es el agente con más privilegios de app01, porque correprivilegedy con el socket de Docker: un fallo en él es el más peligroso de la máquina. Los ficheros van a~/imagenes/del puesto, fuera del repositorio, porque pesan cientos de MB y no se versionan; cómo se saca una imagen de app01 está en Escanear con Trivy. Genera enseguridad/de tu clon el SBOM en CycloneDX de cada una y anota su número de componentes, que da la medida de lo que hay que vigilar. -
Escanea las imágenes con dos escáneres. Pasa Trivy y Grype por las dos imágenes; Grype puede leer la imagen o directamente el SBOM del paso 1. De cada escáner guarda el informe completo en JSON (
trivy-<imagen>.json,grype-<imagen>.json), sin filtros, porque es el que se archiva y al que se vuelve cuando un hallazgo cambia de estado. Saca además una tabla con HIGH y CRITICAL, que es con la que se trabaja. -
Escanea también el código. Pasa pip-audit por las dependencias declaradas (
pip-audit.txt) ytrivy fspor el repositorio, buscando vulnerabilidades y secretos (trivy-fs.json). Son los que ven los problemas antes de que lleguen a una imagen, y con ellos cubres la parte de código del CE. Anota enhallazgos.mdcuántos hallazgos da cada uno y si pip-audit ve alguno que Trivy no.Pista: excluye
seguridad/con--skip-dirs, otrivy fsanalizará también los informes que acabas de generar. -
Reúne lo grave en una tabla de trabajo. Dos imágenes pueden dar cientos de hallazgos, y una tabla de cientos de filas no sirve para decidir. Reúne en
hallazgos.tsvlas filas HIGH y CRITICAL de los tres JSON de Trivy, que es el archivo completo, y pasa a la tabla deseguridad/hallazgos.mdlas ocho más graves: las CRITICAL y, si no llegas a ocho, las HIGH con parche. Encima de la tabla, el recuento por severidad dehallazgos.tsv, para que se vea el tamaño de lo que queda fuera.Pista: las filas, con CVE, objetivo, paquete, versión instalada, versión corregida y severidad, salen de los JSON con este
jq: -
Añade lo que ningún escáner sabe. Rellena en esas ocho filas las dos columnas que deciden la prioridad, como se explica en CVE, CVSS, EPSS y KEV: exploit conocido (KEV y EPSS) y alcanzable desde fuera (sí, no, no sé). La segunda la decides tú, según el paquete esté en el camino que atiende peticiones o solo en la construcción; un "no sé" honrado vale más que un "no" sin mirar, porque la A7.3 parte de estas dos columnas.
Pista: el catálogo KEV se descarga una vez en JSON de la web de CISA y se consulta en local; el EPSS de cada CVE lo da
https://api.first.org/data/v1/epss?cve=<CVE>. -
Contrasta los dos escáneres. Compara la lista de CVE de Trivy con la de Grype sobre la imagen del servicio y anota en
hallazgos.mdlos que solo ve uno de los dos. Son los que más cuidado piden, por lo que se explica en Cómo trabaja un escáner, y en la A7.3 la ficha del CVE dirá quién tiene razón.Pista:
comm -3sobre las dos listas ordenadas de identificadores:.Results[].Vulnerabilities[]?.VulnerabilityIDen Trivy y.matches[].vulnerability.iden Grype.
Comprobación La cabecera de cada informe de imagen de Trivy reconoce el sistema base ((debian 12.x) o (debian 13.x)); hallazgos.tsv no está vacío; la tabla tiene ocho filas (o todas las CRITICAL y las HIGH con parche, si salen menos de ocho) con las dos columnas manuales rellenas y el recuento por severidad encima; pip-audit.txt y trivy-fs.json existen y su resultado está resumido en hallazgos.md; hay al menos un CVE anotado que solo aparece en uno de los escáneres.
Para practicar Haz commit de seguridad/ (los dos SBOM, los JSON de los escáneres, pip-audit.txt y hallazgos.md), sin los .tar: es la base de la A7.3 y de la práctica evaluable.
Sesión 30 · Investigar y decidir¶
La A7.2 terminó con una tabla de ocho hallazgos graves y dos columnas rellenas a mano. Una tabla todavía no es una decisión: hay que decir, para cada fila, qué se hace y por qué, y dejarlo escrito para que el próximo escaneo no vuelva a plantear lo mismo. La teoría sigue el orden en que se trabaja un hallazgo: primero, cómo se lee una fila del informe y qué preguntas deciden su destino; después, las cuatro salidas posibles y cómo se registran las que se aceptan. Las fuentes donde se investiga cada CVE están justo antes de la hoja como material de consulta, y su tiempo de lectura va dentro del de la hoja. La hoja decide cuatro hallazgos y aplica la salida que más hallazgos quita de una vez: reconstruir la imagen sobre la base slim, que queda desplegada en dev como 1.4.3.
Leer un informe¶
El problema: la severidad no decide sola. Un informe de escaneo ordena los hallazgos por gravedad técnica, y la tentación es empezar por arriba y arreglarlo todo. No se puede, porque muchas filas no tienen arreglo, y tampoco conviene, porque la fila más grave puede estar en un componente que no se ejecuta nunca. Leer un informe es hacerle a cada fila las mismas preguntas y en el mismo orden.
Un ejemplo. Esto es un extracto, recortado, de lo que devuelve trivy image sobre una API en python:3.12-slim-bookworm de hace unos meses, sin --ignore-unfixed. Trivy da una sección por cada objetivo que analiza: los paquetes del sistema y, aparte, los de Python:
app-1.4.2.tar (debian 12.5)
Total: 2 (HIGH: 1, CRITICAL: 1)
┌────────────────┬────────────────┬──────────┬──────────────┬───────────────────┬───────────────┬─────────────────────────────────────┐
│ Library │ Vulnerability │ Severity │ Status │ Installed Version │ Fixed Version │ Title │
├────────────────┼────────────────┼──────────┼──────────────┼───────────────────┼───────────────┼─────────────────────────────────────┤
│ zlib1g │ CVE-2023-45853 │ CRITICAL │ will_not_fix │ 1:1.2.13.dfsg-1 │ │ zlib: integer overflow in MiniZip │
│ libnghttp2-14 │ CVE-2023-44487 │ HIGH │ fixed │ 1.52.0-1 │ 1.52.0-1+deb12u1 │ HTTP/2: rapid reset attack │
└────────────────┴────────────────┴──────────┴──────────────┴───────────────────┴───────────────┴─────────────────────────────────────┘
Python (python-pkg)
Total: 1 (HIGH: 1, CRITICAL: 0)
│ setuptools │ CVE-2024-6345 │ HIGH │ fixed │ 65.5.1 │ 70.0.0 │ pypa/setuptools: remote code exec.. │
Las columnas que deciden. Status dice qué ha decidido la distribución sobre ese fallo: fixed, hay versión corregida en sus repositorios; affected, afectado y todavía sin parche; fix_deferred, afectado y con el arreglo aplazado; will_not_fix, afectado, pero la distribución no piensa arreglarlo, normalmente porque el fallo no se da tal como ella compila el paquete o porque lo considera menor. Fixed Version dice a qué versión hay que subir: si es un paquete del sistema, basta con reconstruir la imagen sobre una base al día; si es una librería del lenguaje, hay que subirla en requirements.txt. Y fuera de la tabla está lo que más pesa, lo que se sabe del servicio. Por cada fila, las preguntas del diagrama:
flowchart TD
H["<b>Hallazgo del escáner</b>"]:::dato
Q1{"<b>¿Está en un componente<br>que se ejecuta?</b>"}:::act
ACC["<b>Aceptar con justificación</b><br><small>o quitar el componente</small>"]:::pieza
Q2{"<b>¿Es alcanzable<br>desde fuera?</b>"}:::act
Q3{"<b>¿Está en KEV<br>o EPSS alto?</b>"}:::act
URG["<b>Urgente</b><br><small>parche o mitigación hoy, fuera de ciclo</small>"]:::riesgo
Q4{"<b>¿Hay versión<br>corregida?</b>"}:::act
UPD["<b>Actualizar</b><br><small>en el siguiente ciclo</small>"]:::ok
Q5{"<b>¿Hay base más pequeña<br>o mitigación?</b>"}:::act
MIT["<b>Cambiar base o mitigar</b><br><small>y registrarlo</small>"]:::ok
EXC["<b>Excepción</b><br><small>con fecha de revisión</small>"]:::pieza
H --> Q1
Q1 -- No --> ACC
Q1 -- Sí --> Q2
Q2 -- No --> Q4
Q2 -- Sí --> Q3
Q3 -- Sí --> URG
Q3 -- No --> Q4
Q4 -- Sí --> UPD
Q4 -- No --> Q5
Q5 -- Sí --> MIT
Q5 -- No --> EXC
classDef act fill:#ea580c22,stroke:#ea580c,stroke-width:1.5px
classDef pieza fill:#64748b22,stroke:#64748b,stroke-width:1.5px
classDef dato fill:#2563eb22,stroke:#2563eb,stroke-width:1.5px
classDef infra fill:#a1a1aa14,stroke:#a1a1aa,stroke-width:1.5px
classDef ok fill:#16a34a22,stroke:#16a34a,stroke-width:1.5px
classDef riesgo fill:#dc262622,stroke:#dc2626,stroke-width:1.5px
La severidad del CVE no decide sola: lo que decide es si ese código se ejecuta, si se alcanza desde fuera y si se está explotando de verdad.
Aplicado a las tres filas del informe:
zlib1g / CVE-2023-45853. CRITICAL 9.8, pero el fallo está en MiniZip, una utilidad que forma parte del código fuente de zlib y que Debian no compila enzlib1g. Debian lo marcawill_not_fixprecisamente por eso. No se ejecuta: excepción en.trivyignore, con esa justificación y una fecha para revisar si Debian cambia de opinión.libnghttp2-14 / CVE-2023-44487. Rapid Reset, con explotación conocida (está en KEV). ¿Se ejecuta? La API en Python no habla HTTP/2 ylibnghttp2está en la imagen porquecurlla arrastra. No es alcanzable desde fuera (el HTTP/2 lo termina nginx, que tiene su propia corrección). Pero tiene parche y es unapt upgrade: se actualiza en el siguiente ciclo reconstruyendo la imagen con la base al día. En el proxy nginx, en cambio, sería urgente.setuptools / CVE-2024-6345. Ejecución remota a través depackage_index, que solo se usa al instalar paquetes. En una imagen que ya está construida y no ejecutapip install, no es alcanzable. Aun así la corrección es gratis (subirsetuptoolsen la etapa de construcción o directamente desinstalarlo de la imagen final), así que se hace en ciclo y de paso desaparece una fila del informe.
Conviene fijarse en que la fila más grave del informe ha terminado como excepción, y una HIGH como actualización normal. Esa es la diferencia entre escanear y gestionar vulnerabilidades.
Las cuatro soluciones y el registro de excepciones¶
Cuatro salidas, y siempre por escrito. Las preguntas del diagrama anterior terminan siempre en una de cuatro salidas, y la incidencia del hallazgo tiene que decir cuál se ha tomado. Este segundo diagrama empieza donde termina el primero: una vez decidido que el hallazgo importa, ordena las salidas de la más limpia a la menos limpia:
flowchart TB
H["<b>Hallazgo que importa</b>"]:::dato
Q{"<b>¿Hay versión<br>corregida?</b>"}:::act
A["<b>1 · Actualizar</b><br><small>la imagen base o el paquete</small>"]:::ok
Q2{"<b>¿Hay una base<br>sin ese paquete?</b>"}:::act
B["<b>2 · Cambiar de base</b><br><small>slim, alpine o más pequeña</small>"]:::ok
Q3{"<b>¿Se puede quitar<br>o cerrar el camino?</b>"}:::act
M["<b>3 · Mitigar</b><br><small>quitar el paquete, desactivar la función<br>o filtrar por red</small>"]:::ok
AC["<b>4 · Aceptar</b><br><small>con motivo, responsable<br>y fecha de revisión</small>"]:::pieza
REG(["<b>Registro</b><br><small>la incidencia dice cuál de las cuatro;<br>las aceptadas, además, en .trivyignore</small>"]):::ok
H --> Q
Q -- sí --> A
Q -- no --> Q2
Q2 -- sí --> B
Q2 -- no --> Q3
Q3 -- sí --> M
Q3 -- no --> AC
A & B & M & AC --> REG
classDef act fill:#ea580c22,stroke:#ea580c,stroke-width:1.5px
classDef pieza fill:#64748b22,stroke:#64748b,stroke-width:1.5px
classDef dato fill:#2563eb22,stroke:#2563eb,stroke-width:1.5px
classDef infra fill:#a1a1aa14,stroke:#a1a1aa,stroke-width:1.5px
classDef ok fill:#16a34a22,stroke:#16a34a,stroke-width:1.5px
classDef riesgo fill:#dc262622,stroke:#dc2626,stroke-width:1.5px
Lo que no vale es la quinta salida: dejarlo pasar sin decir nada. Un hallazgo sin decisión escrita vuelve en el siguiente escaneo y nadie recuerda por qué sigue ahí.
- Actualizar la imagen base o el paquete afectado. Reconstruir con
FROM python:3.13-slim-bookwormal día (yapt-get upgradeen el Dockerfile si la base oficial va por detrás de Debian) o subir la versión de la librería en el lockfile. - Cambiar a una base más pequeña:
-slim,-alpine, distroless o Chainguard. Menos paquetes, menos hallazgos y menos superficie de ataque real. Tiene coste: puede que falten librerías nativas, que no haya shell para depurar o que cambie el comportamiento de DNS en Alpine. - Mitigar sin tocar la versión: desinstalar el componente en el Dockerfile (
apt-get purge curl), deshabilitar la función vulnerable en la configuración (un módulo de nginx, un protocolo de TLS), o limitar por red con las reglas del cortafuegos para que el componente no sea alcanzable. - Aceptar el riesgo por escrito, con motivo, responsable y fecha de revisión. Es legítimo, pero solo si está documentado.
Un falso positivo, cuando el escáner atribuye el CVE a un paquete o a una versión que no lo tiene, no es una quinta salida: se registra como una aceptación más, y su motivo es la ficha que demuestra que no aplica.
El registro de excepciones. Las excepciones aceptadas van a .trivyignore, un fichero de texto con un CVE por línea que Trivy lee para no volver a mostrar esos hallazgos. Es la misma idea que las supresiones justificadas de checkov en Despliegue. Va en la raíz del repositorio porque Trivy lo busca en el directorio desde el que se ejecuta. Trivy admite comentarios y fechas de caducidad, y hay que usar las dos cosas:
# CVE-2023-45853: MiniZip no se compila en zlib1g de Debian. Debian: will_not_fix.
# Decidido en #142 por el equipo de Sistemas el 2027-02-09. Revisar cuando Debian cambie de estado.
CVE-2023-45853 exp:2027-08-01
# CVE-2024-6345: setuptools solo se usa en la etapa de construcción; no está en la imagen final desde 1.4.3.
# Cerrar esta excepción al fusionar !57.
CVE-2024-6345 exp:2027-03-15
Cada excepción, con su porqué y su fecha. El comentario dice el motivo, quién lo decidió y dónde (#142 es una incidencia de Gitea y !57, un PR). Con exp:, a partir de esa fecha Trivy vuelve a mostrar el hallazgo, y alguien tiene que revisarlo de nuevo en lugar de que la excepción se quede para siempre. Una línea sin comentario ni fecha es una excepción que nadie revisará, y en seis meses el fichero tiene ochenta líneas que ya no significan nada. En la práctica evaluable, un .trivyignore sin justificaciones cuenta como no entregado.
Fuentes y cómo se leen¶
Material de consulta
Este apartado no se desarrolla en clase: es material de consulta para la hoja de práctica de esta sesión.
Por qué hay que ir a la fuente. El escáner da un resumen de una línea. Para decidir hace falta saber qué versiones afecta de verdad el fallo, si hay parche y cómo se aplica, si hay forma de mitigarlo mientras tanto y si alguien lo está explotando. Cada pregunta tiene su fuente:
- Releases y CHANGELOG del proyecto. En GitHub o GitLab, la pestaña Releases de cada componente. Para las imágenes oficiales, además, el repositorio
docker-library/official-imagesy la página de la imagen en Docker Hub. En una nota de versión se buscan tres cosas por este orden: la sección de seguridad (a veces solo dice "fixes CVE-XXXX"), la lista de cambios incompatibles (breaking changes) y las opciones marcadas como obsoletas. - Avisos de seguridad del proyecto. La pestaña Security de GitHub (los GHSA, GitHub Security Advisories), las listas
announcede PostgreSQL y nginx, y la listaoss-securitypara lo que afecta a varios proyectos a la vez. Las distribuciones publican los suyos: DSA (Debian Security Advisory) en Debian, que es la base de casi todas las imágenes del servicio. - NVD (https://nvd.nist.gov/): la base oficial de CVE del NIST, el instituto de estándares de Estados Unidos. De cada ficha interesa el vector CVSS, las versiones afectadas (en formato CPE, un nombre normalizado de producto y versión) y las referencias, que suelen enlazar al parche.
- OSV (https://osv.dev/): base abierta pensada para lenguajes. Indexa por paquete y versión con rangos precisos, por lo que da menos falsos positivos que NVD en dependencias de Python o Node.
- GitHub Advisory Database (https://github.com/advisories): los GHSA. Suele ser la primera en publicar para librerías, y es la fuente de Dependabot.
- CISA KEV (https://www.cisa.gov/known-exploited-vulnerabilities-catalog): catálogo de la agencia de ciberseguridad de Estados Unidos de vulnerabilidades que se están explotando de verdad. Es corto (menos de uno de cada cien CVE acaba en él), y cualquier cosa que aparezca ahí y esté en el inventario salta la cola.
Al investigar un hallazgo en la actividad A7.3, el orden de lectura es: ficha NVD u OSV para saber qué es y qué versiones afecta, aviso del proyecto para saber si ya hay parche y cómo se activa, issue tracker del componente para ver si hay mitigación mientras tanto, y KEV para saber si corre prisa.
A7.3 Investigar y decidir (sesión 30)¶
Objetivo Pasar de la tabla de hallazgos a decisiones que se puedan defender: cuatro hallazgos investigados, decididos y justificados con su fuente, un .trivyignore documentado y, como la solución que más hallazgos quita de una vez, la imagen del servicio reconstruida con base -slim como app:1.4.3, medida frente a la 1.4.2 y funcionando en dev. Es la parte del trabajo con vulnerabilidades que no hace ninguna herramienta: el criterio.
Antes de empezar
seguridad/hallazgos.mdde la A7.2 y~/imagenes/app-1.4.2.taren el puesto de administración, con acceso a NVD, OSV, GitHub Advisory y el catálogo KEV.- app01 con la copia de trabajo de
servicioenmainy al día. - La consola de OPNsense: app01 no sale a Internet, y construir la imagen nueva descarga la base y las librerías. El paso 4 abre una fila temporal y la cierra en el mismo paso.
- Explicado antes: Leer un informe y Las cuatro soluciones y el registro de excepciones. Dónde se consulta cada CVE está en Fuentes y cómo se leen, el material de consulta de esta hoja.
Pasos
Primero investigas y decides, y solo después tocas la imagen, porque reconstruirla es una de las decisiones. Eliges cuatro hallazgos que cubran casos distintos, los investigas en las fuentes y escribes la decisión de cada uno; los que aceptas van al registro de excepciones. Después aplicas la solución que más hallazgos quita de golpe, el cambio a la base slim: reconstruyes como 1.4.3, la despliegas en dev con el plan B guardado y mides si ha merecido la pena. Todo va en una sola rama, base-slim, que acaba en un PR.
-
Elige cuatro hallazgos e investígalos. Crea la rama
base-slimen tu clon deservicioy elige cuatro filas de la tabla: al menos una CRITICAL y una sin parche, que son las que llevan el diagrama a sus dos extremos (la más grave puede acabar en excepción, y la que no tiene parche obliga a cambiar de base, mitigar o aceptar). Investígalas en el orden de Fuentes y cómo se leen: qué versiones afecta, si hay parche, si hay mitigación y si corre prisa.Pista: si tu tabla no tiene ninguna sin parche, búscala en
hallazgos.tsv(sin parcheowill_not_fix). -
Decide y deja escrito por qué. Escribe en
seguridad/decisiones.md, por hallazgo, las respuestas del diagrama de Leer un informe: CVE y componente, ¿se ejecuta?, ¿alcanzable desde fuera?, KEV y EPSS, cuál de las cuatro soluciones eliges y tres o cuatro líneas de justificación con la fuente enlazada. La fuente es lo que convierte una opinión en una decisión que otra persona puede revisar. -
Registra las excepciones. Cada CVE que aceptes va a
.trivyignore, en la raíz del clon, con su comentario (motivo, quién decide, fecha) y su caducidadexp:, como en Las cuatro soluciones y el registro de excepciones. Comprueba que Trivy lo respeta sobreapp-1.4.2.tar: las filas aceptadas desaparecen del informe y las demás siguen ahí. Sube la rama; es el mismo fichero que leerá la etapa del pipeline en la A7.6. -
Reconstruye la imagen sobre la base slim. Es la segunda de las cuatro soluciones aplicada a la imagen entera: menos paquetes, menos hallazgos. En app01, desde la rama
base-slim, construyeapp:1.4.3sobrepython:3.13-slim-<debian>fijada con digest, con la misma Debian de la A7.1, para que solo cambie la variante y la comparación del paso 6 mida eso y nada más. Antes de desplegarla, comprueba conpip checky unimport flaskque la imagen está sana. app01 no sale a Internet y el build descarga la base y las librerías: abre antes en OPNsense la fila temporal de la pestaña DMZINT (Pass, origensrv_app, destino any, puertosp_web, descripciónTEMPORAL alta 09-02 baja 09-02 build en app01) y bórrala al terminar el build.Pista: la slim no trae las librerías de cliente de PostgreSQL: si el driver no compila o no carga, añade
libpq5en el Dockerfile o usa la variante binaria del driver. -
Despliégala en dev con el plan B guardado. Antes de desplegar, guarda el compose que corre como
compose.anterior.yml: es la vuelta atrás de dev, y la A7.4 la da por hecha. Después despliega la 1.4.3 y sube a la rama el cambio decompose.yml. La 1.4.3 pasa dos días en dev antes de llegar a pre: si algo no funciona, tiene que verse aquí. -
Mide si ha merecido la pena y abre el PR. Desde el puesto, compara la 1.4.2 con la 1.4.3 en tamaño y en hallazgos HIGH y CRITICAL, y añade a
seguridad/decisiones.mdla tabla de comparación (base, tamaño, hallazgos, qué hubo que tocar). Es la prueba de que la decisión ha servido, y las cifras que llevará el CHANGELOG en la A7.6. Abre el PR debase-slim.Pista: trae antes a tu clon el commit que hiciste en app01, o la rama divergirá.
Comprobación Cuatro entradas con las preguntas del diagrama respondidas y la fuente enlazada; Trivy con --ignorefile muestra menos filas que sin él y ninguna línea del .trivyignore va sin comentario ni fecha; la fila temporal ya no está en OPNsense; app:1.4.3 responde en el /health de app01 y tiene menos hallazgos que la 1.4.2.
Para practicar Deja abierto el PR de base-slim con seguridad/decisiones.md, .trivyignore, el Dockerfile con la base nueva y compose.yml en la 1.4.3. No lo fusiones todavía: se fusiona al empezar la A7.4, después de dos días de la 1.4.3 en dev y antes de llevarla a pre.
Si te sobra tiempo Repite la reconstrucción del paso 4 y la medida del paso 6 con python:3.13-alpine (con la fila temporal abierta solo mientras dura el build) y anota qué se rompió. O quita setuptools de la imagen final y comprueba si desaparece una fila del informe.
Sesión 31 · Actualización en pre¶
La 1.4.3 existe y lleva dos días funcionando en dev (A7.3). Llevarla a pre, la copia reducida de producción donde se ensaya cada cambio antes de hacerlo de verdad, ya no es solo cambiar una etiqueta: pre tiene datos que conservar, y si algo sale mal hay que poder volver atrás con la imagen y con los datos. Esta sesión convierte la actualización en un procedimiento que se repite igual para un parche de PostgreSQL que para una versión nueva de la API. La teoría va de lo general a lo concreto: el ciclo completo, el paso a paso con su plan de vuelta atrás, la copia previa con restic (la herramienta de copias del laboratorio, que aquí se usa por primera vez), la comprobación de que los datos no han cambiado y la ventana de mantenimiento, que es el marco del paso a producción. La hoja lo aplica en pre y deja escrito ese paso a producción, que en el laboratorio no existe como entorno.
Actualizar¶
El problema: actualizar es fácil, deshacer no. Cambiar la imagen de un servicio es una línea. Lo difícil es demostrar después que todo funciona igual que antes y, si no, volver al estado anterior sin perder nada (la vuelta atrás o rollback). Por eso una actualización no se improvisa: sigue siempre el mismo ciclo, con copia previa, pruebas y un plan de vuelta atrás escrito antes de tocar nada. Este apartado es ese procedimiento.
El ciclo¶
El recorrido completo. Una versión nueva pasa por tres entornos, y en cada uno contesta a una pregunta distinta: en dev, si arranca; en pre, con datos parecidos a los de producción, si funciona; en producción, si funciona para los usuarios, dentro de una ventana pactada.
flowchart TB
R["<b>Renovate abre un PR</b><br><small>versión nueva</small>"]:::act
CI["<b>Jenkins</b><br><small>build y Trivy</small>"]:::pieza
DEV["<b>Despliegue en dev</b>"]:::pieza
T1["<b>Pruebas de la UT4</b>"]:::pieza
BK["<b>Copia de seguridad de pre</b>"]:::act
PRE["<b>Despliegue en pre</b>"]:::pieza
V1{"<b>Verificación</b><br><small>funcional + integridad</small>"}:::dato
RB1["<b>Rollback en pre</b><br><small>y reporte</small>"]:::riesgo
WIN["<b>Ventana de mantenimiento</b>"]:::act
BK2["<b>Copia de producción</b>"]:::act
PRO["<b>Despliegue en producción</b>"]:::pieza
V2{"<b>La misma verificación</b>"}:::dato
INC(["<b>Incidencia cerrada</b><br><small>y CHANGELOG</small>"]):::ok
RB2["<b>Rollback en producción</b>"]:::riesgo
R --> CI --> DEV --> T1 --> BK --> PRE --> V1
V1 -- OK --> WIN --> BK2 --> PRO --> V2
V1 -- falla --> RB1
V2 -- OK --> INC
V2 -- falla --> RB2
classDef act fill:#ea580c22,stroke:#ea580c,stroke-width:1.5px
classDef pieza fill:#64748b22,stroke:#64748b,stroke-width:1.5px
classDef dato fill:#2563eb22,stroke:#2563eb,stroke-width:1.5px
classDef infra fill:#a1a1aa14,stroke:#a1a1aa,stroke-width:1.5px
classDef ok fill:#16a34a22,stroke:#16a34a,stroke-width:1.5px
classDef riesgo fill:#dc262622,stroke:#dc2626,stroke-width:1.5px
La copia va antes de cada despliegue, no después. Y la verificación es la misma en pre y en producción: si cambia, no está verificando lo mismo.
En el laboratorio no hay producción: el ciclo se recorre hasta pre, y la mitad de abajo del diagrama queda escrita como plan en la incidencia, con el orden de Ventanas de mantenimiento.
Paso a paso¶
Lo que hace seguro cada tramo del ciclo, en el orden en que se hace:
- Fuente. La versión nueva sale de un repositorio: el registry de
gitea01para la aplicación (desde el 19 de febrero, reconstruida por el pipeline con la base actualizada) o Docker Hub para las oficiales, y se comprueba el digest contra el publicado. Mientras no hay registry, la imagen viaja condocker save | docker loaddesde la máquina que la construyó, nunca desde el portátil de alguien, y se comprueba que el ID de imagen es el mismo en los dos lados. - Cambio en el repositorio, no en el servidor. La versión se cambia en el
compose.ymldel repositorio a través de un PR o de un commit revisable; en pre, en la variableapp_imagedel playbook deiac-lab, que la escribe en el.envdeapp01-pre(el compose de pre lee la imagen de ahí). Editar el compose a mano en la máquina es la forma más rápida de que nadie sepa qué hay desplegado. - Dev primero, luego pre. En dev se ve si arranca; en pre, con datos parecidos a producción, se ve si funciona. Nunca directamente en producción, ni para un parche.
- Copia de seguridad previa con el restic del laboratorio, anotando el nombre del snapshot o del volcado. Es el plan B de los datos y tiene que existir antes de tocar nada; cómo se hace está en La copia previa con restic.
-
Plan de vuelta atrás escrito antes de empezar. Para el servicio del curso es la etiqueta y el digest anteriores (o el ID de imagen, mientras no hay registry) apuntados en la incidencia, y el comando exacto para volver:
# Guardar antes de actualizar cp /opt/servicio/compose.yml /opt/servicio/compose.anterior.yml # Vuelta atrás docker compose -f /opt/servicio/compose.anterior.yml up -d appEn pre, donde la imagen sale de
APP_IMAGEen el.env, lo que se guarda es ese fichero (.env.anterior), y la vuelta atrás esdocker compose --env-file /opt/servicio/.env.anterior -f /opt/servicio/compose.yml up -d app. Con imágenes de un registry conviene guardarlo condocker compose config --resolve-image-digests > compose.anterior.yml, que deja escritos los digests. Si la actualización lleva migraciones de esquema, se decide antes si son reversibles o si la vuelta atrás pasa por restaurar la copia, no a los 35 minutos. -
Despliegue. En dev, a mano con
docker compose up -d app; en pre, con el playbook deiac-lab, o con la etapa de despliegue del pipeline cuando la hay, que hace lo mismo y además deja el log del despliegue enlazable. - Migraciones. Una migración es el cambio de estructura de la base de datos (tablas o columnas nuevas, por ejemplo) que una versión trae consigo. La API del curso crea su esquema al arrancar y no las lleva. Cuando una aplicación sí las lleva, o cuando lo que se actualiza es el propio PostgreSQL, hay que comprobar que han terminado antes de dar la actualización por buena; cómo se hace en cada caso está en Para ampliar.
- Verificación con las pruebas de la UT4 (la suite funcional, k6 con sus umbrales, ZAP en modo baseline) más la verificación de integridad de datos de abajo.
- Producción en ventana, con la misma copia previa, la misma verificación y la misma persona que hizo pre, que es la que sabe qué esperar.
La copia previa con restic¶
Material de consulta
No se explica en clase: hace falta para el paso 3 de la A7.4.
Por qué la imagen anterior no basta. El plan de vuelta atrás del paso a paso devuelve el programa, no los datos. Si la versión nueva estropea la base de datos (una migración a medias, un error que escribe mal), volver a la imagen anterior no arregla nada: hace falta una copia de los datos tomada justo antes y guardada fuera de la máquina que se va a tocar.
Qué es restic. Un programa libre de copias de seguridad, de un solo binario, que guarda las copias en un repositorio: un directorio local o, como en el laboratorio, un bucket de un almacenamiento de objetos (MinIO, que habla el mismo protocolo que el S3 de Amazon). Todo lo que entra en el repositorio va cifrado, y los trozos de datos que ya estaban no se vuelven a guardar (deduplicación), así que copiar cada día la misma base ocupa poco más que la primera vez.
Cómo funciona. El repositorio se crea una sola vez, con restic init, que le pone una contraseña: sin ella no se abre, las copias son ilegibles y no hay forma de recuperarla. Cada restic backup añade un snapshot, una copia completa con un identificador corto (ocho caracteres hexadecimales), la fecha, la máquina y las etiquetas que se le pongan con --tag. restic no tiene fichero de configuración: todo se le dice con variables de entorno (dónde está el repositorio, en qué fichero está la contraseña y con qué credencial se entra en MinIO), que en el laboratorio se guardan en un fichero y se cargan antes de usarlo. De una base de datos no se copia su directorio de datos con PostgreSQL en marcha, que podría quedar a medio escribir, sino un volcado de pg_dump, y restic lo puede recibir directamente por la entrada estándar, sin escribirlo antes en ningún disco:
ssh ops@10.20.3.10 'sudo -u postgres pg_dump -Fc servicio' \
| restic backup --stdin --stdin-filename servicio-pre.dump --tag pre
La orden se lanza en el puesto de administración: el volcado sale de db01-pre por SSH y entra en restic sin pasar por el disco de nadie. Dentro del snapshot queda como un fichero, /servicio-pre.dump, que es lo que se saca para restaurar.
Cómo se usa en el curso. Hay un solo repositorio para todo el laboratorio, s3:http://10.10.0.30:29000/backups, en el bucket que nació con el propio MinIO el 18 de diciembre, y se maneja desde el puesto de administración, que está en la misma subred que MinIO y llega a pre por SSH. Se inicializa una sola vez en todo el curso, en la A7.4, y las copias de pre llevan la etiqueta pre. La referencia completa (retención, verificación, restauración) está en restic a fondo. En marzo, cuando la UT8 da de baja pre, esa etiqueta es la que permite borrar sus copias sin tocar el resto del repositorio.
Verificación de integridad de datos¶
El problema. Que la aplicación arranque y responda no demuestra que los datos sigan intactos: una versión que vacía una tabla al arrancar o una migración que pierde filas pueden pasar todas las comprobaciones de salud. La 1.4.3 no lleva migraciones, pero la API crea su esquema al arrancar, y una versión que lo hiciera mal perdería datos sin dar ningún error. Lo único que lo demuestra es comparar los datos de antes y de después.
Cómo se hace. Se toma una foto antes y otra después y se comparan. Estas consultas van en un script integridad.sql que se pasa a psql en los dos momentos y cuya salida se guarda en la incidencia. Van de menos a más precisas; la que da la confianza es la 3:
-- 1. Recuentos por tabla (rápido, detecta pérdidas gordas)
SELECT relname, n_live_tup
FROM pg_stat_user_tables
ORDER BY relname;
-- 2. Recuento exacto de las tablas críticas (n_live_tup es una estimación)
SELECT 'items' AS tabla, count(*) FROM items;
-- 3. Suma de control del contenido, ordenada para que sea determinista
SELECT md5(string_agg(i::text, '|' ORDER BY i.id)) AS hash_items FROM items i;
-- 4. Último registro: id máximo
SELECT max(id) FROM items;
-- 5. Secuencias: que no se hayan reiniciado ni adelantado
SELECT sequencename, last_value FROM pg_sequences WHERE schemaname = 'public';
Están escritas para el esquema del servicio del curso, cuya tabla principal es items; con más tablas, se añade una línea por tabla en las consultas 2 y 3.
Qué aporta cada una: la 1 es casi gratis, pero da recuentos aproximados, porque n_live_tup sale de las estadísticas de PostgreSQL y no de contar; la 2 cuenta de verdad; la 3 convierte cada fila entera en texto, las une en orden y calcula un md5 del conjunto, así que si el hash de items es el mismo antes y después, cada fila es idéntica, columna por columna; la 4 y la 5 detectan que el último identificador o el contador que reparte los siguientes (la secuencia) se hayan movido sin motivo.
Desde dónde se pasa. Desde el puesto de administración, por SSH, igual que la copia: pre no abre conexiones hacia gestión, así que todo se le pide desde fuera, y la salida queda ya en el puesto:
ssh ops@10.20.3.10 'sudo -u postgres psql -d servicio' < scripts/integridad.sql > integridad-antes.txt
Después de actualizar se repite con integridad-despues.txt, y un diff entre las dos tiene que salir vacío.
Lo que solo se ve desde fuera. Una prueba de lectura y escritura de extremo a extremo (crear un item con POST /items y leerlo), que es lo que hace la suite funcional, y los KPI del servicio (p95, tasa de errores) comparados con su línea base. Una actualización que "funciona" pero dobla la latencia es una degradación.
Ventanas de mantenimiento¶
Qué es y para qué. La ventana es un compromiso con los usuarios: en ese rato puede haber cortes y fuera de él no. Para el servicio del curso, martes de 7:00 a 8:00, que es lo que fija la política escrita.
El orden dentro de la ventana es siempre el mismo:
- Aviso previo a los usuarios (banner o correo, 48 horas antes para las versiones mayores).
- Silencio de las alarmas afectadas en Alertmanager, como en los silencios de la UT2, con fecha de fin. El corte está planificado y no debe despertar a nadie ni abrir incidencias por el receptor de la A2.6; la fecha de fin evita que el silencio, olvidado, tape después un fallo de verdad.
- Copia.
- Despliegue.
- Verificación.
- Retirada del silencio.
- Cierre de la incidencia.
Y un reloj. Si a los 20 minutos de la hora prevista de fin no está verificado, se vuelve atrás sin discutir. Esa regla evita el "cinco minutos más" que acaba en dos horas de caída.
A7.4 Actualización en pre (sesión 31)¶
Objetivo Llevar la 1.4.3 de dev a pre recorriendo el ciclo completo, con todo lo necesario para deshacerla: pre dado de alta en la monitorización, copia previa en restic, integridad.sql idéntico antes y después, la suite funcional de la UT4 en verde contra pre y todo enlazado desde una incidencia que deja escrito el plan de paso a producción. En dev la 1.4.3 corre desde la A7.3. Al terminar tienes una actualización que se puede demostrar paso a paso y deshacer en minutos, que es lo que piden los CE f y h.
Antes de empezar
- pre, creado por el IaC de la 5166 UT5 (A5.3 y A5.5) y sin montar a mano: web01-pre 10.20.1.10, app01-pre 10.20.2.10 con el servicio y db01-pre 10.20.3.10 con PostgreSQL. El puesto de administración llega a pre por su
ruta-lab.service; mon01 todavía no. - El PR
base-slimde la A7.3: fusiónalo en Gitea antes del paso 1 y devuelve amainla copia de trabajo de app01. - En el puesto:
~/iac-labcon el playbook de la A5.5, la suite funcional de la A4.4 en tu clon deservicioy, en el gestor de contraseñas, el secreto de la credencialresticde MinIO, que nació en el paso 1 de la A5.2 de Despliegue. - restic 0.18 o superior instalado en el puesto, que se puede traer hecho. Es la primera vez que se usa: lo necesario está en La copia previa con restic, y su referencia completa, en restic a fondo de la UT6.
- Explicado antes: El ciclo, Paso a paso, La copia previa con restic, Verificación de integridad de datos y Ventanas de mantenimiento.
Pasos
Sigues el ciclo de la teoría en su orden, y cada paso deja preparado el siguiente. Primero abres el registro de la actualización, antes de tocar nada, para que exista aunque algo salga mal a mitad. Después haces que mon01 vea pre, porque una actualización que nadie vigila no se puede dar por buena. Con eso preparas el plan B de los datos (el repositorio de copias, la copia previa y la foto de antes) y solo entonces cambias la versión, por el repositorio. Al final compruebas datos y servicio, en un orden que no estropee la comparación, y cierras el registro con todo enlazado y el paso a producción escrito.
-
Abre el registro antes de tocar nada. Crea la incidencia en Gitea: título
Actualizar app 1.4.2 → 1.4.3 (pre), etiquetasactualizacionyseguridad, y en el cuerpo las versiones origen y destino con su ID de imagen, el motivo (los CVE de la A7.3), el responsable y el plan de vuelta atrás. Abrirla primero hace que, si algo sale mal a mitad, ya haya un sitio donde está escrito qué se iba a hacer y cómo se deshace. -
Haz que mon01 vea pre. pre está aislado del resto del laboratorio, y desde gestión solo se llega a él por la pata de
router-pre(10.10.0.2): el puesto ya tiene esa ruta, mon01 todavía no. Pon en mon01 la ruta a10.20.0.0/16porrouter-preen una unidadruta-pre.service, para que sobreviva a un reinicio, y añade al jobnodede Prometheus un grupo nuevo con las tres VM de pre, cada una con suhost(web01-pre,app01-pre,db01-pre) y la etiquetaenv: pre. Sube el cambio amonitoring. Desde ese momento, si la actualización tumba una máquina de pre, lo ves.Pista: la unidad es la
ruta-lab.servicedel puesto con solo la línea de10.20.0.0/16y la tarjeta de mon01,ens18. En marzo, la UT8 retira pre de la monitorización buscandoenv="pre": escríbela exactamente así. -
Prepara el plan B de los datos. Crea en el puesto el repositorio de copias del laboratorio,
s3:http://10.10.0.30:29000/backups, conrestic init, una sola vez en todo el curso: es un repositorio único para todo el laboratorio, y la contraseña que se le pone al crearlo es la que lo abre. Su configuración va en/etc/restic/, del usuarioopsy con permisos 700, en dos ficheros con permisos 600:pass, una contraseña aleatoria que copias al gestor de contraseñas antes de seguir, porque sin ella las copias son ilegibles; yenv, con las cuatro variables (el repositorio,RESTIC_PASSWORD_FILEy la credencialresticde MinIO enAWS_ACCESS_KEY_IDyAWS_SECRET_ACCESS_KEY). Después haz la copia previa con la etiquetapre, como en La copia previa con restic, apunta el id del snapshot en la incidencia y, desde la raíz de tu clon, toma la foto de integridad de antes con las consultas de Verificación de integridad de datos, guardadas enscripts/integridad.sql. Las dos se piden desde el puesto, porque pre no abre conexiones hacia gestión.Pista: restic solo ve las variables de
envsi se exportan al cargar el fichero:set -a; . /etc/restic/env; set +a. Sirestic initdice que el repositorio ya existe, no lo crees de nuevo: mira Errores frecuentes. -
Lleva la 1.4.3 a pre por el repositorio. pre no llega al registry ni a gestión, así que la imagen se le empuja: pasa de app01 a app01-pre con
docker save | docker loada través del puesto, y tiene que llegar con el mismo ID, que es la prueba de que es la misma que ha corrido dos días en dev. Guarda antes el.envde app01-pre como/opt/servicio/.env.anterior: es el plan B de la imagen. Después cambia la versión donde vive de verdad, enapp_imagedeansible/group_vars/all.ymldeiac-lab, súbelo y pasa el playbook de la A5.5 limitado aapp. Así el repositorio dice siempre lo que corre en pre.Pista: en pre la imagen no está en el compose: el playbook la escribe como
APP_IMAGEen el.envde app01-pre. La API responde en el 28080, sin nginx delante. -
Comprueba los datos y después el servicio. Toma la foto de integridad de después (
integridad-despues.txt) y compárala con la de antes: tiene que ser idéntica, porque la 1.4.3 cambia la base de la imagen, no los datos. Luego pasa contra pre la suite funcional de la A4.4 conAPI_URL=http://10.20.2.10:28080. El orden importa: la suite escribe en la base, y pasada antes estropearía la comparación. -
Cierra el expediente con el plan B y el paso a producción. Completa la incidencia, que es lo que se revisa en la A7.6 y en la evaluable. Enlaza el PR de la A7.3, el commit de
iac-lab, el snapshot, las dos salidas de integridad y el resultado de la suite. Escribe la vuelta atrás con sus órdenes exactas, la de la imagen y la de los datos, con el plan B comprobado:compose.anterior.ymlen app01,.env.anterioren app01-pre y el id del snapshot. Añade el plan de paso a producción, que en el laboratorio no existe como entorno: cinco o seis líneas con el orden de Ventanas de mantenimiento aplicado a esta actualización (ventana, aviso, silencio en Alertmanager con fecha de fin, copia, despliegue, la misma verificación que en pre y el reloj de 20 minutos). Se queda abierta hasta la A7.6.Pista: la vuelta atrás de la imagen en pre está en Paso a paso; la de los datos es el camino inverso de la copia, de
restic dumpdel snapshot apg_restore --cleanen db01-pre.
Comprobación up{env="pre"} da las tres VM de pre a 1; restic snapshots --tag pre lista la copia previa; app01-pre corre app:1.4.3 con el mismo ID de imagen que app01 y responde en http://10.20.2.10:28080/health; el diff de integridad sale vacío; la suite funcional pasa contra pre; la incidencia lleva todos los enlaces, la vuelta atrás y el plan de paso a producción.
Para practicar Deja la incidencia abierta con los enlaces, la vuelta atrás y el plan de paso a producción; scripts/integridad.sql en servicio; los commits de iac-lab y de monitoring; y integridad-antes.txt, integridad-despues.txt y el informe de la suite en la carpeta de la práctica.
Si te sobra tiempo Pasa carga.js de la A4.5 contra pre (API_URL=http://10.20.2.10:28080) y compara p95 y tasa de errores con la línea base. Después actualiza PostgreSQL de db01-pre a la versión menor siguiente con la misma disciplina, copia previa y foto de integridad antes y después: ssh ops@10.20.3.10 'sudo apt-get update && sudo apt-get install -y --only-upgrade postgresql-17 && sudo systemctl restart postgresql@17-main', y show server_version al terminar.
Sesión 32 · Fallo provocado¶
En la A7.4 la actualización salió bien. Hoy sale mal a propósito: el profesor entrega una versión de la aplicación, la 1.5.0, que falla al actualizar, y hay que diagnosticarla en pre, decidir entre volver atrás o corregir antes de los 40 minutos que fija la política y contárselo al equipo de desarrollo de forma que pueda reproducirlo. Es la prueba de que el plan B de la A7.4 funciona cuando hace falta, y con reloj. La explicación corta de abajo sigue el orden en que se trabaja un fallo: dónde mirar primero, cómo se clasifica (porque la clase decide qué se hace), por qué el plazo no se negocia y cómo se escribe el reporte. Es lo que se usa en la hoja.
Cuando algo falla¶
Dónde se analiza. El análisis se hace en pre, que para eso existe: lo que allí se rompe no afecta a ningún usuario, y lo que se aprende evita que se rompa en producción.
Dónde mirar, por orden de probabilidad. Se empieza por lo que más a menudo explica un fallo de actualización y menos cuesta mirar:
- Qué ha cambiado. Las notas de la versión, sección de cambios incompatibles. Muchos fallos de actualización están anunciados ahí, y leerlas cuesta menos que buscarlos en el log.
- Logs del arranque.
docker compose logs --tail 200 app. Un contenedor que se reinicia en bucle suele fallar en las primeras diez líneas: variable de entorno que ya no existe, fichero de configuración con formato nuevo, migración que no puede aplicarse. - Dependencias incompatibles. La aplicación nueva requiere una versión de PostgreSQL o de una librería que no está. Suele verse en el arranque, como un error al importar un módulo o al conectar con la base de datos.
- Permisos. La imagen nueva corre con otro usuario (muchas pasaron de root a un usuario sin privilegios en los últimos años) y el volumen tiene el propietario antiguo.
- Formato de configuración. Claves renombradas, YAML en lugar de INI, valores que ahora se validan y antes se ignoraban.
La clasificación decide qué se hace. No todos los fallos merecen la misma respuesta: uno que impide arrancar exige volver atrás ya, y un aviso nuevo en el log no justifica deshacer nada.
| Clase | Definición | Acción |
|---|---|---|
| Bloqueante | El servicio no arranca o no cumple su función principal, o los datos no cuadran | Rollback inmediato, aviso al responsable y reporte a desarrollo |
| Degradación | Funciona pero peor: latencia, errores parciales, una función secundaria caída | Parche si es evidente y cabe en plazo; si no, rollback. Reporte |
| Cosmético | Avisos en el log, cambios de formato de salida, algo visual | Se registra y se sigue adelante |
El plazo se respeta. Cuarenta minutos en pre y no está resuelto: se vuelve atrás y se reporta. Es el plazo de la política escrita, y tiene dos razones. En producción, cada minuto de pruebas es un minuto de servicio roto dentro de una ventana que se acaba. Y cuanto más se toca un sistema roto intentando arreglarlo, más se ensucian las pruebas de lo que pasó, que son justo lo que el equipo de desarrollo necesita para corregirlo.
El reporte a desarrollo. Todo lo que afecte al código o a la configuración de la aplicación se reporta al equipo de desarrollo, y un reporte que no se puede reproducir no sirve: quien lo recibe no estaba delante, y si no consigue provocar el fallo en su entorno no lo puede corregir. La plantilla de referencia, la misma que se pide en cualquier empresa ordenada:
## Fallo al actualizar app 1.4.3 → 1.5.0 en pre
**Fecha y hora:** 2027-02-16 09:15, entorno pre (app01-pre, 10.20.2.10)
**Versión origen:** app:1.4.3 (ID sha256:c0de…) **Versión destino:** app:1.5.0 (ID sha256:f00d…)
**Clasificación:** bloqueante. **Decisión:** rollback a 1.4.3 a las 09:41 (26 min).
### Síntoma
El contenedor `app` se reinicia cada 8 s y `curl http://10.20.2.10:28080/health` da `Connection refused`.
### Pasos para reproducir
1. Con la base de datos de pre (copia restic del 2027-02-16 09:10), cambiar la imagen a app:1.5.0 en el compose.
2. Desplegar el servicio `app`.
3. Los logs del arranque muestran lo de abajo.
### Logs (primeras líneas del arranque)
[ERROR] Exception in worker process
KeyError: 'DATABASE_URL'
(la 1.4.3 leía DB_HOST, DB_NAME, DB_USER y DB_PASSWORD por separado)
### Causa probable
El cambio a una sola variable DATABASE_URL no aparece en las notas de la 1.5.0
ni en la plantilla .env.example del repositorio.
### Qué se pide
Documentar el cambio en las notas y en .env.example, o mantener compatibilidad
con las variables antiguas durante una versión con aviso de obsoleto.
### Enlaces
Incidencia #147 · commit 4e1f2a9 de iac-lab · Copia previa restic snapshot 3fa2b1c
Con eso, quien lo lea reproduce el fallo sin preguntar nada; si falta un bloque, el reporte vuelve con preguntas.
A7.5 Fallo provocado (sesión 32)¶
Objetivo Comprobar que el plan B funciona cuando hace falta. El profesor te da una versión de la aplicación que falla al actualizar: tienes que diagnosticarla en pre, tomar la decisión (rollback o parche) dentro de los 40 minutos y escribir un reporte que el equipo de desarrollo pueda reproducir sin preguntarte nada.
Antes de empezar
- Pre en la 1.4.3 de la A7.4, con el repositorio de copias listo en el puesto de administración.
- La imagen
app:1.5.0del profesor. El registry no existe hasta el 19 de febrero, así que llega como fichero (app-1.5.0.tar, en Aules): cópialo al puesto, cárgalo en app01-pre y comprueba que su ID es el que publica el profesor. - Un reloj a la vista y una incidencia abierta:
Actualizar app 1.4.3 → 1.5.0 (pre), con el plan de vuelta atrás. - Explicado antes: Cuando algo falla, con la tabla de clasificación y la plantilla de reporte.
Pasos
Repites la actualización de la A7.4 con la misma disciplina, para que el plan B exista antes de que algo falle. En cuanto falla, lo primero es guardar las pruebas, porque una vuelta atrás borra los logs del contenedor que ha fallado. Con las pruebas a salvo diagnosticas y clasificas, y la clase decide lo que haces antes del minuto 40. Al final verificas lo que ha quedado, porque deshacer también es un cambio, y lo cuentas en el reporte.
-
Actualiza a la 1.5.0 con la misma disciplina. Apunta la hora de inicio en la incidencia: desde ella corren los 40 minutos. Después repite la preparación de la A7.4: copia previa e
integridad-antes.txtcomo en el paso 3 de la A7.4, el.envde app01-pre (que ahora apunta a la 1.4.3) guardado como.env.anterior, y el cambio deapp_imagecon su pasada del playbook como en el paso 4 de la A7.4. Sin esa preparación no hay decisión posible en plazo. -
Guarda las pruebas del fallo antes de tocar nada. Si
appse reinicia, guarda sus últimas 200 líneas de log enfallo-logs.txty la respuesta completa de/healthen el 28080 de app01-pre. Hazlo antes de cualquier arreglo: al volver atrás, el contenedor se recrea y sus logs desaparecen, y son lo que necesita el reporte.Pista: pídelo todo desde el puesto por SSH, con
sudoy el compose en/opt/servicio/compose.yml; asífallo-logs.txtqueda en el puesto. -
Diagnostica y clasifica. Recorre las causas de Cuando algo falla en su orden y anota la causa probable con la línea de log que la delata. Después clasifica el fallo en bloqueante, degradación o cosmético: la clase decide qué toca hacer en el paso siguiente.
-
Decide y ejecuta antes del minuto 40. Apunta la hora de la decisión. Si haces rollback, primero el servicio, con el
.env.anteriorcomo en Paso a paso, para que vuelva a funcionar cuanto antes; despuésiac-lab, porque si el repositorio sigue diciendo 1.5.0, la siguiente pasada del playbook la volverá a poner. Si el arreglo es evidente y cabe en el plazo, parchea.Pista: en
iac-label cambio se deshace congit revert, sin reescribir la historia. Si la causa es una variable de entorno, el parche va en la tarea del playbook que escribe el.envde pre. -
Verifica lo que ha quedado. Una vuelta atrás también es un cambio, y se comprueba igual que una actualización:
/health, la foto de integridad de después y la suite funcional. -
Reporta y cierra. Redacta el reporte en
docs/reportes/2027-02-16-app-1.5.0.mdcon todos los bloques de la plantilla y los minutos de la decisión, para que alguien que no estaba pueda reproducir el fallo. Cierra la incidencia con la etiquetarevertida(sin ella si parcheaste) y el enlace al reporte: es lo que buscará la auditoría de la A7.6.
Comprobación curl http://10.20.2.10:28080/health responde con la versión que hayas dejado; alguien que no estuvo en clase puede reproducir el fallo con el reporte; la decisión consta con hora y dentro del plazo; app_image en ansible/group_vars/all.yml de iac-lab dice la misma versión que corre en app01-pre.
Para practicar Deja el reporte en el repositorio, la incidencia cerrada con su estado y sus enlaces, y fallo-logs.txt en la carpeta de la práctica.
Si te sobra tiempo Si hiciste rollback, prueba ahora el parche sin reloj y anota en el reporte si confirma la causa.
Sesión 33 · Trazabilidad¶
La A7.4 y la A7.5 dejaron dos actualizaciones, una verificada y otra revertida, con su rastro repartido entre incidencias, PR, commits y ficheros. Dentro de unos meses alguien preguntará qué cambió en el servicio, cuándo, por qué y qué se comprobó, y la respuesta tiene que poder reconstruirse sin depender de la memoria de nadie. Eso es la trazabilidad, y se construye en tres sitios que se enlazan entre sí: la incidencia de cada actualización, el CHANGELOG del servicio, que cuenta lo mismo por versiones, y el pipeline, que desde ahora escanea cada imagen nueva sin que nadie tenga que acordarse. La teoría y la hoja siguen ese orden. La etapa de escaneo se prueba hoy en un job aparte, porque la parte del pipeline donde va a vivir todavía no existe.
Trazabilidad¶
Qué es. Trazabilidad es poder seguir el hilo de un cambio en las dos direcciones: desde una versión desplegada hasta la decisión, las pruebas y la copia que la respaldan, y desde una decisión hasta lo que acabó desplegado. Ese hilo se guarda en el repositorio de incidencias. En clase es el gestor de issues de Gitea (gitea01); en la empresa será GitLab, Jira o GLPI, y la estructura es la misma. La regla es una incidencia por actualización, abierta antes de empezar y cerrada después de verificar en producción, con este contenido:
- Título:
Actualizar app 1.4.2 → 1.4.3 (pre). - Etiquetas:
actualizacion, y una deseguridad/funcionalidad/ciclosegún el motivo;mayorsi lo es;revertidasi acaba así. - Cuerpo: versión origen y destino con digest (o ID de imagen, mientras no hay registry), motivo (el CVE o la funcionalidad, con enlace a la ficha), entornos, fecha prevista, responsable, plan de vuelta atrás y, mientras no se haya hecho, el plan de paso a producción.
- Enlaces, que en Gitea se hacen con
#númeropara incidencias,!númeropara PR y URL para el resto: al PR de Renovate o al del cambio de versión, al commit o al pipeline que desplegó, al informe de Trivy antes y después, a la salida de las pruebas funcionales y del script de integridad, y al snapshot de la copia previa. - Estado: abierta mientras la actualización está en curso y cerrada al verificarla, o cerrada con la etiqueta
revertida, la causa y el enlace al reporte a desarrollo si se deshace. En una empresa es habitual un tablero de proyecto con una columna por estado (planificada,en pre,en producción,verificada); en el laboratorio basta el estado de la incidencia y, como no hay producción, la de pre se cierra con el plan de paso a producción escrito.
Las etiquetas no son decoración: dentro de un año, label:seguridad label:revertida responde en un segundo a "¿cuántas actualizaciones de seguridad ha habido que deshacer?", que es exactamente lo que se pregunta en una auditoría.
CHANGELOG del servicio¶
Por qué además un CHANGELOG. Las incidencias cuentan la historia actualización por actualización, pero quien despliega o audita suele preguntar de otra forma: qué trae la versión 1.4.3. Eso lo contesta el CHANGELOG.md del repositorio, con una sección por versión que enlaza a las incidencias para el detalle. Sigue el formato Keep a Changelog (https://keepachangelog.com/es-ES/1.1.0/), una convención pública muy extendida: una sección por versión con fecha, y dentro, las categorías fijas Added, Changed, Deprecated, Removed, Fixed, Security. Lo que interesa a quien mantiene el servicio es la categoría Security y anotar las versiones de las dependencias:
## [1.4.3] - 2027-02-11
### Security
- Base cambiada a python:3.13-slim-bookworm (digest del 2027-02-08): corrige CVE-2023-44487 en libnghttp2 (#142).
- setuptools eliminado de la imagen final (#143).
### Changed
- Imagen de 1,1 GB a 190 MB por el cambio de base; desplegada en pre (#145).
## [1.4.2] - 2027-01-20
### Fixed
- ...
Con la incidencia, el PR y el CHANGELOG cruzados entre sí, cualquiera puede reconstruir meses después qué cambió, cuándo, por qué y qué se comprobó. Sin eso, "¿desde cuándo va lenta la API?" se responde con arqueología de docker history y la memoria de quien estuviera de guardia.
La etapa de escaneo en el Jenkinsfile¶
El problema: escanear a mano depende de acordarse. Hasta ahora cada imagen se ha escaneado porque una hoja lo pedía. En un servicio vivo, una imagen construida con prisa llega al registry sin que nadie la haya mirado. La solución es una puerta en el pipeline: una etapa que escanea cada imagen nueva antes de publicarla y no la deja pasar si trae algo grave que tenga arreglo. Aplica sola las excepciones decididas en la A7.3, porque lee el mismo .trivyignore.
Dónde va. El pipeline del servicio, definido en el Jenkinsfile de su repositorio, construye la imagen y la sube al registry en la etapa Package, que tiene dos etapas anidadas, Construir y Subir, en el mismo agente (agent { label 'docker' }). Entre las dos deja marcado con un comentario el hueco de la puerta de seguridad: una etapa que escanea la imagen recién construida, que todavía solo existe en el Docker del agente, falla con cualquier CRITICAL corregible, archiva el informe completo y respeta el .trivyignore del repositorio. Usa las mismas variables que el resto del Jenkinsfile: IMAGEN, el nombre de la imagen (registry.lab:5000/app), y TAG, los siete primeros caracteres del commit, que fija la etapa Checkout:
// Dentro de stage('Package') { stages { ... } }, entre 'Construir' y 'Subir'
stage('Escaneo de vulnerabilidades') {
steps {
sh '''
trivy image --format json -o trivy-${TAG}.json ${IMAGEN}:${TAG}
trivy image --format sarif -o trivy-${TAG}.sarif ${IMAGEN}:${TAG}
trivy image --exit-code 1 --severity CRITICAL --ignore-unfixed \
--ignorefile .trivyignore ${IMAGEN}:${TAG}
'''
}
post {
always {
archiveArtifacts artifacts: 'trivy-*.json,trivy-*.sarif', fingerprint: true
}
failure {
echo "Imagen ${IMAGEN}:${TAG} bloqueada por CRITICAL. Revisar trivy-${TAG}.json"
}
}
}
Cómo funciona. La etapa no declara agent: lo hereda de Package, así que corre en el mismo agente que Construir, cuyo Docker tiene la imagen recién construida, y con el mismo workspace, donde está el .trivyignore. Si falla, Subir no se ejecuta y la imagen no llega al registry. El agente necesita Trivy instalado. El bloque post se ejecuta al final de la etapa según cómo haya ido: con always, archiva los informes junto al build (archiveArtifacts los deja descargables desde Jenkins, y fingerprint les calcula una huella para seguirles la pista entre builds), y con failure deja escrito en el log qué imagen se ha parado y dónde mirar.
Tres decisiones más. El JSON se genera siempre, falle o no la puerta, porque es lo que se enlaza desde la incidencia, mientras que el SARIF es el que leen las interfaces de revisión de código. --ignore-unfixed va en la puerta, para no bloquear el pipeline por algo que nadie puede arreglar hoy, pero no en el informe archivado. Y el umbral empieza en CRITICAL: tras un mes con la puerta en verde, se baja a HIGH,CRITICAL. Empezar en HIGH el primer día es la forma de que el equipo desactive la etapa la primera semana.
Hasta que exista Package. Package se monta en Despliegue el 19 de febrero, el día después de esta sesión. Hasta entonces la etapa se prueba en un job aparte que solo construye y escanea, con las mismas variables: IMAGEN es app, porque la imagen se queda en el Docker del agente, y TAG sale del commit como en el Jenkinsfile del servicio. Desde el 19, la etapa se pega tal cual en el hueco de Package, sin cambiar nada. El job aparte, en el fichero Jenkinsfile.escaneo del repositorio:
pipeline {
agent { label 'docker' }
parameters { string(name: 'RAMA', defaultValue: 'main', description: 'Rama que se construye') }
environment { IMAGEN = 'app' }
stages {
stage('Construir') {
steps {
git url: 'https://gitea.lab/ops/servicio.git', branch: params.RAMA, credentialsId: 'git-ro'
script {
env.TAG = sh(script: 'git rev-parse --short=7 HEAD', returnStdout: true).trim()
docker.build("${IMAGEN}:${TAG}", 'api')
}
}
}
// Aquí, la etapa 'Escaneo de vulnerabilidades' de arriba, copiada tal cual
}
}
A7.6 Trazabilidad (sesión 33)¶
Objetivo Que cualquiera pueda reconstruir sin preguntarte qué ha cambiado en el servicio y por qué, y que ninguna imagen vulnerable pase sin que alguien lo decida: las actualizaciones de A7.4 (verificada) y A7.5 (revertida) registradas y enlazadas en Gitea, el CHANGELOG.md al día y la etapa de Trivy funcionando en un job de Jenkins que bloquea una imagen vulnerable y deja pasar la corregida.
Antes de empezar
- Las incidencias de A7.4 y A7.5, aunque estén incompletas.
- El Jenkins de la 5166 UT6:
agent01con la etiquetadockerdesde la A6.3 y la credencialgit-roen la carpetaserviciodesde la A6.4. La etapaPackageno existe hasta mañana, así que hoy la etapa de escaneo se prueba en un job aparte. - El
.trivyignorede A7.3 en la raíz del repositorio, y Trivy en el puesto de administración. - Explicado antes: Trazabilidad, CHANGELOG del servicio y La etapa de escaneo en el Jenkinsfile.
Pasos
Primero dejas completo el rastro de lo que ya ha pasado: las dos incidencias y el CHANGELOG, que lo cuenta por versiones. Después montas la puerta que vigila lo que viene, y la pruebas en los dos sentidos, porque una puerta que nunca has visto cerrarse no está probada. Al final compruebas que todo eso sirve para lo que existe: contestar a una auditoría con búsquedas, no de memoria.
-
Completa las dos incidencias. Revísalas contra la lista de Trazabilidad y completa lo que falte: título con versiones, etiquetas (y
revertidaen la de A7.5), cuerpo con IDs de imagen, motivo y vuelta atrás, y todos los enlaces. Cierra la de A7.4 como verificada en pre, con su plan de paso a producción escrito; la de A7.5 ya está cerrada. -
Cuenta la versión en el CHANGELOG. Escribe
CHANGELOG.mden formato Keep a Changelog: sección[1.4.3]con fecha,Security(base nueva y CVE corregidos, con#incidencia) yChanged(el cambio de base, con el tamaño antes y después de la A7.3). La 1.5.0 revertida no lleva sección porque no está desplegada: el CHANGELOG cuenta lo que llegó a correr, y lo que se deshizo queda en su incidencia. -
Monta la puerta en un job aparte. La etapa de La etapa de escaneo en el Jenkinsfile se prueba hoy en un job propio, porque
Packageno existe hasta mañana. Instala Trivy enagent01(10.10.0.12), que es donde va a correr; deja en la raíz del repositorio elJenkinsfile.escaneodel apartado, con la etapa de escaneo pegada en su sitio, y crea en la carpetaserviciode Jenkins un job de tipo Pipeline llamadoescaneocon ese mismo contenido en Pipeline script.Pista: Trivy es un binario único: basta copiar el del puesto a
/usr/local/bindeagent01. -
Demuestra que la puerta cierra y abre. Lanza el job dos veces. En rojo, con
RAMA=escaneo-rojo: una rama nueva con elFROMde la base completa de la 1.4.2 (el digest de otoño de tu inventario de la A7.1) y el.trivyignorevacío, que tiene que quedarse en la puerta. En verde, conRAMA=main, que lleva la 1.4.3 con sus excepciones y tiene que pasar. Guarda la captura de cada build con sus artefactostrivy-*.jsonytrivy-*.sarif, que tienen que estar también en el que falla, porque es cuando más se necesitan. Enlaza los dos builds desde la incidencia de A7.4.Pista: si ni con la base completa sale ningún CRITICAL corregible, cambia en el job
--severity CRITICALporHIGH,CRITICALsolo para esa ejecución y anótalo. -
Prepara el repositorio para una auditoría. Las etiquetas existen para contestar preguntas, y este paso lo comprueba. Crea las seis etiquetas del apartado (
actualizacion,seguridad,funcionalidad,ciclo,mayor,revertida) con color y descripción, y deja cada incidencia con las suyas. Contesta después con una búsqueda, no de memoria, a tres preguntas, con la búsqueda a la vista en cada captura: cuántas actualizaciones de seguridad ha habido, cuántas se han deshecho y cuál fue la última verificada.
Comprobación En Gitea, label:actualizacion lista las dos incidencias y sus enlaces abren; CHANGELOG.md tiene versión, fecha y solo las categorías fijas; en el job escaneo hay un build rojo con bloqueada por CRITICAL y uno verde, ambos con JSON y SARIF archivados; las tres búsquedas de auditoría devuelven lo que esperabas.
Para practicar Haz commit de CHANGELOG.md y Jenkinsfile.escaneo, deja las incidencias en su estado final y guarda en la carpeta de la práctica las capturas de los dos builds y de las tres búsquedas.
Si te sobra tiempo Monta el índice de actualizaciones en docs/actualizaciones/README.md: una fila por actualización con fecha, versiones, motivo, estado y los enlaces a la incidencia, al PR, al escaneo, a las pruebas y a la copia, enlazado desde el CHANGELOG.md. O baja el umbral a HIGH,CRITICAL en una rama y cuenta las excepciones que harían falta.
Sesión 34 · Práctica evaluable¶
Las hojas A7.1 a A7.6 han recorrido, entre todas, el ciclo completo de una actualización: saber qué hay desplegado, buscar sus fallos, decidir, actualizar con red de seguridad, volver atrás cuando falla y dejar rastro. La evaluable lo junta. Sobre el servicio del curso, entrega en el repositorio de gitea01 y en la carpeta de la práctica lo que han ido dejando esas hojas. La sesión es para completar y ordenar esas evidencias, no para hacer nada nuevo: cada casilla corresponde a un criterio de la tabla y dice de qué paso sale. Repasa cada una contra su hoja, termina lo que quedara a medias y comprueba que los enlaces de las incidencias abren.
- Inventario de versiones con digests y política de actualización escrita (A7.1, pasos 1, 2 y 4).
-
renovate.jsonfuncionando, con captura del PR de Renovate (A7.1, paso 3). - SBOM e informes de Trivy y Grype de las dos imágenes, escaneo del código y tabla de hallazgos (A7.2, pasos 1 a 5).
- Decisión justificada por hallazgo,
.trivyignoredocumentado y comparación de bases (A7.3, pasos 2, 3 y 6). - Evidencia de la actualización en pre: incidencia, copia previa, salida de
integridad.sqlantes y después, suite funcional de la UT4 y plan de paso a producción (A7.4, pasos 1, 3, 5 y 6). - Reporte del fallo provocado con la plantilla de la unidad y la decisión tomada en plazo (A7.5, pasos 4 y 6).
- Incidencias trazables en Gitea con etiquetas y enlaces, CHANGELOG actualizado y etapa de Trivy con un build en rojo y otro en verde (A7.6, pasos 1, 2, 4 y 5).
| Criterio | RA4 | Peso |
|---|---|---|
| Software de base mantenido: seguimiento de versiones e instalación | d | 15 % |
| Vulnerabilidades examinadas (escaneo de código, binarios y librerías, fuentes consultadas) con soluciones | e | 25 % |
| Actualización periódica desde repositorio con verificación de datos | f | 15 % |
| Problemas de actualización analizados y resueltos en plazo, reportados a desarrollo | g | 15 % |
| Funcionamiento e integridad comprobados en pre, con el plan de paso a producción escrito | h | 15 % |
| Documentación y trazabilidad en el repositorio de incidencias | i | 15 % |
Errores frecuentes en el laboratorio¶
- El compose fijado a digest no arranca tras
docker compose pull: "manifest unknown". Se ha copiado el digest de una plataforma concreta dedocker manifest inspecten vez del digest del índice. Sirve el que devuelvedocker inspect --format '{{index .RepoDigests 0}}', o dejar quedocker compose config --resolve-image-digestslo escriba. - Renovate corre y no abre ningún PR. Casi siempre: el token no tiene permiso de escritura en el repositorio, el
scheduleno incluye la hora a la que se lanza, o (desde que existe el registry) la imagen del registry propio no está enhostRulesy Renovate la ignora. ConLOG_LEVEL=debugapareceskippingcon el motivo. - Trivy devuelve 0 hallazgos en una imagen que sí los tiene. Base de datos sin descargar (sin salida a Internet desde la máquina; hay que bajarla en otra con
trivy image --download-db-onlyy copiar~/.cache/trivy), o la imagen no tiene la base de datos de paquetes de la que Trivy saca el inventario (una imagen construida desdescratch, o una a la que se le ha borrado), como se explica en Cómo trabaja un escáner. Se comprueba la línea de cabecera del informe: si no dice(debian 12.x)o(debian 13.x), no ha reconocido la base. - Trivy y Grype dan números muy distintos. Normal: bases de datos y heurísticas diferentes. Solo interesan los hallazgos HIGH y CRITICAL que aparezcan en uno y no en otro, y la ficha decide quién tiene razón.
md5(string_agg(...))da distinto antes y después sin que haya cambiado nada. Falta elORDER BYdentro delstring_agg, así que el orden de las filas depende del plan de ejecución. O la migración ha añadido una columna yi::textahora incluye un campo más.restic initfalla conFatal: unable to open config fileaunque el bucket existe. A/etc/restic/envle faltan las dos variables de la credencial de MinIO,AWS_ACCESS_KEY_IDyAWS_SECRET_ACCESS_KEY, o se ha cargado sin exportarlas: restic no sabe con qué credencial hablar con MinIO.restic initrespondeconfig file already exists. El repositorio ya existe: se inicializó desde otra máquina o lo creó el profesor para toda el aula. No se vuelve a crear con otra contraseña, porque un repositorio restic solo se abre con la contraseña con la que se creó y se perderían las copias anteriores: se piden la contraseña y el secreto de acceso y se ponen en/etc/restic/passy/etc/restic/env.- PostgreSQL no arranca tras subir de 17 a 18: "database files are incompatible with server". Es un cambio de versión mayor sobre un directorio de datos con formato antiguo. No es un fallo de la imagen: hace falta
pg_upgradeo volcado y restauración. Y conpostgres:latest, esto ocurre sin que nadie lo haya pedido. - El contenedor nuevo no puede escribir en el volumen. La imagen ha cambiado el usuario con el que corre (por ejemplo de root a
uid 1000). Se ve en los logs comoPermission denieden el arranque. Se ajusta el propietario del volumen o se fijauser:en el compose, y se anota en la incidencia porque en producción pasará igual. - La etapa de Trivy falla en Jenkins pero en local pasa. En Jenkins no se está leyendo
.trivyignore(el fichero está en otro directorio o falta--ignorefile), o la base de datos del agente está más nueva y ya conoce un CVE de ayer. Lo segundo es correcto y es la razón de tener la etapa. Si lo que sale estrivy: not found, el agente no tiene Trivy: es el paso 3 de la A7.6. - Renovate hace automerge de algo que no era un parche. Un proyecto que no sigue semver (etiquetas tipo
2027.02, ov1.28sin tercer número) hace que Renovate clasifique mal el cambio. Para esos paquetes se añade unapackageRuleconversioningexplícito o se les quita el automerge.
Los enlaces para ampliar y los apartados que van más allá de lo que se hace en clase están en Para ampliar.
UT8 · Terminación segura del contenedor¶
Esta es la última unidad en el centro. Durante el curso el servicio se ha levantado, instrumentado, protegido, probado, copiado y actualizado; ahora toca lo contrario: retirarlo sin dejar rastro. Parece la operación más sencilla de todas, porque apagar una máquina cuesta un minuto, y es la que más fácil sale mal: cada unidad anterior dejó algo del servicio fuera de él (una serie en Prometheus, una copia en MinIO, una credencial en Jenkins, una ruta en otra máquina), y nada de eso desaparece al apagar. Una baja bien hecha encuentra cada rastro, decide cuál se conserva y por qué, destruye el resto de forma que no se pueda recuperar y lo demuestra con una evidencia por punto.
El sujeto de la baja es el entorno pre, creado con OpenTofu en enero y usado desde febrero para ensayar actualizaciones. Después del último despliegue, el 26 de febrero, ya no lo usa nadie, y se da de baja de verdad: contenedores e imágenes, VM y redes, rutas desde gestión, credenciales, copias, correo de alertas, datos y toda referencia en la monitorización. Dev sigue vivo al lado, y buena parte del cuidado de la unidad consiste en no llevárselo por delante. La práctica evaluable, el acta de baja, no tiene sesión propia: se construye en las cinco hojas y se entrega por Aules antes del examen de la segunda evaluación, el 23 de marzo. En la formación en empresa se pide exactamente esto cuando un cliente deja de serlo.
Sesión 35 · Plan de baja¶
Hasta hoy cada unidad ha añadido algo al servicio. Esta empieza a quitarlo y, sin embargo, su primera sesión no toca nada. Una baja tiene dos errores posibles y los dos salen caros: dejarse algo (y quedan datos personales, credenciales vivas o alarmas que nadie atiende) o llevarse lo que no tocaba (y se pierde lo que la ley obliga a guardar, o un trozo de dev). Ninguno de los dos se evita con cuidado al teclear: se evita decidiendo antes, por escrito, qué se va a hacer, y eso se deja en el acta de baja, el documento que recoge quién autorizó la baja, qué se conserva, qué se destruye y con qué evidencia. La teoría va en ese orden: primero, por qué la baja se trata como cualquier otro cambio, con su aprobación y lo que la ley obliga a conservar; después, cómo se encuentra todo lo que pre ha dejado y desde dónde se pregunta a cada sistema; y al final, cómo ese inventario se convierte en la lista de comprobación que guía las cuatro sesiones siguientes. La hoja hace lo mismo con pre: prepara el puesto de administración, inventaría, abre el acta y pide la aprobación.
La baja es un cambio más¶
El problema. Terminar un contenedor "para siempre" no es docker stop. Un servicio deja huella en la infraestructura, en la monitorización, en las copias y en los datos, y cada rastro es un coste o un riesgo: datos personales que siguen existiendo después de que el cliente pidiera su supresión, alarmas HostDown que nadie atiende, una IP reservada que impide reutilizar el rango, un token de Jenkins con permisos sobre un repositorio que ya no existe. Y el error contrario es peor, porque no tiene arreglo: una baja precipitada destruye lo que había que guardar. Por eso la baja se planifica con una lista de comprobación y se documenta como cualquier otro cambio.
flowchart LR
S["<b>docker stop</b><br><small>lo que mucha gente llama «dar de baja»</small>"]:::riesgo
R["<b>Lo que queda vivo</b>"]:::pieza
R1["<b>Datos personales</b><br><small>tras una petición de supresión</small>"]:::riesgo
R2["<b>Alarmas HostDown</b><br><small>que nadie atiende</small>"]:::riesgo
R3["<b>IP reservada</b><br><small>que bloquea el rango</small>"]:::riesgo
R4["<b>Token de Jenkins</b><br><small>sobre un repositorio que ya no existe</small>"]:::riesgo
P(["<b>Baja planificada</b><br><small>lista de comprobación, ventana,<br>y una evidencia por punto</small>"]):::ok
S --> R --> R1 & R2 & R3 & R4
R -.-> P
classDef act fill:#ea580c22,stroke:#ea580c,stroke-width:1.5px
classDef pieza fill:#64748b22,stroke:#64748b,stroke-width:1.5px
classDef dato fill:#2563eb22,stroke:#2563eb,stroke-width:1.5px
classDef infra fill:#a1a1aa14,stroke:#a1a1aa,stroke-width:1.5px
classDef ok fill:#16a34a22,stroke:#16a34a,stroke-width:1.5px
classDef riesgo fill:#dc262622,stroke:#dc2626,stroke-width:1.5px
Cada rastro que se queda es un coste o un riesgo. Por eso la baja se aprueba, se ejecuta y se documenta como cualquier otro cambio.
De dónde sale tratarla como un cambio. Cualquier modificación de un sistema en uso puede romperlo, y muchas de las caídas graves empiezan con un cambio que nadie revisó. La gestión de servicios de TI lleva décadas organizando ese riesgo, y el marco más extendido para hacerlo es ITIL, un catálogo de buenas prácticas que nació a finales de los años ochenta en la administración pública británica y que hoy es el vocabulario común de casi cualquier departamento de sistemas. ITIL reparte los cambios en tres tipos según su riesgo:
- Estándar: repetitivo, de bajo riesgo y aprobado de antemano, como ampliar el disco de una VM siguiendo un procedimiento ya probado. Se ejecuta sin pedir permiso cada vez.
- Normal: pasa por una solicitud (RFC), una evaluación de riesgo, la aprobación de quien tiene autoridad sobre el servicio (en una empresa pequeña, el responsable técnico y el dueño del negocio; en una grande, el CAB, el comité que aprueba los cambios) y una ventana acordada.
- De emergencia: el que no puede esperar, como un fallo en producción o una vulnerabilidad que se está explotando. Se aprueba por una vía abreviada y se revisa después.
La baja de un servicio es siempre un cambio normal. No es estándar, porque destruye datos y no se repite lo bastante para estar aprobada de antemano, y no es una emergencia, porque se sabe con semanas de antelación. En la práctica eso se traduce en cuatro cosas que tienen que existir antes de tocar nada:
- Confirmación escrita del responsable del servicio con fecha. Un correo vale; una conversación en el pasillo no. Ese correo va al acta. En el laboratorio es la respuesta del profesor a la solicitud que se le manda por mensaje en Aules.
- Ventana de ejecución. Aunque el servicio ya no dé tráfico, la baja toca sistemas compartidos (el firewall, Prometheus, la base de datos común). Se hace en horario en que alguien pueda deshacer un error.
- Comunicación. A los usuarios que quedasen (aviso previo de cierre, normalmente con semanas), a los equipos que consumen la monitorización (para que no abran incidencias) y al equipo de soporte.
- Lista de lo que se conserva y hasta cuándo. Esto es lo que más se olvida y lo que más problemas legales da en las dos direcciones: conservar de más incumple el principio de limitación del plazo de conservación, y conservar de menos, una obligación fiscal.
El cuarto punto es el que necesita más explicación, porque no lo decide la técnica sino la ley.
Qué hay que conservar por obligación legal¶
Por qué lo tiene que saber un técnico. Decidir qué se conserva no le corresponde al técnico, sino al responsable del servicio, con su asesoría jurídica y con el DPO (el delegado de protección de datos, la persona que supervisa en la organización el cumplimiento de la normativa de datos personales). Pero quien sabe qué datos hay y dónde está cada copia es el técnico, así que tiene que saber qué preguntar y a quién. Esto no es asesoramiento jurídico. En España pesan sobre todo dos normas: el RGPD, el reglamento europeo de protección de datos, que se aplica desde mayo de 2018, y la LOPDGDD, la ley orgánica española del mismo año que lo adapta. Junto a ellas, las leyes mercantiles y fiscales obligan a guardar ciertos documentos durante años. Los plazos orientativos son estos:
| Tipo de dato | Norma | Plazo habitual |
|---|---|---|
| Facturas y documentación contable | Código de Comercio, art. 30 | 6 años |
| Justificantes con trascendencia tributaria | Ley General Tributaria, art. 66 y siguientes | 4 años, el plazo de prescripción |
| Datos personales de clientes tras el fin de la relación | RGPD art. 5.1.e y 17; LOPDGDD | Bloqueo (art. 32 LOPDGDD) mientras puedan derivarse responsabilidades; después supresión |
| Logs de acceso con IP o usuario | RGPD (son datos personales); ENS (Esquema Nacional de Seguridad) para el sector público | Entre 6 meses y 2 años según finalidad y política; el ENS pide al menos lo que dure el análisis de incidentes |
| Registros de actividades de tratamiento | RGPD art. 30 | Mientras exista el tratamiento, y se actualiza con la baja |
El bloqueo: guardar sin seguir usando. El RGPD obliga a suprimir lo que ya no hace falta, y a la vez puede quedar pendiente una reclamación o una inspección en la que esos datos sean la prueba. El artículo 32 de la LOPDGDD resuelve ese choque con el bloqueo, que es lo que da sentido a "qué se conserva por si acaso": los datos se dejan de tratar, se aíslan con acceso restringido al responsable y a la autoridad, y solo se destruyen cuando prescriben las responsabilidades. Técnicamente eso es un volcado cifrado en un sitio al que solo llega el DPO o el responsable, con fecha de destrucción apuntada en el acta. Lo que no puede ser es "lo dejamos en el bucket de siempre porque nunca se sabe". En pre, ese volcado se saca el 9 de marzo, antes de destruir nada.
El acta tiene que decir, para cada categoría de datos, una de tres cosas: se destruye ahora (y cómo), se bloquea hasta una fecha (y dónde), o se transfiere a otro servicio que hereda el tratamiento.
Inventario de rastros¶
Por qué hace falta un método. Antes de borrar hay que saber qué hay. Un servicio de un año deja rastros en sitios que nadie recuerda, y quien mejor conocía el sistema puede haberse ido hace meses. Fiarse de la memoria garantiza olvidar algo, y lo que se olvida en el inventario no se borra nunca, porque ninguna hoja posterior sabrá que existe. El mapa siguiente agrupa los rastros en seis familias; conviene fijarse en que la mitad no están en el servicio sino alrededor de él (red, identidad, copias, monitorización), y esas son las que se olvidan.
mindmap
root((Servicio pre))
Cómputo
Contenedores y volúmenes
Imágenes locales y en registry
VM en Proxmox y snapshots
Plantillas y backups vzdump
Red
IP e IPAM del SDN
Reservas dnsmasq y DNS
Aliases y reglas OPNsense
NAT y port forwards
Certificados en la CA
Identidad
Usuarios y tokens Proxmox
Credenciales Jenkins
Tokens y deploy keys Gitea
Secretos en .env y vaults
Datos
Base de datos y réplicas
Dumps sueltos
Cachés y colas
WAL archivado
Copias y logs
Repositorio restic en MinIO
Versiones S3 y Object Lock
Streams en Loki
Ficheros rotados en hosts
Monitorización
Targets Prometheus
Reglas y silencios
Rutas Alertmanager
Dashboards y datasources
Jobs Promtail
Runbooks y catálogo
Seis sitios donde queda rastro de un servicio. El que siempre se olvida es el último: la monitorización sigue vigilando algo que ya no existe.
Buscar por lo que identifica a pre. Cada sistema guarda pre a su manera, pero casi siempre con una de tres marcas: la etiqueta env="pre" que la A7.4 puso a sus targets en Prometheus y que heredan sus alertas, el dominio pre.lab de sus nombres y el rango 10.20. de sus direcciones. Buscar esas tres cadenas en todos los sitios donde pueden estar escritas encuentra casi todo, incluido lo que nadie apuntó.
Lo escrito frente a lo que existe. Lo que dice un fichero de configuración no siempre es lo que existe, y al revés: un recurso creado a mano no está escrito en ningún sitio, y una línea de configuración puede referirse a algo que ya no corre. Por eso, además de buscar en lo escrito, se pregunta a cada sistema por lo que tiene ahora, de la forma que ese sistema admite. La forma no es un capricho de cada herramienta: depende de dónde guarde la información y de si la deja ver en claro. En el laboratorio la lista es más corta que el mapa, pero tiene las mismas trampas:
- Repositorios. Lo que está en Git se encuentra con
git grepde las tres cadenas:iac-lab,operacionyentregas-5166en el puesto de administración,monitoringyalertingen mon01. Que salga una línea que no estaba prevista es lo normal; se apunta. - OpenTofu frente a Proxmox.
tofu state listdice lo que la herramienta creó; la API de Proxmox, lo que existe. Comparar las dos listas es lo que descubre lo que se hizo a mano:router-prey las VNets de pre se crearon a mano, antes que el código, y no están en ningún estado. - Prometheus. No basta con leer
prometheus.yml: se pregunta a la API por las series que existen ahora mismo conenv="pre". Configuración y estado no siempre coinciden, y lo que sobra puede estar en cualquiera de los dos. - Jenkins y el registry. Las credenciales de Jenkins están cifradas y solo se listan por la API de su carpeta; las etiquetas del registry, por la suya. Cuáles de esas etiquetas llegaron a pre lo dice
docker imagesen app01-pre. - Red. Las rutas hacia
10.20.0.0/16están en tres máquinas de gestión (el puesto, agent01 y mon01), y los nombres de pre los sirve el dnsmasq derouter-pre, que muere con él. OPNsense no sabe nada de pre, porque gestión llega a pre por la pata querouter-pretiene endevmgmt, sin cruzar el cortafuegos, y eso también se comprueba. - Certificados. En la CA del curso no hay API que preguntar: el fichero
index.txtde~/calista todo lo emitido con su estado (V válido, R revocado, E expirado) y se lee buscando el dominio. - Copias, correo y logs.
restic snapshots --tag preen el repositorio del laboratorio, una búsqueda en Mailpit por las direcciones de pre ylogcli seriesen Loki.
Un inventario caduca. El inventario es una foto del día en que se hace, y si algo cambia antes de ejecutar la baja, se repite la parte afectada. En el curso pasa seguro: la A8.1 inventaría el 25 de febrero, y al día siguiente la A6.8 de Despliegue crea en Jenkins la carpeta de pre, da a agent01 una ruta hacia pre y hace el último despliegue. Por eso la A8.2 empieza repitiendo esa parte.
El inventario se hace así, sistema por sistema, en el paso 3 de la A8.1, y se lanza entero desde el puesto de administración, por las razones del apartado siguiente. Su resultado es la lista de comprobación de la baja: una fila por rastro, en orden de ejecución, con cómo se verifica que ya no está.
El puesto de administración como base de la baja¶
Por qué todo se lanza desde el puesto. La baja entera se ejecuta desde una sola máquina, el puesto de administración (admin01, la 10.10.0.50 de gestión), por tres razones. Es la que llega a todo lo que hay que tocar: a pre por su ruta, a mon01, agent01 y db01 por SSH, y a las API de Proxmox, Jenkins, Grafana, el registry y MinIO. No se destruye, así que lo que se guarda en ella sobrevive a la baja: por eso las evidencias van a ~/operacion/baja/pre/ev/, en la copia de trabajo del repositorio operacion, y nunca a una VM de pre, que se llevaría consigo las pruebas de su propia destrucción. Y ya tiene casi todo lo que hace falta: la CA del curso en ~/ca, la copia de trabajo de iac-lab con el entorno pre de OpenTofu y el entorno de restic de la A7.4 en /etc/restic/.
Cada sistema se pregunta por su camino. Desde el puesto hay cinco formas de llegar, y cada una lleva su credencial:
| Camino | A qué se llega | Con qué credencial |
|---|---|---|
| API por HTTPS | Proxmox, Grafana, Jenkins y el registry | Un token por sistema: el tofu de Proxmox, el de una cuenta de servicio de Grafana y un API token de Jenkins; en el registry, su usuario jenkins |
| SSH | Las VM de pre, agent01, mon01 y db01 | La clave de ops del puesto del aula, que ssh -A presta al puesto de administración |
| Túnel SSH | Prometheus, Alertmanager y Mailpit, que solo escuchan en el 127.0.0.1 de mon01 |
La misma clave, en un túnel hacia mon01 |
| Certificado de cliente | Loki, que lo exige | El certificado logcli de ~/tls-monitorizacion/ |
| Cliente de almacenamiento | El repositorio restic y el bucket backups de MinIO |
La credencial restic de MinIO y la contraseña del repositorio |
Qué es un token y por qué uno propio. Un token es una contraseña pensada para programas: se crea para un uso concreto, con los permisos justos, y se revoca sin tocar la contraseña de ninguna persona. Por eso la unidad crea en Grafana una cuenta de servicio propia, baja-pre, en lugar de usar la de admin: tiene los permisos que necesita la baja y no los de administrador, y se puede borrar sin afectar a nadie.
Un fichero para toda la unidad. Las cinco hojas se reparten en tres semanas y usan los mismos accesos, así que se preparan una sola vez, en un fichero que se carga al empezar cada sesión con . ~/baja-pre.env. Define las variables y las funciones cortas con las que los bloques de esta unidad funcionan tal cual. Lleva secretos, así que va con permisos 600 y fuera de cualquier repositorio. En cada ... va su secreto, y 192.168.1.50 se cambia por la IP del nodo Proxmox de cada puesto:
# ~/baja-pre.env · UT8. Se carga con ". ~/baja-pre.env" al empezar cada sesión
export EV=~/operacion/baja/pre/ev # evidencias: siempre aquí, nunca en una VM que se destruye
export GPG_TTY=$(tty)
export TF_VAR_pve_token='terraform@pve!tofu=...'
export AWS_ACCESS_KEY_ID=tofu AWS_SECRET_ACCESS_KEY='...'
export PVE=https://192.168.1.50:8006/api2/json
pve() { curl -sk -H "Authorization: PVEAPIToken=$TF_VAR_pve_token" "$PVE$1"; }
# Prometheus, Alertmanager y Mailpit solo escuchan en el 127.0.0.1 de mon01: se llega por túnel
tunel() { ssh -fN -L 29090:127.0.0.1:29090 -L 29093:127.0.0.1:29093 -L 28025:127.0.0.1:28025 ops@10.10.0.20; }
export P=http://localhost:29090 MP=http://localhost:28025/api/v1
amt() { ssh ops@10.10.0.20 docker compose -f /opt/monitoring/compose.yml exec -T alertmanager amtool --alertmanager.url=http://localhost:29093 "$@"; }
export G=https://grafana.lab GRAFANA_TOKEN='...'
export LOKI_ADDR=https://10.10.0.20:23100 # Loki pide certificado de cliente
export LOKI_CLIENT_CERT_PATH=~/tls-monitorizacion/logcli.crt LOKI_CLIENT_KEY_PATH=~/tls-monitorizacion/logcli.key
export J=https://jenkins.lab JENKINS_TOKEN='...'
export R=https://registry.lab:5000/v2/app REG_USER=jenkins REG_PASS='...'
rst() { ( set -a; . /etc/restic/env; set +a; exec restic "$@" ); } # restic con el entorno de la A7.4
Las líneas que no se explican solas:
EVes la carpeta de evidencias: dentro deoperacion, que se sube a Gitea, y nunca en una máquina que se va a destruir.GPG_TTYle dice agpgen qué terminal pedir la frase de paso; sin ella, el cifrado del volcado de bloqueo falla.TF_VAR_pve_tokeny las dos variablesAWS_*son el token de Proxmox y la credencialtofude MinIO, que OpenTofu necesita para leer el estado de pre y para destruirlo.pveaprovecha el mismo token para preguntar a la API de Proxmox.tunelabre en segundo plano los tres túneles hacia mon01. Con él abierto, Prometheus, Alertmanager y Mailpit responden en ellocalhostdel puesto, que es adonde apuntanPyMP.amtejecutaamtooldentro del contenedor de Alertmanager de mon01, que ya lo trae, así que no hay que instalarlo en el puesto.- Las tres
LOKI_*son las que leelogcli: la dirección de Loki y el certificado de cliente con su clave. rstlanza restic con el entorno de la A7.4 cargado en una subshell, de modo que sus secretos no se quedan en el entorno de la sesión.
Lista de comprobación completa¶
Del inventario a la lista. El inventario dice qué hay; la lista de comprobación dice en qué orden se quita y cómo se demuestra que ya no está. Esta es la lista para pre, en el orden en que se ejecuta y con la hoja que se encarga de cada punto. Ese orden no es el de las familias del mapa: primero va lo que protege (la aprobación, el silencio, el volcado), después lo que destruye, y al final lo que confirma que no queda nada (el silencio expirado sin alertas y el acta cerrada). Cada línea lleva el comando de verificación, porque una casilla marcada sin evidencia no vale nada en un acta; amt, pve y rst son las funciones del fichero de la unidad. Por qué cada paso va antes que el siguiente se explica el 9 de marzo, en El orden importa.
- Aprobación escrita archivada:
ev/01-aprobacion.txtcon la respuesta del profesor (A8.1) - Silencio en Alertmanager para
env="pre", más largo que la ventana:amt silence query env=pre(A8.2) - Volcado de bloqueo cifrado, comprobado y guardado fuera de los repositorios:
pg_restore -lysha256sum(A8.2) - Dashboards de pre exportados a Git, si los hay:
git -C ~/operacion log -1 -- dashboards/pre/(A8.2) - Job de despliegue a pre deshabilitado: la API de Jenkins lo da en
disabled(A8.2) - Servicio parado y app01-pre sin contenedores, volúmenes, imágenes ni redes propias:
docker ps -a,docker volume ls,docker imagesvacíos (A8.2) -
prevent_destroyretirado en una rama deiac-labque cita la aprobación:git log -1de la ramabaja-pre(A8.3) - VM de pre destruidas con OpenTofu:
tofu state listvacío (A8.3) -
router-prey VNets de pre destruidos:pveno lista nada de la franja 200 ni VNetspre*;ping 10.10.0.2sin respuesta (A8.3) - Rutas a pre retiradas en el puesto, agent01 y mon01:
ip route show 10.20.0.0/16vacío;dig +short app01.pre.lab @10.10.0.1vacío (A8.3) - Carpeta
servicio/prey credencialssh-preborradas en Jenkins, con el historial de despliegues guardado: la carpeta responde 404 (A8.3) - Ningún certificado de pre en la CA, o revocados con su CRL:
grep -i 'pre\.lab' ~/ca/index.txt(A8.3) - Copias de pre fuera del repositorio restic:
restic snapshots --tag prevacío yrestic checksin errores (A8.4) - Bucket sin versiones antiguas de lo borrado:
mc version info lab/backups(A8.4) - Etiquetas desplegadas en pre borradas del registry por digest:
tags/listsin ellas; recolector de basura ejecutado o pendiente con fecha (A8.4) - Correo de alertas de pre borrado de Mailpit sin tocar el resto: la búsqueda da 0 (A8.4)
- Loki sin streams de pre:
logcli series '{host=~".+-pre"}'vacío (A8.4) - Datos del tenant pre (el cliente de prueba de la A8.5) anonimizados y tablas borradas con
VACUUM FULL: la consulta de control da 0 (A8.5) - Réplicas y volcados sueltos revisados en db01 (A8.5)
- Targets de pre retirados:
count(up{env="pre"})vacío (A8.5) - Reglas, rutas y dashboards sin referencias a pre:
git grepy búsquedas por API vacías (A8.5) - Silencio expirado y sin alertas:
amt alert query env=prevacío (A8.5) - Series de pre fuera del TSDB, el almacén en disco de Prometheus: por retención, como pendiente con fecha, o con
delete_series(A8.5) - Acta completa, sin filas sin evidencia ni pendientes sin fecha (A8.5)
A8.1 Plan de baja (sesión 35)¶
Objetivo El puesto de administración preparado para las cinco hojas de la unidad (variables, túnel a la pila y herramientas) y operacion/baja/pre/acta.md abierta con el inventario real de pre y la lista de comprobación, con un comando de verificación por punto. Es la hoja de la que depende todo lo demás: lo que no salga hoy en el inventario no lo borrará ninguna otra, y sin la aprobación que pides al final no se toca nada de pre.
Antes de empezar
- Entra en el puesto de administración con
ssh -A ops@<IP de aula de admin01>: el-Ale presta tu clave para llegar a mon01, agent01, db01 y las VM de pre. - Enciende lo que usa la hoja:
jenkins01,router-pre,app01-preydb01-pre. Para que quepa en la memoria, apagaweb01ydb01de dev, como indica el presupuesto de memoria de Despliegue. - A mano, en el gestor de contraseñas del puesto: el token
tofude Proxmox (A5.1 de Despliegue), los secretos de las credencialestofuyresticde MinIO (A5.2), la contraseña del usuario del registry (A6.6) y la deadminen Jenkins. En el puesto,~/iac-lab/envs/precon su backend inicializado (A5.4) y el entorno de restic de la A7.4 en/etc/restic/. - Lo explicado antes: la baja como cambio, el inventario de rastros, el puesto como base de la baja y la lista de comprobación completa. La plantilla del acta está en la práctica evaluable.
Pasos
Hoy no borras nada. Primero preparas el puesto, porque el inventario se hace con esas herramientas y las cuatro hojas siguientes también; después inventarías pre sistema por sistema; con el inventario delante escribes la lista de comprobación del acta; y al final decides qué se conserva y pides la aprobación, que es lo que abre la puerta a la A8.2.
-
Prepara el fichero de la unidad. Crea
~/baja-pre.enva partir del que está en El puesto de administración como base de la baja, con cada...cambiado por su secreto y la IP del nodo por la tuya. Los bloques de las cinco hojas cuentan con esos nombres de variables y funciones, así que no los cambies. Lo cargarás al empezar cada sesión de la unidad; lleva secretos, así que va con permisos 600 y fuera de cualquier repositorio. -
Completa el puesto y prueba cada acceso. Faltan cuatro piezas: la CA del curso en el almacén del sistema, para que
curlylogcliconfíen en los servicios del laboratorio sin repetir--cacert;jq, para leer las respuestas de las API; el clientemcde MinIO con el aliaslaby la credencialrestic; y los dos tokens que el fichero deja en blanco, el de una cuenta de serviciobaja-precon rol Editor en Grafana y un API token deadminen Jenkins. Con el fichero cargado y el túnel abierto, comprueba que responden Prometheus,amt, Grafana, Jenkins,logcli,rstypve. Lo que falle se arregla hoy: las hojas siguientes lo dan por hecho, y un acceso que se rompe a mitad de la baja deja el entorno a medio destruir.Pista:
mcno es un paquete de Debian sino un binario dedl.min.io, y el alias apunta ahttp://10.10.0.30:29000. Las cuentas de servicio de Grafana están en Administration → Users and access, y el token de Jenkins, en tu usuario → Security. -
Inventaría pre sistema por sistema. Recorre los sistemas de Inventario de rastros, más los dashboards de Grafana con la etiqueta
pre, y deja lo que encuentres en$EV/00-inventario.txt, con una sección por sistema. Lo que tiene API o vive en el puesto se pregunta desde el puesto; lo demás, por SSH: app01-pre (10.20.2.10), agent01 (10.10.0.12) y mon01 (10.10.0.20). Busca con las tres cadenas del apartado y pregunta a cada sistema por lo que existe, no solo por lo que dice su configuración: lo que salga aquí es lo único que se va a borrar. La carpetaservicio/prede Jenkins nace mañana, en la A6.8 de Despliegue: hoy su sección sale con error, y la A8.2 la repite.Pista: las rutas que no se adivinan son
/cluster/resources?type=vmy/cluster/sdn/vnetsen Proxmox,<carpeta>/credentials/store/folder/domain/_/api/json?depth=1en Jenkins y/search?query=10.20.en Mailpit.logcli seriessin--sincesolo mira la última hora. -
Convierte el inventario en la lista del acta. Copia la plantilla en
~/operacion/baja/pre/acta.mdy rellena la identificación. Después escribe la lista de comprobación a partir de la lista completa: una fila por rastro del inventario, en el mismo orden, con su verificación y la hoja que la ejecuta, y las columnas de resultado y evidencia vacías, que irán llenando las hojas siguientes. Una fila sin verificación es una casilla que nadie podrá demostrar.Pista: lo que lista Proxmox y no está en el estado de OpenTofu (
router-prey las VNets) se destruye a mano y lleva sus propias filas. Lo que no esperabas va a "Incidencias durante la baja". -
Decide qué se conserva y pide la aprobación. Rellena "Qué se conserva" con motivo, dónde y hasta cuándo: el volcado de bloqueo, el repositorio
serviciocon su webhook y su Multibranch (dev los sigue usando), el token de OpenTofu, el repositorio restic (guarda más copias que las de pre) y el historial de despliegues de Jenkins. Es la parte del acta que protege de borrar de más. Haz commit del acta y deev/00-inventario.txty pide la aprobación al profesor en un mensaje de Aules con el enlace del commit, para que quien aprueba lea exactamente lo que se va a hacer. Su respuesta, con su fecha, va aev/01-aprobacion.txtantes del 9 de marzo: sin ella la A8.2 no empieza.
Comprobación tunel y las siete comprobaciones del paso 2 responden; ev/00-inventario.txt tiene todas sus secciones, con tres VM de pre en el estado de OpenTofu y cuatro en la franja 200 de Proxmox; la lista del acta empieza por la aprobación y el silencio y termina con la expiración del silencio y el cierre del acta; ninguna fila sin verificación.
Para practicar Deja acta.md y ev/00-inventario.txt en un commit de operacion, y la respuesta del profesor en ev/01-aprobacion.txt. Esa acta es la práctica evaluable.
Si te sobra tiempo Redacta en la identificación del acta la comunicación de la baja: el aviso a los usuarios, a soporte y a la guardia, con la fecha de cada uno.
Sesión 36 · Liberar la infraestructura: servicio y contenedores¶
Con la aprobación en ev/01-aprobacion.txt empieza la ejecución, y lo primero que hay que decidir no es cómo se borra cada cosa sino en qué orden: casi todos los errores graves de una baja son de orden (parar antes de silenciar, borrar antes de guardar, destruir antes de exportar las pruebas). Por eso la teoría empieza por la secuencia completa de la baja y por qué cada paso va antes que el siguiente, y solo después baja a lo de hoy, que es la primera mitad de la infraestructura: lo que vive dentro de las máquinas. Ahí se ve qué libera docker compose down y qué deja, y cómo se saca antes el volcado de bloqueo, la copia de los datos de pre que sobrevivirá a todo lo demás. La hoja recorre exactamente ese orden: pone al día el inventario, silencia, guarda, corta el despliegue, para y libera. Al terminar, las VM de pre siguen en pie a propósito: son el trabajo de la sesión siguiente, el 11 de marzo, y mientras existan la baja todavía tiene vuelta atrás. Los bloques de comandos de la teoría son los que copia la hoja.
El orden importa¶
Por qué el orden decide. Hay una secuencia correcta y no es "borrar de arriba abajo". Si el servicio se para antes de silenciar las alarmas, Alertmanager abre incidencias, el receptor webhook crea tickets y el equipo de guardia recibe un aviso a las diez de la noche por un servicio que se sabía que iba a apagarse. Si los volúmenes se borran antes de haber verificado que la copia bloqueada existe y se puede restaurar, ya no hay vuelta atrás. Si la VM se destruye antes de exportar las evidencias que están dentro (logs de auditoría, por ejemplo), el acta se queda sin pruebas. El silencio es un silencio de Alertmanager: una inhibición temporal, creada a mano, de las alertas que cumplen unas etiquetas, con un comentario que dice por qué.
flowchart TD
A["<b>Aprobación escrita</b><br><small>y lista de comprobación</small>"]:::act
B["<b>Silenciar alarmas y avisar</b>"]:::act
C["<b>Extraer lo que se conserva</b><br><small>dump bloqueado, evidencias, dashboards a Git</small>"]:::dato
D["<b>Parar el servicio</b><br><small>compose down</small>"]:::pieza
E["<b>Periodo de gracia</b><br><small>de 1 a 7 días con todo parado</small>"]:::infra
F["<b>Liberar infraestructura</b><br><small>volúmenes, imágenes, VM, red, DNS, firewall</small>"]:::pieza
G["<b>Revocar certificados y credenciales</b>"]:::pieza
H["<b>Destruir copias y logs externos</b>"]:::riesgo
I["<b>Borrar o anonimizar</b><br><small>datos en la BD compartida</small>"]:::riesgo
J["<b>Desconfigurar monitorización</b><br><small>y quitar los silencios</small>"]:::pieza
K(["<b>Verificar todo y firmar el acta</b>"]):::ok
A --> B --> C --> D --> E --> F --> G --> H --> I --> J --> K
classDef act fill:#ea580c22,stroke:#ea580c,stroke-width:1.5px
classDef pieza fill:#64748b22,stroke:#64748b,stroke-width:1.5px
classDef dato fill:#2563eb22,stroke:#2563eb,stroke-width:1.5px
classDef infra fill:#a1a1aa14,stroke:#a1a1aa,stroke-width:1.5px
classDef ok fill:#16a34a22,stroke:#16a34a,stroke-width:1.5px
classDef riesgo fill:#dc262622,stroke:#dc2626,stroke-width:1.5px
El periodo de gracia es el último momento reversible: una vez pasado, lo que viene debajo ya no se puede deshacer. De ahí que se extraiga antes lo que se conserva.
El periodo de gracia. El paso E es opcional pero muy recomendable: el servicio está parado, nada se ha destruido, y si alguien grita ("el informe mensual tiraba de esa API") se levanta en un minuto. En una empresa una semana es lo habitual. En el laboratorio va del 9 al 11 de marzo: el servicio está parado y app01-pre vacía, pero db01-pre sigue intacta y la imagen sigue en el registry, así que volver atrás es habilitar el job de despliegue de pre y relanzarlo. Lo irreversible empieza el 11, con tofu destroy, y desde ese momento la única vuelta atrás es la copia bloqueada.
Cortar lo que lo puede volver a levantar. Parar el servicio sirve de poco si algo automático lo puede arrancar otra vez. En el curso ese algo es el pipeline del servicio: su etapa Deploy lanza el job despliegue de la carpeta servicio/pre de Jenkins, y un lanzamiento con RUN_DEPLOY marcado volvería a llevar la imagen a app01-pre. Por eso ese job se deshabilita antes de parar el servicio. Deshabilitar no es borrar: el job y su historial siguen ahí, y durante el periodo de gracia volver atrás es habilitarlo y relanzarlo. Se borra con la carpeta el 11 de marzo.
Los silencios, al final. Los silencios se quitan al final, no antes, y con criterio: si el silencio se retira con los targets todavía configurados, salta todo. Primero se retiran los targets y las reglas, se comprueba que no hay alertas pendientes, y entonces se expira el silencio. La lista de comprobación completa sigue exactamente este orden.
Cómo se libera cada capa dentro de la VM¶
Por qué Docker no lo borra todo de una vez. En esta primera mitad se devuelve lo que el entorno pre tenía asignado dentro de app01-pre, y Docker lo reparte en cuatro recursos con vidas distintas: los contenedores (el proceso y su capa de escritura, que se recrean en cada despliegue), los volúmenes (los datos que tienen que sobrevivir a esa recreación), las imágenes (las capas de solo lectura de las que salen los contenedores) y las redes. La separación es deliberada: un down seguido de un up tiene que poder hacerse sin perder datos, y por eso las órdenes normales se llevan los contenedores y dejan los volúmenes. En una baja hay que pedir cada cosa expresamente, y antes de nada se extrae lo que se conserva. Cada recurso tiene un comando para liberarlo y otro para comprobar que ya no está, y ese segundo es el que va al acta.
| Recurso | Cómo se libera | Cómo se verifica |
|---|---|---|
| Contenedores | docker compose down |
docker ps -a vacío |
| Volúmenes | docker compose down -v; docker volume rm |
docker volume ls vacío |
| Imágenes | docker compose down --rmi all; docker image prune -a en un host dedicado |
docker images vacío |
| Redes | docker compose down; docker network rm |
docker network ls sin redes propias |
Docker en app01-pre¶
Qué se lleva cada opción. En app01-pre el servicio es el compose de /opt/servicio, proyecto servicio, con un solo servicio, app: la base de datos de pre vive en db01-pre como paquete de Debian, no en un contenedor. docker compose down para y elimina contenedores y redes del proyecto, pero deja los volúmenes con nombre y las imágenes. Con -v elimina también los volúmenes declarados en el fichero y los anónimos; con --rmi all, las imágenes que usaba el proyecto. Es la forma limpia de terminar un despliegue de Compose porque usa las etiquetas com.docker.compose.project para saber qué es suyo y no toca otros proyectos del mismo host:
docker compose -f /opt/servicio/compose.yml down -v --rmi all --remove-orphans
docker ps -aq; docker volume ls -q; docker images -q # vacíos
Lo que down no ve. --remove-orphans limpia contenedores del proyecto que ya no aparecen en el fichero. Lo que down -v no ve son los volúmenes creados a mano con docker volume create y montados como external: true: se borran con docker volume rm, después de mirar quién más los usa con docker ps -a --filter volume=<nombre>.
prune, solo en un host dedicado. docker image prune -a borra todas las imágenes sin contenedor del host entero, no solo las del proyecto, y docker system prune -af --volumes además contenedores parados, redes sin usar, caché de build y volúmenes anónimos. En app01-pre, donde solo vive este servicio y que se destruye dos días después, es aceptable, y además necesario: --rmi all solo se lleva las imágenes que usa el compose actual, y en app01-pre quedan las de despliegues anteriores, con otras etiquetas, que solo el prune encuentra. En un host compartido no se ejecuta nunca, y se borra imagen por imagen con docker image rm.
Un volumen borrado no es un dato borrado. Un volumen borrado es un rm -rf de /var/lib/docker/volumes/<nombre>/_data: los bloques siguen en el disco de la VM hasta que el disco se destruye. El porqué se explica el 16 de marzo, en Por qué borrar no borra.
El volcado de bloqueo¶
Qué se guarda, y por qué antes que nada. Lo que se conserva sale antes de destruir nada. Para pre es un volcado completo de su base de datos que queda bloqueado en el sentido del artículo 32 de la LOPDGDD, el de Qué hay que conservar por obligación legal: nadie lo vuelve a usar y tiene fecha de destrucción en el acta. Cuando el 11 de marzo se destruya db01-pre y el 16 se borren sus copias de restic, será la única copia que quede de esos datos.
Cómo se saca sin dejar copias en claro. Se pide desde el puesto de administración, de modo que el volcado viaja por SSH y se cifra al llegar, sin tocar ningún disco sin cifrar:
ssh ops@10.20.3.10 'sudo -u postgres pg_dump -Fc servicio' | gpg -c --cipher-algo AES256 -o ~/pre-bloqueo.dump.gpg
gpg -d ~/pre-bloqueo.dump.gpg | pg_restore -l | head -5 # el índice del volcado: no está vacío
sha256sum ~/pre-bloqueo.dump.gpg # la huella que va al acta
Qué demuestra cada línea. -Fc es el formato propio de pg_dump, comprimido, que pg_restore -l sabe listar: esa lista es la prueba de que el volcado se puede restaurar, y sin ella no se destruye nada. La huella de sha256sum va al acta para que, mientras dure el bloqueo, se pueda comprobar que el fichero que se conserva es el mismo que se verificó ese día.
Por qué cifrado, y cómo. El volcado sale del sistema con datos personales dentro, y cifrarlo es lo que hace que no se pueda leer sin la clave, esté donde esté. Lo cifra gpg (GnuPG, la implementación libre del estándar de cifrado OpenPGP). En una empresa se cifra para la clave pública del DPO (gpg -e -r <su clave>), que es quien lo custodia, y ninguna frase de paso tiene que viajar. En el laboratorio, gpg -c cifra con una frase de paso: el fichero se queda en el puesto de administración, fuera de cualquier repositorio, y la frase de paso, en el gestor de contraseñas, de modo que quien consiga uno de los dos no tiene nada. Si gpg falla con Inappropriate ioctl for device, no sabe en qué terminal pedir la frase: le falta GPG_TTY, que carga el fichero de la A8.1.
A8.2 Liberar el servicio y los contenedores (sesión 36)¶
Objetivo pre silenciado, con el volcado de bloqueo guardado y comprobado, el despliegue a pre deshabilitado y app01-pre sin contenedores, volúmenes, imágenes ni redes del servicio, cada verificación en $EV y apuntada en el acta. Es la mitad reversible de la baja: al terminar, el servicio ya no existe, pero lo necesario para levantarlo de nuevo sigue en su sitio hasta el 11 de marzo.
Antes de empezar
- Entra en el puesto de administración con
ssh -A, carga. ~/baja-pre.envy abre el túnel contunel. - Enciende lo mismo que en la A8.1:
jenkins01,router-pre,app01-preydb01-pre, conweb01ydb01de dev apagadas. - La aprobación del profesor en
ev/01-aprobacion.txt. Sin ella no se toca nada. - La A6.8 de Despliegue (26 de febrero) fue el último despliegue en pre, y su práctica evaluable de mañana no despliega.
- Lo explicado antes: el orden de la baja y cómo se libera cada capa dentro de la VM, cuyos bloques se copian aquí.
Pasos
Sigues el orden de El orden importa al pie de la letra, porque cada paso protege al siguiente: primero pones al día el inventario, que caducó con la A6.8; después callas las alarmas, para que lo que viene no despierte a nadie; guardas lo que se conserva mientras todavía hay de dónde sacarlo; cortas el despliegue, para que nada vuelva a levantar pre; y solo entonces paras y liberas. El último paso deja escrito en el acta lo que ha pasado hoy.
-
Pon al día el inventario. El de la A8.1 es del 25 de febrero, y al día siguiente la A6.8 de Despliegue creó la carpeta
servicio/preen Jenkins, dio a agent01 una ruta hacia pre y desplegó en app01-pre. Repite esa parte en$EV/00b-inventario.txt: los jobs y las credenciales de la carpeta, lo que corre en app01-pre con sus etiquetas de imagen y la ruta a pre de agent01. Añade al acta las filas que falten.Pista: las etiquetas
app:<commit>de app01-pre son las que llegaron a pre, y este fichero es lo único que las recordará cuando la A8.4 vaya a borrarlas del registry. -
Silencia pre antes de tocar nada. Desde hoy pre va a desaparecer a trozos, y en cuanto sus VM dejen de contestar
HostDownsaltaría por cada una. Crea un silencio con el matcherenv=pre, autoropsy una duración que pase del 18 de marzo, que es cuando lo retira la A8.5, y guárdalo en$EV/02-silencio.txt. -
Guarda lo que se conserva mientras todavía existe. Saca el volcado de bloqueo de db01-pre (
10.20.3.10) con el bloque de El volcado de bloqueo y deja su índice y su huella en$EV/03-dump-bloqueo.txt: el índice demuestra que se puede restaurar, y la huella, que el fichero que guardas es ese. Si el inventario encontró dashboards de pre, exporta su JSON a~/operacion/dashboards/pre/, porque un dashboard bien hecho sirve para el siguiente servicio. El.gpgse queda en el puesto y la frase de paso, en el gestor de contraseñas; ninguno de los dos va al repositorio. Hazlo bien: desde el 11 de marzo, la única copia al día de los datos de pre es esta.Pista:
pg_restoreviene enpostgresql-client, que no suele estar en el puesto. -
Corta el camino que podría volver a levantar pre. Deshabilita el job
desplieguedeservicio/preen Jenkins, para que ningún lanzamiento del pipeline vuelva a desplegar mientras dura la baja, y guarda en$EV/04-jenkins.txtque la API lo da endisabled. Deshabilitar, no borrar: durante el periodo de gracia es tu vuelta atrás. El Multibranchcino se toca: sigue construyendo.Pista: un job se deshabilita con un POST a su URL terminada en
/disable, y su estado es el campocolorde suapi/json. -
Para el servicio y libera lo que
downdeja. Para el servicio y comprueba desde el puesto que la API (http://10.20.2.10:28080/health) ya no responde: es la prueba de que ha terminado, vista como la vería un cliente. Después libera volúmenes, imágenes y redes como en Docker en app01-pre, incluido elpruneque ese apartado solo admite en un host dedicado, y deja en$EV/05-docker.txtla API sin respuesta y las cuatro listas vacías.Pista: el
prunesolo vale en app01-pre, que no aloja nada más; en app01 de dev se llevaría los volúmenes del servicio. Las redes propias se listan condocker network ls --filter type=custom. -
Cierra la mitad de hoy en el acta. Apunta el resultado y la evidencia de cada fila ejecutada (
ev/00byev/02aev/05), el volcado de bloqueo con su huella en "Qué se conserva" y cualquier desviación en "Incidencias". Haz commit enoperacion, con los dashboards de pre si los había. El acta cuenta hoy lo que ha pasado hoy: dentro de dos semanas nadie recordará en qué orden se hizo.
Comprobación ev/02-silencio.txt con el silencio activo y su fin después del 18 de marzo; ev/03-dump-bloqueo.txt con el índice del volcado y su huella; el job de despliegue en disabled; en ev/05-docker.txt, la API sin respuesta y las cuatro listas vacías. Las VM de pre siguen en pie: al acabar hoy, eso es lo correcto.
Para practicar Haz commit en operacion del acta al día y de ev/00b y ev/02 a ev/05, y deja pre-bloqueo.dump.gpg en el puesto, fuera de los repositorios, y su frase de paso en el gestor de contraseñas.
Sesión 37 · Liberar la infraestructura: VM, red y credenciales¶
El periodo de gracia termina hoy. Hasta ahora la baja se podía deshacer, porque el servicio estaba parado pero las máquinas, la base de datos y la imagen seguían ahí; esta sesión destruye lo que queda fuera de los contenedores, y desde que termine la única vuelta atrás es el volcado de bloqueo. Al acabar no existen las VM de pre, ni router-pre, ni sus VNets; ninguna máquina de gestión tiene ruta hacia pre; Jenkins ya no tiene carpeta ni credencial de pre; y está comprobado que la CA no firmó nada para pre. Con infraestructura como código parece que bastaría una orden, tofu destroy, y la teoría empieza precisamente por lo que esa orden no hace: no conoce lo que se creó a mano, puede llevarse lo compartido y está bloqueada a propósito. De ahí sale lo demás: cómo se libera cada capa fuera de la máquina y por qué aquí cada comprobación se hace desde fuera, preguntando al sistema que tenía el recurso y no a la herramienta que lo borró. Ese segundo apartado es el más largo de la unidad, y de él sale entero el procedimiento de la hoja: en el laboratorio los bloques se copian de ahí tal cual.
Destruir lo declarado no basta¶
El problema. Con infraestructura como código, dar de baja un entorno parece trivial: el código describe pre, y tofu destroy deshace lo que el código describe. Para ver dónde falla hay que recordar cómo decide qué borrar. OpenTofu no le pregunta a Proxmox qué existe: lee su estado, el fichero en el que apunta cada recurso que ha creado con su identificador real (que vm["web01"] es la VM 210, por ejemplo), y que en el laboratorio vive en el bucket tfstate de MinIO; está explicado en El estado a fondo. Así que tofu destroy deshace lo que el estado dice que se creó, ni más ni menos, y en esa frase caben cuatro trampas que aparecen en la hoja de esta sesión.
Lo que se creó a mano no está en el estado. router-pre y las cuatro VNets de pre se crearon a mano en noviembre, dos meses antes de que existiera el código, y sobreviven intactos a tofu destroy; lo mismo la pata de router-pre en gestión y las rutas que la usan. Por eso el inventario de la A8.1 comparó el estado con lo que lista Proxmox, y por eso la verificación se hace contra el sistema y nunca contra el plan.
Y lo que está en el estado no siempre es de pre. El caso contrario destruye de más: si un recurso compartido (una red, un usuario de API) entró en el estado de este entorno, tofu destroy se lo lleva, y con él el entorno que lo compartía. tofu plan -destroy es el único sitio donde eso se ve antes de que pase, y tofu state rm saca el recurso del estado sin tocarlo en el hipervisor.
prevent_destroy para el plan, y hace bien. Las VM de pre llevan lifecycle { prevent_destroy = true } desde que se crearon, puesto para que ningún plan, por error o a propósito, destruyera pre mientras otra asignatura dependía de él. Por eso tofu plan -destroy falla con Instance cannot be destroyed en lugar de destruir. La protección no se salta: se retira en una rama de iac-lab que cita la aprobación del acta, se destruye desde esa rama y la rama no se fusiona. main sigue protegiendo, y la rama queda como prueba de quién levantó la protección, cuándo y con qué permiso.
Revocar una credencial no es lo mismo que borrarla. Un certificado borrado del servidor sigue siendo válido hasta su fecha de caducidad para cualquiera que conserve una copia de la clave privada; lo único que lo invalida es revocarlo y publicar la CRL. Con un token pasa al revés: muere en cuanto se borra del sistema que lo emitió, pero las copias que viajaron (un .env en un portátil, un workspace de Jenkins) no se borran solas y hay que pedir su destrucción por escrito. En el acta se anotan por separado las dos cosas: qué se revocó y a quién se le pidió destruir qué.
flowchart TB
E["<b>Estado de OpenTofu</b><br><small>las tres VM de pre</small>"]:::pieza
PD["<b>prevent_destroy</b><br><small>se retira en una rama<br>que cita la aprobación</small>"]:::act
D["<b>tofu destroy</b>"]:::act
M["<b>Creado a mano</b><br><small>router-pre, VNets de pre,<br>rutas en gestión</small>"]:::riesgo
Q["<b>qm, pvesh, ip route</b><br><small>se deshace a mano</small>"]:::act
C["<b>Compartido</b><br><small>una red o un usuario de API</small>"]:::riesgo
R["<b>tofu state rm</b><br><small>sacarlo del estado antes de destruir</small>"]:::act
V(["<b>Preguntar desde fuera</b><br><small>API de Proxmox, ping, ip route: la única prueba</small>"]):::ok
E --> PD --> D --> V
M --> Q --> V
C --> R --> E
classDef act fill:#ea580c22,stroke:#ea580c,stroke-width:1.5px
classDef pieza fill:#64748b22,stroke:#64748b,stroke-width:1.5px
classDef dato fill:#2563eb22,stroke:#2563eb,stroke-width:1.5px
classDef infra fill:#a1a1aa14,stroke:#a1a1aa,stroke-width:1.5px
classDef ok fill:#16a34a22,stroke:#16a34a,stroke-width:1.5px
classDef riesgo fill:#dc262622,stroke:#dc2626,stroke-width:1.5px
tofu destroy solo deshace lo declarado, y solo cuando se levanta prevent_destroy. Lo creado a mano se deshace a mano, lo compartido se saca antes del estado, y la prueba de que algo ya no existe es siempre una pregunta desde fuera.
Cómo se libera cada capa fuera de la VM¶
Por qué aquí se comprueba desde fuera. La segunda mitad devuelve lo que el entorno tenía asignado fuera de las máquinas: las propias VM, el router y sus redes, las rutas que llevaban a él, los certificados y las credenciales. Dentro de una máquina bastaba con que un listado saliera vacío; aquí la máquina deja de existir y ya no se le puede preguntar, y la herramienta que borra no es buen testigo de su propio trabajo (tofu state list vacío solo dice que OpenTofu ya no recuerda nada). Por eso, de los dos comandos de cada recurso, el que lo libera y el que comprueba que ya no está, el segundo se lanza desde fuera del recurso: la API del hipervisor no lista la VM, el ping no contesta, la tabla de rutas no tiene la entrada. La tabla resume el qué; el cómo y el por qué van debajo, por subapartados.
| Recurso | Cómo se libera | Cómo se verifica |
|---|---|---|
| VM declaradas | tofu destroy del entorno, tras retirar prevent_destroy |
tofu state list vacío; Proxmox sin las VM |
| VM y redes creadas a mano | qm destroy --purge; pvesh delete de subredes y VNets |
la API de Proxmox no las lista |
| Rutas y DNS | quitar la ruta y su unidad de systemd; el DNS de pre muere con su router | ip route show vacío; ping y dig sin respuesta |
| Reglas de firewall | quitar reglas y después aliases | matriz de reglas actualizada; escaneo desde fuera |
| Certificados | revocar en la CA y publicar la CRL | index.txt con R; openssl verify -crl_check |
| Credenciales | borrarlas en el sistema que las emitió | el listado por API ya no las tiene |
| Pipeline y repositorio | nada, si el servicio sigue en otros entornos | el pipeline sigue construyendo |
OpenTofu: la rama, el plan y el estado¶
Rama, plan y destrucción, en ese orden. El entorno pre nació con tofu apply en envs/pre de iac-lab, así que muere con tofu destroy desde el mismo directorio. Primero se retira la protección en una rama, y después se mira el plan antes de aplicarlo: tofu plan -destroy muestra todo lo que va a desaparecer, y es donde se descubre si el estado incluye algo compartido. El plan se guarda en un fichero (-out) y se aplica ese fichero, no uno nuevo: así se destruye exactamente lo que se ha leído.
cd ~/iac-lab && git switch -c baja-pre
# borrar el bloque lifecycle { prevent_destroy = true } del recurso de las VM
git commit -am "Baja de pre: retira prevent_destroy (aprobación en operacion/baja/pre/ev/01-aprobacion.txt)"
git push -u origin baja-pre
cd envs/pre
tofu plan -destroy -out=destroy.plan # Plan: 0 to add, 0 to change, 3 to destroy
tofu apply destroy.plan
tofu state list # vacío
Lo que sobra en el plan, y lo que queda en el estado. Si el plan lista algo que no es de pre, se saca del estado antes de aplicar con tofu state rm <dirección>, que lo olvida sin tocarlo en Proxmox, y se repite el plan. Con el estado vacío, el fichero del estado en MinIO sigue guardando el historial y, con el provider de Proxmox, puede contener datos sensibles como la contraseña de cloud-init: recibe el mismo tratamiento que un volcado de base de datos.
Proxmox: lo que no está en el estado¶
Lo que se deshace a mano, y en qué orden. router-pre (ID 200) y las VNets de pre se destruyen en el nodo con qm y pvesh, en ese orden, porque una VNet con una VM conectada no se deja borrar. Cada VNet lleva su subred, que se borra antes que la VNet, y los cambios del SDN no se aplican hasta pvesh set /cluster/sdn, igual que al crearlas:
qm stop 200 --timeout 60 && qm destroy 200 --purge
i=0
for Z in mgmt front back data; do # premgmt 10.20.0.0/24 ... predata 10.20.3.0/24
pvesh delete /cluster/sdn/vnets/pre$Z/subnets/lab-10.20.$i.0-24
pvesh delete /cluster/sdn/vnets/pre$Z
i=$((i+1))
done
pvesh set /cluster/sdn
Lo que --purge limpia y lo que no. --purge quita la VM de los trabajos de backup, de la replicación y de la configuración de alta disponibilidad; sin él, un trabajo de vzdump (las copias de VM de Proxmox) fallaría con "VM 200 not found" cada semana hasta que alguien lo edite. Los snapshots se destruyen con la VM; los backups vzdump ya hechos y las plantillas clonadas de ella, no: se listan con pvesm list local --content backup y qm list, y se borran aparte. En el laboratorio no hay trabajos de vzdump ni reservas en el IPAM del SDN (el registro en el que el SDN de Proxmox apunta qué dirección tiene cada VM), porque las direcciones de pre son fijas; cómo se borran las copias en Proxmox Backup Server y cómo se libera a mano una IP del IPAM está en Para ampliar.
Rutas, DNS y firewall¶
Por qué una ruta sobrante es un riesgo. Gestión llega a pre por un camino que se abrió a propósito en enero, al crear las VM de pre: la pata de router-pre en devmgmt con la 10.10.0.2, y una ruta a 10.20.0.0/16 por ella en cada máquina que lo necesitaba (el puesto de administración en su unidad ruta-lab.service; agent01 y mon01 en ruta-pre.service). La pata muere con router-pre; las rutas no, y una ruta hacia una red que ya no existe es un camino abierto para la próxima vez que alguien use ese rango:
sudo sed -i '/10\.20\.0\.0\/16/d' /etc/systemd/system/ruta-lab.service # en el puesto
sudo systemctl daemon-reload && sudo ip route del 10.20.0.0/16
sudo systemctl disable --now ruta-pre.service # en agent01 y en mon01
sudo rm /etc/systemd/system/ruta-pre.service && sudo systemctl daemon-reload
sudo ip route del 10.20.0.0/16
La ruta y la unidad, las dos. Quitar la unidad sin borrar la ruta deja la máquina mal hasta el próximo reinicio; borrar la ruta sin quitar la unidad la deja mal desde el próximo reinicio. Van las dos cosas.
Los nombres. Los nombres <host>.pre.lab los resolvía el dnsmasq de router-pre, así que desaparecen con él. Se comprueba además que el DNS de gestión tampoco los resuelve (dig +short app01.pre.lab @10.10.0.1 vacío), porque un nombre que sigue apuntando a una IP libre es un problema futuro: cuando esa IP se reasigne, el nombre apuntará a una máquina que no es.
El cortafuegos. En OPNsense no hay nada de pre, que nunca pasó por el cortafuegos. Si lo hubiera, el orden sería reglas primero y aliases después, porque un alias al que apunta una regla no se deja borrar, y la verificación sería un escaneo desde fuera y la matriz de reglas actualizada en un commit.
Certificados: revocar en la CA del curso¶
Por qué revocar. Un certificado de un servicio dado de baja sigue siendo válido hasta su caducidad aunque la máquina haya desaparecido; si alguien conserva la clave privada (en un volumen, en una copia), puede suplantar el servicio. Revocar es lo único que lo impide, y solo funciona si los clientes consultan la CRL, la lista de certificados revocados que publica la CA.
En pre no hay nada que revocar, y se comprueba. web01-pre se quedó como clon limpio, sin nginx ni certificado, así que en pre no hay nada que revocar, y eso se comprueba en el index.txt de la CA (grep -i 'pre\.lab' ~/ca/index.txt vacío), el registro de todo lo que ha firmado desde que nació, en noviembre. La revocación se explica igual, porque es lo que se hace con cualquier servicio que sí publicaba TLS.
Cómo se revoca con la CA del curso. openssl ca busca el certificado en index.txt, lo marca con R y la fecha, y -gencrl escribe la lista nueva. Los certificados emitidos están en ~/ca/emitidos/, con el número de serie como nombre:
cd ~/ca
openssl ca -config ca.cnf -revoke emitidos/<serie>.pem -crl_reason cessationOfOperation
openssl ca -config ca.cnf -gencrl -out crl.pem
openssl verify -crl_check -CAfile ca.crt -CRLfile crl.pem emitidos/<serie>.pem # certificate revoked
El motivo cessationOfOperation es el correcto para una baja (no keyCompromise, salvo constancia de que la clave se filtró). Los certificados del curso no llevan la extensión que dice dónde se publica la CRL, así que en el laboratorio la lista se reparte a mano a los clientes que la consultan. El certificado de la CA no se toca: se revocan los que firmó. La consulta en línea con un respondedor OCSP y la revocación con step-ca están en Para ampliar.
Credenciales y tokens¶
Qué es una credencial huérfana. Cada sistema tiene las suyas, y un token que sobrevive a su servicio es una credencial huérfana: nadie lo rota, nadie lo audita y sigue siendo válido para quien lo tenga.
Lo que se borra en pre. Son las que se crearon en Jenkins el 26 de febrero para desplegar en pre: la carpeta servicio/pre, con la credencial ssh-pre (la clave con la que el job de despliegue entraba en app01-pre) y el propio job despliegue, más el job de prueba ver-ssh-pre que quedó en servicio/dev. Se borran por la API, guardando antes el historial de despliegues, que es la única prueba de qué versión se desplegó y cuándo, y que desaparece con la carpeta:
JP=https://jenkins.lab/job/servicio/job/pre
curl -s -u admin:$JENKINS_TOKEN "$JP/job/despliegue/api/json?tree=builds[number,result,timestamp,actions[parameters[name,value]]]" > historial.json
curl -X POST -u admin:$JENKINS_TOKEN "$JP/credentials/store/folder/domain/_/credential/ssh-pre/doDelete"
curl -X POST -u admin:$JENKINS_TOKEN "$JP/doDelete"
Lo que no se toca. Igual de importante es lo que no se toca, y el acta lo dice con su motivo: el Multibranch ci, git-ro y registry-cred son de la carpeta servicio y los usa el pipeline, que sigue construyendo; el token tofu de terraform@pve es el de la herramienta, no el del entorno, y es el que acaba de hacer el tofu destroy. En Gitea no hay nada exclusivo de pre, porque el webhook es del repositorio entero. Si lo hubiera, los tokens, las deploy keys y los webhooks se borran por su API (DELETE /api/v1/repos/<dueño>/<repositorio>/keys/<id> y las equivalentes).
Los secretos que viajaron. Además de lo que está en los sistemas, están los secretos que se copiaron fuera de ellos: el .env de app01-pre, con la contraseña de la base de datos, muere con la VM, pero una copia en un portátil o en un workspace de Jenkins no. Esas se piden por escrito y se anotan como "solicitada destrucción a X, confirmada el día Y".
Archivar, no borrar el código¶
Si la baja fuera del servicio entero. Si lo que se diera de baja fuera el servicio entero y no un entorno, el repositorio y el Jenkinsfile tampoco se borrarían: son el registro de cómo se hizo y pueden hacer falta para una auditoría o para resucitar el servicio. En Gitea el repositorio se archiva (Settings, Danger Zone, Archive, o PATCH /api/v1/repos/ops/servicio con {"archived": true}): queda en solo lectura, no acepta pushes ni issues, y se ve en la lista con la marca de archivado. En Jenkins no hay "archivar": el proyecto se deshabilita y se mueve a una carpeta archivo/, porque borrarlo elimina también el historial de builds y sus artefactos. En esta baja no se hace nada de eso: el repositorio servicio, su webhook y el Multibranch siguen, porque dev los usa.
A8.3 Liberar la VM, la red y las credenciales (sesión 37)¶
Objetivo pre sin VM, sin router-pre ni VNets, sin rutas desde gestión y sin carpeta en Jenkins, cada punto verificado desde fuera y guardado en $EV, con la retirada del prevent_destroy en una rama de iac-lab como evidencia de la aprobación. Es la parte irreversible de la baja: al terminar, pre ya no existe en ningún sistema que lo pudiera volver a arrancar.
Antes de empezar
- Entra en el puesto de administración con
ssh -Ay carga. ~/baja-pre.env. - Enciende
jenkins01yagent01, porque hoy tocas Jenkins y la ruta de agent01, y apagaweb01ydb01de dev, como en la A8.1. - A8.2 terminada: pre silenciado, volcado de bloqueo guardado y comprobado, y app01-pre vacía.
- La consola del nodo Proxmox (Shell en la interfaz web) abierta en otra pestaña: solo el paso 2 se hace allí, y el resto, desde el puesto.
- Lo explicado antes: lo que
tofu destroyno se lleva y cómo se libera cada capa fuera de la VM, cuyos bloques se copian aquí tal cual.
Pasos
Vas de lo más grande a lo más pequeño y de lo declarado a lo hecho a mano: primero las VM que conoce OpenTofu, después lo que hay que borrar en el nodo porque OpenTofu no lo conoce, luego los caminos que llevaban a pre desde otras máquinas, y al final las credenciales y los certificados. Cada paso termina con una comprobación desde fuera, y esa comprobación es la evidencia, no la orden que borró.
-
Destruye las VM declaradas desde una rama que deje rastro. Crea en
iac-labuna ramabaja-preque retire elprevent_destroy, como en OpenTofu: la rama, el plan y el estado: su commit citaoperacion/baja/pre/ev/01-aprobacion.txty la rama se sube a Gitea sin fusionarla, para quemainsiga protegiendo y quede constancia de quién levantó la protección y con qué permiso. Lee el plan entero antes de aplicarlo:3 to destroyy solo las tres VM de pre; si sale algo más, sácalo contofu state rm, repite el plan y anótalo en "Incidencias". Las pruebas van a$EV/06-rama-baja.txt(git log -1 --stat),$EV/07-tofu-plan.txty$EV/08-tofu-state.txt(tofu state listvacío).Pista:
grep -rn prevent_destroy modules/ envs/pre/dice en qué fichero está el bloquelifecycle. -
Destruye en el nodo lo que OpenTofu no conoce.
router-prey las cuatro VNets de pre se crearon a mano y han sobrevivido al paso 1. Destrúyelos en la consola del nodo con el bloque de Proxmox: lo que no está en el estado, tal cual, porque su orden es el que Proxmox exige. Desde el puesto, guarda en$EV/09-proxmox.txtla verificación desde fuera: la API de Proxmox sin nada de la franja 200 ni VNetspre*, y sin respuesta alpingla pata de gestión derouter-pre(10.10.0.2) y app01-pre (10.20.2.10). Elpingsolo confirma, porque una VM apagada tampoco contesta: la prueba es la API. -
Cierra los caminos que llevaban a pre. Una ruta hacia una red que ya no existe es un camino abierto para quien reutilice ese rango. Quita la ruta a pre del puesto, de agent01 (
10.10.0.12) y de mon01 (10.10.0.20), tanto la ruta como la unidad de systemd que la vuelve a poner en cada arranque, como en Rutas, DNS y firewall. Guarda en$EV/10-rutas-dns.txtqueip route show 10.20.0.0/16sale vacío en las tres y que el DNS de gestión ya no resuelveapp01.pre.lab.Pista: en el puesto la ruta es una línea de
ruta-lab.service, que lleva también la ruta a dev: se borra esa línea, no la unidad. -
Retira de Jenkins lo que era de pre, salvando antes su historia. Como en Credenciales y tokens: primero el historial de despliegues a
$EV/11-despliegues-pre.json, porque es la única prueba de qué versión estuvo en pre y cuándo, y desaparece con la carpeta; después la credencialssh-pre, la carpetaservicio/prey el job de pruebaver-ssh-predeservicio/dev. Guarda en$EV/12-jenkins.txtque la carpeta de pre da 404 y que enserviciosolo quedanciydev.Pista: la carpeta
dev,ci,git-royregistry-credse quedan: el pipeline sigue construyendo. -
Comprueba que la CA no firmó nada para pre. Un certificado emitido sigue valiendo aunque su máquina ya no exista, así que no basta con saber que web01-pre no tenía nginx: la prueba es el registro de la propia CA. Busca pre en su
index.txty guarda la búsqueda en$EV/13-certificados.txt. Si saliera alguno, revócalo con el bloque de Certificados y guarda también la salida deopenssl verify. -
Cierra la mitad de hoy en el acta. Apunta el resultado y la evidencia de cada fila (
ev/06aev/13), la ramabaja-prey el historial de despliegues en "Qué se conserva", y cualquier desviación en "Incidencias". Haz commit enoperacion.
Comprobación El plan dice 3 to destroy y tofu state list sale vacío; la API de Proxmox no lista nada de la franja 200 ni VNets pre*; los dos ping fallan; ip route show 10.20.0.0/16 vacío en las tres máquinas y dig sin respuesta; la carpeta de pre responde 404 y en servicio solo quedan ci y dev; ev/13 vacío; la rama baja-pre está en Gitea y no en main.
Para practicar Haz commit en operacion del acta al día y de ev/06 a ev/13, y deja la rama baja-pre en iac-lab, sin fusionar.
Si te sobra tiempo Practica la revocación que pre no ha necesitado: emite en ~/ca un certificado de prueba prueba.pre.lab con las dos órdenes de la A3.2 de Despliegue, revócalo con el bloque del apartado de certificados y guarda la salida de openssl verify (certificate revoked) en ev/13b-revocacion.txt. Anota en el acta que era de prueba y que se revocó el mismo día.
Sesión 38 · Copias y logs¶
Las VM de pre ya no existen, pero sus datos sí: siguen en las copias de restic del bucket de MinIO, en las imágenes del registry de gitea01, en el correo de las alertas de Mailpit y, si los hubo, en los logs de Loki. Los cuatro son sistemas compartidos que siguen en uso, así que no se pueden destruir enteros: hay que sacar de ellos lo de pre sin tocar lo demás. Y cambia la escala del problema: ya no basta con que algo desaparezca de un listado, hay que garantizar que no se pueda recuperar, porque borrar casi nunca borra. La teoría sigue ese hilo: primero por qué borrar no borra (SSD, copy-on-write, versionado, snapshots), y de ahí el orden de opciones de más a menos fiable, con el borrado criptográfico primero y, detrás, el borrado en el sistema de copias, que es el que toca en el laboratorio; después los logs externos y el registry, donde borrar una etiqueta tampoco borra las capas; y al final, qué tiene que recuperar photorec en cada escenario de la prueba con la que la hoja demuestra todo lo anterior.
Por qué borrar no borra¶
Lo que hace de verdad rm. rm desvincula el nombre del fichero de sus bloques y marca los bloques como libres. Los datos siguen ahí hasta que otra escritura los pise, y un photorec (herramienta de recuperación de ficheros borrados) los recupera en minutos. Sobre eso se apilan cuatro mecanismos modernos que hacen que ni siquiera sobrescribir garantice nada:
flowchart LR
RM["<b>rm</b><br><small>desvincula el nombre<br>y marca los bloques libres</small>"]:::act
D["<b>Los datos siguen ahí</b><br><small>photorec los recupera en minutos</small>"]:::riesgo
SOB["<b>Sobrescribir</b><br><small>tampoco garantiza nada</small>"]:::riesgo
M1["<b>SSD y wear leveling</b><br><small>escribe en otra celda</small>"]:::infra
M2["<b>Copy-on-write</b><br><small>el bloque viejo sigue vivo</small>"]:::infra
M3["<b>Snapshots y réplicas</b>"]:::infra
M4["<b>Copias de seguridad</b>"]:::infra
CIF(["<b>Borrado criptográfico</b><br><small>si nunca estuvo en claro,<br>basta con destruir la clave</small>"]):::ok
RM --> D --> SOB
SOB --- M1 & M2 & M3 & M4
SOB -.-> CIF
classDef act fill:#ea580c22,stroke:#ea580c,stroke-width:1.5px
classDef pieza fill:#64748b22,stroke:#64748b,stroke-width:1.5px
classDef dato fill:#2563eb22,stroke:#2563eb,stroke-width:1.5px
classDef infra fill:#a1a1aa14,stroke:#a1a1aa,stroke-width:1.5px
classDef ok fill:#16a34a22,stroke:#16a34a,stroke-width:1.5px
classDef riesgo fill:#dc262622,stroke:#dc2626,stroke-width:1.5px
Por eso la lista de opciones empieza por el cifrado y no por sobrescribir: el borrado fiable solo se consigue si los datos nunca estuvieron en claro.
- SSD y wear leveling. El controlador del SSD reparte las escrituras entre celdas para que se desgasten por igual. Cuando
shredescribe tres veces sobre el mismo LBA (la dirección lógica del bloque, lo que el sistema operativo cree que es "el mismo sitio"), el controlador escribe en tres páginas físicas distintas y deja la original marcada como inválida, legible con acceso al chip. TRIM (fstrim,blkdiscard) le dice al controlador que puede borrar esas páginas, pero "puede" no es "debe". - Copy-on-write en ZFS y btrfs. Estos sistemas de ficheros nunca sobrescriben un bloque en su sitio: escriben el nuevo en otro lugar y actualizan los punteros.
shreden ZFS crea tres copias nuevas y deja la original intacta, y si hay un snapshot, el bloque original está referenciado y ni siquiera se marca libre. Los snapshots de LVM-thin, el almacenamiento del nodo del curso, se comportan igual. - Versionado en el almacenamiento de objetos. Un bucket S3 o MinIO con versionado activado no borra nunca:
DELETEcrea un marcador de borrado y la versión anterior sigue ahí. Con Object Lock en modo compliance, ni el administrador puede borrarla antes de que venza la retención. - Snapshots del hipervisor y backups. Un snapshot de "antes de la actualización" contiene el disco completo con los datos recién borrados dentro de la VM. Lo mismo un vzdump de cada domingo.
La consecuencia. El borrado fiable de un soporte que no se controla físicamente (una VM, un bucket en un proveedor, un disco de un servidor alquilado) solo se consigue de una manera: que los datos nunca hayan estado en claro. De ahí el orden de opciones.
Opciones de más a menos fiable¶
Las opciones se ordenan por lo que garantizan, y cuál se puede usar no se decide el día de la baja: depende de cómo se diseñaron las copias.
1. Borrado criptográfico. Si las copias estaban cifradas (restic, borg, gpg, LUKS, S3 con cifrado en servidor), se destruye la clave y los datos se vuelven ruido aunque el fichero exista, aunque haya versiones, aunque haya snapshots. NIST SP 800-88, la guía de borrado de soportes del instituto de estándares de Estados Unidos, lo reconoce como técnica de Purge (borrado que resiste incluso una recuperación de laboratorio) siempre que la clave se haya gestionado bien: nunca almacenada junto a los datos, nunca copiada a sitios fuera de control. En restic, el repositorio tiene una clave maestra que se guarda cifrada con cada contraseña en keys/, y destruir todos los ficheros de keys/ y la contraseña equivale a destruirla:
restic key list # apuntar los IDs en el acta
mc rm --recursive --force --versions lab/<bucket>/keys/ # todas las versiones de las claves
shred -u <fichero de contraseña> # y la contraseña, en todos los sitios donde estaba
restic snapshots # Fatal: wrong password or no key found
En el laboratorio no se puede hacer así, y conviene ver por qué: s3:http://10.10.0.30:29000/backups es un solo repositorio para todo el curso y guarda más copias que las de pre, así que destruir su clave se llevaría también las demás. El borrado criptográfico exige que cada servicio tenga su propio repositorio o su propia clave, y eso se decide al diseñar las copias, no al darlas de baja. Con otras herramientas cambia dónde está la clave, no la idea; los detalles, en Para ampliar:
| Herramienta | Dónde está la clave | Cómo se destruye |
|---|---|---|
| restic | cifrada con cada contraseña, en keys/ del repositorio |
borrar keys/ con sus versiones y la contraseña |
| borg | en el repositorio o en ~/.config/borg/keys/, según el modo |
destruir las copias de la clave y la frase de paso |
| LUKS, el cifrado de discos de Linux | en los keyslots de la cabecera del volumen | cryptsetup luksErase, y destruir la copia de la cabecera |
| KMS de la nube | en el servicio gestor de claves del proveedor | borrado diferido: mientras dura la espera, los datos siguen legibles |
2. Borrado en el sistema de copias. Si el repositorio se conserva porque tiene otros clientes, que es el caso del laboratorio, se borran las copias del servicio y se reescribe. En restic, forget quita los snapshots, pero los datos siguen en el almacén, repartidos en packs, los ficheros en los que restic agrupa los trozos de las copias; --prune reempaqueta y borra los packs que solo usaban esos snapshots, y check confirma después que el repositorio sigue sano para los demás. Es la misma pareja forget y prune de la retención que se describe en restic a fondo, con una diferencia: forget sin política de retención (--keep-*) no borra nada, así que los snapshots se le pasan por identificador, sacados de la búsqueda por etiqueta:
IDS=$(restic snapshots --tag pre --json | jq -r '.[].short_id')
restic forget --prune $IDS
restic snapshots --tag pre # vacío
restic check # no errors were found
Los packs que prune borra desaparecen del almacén de objetos, y ahí está la trampa del punto siguiente.
3. Almacenamiento de objetos. Un bucket con versionado guarda la versión anterior de todo lo que se borra, packs de restic incluidos. Lo primero es saber si lo tiene; si lo tiene, se borran explícitamente las versiones que ya no son actuales y se comprueba que no hay replicación a otro bucket:
mc version info lab/backups # un-versioned, o versioning is enabled
mc rm --recursive --versions --non-current --force lab/backups # solo si tiene versionado
mc replicate ls lab/backups # sin réplicas
El bucket backups se creó con mc mb, sin versionado, así que lo que prune borra desaparece del bucket. Hacerlo versión por versión con la CLI de AWS está en Para ampliar. Si el bucket tiene Object Lock en modo governance, el borrado necesita --bypass-governance-retention y el permiso correspondiente; en modo compliance no hay forma de borrar antes de la fecha de retención, ni siquiera siendo root del proveedor. Eso es lo que se buscaba al activarlo contra el ransomware, y por eso la retención se decide contando con la baja: una retención de 90 días significa que el acta tendrá una fecha de destrucción 90 días posterior, y alguien tiene que volver a verificarla entonces.
4. Sobrescritura y destrucción del soporte. Cuando el soporte es propio y sale de servicio, la referencia que se cita en cualquier pliego es NIST SP 800-88 rev. 1, que ordena los métodos en tres niveles:
- Clear: sobrescritura lógica con las herramientas del sistema operativo; protege frente a una recuperación con software.
- Purge: borrado criptográfico, borrado seguro del propio firmware del disco o desmagnetizado; protege además frente a una recuperación de laboratorio.
- Destroy: trituración o perforación del soporte, con proveedor certificado y certificado de destrucción por número de serie.
El nivel exigible lo fija la normativa que aplique (ENS categoría alta, datos de salud, contratos que lo especifiquen) y el tipo de soporte, y esa elección es lo que hay que justificar en el acta. En una VM de Proxmox no se llega a ninguno de los tres: el disco virtual es un volumen del hipervisor, y sobrescribir desde dentro de la VM no toca el soporte físico. Las herramientas por tipo de soporte están en Para ampliar.
Logs externos¶
Dónde quedan los logs de pre. Los logs del servicio están en tres sitios: en Loki, en los ficheros de cada host y en cualquier sitio al que se reenviaran, como el correo de las alarmas. Los de los hosts (/var/log/, el journal, los JSON del driver de logs de Docker) mueren con las VM. Los otros dos, no.
Loki: el borrado lo hace el compactor. En Loki 3.x el borrado por consulta lo hace el compactor, el mismo componente que aplica la retención de 7 días del Loki del curso, y hay que tenerlo configurado para ello:
compactor:
working_directory: /loki/compactor
retention_enabled: true
delete_request_store: filesystem
limits_config:
deletion_mode: filter-and-delete
retention_period: 168h
retention_stream:
- selector: '{host=~".+-pre"}'
priority: 1
period: 24h
Con eso, la petición de borrado se envía a la API del compactor y se ejecuta en el siguiente ciclo, después del periodo de cancelación (24 horas por defecto, delete_request_cancel_period), durante el cual se puede anular. No es inmediato, y eso se refleja en el acta. Loki pide certificado de cliente, así que las peticiones llevan el del puesto:
T=~/tls-monitorizacion; L=https://10.10.0.20:23100/loki/api/v1/delete
curl -s -X POST -G --cert $T/logcli.crt --key $T/logcli.key $L \
--data-urlencode 'query={host="app01-pre"}' --data-urlencode 'start=1700000000' --data-urlencode "end=$(date +%s)"
curl -s --cert $T/logcli.crt --key $T/logcli.key $L | jq . # received, y después processed
La retention_stream es el cinturón además de los tirantes: aunque llegue algún log rezagado, desaparece en un día. En el laboratorio pre no tuvo Promtail, así que Loki no debería guardar nada suyo: la hoja lo comprueba con logcli series, y solo si aparece algo manda la petición.
Mailpit: borrar solo lo de pre. La bandeja de Mailpit, en cambio, sí guarda correo de pre: cada alerta de pre que se notificó por correo lleva dentro su dirección 10.20.. Se borra de forma selectiva, buscando esos mensajes y borrándolos por su identificador, porque la bandeja es de toda la pila. La API tiene una trampa que conviene conocer antes de usarla: DELETE /api/v1/messages sin lista de identificadores no falla, borra todos los mensajes. Por eso la orden va detrás de la comprobación de que la lista no está vacía:
IDS=$(curl -s -G http://localhost:28025/api/v1/search --data-urlencode 'query=10.20.' | jq -c '[.messages[].ID]')
[ "$IDS" != "[]" ] && curl -s -X DELETE -H 'Content-Type: application/json' -d "{\"IDs\": $IDS}" http://localhost:28025/api/v1/messages
Borrar del registry¶
El rastro que más se olvida. Borrar una imagen de app01-pre no la quita del registry de gitea01, y ese es el rastro que más se olvida: la imagen que corrió en pre, con todo lo que llevaba dentro, sigue ahí para quien la pida. El registry del laboratorio es Distribution (registry:2), que no borra por etiqueta sino por digest, el de Versiones, etiquetas y digests: la huella SHA-256 del manifiesto, el documento que dice qué capas forman la imagen. Y solo borra si arrancó con el borrado habilitado (REGISTRY_STORAGE_DELETE_ENABLED=true; si no, responde 405).
Por digest, y mirando qué más se lleva. Borrar por digest tiene una consecuencia que decide qué se borra: se lleva todas las etiquetas que apuntan a ese manifiesto. El pipeline sube cada imagen con dos, app:<versión> y app:<commit>, así que antes de borrar la etiqueta de un commit que llegó a pre se mira si comparte digest con otra que siga en uso. Si la comparte, no se borra y va al acta como conservada, con su motivo:
R=https://registry.lab:5000/v2/app; U="$REG_USER:$REG_PASS"
ACC='Accept: application/vnd.oci.image.manifest.v1+json,application/vnd.docker.distribution.manifest.v2+json,application/vnd.oci.image.index.v1+json,application/vnd.docker.distribution.manifest.list.v2+json'
digest() { curl -sI -u "$U" -H "$ACC" $R/manifests/$1 | awk -F': ' 'tolower($1)=="docker-content-digest" {print $2}' | tr -d '\r'; }
for T in $(curl -s -u "$U" $R/tags/list | jq -r '.tags[]'); do echo "$(digest $T) $T"; done | sort # etiquetas agrupadas por digest
curl -s -o /dev/null -w '%{http_code}\n' -u "$U" -X DELETE $R/manifests/$(digest <etiqueta>) # 202
La cabecera Accept no es un detalle: sin ella el registry puede devolver el digest de un manifiesto convertido y no el del real, y el DELETE responde 404.
Las capas siguen hasta el recolector. El DELETE desvincula el manifiesto, pero las capas siguen en el disco hasta que se ejecuta el recolector de basura, que recorre el almacén y borra las capas que ya no usa ningún manifiesto. Se lanza con el registry parado:
docker compose -f /opt/registry/compose.yml stop registry
docker compose -f /opt/registry/compose.yml run --rm registry garbage-collect --delete-untagged /etc/docker/registry/config.yml
docker compose -f /opt/registry/compose.yml start registry
Si la imagen se hubiera subido además al registro de paquetes de Gitea o a uno público, habría que borrarla allí también.
Verificar con photorec sobre un volumen de pruebas¶
Por qué una prueba. Para que el "irrecuperable" del acta no sea un acto de fe, la A8.4 lo pone a prueba sobre volúmenes controlados del todo: un fichero imagen con un sistema de ficheros, un volcado reconocible escrito dentro, borrado de distintas formas, y photorec buscando qué queda.
Cómo busca photorec. photorec no necesita el sistema de ficheros para encontrar nada: recorre el soporte bloque a bloque buscando las firmas con las que empieza cada tipo de fichero (la cabecera de un PDF o de un JPEG, o una secuencia reconocible en un texto) y reconstruye lo que encuentra a partir de ahí. Por eso recupera ficheros que ya no tienen nombre, y por eso no encuentra nada en un volumen cifrado, donde no queda ninguna firma reconocible.
Qué tiene que salir en cada escenario. Lo que hay que saber antes de lanzarlo es qué debería salir en cada caso, porque un resultado inesperado casi siempre es un error de la prueba y no un descubrimiento:
rmen ext4. photorec recupera el volcado entero: encuentra la firma de texto y sigue los bloques. Solo si los datos llegaron al disco: sinsyncantes delrm, ext4 puede no haberlos escrito nunca, y la prueba sale falsamente limpia.shreden ext4. ext4 sobrescribe en su sitio, así que photorec no encuentra nada legible. Es el único escenario donde sobrescribir funciona.shredcon un snapshot previo (btrfs, ZFS, LVM-thin). Elshredno ha servido para nada: el snapshot sigue apuntando a los bloques originales y el fichero se lee entero desde él.- Volumen cifrado con las claves destruidas. photorec sobre la imagen en bruto encuentra solo ruido: ninguna firma de fichero.
dd if=/dev/zero of=prueba.img bs=1M count=64 && mkfs.ext4 -q prueba.img
sudo mount -o loop prueba.img /mnt/prueba # se escribe el volcado, sync, se borra y umount
photorec /d rec /cmd prueba.img search # modo no interactivo: deja lo recuperado en rec.1/
grep -rl 'INSERT INTO usuarios' rec.1/
testdisk, y el límite de las dos. testdisk, del mismo paquete, recupera particiones y tablas de ficheros enteras; sirve para demostrar que una tabla de particiones borrada con wipefs sigue siendo reconstruible. Ninguna de las dos herramientas hace nada contra un cifrado con clave destruida, y esa es la conclusión que tiene que aparecer en el informe de la hoja.
A8.4 Copias y logs (sesión 38)¶
Objetivo Las copias de pre fuera del repositorio restic, sus etiquetas fuera del registry, su correo fuera de Mailpit y Loki sin nada suyo, cada punto verificado en $EV, y un informe de photorec con tres escenarios que justifica qué método garantiza la irrecuperabilidad. Lo difícil de hoy no es borrar, sino borrar solo lo de pre en sistemas que siguen en uso, y poder demostrar después que lo borrado no vuelve.
Antes de empezar
- Entra en el puesto de administración con
ssh -A, carga. ~/baja-pre.envy abre el túnel contunel. - A8.3 terminada: las VM de pre no existen. Queda lo que vivía fuera de ellas: las copias en MinIO, las imágenes en el registry de gitea01 y el correo en Mailpit.
- Trabajo que se puede traer hecho:
testdiskybtrfs-progsinstalados en el puesto, para el paso 4. - Lo explicado antes: por qué borrar no borra, con sus opciones y los logs externos, cómo se borra del registry y qué tiene que recuperar photorec.
Pasos
Vas sistema por sistema, de lo que más datos guarda a lo que menos: primero las copias, que son la base de datos de pre entera; después las imágenes, con el código y las dependencias que corrieron en pre; luego el correo y los logs. En los tres el riesgo es el mismo, llevarse lo que no es de pre, y por eso cada paso empieza por listar lo que hay y termina comprobando que lo demás sigue intacto. Los dos últimos pasos son una prueba de laboratorio de lo que deja cada forma de borrar, y la conclusión que va al acta.
-
Saca las copias de pre del repositorio sin dañar las demás. Borra del repositorio restic las copias de pre, y solo esas, como en el punto 2 de Opciones de más a menos fiable, y comprueba que el repositorio sigue sano para el resto (
$EV/14-restic.txt). Mira después si el bucketbackupsguarda versiones antiguas de lo que acabas de borrar y, si las guarda, bórralas ($EV/15-minio.txt): en un bucket con versionado, elpruneno habría borrado nada de verdad.Pista: el repositorio es el de todo el curso. Ni el borrado criptográfico del punto 1 ni un
mc rmsin--non-current: los dos se llevarían también las demás copias. -
Borra del registry las imágenes que corrieron en pre. Son las etiquetas de
ev/00b-inventario.txt. Antes de borrar, guarda la lista de etiquetas agrupadas por digest en$EV/16-registry-antes.txt, como en Borrar del registry: el borrado va por digest y se lleva todas las etiquetas que lo comparten, así que una que comparta digest con otra en uso no se borra y va al acta como conservada, con su motivo. El resultado, en$EV/17-registry.txt. Las capas siguen en disco hasta el recolector de basura: si no lo lanzas hoy, va a "Pendientes con fecha".Pista: las cuatro primeras líneas del bloque del apartado sacan la lista agrupada; cada borrado es la quinta con la etiqueta cambiada.
-
Borra el correo de pre y comprueba Loki. Borra de Mailpit el correo de pre sin tocar el resto de la bandeja (
$EV/18-mailpit.txt) y comprueba que Loki no guarda streams de pre ($EV/19-loki.txt), como en Logs externos. La comprobación de que la lista de identificadores no está vacía no es opcional: con la lista vacía, el DELETE de Mailpit borra todos los mensajes, los de dev incluidos.Pista: la búsqueda de Mailpit devuelve 50 mensajes como mucho si no le pasas
limit. Si Loki guardara algo de pre, su petición de borrado tarda 24 horas: apúntalo en el acta. -
Pon a prueba lo que dice la teoría. Hasta aquí has borrado fiándote de la teoría; ahora compruebas con photorec qué deja cada forma de borrar en los tres escenarios de Verificar con photorec, cada uno en su imagen con un volcado de 500 líneas
INSERT INTO usuarios ...:rmyshreden ext4 (64 MB) yshredcon un snapshot previo en btrfs (256 MB). Antes de lanzar cada uno, apunta qué esperas que salga, y después lo que queda de verdad, en$EV/20-photorec.txt. Sisudo modprobe btrfsfalla, haz el de btrfs en tu portátil si es Linux y anótalo.Pista: sin
syncantes de borrar, ext4 puede no haber escrito nunca el volcado y la prueba sale falsamente limpia. En btrfs el volcado va en un subvolumen, y el snapshot se hace conbtrfs subvolume snapshot -r. -
Escribe la conclusión y pon el acta al día. Redacta
~/operacion/baja/pre/informe-photorec.mdcon una tabla (escenario, método, qué se recuperó, por qué) y la conclusión: qué método garantiza la irrecuperabilidad en un soporte que no controlas. Es la justificación que permite escribir "irrecuperable" en el acta. Pon el acta al día conev/14aev/20y haz commit enoperacion.
Comprobación restic snapshots --tag pre vacío y restic check sin errores; el bucket sin versionado o sin versiones antiguas; cada DELETE del registry con 202 y esas etiquetas fuera de tags/list; Mailpit con 0 correos de pre y el resto de la bandeja intacto; ev/19 vacío; el escenario rm recupera al menos un fichero con el volcado, el shred ninguno, y el snapshot conserva las 500 líneas.
Para practicar Haz commit en operacion del acta al día, ev/14 a ev/20 e informe-photorec.md.
Si te sobra tiempo Libera las capas de las imágenes borradas: en gitea01, el recolector de basura del apartado del registry, con el registry parado, guardando su salida en ev/17b-gc.txt, y quita del acta ese pendiente. Después, un cuarto escenario: una imagen con LUKS, cryptsetup luksErase y photorec sobre la imagen en bruto, que no encuentra ningún fichero.
Sesión 39 · Datos y monitorización¶
Quedan dos familias de rastros, y van al final por motivos distintos. Los datos en una base de datos que sigue en uso son el caso más delicado de la unidad: no se puede destruir ni el soporte ni el servicio, y DELETE engaña todavía más que rm. La monitorización va la última porque es lo que vigila que la baja no rompa nada, y porque el silencio que se puso en la A8.2 solo se puede retirar cuando ya no queda nada de pre capaz de disparar una alarma. La teoría va en ese orden: qué hace de verdad un DELETE en PostgreSQL, cómo se anonimiza cuando hay que conservar estadísticas y qué cambia si la base se elimina entera; y después, en qué orden se desconfigura la monitorización para no disparar alarmas fantasma. La hoja practica lo primero en una base de prueba del PostgreSQL de dev, retira lo segundo de mon01 y cierra el acta, que queda lista para entregar.
Datos confidenciales en la base de datos interna¶
El caso de la empresa. En el laboratorio la base de datos de pre murió con db01-pre el 11 de marzo, y su única copia es el volcado de bloqueo. Pero el caso habitual en una empresa es otro: la base de datos se conserva porque otros servicios la usan, y lo que hay que borrar son las tablas y los datos de un servicio o de un cliente concreto. Pasa sobre todo en las aplicaciones que atienden a varios clientes con una sola base de datos y marcan en una columna de quién es cada fila: cada cliente es un tenant (inquilino), y darlo de baja es borrar sus filas sin tocar las de los demás. Ahí es donde DELETE engaña, y por eso la hoja lo practica en una base de prueba del PostgreSQL de dev, con datos sintéticos de un tenant pre.
Por qué DELETE no borra. PostgreSQL usa MVCC (control de concurrencia por versiones): cada modificación crea una versión nueva de la fila en vez de tocar la vieja, porque una transacción que empezó antes puede necesitar todavía la versión anterior; es lo que permite que una lectura no bloquee a una escritura. Por eso DELETE no borra la fila: marca la tupla (cada una de esas versiones) como muerta y la deja en la página hasta que VACUUM la recicla. Y por lo mismo, un UPDATE deja muerta la versión anterior de la fila, con el dato viejo dentro. VACUUM normal libera el espacio para que la propia tabla lo reutilice, pero ni lo devuelve al sistema operativo ni lo sobrescribe a propósito: los bytes pueden seguir en el fichero de la tabla hasta que otra fila ocupe su sitio, legibles con un editor hexadecimal por cualquiera con acceso al directorio de datos o a una copia del mismo. DROP TABLE desvincula los ficheros de la tabla, con lo que vale lo dicho en Por qué borrar no borra.
-- Antes: ver cuánto hay muerto
SELECT relname, n_live_tup, n_dead_tup FROM pg_stat_user_tables WHERE relname LIKE 'pre_%';
DELETE FROM pre_sesiones; -- deja n_dead_tup = filas
VACUUM FULL pre_sesiones; -- reescribe la tabla en un fichero nuevo y desvincula el viejo
DROP TABLE pre_sesiones;
VACUUM FULL, y dónde más quedan los datos. VACUUM FULL bloquea la tabla en exclusiva mientras la reescribe, lo que en una tabla de 50 GB en producción puede ser una hora sin servicio; cómo se evita ese bloqueo está en Para ampliar. El fichero antiguo se desvincula, no se sobrescribe. Hay tres sitios más donde viven los datos borrados y que la gente olvida: el WAL (el registro en el que PostgreSQL escribe cada cambio antes de aplicarlo, para poder recuperarse de una caída: cada fila insertada o actualizada está en sus segmentos, y en el archivo de WAL si hay PITR, la recuperación a un instante concreto, hasta que la retención los recicle), las réplicas (la réplica aplica el DELETE, pero su copia base inicial y sus propios WAL archivados no) y los volcados sueltos (el pg_dump en /tmp que alguien hizo para probar una migración, el fichero en un portátil, el que dejó el pipeline en un workspace de Jenkins).
Anonimizar cuando hay que conservar estadísticas¶
Cuando no se puede borrar. Si el negocio necesita conservar los pedidos por mes pero no a quién se hicieron, no se borra: se anonimiza.
Seudonimizar no es anonimizar. El RGPD (considerando 26) distingue seudonimización, que sigue siendo dato personal porque con información adicional se puede volver a identificar, de anonimización, que ya no lo es. Un md5(email) sin sal es seudonimización: cualquiera con una lista de correos puede calcular el hash y cruzar. Para que sea anonimización el hash lleva una sal aleatoria que se destruye al acabar, o directamente se sustituye por valores sintéticos que no derivan del original.
Las tablas de la hoja. La A8.5 lo practica sobre tres tablas sintéticas con dos tenants, pre y dev, a partes iguales. Llevan a propósito los tres casos de este apartado: datos personales en columnas (usuarios), un teléfono escrito a mano en texto libre (comentarios) y una tabla exclusiva del servicio (pre_sesiones):
CREATE TABLE usuarios (id int PRIMARY KEY, tenant text, email text, nombre text, telefono text, fecha_nac date);
INSERT INTO usuarios SELECT i, CASE WHEN i % 2 = 0 THEN 'pre' ELSE 'dev' END, 'usuario' || i || '@pre.lab',
'Nombre ' || i, '600' || lpad(i::text, 6, '0'), date '1970-01-01' + i * 7 FROM generate_series(1, 2000) i;
CREATE TABLE comentarios (id serial PRIMARY KEY, usuario_id int, texto text);
INSERT INTO comentarios (usuario_id, texto) SELECT i, 'Llamadme al 600' || lpad(i::text, 6, '0') FROM generate_series(1, 2000) i;
CREATE TABLE pre_sesiones AS SELECT i AS id, md5(i::text) AS token FROM generate_series(1, 5000) i;
El guion. Este es el guion completo sobre esas tablas, con la comprobación al final:
BEGIN;
-- Sal de un solo uso: vive en una tabla temporal y muere con la sesión
CREATE TEMP TABLE sal AS SELECT gen_random_uuid()::text AS s;
UPDATE usuarios u SET
email = encode(sha256(convert_to(u.email || (SELECT s FROM sal), 'UTF8')), 'hex') || '@anon.invalid',
nombre = 'Usuario ' || u.id,
telefono = NULL,
fecha_nac = date_trunc('year', u.fecha_nac) -- se conserva el año para estadísticas de edad
WHERE u.tenant = 'pre';
-- Texto libre: no se puede anonimizar, se borra
DELETE FROM comentarios WHERE usuario_id IN (SELECT id FROM usuarios WHERE tenant = 'pre');
-- Tabla exclusiva del servicio: fuera entera
DROP TABLE pre_sesiones;
COMMIT;
VACUUM (FULL, ANALYZE) usuarios, comentarios;
-- Comprobación: restos tiene que dar 0 y no quedar tuplas muertas
SELECT count(*) AS restos FROM usuarios WHERE tenant = 'pre' AND (email NOT LIKE '%@anon.invalid' OR telefono IS NOT NULL);
SELECT relname, n_live_tup, n_dead_tup FROM pg_stat_user_tables ORDER BY relname;
Cada decisión del guion tiene su motivo:
- La sal vive en una tabla temporal.
gen_random_uuid()genera un valor aleatorio, y la tablaTEMPque lo guarda desaparece al cerrar la sesión, así que nadie podrá usarlo después para recalcular los hashes y cruzarlos. Sin esa sal, el resultado sería seudonimización. - El nombre y el teléfono no se transforman: se sustituyen o se vacían, porque ninguna estadística los necesita.
- La fecha de nacimiento se queda en el año, que basta para las estadísticas de edad; la fecha exacta, junto con otros datos, identifica a una persona, como se explica debajo.
- Los comentarios se borran, porque el texto libre no se puede anonimizar.
- La tabla exclusiva del servicio se borra entera.
VACUUM (FULL, ANALYZE)va después delCOMMIT, porqueVACUUMno puede ejecutarse dentro de una transacción. Y no es opcional: elUPDATEy elDELETEhan dejado muertas las versiones anteriores de las filas, con el correo y el teléfono reales dentro.ANALYZEdeja al día las estadísticas de las tablas, de donde salen las cifras depg_stat_user_tablesque mira la comprobación.
Texto libre y cuasi-identificadores. Los campos de texto libre (comentarios, notas del pedido, motivo de la devolución) no se pueden anonimizar de forma fiable: la gente escribe su teléfono en el comentario. Se borran. Y las columnas cuasi-identificadoras (código postal completo, fecha de nacimiento exacta, sexo) combinadas identifican a una persona aunque no haya nombre, así que se generalizan (año en vez de fecha, provincia en vez de código postal). Ante un encargo de "anonimizar la base de datos" sin más detalle, esa es la conversación que hay que tener antes de escribir el UPDATE.
Si la base de datos se elimina entera¶
El soporte. Se para el servicio, se borra el volumen o el disco y se aplica al soporte lo visto en Opciones de más a menos fiable. Con el volumen cifrado con LUKS, se destruyen las claves y listo. Sin cifrado, la única garantía en una VM es la destrucción del disco virtual más la confianza en que el almacenamiento del hipervisor no lo conserve en snapshots, que conviene verificar antes de destruir con qm listsnapshot <ID>. Es lo que pasó con db01-pre.
Cachés y colas. Quedan las cachés y las colas: Redis guarda en memoria y, si tiene persistencia, en dump.rdb y en el AOF (su fichero de registro de escrituras); FLUSHDB y borrar los ficheros. En RabbitMQ o similar, se purga la cola y se borra el vhost. Un mensaje encolado con datos de un cliente puede sobrevivir años en una cola muerta que nadie consume.
Desconfigurar la monitorización y las alarmas¶
Por qué no basta con dejar de mirar. Un servicio dado de baja que sigue en la monitorización genera alarmas eternas, ocupa retención y, lo peor, puede resucitar por error: alguien ve el target en rojo, piensa que se cayó y lo levanta desde una copia. El orden es el del diagrama de El orden importa: se retiran los targets, se comprueba que no queda nada pendiente y solo entonces se expira el silencio.
Prometheus. pre entró en la monitorización en la A7.4 como un grupo de targets con env: pre dentro del job node de /opt/monitoring/prometheus.yml; desde el 12 de marzo ese job lee además sus targets de dev de un fichero propio, y el grupo de pre sigue escrito a mano. Se borra ese grupo, se valida con promtool y se recarga sin reiniciar (POST /-/reload, que el Prometheus del curso admite). Al retirar un target, sus series dejan de salir en las consultas instantáneas enseguida, pero las muestras siguen en el TSDB hasta que la retención las expulse, 45 días en el laboratorio: count_over_time(up{env="pre"}[7d]) las sigue viendo. Si el acta necesita que desaparezcan ya, lo hace la API de administración, si Prometheus arrancó con --web.enable-admin-api:
curl -X POST -G http://localhost:29090/api/v1/admin/tsdb/delete_series --data-urlencode 'match[]={env="pre"}'
curl -X POST http://localhost:29090/api/v1/admin/tsdb/clean_tombstones
delete_series marca las muestras como borradas; clean_tombstones reescribe los bloques para que desaparezcan del disco. Si no se hace, el acta lleva un pendiente con la fecha en que vence la retención.
Reglas y rutas. Las alarmas del curso son genéricas: HostDown vigila cualquier target del job node, por eso saltó para pre y lo cubrió el silencio. No hay reglas ni rutas propias de pre que retirar, y eso se comprueba con git grep en alerting y preguntando a la API de reglas. Si las hubiera, se retiran con un commit, con el silencio todavía activo para que el vaivén de la recarga no dispare nada.
Alertmanager. Con los targets fuera y ninguna alerta de pre activa ni silenciada (amtool alert query -s env=pre vacío), se expira el silencio con amtool silence expire y se comprueba dos minutos después que no ha vuelto nada.
Grafana. Los dashboards de pre se exportaron a Git en la A8.2, antes de borrarlos, porque un dashboard bien hecho vale para el siguiente servicio y porque en una auditoría pueden pedir "qué se vigilaba". Los compartidos no se tocan: dejan de enseñar pre en cuanto no hay series. En el catálogo de alarmas tampoco se borra nada: la ficha de HostDown sigue valiendo para dev.
A8.5 Datos y monitorización (sesión 39)¶
Objetivo En una base de prueba del PostgreSQL de dev, los datos del tenant pre anonimizados y sus tablas borradas sin tuplas muertas; en la monitorización, ninguna referencia a pre, con el silencio expirado y sin alertas; y el acta de baja completa, que es la práctica evaluable. Con esta hoja la baja termina: lo que al acabar quede sin evidencia o sin fecha es lo que el acta no podrá demostrar.
Antes de empezar
- Entra en el puesto de administración con
ssh -A, carga. ~/baja-pre.envy abre el túnel contunel. - Acceso por SSH con
sudoa db01 de dev (10.10.3.10) y a mon01. db01 es la base de datos de dev, en uso: el trabajo de datos va entero en una base de prueba,bajapre, nunca enservicio. - Lo explicado antes: DELETE y VACUUM FULL, la anonimización y la desconfiguración de la monitorización.
Pasos
Primero los datos y después la monitorización, como en la teoría. En la base de prueba mides, anonimizas y borras, y vuelves a medir, porque la prueba es la diferencia entre las dos medidas; después buscas en db01 las copias que hayan escapado al borrado. En mon01 retiras los targets, compruebas que no queda ninguna otra referencia a pre y solo entonces expiras el silencio, que es lo último que protege a la guardia. El último paso cierra el acta.
-
Prepara la base de prueba y mídela. Crea en db01 la base de prueba
bajaprecon las tablas sintéticas de Anonimizar cuando hay que conservar estadísticas, que son las que espera el guion. Guarda en$EV/21-db-antes.txtla medida de antes: cuántos usuarios tiene el tenantprey las tuplas vivas y muertas de cada tabla. Sin esa foto inicial, el 0 del final no demuestra nada.Pista:
sql() { ssh ops@10.10.3.10 sudo -u postgres psql -X -d bajapre; }manda abajapreel SQL de la entrada estándar, y así nada cae enserviciopor error. Las tuplas están enpg_stat_user_tables. -
Anonimiza y borra con el guion. Guarda en
~/operacion/baja/pre/anonimiza.sqlel guion de Anonimizar cuando hay que conservar estadísticas, que es la evidencia del método, y ejecútalo contrabajapre. Su salida, con la consulta de control y las tuplas muertas, va a$EV/22-db-control.txt: tiene que demostrar que de pre no queda nada legible, ni en las filas vivas ni en las versiones muertas. -
Busca los datos que escaparon al borrado. Una réplica o un volcado suelto guardan los datos tal como estaban antes del guion, como se explica en Datos confidenciales en la base de datos interna. Búscalos en db01 (
$EV/23-db-restos.txt), borra conshred -ulo que sea de pre o debajaprey, al terminar, borra la basebajapre, que ya ha cumplido.Pista: las réplicas salen de
pg_stat_replication, y los volcados, de buscar*.sql*y*.dumpen/tmp,/var/tmp,/homey/var/lib/postgresql. Un volcado de dev es de dev y se queda. -
Retira los targets de pre. En mon01, quita del job
nodede/opt/monitoring/prometheus.ymlel grupo de las tres10.20.x.10:29100conenv: pre, valídalo conpromtool, haz commit solo de ese fichero, ponle la etiquetaentrega-5169-ut8y sube commit y etiqueta: la etiqueta marca el estado demonitoringque se corrige en la práctica evaluable. Recarga Prometheus por el túnel y guarda en$EV/24-prometheus.txtquecount(up{env="pre"})sale vacío.Pista: el resto del job
node, con los targets de dev que lee de su fichero, se queda como está, y el commit lleva soloprometheus.ymlporque Despliegue trabaja en el mismo repositorio. -
Comprueba que no queda nada y solo entonces retira el silencio. Busca referencias a pre en
alerting, en las reglas de Prometheus y en Grafana ($EV/25-reglas-dashboards.txt); si sale algo, retíralo y repite. Solo entonces, sin alertas de pre activas ni silenciadas, expira el silencio y guarda en$EV/26-alertas.txtque dos minutos despuésamt alert query env=presigue vacío. Si lo expiras antes, cualquier resto de pre se convierte en una alarma real para la guardia.Pista:
amt alert query -sincluye las alertas silenciadas, yamt silence query -q env=preda el identificador del silencio. -
Cierra el acta. Apunta el resultado y la evidencia de las filas de hoy (
ev/21aev/26), completa "Qué se destruye y cómo" y los "Pendientes con fecha" (la retención de 45 días del TSDB, el recolector del registry si no se lanzó, la destrucción del volcado de bloqueo), y revisa que ninguna fila de la lista quede sin evidencia o sin fecha: un pendiente sin fecha es algo que nadie hará. Antes de cerrar, retira las dos credenciales que creaste en la A8.1 para hacer la baja, la cuenta de serviciobaja-prede Grafana y tu token de Jenkins, y apúntalo como una fila más: una baja que deja vivas sus propias credenciales incumple lo mismo que comprueba. Commit y push deoperacion, y el enlace del commit en la tarea de la práctica evaluable.
Comprobación restos a 0, sin pre_sesiones y n_dead_tup a 0 en usuarios y comentarios; count(up{env="pre"}) devuelve []; ev/25 vacío; amt alert query env=pre vacío dos minutos después de expirar el silencio; la etiqueta entrega-5169-ut8 en Gitea; la cuenta de servicio baja-pre ya no aparece en Grafana; ninguna fila del acta sin evidencia o sin fecha.
Para practicar Haz commit en operacion del acta completa, anonimiza.sql y ev/21 a ev/26, y deja en monitoring el commit con la etiqueta entrega-5169-ut8.
Si te sobra tiempo Si Prometheus arrancó con --web.enable-admin-api, borra ya las series de pre con el bloque del apartado de monitorización, comprueba que count_over_time(up{env="pre"}[30d]) sale vacío y quita ese pendiente del acta.
Práctica evaluable¶
La práctica evaluable de la unidad no tiene sesión propia ni pide trabajo nuevo: es el acta de baja que abres en la A8.1 y completas en cada hoja, con sus evidencias. Se evalúa el acta, y no cada hoja por separado, porque es lo que queda de una baja cuando ya no hay nada que mirar: la prueba, punto por punto, de que se hizo lo aprobado. Se entrega por Aules antes del examen del 23 de marzo: el enlace al commit de operacion con baja/pre/acta.md, baja/pre/ev/, anonimiza.sql e informe-photorec.md, y la etiqueta entrega-5169-ut8 del repositorio monitoring. El módulo 5166 corrige otra etiqueta del mismo repositorio (entrega-5166-ut7); aquí solo se mira lo que retira esta unidad. Si después de poner la etiqueta corriges algo, la mueves: git tag -f entrega-5169-ut8 && git push -f origin entrega-5169-ut8.
Acta de baja¶
El acta es el entregable de la unidad y el documento que cierra la vida del servicio. Se guarda con las incidencias del servicio (en operacion/, en el gestor de incidencias, en el sistema documental de la empresa) y es lo que se enseña cuando dentro de dos años alguien pregunta "¿qué pasó con los datos de pre?". La plantilla lleva ya los números de evidencia que producen las hojas: cada fila apunta al ev/NN que dejó la hoja correspondiente.
# Acta de baja · entorno pre del servicio del curso
## Identificación
- Servicio: API del curso, entorno pre (VM 210, 220 y 230; router-pre, 200; VNets premgmt, prefront, preback y predata; 10.20.0.0/16)
- Responsable del servicio: <el profesor>
- Ejecuta: <tu usuario>, con revisión de <quien revise>
- Solicitud: enviada el 2027-02-25 (commit <hash>), aprobada el <fecha> (ev/01-aprobacion.txt)
- Ventana: 2027-03-09 a 2027-03-18, con periodo de gracia del 9 al 11 de marzo
- Comunicación: <a quién se avisó y cuándo>
## Qué se conserva
| Elemento | Motivo | Dónde | Hasta | Responsable de destruirlo |
|---|---|---|---|---|
| Volcado completo cifrado (bloqueo, LOPDGDD art. 32) | Posibles reclamaciones | Puesto de administración, fuera de los repositorios; la frase de paso, en el gestor de contraseñas | 2030-03-18 | DPO |
| Repositorio servicio, su webhook y el Multibranch | Dev los sigue usando | Gitea ops/servicio; Jenkins servicio/ci | Indefinido | n/a |
| Rama baja-pre de iac-lab | Evidencia de la aprobación | Gitea ops/iac-lab | Indefinido | n/a |
| Historial de despliegues a pre | Trazabilidad | ev/11-despliegues-pre.json | Indefinido | n/a |
| Repositorio restic | Guarda copias de otros servicios | MinIO, bucket backups | Indefinido | n/a |
## Qué se destruye y cómo
| Elemento | Método | Fecha | Evidencia |
|---|---|---|---|
| VM de pre | tofu destroy desde la rama baja-pre | 2027-03-11 | ev/07-tofu-plan.txt, ev/08-tofu-state.txt |
| Copias de pre en restic | forget por identificador con prune; bucket sin versionado | 2027-03-16 | ev/14-restic.txt, ev/15-minio.txt |
| ... |
## Lista de comprobación
| # | Punto | Verificación | Hoja | Resultado | Evidencia |
|---|---|---|---|---|---|
| 1 | Aprobación escrita | respuesta del profesor en Aules | A8.1 | OK, <fecha> | ev/01-aprobacion.txt |
| 2 | Silencio activo | amt silence query env=pre | A8.2 | OK, id 7c1e… | ev/02-silencio.txt |
| ... |
## Incidencias durante la baja
- 2027-02-25: el inventario encontró router-pre y las cuatro VNets de pre fuera del estado de OpenTofu; se destruyen a mano (A8.3).
## Pendientes con fecha
- 2027-05-02: comprobar que la retención de 45 días ha expulsado las series de pre del TSDB (responsable: ops)
- 2030-03-18: destruir el volcado de bloqueo (responsable: DPO)
## Firmas
Ejecuta: ______ Revisa: ______ Responsable del servicio: ______ Fecha: ______
La sección de pendientes con fecha es la que distingue un acta útil de una que se archiva y se olvida: todo lo que no se pudo destruir en la ventana (una retención, un bloqueo legal, un recolector que no se lanzó) tiene fecha y nombre, y entra en el calendario del equipo.
Enunciado¶
Entrega el acta de baja del entorno pre con todas las secciones de la plantilla y una evidencia por punto. No pide nada que no salga de un paso de las hojas:
-
acta.mdcon todas las secciones y ninguna fila sin evidencia o sin fecha (abierta en la A8.1, pasos 4 y 5; cerrada en la A8.5, paso 6) - Inventario real de pre, antes y después de la A6.8 de Despliegue:
ev/00yev/00b(de la A8.1, paso 3, y de la A8.2, paso 1) - Aprobación escrita:
ev/01(de la A8.1, paso 5) - Silencio, volcado de bloqueo comprobado, despliegue deshabilitado y app01-pre vacía:
ev/02aev/05(de la A8.2, pasos 2 a 5) - Rama que retira
prevent_destroyy destrucción con OpenTofu:ev/06aev/08(de la A8.3, paso 1) -
router-pre, VNets y rutas retirados, verificado desde fuera:ev/09yev/10(de la A8.3, pasos 2 y 3) - Carpeta de pre en Jenkins borrada con su historial guardado, y la CA sin certificados de pre:
ev/11aev/13(de la A8.3, pasos 4 y 5) - Copias de pre fuera de restic y bucket comprobado:
ev/14yev/15(de la A8.4, paso 1) - Etiquetas de pre fuera del registry:
ev/16yev/17(de la A8.4, paso 2) - Correo de pre fuera de Mailpit y Loki sin streams de pre:
ev/18yev/19(de la A8.4, paso 3) - Informe de photorec con tres escenarios y su conclusión:
ev/20einforme-photorec.md(de la A8.4, pasos 4 y 5) - Datos anonimizados y tablas borradas con
VACUUM FULL, con la consulta de control:anonimiza.sqlyev/21aev/23(de la A8.5, pasos 1 a 3) - Monitorización sin referencias a pre y silencio expirado:
ev/24aev/26y la etiquetaentrega-5169-ut8(de la A8.5, pasos 4 y 5)
| Criterio | RA5 | Peso | De dónde sale |
|---|---|---|---|
| Aplicación terminada y todos los recursos de infraestructura liberados y verificados | a | 30 % | A8.1, A8.2, A8.3 y el registry de la A8.4 |
| Copias de seguridad y logs externos eliminados de forma irrecuperable | b | 30 % | A8.4 |
| Datos confidenciales borrados en la base de datos interna | c | 15 % | A8.5, pasos 1 a 3 |
| Conectividad y referencias en monitorización y alarmas eliminadas | d | 25 % | las rutas de la A8.3 y la A8.5, pasos 4 y 5 |
Errores frecuentes en el laboratorio¶
El despliegue vuelve a levantar pre. Entre el 9 y el 11 de marzo alguien lanza el pipeline con RUN_DEPLOY y ENV=pre: el job de despliegue de la carpeta de pre seguía habilitado, lleva la imagen a app01-pre y la levanta, y las evidencias de la A8.2 dejan de ser ciertas sin que nadie lo diga. Orden correcto: deshabilitar ese job antes de parar el servicio, no después (paso 4 de la A8.2). Desde el 11 la carpeta ya no existe y el mismo lanzamiento falla en la etapa Deploy: es lo esperado, y el resto del pipeline sigue construyendo.
tofu plan -destroy falla con Instance cannot be destroyed. Es prevent_destroy, que hace su trabajo. No se salta con -target ni borrando el recurso del estado: se retira en la rama baja-pre, con referencia a la aprobación (paso 1 de la A8.3), y se repite el plan desde esa rama. Si el error sigue, el git switch no se hizo y el plan se está leyendo desde main.
tofu plan -destroy quiere borrar algo que no es de pre. Un recurso compartido (una red, un usuario de API) entró en el estado del entorno. Diagnóstico: el plan lo lista. Solución: tofu state rm de ese recurso antes de destruir, y mover su definición a un estado propio para el futuro.
pvesh delete de una VNet responde que está en uso. Queda una VM conectada: casi siempre router-pre, porque el bucle de las VNets se lanzó antes del qm destroy 200, o una VM de pre que el estado de OpenTofu no conocía. qm config de cada VM de la franja 200 dice cuál.
Después de reiniciar mon01 vuelve la ruta a 10.20.0.0/16. Se borró la ruta con ip route del, pero no la unidad ruta-pre.service, que la vuelve a poner en cada arranque. Van las dos cosas (paso 3 de la A8.3).
docker compose down -v no borra un volumen. Estaba declarado external: true. docker volume ls lo muestra y docker volume rm lo quita. La lección es que down -v solo gestiona lo que Compose creó.
gpg falla con Inappropriate ioctl for device al cifrar el volcado. No sabe en qué terminal pedir la frase de paso: falta GPG_TTY. Está en ~/baja-pre.env; si la shell se abrió sin cargarlo, export GPG_TTY=$(tty) y se repite.
Alertmanager avisa de HostDown de pre aunque hay un silencio. El silencio se puso con env=pre, pero el grupo de targets de pre lleva otro valor (por ejemplo env="pre-lab") porque en la A7.4 se escribió distinto. Diagnóstico: amt alert query -s muestra las etiquetas reales. Se corrige el matcher del silencio.
restic forget --tag pre --prune no borra nada. Sin política de retención (--keep-*), forget no quita ningún snapshot y lo dice: no policy was specified, no snapshots will be removed. Los snapshots se le pasan por identificador (paso 1 de la A8.4).
El registry responde 405 al DELETE del manifiesto. No arrancó con REGISTRY_STORAGE_DELETE_ENABLED=true. En gitea01 se añade REGISTRY_STORAGE_DELETE_ENABLED: "true" al environment de /opt/registry/compose.yml, se recrea con docker compose up -d, se repite el borrado y se anota en "Incidencias". Y si responde 404 con un digest recién leído, es que se pidió el manifiesto sin la cabecera Accept correcta y el registry devolvió el digest de un manifiesto convertido, no del real.
El DELETE de Mailpit vacía la bandeja entera. Se lanzó con la lista de identificadores vacía, y DELETE /api/v1/messages sin identificadores borra todos los mensajes. Por eso la orden va detrás de la comprobación de que la lista no está vacía. Lo borrado no vuelve, y va a "Incidencias".
La petición de borrado de Loki queda en "received" para siempre. El compactor no tiene retention_enabled: true o el deletion_mode sigue en disabled. Los logs de Loki lo dicen: delete requests are disabled. Se corrige la configuración, se reinicia Loki y la petición se procesa en el siguiente ciclo.
count_over_time(up{env="pre"}[7d]) sigue devolviendo datos después de quitar los targets. Es lo esperado: quitar el target detiene el scrape, no borra el TSDB. O se espera a la retención o se usan delete_series y clean_tombstones. Si delete_series responde admin APIs disabled, Prometheus no arrancó con --web.enable-admin-api.
VACUUM FULL falla por falta de espacio. Necesita el doble del tamaño de la tabla porque escribe la copia entera antes de borrar la original. Con df -h se ve. Solución en el laboratorio: borrar primero las tablas grandes con DROP y hacer VACUUM FULL solo en las que se conservan.
Los enlaces para ampliar y los apartados que van más allá de lo que se hace en clase están en Para ampliar.
Recursos
Presentación y evaluación¶
De qué va la asignatura¶
Desplegar un servicio es un día; mantenerlo son años. Esta asignatura se ocupa de todo lo que pasa después de que un servicio llega a producción: vigilarlo, avisar cuando se degrada, probarlo, protegerlo, actualizarlo, copiarlo y, al final de su vida, retirarlo con limpieza. Es el trabajo diario de quien opera una plataforma de contenedores.
La asignatura hermana, Despliegue de plataformas de contenedores (módulo 5166), se cursa el mismo año. Las dos comparten laboratorio y el mismo servicio de referencia: la plataforma se monta allí y se mantiene aquí, y la pila de monitorización de mon01 se monta aquí desde octubre. Qué asignatura explica a fondo cada herramienta que usan las dos está en la tabla de herramientas compartidas.
Resultados de aprendizaje¶
El currículo define cinco resultados de aprendizaje. Las tablas siguientes los recogen en lenguaje llano e indican en qué unidad se trabaja cada criterio; el texto oficial está en el real decreto del curso de especialización.
RA1. Monitoriza el sistema de contenedores: integra los datos, gestiona las alarmas y protege las comunicaciones.
| CE | Qué se pide | Unidad |
|---|---|---|
| a | Integrar métricas, logs y eventos del contenedor en el sistema de monitorización y verificar comunicación, integridad y almacenamiento | UT1 |
| b | Definir umbrales sobre contadores y cadenas en eventos a partir de la documentación | UT2 |
| c | Agregar y correlar contadores para generar indicadores nuevos | UT2 |
| d | Integrar los eventos en el gestor de alarmas, con activación y recuperación probadas | UT2 |
| e | Categorizar las alarmas por fecha, origen, criticidad y servicio, notificarlas y tratarlas | UT2 |
| f | Auditar las comunicaciones de la monitorización: solo protocolos y puertos necesarios | UT3 |
| g | Configurar y documentar las reglas de protección entre el contenedor y la monitorización | UT3 |
RA2. Define indicadores del servicio y lo prueba.
| CE | Qué se pide | Unidad |
|---|---|---|
| a | Documentar las métricas con contador, referencia y categoría (capacidad, rendimiento, calidad) | UT4 |
| b | Implementar indicadores con fórmulas y umbrales por tipo | UT4 |
| c | Elaborar el catálogo de alarmas con origen, fallo, impacto y pasos de resolución | UT4 |
| d | Ejecutar pruebas funcionales, de calidad de servicio, rendimiento, seguridad y estrés | UT4 |
| e | Documentar las pruebas con evidencias, indicadores y registros | UT4 |
| f | Revisar periódicamente los indicadores frente a sus umbrales | UT4 |
RA3. Explota los registros del sistema: logs, accesos, fallos y rendimiento.
| CE | Qué se pide | Unidad |
|---|---|---|
| a | Revisar los archivos de registro de forma periódica y reportar fallos | UT5 (empresa) |
| b | Monitorizar los accesos y detectar intentos de fuerza bruta | UT5 (empresa) |
| c | Analizar fallos y reinicios a partir de crashdumps y registros de error | UT5 (empresa) |
| d | Evaluar el rendimiento del equipo frente a una línea base | UT5 (empresa) |
RA4. Mantiene el sistema: copias de seguridad, actualizaciones y vulnerabilidades.
| CE | Qué se pide | Unidad |
|---|---|---|
| a | Programar copias de seguridad y verificar su ejecución | UT6 (empresa) |
| b | Exportar las copias a un medio externo cumpliendo las políticas de almacenamiento, rotación y limpieza | UT6 (empresa) |
| c | Restaurar periódicamente en una plataforma de pruebas | UT6 (empresa) |
| d | Mantener el software de base: seguir versiones e instalarlas | UT7 |
| e | Examinar vulnerabilidades en código, binarios y librerías y proponer soluciones | UT7 |
| f | Actualizar desde repositorio con verificación de datos | UT7 |
| g | Analizar y resolver los problemas de actualización en plazo y reportarlos a desarrollo | UT7 |
| h | Comprobar funcionamiento e integridad en entorno previo y producción | UT7 |
| i | Documentar y trazar las actualizaciones en el repositorio de incidencias | UT7 |
RA5. Termina el contenedor de forma segura.
| CE | Qué se pide | Unidad |
|---|---|---|
| a | Terminar la aplicación y liberar todos los recursos de infraestructura | UT8 |
| b | Eliminar copias de seguridad y logs externos de forma irrecuperable | UT8 |
| c | Borrar los datos confidenciales de la base de datos interna | UT8 |
| d | Eliminar la conectividad y las referencias en la monitorización y las alarmas | UT8 |
Cómo se evalúa¶
| Evaluación | Qué entra | Peso |
|---|---|---|
| Prácticas evaluables UT1, UT2 y UT3 | Informe de observabilidad, repositorio de alarmas e informe de seguridad de la monitorización | 40 % de la 1ª evaluación |
| Examen 1ª evaluación (10 dic 2026) | UT1 a UT3, prueba práctica en el laboratorio | 60 % de la 1ª evaluación |
| Prácticas evaluables UT4, UT7 y UT8 | Dossier de operación; informe de vulnerabilidades y actualización; acta de baja, que se entrega antes del examen | 40 % de la 2ª evaluación |
| Examen 2ª evaluación (23 mar 2027) | UT4, UT7 y UT8, prueba práctica en el laboratorio | 60 % de la 2ª evaluación |
| Formación en empresa | UT5 y UT6 con ficha de evidencias firmada por el tutor | Según el plan de FE del centro |
Los exámenes son las sesiones 19 (10 de diciembre de 2026) y 40 (23 de marzo de 2027), integradas en el calendario de cada evaluación: la teoría y los exámenes terminan antes de Pascua. Cada práctica evaluable lleva su tabla de criterios y pesos al final de la unidad. La de la UT8 es la única sin sesión propia: el acta de baja se va construyendo en las hojas A8.1 a A8.5 y se entrega por Aules antes del examen del 23 de marzo. Una entrega fuera de plazo sin causa justificada se corrige sobre el 50 %.
Lo que no se admite
Informes sin evidencias (capturas o salidas de comandos con fecha), repositorios con secretos en el historial, datos reales de la empresa sin anonimizar en las fichas de evidencias, y "copias de seguridad" que nunca se han restaurado. En todos los casos la práctica vuelve al alumno sin nota hasta que lo corrija.
Herramientas de la asignatura¶
| Capa | Herramienta | Alternativas que se mencionan |
|---|---|---|
| Métricas | Prometheus, cAdvisor, node_exporter, postgres_exporter | Zabbix, Datadog |
| Logs y eventos | Loki, Promtail, driver json-file | Fluent Bit, Vector, ELK / OpenSearch, Graylog |
| Visualización y alarmas | Grafana, Alertmanager | Grafana Alerting, PagerDuty |
| Incidencias | Issues de Gitea | GitLab, Zammad, GLPI, Jira |
| Pruebas | pytest, k6, OWASP ZAP | newman, JMeter, Locust, Nikto |
| Accesos | fail2ban | CrowdSec |
| Copias | restic, pg_dump, systemd timers, MinIO | borg, Proxmox Backup Server |
| Versiones y vulnerabilidades | Renovate, Syft, Trivy, Grype | Dependabot, Docker Scout, osv-scanner |
| Borrado seguro | restic forget, VACUUM FULL, shred, photorec | nwipe, borrado criptográfico con LUKS |
Herramientas compartidas con Despliegue¶
Varias herramientas salen en las dos asignaturas. Cada una se explica a fondo en una sola sesión, la de la tabla; cuando vuelve a aparecer en la otra asignatura, el apartado abre con un repaso que enlaza a esa sesión y dedica sus minutos solo a lo nuevo.
| Herramienta | Se explica a fondo en | Se instala en |
|---|---|---|
| nmap y tcpdump | Despliegue, sesión 10 (6 nov) | El puesto de administración y la VM que indique cada hoja |
| nftables en el propio host | Sesión 17 (1 dic) | Cada host que se cierra |
| Trivy | Sesión 25 (21 ene) | El puesto de administración, en la A4.6 |
| gitleaks y pre-commit | Despliegue, sesión 28 (27 ene) | El puesto de administración |
| Seguridad de la pila de monitorización | UT3 (26 nov a 3 dic) | mon01 |
| PromQL, reglas, Alertmanager, Grafana y KPI | UT1, UT2 y UT4 | mon01 |
La UT7 de Despliegue trabaja en marzo sobre esta misma pila de mon01 y en el mismo repositorio monitoring, con ficheros propios: repasa lo que aquí se da en la UT1, la UT2, la UT3 y la UT4 y evalúa lo que aquí no se toca (métricas del orquestador protegidas, targets generados con Ansible y secretos fuera del repositorio).
Sobre el material¶
Este material lo ha escrito Víctor Sellés para la asignatura, con el apoyo de Claude (el asistente de IA de Anthropic) en la redacción, ampliación y revisión, a partir de apuntes propios y de la planificación del curso. La revisión final es del autor, igual que los errores que queden, que se corrigen durante el curso.
Metodología¶
Cada sesión de 110 minutos tiene una parte corta de explicación (entre 10 y 30 minutos, según la sesión) y una parte larga de laboratorio sobre el contenedor de referencia. El calendario dice, sesión a sesión, qué se explica y qué se practica, y de qué tipo es cada sesión: teoría y práctica, solo práctica, práctica evaluable, examen o proyecto. Cada unidad está ordenada por sesiones: un par de párrafos sobre lo que se busca y, después, sesión a sesión, los apartados de teoría que se explican ese día seguidos de la hoja de práctica, con objetivo, requisitos previos, pasos, comprobación y un último apartado, «Para practicar». Las hojas no se entregan: lo que se entrega es la práctica evaluable que cierra cada unidad. Como el servicio es el mismo durante todo el curso, lo que se configura en una unidad sigue funcionando en la siguiente: las alarmas de la UT2 usan las métricas de la UT1, las pruebas de la UT4 verifican la actualización de la UT7 y la UT8 da de baja el entorno pre que la UT7 actualizó, con todo lo que dejó en la monitorización, las copias y el resto del laboratorio. Por eso conviene no dejar nada a medias.
Todo lo que se hace se documenta en el momento. Al final de cada unidad esa documentación es la práctica evaluable.
Después de Pascua solo queda la sesión del 8 de abril, que se dedica a un proyecto conjunto con el resto de asignaturas del curso y se plantea aparte.
El laboratorio¶
Esta asignatura no monta un laboratorio nuevo: trabaja sobre el que construye Despliegue de plataformas de contenedores, cuyas convenciones (redes, direcciones, IDs de VM, nombres compartidos y matriz de reglas del cortafuegos) mandan también aquí. Esta página añade solo lo propio: qué hay disponible en cada momento, qué agente corre en cada máquina, cómo se llega a la pila de monitorización cuando queda cerrada, lo que se añade en cada unidad y las convenciones de Mantenimiento.
Qué hay en cada momento¶
Las dos asignaturas van en paralelo, y esta empieza a vigilar contenedores el 1 de octubre, cuando Despliegue todavía está instalando Proxmox. Por eso el laboratorio llega por fases, y cada hoja trabaja solo con lo que existe en su fecha:
| Desde | Qué hay para esta asignatura | Quién lo pone |
|---|---|---|
| 1 oct | Ninguna VM: el servicio y la pila mínima corren con Docker en el puesto del alumno | A1.1 de esta asignatura |
| 15 oct | app01 y mon01 en la red del aula, sin subredes ni cortafuegos | A1.3 de Despliegue (14 oct); se ocupan en la A1.4 |
| 17 nov | Gitea provisional en mon01 (gitea.lab:23001) y remoto de los repositorios |
A2.6 de esta asignatura |
| 27 nov | VPC dev detrás de OPNsense: app01 en back, mon01 en gestión, la base de datos en db01 y el proxy en web01 | A3.3 de Despliegue |
| 3 dic | La pila cerrada: Prometheus, Alertmanager y Mailpit solo por túnel, Loki con certificado de cliente | Práctica evaluable de la UT3 de esta asignatura |
| 18 dic | MinIO en la pila de mon01, destino de las copias | A5.2 de Despliegue |
| 8 ene | Las VM de pre; el servicio llega a ellas el 15 | A5.3 y A5.5 de Despliegue |
| 3 a 12 feb | jenkins01, agent01 y gitea01; Gitea deja mon01 el 12 | A6.1, A6.3 y A6.4 de Despliegue |
| 11 feb | pre en Mantenimiento: monitorización, copia y actualización | A7.4 de esta asignatura |
| 19 feb | El registry y la imagen registry.lab:5000/app |
A6.6 de Despliegue |
| 11 mar | pre destruido | A8.3 de esta asignatura |
El entorno definitivo (desde diciembre; jenkins01 y gitea01 en febrero)¶
El entorno completo de la figura no existe hasta diciembre, y jenkins01 y gitea01 no llegan hasta febrero. Hasta entonces se trabaja con el entorno provisional del apartado siguiente.
flowchart TB
subgraph pve["Proxmox VE · VPC dev"]
subgraph mgmt["gestión 10.10.0.0/24"]
fw["<b>OPNsense</b><br><small>.1</small>"]:::infra
mon["<b>mon01</b><br><small>.20 · Prometheus · Alertmanager<br>Grafana · Loki</small>"]:::act
jenkins["<b>jenkins01</b><br><small>.10 · desde febrero</small>"]:::dato
git["<b>gitea01 + registry</b><br><small>.11 · desde el 12 de febrero</small>"]:::dato
end
subgraph front["front 10.10.1.0/24"]
web["<b>web01</b><br><small>nginx</small>"]:::pieza
end
subgraph back["back 10.10.2.0/24"]
app["<b>app01</b><br><small>API en Docker Compose<br>+ cAdvisor, Promtail</small>"]:::pieza
end
subgraph data["data 10.10.3.0/24"]
db["<b>db01</b><br><small>PostgreSQL + postgres_exporter</small>"]:::pieza
end
end
web -->|28080| app
app -->|5432| db
mon -.->|scrape| web & app & db & jenkins
app -.->|logs 23100| mon
classDef act fill:#ea580c22,stroke:#ea580c,stroke-width:1.5px
classDef pieza fill:#64748b22,stroke:#64748b,stroke-width:1.5px
classDef dato fill:#2563eb22,stroke:#2563eb,stroke-width:1.5px
classDef infra fill:#a1a1aa14,stroke:#a1a1aa,stroke-width:1.5px
classDef ok fill:#16a34a22,stroke:#16a34a,stroke-width:1.5px
classDef riesgo fill:#dc262622,stroke:#dc2626,stroke-width:1.5px
El laboratorio lo construye Despliegue; en naranja, mon01, la única máquina que amplía esta asignatura: su pila de monitorización se monta aquí desde el 1 de octubre.
Entorno provisional (octubre y noviembre)¶
El entorno provisional tiene dos fases, y ninguna necesita más de lo que Despliegue termina el 14 de octubre (Proxmox y la plantilla cloud-init):
Del 1 al 13 de octubre (sesiones 1, 2 y 3): no existe ninguna VM del laboratorio. Todo corre con Docker en el puesto del alumno, en localhost: el servicio del curso entero (nginx, API y PostgreSQL en un solo compose, en /opt/servicio) y, en otro compose, la pila mínima de monitorización (Prometheus, Alertmanager y Grafana, en /opt/monitoring). Los agentes de la UT1 van en un tercer compose, en /opt/agentes. Los tres comparten la red de Docker obs y se llaman entre ellos por el nombre del servicio de compose, sin ninguna IP.
Desde el 15 de octubre (sesión 4): dos VM. Son las de la tabla, que Despliegue clona de la plantilla 9000 el día anterior con Docker Engine, node_exporter y la clave SSH del puesto del alumno ya dentro. Se entra desde el puesto con ssh ops@app01 y ssh ops@mon01, con los nombres en el /etc/hosts del puesto y de mon01, como indica la A1.4.
| VM | Red | Qué lleva | Nace en | Muere en |
|---|---|---|---|---|
| app01 (120) | vmbr0, la red del aula, IP por DHCP; devback (10.10.2.10) desde el 27 de noviembre |
El servicio del curso en Docker Compose (nginx, API y PostgreSQL hasta el 27 de noviembre; desde entonces solo la API) y los agentes de la UT1 | A1.3 de Despliegue (14 de octubre); se ocupa en la A1.4 (15 de octubre) | No se destruye: llega al final del curso |
| mon01 (103) | vmbr0, IP por DHCP; devmgmt (10.10.0.20) desde el 27 de noviembre |
Prometheus, Alertmanager y Grafana con el compose de la A1.1 de esta asignatura; Loki desde la A1.4, Mailpit y la Gitea provisional desde la UT2, MinIO desde el 18 de diciembre | A1.3 de Despliegue (14 de octubre), con 3 GB de RAM | No se destruye: llega al final del curso |
Sin firewall y sin subredes: todo está en la red del aula. Es suficiente para las UT1 y UT2, que van de sacar datos del contenedor y convertirlos en alarmas. La seguridad de esas comunicaciones se trata en la UT3 precisamente cuando hay dónde aplicarla.
web01 y db01 no entran en juego hasta el 27 de noviembre, cuando Despliegue separa las zonas. Hasta entonces no hay que buscarlas ni inventarlas: la base de datos es el servicio db del compose del servicio y el proxy es el servicio nginx del mismo compose, los dos en el mismo host que la API. Las subredes 10.10.x.x existen desde el 30 de octubre (con router-dev como .1 desde el 4 de noviembre) y el cortafuegos desde el 20 de noviembre, con OPNsense, pero app01 y mon01 siguen en la red del aula hasta el traslado.
Migración al entorno definitivo. La hace la A3.3 de Despliegue el 27 de noviembre: pasa app01 a devback (10.10.2.10) y mon01 a devmgmt (10.10.0.20), instala en db01 PostgreSQL con la base servicio y el usuario app y, al lado, su postgres_exporter; pone DB_HOST=10.10.3.10 en el .env del servicio, quita db y nginx de su compose y deja como proxy el nginx de web01. Los datos de prueba de octubre y noviembre no se trasladan: la API crea el esquema vacío en db01. Los de Prometheus y Loki sí se conservan, porque mon01 cambia de red, no de disco. La UT3 de esta asignatura se reparte alrededor de ese día: la A3.1 (26 de noviembre) audita con las VM todavía en el aula y la A3.2 (1 de diciembre) trabaja ya en la VPC. Desde el traslado cambian tres cosas:
- A app01, db01, web01 y mon01 se entra con salto por el puesto de administración:
ssh -J ops@<IP de aula de admin01> ops@10.10.2.10, y lo mismo con la IP de cada una. El laboratorio de Despliegue trae un~/.ssh/configde ejemplo con el que bastassh app01. - Los nombres los resuelve el DNS de OPNsense (
mon01.lab,grafana.lab,gitea.laby los<host>.dev.lab), no el/etc/hosts. - PostgreSQL de dev es un servicio del sistema en db01 y se maneja con
systemctl; el compose del servicio de app01 solo tieneapp.
El entorno pre¶
El entorno pre son tres VM que crea la A5.3 de Despliegue el 8 de enero con OpenTofu (web01-pre, app01-pre y db01-pre, en 10.20.1.10, 10.20.2.10 y 10.20.3.10) más router-pre, que viene de la A2.5. El servicio lo despliega en ellas la A5.5 el 15 de enero: en app01-pre, el compose del servicio en /opt/servicio solo con app; en db01-pre, PostgreSQL con la base servicio; web01-pre se queda como clon limpio, sin nginx ni certificado. Qué hay en cada VM, cómo llega a pre la red de gestión y en qué orden se retira está en la ficha de pre de Despliegue. En esta asignatura se usa así:
- La UT4 no lo toca. Sus pruebas van contra dev (
https://api.dev.lab): la A4.4 es del 14 de enero y pre no sirve la API hasta el 15. - La UT7 lo estrena en la A7.4 (11 de febrero), que da de alta en Prometheus un job
nodeconenv="pre"para las tres VM (node_exporter viene de la plantilla), añade a mon01 la ruta hacia 10.20.0.0/16 (ruta-pre.service), hace la copia de su base de datos y lleva a app01-pre la versión nueva de la aplicación. - La UT8 lo da de baja del 25 de febrero al 11 de marzo, en el orden que fija la ficha: la A8.1 inventaría sin tocar nada, la A8.2 (9 de marzo) para el servicio y libera los contenedores de app01-pre y la A8.3 (11 de marzo) destruye las VM, router-pre y sus redes.
En las hojas, pre se escribe con IP: los nombres <host>.pre.lab solo los resuelve router-pre. Y pre no llega a la red de gestión, solo le responde: lo que va a pre se empuja desde gestión (ficheros con Ansible, imágenes con docker save ... | ssh ... docker load).
Cómo se llega a la pila desde el 3 de diciembre¶
Hasta el traslado del 27 de noviembre la pila se abre con la dirección del aula de mon01 (http://mon01:29090); desde ese día ya hay que pasar por el puesto de administración, con los túneles de abajo; hasta el 3 de diciembre Grafana sigue en el 23000 de mon01, y se abre añadiendo -L 23000:127.0.0.1:23000 al primero y http://localhost:23000. La práctica evaluable de la UT3 la cierra, y desde el 3 de diciembre solo se publican hacia la red de gestión tres servicios; el resto escucha únicamente en el 127.0.0.1 de mon01:
| Servicio | Dónde escucha | Cómo se llega |
|---|---|---|
| Grafana | 10.10.0.20:443, detrás de nginx |
Túnel al 443 desde el puesto del aula; https://grafana.lab/ directamente desde la red de gestión |
| Loki | 10.10.0.20:23100, tras el mismo nginx y con certificado de cliente |
Promtail de app01 y logcli en el puesto de administración; Grafana y Prometheus le hablan por dentro |
| Gitea provisional | 10.10.0.20:23001, hasta el 12 de febrero |
La red de gestión y app01, cada uno por su fila de la matriz de reglas |
| Prometheus, Alertmanager y Mailpit | 127.0.0.1 de mon01, en el 29090, el 29093 y el 28025 |
Túnel SSH, o una shell en mon01 |
Desde el puesto del aula se abren dos túneles con el puesto de administración como salto (<admin01> es su IP del aula; con el ~/.ssh/config de Despliegue, -J ops@<admin01> ops@10.10.0.20 se queda en mon01). El primero deja además una shell abierta en mon01:
ssh -L 29090:127.0.0.1:29090 -L 29093:127.0.0.1:29093 -L 28025:127.0.0.1:28025 -J ops@<admin01> ops@10.10.0.20
ssh -L 28443:10.10.0.20:443 ops@<admin01>
Con los dos abiertos, Prometheus está en http://localhost:29090, Alertmanager en http://localhost:29093 y Mailpit en http://localhost:28025: en el puesto, cada túnel escucha en el mismo puerto de la franja que el servicio. Grafana está en https://grafana.lab:28443, con la línea 127.0.0.1 grafana.lab en el fichero hosts del puesto del aula y la CA del curso (ca.crt) importada en el navegador: el nombre tiene que ser grafana.lab porque es el que lleva el certificado. El 28443 del puesto lo usan también los túneles de OPNsense y de Jenkins de Despliegue, así que de los tres solo puede estar abierto uno a la vez.
En la shell de mon01 valen las direcciones locales, y amtool se usa desde el contenedor de Alertmanager, que ya lo trae:
cd /opt/monitoring
curl -s 'http://localhost:29090/api/v1/query?query=up' | jq .
curl -s -X POST http://localhost:29090/-/reload
docker compose exec alertmanager amtool --alertmanager.url=http://localhost:29093 alert query
curl -s http://localhost:28025/api/v1/messages | jq .total # mensajes en Mailpit
logcli se lanza desde el puesto de administración con el certificado de cliente logcli, que emite la propia práctica evaluable de la UT3 y se guarda con los demás en ~/tls-monitorizacion/:
export LOKI_ADDR=https://10.10.0.20:23100
export LOKI_CA_CERT_PATH=$HOME/tls-monitorizacion/ca.crt
export LOKI_CLIENT_CERT_PATH=$HOME/tls-monitorizacion/logcli.crt
export LOKI_CLIENT_KEY_PATH=$HOME/tls-monitorizacion/logcli.key
logcli labels
Desde el 3 de diciembre no se vuelve a usar http://10.10.0.20:29090, :29093, :23000 ni :28025, ni Loki sin certificado: publicarlos de nuevo para ahorrarse el túnel es deshacer lo que evalúa la UT3.
Agentes por máquina¶
Todas las VM que salen de la plantilla traen node_exporter de fábrica (paquete prometheus-node-exporter, argumentos en /etc/default/prometheus-node-exporter), porque la A1.3 de Despliegue lo mete en la imagen con el mismo virt-customize que el agente de QEMU y deja escrito en esos argumentos que escuche en el 29100. Lo demás lo monta esta asignatura:
| Máquina | Agente | Puerto | Quién lo lee | Qué hoja lo instala o configura |
|---|---|---|---|---|
| Todas las de la plantilla | node_exporter | 29100 | Prometheus, job node; el de app01, cifrado y con el usuario prometheus, en el job node-tls |
Plantilla de la A1.3 de Despliegue. Job para app01 y mon01 en la A1.4 (15 oct), db01 en la A3.2 (1 dic), que además cifra el de app01, y las tres de pre, con env="pre", en la A7.4 (11 feb) |
| app01 | cAdvisor | 28081 | Prometheus, job cadvisor (dentro de la red obs, cadvisor:28081) |
A1.2 (6 oct) en el puesto; a app01 en la A1.4 |
| app01 | /metrics de la API |
29102 | Prometheus, job app |
A1.3 (13 oct) |
| app01 | Promtail | 29080 | Envía a Loki; Prometheus lee sus métricas en el job promtail |
A1.4 (15 oct); certificado de cliente para Loki en la práctica evaluable de la UT3 (3 dic) |
| app01 | docker-events | ninguno | Promtail, que lo manda a Loki con service="docker-events" |
A1.5 (20 oct) |
| db01 | postgres_exporter | 29187 | Prometheus, job postgres |
En el compose del servicio desde la A1.3; desde el 27 nov, paquete prometheus-postgres-exporter en db01 con el usuario monitor, que instala la A3.3 de Despliegue; su target pasa a db01 en la A3.2 |
web01, jenkins01, agent01 y gitea01 traen node_exporter igual. De ellas, la UT7 de Despliegue da de alta en Prometheus jenkins01, en marzo; web01, agent01 y gitea01 se quedan fuera de la monitorización del curso. No se instalan en el curso ni nginx-prometheus-exporter (9113), porque lo que interesa de nginx sale de su log de acceso en JSON, ni blackbox_exporter: la disponibilidad sale de up y pg_up.
Lo que se añade en cada unidad¶
| Unidad | Qué se instala o configura | Dónde |
|---|---|---|
| UT1 | cAdvisor, postgres_exporter, endpoint /metrics en la API, Promtail, docker-events, Loki, job node de Prometheus, límites del driver de logs |
Puesto del alumno hasta el 13 de octubre; app01 y mon01 desde el 15 |
| UT2 | Recording rules y alarmas (con HostDown), Alertmanager con rutas y receptores, Mailpit, receptor webhook de incidencias, Gitea provisional y remoto de los repositorios |
mon01 |
| UT3 | nftables por host (tabla inet fw), filas 11 a 20 de la matriz de OPNsense, certificados de la CA del curso, TLS y basic auth en node_exporter, mTLS entre Promtail y Loki, Grafana detrás de nginx, Prometheus, Alertmanager y Mailpit solo en 127.0.0.1 |
app01, db01, mon01, OPNsense y el puesto de administración |
| UT4 | pytest (newman como alternativa), k6, OWASP ZAP (contenedor), Trivy, recording rules de los indicadores, panel de KPI, catálogo de alarmas, retención de Prometheus a 45 días | Puesto de administración y mon01, siempre contra dev |
| UT7 | Renovate, Syft, Trivy, Grype, restic sobre el MinIO del laboratorio, job node de pre, etapa de escaneo en el pipeline |
Puesto de administración; mon01 (Renovate y el job de pre); la Gitea de mon01 hasta el 12 de febrero y gitea01 después; app01-pre; jenkins01 |
| UT8 | Inventario de baja, tofu destroy de pre, restic forget de las copias de pre, cliente S3 para las versiones del bucket, photorec/testdisk, herramientas de borrado |
Puesto de administración, hosts de pre, mon01 y el registry de gitea01 |
| UT5 y UT6 (empresa) | fail2ban; restic sobre el destino de copias de la empresa | Lo que indique el tutor |
Recursos por puesto¶
El presupuesto de memoria de las dos asignaturas está en el laboratorio de Despliegue y manda también aquí: 16 GB para la VM de Proxmox en el peor momento, con pre incluido hasta el 11 de marzo, y qué se apaga en cada tramo cuando hay menos. Lo que esta asignatura pone en esa cuenta es mon01, con sus 3 GB: Prometheus, Alertmanager, Grafana, Loki y Mailpit, la Gitea provisional hasta el 12 de febrero y MinIO desde el 18 de diciembre. app01 y mon01 se quedan encendidas desde el 15 de octubre hasta el final del curso.
Las pruebas de carga de la UT4 se lanzan desde el puesto de administración, que no es uno de los hosts vigilados, para no falsear sus métricas. Y Loki con retención de 7 días ocupa de 2 a 5 GB de disco por servicio vigilado, y eso es disco, no memoria.
Convenciones¶
La tabla única del laboratorio (VNets y subredes, IP de cada máquina, IDs de VM, dominios, puertos, nombres compartidos y matriz de reglas) está en la página del laboratorio de Despliegue y manda también aquí. Lo que sigue es solo lo propio de Mantenimiento.
| Cosa | Convención |
|---|---|
| Puertos de monitorización | Los de la franja del curso, el de fábrica más 20000, y cada servicio escucha en ese mismo número dentro de su contenedor: 29100 node_exporter, 28081 cAdvisor (el 28080 es de la API), 29187 postgres_exporter, 29102 /metrics de la app, 29080 Promtail, 23100 Loki, 29090 Prometheus, 29093 Alertmanager, 28025 la web de Mailpit (su SMTP sigue en el 1025, solo dentro del compose), 23000 Grafana (desde el 3 de diciembre, solo por el 443 de su nginx), 23001 la Gitea provisional de mon01 hasta el 12 de febrero (el 23000 es de Grafana) y 25000 el receptor de incidencias en el 127.0.0.1 de mon01 |
| Etiquetas de las series | El scrape pone host y service; Promtail pone host, service, container y level, este en minúsculas (info, warning, error). Las consultas LogQL seleccionan por service y level: container lleva el nombre largo (servicio-app-1) y cambia si el servicio se escala o se renombra |
| Etiquetas de alerta | severity (critical, warning, info), team (ops, dev), service (app, db, web, y node en HostDown), env (dev, pre), origen (prometheus, loki, cadvisor, docker-events) |
| Alarmas | Las diez de la A2.4: siete en Prometheus (AppHighErrorRate, AppHighLatency, AppDown, HostDown, AppMemoryHigh, AppRestarting, DbConnectionsHigh) y tres en el ruler de Loki (AppLogErrorsBurst, AppDbTimeout, ContainerOOM); la A4.3 añade PgDown, AppLowAvailability, AppCpuSaturated, DbDiskLow y DiskWillFillIn7d. Son las únicas del laboratorio: un nombre que no esté en esta lista no existe en alerting |
| Nombres de recording rules | nivel:métrica:operación, por ejemplo app:errors:ratio5m |
| Órdenes sobre el servicio | docker compose -f /opt/servicio/compose.yml <acción> <servicio> (proyecto servicio, en dev y en pre), nunca el nombre del contenedor escrito a mano. Las que provocan fallos se lanzan desde el puesto, por SSH: ssh ops@<app01> docker compose -f /opt/servicio/compose.yml stop <servicio> |
| Evidencias de pruebas | tests/evidence/<versión>/ en el repositorio del servicio |
| Retención | Prometheus, 45 días, porque la disponibilidad mensual necesita más de 30 (lo comprueba la A4.2); Loki, 7 días |
| Copias | Un solo repositorio restic en MinIO (s3:http://10.10.0.30:29000/backups), con la contraseña en /etc/restic/pass del puesto de administración, permisos 600. El bucket backups y la credencial restic nacen con el propio MinIO el 18 de diciembre, en la A5.2 de Despliegue; el restic init se hace una sola vez en todo el curso, en el paso 3 de la A7.4. Las copias de pre llevan --tag pre, y la UT8 las borra por esa etiqueta, no el repositorio |
| Imagen del servicio | app:<versión>, construida en app01 desde api/Dockerfile, hasta el 19 de febrero: se escanea en el puesto de administración con ssh ops@10.10.2.10 docker save app:<versión> > app.tar y trivy image --input app.tar. Desde ese día, registry.lab:5000/app:<versión> y registry.lab:5000/app:<commit corto>, que publica el pipeline de Despliegue (su IMAGE). Siempre con versión concreta, y con digest fijado en pre |
Las direcciones que más se usan en esta asignatura, por si hace falta tenerlas a mano: mon01 es la 10.10.0.20 en gestión, el puesto de administración la 10.10.0.50 y MinIO la 10.10.0.30; web01 es la 10.10.1.10 en front, app01 la 10.10.2.10 en back y db01 la 10.10.3.10 en data; en pre, el tercer octeto es el mismo sobre 10.20. Ni web01, ni app01, ni db01 tienen pata de gestión: Prometheus llega a sus exporters atravesando el cortafuegos, que es justo lo que se abre en la UT3.
Repositorios que hay que crear o ampliar¶
| Repositorio | Nace en | Copia de trabajo | Qué le añade esta asignatura |
|---|---|---|---|
servicio |
A1.1 (1 oct), con el código que se da en Aules | app01, en /opt/servicio; en la UT4, un clon en el puesto de administración para las pruebas |
Instrumentación (UT1), pruebas y evidencias (UT4), versiones fijadas, Renovate y etapa de escaneo (UT7). Despliegue le añade el Jenkinsfile en su UT6 |
monitoring |
A1.1 (1 oct), con la pila mínima que se da en Aules | mon01, en /opt/monitoring |
Loki y Promtail (UT1), TLS y cierre de mon01 (UT3). En marzo la UT7 de Despliegue le añade ficheros propios (targets/ut7-*.yml, rules/ut7-*.yml) sin tocar los de aquí |
alerting |
A2.1 (29 oct) | mon01, en /opt/alerting |
Recording rules, alarmas, Alertmanager y receptor de incidencias (UT2); alarmas nuevas y runbooks enlazados (UT4) |
operacion |
A4.1 (15 dic) | Puesto de administración | Fichas de métricas, indicadores, catálogo de alarmas, registros de revisión (UT4); plan y acta de baja (UT8) |
Ninguno muere en el curso: la UT8 retira pre, no los repositorios, y el pipeline sigue construyendo desde servicio. Los tres primeros reciben remoto en la A2.6 (17 de noviembre), en la Gitea provisional de mon01, como http://gitea.lab:23001/ops/<repo>.git, y operacion nace ya con él. En la A6.4 de Despliegue (12 de febrero) Gitea pasa a gitea01 y basta un git remote set-url origin https://gitea.lab/ops/<repo>.git por repositorio. Desde el 27 de noviembre app01 llega a Gitea por una fila permanente de la matriz de reglas (hacia 10.10.0.20:23001 hasta el 12 de febrero y hacia 10.10.0.11:443 después). En marzo las dos asignaturas entregan del mismo monitoring etiquetas distintas: entrega-5169-ut8 y entrega-5166-ut7.
Ninguno puede contener un secreto: las claves y contraseñas van en carpetas que .gitignore excluye (secrets/, tls/), y desde la sesión 28 de Despliegue (27 de enero) el hook de gitleaks lo comprueba antes de cada commit.
Cuando algo se rompe¶
El orden de recuperación es el mismo que en la asignatura de despliegue: snapshot de la VM, recrear desde plantilla o con OpenTofu, snapshot de la VM de Proxmox, reinstalar. A la lista de cosas que guardar fuera del equipo se añaden los ficheros de reglas y de Alertmanager (están en Git, pero conviene comprobar que el último push es reciente), el JSON de los dashboards y la contraseña del repositorio restic. Sin esa contraseña las copias no sirven, y por eso la UT8 no la destruye: el repositorio es uno solo y guarda también lo que se conserva, así que las copias de pre se borran por etiqueta.
Una regla concreta de esta asignatura: antes de cada actividad que provoque fallos a propósito (matar la BD, llenar la memoria, cortar la red), conviene hacer un snapshot. El objetivo es observar el fallo, no pasar la sesión reinstalando.
Chuleta de comandos¶
Lo que se teclea una y otra vez en esta asignatura. Los comandos de Proxmox, OpenTofu, Ansible, Jenkins y certificados están en la chuleta de la asignatura de despliegue.
Tres reglas para que los comandos funcionen tal cual durante todo el curso:
- El servicio se maneja siempre por su compose (
/opt/servicio/compose.yml, proyectoservicio), nunca por el nombre del contenedor escrito a mano. - Desde el 3 de diciembre Prometheus, Alertmanager y Mailpit solo escuchan en el
127.0.0.1de mon01: sus bloques se lanzan desde una shell en mon01, la que deja abierta el túnel de Cómo se llega a la pila, ylogclidesde el puesto de administración con su certificado de cliente. app01,db01ymon01son los nombres cortos de las VM: hasta el 27 de noviembre, los del/etc/hostsde la A1.4; desde el traslado, sus IP de la VPC (10.10.2.10, 10.10.3.10 y 10.10.0.20) o sus nombresapp01.dev.lab,db01.dev.labymon01.lab.
Docker: observar un contenedor¶
C=/opt/servicio/compose.yml # el compose del servicio, en app01 y en app01-pre
APP=$(docker compose -f $C ps -q app) # el contenedor de la API, sin escribir su nombre
docker stats --no-stream
docker compose -f $C logs -f --since 10m app
docker compose -f $C logs --no-log-prefix app | grep '"level": "error"' | tail -50
docker events --filter type=container --format '{{json .}}'
docker inspect $APP --format '{{.RestartCount}} {{.State.ExitCode}} {{.State.OOMKilled}}'
docker inspect $APP --format '{{.State.Health.Status}}'
docker compose -f $C ps && docker compose -f $C top
docker system df -v
ssh ops@app01 docker compose -f /opt/servicio/compose.yml restart app # desde el puesto, sin entrar
# Límites del driver de logs (/etc/docker/daemon.json)
# { "log-driver": "json-file", "log-opts": { "max-size": "50m", "max-file": "5" } }
sudo systemctl restart docker
# cgroups v2 del contenedor
cat /sys/fs/cgroup/system.slice/docker-$(docker inspect -f '{{.Id}}' $APP).scope/memory.current
Prometheus y PromQL¶
# En una shell de mon01
cd /opt/monitoring && docker compose exec prometheus promtool check config /etc/prometheus/prometheus.yml
cd /opt/alerting/prometheus # las reglas viven en el repositorio alerting
docker run --rm -v "$PWD:/w" --entrypoint promtool prom/prometheus check rules /w/rules.yml /w/alerts.yml
docker run --rm -v "$PWD:/w" -w /w/tests --entrypoint promtool prom/prometheus test rules alerts_test.yml
curl -s -X POST http://localhost:29090/-/reload
curl -s 'http://localhost:29090/api/v1/query?query=up' | jq .
curl -s http://app01:29102/metrics | grep -E '^app_'
sum(rate(app_http_requests_total{status=~"5.."}[5m])) / sum(rate(app_http_requests_total[5m]))
histogram_quantile(0.95, sum by (le) (rate(app_http_request_duration_seconds_bucket[5m])))
container_memory_working_set_bytes{service="app"} / container_spec_memory_limit_bytes{service="app"}
changes(process_start_time_seconds{job="app"}[1h]) # es un gauge: changes, no increase
sum(pg_stat_activity_count) / sum(pg_settings_max_connections)
predict_linear(node_filesystem_avail_bytes{mountpoint="/data"}[7d], 30*86400) < 0 # /data: el disco de datos de db01
absent(up{job="app"})
count({job="app"})
Loki y LogQL¶
# Desde el 3 de diciembre, en el puesto de administración y con certificado; antes basta LOKI_ADDR=http://mon01:23100
export LOKI_ADDR=https://10.10.0.20:23100
export LOKI_CA_CERT_PATH=$HOME/tls-monitorizacion/ca.crt
export LOKI_CLIENT_CERT_PATH=$HOME/tls-monitorizacion/logcli.crt LOKI_CLIENT_KEY_PATH=$HOME/tls-monitorizacion/logcli.key
logcli labels
logcli query '{service="app"}' --since=1h --limit=50
logcli query 'sum(count_over_time({service="app", level="error"} [5m]))'
# Borrar streams de un intervalo (compactor con retention_enabled y delete_request_store)
curl -G -X POST --cacert $LOKI_CA_CERT_PATH --cert $LOKI_CLIENT_CERT_PATH --key $LOKI_CLIENT_KEY_PATH \
$LOKI_ADDR/loki/api/v1/delete --data-urlencode 'query={service="app"}' --data-urlencode start=<unix> --data-urlencode end=<unix>
# En mon01: las métricas del propio Promtail de app01
curl -s http://app01:29080/metrics | grep promtail_dropped
{service="app", level="error"}
{service="app", level="error"} | json | msg=~"timeout.*"
sum(count_over_time({service="app", level="error"} [5m]))
count_over_time({service="docker-events", action="oom"} [10m]) > 0
sum(rate({service="nginx"} | json | status >= 500 [1m]))
Alertmanager¶
# En una shell de mon01, en /opt/monitoring: amtool viene dentro del contenedor de Alertmanager
AM="docker compose exec alertmanager amtool --alertmanager.url=http://localhost:29093"
docker compose exec alertmanager amtool check-config /etc/alertmanager/alertmanager.yml
$AM alert query
$AM alert add alertname=Prueba severity=warning service=app
$AM silence add alertname=AppRestarting --duration=2h --comment "mantenimiento"
$AM silence query
$AM silence expire <id>
curl -s http://localhost:29093/api/v2/alerts | jq '.[] | {alertname: .labels.alertname, state: .status.state}'
curl -s http://localhost:28025/api/v1/messages | jq .total # correos que ha recibido Mailpit
Red y firewall del host¶
ss -tlnup
docker ps --format '{{.Names}} {{.Ports}}'
nmap -sS -p- -T4 app01
nmap -sV -p 29100,28081,29102,29080 app01
tcpdump -i ens18 -n port 23100
# nftables del host: el fichero completo es el de la UT3 (tabla inet fw, conjunto mon_ports)
sudo nft -c -f /etc/nftables.conf && sudo systemctl reload nftables
sudo nft list table inet fw
journalctl -k -g 'mon-denegado|mon-docker-denegado' # quién ha chocado con el cortafuegos del host
# TLS
openssl s_client -connect 10.10.2.10:29100 -CAfile ca.crt </dev/null 2>/dev/null | openssl x509 -noout -subject -dates
curl -s --cacert ca.crt -u prometheus https://10.10.2.10:29100/metrics | head
htpasswd -nBC 10 prometheus # hash bcrypt para web.config.file
Pruebas¶
# En el puesto de administración, contra dev: la UT4 no toca pre
export REQUESTS_CA_BUNDLE=~/ca/ca.crt # requests no lee el almacén del sistema: sin esto, SSLError
# Funcionales
API_URL=https://api.dev.lab python3 -m pytest tests/functional -v --junitxml=reports/pytest.xml
docker run --rm -v "$PWD/tests/postman:/etc/newman" -v ~/ca/ca.crt:/ca.crt:ro -e NODE_EXTRA_CA_CERTS=/ca.crt \
postman/newman run api-curso.json --env-var base=https://api.dev.lab --reporters cli,junit
# Carga y estrés
k6 run tests/k6/carga.js
k6 run --vus 50 --duration 5m tests/k6/carga.js
k6 run --summary-export=tests/evidence/1.4.2/k6-resumen.json tests/k6/carga.js
k6 run --out experimental-prometheus-rw tests/k6/carga.js # K6_PROMETHEUS_RW_SERVER_URL por un túnel al 29090 de mon01
# Seguridad: ZAP en mon01, Trivy en el puesto de administración
docker run --rm --network host -v "$PWD/zap:/zap/wrk:rw" ghcr.io/zaproxy/zaproxy:stable zap-baseline.py -t https://api.dev.lab -r zap.html -I # -I: los WARN no dan error
ssh ops@10.10.2.10 docker save app:1.4.2 > /tmp/app.tar # hasta el 19 de febrero la imagen es local, en app01
trivy image --input /tmp/app.tar --severity HIGH,CRITICAL --ignore-unfixed
Accesos y fail2ban¶
journalctl -u ssh --since yesterday -p err
# -u ssh, no _COMM=sshd: en Debian 13 los accesos los registra sshd-session
journalctl -u ssh | grep "Failed password" | awk '{print $(NF-3)}' | sort | uniq -c | sort -rn | head
grep "Failed password" /var/log/auth.log | wc -l
sudo fail2ban-client status
sudo fail2ban-client status sshd
sudo fail2ban-client set sshd unbanip 203.0.113.7
sudo fail2ban-regex /var/log/nginx/error.log /etc/fail2ban/filter.d/nginx-http-auth.conf
Fallos y rendimiento¶
dmesg -T | grep -i -E "oom|killed process"
coredumpctl list && coredumpctl info
cat /proc/sys/kernel/core_pattern
py-spy dump --pid $(pgrep -f gunicorn | head -1)
top -o %MEM; mpstat 1 5; vmstat 1 5; iostat -xz 1 5
ss -s; iftop -i ens18; pidstat 1 5
ps aux --sort=-%mem | head
Copias con restic¶
# En el puesto de administración: el repositorio único del laboratorio
set -a; . /etc/restic/env; set +a # las cuatro variables de la A7.4, que son estas:
# RESTIC_REPOSITORY=s3:http://10.10.0.30:29000/backups RESTIC_PASSWORD_FILE=/etc/restic/pass
# AWS_ACCESS_KEY_ID=restic AWS_SECRET_ACCESS_KEY=... # sin estas dos, restic ni siquiera abre el repositorio
restic init # una sola vez en todo el curso: paso 3 de la A7.4
ssh ops@10.20.3.10 'sudo -u postgres pg_dump -Fc servicio' | restic backup --stdin --stdin-filename servicio-pre.dump --tag pre
restic snapshots --tag pre
restic forget --keep-daily 7 --keep-weekly 4 --keep-monthly 6 --prune
restic check --read-data-subset=5%
restic dump --tag pre latest /servicio-pre.dump > /tmp/servicio-pre.dump # sacar un fichero sin restaurar todo
restic restore latest --target /tmp/restore
restic mount /mnt/restic
pg_restore -l /tmp/servicio-pre.dump | head # el índice del volcado: si sale, el fichero se lee
systemctl list-timers | grep backup # en la empresa (UT6), la copia la lanza un timer
Versiones y vulnerabilidades¶
docker images --digests
docker manifest inspect postgres:17.6 | jq '.config.digest'
docker compose -f /opt/servicio/compose.yml config | grep image:
# La imagen del servicio: app:<versión> en app01 hasta el 19 de febrero; registry.lab:5000/app:<versión> desde entonces
ssh ops@10.10.2.10 docker save app:1.4.2 > /tmp/app.tar
syft docker-archive:/tmp/app.tar -o cyclonedx-json > sbom.json
grype sbom:sbom.json --only-fixed
trivy image --input /tmp/app.tar --format json -o trivy.json
trivy image --severity CRITICAL --exit-code 1 registry.lab:5000/app:1.4.2 # puerta en el pipeline, desde el 19 de febrero
trivy fs . && trivy config .
pip-audit -r api/requirements.txt
# Integridad de datos antes y después, en db01 (o db01-pre)
sudo -u postgres psql -d servicio -c "SELECT relname, n_live_tup FROM pg_stat_user_tables ORDER BY 1;"
sudo -u postgres psql -d servicio -c "SELECT md5(string_agg(t::text, '' ORDER BY id)) FROM items t;"
Terminación segura¶
# pre, siempre con IP y desde el puesto de administración; las evidencias, a una ruta del puesto
ssh ops@10.20.2.10 sudo docker compose -f /opt/servicio/compose.yml down -v --rmi all # A8.2: liberar app01-pre
ssh ops@10.20.2.10 'sudo docker volume ls; sudo docker network ls; sudo docker images' # comprobar que no queda nada
curl --cacert ~/ca/ca.crt -u <usuario> -X DELETE https://registry.lab:5000/v2/app/manifests/<digest> # solo lo desplegado en pre
# En gitea01, con el registry parado: el recolector de basura libera las capas
docker compose -f /opt/registry/compose.yml stop registry
docker compose -f /opt/registry/compose.yml run --rm registry garbage-collect --delete-untagged /etc/docker/registry/config.yml
docker compose -f /opt/registry/compose.yml start registry
cd ~/iac-lab/envs/pre && tofu plan -destroy -out=destroy.plan && tofu apply destroy.plan # A8.3, desde la rama baja-pre sin prevent_destroy
qm stop 200 && qm destroy 200 --purge # router-pre, en el nodo: no es de OpenTofu, viene de la A2.5
restic forget --prune $(restic snapshots --tag pre --json | jq -r '.[].short_id') # sin --keep-*, forget solo borra por identificador
restic snapshots --tag pre # vacío
mc version info lab/backups # un-versioned: lo que prune borra no se queda
mc rm --recursive --versions --non-current --force lab/backups # solo si el bucket tiene versionado
shred -n 3 -z -v /dev/sdb # solo discos magnéticos dedicados
blkdiscard /dev/nvme1n1 # SSD completo
# En una base que se conserva (en la A8.5, bajapre en db01). Dos -c y no uno: VACUUM no puede ir dentro de la transacción que abre un -c con varias órdenes
sudo -u postgres psql -d bajapre -c "UPDATE <tabla> SET <columna> = md5(<columna> || '<sal>');" -c "VACUUM FULL <tabla>;"
sudo -u postgres psql -d bajapre -c "DROP TABLE <tabla>;"
grep pre ~/ca/index.txt # vacío: en pre no se emitió ningún certificado
curl -sG http://localhost:29090/api/v1/query --data-urlencode 'query=count({env="pre"})' # en mon01; [] si no queda nada
sudo photorec /d /tmp/recuperado /cmd /dev/sdb1 search
Glosario¶
Términos de esta asignatura, en una o dos frases, con la unidad donde se tratan a fondo. Toda sigla que se repite en las unidades tiene aquí su entrada, aunque venga de Despliegue; los conceptos de infraestructura que solo se mencionan de paso (hipervisor, DMZ, IaC, pipeline...) están en el glosario de la asignatura de despliegue.
Acta de baja · Documento que cierra la retirada de un servicio: quién autorizó, lista de comprobación verificada con evidencias, qué se conservó y qué claves se destruyeron. UT8.
Alertmanager · Componente que recibe las alertas de Prometheus o Loki, las agrupa, las enruta según etiquetas, las silencia o inhibe y las envía a correo, chat o webhook. UT2.
Anonimizar · Transformar datos personales de forma que no se pueda identificar a la persona, conservando su utilidad estadística. UT8.
API · Interfaz de programación: el conjunto de rutas HTTP con las que un programa pide datos a otro o se los envía. La del servicio del curso está escrita en Python con Flask y escucha en el 28080; Prometheus, Loki y Alertmanager tienen también la suya. UT1.
Append-only · Modo de acceso al destino de las copias en el que el cliente puede añadir datos pero no borrar ni sobrescribir los que ya hay. Es lo que impide que un ransomware que entre por el host copiado destruya también las copias. UT6.
Basic auth · Autenticación HTTP con usuario y contraseña en cada petición. Es lo que se pone en los exporters para que solo respondan a Prometheus, y siempre sobre TLS, porque la contraseña viaja en cada petición. UT3.
bcrypt · Hash lento pensado para guardar contraseñas. El fichero --web.config.file de los exporters guarda así la contraseña de basic auth, nunca en claro. UT3.
BD · Base de datos. En el servicio del curso, PostgreSQL con la base servicio. UT4, UT5.
Borrado criptográfico · Destruir la clave con la que se cifraron unos datos, de modo que quedan irrecuperables aunque los ficheros sigan existiendo. UT8.
Bucket · Contenedor con nombre de un almacenamiento de objetos, donde se guardan los ficheros. En el curso, backups, donde vive el repositorio restic. UT6.
Burn rate · Velocidad a la que se consume el presupuesto de error. Un burn rate de 2 gasta en medio mes lo previsto para uno. UT4.
CA · Autoridad de certificación: quien firma los certificados y en quien confían los clientes. La del curso, Lab 5166 CA, se crea con openssl ca en la A3.2 de Despliegue, vive en ~/ca/ del puesto de administración y firma todo lo que se cifra en las dos asignaturas. UT3.
cAdvisor · Exporter que lee los cgroups y expone métricas de CPU, memoria, red y disco por contenedor. UT1.
Cardinalidad · Número de series distintas que genera una métrica según los valores que toman sus etiquetas. Una etiqueta que cambia en cada despliegue o en cada petición la dispara. UT1.
Catálogo de alarmas · Colección de fichas, una por alarma, con origen, posible fallo, impacto, pasos de análisis, resolución y escalado. UT4.
cgroups · Mecanismo del kernel Linux que limita y contabiliza los recursos de un grupo de procesos. Es de donde salen las métricas de recursos de un contenedor. UT1.
Chunk · Bloque comprimido de líneas de log en Loki. Loki solo indexa las etiquetas del stream y el tiempo; el texto se busca por fuerza bruta dentro de los chunks que las etiquetas seleccionan. UT1.
Clear, Purge y Destroy · Los tres niveles de borrado de soportes de la guía NIST SP 800-88: sobrescritura lógica, borrado que resiste una recuperación de laboratorio y destrucción física del soporte. UT8.
CN · Common Name: el campo clásico de nombre de un certificado. Los clientes modernos lo ignoran y miran el SAN; en el curso sirve para distinguir los certificados en index.txt. UT3.
Copy-on-write · Forma de escribir de ZFS y btrfs que nunca sobrescribe un bloque en su sitio, sino que escribe el nuevo en otro lugar y actualiza los punteros; por eso sobrescribir un fichero no elimina el contenido anterior. UT8.
Core dump · Volcado de la memoria de un proceso en el momento de fallar, que se analiza con un depurador. UT5.
Counter, gauge, histogram, summary · Los cuatro tipos de métrica de Prometheus: acumulador que solo sube, valor que sube y baja, distribución en cubos, percentiles calculados en el cliente. UT1.
CPD · Centro de proceso de datos: la sala o el edificio donde una organización tiene sus servidores. UT2, UT6.
CPU · Procesador. Su uso se lee por host con node_exporter y por contenedor con cAdvisor. UT1, UT5.
CRL · Lista de certificados revocados que publica una autoridad de certificación. Un certificado revocado sigue valiendo para quien no la consulta, así que publicarla es parte de dar de baja un servicio. UT8.
CrowdSec · Alternativa a fail2ban que comparte listas de IP maliciosas entre instalaciones y bloquea mediante bouncers. UT5.
CVE · Identificador público de una vulnerabilidad conocida (CVE-2025-12345). UT7.
CVSS · Puntuación de 0 a 10 de la gravedad de una vulnerabilidad, con un vector que describe cómo se explota. UT7.
Dead man's switch · Alerta que salta cuando deja de llegar una señal periódica; sirve para detectar que una copia de seguridad no se ha ejecutado. UT6.
Deduplicación · Técnica de restic y borg que trocea los ficheros en bloques de tamaño variable y solo guarda los que no estaban. Cada copia es completa al restaurar y cuesta lo que una incremental. UT6.
Digest · Hash SHA-256 que identifica de forma inequívoca una imagen de contenedor. Las etiquetas se mueven; el digest no. UT7.
DNAT · NAT de destino: reescribir la dirección o el puerto de destino de un paquete. Es lo que hace Docker al publicar un puerto, y por eso ese tráfico pasa por la cadena forward y no por input. UT3.
DNS · Sistema de nombres: traduce nombres como grafana.lab a direcciones IP. En la VPC lo sirve OPNsense desde la A3.1 de Despliegue, con un host override por cada nombre del laboratorio. UT3.
DOCKER-USER · Cadena del cortafuegos que Docker crea al principio de FORWARD y promete no tocar. Es el único sitio donde una regla del host filtra el tráfico hacia un puerto publicado por un contenedor. UT3, UT5.
DPO · Delegado de protección de datos: la persona que supervisa el cumplimiento del RGPD en una organización. En una baja custodia el volcado bloqueado y decide su destrucción. UT8.
Driver de logs · Componente de Docker que decide qué se hace con stdout y stderr de un contenedor: fichero JSON, journald, Loki... UT1.
ENS · Esquema Nacional de Seguridad: la norma de seguridad obligatoria para el sector público en España. Fija, entre otras cosas, cuánto se conservan los logs y qué nivel de borrado se exige según la categoría del sistema. UT8.
EPSS · Exploit Prediction Scoring System: probabilidad, entre 0 y 1, de que una CVE se explote en los próximos 30 días. Complementa a CVSS, que solo mide la gravedad teórica. UT7.
Exporter · Programa que expone métricas de algo (el sistema, PostgreSQL, nginx) en formato Prometheus. UT1.
fail2ban · Servicio que lee logs, detecta intentos de acceso fallidos repetidos y bloquea la IP en el firewall durante un tiempo. UT5.
Fatiga de alertas · Situación en la que el canal de avisos recibe tantas notificaciones que el equipo deja de leerlas, y con ellas la que importaba. Se corrige subiendo el umbral, alargando el for o bajando la alarma a warning. UT2.
Fingerprint · Hash que Alertmanager calcula con las etiquetas de una alerta. Es estable, así que empareja el aviso de firing con su resolved y sirve para saber qué incidencia cerrar. UT2.
Grype · Escáner de vulnerabilidades que trabaja sobre imágenes o sobre un SBOM generado por Syft. UT7.
HTTP · Protocolo de la web. Con él Prometheus lee /metrics, Promtail envía a Loki y se llama a la API del servicio; cifrado con TLS pasa a ser HTTPS. UT1.
Inhibición · Regla de Alertmanager que suprime unas alertas mientras otra está activa (si el host está caído, no avisar de cada contenedor de ese host). UT2.
IP · Dirección de red de una máquina. Las del laboratorio son fijas y están en la tabla de convenciones de la página del laboratorio. UT1.
IPAM · Gestión de direcciones IP: el registro con el que el SDN de Proxmox apunta qué dirección tiene cada VM. Al dar de baja un entorno se comprueba que sus direcciones quedan libres. UT8.
JSON · Formato de texto para datos estructurados. Es el de las respuestas de las API, el de los logs estructurados del servicio y el de los informes de k6 y Trivy. UT1.
JUnit · Formato XML de resultados de pruebas, con un elemento por caso. pytest y newman lo generan, y Jenkins lo lee para pintar la tendencia de casos correctos y fallidos entre builds. UT4.
JVM · Máquina virtual de Java, el runtime que ejecuta las aplicaciones Java. Cuando muere deja su propio informe de fallo (hs_err_pid<N>.log) y puede volcar el heap para buscar fugas. UT5.
k6 · Herramienta de pruebas de carga con scripts en JavaScript, umbrales y salida a Prometheus o JSON. UT4.
KEV · Catálogo de CISA de vulnerabilidades explotadas activamente. Si una CVE está ahí, va la primera. UT7.
KPI · Indicador clave (key performance indicator): un valor calculado a partir de varias métricas que describe el servicio para alguien que no conoce el código, como la disponibilidad o la latencia p95. UT4.
Línea base · Los valores normales de un sistema en un periodo sin incidentes, contra los que se compara cualquier medición posterior. UT5.
LogQL · Lenguaje de consulta de Loki: selectores de stream, filtros y funciones que convierten logs en métricas. UT2.
logrotate · Servicio que rota, comprime y caduca los ficheros de registro del sistema según una configuración por servicio. UT5.
Loki · Sistema de almacenamiento e indexación de logs de Grafana Labs, que indexa etiquetas y no el texto. UT1.
LOPDGDD · Ley Orgánica 3/2018 de Protección de Datos Personales y garantía de los derechos digitales, que adapta el RGPD en España. Su artículo 32 regula el bloqueo de datos. UT8.
LUKS · Formato estándar de cifrado de discos en Linux. Su cabecera guarda los keyslots, y borrarlos con cryptsetup luksErase deja el contenido irrecuperable. UT8.
LVM · Gestor de volúmenes lógicos de Linux. Con LVM-thin, el almacenamiento habitual de Proxmox, un snapshot conserva los bloques de un fichero aunque después se sobrescriba. UT8.
Mailpit · Servidor de correo de pruebas: acepta cualquier mensaje por SMTP y lo enseña en una web en lugar de entregarlo. UT2.
mTLS · TLS mutuo: cliente y servidor presentan certificado. Se usa entre Promtail y Loki. UT3.
NAS · Almacenamiento en red al que se llega por SSH o por un protocolo de ficheros. Es el destino habitual de las copias en instalaciones pequeñas y el terreno propio de borg. UT6.
NAT · Traducción de direcciones: el cortafuegos o el kernel reescriben el origen o el destino de los paquetes (SNAT para salir, DNAT para publicar). UT3.
newman · Ejecutor de línea de comandos de las colecciones de Postman. Es la forma de llevar esas pruebas a un pipeline, con informe JUnit. UT4.
NIST · Instituto de estándares de EE. UU. Mantiene la NVD y publica la guía NIST SP 800-88 de borrado de soportes, que ordena los métodos en Clear, Purge y Destroy. UT7, UT8.
NTP · Network Time Protocol: el protocolo con el que las máquinas se ponen en hora por la red. En las VM lo usa chrony, y desde la A3.1 de Despliegue toman la hora de OPNsense. UT1.
NVD · National Vulnerability Database: la base de datos de vulnerabilidades del NIST, con la ficha completa de cada CVE y su vector CVSS. UT7.
Object Lock · Función de S3 y MinIO que impide borrar o modificar un objeto durante un periodo; protege las copias ante ransomware. UT6.
OCSP · Protocolo con el que un cliente pregunta en línea a la autoridad de certificación si un certificado concreto está revocado, en lugar de descargarse la CRL entera. UT8.
OOM · Out of memory. El kernel mata el proceso que más memoria usa cuando un cgroup o el sistema se queda sin ella; el contenedor sale con código 137. UT1, UT5.
OSV · Base de datos abierta de vulnerabilidades por paquete y versión, con API y el escáner osv-scanner. Es la fuente que se consulta cuando el hallazgo viene de una dependencia de la aplicación. UT7.
OWASP · Fundación abierta de seguridad de aplicaciones web, autora de ZAP. UT4.
p95 · Percentil 95: el valor que no supera el 95 % de las muestras. Se usa en lugar del máximo para que un pico aislado no distorsione la latencia o la línea base. UT1, UT4, UT5.
Password spraying · Ataque de acceso que prueba una contraseña muy común contra muchos usuarios distintos, para no disparar los bloqueos por intentos fallidos de una sola cuenta. UT5.
Pending y firing · Estados de una regla de alerta: pending mientras la condición se cumple pero aún no ha pasado el for, firing cuando ya se notifica a Alertmanager. UT2.
photorec · Herramienta que recupera ficheros borrados buscando sus firmas en el disco. Sirve para demostrar que un borrado no ha sido suficiente. UT8.
PID · Identificador de proceso. docker inspect da el del proceso principal de un contenedor, y con él se llega a su cgroup en /proc/<PID>/cgroup. UT1, UT5.
PITR · Point-in-time recovery: restaurar una base de datos al instante exacto anterior al fallo, combinando una copia física con el archivado de WAL. UT6.
PR · Pull request: propuesta de cambio sobre un repositorio que alguien revisa antes de fusionarla en la rama principal. Es lo que abre Renovate por cada actualización disponible. UT7.
Presupuesto de error · Lo que el servicio puede fallar sin incumplir su SLO: con un objetivo del 99,5 % mensual, unas 3,6 horas. UT4.
PromQL · Lenguaje de consulta de Prometheus: selecciona series por sus etiquetas y las combina con funciones como rate(), sum by o histogram_quantile(). UT1, UT2.
Promtail · Agente que lee ficheros de log (o el socket de Docker), añade etiquetas y los envía a Loki. UT1.
promtool, amtool y lokitool · Utilidades de línea de comandos que validan y prueban los ficheros de Prometheus, Alertmanager y el ruler de Loki antes de recargarlos. UT2.
Prune · Operación de restic que reempaqueta el repositorio y elimina los datos que ya no pertenecen a ningún snapshot después de un forget. Es lenta, bloquea el repositorio y se programa aparte de la copia. UT6, UT8.
RAM · Memoria principal. En el laboratorio se asigna a cada VM al clonarla con qm set <ID> --memory, y su reparto está en la página del laboratorio. UT1, UT5.
Recording rule · Regla de Prometheus que precalcula una expresión y la guarda como métrica nueva. UT2.
RED (método) · Revisión de un servicio por tres medidas: peticiones por segundo, errores por segundo y distribución de la latencia. Mira hacia el usuario, donde USE mira hacia los recursos. UT4.
Regla 3-2-1 · Tres copias de los datos, en dos soportes distintos, con una de ellas fuera del sitio. UT6.
Relabel · Reglas de Prometheus y de Promtail que copian, renombran o descartan etiquetas: sobre los orígenes antes de leerlos (relabel_configs) o sobre las series ya leídas, antes de guardarlas (metric_relabel_configs). UT1.
Renovate · Bot que abre un pull request cuando hay versión nueva de una imagen o dependencia. UT7.
restic · Herramienta de copias de seguridad con deduplicación, cifrado y retención integrada, hacia disco, SFTP o S3. UT6.
RFC · Documento que fija un estándar de Internet. En el curso aparece el RFC 3339, el formato de fecha (2026-10-13T09:12:00Z) de los logs estructurados y de Alertmanager. UT1, UT2.
RGPD · Reglamento General de Protección de Datos. Fija los plazos de conservación, el derecho de supresión y la obligación de poder restaurar los datos, que es lo que condiciona dónde vive una copia y cuándo se borra. UT6, UT8.
Rollback · Volver a la versión anterior tras una actualización fallida. Exige tener la etiqueta anterior y una copia previa. UT7.
RPO y RTO · Recovery Point Objective: cuántos datos se pueden perder (cada cuánto se copia). Recovery Time Objective: cuánto se puede tardar en restaurar. UT6.
Ruler · Componente de Loki que evalúa reglas de alerta escritas en LogQL y las envía al mismo Alertmanager que Prometheus. UT2.
Runbook · Instrucciones paso a paso para atender una alarma concreta, enlazadas desde la propia alerta. UT4.
S3 y MinIO · Almacenamiento de objetos por HTTP: S3 es el servicio de Amazon y MinIO su equivalente instalable, que habla el mismo protocolo. En el laboratorio, el MinIO de 10.10.0.30 guarda el repositorio restic en el bucket backups. UT6, UT7, UT8.
SAN · Subject Alternative Name: el campo del certificado con los nombres DNS y las IP para los que vale. Es lo que validan Prometheus, Promtail y los navegadores. UT3.
SARIF · Static Analysis Results Interchange Format: formato normalizado de resultados de análisis de código, el que Gitea, GitLab y GitHub pintan junto al código. Trivy lo escribe con --format sarif. UT7.
SBOM · Software Bill of Materials: inventario de todos los componentes de una imagen, en formato CycloneDX o SPDX. UT7.
Scrape · Lectura periódica que hace Prometheus del /metrics de cada target (cada 15 s en el laboratorio). Si falla, esa muestra no existe y queda un hueco. UT1.
SDN · Red definida por software: en Proxmox, las zonas, VNets y subredes que se declaran en el nodo y sobre las que viven las VPC del laboratorio. UT8.
SEGV · Señal de violación de segmento: la que el kernel manda a un proceso que accede a memoria que no es suya, y la que deja un core dump. En el laboratorio se provoca con kill -SEGV. UT5.
Señales doradas · Las cuatro medidas mínimas de un servicio: latencia, tráfico, errores y saturación. UT4.
Sidecar · Contenedor auxiliar que se pone al lado de un servicio para darle lo que no tiene sin tocar su código. En el curso, un nginx que añade TLS y basic auth delante de cAdvisor y de las métricas de la API. UT3.
Silencio · Supresión temporal de notificaciones de Alertmanager que coinciden con unas etiquetas, típica en mantenimientos. UT2.
SLI, SLO, SLA · Indicador de nivel de servicio (la medida), objetivo (el valor comprometido internamente) y acuerdo (el compromiso contractual con consecuencias). UT4.
SMTP · Protocolo de envío de correo. Alertmanager lo usa para mandar los avisos, y en el laboratorio los recibe Mailpit. UT2.
SO · Sistema operativo. En una imagen, la capa de paquetes de la distribución base, que Trivy escanea por separado de las dependencias del lenguaje. UT7.
SQL · Lenguaje de consulta de las bases de datos relacionales como PostgreSQL. UT6, UT8.
SRE · Site Reliability Engineering: la forma de operar servicios que Google publicó como libro, de donde salen las señales doradas y el principio de alertar por síntomas. UT2, UT4.
SSD · Disco de estado sólido. Su controlador reparte las escrituras entre celdas (wear leveling), así que sobrescribir un fichero no garantiza borrar los datos originales. UT5, UT8.
SSH · Protocolo de acceso remoto cifrado a una máquina. En el laboratorio también sirve para los túneles (ssh -L) con los que se llega a Prometheus, Alertmanager y Mailpit desde el 3 de diciembre. UT3.
SSL · Nombre antiguo de TLS, que sigue apareciendo en mensajes de error y en directivas de nginx (ssl_protocols, ssl_verify_client). UT3.
Steal · Porcentaje de tiempo que una máquina virtual está lista para ejecutar pero el hipervisor no le da CPU. Señal de que el host está sobrevendido. UT5.
Structured metadata · Campos que Loki guarda junto a cada línea sin meterlos en el índice. Sirven para filtrar por valores de cardinalidad alta, como request_id. UT1.
Syft · Generador de SBOM a partir de imágenes, directorios o ficheros de dependencias. UT7.
SYN · Primer paquete de una conexión TCP. nmap con -sS manda solo el SYN y, si vuelve un SYN-ACK, da el puerto por abierto. UT3, UT5.
Timer de systemd · Unit que lanza una unit de servicio a una hora o cada cierto tiempo (OnCalendar), con log en el journal y recuperación de ejecuciones perdidas (Persistent=true). Es la alternativa a cron. UT5, UT6.
TLS · Protocolo que cifra una conexión y permite al cliente comprobar con un certificado quién es el servidor; es la S de HTTPS. En el curso, versión 1.3. UT3.
Trivy · Escáner de vulnerabilidades y configuraciones inseguras para imágenes, sistemas de ficheros, código IaC y repositorios. UT7.
TSDB · Base de datos de series temporales. Es el almacén local de Prometheus y el formato de índice de Loki, y se administra con su propia API para borrar series. UT1, UT8.
UID · Identificador numérico de un usuario en Linux. Los ficheros de un volumen que se restaura tienen que pertenecer al UID con el que corre el contenedor, que no siempre coincide con el del host. UT6.
Umbral · Valor a partir del cual una métrica se considera anómala. Se fija desde la documentación del servicio y se ajusta con datos reales. UT2, UT4.
URL · Dirección completa de un recurso web, con protocolo, nombre, puerto y ruta (http://localhost:29090/api/v1/query). UT2.
USE (método) · Revisión de rendimiento que mira, para cada recurso, su utilización, su saturación y sus errores. UT4, UT5.
VACUUM FULL · Operación de PostgreSQL que reescribe una tabla eliminando las páginas muertas que DELETE y DROP dejan en el disco. UT8.
Ventana de mantenimiento · Franja horaria acordada de antemano en la que se permite actualizar o intervenir sobre un servicio en producción. UT7.
Versionado semántico · Convención MAYOR.MENOR.PARCHE con la que un proyecto promete qué puede romperse al actualizar. UT7.
VM · Máquina virtual. Las del laboratorio las crea Despliegue en Proxmox, cada una con un ID fijo. UT1.
VPC · Red privada virtual del laboratorio, dividida en zonas (gestión, front, back, data) detrás de OPNsense. La dev se monta en la UT2 de Despliegue y app01 y mon01 pasan a ella el 27 de noviembre. UT3.
VU · Usuario virtual de k6: un bucle que ejecuta el escenario una y otra vez. Las pruebas de carga se dimensionan en VU (10, 50, 100). UT4.
WAL · Write-ahead log: diario en el que se anotan las escrituras antes de aplicarlas. PostgreSQL lo usa para poder restaurar a un instante concreto, y el Prometheus de remote_write para no perder muestras si el central no responde. UT1, UT6.
Working set · Memoria que un contenedor usa de verdad, descontando la caché de página que el kernel puede liberar. Es la que se compara con el límite. UT1.
XML · Formato de texto con etiquetas. En el curso, el de los informes JUnit. UT4.
YAML · Formato de configuración por sangría que usan Prometheus, Alertmanager, Loki, Promtail y los ficheros compose. UT1.
ZAP · OWASP Zed Attack Proxy, escáner de seguridad de aplicaciones web. En modo baseline hace un análisis pasivo rápido. UT4.
ZFS · Sistema de ficheros con copy-on-write y snapshots; por eso sobrescribir un fichero en ZFS no borra sus bloques anteriores. UT8.
Para ampliar¶
Esta página recoge, unidad por unidad, dos cosas que no caben en las sesiones: los apartados que van más allá de lo que se hace en clase (no se explican en la sesión ni los necesita ningún paso obligatorio de una hoja de práctica, como mucho algún «Si te sobra tiempo», pero son lo que aparece en el día a día de una empresa) y los enlaces para seguir por cuenta propia. Las unidades quedan así con lo que se da en cada sesión, y en el sitio de donde sale cada apartado dejan una frase con enlace a él. Esto está aquí para ir más lejos y para situar lo que suene durante la formación en empresa.
UT1 · Observabilidad de contenedores: métricas, logs y eventos¶
Apartados y enlaces para ampliar de la UT1. Se recogen el reparto interno de Loki en componentes (el laboratorio lo ejecuta todo en un mismo proceso), otros drivers de logs de Docker, el relevo de Promtail por Grafana Alloy, el exporter de nginx y el Prometheus local con remote_write del «Si te sobra tiempo» de la A1.6. Después, la documentación de referencia de cgroups, cAdvisor, Prometheus, la librería cliente, Promtail, Loki, remote_write y chrony.
El reparto interno de Loki en componentes¶
Loki no es un solo programa: por dentro tiene componentes con papeles distintos y, en producción, cada uno corre en su propio proceso para poder escalarlos por separado. El distributor recibe los push de Promtail, valida las etiquetas y reparte cada stream. El ingester acumula las líneas en memoria y las escribe como chunks cuando se llenan o cuando vence el plazo. El querier resuelve las consultas leyendo lo reciente del ingester y lo antiguo del almacén. El compactor aplica la retención y compacta el índice, que va en formato TSDB, el mismo que usa Prometheus. En el laboratorio, y en muchas empresas pequeñas, se arranca con -target=all (el modo monolítico): los mismos componentes dentro de un único proceso y con el disco local como almacén. El salto de un modo al otro no cambia ni las consultas ni las etiquetas; solo cambia dónde corre cada pieza y qué se puede escalar sin tocar el resto.
Otros drivers de logs de Docker¶
Además de json-file, estos son los drivers que aparecen a menudo:
| Driver | Qué hace | Cuándo |
|---|---|---|
local |
Como json-file pero en formato binario propio, comprimido, con rotación por defecto (100 MB en 5 ficheros) | Cuando no hace falta que otro agente lea el fichero. Docker lo recomienda sobre json-file, pero Promtail y Fluent Bit leen json-file y no local, así que aquí no sirve |
journald |
Envía las líneas al journal de systemd del host con metadatos del contenedor | Hosts donde el journal ya está centralizado; se consulta con journalctl CONTAINER_NAME=app |
loki |
Plugin de Docker que empuja directamente a Loki sin agente intermedio | Tentador, pero si Loki no responde el plugin bloquea el demonio o pierde líneas según configuración; y si el plugin falla, docker entero se resiente. Es preferible el agente |
none |
Descarta todo | Contenedores muy ruidosos cuya salida no importa |
Desde Docker 20.10 existe el dual logging: aunque se use journald o loki, Docker guarda una copia local para que docker logs siga funcionando. No cambia la recomendación: json-file con límites y un agente que lea los ficheros, añada contexto, reintente y no toque el demonio.
Promtail y Grafana Alloy¶
Promtail es el agente de Grafana Labs para enviar logs a Loki. Tiene una advertencia que hay que hacer en 2026: Grafana lo ha declarado al final de su vida (sin cambios desde febrero de 2025, fin de soporte en marzo de 2026) en favor de Grafana Alloy, que hace lo mismo y además métricas y trazas con una configuración en su propio lenguaje. Se usa igualmente porque su configuración es YAML corto, se entiende en una sesión y todos los conceptos (descubrimiento, relabel, pipeline, posiciones, reintentos) se trasladan uno a uno a Alloy; en empresa se encuentran las dos cosas. Para pasar de uno a otro existe alloy convert --source-format=promtail.
nginx-prometheus-exporter y stub_status¶
nginx-prometheus-exporter (puerto 9113 de fábrica, que esta receta pasa al 29113 de la franja del curso) lee el módulo stub_status de nginx, que hay que activar en el proxy y proteger para que solo lo consulte quien tiene que consultarlo. Hasta el 27 de noviembre el proxy es el contenedor nginx del compose del servicio y el fichero se monta dentro de ese contenedor; desde ese día, con el proxy en web01, es un fichero del host y solo lo ve la subred de gestión. Escucha en 127.0.0.1:28082, no en el 28081: ese puerto es el que cAdvisor publica en el host de app01, y dos servicios no pueden compartirlo:
# status.conf, en la configuración del nginx del servicio
server {
listen 127.0.0.1:28082;
location = /stub_status {
stub_status;
allow 127.0.0.1;
deny all;
}
}
docker run -d --name nginx-exporter --network host \
nginx/nginx-prometheus-exporter:latest \
--nginx.scrape-uri=http://127.0.0.1:28082/stub_status \
--web.listen-address=:29113
Lo que da stub_status es poco: nginx_connections_active, nginx_connections_waiting, nginx_http_requests_total y nginx_up. No hay códigos de respuesta ni latencias por ruta; para eso, en nginx de código abierto, la vía es el log de acceso en JSON parseado en Loki (se hace en la sección de logs) o un módulo de estadísticas de terceros. nginx Plus sí expone todo eso por API; la versión libre no.
Un Prometheus local con remote_write¶
remote_write es la alternativa cuando el scrape no puede hacerse: el target está detrás de NAT (una red privada cuyas direcciones mon01 no puede alcanzar directamente), en una red que mon01 no alcanza, o se busca tolerancia a cortes. Se despliega un Prometheus pequeño junto al servicio (en app01, con retención de horas) que hace el scrape en local y empuja las muestras al central. Ese Prometheus local guarda un WAL (write-ahead log) en disco y una cola en memoria: si el central no responde, reintenta con backoff y, al volver, envía lo acumulado desde el WAL. Con dos horas de WAL aguanta cortes largos sin perder nada.
# prometheus.yml del Prometheus local en app01
global:
scrape_interval: 15s
external_labels: { host: app01, env: dev }
scrape_configs:
- job_name: app
static_configs: [{ targets: ["app:29102"] }]
- job_name: cadvisor
static_configs: [{ targets: ["cadvisor:28081"] }]
remote_write:
- url: http://<mon01>:29090/api/v1/write
queue_config:
capacity: 10000
max_shards: 4
max_samples_per_send: 2000
batch_send_deadline: 5s
min_backoff: 30ms
max_backoff: 5s
El Prometheus central tiene que arrancar con --web.enable-remote-write-receiver para aceptar escrituras en /api/v1/write. En Prometheus 3 el protocolo de remote write tiene versión 2.0 (más compacto, con metadatos); por defecto se sigue usando la 1.0 y no hace falta tocar nada. Las métricas del emisor que dicen si va bien: prometheus_remote_storage_samples_pending (cola), prometheus_remote_storage_samples_retried_total, prometheus_remote_storage_samples_failed_total (perdidas de verdad) y prometheus_remote_storage_highest_timestamp_in_seconds frente a prometheus_remote_storage_queue_highest_sent_timestamp_seconds (retraso en segundos).
En el laboratorio la práctica se hace con scrape directo, que es lo que tiene cualquier empresa con una red plana; el remote_write solo se monta en el «Si te sobra tiempo» de la A1.6, para comparar el comportamiento ante el corte. Es el mismo mecanismo que usan los servicios gestionados (Grafana Cloud, Amazon Managed Prometheus, Thanos Receive).
Enlaces¶
- cgroups v2, man 7 cgroups: referencia del kernel con todos los ficheros de
memory.*,cpu.*eio.*que lee cAdvisor. - Documentación de cgroup v2 del kernel: explica
memory.statcampo a campo, incluidoinactive_filey por qué el working set se calcula así. - Prometheus metrics for cAdvisor: lista completa de métricas
container_*con su tipo y qué flag las activa. - Metric and label naming e Instrumentation: las convenciones que siguen estos apuntes y cuándo usar cada tipo de métrica.
- prometheus_client para Python: documentación oficial de la librería que usa la API del curso, con el modo multiproceso y las métricas por defecto.
- postgres_exporter: variables de entorno, colectores disponibles y el formato de las consultas personalizadas.
- Configure logging drivers, Docker docs: todos los drivers, sus opciones y el dual logging.
- Promtail configuration: referencia de
docker_sd_configs,relabel_configs, cada etapa del pipeline ybackoff_config. - Loki configuration y LogQL: referencia del fichero de Loki (retención, límites) y del lenguaje de consulta.
- Prometheus remote write: cómo dimensionar la cola y qué significan sus métricas.
- chrony, documentación oficial:
chronyc tracking,sourcesymakestepexplicados.
UT2 · Umbrales, agregación y gestión de alarmas¶
Apartados y enlaces de la unidad de alarmas que van más allá de lo que se hace en clase; vienen de UT2. Se recogen Grafana Alerting, la medida de la fatiga de alertas, las piezas de Alertmanager que el laboratorio no usa (rutas por horario, Slack, ruta propia de incidencias) y otros gestores de incidencias.
Grafana Alerting como alternativa¶
Grafana 12 trae su propio motor de alertas: reglas que se definen desde la interfaz sobre cualquier fuente de datos (Prometheus, Loki, PostgreSQL, InfluxDB), con periodo de evaluación y pendiente equivalentes a
interval y for, puntos de contacto que son los receptores, y políticas de notificación que son el árbol de rutas. Por debajo lleva embebido un Alertmanager, y puede configurarse para enviar a uno externo (el de mon01) en lugar de al suyo.
Cuándo sí: cuando la fuente de datos no tiene ruler propio (una base de datos SQL, un Elasticsearch), cuando el equipo que define las alertas trabaja sólo en Grafana y no toca ficheros YAML, o cuando hace falta una alerta puntual sobre un panel sin pasar por el repositorio. Cuándo no: cuando las reglas deben vivir en Git con revisión y promtool test (Grafana permite provisionarlas desde ficheros, pero el flujo es más incómodo), cuando ya está el ruler de Loki y Prometheus mandando al mismo Alertmanager (tener reglas en dos motores es tener dos sitios donde buscar), y cuando la disponibilidad de las alertas no puede depender de la base de datos de Grafana. En el laboratorio la norma es: reglas en ficheros, Prometheus y Loki como motores, Grafana para mirar.
Cómo se mide la fatiga de alertas¶
La fatiga de alertas se mide con números que Prometheus y Alertmanager ya dan. Prometheus expone la serie ALERTS{alertname, alertstate, ...} con valor 1 mientras una alerta está en pending o firing, así que se pueden contar las horas que ha estado activa cada una en la última semana:
El 15 es el intervalo de evaluación en segundos y se ajusta al de cada instalación. Alertmanager expone alertmanager_notifications_total{integration} y alertmanager_alerts_received_total, con los que salen las notificaciones por día y por canal. Los indicadores que se usan en la práctica son estos cuatro:
| Indicador | Cómo se calcula | Valor razonable |
|---|---|---|
| Notificaciones por semana y persona de guardia | increase(alertmanager_notifications_total[7d]) entre el número de personas |
Menos de 10 fuera de horario |
| Porcentaje de alarmas accionables | Revisión manual semanal: de las incidencias creadas, cuántas llevaron a un cambio | Por encima del 80 % |
| Alarmas que se resuelven solas antes de que nadie mire | Incidencias cerradas por resolved sin comentario humano |
Por debajo del 20 % |
| Tiempo hasta reconocer | Diferencia entre startsAt y el primer comentario en la incidencia |
Minutos, no horas |
Las incidencias de Gitea que se crean en la UT2 son precisamente lo que permite calcular los tres últimos, porque Alertmanager solo guarda las alertas activas y olvida el pasado.
Rutas por horario, Slack y tickets en Alertmanager¶
El alertmanager.yml que se monta en la UT2 tiene tres rutas y tres receptores. Alertmanager admite bastante más, y el fichero de partida de la unidad llevaba tres piezas que el laboratorio no usa: una ruta que se calla de noche con un intervalo de tiempo, un receptor de Slack y una ruta propia hacia el gestor de incidencias con su propio repeat_interval. Este era el fichero completo:
global:
resolve_timeout: 5m
smtp_smarthost: mailpit:1025
smtp_from: alertmanager@lab
smtp_require_tls: false
templates:
- /etc/alertmanager/templates/*.tmpl
route:
receiver: mail
group_by: [alertname, service]
group_wait: 30s
group_interval: 5m
repeat_interval: 4h
routes:
- matchers: [severity="critical"]
receiver: critical
continue: true
- matchers: [team="dev"]
receiver: dev-chat
group_by: [alertname, service, env]
- matchers: [severity="info"]
receiver: "null"
mute_time_intervals: [noches]
- matchers: [service="db"]
receiver: tickets
repeat_interval: 12h
time_intervals:
- name: noches
time_intervals:
- times: [{ start_time: "22:00", end_time: "08:00" }]
location: Europe/Madrid
inhibit_rules:
- source_matchers: [alertname="HostDown"]
target_matchers: [severity=~"warning|critical"]
equal: [host]
- source_matchers: [alertname="AppDown"]
target_matchers: [service="app"]
equal: [env]
receivers:
- name: mail
email_configs:
- to: ops@lab
send_resolved: true
- name: critical
telegram_configs:
- bot_token_file: /etc/alertmanager/secrets/tg_token
chat_id: -100123456789
parse_mode: HTML
send_resolved: true
webhook_configs:
- url: http://tickets:25000/alertmanager
send_resolved: true
- name: dev-chat
slack_configs:
- api_url_file: /etc/alertmanager/secrets/slack_url
channel: "#dev-alerts"
send_resolved: true
- name: tickets
webhook_configs:
- url: http://tickets:25000/alertmanager
send_resolved: true
- name: "null"
time_intervals define franjas con nombre (aquí, noches, de 22:00 a 08:00 en hora de Madrid) y mute_time_intervals en una ruta hace que esa ruta no notifique dentro de la franja: la alerta sigue existiendo y se ve en la interfaz. La ruta service="db" manda las alertas de la base de datos a un receptor de incidencias propio y sobrescribe repeat_interval para que no insista más de dos veces al día, igual que dev-chat sobrescribe group_by.
Alertmanager 0.28 trae integraciones para correo, Slack, Telegram, Discord, Microsoft Teams, PagerDuty, OpsGenie, Pushover, VictorOps, WeChat, webhook genérico y alguna más. Slack: un webhook entrante de la aplicación de Slack, también en fichero (api_url_file). Todas las integraciones admiten send_resolved, y el valor por defecto cambia según la integración (en Slack es false), por eso conviene ponerlo explícito siempre.
Otros gestores de incidencias: GitLab, n8n, Zammad y GLPI¶
El receptor de la UT2 abre y cierra issues en Gitea. Con GitLab el cambio es mínimo: POST /api/v4/projects/:id/issues con labels: "critical,app,prometheus" (ahí sí van por nombre), cabecera PRIVATE-TOKEN, y el cierre es PUT /issues/:iid con state_event=close.
La alternativa sin código es n8n (o Node-RED), automatización visual encadenando cajas de "cuando llegue esto, haz aquello", con un nodo webhook y un nodo HTTP; hace lo mismo con clics y es lo que se encuentra en empresas que no quieren mantener scripts. Zammad y GLPI tienen API equivalentes si el gestor de incidencias es uno de esos.
Enlaces¶
- Monitoring Distributed Systems, libro SRE de Google: el capítulo del que sale la filosofía de síntomas frente a causas y las cuatro señales de oro. Gratuito y corto.
- Alerting rules, documentación de Prometheus: sintaxis completa de las reglas,
for,keep_firing_fory las variables de plantilla. - Recording rules: best practices: la convención
nivel:métrica:operacionescon ejemplos. - Alerting: best practices: página breve de Prometheus que resume qué alertar y qué no.
- Querying basics y functions: referencia de
rate,increase,changes,absent,histogram_quantiley el resto. - Alertmanager configuration: todas las opciones de rutas, inhibición, intervalos de silencio y cada receptor.
- Notification template reference: los objetos y funciones disponibles en las plantillas.
- LogQL: log queries y metric queries: selectores, filtros, parsers y funciones de rango.
- Loki ruler: configuración del ruler, alertas y recording rules sobre logs.
- Grafana Alerting: reglas, puntos de contacto y políticas de notificación en Grafana 12.
- Uso de la API de Gitea: cómo generar el token y dónde está el Swagger con los endpoints de issues que usa el receptor webhook.
- Mailpit: instalación, opciones y API del SMTP de pruebas.
UT3 · Seguridad de las comunicaciones de monitorización¶
Apartados y enlaces de la UT3, la unidad que audita, cierra y cifra las comunicaciones de la pila de monitorización. Se recogen los casos reales de pilas expuestas a Internet y el detalle de lo que dejan ver Prometheus, Alertmanager y Loki sin autenticación, que la unidad resume en dos líneas.
Casos reales de pilas de monitorización expuestas¶
Esto no es teórico. En diciembre de 2024 el equipo de investigación de Aqua Security publicó un recuento hecho con Shodan y Censys (buscadores que indexan máquinas conectadas a Internet): alrededor de 296.000 instancias de node_exporter y 40.000 servidores Prometheus accesibles desde Internet sin ninguna autenticación, muchos de ellos con /api/v1/status/config mostrando credenciales de servicios en URLs y con la API de administración activa. Ya en 2021 JFrog había hecho un análisis parecido y había encontrado servidores Prometheus expuestos de empresas grandes cuya configuración incluía contraseñas de bases de datos y claves de proveedores de nube. Con Grafana el caso más conocido es la vulnerabilidad CVE-2021-43798 (versiones 8.0 a 8.3): un recorrido de directorios por la ruta de los plugins, sin autenticación, que permitía leer cualquier fichero del contenedor, incluido grafana.db con las credenciales cifradas de todos los datasources (las fuentes de datos conectadas), y el patrón de ataque era buscar Grafanas expuestos con Shodan y recorrer la lista. Y el error más repetido de todos: Grafana con admin/admin de fábrica, alcanzable desde fuera, sin cambiar. En un laboratorio pequeño parece que a nadie le importa; en la empresa donde se hacen las prácticas la pila de monitorización suele ser el servidor más viejo, con menos parches y con más información sobre el resto.
Lo que dejan ver Prometheus, Alertmanager y Loki sin autenticación¶
Los propios servidores de la pila filtran todavía más. La API de Prometheus responde en /api/v1/targets con la lista de todo lo que se monitoriza (un mapa de la infraestructura, con IPs y puertos), y /api/v1/status/config devuelve la configuración cargada. Prometheus enmascara los campos marcados como secreto (password: <secret>), pero no puede saber que la URL de un remote_write o un relabel_config (dos opciones de configuración de Prometheus) lleva un token dentro. Si Prometheus arranca con --web.enable-admin-api, cualquiera puede borrar series con un POST a /api/v1/admin/tsdb/delete_series, y con --web.enable-lifecycle puede pararlo con un POST a /-/quit. Alertmanager acepta silencios sin autenticación por su API: un atacante silencia la alarma y después ataca. Loki sin autenticación es el caso más grave: cualquiera lee todos los logs por /loki/api/v1/query_range (y en los logs de la API están las peticiones completas, con IPs de clientes y a veces con parámetros que nadie debería haber registrado) y cualquiera inyecta líneas falsas por /loki/api/v1/push, lo que sirve para envenenar una investigación o disparar alarmas falsas hasta que el equipo deje de mirarlas.
Enlaces¶
- Packet filtering and firewalls (documentación de Docker): explica la cadena DOCKER-USER, por qué las reglas de INPUT no ven los puertos publicados y cómo restringir el acceso externo.
- Exporter toolkit: web configuration: referencia completa del
web.config.file(TLS, mTLS, basic auth, cabeceras HTTP) que comparten node_exporter y el resto de exporters oficiales. - Configuración de Prometheus: scrape_config: sintaxis exacta de
scheme,tls_config,basic_authypassword_fileen los jobs. - Prometheus security model: lo que Prometheus asume sobre quién llega a su API y por qué no trae autenticación de serie.
- Loki: configuración del servidor: bloque
serverconhttp_tls_configyclient_auth_typepara el mTLS. - Promtail: pipeline stages, replace: comportamiento exacto de la etapa
replacecon y sin grupos de captura, con ejemplos. - Grafana: configure security: acceso anónimo, cookies seguras, proxy inverso y roles.
- Wiki de nftables: Quick reference: sintaxis de tablas, cadenas base, conjuntos y expresiones
ct. - Manual de OPNsense: reglas de cortafuegos: orden de evaluación por interfaz, alias y registro.
- nmap reference guide: port scanning basics: definición oficial de los estados open, closed y filtered y de cada tipo de escaneo.
- Aqua Security: 300.000 servidores Prometheus y exporters expuestos: el informe de diciembre de 2024 con lo que se encontró en instancias abiertas a Internet.
UT4 · Indicadores, KPI y pruebas del servicio¶
Enlaces de ampliación de la unidad UT4: las referencias originales de las señales doradas, USE y RED, el burn rate, los histogramas de Prometheus y la documentación de k6, ZAP, trivy, newman y Jenkins. Se recogen además la colección de Postman entera de la que sale el fragmento de pruebas funcionales de la unidad y los informes automáticos de KPI, con un script que exporta el resumen semanal a Git.
Una colección de Postman entera¶
El apartado de pruebas funcionales de la UT4 se queda con el bloque event, que es lo único propio de las pruebas. Esta es la colección completa de la que sale ese fragmento, en formato v2.1, con las dos peticiones enteras: una correcta y otra que espera un 422 por precio negativo. Sirve para ver dónde encaja cada event y qué rodea a las afirmaciones, que es lo que genera Postman al exportar y lo que después ejecuta newman.
{
"info": { "name": "API curso", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" },
"item": [
{
"name": "GET items",
"request": {
"method": "GET",
"header": [{ "key": "Authorization", "value": "Bearer {{token}}" }],
"url": "{{base}}/items"
},
"event": [{
"listen": "test",
"script": { "exec": [
"pm.test('status 200', () => pm.response.to.have.status(200));",
"pm.test('responde en menos de 500 ms', () => pm.expect(pm.response.responseTime).to.be.below(500));",
"pm.test('devuelve una lista', () => pm.expect(pm.response.json()).to.be.an('array'));"
] }
}]
},
{
"name": "POST item precio negativo",
"request": {
"method": "POST",
"header": [{ "key": "Content-Type", "value": "application/json" },
{ "key": "Authorization", "value": "Bearer {{token}}" }],
"body": { "mode": "raw", "raw": "{\"name\": \"x\", \"price\": -3}" },
"url": "{{base}}/items"
},
"event": [{
"listen": "test",
"script": { "exec": ["pm.test('status 422', () => pm.response.to.have.status(422));"] }
}]
}
]
}
Informes automáticos de KPI¶
Grafana sabe programar informes (un dashboard convertido en PDF y enviado por correo cada lunes), pero en Grafana 12 esa función es de la edición Enterprise y no está en la edición libre que corre en mon01, así que en el laboratorio no se puede usar. Lo que sí trae la edición libre es el plugin grafana-image-renderer, que devuelve la imagen de un panel por su API (/render/d-solo/<uid>/<slug>?panelId=2&from=now-7d&to=now&width=1000&height=500); en el laboratorio no se instala, y las capturas de la A4.5 y la A4.6 se hacen a mano, con el rango de tiempo visible.
La alternativa que no depende de la edición ni del correo es un script que consulte los KPI a Prometheus, escriba el resumen en Markdown y lo confirme en Git. Queda un histórico consultable y diferenciable. Se ejecuta en mon01, donde Prometheus escucha en localhost, sobre un clon del repositorio operacion:
#!/usr/bin/env bash
# bin/kpi-semanal.sh (repositorio operacion): exporta los KPI de la semana a Markdown y lo guarda en Git
set -euo pipefail
PROM=http://localhost:29090
q() { curl -sG "$PROM/api/v1/query" --data-urlencode "query=$1" | jq -r '.data.result[0].value[1] // "n/a"'; }
SEMANA=$(date +%G-W%V)
OUT="revisiones/$SEMANA-kpi.md"
{
echo "# KPI $SEMANA ($(date -I))"
echo
echo "| Indicador | Valor |"
echo "|---|---|"
printf '| Disponibilidad 30 d | %.3f %% |\n' "$(echo "$(q 'app:availability:30d') * 100" | bc -l)"
printf '| p95 7 d (ms) | %.0f |\n' "$(echo "$(q 'quantile_over_time(0.95, app:latency_p95:5m[7d])') * 1000" | bc -l)"
printf '| Errores 7 d | %.3f %% |\n' "$(echo "$(q 'avg_over_time(app:errors:ratio5m[7d])') * 100" | bc -l)"
printf '| Disco /data usado | %.1f %% |\n' "$(echo "(1 - $(q 'node_filesystem_avail_bytes{mountpoint="/data"} / node_filesystem_size_bytes{mountpoint="/data"}')) * 100" | bc -l)"
printf '| Conexiones BD (máx 7 d) | %s |\n' "$(q 'max_over_time(sum(pg_stat_activity_count)[7d:5m])')"
printf '| Alarmas disparadas 7 d | %s |\n' "$(q 'sum(changes(ALERTS_FOR_STATE[7d]))')"
} > "$OUT"
git add "$OUT" && git commit -qm "kpi: $SEMANA" && git push -q
Se lanza los lunes a las 7:00 con un cron o un job de Jenkins, y la revisión semanal empieza leyendo ese fichero en vez de abriendo diez paneles. ALERTS_FOR_STATE es una serie interna de Prometheus que existe mientras una alerta está en pending o firing; contar sus cambios es una forma barata de saber cuántas veces ha saltado algo.
Enlaces¶
- Monitoring Distributed Systems (Google SRE Book): el capítulo de donde salen las cuatro señales doradas y el principio de alertar por síntomas.
- Alerting on SLOs (Google SRE Workbook): burn rate, ventanas múltiples y la tabla de umbrales; es la referencia para las alarmas de calidad.
- The USE Method, Brendan Gregg: el método para recursos, con la lista de comprobación por componente de un servidor Linux.
- Histograms and summaries (Prometheus): cómo funciona
histogram_quantile, el error de interpolación y por qué los cubos deben elegirse alrededor del SLO. - Query functions (Prometheus): referencia de
rate,increase,predict_linear,derivyhistogram_quantilecon sus condiciones de uso. - Documentación de k6: opciones, escenarios y ejecutores, umbrales, salidas (JSON, Prometheus remote write) y la referencia de métricas internas.
- Thresholds (k6): sintaxis completa de umbrales, umbrales por etiqueta y
abortOnFail. - ZAP Baseline Scan: parámetros, códigos de salida y formato del fichero de reglas.
- Trivy: escaneo de imágenes, filtros de severidad,
--ignore-unfixed, formatos de salida y modo servidor. - Running collections with Newman: ejecución, variables de entorno y reporters, incluido el JUnit.
- Pipeline step junit (Jenkins): cómo publica Jenkins los informes y qué significa UNSTABLE.
- Reporting (Grafana): informes programados; conviene leer el aviso de edición antes de contar con ello.
UT5 · Explotación de logs, accesos y rendimiento¶
Enlaces de referencia de la unidad de logs, accesos y rendimiento que se cursa en la empresa: UT5.
Enlaces¶
- journalctl(1) en man7.org: todas las opciones de filtrado (
-u,-p,_COMM,--since,-o json) y las de vacuum; es la referencia que se consulta cada semana. - logrotate(8) en man7.org: directivas, orden de evaluación y el detalle de
copytruncatefrente acreate. - Configuración de logging en Docker: drivers,
max-size,max-file, y el driverjournaldcon sus campos. - LogQL en la documentación de Loki: filtros, parsers (
json,logfmt,pattern,regexp) y las funciones de agregación sobre rangos que usan las alertas de esta unidad. - Wiki de fail2ban en GitHub: manual de uso, escritura de filtros y acciones, y ejemplos de jails.
- Documentación de CrowdSec: conceptos de agente, escenarios y bouncers, e instalación del bouncer para nftables y para OPNsense.
- Wiki de nftables: tablas, sets y prioridades de cadenas, necesario para entender cómo convive el bloqueo de fail2ban con un firewall propio.
- core(5) en man7.org: los especificadores de
core_pattern, el comportamiento con tuberías y el efecto defs.suid_dumpable. - The USE Method, de Brendan Gregg: el método de la sección de rendimiento con su lista de comprobación por recurso para Linux.
- Documentación de node_exporter: colectores y nombres de métricas de CPU, memoria, disco y red que aparecen en la tabla USE.
- Apuntes de la 5166, UT7 Monitorización: la pila de mon01 sobre la que se construyen los paneles de esta unidad.
UT6 · Copias de seguridad y restauración¶
Enlaces de ampliación de la UT6, la unidad de copias de seguridad y restauración que se cursa en la empresa. Se recogen además el afinado de restic y la equivalencia de comandos con BorgBackup: ninguno de los dos hace falta para las tres actividades, pero aparecen en cuanto el repositorio crece o el destino deja de ser un bucket.
Afinado de restic¶
Una copia diaria del tamaño de la del laboratorio no necesita ningún ajuste: restic funciona bien con los valores por defecto, y la caché local de RESTIC_CACHE_DIR ya se lleva casi toda la mejora posible. Lo que sigue aparece cuando el repositorio crece a cientos de gigabytes o cuando la ventana nocturna se queda corta.
--read-concurrency 4sube el número de ficheros que se leen a la vez. Ayuda en discos NVMe y estorba en discos mecánicos, donde el cuello es el cabezal y más peticiones simultáneas solo añaden desplazamientos.RESTIC_PACK_SIZE=64(en MiB) agranda los paquetes que restic sube. Con menos objetos, un bucket grande se lista e indexa antes y se pagan menos peticiones; a cambio, cadaprunereescribe más datos de golpe.--limit-uploady--limit-download(en KiB/s) acotan el ancho de banda para que la copia no deje sin línea al resto de lo que se ejecuta esa noche.--max-unusedenprunedecide cuánto espacio desaprovechado se tolera antes de reempaquetar. Cuanto más bajo, más limpio queda el repositorio y más tarda y más transfiere la operación.
Antes de tocar cualquiera de estos conviene medir. El resumen que restic imprime al final de cada copia da ficheros leídos, datos añadidos y tiempo total, y con eso basta para saber si el cuello está en la CPU, en el disco o en la subida; ajustar el parámetro equivocado no mejora nada y sí complica el script.
BorgBackup y borgmatic: comandos y equivalencias¶
BorgBackup guarda el repositorio en local o en un servidor al que se llega por SSH con borg serve; hasta la versión 2 no habla S3 de forma nativa. La compresión se elige en cada copia (lz4 cuando manda la velocidad, zstd cuando manda el tamaño) y el cifrado al inicializar el repositorio: repokey guarda la clave dentro del propio repositorio protegida por la contraseña, y keyfile la deja en ~/.config/borg/keys/, lo que obliga a custodiarla aparte.
| Operación | restic | borg |
|---|---|---|
| Crear el repositorio | restic init |
borg init --encryption=repokey-blake2 |
| Copiar | restic backup /opt/app /var/backups --tag app |
borg create --stats --compression zstd ssh://backup@nas.empresa.local/./app::{now} /opt/app /var/backups |
| Retención | restic forget --keep-daily 7 --keep-weekly 4 --keep-monthly 6 --prune |
borg prune --keep-daily 7 --keep-weekly 4 --keep-monthly 6 y después borg compact |
| Verificar | restic check --read-data-subset=5% |
borg check --verify-data |
| Restaurar una ruta | restic restore latest --target /restore --include /var/backups/app.dump |
borg extract ssh://backup@nas.empresa.local/./app::2027-05-03T02:30:14 var/backups/app.dump |
| Montar el repositorio | restic mount /mnt/restic |
borg mount |
Dos diferencias importan al operar. La primera es que en borg el espacio no se libera con prune: prune marca los archivos y es compact el que reescribe los segmentos, así que sin compact los datos siguen en el destino y el ocupado no baja. La segunda es que --append-only no es una opción del cliente sino del servidor: se pone en la línea de authorized_keys del usuario de copias del NAS, y con ella el cliente puede crear archivos nuevos pero no borrar los que ya hay. La limpieza se ejecuta entonces desde otra sesión sin esa restricción, igual que el forget --prune de restic se lanza desde otro host.
Borgmatic es una capa encima que sustituye el script propio por un fichero YAML: declara los orígenes, la retención, las órdenes de antes y después de la copia (before_backup: pg_dump...) y los avisos a un servicio de vigilancia tipo healthchecks. Es lo habitual en instalaciones pequeñas que no quieren mantener un bash a mano, y el fichero se lee mucho mejor que el script equivalente.
Enlaces¶
- Documentación de restic: referencia completa de comandos, backends, variables de entorno y el diseño interno del repositorio (capítulo "References"), que explica por qué deduplica y cifra como lo hace.
- BorgBackup, guía rápida: para comparar con restic y entender el modo
--append-onlydel servidor. - PostgreSQL 17, capítulo Backup and Restore: dump lógico, copia física y PITR con archivado de WAL, que es el siguiente paso cuando el RPO baja de una hora.
- pg_dump, página de referencia: todas las opciones de formato y selección, y las limitaciones (no vuelca roles ni tablespaces).
- mysqldump, manual de MySQL 8.4: el detalle de
--single-transactiony cuándo no basta. - systemd.timer(5) y systemd.time(7): sintaxis de
OnCalendar,Persistent,RandomizedDelaySecy ejemplos de expresiones de calendario. - crontab(5) en man7.org: formato de las entradas de cron y las cadenas especiales (
@daily). - node_exporter, textfile collector: cómo exponer métricas propias desde un fichero, con el detalle del
mvatómico. - MinIO, Object Lock y retención: modos governance y compliance, retención por defecto del bucket y su relación con el versionado.
- Healthchecks.io, documentación: el patrón dead man's switch con ejemplos de integración desde bash, cron y systemd.
- Proxmox VE, Backup and Restore: modos de copia de VM (
stop,suspend,snapshot) y por qué el snapshot no sustituye a la copia de aplicación. - Reglamento (UE) 2016/679, RGPD: artículo 32 (seguridad del tratamiento, que incluye la capacidad de restaurar) y capítulo V (transferencias a terceros países), que condicionan dónde puede estar la copia.
UT7 · Actualización y gestión de vulnerabilidades¶
Enlaces para ampliar de la unidad UT7, Actualización y gestión de vulnerabilidades. Se recogen además las bases de imagen que hay por debajo de slim y alpine, la configuración de Dependabot (que no se usa en clase porque el repositorio del curso no está alojado en GitHub), un job de Jenkins que vigila versiones publicadas y las migraciones de esquema y actualizaciones de PostgreSQL.
Bases mínimas: distroless y Chainguard¶
Por debajo de las variantes -slim y -alpine hay imágenes base pensadas para no contener nada más que el intérprete o el binario de la aplicación, sin shell y sin gestor de paquetes.
| Variante | Base | Tamaño orientativo | Cuándo |
|---|---|---|---|
gcr.io/distroless/python3-debian12 |
Debian sin shell ni gestor de paquetes | Muy pequeña | Mínima superficie de ataque. No se puede entrar con docker exec … sh para depurar. |
cgr.dev/chainguard/python |
Wolfi, una distribución mínima de Chainguard pensada para contenedores, reconstruida a diario con cero CVE como objetivo | Muy pequeña | Igual que distroless, pero con los parches mucho más rápidos. La etiqueta latest es gratuita; las versiones fijadas son de pago. |
El efecto sobre el escaneo es directo: si no hay gestor de paquetes, no hay inventario de paquetes del sistema que analizar, y el informe se queda con las dependencias propias de la aplicación, que son las que de verdad se ejecutan. El precio se paga al depurar, porque dentro del contenedor no hay shell: hay que arrancar un contenedor auxiliar que comparta los espacios de nombres del que falla, o publicar una variante :debug de la misma imagen para esos casos. Para un servicio que se despliega sin intervención y se diagnostica por logs y métricas, compensa; para el laboratorio, donde entrar al contenedor es parte del trabajo, no.
Dependabot¶
Dependabot es el robot de actualización de dependencias integrado en GitHub. Se configura con un fichero del propio repositorio, .github/dependabot.yml, con un bloque por ecosistema y directorio:
version: 2
updates:
- package-ecosystem: "docker"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
- package-ecosystem: "pip"
directory: "/api"
schedule:
interval: "weekly"
Frente a Renovate cubre menos gestores, agrupa peor las actualizaciones relacionadas (no tiene un equivalente cómodo de groupName) y solo funciona en GitHub. A cambio no hay nada que instalar ni ningún token de robot que mantener: se activa desde la configuración del repositorio y las propuestas empiezan a llegar. Como el repositorio del curso está en Gitea, en clase se usa Renovate, pero las propuestas de Dependabot aparecen en cualquier proyecto alojado en GitHub, y sus avisos de seguridad, que salen de la GitHub Advisory Database, son la misma fuente que consulta Renovate en vulnerabilityAlerts.
Un job en el pipeline que vigila versiones¶
La tercera vía para enterarse de las versiones nuevas, además de Renovate y Watchtower, es un job programado en Jenkins que compara lo desplegado con lo publicado y abre una incidencia. Es lo que hace Renovate pero a mano; sirve cuando no se puede instalar nada más o cuando la imagen viene de un registry que Renovate no entiende. Solo tiene sentido con imágenes que están en un registry, porque una imagen construida en local no tiene digest publicado con el que comparar. El ejemplo abre la incidencia con tea, el cliente de línea de comandos de Gitea:
DESPLEGADO=$(ssh ops@10.10.2.10 'docker image inspect --format "{{index .RepoDigests 0}}" $(docker compose -f /opt/servicio/compose.yml images -q app)' | cut -d@ -f2)
PUBLICADO=$(docker manifest inspect registry.lab:5000/app:1.4.2 -v | jq -r '.[0].Descriptor.digest')
if [ "$DESPLEGADO" != "$PUBLICADO" ]; then
tea issues create --repo ops/servicio --title "app:1.4.2 reconstruida" \
--labels actualizacion --description "Desplegado $DESPLEGADO, publicado $PUBLICADO"
fi
Migraciones y actualizaciones de PostgreSQL¶
Cuando una actualización lleva migraciones de esquema, se leen las notas de la versión: unas se ejecutan solas al arrancar, otras requieren un comando (alembic upgrade head, python manage.py migrate, flyway migrate). Hay que comprobar que han terminado antes de dar la actualización por buena, y el rollback deja de ser solo la imagen: hay que saber si la migración es reversible (alembic downgrade -1 en una aplicación con Alembic, el gestor de migraciones de SQLAlchemy) o si toca restaurar la copia. Eso se decide antes de empezar, no a mitad de la ventana de mantenimiento.
Para comprobarlo, a las cinco consultas de integridad.sql de la unidad se añade una sexta que lee la tabla de versiones del gestor de migraciones:
-- 6. Migraciones aplicadas (según el ORM)
SELECT version_num FROM alembic_version; -- SQLAlchemy/Alembic
-- SELECT app, name, applied FROM django_migrations ORDER BY applied DESC LIMIT 5;
-- SELECT version, success FROM flyway_schema_history ORDER BY installed_rank DESC LIMIT 5;
Si la migración cambia columnas, el hash de la consulta 3 cambiará legítimamente: en ese caso se hace sobre las columnas que no cambian (md5(string_agg(id || ':' || price, '|' ORDER BY id))). En una tabla de millones de filas esa suma no es gratis; en el servicio del curso, con decenas de miles, son segundos.
Para PostgreSQL, una versión menor (17.5 → 17.6) es parar y arrancar con la imagen nueva, o apt-get install --only-upgrade y reiniciar el servicio cuando PostgreSQL es un paquete del sistema, que es como está en db01; una mayor (17 → 18) requiere pg_upgrade o volcado y restauración, y eso es un proyecto aparte.
Enlaces¶
- Semantic Versioning 2.0.0: la especificación completa, corta, y la que citan todos los proyectos que dicen seguirla.
- Documentación de Renovate: referencia de todas las opciones de
renovate.json, con la lista de gestores que detecta y los presets comoconfig:recommended. - Trivy, documentación oficial: modos de escaneo, filtros, formatos de salida e integración en CI, con ejemplos para Jenkins y GitLab.
- Especificación CVSS v4.0 de FIRST: qué mide cada métrica del vector y cómo se calcula la puntuación; incluye la calculadora.
- EPSS, de FIRST: el modelo, los datos diarios descargables y por qué complementa a CVSS.
- Catálogo KEV de CISA: la lista de vulnerabilidades con explotación confirmada, descargable en JSON y CSV para cruzarla con el SBOM propio.
- OSV.dev: base de datos abierta de vulnerabilidades por paquete y versión, con API y el proyecto
osv-scanner. - Keep a Changelog: el formato del CHANGELOG, con las categorías y las razones de cada regla.
- Guía de actualización de PostgreSQL: diferencia entre versión menor y mayor, y las tres formas de hacer una mayor.
- Documentación de la imagen oficial de Python en Docker Hub: descripción de cada variante (
slim,alpine,bookworm,trixie) y sus limitaciones.
UT8 · Terminación segura del contenedor¶
Enlaces para ampliar de la UT8. Se recoge además el borrado físico de soportes por tipo, que en el laboratorio no llega a practicarse porque el disco de una máquina virtual es un fichero del hipervisor y no un soporte propio, y cinco apartados que la unidad resume: lo que Proxmox guarda fuera de la VM, OCSP y step-ca, el borrado criptográfico con borg, LUKS y KMS, el borrado de versiones con la CLI de AWS y pg_repack.
Borrado físico de soportes por tipo¶
En el laboratorio no hay soportes propios que destruir: el disco de una VM de Proxmox es un fichero o un zvol, y lo que se escriba desde dentro de la VM no llega al medio físico. En una empresa con máquinas propias, en cambio, el método se elige por tipo de soporte y el acta de baja tiene que decir cuál se usó y quién lo ejecutó.
- Discos magnéticos dedicados.
shred -n 3 -z -v /dev/sdbhace tres pasadas aleatorias y una de ceros.nwipe, el sucesor de DBAN, hace lo mismo con verificación y con un registro de la operación, que es lo que sirve como prueba ante un auditor. Los dos cuentan como Clear. - SSD y NVMe.
shredno vale: el controlador reparte las escrituras entre celdas y deja la copia original en otra página, legible con acceso al chip. La herramienta correcta es el borrado seguro del propio firmware del disco,nvme format /dev/nvme0n1 --ses=1(o--ses=2para el borrado criptográfico, si el disco lo soporta) yhdparm --security-eraseen los que hablan SATA.blkdiscard /dev/nvme0n1descarta el dispositivo entero, pero sin garantía de que las celdas se borren de inmediato. - Cinta. El borrado lo hace el software de la librería, no el sistema operativo del host.
- Desmagnetizado. Un desmagnetizador somete el soporte a un campo magnético que destruye la codificación. Cuenta como Purge en discos magnéticos y en cintas, y deja el soporte inservible. En un SSD no sirve de nada, porque la información no se guarda de forma magnética.
- Destrucción física. Trituración o perforación con proveedor certificado, que devuelve un certificado de destrucción con el número de serie de cada soporte. Es lo que exigen los pliegos cuando la categoría del sistema es alta, cuando hay datos de salud o cuando el contrato lo especifica.
La regla que ordena todo esto es la de siempre: si los datos estuvieron cifrados desde el principio y la clave se ha destruido, ninguno de estos métodos hace falta para que sean irrecuperables. El borrado físico es lo que queda cuando esa precaución no se tomó a tiempo.
Lo que Proxmox guarda fuera de la VM¶
Destruir una VM con qm destroy --purge no se lleva todo lo que Proxmox guarda de ella. En el laboratorio del curso no hace falta ninguna de estas dos cosas, porque no hay Proxmox Backup Server y las direcciones de pre son fijas, pero en una instalación con copias y con el IPAM del SDN en uso sí.
En Proxmox Backup Server, si está en uso, los snapshots del grupo vm/210 se eliminan con proxmox-backup-client forget o desde la interfaz, y el espacio se libera en el siguiente garbage collect del datastore, que respeta un periodo de 24 horas y 5 minutos sobre los chunks. Hasta entonces, los datos están ahí.
Con el SDN de Proxmox y el IPAM integrado (pve), las IP se asignan por vnet y las reservas se guardan en el IPAM y en la configuración de dnsmasq. Al destruir las vnets de pre se liberan las dos cosas; si alguna sigue porque es compartida, se liberan las IP una a una, cada una en la vnet de su zona:
pvesh delete /cluster/sdn/vnets/preback/ips --zone lab --vnet preback --ip 10.20.2.10
pvesh delete /cluster/sdn/vnets/prefront/ips --zone lab --vnet prefront --ip 10.20.1.10
pvesh delete /cluster/sdn/vnets/predata/ips --zone lab --vnet predata --ip 10.20.3.10
pvesh set /cluster/sdn # aplicar
pvesh get /cluster/sdn/ipams/pve/status --output-format json | jq '.[]|select(.ip|startswith("10.20."))'
OCSP y step-ca¶
La CRL es una lista que el cliente descarga y consulta; OCSP es la alternativa en línea: un respondedor que contesta si un certificado concreto está revocado. Para OCSP con OpenSSL basta un respondedor mínimo sobre el mismo index.txt de la CA del curso:
cd ~/ca
openssl ocsp -index index.txt -CA ca.crt -rsigner ocsp.pem -rkey ocsp.key -port 2560 -text &
openssl ocsp -issuer ca.crt -cert emitidos/<serie>.pem -url http://localhost:2560 -resp_text | grep 'Cert Status'
# Cert Status: revoked
Si la CA es step-ca, es step ca revoke --cert app01.pre.lab.pem --key app01.pre.lab.key --reason cessationOfOperation y el propio step-ca responde OCSP. El certificado de la CA no se toca; lo que se revoca son los certificados que firmó. Y la clave privada del servicio se destruye con el resto del entorno, que es lo que hace innecesaria la revocación en la práctica pero no en la teoría: la revocación cubre las copias de la clave cuya existencia se desconoce.
Borrado criptográfico con borg, LUKS y KMS¶
La unidad desarrolla el borrado criptográfico con restic y resume el resto en una tabla. Estos son los detalles de cada uno.
Con borg, la clave está en el repositorio (modo repokey) o en ~/.config/borg/keys/ (modo keyfile): borg key export indica cuál y dónde, y se destruyen las copias de la clave y la passphrase. Con LUKS, cryptsetup luksErase /dev/sdb1 borra todos los keyslots de la cabecera (queda la cabecera sin claves; cryptsetup luksDump muestra los slots vacíos) y la copia de la cabecera guardada con luksHeaderBackup hay que destruirla también. Con KMS (el servicio gestor de claves del proveedor) en la nube, aws kms schedule-key-deletion --key-id <id> --pending-window-in-days 7: el borrado es diferido a propósito y en esos siete días cualquier objeto cifrado con esa clave sigue siendo legible.
Cuando el repositorio de borg se conserva y solo se borran las copias de un servicio, el equivalente al forget --prune de restic es borg delete --glob-archives 'pre-*' repo y luego borg compact repo; sin compact borg solo marca los segmentos y el espacio, y los datos, siguen.
Borrar versiones con la CLI de AWS¶
En un bucket con versionado, mc rm --versions borra las versiones de un prefijo de una vez. Con la CLI de AWS se hace versión por versión: listar todas las versiones y marcadores del prefijo, borrarlos explícitamente por VersionId, quitar las reglas de ciclo de vida y comprobar que no hay replicación a otro bucket:
aws --endpoint-url http://10.10.0.30:29000 s3api list-object-versions --bucket backups --prefix pre/ \
--query '{Objects: [Versions[].{Key:Key,VersionId:VersionId}, DeleteMarkers[].{Key:Key,VersionId:VersionId}][]}' \
--output json > versiones.json
aws --endpoint-url http://10.10.0.30:29000 s3api delete-objects --bucket backups --delete file://versiones.json
aws --endpoint-url http://10.10.0.30:29000 s3api list-object-versions --bucket backups --prefix pre/ # vacío
mc replicate ls lab/backups # sin réplicas
delete-objects admite 1000 claves por llamada; con más, se trocea. Con Object Lock en modo governance, el borrado necesita --bypass-governance-retention y el permiso correspondiente; en modo compliance no hay forma de borrar antes de la fecha de retención.
pg_repack: reescribir sin bloquear¶
VACUUM FULL bloquea la tabla en exclusiva mientras la reescribe, lo que en una tabla grande en producción puede ser una hora sin servicio. pg_repack hace lo mismo sin bloqueo largo (crea una copia, la sincroniza con triggers y cambia los ficheros al final): pg_repack -d servicio -t usuarios. En los dos casos el fichero antiguo se desvincula, no se sobrescribe, y necesita el doble del tamaño de la tabla mientras dura la copia.
Enlaces¶
- NIST SP 800-88 rev. 1, Guidelines for Media Sanitization: la referencia para Clear, Purge y Destroy por tipo de soporte; es lo que citan los pliegos.
- Reglamento (UE) 2016/679 (RGPD): artículos 5, 17 y 30 y considerando 26 para entender conservación, supresión y anonimización.
- LOPDGDD, Ley Orgánica 3/2018: el artículo 32 (bloqueo de datos) es el que da forma técnica a "qué se conserva".
- Docker Compose:
docker compose down: qué borra cada opción y qué deja. - Distribution registry: API HTTP y garbage collection: borrado por digest y recolección de blobs.
- OpenTofu:
tofu destroyytofu state rm: destruir sin llevarse lo compartido. - Proxmox VE: página de manual de
qm: opciones--purgey--destroy-unreferenced-disksdeqm destroy. - Grafana Loki: borrado de logs y retención: API del compactor, modos de borrado y
retention_stream. - Prometheus: API de administración del TSDB:
delete_seriesyclean_tombstones. - PostgreSQL 17: VACUUM: qué hace VACUUM y qué hace VACUUM FULL con el espacio.
- restic: gestión de claves y forget y prune: cómo se protege la clave maestra y qué reescribe prune.
- Amazon S3: borrar versiones de objetos: por qué un DELETE en un bucket versionado no borra.
- PhotoRec, CGSecurity: cómo recupera ficheros por firma y por qué no le afecta el sistema de ficheros.
- man shred(1) y man blkdiscard(8): las advertencias sobre SSD y sistemas copy-on-write están en la propia página de manual.
Bibliografía y enlaces¶
Los enlaces concretos de cada unidad están en la página Para ampliar, junto con los apartados que van más allá de lo que se hace en clase. Aquí está lo transversal. La documentación de Proxmox, OpenTofu, Ansible, Jenkins y las plataformas de nube está en la bibliografía de la asignatura de despliegue.
Documentación oficial¶
| Herramienta | Enlace | Qué mirar |
|---|---|---|
| Prometheus | prometheus.io/docs | PromQL, reglas, Alertmanager, buenas prácticas de instrumentación |
| Alertmanager | prometheus.io/docs/alerting | Rutas, inhibición, receptores, plantillas |
| Loki | grafana.com/docs/loki | LogQL, Promtail, retención, ruler |
| Grafana | grafana.com/docs/grafana | Dashboards, alerting, provisioning |
| cAdvisor | github.com/google/cadvisor | Métricas expuestas y despliegue |
| Docker | docs.docker.com/engine/logging | Drivers de logs, eventos, límites de recursos |
| cgroups v2 | kernel.org cgroup-v2 | De dónde salen las métricas de recursos |
| nftables | wiki.nftables.org | Filtrado por host |
| k6 | grafana.com/docs/k6 | Scripts, umbrales, escenarios, salidas |
| OWASP ZAP | zaproxy.org/docs | Baseline scan, informes |
| Postman / newman | learning.postman.com | Ejecución de colecciones en CI |
| fail2ban | github.com/fail2ban/fail2ban | Jails y filtros |
| CrowdSec | docs.crowdsec.net | Alternativa colaborativa |
| restic | restic.readthedocs.io | Copias, retención, restauración |
| MinIO | min.io/docs | Versionado, Object Lock, políticas |
| Trivy | trivy.dev/docs | Escaneo de imágenes, ficheros y configuración |
| Syft y Grype | github.com/anchore | SBOM y escaneo |
| Renovate | docs.renovatebot.com | Configuración y reglas de paquetes |
| NVD, OSV, KEV | nvd.nist.gov, osv.dev, cisa.gov/kev | Fichas de vulnerabilidades y prioridad |
| PostgreSQL | postgresql.org/docs | pg_dump, VACUUM, pg_stat |
| NIST SP 800-88 rev. 1 | csrc.nist.gov | Borrado seguro de soportes |
Libros¶
- Betsy Beyer y otros, Site Reliability Engineering y The Site Reliability Workbook (Google). Gratis en sre.google/books. Los capítulos de monitorización, alertas, SLO y gestión de incidentes son la base de las UT2 y UT4.
- Brian Brazil, Prometheus: Up & Running (O'Reilly, 2ª ed.). El libro de referencia de Prometheus, escrito por uno de sus desarrolladores.
- Charity Majors y otros, Observability Engineering (O'Reilly). Por qué los logs estructurados y la correlación cambian la forma de depurar.
- W. Curtis Preston, Modern Data Protection (O'Reilly). Copias de seguridad explicadas por alguien que lleva treinta años restaurándolas.
- Liz Rice, Container Security (O'Reilly). Qué hay dentro de una imagen y por dónde se ataca.
Para practicar fuera del aula¶
- Prometheus de demostración de PromLabs, con datos reales para ejecutar consultas, y su chuleta de PromQL.
- Grafana Play para explorar dashboards, Loki y alertas sin instalar nada.
- k6 examples con scripts de todos los tipos de prueba.
- OWASP Juice Shop para probar ZAP contra una aplicación hecha para ser atacada.
- Awesome Prometheus alerts con reglas de alerta para casi cualquier exporter, como punto de partida.
Comunidades y noticias¶
- Blog de Grafana Labs y blog de Prometheus.
- Aqua Security blog sobre Trivy y seguridad de contenedores.
- oss-security para avisos de vulnerabilidades en software libre.
- r/sysadmin y r/devops.
Normativa¶
- Real decreto del curso de especialización: se busca en el BOE. El resumen de RA y CE está en la página de presentación.
- RGPD y LOPDGDD para plazos de conservación y borrado de datos personales (UT6 y UT8).
Créditos de las imágenes¶
| Fichero | Autor y licencia | Origen |
|---|---|---|
| prometheus-arquitectura.svg | Proyecto Prometheus, Apache 2.0 | github.com/prometheus/prometheus |
| grafana-dashboard.png | Joel Kennedy, dominio público | Wikimedia Commons |
| cvss4.svg | FIRST, CC BY-SA 4.0 | Wikimedia Commons |
| semver.svg | Surjit Bains, CC BY-SA 4.0 | Wikimedia Commons |
| fail2ban-logo.png | Proyecto fail2ban, GFDL | Wikimedia Commons |
| dmz-un-firewall.svg | Pbroks13, dominio público | Wikimedia Commons |
| Logos de Prometheus, Grafana, Loki y Jenkins | Marcas de sus titulares, uso nominativo | Wikimedia Commons, jenkins.io |