
🌐 También en: English · Deutsch · Français
Parte 6 de la visita a Ansible, y la última. La parte 1 explicó el marco, la parte 2 el servidor y la plataforma, las partes 3 y 4 las dos aplicaciones, la parte 5 la seguridad. Esta parte trata de saber qué está pasando. Describe la versión v3.3.0.
Un servidor que nadie vigila falla en silencio. La instalación de aquí vigila de dos maneras, y ayuda mantenerlas separadas:
| Tipo | Pregunta que responde | Roles |
|---|---|---|
| Métricas | ¿Cómo le va al servidor a lo largo del tiempo? Memoria, disco, CPU, base de datos, PHP – como curvas | common_node_exporter, common_mysqld_exporter, common_kube_metrics, los exporters sidecar (partes 3 y 4), common_prometheus, common_grafana |
| Perro guardián | ¿Hay algo roto ahora mismo? Un servicio caído, un pod en un bucle de fallos, una copia de seguridad que falta, un certificado a punto de caducar | common_monit |
Ambos envían correo, pero de forma distinta: Grafana cuando una métrica cruza un umbral, Monit cuando cambia un estado. Y ambos siguen la regla de la parte 5 – silencio mientras todo va bien, para que cada correo signifique algo.
📈 Cómo fluyen las métricas
El modelo es pull: unos programas llamados exporters ofrecen sus números como una página de texto por HTTP, y Prometheus recoge esa página cada 15 segundos, guarda cada valor con una marca de tiempo y lo conserva 30 días. Grafana pregunta a Prometheus y dibuja las curvas. Nada empuja datos a ningún sitio, y nada sale del servidor.
| Exporter | Se ejecuta | Informa de | Prometheus lo alcanza en |
|---|---|---|---|
| Node Exporter | en el host, ambas pilas | CPU, memoria, discos, sistemas de archivos, red, systemd | 127.0.0.1:9100 |
| mysqld_exporter | en el host, solo Nextcloud | el MariaDB del host: conexiones, consultas, InnoDB | 127.0.0.1:9104 |
| mysqld_exporter | sidecar en el pod de MariaDB, blog | el MariaDB del blog | un NodePort en 127.0.0.1 |
| php-fpm_exporter | sidecar en el pod de la aplicación, ambas | workers de PHP-FPM: ocupados, libres, cola, peticiones lentas | un NodePort en 127.0.0.1 |
| cAdvisor | en el kubelet, ambos stacks | CPU, memoria, limitación, eventos OOM por contenedor | https://127.0.0.1:10250 con token |
| kube-state-metrics | pod en kube-system, ambos | límites, reinicios, motivos de finalización desde la API | NodePort 30808 en 127.0.0.1 |
Todo escucha en localhost o en NodePorts que el cortafuegos descarta desde fuera (parte 5). Prometheus también se enlaza a localhost; solo Grafana es accesible desde fuera, y solo a través del Ingress bajo /grafana/, con su propio inicio de sesión.
🧰 Los exporters: el patrón del binario fijado, una vez más
common_node_exporter y common_mysqld_exporter son pequeños y casi idénticos, y son el ejemplo más claro de un patrón que introdujo la parte 2:
- Un usuario de sistema sin shell de inicio de sesión y sin directorio personal – el proceso no debe ejecutarse como root, y nadie entra nunca como este usuario.
- Leer la versión instalada (
node_exporter --version | awk ..., conchanged_when: false,check_mode: false,failed_when: false– en un host nuevo no hay binario y la respuesta vacía significa «instalar»). - Descargar, descomprimir e instalar solo si difiere de la versión fijada en
versions.yml, con la guarda de simulaciónnot ansible_check_modeen los pasos que necesitan el archivo descargado. - Una unidad de systemd más un drop-in de sandbox, escritos con
copyycontent:, y dos handlers en un orden fijo: recargar systemd y luego reiniciar el servicio.
En el paso 3 hay una pequeña historia. Antes el archivo comprimido era la marca de «ya instalado»: descargar solo si falta /tmp/node_exporter.tar.gz. Pero /tmp se vacía con cada reinicio – así que cada ejecución tras un reinicio volvía a descargar e informaba de un cambio, aunque la versión correcta estuviera instalada. Comparar la versión instalada con la fijada es la prueba honesta.
Node Exporter no tiene archivo de configuración; todos los ajustes son opciones de línea de comandos en la unidad. El rol lo señala, junto con un aviso que vale para cualquier rol que escriba un archivo con content:: las líneas de comentario dentro del contenido forman parte del archivo en el servidor. Editar ahí un comentario cambia la unidad y reinicia el servicio.
El exporter de MariaDB añade una cosa: credenciales. Las lee de un archivo que solo él puede leer (modo 0600, propiedad de su propio usuario), escrito con no_log: true. El usuario con el que entra es la cuenta de solo lectura de la parte 4 – con su propia contraseña, ya no la de root. Su meta/argument_specs.yml es el que los demás roles citan como ejemplo explicado: cuando arranca el rol, Ansible comprueba cada opción listada, las obligatorias deben existir, cada valor debe coincidir con su tipo, y no_log en una opción la mantiene fuera del mensaje de error de la validación.
🗄️ common_prometheus: la base de datos de series temporales
Prometheus se instala igual que los exporters – usuario de sistema, directorios para la base de datos, binario fijado, unidad de systemd, drop-in de sandbox. El comentario de cabecera del rol es el más amable con principiantes de todo el repositorio: explica register, loop, los handlers, el modo check y la diferencia entre template y copy desde cero. Cada tarea que cambia algo que lee Prometheus notifica al mismo handler, restart prometheus – un binario nuevo y una configuración nueva siguen siendo un solo reinicio.
Una configuración de recogida, dos pilas
scrape_configs:
- job_name: "node"
static_configs:
- targets: ["127.0.0.1:9100"]
labels:
instance: "{{ inventory_hostname }}"
{% if stack == 'nextcloud' %}
- job_name: "mysqld_nextcloud"
static_configs:
- targets: ["127.0.0.1:9104"]
- job_name: "php_fpm_nextcloud"
static_configs:
- targets: ["127.0.0.1:<nodeport>"]
{% endif %}
{% if stack == 'wordpress' %}
- job_name: "mysqld_wordpress"
...
- job_name: "php_fpm_wordpress"
...
{% endif %}Otra vez la variable stack de la parte 1. Hasta que los jobs pasaron a depender de la pila, los cuatro jobs de aplicación se generaban en cada servidor, y cada servidor mostraba permanentemente dos objetivos «down» – los puertos de la otra pila. Inofensivo, pero ruido justo en la vista donde se buscan los problemas reales.
Reglas de alerta sin correo
La plantilla también define reglas de alerta: disco por debajo del 20 y del 10 por ciento libre, memoria por debajo del 10 por ciento, CPU por encima del 90 por ciento durante diez minutos, Node Exporter inaccesible. Prometheus las evalúa y las muestra en su página /alerts – pero no envía correo. Para eso haría falta un segundo componente, Alertmanager, que a propósito no está instalado. Los correos vienen en su lugar de Grafana y Monit, y el archivo lo dice en sus primeras líneas, para que nadie espere un correo que no puede llegar.
📊 common_grafana: curvas, tras una subruta, y correo
Grafana es la única parte de la monitorización con interfaz web, y el rol que la configura es un buen caso de estudio de cómo hablar con una aplicación que no tiene módulo de Ansible. Sus tres archivos se ejecutan en orden, porque el segundo y el tercero necesitan un Grafana en marcha:
install.yml: el paquete y unas cuantas líneas ini
Grafana viene de su repositorio oficial, con las versiones beta excluidas. Después lineinfile fija líneas sueltas en grafana.ini: el usuario y la contraseña de administrador (del vault, con no_log), sin cambio de contraseña forzado en el primer inicio de sesión, escucha en todas las interfaces – porque el Ingress llega a él desde la red de pods a través del Service externo de las partes 3 y 4 – y los dos ajustes para funcionar bajo una subruta, root_url y serve_from_sub_path. Sin ellos, Grafana generaría enlaces a /login en lugar de a /grafana/login.
- name: Grafana | Wait for API to become available
ansible.builtin.uri:
url: "http://127.0.0.1:3000/api/health"
status_code: 200
register: grafana_health
check_mode: false
until: grafana_health.status == 200
retries: 30
delay: 3El módulo uri hace peticiones HTTP desde el servidor – aquí al propio Grafana, en localhost. Con until consulta el endpoint de salud hasta que Grafana responde, como mucho 90 segundos.
dashboards.yml: comprobar y luego crear
La API de Grafana no tiene idempotencia propia: «crear fuente de datos» dos veces crea dos fuentes de datos. El rol lo hace seguro con una pareja de peticiones:
- name: Grafana | Check if Prometheus datasource already exists
ansible.builtin.uri:
url: "http://127.0.0.1:3000/api/datasources/name/Prometheus"
user: "..."
password: "..."
force_basic_auth: true
status_code: [200, 404]
register: grafana_ds_check
- name: Grafana | Create Prometheus datasource
ansible.builtin.uri:
url: "http://127.0.0.1:3000/api/datasources"
method: POST
body_format: json
body: { name: Prometheus, type: prometheus, url: "http://127.0.0.1:9090", ... }
when: grafana_ds_check.status == 404status_code: [200, 404] declara esperadas ambas respuestas – «existe» y «no existe» –, de modo que solo un error real (401, 500) hace fallar la tarea. La petición de creación solo se ejecuta con un 404. Es «primero preguntar, luego actuar» para una API HTTP.
Los tres paneles (métricas del host, MySQL, PHP-FPM) vienen de grafana.com y se importan a través de la API. El JSON descargado se vuelve a leer con slurp – en Base64, de ahí | b64decode | from_json – y se fusiona con un ID fijo mediante el filtro combine, de modo que cada importación sobrescribe el mismo panel en lugar de añadir una copia. Todo el bloque de importación se omite en modo check. Después, un pequeño script de Python ajusta las variables de un panel a los nombres de job de esta instalación. Una limitación figura en el rol: uri nunca informa «changed» por sí mismo, así que las escrituras en la API no aparecen en el resumen – las parejas de comprobar y crear solo evitan que se repitan. Un cuarto panel, Container-Ressourcen, no se descarga: su JSON está en el repositorio (files/container-resources.json), lleva su propio ID fijo y se envía tal cual.
alerting.yml: correo por el mismo relay
Para el correo, Grafana necesita ajustes SMTP, y el rol usa el mismo relay que los correos propios del servidor (parte 2), así que no hay una segunda cuenta de correo. Los ajustes se escriben con blockinfile: gestiona todo un bloque de líneas entre dos comentarios marcadores y, en la siguiente ejecución, reemplaza exactamente ese bloque. Luego meta: flush_handlers reinicia Grafana de inmediato, porque las tareas siguientes configuran las alertas a través del Grafana en marcha, que ya debe saber cómo enviar correo. Tras el reinicio hay una segunda espera al endpoint de salud.
Después, de nuevo con comprobar y luego crear: un punto de contacto por correo, la política de notificación por defecto apuntando a él, una carpeta para las reglas y las propias reglas – escritas por un script de Python que el rol deja en el servidor y ejecuta, con las credenciales pasadas como variables de entorno solo para esa tarea. Las reglas:
| Alerta | Salta cuando |
|---|---|
| Partición raíz | más del 80 % usado, durante 5 minutos |
| Partición de datos | más del 80 % usado, durante 5 minutos |
| Memoria | más del 90 % usado, durante 5 minutos |
| CPU | carga media por encima del 85 %, durante 5 minutos |
«Durante 5 minutos» importa: un pico aislado – una copia de seguridad, una generación de vistas previas – no despierta a nadie.
📦 common_kube_metrics: una curva por contenedor
Node Exporter ve el host en su conjunto. Si el contenedor de PHP se frena en su límite de CPU mientras el host está ocioso, o si un pod se acerca poco a poco a su límite de memoria, queda invisible – el 16/10/2026 no había ni una sola serie por contenedor. Desde la versión v3.3.0, dos fuentes más cierran ese hueco:
- cAdvisor está integrado en el kubelet, no hay nada que instalar. Publica CPU, memoria, limitación y eventos OOM de cada contenedor en
https://127.0.0.1:10250/metrics/cadvisor– pero solo a quien llama con un token. El rol crea un ServiceAccountprometheus-scrapercuyo único permiso esgetsobrenodes/metrics, y un Secret de tiposervice-account-token.common_prometheuslo lee conk8s_info(con reintentos, hasta que el controlador de tokens lo ha rellenado) y lo escribe en/etc/prometheus/kubelet-token. - kube-state-metrics convierte los objetos del servidor de API en números: límites y requests, reinicios, el motivo por el que un contenedor terminó por última vez. Se ejecuta en
kube-systemcon permisos de solo lectura, sin root y con un sistema de archivos raíz de solo lectura, detrás del NodePort 30808 – solo en local, como los demás exportadores.
- job_name: "kubelet_cadvisor"
scheme: https
metrics_path: /metrics/cadvisor
authorization:
credentials_file: /etc/prometheus/kubelet-token
tls_config:
insecure_skip_verify: true # K3s's self-signed kubelet certificate, 127.0.0.1 only
static_configs:
- targets: ["127.0.0.1:10250"]
metric_relabel_configs:
- source_labels: [container]
regex: "|POD" # pause containers and pod-level sums
action: dropLa regla de relabel descarta aproximadamente la mitad de las series antes de guardarlas. El cuarto panel de Grafana, Container-Ressourcen, pone cada contenedor en relación con su propio límite – cuota de CPU, limitación, memoria, reinicios, OOM kills – y cuatro alertas por correo vigilan lo mismo: memoria por encima del 90 % del límite, limitación por encima del 50 %, un OOM kill, más de dos reinicios en 15 minutos.
🐕 common_monit: el perro guardián
Monit existe por un incidente contado en su propio artículo: fail2ban había muerto en el blog y nadie se dio cuenta durante dos días. Las métricas no ayudaron – un fail2ban muerto no aparece como una curva. Monit comprueba estados: cada dos minutos ejecuta una lista de comprobaciones, y solo envía correo cuando una comprobación pasa de ok a fallida, o al revés. Un servidor sano no envía nada.
El rol, paso a paso
assertsobre los ajustes de correo. Monit envía sus propios correos por el relay; sin credenciales sería un perro guardián que no puede ladrar. Mejor fallar al principio del rol con un mensaje que diga qué valores del vault faltan.- Averiguar qué servicios existen aquí. El mismo rol se ejecuta en ambas pilas, pero no todos los servicios existen en cada host (el exporter de MariaDB solo en Nextcloud). Así que:
- name: Monit | Detect which candidate services exist on this host
ansible.builtin.command: "systemctl cat {{ item }}.service"
register: monit_unit_check
changed_when: false
failed_when: false
loop: "{{ monit_candidate_services }}"
- name: Monit | Build the list of present services
ansible.builtin.set_fact:
monit_present_services: >-
{{ monit_candidate_services
| zip(monit_unit_check.results)
| selectattr('1.rc', 'equalto', 0)
| map(attribute='0') | list }}systemctl cat imprime un archivo de unidad y sale con un código distinto de cero si la unidad no existe – una forma de solo lectura de preguntar «¿está instalado?». La segunda tarea es un fragmento compacto de Jinja: zip empareja cada nombre candidato con su resultado (elemento 0 = nombre, elemento 1 = resultado), selectattr('1.rc', 'equalto', 0) se queda con las parejas cuyo resultado tuvo código de salida 0, y map(attribute='0') vuelve a sacar los nombres. La plantilla de configuración escribe entonces una comprobación solo para esos.
- Paso 3 – scripts de comprobación y configuración desde
files/y dos plantillas. Los scripts son pequeños y cada uno responde a una pregunta con su código de salida: ¿está activo este servicio? ¿Está cargada la tabla del cortafuegos con sus sets de bloqueo? ¿Hay algún pod en bucle de fallos o sin poder descargar su imagen (los estados breves de «creating» se ignoran)? ¿Leen los jails de fail2ban el log actual del Ingress? ¿Está algún certificado a menos de tres semanas de caducar? - Paso 4 – validar y luego arrancar.
monit -tcomprueba la configuración; el rol arranca Monit y confirma que está activo – con unfailed_whenque lee la salida desystemctl is-active.
Qué vigila Monit
| Comprobación | Avisa cuando | Por qué este umbral |
|---|---|---|
| Sistema | carga o memoria demasiado altas durante tres ciclos | los picos cortos son normales |
| Sistema de archivos raíz | más del 90 % de espacio o de inodos | la última línea, después del 80 % de Grafana |
| fail2ban | no está en marcha – Monit lo reinicia; se rinde y avisa tras tres reinicios en poco tiempo | el incidente con el que empezó todo |
| Servicios | SSH, K3s, Node Exporter, Prometheus, Grafana y – en Nextcloud – el exporter de MariaDB no activos | solo los que existen en este host |
| Cortafuegos | falta la tabla o sus sets de bloqueo | un cortafuegos sin su tabla no protege nada |
| Pods | bucle de fallos, error al descargar la imagen, OOM | los pods pendientes durante un reinicio no son un error |
| Jails de fail2ban | un jail lee un log del Ingress obsoleto | el problema de los jails ciegos de la parte 5 |
| Certificados | quedan menos de 21 días | la renovación empieza a los 30 días; 21 significa que lleva más de una semana fallando – a tiempo para arreglarlo |
| Copia de seguridad | la última copia correcta tiene más de 3 días | la copia se recoge desde fuera; así sabe el servidor que se hizo |
Dos comprobaciones tienen su propio horario, en sintaxis de cron: certificados y copias de seguridad una vez por hora en lugar de en cada ciclo. No se gana nada abriendo una conexión TLS a cada certificado cada dos minutos. La comprobación de la copia es un truco bonito: la tarea de copia externa actualiza un archivo en el servidor tras cada ejecución correcta, y Monit avisa cuando la marca de tiempo de ese archivo se hace demasiado antigua. El servidor no puede ver la copia, pero sí puede ver que nadie le ha hablado de ninguna.
El umbral de los certificados tiene historia. Una vez, una política de imágenes (parte 3) bloqueó en silencio el solucionador de desafíos de cert-manager, y no se pudo renovar ningún certificado. Nadie lo notó hasta que un certificado estaba a punto de caducar. Ahora Monit lo nota con tres semanas de antelación.
Una autoprueba que envía correos reales
¿Cómo sabes que el perro guardián ladra? tasks/selftest.yml detiene Node Exporter un momento, espera a que Monit informe del fallo y lo vuelve a arrancar – lo que envía un correo de alarma real y un correo real de recuperación. Eso es una intervención, así que solo se ejecuta a petición, desde los roles de verificación:
ansible-playbook blog.yml --limit <host> -e monit_selftest=trueAñadido el 19/10/2026: desde que se escribió esta parte, la monitorización ha crecido. common_selinux_monitoring da a Prometheus, a los exportadores y a Grafana sus propios dominios SELinux (primero en una fase de aprendizaje permisiva), Monit corre en un sandbox de systemd, y Grafana registra en warn en lugar de info. Dos roles nuevos, primero en el blog: common_loki (Loki + Grafana Alloy: journal, logs de pods, logs de auditoría y de fail2ban, 14 días, con correos ante accesos bloqueados por SELinux, errores de fail2ban, OOM kills del kernel y falta de logs) y common_trivy (un escaneo nocturno de todas las imágenes en marcha; correo solo para hallazgos CRITICAL/HIGH corregibles nuevos). Ambos tendrán su propio deep dive.
🛠️ En operación: quién envía qué
Todos los correos de toda la visita, en un solo sitio – esta es la tabla que conviene tener junto a la bandeja de entrada:
| De | Correo | Significado | Parte |
|---|---|---|---|
| Rol de verificación | resultado del despliegue | tras cada ejecución del playbook: todas las comprobaciones en verde, o las que fallaron | 1, 3, 4 |
| dnf-automatic | reinicio tras actualización de seguridad / reinicio aún necesario | actualizaciones nocturnas | 2 |
| Grafana | disco, memoria o CPU por encima del umbral | un recurso se está agotando – planificar, no entrar en pánico | 6 |
| Monit | servicio, pod, cortafuegos, jail, certificado, copia de seguridad | algo está roto ahora – mirar | 6 |
| Alarma SSH | nuevo inicio de sesión interactivo | si no fuiste tú: incidente | 5 |
| auditd | k3s_manifests, token, tls, identity | algo que nunca cambia ha cambiado: incidente hasta que se explique | 5 |
| AIDE | cambios de integridad de archivos | leer la lista; esperado tras cambios planificados | 5 |
| rkhunter | avisos | normalmente un cambio de paquete; si no, investigar | 5 |
| ClamAV | archivos infectados encontrados | mirar el archivo y quién lo subió | 5 |
Echar un vistazo
# Monit's view of everything it watches
monit summary
# Prometheus: are all targets up?
curl -s http://127.0.0.1:9090/api/v1/targets | python3 -m json.tool | grep -E '"job"|"health"'
# Prometheus: which of its own alert rules are firing?
curl -s http://127.0.0.1:9090/api/v1/alerts | python3 -m json.tool
# Grafana: https://<your host>/grafana/ – dashboards "Node Exporter Full", MySQL, PHP-FPM, Container-RessourcenLeer las curvas
- Disco: mira la pendiente, no el nivel. Una línea plana al 70 % está bien durante meses; una línea que sube un dos por ciento por semana llega a la alarma del 80 % en un día previsible.
- Memoria: Linux usa la memoria libre como caché de archivos, así que «usada» siempre es alta. Lo que cuenta es «disponible». Si disminuye de forma constante, algo está creciendo.
- PHP-FPM: si todos los workers están ocupados y las peticiones hacen cola, el sitio va lento por muy rápido que sea el servidor. Más workers necesitan más memoria – ver el dimensionamiento de la parte 2.
- MariaDB: las consultas lentas aparecen en el log de consultas lentas, no solo en Grafana. Primero mide, luego optimiza es un artículo justo sobre eso.
🏁 El final de la visita
Ese es cada rol del repositorio – 36 en total, cuatro playbooks, el inventario que los une y las pruebas que los rodean. Si has leído las seis partes, sabes qué se ejecuta en estos servidores y por qué, cómo llegó allí cada pieza, qué hace el código en cada paso y qué hacer cuando llega un correo. Ese era el objetivo: no que copies esta instalación, sino que resulte lo bastante comprensible como para que puedas operarla – o construir la tuya y evitar los errores que están anotados en sus comentarios.
Los roles seguirán cambiando. Los enlaces de esta serie apuntan a la versión v3.3.0, para que sigan mostrando el código que describe el texto. Algunos temas merecen más que una sección – nftables, fail2ban, SELinux y K3s tendrán cada uno su propio artículo, sobre la herramienta en sí en lugar de sobre este repositorio. Se enlazarán aquí.

