Docker Compose: La trampa de las dependencias — ‘depends_on’ no significa listo

¿Te fue útil?

Son las 09:12. Tu despliegue está “verde” porque los contenedores están en ejecución, pero la API devuelve 500. Los registros muestran conexión rechazada a Postgres. Añades depends_on, vuelves a desplegar y… nada cambia salvo que tu nivel de confianza baja.

Esta es la trampa de dependencias de Docker Compose: depends_on controla el orden de inicio, no la disponibilidad. Es una función de conveniencia, no un contrato de fiabilidad. Si la tratas como tal, el sistema tarde o temprano te enseñará humildad—normalmente durante una demo.

Qué hace realmente depends_on (y lo que nunca prometió)

Compose tiene que decidir el orden en que inicia los contenedores. Eso es todo lo que hace depends_on: un grafo dirigido que dice “inicia A antes que B”. No dice que “A está aceptando conexiones”, “A terminó las migraciones”, “A calentó las caches” o “A no se va a caer dos segundos después”.

Cuando la gente dice “depends_on no funciona”, normalmente quieren decir: funcionó exactamente como fue diseñado, y el diseño no era lo que asumieron.

Orden de inicio vs disponibilidad: la distinción clara

  • Orden de inicio: el proceso del contenedor se ha lanzado (o al menos Docker ha intentado iniciarlo).
  • Disponibilidad: el servicio es utilizable para su propósito (socket en escucha, autenticación satisfactoria, esquema presente, upstream accesible, etc.).

Son problemas distintos, y Compose solo resuelve el primero por defecto.

La arista de “service_healthy” (y por qué no es una solución universal)

Algunas implementaciones de Compose soportan dependencias condicionales como condition: service_healthy, que condicionan el arranque dependiente al healthcheck de la dependencia. Eso ayuda, pero sigue sin ser un contrato completo:

  • Los healthchecks pueden estar equivocados (demasiado superficiales, demasiado lentos, demasiado optimistas).
  • Estar sano una vez no significa estar sano para siempre.
  • Tu aplicación aún necesita reintentos porque las redes y el almacenamiento no respetan tu YAML.

Aquí está la verdad operativa: incluso con healthchecks, diseñas tu aplicación como si las dependencias pudieran llegar tarde, ser intermitentes o estar brevemente no disponibles. Compose te ayuda a orquestar; no te exime de la resiliencia.

Una cita que debería colgar en la pared: Idea parafraseada de Werner Vogels: “Todo falla todo el tiempo; construye sistemas que lo esperen”.

Por qué “listo” es difícil: qué ocurre realmente durante el arranque

En un portátil limpio con caches calientes y sin carga, es fácil creer que la disponibilidad es instantánea. En entornos reales, el arranque es un lío de E/S, DNS, planificación de CPU, latencia de almacenamiento y, a veces, un inesperado fsck que no pediste.

Fases típicas de arranque de una dependencia (las partes que olvidas que existen)

  1. Contenedor creado: capas del sistema de archivos montadas, namespaces configurados, red adjuntada.
  2. Entrypoint inicia: el proceso comienza; puede forkear; puede esperar plantillas de configuración.
  3. Servicio se inicializa: lee configuraciones, asigna memoria, verifica permisos.
  4. Disponibilidad del almacenamiento: montajes de volúmenes, replay del journal, recuperación por fallo, replay de WAL.
  5. Disponibilidad de la red: propagación de DNS, servicio se enlaza a sockets, reglas de firewall.
  6. Disponibilidad de la aplicación: migraciones, calentamiento de cachés, seed de datos, elección de líder.

Cualquiera de esos puede retrasar el “listo” por milisegundos o minutos. Y sí, he visto minutos.

Broma #1: “Funcionó en mi máquina” es solo otra forma de decir “mi máquina tiene estándares más bajos”.

El almacenamiento lo empeora (especialmente en el primer arranque)

Las bases de datos no están “arriba” cuando el proceso existe; están arriba cuando pueden aceptar una conexión y manejar una consulta de forma fiable. Postgres puede estar reproduciendo WAL. MySQL puede estar actualizando tablas del sistema. Redis puede estar cargando un snapshot RDB. Si usas almacenamiento en red, añades otra capa de variación temporal.

Para los SREs, la clave es modelar la disponibilidad de las dependencias como estocástica, no determinista. Tu app o maneja esa realidad con gracia, o se convierte en una alarma de pager.

Hechos y contexto histórico (porque esto no pasó por accidente)

Algo de contexto ayuda porque el comportamiento de Compose se confunde frecuentemente con la semántica de Swarm/Kubernetes, y el ecosistema evolucionó en pasos incómodos. Aquí hay hechos concretos que importan operacionalmente:

  1. El objetivo original de Compose fue la ergonomía del desarrollador, no la orquestación de alta disponibilidad. Se optimizó para “ejecutar la pila localmente”, no para “gestionar degradaciones”.
  2. depends_on históricamente solo hacía cumplir el orden de inicio. La disponibilidad estuvo explícitamente fuera de alcance durante mucho tiempo porque depende de la aplicación.
  3. Los healthchecks llegaron a Docker más tarde de lo que muchos suponen; los primeros setups de Compose usaban scripts ad-hoc “wait-for” porque no había primitiva nativa.
  4. Swarm y Kubernetes popularizaron conceptos explícitos de salud/readiness, lo que llevó a que equipos esperaran semánticas similares en todas partes—incluso donde no existen.
  5. El HEALTHCHECK de Docker se ejecuta dentro del namespace del contenedor, lo cual es bueno para probar el estado interno del servicio, pero puede pasar por alto problemas de accesibilidad externa.
  6. Compose v2 es un plugin y no el antiguo binario Python; los detalles de implementación y las funciones soportadas difieren entre entornos, lo que alimenta la confusión de “me funciona a mí”.
  7. El orden de inicio no es el orden de reinicio; una dependencia que se cae puede volver más tarde, y Compose no re-secuenciará el mundo por ti mágicamente.
  8. El DNS dentro de redes de Compose suele ser estable pero no instantáneo; los intentos tempranos de conexión pueden fallar con errores de resolución de nombre en clientes que arrancan rápido.
  9. “DB acepta TCP” no es lo mismo que “esquema listo”; las migraciones pueden seguir en ejecución, causando timeouts o errores de tabla inexistente.

Esa es la trampa: el alcance de la herramienta es más estrecho que el problema operacional, y nuestro cerebro autocompleta las funciones faltantes.

Modos de fallo que verás en producción (aunque jures que no)

1) Conexión rechazada al arrancar, luego “mágicamente” OK

El contenedor de la base de datos inicia rápido. El proceso de BD hace bind tarde. Tu app intenta una vez, falla y sale. Compose la reinicia, o tú lo haces. En el segundo intento, funciona.

Diagnóstico: la app no tiene reintento/backoff, y confundiste orden de inicio con disponibilidad del servicio.

2) “No such host” o fallos DNS transitorios

Clientes rápidos pueden intentar resolver un nombre de servicio antes de que el DNS embebido esté completamente listo o antes de que la red esté adjuntada. Es más raro ahora, pero sigue ocurriendo bajo carga o en nodos lentos.

3) Esquema ausente / migraciones en curso

Postgres acepta conexiones, pero tu tarea de migración aún no se ha ejecutado. Tu app arranca, ejecuta consultas y muere. Obtienes una breve interrupción y un montón de alertas inútiles.

4) Contenedor sano, sistema enfermo

Tu healthcheck de BD es pg_isready, que devuelve éxito. Pero el disco está lleno, la base de datos es de solo lectura o las conexiones están saturadas. Los healthchecks valen lo que valga su definición.

5) Retropresión y timeouts que se hacen pasar por problemas de arranque

Tu dependencia está “arriba” pero dolorosamente lenta: cachés frías, alta espera de I/O, CPU robada. La app hace timeout y sale, y todos culpan a Compose porque estaba cerca.

Broma #2: depends_on es como decir “llegué al restaurante primero” y asumir que la cena ya está cocinada.

Patrones que funcionan: healthchecks, reintentos y secuenciación sensata

Si quieres fiabilidad, necesitas capas. Compose puede ayudar, pero la aplicación tiene que hacer la parte adulta: reintentar, hacer backoff y fallar de forma controlada.

Patrón A: Añadir healthchecks explícitos a las dependencias

Para servicios comunes, define un healthcheck que pruebe una disponibilidad significativa. No solo “proceso existe”. Prefiere una consulta real o un ping que ejercite el subsistema correcto.

Ejemplo: healthcheck de Postgres que valida TCP, autenticación y la capacidad de ejecutar una consulta básica:

cr0x@server:~$ cat docker-compose.yml
services:
  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: example
      POSTGRES_USER: app
      POSTGRES_DB: appdb
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d appdb -h 127.0.0.1 || exit 1"]
      interval: 5s
      timeout: 3s
      retries: 20
      start_period: 10s
  api:
    image: myorg/api:latest
    depends_on:
      db:
        condition: service_healthy

Nota operativa: incluso si condicionas el arranque por salud, aún necesitas reintentos a nivel de app para reinicios, failovers y reinicios de dependencias en vuelo.

Patrón B: Hacer la aplicación resiliente (reintentos con backoff)

El mejor lugar para manejar la disponibilidad de dependencias es el cliente. Tu API debe tolerar que la BD llegue tarde entre 30–120 segundos sin salir. Haz que lo registre con claridad, reintente con backoff exponencial y mantén una señal de liveness separada para que no acepte tráfico prematuramente.

Cuando la gente evita esto porque “oculta fallos”, lo que quieren decir es “prefiero interrupciones a inicios lentos”. Aún puedes alertar sobre arranques lentos; no necesitas crash-loopear para percibir algo.

Patrón C: Separar migraciones/inicialización del arranque de la app

Ejecuta las migraciones como un job de una sola ejecución que bloquee la finalización del despliegue, no el arranque de la app. En términos de Compose, eso puede ser un servicio dedicado que ejecutes explícitamente, o un entrypoint que haga las migraciones con un lock y observabilidad clara.

No ejecutes migraciones en 10 réplicas de app simultáneamente a menos que disfrutes errores de “relation already exists” y discusiones sobre quién empezó primero.

Patrón D: Usar políticas de reinicio intencionalmente

restart: always puede ocultar problemas reales convirtiéndolos en un crash-loop perpetuo. A veces eso es aceptable durante el bootstrap; no es aceptable como modo de operación estable.

Mi preferencia para la mayoría de servicios:

  • Usar restart: unless-stopped para servicios de larga duración en dev/test.
  • En entornos tipo producción, emparejar reinicio con logs sensatos, backoff en la app y healthchecks claros para que “reiniciando” no sea sinónimo de “funcionando”.

Tareas prácticas: comandos, salidas y la decisión que tomas

Esta sección es deliberadamente práctica. Estas son las tareas que ejecutas a las 02:00 cuando intentas responder una pregunta: ¿qué no está listo realmente y por qué?

Tarea 1: Confirma lo que Compose cree que está en ejecución

cr0x@server:~$ docker compose ps
NAME                IMAGE               COMMAND                  SERVICE   STATUS              PORTS
stack-db-1          postgres:16         "docker-entrypoint.s…"   db        running (healthy)   5432/tcp
stack-api-1         myorg/api:latest    "/bin/api"               api       running             0.0.0.0:8080->8080/tcp

Qué significa: Los contenedores están en ejecución; la BD está “healthy” según su healthcheck.

Decisión: Si la API aún falla, esto no es un problema de orden de inicio; pasa a logs y pruebas de conectividad reales.

Tarea 2: Inspecciona el grafo de dependencias y la configuración mergeada

cr0x@server:~$ docker compose config
services:
  api:
    depends_on:
      db:
        condition: service_healthy
    image: myorg/api:latest
  db:
    environment:
      POSTGRES_DB: appdb
      POSTGRES_PASSWORD: example
      POSTGRES_USER: app
    healthcheck:
      interval: 5s
      retries: 20
      start_period: 10s
      test:
      - CMD-SHELL
      - pg_isready -U app -d appdb -h 127.0.0.1 || exit 1
      timeout: 3s
    image: postgres:16

Qué significa: Estás validando lo que Compose ejecutará realmente (tras merges, overrides e interpolación de entorno).

Decisión: Si el healthcheck o la condición de dependencia faltan aquí, estás depurando el archivo equivocado o la implementación de Compose equivocada.

Tarea 3: Lee los logs de la API con marcas de tiempo

cr0x@server:~$ docker compose logs --timestamps --tail=200 api
api-1  2026-02-04T08:12:09.441Z ERROR db connect failed: dial tcp 172.22.0.2:5432: connect: connection refused
api-1  2026-02-04T08:12:09.443Z INFO  exiting with code 1
api-1  2026-02-04T08:12:11.012Z INFO  starting api version=1.9.3

Qué significa: El cliente intentó una vez y salió. Este es el comportamiento clásico de “sin reintento/backoff”.

Decisión: Arregla la lógica de arranque de la app, no Compose. Añade reintentos y solo falla de forma categórica tras un tiempo acotado.

Tarea 4: Lee los logs de la BD alrededor de la inicialización

cr0x@server:~$ docker compose logs --timestamps --tail=200 db
db-1  2026-02-04T08:12:03.118Z PostgreSQL init process complete; ready for start up.
db-1  2026-02-04T08:12:04.002Z database system is ready to accept connections

Qué significa: La BD estuvo lista a las 08:12:04Z, pero la API lo intentó a las 08:12:09Z y aun así recibió rechazo. Ese desajuste sugiere red, dirección equivocada o reinicio de la BD.

Decisión: Valida el target de conexión de la API (host, puerto, TLS) y prueba conectividad desde dentro del namespace de red.

Tarea 5: Valida DNS y alcance de red desde el contenedor de la API

cr0x@server:~$ docker compose exec api getent hosts db
172.22.0.2      db

Qué significa: La resolución DNS dentro de la red de Compose funciona.

Decisión: Si esto falla, tienes un problema de adjunción de red o nombre (red equivocada, nombre de servicio incorrecto, o contenedor no en la misma red).

Tarea 6: Comprueba la conexión TCP a la BD desde el contenedor de la API

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

Qué significa: TCP es alcanzable ahora.

Decisión: Si TCP está bien pero la autenticación/consulta falla, tu probe de readiness debe ser más profundo que “puerto abierto”.

Tarea 7: Realiza una consulta real a la BD desde el contenedor de la API

cr0x@server:~$ docker compose exec api bash -lc 'PGPASSWORD=example psql -h db -U app -d appdb -c "select 1;"'
 ?column?
----------
        1
(1 row)

Qué significa: La autenticación y el camino de consulta básico funcionan.

Decisión: Si tu app aún falla, el problema probablemente sea la configuración de la aplicación (DSN equivocado), migraciones o límites del pool de conexiones—no Compose.

Tarea 8: Inspecciona los detalles de salud del contenedor (no adivines)

cr0x@server:~$ docker inspect --format '{{json .State.Health}}' stack-db-1
{"Status":"healthy","FailingStreak":0,"Log":[{"Start":"2026-02-04T08:12:25.011Z","End":"2026-02-04T08:12:25.042Z","ExitCode":0,"Output":"/var/run/postgresql:5432 - accepting connections\n"}]}

Qué significa: El healthcheck devuelve “accepting connections.” No valida la disponibilidad del esquema ni que el usuario de la API tenga privilegios más allá de conectar.

Decisión: Si necesitas disponibilidad del esquema, crea un probe que verifique una tabla conocida o la versión de migración.

Tarea 9: Verifica bucles de reinicio y códigos de salida

cr0x@server:~$ docker compose ps --all
NAME                SERVICE   STATUS
stack-api-1         api       restarting (1) 3 seconds ago
stack-db-1          db        running (healthy)

Qué significa: La API está en crash-loop. Esto no es “esperar”, está fallando y reiniciando.

Decisión: Pausa el bucle para preservar logs/estado, luego arregla la falla inmediata. Los crash-loops también pueden DoSear tu dependencia.

Tarea 10: Confirma variables de entorno y el DSN real usado

cr0x@server:~$ docker compose exec api env | egrep 'DATABASE_URL|PGHOST|PGPORT|PGUSER'
DATABASE_URL=postgres://app:example@db:5432/appdb?sslmode=disable

Qué significa: La app está configurada para conectar a db dentro de la red de Compose, no a localhost.

Decisión: Si ves localhost aquí, ese es tu error. En contenedores, localhost apunta al propio contenedor, no al servicio BD.

Tarea 11: Identifica cuellos de botella de recursos en el host (CPU, memoria, IO)

cr0x@server:~$ docker stats --no-stream
CONTAINER ID   NAME         CPU %     MEM USAGE / LIMIT     MEM %     NET I/O         BLOCK I/O
a12b3c4d5e6f   stack-db-1   215.32%   1.2GiB / 2GiB        60.00%    2.1MB / 3.4MB   1.2GB / 900MB
b98c7d6e5f4a   stack-api-1  0.32%     55MiB / 512MiB       10.74%    800KB / 700KB   12MB / 4MB

Qué significa: La BD está muy cargada de CPU y haciendo mucho bloque IO. Eso puede retrasar la disponibilidad y hacer que los clientes hagan timeout.

Decisión: Si la BD está saturada durante el arranque, ajusta start_period, aumenta timeouts y considera el rendimiento de almacenamiento (tipo de volumen, contención de IO del host).

Tarea 12: Prueba que el problema es temporización de arranque demorando el inicio de la app

cr0x@server:~$ docker compose stop api
[+] Stopping 1/1
 ✔ Container stack-api-1  Stopped
cr0x@server:~$ sleep 15
cr0x@server:~$ docker compose start api
[+] Starting 1/1
 ✔ Container stack-api-1  Started

Qué significa: Si esto “arregla” el problema, has confirmado una carrera de arranque.

Decisión: No dejes el sleep. Implementa gating de disponibilidad (healthcheck + condition) y reintentos en la app.

Tarea 13: Revisa la línea temporal de eventos para capturar reinicios y transiciones de salud

cr0x@server:~$ docker events --since 10m --filter 'container=stack-db-1' --filter 'container=stack-api-1'
2026-02-04T08:12:01.004Z container create a12b3c4d5e6f (name=stack-db-1)
2026-02-04T08:12:01.210Z container start a12b3c4d5e6f (name=stack-db-1)
2026-02-04T08:12:04.120Z container health_status: healthy a12b3c4d5e6f (name=stack-db-1)
2026-02-04T08:12:04.300Z container start b98c7d6e5f4a (name=stack-api-1)
2026-02-04T08:12:09.443Z container die b98c7d6e5f4a (name=stack-api-1, exitCode=1)

Qué significa: Obtienes una línea temporal exacta: la BD estuvo healthy antes de que la API arrancara, sin embargo la API murió. Eso apunta a algo distinto de la disponibilidad ingenua y hacia configuración, permisos, TLS o algo que el healthcheck no probó.

Decisión: Amplía el healthcheck o añade logging de disponibilidad en la app que diga exactamente qué espera.

Tarea 14: Valida montajes de volúmenes y permisos para dependencias con estado

cr0x@server:~$ docker compose exec db bash -lc 'ls -ld /var/lib/postgresql/data; df -h /var/lib/postgresql/data | tail -1'
drwx------ 19 postgres postgres 4096 Feb  4 08:12 /var/lib/postgresql/data
overlay          80G   78G  2.0G  98% /

Qué significa: Disco al 98% de uso. Postgres puede “iniciar”, pasar healthchecks simplistas y luego comportarse mal por presión de escritura.

Decisión: Trata la presión de disco como un requisito de primer orden para servicios con estado. Arregla la capacidad antes de tocar YAML.

Guía rápida de diagnóstico

Si solo recuerdas una sección, que sea esta. Cuando una pila Compose “arranca” pero no funciona, quieres encontrar el cuello de botella rápido—no escribir fanfiction sobre depends_on.

Primero: establece la clase de fallo (crash-loop de app vs app arriba pero fallando)

cr0x@server:~$ docker compose ps
NAME         SERVICE   STATUS
stack-api-1  api       restarting (1) 2 seconds ago
stack-db-1   db        running (healthy)

Si hay bucle de reinicio: céntrate en los logs de la app y la razón de salida. No persigas la disponibilidad hasta saber qué falla.

Segundo: lee los logs de ambos lados, alineados en tiempo

cr0x@server:~$ docker compose logs --timestamps --tail=100 api
...application errors...
cr0x@server:~$ docker compose logs --timestamps --tail=100 db
...db startup and readiness...

Decisión: Si la BD claramente no estaba lista cuando la API intentó, necesitas gating o reintentos. Si la BD estaba lista, la falla probablemente sea config/auth/esquema/recursos.

Tercero: prueba desde dentro del namespace de red del contenedor que falla

cr0x@server:~$ docker compose exec api getent hosts db
...ip...
cr0x@server:~$ docker compose exec api bash -lc 'timeout 2 bash -lc "

Decisión: DNS fallo → cableado de red. TCP fallo → dependencia no escucha o puerto equivocado. TCP OK pero la app falla → auth/esquema/TLS/pool/timeouts.

Cuarto: busca contención de recursos a nivel host

cr0x@server:~$ docker stats --no-stream
...cpu/mem/io...

Decisión: Si la BD está limitada por I/O, verás que la “disponibilidad” fluctúa porque el mundo está lento, no porque el YAML esté mal.

Quinto: valida qué estás ejecutando realmente

cr0x@server:~$ docker compose config
...resolved config...

Decisión: Si la configuración no es la que pensabas, para. Arregla la fuente de verdad (archivo equivocado, override erróneo, entorno incorrecto) antes de depurar síntomas.

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

1) Síntoma: la API sale inmediatamente con “connection refused”

Causa raíz: El cliente hace un único intento de conexión durante el boot, falla rápido y sale. depends_on no ayudó porque no espera disponibilidad.

Solución: Añade reintento/backoff en la app, o condiciona el inicio con un healthcheck significativo (service_healthy) más una espera acotada en la app.

2) Síntoma: la API puede resolver “db” pero no puede conectar

Causa raíz: La BD está escuchando en otro puerto, ligada a otra dirección, o está en crash-loop. A veces la BD está arrancando y luego reiniciando por corrupción de almacenamiento o problemas de configuración.

Solución: Revisa logs de BD, inspecciona bindings de puerto, asegura que la BD escucha en la interfaz esperada. Prueba TCP desde dentro del contenedor de la API.

3) Síntoma: funciona en el segundo reinicio; falla en despliegue limpio

Causa raíz: Carrera de arranque. El sistema depende accidentalmente del timing.

Solución: Deja de “arreglarlo” con reinicios manuales. Haz la disponibilidad explícita con healthchecks y reintentos en el cliente. Añade un plazo de arranque para fallar de forma ruidosa si no puede recuperarse.

4) Síntoma: “relation does not exist” o “table not found” durante el arranque

Causa raíz: Las migraciones no están completas cuando la app arranca, o múltiples réplicas ejecutan migraciones concurrentemente.

Solución: Ejecuta migraciones como trabajo/step dedicado. Si debes ejecutarlas desde la app, usa locks de advisory o un patrón de runner único y registra el estado de migración claramente.

5) Síntoma: el healthcheck de la BD está sano, pero la app hace timeout

Causa raíz: El healthcheck prueba una condición superficial (socket abierto) pero no el rendimiento, la autenticación o la disponibilidad del esquema. O la BD está sobrecargada (CPU/IO) y lenta.

Solución: Haz el healthcheck significativo (por ejemplo, una consulta). Aumenta timeouts con cautela. Investiga contención de recursos del host y latencia de almacenamiento.

6) Síntoma: todo está “arriba”, pero las solicitudes fallan intermitentemente

Causa raíz: Reinicios de dependencias en vuelo, agotamiento del pool de conexiones, glitches efímeros de DNS/red, o políticas de reinicio que ocultan fallos recurrentes.

Solución: Añade circuit breakers y reintentos con jitter. Monitoriza reinicios y flaps de salud. No uses políticas de reinicio como sustituto de arreglar las causas de los crashes.

7) Síntoma: usar localhost en la config de la app funciona fuera de Docker, falla dentro

Causa raíz: Dentro de un contenedor, localhost es el propio contenedor.

Solución: Usa el nombre de servicio de Compose (db) como host, o usa un alias de red explícito.

Tres mini-historias del mundo corporativo (anonimizadas, plausibles, técnicamente exactas)

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

Una empresa SaaS mediana tenía una pila Compose “simple” en staging que arrancaba Postgres, un contenedor de migraciones y una API. El contenedor de migraciones dependía de Postgres. La API dependía del contenedor de migraciones. Parecía una cadena ordenada de responsabilidad.

Durante un ensayo de despliegue un lunes por la mañana, la API arrancó e inmediatamente empezó a fallar. El equipo hizo lo que suelen hacer: reiniciaron todo. Funcionó en el segundo intento. Se encogieron de hombros y siguieron.

Dos semanas después, reconstruyeron los hosts de staging. Discos limpios, almacenamiento más lento, un kernel ligeramente distinto. En el primer arranque tras el despliegue, Postgres tardó más en reproducir WAL. El contenedor de migraciones arrancó (porque el contenedor de Postgres se había iniciado), intentó conectar, falló una vez y salió con código no cero. La API arrancó igualmente porque la cadena de dependencias solo codificaba orden de inicio, no “migraciones exitosas”. Luego murió por tablas faltantes.

La interrupción no fue dramática, pero fue ruidosa: un montón de alertas, ingenieros confundidos y ejecutivos preguntando por qué “staging está caído de nuevo”. La causa raíz fue dolorosamente simple: modelaron la corrección como orden de inicio de contenedores, no como criterios explícitos de disponibilidad y éxito.

La solución también fue simple pero requirió disciplina: las migraciones se convirtieron en un paso explícito de despliegue con pase/fallo claro. La API ganó reintentos/backoff y un plazo de arranque. Compose siguió usándose, pero como ejecutor—no como motor de fiabilidad.

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

Un equipo de plataforma de datos quería feedback de desarrollador más rápido. Acortaron los intervalos y reintentos de los healthchecks para hacer que los servicios que fallaran “fallecieran rápido”. En teoría, una dependencia fallida se detectaría antes y los devs la arreglarían.

En la práctica, crearon una máquina de flap. En laptops, la BD estaba lenta durante cold starts por las limitaciones de Docker Desktop. El healthcheck estricto fallaba pronto, Compose reportaba unhealthy y los servicios dependientes nunca arrancaban. Los desarrolladores empezaron a “arreglarlo” aumentando límites de CPU locales o deshabilitando healthchecks por completo.

Luego el patrón se filtró al CI. Los runners de CI eran limitados y con vecinos ruidosos. Los healthchecks fallaban frecuentemente durante el start_period, haciendo que pipelines fallaran intermitentemente. Los ingenieros perdieron confianza en la señal y reintentaban pipelines hasta que pasaban. La organización terminó con entrega más lenta, más cómputo desperdiciado y menos alertas útiles.

Lo revertieron admitiendo una verdad aburrida: los healthchecks no son una carrera hacia abajo. Deben corresponder al comportamiento de arranque esperado bajo contención realista. Aumentaron start_period, mantuvieron intervalos razonables y usaron reintentos en la app para suavizar la variación. “Fail fast” se convirtió en “fallar claramente, con contexto”.

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

Un servicio relacionado con pagos tenía un entorno de integración basado en Compose usado por varios equipos. Nada sofisticado: API, worker, Postgres, Redis. El equipo que lo gestionaba era alérgico a la sofisticación, lo cual es un cumplido.

Hicieron cumplir tres reglas: cada dependencia tenía un healthcheck significativo; cada cliente tenía reintento/backoff con un tiempo máximo de arranque; y cada despliegue ejecutaba una prueba de humo desde dentro del namespace de red tras el arranque. La prueba de humo no era extensa—solo lo suficiente para probar la ruta crítica.

Una mañana, un reinicio de host coincidió con una desaceleración de almacenamiento. Postgres arrancó pero estaba lento; los healthchecks tardaron más pero aún pasaron dentro del margen configurado. La API tardó más en declararse lista porque su chequeo interno de disponibilidad esperaba una consulta exitosa más una verificación de versión de migración. No hizo crash-loop, por lo que no azotó Postgres con oleadas de conexiones frías.

El resultado fue profundamente poco espectacular: el arranque fue más largo, y aun así todo funcionó. Los equipos notaron la demora pero no una caída. La diferencia no fue heroica; fue que el sistema estaba diseñado para un mundo donde el arranque es variable y las dependencias pueden llegar tarde.

Listas de verificación / plan paso a paso

Un plan paso a paso para salir de la trampa de dependencias

  1. Deja de usar depends_on como disponibilidad. Consérvalo solo para orden de inicio.
  2. Añade healthchecks a servicios con estado. Hazlos significativos (no solo “puerto abierto”).
  3. Si está soportado, condiciona con condition: service_healthy. Trátalo como conveniencia, no como garantía.
  4. Implementa reintentos cliente con backoff exponencial + jitter. Incluye un plazo máximo de arranque (p. ej., 2–5 minutos).
  5. Separa las migraciones del arranque de la app. Ejecútalas como paso explícito con logging y comportamiento de fallo claro.
  6. Diseña la disponibilidad en torno al viaje del usuario. “DB ping funciona” puede no significar “esquema listo”.
  7. Instrumenta el arranque. Registra qué esperas, cuánto tardó y por qué falló.
  8. Valida desde dentro de los contenedores. Prueba DNS, TCP y una consulta real desde el contenedor de la app.
  9. Vigila recursos. Si el arranque de la BD está limitado por I/O, arregla almacenamiento/contención del host, no el YAML.
  10. Ejecuta una prueba de humo post-arranque. Una pequeña prueba rápida detecta carreras de arranque antes que los usuarios.

Lista operativa para un archivo Compose que no lamentarás

  • Cada servicio con estado tiene un healthcheck con start_period, timeout y retries sensatos.
  • Los clientes usan nombres de servicio, no localhost, para conectividad intra-pila.
  • Las políticas de reinicio se eligen intencionalmente; los crash-loops se tratan como incidentes, no como “autocuración”.
  • Las migraciones/inicialización son de ejecución única y observables.
  • Los logs incluyen timestamps y suficiente contexto para reconstruir una línea temporal de arranque.

Preguntas frecuentes

1) ¿depends_on espera a que el puerto de la BD se abra?

No. Por defecto solo asegura que Compose inicie el contenedor dependencia antes de iniciar el contenedor dependiente. No implica disponibilidad de puerto.

2) Si añado un healthcheck a Postgres, ¿ya está todo listo?

Estás menos equivocado, no acabado. Un healthcheck puede ayudar a condicionar el arranque inicial (si usas service_healthy), pero tu app aún necesita reintentos para reinicios y fallos transitorios.

3) ¿Por qué no usar simplemente un script “wait-for-it” en todas partes?

Porque a menudo solo comprueba la conexión TCP, que es la definición más superficial de “listo”. También tiende a convertirse en pegamento tribal que olvidas mantener. Prefiere reintentos en la app y healthchecks significativos; usa scripts de espera solo cuando sea imprescindible.

4) Mi BD está sana pero las migraciones no han terminado. ¿Cómo lo modelo?

Separa responsabilidades: la salud de la BD significa “la BD puede atender solicitudes”. La finalización de migraciones es un estado de despliegue. Ejecuta migraciones como un paso explícito o un servicio one-shot y condiciona la disponibilidad de la app a una verificación de esquema/versión.

5) ¿Puedo confiar en condition: service_healthy en todos los entornos?

No. El soporte de funciones varía según versiones de Compose y herramientas. Verifica siempre con docker compose config y prueba en el entorno donde despliegas.

6) ¿Por qué solo falla en máquinas nuevas o tras reboot del host?

Los arranques en frío amplifican la variabilidad: caches fríos, discos ocupados, servicios ejecutando recovery y planificación de CPU más ruidosa. Si tu sistema depende de “usualmente arranca rápido”, fallará cuando las cosas estén frías y lentas.

7) ¿Es malo usar restart: always?

No es moralmente malo; es operacionalmente arriesgado. Puede ocultar fallos reales, crear azotes a dependencias y complicar la interpretación de logs. Combina políticas de reinicio con backoff, buenos logs y healthchecks reales.

8) ¿Cómo diferencio problemas de disponibilidad de problemas de rendimiento?

Los problemas de disponibilidad fallan pronto con “connection refused”, errores DNS o fallos de autenticación. Los problemas de rendimiento muestran timeouts, latencia alta y saturación de recursos (docker stats muestra alta CPU/IO). Trátalos de forma diferente.

9) ¿Cuál es el enfoque más simple y fiable para pilas pequeñas?

Haz healthcheck a la BD, añade reintentos cliente con backoff y ejecuta una prueba de humo tras el arranque. Ese trío evita la mayoría de incidentes por carreras de arranque sin convertir tu Compose en un guion cinematográfico.

Siguientes pasos que realmente reducen incidentes

Si tu pila ocasionalmente necesita “solo reinícialo”, no tienes un problema de Compose. Tienes un problema de contrato de disponibilidad. depends_on está bien para ordenar. No es un apretón de manos, no es una promesa y no sustituye a la resiliencia.

Haz esto siguiente, en este orden:

  1. Añade healthchecks significativos a dependencias con estado (BD, colas, caches).
  2. Haz que los clientes reintenten con backoff exponencial, jitter y un plazo máximo de arranque.
  3. Deja de mezclar migraciones en arranques aleatorios de la app; ejecútalas explícitamente y observa el éxito.
  4. Construye una línea temporal durante incidentes usando logs con timestamps y docker events.
  5. Prueba conectividad desde dentro de contenedores antes de reescribir configuraciones.

Compose seguirá haciendo lo que siempre hizo: iniciar contenedores. Tu trabajo es hacer que “iniciado” signifique algo útil. Eso es ingeniería de fiabilidad: convertir los modos de fallo obvios en algo aburrido.

← Anterior
Mentiras de las copias de seguridad de Windows: los 3 ajustes que deciden si puedes restaurar
Siguiente →
Cómo verificar que una copia de seguridad realmente se restaura (sin arruinar el PC)

Deja un comentario