Apuntes DAM
Volver al inicio

Despliegue por releases con enlace simbólico y vuelta atrás

Ejercicio de BashDifícilUnos 70 minutos

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

  1. La primera línea es RELEASES seguida 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 por RELEASES, escribe La 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.
  2. 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 R y las cinco órdenes de la tabla, la release pasa a ser la actual y, si hay más de 3, se escribe rm -rf /var/www/tienda/releases/R para borrar las más antiguas hasta dejar 3.
  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).
  4. ESTADO: # Releases (N): y una línea por release de la más antigua a la más reciente, # R y, en la actual, # R ← current; o # Sin releases.
  5. 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

Órdenes de un despliegue (R es la release, base /var/www/tienda)
PasoOrden
Descargar la versióngit clone --depth 1 --branch vX.Y.Z https://git.ejemplo.com/tienda.git /var/www/tienda/releases/R
Configuración compartidaln -s /var/www/tienda/shared/.env /var/www/tienda/releases/R/.env
Dependenciascomposer install --no-dev --optimize-autoloader --working-dir=/var/www/tienda/releases/R
Cambio atómicoln -sfn /var/www/tienda/releases/R /var/www/tienda/current.tmp && mv -T /var/www/tienda/current.tmp /var/www/tienda/current
Recargar PHPsystemctl 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 $(…).

bash
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.

🖥️BashDespliegue por releases con enlace simbólico y vuelta atrásDifícil

Ejemplo

Entrada (lo que se escribe por teclado)
RELEASES 20260901-1200 20260915-0930
ESTADO
DESPLIEGA 20261001-1800 v1.4.0
DESPLIEGA 20261004-1000 v1.5.0
ROLLBACK
ESTADO
Salida esperada
# 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
⏳
Test oculto #3
⏳
Test oculto #4
0/4 tests pasados · pulsa un test para ver su entrada y su salida esperada

Solución explicada

Ver la solución completa
bash
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
67done

La 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 --ejecutar que ejecute las órdenes con set -euo pipefail y 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.

Dónde se explica