1. El problema de los restos
Este script parece correcto: crea un archivo temporal, trabaja con él y lo borra al final.
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.
$ 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
| Forma | Crea |
|---|---|
mktemp | Un archivo en el directorio temporal: $TMPDIR, o /tmp. |
mktemp -d | Un directorio. |
mktemp -t prefijo.XXXXXX | Un archivo en el directorio temporal, con un prefijo que ayuda a identificarlo. |
mktemp ruta/nombre.XXXXXX | Un 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.
tmp=$(mktemp)
trap 'rm -f "$tmp"' EXIT # se ejecutará al terminar, pase lo que pase
generar_datos > "$tmp"
sort "$tmp" | uniq -c > informe.txt
$ ./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:
- El
trapse registra justo después de crear lo que hay que limpiar, no al final. Si se registrara al final, un fallo intermedio lo dejaría sin registrar. - El comando va entre comillas simples, para que
$tmpse expanda cuando se ejecute la limpieza, no cuando se registra. En este caso daría lo mismo, pero si la variable cambia después, solo las comillas simples usan el valor final. - El código de salida del script se conserva: la limpieza no lo cambia, salvo que llame a
exitcon otro valor.
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:
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ñal | Número | Se produce | Código si termina el proceso |
|---|---|---|---|
INT | 2 | Al pulsar Ctrl+C en la terminal. | 130 |
TERM | 15 | Con kill PID, o al apagar el sistema o detener un servicio. | 143 |
HUP | 1 | Al cerrarse la terminal en que se ejecuta. | 129 |
KILL | 9 | Con 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.
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.
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:
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:
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ñal | Se produce | Uso típico |
|---|---|---|
EXIT | Al terminar el script, por cualquier motivo salvo KILL. | Limpiar. |
ERR | Cuando un comando falla, donde set -e actuaría. | Informar dónde falló. |
RETURN | Al terminar una función o un archivo cargado con source. | Poco frecuente. |
DEBUG | Antes de cada comando. | Depuración (tema 26). |
8. Ver, cambiar e ignorar traps
| Forma | Efecto |
|---|---|
trap 'comando' SEÑAL… | Registra el comando para una o varias señales. Reemplaza el anterior para esa señal. |
trap -p | Muestra los traps registrados. |
trap - SEÑAL | Restaura el comportamiento por defecto. |
trap '' SEÑAL | Ignora 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í:
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.
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.
#!/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))"
$ 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
- Cada temporal se registra en el array en cuanto se crea, y una sola función borra todos. Si el script falla antes de crear el segundo, la limpieza borra solo el primero.
- Los manejadores de
INTyTERMinforman y terminan con el código convencional; suexitdispara la limpieza. - El trabajo se hace sobre una copia, de modo que quitar los
.logy.gitno toca el directorio original.-pruneevita quefindentre en los directorios que va a borrar. - El paquete se escribe junto al destino y se renombra al final. Si la compresión o la verificación fallan, en
dist/queda el paquete anterior completo, nunca uno a medias. - El autor se obtiene con
id -uny no con$USER: algunos entornos, como ciertos servicios o tareas programadas, no definen esa variable, y conset -uel script fallaría.
11. Errores frecuentes
| Escribiste | Qué pasa | Corrección |
|---|---|---|
rm "$tmp" como última línea | Si el script falla o se interrumpe, el temporal queda. | trap 'rm -f "$tmp"' EXIT al crearlo. |
tmp=/tmp/miscript.tmp | Dos ejecuciones se pisan; otro usuario puede crearlo antes. | tmp=$(mktemp) |
El trap registrado al final del script | Un fallo previo deja la limpieza sin registrar. | Registrarlo al comienzo. |
Dos trap … EXIT en el mismo script | El 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 -E | No se activa dentro de las funciones. | set -eEuo pipefail. |
El temporal de una escritura atómica en /tmp | mv entre sistemas de archivos no es atómico. | mktemp "$destino.XXXXXX". |
sleep 300 en un script que debe responder a kill | El trap espera a que termine el sleep. | sleep 300 & wait $! |
12. Resumen
mktempcrea archivos temporales con nombres únicos y permisos restringidos;mktemp -d, directorios.trap 'comando' EXITejecuta una limpieza al terminar el script, por cualquier motivo salvokill -9.- El
trapse registra justo después de crear lo que hay que limpiar, con comillas simples. - Una función de limpieza reúne todo, guarda
$?al empezar y termina con ese código. Lo que hay que limpiar se acumula en un array. INT(Ctrl+C) yTERM(kill) se atrapan para informar y terminar con 130 y 143; suexitdispara la limpieza.KILLno se puede atrapar.- Mientras espera un comando en primer plano, Bash demora los traps;
comando & wait $!permite responder enseguida. trap … ERRinforma cada fallo; conset -Efunciona también dentro de las funciones.trap -pmuestra los traps,trap - SEÑALlos restaura ytrap '' SEÑALignora la señal.- Para escribir un archivo de forma atómica, se escribe en un temporal del mismo directorio y se renombra con
mval terminar.
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
- Escribe un script que cree un temporal con
mktemp, registre su limpieza contrapy muestre un mensaje al limpiar. Hazlo terminar de tres formas: normalmente, conexit 5y con un comando que falla bajoset -e. Comprueba el código en cada caso. - Agrega al script un
sleep 60y pruébalo con Ctrl+C y conkilldesde otra terminal. Cambia elsleepporsleep 60 & wait $!y compara. - Escribe la función de limpieza con un array de temporales y crea tres archivos y un directorio. Comprueba que se borran todos.
- Reproduce la diferencia entre
trap … ERRcon y sinset -Ecuando el fallo ocurre dentro de una función. - 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.
- Prueba
empaquetarcon un directorio propio, y comprueba conls /tmpque no quedan temporales después de un éxito, de un fallo y de una interrupción.