12

Logs y monitorización en Podman

Vistas: 1
Tutorial: Tutorial de Podman
Logs y monitorización en Podman

La primera vez que un contenedor se me cayó en producción y no tenía ni idea de qué había pasado, me pasé horas rebuscando entre logs sin sentido. Desde entonces, los logs son mi prioridad número uno. Tus contenedores están funcionando, tienes healthchecks, auto-updates, secretos bien guardados y Quadlets orquestando tus servicios, pero hay algo que todos los sistemas en producción, y también los servidores caseros, necesitan para no volverse una caja negra: logs. Los logs son la memoria de tu infraestructura, te dicen qué pasó y cuándo pasó. Sin logs, cada error es un misterio, cada caída es un acertijo y cada incidente se convierte en una sesión de adivinanzas. En este capítulo vas a aprender todo lo necesario para dominar los logs en Podman: drivers de logging (journald, k8s-file, none, passthrough), podman logs vs journalctl, persistencia y rotación, Quadlets y logs, exportación a syslog, Vector como agregador centralizado, Loki + Grafana, Logspout y el patrón Cloe completo con scripts de monitorización y ejercicios prácticos.

Fundamentos del logging en Podman

¿Qué le pasa a la salida de tu contenedor?

Cada vez que ejecutas un contenedor, todo lo que su proceso principal escribe en stdout y stderr, la salida estándar y la salida de error, es capturado por Podman y enviado al log driver que tengas configurado. Piensa en el log driver como el fontanero que decide dónde va a parar ese río de texto: a un archivo, al journal de systemd, a ninguna parte, o directamente al terminal del contenedor.

Por defecto, Podman usa el driver journald en la mayoría de las distribuciones modernas. Pero tienes varias opciones, y cada una tiene sus pros y sus contras.

Los cuatro jinetes del logging: drivers disponibles

Podman ofrece actualmente estos log drivers:

DriverAlmacenamientoUso típico
journaldJournal de systemdMayoría de casos; integración nativa con el sistema
k8s-fileArchivo JSON en /var/lib/containers/Compatibilidad con Kubernetes; análisis con herramientas externas
noneNo almacena nadaContenedores efímeros, debugging interactivo
passthroughPasa directamente a stderr del contenedorDesarrollo, cuando quieres ver logs en tiempo real

journald, el driver universal

El driver journald envía los logs del contenedor al journal de systemd. Esto significa que puedes usar journalctl para consultar los logs de cualquier contenedor, igual que consultas los logs de cualquier servicio systemd.

Ventajas:

  • Integración total con systemd. Los logs de tus contenedores viven donde viven los logs del sistema.
  • Metadatos enriquecidos: Podman añade etiquetas como CONTAINER_ID, CONTAINER_NAME, IMAGE_NAME a cada entrada del journal.
  • Rotación gestionada por systemd. No tienes que configurar nada extra.
  • Consultas potentes con journalctl: por unidad, por rango de tiempo, por prioridad, etc.

Inconvenientes:

  • Los logs binarios del journal pueden ocupar más espacio que texto plano.
  • Si usas rootless, el journal es por usuario y necesitas --user en journalctl.
  • Dependes de systemd. No funciona en sistemas sin systemd (aunque en Linux modernos eso es raro).

k8s-file, el driver compatible con Kubernetes

El driver k8s-file escribe los logs en un archivo JSON (uno por contenedor) en /var/lib/containers/storage/... (para root) o en ~/.local/share/containers/storage/... (para rootless).

Ventajas:

  • Formato JSON estructurado, fácil de parsear con herramientas como jq.
  • Compatible con el formato de logs de Kubernetes. Si piensas migrar a K8s, este driver te facilita la transición.
  • Los logs persisten aunque systemd no esté disponible.
  • Rotación configurable mediante --log-opt (max-size, max-file).

Inconvenientes:

  • No tiene los metadatos enriquecidos de journald.
  • Ocupa espacio en el sistema de archivos del contenedor.
  • La rotación no es automática, hay que configurarla explícitamente.

none, silencio absoluto

El driver none descarta toda la salida del contenedor. No almacena nada. Es útil para:

  • Contenedores efímeros de pruebas que no necesitas monitorizar.
  • Cuando ejecutas un contenedor en foreground (-it) y ver los logs en pantalla es suficiente.
  • Reducir E/S en discos lentos para contenedores que generan mucho ruido.

Importante: Cuando ejecutas un contenedor en primer plano (foreground) sin -d, los logs simplemente no se persisten porque el contenedor termina al cerrar la sesión interactiva. No es que Podman cambie el driver, es que en modo interactivo no hay almacenamiento de logs más allá de lo que ves en tu terminal.

passthrough, el pelo en la sopa

El driver passthrough pasa la salida del contenedor directamente a stderr sin almacenarla ni procesarla. Básicamente, es como si el contenedor escribiera directamente en el terminal. Se usa principalmente para:

  • Desarrollo y debugging intensivo.
  • Cuando usas herramientas externas que ya gestionan sus propios logs.
  • Evitar la sobrecarga de almacenamiento.

Podman logs vs journalctl

Vale, ya sabemos dónde van los logs. Ahora toca aprender a leerlos. Aquí tienes dos herramientas principales: podman logs y journalctl. No son intercambiables. Cada una tiene su momento.

podman logs: rápido, sencillo, específico

El comando podman logs es el equivalente a docker logs. Te muestra los logs de un contenedor concreto de forma directa.

# Ver logs de un contenedor
podman logs mi-contenedor

# Últimas 50 líneas (como tail -n)
podman logs --tail 50 mi-contenedor

# Seguir logs en tiempo real (como tail -f)
podman logs --follow mi-contenedor

# Desde un momento específico
podman logs --since 2025-01-01T00:00:00 mi-contenedor

# Hasta un momento específico
podman logs --until 2025-01-02T00:00:00 mi-contenedor

# Con marcas de tiempo
podman logs --timestamps mi-contenedor

# Ver nombres de los contenedores
podman logs --names mi-contenedor

¿Cuándo usar podman logs?

  • Cuando quieres los logs de un contenedor específico y ya sabes su nombre.
  • Cuando estás debuggeando un problema concreto.
  • Cuando trabajas rootless y no quieres acordarte del --user de journalctl.
  • Cuando usas el driver k8s-file (journalctl no puede leerlos).

¿Cuándo NO usar podman logs?

  • Cuando necesitas buscar logs entre múltiples contenedores a la vez.
  • Cuando quieres filtrar por prioridad, unidad systemd, o rangos de tiempo complejos.
  • Cuando necesitas los metadatos enriquecidos que aporta journald.

journalctl: la navaja suiza del logging

Si usas el driver journald (el más común), journalctl es una herramienta infinitamente más potente que podman logs. Te permite consultar, filtrar y analizar logs de todos tus contenedores, y del sistema entero, con una sintaxis coherente.

# Logs de un contenedor específico (por nombre)
journalctl CONTAINER_NAME=mi-contenedor

# Logs de un contenedor específico (por ID)
journalctl CONTAINER_ID=abc123def456

# Logs de una imagen concreta
journalctl IMAGE_NAME=docker.io/library/nginx

# Logs en tiempo real de un contenedor
journalctl -f CONTAINER_NAME=mi-contenedor

# Últimas 100 líneas
journalctl -n 100 CONTAINER_NAME=mi-contenedor

# Desde hace 1 hora
journalctl --since "1 hour ago" CONTAINER_NAME=mi-contenedor

# Entre dos fechas
journalctl --since "2025-01-01" --until "2025-01-02" CONTAINER_NAME=mi-contenedor

# Solo errores (prioridad 3 = err)
journalctl -p err CONTAINER_NAME=mi-contenedor

# En rootless
journalctl --user CONTAINER_NAME=mi-contenedor

Un aviso sobre el comodín CONTAINER_NAME=*

Te voy a ahorrar un dolor de cabeza que a mí me costó un buen rato de depuración. Es tentador escribir journalctl CONTAINER_NAME=* para ver los logs de todos los contenedores de golpe. El problema es que ese comodín no es fiable: journalctl interpreta el * como un glob sobre el valor del campo, pero el resultado depende de cómo se indexaron las entradas y de si el journal está rotado o comprimido. En la práctica he visto consultas que devuelven resultados incompletos, o que directamente no devuelven nada aunque haya logs, sobre todo en sistemas con mucha rotación o con journal por usuario.

La alternativa robusta es enumerar los contenedores con podman ps y consultar cada uno por su nombre exacto, o usar el identificador de syslog que Podman asigna (normalmente el nombre del contenedor) con _COMM=conmon. Así, en lugar de un glob que puede fallar en silencio, obtienes siempre resultados fiables:

# En lugar de journalctl CONTAINER_NAME=*
for c in $(podman ps --format '{{.Names}}'); do
    journalctl CONTAINER_NAME="$c" --since "1 hour ago" --no-pager
done

Este patrón es exactamente el que uso en las funciones del patrón Cloe, y es el que te recomiendo que adoptes en tus propios scripts.

Metadatos que añade Podman al journal:

Cuando usas el driver journald, Podman enriquece cada entrada del log con estos campos:

CampoDescripción
CONTAINER_IDID completo del contenedor
CONTAINER_NAMENombre del contenedor
IMAGE_NAMENombre de la imagen (con tag)
CONTAINER_TAGTag adicional si se configuró
POD_NAMENombre del pod (si aplica)
POD_IDID del pod (si aplica)
SYSLOG_IDENTIFIERNormalmente el nombre del contenedor

Diferencia clave

Característicapodman logsjournalctl
Driver requeridoCualquierasolo journald (o passthrough parcial)
Filtro por contenedorSí (uno cada vez)Sí (uno o varios)
Filtro por tiempo--since / --until--since / --until (más potente)
Filtro por prioridadNoSí (-p)
Buscar en todos los contenedoresNo
Metadatos enriquecidosNo
Formato de salidaTexto planoTexto, JSON, verbose
RootlessFunciona igualRequiere --user

Persistencia, rotación y límites

Los logs son como las maletas: si no las controlas, acaban ocupando todo el maletero. De hecho, uno de los incidentes más comunes en servidores es «disco lleno por logs sin rotar». Vamos a ver cómo evitarlo.

Persistencia de logs

La persistencia de los logs depende del driver que uses:

  • journald: Los logs persisten según la configuración de journald.conf. Por defecto, en la mayoría de distribuciones, el journal es volátil (se guarda en /run/log/journal) y se pierde al reiniciar. Para persistencia, necesitas activar el almacenamiento en /var/log/journal.
  • k8s-file: Los logs persisten mientras exista el contenedor. Si eliminas el contenedor (podman rm), los logs se pierden. Para persistencia fuera del ciclo de vida del contenedor, necesitas configurar volúmenes externos.
  • none: No hay persistencia (obviamente).

Activar persistencia del journal

# Crear el directorio de persistencia
sudo mkdir -p /var/log/journal

# Ajustar permisos
sudo systemd-tmpfiles --create --prefix /var/log/journal

# Forzar la persistencia en la configuración
sudo sed -i 's/#Storage=auto/Storage=persistent/' /etc/systemd/journald.conf

# Reiniciar journald
sudo systemctl restart systemd-journald

Rotación y límites en journald

El journal de systemd se auto-gestiona, pero puedes controlar sus límites:

# Ver cuánto espacio ocupa el journal
journalctl --disk-usage

# Limitar el tamaño máximo
sudo journalctl --vacuum-size=500M

# Limitar por tiempo de retención
sudo journalctl --vacuum-time=30d

# Limitar por número de archivos
sudo journalctl --vacuum-files=5

Para hacer estos límites permanentes, edita /etc/systemd/journald.conf:

[Journal]
Storage=persistent
SystemMaxUse=1G
SystemMaxFileSize=200M
MaxRetentionSec=30day
RuntimeMaxUse=500M
RuntimeMaxFileSize=100M

Luego reinicia journald:

sudo systemctl restart systemd-journald

Rotación y límites en k8s-file

Si usas el driver k8s-file, puedes configurar la rotación directamente al crear el contenedor:

podman run -d \
  --log-driver k8s-file \
  --log-opt max-size=10m \
  --log-opt max-file=3 \
  --name mi-web \
  nginx:alpine

Esto crea hasta 3 archivos de log de 10 MB cada uno. Cuando el primero llega a 10 MB, lo rota a .1, luego a .2, y cuando el tercero se llena el más antiguo se descarta.

También puedes configurarlo globalmente en containers.conf:

[containers]
log_driver = "k8s-file"
log_size_max = 10000000  # 10 MB en bytes

[containers.log]
max_size = "10m"
max_file = 3

La ubicación del archivo containers.conf depende de si es global o por usuario:

  • Global: /etc/containers/containers.conf
  • Usuario: ~/.config/containers/containers.conf

Quadlets y logs

Los Quadlets son servicios systemd que ejecutan contenedores. Y como servicios systemd, sus logs acaban en journald de forma natural. Pero hay ciertos matices que debes conocer.

Cómo fluyen los logs en un Quadlet

Cuando un Quadlet arranca un contenedor, systemd captura la salida del proceso de Podman (no la del contenedor directamente). El contenedor, a su vez, envía sus logs al log driver configurado. El resultado es que tienes dos niveles de logs:

  1. Logs del servicio systemd: journalctl -u mi-servicio, muestra la actividad del servicio: arranque, parada, reinicios, errores de Podman.
  2. Logs del contenedor: podman logs mi-contenedor o journalctl CONTAINER_NAME=mi-contenedor, muestra la salida de la aplicación dentro del contenedor.

Configurar el log driver en Quadlets

Puedes especificar el log driver en un Quadlet usando la clave LogDriver:

[Unit]
Description=Servicio web con logs en journald

[Container]
Image=docker.io/library/nginx:alpine
ContainerName=mi-web
LogDriver=journald
PublishPort=8080:80

[Service]
Restart=always

[Install]
WantedBy=default.target

O si prefieres k8s-file con rotación:

[Container]
Image=docker.io/library/nginx:alpine
ContainerName=mi-web
LogDriver=k8s-file
LogOpt=max-size=10m
LogOpt=max-file=3

Usar --log-opt tag para identificar logs

Una característica muy útil es la opción de log tag, que te permite añadir una etiqueta personalizada a las entradas del journal para identificarlas más fácilmente. Se configura con --log-opt tag=...:

podman run -d \
  --log-driver journald \
  --log-opt tag=servidor-web-produccion \
  --name mi-web \
  nginx:alpine

Luego puedes filtrar por esa etiqueta:

journalctl CONTAINER_TAG=servidor-web-produccion

En Quadlets:

[Container]
Image=docker.io/library/nginx:alpine
ContainerName=mi-web
LogDriver=journald
LogOpt=tag=servidor-web-produccion

Integración con sdnotify y logs

Combinando healthchecks con sdnotify (que viste en el capítulo 10), puedes hacer que systemd sepa exactamente cuándo el contenedor está listo, y los logs reflejarán ese estado:

[Container]
Image=docker.io/library/miapp:latest
ContainerName=mi-api
HealthCmd=curl -f http://localhost:3000/health || exit 1
HealthInterval=30s
SdNotifyMode=conmon

[Service]
Type=notify
NotifyAccess=all

Con esto, los logs de journalctl -u mi-api incluirán los mensajes READY=1 que indican que el servicio está listo.

Exportación a syslog

A veces no quieres centralizar los logs con herramientas modernas como Vector o Loki. A veces tienes un servidor syslog clásico, como rsyslog o syslog-ng, funcionando en tu red, y quieres que todos los logs de tus contenedores vayan a parar allí.

Opción 1: Configurar Podman para enviar a syslog

Podman no tiene un log driver «syslog» nativo como Docker, pero puedes conseguirlo de dos formas:

A través de journald + rsyslog:

Configuras Podman con --log-driver journald y luego rsyslog reenvía los logs del journal a un servidor syslog remoto.

En /etc/rsyslog.d/50-podman.conf:

# Reenviar logs de contenedores Podman a syslog remoto
if $programname startswith 'conmon' or $syslogtag contains 'podman' then {
    *.* @servidor-syslog.local:514
    stop
}

# También puedes filtrar por el campo CONTAINER_NAME
#$template PodmanLogFormat,"%msg%\n"
#:syslogtag, contains, "podman" @@servidor-syslog.local:514

Reinicia rsyslog:

sudo systemctl restart rsyslog

Usando Vector como puente (lo veremos en detalle más adelante):

Vector puede leer del journal y escribir a un sink syslog. Es más flexible y moderno.

Opción 2: Contenedor con syslog-ng como sidecar

Otra aproximación es ejecutar un contenedor con syslog-ng que escuche en un socket UDP/TCP y reenvíe a tu servidor central:

podman run -d \
  --name syslog-relay \
  -p 514:514/udp \
  -v /path/to/syslog-ng.conf:/etc/syslog-ng/syslog-ng.conf:ro \
  docker.io/balabit/syslog-ng:latest

Luego configuras tus contenedores para que envíen logs a ese relay.

Vector como agregador centralizado

Llegamos al plato fuerte. Vector es un pipeline de observabilidad de alto rendimiento, desarrollado por Datadog, que puede recolectar, transformar y enrutar logs (y métricas) de forma increíblemente eficiente. Está escrito en Rust, consume muy pocos recursos, y es ideal para entornos de contenedores.

¿Por qué Vector?

  • Rendimiento: Puede procesar cientos de miles de eventos por segundo con mínima CPU.
  • Flexibilidad: Decenas de sources, transforms y sinks listos para usar.
  • Seguridad: Soporta TLS, autenticación, buffering con confirmación de entrega.
  • Simple: Un solo binario, configuración en YAML/TOML, sin dependencias externas.
  • Idempotente: Garantiza exactly-once delivery en la mayoría de sinks.

Arquitectura típica

                    ┌─────────────┐
                    │ Contenedor A│──► journald ──┐
                    └─────────────┘               │
                                                  │
                    ┌─────────────┐               │
                    │ Contenedor B│──► journald ──┼──► Vector ──► Loki ──► Grafana
                    └─────────────┘               │
                                                  │
                    ┌─────────────┐               │
                    │ Contenedor C│──► k8s-file ──┘
                    └─────────────┘

Vector se ejecuta como un contenedor más (o como servicio systemd) y recolecta logs de todas las fuentes disponibles.

Instalar Vector

Puedes instalarlo como binario nativo o ejecutarlo como contenedor. La forma más limpia en un entorno Podman es como contenedor:

podman run -d \
  --name vector \
  --restart always \
  -v /var/log/journal:/var/log/journal:ro \
  -v /run/systemd/journal/socket:/run/systemd/journal/socket:ro \
  -v /etc/vector/vector.toml:/etc/vector/vector.toml:ro \
  docker.io/timberio/vector:latest

Configuración básica de Vector

Vector se configura con un archivo TOML (o YAML). Aquí tienes una configuración completa que recolecta logs del journal de systemd, añade metadatos, y los envía a Loki:

[sources.podman_journal]
type = "journald"
data_dir = "/var/lib/vector"
current_boot_only = true
batch_size = 100
# Filtro: solo eventos de Podman
include_matches = ["CONTAINER_NAME=*"]

[transforms.clean_podman]
type = "remap"
inputs = ["podman_journal"]
source = '''
  # Limpiar y estructurar los logs
  .message = del(.MESSAGE)
  .container_name = del(.CONTAINER_NAME) ?? "unknown"
  .container_id = del(.CONTAINER_ID) ?? "unknown"
  .image_name = del(.IMAGE_NAME) ?? "unknown"
  .host = del(.HOSTNAME) ?? "localhost"
  .timestamp = del(.__REALTIME_TIMESTAMP)
  .priority = del(.PRIORITY) ?? "6"
'''
[transforms.filter_errors]
type = "filter"
inputs = ["clean_podman"]
condition = '''
  .priority == "3"  # err
  || .priority == "4"  # warning
  || .priority == "2"  # crit
  || .priority == "1"  # alert
  || .priority == "0"  # emerg
'''

[sinks.prometheus]
type = "prometheus_exporter"
inputs = ["clean_podman"]
address = "0.0.0.0:9598"

[sinks.all_logs_to_loki]
type = "loki"
inputs = ["clean_podman"]
endpoint = "http://loki.local:3100"
encoding.codec = "json"
labels.container_name = "{{ container_name }}"
labels.host = "{{ host }}"
labels.image = "{{ image_name }}"
batch_max_bytes = 500000
batch_timeout_secs = 5

[sinks.errors_to_syslog]
type = "socket"
inputs = ["filter_errors"]
address = "syslog-server.local:514"
mode = "udp"
encoding.codec = "json"

Un apunte sobre el comodín aquí. Antes te avisé de que journalctl CONTAINER_NAME=* es poco fiable. Ese aviso va dirigido a la línea de comandos de journalctl, que interpreta el glob de forma inconsistente según cómo esté indexado el journal. En Vector la cosa cambia: el source journald usa su propio motor de coincidencia de patrones sobre los campos, y ahí el comodín * sí funciona de forma fiable para seleccionar todas las entradas que tienen ese campo. Es decir, no te estoy contradiciendo, son dos implementaciones distintas del mismo glob. Si aun así prefieres la máxima precisión, puedes enumerar los contenedores con podman ps y construir una lista explícita de CONTAINER_NAME=... en include_matches.

Veamos qué hace cada parte:

  • Source podman_journal: Lee del journal de systemd, filtrando solo eventos que tienen CONTAINER_NAME (es decir, solo logs de contenedores).
  • Transform clean_podman: Usa VRL (Vector Remap Language) para extraer los campos relevantes del journal y estructurarlos.
  • Transform filter_errors: Filtra solo errores (prioridad 3 o menor) para enviarlos a syslog.
  • Sink all_logs_to_loki: Envía todos los logs a Loki, etiquetándolos por contenedor, host e imagen.
  • Sink errors_to_syslog: Envía solo errores a un servidor syslog.
  • Sink prometheus: Expone métricas de Vector para monitorizar el monitorizador.

Configuración para leer archivos k8s-file

Si usas el driver k8s-file en lugar de journald, Vector puede leer directamente los archivos JSON:

[sources.k8s_file_logs]
type = "file"
include = ["/var/lib/containers/storage/**/*/logs/*.log"]
data_dir = "/var/lib/vector"

[transforms.parse_k8s_file]
type = "remap"
inputs = ["k8s_file_logs"]
source = '''
  # Parsear el formato JSON de k8s-file
  parsed = parse_json!(.message)
  .log = parsed.log
  .stream = parsed.stream
  .time = parsed.time
'''

Vector como contenedor rootless

Si ejecutas Podman en modo rootless, el journal es por usuario y Vector necesita acceder a él. Ojo con un detalle que me ha dado más de un quebradero de cabeza: no existe un flag --user rootless ni nada parecido en podman run. El flag --user espera un UID numérico o un nombre de usuario real del sistema, así que si lo escribes así tal cual, Podman te va a responder con un error del tipo unknown user.

La clave para que Vector lea el journal de tu usuario no está en el flag --user, sino en montar el socket del journal de tu usuario dentro del contenedor. Ese socket vive en /run/user/$(id -u)/systemd/journal/socket, y es lo que Vector necesita para hablar con el journald de tu sesión:

podman run -d \
  --name vector \
  -v /run/user/$(id -u)/systemd/journal/socket:/run/systemd/journal/socket:ro \
  -v $HOME/.config/vector/vector.toml:/etc/vector/vector.toml:ro \
  docker.io/timberio/vector:latest

Fíjate en que monto el socket del usuario en la ruta interna /run/systemd/journal/socket, que es donde Vector espera encontrarlo por defecto. Si prefieres no montarlo ahí, puedes indicarle a Vector la ruta exacta con el parámetro journald_path del source. Y un apunte más, si el socket del usuario no existe (por ejemplo porque no tienes una sesión de usuario activa), puedes crearlo con systemctl --user start systemd-journald o simplemente usar el socket del sistema con los permisos adecuados.

Loki + Grafana

Una vez que Vector te está enviando los logs a Loki, necesitas algo para visualizarlos y consultarlos. Ahí entra Grafana.

¿Qué es Loki?

Loki es un sistema de agregación de logs inspirado en Prometheus. En lugar de indexar el contenido completo de cada log (como hace Elasticsearch), Loki indexa solo las etiquetas (labels) y deja el contenido sin indexar. Esto lo hace:

  • Mucho más barato en almacenamiento.
  • Más rápido para consultas con etiquetas.
  • Perfecto para logs de Kubernetes y contenedores.

Desplegar Loki con Podman

podman run -d \
  --name loki \
  --restart always \
  -p 3100:3100 \
  -v /data/loki:/loki:Z \
  docker.io/grafana/loki:latest \
  -config.file=/etc/loki/local-config.yaml

O mejor, como Quadlet:

[Unit]
Description=Loki log aggregator
After=network-online.target

[Container]
Image=docker.io/grafana/loki:latest
ContainerName=loki
PublishPort=3100:3100
Volume=/data/loki:/loki:Z
Exec=-config.file=/etc/loki/local-config.yaml

[Service]
Restart=always

[Install]
WantedBy=default.target

Desplegar Grafana con Podman

podman run -d \
  --name grafana \
  --restart always \
  -p 3000:3000 \
  -v /data/grafana:/var/lib/grafana:Z \
  docker.io/grafana/grafana:latest

O como Quadlet:

[Unit]
Description=Grafana dashboard
After=network-online.target loki.service
Requires=loki.service

[Container]
Image=docker.io/grafana/grafana:latest
ContainerName=grafana
PublishPort=3000:3000
Volume=/data/grafana:/var/lib/grafana:Z

[Service]
Restart=always

[Install]
WantedBy=default.target

Conectar Grafana con Loki

  1. Abre Grafana en http://localhost:3000 (usuario: admin, contraseña: admin).
  2. Ve a Configuration → Data Sources → Add data source.
  3. Selecciona Loki.
  4. En URL, pon http://loki:3100 (o la IP del host si usas redes separadas).
  5. Haz clic en Save & Test.

Una vez conectado, puedes consultar logs con LogQL, el lenguaje de consulta de Loki:

# Todos los logs de un contenedor
{container_name="mi-web"}

# Logs de error de cualquier contenedor
{container_name=~".+"} |= "error"

# Logs de los últimos 15 minutos
{container_name="mi-api"} |= "ERROR" |= "timeout"

# Contar errores por contenedor
count by (container_name) ({container_name=~".+"} |= "error")

Profundizando en LogQL

LogQL es el lenguaje de consulta de Loki, y aunque al principio parece un primo lejano de PromQL, tiene sus propias reglas que merece la pena dominar. La base de toda consulta es un selector de etiquetas entre llaves, que es lo que determina qué streams de logs vas a mirar. A partir de ahí puedes encadenar expresiones de línea (filtros) y expresiones de etiquetas (transformaciones).

El selector admite varios operadores: = para igualdad exacta, != para negación, =~ para coincidencia con expresión regular y !~ para la negación de esa expresión regular. Por ejemplo, para ver los logs de todos los contenedores menos la base de datos:

{container_name=~".+"} !~ "cloe-db"

Luego vienen los filtros de línea, que se encadenan con |= (contiene), != (no contiene), |~ (coincide con regex) y !~ (no coincide). La gracia es que puedes encadenar varios seguidos para afinar la búsqueda, como ya viste en el ejemplo anterior. Un truco que uso a menudo es buscar un patrón con regex:

{container_name="cloe-api"} |~ "timeout|refused|connection reset"

Y aquí llega la parte que más me gusta, las expresiones de rango con funciones de agregación. Puedes calcular tasas de errores por segundo, que es oro puro para detectar picos:

# Tasa de errores por segundo en los últimos 5 minutos
sum by (container_name) (
  rate({container_name=~".+"} |= "error"[5m])
)

También puedes usar count_over_time para contar eventos en una ventana, o topk para quedarte con los contenedores que más errores generan:

# Los 3 contenedores con más errores en la última hora
topk(3, sum by (container_name) (count_over_time({container_name=~".+"} |= "error"[1h])))

Estas consultas las puedes guardar como paneles en Grafana, y de repente tienes un dashboard de logs que responde solo. Es la diferencia entre mirar logs a mano y tener la infraestructura vigilándose sola.

Logspout, el router minimalista

Logspout es una alternativa ligera a Vector cuando solo necesitas reenviar logs sin transformaciones complejas. Es un pequeño contenedor que se conecta al socket de Docker/Podman y reenvía todos los logs a uno o varios destinos.

¿Por qué Logspout?

  • Ultra-ligero: El binario ocupa ~10 MB.
  • Simple: Configuras destinos con variables de entorno. Fin.
  • Múltiples adaptadores: syslog, HTTP, TCP, UDP, logstash, papertrail, etc.
  • Ideal para: cuando solo quieres centralizar logs sin procesarlos.

Desplegar Logspout

podman run -d \
  --name logspout \
  --restart always \
  -v /var/run/docker.sock:/var/run/docker.sock:ro \
  -e SYSLOG_FORMAT=rfc3164 \
  -e ROUTE_URIS=syslog://syslog-server.local:514 \
  docker.io/gliderlabs/logspout:latest

Atención: Logspout necesita acceso al socket de Podman (que por compatibilidad suele estar en /var/run/docker.sock). Si no existe, crea un alias:

sudo ln -s /run/podman/podman.sock /var/run/docker.sock

Rutas múltiples con Logspout

podman run -d \
  --name logspout \
  -v /var/run/docker.sock:/var/run/docker.sock:ro \
  -e ROUTE_URIS="syslog://syslog-server.local:514,tcp://logs-central.local:5000" \
  docker.io/gliderlabs/logspout:latest

Logspout a Loki (vía HTTP)

podman run -d \
  --name logspout \
  -v /var/run/docker.sock:/var/run/docker.sock:ro \
  -e ROUTE_URIS="http://loki.local:3100/loki/api/v1/push" \
  docker.io/gliderlabs/logspout:latest

Limitaciones de Logspout

  • No hace transformaciones: lo que sale del contenedor, lo reenvía tal cual.
  • No tiene buffer con acknowledgments; si el destino falla, pierdes logs.
  • No soporta filtrado avanzado: o reenvías todo, o no reenvías nada.
  • Usa el socket Docker; necesitas el socket expuesto, lo cual tiene implicaciones de seguridad.

En resumen: Logspout es genial para salir del paso o para entornos pequeños. Vector es la opción seria para producción.

Monitorización de métricas con podman stats

Los logs te dicen qué ha pasado, pero no siempre te dicen por qué. Para eso están las métricas. Y aunque en este capítulo nos hemos centrado en logs, no puedo dejar pasar la oportunidad de enseñarte una de las herramientas más infravaloradas de Podman: podman stats.

Qué es podman stats

podman stats te muestra en tiempo real el consumo de CPU, memoria, red y disco de tus contenedores, en una tabla que se actualiza sola. Es el equivalente a top pero orientado a contenedores, y es la primera línea de defensa cuando algo va lento y no sabes por dónde empezar:

podman stats

La salida te muestra, por cada contenedor, el uso de CPU, la memoria usada y el límite, el tráfico de red de entrada y salida, y la E/S de bloques. Si tienes muchos contenedores, puedes filtrar por los que te interesan:

podman stats --no-stream cloe-web cloe-api

El flag --no-stream es clave: en lugar de quedarse actualizando la tabla en bucle, imprime una única instantánea y termina. Es perfecto para usarlo dentro de scripts, porque puedes capturar la salida y procesarla sin que el comando se quede colgado.

Combinar métricas con logs

Aquí es donde las métricas y los logs se dan la mano. Una de las cosas que más me han ayudado es cruzar ambos mundos: cuando veo un pico de CPU en podman stats, voy a los logs de ese contenedor a ver qué estaba haciendo en ese momento. Para automatizar esa correlación, puedes volcar las métricas a un archivo y filtrar por el contenedor que te interesa:

podman stats --no-stream --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}" > /tmp/metricas.txt
cat /tmp/metricas.txt

Y si quieres ir un paso más allá, puedes usar el formato json para parsear las métricas con jq y alimentar tu propio sistema de alertas:

podman stats --no-stream --format json | jq -r '.[] | select((.cpu_percent | rtrimstr("%") | tonumber) > 80) | .name'

Ese pequeño one-liner te devuelve el nombre de todos los contenedores que están consumiendo más del 80% de CPU. Puedes meterlo en un cron o en un bucle de tu script de monitorización, y tienes un detector de fugas de recursos sin instalar nada más.

Un apunte sobre el consumo de memoria

Ojo con una trampa que me ha mordido más de una vez: la columna de memoria de podman stats muestra el uso dentro del contenedor, no el del proceso en el host. Si un contenedor usa mucha memoria caché, puede parecer que consume más de lo que realmente necesita. No te asustes al primer vistazo, mira la tendencia a lo largo del tiempo antes de sacar conclusiones.

Con esto, ya tienes la pareja completa: los logs para saber qué pasó y las métricas para saber cuánto costó. Juntas forman la base de cualquier monitorización que se precie.

Todo junto

Llegó el momento de ponerlo todo junto. Vamos a construir un sistema completo de logging y monitorización para un despliegue con varios servicios, usando Quadlets, journald, Vector, Loki y Grafana.

Estructura del proyecto

~/podman-logs-cloe/
├── quadlets/
│   ├── web.container
│   ├── api.container
│   ├── db.container
│   ├── vector.container
│   ├── loki.container
│   └── grafana.container
├── config/
│   ├── vector.toml
│   └── loki-config.yaml
├── scripts/
│   ├── monitor.sh
│   ├── functions.sh
│   └── check-logs.sh
└── ejercicios/
    ├── ejercicio-01.md
    ├── ejercicio-02.md
    └── ejercicio-03.md

1. Los servicios

Primero, despleguemos los servicios de ejemplo. Creamos los Quadlets para una web, una API y una base de datos:

quadlets/web.container:

[Unit]
Description=Servicio web Nginx
After=network-online.target

[Container]
Image=docker.io/library/nginx:alpine
ContainerName=cloe-web
LogDriver=journald
LogOpt=tag=cloe-web
PublishPort=8080:80
HealthCmd=wget --no-verbose --tries=1 --spider http://localhost:80/ || exit 1
HealthInterval=30s

[Service]
Restart=always

[Install]
WantedBy=default.target

quadlets/api.container:

[Unit]
Description=API de ejemplo
After=network-online.target web.service

[Container]
Image=docker.io/library/python:3.12-alpine
ContainerName=cloe-api
LogDriver=journald
LogOpt=tag=cloe-api
PublishPort=3000:3000
Exec=sh -c "pip install flask && python -m http.server 3000"
HealthCmd=curl -f http://localhost:3000/health || exit 1

[Service]
Restart=always

[Install]
WantedBy=default.target

quadlets/db.container:

[Unit]
Description=Base de datos PostgreSQL
After=network-online.target

[Container]
Image=docker.io/library/postgres:17-alpine
ContainerName=cloe-db
LogDriver=journald
LogOpt=tag=cloe-db
Volume=pgdata-cloe:/var/lib/postgresql/data:Z
Environment=POSTGRES_PASSWORD_FILE=/run/secrets/db_password
Secret=db_password,type=env,target=POSTGRES_PASSWORD_FILE

[Service]
Restart=always

[Install]
WantedBy=default.target

2. Vector como pipeline central

quadlets/vector.container:

[Unit]
Description=Vector log pipeline
After=network-online.target

[Container]
Image=docker.io/timberio/vector:latest
ContainerName=cloe-vector
LogDriver=journald
Volume=/etc/vector/vector.toml:/etc/vector/vector.toml:ro,Z
Volume=/run/systemd/journal/socket:/run/systemd/journal/socket:ro

[Service]
Restart=always

[Install]
WantedBy=default.target

config/vector.toml:

[sources.podman_journal]
type = "journald"
current_boot_only = true
include_matches = ["CONTAINER_TAG=cloe-*"]

[transforms.structure]
type = "remap"
inputs = ["podman_journal"]
source = '''
.message = del(.MESSAGE)
.container = del(.CONTAINER_TAG) ?? "unknown"
.host = del(.HOSTNAME) ?? "localhost"
.timestamp = del(.__REALTIME_TIMESTAMP) ?? now()
.level = match(string!(.PRIORITY ?? "6"), r'^[0-2]$') ? "critical" :
.PRIORITY == "3" ? "error" :
.PRIORITY == "4" ? "warning" : "info"
'''

[sinks.all_logs]
type = "console"
inputs = ["structure"]
encoding.codec = "json"

[sinks.loki]
type = "loki"
inputs = ["structure"]
endpoint = "http://localhost:3100"
encoding.codec = "json"
labels.container = "{{ container }}"
labels.level = "{{ level }}"
labels.host = "{{ host }}"

[sinks.errors_webhook]
type = "http"
inputs = ["structure"]
uri = "http://localhost:9093/webhook"
encoding.codec = "json"

Solo errores críticos

Se puede filtrar en el transform o con un transform filter adicional

3. Script de monitorización

scripts/functions.sh, funciones reutilizables:

#!/usr/bin/env bash

# ============================================================
# functions.sh — Funciones reutilizables para monitorizar logs
# ============================================================

# Colores para output
INFO="\033[0;34m[INFO]\033[0m"
OK="\033[0;32m[OK]\033[0m"
WARN="\033[0;33m[WARN]\033[0m"
ERROR="\033[0;31m[ERROR]\033[0m"

# Obtener el nombre del contenedor a partir del PID
# Mapea el PID a su cgroup y de ahí al contenedor de Podman
get_container_name_from_pid() {
    local pid=$1
    local cgroup
    local container_id

    # Leer la ruta del cgroup del proceso
    cgroup=$(cat /proc/$pid/cgroup 2>/dev/null | tail -1 || true)

    # En cgroup v2 la ruta incluye el ID del contenedor (podman usa libpod-<id>.scope)
    container_id=$(echo "$cgroup" | grep -oP 'libpod-\K[0-9a-f]{64}' | head -1)

    if [ -z "$container_id" ]; then
        # Fallback a cgroup v1
        container_id=$(echo "$cgroup" | grep -oP 'docker-\K[0-9a-f]{64}' | head -1)
    fi

    if [ -n "$container_id" ]; then
        podman ps --filter "id=$container_id" --format '{{.Names}}'
    else
        echo ""
    fi
}

# Ver logs recientes de un contenedor
logs_recientes() {
    local contenedor=$1
    local lineas=${2:-50}
    local desde=${3:-"1 hour ago"}

    echo -e "$INFO Mostrando últimas $lineas líneas de '$contenedor' desde '$desde'"

    if command -v journalctl &>/dev/null; then
        journalctl CONTAINER_NAME="$contenedor" \
            --since "$desde" \
            -n "$lineas" \
            --no-pager 2>/dev/null || {
            # Fallback a podman logs
            podman logs --tail "$lineas" "$contenedor" 2>/dev/null
        }
    else
        podman logs --tail "$lineas" "$contenedor"
    fi
}

# Contar errores en un intervalo
contar_errores() {
    local contenedor=$1
    local desde=${2:-"24 hours ago"}

    journalctl CONTAINER_NAME="$contenedor" \
        --since "$desde" \
        -p err \
        --no-pager 2>/dev/null | wc -l
}

# Verificar que un contenedor está generando logs
check_logs_activos() {
    local contenedor=$1
    local umbral_minutos=${2:-5}

    local ultimo=$(journalctl CONTAINER_NAME="$contenedor" \
        --since "$umbral_minutos minutes ago" \
        --no-pager 2>/dev/null | tail -1)

    if [ -z "$ultimo" ]; then
        echo -e "$WARN '$contenedor' no ha generado logs en los últimos $umbral_minutos minutos"
        return 1
    else
        echo -e "$OK '$contenedor' está generando logs activamente"
        return 0
    fi
}

# Mostrar estadísticas de journald
estado_journal() {
    echo -e "$INFO Estado del journal de systemd:"
    echo "-------------------------"
    echo "Uso en disco: $(journalctl --disk-usage 2>/dev/null || echo 'N/A')"
    # Contar logs de contenedores iterando por nombre (el comodín * no es fiable)
    local total=0
    for c in $(podman ps --format '{{.Names}}'); do
        n=$(journalctl CONTAINER_NAME="$c" --since '1 hour ago' --no-pager 2>/dev/null | wc -l)
        total=$((total + n))
    done
    echo "Logs de contenedores: $total en la última hora"
}

# Resetear logs de un contenedor (solo k8s-file)
reset_logs() {
    local contenedor=$1
    echo -e "$WARN ¿Reiniciar logs de '$contenedor'? (requiere recrear el contenedor)"
    echo -e "$INFO Los logs de journald no se pueden resetear individualmente"
    echo -e "$INFO Para journald: sudo journalctl --rotate && sudo journalctl --vacuum-size=100M"
}

# Exportar logs a archivo
exportar_logs() {
    local contenedor=$1
    local archivo=${2:-"logs-$contenedor-$(date +%Y%m%d).json"}

    echo -e "$INFO Exportando logs de '$contenedor' a '$archivo'"

    journalctl CONTAINER_NAME="$contenedor" \
        --output=json \
        --no-pager 2>/dev/null > "$archivo" || {
        podman logs "$contenedor" > "$archivo" 2>&1
    }

    echo -e "$OK Exportados $(wc -l < "$archivo") eventos a $archivo"
}

scripts/monitor.sh, script principal de monitorización:

#!/usr/bin/env bash

# ============================================================
# monitor.sh — Monitorización integral de logs en Podman
# ============================================================

source "$(dirname "$0")/functions.sh"

MODO="${1:-simple}"  # simple, detallado, alerta
INTERVALO="${2:-60}"  # segundos entre chequeos
CONTENEDORES=$(podman ps --format '{{.Names}}')

echo -e "$INFO Iniciando monitorización de logs (modo: $MODO, intervalo: ${INTERVALO}s)"
echo "============================================"

case "$MODO" in
    simple)
        # Modo simple: solo lo esencial
        echo -e "$INFO Contenedores activos:"
        for c in $CONTENEDORES; do
            errores=$(contar_errores "$c" "1 hour ago")
            estado=$(check_logs_activos "$c" 5 && echo "✅" || echo "⚠️")
            echo "  $estado $c — $errores errores en la última hora"
        done
        ;;

    detallado)
        # Modo detallado: información completa
        for c in $CONTENEDORES; do
            echo ""
            echo "--- $c ---"
            logs_recientes "$c" 10 "30 minutes ago"
            err_count=$(contar_errores "$c" "1 hour ago")
            echo "Errores (1h): $err_count"
        done
        estado_journal
        ;;

    alerta)
        # Modo alerta: monitoreo continuo
        echo -e "$INFO Monitoreando en tiempo real. Presiona Ctrl+C para salir."
        while true; do
            clear
            echo "=== $(date) ==="
            for c in $CONTENEDORES; do
                err_count=$(contar_errores "$c" "5 minutes ago")
                if [ "$err_count" -gt 0 ]; then
                    echo -e "$ERROR $c: $err_count errores en los últimos 5 minutos"
                    # Mostrar los errores
                    journalctl CONTAINER_NAME="$c" \
                        --since "5 minutes ago" \
                        -p err \
                        --no-pager 2>/dev/null | tail -5
                else
                    echo -e "$OK $c: sin errores"
                fi
            done
            sleep "$INTERVALO"
        done
        ;;

    exportar)
        # Exportar logs de todos los contenedores
        dir="export-logs-$(date +%Y%m%d-%H%M%S)"
        mkdir -p "$dir"
        for c in $CONTENEDORES; do
            exportar_logs "$c" "$dir/$c.json"
        done
        echo -e "$OK Logs exportados a $dir/"
        ;;

    *)
        echo "Modos: simple, detallado, alerta, exportar"
        exit 1
        ;;
esac

scripts/check-logs.sh, chequeo de salud de logs:

#!/usr/bin/env bash

# ============================================================
# check-logs.sh — Verifica que el sistema de logs funciona
# ============================================================

source "$(dirname "$0")/functions.sh"

ERRORES=0

echo "=== Diagnóstico del sistema de logs ==="

# 1. Verificar que journald está funcionando
echo -n "1. Journald activo: "
if systemctl is-active systemd-journald &>/dev/null; then
    echo -e "$OK"
else
    echo -e "$ERROR"
    ((ERRORES++))
fi

# 2. Verificar que los contenedores tienen log driver configurado
echo "2. Drivers de log:"
for c in $(podman ps --format '{{.Names}}'); do
    driver=$(podman inspect "$c" --format '{{.HostConfig.LogConfig.Type}}')
    echo "   $c → $driver"
done

# 3. Verificar que Vector está corriendo (si está instalado)
echo -n "3. Vector: "
if podman ps --format '{{.Names}}' | grep -q vector; then
    echo -e "$OK (contenedor activo)"
else
    echo -e "$WARN (no detectado)"
fi

# 4. Verificar que Loki responde
echo -n "4. Loki: "
if curl -sf http://localhost:3100/ready &>/dev/null; then
    echo -e "$OK"
else
    echo -e "$WARN (no responde en :3100)"
fi

# 5. Verificar espacio en disco para logs
echo -n "5. Espacio en disco: "
uso=$(journalctl --disk-usage 2>/dev/null | grep -oP '\d+\.\d+G|\d+G' || echo "0G")
echo "$uso"

# 6. Probar que los logs fluyen
echo -n "6. Generando evento de prueba: "
test_id=$(podman run --rm --log-driver journald \
    docker.io/library/alpine echo "TEST-LOG-$(date +%s)" 2>&1)
sleep 1
# Buscar el evento por nombre de contenedor (el comodín * no es fiable)
if podman ps --format '{{.Names}}' | grep -q .; then
    encontrado=""
    for c in $(podman ps --format '{{.Names}}'); do
        if journalctl CONTAINER_NAME="$c" --since "10 seconds ago" --no-pager 2>/dev/null | grep -q "TEST-LOG"; then
            encontrado="si"
            break
        fi
    done
    if [ -n "$encontrado" ]; then
        echo -e "$OK (logs fluyendo correctamente)"
    else
        echo -e "$ERROR (no se detectó el evento de prueba)"
        ((ERRORES++))
    fi
else
    echo -e "$ERROR (no hay contenedores activos)"
    ((ERRORES++))
fi

echo ""
echo "=== Resultado: $ERRORES problemas detectados ==="
exit $ERRORES

4. Errores típicos y soluciones

ErrorCausaSolución
podman logs no muestra nadaEl contenedor usa driver noneCambia a journald o k8s-file
journalctl CONTAINER_NAME=X vacíoUsas rootless y falta --userAñade --user o usa sudo journalctl
Logs de journald crecen sin controlFalta configuración de límites en journald.confConfigura SystemMaxUse= y MaxRetentionSec=
Vector no puede leer el journalPermisos insuficientesMonta el socket con :ro o ejecuta como root
«Failed to connect to Loki»Loki no está corriendo o el puerto es incorrectoVerifica podman ps y el endpoint en vector.toml
Logspout no conectaFalta el socket DockerCrea el symlink: ln -s /run/podman/podman.sock /var/run/docker.sock
journalctl --disk-usage lentoMuchos logs acumuladosUsa journalctl --vacuum-size=500M para limpiar

Conclusiones

Los logs son la memoria de tu infraestructura, y Podman te ofrece un ecosistema completo para gestionarlos:

Sobre los fundamentos:

  • Podman soporta cuatro log drivers principales: journald, k8s-file, none y passthrough.
  • journald es el recomendado para la mayoría de casos por su integración con systemd.
  • k8s-file es útil para compatibilidad con Kubernetes y parseo con herramientas externas.

Sobre podman logs vs journalctl:

  • podman logs es rápido y directo para un contenedor concreto.
  • journalctl es infinitamente más potente para consultas transversales y filtros complejos.
  • La elección depende del driver que uses y de lo que necesites hacer.

Sobre persistencia y rotación:

  • journald se auto-gestiona pero necesita configuración para persistencia y límites.
  • k8s-file permite rotación configurable con --log-opt max-size y --log-opt max-file.
  • Los límites se configuran en journald.conf y en containers.conf.

Sobre Quadlets y logs:

  • Los Quadlets usan journald por defecto para los logs del servicio.
  • Puedes configurar LogDriver y LogOpt en los Quadlets.
  • La integración con sdnotify enriquece los logs con información de estado.

Sobre centralización:

  • Vector es la opción moderna y potente: pipeline configurable, múltiples sources/sinks/transforms, alto rendimiento.
  • Loki + Grafana proporciona visualización y consultas centralizadas con LogQL.
  • Logspout es la alternativa minimalista para routing simple.
  • La exportación a syslog sigue siendo válida para entornos clásicos.

En el próximo capítulo te contaré cómo hacer backup y restore de volúmenes, migrar contenedores entre hosts, y estrategias de recuperación ante desastres con Podman. Nos vemos allí.

Artículos relacionados

Más información

Deja una respuesta