Saltar al contenido

Despliegues sin downtime: estrategias blue-green y canary

Guía práctica de estrategias de despliegue sin downtime: blue-green, releases canary y rolling updates con mecanismos de rollback automático.

6 min de lectura
Diagrama de un pipeline de despliegue que muestra las estrategias Blue-Green y Canary con el enrutamiento del tráfico

El costo del tiempo de inactividad en los despliegues

Cada segundo de inactividad durante un despliegue cuesta ingresos, confianza de los usuarios y confianza del equipo. Los desarrolladores que temen los despliegues los realizan con menos frecuencia. Los despliegues menos frecuentes implican conjuntos de cambios más grandes. Los conjuntos de cambios más grandes implican mayor riesgo por despliegue. Este ciclo convierte el despliegue en una ceremonia estresante y poco frecuente, en lugar de una operación rutinaria y aburrida.

El despliegue sin tiempo de inactividad rompe este ciclo. Cuando los despliegues son seguros e invisibles para los usuarios, los equipos despliegan con más frecuencia, con cambios más pequeños y con mayor confianza. Las estrategias que se abordan aquí —Blue-Green, Canary y Rolling Update— son los pilares de esa capacidad.

Arquitectura de despliegue Blue-Green

El despliegue Blue-Green mantiene dos entornos de producción idénticos. En todo momento, uno de ellos (blue) atiende el tráfico en vivo mientras el otro (green) permanece inactivo o recibe la nueva versión. Desplegar consiste en cambiar el enrutador de blue a green.

tstypescript
interface Environment {
  name: "blue" | "green";
  version: string;
  status: "active" | "idle" | "deploying" | "draining";
  healthCheckUrl: string;
  instances: number;
}
 
interface BlueGreenState {
  activeEnvironment: "blue" | "green";
  blue: Environment;
  green: Environment;
  lastSwitch: Date;
}
 
class BlueGreenDeployer {
  constructor(
    private state: BlueGreenState,
    private readonly loadBalancer: LoadBalancer,
    private readonly healthChecker: HealthChecker
  ) {}
 
  async deploy(newVersion: string): Promise<void> {
    const idle =
      this.state.activeEnvironment === "blue"
        ? this.state.green
        : this.state.blue;
 
    console.log(`Deploying ${newVersion} to ${idle.name} environment`);
 
    // Step 1: Deploy to idle environment
    idle.status = "deploying";
    await this.deployToEnvironment(idle, newVersion);
 
    // Step 2: Health check the new deployment
    idle.status = "idle";
    const healthy = await this.healthChecker.verify(
      idle.healthCheckUrl,
      { retries: 5, intervalMs: 3000 }
    );
 
    if (!healthy) {
      throw new Error(`Health check failed for ${idle.name} with ${newVersion}`);
    }
 
    // Step 3: Switch traffic
    console.log(`Switching traffic from ${this.state.activeEnvironment} to ${idle.name}`);
    await this.loadBalancer.route(idle.name);
 
    // Step 4: Update state
    const previous = this.state.activeEnvironment;
    this.state.activeEnvironment = idle.name;
    idle.status = "active";
    this.state[previous].status = "draining";
    this.state.lastSwitch = new Date();
 
    // Step 5: Drain old environment
    await this.waitForConnectionDrain(this.state[previous]);
    this.state[previous].status = "idle";
 
    console.log(`Deployment complete. ${idle.name} is now active.`);
  }
 
  async rollback(): Promise<void> {
    const previous =
      this.state.activeEnvironment === "blue" ? "green" : "blue";
    console.log(`Rolling back to ${previous} environment`);
    await this.loadBalancer.route(previous);
    this.state.activeEnvironment = previous;
  }
 
  private async deployToEnvironment(
    env: Environment,
    version: string
  ): Promise<void> {
    // Actual deployment logic (container pull, app restart, etc.)
    env.version = version;
  }
 
  private async waitForConnectionDrain(env: Environment): Promise<void> {
    // Wait for in-flight requests to complete
    await new Promise((resolve) => setTimeout(resolve, 30000));
  }
}

La principal ventaja de Blue-Green es el rollback instantáneo. Si la nueva versión presenta problemas, volver al entorno anterior toma segundos, porque ese entorno sigue ejecutando la última versión estable conocida.

Implementación de releases Canary

Los releases Canary envían un pequeño porcentaje del tráfico a la nueva versión mientras la mayoría continúa en la versión actual. Si las métricas se mantienen saludables, el tráfico se traslada gradualmente hacia la nueva versión.

tstypescript
interface CanaryConfig {
  initialPercentage: number;
  steps: number[];
  stepDurationMs: number;
  rollbackThresholds: {
    errorRate: number;
    p99Latency: number;
    successRate: number;
  };
}
 
// ❌ All-or-nothing deployment — no safety net
async function riskyDeploy(version: string): Promise<void> {
  await deployToAllInstances(version);
  // If it's broken, ALL users are affected immediately
}
 
// ✅ Gradual canary rollout with automatic rollback
class CanaryDeployer {
  constructor(
    private readonly config: CanaryConfig,
    private readonly router: TrafficRouter,
    private readonly metrics: MetricsCollector
  ) {}
 
  async deploy(newVersion: string): Promise<boolean> {
    // Deploy canary instances with new version
    await this.deployCanaryInstances(newVersion);
 
    // Route initial traffic percentage
    await this.router.setCanaryWeight(this.config.initialPercentage);
    console.log(`Canary started at ${this.config.initialPercentage}% traffic`);
 
    // Gradually increase traffic
    for (const percentage of this.config.steps) {
      await this.wait(this.config.stepDurationMs);
 
      const healthy = await this.checkCanaryHealth();
      if (!healthy) {
        console.error(`Canary unhealthy at ${percentage}% — rolling back`);
        await this.rollback();
        return false;
      }
 
      await this.router.setCanaryWeight(percentage);
      console.log(`Canary promoted to ${percentage}% traffic`);
    }
 
    // Full promotion
    await this.promoteCanary(newVersion);
    console.log("Canary fully promoted");
    return true;
  }
 
  private async checkCanaryHealth(): Promise<boolean> {
    const canaryMetrics = await this.metrics.getCanaryMetrics();
    const baselineMetrics = await this.metrics.getBaselineMetrics();
    const thresholds = this.config.rollbackThresholds;
 
    if (canaryMetrics.errorRate > thresholds.errorRate) {
      console.error(
        `Error rate ${canaryMetrics.errorRate}% exceeds threshold ${thresholds.errorRate}%`
      );
      return false;
    }
 
    if (canaryMetrics.p99Latency > thresholds.p99Latency) {
      console.error(
        `P99 latency ${canaryMetrics.p99Latency}ms exceeds threshold ${thresholds.p99Latency}ms`
      );
      return false;
    }
 
    return true;
  }
 
  private async rollback(): Promise<void> {
    await this.router.setCanaryWeight(0);
    await this.destroyCanaryInstances();
  }
 
  private async promoteCanary(version: string): Promise<void> {
    await this.deployToAllInstances(version);
    await this.router.setCanaryWeight(0);
    await this.destroyCanaryInstances();
  }
}

Los releases Canary limitan el radio de impacto. Si la nueva versión tiene un error que afecta al 1% de las solicitudes, solo ese porcentaje de usuarios se ve afectado durante el período de observación.

Configuración de Rolling Update en Kubernetes

Kubernetes admite de forma nativa las actualizaciones Rolling Update. La configuración controla con qué agresividad los pods nuevos reemplazan a los antiguos y qué verificaciones de estado deben superarse antes de continuar.

ymlyaml
# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: api-server
spec:
  replicas: 6
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxUnavailable: 1    # At most 1 pod down during update
      maxSurge: 2           # At most 2 extra pods during update
  template:
    spec:
      containers:
        - name: api
          image: registry.example.com/api:v2.3.0
          ports:
            - containerPort: 3000
          readinessProbe:
            httpGet:
              path: /health/ready
              port: 3000
            initialDelaySeconds: 5
            periodSeconds: 10
            failureThreshold: 3
          livenessProbe:
            httpGet:
              path: /health/live
              port: 3000
            initialDelaySeconds: 15
            periodSeconds: 20
            failureThreshold: 3
          lifecycle:
            preStop:
              exec:
                command: ["/bin/sh", "-c", "sleep 15"]
tstypescript
// Health check endpoint implementation
import express from "express";
 
const app = express();
 
let isReady = false;
let isShuttingDown = false;
 
// Readiness probe — am I ready to receive traffic?
app.get("/health/ready", (req, res) => {
  if (isReady && !isShuttingDown) {
    res.status(200).json({ status: "ready" });
  } else {
    res.status(503).json({ status: "not-ready" });
  }
});
 
// Liveness probe — am I still alive?
app.get("/health/live", (req, res) => {
  res.status(200).json({ status: "alive" });
});
 
// Graceful shutdown
process.on("SIGTERM", () => {
  console.log("SIGTERM received — starting graceful shutdown");
  isShuttingDown = true;
 
  // Stop accepting new requests
  isReady = false;
 
  // Allow in-flight requests to complete
  setTimeout(() => {
    process.exit(0);
  }, 30000);
});
 
// Startup initialization
async function initialize(): Promise<void> {
  await connectToDatabase();
  await warmCaches();
  isReady = true;
  console.log("Application ready to serve traffic");
}

El hook preStop introduce un retraso antes de que el contenedor reciba la señal SIGTERM, lo que le da tiempo al balanceador de carga para dejar de enrutar tráfico hacia el pod. Sin esto, pueden llegar solicitudes a un pod que ya se está apagando.

Estrategia de migración de bases de datos para despliegues sin tiempo de inactividad

Los despliegues de código son sencillos. Las migraciones de bases de datos son donde los despliegues sin tiempo de inactividad se complican. La regla es: toda migración debe ser retrocompatible con la versión de código anterior.

tstypescript
// Migration strategy: expand-then-contract
 
// Step 1: EXPAND — Add new column (backward compatible)
// Deploy migration while old code is still running
const expandMigration = `
  ALTER TABLE users ADD COLUMN display_name VARCHAR(255);
  -- Old code ignores this column, new code can start using it
`;
 
// Step 2: MIGRATE DATA — Backfill the new column
const dataMigration = `
  UPDATE users SET display_name = name WHERE display_name IS NULL;
  -- Run in batches for large tables
`;
 
// Step 3: Deploy new code that reads from display_name
// Both old and new code work during rolling update
 
// Step 4: CONTRACT — Remove old column (after all instances run new code)
const contractMigration = `
  ALTER TABLE users DROP COLUMN name;
  -- Only safe after all old code instances are gone
`;
 
// Implementation pattern for dual-read during transition
interface UserRepository {
  getDisplayName(userId: string): Promise<string>;
}
 
class TransitionalUserRepository implements UserRepository {
  async getDisplayName(userId: string): Promise<string> {
    const row = await db.query(
      "SELECT display_name, name FROM users WHERE id = $1",
      [userId]
    );
    // Read new column first, fall back to old
    return row.display_name || row.name;
  }
}

Disparadores de rollback automatizado

El rollback manual es lento. El rollback automatizado basado en métricas detecta problemas más rápido que la observación humana.

tstypescript
interface RollbackPolicy {
  metric: string;
  threshold: number;
  comparison: "gt" | "lt";
  windowSeconds: number;
  consecutive: number;
}
 
const rollbackPolicies: RollbackPolicy[] = [
  {
    metric: "http_error_rate_5xx",
    threshold: 5,
    comparison: "gt",
    windowSeconds: 60,
    consecutive: 3,
  },
  {
    metric: "http_latency_p99_ms",
    threshold: 2000,
    comparison: "gt",
    windowSeconds: 120,
    consecutive: 2,
  },
  {
    metric: "health_check_success_rate",
    threshold: 95,
    comparison: "lt",
    windowSeconds: 30,
    consecutive: 3,
  },
];
 
class AutomatedRollbackMonitor {
  private violations = new Map<string, number>();
 
  async evaluate(policies: RollbackPolicy[]): Promise<boolean> {
    for (const policy of policies) {
      const value = await this.getMetricValue(
        policy.metric,
        policy.windowSeconds
      );
 
      const violated =
        policy.comparison === "gt"
          ? value > policy.threshold
          : value < policy.threshold;
 
      const key = policy.metric;
      const count = violated
        ? (this.violations.get(key) ?? 0) + 1
        : 0;
      this.violations.set(key, count);
 
      if (count >= policy.consecutive) {
        console.error(
          `Rollback triggered: ${policy.metric} = ${value} (threshold: ${policy.threshold}) for ${count} consecutive checks`
        );
        return true;
      }
    }
    return false;
  }
 
  private async getMetricValue(
    metric: string,
    windowSeconds: number
  ): Promise<number> {
    // Query metrics backend (Prometheus, Datadog, etc.)
    return 0;
  }
}

Conclusiones clave

El despliegue sin tiempo de inactividad es alcanzable con las estrategias Blue-Green, Canary y Rolling Update. Blue-Green ofrece rollback instantáneo mediante el cambio de entorno. Los releases Canary limitan el radio de impacto trasladando el tráfico de forma gradual. Las actualizaciones Rolling Update son nativas de Kubernetes y funcionan bien con verificaciones de estado adecuadas.

Las migraciones de bases de datos deben ser retrocompatibles: use el patrón de expandir y luego contraer para desacoplar los cambios de esquema de los despliegues de código. Implemente un cierre controlado en sus aplicaciones para que las solicitudes en curso se completen antes de que el proceso finalice. Automatice los disparadores de rollback basados en tasas de error y umbrales de latencia para que los problemas se detecten en segundos, no en minutos.

El objetivo es hacer que el despliegue sea aburrido. Cuando desplegar es seguro, rápido y reversible, los equipos despliegan con más frecuencia y con cambios más pequeños, y los cambios más pequeños son inherentemente menos riesgosos.

Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX