← maurobernal.com.ar

Etiqueta: microservicios

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

  • Arquitectura de Web APIs en .NET 10 — Parte 3: Observabilidad, Resiliencia y Tiempo Real

    Llegamos a la tercera y última parte de la serie Arquitectura Esencial de Web APIs en .NET 10. En la Parte 1 vimos los fundamentos, en la Parte 2 los componentes de performance y escalabilidad. Ahora cerramos con los cuatro que elevan una API de «funcional» a «profesional»: observabilidad, resiliencia, despliegue progresivo y comunicación en tiempo real.

    8. OpenTelemetry: ver lo que pasa adentro

    Tuve años en los que mi «monitoreo» consistía en revisar logs manualmente y rezar para encontrar el error antes de que me llamara el cliente. Hoy, con OpenTelemetry integrado nativamente en .NET 10, no hay excusa para no tener observabilidad completa desde el día uno.

    OpenTelemetry unifica tres pilares de observabilidad:

    • Trazas distribuidas: seguir el flujo de una request a través de múltiples servicios
    • Métricas: CPU, memoria, requests por segundo, latencia
    • Logs: correlacionados automáticamente con las trazas
    builder.Services.AddOpenTelemetry()
        .WithMetrics(metrics => metrics
            .AddAspNetCoreInstrumentation()   // Métricas de HTTP
            .AddRuntimeInstrumentation()      // GC, threadpool, memoria
            .AddPrometheusExporter())         // Expone /metrics para Prometheus
        .WithTracing(tracing => tracing
            .AddAspNetCoreInstrumentation()   // Trazas de requests HTTP
            .AddHttpClientInstrumentation()   // Trazas de llamadas salientes
            .AddEntityFrameworkCoreInstrumentation() // Trazas de queries SQL
            .AddOtlpExporter(otlp =>          // Exporta a Jaeger, Grafana Tempo, etc.
            {
                otlp.Endpoint = new Uri("http://localhost:4317");
            }));
    
    // Opcional: exponer el endpoint de métricas de Prometheus
    app.MapPrometheusScrapingEndpoint("/metrics");

    Una vez que tenés OpenTelemetry configurado, herramientas como Grafana + Prometheus + Tempo te dan dashboards completos, alertas automáticas y trazas distribuidas entre microservicios. Todo lo que necesitás para diagnosticar cualquier problema en producción en minutos en lugar de horas.

    9. Resiliencia con Polly: sobrevivir a las caídas externas

    En arquitecturas de microservicios hay una certeza: el servicio que llamás va a fallar en algún momento. No es una posibilidad, es una garantía estadística. La pregunta es si tu API se cae con él o lo maneja con elegancia.

    Polly —integrado de forma estándar en .NET desde la versión 8— implementa patrones de resiliencia: reintentos con backoff exponencial, timeouts y circuit breakers.

    // Opción 1: Pipeline estándar preconfigurado (la manera más rápida)
    // Incluye: reintentos exponenciales + circuit breaker + timeout + hedging
    builder.Services.AddHttpClient("ExternalAPI", client =>
        client.BaseAddress = new Uri("https://api.externa.com"))
        .AddStandardResilienceHandler();
    
    // Opción 2: Pipeline personalizado para control fino
    builder.Services.AddHttpClient("CriticalService", client =>
        client.BaseAddress = new Uri("https://servicio-critico.com"))
        .AddResilienceHandler("custom", pipeline =>
        {
            // Timeout total de la operación
            pipeline.AddTimeout(TimeSpan.FromSeconds(10));
    
            // Reintentos: 3 intentos con backoff exponencial
            pipeline.AddRetry(new HttpRetryStrategyOptions
            {
                MaxRetryAttempts = 3,
                Delay = TimeSpan.FromMilliseconds(500),
                BackoffType = DelayBackoffType.Exponential,
                UseJitter = true, // Evita thundering herd
                ShouldHandle = args => args.Outcome switch
                {
                    { Exception: HttpRequestException } => PredicateResult.True(),
                    { Result.StatusCode: HttpStatusCode.ServiceUnavailable } => PredicateResult.True(),
                    _ => PredicateResult.False()
                }
            });
    
            // Circuit Breaker: abre después de 50% de fallos en 30 segundos
            pipeline.AddCircuitBreaker(new HttpCircuitBreakerStrategyOptions
            {
                FailureRatio = 0.5,
                SamplingDuration = TimeSpan.FromSeconds(30),
                MinimumThroughput = 10,
                BreakDuration = TimeSpan.FromSeconds(15)
            });
        });

    El Circuit Breaker es especialmente importante: cuando un servicio externo falla repetidamente, el circuit breaker «abre» y deja de intentar llamarlo durante un período, evitando que los timeouts se acumulen y degraden toda tu API.

    10. Feature Flags: deployar sin activar

    Los feature flags cambiaron mi forma de deployar. Antes, cada feature nueva era un riesgo: si algo fallaba en producción, había que hacer rollback, lo cual es lento y estresante. Hoy puedo subir código a producción con la funcionalidad apagada, activarla para el 5% de los usuarios, monitorear, y si todo va bien, activarla para todos. Sin rollback, sin estrés.

    dotnet add package Microsoft.FeatureManagement.AspNetCore
    builder.Services.AddFeatureManagement();
    
    app.MapGet("/api/beta-feature", async (IFeatureManager featureManager) =>
    {
        if (await featureManager.IsEnabledAsync("BetaFeatureX"))
            return Results.Ok("¡Nueva funcionalidad activada!");
    
        return Results.StatusCode(StatusCodes.Status404NotFound);
    });
    
    // También podés proteger endpoints completos con filtros de acción
    app.MapGet("/api/new-algorithm", [FeatureGate("NewAlgorithm")] () =>
        Results.Ok(RunNewAlgorithm()));

    Los flags se configuran en appsettings.json (para desarrollo) o en Azure App Configuration (para producción con control en tiempo real sin redeploy):

    {
      "FeatureManagement": {
        "BetaFeatureX": false,
        "NewAlgorithm": {
          "EnabledFor": [
            {
              "Name": "Percentage",
              "Parameters": { "Value": 10 }
            }
          ]
        }
      }
    }

    Podés activar features para un porcentaje de usuarios, para usuarios específicos, por fecha de activación, o con cualquier lógica custom. Ideal para Canary Releases y pruebas A/B.

    11. Server-Sent Events (SSE): tiempo real sin la complejidad de WebSockets

    WebSockets es la primera opción que se te ocurre para «tiempo real», pero tiene overhead de setup y complejidad de manejo de conexiones bidireccionales. Si solo necesitás enviar datos del servidor al cliente —notificaciones, progreso de tareas, feeds de datos— SSE es mucho más simple y funciona sobre HTTP estándar.

    En .NET 10, implementarlo con IAsyncEnumerable es elegante y eficiente:

    // Ejemplo 1: Stream simple de notificaciones
    app.MapGet("/api/notifications", async IAsyncEnumerable<string> (CancellationToken ct) =>
    {
        for (int i = 0; i < 5 && !ct.IsCancellationRequested; i++)
        {
            await Task.Delay(1000, ct);
            yield return $"data: Notificación {i + 1}\n\n";
        }
    });
    
    // Ejemplo 2: Progreso de una tarea larga
    app.MapPost("/api/export", async IAsyncEnumerable<ProgressUpdate> (ExportRequest request, CancellationToken ct) =>
    {
        var items = await GetItemsToExport(request);
        int total = items.Count;
    
        for (int i = 0; i < total && !ct.IsCancellationRequested; i++)
        {
            await ProcessItem(items[i]);
            yield return new ProgressUpdate
            {
                Current = i + 1,
                Total = total,
                Percentage = (int)((i + 1.0) / total * 100)
            };
        }
    });
    
    record ProgressUpdate(int Current, int Total, int Percentage);

    El cliente recibe un flujo continuo de datos sin necesidad de polling. Perfecto para dashboards en tiempo real, barras de progreso de procesos largos, o feeds de eventos de sistema.

    El checklist completo de producción

    Antes de llevar cualquier Web API a producción, repaso esta lista de los 11 componentes que cubrimos en la serie:

    • Health Checks — Liveness y Readiness configurados
    • Exception Handler global — ProblemDetails para todos los errores
    • Validación — Endpoint Filters antes de la lógica de negocio
    • OpenAPI nativo — Documentación sin Swashbuckle
    • Rate Limiting — Protección en endpoints públicos
    • Output Cache — En endpoints de lectura costosos
    • API Versioning — Desde el primer endpoint
    • OpenTelemetry — Métricas, trazas y logs exportando
    • Polly — Resiliencia en todas las llamadas externas
    • Feature Flags — Para funcionalidades en desarrollo activo
    • SSE — En lugar de polling para actualizaciones en tiempo real

    No hace falta implementarlos todos desde el día uno, pero sí conviene tenerlos en el radar desde que empezás. Cuanto más temprano los incorporés, menos dolores de cabeza cuando la API escale.

    ¿Hay algún componente que uses habitualmente y no esté en la lista? Dejamelo en los comentarios.

    Parte 2: Rate Limiting, Output Cache y API Versioning

  • Arquitectura de Web APIs en .NET 10 — Parte 2: Performance y Escalabilidad

    Esta es la segunda parte de la serie Arquitectura Esencial de Web APIs en .NET 10. En la Parte 1 cubrimos los fundamentos: Health Checks, Exception Handling, Validación y OpenAPI nativo. Ahora le toca el turno a los componentes que marcan la diferencia cuando tu API empieza a recibir carga real.

    Estos tres los ignoré durante demasiado tiempo, hasta que los problemas aparecieron solos en producción.

    5. Rate Limiting: protegé tu API del abuso

    Una API pública sin rate limiting es un blanco fácil. Me tocó ver picos de miles de requests por minuto desde una sola IP dejando el servidor de rodillas. Desde .NET 7 el middleware de rate limiting viene integrado en el framework, y en .NET 10 está completamente maduro.

    .NET ofrece cuatro estrategias distintas según el caso de uso:

    • Fixed Window: X peticiones por ventana de tiempo fija
    • Sliding Window: igual pero la ventana se mueve (más preciso)
    • Token Bucket: permite bursts controlados
    • Concurrency: limita las peticiones simultáneas, no el rate
    builder.Services.AddRateLimiter(options =>
    {
        // Política general: 100 requests por minuto por IP
        options.AddFixedWindowLimiter("General", opt =>
        {
            opt.Window = TimeSpan.FromMinutes(1);
            opt.PermitLimit = 100;
            opt.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
            opt.QueueLimit = 10; // Cuántas requests pueden esperar en cola
        });
    
        // Política estricta para endpoints sensibles
        options.AddSlidingWindowLimiter("Strict", opt =>
        {
            opt.Window = TimeSpan.FromMinutes(1);
            opt.SegmentsPerWindow = 6; // Segmentos de 10 segundos
            opt.PermitLimit = 20;
        });
    
        // Respuesta personalizada cuando se supera el límite
        options.OnRejected = async (context, cancellationToken) =>
        {
            context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
            await context.HttpContext.Response.WriteAsJsonAsync(
                new ProblemDetails { Title = "Too Many Requests", Status = 429 },
                cancellationToken);
        };
    });
    
    var app = builder.Build();
    app.UseRateLimiter();
    
    // Aplicar a endpoints específicos
    app.MapGet("/api/data", () => "Datos")
       .RequireRateLimiting("General");
    
    app.MapPost("/api/auth/login", (LoginDto dto) => Results.Ok())
       .RequireRateLimiting("Strict");

    Para APIs que manejan usuarios autenticados, podés segmentar el rate limiting por user ID en lugar de IP, así los usuarios legítimos no se ven afectados por el comportamiento de otros.

    6. Output Cache: no calcules lo mismo dos veces

    En un proyecto de reportes que tenía consultas de 2 segundos contra SQL Server, implementar Output Caching llevó el tiempo de respuesta promedio a menos de 10ms. Sin tocar una sola línea de lógica de negocio.

    Para endpoints que devuelven datos que no cambian constantemente, el Output Cache guarda la respuesta completa en memoria (o Redis) y la sirve directamente sin ejecutar nada de la lógica de negocio.

    builder.Services.AddOutputCache(options =>
    {
        // Política base: cache de 60 segundos
        options.AddBasePolicy(builder => builder.Expire(TimeSpan.FromSeconds(60)));
    
        // Política nombrada para datos de referencia
        options.AddPolicy("ReferenceData", builder =>
            builder.Expire(TimeSpan.FromMinutes(10))
                   .Tag("reference")); // Tag para invalidación selectiva
    });
    
    var app = builder.Build();
    app.UseOutputCache();
    
    // Cache por 10 segundos (override de la política base)
    app.MapGet("/api/stats", () => GetExpensiveStats())
       .CacheOutput(c => c.Expire(TimeSpan.FromSeconds(10)));
    
    // Usando política nombrada
    app.MapGet("/api/products", () => GetProducts())
       .CacheOutput("ReferenceData");
    
    // Invalidar el cache cuando cambian los datos
    app.MapPost("/api/products", async (ProductDto product, IOutputCacheStore cache) =>
    {
        await SaveProduct(product);
        await cache.EvictByTagAsync("reference", CancellationToken.None); // Invalida el cache
        return Results.Created($"/api/products/{product.Id}", product);
    });

    La invalidación por tags es la clave para usar Output Cache sin miedo a servir datos stale. Cuando mutás datos, invalidás el tag correspondiente y la próxima request regenera el cache.

    Para ambientes distribuidos (múltiples instancias), podés configurar Redis como backing store con AddStackExchangeRedisOutputCache().

    7. API Versioning: cambiar sin romper a tus clientes

    Esta es una de las lecciones más caras que aprendí: si tu API tiene clientes externos y no versiona desde el principio, el día que necesités hacer un cambio incompatible vas a tener un problema enorme. Agregarle versionado a una API ya desplegada es doloroso. Hacerlo desde el inicio es trivial.

    En Minimal APIs el versionado se maneja mediante Version Sets. Primero instalá el paquete Asp.Versioning.Http:

    dotnet add package Asp.Versioning.Http
    // Program.cs
    builder.Services.AddApiVersioning(options =>
    {
        options.DefaultApiVersion = new ApiVersion(1, 0);
        options.AssumeDefaultVersionWhenUnspecified = true;
        options.ReportApiVersions = true; // Devuelve las versiones disponibles en headers
    });
    
    var app = builder.Build();
    
    var apiVersionSet = app.NewApiVersionSet()
        .HasApiVersion(new ApiVersion(1, 0))
        .HasApiVersion(new ApiVersion(2, 0))
        .ReportApiVersions()
        .Build();
    
    // V1: respuesta simple
    app.MapGet("/api/v{version:apiVersion}/users", () =>
        new[] { new { Id = 1, Name = "Juan" } })
       .WithApiVersionSet(apiVersionSet)
       .MapToApiVersion(1, 0);
    
    // V2: respuesta enriquecida con datos extra
    app.MapGet("/api/v{version:apiVersion}/users", () =>
        new[] { new { Id = 1, Name = "Juan", Email = "juan@ejemplo.com", Role = "Admin" } })
       .WithApiVersionSet(apiVersionSet)
       .MapToApiVersion(2, 0);

    Los clientes existentes siguen usando /api/v1/users sin ningún cambio. Los nuevos pueden adoptar /api/v2/users con el formato enriquecido. Convivencia perfecta.

    Además del versionado por URL, podés usar query string (?api-version=2.0) o header (X-API-Version: 2.0) según las necesidades de tus clientes.

    Resumen de la Parte 2

    Tres componentes que hacen la diferencia cuando la API escala:

    • Rate Limiting → protección contra abuso, con estrategias flexibles por caso de uso
    • Output Cache → performance brutal en endpoints de lectura, con invalidación inteligente
    • API Versioning → libertad para evolucionar sin romper contratos existentes

    En la Parte 3 cerramos la serie con los componentes más avanzados: OpenTelemetry, Polly, Feature Flags y Server-Sent Events.

  • Arquitectura de Web APIs en .NET 10 — Parte 1: Los fundamentos que no pueden faltar

    Cuando empecé a trabajar con .NET, mi idea de una «API lista para producción» era que compilara y los endpoints respondieran correctamente. Con el tiempo aprendí que hay una diferencia enorme entre una API que funciona en tu máquina y una que sobrevive el mundo real.

    Esta es la primera parte de una serie de tres artículos donde te cuento los 11 componentes que hoy considero esenciales en cualquier Web API con .NET 10. Empezamos por los fundamentos: los cuatro que deberías tener desde el primer día, sin importar el tamaño del proyecto.

    1. Health Checks: que el orquestador sepa si tu API está viva

    La primera vez que deployé en Kubernetes sin health checks fue un desastre silencioso. El pod figuraba como «Running», pero la base de datos no conectaba y las requests simplemente fallaban sin que el orquestador se enterara.

    Los Health Checks exponen endpoints que le dicen a Kubernetes (o a cualquier balanceador de carga) dos cosas fundamentales:

    • Liveness: ¿el proceso sigue vivo?
    • Readiness: ¿está listo para recibir tráfico? (sus dependencias —base de datos, Redis, etc.— están funcionando)
    // Program.cs
    builder.Services.AddHealthChecks()
        .AddNpgSql(builder.Configuration.GetConnectionString("Default")); // Verifica PostgreSQL
    
    var app = builder.Build();
    
    app.MapHealthChecks("/health");          // Endpoint general
    app.MapHealthChecks("/health/ready",    // Solo readiness
        new HealthCheckOptions { Predicate = check => check.Tags.Contains("ready") });
    app.MapHealthChecks("/health/live",     // Solo liveness
        new HealthCheckOptions { Predicate = _ => false });

    Con el paquete AspNetCore.HealthChecks.* podés agregar chequeos para SQL Server, Redis, servicios HTTP externos y más. Una línea por dependencia crítica.

    2. Exception Handling Global con IExceptionHandler

    Nada peor que una API que le devuelve un stack trace de C# al cliente, o un error 500 genérico sin información útil. Durante mucho tiempo usé try/catch en cada endpoint, hasta que descubrí IExceptionHandler.

    Con esta interfaz centralizás el manejo de errores en un solo lugar y respondés siempre con el formato estándar ProblemDetails (RFC 7807), que es lo que los clientes modernos esperan.

    public class GlobalExceptionHandler : IExceptionHandler
    {
        private readonly ILogger<GlobalExceptionHandler> _logger;
    
        public GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger)
        {
            _logger = logger;
        }
    
        public async ValueTask<bool> TryHandleAsync(
            HttpContext context, Exception exception, CancellationToken cancellationToken)
        {
            _logger.LogError(exception, "Error no controlado: {Message}", exception.Message);
    
            var problemDetails = new ProblemDetails
            {
                Status = StatusCodes.Status500InternalServerError,
                Title = "Ocurrió un error inesperado",
                Detail = exception.Message
            };
    
            context.Response.StatusCode = problemDetails.Status.Value;
            await context.Response.WriteAsJsonAsync(problemDetails, cancellationToken);
            return true; // Excepción manejada, detiene la cadena
        }
    }
    
    // Program.cs
    builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
    builder.Services.AddProblemDetails();
    app.UseExceptionHandler();

    Podés crear múltiples handlers y registrarlos en orden: el primero que devuelva true corta la cadena. Útil para manejar ValidationException, NotFoundException, etc., con respuestas específicas.

    3. Validación Nativa con Endpoint Filters

    Validar el payload antes de que llegue a la lógica de negocio es crítico. En .NET 10, las Minimal APIs tienen Endpoint Filters que actúan como un pipeline de validación declarativo, sin necesidad de librerías externas para los casos más comunes.

    app.MapPost("/api/users", (UserDto user) =>
    {
        return Results.Created($"/api/users/{user.Id}", user);
    })
    .AddEndpointFilter(async (context, next) =>
    {
        var user = context.GetArgument<UserDto>(0);
    
        var errors = new Dictionary<string, string[]>();
    
        if (string.IsNullOrEmpty(user.Name))
            errors["Name"] = new[] { "El nombre es obligatorio" };
    
        if (user.Age < 0 || user.Age > 120)
            errors["Age"] = new[] { "La edad debe estar entre 0 y 120" };
    
        if (errors.Any())
            return Results.ValidationProblem(errors);
    
        return await next(context);
    });

    Para proyectos más grandes donde necesitás validaciones complejas, FluentValidation sigue siendo una excelente opción que se integra perfectamente con este patrón.

    4. OpenAPI Integrado: adiós Swashbuckle

    Durante años, generar documentación Swagger significaba instalar Swashbuckle y rezar para que no conflictuara con otras dependencias. En .NET 10, la generación de especificación OpenAPI es nativa, sin dependencias externas, con mejor performance de arranque y mantenimiento garantizado por Microsoft.

    builder.Services.AddOpenApi(); // Generación nativa
    
    var app = builder.Build();
    
    if (app.Environment.IsDevelopment())
    {
        app.MapOpenApi(); // Expone la spec en /openapi/v1.json
    
        // Opción A: Scalar (UI moderna, recomendada)
        app.MapScalarApiReference();
    
        // Opción B: Swagger UI clásico
        // app.UseSwaggerUI(c => c.SwaggerEndpoint("/openapi/v1.json", "API v1"));
    }

    Para la UI de documentación, te recomiendo probar Scalar: tiene mucho mejor UX que Swagger UI clásico y es trivial de integrar.

    Resumen de la Parte 1

    Estos cuatro componentes son los que agrego en cualquier proyecto nuevo desde el día uno:

    • Health Checks → para que Kubernetes y el balanceador sepan el estado real de tu API
    • Exception Handler global → respuestas de error consistentes y sin leakear internos
    • Validación con Endpoint Filters → datos limpios antes de tocar la lógica de negocio
    • OpenAPI nativo → documentación siempre actualizada sin overhead

    En la Parte 2 vemos los tres componentes que marcan la diferencia cuando la API empieza a escalar: Rate Limiting, Output Cache y API Versioning.

  • Microservicios con Docker: lo que aprendí armando mi primera arquitectura

    Arrancé con un monolito .NET en un solo contenedor. Funcionaba bien, hasta que el equipo creció y todos tocábamos el mismo código. Desplegar un cambio en la pantalla de login requería redeployar toda la aplicación. Fue entonces cuando empecé a explorar microservicios con Docker.

    Monolito vs microservicios: cuándo tiene sentido el cambio

    AspectoMonolito en DockerMicroservicios en Docker
    Complejidad inicialBajaAlta
    Deploy independienteNo — todo o nadaSí — servicio por servicio
    Escalabilidad selectivaNoSí — escalar solo lo que lo necesita
    Fallo aisladoUn bug afecta todoUn servicio caído no baja todo
    Equipos independientesDifícilCada equipo dueño de su servicio
    Overhead operacionalBajoAlto — más servicios que monitorear

    Mi recomendación: empezá con el monolito. Cuando los puntos de dolor de la tabla de arriba se vuelvan reales en tu día a día, ahí es el momento de dividir.

    Mi primera arquitectura de microservicios: auth + api + frontend

    # docker-compose.yml — tres servicios independientes
    
    version: '3.8'
    
    services:
      # Servicio de autenticación (JWT, usuarios)
      auth-service:
        build: ./services/auth
        environment:
          - DB_CONNECTION=Host=postgres;Database=auth;Username=auth;Password=${AUTH_DB_PASS}
          - JWT_SECRET=${JWT_SECRET}
          - JWT_EXPIRY=1h
        depends_on:
          postgres:
            condition: service_healthy
        restart: unless-stopped
        # Sin puerto expuesto - solo accesible internamente
    
      # API principal de negocio
      api-service:
        build: ./services/api
        environment:
          - DB_CONNECTION=Host=postgres;Database=apidb;Username=api;Password=${API_DB_PASS}
          - AUTH_SERVICE_URL=http://auth-service:8080
          - CACHE_URL=redis:6379
        depends_on:
          - auth-service
          - redis
        restart: unless-stopped
    
      # Frontend React
      frontend:
        build: ./services/frontend
        environment:
          - REACT_APP_API_URL=http://api-service:8080
        restart: unless-stopped
    
      # Proxy - único punto de entrada externo
      nginx:
        image: nginx:1.25-alpine
        ports:
          - "80:80"
        volumes:
          - ./nginx/microservices.conf:/etc/nginx/nginx.conf:ro
        depends_on:
          - frontend
          - api-service
    
      # Infraestructura compartida
      postgres:
        image: postgres:16-alpine
        environment:
          POSTGRES_MULTIPLE_DATABASES: auth,apidb  # extensión para múltiples DBs
          POSTGRES_PASSWORD: ${POSTGRES_ROOT_PASS}
        volumes:
          - postgres-data:/var/lib/postgresql/data
        healthcheck:
          test: ["CMD-SHELL", "pg_isready"]
          interval: 10s
          retries: 5
    
      redis:
        image: redis:7-alpine
        volumes:
          - redis-data:/data
    
    volumes:
      postgres-data:
      redis-data:

    Comunicación entre servicios

    Dentro de la red Docker, los servicios se llaman por nombre. La API valida tokens llamando al servicio de auth en cada request:

    # En el código de api-service (.NET):
    // Validar token contra auth-service
    var authResponse = await _httpClient.GetAsync(
        $"{_authServiceUrl}/validate?token={token}"
    );
    
    // En el docker-compose, AUTH_SERVICE_URL = http://auth-service:8080
    // Docker resuelve "auth-service" al contenedor correcto automáticamente

    Deploy independiente: la ventaja real

    # Actualizar solo el servicio de auth sin tocar nada más
    docker compose up -d --build auth-service
    
    # Escalar solo la API (recibe más carga)
    docker compose up -d --scale api-service=3
    
    # Rollback solo del frontend
    docker compose stop frontend
    docker compose rm -f frontend
    TAG=anterior docker compose up -d frontend

    Lo que aprendí en el proceso

    La transición de monolito a microservicios no es solo técnica: es organizacional. Cada servicio necesita su propio repositorio (o al menos su propia carpeta), su propio pipeline de CI/CD y su propio dueño. La complejidad operacional sube. Por eso Docker Compose no es suficiente para microservicios en producción a escala — ese es el camino hacia Kubernetes, que vemos en el próximo artículo.


    Artículo anterior: Docker en CI/CD | Serie Docker Completo | Próximo: Seguridad en Docker →


    Artículo anterior: Docker en mi pipeline de CI/CD: builds reproducibles sin sorpresas | Serie Docker Completo | Próximo: Seguridad en Docker: errores que cometí y cómo los corregí →

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)