AWS · Bases de datos · 2026-09-03 · 6 min de lectura
El typo que ensayó una migración de producción
El MySQL de producción de un cliente tenía que mudarse — cluster nuevo, cutover escalonado servicio por servicio, nada de big bang. Las migraciones de bases de producción son esa clase de cambio donde el plan vale exactamente lo que valen las partes que ensayaste. Así que ensayamos todo, en tres niveles de fidelidad creciente, antes de tocar producción.
El tercer nivel venía con regalo: el cluster Aurora compartido de dev había sido creado, hacía tiempo, con un typo en el nombre — stagging. Renombrar un cluster de Aurora como corresponde implica crear uno nuevo y mudar todo… que es exactamente una migración. Así que el fix del typo se convirtió en el ensayo general: mismo procedimiento, mismo tooling, aplicaciones reales, datos reales, y un resultado útil incluso si el ensayo fuera lo único logrado.
Nivel 1: un lab en Docker que rompe cosas a propósito
El diseño de la migración es replicación binlog con GTID auto-positioning, más filtros de replicación para cortar schema por schema: cuando el servicio de un schema se muda al cluster nuevo, un filtro lo blinda de las escrituras residuales que siguen llegando del lado viejo. Un ensayo end-to-end anterior había validado el canal de replicación en sí (snapshot cifrado cross-account, GTID auto-position, CDC con lag cero) — pero no los filtros. Y los filtros son lo que hace viable una migración escalonada.
Dos contenedores MySQL 8.0.46 (GTID activo, binlog ROW), cero infraestructura del cliente, cero costo, 30–45 minutos por corrida. Lo que salió de ahí:
replicate-wild-ignore-table, noreplicate-ignore-db. El filtro a nivel db tiene una sutileza de evaluación documentada (reglas de replicación de MySQL): unALTER TABLE schema.tablacalificado, ejecutado desde el contexto de otro schema, igual se aplica sobre el schema que creías protegido. El filtro wildcard de tabla no tiene ese agujero. Reprodujimos los dos comportamientos en vivo.- El filtro entra después de drenar, nunca antes. Aplicarlo con transacciones todavía en vuelo descarta las últimas escrituras del schema de forma silenciosa y definitiva — sus GTID quedan marcados como ejecutados, así que nada las reintenta jamás. El orden de las operaciones, otra vez.
- Las transacciones filtradas llegan como transacciones vacías, así que la consistencia de GTID y el auto-positioning sobreviven al filtrado. El canal sigue sano para todos los schemas que aún no cortaron.
- El valor del filtro se reemplaza, no se acumula. Cada cutover tiene que enumerar todos los schemas ya migrados, o el anterior pierde su blindaje.
- Confiar en
SHOW REPLICA STATUS, no enperformance_schema— el segundo reportó reglas de filtro inconsistentes durante el lab.
El lab también reproduce los modos de falla a propósito, con su recuperación: una escritura sin filtrar del origen pisando en silencio una fila del destino (sin error, sin alarma — el más aterrador); error 1032 cuando el destino borró la fila; 1062 por colisión de clave primaria — y estos dos últimos detienen el applier para todos los schemas, incluidos los no migrados; y 1396 cuando los dos lados crean el mismo usuario (CREATE USER IF NOT EXISTS es la vacuna). Seis ejercicios de error deliberado entraron al runbook, cada uno con la salida real esperada de cada comando. Si la persona que ejecuta el procedimiento del día D ya vio el error 1062 y lo arregló, deja de ser un incidente — es un ítem de checklist.
Nivel 2: las preguntas que solo Aurora puede responder
Un lab en Docker no puede decirte cómo se comportan los parameter groups de Aurora. Quedaban dos cosas abiertas, y cualquiera de las dos podía cambiar el procedimiento del día D: ¿replicate-wild-ignore-table con ApplyMethod=immediate toma efecto sin reboot (filtros de replicación en Aurora)? ¿Y el parameter group valida la sintaxis del filtro como lo hace el motor por la vía SQL?
Para eso era el ensayo general.
Nivel 3: el ensayo general (gracias, typo)
El procedimiento de producción completo, corrido contra el cluster mal nombrado como origen y un cluster nuevo bien nombrado como destino — con los ~20 servicios reales que lo usan conectados todo el tiempo.
Preparar el origen. Binlog ROW más la escalera GTID online (enforce_gtid_consistency=ON → gtid_mode=ON_PERMISSIVE → ON, según documenta MySQL) — con las aplicaciones conectadas. Cuatro reinicios, medidos: 10 s, 9 s, 8 s, ~8 s; todas las apps se reconectaron solas. Ahora el plan de producción dice "cuatro reinicios de menos de diez segundos cada uno", con evidencia, en lugar de "algunos reinicios".
Destino y replicación. Cluster nuevo creado vía Terragrunt desde el snapshot post-GTID (restore ~25 min); replicación binlog con auto-position enganchada, lag 0 sostenido durante toda la convivencia.
Las respuestas de Aurora. ApplyMethod=immediate sobre el parámetro del filtro toma efecto sin reboot — el cluster nunca salió de in-sync. Hallazgo extra: RDS combina su filtro interno mysql.% con el tuyo en lugar de reemplazarlo. Y el blindaje aguantó sobre Aurora real: con el filtro activo, el origen no pudo pisar filas del destino con nada de lo que le tiramos.
El gotcha que nadie tenía en la lista. El security group del destino no tenía regla de egreso — Terraform elimina el egress permisivo por defecto salvo que lo re-declares — y el hilo de IO de la replicación se quedó en Connecting, sin ningún error visible en ningún lado. Un cuelgue silencioso e indefinido como único síntoma de una omisión de una línea en el SG. Eso hoy es un check de pre-vuelo en el runbook de producción.
El cutover: 15 minutos de punta a punta.
congelar 21 servicios ECS (desired=0) ~2 min
drenaje: WAIT_FOR_EXECUTED_GTID_SET = 0 al instante
integridad: 772 tablas / ~93 GiB
conteo exacto de filas en ambos lados,
en paralelo ~5,5 min → diff idéntico
repuntar: 19 secrets de Secrets Manager 58 s (rollback: AWSPREVIOUS)
servicios arriba y estables 2 min 32 s
filtro definitivo multi-schema, sin reboot 37 s
Conexiones verificadas en el destino, cero en el origen.
Un teardown que termina el trabajo. Canal de replicación detenido y reseteado (destino standalone), snapshot final tomado, y el cluster mal nombrado destruido vía Terragrunt — siete recursos, dejó de facturar. Un ensayo que deja el cluster viejo corriendo "por las dudas" no está terminado; es una fuga de costo con valor sentimental.
Qué se compró con esto
El plan de migración de producción ahora contiene cero pasos sin ensayar. Cada duración que figura — reinicios, restore, drenaje, validación por conteo, rotación de secrets, recuperación de servicios — es una medición de esta corrida, no una estimación. Y las partes que asustan (el pisado silencioso de datos, la trampa del filtro-antes-de-drenar, el cuelgue del SG) ya pasaron todas una vez, sobre fierros que nadie lloraría, con la recuperación escrita.
Lecciones
- 1. Buscá un vehículo descartable para tu ensayo general. Un cluster con typo en el nombre fue un regalo: el ensayo produjo valor permanente (el rename) más allá de des-riesgar producción.
- 2. Practicá las fallas, no solo el procedimiento. Seis ejercicios de error deliberado convirtieron los escenarios más temidos del runbook en cosas que el operador ya arregló una vez.
- 3. La secuencia le gana a la configuración. Los dos modos feos de pérdida de datos de este diseño vienen del orden (filtro antes de drenar; escrituras de convivencia sin filtrar), no de ningún parámetro mal puesto.
- 4. Validá contando, no por sensaciones. Comparar 772 tablas por conteo exacto de filas en paralelo llevó 5,5 minutos — un precio ridículamente barato por decir "idéntico" con evidencia.
- 5. Cada paso reversible y cada teardown total. Los secrets vuelven con
AWSPREVIOUS, los clusters mueren por IaC con snapshot final, y nada queda arriba "por las dudas".