Cuando un healthcheck derriba tu stack: Me pasó usando Grav + Docker Swarm

Hace unos días tuve un caso curioso con un servicio basado en Grav, corriendo dentro de un clúster Docker Swarm. Todo funcionaba bien durante meses, hasta que un reinicio rutinario del servidor terminó con un servicio marcándose como unhealthy una y otra vez (ojo, en health check habia sido agregado recientemente).
El contenedor arrancaba, Apache corría, pero Swarm insistía en reiniciarlo sin descanso. Lo que parecía un error de configuración terminó revelando una cadena de causas: montajes relativos, volúmenes limpiados, un tema faltante y un healthcheck demasiado estricto.
El error inicial: “invalid mount config for type bind”
Después de reiniciar el stack, el primer mensaje que apareció fue:
invalid mount config for type "bind": bind source path does not exist
Era un clásico en Swarm: una ruta relativa (./data/grav/...
) que el demonio no podía resolver desde su contexto raíz.
En mi docker-stack.yml
, el servicio aurora_grav
usaba este bloque:
volumes:
- ./data/grav/backup:/var/www/backup
- ./data/grav/logs:/var/www/logs
- ./data/grav/user:/var/www/html/user
- ./data/grav/cache:/var/www/html/cache
El problema es que, en Swarm, el contexto de ejecución no es el mismo que el del docker-compose
local.
La ruta relativa ./data/grav/cache
simplemente no existía desde el punto de vista del nodo orion (suelo poner nombres a mis servidores).
Solución inmediata
Creé las carpetas necesarias y cambié las rutas a absolutas:
volumes:
- /home/appuser/deploy/data/grav/backup:/var/www/backup
- /home/appuser/deploy/data/grav/logs:/var/www/logs
- /home/appuser/deploy/data/grav/user:/var/www/html/user
- /home/appuser/deploy/data/grav/cache:/var/www/html/cache
Tras eso:
docker service update --force aurora_grav
El error de “invalid mount” desapareció. Pero el contenedor seguía reiniciándose.
Segunda pista: el estado “unhealthy”
El servicio quedaba así:
docker stack services aurora
ID NAME MODE REPLICAS IMAGE PORTS
x5l... aurora_grav replicated 0/1 jack/grav:1.2.0 *:8080->80/tcp
y los logs indicaban:
task: non-zero exit (137): dockerexec: unhealthy container
La aplicación arrancaba, pero el healthcheck la mataba.
Veamos qué decía ese bloque en el YAML:
healthcheck:
test: ["CMD", "wget", "-q", "-O", "-", "http://localhost/"]
interval: 30s
timeout: 10s
retries: 3
start_period: 20s
A simple vista parecía correcto, pero Grav podía estar tardando más de 20 s en generar la primera respuesta, sobre todo si la caché se reconstruía desde cero.
Verificando el contenedor desde adentro
Primero listé los contenedores activos:
docker ps -a --filter label=com.docker.swarm.service.name=aurora_grav
Obtuve algo como:
CONTAINER ID STATUS NAMES
a17c47c9b72b Up 1 minute aurora_grav.1.s8pyele2siho7kctq1zvf4ahw
Entré directamente:
docker exec -it a17c47c9b72b bash
ls -la /var/www/html
Y ahí estaba todo:
index.php
, system/
, user/
, vendor/
, cache/
… Grav parecía sano.
Así que el problema no era de archivos faltantes.
Tercera pista: el healthcheck no llega a 200 OK
Ejecuté manualmente el mismo comando que el healthcheck:
curl -v http://127.0.0.1/ | head -n 10
Resultado:
HTTP/1.1 500 Internal Server Error
Theme 'future2021' does not exist, unable to display page.
Bingo.
El contenedor estaba perfectamente operativo, pero Grav devolvía un HTTP 500 porque el tema configurado en system.yaml
ya no existía. En este punto todo parecia confuso porque ese tema hace mucho que deje de utilizarlo, pero parece ser requerido (esto debo investigarlo por separado).
La causa oculta: procesos de limpieza del servidor
En este nodo (orion), se ejecutaban tareas de mantenimiento que eliminaban archivos temporales, entre ellos el contenido de /home/appuser/deploy/data/grav/cache
y /home/appuser/deploy/data/grav/logs
.
Grav, al montar esas carpetas como volúmenes compartidos, dependía de que existieran y tuvieran los archivos correctos.
Al vaciarse la caché, el CMS intentó reconstruir todo al arrancar, pero el tema faltante impidió renderizar la página de inicio.
Por eso el healthcheck (wget http://localhost/
) recibía un 500 y Docker lo declaraba “unhealthy”.
Reparando Grav
La solución fue sencilla una vez identificado el error:
Reinstalar el tema
Entré al contenedor y reinstalé el tema faltante:
docker exec -it a17c47c9b72b bash
cd /var/www/html
bin/gpm install future2021
Validar la respuesta
Una nueva prueba con curl
mostró:
HTTP/1.1 302 Found
Location: /en
Perfecto.
Ahora Grav respondía con un redirect —una señal de vida para el healthcheck.
Cuarta pista: los falsos positivos del healthcheck
El problema es que tanto wget
como curl -f
interpretan 302 Found como un error.
Por defecto, Docker solo considera “saludable” una respuesta 2xx.
Así que, aunque Grav ya estaba bien, Swarm seguía marcándolo como unhealthy.
La solución fue ajustar el comando de verificación para aceptar 200, 301 y 302 como válidos.
Ajustando el healthcheck correctamente
Reemplacé el bloque original por:
healthcheck:
test: ["CMD-SHELL", "curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1/ | grep -qE '200|301|302'"]
interval: 30s
timeout: 10s
retries: 5
start_period: 60s
Este comando:
curl -s -o /dev/null -w '%{http_code}'
obtiene solo el código HTTP.grep -qE '200|301|302'
valida que sea 200, 301 o 302 sin producir salida.- Devuelve código de salida
0
si el servicio está vivo,1
si no.
Para verificarlo manualmente:
docker exec -it a17c47c9b72b bash -c "curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1/ | grep -qE '200|301|302'; echo $?"
El resultado 0
indica que Docker lo considerará healthy.
Consolidando los cambios
El bloque final del servicio quedó así:
aurora_grav:
image: jack/grav:1.2.0
deploy:
placement:
constraints:
- node.hostname == orion
replicas: 1
restart_policy:
condition: any
delay: 10s
max_attempts: 10
window: 60s
update_config:
parallelism: 1
order: start-first
delay: 5s
rollback_config:
parallelism: 1
order: start-first
resources:
limits:
cpus: '0.50'
memory: 512M
reservations:
cpus: '0.25'
memory: 256M
networks:
- aurora_net
stop_grace_period: 30s
healthcheck:
test: ["CMD", "curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1/ | grep -qE '200|301|302'"]
interval: 30s
timeout: 10s
retries: 5
start_period: 60s
ports:
- 8080:80
volumes:
- /home/appuser/deploy/data/grav/backup:/var/www/backup
- /home/appuser/deploy/data/grav/logs:/var/www/logs
- /home/appuser/deploy/data/grav/user:/var/www/html/user
- /home/appuser/deploy/data/grav/cache:/var/www/html/cache
Luego:
docker stack deploy -c docker-stack.yml aurora
El estado cambió a:
aurora_grav replicated 1/1 running
Validando el resultado
Para asegurarse de que el sistema volvía a estar estable:
docker service ls | grep aurora_grav
docker service ps aurora_grav
docker service logs aurora_grav --tail 20
Y para monitorear su salud en tiempo real:
watch -n 5 docker inspect --format '{{.Spec.Name}}: {{.ServiceStatus.DesiredTasks}} / {{.ServiceStatus.RunningTasks}}' $(docker service ls -q)
Todo se mantuvo estable, incluso después de reinicios y limpiezas posteriores.
Algunas Lecciones Aprendidas
-
Los montajes relativos son enemigos de Docker Swarm.
Usa siempre rutas absolutas; el contexto del stack no es el mismo que el del host. -
Los procesos de mantenimiento pueden romper tu aplicación.
Si tu CMS depende de archivos en volúmenes compartidos, protégelos o replica los datos antes de cada limpieza. -
Un healthcheck mal diseñado puede matar un servicio sano.
Docker no interpreta redirecciones ni códigos 3xx como éxito, y eso puede ser fatal para aplicaciones web modernas. -
Los HTTP 500 siempre tienen contexto.
En este caso, no era un bug del contenedor, sino un tema faltante que Grav intentaba cargar. -
Siempre verifica desde dentro del contenedor.
Uncurl 127.0.0.1
revela más que cualquier log del service.
Lo interesante de este incidente no fue el error en sí, sino la forma en que pequeñas decisiones se encadenan:
- un cron de limpieza.
- un volumen montado,
- un tema faltante,
- y un healthcheck exigente.
Nada de eso, individualmente, era un problema. Pero juntos, formaron una tormenta perfecta que mantuvo el servicio en un bucle de reinicio durante horas.
Después de ajustar el healthcheck y reinstalar el tema, Grav volvió a funcionar de forma estable. Ahora los reinicios del nodo son seguros, y los volúmenes de Grav se respaldan antes de cada mantenimiento.
Conclusión
A veces no es el software el que falla, sino los supuestos que hacemos sobre su entorno. Grav no tenía nada roto: solo necesitaba tiempo, contexto y un poco de flexibilidad en su healthcheck.
Moral: no confíes ciegamente en el código de salida de tus herramientas. Asegúrate de que “saludable” significa lo mismo para Docker que para tu aplicación.
Happy debugging ⚙️
Photo by Derek Laliberte on Unsplash
Written with StackEdit.