Cambiar tema

Tres pilares del despliegue en producción con Docker Compose: health checks, reinicio y límites de recursos

Easton editorial illustration: input-process-output transport line

Un SMS de alerta del servidor te saca de la cama.

Abres el portátil: el contenedor aparece como running, con un puntito verde que parece saludable. ¿Pero acceder al servicio? 502 Bad Gateway.

El contenedor de la base de datos aún no había terminado de arrancar y el de la aplicación ya intentaba conectarse. Fallo de conexión, servicio caído. El contenedor sigue en «running», pero el servicio lleva rato muerto.

Así fue mi primera experiencia desplegando Docker Compose en producción.

Que arranque y que sea estable son cosas distintas. docker-compose up levanta todo de un golpe, pero eso no garantiza que aguante una explosión de memoria a las tres de la mañana o un crash de proceso.

Los tres pilares del despliegue en producción con Docker Compose — health checks, políticas de reinicio y límites de recursos — existen para cubrir ese hueco. En este artículo comparto la configuración que saqué de esos tropiezos, con plantillas YAML listas para copiar, para pasar de «funciona» a «funciona de forma fiable».

1. Health check (healthcheck): saber si el contenedor está realmente disponible

El estado running de docker ps solo indica que el proceso del contenedor sigue vivo. ¿Puede atender peticiones con normalidad? No lo sabes.

El health check hace que Docker «revise» el contenedor de forma periódica: una petición HTTP, una conexión a la base de datos o un script, para comprobar si el servicio responde de verdad.

¿Cómo funciona?

Docker ejecuta el comando de comprobación dentro del contenedor según el intervalo que definas. Código de salida 0 = saludable; distinto de 0 = no saludable. Tras varios fallos seguidos, el contenedor pasa a unhealthy.

Lo importante: un health check fallido no reinicia el contenedor automáticamente. Solo expone el estado. Para recuperación automática hay que combinarlo con depends_on condicionado y la política de reinicio.

Cuatro parámetros que debes dominar

healthcheck:
  test: ["CMD-SHELL", "curl -f http://localhost:3000/health || exit 1"]
  interval: 30s      # Cada cuánto comprobar
  timeout: 10s       # Tiempo máximo por comprobación
  retries: 3         # Fallos seguidos antes de marcar unhealthy
  start_period: 60s  # Periodo de gracia al arrancar; los fallos no cuentan para retries

Yo ignoré start_period al principio: la app tardaba en arrancar, la conexión a la base de datos unos 40 segundos, pero el health check empezaba a los 10. Tres fallos seguidos y unhealthy. Con start_period: 60s la app tiene margen para inicializarse.

Comandos de health check habituales

Servicio web:

healthcheck:
  test: ["CMD-SHELL", "curl -f http://localhost:3000/health || exit 1"]
  interval: 30s
  timeout: 10s
  retries: 3
  start_period: 30s

PostgreSQL:

healthcheck:
  test: ["CMD-SHELL", "pg_isready -U postgres"]
  interval: 10s
  timeout: 5s
  retries: 5

Redis:

healthcheck:
  test: ["CMD", "redis-cli", "ping"]
  interval: 10s
  timeout: 3s
  retries: 3

Arranque condicionado con depends_on

Aquí es donde el health check brilla de verdad: esperar a que las dependencias estén listas antes de arrancar.

services:
  app:
    depends_on:
      db:
        condition: service_healthy  # Esperar a que pase el health check de la base de datos
      redis:
        condition: service_healthy  # Esperar a que pase el health check de Redis

Antes usaba depends_on: [db, redis]: la app arrancaba mientras la base de datos aún se inicializaba. Sin conexión, error y salida. Con condition: service_healthy la app espera hasta que la base responda a pg_isready. Problema resuelto.

2. Política de reinicio (restart): autorrecuperación del contenedor

¿Quién levanta el contenedor cuando cae?

¿docker restart manual? A las tres de la mañana, con la alarma sonando, no apetece.

La política de reinicio delega en el daemon de Docker: tras salir el contenedor, decide si hay que reiniciarlo.

Comparación de las cuatro políticas

PolíticaComportamientoEscenario
noSi cae, cae; no reiniciaPruebas puntuales, CI/CD
alwaysReinicia siempre, cualquier salidaServicios críticos
on-failureSolo reinicia si la salida es anómalaContenedores tipo tarea
unless-stoppedReinicia siempre salvo stop manualOpción preferida en producción

Producción: unless-stopped

restart: unless-stopped

¿Por qué unless-stopped y no always?

La diferencia está en el comportamiento tras un docker stop manual:

  • always: tras un stop manual, si reinicias el sistema o el servicio Docker, el contenedor vuelve a arrancar
  • unless-stopped: tras un stop manual, se queda parado; no «revive» solo

Imagina que paras un contenedor para mantenimiento y, tras un reinicio del servidor, vuelve a levantarse solo. No es lo que quieres.

Límite de reintentos con on-failure

on-failure admite un tope de reintentos:

restart: on-failure:5  # Como máximo 5 reinicios

Si el contenedor falla al arrancar cinco veces seguidas, Docker se rinde. Sirve cuando el crash se repite por causas externas (base de datos inaccesible, configuración errónea) y quieres evitar un bucle infinito de reinicios.

¿Cómo elegir? En resumen

  • Servicios core (Web, API, base de datos): unless-stopped
  • Tareas en segundo plano, scripts programados: on-failure
  • Desarrollo, ejecución temporal: no

Trampa habitual: la política de reinicio solo responde a «¿reinicio cuando el contenedor sale?». No responde a «¿el servicio está realmente sano?». Para eso siguen haciendo falta los health checks.

3. Límites de recursos (deploy.resources): evitar que un contenedor se descontrole

¿Te ha pasado? Un contenedor con fuga de memoria se come toda la RAM del servidor y el OOM Killer acaba con el resto.

A mí sí. No tiene gracia.

Los límites de recursos ponen un «techo» a cada contenedor: si lo supera, se lo cargan y proteges al resto.

limits vs reservations

deploy:
  resources:
    limits:
      cpus: '1.0'      # Máximo 1 CPU
      memory: 512M     # Máximo 512 MB de RAM
    reservations:
      cpus: '0.5'      # Reservar al menos 0,5 CPU
      memory: 256M     # Reservar al menos 256 MB de RAM
  • limits: límite duro; si se supera, el proceso muere (OOM)
  • reservations: límite blando; le dice al scheduler «este contenedor necesita al menos estos recursos»

En pocas palabras: limits es «no puedes pasar de aquí»; reservations es «garantiza al menos esto».

¿Cómo fijar el límite de CPU?

cpus: '1.0'   # Hasta 1 núcleo completo
cpus: '0.5'   # Hasta el 50 % de un núcleo
cpus: '2.0'   # Hasta 2 núcleos

El límite de CPU es blando: si lo superas, te limitan la velocidad; no te matan el proceso. Mejor pecar de generoso.

¿Cómo fijar el límite de memoria?

memory: 512M    # 512 MB
memory: 2G      # 2 GB

El límite de memoria es duro. Si lo superas, el OOM Killer mata el contenedor. Sin negociación.

Valores que suelo usar:

  • Apps Node.js: al menos 512M; en producción recomiendo 1G
  • Apps Python: 256M - 512M
  • PostgreSQL: 1G - 4G según conexiones y datos
  • Redis: 256M - 512M; como caché puede hacer falta más

Configuración de ejemplo

services:
  app:
    image: myapp:latest
    deploy:
      resources:
        limits:
          cpus: '1.0'
          memory: 1G
        reservations:
          cpus: '0.25'
          memory: 256M

Con esto la app usa como máximo 1 CPU y 1 GB de RAM, y Docker reserva al menos 0,25 CPU y 256 MB.

Nota: deploy está pensado sobre todo para Docker Swarm. En un solo host con docker-compose up, los límites funcionan con Docker Compose V2 o con docker-compose --compatibility. Alternativa más universal en host único: mem_limit y cpus (obsoletos) o deploy (Compose V2.20+).

4. Plantilla completa: YAML de producción listo para copiar

Cada pilar ayuda por separado; juntos forman una configuración de producción. Abajo, un ejemplo completo con servicio web + PostgreSQL + Redis que puedes copiar y adaptar.

Ejemplo completo

version: '3.8'

services:
  # Aplicación web
  app:
    image: myapp:latest
    restart: unless-stopped
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy
    healthcheck:
      test: ["CMD-SHELL", "curl -f http://localhost:3000/health || exit 1"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 60s
    deploy:
      resources:
        limits:
          cpus: '1.0'
          memory: 1G
        reservations:
          cpus: '0.25'
          memory: 256M
    logging:
      driver: json-file
      options:
        max-size: "10m"   # Máximo 10 MB por archivo de log
        max-file: "3"     # Conservar como máximo 3 archivos

  # Base de datos PostgreSQL
  db:
    image: postgres:15
    restart: unless-stopped
    environment:
      POSTGRES_USER: appuser
      POSTGRES_PASSWORD: apppassword
      POSTGRES_DB: appdb
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s
    deploy:
      resources:
        limits:
          cpus: '2.0'
          memory: 2G
        reservations:
          cpus: '0.5'
          memory: 512M
    volumes:
      - pgdata:/var/lib/postgresql/data

  # Caché Redis
  redis:
    image: redis:7-alpine
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 3s
      retries: 3
    deploy:
      resources:
        limits:
          cpus: '0.5'
          memory: 512M
        reservations:
          memory: 128M

volumes:
  pgdata:

Separar configuraciones

Desarrollo y producción suelen diferir. Dos archivos lo resuelven:

# compose.yaml - Entorno de desarrollo
version: '3.8'
services:
  app:
    build: .
    ports:
      - "3000:3000"
    restart: "no"  # En desarrollo no quiero reinicio automático
# compose.production.yaml - Overrides de producción
version: '3.8'
services:
  app:
    image: myapp:v1.2.3  # En producción, imagen ya construida
    restart: unless-stopped
    healthcheck:
      test: ["CMD-SHELL", "curl -f http://localhost:3000/health || exit 1"]
      interval: 30s
      timeout: 10s
      retries: 3
    deploy:
      resources:
        limits:
          memory: 1G

Arrancar producción:

docker-compose -f compose.yaml -f compose.production.yaml up -d

Los archivos se fusionan; compose.production.yaml sobrescribe a compose.yaml.

Gestión de logs: evitar llenar el disco

El driver por defecto json-file crece sin límite. Sin tope, a los meses el disco se llena de logs.

logging:
  driver: json-file
  options:
    max-size: "10m"   # Máximo 10 MB por archivo
    max-file: "3"     # Máximo 3 archivos, 30 MB en total

Como máximo 30 MB de logs por contenedor, con rotación automática. Lo añado a cada servicio para no tener que limpiar a mano más adelante.

Resumen

En resumen, la lógica de los tres pilares es:

El health check detecta el problema → la política de reinicio recupera → los límites evitan que el fallo se propague

Con esta combinación, tu aplicación en Docker Compose puede:

  1. Esperar a que las dependencias estén listas al arrancar, en lugar de lanzarse a ciegas
  2. Levantarse sola tras un crash, sin que tengas que levantarte a las tres de la mañana
  3. Contener un contenedor descontrolado para que no tumbe todo el servidor

Revisa tu docker-compose.yml. ¿Qué te falta? Complétalo.

Si aún no despliegas en producción con Docker Compose, la próxima vez lleva estas tres configuraciones. Te lo agradecerás.

Configurar los tres pilares del despliegue en producción con Docker Compose

Añade health checks, políticas de reinicio y límites de recursos a Docker Compose para un despliegue estable de nivel producción

⏱️ Estimated time: 15 min

  1. 1

    Step 1: Añadir configuración de health check

    Añade healthcheck a cada servicio:

    • test: comando de comprobación (curl, pg_isready, redis-cli ping, etc.)
    • interval: intervalo entre comprobaciones (recomendado 10-30s)
    • timeout: tiempo de espera (recomendado 5-10s)
    • retries: intentos fallidos (recomendado 3-5)
    • start_period: periodo de gracia al arrancar (30-60s según el tiempo de inicio de la app)
  2. 2

    Step 2: Configurar arranque condicionado por dependencias

    Usa el parámetro condition de depends_on:

    • Cambia depends_on: [db] por depends_on: db: condition: service_healthy
    • Asegúrate de que el servicio dependiente tenga health check configurado
    • El servicio de la app esperará a que la dependencia esté realmente disponible antes de arrancar
  3. 3

    Step 3: Definir la política de reinicio

    Elige la política según el tipo de servicio:

    • Servicios core (Web/API/base de datos): restart: unless-stopped
    • Tareas en segundo plano/scripts programados: restart: on-failure:5
    • Desarrollo y depuración: restart: "no"
    • Evita always (reinicia tras un stop manual si el servidor o Docker se reinician)
  4. 4

    Step 4: Configurar límites de recursos

    Define limits y reservations en deploy.resources:

    • limits: límite duro; si se supera, el OOM Killer actúa
    • reservations: límite blando; recursos mínimos que Docker garantiza
    • Apps Node.js: limits.memory al menos 512M
    • Base de datos: 1G-4G según conexiones y volumen de datos
  5. 5

    Step 5: Añadir rotación de logs

    Evita que los logs llenen el disco:

    • logging.driver: json-file (driver por defecto)
    • logging.options.max-size: "10m" (máximo 10 MB por archivo)
    • logging.options.max-file: "3" (conservar 3 archivos)
    • Tope total de 30 MB con rotación automática

FAQ

¿Se reinicia automáticamente el contenedor si falla el health check?
No. El health check solo marca el contenedor como unhealthy; no dispara un reinicio por sí solo. Hay que combinarlo con una política de reinicio (p. ej. unless-stopped) y la condición service_healthy de depends_on. La política de reinicio actúa cuando el proceso del contenedor termina.
¿Cuál es la diferencia entre unless-stopped y always?
La diferencia clave está en el comportamiento tras un stop manual: con always, si ejecutas docker stop y luego reinicias el servidor o el servicio Docker, el contenedor vuelve a arrancar; con unless-stopped, tras un stop manual permanece detenido. En producción se recomienda unless-stopped.
¿Qué diferencia hay entre limits y reservations en los límites de recursos?
limits es un límite duro: si la memoria supera limits.memory, el OOM Killer mata el contenedor; reservations es un límite blando que indica al scheduler los recursos mínimos, pero el contenedor puede usar más. El límite de CPU es blando (se reduce la velocidad); el de memoria es duro (se mata el proceso).
¿Para qué sirve el parámetro start_period?
start_period es el periodo de gracia al arrancar. Durante ese tiempo, los fallos del health check no cuentan para retries. En apps lentas al iniciar (p. ej. 40 segundos para conectar a la base de datos), start_period: 60s evita que se marquen como unhealthy nada más arrancar.
¿Cómo usar configuraciones distintas en desarrollo y producción?
Separa con dos archivos:

• compose.yaml: configuración de desarrollo (build: ., restart: "no")
• compose.production.yaml: overrides de producción (image: xxx, restart: unless-stopped)
• Comando de arranque: docker-compose -f compose.yaml -f compose.production.yaml up -d
• El segundo archivo sobrescribe al primero
¿Se puede usar deploy.resources en un despliegue en un solo host?
Sí. Aunque deploy está pensado sobre todo para Docker Swarm, Docker Compose V2.20+ lo soporta en un solo host. En versiones antiguas, usa --compatibility o los parámetros obsoletos mem_limit/cpus. Se recomienda actualizar a la última versión de Docker Compose.

8 min de lectura · Publicado el: 24 abr 2026 · Actualizado el: 21 ago 2026

Comentarios

Inicia sesión con GitHub para dejar un comentario

Easton BlogEaston Blog