Un script de Bash parece inofensivo hasta que se ejecuta en producción con un nombre de fichero que contiene espacios, o hasta que falla silenciosamente a mitad de una operación destructiva. Las prácticas que vamos a ver no son estética ni convención: cada una existe porque alguien —o muchos— pagaron el precio de ignorarla.
ShellCheck es un analizador estático para scripts de shell. Está en los repositorios oficiales de Debian y lo instalas con apt install shellcheck. Antes de ejecutar cualquier script nuevo, pásalo por shellcheck script.sh. Detecta el 80 % de los errores clásicos: variables sin comillas, comparaciones con [ ] que se rompen con valores vacíos, uso de $( ) frente a backticks, y docenas de patrones más. No es opcional en código que toca datos o sistemas reales.
set -euo pipefail al inicio del script cambia tres comportamientos del intérprete. -e hace que el script termine ante cualquier comando que devuelva un código de salida distinto de cero, en lugar de continuar alegremente. -u convierte las variables no definidas en error en vez de expandirlas a cadena vacía, que es una fuente clásica de bugs silenciosos. -o pipefail hace que una tubería falle si falla cualquier comando dentro de ella, no solo el último: sin esto, comando_que_falla | grep algo devuelve 0 si grep tiene éxito.
El word splitting es el mecanismo por el que Bash divide una expansión de variable en múltiples palabras usando IFS como delimitador. Cuando escribes rm $fichero y $fichero contiene "mi documento.txt", Bash ejecuta rm mi documento.txt, intentando borrar dos ficheros. La solución es siempre citar: rm "$fichero". No hay excepciones razonables para variables que pueden contener rutas o datos del usuario.
[[ ]] reemplaza a [ ] para tests condicionales porque es una construcción del propio Bash, no un comando externo. Eso significa que no está sujeta al word splitting, acepta operadores como && y || sin escapar, y maneja comparaciones de patrones con =~ de forma nativa. La única razón para usar [ ] hoy es compatibilidad con sh puro, que no es el caso cuando el shebang dice #!/bin/bash.
trap registra funciones o comandos que se ejecutarán cuando el script reciba una señal o alcance ciertas condiciones. trap 'cleanup' EXIT garantiza que la función cleanup se ejecuta al salir, independientemente de si fue por exit 0, por un error con -e, o por una señal. trap 'echo "Error en línea $LINENO"' ERR se activa en cada comando que devuelve un código de salida no cero, lo que con -e significa justo antes de que el script termine: el momento exacto en que necesitas información de diagnóstico.
mktemp crea ficheros temporales con nombres únicos y seguros. Escribir directamente en /tmp/mi_script_tmp es una vulnerabilidad de symlink race: un proceso malicioso puede crear ese fichero antes que tú y apuntarlo a cualquier destino. mktemp genera un nombre impredecible y crea el fichero con permisos 600. mktemp -d hace lo mismo para directorios.
Cuando el script empieza a necesitar estructuras de datos complejas, parsear JSON o YAML, o aritmética más allá de enteros simples, estás en el territorio donde Bash deja de ser la herramienta correcta. Un script de 300 líneas con arrays asociativos y llamadas a jq es difícil de mantener y difícil de testear; ese problema lo resuelve Python con mayor claridad y con bibliotecas diseñadas para él.
#!/usr/bin/env bash
# Procesador de backups con manejo robusto de errores y limpieza garantizada
set -euo pipefail
# ── Constantes ──────────────────────────────────────────────────────────
readonly ORIGEN="/var/data/aplicacion"
readonly DESTINO="/mnt/backups"
readonly RETENCION_DIAS=30
# ── Variables de estado ─────────────────────────────────────────────────
TMPDIR_TRABAJO=""
LOCKFILE=""
# ── Función de limpieza garantizada con trap EXIT ───────────────────────
cleanup() {
local exit_code=$?
# Eliminar directorio temporal si fue creado
if [[ -n "$TMPDIR_TRABAJO" && -d "$TMPDIR_TRABAJO" ]]; then
rm -rf "$TMPDIR_TRABAJO"
fi
# Liberar lock si fue adquirido
if [[ -n "$LOCKFILE" && -f "$LOCKFILE" ]]; then
rm -f "$LOCKFILE"
fi
# Propagar el código de salida original, no el de cleanup
exit "$exit_code"
}
# ── Manejador de errores: se activa antes de que -e termine el script ───
error_handler() {
# $BASH_LINENO[0] es la línea donde ocurrió el error
echo "[ERROR] Fallo en línea ${BASH_LINENO[0]}: último comando devolvió código $?" >&2
echo "[ERROR] Stack de llamadas:" >&2
# Iterar desde frame 1 para omitir error_handler mismo
local i
for (( i=1; i<${#FUNCNAME[@]}; i++ )); do
echo " [$i] ${FUNCNAME[$i]} — ${BASH_SOURCE[$i]}:${BASH_LINENO[$i-1]}" >&2
done
}
trap 'cleanup' EXIT
trap 'error_handler' ERR
# ── Lock para evitar ejecuciones concurrentes ───────────────────────────
# noclobber hace que > falle si el fichero ya existe; atómico en Linux
LOCKFILE="/tmp/backup_aplicacion.lock"
if ( set -o noclobber; echo $$ > "$LOCKFILE" ) 2>/dev/null; then
: # lock adquirido
else
echo "[WARN] Otra instancia en ejecución (PID $(cat "$LOCKFILE")). Saliendo." >&2
# Desactivar trap EXIT para no intentar borrar un lock que no es nuestro
trap - EXIT
exit 1
fi
# ── Validar entorno ─────────────────────────────────────────────────────
validar_entorno() {
# [[ ]] no hace word splitting; safe aunque $ORIGEN tuviera espacios
if [[ ! -d "$ORIGEN" ]]; then
echo "[ERROR] Directorio origen no existe: $ORIGEN" >&2
exit 1
fi
if [[ ! -w "$DESTINO" ]]; then
echo "[ERROR] Destino no existe o no es escribible: $DESTINO" >&2
exit 1
fi
}
# ── Crear directorio temporal de trabajo ───────────────────────────────
preparar_trabajo() {
# mktemp -d crea directorio con nombre único; nunca escribir en /tmp/nombre_fijo
TMPDIR_TRABAJO=$(mktemp -d)
# Heredoc para generar manifiesto: más legible que múltiples echo
cat > "$TMPDIR_TRABAJO/manifiesto.txt" <<EOF
Backup iniciado: $(date --iso-8601=seconds)
Origen: $ORIGEN
Destino: $DESTINO
Host: $(hostname -f)
Usuario: $(id -un)
EOF
}
# ── Comprimir y transferir ───────────────────────────────────────────────
ejecutar_backup() {
local timestamp
timestamp=$(date +%Y%m%d_%H%M%S)
local nombre_backup="backup_${timestamp}.tar.gz"
local ruta_destino="${DESTINO}/${nombre_backup}"
# tar con pipefail activo: si tar falla, el script se detiene
# -C cambia directorio antes de empaquetar para rutas relativas en el archivo
tar -czf "${TMPDIR_TRABAJO}/${nombre_backup}" -C "$(dirname "$ORIGEN")" \
"$(basename "$ORIGEN")"
# Copiar el archivo y el manifiesto juntos
cp "${TMPDIR_TRABAJO}/${nombre_backup}" "$ruta_destino"
cp "${TMPDIR_TRABAJO}/manifiesto.txt" "${ruta_destino%.tar.gz}.txt"
echo "[OK] Backup creado: $ruta_destino"
}
# ── Purgar backups antiguos ─────────────────────────────────────────────
purgar_antiguos() {
local eliminados=0
# Usar while+read es más seguro que for con glob cuando hay muchos ficheros
while IFS= read -r -d '' fichero; do
rm -f "$fichero"
(( eliminados++ )) || true # || true: el incremento puede devolver 0, no es error
done < <(find "$DESTINO" -maxdepth 1 -name "backup_*.tar.gz" \
-mtime +"$RETENCION_DIAS" -print0)
echo "[OK] Backups eliminados por antigüedad: $eliminados"
}
# ── Main ────────────────────────────────────────────────────────────────
validar_entorno
preparar_trabajo
ejecutar_backup
purgar_antiguos
Qué está pasando en cada decisión relevante
set -euo pipefail aparece en la línea 6 antes de cualquier código ejecutable. Si lo pusieras después de definir variables, esas asignaciones ya habrían corrido sin protección. La posición importa.
cleanup() captura $? como primera instrucción. Si lo hiciera más tarde, los comandos dentro de la función habrían sobreescrito ese valor. El exit "$exit_code" al final propaga el código original del script, no el de las operaciones de limpieza, lo que es crítico para que el proceso llamante —cron, systemd, un pipeline de CI— reciba el estado correcto.
El lock con noclobber usa una subshell ( set -o noclobber; echo $$ > "$LOCKFILE" ) porque noclobber afecta a la shell actual y no queremos ese efecto secundario permanente. La operación es atómica a nivel de sistema de ficheros en Linux: el kernel garantiza que la creación y apertura exclusiva del fichero son una sola llamada a open(2) con O_CREAT | O_EXCL.
TMPDIR_TRABAJO=$(mktemp -d) almacena la ruta en una variable antes de que la función cleanup exista en el scope relevante. Nótese que la variable se inicializa a cadena vacía en el scope global, lo que hace que el [[ -n "$TMPDIR_TRABAJO" ]] en cleanup sea seguro incluso si el script falla antes de llamar a preparar_trabajo.
El while IFS= read -r -d '' junto con -print0 es el patrón correcto para iterar ficheros con find cuando los nombres pueden contener espacios o caracteres especiales. IFS= evita el trimming de espacios, -r evita que read interprete backslashes, y -d '' usa el byte nulo como delimitador, que es el único carácter que nunca aparece en un nombre de fichero en Linux.
(( eliminados++ )) || true merece atención: con set -e, una expresión aritmética que evalúa a cero se trata como fallo. Si eliminados empieza en 0, (( eliminados++ )) devuelve 1 (falso, porque el valor antes del incremento era cero), lo que dispararía set -e. El || true neutraliza ese comportamiento sin desactivar -e globalmente.
El heredoc para manifiesto.txt no es solo estética. Cada echo adicional es un proceso que puede fallar, tiene su propia expansión de variables, y requiere escapar caracteres especiales manualmente. El heredoc es una sola operación de escritura con expansión controlada, y se lee de un vistazo sin contar cuántas comillas estás balanceando.
N° 76