StackPractices
intermediate Por Mathias Paulenko

Patrón Canary Release

Rutear un porcentaje pequeno de trafico a la nueva version mientras el resto se queda en estable. Monitorear salud y aumentar gradualmente o revertir segun resultados.

Descripción general

El patrón Canary Release rutear un porcentaje pequeno de trafico a una nueva version mientras la mayoria continua recibiendo la version estable. El nombre viene de los mineros de carbon que llevaban canarios para detectar gases peligrosos: el canario servia como sistema de alerta temprana. En software, la version canario sirve primero a un grupo pequeno de usuarios. Si falla, solo ese grupo pequeno se ve afectado.

El patron funciona en etapas: empieza en 1%, observa tasas de error y latencia, luego aumenta a 5%, 10%, 25%, 50% y 100%. En cada etapa, las metricas de salud determinan si proceder o revertir. A diferencia de blue-green (que cambia todo el trafico de una vez), canary release cambia el trafico gradualmente, dandote tiempo para detectar problemas que solo aparecen bajo carga real de usuarios.

Cuándo usarlo

Usa el patrón Canary Release cuando:

  • Quieres validar una nueva version con usuarios reales antes del rollout completo
  • Los problemas pueden solo aparecer bajo trafico de produccion (no detectados por tests de staging)
  • Necesitas control mas fino que el switch todo-o-nada de blue-green
  • Tu infraestructura soporta traffic splitting (pesos de load balancer, service mesh)
  • Ejemplos: versionado de API, rediseños de UI, cambios performance-sensitive, cambios de queries de BD

Solución

Python

from dataclasses import dataclass
from typing import Callable, List, Optional, Dict
from enum import Enum
import time

class CanaryStatus(Enum):
    PENDING = "pending"
    RUNNING = "running"
    PROMOTED = "promoted"
    ROLLED_BACK = "rolled_back"

@dataclass
class HealthMetrics:
    error_rate: float
    p95_latency_ms: float
    success_rate: float

    def is_healthy(self, max_error_rate: float = 0.02, max_p95: float = 300) -> bool:
        return self.error_rate <= max_error_rate and self.p95_latency_ms <= max_p95

@dataclass
class CanaryStage:
    percentage: float
    bake_time_seconds: float
    passed: bool = False

class CanaryRelease:
    def __init__(self, version: str, stable_version: str,
                 health_check_fn: Callable[[], HealthMetrics],
                 max_error_rate: float = 0.02, max_p95: float = 300):
        self.version = version
        self.stable_version = stable_version
        self.health_check_fn = health_check_fn
        self.max_error_rate = max_error_rate
        self.max_p95 = max_p95
        self.status = CanaryStatus.PENDING
        self.current_stage_idx = -1
        self.stages = [
            CanaryStage(1.0, 0.3), CanaryStage(5.0, 0.3),
            CanaryStage(25.0, 0.3), CanaryStage(50.0, 0.3),
            CanaryStage(100.0, 0.3),
        ]

    def _run_stage(self, stage: CanaryStage) -> bool:
        self.current_stage_idx += 1
        self.status = CanaryStatus.RUNNING
        print(f"  Canary at {stage.percentage}% -> stable={100 - stage.percentage}%")
        time.sleep(stage.bake_time_seconds)
        metrics = self.health_check_fn()
        stage.passed = metrics.is_healthy(self.max_error_rate, self.max_p95)
        print(f"    error_rate={metrics.error_rate:.2%}, p95={metrics.p95_latency_ms}ms")
        if stage.passed:
            print(f"    PASS - advancing")
            return True
        print(f"    FAIL - rolling back")
        self.status = CanaryStatus.ROLLED_BACK
        return False

    def execute(self) -> Dict:
        print(f"\n=== Canary Release: v{self.version} (stable: v{self.stable_version}) ===")
        for i, stage in enumerate(self.stages):
            if not self._run_stage(stage):
                return {"version": self.version, "status": self.status.value,
                        "rolled_back": True, "failed_stage": i,
                        "max_percentage": self.stages[self.current_stage_idx].percentage}
        self.status = CanaryStatus.PROMOTED
        print(f"\nCanary promoted. v{self.version} is now at 100%.")
        return {"version": self.version, "status": self.status.value,
                "rolled_back": False, "stages_passed": len(self.stages)}

# Uso
call_count = 0
def mock_health_check() -> HealthMetrics:
    global call_count
    call_count += 1
    if call_count == 3:
        return HealthMetrics(error_rate=0.05, p95_latency_ms=400, success_rate=0.95)
    return HealthMetrics(error_rate=0.005, p95_latency_ms=80, success_rate=0.995)

canary = CanaryRelease("3.0.0", "2.9.0", mock_health_check, 0.02, 300)
print(f"\nResult: {canary.execute()}")

JavaScript

class CanaryRelease {
  constructor(version, stableVersion, healthCheckFn, options = {}) {
    this.version = version;
    this.stableVersion = stableVersion;
    this.healthCheckFn = healthCheckFn;
    this.maxErrorRate = options.maxErrorRate ?? 0.02;
    this.maxP95 = options.maxP95 ?? 300;
    this.status = "pending";
    this.currentStageIdx = -1;
    this.stages = [
      { percentage: 1.0, bakeTimeMs: 50 }, { percentage: 5.0, bakeTimeMs: 50 },
      { percentage: 25.0, bakeTimeMs: 50 }, { percentage: 50.0, bakeTimeMs: 50 },
      { percentage: 100.0, bakeTimeMs: 50 },
    ];
  }

  async _runStage(stage) {
    this.currentStageIdx++;
    this.status = "running";
    console.log(`  Canary at ${stage.percentage}% -> stable=${100 - stage.percentage}%`);
    await new Promise(r => setTimeout(r, stage.bakeTimeMs));
    const m = this.healthCheckFn();
    const healthy = m.errorRate <= this.maxErrorRate && m.p95LatencyMs <= this.maxP95;
    console.log(`    error_rate=${(m.errorRate * 100).toFixed(2)}%, p95=${m.p95LatencyMs}ms`);
    if (healthy) { console.log("    PASS - advancing"); return true; }
    console.log("    FAIL - rolling back"); this.status = "rolled_back"; return false;
  }

  async execute() {
    console.log(`\n=== Canary Release: v${this.version} (stable: v${this.stableVersion}) ===`);
    for (let i = 0; i < this.stages.length; i++) {
      if (!await this._runStage(this.stages[i])) {
        return { version: this.version, status: this.status, rolledBack: true, failedStage: i,
                 maxPercentage: this.stages[this.currentStageIdx].percentage };
      }
    }
    this.status = "promoted";
    console.log(`\nCanary promoted. v${this.version} is now at 100%.`);
    return { version: this.version, status: this.status, rolledBack: false, stagesPassed: this.stages.length };
  }
}

// Uso
let callCount = 0;
const mockHealthCheck = () => {
  callCount++;
  if (callCount === 3) return { errorRate: 0.05, p95LatencyMs: 400, successRate: 0.95 };
  return { errorRate: 0.005, p95LatencyMs: 80, successRate: 0.995 };
};

(async () => {
  const canary = new CanaryRelease("3.0.0", "2.9.0", mockHealthCheck, { maxErrorRate: 0.02, maxP95: 300 });
  console.log("\nResult:", await canary.execute());
})();

Java

import java.util.*;

public class CanaryRelease {

    record HealthMetrics(double errorRate, double p95LatencyMs, double successRate) {
        boolean isHealthy(double maxError, double maxP95) {
            return errorRate <= maxError && p95LatencyMs <= maxP95;
        }
    }

    record Stage(double percentage, long bakeTimeMs) {}

    private final String version, stableVersion;
    private final java.util.function.Supplier<HealthMetrics> healthCheckFn;
    private final double maxErrorRate, maxP95;
    private String status = "pending";
    private int currentStageIdx = -1;
    private final List<Stage> stages = List.of(
        new Stage(1.0, 50), new Stage(5.0, 50), new Stage(25.0, 50),
        new Stage(50.0, 50), new Stage(100.0, 50)
    );

    public CanaryRelease(String version, String stableVersion,
                         java.util.function.Supplier<HealthMetrics> fn, double maxError, double maxP95) {
        this.version = version; this.stableVersion = stableVersion;
        this.healthCheckFn = fn; this.maxErrorRate = maxError; this.maxP95 = maxP95;
    }

    private boolean runStage(Stage stage) {
        currentStageIdx++;
        status = "running";
        System.out.printf("  Canary at %.0f%% -> stable=%.0f%%%n", stage.percentage(), 100 - stage.percentage());
        try { Thread.sleep(stage.bakeTimeMs()); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
        HealthMetrics m = healthCheckFn.get();
        boolean healthy = m.isHealthy(maxErrorRate, maxP95);
        System.out.printf("    error_rate=%.2f%%, p95=%.0fms%n", m.errorRate() * 100, m.p95LatencyMs());
        if (healthy) { System.out.println("    PASS - advancing"); return true; }
        System.out.println("    FAIL - rolling back"); status = "rolled_back"; return false;
    }

    public Map<String, Object> execute() {
        System.out.printf("%n=== Canary Release: v%s (stable: v%s) ===%n", version, stableVersion);
        for (int i = 0; i < stages.size(); i++) {
            if (!runStage(stages.get(i))) {
                return Map.of("version", version, "status", status, "rolledBack", true,
                    "failedStage", i, "maxPercentage", stages.get(currentStageIdx).percentage());
            }
        }
        status = "promoted";
        System.out.printf("%nCanary promoted. v%s is now at 100%%.%n", version);
        return Map.of("version", version, "status", status, "rolledBack", false, "stagesPassed", stages.size());
    }

    public static void main(String[] args) {
        int[] callCount = {0};
        var healthCheck = (java.util.function.Supplier<HealthMetrics>) () -> {
            callCount[0]++;
            if (callCount[0] == 3) return new HealthMetrics(0.05, 400, 0.95);
            return new HealthMetrics(0.005, 80, 0.995);
        };
        System.out.println("\nResult: " + new CanaryRelease("3.0.0", "2.9.0", healthCheck, 0.02, 300).execute());
    }
}

Explicación

El canary release procede a traves de etapas predefinidas:

  1. Rutear un porcentaje: Configura el load balancer o service mesh para enviar un porcentaje pequeno de trafico a la version canary. El resto va a la version estable. Ambas versiones corren simultaneamente.
  2. Bake y medir: Espera el bake time para acumular trafico, luego recolecta metricas de salud del canary. Compara tasa de error, latencia y metricas de negocio contra umbrales.
  3. Avanzar o revertir: Si las metricas estan dentro de umbrales, aumenta el porcentaje del canary a la siguiente etapa. Si las metricas se degradan, rutea todo el trafico de vuelta a la version estable y detiene el canary.
  4. Promover: Cuando el canary llega a 100% y las metricas son saludables, el canary se convierte en la nueva version estable. La version estable anterior se puede decomisionar.

Variantes

VarianteDescripciónCaso de uso
Canary por usuarioRutear usuarios especificos (equipo interno, beta users) al canaryTesting controlado con usuarios conocidos
Canary por headerRutear por header de request (cookie, custom header)Testing con clientes o regiones especificas
Canary shadowEnviar trafico al canary pero descartar respuestasTestear bajo carga real sin impacto al usuario
Canary auto-promoteAvanzar etapas automaticamente si las metricas pasanCambios de bajo riesgo con monitoreo fuerte

Buenas prácticas

  • Empieza en 1% o menos para minimizar el blast radius
  • Monitorea tanto metricas tecnicas como de negocio (tasa de error, latencia, conversion)
  • Define bake times suficientemente largos para data estadisticamente significativa
  • Define umbrales de auto-rollback antes de empezar el canary
  • Usa sticky sessions para que un usuario se quede en la misma version durante el canary
  • Mantén la version estable corriendo hasta que el canary llegue a 100% y se confirme saludable

Errores comunes

  • Empezar con un porcentaje demasiado alto (10% o mas), anulando el proposito de alerta temprana
  • No monitorear metricas de negocio, solo verificar tasas de error
  • Bake times demasiado cortos, avanzando antes de que acumule suficiente data
  • Sin auto-rollback, requiriendo intervencion manual durante incidentes
  • Usuarios rebotando entre versiones por falta de sticky sessions
  • Decomisionar la version estable antes de que el canary se promueva completamente

Notas de Producción

  • Despliega gradualmente usando canary o blue-green para detectar regresiones temprano.
  • Configura alertas para errores, latencia p99 y tasa de fallos antes de habilitar en producción.
  • Documenta el rollback en el runbook; prueba el procedimiento en staging al menos una vez por trimestre.
  • Revisa logs estructurados con correlation IDs para trazar requests end-to-end en incidentes.

Puntos Clave

  • Aplica patrón canary release cuando necesites una solución práctica para tu caso de uso.
  • Monitorea el rendimiento después de implementar; mide latencia, errores y uso de recursos antes y después.
  • Revisa la sección de Troubleshooting ante errores comunes; la mayoría tienen causa raíz documentada con solución.
  • Mantén dependencias actualizadas y ejecuta tests en CI para prevenir regresiones en producción.

Troubleshooting

  • Pipeline fails silently: enable verbose logging and store pipeline artifacts between stages so you can inspect the exact state that failed.
  • Container crashes on startup: check that environment variables, secrets, and config files are mounted correctly. Read the first 50 lines of logs before scaling replicas.
  • Deployment rolls back repeatedly: verify health checks, resource limits, and startup probes. A failing readiness probe is a common cause of rolling restarts.
  • Slow CI builds: cache dependencies and docker layers. Split large test suites into parallel jobs to reduce wall-clock time.
  • Drift between environments: use infrastructure-as-code and immutable artifacts.

Errores Comunes en Producción

  • Aplicar el patrón donde no se necesita abstracción, agregando complejidad accidental.
  • Dejar que el patrón se filtre en módulos no relacionados y confundir los límites de responsabilidad.
  • Sobre-ingeniería en la primera implementación en lugar de comenzar simple y medir el dolor.
  • Saltar los tests de contrato, de modo que las refactorizaciones rompan consumidores en silencio.
  • Ignorar modos de fallo que el patrón no cubre.
  • Usar el patrón como opción por defecto en lugar de elegir la herramienta adecuada para la escala actual.
  • Olvidar documentar cuándo dejar de usar el patrón y qué lo reemplaza.
  • Carecer de observabilidad sobre rendimiento y propagación de errores del patrón.

Preguntas frecuentes

Como se diferencia canary release de blue-green deployment?

Blue-green cambia 100% del trafico entre dos entornos instantaneamente. Canary gradualmente cambia un porcentaje de trafico a la nueva version mientras la vieja continua sirviendo el resto. Canary da control mas granular y deteccion mas temprana.

Con que porcentaje debo empezar?

1% es el estandar. Para servicios de alto trafico, incluso 1% representa miles de usuarios. Para servicios de bajo trafico, quizas necesites un porcentaje mas alto para obtener data significativa en un bake time razonable.

Como ruteo un porcentaje de trafico al canary?

Usa pesos de load balancer (e.g., AWS ALB weighted routing), reglas de service mesh (Istio, Linkerd), feature flags (LaunchDarkly, Unleash), o traffic splitting de API gateway. Todos te permiten ajustar el porcentaje sin redesplegar.

Que pasa si el canary pasa metricas tecnicas pero los usuarios reportan problemas?

Incluye metricas de usuario en tu health check: tasa de tickets de soporte, errores reportados por usuarios, duracion de sesion, bounce rate. Las metricas tecnicas detectan crashes, pero las metricas de usuario detectan regresiones de UX que no aparecen como errores.