Queda el cuarto frente, y es el que más despliegues rompe. Todo lo construido en tres módulos descansa sobre una propiedad que el esquema de la base de datos no tiene: el artefacto reservalia/api:a3f9c21 es inmutable y se sustituye por el anterior en cuatro minutos, pero una columna borrada no vuelve. El rollback.yml de la 03-05 sabe volver a un digest anterior; no sabe deshacer un DROP COLUMN, y por eso la propia lección lo dejó escrito como el único motivo por el que el botón de vuelta atrás podría no funcionar. Esta lección cierra ese hueco y, con él, el módulo. Veremos por qué el esquema es estado compartido y qué implica eso; cómo se versionan las migraciones y por qué se ejecutan desde el pipeline y nunca desde el portátil de Diego; desarrollaremos paso a paso el patrón expand and contract sobre un caso real —renombrar hora_inicio a inicio_utc sin cortar el servicio—; clasificaremos los cambios de esquema en seguros, peligrosos y prohibidos en caliente; entenderemos por qué un ALTER TABLE puede tumbar producción y cómo evitarlo; colocaremos la migración en su sitio exacto dentro de cd.yml; y terminaremos con los datos de prueba, la anonimización y una advertencia de cumplimiento normativa que conviene leer entera.
Contenido
- El esquema es estado compartido y no se revierte
- Migraciones versionadas: convención, herramienta y tabla de control
- Por qué se ejecutan desde el pipeline y nunca desde un portátil
- Expand and contract: el caso
hora_inicio→inicio_utc - Clasificación de los cambios de esquema
- Migraciones y bloqueos: cómo un
ALTER TABLEtumba producción - Backfill por lotes sin saturar la base
- Dónde encaja la migración en
cd.yml - Datos de prueba y anonimización
- Errores Comunes y Consejos
- Ejercicios
- Conclusión y cierre del módulo
- El esquema es estado compartido y no se revierte
Compara las dos mitades del sistema que Reservalia despliega:
El artefacto (reservalia/api) |
El esquema (RDS PostgreSQL) | |
|---|---|---|
| Naturaleza | Inmutable, identificado por digest; una copia por tarea ECS | Mutable y único por entorno |
| Volver atrás | Desplegar el digest anterior: 4 min | Depende del cambio; a veces imposible |
| Si sale mal | Se sustituye y no queda rastro | Los datos perdidos no vuelven |
| Quién lo comparte | Nadie: cada tarea tiene el suyo | Todas las versiones del código a la vez |
La última fila es la clave de toda la lección. Durante un rolling update de la 03-04 —o durante un canary al 10 %— conviven en producción dos versiones del código hablando con una sola base de datos. Si la migración deja el esquema en un estado que la versión antigua no entiende, la mitad de las peticiones falla mientras dura el despliegue. Y si hay que revertir el código, la versión antigua se encontrará un esquema que ya no es el suyo. Por eso rollback.yml no salva una migración destructiva, y conviene ver el caso concreto. Supón un despliegue que ejecuta ALTER TABLE citas DROP COLUMN hora_inicio y despliega el código nuevo. Diez minutos después se detecta un fallo grave y Nuria lanza el rollback: la imagen anterior vuelve en cuatro minutos… y falla en cada petición, porque su código pregunta por una columna que ya no existe. El rollback del artefacto se ha completado con éxito y el servicio sigue caído. Restaurar una copia de seguridad tampoco es la respuesta: significa perder todas las reservas creadas desde el DROP.
Nuria: "Un despliegue que no se puede deshacer en cinco minutos no es un despliegue, es una apuesta." Y una migración destructiva convierte cualquier despliegue en eso.
La conclusión que ordena el resto: el esquema debe cambiar de forma que ninguna versión desplegada se rompa, ni la nueva ni la anterior. Eso no es una restricción del pipeline, es una restricción de diseño de cada cambio.
- Migraciones versionadas: convención, herramienta y tabla de control
Una migración es un cambio de esquema escrito como un fichero versionado en el repositorio, no como un comando tecleado en psql. Reservalia las guarda en apps/api/src/db/migraciones/ —la ruta que ya aparecía en el CODEOWNERS de la 02-07, exigiendo revisión de Marta o Nuria— con esta convención de nombres:
apps/api/src/db/migraciones/
20260715093000_expandir_inicio_utc.sql
20260716101500_backfill_inicio_utc.sql
20260722084500_contraer_hora_inicio.sqlMarca de tiempo + descripción en snake_case. La marca ordena la ejecución de forma determinista y evita el problema clásico de los números correlativos: dos personas que trabajan a la vez crean 005_ y 005_, y el conflicto solo aparece al fusionar. Con marcas de tiempo, dos migraciones simultáneas se ordenan solas. La herramienta —node-pg-migrate en Reservalia, pero el mecanismo es universal (Flyway, Liquibase, Alembic, ActiveRecord)— mantiene una tabla de control dentro de la propia base de datos:
CREATE TABLE IF NOT EXISTS migraciones_aplicadas (
nombre text PRIMARY KEY, -- 1 · el nombre del fichero
hash text NOT NULL, -- 2 · huella del contenido
aplicada_en timestamptz NOT NULL DEFAULT now(),
duracion_ms integer NOT NULL
);- El nombre como clave primaria es lo que hace la migración idempotente: al arrancar, la herramienta compara los ficheros del repositorio con las filas de esta tabla y ejecuta solo lo que falta. Volver a lanzar el job no repite nada, que es el requisito de la 03-02.
- El hash del contenido detecta el error más peligroso: que alguien edite una migración ya aplicada. Si el hash del fichero no coincide con el registrado, la herramienta se detiene con un error explícito. Una migración aplicada es historia: se corrige con una migración nueva, nunca editando la anterior, porque el entorno donde ya se ejecutó no volverá a leerla.
- Y
duracion_msno está por curiosidad. Una migración que enstagingtarda 200 ms y enprodtarda 40 segundos está diciendo que la tabla es mucho mayor y que el bloqueo del apartado 6 va a doler.
- Por qué se ejecutan desde el pipeline y nunca desde un portátil
En la 01-04, el ritual del viernes incluía a Diego lanzando migraciones a mano en psql conectado a producción. Eso desapareció, y merece la pena enumerar por qué no debe volver ni siquiera "solo esta vez":
| Migración a mano | Migración desde el pipeline |
|---|---|
| Nadie sabe qué se ejecutó exactamente | Queda el fichero, el commit y el log |
| El orden depende de la memoria de una persona | Lo determina la tabla de control |
staging y prod divergen sin que nadie lo note |
Los tres entornos ejecutan lo mismo |
| Requiere credenciales de producción en un portátil | El pipeline usa OIDC, sin claves permanentes |
| No hay revisión previa, y si falla se improvisa | Pasa por PR con CODEOWNERS, con procedimiento escrito |
El argumento decisivo es el tercero. La deriva entre entornos de la 03-03 —el problema que resolvió Terraform para la infraestructura— existe también en el esquema, y es peor: una migración que en staging se ejecutó con una variante y en prod con otra hace que las pruebas de staging dejen de significar nada, y nadie lo descubre hasta el día del incidente. Si el esquema no está bajo control de versiones, no tienes entornos equivalentes por mucho Terraform que escribas.
- Expand and contract: el caso
hora_inicio → inicio_utc
hora_inicio → inicio_utcEl problema real de Reservalia: la columna citas.hora_inicio es un timestamp sin zona horaria, y con negocios que empiezan a operar fuera de la Península los cálculos de calcularHuecos se están equivocando en los cambios de hora. Hay que pasar a inicio_utc de tipo timestamptz. Es, en apariencia, un ALTER TABLE ... RENAME COLUMN. Y hacerlo así rompería el servicio: en el instante del renombrado, todas las tareas que aún corren la versión antigua empiezan a fallar.
El patrón expand and contract (expandir y contraer) resuelve esto convirtiendo un cambio incompatible en una secuencia de cambios compatibles. Cuatro fases, cada una desplegable y reversible por separado:
flowchart TD
F1["FASE 1 · Expandir<br/>añadir inicio_utc, nullable<br/>+ trigger de sincronía"] --> F2["FASE 2 · Migrar datos<br/>backfill por lotes"]
F2 --> F3["FASE 3 · Desplegar código<br/>lee y escribe inicio_utc"]
F3 --> F4["FASE 4 · Contraer<br/>quitar trigger y DROP hora_inicio"]
F1 -.->|"código viejo sigue OK"| F2
F3 -.->|"esperar días, verificar"| F4
Fase 1 · Expandir. Se añade la columna nueva sin tocar la vieja, y un trigger mantiene las dos sincronizadas mientras convivan:
-- 20260715093000_expandir_inicio_utc.sql
ALTER TABLE citas ADD COLUMN inicio_utc timestamptz; -- 1 · nullable, sin default
CREATE OR REPLACE FUNCTION sincronizar_inicio() RETURNS trigger AS $$
BEGIN
IF NEW.inicio_utc IS NULL AND NEW.hora_inicio IS NOT NULL THEN
NEW.inicio_utc := NEW.hora_inicio AT TIME ZONE 'Europe/Madrid'; -- 2
ELSIF NEW.hora_inicio IS NULL AND NEW.inicio_utc IS NOT NULL THEN
NEW.hora_inicio := NEW.inicio_utc AT TIME ZONE 'Europe/Madrid'; -- 3
END IF;
RETURN NEW;
END $$ LANGUAGE plpgsql;
CREATE TRIGGER trg_sincronizar_inicio
BEFORE INSERT OR UPDATE ON citas FOR EACH ROW EXECUTE FUNCTION sincronizar_inicio();- Nullable y sin valor por defecto: es lo que hace que este
ALTER TABLEsea instantáneo y no reescriba la tabla (apartado 6). - El código viejo sigue escribiendo
hora_inicioy el trigger rellenainicio_utc. La versión antigua no se entera de nada. - El código nuevo escribirá
inicio_utcy el trigger rellenaráhora_inicio, de modo que si hay que revertir el código, la columna vieja está al día. Esta dirección del trigger es la que hace el rollback seguro, y es justamente la que se olvida. Tras esta migración conviven sin problema el código viejo, que escribehora_inicio, y el nuevo cuando se despliegue, que escribiráinicio_utc.
Fase 2 · Migrar los datos. El trigger solo cubre las filas que se tocan; las 640.000 citas históricas hay que rellenarlas con un backfill por lotes (apartado 7). Al terminar, inicio_utc está completa. Fase 3 · Desplegar el código nuevo. Ahora, y no antes, apps/api/src/dominio/agenda.ts pasa a leer y escribir inicio_utc. El despliegue es un rolling update normal de la 03-04: durante unos minutos conviven las dos versiones, y las dos funcionan porque ambas columnas están completas y sincronizadas. Y aquí está la propiedad que justifica todo el patrón: si el canary detecta un problema, rollback.yml funciona sin tocar la base de datos.
Fase 4 · Contraer. Solo cuando han pasado varios días, el código nuevo está estable en prod y se ha verificado que nadie lee ya la columna vieja:
-- 20260722084500_contraer_hora_inicio.sql
DROP TRIGGER IF EXISTS trg_sincronizar_inicio ON citas;
DROP FUNCTION IF EXISTS sincronizar_inicio();
ALTER TABLE citas ALTER COLUMN inicio_utc SET NOT NULL; -- 1
ALTER TABLE citas DROP COLUMN hora_inicio; -- 2- La restricción
NOT NULLse añade al final, cuando ya se sabe que no hay nulos. Ponerla en la fase 1 habría roto todas las escrituras del código viejo. - Este es el único paso irreversible del proceso, y por eso va en una migración aparte, una semana después y con su propia revisión. Antes de ejecutarlo conviene una comprobación objetiva: buscar
hora_inicioen el código desplegado y consultar en los logs si alguna consulta la menciona en los últimos siete días.
El coste es real y hay que decirlo: cuatro pull requests y una semana para lo que parecía un renombrado de un minuto. A cambio, cero segundos de indisponibilidad y un rollback posible en todo momento salvo en los cinco segundos finales de la fase 4. Para una tabla con 640.000 filas y 340 negocios de pago, el intercambio es evidente.
- Clasificación de los cambios de esquema
| Cambio | Categoría | Por qué | Alternativa segura |
|---|---|---|---|
ADD COLUMN nullable, sin default |
Seguro | Metadatos: instantáneo | — |
CREATE TABLE nueva · CREATE INDEX CONCURRENTLY |
Seguro | Nadie la usa aún · no bloquea escrituras | — |
ADD COLUMN con default volátil |
Peligroso | Reescribe la tabla entera bajo bloqueo | Añadir nullable + backfill por lotes |
CREATE INDEX sin CONCURRENTLY |
Peligroso | Bloquea escrituras mientras dura | Usar CONCURRENTLY |
ALTER COLUMN ... TYPE |
Peligroso | Reescribe y bloquea | Columna nueva + expand/contract |
ADD CONSTRAINT (FK, CHECK) o SET NOT NULL |
Peligroso | Recorre y valida toda la tabla bajo bloqueo | NOT VALID y después VALIDATE CONSTRAINT |
DROP COLUMN en uso |
Prohibido en caliente | Rompe el código antiguo; irreversible | Expand and contract |
RENAME COLUMN o RENAME TABLE |
Prohibido en caliente | Ninguna versión sobrevive al instante del cambio | Expand and contract |
DROP TABLE |
Prohibido en caliente | Irreversible y sin rollback | Dejar de usarla, esperar, y borrar |
Dos matices sobre las filas más traicioneras. ADD COLUMN con default depende de la versión y del tipo de default: PostgreSQL 11 y posteriores manejan un default constante sin reescribir la tabla, pero un default volátil —now(), gen_random_uuid()— sigue obligando a reescribir cada fila bajo un bloqueo exclusivo. Y el truco de NOT VALID vale para casi todas las restricciones: se añade la restricción sin validar lo existente (instantáneo, y ya aplica a las filas nuevas) y después se ejecuta VALIDATE CONSTRAINT, que recorre la tabla con un bloqueo mucho más suave que permite seguir escribiendo.
- Migraciones y bloqueos: cómo un
ALTER TABLE tumba producción
ALTER TABLE tumba producciónEste es el mecanismo que conviene entender de verdad, porque explica incidentes que parecen inexplicables. Un ALTER TABLE necesita un bloqueo exclusivo (ACCESS EXCLUSIVE) sobre la tabla, y para obtenerlo tiene que esperar a que terminen las transacciones en curso. Hasta aquí, nada grave. El problema es lo que ocurre mientras espera:
flowchart LR
A["Consulta lenta<br/>sobre citas · 30 s"] --> B["ALTER TABLE espera<br/>el bloqueo exclusivo"]
B --> C["Toda petición nueva<br/>se encola detrás"] --> D["El pool se agota"]
D --> E["La API deja de responder<br/>sin que el ALTER haya empezado"]
La cola de bloqueos de PostgreSQL respeta el orden de llegada, así que un ALTER TABLE en espera bloquea a todos los que llegan después, incluidos los simples SELECT. El resultado es una caída completa provocada por una migración que aún no ha ejecutado nada. Tres defensas, y las tres son baratas:
SET lock_timeout = '3s'; -- 1 · no esperar indefinidamente
SET statement_timeout = '30s'; -- 2 · no ejecutar indefinidamente
ALTER TABLE citas ADD COLUMN inicio_utc timestamptz;lock_timeouthace que la migración falle rápido en lugar de bloquear la cola. Un fallo limpio a los tres segundos es infinitamente mejor que una caída: se reintenta más tarde, cuando no haya una consulta larga en curso. Es la línea que más incidentes evita de toda la lección.statement_timeoutprotege del caso contrario: una sentencia que sí obtuvo el bloqueo pero tarda diez minutos reescribiendo la tabla.- La tercera defensa es
CREATE INDEX CONCURRENTLY, imprescindible en tablas grandes: construye el índice sin bloquear escrituras, a costa de tardar más y de dos pasadas sobre la tabla. Tiene dos peculiaridades que hay que conocer: no puede ejecutarse dentro de una transacción —la mayoría de herramientas de migración envuelven cada fichero en una, así que hay que desactivarlo explícitamente para esa migración—, y si falla deja un índice inválido que hay que borrar a mano antes de reintentar.
- Backfill por lotes sin saturar la base
Rellenar 640.000 filas con un solo UPDATE es la otra forma clásica de tumbar producción: una transacción gigante que bloquea filas durante minutos, hace crecer el WAL, dispara el retraso de la réplica y satura la CPU. La alternativa es aburrida y funciona: lotes pequeños, con pausa entre ellos.
#!/usr/bin/env bash
# infra/scripts/backfill-lotes.sh
set -euo pipefail
LOTE=${LOTE:-1000}; PAUSA=${PAUSA:-0.2} # 1
while true; do
N=$(psql "$DATABASE_URL" -tA <<SQL | grep -c 1 || true
WITH pendientes AS (
SELECT id FROM citas WHERE inicio_utc IS NULL
ORDER BY id LIMIT $LOTE
FOR UPDATE SKIP LOCKED -- 2
)
UPDATE citas c SET inicio_utc = c.hora_inicio AT TIME ZONE 'Europe/Madrid'
FROM pendientes p WHERE c.id = p.id RETURNING 1;
SQL
)
echo "Actualizadas $N filas"
[ "$N" -eq 0 ] && break # 3
sleep "$PAUSA" # 4
done- Lotes de 1.000 filas y pausa de 200 ms: cada transacción dura milisegundos y libera los bloqueos enseguida. Los valores se ajustan mirando la latencia p95 del panel
reservalia-api-prodmientras corre. FOR UPDATE SKIP LOCKEDevita esperar por filas que otra transacción está tocando: se saltan y se cogerán en la siguiente vuelta. Sin esto, el backfill compite con el tráfico real.- La condición de parada es "no quedan filas pendientes", no un contador. Eso hace el script reanudable: si se interrumpe a mitad, se vuelve a lanzar y sigue donde estaba. Es la misma idempotencia de la 03-02, aplicada a datos.
- La pausa es lo que hace el backfill amable. Sin ella el bucle satura la base aunque cada lote sea pequeño. Es preferible un backfill que tarda dos horas y que nadie nota, a uno de cuatro minutos que dispara la alerta
ApiLatenciaReservas.
Y dos reglas más: el backfill se ejecuta por separado del despliegue, como un job bajo demanda, para que un proceso de dos horas no bloquee el pipeline; y se vigila mientras corre, con el panel abierto y la disposición a pararlo, que es trivial porque es reanudable.
- Dónde encaja la migración en
cd.yml
cd.ymlLa migración es un job propio, anterior al despliegue del código y con sus propias reglas:
migrar-prod:
needs: [desplegar-staging]
environment: prod-migraciones # 1 · aprobación propia
runs-on: ubuntu-22.04
timeout-minutes: 15
permissions: { id-token: write, contents: read }
steps:
- uses: ./.github/actions/preparar-node # de la 04-05
- uses: aws-actions/configure-aws-credentials@v4
with: { role-to-assume: '${{ secrets.AWS_ROLE_MIGRAR }}', aws-region: eu-west-1 }
- name: Migraciones pendientes # 2
run: npm run migrate:status --workspace apps/api
- name: Aplicar migraciones
env: { PGOPTIONS: '-c lock_timeout=3s -c statement_timeout=60s' } # 3
run: npm run migrate --workspace apps/api
desplegar-prod:
needs: [migrar-prod] # 4 · el orden importa
environment: prod- Un entorno propio,
prod-migraciones, con su propia lista de revisores. Aprobar un despliegue de código y aprobar un cambio de esquema son decisiones distintas: la primera se deshace en cuatro minutos, la segunda puede no deshacerse. Separarlas hace que el aprobador vea el SQL antes de decir que sí. migrate:statusantes de aplicar imprime qué migraciones se van a ejecutar. Es información para el aprobador y queda en el log del run: la respuesta a "¿qué se cambió el día 15?".- Los tiempos de espera se fijan por conexión con
PGOPTIONS, de modo que se aplican a todas las sentencias de la migración sin tener que escribirlos en cada fichero. - La migración va antes del despliegue del código, y esto solo es correcto porque el patrón del apartado 4 garantiza que el esquema nuevo es compatible con el código antiguo. Si una migración no cumple esa propiedad, el orden no la salva: el problema es la migración.
Qué hacer si falla a medias. Primero, entender qué significa exactamente: la herramienta ejecuta cada fichero dentro de una transacción, así que una migración individual es atómica, o se aplica entera o no se aplica. Lo que no es atómico es la serie: si hay tres pendientes y falla la segunda, la primera quedó aplicada y registrada. La tabla migraciones_aplicadas dice exactamente dónde se paró. Y el procedimiento, que conviene tener escrito antes de necesitarlo: (1) no reintentar a ciegas —si falló por lock_timeout, reintentar en un momento tranquilo es correcto; si falló por un error de SQL, reintentar da el mismo error—; (2) el despliegue del código no se ha ejecutado, porque desplegar-prod depende de migrar-prod, así que producción sigue con la versión anterior y el esquema anterior más lo que se aplicara: si el patrón se respetó, eso es un estado funcional; (3) corregir con una migración nueva, jamás editando la que falló; y (4) anotarlo como incidente en la tabla incidentes de la 03-06, porque cuenta para el change failure rate. Un caso aparte: un CREATE INDEX CONCURRENTLY no puede ir en una transacción, así que esa migración no es atómica y, si falla, deja un índice inválido que hay que borrar antes de reintentar. Merece un comentario dentro del propio fichero para quien lo encuentre a las tres de la madrugada.
- Datos de prueba y anonimización
La forma más rápida de tener un staging realista es copiar la base de datos de producción. No se hace. Y no solo por normativa:
| Riesgo | Qué significa en Reservalia |
|---|---|
| Legal | Los datos de las citas incluyen nombre, teléfono y a veces el motivo de la visita: son datos personales, y algunos pueden ser de salud |
| Superficie de exposición | staging tiene menos controles, más accesos y a veces logs más verbosos |
| Fugas por integraciones | Un entorno de pruebas con datos reales puede enviar SMS o correos a clientes reales |
| Retención | Quien ejerce su derecho de supresión sigue estando en la copia de staging |
La alternativa es generar datos sintéticos con un script que crea negocios, horarios y citas con distribuciones parecidas a las reales —incluyendo los casos difíciles: horario partido, cambios de hora, reservas solapadas— pero con datos inventados. La ventaja añadida es que son reproducibles: las pruebas E2E de la 02-04 pueden dar por hecho que el negocio demo existe con una agenda conocida.
// apps/api/src/db/semillas/generar.ts (fragmento)
export function generarCitas(negocioId: number, n: number) {
return Array.from({ length: n }, (_, i) => ({
negocioId,
cliente: `Cliente Ficticio ${i}`, // 1
telefono: `+34 600 000 ${String(i).padStart(3, '0')}`, // 2
inicioUtc: new Date(Date.UTC(2026, 9, 25, 8 + (i % 9), 0)), // 3
}));
}- Nombres claramente falsos: si algo se filtra en un log o en una captura, se ve al instante que no es real.
- Teléfonos de un rango no asignable, de modo que un envío accidental de SMS no llegue a nadie.
- Fechas elegidas a propósito alrededor de un cambio de hora, que es justo el caso que motivó la migración de esta lección.
Si aun así hay que partir de datos reales —a veces es la única forma de reproducir un problema de rendimiento con volumen real—, entonces anonimización antes de que los datos salgan de producción: sustituir nombres, teléfonos y correos por valores generados, con cuidado de mantener las propiedades estadísticas que importan (cuántas citas por negocio, qué distribución horaria). Y con dos cautelas: la anonimización mal hecha es reversible —un identificador que se conserva puede recomponer la identidad—, y el proceso debe ejecutarse en un entorno tan protegido como producción, porque durante un rato maneja datos reales.
Advertencia. Este apartado explica principios técnicos, no asesoramiento legal. El tratamiento de datos personales está regulado —RGPD en la Unión Europea, y normativa sectorial adicional si hay datos de salud— y las decisiones sobre qué se puede copiar, anonimizar o conservar deben tomarse con el criterio de un profesional de protección de datos y del responsable de cumplimiento de tu organización. Un curso técnico no sustituye esa revisión.
Errores Comunes y Consejos
Error 1: hacer RENAME COLUMN o DROP COLUMN en un despliegue normal. Rompe todas las tareas que aún corren la versión anterior y deja el rollback inservible. Error 2: editar una migración ya aplicada en lugar de escribir una nueva: el entorno donde ya se ejecutó no volverá a leerla, y los entornos divergen en silencio.
Error 3: contraer el mismo día que se expande. El valor entero del patrón está en la espera entre la fase 3 y la 4; sin ella solo has escrito más SQL para el mismo riesgo. Error 4: ALTER TABLE sin lock_timeout, que convierte una migración en espera en una caída completa por la cola de bloqueos. Error 5: backfill en un único UPDATE, con bloqueos largos, WAL disparado y réplica retrasada; por lotes y con pausa.
Error 6: CREATE INDEX sin CONCURRENTLY sobre una tabla grande en producción. Error 7: ejecutar migraciones desde un portátil "solo esta vez", que es exactamente así como los entornos empiezan a divergir. Error 8: copiar datos de producción a staging, con todo lo que implica en riesgo legal y de fugas.
Consejo 1: revisa el SQL de las migraciones con la misma atención que el código, y con CODEOWNERS que lo garantice. Consejo 2: ensaya la migración sobre una copia con el volumen de producción y anota cuánto tarda; es el único dato que predice el bloqueo. Consejo 3: escribe en cada migración destructiva un comentario con la fecha en que dejó de usarse la columna y quién lo verificó, y Consejo 4: ten el procedimiento de fallo escrito antes de necesitarlo.
Ejercicios
Ejercicio 1
Diego necesita añadir a citas una columna estado de tipo texto, obligatoria y con valor por defecto 'confirmada', sobre una tabla de 640.000 filas y con tráfico real. Escribe la secuencia completa de migraciones y despliegues, indicando qué versiones del código conviven en cada momento y en qué punto exacto deja de ser posible el rollback.
Ejercicio 2
A las 10:15 de un martes, un despliegue ejecuta CREATE INDEX idx_citas_negocio ON citas(negocio_id) sobre una tabla de 640.000 filas. A los 40 segundos, la API deja de responder por completo, incluidas las peticiones que no tocan citas. Explica el mecanismo exacto del fallo, por qué afecta a peticiones ajenas a esa tabla y cómo se habría evitado.
Ejercicio 3
Un equipo quiere reproducir en staging un problema de rendimiento que solo aparece con volumen real, y propone restaurar allí una copia de seguridad de producción "solo durante dos días". Argumenta la respuesta y propón una alternativa concreta que resuelva la necesidad técnica.
Soluciones
Solución 1. El error de partida sería una sola migración con ADD COLUMN estado text NOT NULL DEFAULT 'confirmada'. Aunque el default sea constante y PostgreSQL moderno no reescriba la tabla, el NOT NULL inmediato rompe al código antiguo, que hace INSERT sin esa columna… y en realidad no lo rompe, porque el default la rellena; el problema real es el inverso y más sutil: si después hay que revertir el código, no pasa nada, pero si la columna fuera NOT NULL sin default, todos los INSERT del código antiguo fallarían. La secuencia segura, que funciona en ambos casos, es:
- Migración 1 (expandir):
ALTER TABLE citas ADD COLUMN estado text;— nullable, sin default. Instantáneo. Conviven: código viejo (ignora la columna) y código nuevo si estuviera desplegado. Rollback: trivial. - Backfill por lotes: rellenar
estado = 'confirmada'en las 640.000 filas existentes con el script del apartado 7. Sin bloqueos largos, reanudable. - Despliegue del código nuevo: escribe siempre
estadoy tolera leerNULLen las filas que aún no se hayan rellenado si el backfill sigue en curso. Conviven las dos versiones y ambas funcionan; el rollback sigue siendo seguro. - Migración 2 (endurecer): cuando el backfill ha terminado y el código nuevo lleva días estable,
ALTER TABLE citas ALTER COLUMN estado SET DEFAULT 'confirmada';y despuésSET NOT NULL—precedido, en una tabla grande, de unCHECK (estado IS NOT NULL) NOT VALID+VALIDATE CONSTRAINT, para no recorrer la tabla bajo bloqueo exclusivo—.
El rollback deja de ser posible en el paso 4, y solo parcialmente: revertir el código sigue funcionando —la versión antigua simplemente ignora la columna—, pero el NOT NULL impide volver a un código que insertara filas sin estado. Por eso el paso 4 va aparte y varios días después.
Solución 2. El mecanismo, en tres tiempos. (1) CREATE INDEX sin CONCURRENTLY toma un bloqueo que impide las escrituras sobre citas durante toda la construcción del índice, que en 640.000 filas son decenas de segundos. (2) Todas las escrituras sobre citas se encolan detrás. (3) Cada petición encolada retiene una conexión del pool; en cuarenta segundos, con el tráfico de un martes por la mañana, el pool se agota. Y ahí está la respuesta a por qué afecta a peticiones ajenas a citas: el pool de conexiones es un recurso compartido de toda la aplicación, así que una petición a /api/negocios que solo lee otra tabla tampoco consigue conexión y falla igual. Es exactamente la señal de saturación que la 03-06 describía como la única que avisa antes: la alerta PoolConexionesAlto habría saltado, aunque con quince minutos de ventana probablemente demasiado tarde.
Cómo se habría evitado, en tres capas: (a) CREATE INDEX CONCURRENTLY, que no bloquea escrituras —recordando que no puede ir dentro de una transacción y que si falla deja un índice inválido que borrar—; (b) SET lock_timeout = '3s', que habría hecho fallar la migración limpiamente en lugar de encolar a toda la aplicación, convirtiendo una caída en un job rojo; y (c) haber ensayado la migración sobre una copia con volumen de producción, que habría mostrado los 40 segundos de construcción antes de tocar nada. La primera es la solución, la segunda es la red de seguridad y la tercera es lo que evita la sorpresa.
Solución 3. La respuesta es no, y el argumento no es solo normativo. Los datos de las citas de Reservalia incluyen nombre, teléfono y a veces el motivo de la visita —potencialmente datos de salud—; staging tiene menos controles de acceso, más personas con permisos y logs más verbosos; y hay un riesgo específico y muy concreto: si el entorno tiene configurado el proveedor de SMS, un ciclo de pruebas puede enviar recordatorios a clientes reales, con el flag recordatorios_sms activado sin pensar. El "solo dos días" tampoco ayuda: los dos días se convierten en dos meses, y una copia de seguridad de staging tomada durante esa ventana puede sobrevivir años. La alternativa que sí resuelve la necesidad técnica —que es legítima: reproducir un problema de rendimiento exige volumen realista— tiene tres piezas. (1) Generar volumen sintético: el script del apartado 9 escalado a 640.000 citas con las mismas distribuciones que producción (citas por negocio, concentración horaria, proporción de horarios partidos). El rendimiento depende del volumen y la forma de los datos, no de que los nombres sean verdaderos. (2) Si el problema depende de una distribución muy específica, extraer de producción solo las estadísticas —histogramas, cardinalidades— y usarlas para parametrizar el generador; se llevan los números, no los datos. (3) Si nada de lo anterior basta, un volcado anonimizado en origen, dentro del perímetro de producción, con sustitución irreversible de todos los campos personales, con fecha de caducidad automática del entorno, con las integraciones externas desactivadas por configuración, y con la aprobación previa del responsable de protección de datos. Es el último recurso, no el primero, y es precisamente la decisión que un curso no puede tomar por ti.
Conclusión y cierre del módulo
El cuarto frente está cerrado. Reservalia ya no trata el esquema como un artefacto más: sabe que es estado compartido, que una sola base de datos sirve a todas las versiones del código desplegadas a la vez, y que por eso el rollback.yml de la 03-05 —que devuelve un digest en cuatro minutos— no puede deshacer un DROP COLUMN. Sus migraciones son ficheros versionados en apps/api/src/db/migraciones/, con marca de tiempo en el nombre, revisión obligatoria por CODEOWNERS y una tabla migraciones_aplicadas que las hace idempotentes y detecta por hash si alguien edita una ya aplicada. Se ejecutan desde el pipeline y nunca desde un portátil, en un job migrar-prod con su propio entorno de aprobación —porque aprobar código y aprobar un cambio de esquema son decisiones distintas—, con migrate:status delante del aprobador y lock_timeout y statement_timeout fijados por conexión. Y sobre todo, Reservalia sabe diseñar el cambio para que ninguna versión se rompa. El patrón expand and contract convirtió el renombrado de hora_inicio a inicio_utc en cuatro fases compatibles —expandir con trigger bidireccional, migrar los datos por lotes, desplegar el código, y contraer una semana después—, de modo que en todo momento el rolling update de la 03-04 y el canary conviven con un esquema que ambas versiones entienden y el rollback sigue siendo posible. Alrededor de ese patrón hay una clasificación clara de qué es seguro, peligroso y prohibido en caliente, con la alternativa de cada caso; la comprensión de por qué un ALTER TABLE en espera tumba una aplicación entera a través de la cola de bloqueos y del pool de conexiones; un backfill por lotes, pausado y reanudable; y una política sobre datos de prueba que descarta copiar producción y genera datos sintéticos, con la advertencia de que esa decisión se toma con un profesional de protección de datos y no con un curso.
Con esto, los cuatro frentes que el módulo 3 dejó abiertos están resueltos:
| Frente | Estado | Qué lo resolvió |
|---|---|---|
| Pipeline lento | ✅ | Medir, caché, sharding, ejecución selectiva: de 6 a 3 min (04-04) |
| Dependencias sin control | ✅ | Lockfile, npm ci, dependabot.yml y política de actualización (04-02) |
| Seguridad del suministro | ✅ | Job seguridad, OIDC, acciones por SHA, SBOM y firma (04-03) |
| Migraciones de esquema | ✅ | Migraciones versionadas y expand and contract (04-06) |
Y el número que lo resume, medido con la misma vara de la línea base del módulo 1: el change failure rate ha bajado del 6,5 % al 3,8 %, por debajo del objetivo del 5 % que era el único que quedaba sin cumplir. Las cuatro métricas DORA están ahora en verde —12 despliegues por semana, 3,5 horas de lead time, 3,8 % de fallos y 9 minutos de recuperación—, y los fallos que quedan ya no son de proceso: son los que cualquier equipo tendrá siempre, los que se descubren solo cuando el software se encuentra con usuarios reales.
Marta: "Prefiero desplegar diez veces al día y que cada despliegue sea aburrido." Ese era el objetivo desde la primera lección, y el trabajo de estos tres módulos consistió, exactamente, en hacer aburrido lo que antes ocupaba tres horas de un viernes.
Lo que viene ahora cambia de registro. Hasta aquí hemos construido un pipeline para un producto, tomando en cada punto la decisión que le convenía a Reservalia: monorepo de Node, GitHub Actions, contenedores sobre ECS, trunk-based con squash. Todas esas decisiones fueron razonadas, pero ninguna es universal. El módulo 5, Implementación de CI/CD en Proyectos Reales, coge lo aprendido y lo somete a contextos que no se parecen al de Reservalia: una aplicación móvil con firmas, tiendas y revisiones que tardan días; un sistema de microservicios donde el grafo de despliegue tiene decenas de nodos; y un proyecto heredado sin pruebas al que hay que llevar CI/CD sin poder reescribirlo. Empieza por el más cercano, Caso de Estudio: Proyecto Web, que aterriza el pipeline completo sobre una aplicación web real de principio a fin —incluida la parte de apps/web que hemos tratado siempre como acompañante de la API— y sirve de puente entre el sistema que hemos construido y los tres casos que lo pondrán a prueba.
Curso de CI/CD: Integración y Despliegue Continuo
Módulo 1: Introducción a CI/CD
- Conceptos Básicos de CI/CD
- Beneficios de CI/CD
- Herramientas Populares de CI/CD
- El Proyecto del Curso: la Aplicación que Vamos a Automatizar
- Métricas DORA: Cómo se Mide la Entrega de Software
Módulo 2: Integración Continua (CI)
- Introducción a la Integración Continua
- Configuración de un Entorno de CI
- Automatización de la Construcción
- Pruebas Automatizadas
- Calidad de Código y Análisis Estático
- Artefactos, Versionado y Promoción
- Integración con Control de Versiones
Módulo 3: Despliegue Continuo (CD)
- Introducción al Despliegue Continuo
- Automatización del Despliegue
- Infraestructura como Código y Entornos Reproducibles
- Estrategias de Despliegue
- Feature Flags, Rollback y Recuperación ante Fallos
- Monitoreo y Retroalimentación
Módulo 4: Prácticas Avanzadas de CI/CD
- Pipelines de CI/CD
- Gestión de Dependencias
- Seguridad en CI/CD
- Escalabilidad y Rendimiento
- Pipeline as Code: Plantillas, Reutilización y Pruebas del Pipeline
- Bases de Datos en el Pipeline: Migraciones Seguras
Módulo 5: Implementación de CI/CD en Proyectos Reales
- Caso de Estudio: Proyecto Web
- Caso de Estudio: Aplicación Móvil
- Caso de Estudio: Microservicios
- Caso de Estudio: Modernizar un Proyecto Legacy
Módulo 6: Herramientas y Tecnologías
- Jenkins
- GitLab CI/CD
- CircleCI
- Travis CI
- Docker y Kubernetes
- GitHub Actions a Fondo
- Comparativa y Criterios para Elegir Herramienta
Módulo 7: Ejercicios Prácticos
- Ejercicio 1: Configuración de un Pipeline Básico
- Ejercicio 2: Integración de Pruebas Automatizadas
- Ejercicio 3: Despliegue en un Entorno de Producción
- Ejercicio 4: Monitoreo y Retroalimentación
- Ejercicio 5: Endurecer el Pipeline con Seguridad y Secretos
- Proyecto Final: Pipeline Completo de Extremo a Extremo
