← maurobernal.com.ar

Etiqueta: devops

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

  • Guía práctica: cómo migré un proyecto de Angular 19/20 a Angular 21.2 sin morir en el intento

    Migrar un proyecto Angular de v19/20 a v21 puede sonar intimidante. En la práctica, si seguís el proceso correcto, es incremental: cada paso es reversible y el proyecto sigue funcionando en todo momento. Esta es la guía que apliqué en proyectos reales.

    Antes de empezar: el inventario

    # Ver versión actual y dependencias desactualizadas
    ng version
    ng update
    
    # Verificar compatibilidad antes de actualizar
    npx npm-check-updates -u --target minor

    Paso 1: Actualizar el core

    # Siempre de a una versión mayor a la vez
    # Si estás en v19, primero actualizar a v20, luego a v21
    
    # De v20 a v21:
    ng update @angular/core@21 @angular/cli@21
    
    # El comando aplica schematics automáticos:
    # - Actualiza imports deprecated
    # - Adapta APIs que cambiaron
    # - Avisa sobre cambios manuales necesarios
    
    # Verificar que compila
    ng build --configuration production

    Paso 2: Adoptar Zoneless

    // app.config.ts
    import { provideZonelessChangeDetection } from '@angular/core';
    
    export const appConfig: ApplicationConfig = {
      providers: [
        provideZonelessChangeDetection(),
        provideRouter(routes)
      ]
    };
    
    // angular.json: "polyfills": []  // eliminar zone.js
    // npm uninstall zone.js

    Paso 3: Migrar de Karma a Vitest

    ng generate @angular/build:vitest
    ng test  # verificar que los tests pasan
    npm uninstall karma karma-chrome-launcher karma-coverage karma-jasmine karma-jasmine-html-reporter

    Paso 4: Standalone components

    # Angular 21 asume standalone por defecto
    # Migración automática:
    ng generate @angular/core:standalone --mode=convert-to-standalone
    ng generate @angular/core:standalone --mode=prune-ng-modules
    ng generate @angular/core:standalone --mode=standalone-bootstrap

    Checklist completo

    PasoAcciónObligatorio
    1ng update @angular/core@21 @angular/cli@21✅ Sí
    2Verificar que la app compila y los tests pasan✅ Sí
    3Activar provideZonelessChangeDetection()Recomendado
    4Eliminar Zone.js de polyfillsCon paso 3
    5Migrar a VitestRecomendado
    6Nuevos formularios con Signal FormsGradual
    7Componentes de nav con funciones standalone del RouterGradual
    8Configurar ng mcp para integración con IAOpcional

    Errores comunes y cómo resolverlos

    // ERROR: El componente no se actualiza después de activar Zoneless
    // → Convertir estado a Signals o llamar markForCheck()
    
    // ERROR: Tests fallan con "No current Angular test" después de Vitest
    // → Verificar imports de @angular/core/testing
    
    // ERROR: "Cannot find module zone.js"
    // → Buscar y eliminar import 'zone.js' en el proyecto:
    // grep -r "import 'zone.js'" src/
    
    // ERROR: ExpressionChangedAfterItHasBeenCheckedError en Zoneless
    // → Usar signal.update() o signal.set() en lugar de mutación directa

    Mi experiencia: cuánto tardó la migración

    En un proyecto de tamaño medio (45 componentes, 12 servicios, 80 tests): el ng update tardó 10 minutos. Revisar avisos y corregir deprecaciones: media jornada. Migración a Zoneless con todos los tests adaptados a Vitest: un día. Adopción gradual de Signal Forms en los formularios principales: dos semanas, a medida que tocábamos cada módulo.

    No es un proceso de un fin de semana, pero tampoco es una reescritura. Es una evolución incremental que podés hacer en paralelo con el trabajo normal del equipo.


    Artículo anterior: Router Signals en Angular 21: navegación standalone sin cargar todo el Router | Fin de la Serie Angular 20 → 21.2

    ¿Tenés preguntas sobre la migración en tu proyecto específico? Dejá tu caso en los comentarios. 👇

  • Vitest reemplaza a Karma y Angular habla con tu IA: el nuevo ecosistema de herramientas

    Karma murió. No es una exageración: Angular 21 lo retiró como runner por defecto y lo reemplazó con Vitest. Y junto con eso llegó algo que no esperaba: soporte nativo para que asistentes de IA entiendan la estructura de un proyecto Angular en tiempo real con el protocolo MCP.

    Por qué Karma quedó obsoleto

    Karma fue el runner de tests de Angular durante más de una década. Abre un browser real, corre los tests ahí y reporta los resultados. Funcional, pero lento, difícil de configurar en CI y sin soporte nativo para el modelo Zoneless. En el mundo actual de testing, donde queremos ejecución rápida y paralela, Karma no competía.

    Vitest: el nuevo estándar

    • Ejecución en paralelo: múltiples workers, tests más rápidos
    • Sin browser real: usa jsdom o happy-dom — más rápido y estable en CI
    • Soporte nativo Zoneless: sin el overhead de Zone.js en los tests
    • Watch mode inteligente: solo re-ejecuta los tests afectados por cambios
    • API compatible con Jest: si venís de Jest, la curva es mínima
    # Configurar Vitest en un proyecto Angular 21 existente
    ng generate @angular/build:vitest
    
    # O en angular.json
    {
      "test": {
        "builder": "@angular/build:unit-test",
        "options": {
          "runner": "vitest"
        }
      }
    }

    Tests con Vitest + Signals

    import { TestBed } from '@angular/core/testing';
    import { provideZonelessChangeDetection } from '@angular/core';
    import { describe, it, expect } from 'vitest';
    import { ContadorComponent } from './contador.component';
    
    describe('ContadorComponent', () => {
      beforeEach(() => {
        TestBed.configureTestingModule({
          imports: [ContadorComponent],
          providers: [provideZonelessChangeDetection()]
        });
      });
    
      it('debería incrementar el contador', () => {
        const fixture = TestBed.createComponent(ContadorComponent);
        const comp = fixture.componentInstance;
    
        expect(comp.contador()).toBe(0);
        comp.incrementar();
        // Sin detectChanges() manual — Signals actualizan sincrónicamente
        expect(comp.contador()).toBe(1);
        expect(comp.doble()).toBe(2);
      });
    
      it('debería renderizar el valor actualizado', async () => {
        const fixture = TestBed.createComponent(ContadorComponent);
        fixture.detectChanges();
    
        const button = fixture.nativeElement.querySelector('button');
        button.click();
    
        await fixture.whenStable();
        expect(fixture.nativeElement.querySelector('p').textContent).toContain('1');
      });
    });

    ng mcp: Angular habla con tu IA

    La feature que más me sorprendió de Angular 21.2: soporte nativo para el Model Context Protocol (MCP). Con un solo comando, Angular expone la estructura del proyecto a asistentes de IA locales para que entiendan el contexto en tiempo real.

    # Levantar el servidor MCP de Angular
    ng mcp
    
    # Configuración en .cursor/mcp.json o claude_desktop_config.json:
    {
      "mcpServers": {
        "angular": {
          "command": "ng",
          "args": ["mcp"],
          "cwd": "/path/to/mi-proyecto-angular"
        }
      }
    }

    Con el servidor MCP activo, el asistente de IA tiene acceso al árbol de componentes, rutas del router, servicios y sus dependencias, grafo de Signals y configuración del proyecto. En la práctica: cuando le pedís al asistente que genere un componente, lo hace correctamente dentro del contexto de tu proyecto — no código genérico que hay que adaptar.

    Migrar de Karma a Vitest

    # 1. Desinstalar Karma
    npm uninstall karma karma-chrome-launcher karma-jasmine karma-jasmine-html-reporter
    
    # 2. Ejecutar el schematic de migración
    ng generate @angular/build:vitest
    
    # 3. Verificar tests
    ng test
    
    # Los tests de Jasmine son mayormente compatibles
    # Solo ajustar: jasmine.SpyObj → vi.Mocked cuando sea necesario

    Artículo anterior: Novedades del template en Angular 21: regex, spread, instanceof y más | Serie Angular 20 → 21.2 | Próximo: Router Signals en Angular 21: navegación standalone sin cargar todo el Router →

  • Docker vs Kubernetes: cuándo me alcanza con uno y cuándo necesito el otro

    Me pidieron «alta disponibilidad» para un sistema crítico. Tenía Docker Compose funcionando perfecto en un solo nodo. Pensé: «con –scale lo resuelvo». Hasta que entendí que escalar horizontalmente en múltiples nodos con Compose no es trivial. Ese fue el momento en que Kubernetes dejó de ser «esa tecnología complicada» y se convirtió en la herramienta correcta para el trabajo.

    Docker standalone: cuándo alcanza

    No todo necesita Kubernetes. Docker solo, o Docker Compose, es perfecto para muchos casos de uso:

    • Aplicaciones en un solo servidor
    • Entornos de desarrollo local
    • Proyectos personales o startups en etapa temprana
    • Workloads que no requieren alta disponibilidad real
    • Pipelines de CI/CD

    La tabla que lo resume todo

    CaracterísticaDocker soloDocker SwarmKubernetes
    ComplejidadBajaMediaAlta
    HA multi-nodoNoSí (básico)Sí (avanzado)
    Auto-scalingManualBásicoHPA / VPA automático
    Self-healingrestart policySí (avanzado)
    Rolling updatesManualSí (con control fino)
    SecretsEnv vars / archivosDocker SecretsKubernetes Secrets + Vault
    NetworkingBridge / overlay básicoOverlayCNI (Calico, Flannel, etc.)
    Observabilidaddocker logs / statsBásicaPrometheus, Grafana, Jaeger
    Curva de aprendizajeDíasSemanasMeses

    Docker Swarm: el punto medio

    Swarm es el orquestador nativo de Docker. Más simple que Kubernetes, soporta multi-nodo y HA básica. Si necesitás distribuir contenedores en 2-5 nodos sin la complejidad de K8s, Swarm es una opción válida.

    # Inicializar un swarm
    docker swarm init --advertise-addr 192.168.1.10
    
    # Agregar un nodo worker
    docker swarm join --token SWMTKN-1-xxx 192.168.1.10:2377
    
    # Desplegar un stack (similar a docker compose)
    docker stack deploy -c docker-compose.yml mi-app
    
    # Ver servicios del stack
    docker service ls
    docker service ps mi-app_api
    
    # Escalar un servicio
    docker service scale mi-app_api=3

    Kubernetes: cuándo lo necesitás de verdad

    En mi cluster SUSE Linux HA con dos nodos, Swarm funcionaba. Pero cuando los requisitos crecieron — deploys sin downtime garantizado, auto-scaling basado en métricas, gestión de secretos centralizada, rollbacks automáticos — Kubernetes fue la respuesta correcta.

    La diferencia fundamental: Docker (y Swarm) son herramientas para correr contenedores. Kubernetes es una plataforma para gestionar aplicaciones. La distinción importa cuando tu aplicación crece.

    # El mismo concepto en Docker Compose vs Kubernetes
    
    # docker-compose.yml
    services:
      api:
        image: mi-api:1.4.2
        replicas: 3
        restart: unless-stopped
    
    # ─────────────────────────────────────────────
    # En Kubernetes (deployment.yaml)
    apiVersion: apps/v1
    kind: Deployment
    metadata:
      name: api
    spec:
      replicas: 3
      strategy:
        type: RollingUpdate
        rollingUpdate:
          maxUnavailable: 1    # nunca baja de 2 replicas durante update
          maxSurge: 1          # puede tener 4 temporalmente
      selector:
        matchLabels:
          app: api
      template:
        spec:
          containers:
          - name: api
            image: mi-api:1.4.2
            resources:
              requests:
                memory: "256Mi"
                cpu: "250m"
              limits:
                memory: "512Mi"
                cpu: "500m"
            livenessProbe:
              httpGet:
                path: /health
                port: 80
              initialDelaySeconds: 30
            readinessProbe:
              httpGet:
                path: /ready
                port: 80

    El camino natural: Docker → Compose → Kubernetes

    No es un salto, es una progresión. Empecé con docker run, pasé a Compose para gestionar múltiples servicios, y cuando los requisitos de producción superaron lo que Compose podía manejar cómodamente, migramos a Kubernetes. Los conceptos son los mismos — imágenes, contenedores, redes, volúmenes — pero Kubernetes agrega la capa de orquestación inteligente que necesitás a escala.

    El conocimiento de Docker no se descarta al llegar a Kubernetes: es el prerequisito. Todo pod de Kubernetes corre contenedores Docker. Los Dockerfiles que aprendiste a escribir son exactamente los mismos. La diferencia está en quién los gestiona y con qué nivel de sofisticación.

    Mi setup actual

    Hoy corro Kubernetes en mi cluster on-premise SUSE con dos nodos en HA. Docker sigue presente — lo uso en CI/CD para buildear imágenes y en desarrollo local. Compose lo uso para levantar entornos de desarrollo con múltiples servicios. Kubernetes gestiona producción. Cada herramienta en su lugar.


    Artículo anterior: Seguridad en Docker | Fin de la Serie Docker Completo

    Esta fue la serie completa sobre Docker. Si llegaste hasta acá, tenés las bases para trabajar con contenedores en entornos reales. El siguiente paso natural es Kubernetes — cubriremos eso en una serie dedicada.


    Artículo anterior: Seguridad en Docker: errores que cometí y cómo los corregí | Fin de la Serie Docker Completo

  • Seguridad en Docker: errores que cometí y cómo los corregí

    Desplegué una imagen con credenciales hardcodeadas en el Dockerfile. Una vez. Cuando me di cuenta, la imagen estaba en el registry privado de la empresa y nadie más la había visto — pero el susto fue suficiente para que revisara la seguridad de todos mis contenedores ese mismo día.

    Los errores de seguridad más comunes en Docker

    La mayoría de los problemas de seguridad en Docker no son bugs exóticos: son malas prácticas que cometemos por desconocimiento o por apurarnos. Acá van los que yo cometí y cómo los corregí.

    Error 1: Correr contenedores como root

    # ❌ Por defecto, el proceso corre como root dentro del contenedor
    FROM node:20-alpine
    WORKDIR /app
    COPY . .
    RUN npm install
    CMD ["node", "server.js"]
    # Si el proceso es comprometido, tiene privilegios de root dentro del contenedor
    
    # ✅ Crear y usar un usuario no-root
    FROM node:20-alpine
    WORKDIR /app
    
    # Crear usuario sin privilegios
    RUN addgroup -S appgroup && adduser -S appuser -G appgroup
    
    # Copiar archivos con el usuario correcto
    COPY --chown=appuser:appgroup . .
    RUN npm install --only=production
    
    USER appuser
    CMD ["node", "server.js"]

    Error 2: Secretos en el Dockerfile o en variables de entorno planas

    # ❌ NUNCA - el secreto queda grabado en una capa para siempre
    ENV DB_PASSWORD=mipassword123
    RUN curl -H "Authorization: Bearer TOKENREALAQUI" https://api.interna.com/config
    
    # Aunque hagas otra capa que lo "borre", sigue en la historia de la imagen:
    docker history mi-imagen  # el secreto es visible
    
    # ✅ Para secrets en build-time, usar BuildKit secrets
    # Esto NO deja rastro en las capas
    # syntax=docker/dockerfile:1
    FROM alpine
    RUN --mount=type=secret,id=api_token     TOKEN=$(cat /run/secrets/api_token) &&     curl -H "Authorization: Bearer $TOKEN" https://api.interna.com/config
    
    # Build:
    docker build --secret id=api_token,src=./secrets/api_token .
    
    # ✅ Para secrets en runtime, usar Docker secrets (Swarm) o variables de entorno
    # via archivo .env que NUNCA va al repositorio
    docker run --env-file .env.prod mi-imagen

    Error 3: Imágenes base desactualizadas

    # Escanear una imagen en busca de vulnerabilidades conocidas
    docker scout cves mi-imagen:latest
    
    # O con Trivy (más completo, lo que uso en CI)
    docker run --rm   -v /var/run/docker.sock:/var/run/docker.sock   aquasec/trivy image mi-imagen:latest
    
    # Resultado típico:
    # 2026-03-11T10:00:00Z INFO Detected OS: alpine 3.18
    # CRITICAL: 0, HIGH: 1, MEDIUM: 3, LOW: 8
    # HIGH: libssl CVE-2024-XXXXX - update to 3.1.5-r0

    Error 4: El .dockerignore inexistente

    # Sin .dockerignore, COPY . . incluye todo — incluyendo:
    # - .git/ (historial completo del repo)
    # - .env (credenciales locales)
    # - node_modules/ (pesado e innecesario)
    # - tests/ (código de tests en la imagen de producción)
    
    # .dockerignore completo que uso en todos mis proyectos:
    .git
    .gitignore
    .env
    .env.*
    !.env.example
    **/node_modules
    **/bin
    **/obj
    **/*.log
    **/.DS_Store
    docker-compose*.yml
    Dockerfile*
    tests/
    docs/
    README.md
    .github/

    Dockerfile inseguro vs seguro: comparación completa

    # ❌ Dockerfile inseguro
    FROM node:latest                    # versión impredecible
    ENV API_KEY=abc123supersecret       # secreto en capa
    WORKDIR /app
    COPY . .                            # sin .dockerignore, incluye .env y .git
    RUN npm install                     # instala todo incluyendo devDependencies
    EXPOSE 3000
    CMD ["node", "server.js"]           # corre como root
    # ✅ Dockerfile seguro
    FROM node:20.11.0-alpine3.19        # versión fija y verificable
    
    WORKDIR /app
    
    # Usuario no-root
    RUN addgroup -S app && adduser -S app -G app
    
    # Dependencias primero (aprovecha cache, sin devDependencies)
    COPY --chown=app:app package*.json ./
    RUN npm ci --only=production && npm cache clean --force
    
    # Código fuente (sin secretos - .dockerignore los excluye)
    COPY --chown=app:app src/ ./src/
    
    # Sin variables de entorno sensibles en la imagen
    # Se pasan en runtime con --env-file
    
    USER app
    EXPOSE 3000
    
    # Healthcheck
    HEALTHCHECK --interval=30s --timeout=3s --retries=3   CMD wget -q -O /dev/null http://localhost:3000/health || exit 1
    
    CMD ["node", "src/server.js"]

    Limitar capacidades del contenedor

    # Eliminar todas las capabilities de Linux y agregar solo las necesarias
    docker run   --cap-drop=ALL   --cap-add=NET_BIND_SERVICE \   # solo si necesita bind a puerto < 1024
      --read-only \                   # sistema de archivos de solo lectura
      --tmpfs /tmp \                  # área de escritura temporal
      --security-opt=no-new-privileges   --user 1001:1001   mi-imagen:latest

    El checklist que uso antes de cada deploy

    • ✅ ¿La imagen corre con usuario no-root?
    • ✅ ¿Hay .dockerignore con .env excluido?
    • ✅ ¿Los secretos van en runtime, no en la imagen?
    • ✅ ¿La imagen base tiene versión fija?
    • ✅ ¿Pasé Trivy o docker scout y no hay CVEs críticos?
    • ✅ ¿Los puertos expuestos son solo los necesarios?
    • ✅ ¿Hay healthcheck configurado?

    Artículo anterior: Microservicios con Docker | Serie Docker Completo | Próximo: Docker vs Kubernetes →


    Artículo anterior: Microservicios con Docker: lo que aprendí armando mi primera arquitectura | Serie Docker Completo | Próximo: Docker vs Kubernetes: cuándo me alcanza con uno y cuándo necesito el otro →

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)