Al cerrar 05-02 quedó identificado el agujero que sigue teniendo informe-diario.sh: si algo falla dentro, el script no se entera. Bash, por diseño, ejecuta la línea siguiente pase lo que pase con la anterior. Si el grep sobre el CSV no encuentra nada, si una variable está mal escrita y se expande a la cadena vacía, si el disco se llena a media escritura, el script sigue adelante y produce un informe silenciosamente falso —lo peor que puede hacer una herramienta de operaciones, porque nadie sospecha de un número equivocado que llega puntual cada mañana—. Y además deja atrás el temporal de mktemp que en 05-01 prometimos aprender a limpiar. Esta lección le da a Bash la disciplina que no trae de serie, y te da a ti las herramientas para ver qué está pasando cuando algo no cuadra.
Contenido
- El fallo silencioso
set -e: qué detecta y qué noset -u: variables no definidasset -o pipefailyPIPESTATUSset -euo pipefaily su crítica honestatrap: señales y pseudoseñales- Limpieza garantizada con
EXIT morir()ytrap ... ERRcon contexto- Convenio de códigos de salida
- Reintentos con retroceso
- Depuración:
-n,-x,PS4,-v,DEBUGy bisección informe-diario.shendurecido
- El fallo silencioso
#!/usr/bin/env bash
cd /srv/veloz/datos/no-existe # falla: "No such file or directory"
rm -rf ./* # se ejecuta IGUALMENTE, en el directorio actualEse ejemplo, con variantes, ha destruido servidores de verdad. Bash reporta el error en stderr y continúa. Contra eso hay tres interruptores.
set -e: qué detecta y qué no
set -e: qué detecta y qué noset -e (o set -o errexit) hace que el script termine en cuanto un comando devuelva un código distinto de cero: con él al principio, el ejemplo anterior muere en el cd y nunca llega al rm. Lo importante es saber qué no detecta, porque las excepciones son muchas y deliberadas: un comando cuyo fallo forme parte de una decisión no debe matar el script.
| Situación | ¿set -e aborta? |
Por qué |
|---|---|---|
grep patron f sin coincidencias |
Sí | Devuelve 1 y no está protegido |
if grep -q patron f; then |
No | Es la condición de un if |
while ! ping -c1 host; do |
No | Es la condición de un bucle |
grep -q x f && echo hay |
No | Salvo que falle el último de la cadena |
| `comando | true` | |
! comando |
No | Está negado |
f() { falso; }; if f; then |
No | La función entera pierde el -e dentro de un if |
falso | wc -l |
No | Solo cuenta el último de la tubería (sección 4) |
(( contador++ )) con contador a 0 |
Sí | Sorpresa clásica, ya vista en 04-06 |
local x=$(falso) |
No | El código es el de local, no el de la sustitución |
Las dos últimas filas son las que más quebraderos dan. La de local tiene solución: declara y asigna en líneas separadas (local fecha y luego fecha=$(date -d "$1" +%F)), y así el fallo sí se detecta. Y la penúltima —una función pierde set -e en su interior cuando se llama desde un if o desde un &&— es la razón principal por la que hay quien desconfía de set -e. No es un error de Bash: es POSIX. Pero significa que no puedes delegar toda tu gestión de errores en -e.
set -u: variables no definidas
set -u: variables no definidasBajo set -u, un echo "Informe de $CIUDADD" con la variable mal escrita aborta con bash: CIUDADD: variable sin asignar. Sin -u, esa errata se expande a la cadena vacía y produce rm -rf "$DIR_BASE/" con DIR_BASE vacío, es decir rm -rf /. Convive perfectamente con los valores por defecto de 03-06, que son la forma explícita de decir "esta puede no existir":
nivel="${VELOZ_LOG_NIVEL:-info}" # por defecto si no está definida
destino="${1:?falta el destino}" # error propio con mensaje claro
[[ -n "${DEBUG:-}" ]] && set -x # el :- es obligatorio bajo set -uCuidado con los arrays: bajo -u y en Bash anterior a 4.4, "${arr[@]}" sobre un array vacío aborta. El idioma seguro es "${arr[@]:-}" o comprobar antes con (( ${#arr[@]} > 0 )).
set -o pipefail y PIPESTATUS
set -o pipefail y PIPESTATUSEn 02-04 quedó aplazado esto: el código de salida de una tubería es el de su último comando.
zcat /var/log/veloz/no-existe.gz | wc -l
echo $? # 0 ← ¡éxito! porque wc -l funcionó perfectamente (imprimió 0)
set -o pipefail
zcat /var/log/veloz/no-existe.gz | wc -l
echo $? # 1 ← ahora síUn fallo así en informe-diario.sh produce "0 errores hoy" cuando la verdad es "no pude leer el fichero". pipefail lo arregla: la tubería devuelve el código del último comando que falló, o 0 si ninguno falló. Cuando necesitas saber cuál de los eslabones falló, el array PIPESTATUS guarda todos los códigos, en orden:
zcat acceso.log.2.gz | grep ' 500 ' | wc -l
echo "${PIPESTATUS[@]}" # 0 1 0 ← el grep no encontró nada (código 1)PIPESTATUS se sobrescribe con cada comando, incluido un echo. Si vas a usarlo, cópialo primero: local -a codigos=( "${PIPESTATUS[@]}" ).
set -euo pipefail y su crítica honesta
set -euo pipefail y su crítica honestaLa cabecera que verás en casi todo script serio es set -euo pipefail justo bajo el shebang, a veces acompañada de un IFS=$'\n\t' restrictivo (03-06). Es un buen valor por defecto y deberías usarlo. Pero conviene decir con claridad por qué no es magia:
set -etiene el agujero de las funciones llamadas desdeif, así que las funciones críticas deben comprobar sus propios errores.- Aborta con un mensaje del comando que falló, sin decir en qué línea de tu script ocurrió. Eso lo arregla el
trap ... ERRde la sección 8. - Convierte cualquier código distinto de cero en fatal, y hay comandos donde eso es normal:
grepsin coincidencias,diffcon diferencias,pgrepsin procesos. Hay que marcarlos explícitamente.
Para esos casos, tres idiomas:
grep -c ERROR "$LOG" || true # "puede devolver 1, es aceptable"
if ! errores=$(grep -c ERROR "$LOG"); then errores=0; fi # mejor: distingue el caso
set +e; comando_que_falla_a_menudo; codigo=$?; set -e # desactivar puntualmenteLa regla: || true para el caso trivial, if ! cuando quieres reaccionar, y set +e/set -e acotado a las mínimas líneas posibles. Un set +e al principio de un script largo es peor que no haber puesto nunca -e, porque da falsa sensación de seguridad.
trap: señales y pseudoseñales
trap: señales y pseudoseñalestrap 'comandos' SEÑAL [SEÑAL...] registra código que se ejecuta cuando llega una señal. Sus variantes: trap - SEÑAL restaura el comportamiento por defecto, trap '' SEÑAL la ignora y trap -p lista las trampas activas.
Además de las señales reales de 05-02, Bash define cuatro pseudoseñales:
| Pseudoseñal | Se dispara | Uso típico |
|---|---|---|
EXIT |
Al salir el script, por la vía que sea | Limpieza garantizada |
ERR |
Cuando un comando devuelve código ≠ 0 (mismas excepciones que -e) |
Informar de dónde falló |
DEBUG |
Antes de cada comando | Depuración fina |
RETURN |
Al volver de una función o de un source |
Perfilado, trazas |
Usa comillas simples en el código de la trampa. trap 'rm -f "$tmp"' EXIT es correcto porque $tmp se resuelve al dispararse; con comillas dobles se expandiría al registrar la trampa y capturarías el valor equivocado.
- Limpieza garantizada con
EXIT
EXITAquí se cierra lo prometido en 05-01 y 05-02:
readonly TMPDIR_INFORME=$(mktemp -d)
limpiar() {
local codigo=$? # capturar ANTES de hacer nada
rm -rf "$TMPDIR_INFORME"
exit "$codigo" # preservar el código original
}
trap limpiar EXITEXIT se dispara con exit, al terminar la última línea, cuando set -e aborta y cuando llega una señal capturada. Dos detalles que la gente olvida: el local codigo=$? debe ser la primera línea de la función, porque cualquier comando anterior lo sobrescribe; y el exit "$codigo" final evita que la limpieza convierta un fallo en un éxito aparente. Para que Ctrl-C también pase por ella, añade las señales: trap limpiar EXIT INT TERM HUP.
morir() y trap ... ERR con contexto
morir() y trap ... ERR con contextoTodo script serio tiene una función para abortar con mensaje. La convención: mensaje a stderr, código de salida significativo.
# morir — Escribe un mensaje de error y termina. Uso: morir <código> <mensaje...>
morir() {
local codigo="${1:?}"; shift
printf '%s [ERROR] %s\n' "$(date '+%F %T')" "$*" >&2
exit "$codigo"
}
validar_entorno() {
[[ -r "$RUTA_CSV" ]] || morir 66 "No puedo leer $RUTA_CSV"
command -v bc > /dev/null || morir 69 "Falta la utilidad bc"
}Y para los fallos que no previste, una trampa ERR que diga exactamente dónde ocurrió:
trap 'printf "[FATAL] %s:%d en %s(): código %d\n" "${BASH_SOURCE[0]}" \
"$LINENO" "${FUNCNAME[0]:-main}" "$?" >&2' ERRLas tres variables que dan el contexto son las que convierten un mensaje inútil en un diagnóstico:
| Variable | Contenido |
|---|---|
$LINENO |
Número de línea actual |
${BASH_SOURCE[0]} |
Fichero donde está esa línea (clave con librerías, 05-06) |
${FUNCNAME[0]} |
Función en curso; el array completo es la pila de llamadas |
FUNCNAME es un array: ${FUNCNAME[1]} es quien llamó a la función actual, y recorriéndolo junto con BASH_LINENO se imprime una traza de pila completa. Eso sí: para que la trampa ERR se herede dentro de funciones y subshells hace falta set -E (o set -o errtrace); sin él, las funciones no la disparan.
- Convenio de códigos de salida
En 03-01 viste exit N. Ahora el convenio, que importa cuando otro script o cron lee tu resultado:
| Código | Significado |
|---|---|
| 0 | Éxito |
| 1 | Error genérico |
| 2 | Uso incorrecto: faltan argumentos u opción desconocida |
| 64 | EX_USAGE: error en la línea de comandos |
| 65 | EX_DATAERR: los datos de entrada son incorrectos (CSV corrupto) |
| 66 | EX_NOINPUT: el fichero de entrada no existe o no se puede leer |
| 69 | EX_UNAVAILABLE: un servicio o dependencia no está disponible |
| 73 | EX_CANTCREAT: no se puede crear el fichero de salida |
| 78 | EX_CONFIG: error de configuración |
| 124 / 126 | timeout expiró (05-02) / el fichero existe pero no es ejecutable |
| 127 / 130 | Comando no encontrado (no está en el PATH) / Ctrl-C (128 + 2, SIGINT) |
Los del rango 64-78 vienen de sysexits.h de BSD; no todo el mundo los usa, pero son mejor convenio que inventar números. Y la regla 128 + N para señales explica de un vistazo el 137 (128+9, SIGKILL) y el 143 (128+15, SIGTERM). Reserva los códigos por encima de 125 y no uses valores mayores de 255: se truncan módulo 256, así que exit 256 es exit 0.
- Reintentos con retroceso
Los fallos de red son transitorios por naturaleza: reintentar es correcto, pero reintentar de inmediato y sin límite es un ataque contra tu propio servidor. El patrón es retroceso exponencial:
# reintentar — Ejecuta un comando con espera creciente. Uso: reintentar <n> <comando...>
reintentar() {
local intentos="${1:?}" n=1 espera=1; shift
until "$@"; do
(( n >= intentos )) && { printf 'Fallo tras %d intentos\n' "$n" >&2; return 1; }
sleep "$espera"; (( espera *= 2 )); (( ++n ))
done
}
reintentar 5 timeout 10 curl -sf http://localhost:8080/enviosLas esperas son 1, 2, 4, 8 segundos. Nótese la combinación con timeout de 05-02: sin él, un intento colgado impide llegar al siguiente. El uso real con curl y APIs llega en 06-04 y 06-05.
- Depuración:
-n, -x, PS4, -v, DEBUG y bisección
-n, -x, PS4, -v, DEBUG y bisecciónbash -n informe-diario.sh # solo comprueba la SINTAXIS, no ejecuta nada
bash -x informe-diario.sh # traza: imprime cada comando ya expandido
bash -v informe-diario.sh # imprime cada línea tal cual, antes de expandirbash -n debería ser un reflejo antes de guardar cualquier script que vaya a cron: detecta el fi que falta sin ejecutar el rm. Y set -x / set +x acotan la traza a la parte sospechosa, que es lo que la hace usable en un script largo.
La traza sale a stderr precedida de +. Ese prefijo es la variable PS4, y enriquecerla convierte una traza ilegible en un diagnóstico:
export PS4='+ ${BASH_SOURCE##*/}:${LINENO}:${FUNCNAME[0]:-main}(): '
exec 8> ~/veloz-ops/logs/traza.$$
export BASH_XTRACEFD=8 # la traza va al descriptor 8, no a stderr
set -x
acumular_dia "$fecha"
set +xLa traza resultante tiene esta forma —+ informe-diario.sh:91:acumular_dia(): [[ 2026-08-03 == 2026-08-03 ]]—: cada línea dice fichero, línea y función. Y BASH_XTRACEFD la desvía a un fichero para que no se mezcle con la salida del informe (el descriptor 8 es de la familia que verás a fondo en 05-05). Por su parte, trap 'printf "línea %d: total=%s\n" "$LINENO" "${total:-}" >&2' DEBUG inspecciona el estado antes de cada comando, útil para cazar dónde cambia una variable.
Cuando nada de esto basta, queda la bisección: colocar exit 99 a mitad del script, ver si el problema aparece, mover el exit a la mitad de la mitad que corresponda y repetir. Con cinco o seis ejecuciones localizas la línea culpable de un script de mil. Y para prevenir en lugar de curar, shellcheck detecta estáticamente la mayoría de estos fallos antes de ejecutar nada: es el tema de 08-05.
informe-diario.sh endurecido
informe-diario.sh endurecido#!/usr/bin/env bash
# informe-diario.sh — Informe diario de reparto de Veloz Envíos
set -Eeuo pipefail # -E: las funciones heredan el trap ERR
readonly RUTA_CSV="${VELOZ_CSV:-/srv/veloz/datos/envios.csv}"
TMPDIR_INFORME=$(mktemp -d) || exit 73; readonly TMPDIR_INFORME
limpiar() { local c=$?; rm -rf "$TMPDIR_INFORME"; exit "$c"; }
trap limpiar EXIT INT TERM
trap 'printf "[FATAL] %s:%d en %s(): código %d\n" "${BASH_SOURCE[0]}" \
"$LINENO" "${FUNCNAME[0]:-main}" "$?" >&2' ERR
main() {
[[ "${1:-}" == --debug ]] && { shift; activar_debug; }
validar_entorno
...
}
main "$@"Cuatro líneas de cabecera y dos trampas: el script ya no miente. Si el CSV no se puede leer, muere con 66 y un mensaje; si algo inesperado falla, dice fichero, línea y función; y el temporal se borra por las cuatro vías posibles de salida.
Errores Comunes y Consejos
- Creer que
set -elo cubre todo. Las funciones llamadas desdeiflo pierden. Comprueba también a mano. trapcon comillas dobles. Las variables se expanden al registrar la trampa, no al dispararla.- No capturar
$?en la primera línea del manejadorEXIT, u olvidar elexit "$codigo"final. Un fallo se convierte en éxito aparente ycronno avisa. trap ... ERRsinset -E. Las funciones no lo disparan y el diagnóstico no aparece nunca.- Usar
PIPESTATUSdespués de otro comando. Se sobrescribe con cada uno; cópialo de inmediato. exit 300. Se trunca módulo 256 y sale con 44. Mantente entre 0 y 125.- Consejo: escribe la trampa de limpieza en la misma línea en que creas el temporal. Si lo dejas para "luego", el
returntemprano que añadas dentro de un mes se llevará el borrado por delante.
Ejercicios
Ejercicio 1. Escribe una cabecera completa para un script archivar-historico.sh que use modo estricto, cree un directorio temporal, garantice su borrado también ante Ctrl-C, preserve el código de salida original e informe de la línea exacta ante cualquier fallo imprevisto.
Ejercicio 2. La función contar_errores() usa zgrep -c ERROR "$LOG" | tr -d ' '. Explica por qué bajo set -e sin pipefail puede devolver un número falso, y reescríbela para que distinga "cero errores" de "no pude leer el fichero", saliendo con 66 en el segundo caso.
Ejercicio 3. Añade a informe-diario.sh una opción --debug que active una traza enriquecida (fichero, línea y función) volcada a ~/veloz-ops/logs/traza-AAAA-MM-DD.log, sin ensuciar ni la salida del informe ni sus errores.
Soluciones
Solución 1.
#!/usr/bin/env bash
set -Eeuo pipefail
TRABAJO=$(mktemp -d) || exit 73
readonly TRABAJO
limpiar() { local c=$?; rm -rf "$TRABAJO"; exit "$c"; }
trap limpiar EXIT INT TERM HUP
trap 'printf "[FATAL] %s:%d %s(): código %d\n" "${BASH_SOURCE[0]}" "$LINENO" \
"${FUNCNAME[0]:-main}" "$?" >&2' ERR-E es imprescindible para que la trampa ERR funcione dentro de funciones. INT TERM HUP junto a EXIT puede parecer redundante —una señal capturada acaba disparando EXIT—, pero explicitarlas garantiza el borrado también si alguien redefine el comportamiento por defecto.
Solución 2. Sin pipefail, el código de la tubería es el de tr, que siempre vale 0: si zgrep no pudo abrir el fichero, la función devuelve la cadena vacía como si fueran cero errores. Además, zgrep -c devuelve 1 cuando hay cero coincidencias, que bajo set -e mataría el script sin ser un error real.
contar_errores() { # Uso: contar_errores <fichero>
local log="${1:?}" n
[[ -r "$log" ]] || morir 66 "No puedo leer $log"
n=$(grep -c 'ERROR' "$log") || n=0 # el || captura el "sin coincidencias"
printf '%s\n' "$n"
}La guarda [[ -r ]] separa el fallo real (fichero ilegible → código 66) del caso legítimo (cero errores → imprime 0). Es más claro que cualquier acrobacia con PIPESTATUS, y la lección es general: comprueba las precondiciones tú mismo en vez de deducirlas de códigos ambiguos.
Solución 3.
activar_debug() {
local traza=~/veloz-ops/logs/traza-$(date +%F).log
exec 8>> "$traza" # descriptor propio, en modo añadir
export BASH_XTRACEFD=8
export PS4='+ ${BASH_SOURCE##*/}:${LINENO}:${FUNCNAME[0]:-main}(): '
set -x
log_info "Traza activada en $traza"
}
[[ "${1:-}" == --debug ]] && { shift; activar_debug; }BASH_XTRACEFD=8 desvía la traza al descriptor 8, así que ni stdout (el informe) ni stderr (los avisos) se contaminan: puedes seguir canalizando el informe a un fichero mientras lees la traza en otra terminal con tail -f. El >> conserva las trazas de varias ejecuciones del mismo día.
Conclusión
Bash no aborta por su cuenta, así que la robustez se pide explícitamente. set -e mata el script al primer fallo, con excepciones deliberadas —condicionales, &&/||, negaciones, funciones llamadas desde if— que debes conocer para no confiarte; set -u convierte una errata en un error visible en lugar de en una cadena vacía peligrosa; set -o pipefail arregla el código de salida de las tuberías, y PIPESTATUS dice qué eslabón falló. La línea set -Eeuo pipefail es el punto de partida correcto, pero no una garantía: hace falta marcar con || true o if ! los comandos cuyo fallo es normal. trap completa el cuadro: EXIT da la limpieza garantizada que hace segura la pareja mktemp + borrado, y ERR con $LINENO, BASH_SOURCE y FUNCNAME convierte un error mudo en un diagnóstico con fichero, línea y función. Una morir() con códigos del convenio sysexits y un patrón de reintentos con retroceso completan el arsenal. Y para investigar: bash -n valida sintaxis, set -x con un PS4 enriquecido y BASH_XTRACEFD produce una traza legible y aislada, y la bisección con exit temporal localiza lo que se resista.
Queda una deuda pendiente desde el Módulo 2. El toolkit valida rutas, procesos y códigos, pero cuando tiene que validar texto —una fecha --fecha 2026-08-03, una IP, un código HTTP, la estructura de una línea de app.log— sigue recurriendo a comparaciones frágiles con globs. En 05-04 llegan por fin las expresiones regulares de verdad: los tres sabores y qué herramienta usa cada uno, la sintaxis desde cero, y el operador =~ de Bash con BASH_REMATCH, que valida y extrae en un solo paso.
Curso de Programación en Bash
Módulo 1: Introducción a Bash
- ¿Qué es Bash?
- Configurando tu Entorno
- Navegación Básica en la Línea de Comandos
- Entendiendo el Shell
- Encontrar Ayuda: man, help y --help
Módulo 2: Comandos Básicos de Bash
- Operaciones con Archivos y Directorios
- Comandos de Procesamiento de Texto
- Permisos y Propiedad de Archivos
- Redirección y Tuberías
- Comodines y Expansión de Rutas
- Historial y Atajos de Teclado
Módulo 3: Fundamentos de Scripting
- Creando y Ejecutando un Script
- Variables y Constantes
- Operadores Básicos
- Sentencias Condicionales
- Argumentos y Entrada del Usuario
- Comillas, Expansión y Sustitución
Módulo 4: Scripting Intermedio
- Bucles en Bash
- Funciones en Bash
- Arrays y Arrays Asociativos
- Manipulación de Cadenas
- La Sentencia case y los Menús Interactivos
- Aritmética y Cálculos Numéricos
Módulo 5: Técnicas Avanzadas de Scripting
- Operaciones Avanzadas con Archivos
- Gestión de Procesos
- Manejo de Errores y Depuración
- Expresiones Regulares
- Entrada/Salida Avanzada: Descriptores y Here-Documents
- Scripts Modulares y Librerías Reutilizables
Módulo 6: Trabajando con Herramientas Externas
Módulo 7: Automatización y Programación
- Trabajos Cron
- Automatizando Tareas
- Scripts de Respaldo y Restauración
- Monitoreo y Registro
- Servicios y Temporizadores con systemd
- Automatización Remota con SSH
Módulo 8: Mejores Prácticas y Optimización
- Escribiendo Código Legible
- Optimizando Scripts en Bash
- Consideraciones de Seguridad
- Control de Versiones con Git
- Análisis Estático con ShellCheck y shfmt
- Pruebas Automatizadas con Bats
- Portabilidad: POSIX sh frente a Bashismos
