Saltar al contenido
Volver a Aprender
Automatización23 de julio de 20265 min de lectura

Scripts de shell que escalan

El modo estricto línea a línea, las comillas, los arrays en lugar de eval, la limpieza con trap y las trampas que sobreviven a set -e. Los hábitos que mantienen honesto un script a las 3 de la madrugada.

Kushagra SharmaFundador e ingeniero de producto

Los scripts de shell robustos resultan aburridos. Esa es la idea. Un script que puedes leer a las 3 de la madrugada durante un incidente, que falla con ruido en lugar de corromper el estado, supera siempre a una línea ingeniosa.

Las tres primeras líneas que importan

La mayoría de los scripts de shell rotos tienen un rasgo en común: siguen adelante después de que algo ya ha fallado. El comportamiento por defecto es encogerse de hombros ante un error y continuar, que es como un script de copia de seguridad «tiene éxito» escribiendo cero bytes.

Empieza todos los scripts de la misma manera:

#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'

Esto es lo que consigues con eso:

  • set -e aborta con el primer comando que devuelve un valor distinto de cero, así que un fallo se detiene en lugar de propagarse en daños.
  • set -u trata las variables sin definir como errores. Te equivocas al escribir el nombre de una variable y te enteras enseguida, en lugar de que rm -rf "$PREFX/data" se expanda a rm -rf "/data".
  • set -o pipefail hace que una tubería falle si falla cualquier etapa, no solo la última. Sin él, curl ... | tar x informa de éxito incluso cuando curl murió.
  • Fijar IFS en salto de línea y tabulador elimina toda una clase de errores de división de palabras con nombres de archivo que contienen espacios.

Esto no es paranoia. Es la diferencia entre un script que se detiene y un script que causa daños a gran escala.

Pon comillas a todo

La división de palabras y la expansión de patrones son las dos trampas que arruinan los scripts de shell. Una variable sin comillas se divide por los espacios y se expande como un patrón, lo que significa que un archivo llamado my report.txt llega como dos argumentos, y una variable que contiene * coincide con todo tu directorio.

La regla es simple: si es una variable o una sustitución de comandos, va entre comillas dobles.

# mal: se rompe con los espacios, expande los globs
cp $src $dst

# bien
cp "$src" "$dst"

Usa "$@" (entre comillas) para reenviar argumentos, nunca $* ni un $@ pelado. Usa un array para una lista de elementos en lugar de una cadena delimitada por espacios:

files=("$dir"/*.log)
gzip "${files[@]}"
Si te sorprendes construyendo un comando en una variable de cadena y ejecutándolo con eval, detente. Casi seguro que lo que quieres es un array. eval es donde las comillas van a morir.

Funciones, no scripts planos

Un script de 300 líneas que se ejecuta de arriba abajo es un script que nadie puede probar. Divide el trabajo en funciones, ponles nombre por lo que hacen y deja un main al final. Declara las variables locales con local para que una variable dentro de una función no pueda pisar en silencio a otra.

log() { printf '%s %s\n' "$(date +%H:%M:%S)" "$*" >&2; }
die() { log "FATAL: $*"; exit 1; }

upload_artifact() {
  local file="$1" bucket="$2"
  [[ -f "$file" ]] || die "missing artifact: $file"
  aws s3 cp "$file" "s3://$bucket/" || die "upload failed: $file"
}

main() {
  upload_artifact "$1" "${BUCKET:?BUCKET must be set}"
}

main "$@"

Hay tres cosas ahí que merece la pena copiar. log escribe en stderr, así que nunca contamina los datos que el script emite por stdout. ${BUCKET:?BUCKET must be set} falla con ruido y con tu propio mensaje cuando falta la variable, en lugar de expandirse en silencio a nada. Y llamar a main "$@" en la última línea significa que todo el archivo se analiza antes de que se ejecute nada, así que un script guardado a mitad de edición no se ejecuta a medias.

Limpia con trap

Lo difícil de un script no es el camino feliz, es lo que pasa cuando muere a la mitad. Los archivos temporales, los directorios de bloqueo y las salidas a medio escribir se filtran si no se gestiona la salida de forma explícita.

trap ejecuta una función cuando el script termina por cualquier motivo: éxito, error o Ctrl-C. Combínalo con mktemp y la limpieza se dispara de verdad.

workdir="$(mktemp -d)"
cleanup() { rm -rf "$workdir"; }
trap cleanup EXIT

# hacer el trabajo en «$workdir». Desaparece sea cual sea la forma de salir

Un solo trap sobre EXIT cubre una salida normal y la mayoría de las señales. Si necesitas distinguir un fallo de una ejecución limpia, intercepta ERR por separado y registra ahí. Lo importante es que la limpieza viva en un solo lugar, declarada de entrada, en lugar de copiarse y pegarse delante de cada exit.

Las trampas que sobreviven a set -e

set -e no es un campo de fuerza. Tiene agujeros bien conocidos, y hacer como si no existieran es como te muerde justo lo que creías cubierto.

  • Un comando que falla en la condición de un if no provoca una salida. Es intencionado, pero significa que if grep -q foo file; then no abortará aunque grep falle por un motivo distinto de «sin coincidencias».
  • Un comando dentro de una sustitución $(...) puede tragarse su fallo según el contexto. Asigna primero, comprueba después.
  • local x="$(cmd)" enmascara el código de salida de cmd, porque local en sí tiene éxito. Separa la declaración: local x; x="$(cmd)".
  • Una aritmética como ((count++)) devuelve un valor distinto de cero cuando el resultado es 0, lo que bajo set -e mata el script. Usa count=$((count + 1)) en su lugar.

Nada de eso es un argumento para abandonar set -e. Es un argumento para conocer sus bordes. Una herramienta cuyos límites entiendes vale más que una herramienta en la que confías a ciegas.

Saber cuándo parar

Los scripts que sobreviven son los que se tratan como código de verdad: revisados, analizados con shellcheck y lo bastante pequeños como para caber en la cabeza. El listón de «solo un script de shell» es el de cualquier otra cosa que se ejecute en producción, porque a producción le da igual la diferencia.

Cuando un script crece más allá de unos cientos de líneas, o empieza a manejar estructuras de datos de verdad, esa es la señal para recurrir a un lenguaje con tipos y un ejecutor de pruebas. El shell es pegamento. Respeta aquello en lo que es realmente bueno, niégate a forzarlo más allá y ejecuta shellcheck en CI para que las trampas de arriba nunca lleguen a la rama principal.

Compartir este artículo

¿Necesitas que construyamos un producto?

Describe el problema con tus propias palabras. Recibirás una respuesta directa sobre lo que exige construirlo de verdad y sobre cómo funcionaría un encargo.