Docker: el patrón de Compose que previene el 90% de las interrupciones en producción

¿Te fue útil?

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:

  1. Cada servicio crítico tiene una comprobación de salud que refleje la verdadera disponibilidad (no “el proceso existe”).
  2. Las dependencias se controlan en base a la salud, no a la creación del contenedor.
  3. Los trabajos one-shot son explícitos (migraciones, comprobaciones de esquema, bootstrap) y son idempotentes.
  4. El estado está aislado en volúmenes nombrados (o bind mounts bien gestionados) con rutas de respaldo y restauración.
  5. La configuración es inmutable por despliegue (env + archivos) y los cambios son deliberados.
  6. La política de reinicio es una decisión, no un valor por defecto. Estrellarse para siempre no es “alta disponibilidad”.
  7. Los logs están acotados para que el “modo debug” no se convierta en “disco lleno”.
  8. Existen límites de recursos para que un servicio no pueda dejar sin recursos al host y arrastrar los demás.
  9. 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_on no 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 ping y 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 inspect o 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?

  1. 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/tcp
    

    Decisió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.

  2. 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_KEY
    

    Decisión: Falta configuración/secret. No redeployes. Arregla la inyección de configuración.

Segundo: ¿está enfermo el host (disco, memoria, CPU, IO)?

  1. 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.

  2. 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          0B
    

    Decisión: Si la memoria disponible es baja y no hay swap, espera OOM kills. Reduce carga o añade memoria/límites.

  3. 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  55
    

    Decisió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)?

  1. 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   redis
    

    Decisión: Si la resolución falla, tienes problemas de attachment de red o nombres de servicio.

  2. 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”)

  1. Existen healthchecks para cada servicio que importa (BD, cache, API, proxy).
  2. Los healthchecks son honestos: validan readiness, no solo liveness.
  3. Las dependencias se controlan con condition: service_healthy.
  4. Las migraciones son un trabajo one-shot con restart: "no" y controladas vía service_completed_successfully.
  5. Los secretos son archivos (o inyectados de forma segura), no pegados en el historial de la shell.
  6. Volúmenes nombrados para estado, a menos que tengas una razón operativa para bind mounts.
  7. Existen backups y las restauraciones se prueban. Programa un drill de restauración.
  8. Los logs están acotados (tamaño + archivos) en cada servicio.
  9. Se establecen límites de recursos para que un servicio no tumbe el host.
  10. La publicación de puertos es mínima; el tráfico interno permanece en redes internas.
  11. Los tags de imagen están fijados; las actualizaciones son deliberadas, no “latest” sorpresa.
  12. 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

  1. Día 1: Inventario y grafo
    • Lista servicios, dependencias y cuáles son stateful.
    • Decide qué endpoints representan readiness.
  2. Día 2: Añadir healthchecks
    • BD: pg_isready (u equivalente).
    • API: un endpoint /healthz que verifique dependencias críticas.
    • Cache: redis-cli ping o una prueba real de lectura/escritura si hace falta.
  3. Día 3: Controlar dependencias
    • Reemplaza depends_on ingenuo por condiciones de salud.
    • Introduce un servicio migrate one-shot y controla la API con él.
  4. 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.
  5. Día 5: Hacer que el logging sea aburrido
    • Configura rotación de logs usando anchors.
    • Confirma vía docker inspect que cada contenedor lo tomó.
  6. 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.
  7. 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)

  1. Ejecuta docker compose ps e identifica el primer servicio unhealthy o exited.
  2. Revisa df -h y free -h antes de cambiar configuraciones.
  3. Extrae logs del servicio que falla y sus dependencias (--tail=200 con timestamps).
  4. Valida DNS y conectividad desde dentro del contenedor que falla (getent, nc).
  5. Si hay un bucle de reinicio: deshabilita reinicios temporalmente, reproduce una vez, captura el primer error y luego arregla.
  6. 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:

  1. Añade comprobaciones de salud honestas a cada servicio crítico y confirma que fallan cuando el servicio no está verdaderamente listo.
  2. Convierte migraciones en un servicio one-shot de Compose y controla el arranque de la app según su éxito.
  3. Aplica rotación de logs en todas partes usando un anchor de Compose y verifica con docker inspect.
  4. Fija límites de memoria para los mayores consumidores y observa señales OOM; ajusta según carga real.
  5. 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.”

← Anterior
«Acceso denegado» a tus propios archivos tras reinstalar: la solución de propiedad
Siguiente →
Tartamudeos en juegos en PC rápido: latencia DPC y cómo solucionarlo

Deja un comentario