Si alguna vez has visto una pila de Compose “arrancarse” mientras tu aplicación real sigue caída, ya conoces el secreto feo:
arrancar contenedores no es lo mismo que los servicios estén listos.
En producción, ese hueco es donde nacen las alertas.
Las fallas suelen ser aburridas: una base de datos que necesita 12 segundos más, una migración que se ejecutó dos veces, un volumen obsoleto, un disco de logs llenándose,
una optimización “útil” que eliminó silenciosamente las protecciones. Compose no es el villano. Los patrones por defecto sí lo son.
El patrón: Compose como un grafo de servicios con puertas y observabilidad
El patrón de Compose que evita la mayoría de las interrupciones en producción no es una sola línea en docker-compose.yml. Es una postura:
trata tu pila como un grafo de servicios con disponibilidad explícita, inicio controlado, recursos acotados, estado duradero y
comportamiento de fallo predecible.
Aquí está la idea central:
- Cada servicio crítico tiene una comprobación de salud que refleje la verdadera disponibilidad (no “el proceso existe”).
- Las dependencias se controlan en base a la salud, no a la creación del contenedor.
- Los trabajos one-shot son explícitos (migraciones, comprobaciones de esquema, bootstrap) y son idempotentes.
- El estado está aislado en volúmenes nombrados (o bind mounts bien gestionados) con rutas de respaldo y restauración.
- La configuración es inmutable por despliegue (env + archivos) y los cambios son deliberados.
- La política de reinicio es una decisión, no un valor por defecto. Estrellarse para siempre no es “alta disponibilidad”.
- Los logs están acotados para que el “modo debug” no se convierta en “disco lleno”.
- Existen límites de recursos para que un servicio no pueda dejar sin recursos al host y arrastrar los demás.
- Tienes un bucle de diagnóstico rápido: tres comandos para identificar el cuello de botella en menos de dos minutos.
El resultado práctico: menos fallos en cascada, menos tormentas de reinicios, menos incidentes de “funciona en mi máquina” y muchas menos
misteriosas alertas a las 3 a.m. causadas por problemas invisibles de ordenación.
Una cita que vale la pena tener pegada en tu monitor: La esperanza no es una estrategia.
— James Greene (comúnmente citado en círculos de operaciones).
Si no estás seguro de la literalidad, trátala como una paráfrasis y sigue adelante; el punto se mantiene.
Broma #1: Lo bueno de “funciona en mi máquina” es que es verdad. Lo malo es que tu máquina no es producción.
Qué no es este patrón
- No significa “convertir todo en Kubernetes.” Compose es válido para muchos sistemas en producción.
- No es “solo añadir
depends_on.” Sin control por salud, es teatro de ordenamiento. - No es “restart: always.” Así conviertes una mala configuración en un bucle infinito con excelentes métricas de uptime para el runtime de contenedores.
Por qué funciona: colapsas la incertidumbre
La mayoría de las interrupciones en despliegues Compose son incertidumbre disfrazada de conveniencia:
“Probablemente arranque lo suficientemente rápido,” “la red estará lista,” “la migración solo se ejecutará una vez,” “el archivo de log no crecerá,”
“el volumen es igual que la última vez,” “esa variable de entorno está definida en algún lugar.”
Este patrón elimina el “probablemente.” Lo sustituye por comprobaciones y puertas que puedes inspeccionar.
Datos interesantes y contexto histórico
- Compose empezó como Fig (era 2013–2014), una herramienta para desarrolladores para definir apps multi-contenedor; el endurecimiento para producción llegó después mediante patrones, no por defecto.
- Las comprobaciones de salud de Docker se introdujeron después de que los operadores siguieran inventando scripts “wait-for-it”; la plataforma acabó reconociendo que la readiness es una necesidad de primera clase.
depends_onno significa “listo” por defecto; históricamente significa “inicia este contenedor antes que aquel”, lo cual rara vez es el requisito real.- Las políticas de reinicio no son reintentos; son “seguir intentando para siempre.” Muchos postmortems mencionan una tormenta de reinicios que ocultó el primer error útil.
- Los contenedores no contienen el kernel; los vecinos ruidosos siguen existiendo. Sin límites de CPU/memoria, un servicio puede degradar el host y todo lo que corre en él.
- Los volúmenes locales son fáciles, la portabilidad no lo es; los volúmenes nombrados son portables en definición, pero el ciclo de vida de los datos sigue siendo tu responsabilidad.
- Los drivers de logging importan; json-file es cómodo hasta que una app parlanchina convierte el disco en un incidente de expansión lenta.
- Compose no es un scheduler; no redistribuirá cargas entre nodos ni gestionará fallos de nodo como un orquestador. Tu diseño debe asumir un host único a menos que implementes otra cosa.
- Los “init containers” existían como patrón mucho antes de que Kubernetes popularizara el término; los bootstraps one-shot son una necesidad universal en sistemas distribuidos.
Por qué las pilas Compose fallan en producción (los reincidentes)
1) El orden de arranque se confunde con la disponibilidad de dependencias
Tu contenedor de API arranca. Intenta conectarse a Postgres. Postgres está “arriba” en términos de Docker (el PID existe), pero aún está reproduciendo WAL,
realizando recuperación o simplemente no está escuchando todavía. La API falla, se reinicia, falla de nuevo. Ahora tienes una interrupción que parece un problema de API,
pero en realidad es un problema de readiness.
Aquí es donde comprobaciones de salud + control se pagan a sí mismos. No quieres que tu API haga de probe de disponibilidad para la base de datos.
Es malo para el uptime y peor para los logs.
2) Las migraciones se tratan como un efecto lateral en lugar de un trabajo
Un antipatrón común: el entrypoint del contenedor de la app ejecuta migraciones y luego arranca el servidor.
En un solo contenedor, en un solo host, con una sola réplica, tal vez está bien.
En la vida real, la app se reinicia, la migración se vuelve a ejecutar, bloquea tablas o aplica cambios parcialmente.
Haz que las migraciones sean un servicio one-shot dedicado. Hazlas idempotentes. Haz que bloqueen el arranque de la app hasta que terminen.
Tu yo del futuro te lo agradecerá. Probablemente en forma de menos alertas.
3) Los volúmenes se tratan como “algún directorio”
Los servicios con estado no fallan educadamente. Fallan corrompiendo, llenando o montándose con permisos incorrectos.
Los volúmenes nombrados ayudan porque desacoplan la ruta de datos del layout aleatorio del sistema de archivos, pero no reemplazan:
backups, restauraciones, comprobaciones de capacidad y control de cambios.
4) Las políticas de reinicio ocultan fallos reales
restart: always es un instrumento contundente. Reiniciará diligentemente un contenedor con una errata en una variable de entorno, un archivo secreto faltante
o una migración fallida. Tu monitorización ve flapping. Tus logs se convierten en una licuadora. Mientras tanto, la causa raíz pasa una vez cada tres segundos.
Usa restart: unless-stopped o on-failure de forma intencional. Combínalo con comprobaciones de salud para que “en ejecución” no sea una mentira.
5) Sin límites de recursos, sorpresa: muerte del host
Compose dejará que un contenedor consuma toda la memoria, dispare el OOM killer y tumbe servicios no relacionados.
Esto no es teórico. Es el truco más antiguo del libro “por qué todo murió”.
6) Los logs se comen el disco
El driver de logging JSON por defecto puede crecer sin límites. Si el disco del host se llena, tu base de datos puede dejar de escribir,
tu app puede dejar de crear archivos temporales y Docker mismo puede volverse inestable.
Logueo acotado no es un extra; es un cinturón de seguridad.
7) Las elecciones de red “convenientes” crean acoplamientos invisibles
Publicar cada puerto al host se siente práctico. También es la forma en que terminas con conflictos de puertos, exposición no intencionada
y un “debug rápido” que se vuelve arquitectura permanente.
Usa redes internas. Publica solo lo que necesiten los humanos o sistemas ascendentes. Mantén el tráfico east-west dentro de la red de Compose.
Archivo Compose de referencia (anotado, orientado a producción)
Esto es un patrón, no un texto sagrado. Adáptalo. La parte importante es la interacción:
comprobaciones de salud, control, trabajos explícitos, logs acotados y volúmenes duraderos.
cr0x@server:~$ cat docker-compose.yml
version: "3.9"
x-logging: &default-logging
driver: "json-file"
options:
max-size: "10m"
max-file: "5"
networks:
appnet:
driver: bridge
volumes:
pgdata:
redisdata:
services:
postgres:
image: postgres:16
environment:
POSTGRES_DB: appdb
POSTGRES_USER: app
POSTGRES_PASSWORD_FILE: /run/secrets/pg_password
secrets:
- pg_password
volumes:
- pgdata:/var/lib/postgresql/data
networks: [appnet]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d appdb -h 127.0.0.1"]
interval: 5s
timeout: 3s
retries: 20
start_period: 10s
restart: unless-stopped
logging: *default-logging
redis:
image: redis:7
command: ["redis-server", "--appendonly", "yes"]
volumes:
- redisdata:/data
networks: [appnet]
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 20
restart: unless-stopped
logging: *default-logging
migrate:
image: ghcr.io/example/app:1.9.3
command: ["./app", "migrate", "up"]
environment:
DATABASE_URL_FILE: /run/secrets/db_url
secrets:
- db_url
networks: [appnet]
depends_on:
postgres:
condition: service_healthy
restart: "no"
logging: *default-logging
api:
image: ghcr.io/example/app:1.9.3
environment:
DATABASE_URL_FILE: /run/secrets/db_url
REDIS_URL: redis://redis:6379/0
PORT: "8080"
secrets:
- db_url
networks: [appnet]
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
migrate:
condition: service_completed_successfully
ports:
- "127.0.0.1:8080:8080"
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/healthz | grep -q ok"]
interval: 10s
timeout: 3s
retries: 10
start_period: 10s
restart: unless-stopped
logging: *default-logging
deploy:
resources:
limits:
memory: 512M
secrets:
pg_password:
file: ./secrets/pg_password.txt
db_url:
file: ./secrets/db_url.txt
Qué tomar prestado de este archivo
- Las comprobaciones de salud reflejan la verdadera disponibilidad:
pg_isready,redis-cli pingy un endpoint HTTP que valide que la app está sirviendo. - El control incluye “completado con éxito” para migraciones. Si las migraciones fallan, la API permanece abajo, de forma ruidosa, con un error accionable.
- Secretos mediante archivos para que las contraseñas no aparezcan en la salida de
docker inspecto en el historial de la shell. - Puertos ligados a localhost por seguridad. Coloca un reverse proxy delante si necesitas acceso externo.
- Logs acotados mediante rotación. Esto previene incidentes donde el log en modo debug llena el disco.
- Volúmenes nombrados para servicios stateful. No es magia, pero al menos es explícito.
Qué deberías personalizar inmediatamente
- Límites de memoria/CPU según el tamaño de tu host y el comportamiento de los servicios.
- Lógica de healthcheck para que coincida con el estado real de “listo” de tu aplicación (p. ej., conectividad DB + migraciones aplicadas).
- Programación de backups y procedimiento de restauración para volúmenes. Si no puedes restaurar, no tienes backups; tienes deseos caros.
Tareas prácticas: comandos, salidas y la decisión que tomas
Estos no son “diagnósticos bonitos.” Son las jugadas que haces mientras el reloj del incidente corre, y las jugadas que haces en martes tranquilos
para prevenir el incidente en primer lugar.
Tarea 1: Ver qué cree Compose que está en ejecución
cr0x@server:~$ docker compose ps
NAME IMAGE COMMAND SERVICE STATUS PORTS
stack-postgres-1 postgres:16 "docker-entrypoint.s…" postgres Up 2 minutes (healthy) 5432/tcp
stack-redis-1 redis:7 "docker-entrypoint.s…" redis Up 2 minutes (healthy) 6379/tcp
stack-migrate-1 ghcr.io/example/app:1.9.3 "./app migrate up" migrate Exited (0) 90 seconds ago
stack-api-1 ghcr.io/example/app:1.9.3 "./app server" api Up 2 minutes (healthy) 127.0.0.1:8080->8080/tcp
Qué significa: “Up” no es suficiente; quieres (healthy) para servicios de larga duración y Exited (0) para trabajos one-shot como migraciones.
Decisión: Si la API está Up pero no (healthy), depura la readiness (lógica de healthcheck, dependencias, tiempo de arranque). Si migrate tiene código distinto de cero, detén y arregla las migraciones primero.
Tarea 2: Identificar la primera falla en los logs (no la más ruidosa)
cr0x@server:~$ docker compose logs --no-color --timestamps --tail=200 api
2026-02-04T01:18:42Z api | ERROR: could not connect to postgres: connection refused
2026-02-04T01:18:45Z api | INFO: retrying in 3s
2026-02-04T01:18:48Z api | ERROR: migration state not found
Qué significa: Estás viendo síntomas. El primer error es “connection refused”, lo que implica que Postgres no estaba escuchando aún o la red/DNS falló.
El posterior “migration state not found” podría ser consecuencia.
Decisión: Revisa la salud y los logs de Postgres a continuación; no te concentres solo en la app.
Tarea 3: Inspeccionar en detalle el estado de salud del contenedor
cr0x@server:~$ docker inspect --format '{{json .State.Health}}' stack-postgres-1
{"Status":"healthy","FailingStreak":0,"Log":[{"Start":"2026-02-04T01:18:21.112Z","End":"2026-02-04T01:18:21.189Z","ExitCode":0,"Output":"/var/run/postgresql:5432 - accepting connections\n"}]}
Qué significa: La comprobación de salud pasa y reporta “accepting connections.” Buena señal.
Decisión: Si la app sigue sin poder conectar, investiga la red (DNS, attachment de red) o la cadena de conexión incorrecta.
Tarea 4: Validar el descubrimiento de servicios desde dentro de la red
cr0x@server:~$ docker exec -it stack-api-1 getent hosts postgres
172.22.0.2 postgres
Qué significa: DNS resuelve postgres a una IP de contenedor en la red de Compose.
Decisión: Si esto falla, probablemente tengas una mala configuración de red (servicio no en la misma red, error tipográfico en la red personalizada, o uso incorrecto de host networking).
Tarea 5: Probar conectividad TCP hacia la dependencia desde el contenedor de la app
cr0x@server:~$ docker exec -it stack-api-1 bash -lc 'nc -vz postgres 5432'
Connection to postgres (172.22.0.2) 5432 port [tcp/postgresql] succeeded!
Qué significa: La ruta de red está abierta. Si la app sigue fallando, probablemente sean credenciales, modo SSL, nombre de BD o parámetros de conexión.
Decisión: Verifica cuidadosamente el contenido y parseo de los secretos y revisa los logs de autenticación de Postgres.
Tarea 6: Confirmar qué configuración recibió realmente el contenedor
cr0x@server:~$ docker exec -it stack-api-1 bash -lc 'ls -l /run/secrets && sed -n "1p" /run/secrets/db_url'
total 4
-r--r----- 1 root root 74 Feb 4 01:17 db_url
postgres://app:REDACTED@postgres:5432/appdb?sslmode=disable
Qué significa: El secreto existe, los permisos parecen razonables y la URL apunta a postgres.
Decisión: Si el archivo falta o está vacío, arregla el montaje del secreto y el proceso de despliegue. Si la URL apunta a localhost, ahí está tu interrupción.
Tarea 7: Revisar los logs de Postgres por problemas de autenticación y recuperación
cr0x@server:~$ docker compose logs --tail=120 postgres
postgres | LOG: database system is ready to accept connections
postgres | FATAL: password authentication failed for user "app"
Qué significa: Postgres está arriba; las credenciales son incorrectas.
Decisión: Rota/arregla el secreto de la contraseña y luego reinicia los servicios afectados. No “reinicies todo” sin arreglar la causa raíz.
Tarea 8: Detectar bucles de reinicio rápidamente
cr0x@server:~$ docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.RunningFor}}'
NAMES STATUS RUNNING FOR
stack-api-1 Restarting (1) 2 seconds ago 3 minutes
stack-postgres-1 Up 5 minutes (healthy) 5 minutes
stack-redis-1 Up 5 minutes (healthy) 5 minutes
Qué significa: La API está haciendo flapping; tus logs pueden estar truncados entre reinicios.
Decisión: Deshabilita temporalmente el reinicio para el servicio que falla o escálalo a cero, captura logs, arregla y luego re-habilita. Los bucles de reinicio hacen perder tiempo y ocultan el primer error.
Tarea 9: Obtener el código de salida y el último error de un contenedor caído
cr0x@server:~$ docker inspect --format '{{.State.ExitCode}} {{.State.Error}}' stack-api-1
1
Qué significa: El código de salida 1 es genérico; debes usar logs y la salida de la app para precisar la falla.
Decisión: Si el código de salida es consistentemente el mismo, probablemente sea una mala configuración determinista (secretos, env, migración) más que un fallo transitorio de infra.
Tarea 10: Comprobar la presión del disco del host antes de hacer algo ingenioso
cr0x@server:~$ df -h
Filesystem Size Used Avail Use% Mounted on
/dev/nvme0n1p2 200G 192G 8.0G 97% /
Qué significa: 97% usado. Estás en zona de peligro. Las bases de datos y Docker se comportan mal cuando el disco está justo.
Decisión: Detén el crecimiento de logs (rota, trunca con cuidado), limpia imágenes no usadas o expande el disco. No redeployes repetidamente y empeores la situación.
Tarea 11: Revisar el desglose de uso de disco de Docker
cr0x@server:~$ docker system df
TYPE TOTAL ACTIVE SIZE RECLAIMABLE
Images 28 6 35.2GB 23.4GB (66%)
Containers 14 4 1.1GB 920MB (83%)
Local Volumes 7 2 96.0GB 22.0GB (22%)
Build Cache 0 0 0B 0B
Qué significa: Los volúmenes son grandes (esperado para bases de datos). Las imágenes son recuperables.
Decisión: Prunea imágenes/contendedores no usados primero; no toques volúmenes sin un plan de backup/restore y una comprensión clara de lo que vas a eliminar.
Tarea 12: Prunea imágenes no usadas con seguridad (cuando hayas confirmado)
cr0x@server:~$ docker image prune -a
Deleted Images:
deleted: sha256:4c2c6b1f8b7c...
Total reclaimed space: 18.6GB
Qué significa: Recuperaste espacio borrando imágenes no usadas.
Decisión: Si la presión de disco persiste, ataca logs y volúmenes a continuación. Si sigues cerca del lleno, necesitas ampliar capacidad o una política de limpieza.
Tarea 13: Identificar quién consume memoria y dispara OOM
cr0x@server:~$ docker stats --no-stream
CONTAINER ID NAME CPU % MEM USAGE / LIMIT MEM % NET I/O BLOCK I/O PIDS
c0ffee12ab34 stack-api-1 180.23% 620MiB / 512MiB 121.1% 120MB / 95MB 2.1GB / 45MB 42
bada55aa9876 stack-postgres-1 35.01% 900MiB / 0B 0.0% 80MB / 110MB 8.2GB / 3.1GB 19
Qué significa: La API excedió su límite de memoria y puede estar siendo terminada; Postgres no tiene límite (0B muestra “ilimitado”).
Decisión: Aumenta la memoria de la API si es legítimo, arregla fugas de memoria y establece límites sensatos para Postgres también—alineados con la RAM del host y necesidades de cache.
Tarea 14: Revisar kills por OOM del kernel en el host
cr0x@server:~$ dmesg -T | tail -n 12
[Mon Feb 4 01:22:13 2026] Out of memory: Killed process 21134 (app) total-vm:2104820kB, anon-rss:682312kB, file-rss:104kB, shmem-rss:0kB, UID:0 pgtables:1820kB oom_score_adj:0
Qué significa: El kernel mató tu app. Eso no es “el contenedor se cayó”; es “el host se quedó sin memoria.”
Decisión: Añade memoria, pon límites, reduce la concurrencia o arregla la app. Si ignoras las señales OOM, tu próximo incidente será más ruidoso.
Tarea 15: Verificar que la rotación de logs se aplicó realmente
cr0x@server:~$ docker inspect --format '{{.HostConfig.LogConfig.Type}} {{json .HostConfig.LogConfig.Config}}' stack-api-1
json-file {"max-file":"5","max-size":"10m"}
Qué significa: El contenedor usa logs json-file acotados.
Decisión: Si ves configuración vacía, tu ancla de logging no se aplicó o dependes de defaults del daemon. Arréglalo en el nivel de Compose.
Tarea 16: Confirmar volúmenes y puntos de montaje (la comprobación “¿dónde están mis datos?”)
cr0x@server:~$ docker volume ls
DRIVER VOLUME NAME
local stack_pgdata
local stack_redisdata
cr0x@server:~$ docker volume inspect stack_pgdata | sed -n '1,12p'
[
{
"CreatedAt": "2026-02-03T21:10:44Z",
"Driver": "local",
"Name": "stack_pgdata",
"Mountpoint": "/var/lib/docker/volumes/stack_pgdata/_data",
"Scope": "local"
}
]
Qué significa: Los datos viven bajo el punto de montaje de volúmenes de Docker en este host.
Decisión: Si esperabas datos en otro lado (como un bind mount), reconcilia eso ahora—antes de que un reemplazo de host se convierta en un evento de borrado accidental de datos.
Tarea 17: Tomar un backup lógico rápido y consistente de Postgres (para BD pequeñas/medianas)
cr0x@server:~$ docker exec -t stack-postgres-1 pg_dump -U app -d appdb | gzip -c > /var/backups/appdb_$(date +%F).sql.gz
Qué significa: Creaste un volcado SQL gzipped en el host.
Decisión: Si la base de datos es grande, esto puede ser demasiado lento para respuesta de incidentes. Planea backups físicos o replicación; no lo descubras en una caída.
Tarea 18: Verificar el endpoint de salud desde el host
cr0x@server:~$ curl -fsS http://127.0.0.1:8080/healthz
ok
Qué significa: El servicio es alcanzable desde el host y devuelve el cuerpo esperado.
Decisión: Si esto falla pero el contenedor está “healthy”, tu healthcheck está mintiendo o el binding de puertos es incorrecto.
Guion de diagnóstico rápido
Cuando producción está caída, no necesitas sabiduría. Necesitas un bucle corto que identifique rápidamente el cuello de botella y te impida
“arreglar” lo equivocado a toda prisa.
Primero: ¿es un problema de disponibilidad de dependencias o un bug de la app?
- Revisa el estado del grafo:
cr0x@server:~$ docker compose ps NAME IMAGE COMMAND SERVICE STATUS PORTS stack-api-1 ghcr.io/example/app:1.9.3 "./app server" api Up 1 minute (unhealthy) 127.0.0.1:8080->8080/tcp stack-postgres-1 postgres:16 "docker-entrypoint" postgres Up 1 minute (healthy) 5432/tcpDecisión: Si las dependencias están healthy pero la API está unhealthy, enfócate en la configuración de la API y su propio camino de readiness.
- Lee las últimas 200 líneas del servicio que falla:
cr0x@server:~$ docker compose logs --tail=200 api api | ERROR: missing required setting: JWT_PUBLIC_KEYDecisión: Falta configuración/secret. No redeployes. Arregla la inyección de configuración.
Segundo: ¿está enfermo el host (disco, memoria, CPU, IO)?
- Disco:
cr0x@server:~$ df -h / Filesystem Size Used Avail Use% Mounted on /dev/nvme0n1p2 200G 199G 1.0G 100% /Decisión: Trata “100%” como “nada funciona.” Libera espacio antes de hacer otra cosa.
- Presión de memoria:
cr0x@server:~$ free -h total used free shared buff/cache available Mem: 16Gi 15Gi 210Mi 120Mi 790Mi 420Mi Swap: 0B 0B 0BDecisión: Si la memoria disponible es baja y no hay swap, espera OOM kills. Reduce carga o añade memoria/límites.
- Quién consume recursos:
cr0x@server:~$ docker stats --no-stream CONTAINER ID NAME CPU % MEM USAGE / LIMIT MEM % NET I/O BLOCK I/O PIDS c0ffee12ab34 stack-api-1 220.14% 480MiB / 512MiB 93.8% 140MB / 98MB 1.8GB / 22MB 55Decisión: Si un contenedor está saturando CPU, sospecha bucles cerrados, reintentos o un “thundering herd” desde dependencias fallidas.
Tercero: ¿es de red (DNS, puertos, exposición)?
- DNS dentro del contenedor:
cr0x@server:~$ docker exec -it stack-api-1 getent hosts postgres redis 172.22.0.2 postgres 172.22.0.3 redisDecisión: Si la resolución falla, tienes problemas de attachment de red o nombres de servicio.
- Pruebas de conectividad:
cr0x@server:~$ docker exec -it stack-api-1 bash -lc 'nc -vz postgres 5432; nc -vz redis 6379' Connection to postgres (172.22.0.2) 5432 port [tcp/postgresql] succeeded! Connection to redis (172.22.0.3) 6379 port [tcp/redis] succeeded!Decisión: La ruta de red está bien; el foco pasa a auth/config/migraciones.
El objetivo del guion es evitar el arco clásico del incidente: “reinicia todo”, luego “sigue roto”, luego “¿dónde están los logs?”,
luego “cambiamos tres cosas a la vez.” No seas ese arco.
Tres microhistorias corporativas desde el frente
Microhistoria #1: El incidente causado por una suposición errónea
Una SaaS mediana corría una pila Compose en un solo host: Postgres, Redis, API y un worker. Eran cuidadosos—en su mayoría.
Usaban depends_on porque habían oído “ayuda con el orden.” Supusieron que el orden significaba disponibilidad.
Esa suposición vivió en producción durante meses porque la mayoría de reinicios eran manuales y espaciados.
Un día el host se reinició tras un parche de kernel rutinario. Postgres tardó más de lo habitual porque tuvo que reproducir más WAL de lo normal.
La API arrancó inmediatamente, intentó conectar, falló y se reinició. El worker hizo lo mismo. Ambos estaban configurados con
restart: always.
Su monitor externo vio HTTP 500s y timeouts. Internamente, los logs eran una ráfaga de errores de conexión en modo ametralladora.
El lead de ingeniería inicialmente sospechó una corrupción de Postgres porque “tarda demasiado.” La base de datos estaba bien; simplemente estaba ocupada.
Pero la API y el worker la atacaron con reintentos, volviéndola más ocupada.
Lo arreglaron con un solo cambio: agregar una comprobación real de Postgres y controlar el inicio de la app por esa comprobación. Segundo cambio: limitar la concurrencia de reintentos en la capa de app.
El siguiente reboot fue sin incidentes. La interrupción no fue causada por Compose. Fue causada por tratar un evento de inicio como una garantía de disponibilidad.
La lección no fue sutil: el grafo de dependencias existe tanto si lo modelas como si no. Si no lo modelas, producción lo hará por ti.
Microhistoria #2: La optimización que salió mal
Un equipo de servicios financieros estaba presionado para reducir uso de disco. Sus hosts iban justos y los directorios de datos de Docker seguían creciendo.
Alguien notó que los logs JSON eran enormes. “Optimizó” cambiando varios servicios a logging mínimo y pruning agresivo.
El cambio parecía responsable: menor retención de logs, más pruning, más automatización.
Semanas después, surgió un bug sutil: un job en background fallaba ocasionalmente al renovar un token, causando fallos intermitentes aguas abajo.
El incidente era real pero lo suficientemente intermitente como para confundir. El ingeniero on-call buscó logs—solo para descubrir que los logs relevantes del contenedor
habían sido podados por su propia automatización antes de que alguien notara el patrón.
El equipo trató de compensar aumentando la verbosidad temporalmente. Eso causó otro problema: la presión de disco subió, porque la rotación
no se aplicó de forma consistente entre servicios. Algunos contenedores rotaban. Otros no. La “optimización” había creado un mix de políticas,
que es como la producción convierte lo “razonable” en “caos.”
La solución final fue aburrida: aplicar una política uniforme de logs vía anchors en Compose, mantener suficiente historial para cubrir la ventana de detección del monitoring,
y enviar logs críticos de aplicación a un sistema central en lugar de depender de logs locales. También quitaron el pruning automático durante horas de negocio.
La lección: el disco es un recurso, los logs son un recurso, y “optimizar” sin observabilidad es simplemente recortar costos a ciegas.
Microhistoria #3: La práctica aburrida pero correcta que salvó el día
Una compañía de e-commerce corría Compose para servicios internos: actualizaciones de catálogo, ingestión de precios y una API pequeña. Nada glamoroso.
Pero tenían una cosa que muchos equipos omiten: un ejercicio escrito de restauración de volúmenes, probado trimestralmente.
El ejercicio no era “tenemos backups.” Era “restauramos un backup en un host limpio y comprobamos que el servicio funciona.”
Durante un cambio rutinario, un ingeniero limpió un directorio en el host—confiado en que era “solo cosas viejas de Docker.”
No lo era. Era un directorio bind-mounted usado por un servicio stateful. El contenedor aún arrancó. Incluso pareció correcto por unos minutos.
Luego empezó a devolver datos parciales y timeouts.
No discutieron culpas. Ejecutaron el ejercicio. Pararon la pila, restauraron desde el backup más reciente en un volumen nombrado limpio,
y levantaron los servicios con el mismo Compose file. Validaron los healthchecks y luego ejecutaron el job de comprobación de consistencia de la app.
El impacto total fue limitado porque sabían exactamente qué significaba “restaurar” en su entorno.
El postmortem no fue heroico. Fue clínico. Migraron ese servicio fuera de bind mounts hacia un volumen nombrado con gestión de ciclo de vida más clara,
y añadieron un ítem en la checklist previa al cambio: verificar puntos de montaje para servicios stateful antes de limpiar a nivel host.
La lección: la práctica aburrida—ejercicios de restauración—convierte incidentes de datos de existenciales a inconvenientes.
Errores comunes: síntoma → causa raíz → solución
1) La API hace flap (reinicios cada pocos segundos)
Síntoma: Restarting (1) en docker ps, los logs muestran errores de conexión repetidos.
Causa raíz: Dependencias no listas; fallos de migraciones; secretos faltantes; la política de reinicio oculta el primer error.
Solución: Añade healthchecks; controla depends_on con condiciones de salud; separa migraciones en un servicio one-shot; deshabilita temporalmente reinicios para capturar el primer fallo.
2) Todo “Up” pero los usuarios obtienen 502/timeout
Síntoma: Compose reporta servicios en ejecución; el proxy externo devuelve 502 o timeouts.
Causa raíz: El healthcheck es demasiado superficial (el proceso existe) o apunta a la interfaz equivocada; la app está en ejecución pero no sirve.
Solución: Haz que el healthcheck golpee un endpoint real y valide una respuesta real; asegúrate de que el servicio se vincule a la dirección correcta; alinea el upstream del proxy con el puerto del contenedor.
3) La base de datos está sana, la app no puede autenticar
Síntoma: Los logs de Postgres muestran password authentication failed.
Causa raíz: Archivo de secreto incorrecto, obsoleto o formateado con una nueva línea final que tu app maneja mal; usuario DB equivocado; nombre de BD incorrecto.
Solución: Usa patrones _FILE en env; estandariza el formateo de secretos; valida secretos dentro del contenedor; rota credenciales deliberadamente.
4) Corte repentino tras activar logging de debug
Síntoma: El disco se llena; Docker y la BD se vuelven inestables; las escrituras fallan.
Causa raíz: Logs json-file sin límites; falta rotación en algún servicio; verbosidad de debug demasiado alta.
Solución: Impone rotación de logs vía anchors; limita la verbosidad; añade monitorización de disco; no dependas de limpiezas manuales.
5) Interrupción súbita tras reboot, los datos “desaparecen”
Síntoma: La aplicación actúa como una instalación nueva; la BD no tiene tablas; Redis se reinicia inesperadamente.
Causa raíz: La ruta de bind mount cambió, permisos impiden lecturas o el servicio usa un volumen diferente al esperado.
Solución: Prefiere volúmenes nombrados para estado; verifica montajes con docker inspect; documenta nombres de volúmenes; ejecuta drills de restauración.
6) Un servicio provoca ralentización en todo el sistema
Síntoma: Alta carga, OOM kills, IO wait; múltiples contenedores se vuelven unhealthy.
Causa raíz: Sin límites de recursos; query descontrolada; job por lotes que choca con tráfico pico.
Solución: Fija límites de memoria/CPU; programa jobs pesados; limita concurrencia; monitoriza métricas del host y stats de contenedores.
7) “Funciona localmente, falla en prod” tras cambiar imágenes
Síntoma: La nueva versión de la imagen falla inmediatamente; la versión anterior funciona.
Causa raíz: Deriva de configuración; variable de entorno faltante; valores por defecto incompatibles; migraciones necesarias pero no ejecutadas.
Solución: Fija tags de imagen; trata la configuración como versionada; exige la finalización del job de migración antes del arranque de la app; mantén una ruta de rollback que no muta el estado.
Broma #2: Lo único peor que una interrupción es una interrupción con “reinicios automáticos útiles”—como una alarma de humo que se reinicia cortésmente.
Listas de verificación / plan paso a paso
Checklist del patrón Compose para producción (haz esto antes de llamarlo “producción”)
- Existen healthchecks para cada servicio que importa (BD, cache, API, proxy).
- Los healthchecks son honestos: validan readiness, no solo liveness.
- Las dependencias se controlan con
condition: service_healthy. - Las migraciones son un trabajo one-shot con
restart: "no"y controladas víaservice_completed_successfully. - Los secretos son archivos (o inyectados de forma segura), no pegados en el historial de la shell.
- Volúmenes nombrados para estado, a menos que tengas una razón operativa para bind mounts.
- Existen backups y las restauraciones se prueban. Programa un drill de restauración.
- Los logs están acotados (tamaño + archivos) en cada servicio.
- Se establecen límites de recursos para que un servicio no tumbe el host.
- La publicación de puertos es mínima; el tráfico interno permanece en redes internas.
- Los tags de imagen están fijados; las actualizaciones son deliberadas, no “latest” sorpresa.
- Existe un plan de rollback que considere cambios en esquema de base de datos.
Paso a paso: endurecer una pila Compose existente en una semana
- Día 1: Inventario y grafo
- Lista servicios, dependencias y cuáles son stateful.
- Decide qué endpoints representan readiness.
- Día 2: Añadir healthchecks
- BD:
pg_isready(u equivalente). - API: un endpoint
/healthzque verifique dependencias críticas. - Cache:
redis-cli pingo una prueba real de lectura/escritura si hace falta.
- BD:
- Día 3: Controlar dependencias
- Reemplaza
depends_oningenuo por condiciones de salud. - Introduce un servicio
migrateone-shot y controla la API con él.
- Reemplaza
- Día 4: Estabilizar el estado
- Mueve servicios stateful a volúmenes nombrados si es factible.
- Documenta nombres de volúmenes y puntos de montaje.
- Día 5: Hacer que el logging sea aburrido
- Configura rotación de logs usando anchors.
- Confirma vía
docker inspectque cada contenedor lo tomó.
- Día 6: Añadir límites de recursos y probar carga
- Empieza con límites conservadores de memoria para apps parlanchinas; asegura que la BD tenga margen suficiente.
- Observa OOM y throttling bajo tráfico realista.
- Día 7: Ensayar fallos
- Reinicia el host en ventana de mantenimiento y observa cómo vuelve la pila.
- Simula retraso de dependencias y asegura que el control prevenga flaps.
- Realiza un drill de restauración para el volumen de la base de datos.
Checklist de respuesta a incidentes (cuando ya estás caído)
- Ejecuta
docker compose pse identifica el primer servicio unhealthy o exited. - Revisa
df -hyfree -hantes de cambiar configuraciones. - Extrae logs del servicio que falla y sus dependencias (
--tail=200con timestamps). - Valida DNS y conectividad desde dentro del contenedor que falla (
getent,nc). - Si hay un bucle de reinicio: deshabilita reinicios temporalmente, reproduce una vez, captura el primer error y luego arregla.
- No prunees volúmenes durante un incidente a menos que vayas a restaurar desde backups verificados.
Preguntas frecuentes
1) ¿Garantiza depends_on que mi base de datos está lista?
No por defecto. Solo influye el orden de arranque. Usa healthchecks y controla con condition: service_healthy, o implementa lógica explícita de readiness.
2) ¿Son suficientes las comprobaciones de salud para prevenir problemas de arranque?
Son necesarias, no suficientes. Las healthchecks evitan flaps por “arrancar demasiado pronto”, pero aún necesitas migraciones idempotentes, secretos correctos, reintentos sensatos y límites de recursos.
3) ¿Por qué no poner las migraciones en el comando de arranque de la app?
Porque los reinicios ocurren. Cuando la app se reinicia, las migraciones vuelven a ejecutarse a menos que las hagas explícitamente seguras e idempotentes. Un servicio one-shot hace visible y controlable el ciclo de vida.
4) ¿Debo usar volúmenes nombrados o bind mounts para bases de datos?
Por defecto, usa volúmenes nombrados para claridad y portabilidad dentro del ciclo de vida de Docker. Usa bind mounts solo cuando tengas una razón operativa fuerte y disciplina sobre permisos, backups y gestión de rutas en el host.
5) ¿Cómo evito que los secretos aparezcan en docker inspect?
Evita variables de entorno planas para secretos crudos. Usa secretos basados en archivos y haz que tu app lea desde variables *_FILE (o equivalente). También evita poner secretos en labels de Compose o en líneas de comando.
6) ¿Es restart: always alguna vez buena idea?
A veces—para servicios “crash-only” donde los fallos son genuinamente transitorios y tienes monitorización fuerte. En la mayoría de las apps de negocio, oculta malas configuraciones deterministas y crea tormentas de reinicio ruidosas.
7) ¿Cómo hago “rolling updates” en Compose?
Compose no es un orquestador completo. Puedes aproximar actualizaciones seguras ejecutando múltiples instancias detrás de un proxy, actualizando una a la vez y usando healthchecks para controlar el tráfico. Si necesitas rolling updates reales entre nodos, quieres un scheduler.
8) ¿Por qué ligar puertos de la API a 127.0.0.1?
Porque la mayoría de los servicios internos no necesitan ser accesibles públicamente. Lía a localhost y pon un reverse proxy (o reglas de firewall) delante. Esto reduce exposición accidental y colisiones de puertos.
9) Mis contenedores están healthy pero la app está lenta. ¿Qué hago?
La salud es binaria; el rendimiento no. Revisa IO wait del host, espacio en disco, presión de memoria y stats de contenedores. Luego perfila la app y las consultas DB. La mayoría de incidentes de “lentitud” son contención de recursos, no un problema de Compose.
10) ¿Puedo ejecutar Compose en producción en un solo host de forma responsable?
Sí—si aceptas el dominio de fallo y construyes en consecuencia: backups, drills de restauración, monitorización del host, planificación de capacidad y un proceso documentado de reconstrucción. Compose no te salvará de las físicas de un host único.
Conclusión: próximos pasos que puedes hacer esta semana
El patrón de Compose que previene la mayoría de las interrupciones no es glamoroso. Ese es el punto. Ganas fiabilidad en producción eliminando la ambigüedad:
la readiness es explícita, las dependencias se controlan, el estado se gestiona, los logs están acotados y el uso de recursos está restringido.
Próximos pasos que realmente mueven la aguja:
- Añade comprobaciones de salud honestas a cada servicio crítico y confirma que fallan cuando el servicio no está verdaderamente listo.
- Convierte migraciones en un servicio one-shot de Compose y controla el arranque de la app según su éxito.
- Aplica rotación de logs en todas partes usando un anchor de Compose y verifica con
docker inspect. - Fija límites de memoria para los mayores consumidores y observa señales OOM; ajusta según carga real.
- Realiza un ejercicio de restauración para tu volumen de base de datos en un host limpio. Si no puedes hacerlo, deja de llamarlo “respaldado”.
Haz esas cinco cosas y evitarás la mayoría de las interrupciones que parecen “problemas de Docker” pero que en realidad son
“no especificamos el sistema que creíamos tener.”