
🌐 También en: English · Deutsch · Français
Deep Dive de la serie en torno al Ansible Tour. Nivel: administración Linux avanzada. El Tour explica los roles; los deep dives explican la tecnología que hay debajo – cómo funciona, por qué está aquí, qué hace exactamente esta instalación con ella, cómo se opera en el día a día y qué otras opciones habría habido.
Las métricas dicen que algo va mal: la memoria está al 90 %, un contenedor se ha reiniciado tres veces, el disco se llena. Rara vez dicen por qué. El porqué está en los logs – y en un host K3s están repartidos entre el journal de systemd, un directorio por pod en /var/log/pods, el log de auditoría y un puñado de ficheros de aplicaciones. Hasta ahora, encontrar algo significaba SSH, journalctl, kubectl logs y grep, una fuente tras otra. Este artículo explica cómo Loki y Grafana Alloy lo convierten en un único lugar consultable junto a los paneles, qué corre exactamente en el blog desde el 19 de octubre de 2026 – y qué encontraron las nuevas alertas en su primera hora, antes de que nadie lo buscara.
🧭 Cómo funcionan Loki y Alloy
La idea: indexar las etiquetas, no las palabras
Los sistemas clásicos como Elasticsearch construyen un índice de texto completo: cada palabra de cada línea se puede buscar, y el índice suele ser mayor que los propios logs. Loki hace lo contrario. Solo indexa un pequeño conjunto de etiquetas (labels) por flujo – por ejemplo job="journal", unit="sshd.service", namespace="wordpress" – y guarda las líneas comprimidas en chunks. Una consulta elige primero los flujos por sus etiquetas (barato, mediante el índice) y luego solo recorre los chunks de esos flujos buscando el texto (fuerza bruta, pero sobre pocos datos). Es el mismo modelo que usa Prometheus para las métricas, y por eso encajan tan bien.
De ahí sale una regla que decide si Loki sigue siendo rápido: las etiquetas deben tener pocos valores. Host, job, unidad de systemd, namespace, contenedor – unas pocas docenas de valores cada uno – son buenas etiquetas. Un nombre de usuario, una dirección IP o un ID de petición no lo son: cada valor nuevo crea un flujo nuevo, y miles de flujos diminutos inflan el índice y vuelven inútiles los chunks. Esos valores se quedan en la línea y se filtran al consultar.
Flujos, chunks, índice, compactador
- Flujo (stream): todas las líneas con exactamente el mismo conjunto de etiquetas.
{job="journal", unit="k3s.service", level="info"}es un flujo. - Chunk: un bloque comprimido de líneas de un flujo, que se escribe en el sistema de ficheros cuando está lleno o es lo bastante antiguo.
- Índice (TSDB, esquema v13): asigna conjuntos de etiquetas a chunks, un fichero de índice por día.
- Compactador: une los ficheros de índice diarios y – esto es importante – es el componente que realmente borra los datos más antiguos que el periodo de retención. Sin
retention_enabled, ese ajuste solo oculta los datos antiguos en las consultas.
LogQL en dos pasos
Una consulta LogQL siempre empieza con un selector de flujos entre llaves, seguido de filtros opcionales. Con una ventana de tiempo y una función alrededor, la misma consulta se convierte en una métrica – eso es lo que usan las alertas.
# todas las líneas de la unidad k3s que contienen "error" (sin mayúsculas)
{job="journal", unit="k3s.service"} |~ "(?i)error"
# como número: ¿cuántas de esas líneas en los últimos 15 minutos?
sum(count_over_time({job="journal", unit="k3s.service"} |~ "(?i)error" [15m]))|= significa «contiene», != «no contiene», |~ y !~ son las variantes con regex. Analizadores como | json o | logfmt convierten partes de una línea en etiquetas temporales que solo existen durante la consulta – la manera barata de filtrar por un valor que nunca debe convertirse en una etiqueta real.
Grafana Alloy: el recolector
Alloy es el agente de Grafana, sucesor de Promtail y del Grafana Agent. Su configuración es una cadena de componentes: las fuentes leen logs (journal, ficheros), los procesadores los modifican (analizar, reetiquetar, fijar la marca de tiempo, descartar) y los writers los envían a Loki. Cada componente declara adónde va su salida (forward_to), así que la configuración se lee como un diagrama de flujo de datos. Alloy recuerda en su propio directorio de datos hasta dónde ha leído cada fichero y el journal, de modo que un reinicio no pierde ni repite líneas.
🛡️ Por qué está aquí
- Un solo lugar para todos los logs. Journal, cada pod, el log de auditoría y fail2ban, consultables en Grafana junto a las métricas, durante los últimos 14 días – también para pods que ya no existen y cuyos logs
kubectl logsya no puede mostrar. - Alertas sobre lo que las métricas no ven. Una denegación de SELinux, un baneo fallido de fail2ban o un OOM kill del kernel son líneas de log, no números. Antes solo se descubrían si alguien miraba por casualidad.
- Lo bastante ligero para un servidor pequeño. Loki y Alloy juntos necesitan alrededor de medio gigabyte de RAM y unos pocos megabytes de disco por hora en el blog – una pila de texto completo necesitaría varias veces eso.
🔧 Qué hace este repositorio
El rol common_loki se ejecuta después de common_grafana (cuyo repositorio de paquetes y API utiliza) y antes de common_monit (que luego vigila ambos servicios). Desde el 19 de octubre de 2026 corría en el blog; los dos servidores Nextcloud siguieron el 20 de octubre tras un día de observación.
Paquetes, fijados
Loki y Alloy son RPM del repositorio oficial de Grafana, el mismo del que se instala Grafana. Las versiones están fijadas en versions.yml como cualquier otro componente (loki_version, alloy_version); la prueba de humo al final de la ejecución lee su métrica build_info y avisa si la versión en marcha no es la fijada.
Loki: un binario, disco local, 14 días
El paquete trae una configuración de demostración que lo guarda todo en /tmp/loki – perdido tras cada reinicio – con logs en modo debug. El rol la sustituye por completo. Las partes importantes:
server:
http_listen_address: 127.0.0.1 # solo lee Grafana en el mismo host
grpc_listen_address: 127.0.0.1
common:
path_prefix: /var/lib/loki
replication_factor: 1
ring:
kvstore:
store: inmemory # un proceso, sin clúster
schema_config:
configs:
- from: 2026-10-01
store: tsdb
object_store: filesystem
schema: v13
limits_config:
retention_period: 336h # 14 días
compactor:
retention_enabled: true # la parte que borra de verdad
analytics:
reporting_enabled: falseLa plantilla se comprueba antes de sustituir el fichero real: el validate de Ansible ejecuta loki -verify-config sobre el candidato. Una errata hace fallar la ejecución del playbook, no un servicio que ya no arranca.
Alloy: cuatro fuentes, unas pocas etiquetas
- Journal con las etiquetas
unit,levelytransport.transport="kernel"separa los mensajes propios del kernel – ahí escribe el OOM killer. - Logs de los pods desde
/var/log/pods/<namespace>_<pod>_<uid>/<contenedor>/N.log. Una regla de reetiquetado extrae namespace, pod y contenedor de la ruta;stage.criquita el prefijo de containerd (hora, flujo, marca de línea parcial) y une las líneas partidas. - audit.log. En estos hosts las denegaciones de SELinux no están en el journal – auditd es dueño del socket de auditoría del kernel –, así que Alloy lee el fichero.
- fail2ban.log, porque un baneo fallido solo se registra ahí.
Todos los flujos reciben la etiqueta host; la etiqueta automática filename se descarta, porque solo repetiría información que ya está en las demás etiquetas.
Leer los logs de root sin ser root
El paquete ejecuta Alloy como el usuario alloy, en los grupos adm y systemd-journal. Eso basta para el journal, pero no para audit.log (root, modo 0600), fail2ban.log y los logs de los pods (root, 0640). La solución perezosa sería ejecutar Alloy como root. En su lugar, el rol le da una única capability de Linux, en un drop-in de systemd:
[Service]
AmbientCapabilities=CAP_DAC_READ_SEARCH
CapabilityBoundingSet=CAP_DAC_READ_SEARCH
NoNewPrivileges=yes
ProtectSystem=strict
ReadWritePaths=/var/lib/alloy
ProtectHome=yes
PrivateTmp=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
...CAP_DAC_READ_SEARCH se salta las comprobaciones de lectura en ficheros y directorios – y nada más. Alloy puede leer todos los logs, pero no puede escribir fuera de su directorio de datos, ni cambiar propietarios, ni cargar módulos. Loki recibe un drop-in parecido sin ninguna capability. systemd-analyze security puntúa las dos unidades con 4.0 y 3.8 – «OK» en su escala, donde un servicio sin endurecer ronda el 9.
Grafana: fuente de datos, alertas y una autoprueba
El rol crea la fuente de datos «Loki» a través de la API de Grafana (comprobar primero, crear solo si falta) y cuatro reglas de alerta en la carpeta que ya contiene las alertas de métricas:
| Alerta | LogQL (ventana de 15 minutos) | Por qué |
|---|---|---|
| SELinux ha bloqueado algo | {job="audit"} |= "type=AVC" |= "permissive=0" | Una denegación real. Las líneas permissive=1 de los dominios aún en fase de aprendizaje no cuentan. |
| fail2ban informa de errores | {job="fail2ban"} |= "]: ERROR" != "KeyError('/var/log/pods/" | Una acción de baneo o un filtro está roto. El KeyError excluido es inofensivo (ver más abajo). |
| OOM killer del kernel | {job="journal", transport="kernel"} |~ "(?i)out of memory: killed process" | Cubre también procesos del host, no solo contenedores. |
| No llegan logs | sum(count_over_time({job="journal"}[15m])) < 1 | La propia cadena está rota. Aquí «sin datos» cuenta como alerta. |
Al final, el rol prueba toda la cadena: escribe un marcador único con logger en el journal y consulta Loki hasta que aparece. Si Alloy o Loki están rotos, la ejecución del playbook falla justo ahí.
🔍 La primera hora: cuatro hallazgos que nadie había buscado
Menos de una hora después del primer despliegue, las nuevas alertas ya habían enviado correos. Ninguno era ruido – cada uno señalaba algo real, y dos de ellos errores míos.
1. Líneas viejas, marcas de tiempo nuevas
Las dos primeras alertas saltaron de inmediato: un AVC y un error de fail2ban. La línea de fail2ban era de las 11:13 de esa misma mañana. Lo que había pasado: en su primer arranque, Alloy lee cada fichero desde el principio y, sin más instrucciones, pone a cada línea la hora en que la leyó. Todo el atraso de audit.log y fail2ban.log llegó a Loki como «ahora» – y las alertas, que cuentan las líneas de los últimos 15 minutos, vieron de golpe horas de errores antiguos.
La solución es tomar la marca de tiempo de la propia línea. El log de auditoría lleva el tiempo epoch en msg=audit(1792426019.999:37103), fail2ban escribe la hora local sin zona:
loki.process "files" {
stage.match {
selector = "{job=\"audit\"}"
stage.regex { expression = "msg=audit\\((?P<ts>[0-9]+[.][0-9]+):" }
stage.timestamp { source = "ts" format = "Unix" }
}
stage.match {
selector = "{job=\"fail2ban\"}"
stage.regex { expression = "^(?P<ts>[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9:]{8},[0-9]{3})" }
stage.timestamp { source = "ts" format = "2006-01-02 15:04:05,000" location = "UTC" }
}
...
}La zona horaria no está fijada en el rol: lee la del host con timedatectl y la pasa a la plantilla, para que el mismo rol acierte también en un host que use la hora local.
2. El primer AVC real: un instalador de paquetes
El AVC era real y nuevo: durante la instalación del RPM de Alloy, useradd -m intentó crear .bash_logout en el nuevo directorio personal /var/lib/alloy. Ese directorio tiene el tipo var_lib_t, y la política no permite a useradd_t crear ahí ficheros de usuario. Inofensivo – Alloy no necesita perfil de shell –, pero justo el tipo de evento que antes desaparecía en un log de auditoría de 6 MB.
3. Errores de fail2ban que llevaban días ahí
Los errores de fail2ban en Loki eran de dos clases. Una inofensiva: KeyError('/var/log/pods/…'), el backend de sondeo pierde la pista de un log de pod que se borró en un reinicio; la jail sigue funcionando. La otra no: el 13 y el 14 de octubre, Failed to execute ban jail 'nginx-k3s-bad-paths' action 'nftables…' – baneos que no se produjeron, durante los días en que se estaba rehaciendo la recarga del cortafuegos. Nadie los había visto. Ahora la alerta solo excluye el patrón inofensivo conocido.
4. Un AVC de la siguiente herramienta nueva
Una hora después se instaló Trivy (su propio deep dive llegará). Su unidad de escaneo tenía NoNewPrivileges=yes, y el script llama a k3s kubectl. El binario de k3s pasa al dominio container_runtime_t, y con NoNewPrivileges SELinux rechaza esa transición si la política no la permite explícitamente: avc: denied { nnp_transition }. La alerta de Loki saltó a los pocos minutos del primer escaneo. No era la primera vez que este mecanismo daba problemas aquí – MariaDB y Grafana ya habían chocado con él –, pero sí la primera vez que lo encontraba una máquina y no un servicio caído.
🧰 Administración en el día a día
Recetas de LogQL
# todo lo que SELinux ha denegado hoy, sin los dominios en aprendizaje
{job="audit"} |= "type=AVC" |= "permissive=0"
# qué se ha baneado en la última hora, y por qué
{job="fail2ban"} |= "Ban "
# logs de un pod que ya no existe (namespace y contenedor son etiquetas)
{namespace="wordpress", container="wordpress-fpm"} |= "Fatal"
# mensajes del kernel: OOM, segfaults, hardware
{job="journal", transport="kernel"}
# inicios de sesión SSH con clave, por usuario (analizado al consultar, no es etiqueta)
{job="journal", unit="sshd.service"} |= "Accepted publickey" | regexp "for (?P<user>\\S+) from"
# ¿qué unidades escriben más? (encuentra servicios charlatanes)
topk(10, sum by (unit) (count_over_time({job="journal"}[1h])))¿Llega todo?
# Loki listo, Alloy listo (ambos solo en 127.0.0.1)
curl -s 127.0.0.1:3100/ready
curl -s 127.0.0.1:12345/-/ready
# líneas por fuente en los últimos 10 minutos
for j in journal pods audit fail2ban; do
curl -sG 127.0.0.1:3100/loki/api/v1/query \
--data-urlencode "query=sum(count_over_time({job=\"$j\"}[10m]))"
done
# qué namespaces conoce Loki
curl -s 127.0.0.1:3100/loki/api/v1/label/namespace/values
# la visión de Alloy: avisos de la última hora
journalctl -u alloy -p warning --since -1hDisco y memoria
Tras las dos primeras horas en el blog: Loki unos 240 MB de RSS, Alloy unos 275 MB, entre los dos unos dos minutos de CPU, y 13 MB en /var/lib/loki – unos 150 MB al día, es decir, cerca de 2 GB con los 14 días completos. Los servidores Nextcloud tienen más pods y más tráfico; por eso el rol se quedó primero un día en el blog y se compararon las cifras antes de seguir. Si el disco crece más deprisa de lo esperado, la primera pregunta es qué flujo es grande – topk por unit o namespace lo responde – y la segunda, si ese flujo hace falta guardarlo; Alloy puede descartar líneas con stage.drop antes de que lleguen a Loki.
Conviene saber
- Zona horaria del navegador. Grafana muestra las horas en la zona del navegador; los servidores van en UTC. Una línea «de las 11:13» en un fichero aparece a las 13:13 en Grafana en verano.
- Las líneas viejas se rechazan.
reject_old_samples_max_age: 168h– un fichero con líneas de hace más de una semana (por ejemplo, una copia de seguridad restaurada) no se importa. Es intencionado. - Reiniciar es seguro. Alloy guarda sus posiciones de lectura en
/var/lib/alloy/data. Si se borra ese directorio, vuelve a leerlo todo – ahora con marcas de tiempo correctas, pero duplicado.
🆘 Chuleta: síntoma → comando
La versión corta de este artículo para el momento en que algo va mal. Ejecutar en el host como root; <…> son marcadores.
| Síntoma | Comando |
|---|---|
| ¿Está listo Loki, está listo Alloy? | curl -s 127.0.0.1:3100/ready |
| Faltan los logs de una fuente | curl -sG 127.0.0.1:3100/loki/api/v1/query \ |
| ¿Qué ha denegado SELinux? | {job="audit"} |= "type=AVC" |= "permissive=0" |
| Logs de un pod que ya no existe | {namespace="<ns>", container="<container>"} |= "Fatal" |
| Kernel: OOM, segfaults, hardware | {job="journal", transport="kernel"} |
| El disco crece más de lo previsto | du -sh /var/lib/loki |
| Las horas en Grafana van dos horas desfasadas | # browser time zone vs. UTC on the servers - not a bug |
🔭 Lo que viene
- Un dominio SELinux para Loki y Alloy, como el que recibió la pila de monitorización: hoy corren como
unconfined_service_t, limitados solo por systemd. - Logs y métricas en una sola vista. Grafana puede mostrar bajo un panel de métricas las líneas de log correspondientes al mismo periodo – útil para el panel de contenedores.
- Menos etiquetas, y mejores, para los pods si crece el número de flujos: el nombre del pod cambia con cada reinicio y podría pasar a ser metadato estructurado en lugar de etiqueta.
⚖️ Alternativas – y por qué Loki
| Opción | Punto fuerte | Por qué no aquí |
|---|---|---|
| journald + grep | Gratis, siempre está | Un host, una fuente cada vez, sin alertas, los logs de los pods desaparecen con el pod. |
| ELK / OpenSearch | Búsqueda de texto completo sobre todo, muy potente | Java, varios GB de RAM para un único nodo – más de lo que necesita toda la pila del blog. |
| Graylog | Buena interfaz, flujos y alertas integrados | Probado antes en un laboratorio: necesita MongoDB y OpenSearch – de nuevo pesado para un servidor pequeño. |
| Promtail + Loki | La combinación clásica | Promtail está en modo de mantenimiento; Alloy es su sucesor y además maneja métricas y trazas. |
| Loki + Alloy | Índice por etiquetas como Prometheus, pequeño, nativo en Grafana | Elegido: encaja con el Prometheus/Grafana existente y con el presupuesto de RAM. |
Lo decisivo no fueron las funciones, sino el encaje. Prometheus y Grafana ya estaban, los correos de alerta ya funcionaban, y el modelo de etiquetas es el mismo que el de las métricas. Loki añadió la mitad que faltaba – el porqué detrás de los números – por alrededor de medio gigabyte de RAM.


