La gestión de migraciones en bases de datos ha evolucionado significativamente con la complejidad de los sistemas modernos. Durante años, el enfoque tradicional se basaba en un proceso simple pero efectivo: ejecutar las migraciones durante el despliegue de una nueva versión. Esta metodología resulta adecuada en proyectos de escala reducida, caracterizados por una única instancia, volumen de datos limitado y tráfico moderado.
Sin embargo, al escalar a entornos de alta concurrencia —caracterizados por múltiples instancias en ejecución simultánea y tablas que albergan millones de registros— la estrategia de migración en tiempo de inicialización presenta limitaciones críticas en términos de seguridad, eficiencia y disponibilidad del sistema.
Este artículo presenta una metodología robusta para gestionar migraciones en sistemas que requieren alta disponibilidad, basada en experiencia práctica en entornos productivos. Si bien no pretende ser una solución universal, ofrece un marco de trabajo probado y adaptable a las necesidades específicas de cada proyecto.
Objetivo del artículo
El objetivo es mostrar un enfoque que te permita aplicar migraciones de manera segura, escalable y sin downtime en sistemas distribuidos.
Como siempre, no se trata de una regla fija: cada proyecto tiene sus particularidades. Pero creo que este patrón es una buena base sobre la que iterar y adaptarlo a cada caso.
Desafíos de la migración en tiempo de inicialización
Escenario básico
En un entorno de desarrollo o producción simple, el proceso de migración sigue una secuencia lineal y predecible:
- Despliegue de nueva versión
- Ejecución de migraciones
- Inicialización de la aplicación
- Verificación de sincronización con la base de datos
Esta aproximación resulta viable debido a que el periodo de inconsistencia entre el esquema de base de datos y el código en ejecución es prácticamente imperceptible.
Complejidades en entornos de producción
No obstante, en un sistema distribuido con múltiples instancias y volúmenes de datos significativos, emergen desafíos técnicos críticos:
- Condiciones de carrera: Múltiples instancias intentando ejecutar migraciones simultáneamente
- Bloqueos de recursos: Operaciones de larga duración (ej. creación de índices) pueden impactar en la disponibilidad
- Desincronización temporal: Ventanas de tiempo donde el código en ejecución no está alineado con el esquema de datos
Consecuencia directa: Compromiso significativo de la disponibilidad del sistema e integridad de datos.
Ejemplo práctico: añadir last_login_at
Para ilustrar la complejidad inherente a las migraciones en sistemas distribuidos, analicemos un caso práctico representativo.
Contexto del sistema
Consideremos un entorno con las siguientes características:
- Volumen de datos: Tabla
users
con un millón de registros - Patrón de uso: Alta frecuencia de operaciones de lectura/escritura
- Infraestructura: Cluster Kubernetes con múltiples instancias para gestionar el tráfico elevado
- Requisito: Incorporar campo
last_login_at
con restricciónNOT NULL
Análisis del desafío técnico
La adición del campo last_login_at
presenta una complejidad fundamental: la imposibilidad de agregar una columna no nullable a registros existentes sin un valor por defecto. Una aproximación inicial sugeriría una secuencia de tres pasos:
- Crear la columna
last_login_at
como nullable - Actualizar registros existentes con un valor predeterminado (fecha actual o fecha de creación)
- Modificar la columna para aplicar restricción
NOT NULL
Implicaciones en producción
Esta estrategia, aparentemente directa, presenta riesgos significativos en un entorno de producción:
- Impacto en rendimiento: La actualización masiva de un millón de registros implica una operación de larga duración
- Problemas de concurrencia: Múltiples instancias intentando ejecutar la migración simultáneamente
- Riesgos operacionales:
- Potenciales deadlocks por bloqueos prolongados
- Timeouts en pods de Kubernetes por demoras excesivas
- Degradación significativa del rendimiento durante la migración
Buenas prácticas: el patrón expand → backfill → contract
Vamos a ver como se podría afrontar este problema de forma segura y sin downtime.
La solución pasa por utilizar un conocido patrón llamado expand → backfill → contract para separar las migraciones en fases seguras:
- Expand: hacer cambios no destructivos y compatibles hacia atrás (añadir columnas nuevas, índices, constraints en modo "NOT VALID").
- Backfill: rellenar los datos históricos en segundo plano, en lotes pequeños, sin bloquear.
- Contract: una vez que todo está listo, aplicar los cambios destructivos (ej. poner la columna en NOT NULL, borrar lo viejo).
Esto se combina con la regla N/N-1: la nueva versión de la app debe ser compatible con el esquema anterior, y viceversa.
A diferencia del enfoque anterior en el que ejecutábamos la migración en único proceso, ahora vamos a invocar procesos independientes para cada una de las fases.
Vamos a ver como se aplicacaría en el ejemplo mencionado en el apartado anterior.
- En un primer paso, lanzaríamos la migración
expand
, que simplemente añade la columnalast_login_at
como nullable. Además en este paso se lanza la nueva versión de la aplicación. Esta versión debe asegurar compatibilidad con ambos esquemas. Cuando un usuario haga login, se informará el campolast_login_at
con la fecha actual. Cuando un servicio recupere un usuario, se contemplará que este campo puede ser null, a pesar de que a futuro sabemos que no lo será. - Nada más lanzar esta nueva versión, en un nuevo proceso se ejecutará lo que se conoce como
backfill
. Este proceso se encargará de actualizar todos los valores existentes agregando un valor al campolast_login_at
. Este proceso se realizará en lotes pequeños, sin bloquear la tabla, por ejemplo en lotes de 500 registros. Además, no se ejecuta desde las instancias que corren la aplicación, si no que tendrá su propia instancia encargada de realizar esta tarea. - Cuando el backfill finalice, se lanza el último proceso en el que se ejecuta la migración
contract
, que en nuestro ejemplo significa convertir la columnalast_login_at
aNOT NULL
. Opcionalmente en este paso, se podría lanzar una nueva versión de la aplicación que deje de contemplar el caso de que el campolast_login_at
sea null.
En este ejemplo estamos simplemente agregando una nueva columna a una tabla, lo cual es un caso bastante simple. En proyectos reales, los cambios pueden ser mucho más complejos, como cambiar relaciones, renombrar columnas, etc. Cada caso tendrá sus particularidades y requerirá analizar detenidamente la mejor forma de aplicar el patrón, garantizando que el sistema es compatible con ambas versiones.
Orquestación en Kubernetes y CI/CD
La implementación práctica del patrón expand-contract requiere una infraestructura robusta que automatice y coordine las diferentes fases del proceso. A continuación, presentamos una arquitectura de referencia basada en Kubernetes y ArgoCD.
Contexto tecnológico
La solución propuesta está diseñada para el siguiente stack tecnológico:
- Framework: NestJS con TypeORM
- Orquestación: Kubernetes
- CD: ArgoCD
- Arquitectura: Microservicios
Si bien el enfoque está optimizado para estas tecnologías, los principios son adaptables a otros stacks tecnológicos.
Arquitectura de la solución
Proyecto de migraciones
El primer componente fundamental es un proyecto independiente dedicado exclusivamente a la gestión de migraciones. Esta separación de responsabilidades permite:
- Gestión independiente del ciclo de vida de las migraciones
- Organización clara del código por fases (expand, backfill, contract)
- Aislamiento de la lógica de migración del código de la aplicación
Adaptación del sistema de migraciones
TypeORM, por defecto, genera migraciones monolíticas que no se alinean con el patrón expand-contract. Para adaptar este comportamiento:
- Se mantiene la generación automática de migraciones como punto de partida
- Se refactoriza la migración generada, separándola en las fases correspondientes
- Se distribuyen los componentes en las carpetas expand, backfill y contract del nuevo proyecto dedicado a migraciones.
Configuración del sistema de ejecución
La ejecución de migraciones requiere una configuración específica para cada fase. Implementamos esto mediante:
- En el proyecto de las migraciones, creamos 3 ficheros
data-source
diferentes, que mantengan una configuración común pero cada uno de ellos cargando las migraciones de su correspondiente carpeta.
import * as expandMigrations from './migrations/expand';
export const datasource: DataSourceOptions = {
type: 'postgres',
host: configService.get<string>('DB_HOST'),
port: configService.get<string | undefined>('DB_PORT')
? parseInt(configService.get<string>('DB_PORT'), 10)
: 5432,
username: configService.get<string>('DB_USERNAME'),
password: configService.get<string>('DB_PASSWORD'),
database: configService.get<string>('DB_DATABASE'),
schema: 'migrations',
migrations: Object.values(expandMigrations) as any,
entities: Object.values(entities) as any,
};
- En el
package.json
de este mismo proyecto, creamos diferentes scripts para lanzar las migraciones de cada fase, usando su correspondientedata-source
{
"scripts": {
"migration:expand": "typeorm migration:run -d ./db/expand-ds.ts",
"migration:backfill": "typeorm migration:run -d ./db/backfill-ds.ts",
"migration:contract": "typeorm migration:run -d ./db/contract-ds.ts",
}
}
Integración con ArgoCD y Kubernetes
La orquestación de las diferentes fases de migración se implementa aprovechando las capacidades de ArgoCD y Kubernetes. Específicamente, utilizamos el concepto de Resource Hooks
de ArgoCD para ejecutar las migraciones en momentos precisos del ciclo de despliegue.
Definición de Jobs
Implementamos dos tipos de hooks principales:
- PreSync: Ejecuta las migraciones expand antes del despliegue
- PostSync: Gestiona el proceso de backfill después del despliegue
Esta separación garantiza una secuencia ordenada y controlada de las operaciones de migración.
apiVersion: batch/v1
kind: Job
metadata:
name: expand-migrations
annotations:
argocd.argoproj.io/hook: PreSync
argocd.argoproj.io/hook-delete-policy: BeforeHookCreation
spec:
template:
spec:
restartPolicy: Never
containers:
- name: migration
image: your-registry/migrations-app:1.2.3
command: ["npm","run","migration:expand"]
apiVersion: batch/v1
kind: Job
metadata:
name: backfill-migrations
annotations:
argocd.argoproj.io/hook: PostSync
argocd.argoproj.io/hook-delete-policy: HookSucceeded
spec:
template:
spec:
restartPolicy: Never
containers:
- name: migration
image: your-registry/migrations-app:1.2.3
command: ["npm","run","migration:backfill"]
Configuramos ArgoCD para que aplique estos Jobs en la aplicación destinada a ejecutar las migraciones. Y configuramos nuestro CI para que, cualquier cambio en el repo del proyecto de migraciones, lance nueva versión. Para el Job del backfill, es buena idea configurar una notificación que se dispare cuando finalice, enviando un email, notificación por Slack o lo que se quiera.
Ahora bien, ¿qué pasa con el contract? Como decíamos en apartados anteriores, la fase contract es una muy buena práctica realizarla de forma manual, para que se pueda llevar a cabo verificaciones y asegurar que todo está correcto antes de proceder. ¿Como encajamos esto en el flujo? Podemos hacer lo siguiente: creamos una nueva aplicación en el ArgoCD, que contendrá únicamente el Job para ejecutar el contract. Además, configuramos esta aplicación para no sincronizar automáticamente.
apiVersion: batch/v1
kind: Job
metadata:
name: contract-migrations
spec:
template:
spec:
restartPolicy: Never
containers:
- name: migration
image: your-registry/migrations-app:1.2.3
command: ["npm","run","migration:contract"]
Cuando se quiera ejecutar este Job, podemos forzar un Sync desde la UI de ArgoCD.
Con esto tenemos todas las piezas necesarias. Veamos ahora como funcionaría volviendo al ejemplo anterior de agregar el campo last_login_at
a nuestra tabla usuarios.
- Creamos las migraciones necesarias, partiendo de la generada por TypeORM o desde 0. Nos aseguramos que no empleamos ninguna migración que pueda bloquear la base de datos, sobre todo en el proceso de backfill. Guardamos dichas migraciones en el proyecto dedicado a las migraciones.
- Desplegamos nueva versión al repo del proyecto de las migraciones. Esto forzará a ejecutar las fases expand y, al finalizar, backfill.
- Desplegamos la nueva versión de la aplicación, garantizando compatibilidad en su código con los 2 esquemas, el antiguo y el nuevo.
- Cuando sepamos que el backfill ha finalizado, realizamos pruebas para asegurar integridad en la base de datos. Desde la UI de ArgoCD forzamos un Sync en la app dedicada a la fase contract.
- Opcionalmente, podemos lanzar una nueva versión de la app eliminando condiciones que hayamos puesto para asegurar la compatibilidad con los 2 esquemas. En este punto podemos asegurar que solo el nuevo esquema debe ser compatible.
Para finalizar, comentar que no es obligatorio realizar el flujo completo en cada cambio al esquema de la base de datos. En muchas ocasiones nos encontraremos con cambios que no necesitan asegurar compatibilidad entre los esquemas, como por ejemplo, crear una nueva tabla totalmente independiente. Para estos casos, podemos fácilmente crear la migración en la carpeta expand, desplegar migraciones para que se lance esta fase. El backfill, al no tener nuevas migraciones, directamente no realizará nada. Y el contracto nunca lo lanzamos. Por tanto, solo el expand se ejecuta y, tras finalizar, podemos subir la nueva versión de la aplicación.
Conclusiones y consideraciones finales
La gestión de migraciones en bases de datos es un aspecto crítico en el desarrollo de sistemas robustos y altamente disponibles. El patrón expand-contract, combinado con una infraestructura automatizada de CI/CD, nos proporciona un marco de trabajo robusto para abordar este desafío.
Puntos clave a recordar:
- La seguridad y la disponibilidad del sistema deben ser prioritarias al diseñar estrategias de migración
- La automatización del proceso es fundamental, pero debe mantener puntos de control manual cuando sea necesario
- La compatibilidad entre versiones (N/N-1) es un requisito no negociable
- No todos los cambios requieren el flujo completo - adapta el patrón según la complejidad del cambio
Este enfoque ha demostrado ser efectivo en entornos de producción reales, permitiendo evolucionar el esquema de datos de manera segura y controlada, sin comprometer la disponibilidad del sistema. Como con cualquier patrón arquitectónico, la clave está en entender los principios subyacentes y adaptarlos a las necesidades específicas de cada proyecto.