← Volver al listado de tecnologías

Capítulo 23: Deployment con Docker y Kubernetes

Por: SiempreListo
event-sourcingdockerkubernetesdeploymentdevops

Capítulo 23: Deployment con Docker y Kubernetes

“Un sistema Event Sourced requiere consideraciones especiales para producción”

Consideraciones Especiales para Event Sourcing

Desplegar sistemas Event Sourced presenta desafíos únicos:

  1. Projection Workers: Deben ser singleton para evitar procesamiento duplicado
  2. Event Store: Es crítico; requiere backups especiales
  3. Migraciones: Los eventos son inmutables; el schema evoluciona de forma diferente
  4. Consistencia Eventual: Las proyecciones pueden estar desactualizadas durante deploys

Este capítulo cubre la configuración de producción con Docker y Kubernetes.

Docker

Dockerfile Multi-stage

Un Dockerfile multi-stage separa la construcción de la imagen final:

Esto reduce el tamaño de imagen y superficie de ataque.

# Dockerfile
FROM oven/bun:1.0 AS builder

WORKDIR /app
COPY package.json bun.lockb ./
RUN bun install --frozen-lockfile

COPY . .
RUN bun run build

# Production image
FROM oven/bun:1.0-slim

WORKDIR /app

# Non-root user
RUN addgroup --system --gid 1001 nodejs && \
    adduser --system --uid 1001 orderflow
USER orderflow

COPY --from=builder --chown=orderflow:nodejs /app/dist ./dist
COPY --from=builder --chown=orderflow:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=orderflow:nodejs /app/package.json ./

ENV NODE_ENV=production
ENV PORT=3000

EXPOSE 3000

HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
  CMD curl -f http://localhost:3000/health/live || exit 1

CMD ["bun", "run", "dist/server.js"]

Docker Compose Producción

Para producción simple o staging, Docker Compose orquesta múltiples contenedores. Notas importantes:

# docker-compose.prod.yml
services:
  api:
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - "3000:3000"
    environment:
      DATABASE_URL: postgres://orderflow:${DB_PASSWORD}@postgres:5432/orderflow
      REDIS_URL: redis://redis:6379
      NODE_ENV: production
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    deploy:
      replicas: 3
      resources:
        limits:
          cpus: '1'
          memory: 512M
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:3000/health/live"]
      interval: 30s
      timeout: 10s
      retries: 3

  projection-worker:
    build:
      context: .
      dockerfile: Dockerfile.worker
    environment:
      DATABASE_URL: postgres://orderflow:${DB_PASSWORD}@postgres:5432/orderflow
    depends_on:
      - postgres
    deploy:
      replicas: 1  # Solo una instancia para evitar duplicados

  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: orderflow
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_DB: orderflow
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U orderflow"]
      interval: 10s
      timeout: 5s
      retries: 5

  redis:
    image: redis:7-alpine
    volumes:
      - redis_data:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 5s
      retries: 5

volumes:
  postgres_data:
  redis_data:

Kubernetes

Kubernetes es el estándar para orquestación de contenedores en producción. Los conceptos clave son:

Deployment API

# k8s/api-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: orderflow-api
  labels:
    app: orderflow
    component: api
spec:
  replicas: 3
  selector:
    matchLabels:
      app: orderflow
      component: api
  template:
    metadata:
      labels:
        app: orderflow
        component: api
    spec:
      containers:
        - name: api
          image: orderflow/api:latest
          ports:
            - containerPort: 3000
          env:
            - name: DATABASE_URL
              valueFrom:
                secretKeyRef:
                  name: orderflow-secrets
                  key: database-url
            - name: NODE_ENV
              value: "production"
          resources:
            requests:
              cpu: "250m"
              memory: "256Mi"
            limits:
              cpu: "1000m"
              memory: "512Mi"
          readinessProbe:
            httpGet:
              path: /health/ready
              port: 3000
            initialDelaySeconds: 5
            periodSeconds: 10
          livenessProbe:
            httpGet:
              path: /health/live
              port: 3000
            initialDelaySeconds: 10
            periodSeconds: 30
---
apiVersion: v1
kind: Service
metadata:
  name: orderflow-api
spec:
  selector:
    app: orderflow
    component: api
  ports:
    - port: 80
      targetPort: 3000
  type: ClusterIP

Projection Worker (StatefulSet)

El projection worker usa StatefulSet en lugar de Deployment porque:

  1. Una sola réplica: Evita procesamiento duplicado de eventos
  2. Identidad estable: El pod siempre tiene el mismo nombre (útil para logging)
  3. Ordenamiento: Kubernetes espera que el pod anterior termine antes de crear el nuevo
# k8s/projection-worker.yaml
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: orderflow-projection-worker
spec:
  serviceName: projection-worker
  replicas: 1  # Una sola réplica para evitar procesamiento duplicado
  selector:
    matchLabels:
      app: orderflow
      component: projection-worker
  template:
    metadata:
      labels:
        app: orderflow
        component: projection-worker
    spec:
      containers:
        - name: worker
          image: orderflow/worker:latest
          env:
            - name: DATABASE_URL
              valueFrom:
                secretKeyRef:
                  name: orderflow-secrets
                  key: database-url
            - name: WORKER_ID
              valueFrom:
                fieldRef:
                  fieldPath: metadata.name
          resources:
            requests:
              cpu: "100m"
              memory: "128Mi"
            limits:
              cpu: "500m"
              memory: "256Mi"

ConfigMap y Secrets

ConfigMap almacena configuración no sensible; Secret almacena credenciales cifradas. Ambos se inyectan como variables de entorno o archivos.

# k8s/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: orderflow-config
data:
  LOG_LEVEL: "info"
  PROJECTION_POLL_INTERVAL: "100"
  SNAPSHOT_INTERVAL: "100"
---
# k8s/secrets.yaml (usar sealed-secrets en producción)
apiVersion: v1
kind: Secret
metadata:
  name: orderflow-secrets
type: Opaque
stringData:
  database-url: postgres://orderflow:password@postgres:5432/orderflow

Ingress

# k8s/ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: orderflow-ingress
  annotations:
    nginx.ingress.kubernetes.io/ssl-redirect: "true"
    cert-manager.io/cluster-issuer: "letsencrypt-prod"
spec:
  tls:
    - hosts:
        - api.orderflow.example.com
      secretName: orderflow-tls
  rules:
    - host: api.orderflow.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: orderflow-api
                port:
                  number: 80

HorizontalPodAutoscaler

El HPA escala automáticamente el número de réplicas basándose en métricas (CPU, memoria, o métricas custom). Solo aplica a la API, no al projection worker (que debe ser singleton).

# k8s/hpa.yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: orderflow-api-hpa
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: orderflow-api
  minReplicas: 3
  maxReplicas: 10
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 70
    - type: Resource
      resource:
        name: memory
        target:
          type: Utilization
          averageUtilization: 80

CI/CD Pipeline

Un pipeline de CI/CD automatiza el proceso de test, build y deploy. Las etapas típicas son:

  1. Test: Ejecutar tests unitarios y de integración
  2. Build: Construir imagen Docker y pushear al registry
  3. Deploy: Actualizar Kubernetes con la nueva imagen
# .github/workflows/deploy.yml
name: Deploy

on:
  push:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: oven-sh/setup-bun@v1
      - run: bun install
      - run: bun test

  build:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Build and push Docker image
        uses: docker/build-push-action@v5
        with:
          push: true
          tags: |
            ghcr.io/${{ github.repository }}/api:${{ github.sha }}
            ghcr.io/${{ github.repository }}/api:latest

  deploy:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Deploy to Kubernetes
        run: |
          kubectl set image deployment/orderflow-api \
            api=ghcr.io/${{ github.repository }}/api:${{ github.sha }}
          kubectl rollout status deployment/orderflow-api

Consideraciones de Producción

Backup de Event Store

El Event Store es la fuente de verdad del sistema. Perder eventos significa perder historia irrecuperable. Los backups deben:

#!/bin/bash
# backup-events.sh
DATE=$(date +%Y%m%d_%H%M%S)
BACKUP_FILE="events_backup_${DATE}.sql"

pg_dump -h $DB_HOST -U orderflow \
  -t events \
  --data-only \
  -f $BACKUP_FILE

# Comprimir y subir a S3
gzip $BACKUP_FILE
aws s3 cp ${BACKUP_FILE}.gz s3://backups/orderflow/

Migración de Base de Datos

Las migraciones crean y modifican el schema de las tablas. En Event Sourcing:

// migrations/001_initial.ts
export async function up(db: Database): Promise<void> {
  await db.execute(sql`
    CREATE TABLE IF NOT EXISTS events (
      global_position BIGSERIAL PRIMARY KEY,
      stream_id VARCHAR(255) NOT NULL,
      stream_position INT NOT NULL,
      event_type VARCHAR(255) NOT NULL,
      data JSONB NOT NULL,
      metadata JSONB DEFAULT '{}',
      created_at TIMESTAMPTZ DEFAULT NOW(),
      UNIQUE(stream_id, stream_position)
    )
  `);

  await db.execute(sql`
    CREATE INDEX idx_events_stream ON events(stream_id, stream_position)
  `);
}

export async function down(db: Database): Promise<void> {
  await db.execute(sql`DROP TABLE IF EXISTS events`);
}

Resumen

Glosario

Multi-stage Build

Definición: Técnica de Docker donde el Dockerfile tiene múltiples stages; solo el contenido del stage final se incluye en la imagen.

Por qué es importante: Reduce tamaño de imagen dramáticamente (de ~1GB con node_modules de desarrollo a ~100MB solo con producción).

Ejemplo práctico: Stage “builder” instala devDependencies y compila TypeScript; stage “final” copia solo dist/ y node_modules de producción.


StatefulSet

Definición: Recurso de Kubernetes para workloads que requieren identidad de red estable, almacenamiento persistente, y ordenamiento de pods.

Por qué es importante: Los projection workers necesitan ejecutarse como singleton; StatefulSet garantiza que solo hay una instancia activa.

Ejemplo práctico: orderflow-projection-worker-0 siempre es el mismo pod; si muere, Kubernetes lo recrea con el mismo nombre antes de crear -1.


Deployment vs StatefulSet

Definición: Deployment maneja pods intercambiables; StatefulSet maneja pods con identidad persistente.

Por qué es importante: La API puede escalar con Deployment (pods intercambiables); projection worker usa StatefulSet (singleton con identidad).

Ejemplo práctico: API: orderflow-api-7b8c9-xyz, orderflow-api-7b8c9-abc (nombres aleatorios). Worker: orderflow-projection-worker-0 (nombre fijo).


Readiness Probe

Definición: Verificación periódica que Kubernetes ejecuta para determinar si un pod puede recibir tráfico.

Por qué es importante: Un pod que arrancó pero aún carga datos no debe recibir requests; el probe previene esto.

Ejemplo práctico: /health/ready verifica que la proyección está actualizada (lag < 1000); hasta entonces, el pod no recibe tráfico del Service.


Liveness Probe

Definición: Verificación periódica que determina si un pod está vivo; si falla, Kubernetes lo reinicia.

Por qué es importante: Detecta procesos que se colgaron pero no murieron; el reinicio automático recupera el servicio.

Ejemplo práctico: /health/live devuelve 200 si el proceso responde; si tarda más de 10s o devuelve error 3 veces seguidas, Kubernetes mata y recrea el pod.


HorizontalPodAutoscaler (HPA)

Definición: Recurso de Kubernetes que ajusta automáticamente el número de réplicas basándose en métricas.

Por qué es importante: Escala la API según demanda real; durante picos crea más pods, en calma los reduce (ahorra costos).

Ejemplo práctico: CPU promedio > 70% → HPA crea más réplicas hasta maxReplicas. CPU < 50% → reduce réplicas hasta minReplicas.


ConfigMap

Definición: Recurso de Kubernetes para almacenar configuración no sensible como pares clave-valor o archivos.

Por qué es importante: Separa configuración del código; permite cambiar comportamiento sin reconstruir imagen.

Ejemplo práctico: LOG_LEVEL: "info", SNAPSHOT_INTERVAL: "100" se inyectan como variables de entorno al pod.


Secret

Definición: Recurso de Kubernetes para almacenar datos sensibles (passwords, tokens) cifrados en base64.

Por qué es importante: Las credenciales no deben estar en ConfigMaps (visibles) ni en imágenes Docker (versionadas).

Ejemplo práctico: database-url: postgres://user:password@host/db se almacena como Secret y se inyecta como variable de entorno.


Rolling Update

Definición: Estrategia de deployment donde los pods se actualizan gradualmente, manteniendo disponibilidad durante el proceso.

Por qué es importante: Permite deployments sin downtime; si el nuevo pod falla, Kubernetes detiene el rollout automáticamente.

Ejemplo práctico: Con 3 réplicas, Kubernetes crea 1 pod nuevo, espera que esté ready, termina 1 pod viejo, repite hasta actualizar los 3.


Ingress

Definición: Recurso de Kubernetes que gestiona acceso HTTP/HTTPS externo, con routing basado en host y path.

Por qué es importante: Centraliza TLS termination, routing, y load balancing para múltiples servicios bajo un solo dominio.

Ejemplo práctico: api.orderflow.com/orders → Service orderflow-api; api.orderflow.com/metrics → Service prometheus.


← Capítulo 22: Monitoreo | Volver al Índice →