Bash Scripting: Escribir Scripts Robustos y Mantenibles
Guía completa del scripting en Bash: variables, quoting, set -euo pipefail, funciones, getopts, trap, arrays y shellcheck para scripts fiables.
InSkillCoach
Bash Scripting: Escribir Scripts Robustos y Mantenibles
Cualquiera puede escribir un script Bash que funcione una vez, en su máquina, un día de buen tiempo. Escribir un script que sobreviva a los espacios en los nombres de archivo, a los comandos que fallan en silencio y a las interrupciones en mitad de un procesamiento es otra historia. En esta guía repasamos las técnicas que separan un script desechable de un script de producción: el modo estricto, un quoting riguroso, las funciones, la gestión de argumentos, la limpieza con trap y la validación con shellcheck. Todo ello culmina en un script de copia de seguridad completo y comentado.
1. Shebang y Ejecución
La primera línea importa
El shebang indica al sistema qué intérprete utilizar. Coexisten dos formas:
#!/bin/bash
# Ruta directa: simple, funciona en casi todas las distribuciones
#!/usr/bin/env bash
# Busca bash en el PATH: preferible para la portabilidad (macOS, Nix, etc.)
No utilice #!/bin/sh si su script contiene construcciones específicas de Bash ([[ ]], arrays, local): en Debian y Ubuntu, /bin/sh apunta a dash, que no las entiende.
Hacer el script ejecutable
# Dar permiso de ejecución
chmod +x mon_script.sh
# Ejecutar con la ruta relativa
./mon_script.sh
# Ejecutar sin permiso de ejecución (a través del intérprete)
bash mon_script.sh
# Comprobar la sintaxis sin ejecutar
bash -n mon_script.sh
# Ejecutar en modo traza para depurar (muestra cada comando)
bash -x mon_script.sh
2. El Modo Estricto: set -euo pipefail
Es la primera línea que hay que escribir después del shebang en cualquier script serio:
#!/usr/bin/env bash
set -euo pipefail
Detalle de cada opción:
| Opción | Efecto | Sin ella |
|---|---|---|
set -e | Detiene el script en el primer comando que falla | El script continúa tras un error y agrava los daños |
set -u | Error si se usa una variable no definida | Una errata produce una cadena vacía silenciosa |
set -o pipefail | Un pipeline falla si alguno de sus comandos falla | commande_cassee | grep x tiene éxito si grep tiene éxito |
Ejemplo concreto del peligro sin set -u:
# CATÁSTROFE potencial: si REPERTOIRE no está definido,
# esta línea se convierte en "rm -rf /" con ciertos valores
rm -rf "${REPERTOIRE}/cache"
# Con set -u, el script se detiene inmediatamente con:
# línea N: REPERTOIRE: variable sin asignar
Cuando un comando tiene permitido fallar, dígalo explícitamente:
# Permitir el fallo de un comando concreto a pesar de set -e
grep "motif" fichier.log || true
# O capturar el código de retorno
if ! systemctl is-active --quiet nginx; then
echo "nginx est arrêté"
fi
3. Variables y Quoting
Declarar y utilizar variables
# Asignación: ningún espacio alrededor del signo igual
nom="serveur-web-01"
# Rodee siempre las variables con comillas dobles
echo "Machine : ${nom}"
# Sustitución de comandos: prefiera $() a las comillas invertidas
date_jour=$(date +%Y-%m-%d)
# Valor por defecto si la variable está vacía o ausente
port="${PORT:-8080}"
# Error explícito si la variable está ausente
fichier="${1:?Usage : $0 <fichier>}"
# Variables de solo lectura para las constantes
readonly VERSION="2.1.0"
El quoting: la fuente del bug número uno
Una variable sin comillas sufre división en palabras y expansión de comodines. Es la causa de la mayoría de los bugs de Bash.
fichier="rapport final.pdf"
# MAL: Bash ve dos argumentos, "rapport" y "final.pdf"
cp $fichier /backup/
# CORRECTO: un único argumento, sea cual sea el contenido
cp "$fichier" /backup/
# MAL: si el directorio contiene *.txt, el comodín se expande
message="Fichiers *.txt trouvés"
echo $message
# CORRECTO
echo "$message"
Regla simple: ponga siempre comillas a sus variables ("$var"), salvo que sepa exactamente por qué no lo hace.
4. Pruebas con [[ ]]
El doble par de corchetes [[ ]] es propio de Bash y más seguro que [ ]: no hay división en palabras en su interior, los operadores lógicos son legibles y la coincidencia de patrones está integrada.
# Pruebas sobre cadenas
if [[ -z "$variable" ]]; then echo "vide"; fi
if [[ -n "$variable" ]]; then echo "non vide"; fi
if [[ "$reponse" == "oui" ]]; then echo "confirmé"; fi
# Coincidencia de patrón (glob)
if [[ "$fichier" == *.log ]]; then echo "fichier de log"; fi
# Expresión regular
if [[ "$ip" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "ressemble à une adresse IPv4"
fi
# Pruebas sobre archivos
if [[ -f "/etc/nginx/nginx.conf" ]]; then echo "fichier existe"; fi
if [[ -d "/var/www" ]]; then echo "répertoire existe"; fi
if [[ -r "$fichier" && -w "$fichier" ]]; then echo "lecture et écriture"; fi
# Comparaciones numéricas (o use (( )) para la aritmética)
if [[ "$nombre" -gt 100 ]]; then echo "supérieur à 100"; fi
if (( nombre > 100 )); then echo "idem, syntaxe arithmétique"; fi
5. Bucles
# Bucle sobre una lista explícita
for service in nginx postgresql redis; do
systemctl status "$service" --no-pager
done
# Bucle sobre archivos: el glob gestiona correctamente los espacios
for fichier in /var/log/*.log; do
echo "Traitement de $fichier"
done
# Bucle numérico estilo C
for (( i = 1; i <= 5; i++ )); do
echo "Tentative $i"
done
# Bucle while con contador
tentatives=0
while (( tentatives < 3 )); do
(( tentatives++ )) || true
echo "Essai numéro $tentatives"
done
# Bucle until: esperar a que un servicio responda
until curl -sf http://localhost:8080/health > /dev/null; do
echo "En attente du service..."
sleep 2
done
No itere nunca sobre la salida de ls: utilice globs o find con -print0.
6. Funciones
# Definición: variables locales obligatorias para evitar efectos secundarios
log() {
local niveau="$1"
local message="$2"
echo "[$(date '+%Y-%m-%d %H:%M:%S')] [${niveau}] ${message}" >&2
}
# Función con valor de retorno a través de stdout
espace_libre_mo() {
local point_montage="$1"
df --output=avail -m "$point_montage" | tail -1 | tr -d ' '
}
# Uso
log "INFO" "Démarrage du traitement"
libre=$(espace_libre_mo /backup)
if (( libre < 1024 )); then
log "ERREUR" "Moins de 1 Go disponible sur /backup"
exit 1
fi
Dos reglas de oro: declare todas las variables de función con local y escriba los mensajes de diagnóstico en stderr (>&2) para no contaminar los valores devueltos por stdout.
7. Argumentos y getopts
Las variables especiales
echo "Nom du script : $0"
echo "Premier argument : $1"
echo "Nombre d'arguments : $#"
echo "Tous les arguments : $*"
# "$@" preserva los argumentos individuales (preferir siempre esta forma)
for arg in "$@"; do
echo "Argument : $arg"
done
Analizar las opciones con getopts
#!/usr/bin/env bash
set -euo pipefail
usage() {
echo "Usage : $0 [-v] [-o fichier_sortie] -s source" >&2
exit 1
}
verbeux=false
sortie="/tmp/resultat.txt"
source_dir=""
while getopts ":vo:s:" option; do
case "$option" in
v) verbeux=true ;;
o) sortie="$OPTARG" ;;
s) source_dir="$OPTARG" ;;
:) echo "L'option -$OPTARG requiert un argument" >&2; usage ;;
\?) echo "Option inconnue : -$OPTARG" >&2; usage ;;
esac
done
shift $((OPTIND - 1))
# Validar los argumentos obligatorios
[[ -n "$source_dir" ]] || usage
"$verbeux" && echo "Source : $source_dir, sortie : $sortie"
8. trap y Limpieza
Un script puede interrumpirse en cualquier momento (Ctrl+C, kill, error con set -e). trap garantiza que la limpieza se realice pase lo que pase.
#!/usr/bin/env bash
set -euo pipefail
# Crear un directorio de trabajo temporal
travail=$(mktemp -d)
# La función de limpieza se ejecuta al salir del script,
# ya sea una salida normal, debida a un error o a una interrupción
nettoyage() {
local code=$?
rm -rf "$travail"
echo "Nettoyage effectué (code de sortie : $code)" >&2
}
trap nettoyage EXIT
# Mostrar la línea culpable en caso de error: valioso para la depuración
trap 'echo "Erreur à la ligne $LINENO" >&2' ERR
# El resto del script puede usar $travail con tranquilidad
cp /etc/hosts "$travail/"
El trap sobre EXIT cubre casi todos los casos: salida normal, exit explícito, error con set -e y señales SIGINT/SIGTERM (Bash ejecuta el trap EXIT tras recibir la señal).
9. Arrays
# Array indexado
serveurs=("web-01" "web-02" "db-01")
# Añadir un elemento
serveurs+=("cache-01")
# Número de elementos
echo "Nombre de serveurs : ${#serveurs[@]}"
# Iterar: siempre "${tableau[@]}" con comillas
for serveur in "${serveurs[@]}"; do
echo "Vérification de $serveur"
done
# Array asociativo (Bash 4 y superior)
declare -A ports
ports[http]=80
ports[https]=443
ports[ssh]=22
for service in "${!ports[@]}"; do
echo "$service écoute sur le port ${ports[$service]}"
done
10. Lectura de Archivos
El método canónico para leer un archivo línea a línea:
# IFS= preserva los espacios al principio y al final de la línea
# -r impide la interpretación de las barras invertidas
while IFS= read -r ligne; do
echo "Lu : $ligne"
done < /etc/hostname
# Leer un archivo CSV separando los campos
while IFS=, read -r nom ip role; do
echo "Serveur $nom ($ip) : rôle $role"
done < serveurs.csv
# Cargar un archivo entero en un array
mapfile -t lignes < /etc/hosts
echo "Le fichier contient ${#lignes[@]} lignes"
Evite la trampa clásica cat fichier | while read: el pipe crea una subshell y las variables modificadas dentro del bucle se pierden al salir.
11. shellcheck: su revisor automático
shellcheck analiza sus scripts y detecta los errores clásicos: variables sin comillas, comparaciones dudosas, construcciones no portables.
# Instalación
sudo apt install shellcheck # Debian/Ubuntu
sudo dnf install ShellCheck # Fedora/Rocky
# Analizar un script
shellcheck mon_script.sh
# Ignorar una regla concreta en una línea (con justificación en comentario)
# shellcheck disable=SC2086 # división voluntaria de las opciones
commande $options
Integre shellcheck en su CI: un script que no pasa shellcheck no debería fusionarse.
12. Ejemplo Completo: Script de Copia de Seguridad
Este script reúne todas las técnicas vistas anteriormente.
#!/usr/bin/env bash
#
# backup.sh — Sauvegarde compressée d'un répertoire avec rotation.
# Usage : backup.sh [-k nombre_de_copies] -s /repertoire/source -d /repertoire/destination
#
set -euo pipefail
readonly SCRIPT_NAME="${0##*/}"
# --- Fonctions -------------------------------------------------------------
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >&2
}
usage() {
echo "Usage : $SCRIPT_NAME [-k copies] -s source -d destination" >&2
exit 1
}
nettoyage() {
# Supprimer l'archive partielle si le script est interrompu
if [[ -n "${archive_temp:-}" && -f "$archive_temp" ]]; then
rm -f "$archive_temp"
log "Archive partielle supprimée : $archive_temp"
fi
}
trap nettoyage EXIT
# --- Analyse des arguments -------------------------------------------------
copies=7
source_dir=""
dest_dir=""
while getopts ":k:s:d:" option; do
case "$option" in
k) copies="$OPTARG" ;;
s) source_dir="$OPTARG" ;;
d) dest_dir="$OPTARG" ;;
*) usage ;;
esac
done
[[ -n "$source_dir" && -n "$dest_dir" ]] || usage
[[ -d "$source_dir" ]] || { log "ERREUR : source introuvable : $source_dir"; exit 1; }
mkdir -p "$dest_dir"
# --- Sauvegarde ------------------------------------------------------------
horodatage=$(date +%Y%m%d-%H%M%S)
archive="${dest_dir}/backup-${horodatage}.tar.gz"
archive_temp="${archive}.part"
log "Sauvegarde de $source_dir vers $archive"
# Écrire dans un fichier temporaire puis renommer : l'archive finale
# n'existe que si elle est complète
tar -czf "$archive_temp" -C "$(dirname "$source_dir")" "$(basename "$source_dir")"
mv "$archive_temp" "$archive"
archive_temp=""
log "Archive créée : $(du -h "$archive" | cut -f1)"
# --- Rotation : conserver les N archives les plus récentes ------------------
mapfile -t anciennes < <(ls -1t "${dest_dir}"/backup-*.tar.gz 2>/dev/null | tail -n +"$((copies + 1))")
for ancienne in "${anciennes[@]}"; do
rm -f "$ancienne"
log "Ancienne archive supprimée : $ancienne"
done
log "Terminé : $(ls -1 "${dest_dir}"/backup-*.tar.gz | wc -l) archive(s) conservée(s)"
Buenas prácticas
- Comience cada script con
set -euo pipefaily justifique cada excepción con|| true. - Ponga comillas sistemáticamente a sus variables:
"$var","${tableau[@]}","$@". - Declare las variables de función con
localy las constantes conreadonly. - Escriba los logs en stderr para dejar stdout disponible para los datos.
- Utilice
mktemppara los archivos temporales y untrap EXITpara limpiarlos. - Escriba en un archivo temporal y renombre después con
mv: una operación atómica evita los archivos a medio escribir. - Pase cada script por shellcheck antes de desplegarlo.
- Si un script supera unos cientos de líneas o manipula estructuras de datos complejas, considere Python: Bash destaca en la orquestación de comandos, no en la lógica de negocio.
Conclusión
La robustez de un script Bash no viene de su longitud, sino de su disciplina: el modo estricto que transforma los errores silenciosos en paradas claras, el quoting que neutraliza los nombres de archivo problemáticos, trap que garantiza la limpieza y shellcheck que revisa cada línea por usted. Estos hábitos cuestan unos segundos al escribir y ahorran horas de depuración en producción.
Tome el script de copia de seguridad de la sección 12 como esqueleto para sus propias herramientas: la estructura de logging, argumentos, trap y operaciones atómicas se adapta a casi todos los scripts de administración.
Acerca de InSkillCoach
Experto en formación y tecnologías
Coach especializado en tecnologías avanzadas e IA, impulsado por GNeurone Inc.
Certificaciones:
- AWS Certified Solutions Architect – Professional
- Certificaciones de Google Cloud
- Microsoft Certified: DevOps Engineer Expert
- Certified Kubernetes Administrator (CKA)
- CompTIA Security+
Comentarios
Los comentarios funcionan con GitHub Discussions
Inicia sesión con GitHub para participar en la discusión