Skip to content

🎯 Meta del capítulo: pasar de "copio comandos de internet" a escribir scripts que otra persona pueda leer, ejecutar en producción y confiar en ellos. Al terminar tendrás un script de backup y otro de despliegue, escritos por ti, con manejo de errores de verdad.

🧭 ¿Por qué Bash y no Python? Porque Bash ya está instalado en absolutamente todos los servidores Linux, todos los contenedores Docker y todos los runners de CI. Un Dockerfile ejecuta shell. Un docker-entrypoint.sh es shell. Un job de GitHub Actions es shell. No puedes evitarlo, así que más vale escribirlo bien.

Cuándo NO usar Bash: si el script pasa de ~150 líneas, necesita estructuras de datos, o tienes que parsear JSON/XML complejo → cambia a Python. Bash es un pegamento excelente entre programas y un lenguaje de programación mediocre. Saber dónde está esa frontera es criterio de ingeniero.


T2.1 · Tu primer script (y las tres líneas que nadie te explica)

bash
#!/usr/bin/env bash
set -euo pipefail

echo "Hola desde un script"
bash
chmod +x saludo.sh      # darle permiso de ejecución (sin esto: "Permission denied")
./saludo.sh             # ejecutarlo

Las dos primeras líneas son las más importantes del archivo:

La línea 1: el shebang #!

bash
#!/usr/bin/env bash     # ✅ RECOMENDADO
#!/bin/bash             # funciona, pero rígido
#!/bin/sh               # ⚠️ NO es bash: es una shell más limitada

#! le dice al sistema qué intérprete debe ejecutar el archivo. /usr/bin/env bash busca bash en el PATH, así que funciona igual en Linux (donde está en /bin/bash) que en macOS con Homebrew (donde el bash moderno está en /opt/homebrew/bin/bash).

⚠️ #!/bin/sh no es Bash. En Debian/Ubuntu, /bin/sh es dash, una shell mínima y rápida que no tiene arrays, ni [[ ]], ni local, ni sustitución de procesos. Si tu script empieza con #!/bin/sh y usas sintaxis de Bash, fallará con errores crípticos como [[: not found. Regla: si escribes Bash, declara Bash.

La línea 2: set -euo pipefail, el cinturón de seguridad

Por defecto, Bash tiene un comportamiento peligroso: si un comando falla, sigue como si nada.

bash
set -e              # ABORTAR si cualquier comando devuelve error (exit code ≠ 0)
set -u              # ABORTAR si se usa una variable no definida
set -o pipefail     # una tubería falla si CUALQUIER parte falla, no solo la última

Mira la diferencia:

bash
# ❌ SIN set -e — el desastre silencioso
cd /carpeta/que/no/existe    # falla, imprime error… y CONTINÚA
rm -rf *                      # 💀 se ejecuta en la carpeta donde estabas antes

# ✅ CON set -e
set -e
cd /carpeta/que/no/existe    # falla → el script MUERE aquí
rm -rf *                      # nunca se ejecuta

Y pipefail:

bash
# Sin pipefail: el resultado es el del ÚLTIMO comando de la tubería
cat archivo_inexistente | wc -l     # wc funciona → exit code 0 → "todo bien" 🤡

set -o pipefail
cat archivo_inexistente | wc -l     # ahora sí devuelve error

💡 Escribe set -euo pipefail en la línea 2 de TODOS tus scripts. Es la diferencia entre un script que falla ruidosamente en la línea correcta y uno que hace medio despliegue, deja el sistema en un estado inconsistente y termina diciendo "OK".

⚠️ Una trampa de set -e: un comando que "falla legítimamente" también aborta el script. Si quieres permitir el fallo, dilo explícitamente:

bash
grep "ERROR" app.log || true          # permitir que no encuentre nada
if grep -q "ERROR" app.log; then ...  # dentro de un if, -e no aborta

T2.2 · Variables y el infierno de las comillas

bash
nombre="Ana"              # ⚠️ SIN espacios alrededor del =
edad=30
ruta="/var/log/app.log"

echo "$nombre tiene $edad años"
echo "${nombre}s"         # las llaves delimitan el nombre: "Anas" (sin ellas sería $nombres)

Las comillas: la fuente nº1 de bugs en Bash

FormaQué haceCuándo usarla
"$var"Expande la variable, respeta espacios📌 Siempre, por defecto
'$var'Literal: imprime $var tal cualCuando NO quieres expansión
$varExpande y parte por espaciosCasi nunca. Es un bug esperando
bash
archivo="mi documento.txt"

rm $archivo       # ❌ Bash lo ve como DOS argumentos: "mi" y "documento.txt"
rm "$archivo"     # ✅ un solo argumento con el espacio incluido

🧠 La regla que resuelve el 90% de los bugs de Bash: pon comillas dobles alrededor de toda expansión de variable, siempre. "$var", "$1", "$(comando)", "${array[@]}". No hay prácticamente ningún caso en que quitar las comillas mejore algo, y hay mil en que las necesitas. Cuando dudes: comillas.

Sustitución de comandos

bash
fecha=$(date +%Y-%m-%d)          # ✅ moderno, anidable
fecha=`date +%Y-%m-%d`           # ❌ retrocompatible pero ilegible: no lo uses

archivos=$(ls -1 | wc -l)
echo "Hoy es $fecha y hay $archivos archivos"

Valores por defecto y variables obligatorias

bash
PUERTO="${PUERTO:-8080}"             # si PUERTO está vacío o no existe → 8080
NOMBRE="${1:-mundo}"                 # primer argumento, o "mundo" si no se pasó

DB_PASS="${DB_PASS:?Falta la variable DB_PASS}"   # 📌 ABORTA con mensaje si falta
DIR="${DIR:?directorio vacío}"; rm -rf "$DIR"     # y así rm -rf nunca borra la raíz

💡 ${VAR:?mensaje} es la mejor forma de validar configuración. Una línea, y el script muere con un error claro en vez de continuar con una variable vacía y hacer algo catastrófico.

Variables de entorno vs variables locales

bash
mi_var="solo dentro de este script"        # variable de shell
export MI_VAR="visible para los hijos"     # variable de ENTORNO

Solo las exportadas pasan a los programas que lances. Por eso tu app en Docker ve DATABASE_URL si se hizo export (o si viene del environment: del compose), y no la ve si solo se asignó.

Aritmética

bash
a=5
b=3
suma=$((a + b))                # 8   ← doble paréntesis para matemáticas
echo $(( a * b ))              # 15

# Bash solo hace enteros. Para decimales, usa bc o awk:
echo "scale=2; 10/3" | bc      # 3.33

T2.3 · Argumentos: hacer scripts reutilizables

bash
#!/usr/bin/env bash
set -euo pipefail

echo "Script:            $0"
echo "Primer argumento:  ${1:-}"
echo "Segundo:           ${2:-}"
echo "Cuántos hay:       $#"
echo "Todos:             $*"      # una sola cadena
echo "Todos (correcto):  $@"      # array — úsalo con "$@"
bash
./script.sh hola mundo
# Script: ./script.sh · Primer: hola · Segundo: mundo · Cuántos: 2

Validar los argumentos siempre:

bash
if [[ $# -lt 1 ]]; then
    echo "Uso: $0 <entorno> [version]" >&2
    exit 1
fi

ENTORNO="$1"
VERSION="${2:-latest}"

💡 >&2 manda el mensaje a stderr (la salida de errores) en vez de stdout. Importa: si alguien hace ./script.sh > salida.txt, los errores seguirán viéndose en pantalla en vez de acabar mezclados con los datos. Todo mensaje de error y de log va a >&2.

Opciones con nombre (--flag)

Para scripts serios, acepta flags en vez de posiciones:

bash
#!/usr/bin/env bash
set -euo pipefail

ENTORNO=""
VERBOSO=false

while [[ $# -gt 0 ]]; do
    case "$1" in
        -e|--entorno)  ENTORNO="$2"; shift 2 ;;
        -v|--verboso)  VERBOSO=true; shift ;;
        -h|--ayuda)    echo "Uso: $0 --entorno <prod|dev> [--verboso]"; exit 0 ;;
        *)             echo "Opción desconocida: $1" >&2; exit 1 ;;
    esac
done

[[ -z "$ENTORNO" ]] && { echo "Falta --entorno" >&2; exit 1; }
echo "Desplegando en $ENTORNO (verboso=$VERBOSO)"

shift descarta el argumento procesado y desplaza el resto; shift 2 descarta el flag y su valor.


T2.4 · Condicionales: [[ ]] y nada más

bash
if [[ "$entorno" == "produccion" ]]; then
    echo "Cuidado"
elif [[ "$entorno" == "staging" ]]; then
    echo "Pruebas"
else
    echo "Desarrollo"
fi

⚠️ Usa [[ ]], no [ ]. [ ] es un programa (/usr/bin/test) heredado de los años 70: se traga variables sin comillas partidas por espacios, no soporta &&/|| dentro, ni regex. [[ ]] es sintaxis de Bash, más segura y más potente. Solo usa [ ] si de verdad necesitas compatibilidad con sh.

Comparaciones

TipoOperadoresEjemplo
Cadenas== != < >[[ "$a" == "$b" ]]
Cadena vacía-z (vacía) -n (no vacía)[[ -z "$var" ]]
Números-eq -ne -lt -le -gt -ge[[ $n -gt 10 ]]
Patrón== con comodines[[ "$f" == *.log ]]
Regex=~[[ "$email" =~ ^[^@]+@[^@]+$ ]]

🧠 == para texto, -eq para números. [[ "10" > "9" ]] es falso porque compara alfabéticamente ("1" < "9"). [[ 10 -gt 9 ]] es verdadero. Confundirlos da bugs que solo aparecen al pasar de 9 a 10 — de los peores de encontrar.

Comprobar archivos (imprescindible en scripts)

bash
[[ -f "$archivo" ]]     # existe y es un archivo normal
[[ -d "$carpeta" ]]     # existe y es un directorio
[[ -e "$ruta" ]]        # existe (lo que sea)
[[ -r "$archivo" ]]     # tengo permiso de LECTURA
[[ -w "$archivo" ]]     # tengo permiso de ESCRITURA
[[ -x "$archivo" ]]     # es ejecutable
[[ -s "$archivo" ]]     # existe y NO está vacío
bash
if [[ ! -f "config.yml" ]]; then
    echo "Falta config.yml" >&2
    exit 1
fi

Combinar y el atajo && / ||

bash
if [[ -f "$f" && -r "$f" ]]; then ... fi        # Y
if [[ "$e" == "dev" || "$e" == "test" ]]; then ... fi   # O

# Forma corta (para una sola acción)
[[ -d "$dir" ]] || mkdir -p "$dir"     # si NO existe, créalo
comando_importante && echo "OK"         # si funciona, avisa

Códigos de salida: el lenguaje de los scripts

bash
exit 0      # 📌 ÉXITO
exit 1      # error genérico
exit 2      # error de uso (argumentos mal)
bash
mi_comando
if [[ $? -ne 0 ]]; then echo "falló"; fi    # $? = código del último comando

# Mejor todavía — más directo y legible:
if ! mi_comando; then echo "falló"; fi

🧠 En shell, 0 es éxito y cualquier otro número es error. Es al revés que en el resto de la programación, donde 0 suele ser "falso". La razón: solo hay una forma de acertar, pero muchas de fallar — así el número puede indicar qué falló. Tu CI usa exactamente esto para decidir si el pipeline pasa o no.


T2.5 · Bucles

bash
# Sobre una lista
for entorno in dev staging prod; do
    echo "Desplegando en $entorno"
done

# Sobre archivos (💡 esto es un GLOB, no ejecuta ls)
for archivo in /var/log/*.log; do
    [[ -e "$archivo" ]] || continue      # si no hay ninguno, el glob queda literal
    echo "Procesando: $archivo"
done

# Rango numérico
for i in {1..5}; do echo "Intento $i"; done
for i in {0..20..5}; do echo "$i"; done      # de 5 en 5

# Estilo C
for ((i = 0; i < 5; i++)); do echo "$i"; done

# Mientras
contador=0
while [[ $contador -lt 3 ]]; do
    echo "$contador"
    ((contador++))
done

Leer un archivo línea a línea (la forma correcta)

bash
while IFS= read -r linea; do
    echo "→ $linea"
done < archivo.txt

⚠️ while IFS= read -r tiene una razón para cada parte:

  • IFS= evita que se recorten los espacios del principio y del final.
  • -r evita que las barras invertidas (\) se interpreten como escapes.
  • < archivo al final redirige el archivo a la entrada del bucle.

Y nunca uses for linea in $(cat archivo): eso parte por espacios, no por líneas, así que una línea con espacios se rompe en trozos. Es el error clásico nº2 de Bash después de las comillas.

bash
break        # salir del bucle
continue     # saltar a la siguiente vuelta

T2.6 · Funciones

bash
saludar() {
    local nombre="${1:?falta el nombre}"     # local = no contamina el resto del script
    echo "Hola, $nombre"
}

saludar "Ana"

Cómo devolver valores (aquí Bash es raro y conviene entenderlo bien):

bash
# ❌ "return" NO devuelve datos: devuelve el CÓDIGO DE SALIDA (0-255)
sumar() { return $(( $1 + $2 )); }     # sumar 200 300 → devuelve 244 (desborda) 💀

# ✅ Para devolver DATOS: se imprimen y se capturan
sumar() { echo $(( $1 + $2 )); }
resultado=$(sumar 200 300)             # 500 ✅

# ✅ Para devolver ÉXITO/FALLO: se usa return (o simplemente el último comando)
existe_usuario() {
    id "$1" &>/dev/null       # &>/dev/null = silenciar toda la salida
}
if existe_usuario "ana"; then echo "existe"; fi

🧠 Regla mental: en Bash, una función imprime resultados y devuelve estados. echo es tu return de datos; return es tu throw/booleano. Mezclar los dos es el origen de muchos scripts confusos.

El patrón de logging que deberías copiar siempre

bash
log()   { echo "[$(date '+%H:%M:%S')] $*"; }
info()  { log "ℹ️  $*"; }
ok()    { log "✅ $*"; }
error() { log "❌ $*" >&2; }
fatal() { error "$*"; exit 1; }

info "Empezando el despliegue"
[[ -f config.yml ]] || fatal "Falta config.yml"
ok "Despliegue terminado"

Con estas cinco líneas tus scripts pasan de "imprimen cosas" a "tienen logs". fatal sobre todo: un solo sitio donde se decide cómo se aborta.


T2.7 · Arrays

bash
servidores=("web1" "web2" "db1")

echo "${servidores[0]}"        # web1 (empiezan en 0)
echo "${servidores[@]}"        # todos
echo "${#servidores[@]}"       # 3 → cuántos hay

servidores+=("cache1")         # añadir al final

for s in "${servidores[@]}"; do        # ⚠️ SIEMPRE con "${arr[@]}" entre comillas
    echo "Conectando a $s"
done

Arrays asociativos (diccionarios; requieren Bash 4+):

bash
declare -A puertos
puertos[web]=80
puertos[api]=8080
puertos[db]=5432

echo "${puertos[api]}"                 # 8080
for servicio in "${!puertos[@]}"; do   # ! = las CLAVES
    echo "$servicio → ${puertos[$servicio]}"
done

💡 Uso real de arrays: construir comandos con argumentos variables.

bash
opciones=(--rm --network host)
[[ "$DEBUG" == true ]] && opciones+=(--env DEBUG=1)
docker run "${opciones[@]}" mi-imagen

Esto es mucho más seguro que meter todo en una cadena, porque cada elemento del array llega como un argumento aunque contenga espacios.


T2.8 · Entrada, salida y redirección

bash
comando > archivo.txt          # stdout a un archivo (SOBRESCRIBE)
comando >> archivo.txt         # stdout, AÑADIENDO al final
comando 2> errores.txt         # solo stderr
comando > salida.txt 2>&1      # 📌 stdout Y stderr al mismo archivo
comando &> todo.txt            # lo mismo, forma corta de Bash
comando > /dev/null 2>&1       # silenciar TODO (/dev/null es el agujero negro)

comando1 | comando2            # tubería: salida de uno → entrada del otro
comando | tee archivo.txt      # ver en pantalla Y guardar a la vez

Los tres canales:

NombreQué es
0stdinLa entrada
1stdoutLa salida normal (datos)
2stderrLos mensajes de error

🧠 Que stdout y stderr estén separados es un diseño brillante. Permite ./script.sh | grep x filtrando solo los datos, mientras los errores siguen apareciendo en tu pantalla. Por eso tus logs van a >&2 y tus resultados a stdout: quien use tu script podrá encadenarlo.

Here-documents: bloques de texto multilínea

bash
cat > /etc/nginx/sites-available/miapp <<'EOF'
server {
    listen 80;
    server_name miapp.com;
    location / { proxy_pass http://localhost:3000; }
}
EOF

💡 <<'EOF' con comillas simples NO expande variables (el texto va literal: $host se queda como $host, que es justo lo que quiere Nginx). <<EOF sin comillas sí las expande. Elegir mal aquí es un clásico al generar configuraciones: tu $server_name desaparece porque Bash lo sustituyó por una variable vacía.

bash
# Con expansión, para plantillas:
cat > .env <<EOF
DATABASE_URL=postgres://user:${DB_PASS}@localhost/db
PUERTO=${PUERTO:-8080}
EOF

T2.9 · trap: limpiar aunque el script explote

trap ejecuta código cuando el script termina, pase lo que pase (error, Ctrl+C, kill).

bash
#!/usr/bin/env bash
set -euo pipefail

TEMPORAL=$(mktemp -d)                    # carpeta temporal segura
trap 'rm -rf "$TEMPORAL"' EXIT           # 📌 se borra SIEMPRE al salir

echo "trabajando en $TEMPORAL"
cp -r ./datos "$TEMPORAL/"
# … aunque esto falle, aunque pulses Ctrl+C, la carpeta se limpia
SeñalCuándo salta
EXITAl terminar el script, por el motivo que sea 📌
ERRCuando un comando falla (con set -e)
INTCtrl+C
TERMkill normal

Patrón profesional: mensaje de error con número de línea

bash
trap 'echo "❌ Error en la línea $LINENO (código $?)" >&2' ERR

Con esa única línea, cuando el script falle te dirá exactamente dónde. Ahorra horas.

Patrón avanzado: deshacer a medias (rollback)

bash
limpiar() {
    local codigo=$?
    if [[ $codigo -ne 0 ]]; then
        error "Fallo (código $codigo). Revirtiendo…"
        docker compose -f docker-compose.old.yml up -d
    fi
    rm -f /tmp/deploy.lock
}
trap limpiar EXIT

T2.10 · Depurar scripts

bash
bash -x script.sh          # 📌 imprime CADA comando antes de ejecutarlo
bash -n script.sh          # solo comprueba la sintaxis, no ejecuta
set -x                     # activar el traceo desde una línea concreta
set +x                     # desactivarlo
bash
# Traceo con número de línea y nombre de función (ponlo mientras depuras):
export PS4='+ ${BASH_SOURCE}:${LINENO}:${FUNCNAME[0]:-main}: '
bash -x script.sh

ShellCheck: el linter obligatorio

bash
sudo apt install shellcheck      # o: brew install shellcheck
shellcheck script.sh

ShellCheck detecta variables sin comillas, [ ] mal usado, $? capturado tarde, globs peligrosos… Tiene extensión para VS Code y se integra en CI:

yaml
# .github/workflows/lint.yml
- name: ShellCheck
  run: shellcheck scripts/*.sh

💡 ShellCheck no es opcional. Bash tiene tantas trampas sutiles que incluso gente con años de experiencia lo usa siempre. Si un script tuyo va a producción y no pasa ShellCheck limpio, todavía no está terminado.


T2.11 · Proyecto 1 — Script de backup de base de datos

Un script real, completo y comentado. Léelo entero: usa casi todo lo del capítulo.

bash
#!/usr/bin/env bash
#
# backup-db.sh — Copia de seguridad de PostgreSQL con rotación.
# Uso: ./backup-db.sh [--dir CARPETA] [--retener DIAS]
#
set -euo pipefail

# ─── Configuración (variables de entorno con valores por defecto) ───────────
DB_NAME="${DB_NAME:?Define DB_NAME}"
DB_USER="${DB_USER:-postgres}"
DB_HOST="${DB_HOST:-localhost}"
DIR_BACKUP="${DIR_BACKUP:-/var/backups/postgres}"
RETENER_DIAS="${RETENER_DIAS:-7}"

# ─── Argumentos ─────────────────────────────────────────────────────────────
while [[ $# -gt 0 ]]; do
    case "$1" in
        --dir)     DIR_BACKUP="$2"; shift 2 ;;
        --retener) RETENER_DIAS="$2"; shift 2 ;;
        -h|--help) sed -n '2,5p' "$0"; exit 0 ;;
        *)         echo "Opción desconocida: $1" >&2; exit 2 ;;
    esac
done

# ─── Utilidades ─────────────────────────────────────────────────────────────
log()   { echo "[$(date '+%F %T')] $*"; }
error() { log "❌ $*" >&2; }
fatal() { error "$*"; exit 1; }

# ─── Limpieza garantizada ───────────────────────────────────────────────────
TEMPORAL="$(mktemp -d)"
trap 'rm -rf "$TEMPORAL"' EXIT
trap 'error "Fallo en la línea $LINENO"' ERR

# ─── Comprobaciones previas (fallar PRONTO y CLARO) ────────────────────────
command -v pg_dump >/dev/null || fatal "pg_dump no está instalado"
mkdir -p "$DIR_BACKUP"
[[ -w "$DIR_BACKUP" ]] || fatal "Sin permiso de escritura en $DIR_BACKUP"

# ─── Backup ─────────────────────────────────────────────────────────────────
MARCA="$(date +%Y%m%d_%H%M%S)"
ARCHIVO="$DIR_BACKUP/${DB_NAME}_${MARCA}.sql.gz"

log "Volcando '$DB_NAME' desde $DB_HOST…"
# Se escribe primero al temporal: si falla a medias, no queda un .gz corrupto
# en la carpeta de backups haciéndose pasar por válido.
pg_dump --host="$DB_HOST" --username="$DB_USER" --no-password \
        --format=plain "$DB_NAME" | gzip -9 > "$TEMPORAL/volcado.sql.gz"

# Verificar que el archivo no está vacío ni corrupto
[[ -s "$TEMPORAL/volcado.sql.gz" ]] || fatal "El volcado está vacío"
gzip -t "$TEMPORAL/volcado.sql.gz"  || fatal "El archivo comprimido está corrupto"

mv "$TEMPORAL/volcado.sql.gz" "$ARCHIVO"
chmod 600 "$ARCHIVO"          # un backup contiene TODOS los datos: protégelo

TAMANO="$(du -h "$ARCHIVO" | cut -f1)"
log "✅ Backup creado: $ARCHIVO ($TAMANO)"

# ─── Rotación: borrar los más viejos que N días ────────────────────────────
log "Borrando backups de más de $RETENER_DIAS días…"
BORRADOS="$(find "$DIR_BACKUP" -name "${DB_NAME}_*.sql.gz" \
                 -mtime +"$RETENER_DIAS" -print -delete | wc -l)"
log "Borrados: $BORRADOS"

log "🎉 Terminado"

Programarlo con cron para que corra cada noche a las 3:

bash
crontab -e
cron
0 3 * * * DB_NAME=miapp /home/deploy/scripts/backup-db.sh >> /var/log/backup.log 2>&1

⚠️ Un backup que nunca se ha restaurado no es un backup: es una esperanza. Prueba la restauración al menos una vez al mes:

bash
gunzip -c backup.sql.gz | psql -U postgres -d base_de_pruebas

Muchas empresas descubren que sus backups estaban vacíos justo el día que los necesitan.


T2.12 · Proyecto 2 — Script de despliegue con rollback

bash
#!/usr/bin/env bash
#
# deploy.sh — Despliega la app y revierte automáticamente si el health check falla.
# Uso: ./deploy.sh <version>
#
set -euo pipefail

VERSION="${1:?Uso: $0 <version>   (ej: v1.4.2)}"
APP="miapp"
URL_SALUD="http://localhost:3000/health"
LOCK="/tmp/${APP}-deploy.lock"

log()   { echo "[$(date '+%T')] $*"; }
error() { log "❌ $*" >&2; }
fatal() { error "$*"; exit 1; }

# ─── Evitar dos despliegues simultáneos ─────────────────────────────────────
# noclobber: crear el lock falla si ya existe → operación atómica
if ! (set -o noclobber; echo "$$" > "$LOCK") 2>/dev/null; then
    fatal "Ya hay un despliegue en curso (PID $(cat "$LOCK"))"
fi
trap 'rm -f "$LOCK"' EXIT

# ─── Guardar la versión actual para poder volver ────────────────────────────
VERSION_ANTERIOR="$(docker inspect --format='{{index .Config.Labels "version"}}' \
                    "$APP" 2>/dev/null || echo "ninguna")"
log "Versión actual: $VERSION_ANTERIOR → nueva: $VERSION"

revertir() {
    [[ "$VERSION_ANTERIOR" == "ninguna" ]] && fatal "No hay versión anterior a la que volver"
    error "Revirtiendo a $VERSION_ANTERIOR…"
    docker stop "$APP" >/dev/null 2>&1 || true
    docker rm   "$APP" >/dev/null 2>&1 || true
    docker run -d --name "$APP" -p 3000:3000 \
        --label "version=$VERSION_ANTERIOR" "$APP:$VERSION_ANTERIOR"
    fatal "Revertido a $VERSION_ANTERIOR"
}

# ─── 1. Descargar la imagen ANTES de tocar nada que esté funcionando ────────
log "Descargando $APP:$VERSION…"
docker pull "$APP:$VERSION" || fatal "No existe la imagen $APP:$VERSION"

# ─── 2. Sustituir el contenedor ─────────────────────────────────────────────
log "Parando el contenedor actual…"
docker stop "$APP" >/dev/null 2>&1 || true
docker rm   "$APP" >/dev/null 2>&1 || true

log "Arrancando la nueva versión…"
docker run -d --name "$APP" -p 3000:3000 \
    --label "version=$VERSION" "$APP:$VERSION"

# ─── 3. Health check con reintentos ─────────────────────────────────────────
log "Comprobando salud (máx. 30s)…"
for intento in {1..15}; do
    if curl --fail --silent --max-time 2 "$URL_SALUD" >/dev/null; then
        log "✅ Sano al segundo intento $intento"
        log "🎉 Desplegada la versión $VERSION"
        exit 0
    fi
    sleep 2
done

error "La app no responde tras 30 segundos"
docker logs --tail 50 "$APP" >&2      # 📌 mostrar el motivo antes de revertir
revertir

Lo que hace bueno a este script (y que deberías copiar en los tuyos):

  1. Descarga antes de parar. No se toca lo que funciona hasta tener lista la alternativa.
  2. Lock atómico. Dos despliegues a la vez dejan el sistema en un estado impredecible.
  3. Health check con reintentos. Una app tarda unos segundos en arrancar; comprobar una sola vez inmediatamente daría un falso negativo siempre.
  4. Muestra los logs antes de revertir. Si no, revierte y te quedas sin saber por qué falló.
  5. Rollback automático. El despliegue no "falla": vuelve a un estado bueno conocido.

T2.13 · Chuleta rápida

bash
#!/usr/bin/env bash
set -euo pipefail                      # cinturón de seguridad

"$var"  "${var:-por_defecto}"  "${var:?obligatoria}"     # variables
"$(comando)"                                              # sustitución
$(( a + b ))                                              # aritmética

[[ -f "$f" ]] [[ -d "$d" ]] [[ -z "$s" ]] [[ $n -gt 5 ]]  # tests
[[ "$a" == "$b" ]] [[ "$x" =~ ^re ]]

for x in "${arr[@]}"; do done                           # bucles
while IFS= read -r linea; do done < archivo

f() { local x="$1"; echo "resultado"; }                   # funciones
r=$(f "arg")

cmd > out 2>&1 · cmd &>/dev/null · cmd | tee log          # redirección
cat <<'EOF' EOF                                          # here-doc literal

trap 'rm -rf "$TMP"' EXIT                                  # limpieza
bash -x script.sh · shellcheck script.sh                   # depurar

✅ Ejercicio del capítulo

Escribe revisa-sistema.sh, un script de diagnóstico que ejecutarías al entrar en un servidor con problemas. Requisitos:

Obligatorios

  1. Shebang correcto y set -euo pipefail.
  2. Acepta --json (salida en JSON) y --help (uso y salida con código 0).
  3. Comprueba y reporta:
    • Espacio en disco de /. Avisa si supera el 80%.
    • Memoria disponible.
    • Carga del sistema (uptime).
    • Si los servicios nginx, postgresql y docker están activos (que falten no debe romper el script).
    • Cuántos puertos están escuchando.
  4. Un array asociativo con los servicios a comprobar, para poder añadir más en una sola línea.
  5. Funciones log, advertencia y error; los errores van a stderr.
  6. Código de salida significativo: 0 todo bien, 1 alguna advertencia, 2 algo crítico (disco > 95% o un servicio caído).
  7. trap que limpie cualquier temporal.
  8. Pasa shellcheck sin ningún aviso.

Extras (si quieres apretar)

  1. Una opción --umbral N que cambie el porcentaje de disco que dispara la advertencia.
  2. Colores en la salida (verde/amarillo/rojo) solo si la salida es una terminal — pista: [[ -t 1 ]] es verdadero cuando stdout es una terminal y falso cuando se redirige a un archivo. Un log lleno de códigos de escape es ilegible.
  3. Que --json produzca JSON válido de verdad (compruébalo con jq .).
💡 Pistas de la solución (abre solo si te atascas)
  • Disco: df -h / | awk 'NR==2 {print $5}' | tr -d '%' te da el porcentaje como número limpio, listo para comparar con -gt.
  • Servicios sin romper el script: systemctl is-active --quiet "$s" devuelve 0 o 1. Con set -e activo, un if ! systemctl is-active …; then no aborta (dentro de un if, set -e queda desactivado). Fuera de un if, añade || true.
  • Acumular el estado de salida: lleva una variable codigo_salida=0 y súbela solo si el nuevo estado es peor: (( nuevo > codigo_salida )) && codigo_salida=$nuevo. Al final, exit "$codigo_salida".
  • Colores condicionales:
    bash
    if [[ -t 1 ]]; then ROJO=$'\e[31m'; RESET=$'\e[0m'; else ROJO=""; RESET=""; fi
  • JSON: si tienes jq, constrúyelo con jq -n --arg disco "$d" '{disco: $disco}' en vez de concatenar cadenas a mano — así el escapado es correcto siempre.
  • Estructura recomendada: una función por comprobación, que imprima su resultado y devuelva su código; el main las llama en orden y agrega los códigos. Eso te deja añadir comprobaciones sin tocar nada más (principio abierto/cerrado del capítulo 10, aplicado a un script).

🧠 Autoevaluación

  • -e: aborta el script si un comando devuelve código distinto de 0.
  • -u: aborta si se usa una variable no definida (caza erratas en nombres de variable).
  • -o pipefail: hace que una tubería devuelva error si cualquier comando de la cadena falla, no solo el último.

Sin esto, Bash continúa tras un error. Un script de despliegue puede fallar al copiar los archivos, seguir adelante, reiniciar el servicio con la versión vieja y terminar imprimiendo "Despliegue OK". Fallar pronto y ruidosamente es preferible a un estado inconsistente silencioso.

Sin comillas, Bash aplica word splitting: parte el valor de la variable por espacios y trata cada trozo como un argumento distinto. Si archivo="mi documento.txt", el comando se convierte en rm mi documento.txt, que intenta borrar dos archivos. Peor aún: si la variable está vacía y el comando es rm -rf $DIR/*, se convierte en rm -rf /*. Las comillas dobles preservan el valor como un único argumento, espacios incluidos.

return establece el código de salida de la función: un entero de 0 a 255 que significa éxito/fallo, no un dato. echo escribe en stdout, y es así como una función "devuelve" datos: se capturan con resultado=$(mi_funcion).

Usar return para devolver un número es un bug esperando: return 300 da 44 (desbordamiento módulo 256) y return "texto" es directamente un error. Regla: echo para datos, return para éxito/fallo.

Un rm al final solo se ejecuta si el script llega al final. Con trap … EXIT, la limpieza se ejecuta pase lo que pase: si un comando falla y set -e aborta, si el usuario pulsa Ctrl+C, si alguien manda un kill, o si se sale con exit 1 desde cualquier punto.

Sin trap, cada script que falle deja basura en /tmp; multiplicado por un cron nocturno durante meses, eso es un disco lleno — y ya sabes del capítulo T1 el caos que provoca.

Porque $(cat archivo) se somete a word splitting: se parte por espacios, tabuladores y saltos de línea, no por líneas. La línea ERROR: fallo de conexión se convierte en cuatro iteraciones. Además, los comodines del contenido se expandirían como globs.

La forma correcta es while IFS= read -r linea; do … done < archivo: IFS= conserva los espacios de los extremos, -r no interpreta las barras invertidas, y el bucle avanza línea a línea de verdad.

Porque parar el contenedor inicia la caída del servicio, y todo lo que ocurra después alarga esa caída. Si la imagen no existe, el registro está caído o la red falla, con el orden correcto el script aborta sin haber tocado el contenedor en funcionamiento: cero downtime y cero riesgo. Con el orden invertido, ya has tirado el servicio antes de descubrir que no puedes levantar el nuevo — y te quedas caído durante todo el tiempo que tardes en reaccionar.

Es un principio general: prepara todo lo que pueda fallar antes de tocar lo que funciona.

Señales claras: el script supera ~150 líneas; necesitas estructuras de datos anidadas (Bash solo tiene cadenas y arrays planos); tienes que parsear JSON/XML de verdad; necesitas manejo de errores con tipos de excepción; requieres tests unitarios de la lógica; o haces aritmética con decimales.

Bash es un pegamento excelente entre programas (encadenar comandos, mover archivos, orquestar) y un lenguaje de programación mediocre (lógica compleja, datos estructurados). Cuando el script deja de ser "una secuencia de comandos" y pasa a ser "un programa que además ejecuta comandos", es el momento de cambiar.


Siguiente: 03-caja-de-herramientas-cli.md — grep, sed, awk, jq, find y curl: los multiplicadores de fuerza.