Módulo 3: El patrón strangler fig
Desviar el tráfico por porcentaje
Descripción
Tienes el facade puesto (lección 2) y el servicio nuevo construido al lado (lección 3). Las dos piezas están en su lugar, esperando. Esta lección gira la perilla: desviar el tráfico por porcentaje. Es la fase que le da vida al strangler fig —el momento en que el tráfico real empieza a moverse del legacy al modern, controlado por un solo número, el traffic_percent—. Empiezas en 0 (todo al viejo), subes a 10 (un canary), a 50 (mitad y mitad), a 100 (todo al nuevo), observando en cada nivel antes de subir al siguiente.
La idea de subir por escalones —y no saltar del 0 al 100— es el corazón de la seguridad del patrón. A esto se le llama un canary release: mandas primero una fracción pequeña del tráfico al nuevo, como los mineros bajaban un canario a la mina para detectar gas antes de arriesgar a las personas. Si el canario canta, todo bien, subes el porcentaje. Si el canario se cae, solo perdiste al canario —una fracción pequeña del tráfico—, no la mina entera. Desviar el 10% primero significa que, si el modern tiene un problema, solo el 10% de los usuarios lo ve, y tú te enteras con tiempo de arreglarlo antes de exponer al resto.
Y hay una segunda pieza que hace el desvío verdaderamente seguro: el fallback. Si el modern lanza un error al procesar una request, el router no le devuelve el error al usuario —lo manda a la vieja ruta, al legacy, que sigue ahí y sigue funcionando—. El usuario recibe una respuesta correcta (la del legacy), aunque el nuevo haya fallado. El fallback convierte "el modern falló" de un incidente visible a un evento invisible que solo tú, en tus métricas, registras. Esta lección lo ejecuta y descubre algo revelador: aun a 100% de tráfico "en el nuevo", los casos que el modern todavía no maneja siguen cayendo al legacy por el fallback —y eso te dice, con números y sin drama, que el modern aún no está completo—.
Conexión con el módulo. Las lecciones 2 y 3 dejaron el facade y el modern listos; esta hace la fase 3, el desvío por porcentaje con fallback, que es el núcleo del strangler. La lección 5 abre las formas de decidir el desvío (por ruta, por porcentaje, por feature flag); la 6 profundiza en el fallback y en la observabilidad de las dos rutas para decidir cuándo subir; y la 7 lleva el porcentaje a 100 y retira el legacy. Fíjate en la frontera: aquí el desvío es por porcentaje del tráfico (el bucket de la request decide). Desviar por endpoint o por usuario son otras estrategias que compara la lección 5; y decidir cuándo es seguro subir el porcentaje —con qué métricas— es la lección 6. Aquí construimos el mecanismo del desvío por porcentaje y el fallback que lo protege.
Una analogía: abrir la llave de a poco, con el balde viejo debajo
Imagina que reemplazas la tubería de agua de una casa. La tubería vieja funciona; la nueva la acabas de instalar pero no confías del todo en ella. No abres la llave de la tubería nueva al máximo de golpe —si tiene una fuga, inundas la casa—. Abres la llave de a poco: primero dejas pasar un chorrito, el 10% del agua, y miras si hay fugas. Si no hay, abres a la mitad. Miras otra vez. Abres al máximo. Solo cuando toda el agua pasa por la tubería nueva sin una gota fuera de lugar, cierras la vieja.
Y por si acaso, mientras haces esto, dejas la tubería vieja conectada como respaldo: si la nueva empieza a gotear en un punto, una válvula desvía el agua de vuelta por la vieja mientras arreglas. Nadie se queda sin agua ni un segundo. La casa siempre tiene agua corriendo; lo único que cambia es por cuál tubería.
El traffic_percent es cuánto abres la llave de la tubería nueva: 0, 10, 50, 100. El canary es abrir primero solo un chorrito para detectar fugas con poco riesgo. Y el fallback es la tubería vieja conectada como respaldo: si el agua no puede pasar por la nueva (el modern lanza un error), la válvula la manda por la vieja (el legacy) y la casa nunca se queda seca. Abrir la llave de a poco, con el respaldo conectado, es exactamente esta fase del strangler.
Ejemplo trabajado: el ramp 0→10→50→100 con fallback
Vamos a ejecutar el desvío completo. Tenemos el legacy_catalog —sólido, maneja todos los casos— y el modern_catalog —nuevo, rápido, pero con un bug: aún no maneja el caso "sin stock" y lanza un error cuando la query es webcam—. El strangler_router enruta por porcentaje usando el bucket estable, y —clave— envuelve la llamada al modern en un try/except: si el modern lanza cualquier error, cae al legacy y lo cuenta como un fallback. Corremos un lote fijo de 1000 requests, donde 1 de cada 10 consulta webcam (el caso con bug), y subimos el traffic_percent por los escalones 0, 10, 50, 100, midiendo cuántas fueron por cada camino.
import zlib
# === Legacy: solido, maneja todos los casos (incluido stock 0). ===
def legacy_catalog(request):
return {"status": 200, "source": "legacy", "product": request["q"]}
# === Modern: nuevo, rapido... pero AUN tiene un bug en el caso "sin stock". ===
def modern_catalog(request):
if request["q"] == "webcam": # ruta no implementada todavia
raise ValueError("modern: unhandled out-of-stock path")
return {"status": 200, "source": "modern", "product": request["q"]}
# === El strangler router: enruta por porcentaje, con FALLBACK al legacy. ===
def bucket(request_id):
return zlib.crc32(str(request_id).encode()) % 100
def strangler_router(request, traffic_percent, counters):
if bucket(request["id"]) < traffic_percent:
try:
resp = modern_catalog(request) # intenta la ruta nueva
counters["modern_ok"] += 1
return resp
except Exception:
counters["fallback"] += 1 # el nuevo fallo: cae al viejo
return legacy_catalog(request) # la vieja ruta es la red
counters["legacy"] += 1
return legacy_catalog(request)
# --- Un lote fijo: 1 de cada 10 requests consulta "webcam" (el caso con bug). ---
N = 1000
requests = [{"id": i, "q": "webcam" if i % 10 == 0 else "ssd"} for i in range(1, N + 1)]
print(f"Subiendo traffic_percent 0 -> 10 -> 50 -> 100 ({N} requests por nivel)\n")
print(f"{'traffic':>8}{'modern_ok':>11}{'fallback':>10}{'legacy':>8}"
f"{'servidas x legacy':>19}")
print("-" * 56)
for pct in (0, 10, 50, 100):
counters = {"modern_ok": 0, "fallback": 0, "legacy": 0}
for req in requests:
strangler_router(req, pct, counters)
served_by_legacy = counters["legacy"] + counters["fallback"]
print(f"{pct:>7}%{counters['modern_ok']:>11}{counters['fallback']:>10}"
f"{counters['legacy']:>8}{served_by_legacy:>19}")
print("-" * 56)
print("\n A 100%: modern sirve la mayoria, pero las ~100 requests de 'webcam'")
print(" siguen cayendo al legacy por el FALLBACK -> el nuevo aun NO esta listo")
print(" para retirar el legacy. El fallback te protege Y te avisa.")
Qué esperar. Al correr el archivo, la salida es exactamente esta:
Subiendo traffic_percent 0 -> 10 -> 50 -> 100 (1000 requests por nivel)
traffic modern_ok fallback legacy servidas x legacy
--------------------------------------------------------
0% 0 0 1000 1000
10% 99 10 891 901
50% 471 49 480 529
100% 900 100 0 100
--------------------------------------------------------
A 100%: modern sirve la mayoria, pero las ~100 requests de 'webcam'
siguen cayendo al legacy por el FALLBACK -> el nuevo aun NO esta listo
para retirar el legacy. El fallback te protege Y te avisa.
Lee la tabla escalón por escalón, porque cada renglón cuenta una parte de la historia.
En 0%, las 1000 requests van al legacy (legacy=1000), cero al modern. La llave está cerrada; toda el agua pasa por la tubería vieja. modern_ok y fallback en cero: el modern ni se toca.
En 10%, el bucket manda 109 requests a la ruta del modern. De esas, 99 tienen éxito (modern_ok=99) y 10 caen en fallback —son las que consultaban webcam, el caso que el modern no maneja, y que el try/except desvió al legacy—. Las otras 891 nunca fueron seleccionadas para el modern y fueron directo al legacy. Fíjate en algo importante: esas 10 requests de webcam no fueron un error para el usuario. El modern falló, sí, pero el fallback las atendió con el legacy, así que el usuario recibió su respuesta correcta. El fallo del modern quedó invisible para el cliente y visible solo en tu contador de fallback.
En 50%, el bucket selecciona ~520 requests para el modern; 471 tienen éxito y 49 caen en fallback (las de webcam que cayeron en la mitad seleccionada). Y en 100%, el bucket selecciona todas las 1000: 900 tienen éxito en el modern y 100 caen en fallback —las 100 requests de webcam de todo el lote—. La columna legacy (directo, no por fallback) llega a 0: ninguna request fue elegida para el legacy.
Aquí está la revelación de la lección, en la columna servidas x legacy (que suma legacy + fallback). A 100%, esa columna marca 100, no 0. Aunque desviaste "todo el tráfico al nuevo", 100 requests siguieron siendo atendidas por el legacy —vía fallback, porque el modern no las supo manejar—. Esto es información valiosísima: te dice, con números y sin que ningún usuario se haya visto afectado, que el modern todavía no está completo. No puedes retirar el legacy: si lo apagaras ahora, esas 100 requests de webcam no tendrían quién las atienda. El fallback hizo dos cosas a la vez: te protegió (ningún usuario vio un error) y te avisó (el modern tiene un hueco en el caso webcam). Cuando arregles ese caso, el fallback a 100% bajará a 0, y entonces —y solo entonces— podrás retirar el legacy. Esa es la lección 7.
Profundización: el fallback como red y como detector
El fallback tiene dos funciones, y es fácil ver solo la primera. La obvia es la red de seguridad: si el modern falla, el usuario no se queda sin respuesta. La menos obvia, y quizás más valiosa, es que el fallback es un detector de completitud: cada request que cae en fallback es una prueba, tomada del tráfico real, de que el modern tiene un caso que aún no maneja. El burn-down de fallbacks hacia cero es tu medida de qué tan listo está el modern para reemplazar por completo al legacy.
request
│
┌───────┴────────┐
bucket < percent? no ──> legacy_catalog (elegida para el viejo)
│ si
▼
modern_catalog
│
┌────┴─────┐
ok error
│ │
respuesta FALLBACK ──> legacy_catalog (el nuevo fallo; la red atrapa)
del modern (+cuenta el fallback como senial de "modern incompleto")
Un detalle de diseño importante: el fallback atrapa el error después de intentar el modern, lo que significa que la request paga el costo de intentar el modern y el costo del legacy. Es más lento que ir directo al legacy. Ese sobrecosto de latencia es aceptable porque es temporal (desaparece cuando arreglas el modern) y raro (solo ocurre en los casos que fallan), pero es real, y la lección 6 lo mide. La regla es: el fallback es para errores inesperados del modern, no para casos que sabes que el modern no maneja. Si sabes que el modern no maneja webcam, lo mejor no es dejar que falle y caiga en fallback cada vez —es no enrutar webcam al modern en primer lugar (una decisión por ruta, lección 5)—. El fallback es la red para lo que no viste venir; no un sustituto de completar el modern.
Hay una decisión sutil que el ejemplo esconde: ¿qué cuenta como "error" que dispara el fallback? En el ejemplo, cualquier excepción. En producción, la línea es más fina: un error 500 del modern claramente dispara fallback; pero ¿un timeout? ¿una respuesta 404 legítima (el producto no existe)? Un 404 no es un fallo del modern —es una respuesta correcta— y no debería disparar fallback, o enmascararías respuestas válidas. Definir qué es un fallo que merece fallback es parte de diseñar el router, y se afina con la observabilidad de la lección 6.
Errores comunes
Saltar del 0% al 100% sin canary. Qué pasa: confiado en que el modern "ya está probado", el equipo pone el traffic_percent en 100 de un solo cambio. Por qué pasa: los escalones intermedios se sienten lentos y burocráticos —"si funciona, funciona"—. Cómo detectarlo: no hay un periodo de 10% ni de 50% en el historial; el tráfico pasó de todo-viejo a todo-nuevo en un despliegue. Cómo corregirlo: el punto entero del canary es descubrir los problemas del modern con poco tráfico expuesto. A 10%, un bug del modern afecta al 10% de los usuarios y te da tiempo de reaccionar; a 100%, afecta a todos a la vez, que es exactamente el big-bang que el strangler existe para evitar. Sube por escalones, observa en cada uno (lección 6), y solo sube al siguiente cuando el actual esté sano. La velocidad de subir no es una virtud; la seguridad de subir sí.
Desviar tráfico sin fallback. Qué pasa: el router manda el porcentaje al modern y, si el modern falla, le devuelve el error al usuario. Por qué pasa: el fallback es código extra (el try/except, la segunda llamada) y se omite "para simplificar". Cómo detectarlo: cuando el modern falla en producción, aparecen errores 500 visibles para los usuarios en la fracción de tráfico desviada. Cómo corregirlo: el fallback no es opcional en un strangler —es lo que hace que desviar tráfico sea seguro—. Sin fallback, cada request que mandas al modern es una apuesta sin red: si el modern tiene un caso no manejado, el usuario ve el error. Con fallback, el peor caso es una respuesta del legacy (correcta, quizás más lenta) y un contador que se incrementa. El costo de escribir el try/except es minúsculo comparado con el costo de exponer los bugs del modern a los usuarios reales.
Confundir "100% de tráfico enrutado" con "el modern está completo". Qué pasa: el equipo ve el traffic_percent en 100 y declara la migración terminada, lista para retirar el legacy. Por qué pasa: "100%" suena a final. Cómo detectarlo: aunque el traffic_percent sea 100, la columna de fallback (o servidas x legacy) no es 0 —como en el ejemplo, donde a 100% aún caían 100 requests al legacy—. Cómo corregirlo: el criterio para retirar el legacy no es "el traffic_percent llegó a 100", es "el legacy ya no atiende una sola request, ni siquiera por fallback". Mientras el fallback sea mayor que 0 a 100%, el modern tiene huecos que el legacy está tapando, y apagar el legacy dejaría esas requests sin atender. El traffic_percent mide lo que intentaste; el fallback mide lo que el modern no pudo. Solo cuando el segundo es 0 puedes retirar el legacy (lección 7).
Ejercicios
Ejercicio 1 — Lee el reparto con fallback. En la salida, a traffic_percent=10 hubo modern_ok=99, fallback=10, legacy=891. (a) ¿Cuántas requests fueron enrutadas al modern en total? (b) ¿Por qué 10 de ellas terminaron en el legacy? (c) Si el modern arreglara el caso webcam, ¿cuánto valdría fallback a 10%, y cuánto modern_ok?
Ver solución
(a) Fueron enrutadas al modern 109 requests: las que tuvieron éxito (modern_ok=99) más las que cayeron en fallback tras intentar el modern (fallback=10). El bucket seleccionó 109 para la ruta nueva; de esas, 99 el modern las resolvió y 10 fallaron. 99 + 10 = 109, aproximadamente el 10% de 1000.
(b) Porque esas 10 requests consultaban webcam, el caso que el modern no maneja (lanza ValueError). Fueron elegidas para el modern por su bucket, el modern intentó atenderlas y falló, y el try/except las desvió al legacy —el fallback—. El usuario recibió la respuesta del legacy; el fallo quedó solo en el contador.
(c) Si el modern arreglara webcam, el fallback a 10% valdría 0 (ya no falla nada) y modern_ok valdría 109 (las 109 requests enrutadas al modern ahora todas exitosas). Todo el tráfico enrutado al modern sería servido por el modern. Ese es el estado sano que permite subir el porcentaje con confianza, y —a 100%— retirar el legacy.
Ejercicio 2 — El fallback como detector. El texto dice que el fallback "te protege Y te avisa". (a) Explica qué significa cada parte con las 100 requests que cayeron en fallback a 100%. (b) ¿Por qué es mejor enterarte de un hueco del modern por el fallback que por un test que se te olvidó escribir? (c) ¿Qué métrica, observada a lo largo del ramp, te diría que el modern está listo para reemplazar al legacy por completo?
Ver solución
(a) Te protege: esas 100 requests de webcam habrían sido errores 500 para los usuarios si el modern las hubiera atendido sin red; en cambio, el fallback las mandó al legacy y los usuarios recibieron respuestas correctas. Nadie se vio afectado. Te avisa: el contador de fallback=100 a 100% de tráfico es la evidencia, tomada del tráfico real de producción, de que el modern tiene un caso (webcam) que aún no maneja. Sin ese contador, no sabrías que el hueco existe.
(b) Porque el fallback te avisa con datos del tráfico real, no con tu imaginación de qué casos podrían faltar. Un test que se te olvidó escribir es, por definición, un caso que no se te ocurrió —y si no se te ocurrió el test, tampoco se te ocurrió el código—. El fallback, en cambio, ejercita el modern con las requests que los usuarios de verdad hacen, e ilumina exactamente los huecos que importan (los que la gente realmente consulta). Es un detector alimentado por la realidad, no por tu lista de casos anticipados.
(c) La métrica es el conteo de fallbacks a un traffic_percent alto (idealmente a 100%). Si a 100% el fallback es 0 —el modern atendió con éxito todas las requests que los usuarios hicieron— el modern está listo para reemplazar al legacy: no hay ningún caso que el legacy esté tapando. Mientras el fallback a 100% sea mayor que 0, el modern tiene huecos y el legacy sigue siendo necesario. Es exactamente el criterio de retiro de la lección 7.
Ejercicio 3 — El canary que salva. Imagina que el modern tiene un bug grave que hace que todas las requests fallen (no solo webcam). (a) Con traffic_percent=10 y fallback, ¿qué verían los usuarios? (b) ¿Qué verías tú en los contadores? (c) Compara con lo que pasaría si hubieras saltado a traffic_percent=100 sin fallback.
Ver solución
(a) Con 10% y fallback: el bucket enruta ~109 requests al modern, todas fallan, y todas caen en fallback al legacy. Los usuarios reciben respuestas correctas del legacy —el fallback atrapa el 100% de los fallos del modern—. Desde afuera, el sistema funciona perfecto. Ningún usuario ve un error.
(b) Verías modern_ok=0, fallback≈109, legacy≈891 a 10%. Un fallback disparado (todo el tráfico enrutado al modern cayendo al legacy) es una alarma clarísima: el modern está completamente roto. Lo detectas de inmediato, con el 100% de los usuarios aún protegidos, y frenas el ramp (bajas el traffic_percent a 0) mientras arreglas. El canary cantó y solo "murió" en tus métricas, no en la experiencia de nadie.
(c) Si hubieras saltado a traffic_percent=100 sin fallback: el bucket enruta todas las 1000 requests al modern, todas fallan, y sin fallback el error 500 llega a todos los usuarios. Es una caída total del GET /products en producción, para el 100% de la gente, de golpe. La combinación de canary (poco tráfico expuesto) más fallback (red de seguridad) convierte un bug catastrófico en un no-evento que solo tú ves; saltar a 100% sin fallback lo convierte en un incidente mayor. Esta es, en una imagen, la razón de ser del patrón.
Resumen y siguiente paso
En esta lección hiciste la fase central del strangler fig: desviar el tráfico por porcentaje. Viste, con la llave de agua que se abre de a poco y el balde viejo debajo, que subir el traffic_percent por escalones (canary) y mantener el fallback conectado hace que el desvío sea seguro: si el nuevo falla, el viejo atiende y nadie se queda sin agua. Y lo ejecutaste sobre 1000 requests, subiendo 0→10→50→100, midiendo cuántas fueron por cada ruta. Descubriste la doble función del fallback: te protege (los 100 fallos del caso webcam quedaron invisibles para los usuarios) y te avisa (esos 100 fallbacks a 100% te dicen, con datos reales, que el modern aún tiene un hueco y que no puedes retirar el legacy todavía).
Antes de avanzar deberías poder: explicar por qué se sube por escalones (canary) y no se salta al 100%; describir la doble función del fallback (red y detector); leer un reparto con modern_ok, fallback y legacy y sumar correctamente cuántas atendió cada servicio; y argumentar por qué "100% de tráfico enrutado" no es lo mismo que "el modern está completo".
La lección 5 abre las formas de desviar. En esta lección desviaste por porcentaje (el bucket de la request decide), pero no es la única manera: puedes desviar por ruta/endpoint (todo el listado al nuevo, el detalle aún al viejo), por porcentaje/canary (lo que viste, pero pegado al usuario para que no salte de ruta a media sesión), o por feature flag/usuario (solo staff y beta ven el nuevo, fuera del azar del porcentaje). Vas a ejecutar las tres sobre la misma cola de requests y a medir la estabilidad por usuario —por qué un cliente no debe caer en el nuevo en una request y en el viejo en la siguiente—.
Recursos
- Martin Fowler, "CanaryRelease" — martinfowler.com/bliki/CanaryRelease.html. La entrada que define el canary release: exponer una versión nueva a un subconjunto pequeño del tráfico antes de ir a todos. El origen de la imagen del canario en la mina. En inglés.
- Sam Newman, Monolith to Microservices (O'Reilly, 2019), cap. 3 — el desvío incremental de llamadas del monolito al servicio nuevo, y el rol del proxy que controla qué fracción del tráfico va a cada lado. En inglés.
- Chris Richardson, "Pattern: Strangler application" — microservices.io/patterns/refactoring/strangler-application.html. El desvío gradual de funcionalidad del monolito al conjunto de servicios nuevos, con la vieja implementación disponible como respaldo. En inglés.
- Paul Hammant, "Legacy Application Strangulation: Case Studies" (2013) — paulhammant.com/2013/07/14/legacy-application-strangulation-case-studies. Casos reales donde el tráfico se desvía poco a poco con la vieja ruta como fallback, y cómo se decide subir la fracción. En inglés.