Todo parece ir bien hasta que reinicias. Entonces el “simple” stack de Docker Compose se convierte en una escena del crimen: los contenedores arrancan en el orden equivocado, los volúmenes aún no están montados, faltan redes y tu base de datos está arriba pero tu app está convencida de que el universo sigue caído.
Compose es excelente describiendo una aplicación. systemd es excelente asegurándose de que la máquina se comporte como tal. Únelos correctamente y dejas de escribir espagueti shell de arranque que sólo funciona cuando nadie mira.
Qué estamos resolviendo (y qué no)
Esto trata sobre ejecutar stacks definidos con Compose de forma fiable tras un reinicio usando systemd. Fiable significa:
- El stack arranca en el inicio sin intervención manual.
- No arranca demasiado pronto (antes de que los discos, la red o Docker estén listos).
- Se apaga limpiamente para no corromper servicios con estado.
- Puedes diagnosticar fallos rápido con las herramientas ya presentes en la máquina.
Esto no trata de convertir Compose en Kubernetes. Compose no va a ser un scheduler, un gestor de clúster autorreparable ni un orquestador multinodo. Si necesitas eso, ya lo sabes y ya tienes cicatrices.
Además: no vamos a hacer “@reboot sleep 30 && docker compose up -d”. Eso no es ingeniería. Es un ritual.
Hechos e historia que realmente importan
Un poco de contexto ayuda porque la mitad de los “problemas Compose + systemd” en realidad son “asumí que el comportamiento antiguo aún aplica”. Aquí tienes hechos concretos con consecuencias operativas:
- Compose nació como Fig (2013), una herramienta en Python. Esa herencia hace que algunas personas todavía piensen “Compose es una cosa de Python” y lo traten como un script en lugar de como una herramienta de ciclo de vida.
- Docker introdujo pronto políticas de reinicio (
restart: always,unless-stopped). Esas políticas las aplica el demonio Docker, no systemd, lo que significa que se comportan distinto durante el orden de apagado. - systemd se volvió mainstream en las distros principales a mediados de la década de 2010. Antes de eso, los scripts init eran “mejor esfuerzo”. Si estás copiando guías de esa época, heredas su aleatoriedad.
- Compose V2 es un plugin del CLI de Docker (
docker compose), no el antiguo binariodocker-composeen Python. Las unidades que codifican a mano la ruta antigua fallan tras actualizaciones. - El nombre de la unidad de Docker varía según la distro (interacción entre
docker.serviceydocker.socket). El orden correcto requiere ser explícito sobre de qué dependes. - “depends_on” nunca significó “esperar hasta que esté listo”. Es orden de inicio, no disponibilidad. Los healthchecks más lógica de espera (o la lógica de reintento de la app) siguen siendo importantes.
- journald no es una característica de Docker; es una decisión de registro del host. Si no integras los logs en la ruta de logging del host, depurarás problemas de arranque a ciegas.
- El apagado del sistema es un universo distinto al arranque. Si no gestionas los timeouts de apagado, tu base de datos puede recibir SIGKILL como si hubiera robado algo.
- Rootless Docker es ops real ahora, y cambia dónde viven los sockets, cómo se instalan las unidades y quién posee el ciclo de vida. Las unidades escritas para Docker con privilegios fallan silenciosamente bajo rootless.
Una cita para tener en la pared, porque describe el 90% de las fallas en tiempo de arranque:
Werner Vogels (idea parafraseada): “Todo falla; diseña para que la falla sea esperada y la recuperación automática.”
Principios para stacks resistentes al reinicio
1) Elige un supervisor: systemd o políticas de reinicio de Docker
No los dejes pelear. Puedes usar ambos, pero debes entender la consecuencia: systemd supervisa el comando Compose, mientras Docker supervisa los contenedores. Si systemd considera el servicio “terminado” y Docker reinicia los contenedores por su cuenta, puedes acabar con señales de salud engañosas y reinicios confusos.
Mi preferencia para un host único con pocos stacks:
- Usar systemd para iniciar el stack en el arranque y detenerlo en el apagado.
- Usar políticas de reinicio de Docker dentro de Compose para reinicios de contenedores después de que el stack esté en ejecución (caídas, fallos transitorios).
Esa combinación mantiene explícito el ciclo de vida de arranque/apagado mientras te da resiliencia en tiempo de ejecución.
2) Haz real el orden: discos, red, Docker, luego Compose
“After=docker.service” es necesario pero a menudo insuficiente. Si tu stack depende de un sistema de archivos montado (NFS, iSCSI, disco encriptado, importación de dataset ZFS), debes expresar ese orden también. De lo contrario tus contenedores arrancan con directorios vacíos y crean estado nuevo en el lugar equivocado, que es como te encuentras a las 2 a.m. preguntándote “¿por qué usa SQLite en /var/lib?”.
3) No confundas “en ejecución” con “listo”
systemd puede decir que un servicio inició. Docker puede decir que un contenedor está en ejecución. Ninguno puede decir que Postgres terminó la recuperación tras un fallo o que tu app ejecutó migraciones, salvo que lo conectes.
Aquí los healthchecks, reintentos y timeouts dejan de ser académicos y empiezan a evitar ruido en el pager.
4) Mantén los servicios con estado aburridos
Aburrido significa: rutas estables, montajes explícitos, timeouts de parada explícitos y sin actualizaciones sorpresa al reiniciar. El enfoque “cool” es como aprendes por las malas que las bases de datos odian los SIGKILL abruptos.
Broma corta #1: Si tu stack solo arranca cuando susurras “sólo esta vez”, tu servidor ha desarrollado dependencia emocional, no automatización.
Diseño de una unidad systemd correcta para Compose
Qué parece “correcto”
Un buen archivo unit hace cuatro cosas:
- Se ordena después de los prerequisitos (Docker, montajes, network-online si se necesita).
- Inicia el stack de forma idempotente.
- Detiene el stack limpiamente dentro de un timeout realista.
- Hace visibles los logs y los estados de fallo donde tus herramientas habituales los verán.
Semánticas de unidad que importan en producción
Estos son los mandos que deciden si te tomas el café o lees postmortems:
- Type=oneshot + RemainAfterExit=yes: systemd ejecuta un comando para levantar el stack y luego considera el servicio “activo” sin mantener un proceso adjunto. Esto refleja la realidad: Docker mantiene los contenedores, no el proceso Compose.
- ExecStart/ExecStop: Usar
docker compose up -dpara iniciar, ydocker compose downostoppara detener. Elige según quieras eliminar redes/volúmenes. - TimeoutStartSec/TimeoutStopSec: Da tiempo suficiente para pulls de imágenes (inicio) y para que las bases de datos vuelquen (parada). Los timeouts por defecto no son una declaración moral; son solo valores por defecto.
- WorkingDirectory: Fíjalo. Compose resuelve rutas relativas, archivos env y nombres de proyecto en función de él. Dejarlo implícito es como arrancar accidentalmente un proyecto vacío desde
/. - EnvironmentFile: Bueno para configuración por host que no está en Git. También útil para mantener secretos fuera de los unit files (aunque no es un sistema secreto completo).
- RequiresMountsFor=: Poco usado y excelente. Hace que systemd espere a que el sistema de archivos de una ruta esté montado antes de iniciar.
- After=network-online.target: Úsalo solo si realmente lo necesitas. Puede ralentizar el arranque si tu configuración de red es inestable. Úsalo cuando dependas de recursos remotos.
Qué comando Compose usar: up, start, down, stop
Aquí el mapeo opinado:
- Start:
docker compose up -d --remove-orphans(elimina contenedores olvidados de configuraciones antiguas; evita servicios fantasmas). - Stop (amigable con estado):
docker compose stop(mantiene redes y contenedores definidos; reinicio más rápido). - Stop (pizarra limpia):
docker compose down(elimina contenedores y redes; úsalo cuando quieras recrear en el arranque).
Para la mayoría de stacks en producción: inicia con up -d, detén con stop. Usa down cuando tengas una razón contundente (como patrones de infraestructura inmutable) y estés seguro de que tus volúmenes son externos/persistentes.
Registro: elige journald o apegarte a Docker logs, pero sé deliberado
Durante fallos de arranque, quieres una única vista unificada. Si tu organización ya usa el journal de systemd, haz que la unidad systemd produzca salida útil: añade --log-level donde se soporte y asegura que los fallos devuelvan código distinto de cero.
Separadamente, decide dónde van stdout/stderr de los contenedores. El driver json por defecto de Docker está bien hasta que no lo está; entonces descubres uso de disco de la forma divertida. Si usas journald como driver de logs de Docker, obtienes consulta centralizada a nivel de host con journalctl. Si mantienes json-file, usa configuración de rotación.
Patrones concretos de archivo unit (rootful y rootless)
Patrón A: Docker con privilegios, unidad oneshot, orden limpio
Este es el patrón que despliego con más frecuencia en un host único. Es simple, predecible y no pretende que Compose sea un daemon.
cr0x@server:~$ sudo tee /etc/systemd/system/compose@.service > /dev/null <<'EOF'
[Unit]
Description=Docker Compose stack (%i)
Requires=docker.service
After=docker.service
Wants=network-online.target
After=network-online.target
# If your stack uses persistent data on a specific mount, uncomment and set:
# RequiresMountsFor=/srv/%i
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=/srv/%i
EnvironmentFile=-/srv/%i/.env
# Pull is optional; use it when you can tolerate boot-time pulls.
ExecStart=/usr/bin/docker compose up -d --remove-orphans
ExecStop=/usr/bin/docker compose stop
ExecStopPost=/usr/bin/docker compose rm -f
TimeoutStartSec=300
TimeoutStopSec=180
[Install]
WantedBy=multi-user.target
EOF
Notas que deberías tener en cuenta:
compose@.servicees una unidad plantilla. Puedes ejecutarcompose@myappy usará/srv/myapp.EnvironmentFile=-lo hace opcional. Si falta, la unidad aún se ejecuta.ExecStopPost rm -felimina contenedores detenidos para que un futuroupno herede estado raro. Si prefieres conservar contenedores, borra esa línea.
Patrón B: Docker con privilegios, “down al detener” para stacks inmutables
Si tratas el host como ganado (o al menos como una mascota aburrida), quizá quieras down para que cada arranque recree contenedores. Asegúrate de que los volúmenes sean volúmenes reales, no defaults anónimos.
cr0x@server:~$ sudo tee /etc/systemd/system/compose-immutable@.service > /dev/null <<'EOF'
[Unit]
Description=Immutable Docker Compose stack (%i)
Requires=docker.service
After=docker.service
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=/srv/%i
ExecStart=/usr/bin/docker compose up -d --remove-orphans
ExecStop=/usr/bin/docker compose down --remove-orphans
TimeoutStartSec=300
TimeoutStopSec=240
[Install]
WantedBy=multi-user.target
EOF
Sé honesto: si tu base de datos usa un bind mount en /srv/%i/data y esa ruta no está montada aún, down no te salvará. Solo recreará mal más rápido.
Patrón C: Docker rootless + unidades systemd de usuario
Docker rootless es atractivo por límites de seguridad. También es lo suficientemente distinto como para castigar el copy-paste. El socket y el servicio viven en la sesión de usuario, y las unidades deben instalarse como servicios de usuario.
cr0x@server:~$ mkdir -p ~/.config/systemd/user
cr0x@server:~$ tee ~/.config/systemd/user/compose@.service > /dev/null <<'EOF'
[Unit]
Description=User Docker Compose stack (%i)
After=default.target
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=%h/stacks/%i
ExecStart=/usr/bin/docker compose up -d --remove-orphans
ExecStop=/usr/bin/docker compose stop
TimeoutStartSec=300
TimeoutStopSec=180
[Install]
WantedBy=default.target
EOF
Y debes habilitar lingering si esperas que arranque al iniciar el sistema sin login interactivo:
cr0x@server:~$ sudo loginctl enable-linger cr0x
Si olvidas lingering, todo funciona en tu terminal y falla tras reinicio. No es un misterio; es un desajuste de ciclo de vida.
Tareas prácticas: comandos, salidas y decisiones
Esto no es “bonito saber”. Son las cosas que realmente ejecutas cuando alguien dice: “No volvió tras el reinicio”. Cada tarea incluye el comando, la salida representativa, qué significa y la decisión que tomas.
Task 1: Confirmar Compose V2 vs binario legacy Compose
cr0x@server:~$ docker compose version
Docker Compose version v2.24.6
Significado: Compose está disponible como plugin del CLI de Docker.
Decisión: Escribe archivos unit que llamen /usr/bin/docker compose. No codifiques docker-compose a menos que hayas verificado que existe y está gestionado.
Task 2: Verificar que el demonio Docker esté arriba y no degradado
cr0x@server:~$ systemctl status docker --no-pager
● docker.service - Docker Application Container Engine
Loaded: loaded (/lib/systemd/system/docker.service; enabled; vendor preset: enabled)
Active: active (running) since Tue 2026-01-03 09:12:41 UTC; 2min 10s ago
TriggeredBy: ● docker.socket
Docs: man:docker(1)
Significado: Docker está en ejecución y activado por socket.
Decisión: Si Docker está inactive o failed, arregla Docker primero. Las unidades Compose que dependen de Docker fallarán en cascada.
Task 3: Comprobar si tu unidad Compose está habilitada y qué target la quiere
cr0x@server:~$ systemctl is-enabled compose@myapp.service
enabled
Significado: Debe arrancar en el arranque cuando se alcance su target.
Decisión: Si está disabled, habilítala. Si está static, escribiste una unidad sin sección [Install].
Task 4: Ver si systemd considera la unidad Compose activa
cr0x@server:~$ systemctl status compose@myapp.service --no-pager
● compose@myapp.service - Docker Compose stack (myapp)
Loaded: loaded (/etc/systemd/system/compose@.service; enabled; preset: enabled)
Active: active (exited) since Tue 2026-01-03 09:13:04 UTC; 1min 40s ago
Process: 2214 ExecStart=/usr/bin/docker compose up -d --remove-orphans (code=exited, status=0/SUCCESS)
Significado: La acción de “arrancar” de Compose tuvo éxito; los contenedores deberían estar gestionados por Docker.
Decisión: Si está failed, ve a los logs del journal para la unidad y arregla el error inmediato (env faltante, fichero compose faltante, permisos).
Task 5: Leer los logs de la unidad del último arranque
cr0x@server:~$ journalctl -u compose@myapp.service -b --no-pager
Jan 03 09:13:03 server systemd[1]: Starting Docker Compose stack (myapp)...
Jan 03 09:13:04 server docker[2214]: [+] Running 3/3
Jan 03 09:13:04 server docker[2214]: ✔ Network myapp_default Created
Jan 03 09:13:04 server docker[2214]: ✔ Container myapp-db-1 Started
Jan 03 09:13:04 server docker[2214]: ✔ Container myapp-api-1 Started
Jan 03 09:13:04 server systemd[1]: Started Docker Compose stack (myapp).
Significado: Esta es la fuente de la verdad sobre si el inicio en boot sucedió.
Decisión: Si los logs muestran ficheros faltantes o errores de montaje, arregla dependencias/orden. Si los logs muestran éxito pero la app está caída, el problema está dentro de los contenedores o sus dependencias (readiness, red, recuperación de BD).
Task 6: Confirmar que los contenedores existen y coinciden con el proyecto Compose
cr0x@server:~$ docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'
NAMES STATUS PORTS
myapp-api-1 Up 1 minute (healthy) 0.0.0.0:8080->8080/tcp
myapp-db-1 Up 1 minute 5432/tcp
Significado: Los contenedores están presentes; el estado de salud es visible para servicios con healthchecks.
Decisión: Si los contenedores faltan, Compose no se ejecutó o lo hizo en el directorio equivocado. Si los contenedores se están reiniciando, inspecciona logs y presión de recursos.
Task 7: Inspeccionar por qué un contenedor se está reiniciando (último código de salida, OOM, health)
cr0x@server:~$ docker inspect myapp-api-1 --format '{{.State.Status}} {{.State.ExitCode}} OOM={{.State.OOMKilled}} Health={{if .State.Health}}{{.State.Health.Status}}{{end}}'
running 0 OOM=false Health=unhealthy
Significado: Está en ejecución pero unhealthy; probablemente una dependencia no está lista o la app está mal configurada.
Decisión: No reinicies a ciegas. Revisa logs y la alcanzabilidad de dependencias. Si OOM=true, ajusta límites de memoria o la capacidad del host.
Task 8: Validar resolución del archivo compose y el entorno en el WorkingDirectory exacto
cr0x@server:~$ cd /srv/myapp
cr0x@server:~$ /usr/bin/docker compose config
name: myapp
services:
api:
image: registry.local/myapp-api:1.9.2
environment:
DB_HOST: db
db:
image: postgres:16
Significado: Compose puede parsear y renderizar la configuración final.
Decisión: Si esto falla, systemd fallará también. Arregla la sintaxis, variables env faltantes, ficheros ausentes o el WorkingDirectory incorrecto.
Task 9: Confirmar que la ruta de datos persistente está montada antes de que Compose se ejecute
cr0x@server:~$ findmnt /srv/myapp
TARGET SOURCE FSTYPE OPTIONS
/srv/myapp tank/appdata/myapp zfs rw,xattr,noacl
Significado: El directorio de datos de tu stack es un montaje real (aquí, un dataset ZFS).
Decisión: Si findmnt no muestra nada, estás escribiendo en el filesystem raíz. Añade RequiresMountsFor=/srv/myapp a la unidad y arregla el orden de montaje/importación.
Task 10: Comprobar el orden de arranque y el grafo de dependencias
cr0x@server:~$ systemctl list-dependencies compose@myapp.service --no-pager
compose@myapp.service
● ├─docker.service
● ├─network-online.target
● └─multi-user.target
Significado: systemd no iniciará la unidad Compose hasta que esas unidades estén satisfechas.
Decisión: Si tu montaje no figura, estás confiando en el timing. Añade RequiresMountsFor o unidades de montaje explícitas.
Task 11: Tiempo cuando el arranque es lento: critical chain
cr0x@server:~$ systemd-analyze critical-chain compose@myapp.service
compose@myapp.service +1.820s
└─docker.service +1.301s
└─network-online.target +1.005s
└─NetworkManager-wait-online.service +1.002s
Significado: Tu stack Compose no es la parte lenta; el sistema está esperando network-online.
Decisión: Si el stack no necesita verdaderamente network-online, elimínalo. De lo contrario, arregla el proveedor de network-online (p.ej., la configuración del servicio wait-online).
Task 12: Confirmar comportamiento de apagado y timeout de parada
cr0x@server:~$ systemctl show compose@myapp.service -p TimeoutStopUSec -p ExecStop
TimeoutStopUSec=3min
ExecStop={ path=/usr/bin/docker ; argv[]=/usr/bin/docker compose stop ; ignore_errors=no ; start_time=[n/a] ; stop_time=[n/a] ; pid=0 ; code=(null) ; status=0/0 }
Significado: systemd permitirá 3 minutos para una parada limpia.
Decisión: Si corres bases de datos, 10 segundos es comedia. Aumenta TimeoutStopSec y ajusta stop_grace_period en Compose si hace falta.
Task 13: Validar el driver de logs de Docker y rotación (para prevenir sorpresas de disco lleno en boot)
cr0x@server:~$ docker info --format '{{.LoggingDriver}}'
json-file
Significado: Los contenedores registran en archivos JSON por defecto.
Decisión: Asegura que /etc/docker/daemon.json configure rotación, o considera journald si encaja en tu modelo ops. Disco lleno durante el arranque puede impedir que Docker arranque.
Task 14: Detectar si estás tratando con Docker rootless cuando pensabas que no
cr0x@server:~$ docker context show
default
cr0x@server:~$ systemctl --user status docker --no-pager
Unit docker.service could not be found.
Significado: Probablemente Docker con privilegios (o el servicio de usuario no instalado). Las configuraciones rootless suelen tener un servicio docker a nivel de usuario y una ruta de socket distinta.
Decisión: Alinea la ubicación de la unidad (system vs user) con cómo corre Docker. Suposiciones desajustadas causan “funciona en la shell, falla en el arranque”.
Guion rápido de diagnóstico
Cuando un stack no vuelve tras reinicio, no tienes tiempo para danza interpretativa. Este es el camino más rápido al cuello de botella.
Primero: prueba si systemd ejecutó el comando de inicio
- Comprueba el estado de la unidad:
systemctl status compose@X.service - Comprueba logs del último arranque:
journalctl -u compose@X.service -b
Si la unidad nunca se ejecutó, estás en tierra de habilitación/Install/target. Si se ejecutó y falló, arregla el error reportado antes de tocar contenedores.
Segundo: prueba si Docker estaba listo y se mantuvo vivo
systemctl status dockerjournalctl -u docker -bpara errores del driver de almacenamiento, disco lleno, permisos, crash loops del demonio.
Si Docker está degradado, Compose es irrelevante. Arregla almacenamiento, disco o configuración de Docker primero.
Tercero: prueba si los prerequisitos estaban disponibles (montajes, red, secretos)
findmnt /srv/Xo rutas relevantes.systemctl list-dependencies compose@X.servicepara ver si se requieren montajes.- Comprueba presencia y permisos de archivos env, ficheros compose y directorios de bind mount.
Cuarto: si “todo” arrancó, persigue readiness y fallos a nivel app
docker psy estado de salud.docker logs --tail=200para los contenedores que fallan.docker inspectpara código de salida, OOMKilled, fallos de health.
Quinto: aisla cuellos de botella de recursos
- Presión CPU/memoria:
docker stats --no-stream,free -h. - Presión de disco:
df -h,docker system df. - Montajes lentos:
systemd-analyze critical-chainy logs de unidades de montaje.
Broma corta #2: “Funcionó después de reiniciar otra vez” no es una solución; es una tragamonedas con mejor branding.
Errores comunes: síntoma → causa raíz → solución
Esta es la sección que desearás haber leído antes de la llamada de incidente.
1) Síntoma: La unidad dice “active (exited)” pero faltan contenedores
- Causa raíz: Wrong WorkingDirectory, así que Compose se ejecutó contra un directorio vacío y creó un proyecto nuevo en otro sitio (o no hizo nada).
- Solución: Establece
WorkingDirectory=/srv/myapp(o equivalente). Ejecutadocker compose configdesde ese directorio para validar. Considera--project-namesi debes, pero generalmente el nombre basado en directorio está bien.
2) Síntoma: Los contenedores arrancan y luego la app no se conecta a la BD justo después del arranque
- Causa raíz: Confiaste en
depends_onpara disponibilidad. El contenedor de BD está en ejecución pero aún no acepta conexiones (recuperación tras fallo, fsck, replay WAL, desbloqueo de cifrado). - Solución: Añade un healthcheck al contenedor de la base de datos e implementa reintentos/backoff en la aplicación. Si debes condicionar el arranque, usa un pequeño paso init/wait en el entrypoint de la app, no en systemd.
3) Síntoma: Tras el reinicio, el directorio de datos está vacío o “reseteado”
- Causa raíz: El montaje no estaba listo; el contenedor creó un directorio nuevo en el filesystem raíz e inicializó datos frescos. Después el montaje aparece y oculta los datos equivocados.
- Solución: Añade
RequiresMountsFor=/srv/myapp(o la ruta exacta de datos) a la unidad. Para ZFS, asegúrate que la importación ocurra temprano. Para discos encriptados, asegura que la unidad de desbloqueo preceda a Docker/Compose.
4) Síntoma: La unidad Compose falla con “Cannot connect to the Docker daemon” en el arranque
- Causa raíz: Tu unidad se ejecuta antes de que el socket/demonio Docker esté listo, o Docker está lento debido a chequeos de almacenamiento.
- Solución: Asegura
Requires=docker.serviceyAfter=docker.service. Si Docker está activado por socket, depende igualmente del servicio. Considera aumentarTimeoutStartSecpara tu unidad Compose si el arranque de Docker es lento.
5) Síntoma: El apagado cuelga mucho tiempo y luego los contenedores son asesinados
- Causa raíz: Timeouts de parada demasiado cortos, o tu unidad no detiene el stack en absoluto, dejando a Docker manejarlo tarde en el apagado.
- Solución: Añade
ExecStop=docker compose stopy unTimeoutStopSecrealista. Para servicios stateful, fija Composestop_grace_periody evitadownsi quieres reinicios más rápidos sin recreación.
6) Síntoma: La unidad funciona manualmente, falla en el arranque con vars env faltantes
- Causa raíz: Usaste variables de perfil de shell o dependiste de un entorno interactivo. Los servicios systemd no cargan tus archivos RC de shell.
- Solución: Usa
EnvironmentFile=en la unidad, o incorpora env en Composeenv_file. Valida consystemctl showydocker compose config.
7) Síntoma: El arranque es lento porque network-online espera eternamente
- Causa raíz: Añadiste
network-online.targetpor costumbre, pero tu stack no lo necesita, o tu servicio de espera de red está mal configurado. - Solución: Elimina la dependencia network-online a menos que necesites recursos de red remotos al inicio. Si lo necesitas, arregla el servicio wait-online o la configuración del gestor de red.
8) Síntoma: El stack Compose rootless no arranca tras el reinicio
- Causa raíz: El servicio de usuario no se inicia en el arranque porque no se habilitó lingering, o la unidad se instaló como unidad de sistema cuando Docker es rootless.
- Solución: Instala como unidad de usuario y ejecuta
loginctl enable-linger USER. Confirma consystemctl --user is-enabledy revisa logs del journal de usuario.
Tres mini-historias del mundo corporativo
Mini-historia 1: El incidente provocado por una suposición equivocada
Una compañía mediana corría una API orientada al cliente en una sola VM potente. Nada lujoso: stack Compose con un contenedor API, Redis y Postgres. Usaron restart: always en todo y lo llamaron “alta disponibilidad”. Funcionó por meses, que es como te vuelves confiado sin motivo.
Durante una actualización rutinaria del kernel, la VM se reinició. Docker volvió. Los contenedores volvieron. La API volvió, técnicamente. Pero devolvió 500s durante unos diez minutos, y luego se recuperó sola. El on-call lo vio, se encogió de hombros y siguió. “Transitorio”.
Una semana después, otro reinicio ocurrió—esta vez en un periodo más ocupado. La lógica de migraciones de la API se ejecutó al inicio, supuso que la base de datos estaba accesible de inmediato y falló con estrépito. La política de reinicio del contenedor la reinició diligentemente, rápido, una y otra vez, golpeando los logs y manteniendo el servicio abajo. Postgres estaba bien; simplemente estaba reproduciendo WAL en un disco más lento de lo usual tras un apagado no limpio.
La suposición equivocada fue sutil: creyeron que “contenedor en ejecución” implicaba “dependencia lista”. También creyeron que la política de reinicio de Docker bastaba para manejar la secuencia de arranque. Ambas creencias son comunes. Ambas están equivocadas de forma que sólo se manifiesta durante arranque o recuperación.
La solución fue aburrida y efectiva: systemd inició el stack después de montajes y Docker, se añadieron healthchecks a Postgres y Redis, y la API cambió para reintentar la conexión a la BD con backoff antes de ejecutar migraciones. El siguiente reinicio fue sin incidentes, que es la mejor clase de historia.
Mini-historia 2: La optimización que salió mal
Otra empresa quería arranques más rápidos. Tenían una docena de proyectos Compose en un host (herramientas internas, dashboards, servicios pequeños). Alguien notó que esperar a network-online.target añadía segundos. Así que lo eliminaron de todas las unidades y declararon victoria.
El arranque fue más rápido. Luego empezaron los fallos raros: un contenedor de métricas no podía resolver DNS durante su inicio y cacheó el fallo. Un servicio de verificación de licencias intentó alcanzar un endpoint externo una vez, falló y se deshabilitó hasta reinicio manual. Un reverse proxy arrancó sin poder resolver nombres upstream y sirvió páginas de error por defecto.
En el postmortem, el equipo descubrió algo incómodo: esos servicios nunca fueron robustos ante ausencia transitoria de red. La espera network-online había estado enmascarando fragilidad de la aplicación. El eliminarla hizo visible la fragilidad, y la “optimización” convirtió la velocidad de arranque en inestabilidad del servicio.
La solución final fue matizada. Reintrodujeron network-online solo para los stacks que realmente lo necesitaban, y arreglaron los peores casos para reintentar operaciones de red correctamente. El tiempo de arranque mejoró un poco, la fiabilidad mejoró mucho, y el equipo aprendió que recortar segundos del arranque es barato hasta que no lo es.
Mini-historia 3: La práctica aburrida pero correcta que salvó el día
Una compañía del sector financiero ejecutaba un pequeño stack Compose para una canalización de reporting: scheduler, worker y una base de datos. El host usaba almacenamiento encriptado y un dataset dedicado para los datos de la BD. Su unidad systemd tenía una línea extra comparada con las de todos los demás: RequiresMountsFor=/srv/reporting.
Una mañana, tras un reinicio no atendido, la etapa de desbloqueo del cifrado tomó más tiempo de lo habitual porque un servicio dependiente reintentó la recuperación de clave. El sistema estaba “arriba” pero el dataset no estaba montado cuando Docker arrancó. Sin ordenación, el contenedor de BD habría inicializado una BD nueva en un directorio sin montar en el filesystem raíz.
Pero la unidad no arrancó. systemd esperó. Docker estaba listo, la red estaba bien, pero el montaje no estaba, así que la unidad Compose quedó en cola. Cuando el dataset montó, el stack arrancó normalmente. Sin directorios divididos. Sin BD fantasma. Sin drama de restauración.
Al auditar los logs de arranque más tarde, el único artefacto fue un inicio retrasado. Ese retraso fue una característica: evitó divergencia silenciosa de datos. El mejor trabajo de fiabilidad a menudo parece “no pasó nada”, que es la estética aceptable en producción.
Listas de verificación / plan paso a paso
Plan paso a paso: migrar un stack Compose a systemd limpiamente
- Normaliza la ubicación del stack: coloca cada proyecto bajo un directorio estable (ejemplo:
/srv/myapp). Decide si quieres unidades plantilla (compose@.service) o unidades por stack. - Haz el estado explícito: asegura que las bases de datos usen volúmenes con nombre o bind mounts hacia una ruta que controles. Evita volúmenes anónimos para lo que te importe.
- Valida la configuración Compose de forma determinista: ejecuta
docker compose configdesde el directorio previsto. Arregla warnings y variables faltantes. - Escribe la unidad:
WorkingDirectory=fijado al directorio del stack.Requires=docker.service,After=docker.service.RequiresMountsFor=para cualquier ruta de datos persistente en montajes dedicados.ExecStart=docker compose up -d --remove-orphansExecStop=docker compose stop(odownsi realmente lo deseas).- Time outs realistas.
- Recarga systemd:
systemctl daemon-reload. - Habilita la unidad:
systemctl enable --now compose@myapp.service. - Prueba el comportamiento de reinicio sin reiniciar: detén Docker, arranca Docker, asegura que la unidad se comporte. Luego haz un reinicio real en una ventana de mantenimiento y vigila los logs del journal.
- Instrumenta la disponibilidad: añade healthchecks para dependencias críticas; asegura que las apps reintenten en fallos de dependencias. No hagas que systemd haga reintentos a nivel aplicación.
- Establece la política de logs: elige driver de logs y rotación. Confirma que el uso de disco no explotará.
- Documenta el contrato operativo: qué significan “start”, “stop” y “upgrade” para este stack, incluyendo expectativas de seguridad de datos.
Lista operativa: antes de declarar “fiable”
- La unidad arranca exitosamente en cold boot (no solo en reinicio en caliente).
- La unidad espera los montajes requeridos; no se crean directorios de datos en el filesystem equivocado.
- Los contenedores de BD tienen periodos de gracia de parada realistas; el apagado no los SIGKILLea rutinariamente.
- Los logs de fallos de inicio son visibles en
journalctl -uy los logs de contenedores se retienen/rotan. - Eliminar un servicio de Compose no lo deja corriendo para siempre (usa
--remove-orphans). - Se probó un escenario de desastre: Docker no arranca, disco lleno, montaje faltante, red ausente. El sistema falla ruidoso, no silencioso.
Preguntas frecuentes
1) ¿Debo usar políticas de reinicio de Docker si tengo unidades systemd?
Sí, pero en capas distintas. systemd debería gestionar “iniciar el stack en el arranque” y “detenerlo en el apagado”. Las políticas de reinicio de Docker manejan caídas de contenedores en tiempo de ejecución. Mantenlas alineadas y evita la ilusión de supervisión doble.
2) ¿La unidad systemd debería ser Type=simple y ejecutar “docker compose up” sin -d?
Generalmente no. Ejecutar sin -d liga la salud del servicio a un proceso cliente de larga duración. Puede funcionar, pero es frágil: logs, comportamiento de TTY y fallos del cliente pueden confundir a systemd. Type=oneshot + up -d es más limpio para la mayoría de hosts.
3) ¿Es suficiente “depends_on” para controlar el orden de inicio?
Controla el orden de inicio, no la disponibilidad. Si necesitas disponibilidad, usa healthchecks y lógica de reintento. Trata la disponibilidad como responsabilidad de la aplicación, no como un truco de orquestación.
4) ¿ExecStop debería usar “docker compose down” o “stop”?
stop es más seguro para stacks stateful y más rápido para reiniciar. down está bien cuando quieres recrear contenedores y confías en que los datos persistentes están en volúmenes nombrados/bind mounts que no desaparecerán.
5) ¿Cómo aseguro que mi stack no arranque antes que mis datasets ZFS o volúmenes encriptados?
Usa RequiresMountsFor= apuntando a la ruta que tu stack necesita (por ejemplo, /srv/myapp o /var/lib/myapp). Eso fuerza a systemd a esperar el montaje. También asegúrate que el propio montaje esté configurado para aparecer antes de multi-user.
6) ¿Por qué la unidad dice “active (exited)”—no está mal?
Es correcto para una unidad oneshot con RemainAfterExit=yes. El trabajo de la unidad es ejecutar el comando de inicio. Docker luego mantiene los contenedores en ejecución. Si quieres que systemd siga un proceso, necesitas otro patrón.
7) ¿Cómo ejecuto múltiples stacks limpiamente?
Usa una unidad plantilla (compose@.service) y una convención de directorios como /srv/<stack>. Cada stack se habilita por separado: systemctl enable --now compose@stackname. Esto evita una unidad monolítica que intenta hacerlo todo y falla de forma ambigua.
8) ¿Cuál es la forma limpia de manejar secretos?
Como mínimo, mantén secretos fuera de los unit files y fuera de Git. Usa EnvironmentFile= con permisos adecuados, o los secrets de Compose respaldados por ficheros. Si ya usas un gestor de secretos, intégralo en tiempo de ejecución del contenedor. No pretendas que systemd sea una bóveda de secretos.
9) ¿Qué pasa con Podman Compose o quadlets?
Podman tiene integración first-class con systemd vía quadlets, y es una opción válida. Pero este artículo trata específicamente sobre Docker Compose. Si estás en Podman, apóyate en sus patrones nativos en lugar de emular Docker.
10) ¿Cómo evito que el arranque tire imágenes y se quede estancado para siempre?
No hagas pulls en el arranque a menos que realmente lo quieras. Mantén imágenes pre-pulladas mediante un trabajo de actualización separado o un workflow de mantenimiento. Si debes hacer pulls en el arranque, aumenta TimeoutStartSec y acepta el trade-off.
Conclusión: los siguientes pasos correctos
Si quieres que los stacks Compose se comporten tras un reinicio, deja de tratar el arranque como superstición y empieza a tratarlo como gestión de dependencias. systemd es excelente en ordenación y ciclo de vida. Compose es excelente declarando el stack. Juntos, son aburridos en el mejor sentido.
Siguientes pasos que rentan de inmediato:
- Escribe una unidad plantilla con
WorkingDirectory,After/RequiresyRequiresMountsFor. - Habilítala por stack y verifica con
journalctl -btras un reinicio controlado. - Añade healthchecks y reintentos donde “en ejecución” no es igual a “listo”.
- Fija timeouts de parada que respeten tus servicios con estado.
Tu yo futuro aún recibirá páginas a veces. Pero serán por problemas reales, no porque tus contenedores llegaron antes que tus discos a la línea de salida.