0%
Bash Scripting: Escribir Scripts Robustos y Mantenibles

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.

I

InSkillCoach

· min

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ónEfectoSin ella
set -eDetiene el script en el primer comando que fallaEl script continúa tras un error y agrava los daños
set -uError si se usa una variable no definidaUna errata produce una cadena vacía silenciosa
set -o pipefailUn pipeline falla si alguno de sus comandos fallacommande_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 pipefail y justifique cada excepción con || true.
  • Ponga comillas sistemáticamente a sus variables: "$var", "${tableau[@]}", "$@".
  • Declare las variables de función con local y las constantes con readonly.
  • Escriba los logs en stderr para dejar stdout disponible para los datos.
  • Utilice mktemp para los archivos temporales y un trap EXIT para 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.

InSkillCoach

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+
1.3k
111

Comentarios

Los comentarios funcionan con GitHub Discussions

Inicia sesión con GitHub para participar en la discusión

¡Enlace copiado!