Nota

Despliegue atómico de un sitio estático donde no hay ni git ni transacciones

Despliegue atómico de un sitio estático sin git en el servidor: el extremo receptor solo ofrece scp y ssh. Con eso no se construye una transacción, pero sí un despliegue que sobrevive a un corte y se repite sin consecuencias.

Un sitio de trescientas páginas vive en un VPS corriente detrás de un panel de hosting. En el servidor no hay git, ni agente de CI, ni conmutador de releases. Publicar un solo artículo toca treinta ficheros: páginas en cuatro idiomas, una imagen, índices de sección, feeds, cinco sitemaps. Cada fichero sube con su propio scp. Que la conexión se corte en el decimoquinto es un día normal de trabajo, no un caso exótico.

La atomicidad de ese conjunto no está a la venta. Otra propiedad sí lo está: «falló, vuelve a lanzarlo y el resultado será correcto». La distancia entre esos dos objetivos define todo el diseño.

Dos primitivas, y ambas hay que conocerlas con precisión

rename(2) sustituye el destino de forma atómica: ningún proceso llega a observar un instante en el que el fichero no está. De ahí la maniobra estándar — subir a un fichero temporal y luego renombrar:

scp file host:/path/page.html.tmp.$RUN_ID
ssh "mv /path/page.html.tmp.$RUN_ID \
        /path/page.html"

La restricción que rompe una implementación pulcra: rename devuelve EXDEV cuando las rutas están en sistemas de ficheros montados distintos — incluso si se trata del mismo sistema montado en dos puntos. Un directorio de staging compartido como /tmp/upload parece más ordenado que un temporal junto al destino, y convierte el renombrado en una copia sin decir nada. Copiar no es atómico.

mkdir(2) aporta la otra mitad: o el directorio lo creaste tú, o ya existía (EEXIST). Un test-and-set atómico y el único mutex disponible sobre ssh pelado sin instalar nada en el extremo receptor.

La exclusión mutua aquí no es una formalidad. El estado del despliegue vive en un fichero que se lee, se modifica y se vuelve a escribir. Dos ejecuciones simultáneas producen una actualización perdida, y el aspecto es inofensivo: los ficheros del primer despliegue siguen en su sitio y son accesibles por sus direcciones. Solo desaparece de los índices y del sitemap — y no vuelve nunca, porque la siguiente ejecución toma como base el estado ya corrompido.

Atómico es el fichero, no el conjunto

Treinta subidas consecutivas no se pliegan en una sola operación. La primera de dos decisiones es desplegar en orden de dependencias, para que nada aparezca antes que aquello a lo que apunta: primero los assets, después las páginas, después índices y feeds, y solo entonces el sitemap.

Un corte a media faena deja un sitio a medio desplegar en vez de un sitio con enlaces a la nada. Comprobarlo exige una lista de direcciones, no la vista: si dejas la imagen fuera de la comprobación, acabas fácilmente con una página que responde 200 cuyo og:image responde 404. Ese defecto sobrevivió en cuatro páginas de sección mías precisamente por eso — la comprobación miraba documentos y no miraba assets.

El registro de estado se escribe el último

La segunda decisión circula como «el manifiesto es el commit marker», y la lectura evidente de esa frase es falsa.

Falso: que hasta el registro de estado no se ve nada. Índices, hubs y sitemap se renderizan desde el estado modificado localmente y suben al servidor antes que él. Al llegar a la última etapa el sitio en vivo ya muestra el material nuevo por completo, mientras el registro en el servidor sigue siendo el viejo.

Cierto: el registro de estado es un hecho duradero — «la transacción llegó al final» — y la entrada para la siguiente ejecución. La invariante que de verdad sostiene el diseño dice:

El registro nunca va por delante del sitio. El sitio sí puede ir por delante del registro — eso se recupera. Al revés, no.

Veamos el orden inverso. El registro se confirma primero y la subida falla. La siguiente ejecución ve el objeto en el registro y se niega a desplegarlo como nuevo — queda el modo actualización, que presupone una versión anterior viva. Cualquier renderizado posterior de los índices tomará el objeto del registro y enlazará a una página que no existe. El sitemap anunciará a los buscadores una dirección que devuelve 404.

Con el orden correcto, un fallo deja el sistema en estado «no desplegado»: el objeto no está en el registro, una nueva ejecución sigue el camino normal y lo reescribe todo. Donde el destino sí tiene una primitiva de conmutación real, el mismo trabajo lo hacen los despliegues canary y la progressive delivery — aquí ese papel lo cumple el orden de las operaciones.

Conviene escribir las ventanas de fallo

Fallo duranteSitioRegistroRecuperación
subida de páginaspáginas vivas, ningún índice las enlazaviejorepetir la ejecución como nueva
subida de índicesíndices enlazan, sitemap desfasadoviejoídem
verificaciónvisualmente consistenteviejoídem
tras el commitconsistentenuevonada

El mensaje de error debe nombrar el estado sin rodeos. «Las páginas pueden estar vivas pero sin indexar» sirve más que «deploy failed»: el operador lo lee en el peor momento posible y no debería reconstruir el modelo de fallo leyendo el código. Es la misma cuestión que definir «recuperado» durante un incidente — si no se nombra el estado, cada cual lo completa por su cuenta.

La verificación va antes del commit

Todas las direcciones afectadas se comprueban en busca de un 200 antes de escribir el estado. Una sola que no sea 200 y no hay commit; el sistema se queda en estado «no desplegado». El orden inverso convierte una puerta en un informe de lo ya ocurrido: los mismos defectos, y ya nada que arreglar.

Un código de respuesta confirma disponibilidad, no corrección. Las comprobaciones son perfectamente capaces de dar confianza donde no la hay — una cabecera CSP en verde es un ejemplo.

Por qué no un symlink y por qué no rsync

Conmutar un symlink exige control sobre la disposición de directorios, que el hosting con panel no concede. De paso aflora un detalle: el comando de siempre no hace lo que se espera de él. Medición con strace sobre uutils coreutils 0.8.0:

$ ln -sfn <new> current
unlink("current")           = 0
symlink("<new>", "current") = 0

Dos operaciones, y entre ellas la ruta no existe. Una petición que caiga en esa ventana recibe un 404. La documentación de GNU coreutils describe -f literalmente como eliminar el destino existente y no promete atomicidad alguna. La variante atómica hace un único renombrado:

ln -s <new> current.tmp \
  && mv -T current.tmp current

rsync --delay-updates es la herramienta correcta cuando desplegar significa sincronizar un directorio entero. Su página de manual es honesta sobre la garantía: los ficheros se acumulan en un directorio intermedio y al final se renombran a su sitio «en rápida sucesión», lo que hace la actualización «a little more atomic». No es una transacción, es una ventana más corta.

Lo que este diseño honestamente no da

No hay rollback automático: los ficheros subidos no se revierten, y volver atrás se hace a mano contra las copias de seguridad. kill -9 deja el lock colgado — la liberación vive en el bloque de limpieza del proceso, y el lock no tiene TTL. Y rename(2) garantiza que nadie vea un estado intermedio, pero no que el renombrado sobreviva a un corte de corriente camino del disco: para eso hace falta un fsync del directorio. Atomicidad y durabilidad son propiedades distintas.

Ninguno de estos límites estorba el trabajo. Fingir que no existen, sí.

© 2026 axyi.ru · CC BY 4.0