Migraste una base de datos MySQL de Amazon RDS a Amazon Aurora. La read replica de Aurora se puso al día, la promoviste, cambiaste el endpoint de la app, y ahora los writes aterrizan en Aurora.

Diez minutos después, o quizá tres horas después, algo anda mal y quieres regresar a la vieja instancia de RDS.

Aquí está la trampa. La vieja instancia de RDS está congelada en el momento en que hiciste el corte. Cada pedido, registro y edición que pegó en Aurora desde entonces no existe en RDS. "Revertir" de forma ingenua significa "tirar a la basura todo lo que pasó en la base de datos nueva". Y el arreglo de siempre, tomar una ventana de mantenimiento y recargar RDS desde Aurora, no escala: a 100 TB una recarga se mide en días, no en minutos.

Esta es la historia de construir una reversión que conserva los datos
nuevos, probarla en AWS real hasta que se rompió de maneras interesantes, y reducirla a un manual de operaciones que una persona cansada pueda seguir a las 3 de la mañana. Todo lo de abajo lo corrí en instancias desechables en una región aislada y las borré después.

TL;DR

  • La vieja instancia de RDS no debería ser un backup congelado. Hazla una réplica inversa viva de Aurora: después de promover Aurora, apunta RDS de vuelta a Aurora sobre replicación por binary-log para que siga tragándose cada write nuevo. La reversión es entonces "déjala terminar, luego cambia el endpoint".
  • Con la réplica inversa mantenida caliente, el tiempo de reversión es constante e independiente del tamaño de la base de datos: como 1 a 5 minutos a 1 GB y a 100 TB, porque haces el corte del delta, no de la base de datos.
  • La única compuerta que importa es Seconds_Behind_Source = 0. Llegar ahí significa que RDS tiene el 100% de los writes de la era Aurora. Hacer el corte antes pierde datos.
  • Dos rutas funcionan: replicación nativa por binlog (recomendada, barata, replica DDL) y AWS DMS (más manejada por consola, pero ignora en silencio DROP TABLE y unos cuantos ALTER). Las dos las validé en vivo.
  • Un "standby frío vacío" (una instancia lista pero sin datos) cuesta lo mismo por hora que una réplica caliente y te da la velocidad de reversión de no tener nada. Si pagas por mantener una caja corriendo, mantenla caliente.
  • Los filos que costaron tiempo real de debug: una regla de security group que falla en silencio, una edición de parameter group que es todo-o-nada, un stored procedure cuyo conteo de argumentos no es lo que los docs implican, y una regla de compatibilidad de versión que bloquea la migración de plano.

La forma del problema

La migración hacia adelante de RDS a Aurora es una cosa resuelta de un solo botón: creas una read replica de Aurora desde la instancia de RDS, esperas, promueves. AWS hace el snapshot y convierte por ti. Esa parte la tomo como dada.

La dirección interesante es hacia atrás. Una vez que Aurora es primaria y toma writes, la vieja instancia de RDS se va quedando más desactualizada cada segundo. Una reversión que importa tiene que responder una pregunta: ¿a dónde van los writes de la era Aurora? Si la respuesta es "a ningún lado, los perdemos", no es una reversión, es un desastre con pasos extra.

La respuesta que escala es invertir la replicación. Cuando RDS era la fuente y Aurora la réplica, los datos fluían de RDS a Aurora. Después de la promoción lo configuras al revés: Aurora es la fuente, RDS es la réplica.
Ahora RDS jala de forma continua todo lo que Aurora escribe. La vieja
instancia no está congelada, es un espejo caliente que siempre va un segundo o dos atrás de Aurora. Revertir se vuelve: detén Aurora, deja que RDS termine el último segundo, cambia el endpoint.

La parte elegante es el costo de la sincronización inicial. En el instante de la promoción, RDS ya es byte por byte igual a Aurora, porque era la fuente. Así que el enlace inverso no re-copia la base de datos. La siembras en la coordenada de la promoción y de ahí en adelante solo carga el delta.
Por eso el tamaño de la base de datos deja de importar.

Construyéndolo, y viéndolo romperse

Levanté el mundo post corte en instancias desechables: un clúster de Aurora MySQL como "prod nuevo", una instancia de RDS for MySQL como el objetivo de reversión, en una región aislada. Luego cableé la replicación inversa y la empujé por el camino feliz y por los caminos de falla.

Callejón sin salida 1: el security group que falla en silencio

Primer intento de arrancar la replicación, la réplica nada más se quedó ahí sentada:

Replica_IO_Running: Connecting
Source_Server_Id: 0
Last_IO_Errno: 0
Last_IO_Error:

Enter fullscreen mode Exit fullscreen mode

Connecting, para siempre. Sin número de error, sin texto de error. El hilo de IO estaba tratando de alcanzar Aurora y no recibía nada, pero nada se reportaba. La causa: las dos bases de datos eran públicamente accesibles (para que mi cliente de SQL las alcanzara), lo que significaba que la réplica resolvía el endpoint de Aurora a su IP pública. La conexión de replicación entonces llegaba a Aurora desde la IP pública de la réplica, que el security group no permitía. Un drop silencioso, presentado como un esperanzado Connecting.

La lección no es "abre la IP pública". Es lo contrario. En producción, mantén las dos bases de datos privadas. Entonces el tráfico de réplica a fuente fluye sobre IPs privadas dentro de la VPC, y una sola regla de autorreferencia del security group lo cubre. La accesibilidad pública fue lo que creó el problema.

El camino feliz

Con la red arreglada, la mecánica funcionó exactamente como se anunciaba.
Escribe dos filas a Aurora, y en unos segundos aparecen en RDS. Detén el enlace, drena a cero lag, corre los dos procedimientos de reversión, y RDS se vuelve una primaria independiente que acepta writes mientras Aurora queda desacoplada. Cero datos perdidos. La secuencia completa está en el manual de operaciones al final.

Callejón sin salida 2: revertir demasiado pronto pierde exactamente lo que crees

Todo el diseño descansa en una sola disciplina, así que me obligué a verla fallar. Detuve la réplica, escribí dos filas más a Aurora, y luego "reverti" sin esperar a que la réplica se pusiera al día.

  • Aurora: 7 filas.
  • RDS después de revertir: 5 filas.

Las dos filas escritas durante la ventana se fueron. Esta es toda la razón por la que Seconds_Behind_Source = 0 es una compuerta dura y no una sugerencia. La reversión es sin pérdidas solo si congelas los writes en la fuente y dejas que la réplica drene a cero antes de hacer el corte.

Callejón sin salida 3: qué rompe de verdad la réplica

Esperaba que los writes divergentes en el objetivo rompieran la replicación ruidosamente. No lo hicieron. Las read replicas de RDS traen por default slave_exec_mode = IDEMPOTENT, que en silencio hace que la fuente gane un conflicto de primary key en lugar de dar error. Así que un write perdido en el objetivo de reversión se sobreescribe calladito, no se marca.

Lo que la rompe es el schema drift. Tiré una columna en el objetivo, luego escribí una fila en la fuente que usaba esa columna. El aplicador de SQL se detuvo en seco:

Last_SQL_Errno: 13146
Replica_SQL_Running: No

Enter fullscreen mode Exit fullscreen mode

La réplica se congeló y se quedó atrás en silencio, todavía reportando un hilo de IO sano. La conclusión operativa: alarma sobre que Replica_SQL_Running voltee a No, no solo sobre el lag. Y durante la ventana de standby caliente, trata el objetivo como estrictamente solo lectura y congela el DDL en la fuente.

Callejón sin salida 4: el conteo de argumentos que los docs implican está mal

La variante GTID es la que quieres en producción, porque sobrevive a un failover de Aurora. Al configurarla, el stored procedure rechazó mi llamada:

ERROR 1318 (42000): Incorrect number of arguments for PROCEDURE
mysql.rds_set_external_master_with_auto_position; expected 6, got 5

Enter fullscreen mode Exit fullscreen mode

El procedimiento toma seis argumentos: host, port, user, password,
ssl_encryption, y delay. Leyendo ejemplos que omiten el delay final, es fácil pasar cinco. Trampa relacionada: el tunable se llama gtid-mode con guion en el parameter group, mientras que gtid_mode con guion bajo es una vista de runtime de solo-lectura que rechaza modificaciones.

Callejón sin salida 5: la edición del parameter group que se aplicó a medias

Esta costó el más tiempo y produjo el síntoma más confuso. Después de
habilitar GTID, la replicación no arrancaba:

Got fatal error 1236 from source when reading data from binary log:
'Binary log is not open'

Enter fullscreen mode Exit fullscreen mode

En Aurora, @@gtid_mode era ON, pero @@log_bin era 0. El binary logging estaba apagado, así que no había nada que replicar. La causa: había puesto binlog_format=ROW y dos parámetros de GTID en una sola edición del parameter group, y uno de los nombres de parámetro estaba mal. Un modify de parameter group es todo o nada. Toda la edición se rechazó, lo que en silencio dejó binlog_format en su default OFF. Los parámetros de GTID, puestos en una llamada posterior exitosa, estaban bien, que es por lo que el
estado se veía a medio arreglar.

El arreglo: pon binlog_format=ROW, reinicia el writer de Aurora, y
verifica @@log_bin = 1 y un SHOW MASTER STATUS no vacío antes de cablear la réplica. Aurora necesita el reinicio para de verdad abrir el binary log.

Una vez que eso quedó bien, el auto-posicionamiento de GTID funcionó de principio a fin:

Auto_Position: 1
Replica_IO_Running: Yes
Replica_SQL_Running: Yes
Seconds_Behind_Source: 0
Retrieved_Gtid_Set: <aurora-uuid>:1-3

Enter fullscreen mode Exit fullscreen mode

La segunda ruta: AWS DMS

El binlog nativo es la ruta recomendada, pero AWS DMS vale la pena conocerlo porque es casi por completo manejado por consola. Corrí una tarea de full load más CDC de Aurora a una segunda instancia de RDS. Funcionó, y es rápido:

Métrica Resultado
Full load, 2 tablas, 274 MB 20.2 s (como 48 GB/h en la instancia más chica)
CDC, un solo insert propagado en menos de 12 s

Pero DMS tiene un hueco que el binlog no. Creé una tabla en Aurora (se replicó bien), luego la tiré:

  • Aurora: tabla ida.
  • Objetivo de DMS: tabla todavía ahí.

El CDC de DMS no replica DROP TABLE ni RENAME TABLE, e ignora unos cuantos ALTER. Sobre una ventana de reversión larga eso es schema drift silencioso. Usa DMS si quieres el flujo de consola y puedes vivir con la restricción; usa binlog si quieres que el DDL venga incluido.

Los números, y por qué el tamaño deja de importar

El punto de todo el ejercicio es que el tiempo de reversión se desacopla del tamaño de la base de datos. Dos tablas cuentan la historia. La primera es la ejecución de la reversión cuando la réplica caliente se mantiene corriendo:

Tamaño de la BD Tiempo de reversión (réplica caliente)
1 GB ~1 a 2 min
100 GB ~1 a 3 min
1 TB ~1 a 5 min
100 TB ~1 a 5 min

Constante. La única variable es cuánto delta se apiló durante el incidente, y una instancia de clase producción drena eso a como 1 a 2 GB por minuto, así que hasta un backlog gordo de 10 GB se limpia en minutos de un solo dígito.

La segunda tabla es lo que pasa si no mantuviste la réplica caliente y tienes que reconstruir el fallback desde cero. Este es el camino a evitar:

Tamaño de la BD Reconstruir vía DMS paralelo (~0.9 TB/h) Reconstruir vía dump + restore (~60 GB/h)
100 GB ~7 min ~1.7 h
1 TB ~1.1 h ~17 h
100 TB ~4 a 5 días inviable

A 100 TB la diferencia es minutos contra días. Ese es todo el argumento para mantener la vieja instancia caliente.

Un ancla medida para los números de reconstrucción: en una instancia de clase producción (32 vCPU, 256 GB) un insert lógico de un solo hilo corrió a como 1.2 GB por minuto, o 72 GB/h. El apply de binlog basado en filas y el restore lógico son del mismo orden de magnitud, que es sobre lo que están construidos los estimados de arriba. Las mediciones de instancia chica (un par de MB/s de apply de binlog, como 48 GB/h de full load de DMS) son pisos conservadores que escalan hacia arriba con el tamaño de la instancia.

El error que costó los 6 dólares

Traté de sacar números exactos de clase producción corriendo toda la matriz en instancias de 32 vCPU con 100 GB de datos. Pasaron dos cosas. Primero, la migración hacia adelante se negó a arrancar:

Cannot upgrade from mysql 8.0.46 to aurora-mysql 8.0.mysql_aurora.3.12.0

Enter fullscreen mode Exit fullscreen mode

Una read replica de RDS a Aurora requiere que la versión de MySQL del motor de Aurora sea al menos la de la fuente. La fuente iba un minor adelante del Aurora más nuevo, así que se rechazó. Reconstruye la fuente en una versión que empate y funciona, pero eso significaba regenerar los datos. Segundo, y más al punto, las instancias grandes cuestan dinero real por minuto. Maté la corrida y me eché para atrás a calcular desde las anclas de instancia chica.
La lección, pagada en dólares: no necesitas hardware de clase producción para medir una tasa de throughput. Una tasa se extrapola. Mide barato, extrapola con honestidad, etiqueta los pisos.

La trampa del standby frío vacío

Un punto medio tentador es mantener una instancia de RDS aprovisionada y corriendo pero vacía, lista para llenarse bajo demanda. Suena ahorrador. Es lo peor de los dos mundos.

Una instancia vacía e inactiva cuesta lo mismo por hora que una réplica caliente, porque pagas por el cómputo, no por los datos. Pero una instancia vacía no tiene línea base, así que revertir significa cargar la base de datos entera en ella primero. Ese es el camino de reconstrucción lineal con el tamaño: minutos a 100 GB, días a 100 TB. Pagas precio completo de standby por la velocidad de reversión de no tener nada. Peor, estarías recargando desde Aurora, la base de datos de la que estás tratando de escapar, así que si el gatillo fue corrupción la copias fielmente.

Si estás pagando por mantener una caja corriendo como objetivo de reversión, mantenla caliente. Si de plano no puedes, la opción honesta es ningún standby y una reversión aceptada de varios días con pérdida alta, no una caja vacía que cuesta lo mismo que la buena opción.

El manual de operaciones

Todo el asunto en una oración: la vieja instancia de RDS es una copia viva que ha estado tragándose cada write de Aurora desde el corte, así que revertir es detener Aurora, dejar que RDS termine los últimos writes, cambiar el endpoint.

Configúralo una vez, cuando promuevas Aurora:

  1. Agarra la coordenada de binlog de Aurora en el instante en que la app empieza a escribirle (SHOW MASTER STATUS).
  2. Apunta RDS a Aurora desde esa coordenada (mysql.rds_set_external_master o la variante de auto-posición de GTID) y arranca la replicación.
  3. Confirma Replica_IO_Running: Yes y Replica_SQL_Running: Yes. Déjala corriendo. Alarma sobre que el hilo de SQL se detenga. No le escribas a RDS y no cambies el schema en Aurora mientras corre.

Revierte, cuando lo decidas:

  1. Detén que la app le escriba a Aurora.
  2. SET GLOBAL read_only = ON en Aurora, para que nada se cuele.
  3. Observa RDS hasta Seconds_Behind_Source = 0 con los dos hilos en Yes. Esta es la compuerta. Significa que RDS ahora tiene cada write de la era Aurora. Si un hilo dice No, párate y escala, no hagas el corte.
  4. mysql.rds_stop_replication y luego mysql.rds_reset_external_master en RDS.
  5. SET GLOBAL read_only = OFF en RDS.
  6. Apunta el endpoint de la app a RDS. Vuélvela a levantar.

No hay deshacer después del paso 4. Una vez que el enlace se corta y RDS es escribible, las dos bases de datos son independientes. Que es exactamente por lo que el paso 3 no es opcional: es la garantía de que te estás llevando todos los datos de vuelta contigo.