← Automatización con Scripts en Bash Bloque 6 · Manejo de errores y scripts robustos · Tema 24 de 42

Tema 24

Limpiar al salir con trap

Muchos scripts crean cosas que solo necesitan mientras se ejecutan: un archivo temporal, un directorio de trabajo, un archivo de bloqueo, un proceso en segundo plano. Si el script termina bien, los borra al final. Pero si falla a la mitad, si set -e lo detiene o si alguien pulsa Ctrl+C, esas líneas finales nunca se ejecutan, y los restos se acumulan. El comando trap permite registrar una limpieza que se ejecuta siempre, termine como termine el script.

1. El problema de los restos

Este script parece correcto: crea un archivo temporal, trabaja con él y lo borra al final.

una limpieza que no siempre ocurre
tmp=/tmp/informe.tmp
generar_datos > "$tmp"
sort "$tmp" | uniq -c > informe.txt
rm -f "$tmp"                      # solo si se llega hasta aquí

Tiene tres problemas. Si generar_datos falla y set -e detiene el script, el rm no se ejecuta. Si alguien lo interrumpe con Ctrl+C, tampoco. Y el nombre fijo /tmp/informe.tmp es compartido: si dos copias del script se ejecutan a la vez, una pisa el archivo de la otra; y en un directorio común como /tmp, otro usuario podría haber creado ese nombre antes, a propósito. La sección 2 resuelve el nombre, y el resto del tema, la limpieza.

2. Archivos temporales con mktemp

mktemp crea un archivo temporal con un nombre único e impredecible, con permisos que solo permiten leerlo a su dueño, y escribe su ruta. Con -d crea un directorio.

terminal
$ tmp=$(mktemp); echo "$tmp"
/tmp/tmp.xDNivukT2h
$ dir=$(mktemp -d); echo "$dir"
/tmp/tmp.67F4le6qUs
$ mktemp -t respaldo.XXXXXX              # con un nombre reconocible
/tmp/respaldo.k3Q9vZ
$ mktemp "informe.txt.XXXXXX"             # en el directorio actual
informe.txt.Hq72Lp
FormaCrea
mktempUn archivo en el directorio temporal: $TMPDIR, o /tmp.
mktemp -dUn directorio.
mktemp -t prefijo.XXXXXXUn archivo en el directorio temporal, con un prefijo que ayuda a identificarlo.
mktemp ruta/nombre.XXXXXXUn archivo en un directorio concreto. Las X finales se reemplazan por caracteres al azar.

Un directorio temporal con mktemp -d suele ser más cómodo que varios archivos: el script crea dentro todo lo que necesite, con nombres simples, y al final se borra el directorio entero. La forma con una ruta concreta tiene un uso importante, la escritura atómica, que veremos en la sección 9.

3. trap … EXIT: limpiar siempre

trap registra un comando que Bash ejecutará cuando ocurra cierto evento. El evento más útil es EXIT, una pseudoseñal que se produce cuando el script termina, sea cual sea el motivo: al llegar al final, con exit, detenido por set -e, o terminado por una señal como Ctrl+C.

la limpieza registrada al comienzo
tmp=$(mktemp)
trap 'rm -f "$tmp"' EXIT          # se ejecutará al terminar, pase lo que pase

generar_datos > "$tmp"
sort "$tmp" | uniq -c > informe.txt
terminal · tres finales distintos, la misma limpieza
$ ./prueba                          # termina normalmente
limpieza
código 0
$ ./prueba                          # con exit 3 a la mitad
limpieza
código 3
$ ./prueba                          # detenido por set -e
limpieza
código 1

Tres detalles importan:

4. Una función de limpieza

Cuando hay varias cosas que limpiar, o la limpieza tiene varios pasos, conviene escribirla como una función y registrar la función. Un patrón práctico es acumular en un array todo lo que hay que borrar, a medida que se crea:

una limpieza que crece con el script
temporales=()

limpiar() {
  local codigo=$?                   # el código con que terminaba el script
  (( ${#temporales[@]} == 0 )) || rm -rf -- "${temporales[@]}"
  [[ -n "${pid_servidor:-}" ]] && kill "$pid_servidor" 2> /dev/null
  exit "$codigo"
}
trap limpiar EXIT

trabajo=$(mktemp -d)
temporales+=("$trabajo")            # registrar cada cosa al crearla

python3 -m http.server 8000 &
pid_servidor=$!

La primera línea de la función guarda $?, que en ese momento es el código con que terminaba el script; cualquier comando de la limpieza lo reemplazaría. La última línea termina con ese mismo código, de modo que quien llamó al script recibe el resultado real y no el de la limpieza. Un solo trap no se puede «sumar» a otro: registrar un segundo trap … EXIT reemplaza al primero. Por eso todo lo que haya que limpiar va en la misma función.

5. Las señales: Ctrl+C y kill

Las señales son avisos que el sistema operativo envía a un proceso. Algunas tienen un significado estándar, y un script puede atraparlas con trap para reaccionar:

SeñalNúmeroSe produceCódigo si termina el proceso
INT2Al pulsar Ctrl+C en la terminal.130
TERM15Con kill PID, o al apagar el sistema o detener un servicio.143
HUP1Al cerrarse la terminal en que se ejecuta.129
KILL9Con kill -9 PID.137

En Bash, el trap … EXIT se ejecuta también cuando el script termina por INT o por TERM, de modo que la limpieza básica ya está cubierta. Atrapar esas señales explícitamente sirve para dos cosas más: mostrar un mensaje que explique qué pasó, y terminar con el código convencional, que permite a quien llamó al script saber que fue interrumpido.

reaccionar a una interrupción
trap limpiar EXIT
trap 'echo "${0##*/}: interrumpido" >&2; exit 130' INT
trap 'echo "${0##*/}: terminado" >&2; exit 143' TERM

El exit dentro del manejador de la señal termina el script, y eso dispara a su vez el trap … EXIT, que hace la limpieza. Así, cada cosa tiene su lugar: los manejadores de señales informan y fijan el código; la limpieza está en un solo sitio.

KILL no se puede atrapar

La señal KILL (kill -9) termina el proceso de inmediato, sin darle ninguna oportunidad de reaccionar: ningún trap, ni siquiera el de EXIT, se ejecuta. Por eso kill -9 debe ser el último recurso, después de un kill normal. Y por eso un script robusto no debe depender de que su limpieza ocurra siempre: los temporales van en /tmp, que el sistema vacía periódicamente, y los bloqueos usan mecanismos que se liberan solos, como veremos en el tema 27.

6. Señales mientras se espera un comando

Hay un detalle del funcionamiento de Bash que sorprende la primera vez: mientras el script espera a que termine un comando en primer plano, Bash no ejecuta los trap de las señales que recibe hasta que ese comando termina. Si el script está en medio de un sleep 300 y recibe TERM, el manejador se ejecutará recién cinco minutos después.

Con Ctrl+C no suele notarse, porque la terminal envía INT a todo el grupo de procesos, y el comando en curso también se interrumpe. Con kill dirigido solo al script, sí. Cuando un script pasa mucho tiempo esperando y debe responder rápido a TERM, por ejemplo porque corre como servicio, la solución es lanzar la espera en segundo plano y esperarla con wait, que sí se interrumpe al llegar una señal atrapada:

esperar de forma interrumpible
while true; do
  revisar_estado
  sleep 60 & wait $!               # una señal interrumpe el wait de inmediato
done

7. trap … ERR

La pseudoseñal ERR se produce cada vez que un comando falla, en las mismas condiciones en que set -e detendría el script. Como vimos en el tema 23, sirve para informar dónde ocurrió el fallo:

informar cada fallo
set -eEuo pipefail
trap 'echo "${0##*/}: falló «$BASH_COMMAND» en la línea $LINENO (código $?)" >&2' ERR

Por defecto, el trap … ERR no se hereda dentro de las funciones: un fallo dentro de una función detiene el script por set -e, pero sin el mensaje. La opción -E (errtrace) hace que también se active dentro de ellas. Por eso, cuando se usa trap … ERR, el modo estricto se escribe set -eEuo pipefail.

PseudoseñalSe produceUso típico
EXITAl terminar el script, por cualquier motivo salvo KILL.Limpiar.
ERRCuando un comando falla, donde set -e actuaría.Informar dónde falló.
RETURNAl terminar una función o un archivo cargado con source.Poco frecuente.
DEBUGAntes de cada comando.Depuración (tema 26).

8. Ver, cambiar e ignorar traps

FormaEfecto
trap 'comando' SEÑAL…Registra el comando para una o varias señales. Reemplaza el anterior para esa señal.
trap -pMuestra los traps registrados.
trap - SEÑALRestaura el comportamiento por defecto.
trap '' SEÑALIgnora la señal.

Ignorar una señal tiene usos puntuales, como proteger un tramo que no debe interrumpirse a la mitad, por ejemplo la actualización de varios archivos que deben quedar coherentes entre sí:

un tramo que no se interrumpe
trap '' INT TERM                   # ignorar Ctrl+C y kill durante el cambio
mv -f "$nuevo_config" "$config"
mv -f "$nueva_version" "$version"
trap - INT TERM                    # volver a lo normal

Las subshells, como las de ( … ) y $( … ), no heredan los traps del script, salvo los que ignoran señales. Una subshell que crea sus propios temporales necesita su propio trap.

9. Escribir un archivo de forma atómica

Un problema emparentado con la limpieza: un script que genera un archivo importante, como una configuración o un informe que otros leen, y falla a la mitad, deja un archivo incompleto en lugar del bueno anterior. Y mientras lo escribe, otro programa puede leer una versión a medio hacer.

La solución es escribir en un archivo temporal del mismo directorio y, solo cuando está completo, renombrarlo con mv sobre el definitivo. En un mismo sistema de archivos, el renombrado es atómico: ocurre de una vez, y quien lea el archivo ve la versión anterior completa o la nueva completa, nunca una mezcla.

escritura atómica
destino=/srv/web/estado.json
tmp=$(mktemp "$destino.XXXXXX")      # en el mismo directorio que el destino
trap 'rm -f "$tmp"' EXIT

generar_estado > "$tmp"              # si falla, el destino queda intacto
chmod 644 "$tmp"                     # mktemp crea con permisos 600
mv -f "$tmp" "$destino"              # el reemplazo es instantáneo

Si generar_estado falla, set -e detiene el script, el trap borra el temporal, y el archivo anterior sigue en su lugar, intacto. Si funciona, después del mv el temporal ya no existe, y el rm -f de la limpieza no hace nada. El temporal tiene que estar en el mismo directorio porque entre dos sistemas de archivos distintos, por ejemplo de /tmp a /srv, mv copia y borra, y ya no es atómico.

10. Un script completo

Este script crea un paquete .tar.gz de un directorio, como el de un sitio web antes de desplegarlo: copia el directorio a una carpeta temporal, quita los archivos que no deben publicarse, agrega un archivo VERSION, comprime, verifica el resultado y, solo si todo funcionó, lo deja en su destino. Pase lo que pase, no deja restos.

empaquetar
#!/usr/bin/env bash
#
# empaquetar — crea un paquete .tar.gz de un directorio, sin temporales ni registros,
# con un archivo VERSION, y lo deja en su destino solo si se completó.
#
# Uso: empaquetar ORIGEN DESTINO.tar.gz
#
set -euo pipefail
shopt -s inherit_errexit

temporales=()

limpiar() {
  local codigo=$?
  (( ${#temporales[@]} == 0 )) || rm -rf -- "${temporales[@]}"
  exit "$codigo"
}
trap limpiar EXIT
trap 'echo "${0##*/}: interrumpido" >&2; exit 130' INT
trap 'echo "${0##*/}: terminado" >&2; exit 143' TERM

uso="Uso: ${0##*/} ORIGEN DESTINO.tar.gz"
origen="${1:?$uso}"
destino="${2:?$uso}"
[[ -d "$origen" ]] || { echo "${0##*/}: no existe el directorio $origen" >&2; exit 1; }

# Copia de trabajo, para no tocar el original.
trabajo=$(mktemp -d)
temporales+=("$trabajo")

nombre=$(basename "$origen")
cp -a -- "$origen" "$trabajo/$nombre"
find "$trabajo/$nombre" \( -name '*.tmp' -o -name '*.log' -o -name '.git' \) -prune -exec rm -rf {} +
printf 'versión %s\ncreado %s por %s\n' "$(date +%Y%m%d.%H%M)" "$(date -Is)" "$(id -un)" \
  > "$trabajo/$nombre/VERSION"

# El paquete se crea junto al destino y se renombra al final: escritura atómica.
parcial=$(mktemp "$destino.XXXXXX")
temporales+=("$parcial")
tar -czf "$parcial" -C "$trabajo" "$nombre"
tar -tzf "$parcial" > /dev/null                # comprobar que se puede leer
mv -f -- "$parcial" "$destino"
echo "Paquete creado: $destino ($(du -h -- "$destino" | cut -f1))"
terminal
$ empaquetar sitio dist/sitio.tar.gz
Paquete creado: dist/sitio.tar.gz (2.1M)
$ tar -tzf dist/sitio.tar.gz | head -4
sitio/
sitio/VERSION
sitio/css/
sitio/css/estilos.css
$ empaquetar sitio /no/existe/sitio.tar.gz; echo "código $?"
mktemp: failed to create file via template '/no/existe/sitio.tar.gz.XXXXXX': No such file or directory
código 1
$ empaquetar sitio-grande dist/grande.tar.gz   # y se pulsa Ctrl+C a la mitad
^Cempaquetar: interrumpido
$ ls dist/                                     # ni paquete a medias ni temporales
sitio.tar.gz

11. Errores frecuentes

EscribisteQué pasaCorrección
rm "$tmp" como última líneaSi el script falla o se interrumpe, el temporal queda.trap 'rm -f "$tmp"' EXIT al crearlo.
tmp=/tmp/miscript.tmpDos ejecuciones se pisan; otro usuario puede crearlo antes.tmp=$(mktemp)
El trap registrado al final del scriptUn fallo previo deja la limpieza sin registrar.Registrarlo al comienzo.
Dos trap … EXIT en el mismo scriptEl segundo reemplaza al primero.Una sola función de limpieza.
Una limpieza que no conserva $?El script termina con el código de la limpieza.local codigo=$? y exit "$codigo".
trap … ERR sin set -ENo se activa dentro de las funciones.set -eEuo pipefail.
El temporal de una escritura atómica en /tmpmv entre sistemas de archivos no es atómico.mktemp "$destino.XXXXXX".
sleep 300 en un script que debe responder a killEl trap espera a que termine el sleep.sleep 300 & wait $!

12. Resumen

13. Preguntas de repaso

Intenta responder cada una antes de abrir la respuesta.

1. ¿Por qué no conviene usar un nombre fijo como /tmp/miscript.tmp para un temporal?

Porque dos ejecuciones simultáneas del script usarían el mismo archivo, y porque en un directorio compartido otro usuario podría crear ese nombre antes. mktemp genera un nombre único e impredecible.

2. ¿En qué casos se ejecuta un trap … EXIT?

Al terminar el script por cualquier motivo: al llegar al final, con exit, detenido por set -e, o terminado por señales como INT o TERM. No se ejecuta con kill -9.

3. ¿Por qué el trap se registra al comienzo y no al final?

Porque si el script falla antes de llegar al final, el trap no llegaría a registrarse, y la limpieza no ocurriría.

4. ¿Qué pasa si se registran dos trap … EXIT?

El segundo reemplaza al primero. Toda la limpieza debe ir en un solo comando o función.

5. ¿Por qué la función de limpieza empieza con local codigo=$? y termina con exit "$codigo"?

Para conservar el código con que terminaba el script. Los comandos de la limpieza reemplazarían $?, y el script terminaría con el código de la limpieza en lugar del real.

6. ¿Qué código de salida conviene usar al atrapar Ctrl+C, y por qué?

130, es decir 128 más el número de la señal INT (2). Es la convención, y permite a quien llamó al script saber que fue interrumpido.

7. ¿Por qué un script que está en sleep 300 tarda en reaccionar a kill?

Porque Bash no ejecuta los traps mientras espera un comando en primer plano. Lanzando el comando en segundo plano y esperándolo con wait, la señal interrumpe la espera de inmediato.

8. ¿Para qué sirve set -E?

Para que el trap … ERR se active también dentro de las funciones, que de otro modo no lo heredan.

9. ¿Por qué el temporal de una escritura atómica debe estar en el mismo directorio que el destino?

Porque el renombrado con mv solo es atómico dentro de un mismo sistema de archivos. Entre sistemas distintos, mv copia y borra, y otro programa podría ver el archivo a medio escribir.

10. ¿Qué queda en el destino si un script con escritura atómica falla antes del mv?

La versión anterior del archivo, completa e intacta. El temporal lo borra el trap.

Para practicar

  1. Escribe un script que cree un temporal con mktemp, registre su limpieza con trap y muestre un mensaje al limpiar. Hazlo terminar de tres formas: normalmente, con exit 5 y con un comando que falla bajo set -e. Comprueba el código en cada caso.
  2. Agrega al script un sleep 60 y pruébalo con Ctrl+C y con kill desde otra terminal. Cambia el sleep por sleep 60 & wait $! y compara.
  3. Escribe la función de limpieza con un array de temporales y crea tres archivos y un directorio. Comprueba que se borran todos.
  4. Reproduce la diferencia entre trap … ERR con y sin set -E cuando el fallo ocurre dentro de una función.
  5. Escribe un script que regenere un archivo de forma atómica, y comprueba que, si lo haces fallar a la mitad, el archivo anterior queda intacto.
  6. Prueba empaquetar con un directorio propio, y comprueba con ls /tmp que no quedan temporales después de un éxito, de un fallo y de una interrupción.