← maurobernal.com.ar

Etiqueta: multi-container

  • Docker Compose de cero a producción — Parte 3: Proyectos reales y estrategias Dev vs Prod

    Llegamos al cierre de la serie Docker Compose de cero a producción. En la Parte 1 vimos el archivo compose.yaml, en la Parte 2 los comandos y las dependencias robustas. Ahora lo llevamos a la práctica: dos proyectos reales completos y la estrategia que uso para manejar configuraciones distintas entre desarrollo y producción.

    Proyecto 1: WordPress + MySQL

    El ejemplo clásico de un stack de dos niveles: una aplicación web y su base de datos. Este proyecto completo vive en un solo archivo.

    Estructura del proyecto

    wordpress_project/
    └── compose.yaml

    compose.yaml

    services:
      db:
        image: mysql:8.0
        container_name: wordpress_db
        restart: unless-stopped
        environment:
          MYSQL_DATABASE: wordpress
          MYSQL_USER: wp_user
          MYSQL_PASSWORD: wp_password
          MYSQL_ROOT_PASSWORD: root_secret_password
        volumes:
          - db_data:/var/lib/mysql
        networks:
          - app_network
        healthcheck:
          test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
          interval: 10s
          timeout: 5s
          retries: 5
          start_period: 30s
    
      wordpress:
        image: wordpress:latest
        container_name: wordpress_app
        restart: unless-stopped
        depends_on:
          db:
            condition: service_healthy   # Espera que MySQL esté listo de verdad
        ports:
          - "8000:80"
        environment:
          WORDPRESS_DB_HOST: db:3306     # 'db' es el nombre del servicio = hostname
          WORDPRESS_DB_USER: wp_user
          WORDPRESS_DB_PASSWORD: wp_password
          WORDPRESS_DB_NAME: wordpress
        networks:
          - app_network
    
    networks:
      app_network:
        driver: bridge
    
    volumes:
      db_data:
        driver: local

    Puesta en marcha

    cd wordpress_project
    docker compose up -d
    
    # Esperar unos segundos y visitar http://localhost:8000
    # El instalador de WordPress estará listo

    Lo que me parece elegante de este ejemplo: WORDPRESS_DB_HOST: db:3306. No hay IP, no hay configuración de DNS manual. El nombre del servicio db funciona directamente como hostname gracias a la red que Compose crea automáticamente. Esto es lo que hace que los entornos sean tan portables.

    Proyecto 2: API REST con Node.js y PostgreSQL

    Este ejemplo es más representativo del trabajo diario: construimos nuestra propia imagen desde un Dockerfile y configuramos un entorno de desarrollo con hot-reload.

    Estructura del proyecto

    api_project/
    ├── compose.yaml
    ├── Dockerfile
    ├── index.js
    └── package.json

    Dockerfile

    FROM node:18-alpine
    WORKDIR /usr/src/app
    COPY package*.json ./
    RUN npm install
    COPY . .
    EXPOSE 3000
    CMD ["npm", "start"]

    index.js

    const express = require('express');
    const { Pool } = require('pg');
    const app = express();
    
    const pool = new Pool({
      user: process.env.DB_USER,
      host: process.env.DB_HOST,      // Viene del ambiente: 'db'
      database: process.env.DB_NAME,
      password: process.env.DB_PASSWORD,
      port: 5432,
    });
    
    app.get('/', async (req, res) => {
      try {
        const result = await pool.query('SELECT NOW()');
        res.send(`Hora en la base de datos: ${result.rows[0].now}`);
      } catch (err) {
        res.status(500).send('Error conectando a la base de datos');
      }
    });
    
    app.listen(3000, () => console.log('API corriendo en http://localhost:3000'));

    compose.yaml

    services:
      db:
        image: postgres:15-alpine
        environment:
          POSTGRES_DB: apidb
          POSTGRES_USER: apiuser
          POSTGRES_PASSWORD: apipassword
        volumes:
          - pg_data:/var/lib/postgresql/data
        healthcheck:
          test: ["CMD-SHELL", "pg_isready -U apiuser -d apidb"]
          interval: 10s
          timeout: 5s
          retries: 5
    
      api:
        build: .
        ports:
          - "3000:3000"
        volumes:
          - .:/usr/src/app       # Bind mount: cambios en el código se reflejan al instante
        environment:
          DB_HOST: db
          DB_USER: apiuser
          DB_PASSWORD: apipassword
          DB_NAME: apidb
        depends_on:
          db:
            condition: service_healthy
    
    volumes:
      pg_data: {}
    # La primera vez, --build para construir la imagen
    docker compose up --build -d
    
    # Verificar que funciona
    curl http://localhost:3000
    # → Hora en la base de datos: 2025-05-01 14:23:45.123456+00

    El bind mount (- .:/usr/src/app) es el truco de desarrollo: cualquier cambio en el código se refleja dentro del contenedor sin reconstruir la imagen. Para que los cambios se apliquen automáticamente en Node.js, agregá nodemon como dependencia de desarrollo y usalo en el CMD.

    Gestión de entornos: desarrollo vs producción

    Esta fue una de las lecciones más importantes que aprendí: nunca uses el mismo archivo compose.yaml sin modificaciones para desarrollo y producción. Las necesidades son opuestas: en desarrollo querés agilidad y debuggear fácil; en producción, estabilidad, seguridad y recursos bien definidos.

    Estrategia con compose.override.yaml

    Compose tiene un mecanismo automático: si existe un compose.override.yaml en el mismo directorio, lo fusiona automáticamente con el compose.yaml base al ejecutar docker compose up.

    # compose.yaml — Base (versionado en Git)
    services:
      api:
        build: .
        restart: unless-stopped
        environment:
          - APP_ENV=production
    # compose.override.yaml — Desarrollo (puede NO versionarse si tiene configs locales)
    services:
      api:
        ports:
          - "8000:8000"
          - "9229:9229"        # Puerto de debugging de Node.js
        volumes:
          - .:/app             # Bind mount para hot-reload
        environment:
          - APP_ENV=development   # Sobreescribe el valor del base
    
      # Servicio adicional solo para desarrollo
      pgadmin:
        image: dpage/pgadmin4
        ports:
          - "5050:80"
        environment:
          PGADMIN_DEFAULT_EMAIL: dev@local.com
          PGADMIN_DEFAULT_PASSWORD: dev

    Estrategia con múltiples archivos explícitos

    # compose.prod.yaml — Producción
    services:
      api:
        restart: always
        deploy:
          resources:
            limits:
              cpus: '1.0'
              memory: 512M
        logging:
          driver: "json-file"
          options:
            max-size: "10m"
            max-file: "3"
    # Desarrollo (usa override automático)
    docker compose up -d
    
    # Producción (combina base + prod explícitamente)
    docker compose -f compose.yaml -f compose.prod.yaml up -d

    Archivos .env para variables de entorno

    Para evitar hardcodear valores en los archivos YAML (especialmente secretos), uso archivos .env. Compose los carga automáticamente si existen en el mismo directorio.

    # .env (NO versionar si tiene contraseñas reales — agregarlo al .gitignore)
    TAG=15-alpine
    POSTGRES_USER=miusuario
    POSTGRES_PASSWORD=supersecreto
    POSTGRES_DB=miapp
    # compose.yaml usando las variables del .env
    services:
      db:
        image: postgres:${TAG}
        environment:
          POSTGRES_USER: ${POSTGRES_USER}
          POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
          POSTGRES_DB: ${POSTGRES_DB}

    Cada desarrollador tiene su propio .env local con sus credenciales. El archivo compose.yaml se versiona sin datos sensibles. Para producción, las variables se inyectan desde el CI/CD o desde un gestor de secretos.

    Builds multi-stage para producción

    Las imágenes de desarrollo son gordas (incluyen herramientas de build, dependencias de dev, etc.). Las de producción deben ser mínimas. Los builds multi-stage resuelven esto con un solo Dockerfile:

    # Dockerfile
    # ---- Etapa de Build ----
    FROM node:20-alpine AS builder
    WORKDIR /app
    COPY package.json .
    RUN npm install
    COPY . .
    RUN npm run build
    
    # ---- Etapa de Producción ----
    FROM node:20-slim AS production
    WORKDIR /app
    COPY --from=builder /app/dist ./dist
    COPY --from=builder /app/node_modules ./node_modules
    EXPOSE 3000
    CMD ["node", "dist/index.js"]
    # compose.yaml
    services:
      api:
        build:
          context: .
          target: production   # Solo construye hasta la etapa 'production'

    La imagen de producción solo contiene lo necesario para correr — sin el compilador, sin herramientas de build, sin dependencias de desarrollo. Más pequeña, más segura, más rápida de desplegar.

    El checklist final para ir a producción con Compose

    • restart: unless-stopped o always en todos los servicios críticos
    • ✅ Healthchecks definidos en las bases de datos y servicios de los que dependen otros
    • depends_on con condition: service_healthy donde corresponde
    • ✅ Volúmenes nombrados para cualquier dato que deba persistir
    • ✅ Secretos y contraseñas en .env o variables de CI/CD, nunca hardcodeados
    • ✅ Imágenes multi-stage para producción
    • deploy.resources.limits configurados para evitar que un servicio consuma todo el host
    • ✅ Logging configurado (json-file con max-size para evitar llenar el disco)

    ¿Y después de Compose?

    Docker Compose es ideal para un único host. Cuando necesitás escalar a múltiples máquinas, alta disponibilidad real o gestión de carga compleja, los próximos pasos naturales son:

    • Docker Swarm: orquestación multi-host con conceptos muy similares a Compose. Curva de aprendizaje baja si ya dominás Compose.
    • Kubernetes: el estándar de la industria para producción a gran escala. La herramienta kompose convert traduce tus archivos Compose a manifiestos de Kubernetes para facilitar la migración.

    Lo bueno: los conceptos que aprendiste en esta serie (servicios, redes, volúmenes, variables de entorno, healthchecks) se trasladan directamente a esos entornos.

    Parte 2: CLI esencial, depends_on, healthchecks y perfiles

  • Docker Compose de cero a producción — Parte 2: CLI, dependencias robustas y perfiles

    Esta es la segunda parte de la serie Docker Compose de cero a producción. En la Parte 1 vimos qué es Compose y cómo estructurar el archivo compose.yaml. Ahora le toca al día a día: los comandos que vas a escribir decenas de veces, cómo hacer que las dependencias entre servicios funcionen bien de verdad, y cómo manejar servicios opcionales con perfiles.

    Los comandos que más vas a usar

    Ciclo de vida

    # Levantar todo en segundo plano
    docker compose up -d
    
    # Levantar y forzar reconstrucción de imágenes
    docker compose up -d --build
    
    # Levantar solo servicios específicos
    docker compose up -d api db
    
    # Detener y eliminar contenedores + redes (conserva volúmenes)
    docker compose down
    
    # Detener y eliminar TODO incluyendo volúmenes (¡cuidado: borra datos!)
    docker compose down -v
    
    # Solo detener sin eliminar (los contenedores quedan, solo se apagan)
    docker compose stop
    
    # Iniciar contenedores ya existentes (sin recrear)
    docker compose start
    
    # Reiniciar servicios
    docker compose restart api

    La diferencia entre stop/start y down/up importa: stop preserva los contenedores existentes (más rápido para reinicios), mientras que down los elimina completamente (útil para empezar limpio).

    Inspección y debugging

    # Ver estado de todos los contenedores del proyecto
    docker compose ps
    
    # Ver logs de todos los servicios en tiempo real
    docker compose logs -f
    
    # Ver logs solo de un servicio, últimas 50 líneas
    docker compose logs -f --tail=50 api
    
    # Entrar a un contenedor con una shell interactiva
    docker compose exec api bash
    docker compose exec db psql -U myuser -d mydb
    
    # Ejecutar un comando puntual en un contenedor nuevo (y eliminarlo al terminar)
    docker compose run --rm api npm run migrate
    
    # Ver procesos dentro de cada servicio
    docker compose top
    
    # Validar y ver la configuración final (muy útil para depurar overrides y variables)
    docker compose config

    docker compose config es uno de los comandos más útiles que tardé en descubrir: muestra la configuración final después de fusionar todos los archivos y reemplazar variables de entorno. Ideal para verificar que los overrides están funcionando como esperás.

    Gestión de imágenes

    # Construir imágenes sin levantar servicios
    docker compose build
    
    # Construir solo un servicio específico
    docker compose build api
    
    # Descargar las últimas versiones de las imágenes
    docker compose pull
    
    # Ver qué imágenes usa el proyecto
    docker compose images

    depends_on y healthchecks: dependencias que funcionan de verdad

    Este es uno de los puntos donde más errores cometí al principio. depends_on: db solo garantiza que el contenedor de la base de datos inicie antes — no que la base de datos esté lista para aceptar conexiones. El resultado: la API arranca, intenta conectarse a Postgres que todavía está inicializándose, falla, y el contenedor muere.

    La solución correcta: combinar depends_on con healthcheck.

    services:
      api:
        build: .
        depends_on:
          db:
            condition: service_healthy   # Espera hasta que db esté HEALTHY, no solo running
          cache:
            condition: service_started   # Para este, alcanza con que esté corriendo
    
      db:
        image: postgres:15-alpine
        environment:
          POSTGRES_USER: myuser
          POSTGRES_DB: mydb
          POSTGRES_PASSWORD: mypassword
        healthcheck:
          # Comando que se ejecuta dentro del contenedor para verificar salud
          test: ["CMD-SHELL", "pg_isready -U myuser -d mydb"]
          interval: 10s      # Verificar cada 10 segundos
          timeout: 5s        # Esperar máximo 5 segundos por respuesta
          retries: 5         # Marcar como unhealthy después de 5 fallos
          start_period: 30s  # Período de gracia al inicio (no cuenta como fallo)
    
      cache:
        image: redis:alpine
        healthcheck:
          test: ["CMD", "redis-cli", "ping"]
          interval: 5s
          timeout: 3s
          retries: 3

    Las tres condiciones disponibles para depends_on:

    • service_started: el contenedor arrancó (comportamiento por defecto, no espera que la app esté lista)
    • service_healthy: el healthcheck del servicio devuelve éxito — el que más usás
    • service_completed_successfully: para tareas de inicialización que deben completarse antes de continuar (migraciones, seeds)

    Ejemplo práctico de service_completed_successfully para migraciones:

    services:
      migrate:
        build: .
        command: ["npm", "run", "migrate"]   # Se ejecuta una vez y termina
        depends_on:
          db:
            condition: service_healthy
    
      api:
        build: .
        depends_on:
          migrate:
            condition: service_completed_successfully  # La API arranca después de migrar
          db:
            condition: service_healthy

    Escalado: múltiples instancias de un servicio

    Compose permite correr múltiples instancias del mismo servicio con el flag --scale. Útil para simular carga durante desarrollo o para escalar workers en background.

    # Levantar 3 instancias del servicio 'worker'
    docker compose up -d --scale worker=3
    
    # Escalar mientras el proyecto ya está corriendo
    docker compose up -d --scale api=2 --scale worker=5

    Dos cosas importantes para que el escalado funcione:

    • No uses container_name en servicios que vas a escalar (los nombres deben ser únicos)
    • No mapees puertos fijos al host — usá puertos efímeros ("3000" en lugar de "3000:3000") o rangos ("3000-3005:3000")
    services:
      api:
        build: .
        ports:
          - "3000"    # Puerto efímero: Docker asigna un puerto libre del host por instancia
        # NO poner container_name si vas a escalar

    Perfiles: servicios que no siempre querés levantar

    Siempre hay servicios auxiliares que solo necesitás en ciertas situaciones: un gestor de base de datos visual, un servidor de correo falso para desarrollo, un servicio de métricas. Con perfiles podés tenerlos definidos en el mismo archivo pero inactivos por defecto.

    services:
      api:
        build: .
        # Sin perfil = siempre activo
    
      db:
        image: postgres:15
        # Sin perfil = siempre activo
    
      # Solo activo con el perfil 'tools'
      pgadmin:
        image: dpage/pgadmin4
        profiles: ["tools"]
        ports:
          - "5050:80"
        environment:
          PGADMIN_DEFAULT_EMAIL: admin@local.com
          PGADMIN_DEFAULT_PASSWORD: admin
    
      # Solo activo con el perfil 'tools'
      mailhog:
        image: mailhog/mailhog
        profiles: ["tools"]
        ports:
          - "8025:8025"   # UI web para ver emails enviados en desarrollo
    
      # Solo activo con el perfil 'monitoring'
      prometheus:
        image: prom/prometheus
        profiles: ["monitoring"]
    # Solo servicios base (api + db)
    docker compose up -d
    
    # Servicios base + herramientas de administración
    docker compose --profile tools up -d
    
    # Todo a la vez
    docker compose --profile tools --profile monitoring up -d

    Uso esto constantemente para pgAdmin, Adminer, MailHog o Kibana. Están definidos, documentados y listos para usar cuando los necesito, pero no arrancan por defecto y no consumen recursos innecesariamente.

    Resumen de la Parte 2

    • CLI: up/down/logs/exec/config son los comandos del día a día
    • depends_on + healthcheck: la combinación correcta para dependencias reales entre servicios
    • Escalado: --scale para múltiples instancias, sin container_name ni puertos fijos
    • Perfiles: servicios auxiliares disponibles cuando los necesitás, sin que estorben el resto

    En la Parte 3 cerramos la serie con dos proyectos reales completos y las estrategias para manejar entornos de desarrollo y producción desde el mismo base de archivos.

  • Docker Compose de cero a producción — Parte 1: Qué es y cómo funciona

    Antes de Docker Compose, levantar un entorno de desarrollo con varios servicios era un ritual de paciencia. Un docker run para la base de datos, otro para el backend, otro para Redis, acordarse de los flags de red, los volúmenes, las variables de entorno… y rezar para que el próximo dev del equipo pudiera repetir exactamente los mismos pasos.

    Docker Compose resolvió todo eso con un solo archivo YAML y un comando. En esta primera parte de la serie te cuento qué es, de dónde viene y cómo se estructura ese archivo que se vuelve la única fuente de verdad de tu entorno.

    ¿Qué es Docker Compose y por qué importa?

    Docker Compose es una herramienta diseñada para definir y ejecutar aplicaciones que constan de múltiples contenedores. Su propósito es simple pero poderoso: en lugar de gestionar cada contenedor con comandos individuales, describís el estado deseado de todo el sistema en un archivo compose.yaml, y Compose se encarga de alcanzar ese estado.

    Los beneficios que más uso en el día a día:

    • Entornos reproducibles: el clásico «funciona en mi máquina» desaparece. Si el entorno está en código, todos ejecutan lo mismo.
    • Onboarding en minutos: un nuevo dev solo necesita Docker instalado. Un docker compose up y tiene todo funcionando.
    • IaC a nivel desarrollador: el compose.yaml vive junto al código en Git. El entorno es versionable, revisable y auditable.
    • Ciclos rápidos: Compose reutiliza contenedores que no cambiaron. Los reinicios son rápidos.

    De Fig a Compose V2: una historia corta pero importante

    Docker Compose no nació en Docker. Empezó como Fig, un proyecto de la empresa Orchardup que ya ofrecía exactamente esta idea: definir y levantar entornos multi-contenedor con un archivo YAML. Docker Inc. vio el valor, adquirió Orchardup en 2013 y relanzó Fig como Docker Compose.

    Hoy existen dos versiones del CLI que vale la pena distinguir:

    CaracterísticaV1 (legado)V2 (actual)
    Comandodocker-compose (con guion)docker compose (sin guion)
    LenguajePythonGo
    Campo version:Requerido (2.0 a 3.8)Ignorado (usa Compose Spec)
    IntegraciónBinario separadoPlugin del Docker CLI

    Si encontrás documentación con docker-compose (con guion) y version: '3.8' en la primera línea, es sintaxis de V1. Todo el contenido de esta serie usa V2, que es el estándar actual.

    El archivo compose.yaml: la única fuente de verdad

    El corazón de Docker Compose es su archivo de configuración. Puede llamarse compose.yaml (preferido), compose.yml, docker-compose.yaml o docker-compose.yml — todos son reconocidos por compatibilidad.

    Las directivas de nivel superior que vas a usar en prácticamente todo proyecto:

    # compose.yaml
    services:     # Los contenedores de tu aplicación (obligatorio)
    networks:     # Redes personalizadas entre servicios (opcional)
    volumes:      # Volúmenes nombrados para persistencia (opcional)
    secrets:      # Datos sensibles (contraseñas, claves API) (opcional)
    configs:      # Configuración no sensible externa a la imagen (opcional)

    Definiendo servicios

    Cada servicio representa un componente de tu aplicación corriendo en uno o más contenedores. Los atributos que más uso:

    services:
      web:
        image: nginx:alpine            # Imagen de Docker Hub
        container_name: mi_nginx       # Nombre personalizado (cuidado si escalás)
        ports:
          - "8080:80"                  # host:contenedor
        environment:
          APP_ENV: production
        env_file:
          - .env                       # Variables desde archivo (no versionarlo con secretos)
        restart: unless-stopped        # Reinicio automático excepto si lo detenés manualmente
    
      api:
        build:                         # Construir desde Dockerfile en lugar de usar imagen
          context: .
          dockerfile: Dockerfile
          args:
            - NODE_VERSION=20          # ARGs del Dockerfile
          target: production           # Para builds multi-stage
        command: ["npm", "start"]      # Sobrescribe CMD del Dockerfile
        depends_on:
          - db                         # Arranca después de 'db'
        
      db:
        image: postgres:15-alpine
        volumes:
          - pg_data:/var/lib/postgresql/data   # Volumen nombrado para persistencia

    La flexibilidad de elegir entre image y build es clave: usás imagen oficial para componentes estándar (Postgres, Redis, Nginx) y build para tu código propio. Pueden convivir sin problema en el mismo archivo.

    Redes: cómo se comunican los servicios

    Cuando levantás un proyecto con docker compose up, Compose crea automáticamente una red bridge y conecta todos los servicios a ella. La magia: los servicios se encuentran entre sí usando su nombre como hostname DNS.

    services:
      api:
        image: mi-api
        environment:
          # 'db' es el nombre del servicio, funciona como hostname
          DB_HOST: db
          REDIS_HOST: cache
    
      db:
        image: postgres:15
    
      cache:
        image: redis:alpine

    Para mayor control, podés definir redes personalizadas y segmentar qué servicios pueden verse entre sí:

    services:
      frontend:
        networks: [public]             # Solo en la red pública
    
      api:
        networks: [public, internal]   # En ambas redes
    
      db:
        networks: [internal]           # Solo en la red interna, no expuesta al exterior
    
    networks:
      public:
        driver: bridge
      internal:
        driver: bridge
        internal: true                 # Sin acceso al exterior

    Volúmenes: persistencia de datos

    Por defecto, los datos dentro de un contenedor desaparecen cuando el contenedor se elimina. Los volúmenes resuelven esto. Hay dos tipos que uso constantemente:

    services:
      db:
        image: postgres:15
        volumes:
          # Tipo 1: Volumen nombrado — Docker gestiona el almacenamiento
          # Ideal para producción y datos de base de datos
          - pg_data:/var/lib/postgresql/data
    
      api:
        build: .
        volumes:
          # Tipo 2: Bind mount — mapea un directorio del host al contenedor
          # Ideal para desarrollo: cambios en el código se reflejan al instante
          - .:/usr/src/app
    
    # Los volúmenes nombrados se declaran en el nivel superior
    volumes:
      pg_data:
        driver: local

    La regla que sigo: volúmenes nombrados para datos que deben persistir (bases de datos, uploads, logs), bind mounts para el código fuente durante el desarrollo para tener hot-reload sin reconstruir la imagen.

    Resumen de la Parte 1

    • Docker Compose define entornos multi-contenedor en un solo archivo declarativo
    • Usá siempre V2 (docker compose sin guion); el campo version: ya no es necesario
    • Los servicios se comunican usando su nombre como hostname — no necesitás IPs ni configuración manual
    • Volúmenes nombrados para persistencia, bind mounts para desarrollo ágil

    En la Parte 2 vemos los comandos esenciales del CLI y las características que hacen que los entornos sean robustos: depends_on con healthchecks, escalado y perfiles.

Tags

tsql (27)mssql (26)sql (20)devops (20)dotnet (18)docker (15)performance (14)contenedores (11)dotnet10 (10)linux (9)csharp (8)microservicios (7)angular (7)angular21 (7)sql server (6)issabel (6)docker-compose (6)typescript (6)mysql (5).NET (5)