DevOps from Zero to Hero: Migraciones de Base de Datos y Deployments Sin Downtime
Apoya este blog
Si te resulta util este contenido, considera apoyar el blog.
Introduccion
Bienvenido al articulo diecisiete de la serie DevOps from Zero to Hero. En los articulos anteriores construimos un pipeline completo de CI/CD, configuramos observabilidad, y desplegamos nuestra API de TypeScript en Kubernetes. Todo funciona, el pipeline esta verde, y los deploys salen bien. Pero hay un tema que estuvimos esquivando en silencio: la base de datos.
Desplegar codigo nuevo de la aplicacion es relativamente sencillo. Construis una imagen nueva, la desplegás, y si algo sale mal, haces rollback. Pero los cambios en la base de datos son diferentes. Son stateful. No podes simplemente “deshacer” un drop de columna. Afectan a todas las instancias de tu aplicacion simultaneamente. Y si te equivocas en el orden, podes tirar abajo todo tu servicio.
En este articulo vamos a cubrir que son las migraciones de base de datos y como funcionan, como escribir migraciones seguras usando Prisma, el patron expand-contract para hacer cambios de esquema retrocompatibles, estrategias de deployment sin downtime en Kubernetes, health checks y readiness probes, y estrategias de rollback para cuando las cosas salen mal. Al final, vas a saber como enviar cambios de base de datos con confianza, incluso bajo trafico de produccion.
Vamos a meternos de lleno.
Por que los cambios en la base de datos son la parte mas riesgosa de los deployments
El codigo de la aplicacion es stateless. Si desplegás una version mala, haces rollback a la imagen anterior del contenedor y el problema desaparece. El codigo viejo corre exactamente como antes. Pero las bases de datos son stateful. Una vez que dropeas una columna, esos datos se perdieron. Una vez que renombras una tabla, cada query que referencia el nombre viejo se rompe al instante.
Esto es lo que hace que los cambios de base de datos sean tan peligrosos:
- Son estado compartido: Cada pod, cada instancia, cada replica lee de la misma base de datos. Un cambio de esquema los afecta a todos de una vez. No podes hacer un rollout gradual de un cambio de base de datos como lo haces con codigo de aplicacion.
- Son dificiles de revertir: Agregar una columna es facil de deshacer (la dropeas). Pero dropear una columna, cambiar un tipo de columna, o borrar datos? Esas operaciones son destructivas. No podes “desborrar” una columna y recuperar los datos.
- El orden importa: Si tu codigo espera una columna que todavia no existe, se rompe. Si tu migracion remueve una columna que el codigo viejo todavia referencia, se rompe. La secuencia entre deploys de codigo y cambios de esquema es critica.
- Toman locks: Muchos cambios de esquema (especialmente en tablas grandes) adquieren locks que bloquean lecturas o escrituras. Una migracion que tarda 30 segundos en tu base de datos de desarrollo puede tardar 30 minutos en una tabla de produccion con millones de filas, bloqueando todo el trafico.
El desafio central es este: durante un deployment, vas a tener codigo viejo y codigo nuevo corriendo al mismo tiempo. Tu esquema de base de datos debe ser compatible con ambas versiones simultaneamente. Esta restriccion guia cada decision que vamos a tomar en este articulo.
Fundamentos de migraciones
Una migracion de base de datos es un cambio versionado e incremental a tu esquema de base de datos. En lugar de ejecutar sentencias SQL manualmente contra tu base de datos, escribis archivos de migracion que describen el cambio, y una herramienta de migracion los aplica en orden.
Cada migracion tiene dos partes:
- Up: El cambio hacia adelante. Crear una tabla, agregar una columna, crear un indice. Esto es lo que se ejecuta cuando aplicas la migracion.
- Down: El cambio inverso. Dropear la tabla, remover la columna, remover el indice. Esto es lo que se ejecuta cuando haces rollback de la migracion.
Los archivos de migracion tipicamente se nombran con un timestamp o numero de secuencia para que la herramienta sepa en que orden ejecutarlos:
migrations/
20260601120000_create_users_table/
migration.sql
20260602090000_add_email_to_orders/
migration.sql
20260603140000_create_audit_log/
migration.sql
La herramienta de migracion lleva registro de cuales migraciones fueron aplicadas en una tabla
especial (generalmente llamada _prisma_migrations o schema_migrations). Cuando ejecutas
migrate, chequea cuales migraciones estan pendientes y las aplica en orden. Esto te da un
historial completo y auditable de cada cambio de esquema, la capacidad de reproducir tu esquema
de base de datos desde cero, y un mecanismo para revertir cambios cuando sea necesario.
Configurando migraciones con Prisma
Vamos a usar Prisma como nuestro ORM y herramienta de migracion de TypeScript. Prisma tiene un enfoque diferente al de las herramientas de migracion tradicionales: vos definis tu esquema en un archivo declarativo, y Prisma genera las migraciones SQL por vos.
Primero, instala Prisma en tu proyecto:
npm install prisma --save-dev
npm install @prisma/client
# Inicializar Prisma con PostgreSQL
npx prisma init --datasource-provider postgresql
Esto crea un archivo prisma/schema.prisma. Definamos nuestro primer modelo:
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id Int @id @default(autoincrement())
name String
email String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
orders Order[]
}
model Order {
id Int @id @default(autoincrement())
amount Float
status String @default("pending")
userId Int
user User @relation(fields: [userId], references: [id])
createdAt DateTime @default(now())
}
Ahora genera la primera migracion:
npx prisma migrate dev --name create_users_and_orders
Prisma compara tu archivo de esquema con el estado actual de la base de datos, genera el SQL, y lo aplica. La migracion generada se ve asi:
-- CreateTable
CREATE TABLE "User" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"email" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Order" (
"id" SERIAL NOT NULL,
"amount" DOUBLE PRECISION NOT NULL,
"status" TEXT NOT NULL DEFAULT 'pending',
"userId" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Order_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- AddForeignKey
ALTER TABLE "Order" ADD CONSTRAINT "Order_userId_fkey"
FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
Ahora agreguemos una columna. Supongamos que necesitamos un campo phone en el modelo User:
model User {
id Int @id @default(autoincrement())
name String
email String @unique
phone String? // nullable, asi las filas existentes estan bien
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
orders Order[]
}
npx prisma migrate dev --name add_phone_to_users
El SQL generado:
-- AlterTable
ALTER TABLE "User" ADD COLUMN "phone" TEXT;
Fijate que la columna es nullable (TEXT sin NOT NULL). Esto es importante. Si la hicieramos
obligatoria, la migracion fallaria porque las filas existentes no tendrian un valor para la nueva
columna. Hacer las columnas nuevas nullables (o darles un valor por defecto) es uno de los patrones
mas basicos de migraciones seguras.
Ahora renombremos una columna. Supongamos que queremos renombrar name a fullName. En Prisma,
usas el atributo @map para renombrar la columna de la base de datos sin cambiar el nombre del
campo en Prisma:
model User {
id Int @id @default(autoincrement())
fullName String @map("full_name")
email String @unique
phone String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
orders Order[]
@@map("users")
}
Pero pará. Si simplemente renombramos la columna directamente, cualquier codigo de la aplicacion que todavia consulte el nombre viejo de la columna se va a romper. Este es exactamente el tipo de operacion peligrosa que necesitamos manejar con el patron expand-contract, que vamos a cubrir a continuacion.
Patrones de migracion seguros
La regla de oro de las migraciones seguras es: nunca hagas un cambio que rompa cosas en un solo deploy. En cambio, dividilo en multiples pasos donde cada paso es retrocompatible.
Estos son los patrones que deberias seguir:
Agregar una columna (seguro)
Agregar una columna nullable o una columna con valor por defecto siempre es seguro. El codigo viejo ignora la nueva columna. El codigo nuevo puede usarla.
-- Seguro: columna nullable, el codigo viejo la ignora
ALTER TABLE "User" ADD COLUMN "phone" TEXT;
-- Seguro: columna con default, el codigo viejo la ignora
ALTER TABLE "Order" ADD COLUMN "currency" TEXT NOT NULL DEFAULT 'USD';
Agregar un indice (seguro, pero cuidado con el tiempo de lock)
Crear un indice es seguro desde el punto de vista de compatibilidad, pero en tablas grandes puede
lockear la tabla por mucho tiempo. Usa CONCURRENTLY en PostgreSQL para evitar bloquear escrituras:
-- Esto lockea la tabla hasta que el indice se construye (peligroso en tablas grandes)
CREATE INDEX idx_orders_user_id ON "Order" ("userId");
-- Esto construye el indice sin lockear (seguro para produccion)
CREATE INDEX CONCURRENTLY idx_orders_user_id ON "Order" ("userId");
Crear una tabla nueva (seguro)
Las tablas nuevas no afectan al codigo existente para nada. Siempre seguro.
Dropear una columna (peligroso si se hace mal)
Si dropeas una columna que el codigo viejo todavia lee, esas queries van a fallar. Nunca dropees una columna en el mismo deploy que todavia la referencia.
Renombrar una columna (peligroso si se hace en un solo paso)
Un rename de columna es esencialmente un drop mas un add desde la perspectiva de la aplicacion. El codigo viejo consulta el nombre viejo. El codigo nuevo consulta el nombre nuevo. Durante un rolling update, ambas versiones corren simultaneamente, y una de ellas siempre va a estar rota.
El patron expand-contract
El patron expand-contract es la forma estandar de hacer cambios de esquema que rompen cosas de manera segura. Funciona en tres fases:
Fase 1: Expand (agregar lo nuevo)
Agrega la nueva columna al lado de la vieja. Actualiza tu codigo para escribir en ambas columnas. Desplegá este cambio.
-- Migracion 1: Agregar la nueva columna
ALTER TABLE "users" ADD COLUMN "full_name" TEXT;
// El codigo de la aplicacion escribe en ambas columnas
async function updateUser(id: number, name: string) {
await prisma.user.update({
where: { id },
data: {
name: name, // columna vieja (para codigo viejo que todavia la lee)
fullName: name, // columna nueva (para codigo nuevo)
},
});
}
Fase 2: Migrar datos
Backfillá la nueva columna con datos de la columna vieja. Esto puede ser un script de migracion o un job en background.
-- Migracion 2: Backfill de datos existentes
UPDATE "users" SET "full_name" = "name" WHERE "full_name" IS NULL;
En este punto, ambas columnas tienen los mismos datos. El codigo viejo lee de name, el codigo
nuevo lee de full_name, y todo funciona.
Fase 3: Contract (remover lo viejo)
Una vez que todo el codigo de la aplicacion fue actualizado para usar la nueva columna, y verificaste que ninguna query referencia la columna vieja, podes dropearla.
-- Migracion 3: Dropear la columna vieja (solo despues de que todo el codigo use full_name)
ALTER TABLE "users" DROP COLUMN "name";
Este enfoque de tres fases significa que en cada punto durante el rollout, el esquema de la base de datos es compatible con ambas versiones (vieja y nueva) de tu codigo. Sin downtime, sin errores, sin perdida de datos.
Aca está la linea de tiempo:
# Linea de tiempo expand-contract
#
# Deploy 1: Agregar columna "full_name", escribir en ambas columnas
# Codigo viejo: lee "name" -> funciona (la columna todavia existe)
# Codigo nuevo: lee "full_name" -> funciona (la columna recien se agrego)
#
# Deploy 2: Backfill "full_name" desde "name"
# Todas las filas ahora tienen ambas columnas pobladas
#
# Deploy 3: Remover todas las lecturas de "name", dropear columna
# Codigo viejo: ya no existe (rollout completo)
# Codigo nuevo: lee "full_name" -> funciona (unica columna restante)
Si, esto toma tres deploys en lugar de uno. Ese es el trade-off. La seguridad cuesta velocidad, pero te salva de incidentes a las 3 de la mañana.
Patrones peligrosos a evitar
Estos son los cambios de esquema que causan la mayor cantidad de caidas, y como manejarlos en su lugar:
- Renombrar una columna en un solo deploy: Es un drop mas un add. Usa expand-contract en su lugar. Agrega la nueva columna, backfillá, actualizá el codigo, y despues dropeá la columna vieja.
- Cambiar un tipo de columna in-place: Cambiar
VARCHAR(50)aTEXTpuede parecer inofensivo, pero cambiarTEXTaINTEGERva a fallar si alguna fila contiene datos no numericos. Agrega una nueva columna con el tipo nuevo, backfillá con conversion de tipo, cambiá el codigo, y despues dropeá la columna vieja.- Agregar una restriccion NOT NULL sin default: Si le agregas
NOT NULLa una columna existente que tiene valores null, la migracion va a fallar. Primero backfillá todos los nulls, despues agrega la restriccion.- Dropear una tabla que todavia es referenciada: Las restricciones de foreign key van a bloquear el drop, pero el codigo de la aplicacion se va a romper. Primero removélas del codigo, despues dropeá.
- Ejecutar migraciones de datos grandes en la transaccion principal: Actualizar millones de filas en una sola transaccion lockea la tabla y puede causar timeouts. Procesá tus updates en lotes (1000-5000 filas por vez) con pausas pequeñas entre lotes.
Una regla util: si una migracion no se puede revertir con un simple “undo” de migracion, es peligrosa y necesita el tratamiento expand-contract.
Deployments sin downtime en Kubernetes
Ahora que sabemos como manejar cambios de base de datos de forma segura, veamos la otra mitad del rompecabezas: desplegar codigo de la aplicacion sin tirar requests. Kubernetes te da varios mecanismos para esto.
Rolling updates
La estrategia de deployment por defecto en Kubernetes es RollingUpdate. Reemplaza gradualmente
pods viejos con pods nuevos, asegurandose de que siempre haya algunos pods disponibles para
servir trafico.
apiVersion: apps/v1
kind: Deployment
metadata:
name: myapp
spec:
replicas: 3
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1 # Como maximo 1 pod extra durante el rollout
maxUnavailable: 0 # Nunca tener menos de 3 pods sanos
selector:
matchLabels:
app: myapp
template:
metadata:
labels:
app: myapp
spec:
containers:
- name: myapp
image: myapp:v2
ports:
- containerPort: 3000
Con maxSurge: 1 y maxUnavailable: 0, Kubernetes va a:
- Paso 1: Crear 1 pod nuevo (v2). Ahora tenes 3 viejos + 1 nuevo = 4 pods en total.
- Paso 2: Esperar hasta que el pod nuevo pase su readiness probe.
- Paso 3: Terminar 1 pod viejo (v1). Ahora tenes 2 viejos + 1 nuevo = 3 pods.
- Paso 4: Crear otro pod nuevo (v2). Ahora tenes 2 viejos + 2 nuevos = 4 pods.
- Repetir hasta que todos los pods esten corriendo v2.
Durante este proceso, tanto los pods v1 como v2 estan sirviendo trafico. Esta es exactamente la razon por la que tu esquema de base de datos debe ser compatible con ambas versiones.
La estrategia Recreate
La estrategia Recreate mata todos los pods viejos antes de crear los nuevos. Esto implica
downtime, asi que solo deberias usarla cuando tu aplicacion no puede correr dos versiones
simultaneamente (por ejemplo, si mantiene un lock exclusivo sobre un recurso).
strategy:
type: Recreate
Para casi todas las aplicaciones web, RollingUpdate es lo que queres.
Health checks: liveness, readiness, y startup probes
Los probes son la forma en que Kubernetes sabe si tu pod esta sano. Hay tres tipos, y cada uno sirve para un proposito diferente:
- Readiness probe: “Este pod esta listo para recibir trafico?” Kubernetes solo envia trafico a pods que pasan su readiness probe. Durante un deployment, los pods nuevos no van a recibir trafico hasta que esten listos. Este es el probe mas importante para deployments sin downtime.
- Liveness probe: “Este pod sigue vivo?” Si un pod falla su liveness probe, Kubernetes lo reinicia. Usalo para recuperarte de deadlocks o procesos trabados. Cuidado: si tu liveness probe es muy agresivo, Kubernetes va a reiniciar pods que simplemente estan lentos, creando un crash loop.
- Startup probe: “Este pod termino de arrancar?” Esto es para aplicaciones con un arranque lento (cargando caches grandes, corriendo migraciones). El startup probe corre primero, y los liveness/readiness probes no arrancan hasta que pase.
Asi se configuran los tres:
containers:
- name: myapp
image: myapp:v2
ports:
- containerPort: 3000
readinessProbe:
httpGet:
path: /health/ready
port: 3000
initialDelaySeconds: 5
periodSeconds: 10
failureThreshold: 3
livenessProbe:
httpGet:
path: /health/live
port: 3000
initialDelaySeconds: 15
periodSeconds: 20
failureThreshold: 3
startupProbe:
httpGet:
path: /health/started
port: 3000
failureThreshold: 30
periodSeconds: 10
Y asi se ven los endpoints de health check en tu API de TypeScript:
// Endpoints de health check
app.get("/health/live", (req, res) => {
// Liveness: el proceso esta corriendo?
// Mantenelo simple. Si este endpoint responde, el proceso esta vivo.
res.status(200).json({ status: "alive" });
});
app.get("/health/ready", async (req, res) => {
// Readiness: este pod puede servir trafico?
// Chequear que todas las dependencias sean alcanzables.
try {
await prisma.$queryRaw`SELECT 1`; // la base de datos es alcanzable
res.status(200).json({ status: "ready" });
} catch (error) {
res.status(503).json({ status: "not ready", error: "database unreachable" });
}
});
app.get("/health/started", (req, res) => {
// Startup: la app termino de inicializar?
if (appIsInitialized) {
res.status(200).json({ status: "started" });
} else {
res.status(503).json({ status: "starting" });
}
});
Un error comun es hacer el liveness probe demasiado estricto. Si tu liveness probe chequea la base de datos, y la base de datos tiene un breve problema de red, Kubernetes va a reiniciar todos tus pods de una, empeorando mucho la situacion. Mantene los liveness probes simples (solo “el proceso esta corriendo?”) y usa readiness probes para chequeos de dependencias.
Shutdown graceful: preStop hooks y connection draining
Cuando Kubernetes termina un pod durante un rolling update, envia una señal SIGTERM. Tu
aplicacion deberia capturar esta señal y dejar de aceptar requests nuevos mientras termina los
requests en curso. Pero hay una race condition: Kubernetes remueve el pod de los endpoints del
servicio al mismo tiempo que envia SIGTERM, y la remocion del endpoint tarda un momento en
propagarse. Durante esa ventana, el trafico puede seguir siendo ruteado a un pod que se esta
apagando.
La solucion es un hook preStop que agrega una pequeña demora:
containers:
- name: myapp
image: myapp:v2
lifecycle:
preStop:
exec:
command: ["sh", "-c", "sleep 10"]
Esto le dice a Kubernetes que espere 10 segundos antes de enviar SIGTERM. Durante esos 10
segundos, el pod es removido de los endpoints del servicio, asi que no se rutea trafico nuevo hacia
el. Despues del sleep, se envia SIGTERM y la aplicacion puede apagarse de forma graceful.
En tu aplicacion TypeScript, manejá la señal de shutdown:
// Handler de shutdown graceful
process.on("SIGTERM", () => {
console.log("SIGTERM recibido. Iniciando shutdown graceful...");
// Dejar de aceptar conexiones nuevas
server.close(() => {
console.log("Servidor HTTP cerrado. Limpiando...");
// Cerrar conexiones de base de datos
prisma.$disconnect().then(() => {
console.log("Base de datos desconectada. Saliendo.");
process.exit(0);
});
});
// Forzar salida despues de 30 segundos si el shutdown graceful se traba
setTimeout(() => {
console.error("Shutdown graceful excedio el timeout. Forzando salida.");
process.exit(1);
}, 30000);
});
Tambien, configura terminationGracePeriodSeconds en el spec del pod para darle a tu aplicacion
suficiente tiempo para drenar. El default son 30 segundos, pero ajustalo segun cuanto tardan tus
requests mas largos:
spec:
terminationGracePeriodSeconds: 60
containers:
- name: myapp
# ...
PodDisruptionBudgets
Un PodDisruptionBudget (PDB) le dice a Kubernetes cuantos pods deben permanecer disponibles durante disrupciones voluntarias como drains de nodos, upgrades de cluster, o scale-downs del autoscaler. Sin un PDB, Kubernetes podria drenar todos tus nodos de una durante un upgrade de cluster, tirando abajo todos los pods simultaneamente.
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: myapp-pdb
spec:
minAvailable: 2 # Al menos 2 pods deben estar corriendo siempre
selector:
matchLabels:
app: myapp
Tambien podes usar maxUnavailable en lugar de minAvailable:
spec:
maxUnavailable: 1 # Como maximo 1 pod puede estar caido a la vez
Para un deployment con 3 replicas, minAvailable: 2 y maxUnavailable: 1 son equivalentes. Usa
el que se lea mas claro para tu equipo.
Deployments blue-green
Los deployments blue-green toman un enfoque diferente a los rolling updates. En lugar de reemplazar pods gradualmente, corres dos entornos completos simultaneamente: el entorno “blue” (produccion actual) y el entorno “green” (version nueva). Una vez que el entorno green esta validado, cambias el trafico de blue a green en un solo paso.
Asi se implementa blue-green con servicios de Kubernetes:
# Deployment Blue (produccion actual, corriendo v1)
apiVersion: apps/v1
kind: Deployment
metadata:
name: myapp-blue
spec:
replicas: 3
selector:
matchLabels:
app: myapp
version: blue
template:
metadata:
labels:
app: myapp
version: blue
spec:
containers:
- name: myapp
image: myapp:v1
---
# Deployment Green (version nueva, corriendo v2)
apiVersion: apps/v1
kind: Deployment
metadata:
name: myapp-green
spec:
replicas: 3
selector:
matchLabels:
app: myapp
version: green
template:
metadata:
labels:
app: myapp
version: green
spec:
containers:
- name: myapp
image: myapp:v2
---
# Service (apunta a blue inicialmente)
apiVersion: v1
kind: Service
metadata:
name: myapp
spec:
selector:
app: myapp
version: blue # Cambialo a "green" para switchear el trafico
ports:
- port: 80
targetPort: 3000
Para switchear el trafico, actualizas el selector del servicio de version: blue a
version: green. Todo el trafico se mueve de una. Si algo esta mal, switcheas de vuelta a blue.
- Cuando usar blue-green: Cuando necesitas rollback instantaneo (un cambio de label), cuando queres validar la version nueva con patrones de trafico real antes de commitear, o cuando tu aplicacion no tolera versiones mezcladas.
- Cuando quedarse con rolling updates: Para la mayoria de los deployments estandar. Blue-green requiere el doble de recursos (ambos entornos corren simultaneamente) y agrega complejidad operacional.
Estrategias de rollback
Sin importar lo cuidadoso que seas, las cosas van a salir mal. Tener un plan de rollback no es opcional. Estas son las tres estrategias principales:
Rollback de Kubernetes
Kubernetes mantiene un historial de tus deployments. Podes hacer rollback a una version anterior con un solo comando:
# Ver historial de rollout
kubectl rollout history deployment/myapp
# Rollback a la version anterior
kubectl rollout undo deployment/myapp
# Rollback a una revision especifica
kubectl rollout undo deployment/myapp --to-revision=3
# Ver el progreso del rollback
kubectl rollout status deployment/myapp
Esto solo hace rollback del codigo de la aplicacion (la imagen del contenedor). No hace rollback de las migraciones de base de datos. Si tu migracion fue aditiva (agregar una columna), el codigo viejo simplemente ignora la nueva columna, y no hay nada que revertir. Si tu migracion fue destructiva (dropear una columna), necesitas un rollback de base de datos.
Rollback de base de datos
Prisma no tiene un comando built-in de “deshacer ultima migracion” para produccion. En produccion, escribis una nueva migracion que revierte el cambio:
# En desarrollo, podes resetear (destruye todos los datos)
npx prisma migrate reset
# En produccion, crea una nueva migracion de "undo"
npx prisma migrate dev --name undo_add_phone_to_users
La migracion de “undo” es simplemente otra migracion que revierte el cambio anterior:
-- Migracion de undo: remover la columna phone
ALTER TABLE "User" DROP COLUMN "phone";
Esta es otra razon para preferir migraciones aditivas. Agregar una columna es facil de deshacer (la dropeas). Dropear una columna es imposible de deshacer (los datos se perdieron). Si seguis el patron expand-contract, tu “undo” siempre es simplemente “dropear la columna que agregaste.”
Feature flags como alternativa a los rollbacks
En lugar de hacer rollback de codigo o cambios de base de datos, podes usar feature flags para deshabilitar la funcionalidad nueva sin cambiar el codigo desplegado:
// Chequeo de feature flag
app.get("/api/orders", async (req, res) => {
const orders = await prisma.order.findMany({
include: {
user: true,
},
});
if (featureFlags.isEnabled("show-order-currency")) {
// Comportamiento nuevo: incluir campo currency
return res.json(orders.map(o => ({
...o,
currency: o.currency ?? "USD",
})));
}
// Comportamiento viejo: sin campo currency
return res.json(orders);
});
Los feature flags te permiten desacoplar el deployment del release. Desplegás el codigo (incluyendo la migracion), pero la funcionalidad nueva esta detras de un flag. Si algo sale mal, apagás el flag. No necesitas rollback, ni redesplegar, ni deshacer nada en la base de datos.
Ejemplo practico: agregar una columna bajo trafico de produccion
Juntemos todo con un escenario real. Necesitamos agregar una columna currency a la tabla Order.
La API esta sirviendo trafico, y no podemos permitirnos ningun downtime.
Paso 1: Escribir la migracion
Actualiza el esquema de Prisma:
model Order {
id Int @id @default(autoincrement())
amount Float
status String @default("pending")
currency String @default("USD") // nueva columna con default
userId Int
user User @relation(fields: [userId], references: [id])
createdAt DateTime @default(now())
}
Genera y revisa la migracion:
npx prisma migrate dev --name add_currency_to_orders
SQL generado:
ALTER TABLE "Order" ADD COLUMN "currency" TEXT NOT NULL DEFAULT 'USD';
Esto es seguro porque la columna tiene un valor por defecto, asi que las filas existentes reciben
'USD' automaticamente, y el codigo viejo que no conoce la columna simplemente la ignora.
Paso 2: Actualizar el codigo de la aplicacion
Actualiza la API para usar la nueva columna:
// Endpoint de creacion de ordenes actualizado
app.post("/api/orders", async (req, res) => {
const { amount, userId, currency } = req.body;
const order = await prisma.order.create({
data: {
amount,
userId,
currency: currency ?? "USD", // usar currency provista o default
},
});
res.status(201).json(order);
});
Paso 3: Correr la migracion en CI/CD
Agrega un paso de migracion a tu pipeline de CI/CD que corra antes del deployment de la aplicacion:
# En tu workflow de GitHub Actions
jobs:
migrate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Install dependencies
run: npm ci
- name: Run database migrations
run: npx prisma migrate deploy
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
deploy:
needs: migrate # Desplegar solo despues de que las migraciones pasen
runs-on: ubuntu-latest
steps:
- name: Update deployment image
run: |
kubectl set image deployment/myapp \
myapp=myapp:${{ github.sha }}
- name: Wait for rollout
run: kubectl rollout status deployment/myapp --timeout=300s
Paso 4: Verificar
Despues del deploy, verifica que todo este funcionando:
# Chequear que la migracion fue aplicada
npx prisma migrate status
# Testear la API
curl -X POST https://api.example.com/api/orders \
-H "Content-Type: application/json" \
-d '{"amount": 29.99, "userId": 1, "currency": "EUR"}'
# Verificar que la respuesta incluya el currency
curl https://api.example.com/api/orders/1
Como usamos un cambio aditivo con valor por defecto, todo el proceso fue sin downtime. Los pods
viejos que no conocen la columna currency siguieron sirviendo trafico mientras los pods nuevos
se desplegaban. Sin conflictos, sin errores, sin interrupcion.
Checklist de migraciones
Antes de correr cualquier migracion en produccion, pasa por este checklist:
- La migracion es aditiva? Agregar columnas (nullables o con defaults), agregar tablas, y agregar indices son seguros. Todo lo demas necesita cuidado extra.
- El codigo viejo puede funcionar con el nuevo esquema? Durante un rolling update, codigo viejo y nuevo corren simultaneamente. Asegurate de que el codigo viejo no se rompa.
- El codigo nuevo puede funcionar con el esquema viejo? Si la migracion falla o se atrasa, el codigo nuevo puede seguir funcionando?
- Probaste la migracion en una copia de datos de produccion? Tu base de datos de desarrollo tiene 100 filas. Produccion tiene 10 millones. Lo que tarda 1 segundo en dev puede tardar 10 minutos en produccion.
- Tenes un plan de rollback? Que SQL ejecutarias para deshacer esta migracion? Escribilo antes de desplegar.
- Estas usando
CONCURRENTLYpara crear indices? En tablas grandes, la creacion de indices lockea la tabla. UsaCREATE INDEX CONCURRENTLYen PostgreSQL.- Estas procesando migraciones de datos grandes en lotes? No actualices millones de filas en una sola transaccion. Hacelo en lotes.
Que viene despues
Ahora sabemos como hacer cambios de base de datos de forma segura, desplegar codigo de la aplicacion sin downtime, y hacer rollback cuando las cosas salen mal. En el proximo articulo, vamos a explorar la seguridad en el pipeline de CI/CD: escaneo de vulnerabilidades, gestion de secretos, y hardening de tu proceso de deployment.
Espero que te haya resultado util y que lo hayas disfrutado, hasta la proxima!
Errata
Si encontras algun error o tenes alguna sugerencia, por favor mandame un mensaje para que lo pueda corregir.
Tambien, podes ver el codigo fuente y los cambios en los fuentes aca
$ Comentarios
Online: 0Por favor inicie sesión para poder escribir comentarios.