Docker Compose: depends_on te mintió — Preparación correcta sin trucos

¿Te fue útil?

Inicias tu stack. El contenedor de la base de datos está “arriba”. El contenedor de la API se inicia. Luego se cae porque no puede conectarse.
Añades depends_on. Sigue fallando. Añades sleep 10. Funciona… hasta el lunes.

Si alguna vez has visto un stack de Compose parpadear como un neón moribundo, esto es la razón: depends_on nunca fue una puerta de preparación.
Es una pista de orden de arranque. Tratarlo como una garantía de readiness es cómo obtienes fallos intermitentes que solo se reproducen durante demos.

Lo que realmente hace depends_on (y lo que no hace)

Compose tiene dos ideas separadas que la gente sigue mezclando en una:
orden de arranque y preparación (readiness).
depends_on sólo aborda la primera—más o menos.

La verdad simple

  • Puede arrancar contenedores en un orden dado. Eso es todo.
  • No espera a que tu servicio esté listo. “Contenedor iniciado” no es “base de datos aceptando consultas”.
  • No valida la accesibilidad de red. Tu dependencia puede estar “up” pero inalcanzable por DNS, reglas de firewall o nombre de host equivocado.
  • No previene condiciones de carrera. Si tu app hace migraciones al arrancar y la BD aún se está inicializando, igual puedes perder.

Algunas implementaciones y versiones de Compose soportan depends_on con condiciones como service_healthy.
Eso se acerca a lo que la gente quiere, pero aun así: sólo es tan bueno como tu healthcheck.
Un mal healthcheck es solo sleep 10 con más papeleo.

Aquí está el cambio de mentalidad: la readiness es un contrato a nivel de aplicación.
Docker puede ejecutar procesos. No puede saber cuándo tu BD ha reproducido el WAL, tu app ha calentado caches,
o tus migraciones de esquema han terminado. Tienes que definir esas señales.

Broma #1: Usar sleep 10 como readiness es como arreglar la pérdida de paquetes gritando al router. Se siente productivo, y no lo es.

Por qué “contenedor iniciado” es un hito inútil

Si ejecutas Postgres, “iniciado” puede significar que todavía está ejecutando scripts de inicialización, creando usuarios o reproduciendo logs.
Para Elasticsearch, “iniciado” puede significar que la JVM existe pero el clúster está en rojo.
Para almacenes de objetos, “iniciado” puede significar que las credenciales aún no están cargadas.

Compose no conoce tu semántica. E incluso si la conociera, la readiness en varias etapas es común:
DNS listo, puerto TCP abierto, handshake TLS posible, autenticación funcionando, esquema presente, migraciones completas, workers en segundo plano funcionando.
Elige la etapa de la que realmente dependes y luego verifica eso.

Hechos e historia que puedes usar en discusiones

Cuando intentas convencer a un equipo de dejar de poner “wait-for-it.sh” pegado al arranque de sus apps,
ayuda saber de dónde vino este desastre.

  1. Compose originalmente estaba orientado a flujos de trabajo de desarrollador, no a orquestación de producción. El orden de arranque era “suficiente” para laptops.
  2. “Healthy” no es un estado de runtime nativo de Docker igual que “running”; es el resultado de un healthcheck, opcional y definido por la app.
  3. Las versiones clásicas del archivo Compose cambiaron semánticas con el tiempo; algunas funciones existían en la sintaxis v2 pero se volvieron más confusas en v3 (especialmente en la era Swarm).
  4. Swarm y Kubernetes empujaron modelos distintos: Swarm se apoyó en el ciclo de vida del contenedor; Kubernetes hizo readiness/liveness primordiales, pero aun así definidos por la app.
  5. Los puertos pueden estar abiertos mucho antes de que los servicios sean utilizables. Muchos demonios bindean temprano y luego realizan init interno.
  6. El DNS dentro de redes Docker es eventualmente consistente durante reinicios rápidos; fallos de resolución durante ráfagas de arranque son algo real.
  7. Las políticas de reinicio pueden crear estampidas: una app que falla rápido puede golpear una BD que ya está luchando por arrancar.
  8. Los healthchecks se diseñaron para “está vivo?” y se reinterpretaron para “está listo?”, que no siempre es la misma pregunta.

Conclusión: no estás “haciéndolo mal” porque Compose sea malo. Lo haces mal porque le pides a Compose que sea Kubernetes.
Compose aún puede ser fiable. Solo tienes que ser explícito.

Los modos de fallo que sigues diagnosticando mal

1) Conexión rechazada al arrancar, luego funciona más tarde

Eso suele ser una carrera. El proceso objetivo no ha hecho bind del puerto aún, o lo ha hecho en otra interfaz.
A veces es lo contrario: el puerto está abierto pero el protocolo no está listo (TLS no cargado, BD no aceptando autenticación).

2) “Temporary failure in name resolution”

El DNS integrado de Docker suele ser sólido, pero bajo un churn rápido de contenedores aún puedes obtener fallos transitorios de resolución.
Si tu app trata un error DNS como fatal, has construido un arranque frágil.
Tu plan de readiness debería incluir reintentos con backoff para la resolución de nombres y los intentos de conexión.

3) El healthcheck dice “healthy” pero la app aún falla

El healthcheck es demasiado superficial. Un chequeo de conexión TCP no es lo mismo que “el esquema existe”.
Un curl / devolviendo 200 puede significar “el servidor web está arriba”, no “la aplicación puede hablar con la BD”.
Los healthchecks deben reflejar el límite de dependencia.

4) Todo funciona localmente, falla en CI

Los hosts de CI tienen diferente CPU, disco y comportamiento de entropía.
I/O lento hace que la inicialización de la BD sea más larga. DNS lento hace fallar la resolución temprana.
Los timeouts calibrados para tu portátil se vuelven basura en un runner estrangulado.
Si tu solución es “añadir 30 segundos de sleep”, solo desplazaste la flaqueza.

5) Reinicios en cascada

Un servicio dependiente falla rápido y se reinicia agresivamente. Cada reinicio dispara reintentos, migraciones, calentado de cache.
Mientras tanto la BD aún arranca y ahora también bajo carga.
Obtienes un bucle de realimentación: el servicio dependiente se convierte en una herramienta de denegación de servicio contra su propia dependencia.

Patrones correctos de readiness (sin ingeniería basada en siestas)

Patrón A: Usa healthchecks que verifiquen lo que realmente necesitas

No pruebes que un puerto esté abierto. Prueba que el sistema pueda completar la operación mínima que tu servicio dependiente requiere.
Para una base de datos, eso puede ser “puede autenticarse y ejecutar una consulta trivial”.
Para un servicio HTTP, puede ser “devuelve 200 en un endpoint de readiness que comprueba dependencias descendentes”.

Ejemplo: el healthcheck de Postgres debería ejecutar pg_isready y, idealmente, una consulta si dependes de que exista una base de datos específica.
Para Redis, redis-cli ping está bien. Para Kafka, es más complicado.

Patrón B: Asegura el arranque usando el estado de salud (cuando esté disponible), no el inicio del contenedor

Si tu Compose soporta depends_on con condiciones como service_healthy, úsalo.
Pero trátalo como un mecanismo de refuerzo, no como el diseño principal.
El diseño principal sigue siendo: el healthcheck debe representar la readiness.

Patrón C: Integra reintentos/backoff en tu aplicación

Este es el que los ingenieros resisten porque parece “encubrir” problemas de infraestructura.
No lo es. Las redes son poco fiables. Las carreras de arranque ocurren. Las dependencias se reinician.
Si tu app no puede reintentar una conexión a la BD durante 30–60 segundos con backoff con jitter, no está lista para producción.

Hay diferencia entre “reintentar porque el mundo es desordenado” y “reintentar para siempre porque rechazamos arreglar la configuración”.
Pon un límite superior. Emite logs estructurados. Falla tras un timeout sensato.

Patrón D: Separa el “trabajo de init” de “servir tráfico”

Migraciones de esquema, creación de buckets, plantillas de índice y “crear usuario admin” no deberían ejecutarse dentro del proceso principal
a menos que estés preparado para los problemas de concurrencia e idempotencia.

En Compose, un patrón limpio es: un servicio one-shot de “init” que ejecuta y sale con éxito, y tu app depende de él.
Tu contenedor de init debe ser idempotente: seguro de ejecutar varias veces, seguro si se completó parcialmente.

Patrón E: Prefiere endpoints explícitos de readiness para servicios HTTP

Si tu API necesita BD + cola + almacenamiento de objetos, expón un endpoint /ready que compruebe esas dependencias.
Luego tu healthcheck llama a eso. Ahora tu definición de “ready” coincide con los requisitos reales.

Patrón F: Evita scripts frágiles de “wait-for” pegados al ENTRYPOINT

A la gente le encanta soltar un script que hace loop en una comprobación de puerto.
Es fácil. También suele estar equivocado: puerto abierto ≠ ready, y el script se convierte en una mini-plataforma sin propietario.

Si debes esperar, haz una comprobación de cliente real (por ejemplo, ejecutar una consulta DB). Y mantenla mínima.
Mejor: usa healthchecks + gating de dependencias + reintentos en la app.

Cita (idea parafraseada), atribuida: Werner Vogels ha insistido repetidamente en la idea de que “todo falla, todo el tiempo”, así que los sistemas deben suponer fallos y recuperarse automáticamente.

Tareas prácticas: comandos, salidas, decisiones

Estos son los movimientos que realmente uso cuando un stack de Compose no arranca limpio.
Cada tarea incluye: comando, salida típica, lo que significa y la decisión que tomas a partir de ello.

Tarea 1: Demuestra lo que Compose cree que es la configuración

cr0x@server:~$ docker compose config
services:
  api:
    depends_on:
      db:
        condition: service_healthy
    environment:
      DATABASE_URL: postgres://app:***@db:5432/app
  db:
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d app"]
      interval: 5s
      timeout: 3s
      retries: 20

Significado: Esta es la configuración normalizada que Compose ejecutará, tras merges y sustitución de env.
Si condition: service_healthy desaparece aquí, tu implementación de Compose no lo está respetando.

Decisión: Si la salida de config no muestra lo que esperas, deja de depurar en tiempo de ejecución.
Arregla el YAML y la descoincidencia de versión/implementación primero.

Tarea 2: Observa orden de arranque y razones de salida de un vistazo

cr0x@server:~$ docker compose up --detach && docker compose ps
[+] Running 2/2
 ✔ Container stack-db-1   Started   0.7s
 ✔ Container stack-api-1  Started   0.2s
NAME          IMAGE             COMMAND                  SERVICE   STATUS
stack-db-1    postgres:16       "docker-entrypoint.s…"   db        Up 2 seconds (health: starting)
stack-api-1   myapi:latest      "/app/start"             api       Restarting (1) 2 seconds ago

Significado: La BD está “starting” en términos de salud; la API ya se está reiniciando. Esa es tu carrera, visible.

Decisión: Si un servicio dependiente se reinicia mientras las dependencias están “starting”, necesitas gating y/o reintentos.

Tarea 3: Inspecciona el estado de salud con precisión

cr0x@server:~$ docker inspect --format '{{json .State.Health}}' stack-db-1
{"Status":"starting","FailingStreak":2,"Log":[{"Start":"2026-01-02T10:01:01.123Z","End":"2026-01-02T10:01:01.456Z","ExitCode":1,"Output":"/bin/sh: pg_isready: not found\n"}]}

Significado: Tu comando de healthcheck no existe en la imagen. Las imágenes de Postgres tienen pg_isready, pero las derivadas slim podrían no tenerlo.

Decisión: Arregla el healthcheck para usar herramientas disponibles, o instala las herramientas cliente. Un healthcheck que da error es peor que no tener ninguno.

Tarea 4: Confirma que el proceso realmente está escuchando

cr0x@server:~$ docker exec -it stack-db-1 ss -lntp
State  Recv-Q Send-Q Local Address:Port Peer Address:PortProcess
LISTEN 0      244    0.0.0.0:5432      0.0.0.0:*    users:(("postgres",pid=1,fd=6))

Significado: Postgres ha ligado el TCP 5432. Esto es necesario, no suficiente.

Decisión: Si no está escuchando, revisa logs y configuración de la BD. Si está escuchando pero los clientes fallan, sube en la pila: auth, DNS, TLS, esquema.

Tarea 5: Prueba la resolución de nombres desde el contenedor dependiente

cr0x@server:~$ docker exec -it stack-api-1 getent hosts db
172.20.0.2   db

Significado: La resolución DNS funciona en ese momento.

Decisión: Si la resolución falla de forma intermitente, añade reintentos con backoff y considera ralentizar los bucles de reinicio.

Tarea 6: Prueba conectividad a nivel TCP (rápido, superficial)

cr0x@server:~$ docker exec -it stack-api-1 bash -lc 'timeout 2 bash -lc "

Significado: Un handshake TCP es posible.

Decisión: Si TCP falla, es problema de red/nombre/firewall/listen. Si TCP funciona, necesitas comprobaciones a nivel de protocolo.

Tarea 7: Prueba readiness usando una operación real de cliente (Postgres)

cr0x@server:~$ docker exec -it stack-api-1 bash -lc 'psql "postgres://app:app@db:5432/app" -c "select 1;"'
 ?column?
----------
        1
(1 row)

Significado: La autenticación funciona, la BD existe, las consultas funcionan. Eso es readiness real.

Decisión: Si esto falla, deja de culpar a Compose. Arregla credenciales, init de BD o migraciones.

Tarea 8: Lee los logs con marcas de tiempo y sin caos de desplazamiento

cr0x@server:~$ docker compose logs --timestamps --tail=80 api
2026-01-02T10:01:03.002Z api-1  ERROR db connect failed: dial tcp: lookup db: temporary failure in name resolution
2026-01-02T10:01:03.540Z api-1  ERROR exiting after 1 attempt

Significado: Esto no es “BD lenta”, es una resolución DNS transitoria + app que sale tras un intento.

Decisión: Añade lógica de reintento. También considera reducir la agresividad de los reinicios para que el churn de DNS/demonios se estabilice.

Tarea 9: Inspecciona la política de reinicio y el bucle actual

cr0x@server:~$ docker inspect --format '{{.HostConfig.RestartPolicy.Name}}' stack-api-1
always

Significado: El contenedor se reiniciará para siempre, incluso si falla instantáneamente.

Decisión: Usa on-failure para algunos servicios durante desarrollo, o añade backoff/timeout dentro de la app para evitar autodenegación de servicio.

Tarea 10: Confirma que los contenedores están en la misma red

cr0x@server:~$ docker network inspect stack_default --format '{{json .Containers}}'
{"a1b2c3d4":{"Name":"stack-db-1","IPv4Address":"172.20.0.2/16"},"e5f6g7h8":{"Name":"stack-api-1","IPv4Address":"172.20.0.3/16"}}

Significado: Comparten la red de proyecto por defecto.

Decisión: Si un servicio está en otra red, tu hostname podría no resolverse o enrutar. Arregla redes antes de tocar timeouts.

Tarea 11: Valida que tu healthcheck realmente se esté ejecutando

cr0x@server:~$ docker inspect --format '{{range .State.Health.Log}}{{.ExitCode}} {{.Output}}{{end}}' stack-db-1 | tail -n 3
0 /var/run/postgresql:5432 - accepting connections
0 /var/run/postgresql:5432 - accepting connections
0 /var/run/postgresql:5432 - accepting connections

Significado: El healthcheck se ejecuta y pasa. Eso es un prerrequisito si confías en service_healthy.

Decisión: Si los healthchecks no se disparan, verifica que la imagen soporte HEALTHCHECK y que Compose lo tiene configurado correctamente.

Tarea 12: Mide el tiempo de cold-start de la dependencia

cr0x@server:~$ time docker compose up -d db && docker inspect --format '{{.State.Health.Status}}' stack-db-1
healthy

real	0m18.412s
user	0m0.071s
sys	0m0.052s

Significado: La BD tardó ~18 segundos en estar healthy en esta ejecución.

Decisión: Ajusta timeouts/ventanas de reintento de dependientes basándote en la realidad medida, no en sensaciones. Si CI es más lento, mídelo allí también.

Tarea 13: Verifica el endpoint de readiness de tu app desde la red interna

cr0x@server:~$ docker exec -it stack-api-1 curl -fsS http://localhost:8080/ready
{"status":"ready","db":"ok","queue":"ok"}

Significado: La app se declara lista y verifica sus propias dependencias.

Decisión: Si este endpoint miente, arréglalo. Tu orquestación solo es tan fiable como la señal que proporcionas.

Tarea 14: Detecta “puerto abierto pero servicio no listo” con estado HTTP

cr0x@server:~$ docker exec -it stack-api-1 curl -i http://localhost:8080/
HTTP/1.1 503 Service Unavailable
Content-Type: application/json
Content-Length: 62

{"error":"warming up","details":"migrations running"}

Significado: El servidor está vivo pero no listo. Eso es buen comportamiento.

Decisión: Haz que tu healthcheck llame a /ready, no a /. Mantén / para comportamiento orientado al usuario si quieres.

Tarea 15: Identifica el almacenamiento lento como el verdadero “bug de readiness”

cr0x@server:~$ docker exec -it stack-db-1 bash -lc 'dd if=/dev/zero of=/var/lib/postgresql/data/.bench bs=1M count=256 conv=fsync'
256+0 records in
256+0 records out
268435456 bytes (268 MB, 256 MiB) copied, 9.82 s, 27.3 MB/s

Significado: Si ves decenas de MB/s con fsync, tu “readiness de BD” puede ser simplemente “disco lento”.
Los contenedores no arreglan la física.

Decisión: Si el almacenamiento es lento, aumenta timeouts de readiness y arregla el disco subyacente (o mueve volúmenes), en lugar de esparcir sleeps en el código de la app.

Playbook de diagnóstico rápido

Cuando el stack no levanta, no te vuelvas loco. Ejecuta esto en orden. El objetivo es encontrar el cuello de botella en menos de cinco minutos.

Primero: confirma si tienes alguna señal de readiness

  • Ejecuta docker compose config y busca bloques healthcheck y cualquier condición de dependencia.
  • Ejecuta docker compose ps y comprueba si las dependencias están (health: starting), (health: unhealthy) o no tienen salud.

Si no hay healthcheck, tu “readiness” es pensamiento mágico. Añade uno.

Segundo: determina si el fallo es red/DNS vs protocolo/auth

  • Desde el contenedor que falla: getent hosts <service> (DNS)
  • Después: chequeo TCP al puerto (conectividad)
  • Luego: operación real de cliente (protocolo/auth)

Esta secuencia evita el error clásico: pasar una hora ajustando la BD cuando el hostname está mal.

Tercero: detén las tormentas de reinicio antes de que oculten el error real

  • Revisa la política de reinicio. Si está haciendo flapping, reduce temporalmente el servicio dependiente: docker compose stop api.
  • Levanta la dependencia sola. Hazla healthy primero.
  • Luego inicia la app y observa el primer fallo, no el número 50.

Cuarto: revisa almacenamiento y contención de CPU

  • Una BD que está “starting” para siempre a menudo significa disco lento, stalls de fsync o presión de memoria.
  • Mide con una rápida prueba de escritura fsync o inspecciona métricas del host si están disponibles.

Broma #2: Compose no tiene una bandera “wait for SAN”, porque admitir que tienes un SAN ya es una forma de check de readiness.

Errores comunes: síntomas → causa raíz → solución

“Usé depends_on, ¿por qué sigue fallando?”

Síntoma: Servicio dependiente arranca e inmediatamente da error al conectar con BD/cola.

Causa raíz: depends_on hace orden de arranque, no readiness.

Solución: Añade un healthcheck real a la dependencia; controla con service_healthy si está soportado; añade reintentos/backoff en la app.

“Healthcheck dice healthy pero la app falla con migraciones”

Síntoma: BD healthy, app falla con “relation does not exist” o “database does not exist”.

Causa raíz: El healthcheck solo validó conectividad, no la readiness de esquema/datos.

Solución: Añade un job de init que ejecute migraciones idempotentes; o haz que la readiness dependa de la finalización de migraciones; o modifica el healthcheck para probar la existencia de objetos requeridos.

“Temporary failure in name resolution” al arrancar

Síntoma: Fallo DNS una vez; la app sale; se reinicia; a veces funciona.

Causa raíz: Carrera de DNS en arranque + la app no reintenta.

Solución: Reintenta DNS/conexión con backoff por una ventana acotada; reduce la agresividad de reinicios; evita crashear en el primer fallo de resolución.

“Connection refused” aunque el servicio esté arriba

Síntoma: Contenedor objetivo está corriendo; los clientes ven ECONNREFUSED.

Causa raíz: Servicio aún no escucha, puerto equivocado, bind a interfaz equivocada o configuración de seguridad rechazando tempranamente.

Solución: Revisa ss -lntp dentro del contenedor; verifica el mapeo de puertos vs puerto interno; confirma la dirección de escucha; usa un healthcheck consciente del protocolo.

“Funciona después de añadir sleep 30, así que ya está”

Síntoma: Los fallos desaparecen localmente; CI sigue fallando; los redeploys en producción son lentos.

Causa raíz: Sleep es una suposición; el tiempo de arranque es variable con I/O, CPU y rutas de init.

Solución: Elimina sleeps. Reemplázalos con healthchecks + gating + reintentos. Mide el warm-up de dependencias y ajusta timeouts según comportamiento observado.

“Todo está healthy pero las peticiones fallan durante 2 minutos”

Síntoma: Los healthchecks pasan, pero la app devuelve 500 porque está calentando caches o construyendo índices.

Causa raíz: Tu definición de readiness es incorrecta; estás comprobando liveness.

Solución: Implementa un endpoint de readiness que compruebe dependencias críticas y la finalización del warm-up interno; haz que el healthcheck llame a ese endpoint.

“Reiniciar lo arregla”

Síntoma: El primer arranque falla; el segundo arranque funciona.

Causa raíz: Tienes un problema de ordenación de inicialización oculto (usuarios/BD/esquema creados en la primera ejecución).

Solución: Extrae el init a un job one-shot, o hazlo idempotente y repetible de forma segura. Asegura que la app espere a la finalización del init.

Tres micro-historias corporativas desde el terreno

Micro-historia 1: El incidente causado por una suposición equivocada

Una empresa SaaS mediana ejecutaba un “entorno de integración” interno usando Docker Compose en una VM potente. No era producción,
pero era donde los ingenieros validaban cambios antes de enviarlos. El stack incluía un contenedor Postgres y un contenedor API.
Alguien añadió depends_on: [db] y se sintió responsable. Lo eran.

La API tenía una ruta de arranque que aplicaba migraciones automáticamente. En la mayoría de los días, Postgres se inicializaba lo bastante rápido como para que el primer intento de conexión de la API tuviera éxito.
Algunos días—tras reinicios del host o cuando la caché de disco de la VM estaba fría—Postgres tardaba más en aceptar conexiones.
La API intentaba una vez, fallaba y salía. La política de reinicio la traía de vuelta. Ese segundo intento normalmente funcionaba.

Luego llegó un cambio que hizo que el arranque de Postgres fuera más lento: extensiones extra instaladas en el primer arranque, más scripts de init.
La API ahora fallaba tres o cuatro veces antes de tener éxito. Los ingenieros veían logs de flapping, ejecutaban docker compose up y seguían.
El día que importaba, el entorno se usó para una demo cara al cliente. La API nunca se estabilizó porque la tormenta de reinicios provocó intentos repetidos de migración,
cada uno bloqueando tablas y extendiendo más el tiempo de arranque.

La suposición equivocada no fue “depends_on funciona”. Fue más sutil: “si finalmente se levanta, está bien”.
Así es como fallos intermitentes de arranque se convierten en caídas completas bajo carga o en los momentos más sensibles.
La solución fue aburrida: un healthcheck de Postgres, gating con service_healthy, y mover migraciones a un job one-shot
que se ejecutaba exactamente una vez por despliegue y registraba en alto cuando fallaba.

Micro-historia 2: La optimización que salió mal

Otra organización persiguió builds de CI más rápidos. Redujeron imágenes agresivamente: bases más pequeñas, menos paquetes, menos utilidades “innecesarias”.
Alguien eliminó las herramientas cliente de Postgres de una imagen de aplicación porque “no necesitamos psql en producción”.
Cierto, en su mayoría. Pero también usaban psql en un script de readiness al arranque para verificar existencia de esquema.

El pipeline empezó a fallar. No de forma consistente—porque el cache hacía que algunos runners todavía tuvieran capas antiguas, y algunos jobs usaban rutas de build distintas.
En ejecuciones que fallaban, el contenedor de la API arrancaba, intentaba ejecutar psql y daba psql: command not found.
El contenedor salía, la política de reinicio reintentaba y el job agotaba el tiempo. La gente culpaba a la base de datos. Era inocente.

La “optimización” empeoró: para reducir ruido de logs, alguien cambió el entrypoint para tragar el error y volver a un chequeo basado en puerto.
Ahora el contenedor “esperaba” a que TCP 5432 estuviera disponible y arrancaba la app.
La app entonces inmediatamente se encontraba con “relation missing” porque las migraciones no estaban garantizadas, y el fallo se movió más adelante en la secuencia de arranque.

Finalmente hicieron lo que debió hacerse primero: reemplazaron el script ad-hoc por un healthcheck correcto de Postgres,
y la app añadió un bucle de reintentos acotado para conexiones a BD.
Si realmente necesitaban una comprobación de esquema, añadieron un contenedor de migraciones dedicado que incluía las herramientas correctas y hacía un único trabajo en su vida.
El CI se volvió más rápido, pero lo más importante es que se volvió predecible.

Micro-historia 3: La práctica aburrida pero correcta que salvó el día

Un equipo de servicios financieros ejecutaba múltiples stacks Compose para entornos de prueba en hosts compartidos.
Los entornos no eran “de juguete”: se usaban para ensayar incidentes y validar rollbacks.
El equipo tenía una regla: cada dependencia debe tener un healthcheck que coincida con una operación real de cliente,
y cada app debe reintentar dependencias críticas durante el arranque.

Hizo que sus archivos Compose fueran un poco más largos. También hizo sus vidas más cortas, en el buen sentido.
Tenían endpoints de readiness para servicios HTTP, healthchecks de base de datos que realizaban autenticación, y servicios one-shot para migraciones.
También configuraron políticas de reinicio deliberadamente: las bases de datos no se reiniciaban por cada fallo transitorio, y las apps no apaleaban dependencias con reintentos instantáneos.

Una mañana, tras un ciclo de parcheo del host, varios entornos tardaron más de lo habitual en levantarse.
El almacenamiento estuvo degradado brevemente tras un resync de RAID. Los contenedores Postgres tardaron más en estar listos.
Los stacks no se hundieron en tormentas de reinicios. Las apps esperaron. Los healthchecks permanecieron en “starting” hasta que la BD fue realmente usable.

El equipo lo notó, porque monitoreaban el estado de salud y el tiempo de arranque, no solo “el contenedor está corriendo”.
Aplazaron un ensayo programado 20 minutos en lugar de pasar dos horas discutiendo con fantasmas.
Las prácticas aburridas no parecen heroicas. Simplemente evitan que necesites héroes.

Listas de verificación / plan paso a paso

Paso a paso: convertir un stack Compose inestable en uno fiable

  1. Define la readiness por dependencia.
    Para BD: “puede autenticarse y ejecutar consulta”. Para servicios HTTP: “/ready devuelve ok y dependencias downstream ok”.
  2. Añade healthchecks a cada dependencia stateful.
    Evita chequeos puramente de puerto a menos que eso sea realmente tu único requisito.
  3. Controla el arranque de dependientes con health cuando esté soportado.
    Si tu Compose soporta service_healthy, úsalo. Si no, confía en reintentos de la app y considera el patrón de init job.
  4. Añade reintentos acotados con backoff en la aplicación.
    Incluye fallos de resolución DNS, timeouts de conexión y fallos de autenticación que pueden ocurrir durante la inicialización.
  5. Separa el trabajo de init del trabajo de servir.
    Migraciones, creación de buckets, plantillas de índice van en un servicio one-shot que se puede volver a ejecutar de forma segura.
  6. Haz que el init sea idempotente.
    Usa “create if not exists”, migraciones transaccionales y reejecuciones seguras. Asume que se ejecutará dos veces.
  7. Afina políticas de reinicio para evitar tormentas.
    Si un servicio falla porque las dependencias no están listas, no debería reiniciarse 20 veces por minuto.
  8. Instrumenta el tiempo de arranque.
    Registra “starting”, “connected to db”, “migrations complete”, “ready”. No puedes arreglar lo que no mides.
  9. Prueba arranques en frío en CI.
    Purga caches ocasionalmente o ejecuta en runners limpios. Mide la ruta lenta.
  10. Deja de usar sleeps como mecanismo de control.
    Reemplázalos con comprobaciones que representen la readiness real, o elimínalos y confía en reintentos.

Checklist: cómo es un buen healthcheck

  • Se ejecuta rápido (idealmente < 1s) cuando está healthy.
  • Falla de forma fiable cuando el servicio no es usable para los dependientes.
  • Usa protocolo cliente real cuando sea posible (consulta SQL, petición HTTP).
  • No requiere acceso de red externo ni dependencias frágiles.
  • Tiene intervalos y reintentos sensatos basados en tiempos de arranque medidos.
  • Produce salida clara de fallo en docker inspect.

Checklist: qué debería hacer tu app durante el arranque

  • Reintentar conexiones a dependencias por una ventana acotada (por ejemplo, 60–180 segundos según el entorno).
  • Usar backoff exponencial con jitter para evitar estampidas de reintentos sincronizados.
  • Registrar cada intento fallido con la razón, pero no spamear: agrega o limita la tasa si es necesario.
  • Salir con un error claro si la dependencia no es alcanzable tras la ventana.
  • Exponer un endpoint de readiness que refleje la capacidad real de servir.

Preguntas frecuentes

1) ¿depends_on alguna vez espera por readiness?

No por sí mismo. Algunas implementaciones de Compose soportan condiciones como service_healthy, que pueden depender de un healthcheck.
Pero el healthcheck debe existir y debe reflejar la readiness real.

2) ¿Es válido un chequeo de puerto TCP como healthcheck?

A veces. Si tu servicio dependiente solo requiere “puerto abierto”, está bien. Eso es raro.
La mayoría de servicios necesitan autenticación, enrutamiento, esquema o inicialización interna—así que un chequeo a nivel de protocolo es más seguro.

3) ¿Por qué no simplemente aumentar el sleep a 60 segundos?

Porque el tiempo de arranque es variable. Harás las rutas rápidas más lentas y aún fallarás en las rutas lentas.
Además, los sleeps ocultan problemas reales: credenciales equivocadas, hostnames erróneos, migraciones faltantes.

4) ¿Debería hacer migraciones en el arranque de la app?

Si lo haces, debes manejar concurrencia, idempotencia y fallos de forma limpia.
En stacks Compose, un servicio de migraciones one-shot suele ser más limpio y fácil de observar.

5) ¿Cuál es la diferencia entre liveness y readiness aquí?

Liveness: “el proceso está vivo y no está bloqueado”. Readiness: “puede atender correctamente las peticiones ahora mismo”.
Los healthchecks de Compose a menudo se usan como liveness; puedes usarlos para readiness, pero solo si los defines así.

6) Mi servicio está “healthy” pero aún no es accesible desde otro contenedor. ¿Cómo?

El healthcheck se ejecuta dentro del contenedor. Puede pasar incluso si el servicio no es accesible por la red (bind a dirección equivocada, red equivocada, reglas de firewall).
Verifica la dirección de escucha (ss -lntp) y la membresía de red (docker network inspect).

7) ¿Usar restart: always — bueno o malo?

Ni lo uno ni lo otro. Es una herramienta. Para dependencias que puedan crashar, puede ayudar.
Para apps que fallan rápido porque las dependencias no están listas, puede crear tormentas de reinicio y ocultar la causa raíz.
Úsalo junto con lógica de reintento sensata y buenos logs.

8) ¿Puedo confiar en el orden de docker compose up para bases de datos y caches?

Puedes confiar en que “Compose intentará arrancar contenedores en ese orden”.
No puedes confiar en que “la dependencia sea usable cuando se inicie el dependiente”.
Si tu app necesita una BD usable, necesitas checks de readiness y reintentos.

9) ¿Cómo manejo múltiples dependencias (BD + cola + almacenamiento)?

Define la readiness en el límite de la app: crea un endpoint de readiness que compruebe todas las dependencias críticas.
Controla el tráfico con esa readiness y asegúrate de que cada dependencia tenga su propio healthcheck cuando sea posible.

Conclusión: próximos pasos que realmente debes hacer

Deja de pedirle a depends_on que haga un trabajo para el que nunca fue contratado. Úsalo para el orden de arranque si quieres.
Pero para fiabilidad, necesitas señales reales de readiness, y sistemas que toleren las carreras de arranque.

  1. Añade healthchecks a cada dependencia importante (BD, cache, cola, gateways de almacenamiento de objetos).
  2. Haz que los healthchecks representen la usabilidad real, no “el puerto está abierto”.
  3. Si está soportado, controla el arranque con service_healthy. Si no, trátalo como opcional y confía en reintentos desde la app.
  4. Mueve migraciones e inicializaciones únicas a un contenedor job dedicado e idempotente.
  5. Implementa reintentos acotados con backoff en cada servicio que hable con una dependencia al arrancar.
  6. Usa el playbook de diagnóstico rápido cuando vuelva a romperse, porque volverá—solo que menos dramáticamente.

La meta no es la perfección. Son arranques aburridos. Los arranques aburridos te permiten dedicar atención a problemas de producto en vez de a la ruleta del tiempo de arranque.

← Anterior
CSS Grid vs Flexbox: reglas de decisión y recetas de diseño que resisten en producción
Siguiente →
Invalidación de caché en compilaciones Docker: por qué las builds son lentas y cómo acelerarlas

Deja un comentario