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:
| Driver | Almacenamiento | Uso típico |
|---|---|---|
journald | Journal de systemd | Mayoría de casos; integración nativa con el sistema |
k8s-file | Archivo JSON en /var/lib/containers/ | Compatibilidad con Kubernetes; análisis con herramientas externas |
none | No almacena nada | Contenedores efímeros, debugging interactivo |
passthrough | Pasa directamente a stderr del contenedor | Desarrollo, 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_NAMEa 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
--userenjournalctl. - 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
--userde 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:
| Campo | Descripción |
|---|---|
CONTAINER_ID | ID completo del contenedor |
CONTAINER_NAME | Nombre del contenedor |
IMAGE_NAME | Nombre de la imagen (con tag) |
CONTAINER_TAG | Tag adicional si se configuró |
POD_NAME | Nombre del pod (si aplica) |
POD_ID | ID del pod (si aplica) |
SYSLOG_IDENTIFIER | Normalmente el nombre del contenedor |
Diferencia clave
| Característica | podman logs | journalctl |
|---|---|---|
| Driver requerido | Cualquiera | solo journald (o passthrough parcial) |
| Filtro por contenedor | Sí (uno cada vez) | Sí (uno o varios) |
| Filtro por tiempo | --since / --until | --since / --until (más potente) |
| Filtro por prioridad | No | Sí (-p) |
| Buscar en todos los contenedores | No | Sí |
| Metadatos enriquecidos | No | Sí |
| Formato de salida | Texto plano | Texto, JSON, verbose |
| Rootless | Funciona igual | Requiere --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:
- Logs del servicio systemd:
journalctl -u mi-servicio, muestra la actividad del servicio: arranque, parada, reinicios, errores de Podman. - Logs del contenedor:
podman logs mi-contenedorojournalctl 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 dejournalctl, que interpreta el glob de forma inconsistente según cómo esté indexado el journal. En Vector la cosa cambia: el sourcejournaldusa 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 conpodman psy construir una lista explícita deCONTAINER_NAME=...eninclude_matches.
Veamos qué hace cada parte:
- Source
podman_journal: Lee del journal de systemd, filtrando solo eventos que tienenCONTAINER_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
- Abre Grafana en
http://localhost:3000(usuario:admin, contraseña:admin). - Ve a Configuration → Data Sources → Add data source.
- Selecciona Loki.
- En URL, pon
http://loki:3100(o la IP del host si usas redes separadas). - 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
| Error | Causa | Solución |
|---|---|---|
podman logs no muestra nada | El contenedor usa driver none | Cambia a journald o k8s-file |
journalctl CONTAINER_NAME=X vacío | Usas rootless y falta --user | Añade --user o usa sudo journalctl |
| Logs de journald crecen sin control | Falta configuración de límites en journald.conf | Configura SystemMaxUse= y MaxRetentionSec= |
| Vector no puede leer el journal | Permisos insuficientes | Monta el socket con :ro o ejecuta como root |
| «Failed to connect to Loki» | Loki no está corriendo o el puerto es incorrecto | Verifica podman ps y el endpoint en vector.toml |
| Logspout no conecta | Falta el socket Docker | Crea el symlink: ln -s /run/podman/podman.sock /var/run/docker.sock |
journalctl --disk-usage lento | Muchos logs acumulados | Usa 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,noneypassthrough. journaldes el recomendado para la mayoría de casos por su integración con systemd.k8s-filees útil para compatibilidad con Kubernetes y parseo con herramientas externas.
Sobre podman logs vs journalctl:
podman logses rápido y directo para un contenedor concreto.journalctles 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-sizey--log-opt max-file. - Los límites se configuran en
journald.confy encontainers.conf.
Sobre Quadlets y logs:
- Los Quadlets usan journald por defecto para los logs del servicio.
- Puedes configurar
LogDriveryLogOpten los Quadlets. - La integración con
sdnotifyenriquece 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
- Tutorial de Podman
- Cómo instalar Podman
- Gestionar imágenes con Podman
- Gestionar contenedores con Podman
- Ejecutar contenedores con Podman
- Journalctl y logs en Systemd
- Logs en Docker