Administración de Linux

Scripts en Bash: scripts seguros con argumentos, bucles, funciones y traps

Intermedio10 min de lecturaActualizado

Esta página se tradujo del inglés con ayuda de IA y se cotejó con el original. Leer la versión en inglés

Un script de shell es código de producción que casualmente parece una lista de comandos, y la mayoría de sus bugs nacen de la costumbre del shell de seguir adelante tras un fallo. Esta guía muestra la estructura de un script en el que se puede confiar: un preámbulo estricto, argumentos analizados, condiciones comprobadas, funciones con códigos de salida reales, una limpieza gestionada por un trap y un linter en el ciclo de trabajo. Termina con un script de copia de seguridad completo y comentado.

Lo que aprenderás

  • Estructurar un script con un shebang, un preámbulo estricto y funciones bien separadas
  • Analizar opciones y argumentos posicionales y validarlos antes de hacer cualquier trabajo
  • Elegir la construcción de test adecuada y escribir bucles que resistan espacios y listas vacías
  • Garantizar la limpieza y mensajes de error útiles con trap y los manejadores ERR y EXIT
  • Pasar los scripts por ShellCheck y decidir cuándo Bash no es la herramienta adecuada

Lo que distingue un puñado de comandos que tecleaste una vez de un script del que dependen otras personas es el manejo de errores. El shell interactivo es indulgente: un comando falla, ves el mensaje y pruebas otra cosa. Un script no tiene a nadie vigilándolo y, por defecto, el shell simplemente pasa a la línea siguiente. Así es como un script de copias de seguridad borra la copia anterior después de que la nueva no se haya podido crear, y como un script de despliegue reinicia un servicio con un archivo de configuración que nunca llegó a escribirse.

Esta guía trata de escribir ese segundo tipo de script a conciencia. Da por hecho que ya conoces la línea de comandos —expansiones, comillas, redirecciones, código de salida— de los fundamentos del shell, y se centra en las construcciones que convierten comandos en un programa: modo estricto, manejo de argumentos, control de flujo, funciones, arrays, traps de limpieza y linting.

La forma de un script

Un script empieza con un shebang, la secuencia de dos caracteres #! seguida del intérprete que debe ejecutar el kernel. #!/usr/bin/env bash encuentra Bash dondequiera que esté instalado en lugar de suponer /bin/bash, lo que importa en sistemas donde está instalado bajo /usr/local. No escribas #!/bin/sh a menos que el script sea estrictamente POSIX: en Debian y Ubuntu, /bin/sh es Dash, que no tiene arrays, ni [[, ni local -n, y cuyo echo se comporta de otra manera.

#!/usr/bin/env bash
#
# deploy.sh — install a release into /opt and switch the current symlink.
# Usage: deploy.sh [-n] [-d DIR] VERSION

Hazlo ejecutable con chmod +x deploy.sh y ejecútalo como ./deploy.sh. Ejecutar bash deploy.sh ignora por completo el shebang, y así es exactamente como un script de Bash acaba interpretado por Dash en un job de CI y fallando en una construcción que funciona en cualquier otro sitio.

Pon al principio un bloque de comentarios que diga qué hace el script y cómo se invoca. Dentro de seis meses, ese bloque será la documentación.

El preámbulo estricto

Tres opciones del shell, activadas juntas, cambian el comportamiento por defecto de «seguir pase lo que pase» a «detenerse a la primera señal de problemas».

set -euo pipefail
IFS=$'\n\t'
  • -e termina el script cuando un comando devuelve un código distinto de cero que no se está comprobando.
  • -u trata la expansión de una variable no definida como un error, lo que detecta erratas y configuración ausente en lugar de sustituir en silencio una cadena vacía. Es lo que impide que rm -rf "$PREFIX/data" se convierta en rm -rf /data.
  • -o pipefail hace que un pipeline devuelva el código de la última etapa que falla en lugar de solo el de la última etapa, de modo que curl … | tar -x ya no informa de éxito cuando la descarga ha fallado.
  • Reducir IFS al salto de línea y al tabulador elimina el espacio del conjunto de caracteres de separación de palabras, lo que limita el daño que puede causar una expansión sin comillas. Es opcional y cambia el comportamiento de algunas construcciones idiomáticas, así que adóptalo sabiendo lo que haces.

Ten presente lo que -e no detecta. Un comando dentro de if, while, && o ! se está comprobando, así que su fallo es esperado y no provoca la salida. local var=$(cmd) oculta el código de salida de cmd tras el de local, que siempre tiene éxito, así que sepáralo:

local sha
sha=$(sha256sum "$file" | cut -d' ' -f1)   # now a failure here stops the script

Cuando de verdad se permite que un comando falle, dilo de forma explícita con || true para que la intención quede a la vista, y cuando quieras el código de salida sin terminar, captúralo:

grep -q "^enabled=1" "$conf" || echo "warning: feature disabled"
if ! systemctl is-active --quiet nginx; then
  echo "nginx is not running" >&2
fi

Argumentos y opciones

Los parámetros posicionales son $1, $2, etc.; $# es su número, "$@" los expande todos como palabras entrecomilladas independientes y $0 es el nombre del propio script. "$@" con sus comillas es una de las pocas cosas de Bash que casi nunca están mal, mientras que $* lo une todo en una sola cadena y casi nunca es lo correcto.

Para cualquier cosa que vaya más allá de uno o dos argumentos, analiza las opciones con getopts. Gestiona opciones cortas, su agrupación y las opciones con valor, y como es un builtin, no hay nada que instalar.

usage() {
  cat >&2 <<'USAGE'
Usage: deploy.sh [-n] [-d DIR] VERSION
  -n        dry run: print what would happen, change nothing
  -d DIR    installation root (default /opt)
USAGE
  exit 64
}

dry_run=false
root=/opt

while getopts ':nd:h' opt; do
  case "$opt" in
    n) dry_run=true ;;
    d) root=$OPTARG ;;
    h) usage ;;
    :) printf 'option -%s requires an argument\n' "$OPTARG" >&2; usage ;;
    ?) printf 'unknown option -%s\n' "$OPTARG" >&2; usage ;;
  esac
done
shift $((OPTIND - 1))

[[ $# -eq 1 ]] || usage
version=$1

Los dos puntos iniciales de ':nd:h' ponen getopts en modo de error silencioso para que se usen tus propios mensajes; unos dos puntos tras una letra indican que esa opción recibe un valor. shift $((OPTIND - 1)) elimina las opciones ya analizadas y deja en su sitio los argumentos posicionales.

Valida todo antes de hacer nada destructivo. Un script que comprueba sus entradas al principio y después se ejecuta es mucho más fácil de razonar que uno que descubre a mitad de camino que falta un directorio.

[[ -d $root ]]            || { printf 'no such directory: %s\n' "$root" >&2; exit 66; }
[[ $version =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { printf 'bad version: %s\n' "$version" >&2; exit 65; }
command -v rsync >/dev/null || { echo 'rsync is required' >&2; exit 69; }

Termina con 0 si todo va bien y con un código distinto de cero diferente por cada tipo de fallo; los valores de /usr/include/sysexits.h (64 uso, 65 datos, 66 sin entrada, 69 no disponible) son una convención razonable cuando necesitas algo más que «se ha roto». Elijas lo que elijas, envía los diagnósticos a la salida de error con >&2, para que quien pase tu salida por una tubería reciba solo la salida.

Condiciones

Bash ofrece tres construcciones de test, y las diferencias importan.

ConstrucciónÚsala para
[[ … ]]todo, en Bash: sin separación de palabras dentro, admite =~, &&, || y coincidencia de patrones con ==
[ … ]solo scripts POSIX; cada expansión debe ir entre comillas
(( … ))aritmética entera: (( count > 3 )), (( i++ ))
[[ -f $config ]]            # exists and is a regular file
[[ -d $dir && -w $dir ]]    # directory, writable
[[ -s $log ]]               # exists and is non-empty
[[ -z ${API_TOKEN:-} ]]     # unset or empty, safe under set -u
[[ $name == web-* ]]        # glob match
[[ $line =~ ^([0-9]{3})\ (.*)$ ]] && code=${BASH_REMATCH[1]}
(( retries < max_retries ))

Dentro de [[ ]] no hay separación de palabras, así que [[ -f $config ]] es seguro aunque la ruta contenga espacios: uno de los pocos lugares donde omitir las comillas está bien. A la derecha de == y =~, las comillas tienen el efecto contrario: con comillas es literal; sin ellas, es un patrón o una regex.

case es más limpio que una cadena de comparaciones cuando hay que contrastar un valor con varios patrones:

case "$environment" in
  prod|production) replicas=6 ;;
  stag*)           replicas=2 ;;
  dev)             replicas=1 ;;
  *) printf 'unknown environment: %s\n' "$environment" >&2; exit 65 ;;
esac

Bucles que no se rompen con los espacios

Iterar sobre un glob o un array es seguro; iterar sobre la salida de ls no lo es.

for file in /var/log/app/*.log; do      # safe; use shopt -s nullglob for empty matches
  gzip -9 -- "$file"
done

for host in "${hosts[@]}"; do
  ssh "$host" 'uptime'
done

for i in {1..5}; do
  curl -fsS "https://api.example.com/health" && break
  sleep $(( i * 2 ))                    # simple exponential backoff
done

Leer un archivo línea a línea tiene una única forma correcta, y cada una de sus piezas es imprescindible:

while IFS= read -r line; do
  printf 'got: %s\n' "$line"
done < "$input_file"

IFS= impide que se eliminen los espacios en blanco al principio y al final, -r evita que las barras invertidas se interpreten como escapes, y la redirección en el done mantiene el bucle en el shell actual. Este último punto es el origen de uno de los bugs más comunes de Bash: pasar la salida a un bucle con una tubería (cat file | while read …) lo ejecuta en un subshell, así que cualquier variable que defina desaparece después. Usa una redirección, o una sustitución de procesos cuando la entrada sea un comando:

count=0
while IFS= read -r unit; do
  (( count++ ))
done < <(systemctl list-units --type=service --state=failed --no-legend | awk '{print $1}')
printf 'failed units: %d\n' "$count"

Para nombres de archivo que puedan contener absolutamente cualquier cosa, combina find -print0 con read -d '':

while IFS= read -r -d '' path; do
  rm -v -- "$path"
done < <(find /tmp/build -type f -name '*.tmp' -print0)

Funciones

Una función agrupa pasos, les da un nombre y permite probarlos de forma aislada. Los argumentos llegan como $1, $2, "$@", exactamente igual que en el propio script; return fija un código de salida, y lo que la función escribe en stdout es la forma de devolver un valor.

log() { printf '%s [%s] %s\n' "$(date -Is)" "$1" "${*:2}" >&2; }

require_service() {
  local unit=$1
  if ! systemctl is-active --quiet "$unit"; then
    log ERROR "$unit is not running"
    return 1
  fi
}

current_release() {
  readlink -f /opt/app | sed 's|.*/||'
}

release=$(current_release)
require_service postgresql || exit 69

Declara cada variable interna como local; si no, es global y tarde o temprano dos funciones chocarán. Envía las líneas de log a la salida de error, como hace log arriba, para que una función cuya stdout se captura con una sustitución de comandos no quede contaminada por su propio logging: un bug sutil que produce valores extrañísimos en quien la llama.

return solo acepta valores entre 0 y 255, así que una función no puede devolver de ese modo un número, una cadena o una lista. Imprímelo y captúralo.

Arrays

Los arrays son el motivo para exigir Bash en lugar del sh de POSIX. Guardan una lista de valores con sus límites intactos, algo que las cadenas no pueden hacer.

rsync_opts=(--archive --delete --human-readable)
[[ $dry_run == true ]] && rsync_opts+=(--dry-run)
rsync "${rsync_opts[@]}" "$src/" "$dest/"

hosts=(web01 web02 db01)
printf 'host count: %d\n' "${#hosts[@]}"
printf '%s\n' "${hosts[@]}"

mapfile -t failed < <(systemctl list-units --state=failed --no-legend | awk '{print $1}')

"${array[@]}" con comillas se expande a una palabra por elemento, conservando los espacios; ${array[*]} lo une todo en una sola cadena. mapfile -t (también llamado readarray) lee líneas en un array sin los saltos de línea finales y es mucho más seguro que arr=$(cmd) seguido de separación de palabras.

Los arrays asociativos funcionan como un pequeño almacén clave-valor y hay que declararlos:

declare -A port_of=( [http]=80 [https]=443 [postgres]=5432 )
for name in "${!port_of[@]}"; do
  printf '%-10s %s\n' "$name" "${port_of[$name]}"
done

Here-documents y archivos generados

Un here-document pasa un bloque de texto literal a la entrada estándar de un comando. Entrecomillar o no el delimitador decide si el cuerpo se expande:

cat > /etc/myapp/config.ini <<EOF
[server]
host = ${bind_address}
port = ${port}
EOF

sudo tee /etc/systemd/system/myapp.service > /dev/null <<'UNIT'
[Unit]
Description=My application
UNIT

<<'EOF' con comillas es literal: no se expande nada en su interior, que es justo lo que quieres al generar un script o una configuración llena de signos $. <<EOF sin comillas expande variables y sustituciones de comandos. <<-EOF además elimina los tabuladores iniciales (no los espacios), lo que permite indentar el cuerpo al nivel del código que lo rodea.

Nunca escribas un archivo de configuración en su sitio con una redirección mientras un servicio lo está leyendo. Escribe en un archivo temporal del mismo directorio y colócalo con mv: dentro de un mismo sistema de archivos, mv es atómico, así que los lectores ven el archivo antiguo o el nuevo, nunca uno escrito a medias.

Limpieza con traps

Un trap ejecuta un comando cuando el shell recibe una señal o está a punto de terminar. Es la forma en que un script limpia lo que deja tras de sí, tanto si ha tenido éxito como si ha fallado o lo han interrumpido.

workdir=$(mktemp -d)
cleanup() {
  local status=$?
  rm -rf -- "$workdir"
  (( status == 0 )) || log ERROR "failed with status $status at line $BASH_LINENO"
  exit "$status"
}
trap cleanup EXIT
trap 'log WARN "interrupted"; exit 130' INT TERM

EXIT se dispara en todas las vías de salida, lo que lo convierte en el lugar adecuado para borrar directorios temporales, liberar un bloqueo o restaurar un archivo de configuración. Captura $? en la primera instrucción del manejador, antes de que cualquier otra cosa lo sobrescriba.

Un patrón complementario impide que se ejecuten a la vez dos copias de un script periódico:

exec 9> /var/lock/backup.lock
flock -n 9 || { echo 'another run is in progress' >&2; exit 75; }

El bloqueo lo mantiene el descriptor de archivo y se libera automáticamente cuando termina el proceso, incluso si lo matan: no queda ningún archivo de bloqueo huérfano que limpiar. flock también existe como envoltorio de comandos, muy práctico en una unidad timer: flock -n /var/lock/backup.lock /usr/local/bin/backup.sh.

Un ejemplo completo

Todas las piezas juntas, en un script que podrías programar desde un timer de systemd:

#!/usr/bin/env bash
#
# backup-db.sh — dump a PostgreSQL database, compress it, upload it and prune old copies.
set -euo pipefail

readonly DB=${DB:-orders}
readonly DEST=${DEST:-/srv/backups}
readonly KEEP_DAYS=${KEEP_DAYS:-14}
readonly STAMP=$(date +%Y%m%dT%H%M%S)

log() { printf '%s %s\n' "$(date -Is)" "$*" >&2; }

workdir=$(mktemp -d)
trap 'rc=$?; rm -rf -- "$workdir"; exit "$rc"' EXIT

exec 9> /var/lock/backup-db.lock
flock -n 9 || { log 'another backup is running'; exit 75; }

for tool in pg_dump zstd sha256sum; do
  command -v "$tool" >/dev/null || { log "missing required tool: $tool"; exit 69; }
done

dump=$workdir/$DB-$STAMP.sql.zst
log "dumping $DB"
pg_dump --format=plain --no-owner "$DB" | zstd -T0 -19 -o "$dump" -q

# Guard against an empty or truncated dump before anything is pruned.
size=$(stat -c %s "$dump")
(( size > 1024 )) || { log "dump is only $size bytes; aborting"; exit 65; }

sha256sum "$dump" > "$dump.sha256"
install -d -m 0750 "$DEST"
mv -- "$dump" "$dump.sha256" "$DEST/"
log "stored $DEST/$(basename "$dump") ($size bytes)"

# Only prune once the new backup is safely in place.
deleted=0
while IFS= read -r -d '' old; do
  rm -f -- "$old" "$old.sha256"
  (( ++deleted ))
done < <(find "$DEST" -maxdepth 1 -name "$DB-*.sql.zst" -mtime "+$KEEP_DAYS" -print0)
log "pruned $deleted old backup(s)"

Fíjate en el orden: verificar las herramientas, tomar el bloqueo, producir el nuevo artefacto, comprobar que es plausible, moverlo a su sitio de forma atómica y solo entonces borrar lo antiguo. Un script que primero purga y después hace el volcado acabará dejándote sin ninguna copia de seguridad.

Linting, tests y formato

ShellCheck es, con diferencia, la herramienta más rentable en el desarrollo con shell. Analiza estáticamente un script e informa de expansiones sin comillas, cat innecesarios, variables perdidas en subshells, usos incorrectos de [ y decenas de bugs reales más, cada uno con una página de wiki que explica el razonamiento.

sudo apt install shellcheck        # or: dnf install ShellCheck
shellcheck -x -S style deploy.sh   # -x follows sourced files
shfmt -i 2 -ci -w deploy.sh        # consistent formatting
bash -n deploy.sh                  # syntax check without executing
bash -x deploy.sh 1.2.3            # trace every command as it runs

Ejecuta ShellCheck en CI junto con el resto de comprobaciones: encaja de forma natural en los pipelines descritos en entrega continua. Cuando una advertencia sea realmente errónea, silencia solo esa aparición con un comentario # shellcheck disable=SC2086 y una nota que explique el motivo, en lugar de desactivar la comprobación de forma global.

Para el comportamiento, bats te ofrece un pequeño framework de tests, pero se consigue muchísimo simplemente escribiendo funciones que reciben argumentos e imprimen resultados, y llamándolas desde un script de pruebas. Añade set -x temporalmente, o PS4='+ ${BASH_SOURCE}:${LINENO}: ' justo antes, para obtener trazas que muestren archivo y línea.

Errores comunes

  • Expansiones sin comillas. cp $src $dst se rompe con la primera ruta que tenga un espacio. Entrecomíllalo todo y deja que ShellCheck encuentre las que se te escapen.
  • Analizar la salida de ls. Está pensada para personas. Usa un glob, find -print0 o mapfile.
  • Pasar datos a un bucle while con una tubería. El bucle se ejecuta en un subshell y sus variables desaparecen. Usa una redirección o una sustitución de procesos.
  • Dar por hecho que cd ha funcionado. cd "$dir" && rm -rf ./* es seguro; lo mismo en dos líneas sin set -e es un desastre.
  • local x=$(cmd) con set -e. El fallo queda oculto. Declara y asigna por separado.
  • Construir rutas sin comprobarlas. rm -rf "$root/$name" con un $name vacío borra el árbol entero. set -u junto con una validación explícita lo evita.
  • Escribir 800 líneas de Bash. A ese tamaño el lenguaje juega en tu contra; pásate a Python, Go o una herramienta de gestión de configuración.

Puntos clave

  • Empieza cada script con #!/usr/bin/env bash, set -euo pipefail y un comentario que diga qué hace, y conoce los casos que -e no puede detectar.
  • Analiza las opciones con getopts, valida todas las entradas de antemano, escribe los diagnósticos en stderr y termina con códigos que signifiquen algo.
  • Prefiere [[ … ]] para los tests y (( … )) para la aritmética; usa case cuando compares un valor con varios patrones.
  • Recorre globs y arrays, alimenta while read con una redirección o una sustitución de procesos, y usa -print0/read -d '' para nombres de archivo arbitrarios.
  • Mantén las variables de las funciones como local, haz el logging en stderr y devuelve valores imprimiéndolos, no mediante return.
  • trap … EXIT garantiza la limpieza en todas las vías de salida, y flock sobre un descriptor de archivo evita ejecuciones solapadas sin archivos de bloqueo huérfanos.
  • Deja las operaciones destructivas para el final: crear, verificar, mover a su sitio de forma atómica y, después, purgar.
  • Pasa ShellCheck a todos los scripts y trata sus hallazgos como fallos de build, no como sugerencias.

Fuentes y lecturas recomendadas

Preguntas frecuentes

¿set -e hace que mi script sea seguro?
Ayuda, pero tiene puntos ciegos documentados. Un comando que falla dentro de una condición, a la izquierda de un && o en una función llamada desde un if no provoca la salida. Una sustitución de comandos dentro de una asignación oculta el código de salida, salvo que declares y asignes en líneas separadas. Trata el preámbulo estricto como una red de seguridad que complementa un manejo de errores cuidadoso, no como un sustituto.
¿Por qué mi bucle while pierde las variables que ha definido?
Cada etapa de un pipeline se ejecuta en un subshell, así que un bucle a la derecha de una tubería modifica una copia del entorno que desaparece cuando termina el pipeline. Alimenta el bucle con una redirección o una sustitución de procesos —done < file, o done < <(command)— y se ejecutará en el shell actual, donde las asignaciones sobreviven. Bash también ofrece shopt -s lastpipe para shells no interactivos.
¿Cuándo debo dejar Bash y pasarme a Python?
Bash es excelente para lanzar programas y conectar sus entradas y salidas. Es flojo con las estructuras de datos, la aritmética, la manipulación de cadenas, el manejo de errores a lo largo de muchos pasos y todo lo que necesite tests. En cuanto un script supera unos pocos cientos de líneas, necesita datos anidados, procesa JSON o XML en serio o tiene que tener tests unitarios, un lenguaje de programación de verdad será más corto y mucho más fácil de mantener.
¿Cómo manejo nombres de archivo que contienen espacios o saltos de línea?
Pon entre comillas cada expansión, usa arrays en lugar de cadenas separadas por espacios y nunca analices la salida de ls. Con find, usa -print0 junto con xargs -0, o -exec cmd {} +, que pasa los argumentos directamente sin intervención de ningún shell. Para leer una lista línea a línea, usa IFS= read -r -d '' con una entrada separada por caracteres nulos, de modo que incluso un salto de línea dentro de un nombre se trate correctamente.