🎯 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
Dockerfileejecuta shell. Undocker-entrypoint.shes 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)
#!/usr/bin/env bash
set -euo pipefail
echo "Hola desde un script"chmod +x saludo.sh # darle permiso de ejecución (sin esto: "Permission denied")
./saludo.sh # ejecutarloLas dos primeras líneas son las más importantes del archivo:
La línea 1: el shebang #!
#!/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/shno es Bash. En Debian/Ubuntu,/bin/shesdash, una shell mínima y rápida que no tiene arrays, ni[[ ]], nilocal, ni sustitución de procesos. Si tu script empieza con#!/bin/shy 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.
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 últimaMira la diferencia:
# ❌ 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 ejecutaY pipefail:
# 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 pipefailen 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:bashgrep "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
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
| Forma | Qué hace | Cuándo usarla |
|---|---|---|
"$var" | Expande la variable, respeta espacios | 📌 Siempre, por defecto |
'$var' | Literal: imprime $var tal cual | Cuando NO quieres expansión |
$var | Expande y parte por espacios | Casi nunca. Es un bug esperando |
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
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
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
mi_var="solo dentro de este script" # variable de shell
export MI_VAR="visible para los hijos" # variable de ENTORNOSolo 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
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.33T2.3 · Argumentos: hacer scripts reutilizables
#!/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 "$@"./script.sh hola mundo
# Script: ./script.sh · Primer: hola · Segundo: mundo · Cuántos: 2Validar los argumentos siempre:
if [[ $# -lt 1 ]]; then
echo "Uso: $0 <entorno> [version]" >&2
exit 1
fi
ENTORNO="$1"
VERSION="${2:-latest}"💡
>&2manda 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:
#!/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
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 consh.
Comparaciones
| Tipo | Operadores | Ejemplo |
|---|---|---|
| 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,-eqpara 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)
[[ -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íoif [[ ! -f "config.yml" ]]; then
echo "Falta config.yml" >&2
exit 1
fiCombinar y el atajo && / ||
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, avisaCódigos de salida: el lenguaje de los scripts
exit 0 # 📌 ÉXITO
exit 1 # error genérico
exit 2 # error de uso (argumentos mal)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
# 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++))
doneLeer un archivo línea a línea (la forma correcta)
while IFS= read -r linea; do
echo "→ $linea"
done < archivo.txt⚠️
while IFS= read -rtiene una razón para cada parte:
IFS=evita que se recorten los espacios del principio y del final.-revita que las barras invertidas (\) se interpreten como escapes.< archivoal 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.
break # salir del bucle
continue # saltar a la siguiente vueltaT2.6 · Funciones
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):
# ❌ "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.
echoes tureturnde datos;returnes tuthrow/booleano. Mezclar los dos es el origen de muchos scripts confusos.
El patrón de logging que deberías copiar siempre
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
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"
doneArrays asociativos (diccionarios; requieren Bash 4+):
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.
bashopciones=(--rm --network host) [[ "$DEBUG" == true ]] && opciones+=(--env DEBUG=1) docker run "${opciones[@]}" mi-imagenEsto 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
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 vezLos tres canales:
| Nº | Nombre | Qué es |
|---|---|---|
| 0 | stdin | La entrada |
| 1 | stdout | La salida normal (datos) |
| 2 | stderr | Los mensajes de error |
🧠 Que stdout y stderr estén separados es un diseño brillante. Permite
./script.sh | grep xfiltrando solo los datos, mientras los errores siguen apareciendo en tu pantalla. Por eso tus logs van a>&2y tus resultados a stdout: quien use tu script podrá encadenarlo.
Here-documents: bloques de texto multilínea
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:$hostse queda como$host, que es justo lo que quiere Nginx).<<EOFsin comillas sí las expande. Elegir mal aquí es un clásico al generar configuraciones: tu$server_namedesaparece porque Bash lo sustituyó por una variable vacía.
# Con expansión, para plantillas:
cat > .env <<EOF
DATABASE_URL=postgres://user:${DB_PASS}@localhost/db
PUERTO=${PUERTO:-8080}
EOFT2.9 · trap: limpiar aunque el script explote
trap ejecuta código cuando el script termina, pase lo que pase (error, Ctrl+C, kill).
#!/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ñal | Cuándo salta |
|---|---|
EXIT | Al terminar el script, por el motivo que sea 📌 |
ERR | Cuando un comando falla (con set -e) |
INT | Ctrl+C |
TERM | kill normal |
Patrón profesional: mensaje de error con número de línea
trap 'echo "❌ Error en la línea $LINENO (código $?)" >&2' ERRCon esa única línea, cuando el script falle te dirá exactamente dónde. Ahorra horas.
Patrón avanzado: deshacer a medias (rollback)
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 EXITT2.10 · Depurar scripts
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# 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.shShellCheck: el linter obligatorio
sudo apt install shellcheck # o: brew install shellcheck
shellcheck script.shShellCheck detecta variables sin comillas, [ ] mal usado, $? capturado tarde, globs peligrosos… Tiene extensión para VS Code y se integra en CI:
# .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.
#!/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:
crontab -e0 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:
bashgunzip -c backup.sql.gz | psql -U postgres -d base_de_pruebasMuchas empresas descubren que sus backups estaban vacíos justo el día que los necesitan.
T2.12 · Proyecto 2 — Script de despliegue con rollback
#!/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
revertirLo que hace bueno a este script (y que deberías copiar en los tuyos):
- Descarga antes de parar. No se toca lo que funciona hasta tener lista la alternativa.
- Lock atómico. Dos despliegues a la vez dejan el sistema en un estado impredecible.
- Health check con reintentos. Una app tarda unos segundos en arrancar; comprobar una sola vez inmediatamente daría un falso negativo siempre.
- Muestra los logs antes de revertir. Si no, revierte y te quedas sin saber por qué falló.
- Rollback automático. El despliegue no "falla": vuelve a un estado bueno conocido.
T2.13 · Chuleta rápida
#!/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
- Shebang correcto y
set -euo pipefail. - Acepta
--json(salida en JSON) y--help(uso y salida con código 0). - Comprueba y reporta:
- Espacio en disco de
/. Avisa si supera el 80%. - Memoria disponible.
- Carga del sistema (
uptime). - Si los servicios
nginx,postgresqlydockerestán activos (que falten no debe romper el script). - Cuántos puertos están escuchando.
- Espacio en disco de
- Un array asociativo con los servicios a comprobar, para poder añadir más en una sola línea.
- Funciones
log,advertenciayerror; los errores van a stderr. - Código de salida significativo:
0todo bien,1alguna advertencia,2algo crítico (disco > 95% o un servicio caído). trapque limpie cualquier temporal.- Pasa
shellchecksin ningún aviso.
Extras (si quieres apretar)
- Una opción
--umbral Nque cambie el porcentaje de disco que dispara la advertencia. - 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. - Que
--jsonproduzca JSON válido de verdad (compruébalo conjq .).
💡 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. Conset -eactivo, unif ! systemctl is-active …; thenno aborta (dentro de unif,set -equeda desactivado). Fuera de unif, añade|| true. - Acumular el estado de salida: lleva una variable
codigo_salida=0y 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 conjq -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
mainlas 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.