· 10 min read
Sin plataforma y sin downtime: el patrón del symlink
rsync a un directorio versionado, swap atómico del symlink, conserva las últimas cinco releases. Este es el patrón completo. Cómo hacer el swap sin sorpresas y lo que el sistema de ficheros no puede cubrir por ti.
Esta es la versión en español de Zero-downtime deploys without a platform: the symlink pattern.
Desplegar un sitio estático parece la parte fácil. Compilas los ficheros, los copias al servidor, nginx los sirve. Luego ves entrar un despliegue en producción mientras alguien está cargando una página, y entiendes por qué existen las plataformas de despliegue.
La versión básica es un solo rsync directo al directorio desde el que sirve nginx. rsync escribe los ficheros uno a uno, así que durante unos cientos de milisegundos ese directorio contiene una mezcla: un index.html nuevo apuntando a hashes de assets que todavía no han llegado, o HTML antiguo junto a assets nuevos. Un visitante que carga la página en esa ventana obtiene un render roto. En un blog personal es un parpadeo que nadie reporta. En cualquier cosa que procese pagos es un bug.
Añadir un CDN y un segundo origen amplía esa ventana, no la reduce. Este blog corre dos nodos de origen detrás de Akamai en regiones separadas. Haz rsync en ambos en secuencia y hay un tramo en el que un nodo ya tiene la build nueva y el otro todavía tiene la antigua. El despliegue en cadena acaba de poner en producción dos versiones del sitio al mismo tiempo.
La solución no es un rsync más rápido. Es hacer que el corte de lo viejo a lo nuevo sea una sola operación que el servidor web no pueda pillar a medias. Esa operación ya existe en el sistema de ficheros, y es el humilde swap de symlink.
El patrón
Imagina el symlink como el cartel en la puerta de una oficina que dice “estamos aquí”. Alquilar la oficina de al lado, amueblarla y cablearla lleva tiempo. Mover el cartel lleva un segundo. Nadie entra en una sala a medio amueblar, porque no pones el cartel apuntando a una sala hasta que la sala está lista.
Esa es la idea. El despliegue son tres operaciones, en este orden:
rsyncdel build a un directorio versionado nuevo,releases/<timestamp>/. nginx no tiene ni idea de que este directorio existe. La transferencia puede tardar lo que necesite, porque todavía no hay tráfico apuntando hacia él.- Crea un nuevo symlink con un nombre nuevo y sustitúyelo de forma atómica:
ln -sfn releases/<timestamp> current.newseguido demv -Tf current.new current.currentes el path desde el que sirve nginx. El cartel pasa de una puerta a la siguiente, y el sistema de ficheros ve un solo eventorenamesin ningún estado intermedio. - Limpieza. Conserva las últimas cinco releases, elimina el resto.
El layout de releases en cada nodo tiene esta pinta:
/var/www/blog/
├── releases/
│ ├── <release-id-1>/ ← anterior
│ ├── <release-id-2>/ ← anterior
│ └── <release-id-3>/ ← actual
└── current -> releases/<release-id-3>
El root de nginx apunta al symlink, nunca a una release específica:
root /var/www/blog/current;
La segunda mitad del despliegue corre en cada nodo de origen después de que rsync termina:
RELEASE_ID=$(date +%Y%m%d-%H%M%S)
cd /var/www/blog
# Crea el nuevo symlink con un nombre nuevo y luego lo sustituye de forma atómica
ln -sfn releases/$RELEASE_ID current.new
mv -Tf current.new current
# Limpieza: conserva las últimas 5 releases por fecha de modificación
ls -1dt releases/*/ \
| tail -n +6 \
| xargs -r rm -rf
El swap en dos pasos
Fíjate bien en el swap. Son dos comandos, no uno:
ln -sfn releases/<id> current.new
mv -Tf current.new current
El primer comando crea un nuevo symlink en un nombre que no existía antes, current.new. Ese nombre aún no existe, así que ln hace lo que mejor sabe hacer sin complicaciones: escribe un nuevo symlink que apunta donde le dices. El segundo comando reemplaza de forma atómica el current activo con el que acabas de preparar.
La separación importa porque ln -sfn se vuelve problemático en el momento en que lo apuntas a un nombre que ya existe y que ya resuelve a un directorio. En Linux con GNU coreutils, ln -sfn releases/<id> current contra un symlink-a-directorio existente puede seguir el enlace y crear un nuevo symlink dentro del destino, dejándote con current/current -> releases/<id> y un webroot que ya no apunta donde nginx espera. El comportamiento exacto varía entre versiones de coreutils y plataformas, lo que ya es razón suficiente para no invocarlo así nunca.
mv -Tf no tiene esa ambigüedad. Llama a rename(2), que el kernel garantiza como atómico cuando origen y destino están en el mismo sistema de ficheros: current resuelve a la release antigua o a la nueva, nunca a nada intermedio y nunca a las dos a la vez. El flag -T le dice a mv que trate el destino como un nombre a reemplazar en lugar de un directorio al que moverse dentro. El -f fuerza el reemplazo.
El patrón que emerge: ln es la primitiva de creación de symlinks, segura con un nombre nuevo. mv es la primitiva de sustitución, segura frente a lo que haya en el nombre antiguo. Usa cada una para el trabajo que hace sin sorpresas.
Dos notas de alcance. La atomicidad de rename(2) solo se mantiene dentro de un único sistema de ficheros, así que mantén releases/ y current bajo el mismo mount. Sepáralos en una partición distinta, NFS o un bind cross-mount y la garantía desaparece. Y la atomicidad es sobre el swap en sí, no sobre las capas por encima: peticiones HTTP en curso, entradas de open_file_cache dentro de nginx, o un edge del CDN sirviendo el HTML anterior son problemas distintos que el sistema de ficheros no puede resolver. La siguiente sección cubre los dos primeros; el CDN llega al final.
¿nginx realmente lo nota?
Una preocupación razonable: si nginx lee current solo una vez al arrancar, el swap no cambiaría nada hasta un reload. nginx sí resuelve el string del path de root cuando arranca el worker, pero recorre ese path en cada open() en la ruta de servicio de ficheros, así que el nuevo destino tiene efecto en la siguiente petición. Sin reload, sin restart, con un matiz. Si open_file_cache está activo (desactivado en la config por defecto, habitual en ajustes de producción), el swap es invisible para las entradas que aún están dentro de la ventana de caché. Ajusta open_file_cache_valid según la rapidez con la que necesites que los despliegues sean observables, o acepta la ventana.
No te fíes de mi palabra. Abre el access log en un terminal, ejecuta el swap en otro, y solicita un fichero que sabes que cambió entre releases. Con la ruta de servicio de ficheros por defecto y sin stack de cachés por encima, los bytes nuevos aparecen de inmediato. Verificarlo con un test de cinco segundos vale más que confiar en un blog post, incluido este.
La pregunta de los dos nodos
Dos nodos plantean una pregunta de orden: ¿swappearlos al mismo tiempo o uno detrás del otro?
Swaps simultáneos arriesgan una ventana breve en la que node01 sirve la nueva release y node02 todavía sirve la antigua. Swaps secuenciales cierran esa ventana pero alargan el despliegue y requieren drenar cada nodo antes de tocarlo.
Esta configuración hace el swap en ambos a la vez, y la razón es la política de balanceo de carga, no la pereza. El GTM de Akamai está en Ranked Failover, no en round-robin. El tráfico real solo llega a node02 cuando node01 está caído. En cualquier momento normal un solo nodo responde a todos los visitantes y el otro permanece en caliente como reserva, así que el desfase de versión entre los dos durante un swap de dos segundos nunca llega a un humano. La salvedad honesta: las liveness probes de GTM corren en su propio calendario. Hay una ventana teórica estrecha en la que una probe coincide con el swap del nodo primario y desplaza el tráfico al secundario en mitad del despliegue. Para un blog estático la probabilidad de que un visitante caiga en esa ventana es ruido estadístico. Para un par activo-activo de un flujo de checkout, la respuesta cambia: swappea en secuencia, drena cada nodo antes de tocarlo, y acepta el despliegue más lento.
El usuario de despliegue puede hacer exactamente una cosa
La cuenta que recibe el rsync no es un login de propósito general. Un bloque SSH Match lo reduce al mínimo que necesita y nada más:
Match User <deploy-user>
AuthenticationMethods publickey
AllowAgentForwarding no
AllowTcpForwarding no
PermitTunnel no
X11Forwarding no
Sin shell, sin forwarding, sin túnel: puede recibir ficheros y ejecutar el script de swap, y esa es la descripción completa del trabajo. Esto es el principio de mínimo privilegio aplicado a una cuenta de servicio, sin eufemismos sobre lo que “mínimo” sigue permitiendo: una clave comprometida permite a un atacante publicar una release arbitraria, exfiltrar todo lo que hay en releases/, o swappear current a un directorio vacío y tirar el blog. Es un daño real. Lo que el bloque Match compra es un límite: el daño se queda en los ficheros del sitio estático y no se convierte en una shell, un pivote hacia otras cuentas o un túnel al resto del servidor. Una restricción command= en authorized_keys lo estrecha todavía más, hasta el único comando permitido.
El rollback es el mismo movimiento al revés
Aquí está el beneficio colateral que hace que todo el patrón valga la pena. Como las releases antiguas se quedan en disco, el rollback no necesita ningún repositorio de artefactos, ninguna recompilación ni ninguna transferencia. Los bytes ya están en releases/. Hacer rollback es el swap otra vez, apuntando a un directorio anterior:
ln -sfn releases/<release-id-2> current.new
mv -Tf current.new current
Un workflow de GitHub Actions rollback.yml acepta un ID de release como input, lo valida contra lo que realmente está en el nodo, y ejecuta ese swap en cada origen. Termina en unos pocos segundos, la mayor parte de los cuales son el handshake SSH. La primitiva que publica una release es la misma que la despublica, así que el rollback deja de ser un procedimiento documentado que esperas que funcione bajo presión y se convierte en un comando que ya has ejecutado cien veces.
Lo que realmente necesitas
Nada de esto es específico de Astro, de nginx ni de ficheros estáticos. El swap de symlink es la base sobre la que construir cualquier despliegue. Los stacks dinámicos añaden más pasos: señalizar al proceso en ejecución, invalidar un opcache, ejecutar migraciones, drenar conexiones de larga duración. Un CDN por delante añade uno más: purgar la caché del edge para el HTML envelope, ya que las URLs de assets con hash de contenido se encargan del resto. Capistrano formalizó la parte del symlink para Rails en 2009, y la mayoría de plataformas siguen haciendo alguna versión de esto detrás de un panel más amigable. No estás evitando su inteligencia; estás haciendo la capa de abajo tú mismo, la parte que nunca fue tan inteligente en primer lugar.
Si tienes un self-hosting, puedes añadir esto en una tarde. Crea un directorio releases/, apunta un symlink current a uno de ellos, escribe el script post-despliegue de cuatro líneas de arriba, y pon el root de nginx en el symlink. El despliegue atómico y el rollback gratuito salen de esas cuatro piezas. El coste es un puñado de comandos de shell y una convención de nombres, y lo que recuperas es no volver a ver nunca más un directorio a medio escribir ir a producción mientras alguien está leyendo.