Despliegue por releases con enlace simbólico y vuelta atrás
El script de despliegue de una aplicación PHP al estilo de Capistrano: cada versión en su carpeta, cambio atómico del enlace current, solo las tres últimas releases, vuelta atrás instantánea y estado del servidor, en modo simulación. Arrays, expresiones regulares y despliegue sin corte.
- Arrays y ${array[-1]}
- Funciones que devuelven por echo
- [[ =~ ]] para validar
- ${var^^}
- Enlaces simbólicos (ln -sfn)
- Despliegue atómico
Enunciado
Copiar los ficheros nuevos encima de los de la web en producción tiene un problema: durante unos segundos conviven ficheros viejos y nuevos, y si algo sale mal no hay forma rápida de volver atrás. Las herramientas de despliegue (Capistrano, Deployer, Envoyer) lo resuelven con releases: cada versión se instala en su propia carpeta (releases/20261004-1000) y el servidor web apunta a un enlace simbólico current.
Desplegar es preparar la carpeta nueva por completo y, solo al final, cambiar el enlace. Ese cambio se hace de forma atómica (se crea un enlace temporal y se renombra encima del antiguo con mv -T), así que ninguna petición ve una web a medias. Volver atrás es cambiar el enlace a la release anterior: un segundo, sin reinstalar nada.
El script recibe las releases que ya hay y una serie de órdenes, y escribe las órdenes de shell que ejecutaría. Las líneas informativas empiezan por #, así la salida se puede revisar y, después, ejecutar tal cual.
Qué tiene que hacer el programa
- La primera línea es
RELEASESseguida de las carpetas que ya existen, de la más antigua a la más reciente (puede no haber ninguna); la más reciente es la actual. Si no empieza porRELEASES, escribeLa primera línea debe ser RELEASES r1 r2… (puede estar vacía)y termina. Las órdenes pueden ir en minúsculas; las líneas vacías se ignoran; una orden desconocida escribe# Orden desconocida: orden. DESPLIEGA AAAAMMDD-HHMM vX.Y.Z: si el formato no es ese,# Uso: DESPLIEGA AAAAMMDD-HHMM vX.Y.Z; si la release ya existe,# La release R ya existe; si es anterior a la más reciente,# La release R es anterior a ÚLTIMA. Si todo va bien, escribe# Desplegando vX.Y.Z en Ry las cinco órdenes de la tabla, la release pasa a ser la actual y, si hay más de 3, se escriberm -rf /var/www/tienda/releases/Rpara borrar las más antiguas hasta dejar 3.ROLLBACK: si no hay actual o es la más antigua,# No hay una release anterior a la que volver; si no,# Volviendo de ACTUAL a ANTERIOR, la orden del enlace y la recarga de PHP-FPM, y la anterior pasa a ser la actual (se puede volver atrás varias veces seguidas).ESTADO:# Releases (N):y una línea por release de la más antigua a la más reciente,# Ry, en la actual,# R ← current; o# Sin releases.- Un despliegue después de un rollback despliega la versión nueva como más reciente (la release a la que se había vuelto deja de ser la actual).
Entrada
Línea 1: RELEASES y las releases existentes. Resto: DESPLIEGA fecha vX.Y.Z, ROLLBACK o ESTADO.
Datos de referencia
| Paso | Orden |
|---|---|
| Descargar la versión | git clone --depth 1 --branch vX.Y.Z https://git.ejemplo.com/tienda.git /var/www/tienda/releases/R |
| Configuración compartida | ln -s /var/www/tienda/shared/.env /var/www/tienda/releases/R/.env |
| Dependencias | composer install --no-dev --optimize-autoloader --working-dir=/var/www/tienda/releases/R |
| Cambio atómico | ln -sfn /var/www/tienda/releases/R /var/www/tienda/current.tmp && mv -T /var/www/tienda/current.tmp /var/www/tienda/current |
| Recargar PHP | systemctl reload php8.3-fpm |
Ejemplos de ejecución
Tu programa debe escribir exactamente esta salida para estas entradas. Las pruebas del editor incluyen estos ejemplos y otros casos ocultos.
Dos despliegues y una vuelta atrás
Entrada
RELEASES 20260901-1200 20260915-0930 ESTADO DESPLIEGA 20261001-1800 v1.4.0 DESPLIEGA 20261004-1000 v1.5.0 ROLLBACK ESTADO
Salida por consola
# Releases (2): # 20260901-1200 # 20260915-0930 ← current # Desplegando v1.4.0 en 20261001-1800 git clone --depth 1 --branch v1.4.0 https://git.ejemplo.com/tienda.git /var/www/tienda/releases/20261001-1800 ln -s /var/www/tienda/shared/.env /var/www/tienda/releases/20261001-1800/.env composer install --no-dev --optimize-autoloader --working-dir=/var/www/tienda/releases/20261001-1800 ln -sfn /var/www/tienda/releases/20261001-1800 /var/www/tienda/current.tmp && mv -T /var/www/tienda/current.tmp /var/www/tienda/current systemctl reload php8.3-fpm # Desplegando v1.5.0 en 20261004-1000 git clone --depth 1 --branch v1.5.0 https://git.ejemplo.com/tienda.git /var/www/tienda/releases/20261004-1000 ln -s /var/www/tienda/shared/.env /var/www/tienda/releases/20261004-1000/.env composer install --no-dev --optimize-autoloader --working-dir=/var/www/tienda/releases/20261004-1000 ln -sfn /var/www/tienda/releases/20261004-1000 /var/www/tienda/current.tmp && mv -T /var/www/tienda/current.tmp /var/www/tienda/current systemctl reload php8.3-fpm rm -rf /var/www/tienda/releases/20260901-1200 # Volviendo de 20261004-1000 a 20261001-1800 ln -sfn /var/www/tienda/releases/20261001-1800 /var/www/tienda/current.tmp && mv -T /var/www/tienda/current.tmp /var/www/tienda/current systemctl reload php8.3-fpm # Releases (3): # 20260915-0930 # 20261001-1800 ← current # 20261004-1000
Órdenes que no valen
Entrada
RELEASES 20261001-1800 DESPLIEGA 2026-10-04 v1.5 DESPLIEGA 20261001-1800 v1.5.0 DESPLIEGA 20260930-0800 v1.5.0 ROLLBACK PUBLICA
Salida por consola
# Uso: DESPLIEGA AAAAMMDD-HHMM vX.Y.Z # La release 20261001-1800 ya existe # La release 20260930-0800 es anterior a 20261001-1800 # No hay una release anterior a la que volver # Orden desconocida: PUBLICA
Guía paso a paso
Intenta resolverlo por tu cuenta y abre un paso solo cuando te atasques: cada uno te acerca a la solución sin dártela entera.
1. Las releases en un array
Guarda las releases en un array de la más antigua a la más reciente. ${releases[-1]} es la última, ${#releases[@]} cuántas hay y releases=("${releases[@]:1}") quita la primera (la más antigua).
2. Validar con expresiones regulares
[[ $a =~ ^[0-9]{8}-[0-9]{4}$ ]] comprueba la fecha de la release y [[ $b =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] la etiqueta de versión. Como las fechas tienen el formato AAAAMMDD-HHMM, [[ $a < $b ]] compara textos y sirve para saber cuál es anterior.
3. Una función que «devuelve» un valor
Las funciones de Bash solo devuelven un código de salida. Para devolver un dato se escribe con echo y se recoge con $(…).
indice_de() {
local i
for i in "${!releases[@]}"; do [ "${releases[$i]}" = "$1" ] && { echo "$i"; return; }; done
echo -1
}
i=$(indice_de "$actual")4. El cambio atómico del enlace
ln -sfn destino current en dos pasos no es atómico (borra y crea). Creando current.tmp y renombrándolo con mv -T sobre current, el sistema cambia el enlace en una sola operación: en ningún instante deja de existir.
5. Rollback y la release actual
Guarda en una variable cuál es la actual: no siempre es la última del array (después de un rollback no lo es). El rollback busca su posición y elige la anterior.
Resuélvelo aquí
El editor trae el esqueleto del programa. Pulsa «Ejecutar» para comprobarlo con los ejemplos y con 2 casos ocultos que buscan los errores típicos.
Ejemplo
RELEASES 20260901-1200 20260915-0930 ESTADO DESPLIEGA 20261001-1800 v1.4.0 DESPLIEGA 20261004-1000 v1.5.0 ROLLBACK ESTADO
# Releases (2): # 20260901-1200 # 20260915-0930 ← current # Desplegando v1.4.0 en 20261001-1800 git clone --depth 1 --branch v1.4.0 https://git.ejemplo.com/tienda.git /var/www/tienda/releases/20261001-1800 ln -s /var/www/tienda/shared/.env /var/www/tienda/releases/20261001-1800/.env composer install --no-dev --optimize-autoloader --working-dir=/var/www/tienda/releases/20261001-1800 ln -sfn /var/www/tienda/releases/20261001-1800 /var/www/tienda/current.tmp && mv -T /var/www/tienda/current.tmp /var/www/tienda/current systemctl reload php8.3-fpm # Desplegando v1.5.0 en 20261004-1000 git clone --depth 1 --branch v1.5.0 https://git.ejemplo.com/tienda.git /var/www/tienda/releases/20261004-1000 ln -s /var/www/tienda/shared/.env /var/www/tienda/releases/20261004-1000/.env composer install --no-dev --optimize-autoloader --working-dir=/var/www/tienda/releases/20261004-1000 ln -sfn /var/www/tienda/releases/20261004-1000 /var/www/tienda/current.tmp && mv -T /var/www/tienda/current.tmp /var/www/tienda/current systemctl reload php8.3-fpm rm -rf /var/www/tienda/releases/20260901-1200 # Volviendo de 20261004-1000 a 20261001-1800 ln -sfn /var/www/tienda/releases/20261001-1800 /var/www/tienda/current.tmp && mv -T /var/www/tienda/current.tmp /var/www/tienda/current systemctl reload php8.3-fpm # Releases (3): # 20260915-0930 # 20261001-1800 ← current # 20261004-1000
Solución explicada
Ver la solución completa
1#!/bin/bash
2# Despliegue por releases (como Capistrano o Deployer): cada versión en su carpeta y un enlace «current»
3# que se cambia de golpe. Modo simulación: escribe las órdenes en lugar de ejecutarlas.
4export LC_ALL=C
5BASE=/var/www/tienda
6MAX_RELEASES=3
7
8releases=() # carpetas de releases, de la más antigua a la más reciente
9actual=""
10
11read -r palabra resto
12if [ "$palabra" != "RELEASES" ]; then
13 echo "La primera línea debe ser RELEASES r1 r2… (puede estar vacía)"
14 exit 0
15fi
16for r in $resto; do releases+=("$r"); done
17[ ${#releases[@]} -gt 0 ] && actual=${releases[-1]}
18
19indice_de() { # posición de una release en el array, o -1
20 local i
21 for i in "${!releases[@]}"; do [ "${releases[$i]}" = "$1" ] && { echo "$i"; return; }; done
22 echo -1
23}
24
25while read -r orden a b; do
26 [ -z "$orden" ] && continue
27 case ${orden^^} in
28 DESPLIEGA)
29 if [[ ! $a =~ ^[0-9]{8}-[0-9]{4}$ ]] || [[ ! $b =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
30 echo "# Uso: DESPLIEGA AAAAMMDD-HHMM vX.Y.Z"; continue
31 fi
32 if [ "$(indice_de "$a")" -ge 0 ]; then echo "# La release $a ya existe"; continue; fi
33 if [ ${#releases[@]} -gt 0 ] && [[ $a < ${releases[-1]} ]]; then echo "# La release $a es anterior a ${releases[-1]}"; continue; fi
34 echo "# Desplegando $b en $a"
35 echo "git clone --depth 1 --branch $b https://git.ejemplo.com/tienda.git $BASE/releases/$a"
36 echo "ln -s $BASE/shared/.env $BASE/releases/$a/.env"
37 echo "composer install --no-dev --optimize-autoloader --working-dir=$BASE/releases/$a"
38 # El cambio de versión es atómico: se crea un enlace temporal y se renombra sobre «current»
39 echo "ln -sfn $BASE/releases/$a $BASE/current.tmp && mv -T $BASE/current.tmp $BASE/current"
40 echo "systemctl reload php8.3-fpm"
41 releases+=("$a")
42 actual=$a
43 # Solo se conservan las últimas releases, sin borrar nunca la actual
44 while [ ${#releases[@]} -gt $MAX_RELEASES ]; do
45 echo "rm -rf $BASE/releases/${releases[0]}"
46 releases=("${releases[@]:1}")
47 done
48 ;;
49 ROLLBACK)
50 i=$(indice_de "$actual")
51 if [ -z "$actual" ] || [ "$i" -le 0 ]; then echo "# No hay una release anterior a la que volver"; continue; fi
52 anterior=${releases[$((i - 1))]}
53 echo "# Volviendo de $actual a $anterior"
54 echo "ln -sfn $BASE/releases/$anterior $BASE/current.tmp && mv -T $BASE/current.tmp $BASE/current"
55 echo "systemctl reload php8.3-fpm"
56 actual=$anterior
57 ;;
58 ESTADO)
59 if [ ${#releases[@]} -eq 0 ]; then echo "# Sin releases"; continue; fi
60 echo "# Releases (${#releases[@]}):"
61 for r in "${releases[@]}"; do
62 [ "$r" = "$actual" ] && echo "# $r ← current" || echo "# $r"
63 done
64 ;;
65 *) echo "# Orden desconocida: $orden" ;;
66 esac
67doneLa idea clave del despliegue por releases es separar la preparación (descargar, configurar, instalar dependencias en una carpeta nueva, sin prisa) del cambio de versión, que es solo mover un enlace. Si la preparación falla, la web sigue funcionando con la versión anterior.
El cambio con ln -sfn a un enlace temporal y mv -T es atómico porque rename lo es en Linux: las peticiones que llegan ven la versión vieja o la nueva, nunca una mezcla. Con PHP-FPM se recarga para vaciar la caché de OPcache, que guarda rutas resueltas.
Conservar varias releases es lo que hace instantáneo el rollback, y limitarlas a tres evita llenar el disco. El estado (qué release es la actual) vive en una variable aparte porque, tras volver atrás, ya no es la más reciente.
El modo simulación (escribir las órdenes en lugar de ejecutarlas) es una práctica de seguridad en scripts que tocan producción: se revisa la salida y, si es correcta, se ejecuta con bash. Las líneas con # son comentarios y no hacen nada al ejecutarla.
Para ir más allá
- Añade la opción
--ejecutarque ejecute las órdenes conset -euo pipefaily aborte el despliegue si falla alguna antes del cambio de enlace. - Haz que el despliegue compruebe la salud de la web (
curl -fsS http://localhost/salud) tras cambiar el enlace y haga rollback automático si falla. - Escribe el mismo proceso como un job de GitHub Actions que se conecte por SSH al servidor.