Podman Quadlet — Contenedores como servicios de systemd¶
El problema¶
Tu contenedor arranca perfecto… hasta que reinicias el servidor. Entonces descubres el patrón de siempre: un docker-compose.yml, un restart: always, y una unit de systemd escrita a mano que llama a docker compose up — dos supervisores discutiendo por el mismo proceso. Cuando el contenedor muere, systemd cree que el servicio sigue vivo porque el comando de arranque retornó 0 hace media hora.
Quadlet elimina la capa intermedia: escribes un fichero declarativo, systemd genera la unit y el contenedor es el servicio. Un solo supervisor, dependencias reales, journalctl funcionando como con cualquier otro daemon.
Qué cubre y qué no
Aquí se trata el ciclo de vida: declarar, arrancar, ordenar dependencias, comprobar salud y actualizar. El modelo de privilegios rootless (user namespaces, subuid/subgid) vive en Podman Rootless, que introduce Quadlet de forma breve; esta página es la versión completa.
📋 Tabla de Contenidos¶
- Cómo funciona
- Tu primer .container
- Dónde van los ficheros
- Volúmenes, redes y pods
- Healthchecks y ciclo de vida
- Dependencias y orden de arranque
- Actualizaciones automáticas
- Migrar desde Docker Compose
- Troubleshooting
- Buenas prácticas
- Referencias
Cómo funciona¶
Quadlet es un generador de systemd. No es un daemon ni un proceso: se ejecuta durante daemon-reload, lee tus ficheros .container y escribe units .service efímeras en memoria.
flowchart LR
A["miapp.container"] -->|daemon-reload| B[Generador Quadlet]
B --> C["miapp.service<br/><i>unit generada</i>"]
C -->|systemctl start| D["podman run ..."]
Tres consecuencias prácticas de que sea un generador:
- Todo cambio exige
daemon-reload. Editar el fichero no basta. - No puedes
systemctl enableuna unit generada: no existe como fichero enlazable en disco. El arranque automático se declara con[Install] WantedBy=dentro del propio.container. - Los errores de sintaxis aparecen en
daemon-reload, no al arrancar. Un fichero mal escrito simplemente no genera unit, ysystemctl startresponde "Unit not found".
Tu primer .container¶
# ~/.config/containers/systemd/whoami.container
[Unit]
Description=Servicio whoami
After=network-online.target
[Container]
Image=docker.io/traefik/whoami:latest
PublishPort=8080:80
Environment=WHOAMI_NAME=demo
[Service]
Restart=always
[Install]
WantedBy=default.target
systemctl --user daemon-reload
systemctl --user start whoami.service
systemctl --user status whoami.service
El nombre de la unit se deriva del fichero: whoami.container → whoami.service. Las secciones [Unit], [Service] e [Install] son systemd estándar y se copian tal cual a la unit generada; solo [Container] es específica de Quadlet.
Que sobreviva al cierre de sesión
Los servicios de usuario mueren al desconectarte, salvo que actives lingering:
loginctl enable-linger $USER
WantedBy=default.target arranca el contenedor al hacer login, no al arrancar la máquina. Es el fallo número uno al desplegar rootless en un servidor.
Dónde van los ficheros¶
| Modo | Ruta | Control |
|---|---|---|
| Rootless (recomendado) | ~/.config/containers/systemd/ |
systemctl --user |
| Rootful | /etc/containers/systemd/ |
sudo systemctl |
| Paquetes de distribución | /usr/share/containers/systemd/ |
sudo systemctl |
En rootless usa WantedBy=default.target; en rootful, WantedBy=multi-user.target. Copiar un ejemplo de uno a otro sin cambiar esto genera un servicio que nunca arranca solo.
Volúmenes, redes y pods¶
Cada tipo de recurso tiene su extensión, y los .container se refieren a ellos por nombre de fichero, no por el nombre real del recurso. Quadlet resuelve las dependencias solo.
# ~/.config/containers/systemd/appdata.volume
[Volume]
VolumeName=appdata
Label=app=miapp
# ~/.config/containers/systemd/interna.network
[Network]
Subnet=10.89.0.0/24
Gateway=10.89.0.1
# ~/.config/containers/systemd/miapp.container
[Container]
Image=docker.io/library/nginx:alpine
Volume=appdata.volume:/usr/share/nginx/html:Z
Network=interna.network
PublishPort=8080:80
Referenciar appdata.volume hace dos cosas a la vez: monta el volumen y añade un Requires=/After= sobre appdata-volume.service. El volumen se crea antes de que arranque el contenedor, sin que tengas que ordenarlo tú.
SELinux: :Z y :z
En Fedora, RHEL o cualquier distro con SELinux en enforcing, un volumen sin etiqueta produce "Permission denied" dentro del contenedor aunque los permisos POSIX sean correctos. :Z etiqueta el contenido en exclusiva para ese contenedor; :z lo comparte entre varios. Usa :Z salvo que dos contenedores necesiten el mismo directorio.
Para agrupar contenedores que comparten red — el patrón localhost entre app y sidecar:
# ~/.config/containers/systemd/web.pod
[Pod]
PublishPort=8080:80
# En cada .container del grupo
[Container]
Pod=web.pod
Los puertos se publican en el pod, no en los contenedores. Dentro del pod, los miembros se alcanzan por localhost.
Healthchecks y ciclo de vida¶
Restart=always reinicia el contenedor cuando el proceso muere. No hace nada cuando el proceso sigue vivo pero el servicio está colgado: pool de conexiones agotado, deadlock, bucle infinito. Ese es exactamente el fallo que más tiempo tarda en detectarse.
[Container]
Image=docker.io/library/nginx:alpine
HealthCmd=curl -fsS http://localhost/ || exit 1
HealthInterval=30s
HealthTimeout=5s
HealthRetries=3
HealthStartPeriod=10s
HealthOnFailure=restart
| Clave | Para qué |
|---|---|
HealthCmd |
Comando ejecutado dentro del contenedor; salida 0 = sano |
HealthInterval |
Cada cuánto se comprueba |
HealthRetries |
Fallos consecutivos antes de marcarlo unhealthy |
HealthStartPeriod |
Margen de arranque: los fallos aquí no cuentan |
HealthOnFailure |
Qué hacer al fallar: none, kill, restart, stop |
HealthOnFailure=restart es la clave que convierte el healthcheck en algo más que un adorno del podman ps. Sin ella, el contenedor se queda marcado como enfermo indefinidamente y nadie se entera.
HealthStartPeriod importa más de lo que parece: una base de datos que tarda 40 segundos en aceptar conexiones con un start-period de 10 s entra en bucle de reinicio y nunca llega a arrancar. Mide el arranque real y añade margen.
El healthcheck debe existir dentro de la imagen
HealthCmd=curl ... sobre una imagen alpine sin curl falla siempre, y el síntoma es un contenedor reiniciándose sin causa aparente en los logs. Comprueba qué tienes disponible:
podman exec miapp sh -c 'command -v curl wget nc'
pg_isready, redis-cli ping, nginx -t) o expón un endpoint que responda a wget -qO-.
Estado actual y por qué falló:
podman healthcheck run miapp # ejecutar la comprobación ahora
podman inspect miapp --format '{{.State.Health.Status}}'
podman inspect miapp --format '{{range .State.Health.Log}}{{.Output}}{{end}}'
El mismo razonamiento, aplicado a orquestación, está en Probes de Kubernetes: HealthStartPeriod es el equivalente conceptual de startupProbe.
Dependencias y orden de arranque¶
Con Compose, depends_on solo espera a que el contenedor arranque, no a que el servicio esté listo. Aquí tienes systemd de verdad:
# app.container
[Unit]
Requires=db.service
After=db.service
[Container]
Image=registry.example.com/miapp:1.4.2
Network=interna.network
Requires= propaga el fallo (si db no arranca, app tampoco) y After= fija el orden. Para esperar a que la base de datos esté lista y no solo arrancada, combínalo con un healthcheck en db.container y Restart=on-failure en app: si arranca antes de tiempo, falla y systemd reintenta hasta que la dependencia responde.
Reinicio controlado, sin martillear el servicio:
[Service]
Restart=on-failure
RestartSec=10
StartLimitBurst=5
StartLimitIntervalSec=300
Tras 5 fallos en 5 minutos systemd para de reintentar y deja la unit en failed, donde tu monitorización puede verla. Preferible a un contenedor reiniciándose en bucle durante días sin que salte ninguna alarma.
Actualizaciones automáticas¶
[Container]
Image=docker.io/library/nginx:alpine
Label=io.containers.autoupdate=registry
systemctl --user enable --now podman-auto-update.timer
podman auto-update --dry-run # qué se actualizaría, sin tocar nada
Con registry, Podman compara el digest local contra el del registro y, si difiere, tira la imagen nueva y reinicia la unit. Si el contenedor no arranca, hace rollback automático a la imagen anterior: es la razón por la que esto es utilizable en producción y un watchtower a pelo no.
Alternativa Label=io.containers.autoupdate=local: solo actualiza si construyes la imagen tú mismo en local; no consulta ningún registro.
Auto-update quiere tags móviles, tus despliegues quieren tags fijos
autoupdate=registry sobre :1.4.2 no hace nada: ese tag no cambia. Sobre :latest funciona, pero significa aceptar cualquier versión que publique el mantenedor, incluido un cambio incompatible, a las 3 de la mañana.
Punto medio razonable: tags de minor (:1.4) para servicios de casa, tags fijos y actualización deliberada para lo que no puede caerse. El rollback automático cubre el fallo de arranque, no el cambio de comportamiento que rompe tus datos.
Migrar desde Docker Compose¶
podman-compose existe y funciona, pero mantiene los dos supervisores. Si el objetivo es que systemd gestione el servicio, la traducción es directa:
| Compose | Quadlet |
|---|---|
image: |
Image= |
ports: |
PublishPort= |
volumes: |
Volume= (+ :Z con SELinux) |
environment: |
Environment= |
env_file: |
EnvironmentFile= |
networks: |
Network= |
restart: always |
[Service] Restart=always |
depends_on: |
[Unit] Requires= + After= |
healthcheck: |
HealthCmd= y familia |
user: |
User= |
cap_drop: |
DropCapability= |
| (sin equivalente) | PodmanArgs= para flags sin clave propia |
Atajo para servicios ya en marcha: genera el esqueleto desde el contenedor y edítalo.
podman container create --name tmp -p 8080:80 docker.io/library/nginx:alpine
podman generate systemd --new --files --name tmp # base a portar a .container
generate systemd está deprecado en favor de Quadlet — sirve como punto de partida, no como destino.
Regla de decisión: Compose para desarrollo local (levantar y tirar el stack entero), Quadlet para servidores (arranque, dependencias, healthcheck y logs integrados con el resto del sistema).
Troubleshooting¶
| Síntoma | Causa | Comprobación |
|---|---|---|
Unit miapp.service not found |
Error de sintaxis, o fichero en ruta equivocada | Ver la salida de daemon-reload |
| No arranca al reiniciar el host | Falta lingering | loginctl show-user $USER \| grep Linger |
Permission denied en un volumen |
Etiqueta SELinux ausente | Añadir :Z |
| Reinicios en bucle sin error claro | HealthCmd inejecutable en la imagen |
podman inspect --format '{{range .State.Health.Log}}{{.Output}}{{end}}' |
| Cambios que no se aplican | Falta daemon-reload |
Repetir reload y restart |
| Falla en rootless, funciona en root | Puerto <1024, o subuid sin configurar | sysctl net.ipv4.ip_unprivileged_port_start |
Ver el podman run exacto que Quadlet ha generado — la herramienta que resuelve la mayoría de los casos:
# Podman 5.x
podman quadlet print miapp.container
# Cualquier versión: ejecutar el generador a mano
/usr/lib/systemd/system-generators/podman-system-generator --user --dryrun
Si el podman run resultante no es el que esperabas, el problema está en el fichero, no en Podman. Y los logs, como en cualquier servicio del sistema:
journalctl --user -u miapp.service -f
Buenas prácticas¶
- Un fichero por recurso, versionados en Git y desplegados con Ansible. Los
.containerson texto plano: son infraestructura como código sin herramienta adicional. loginctl enable-lingeren cualquier servidor rootless. Sin él, nada arranca solo.- Healthcheck en todo lo que sirva tráfico, con
HealthOnFailure=restarty unHealthStartPeriodmedido, no adivinado. StartLimitBurstsiempre. Un bucle de reinicio silencioso es peor que un servicio caído y visible.- Referencia
.volumey.networkpor fichero, no por nombre de recurso: las dependencias de arranque salen gratis. - Secretos con
Secret=, no conEnvironment=: las variables de entorno son legibles enpodman inspecty en el journal. Ver gestión de secretos. - Auto-update con criterio. Actívalo donde el rollback automático baste; en lo crítico, actualiza tú.