21

Señales y Trap en Bash: Domina la Gestión de Procesos en Linux

Vistas: 1
Tutorial: Scripts en Bash
Señales y Trap en Bash: Domina la Gestión de Procesos en Linux

Como te comenté en el capítulo anterior, las redirecciones te permiten controlar el flujo de datos de tus scripts. Pero, ¿qué pasa cuando tu script necesita reaccionar a eventos externos? ¿Cuando alguien pulsa Ctrl+C, cuando cierras la sesión, o cuando otro proceso le pide que haga algo?

Las señales

Las señales son el mecanismo fundamental de notificación asíncrona en Linux. Cuando pulsas Ctrl+C en tu terminal, el sistema no mata tu proceso mágicamente: envía una señal (SIGINT) al proceso en primer plano. Cuando cierras la sesión SSH, el sistema envía SIGHUP. Cuando tu script escribe en un pipe roto, recibe SIGPIPE.

Cada señal es un número entero pequeño. Tu script puede ignorarla, capturarla (ejecutando código propio) o dejarla actuar con su comportamiento por defecto (que suele ser terminar el proceso).

El comando trap es la herramienta que Bash te da para instalar manejadores de señales funciones que se ejecutan cuando una señal concreta llega. Sin trap, tu script es un pasajero pasivo: las señales pasan y el shell reacciona con el comportamiento por defecto. Con trap, tú controlas qué ocurre.

Este capítulo te lleva desde los fundamentos de las señales hasta patrones profesionales: demonios que recargan configuración sin reiniciar, scripts que limpian archivos temporales incluso si los matan, coordinación entre procesos con señales de usuario, y timeouts implementados con señales reales.

Concepto clave: las señales no son una abstracción de Bash. Son un mecanismo real del kernel de Linux. Cuando usas trap, estás llamando a la llamada al sistema sigaction() bajo el capó. Lo que aprendas aquí te sirve para cualquier lenguaje que maneje señales en Unix.

Las señales esenciales

No necesitas memorizar las 30+ señales de Linux. Hay un puñado que usarás el 99% del tiempo:

  • SIGINT (2): Capturable. Interrupción desde teclado (Ctrl+C). Sirve para preguntar confirmación o limpiar antes de terminar.
  • SIGTERM (15): Capturable. Petición educada de terminación (señal por defecto de kill).
  • SIGHUP (1): Capturable. Originalmente «colgado de terminal». Hoy se usa para recarga de configuración sin reiniciar.
  • SIGUSR1 (10): Capturable. Definida por el usuario. Ideal para rotar logs, acciones personalizadas.
  • SIGUSR2 (12): Capturable. Definida por el usuario. Ideal para mostrar estadísticas.
  • SIGQUIT (3): Capturable. Terminación forzada con core dump (Ctrl+\).
  • SIGPIPE (13): Capturable. Escribir en pipe roto. A menudo se ignora.
  • SIGALRM (14): Capturable. Timeout o alarma temporizada.
  • SIGKILL (9): No capturable. Último recurso. Mata siempre.
  • SIGSTOP (19): No capturable. Pausa forzosa del proceso.

Las más importantes para tus scripts son SIGINT, SIGTERM y SIGHUP. Con esas tres cubres el 90% de los casos.

Sintaxis de trap

La sintaxis básica de trap tiene tres formas:

# Capturar: ejecuta 'código' cuando llegue la señal
trap 'código_a_ejecutar' SEÑAL

# Ignorar: la señal no hace nada
trap '' SEÑAL

# Restablecer: vuelve al comportamiento por defecto
trap - SEÑAL

El código a ejecutar puede ser una cadena con comandos o, mucho mejor, el nombre de una función:

#!/bin/bash

limpiar() {
    echo "[$$] Limpiando recursos..."
    rm -f /tmp/mi_script_*.tmp
    echo "[$$] Recursos liberados"
}

trap limpiar SIGTERM SIGINT

El shell ejecuta el código del trap después de que el comando actual termine (normalmente), no interrumpiendo el comando a mitad.

Puedes capturar múltiples señales con el mismo manejador o manejadores diferentes:

trap 'echo "Adiós mundo cruel"' SIGINT SIGTERM
trap 'recargar_config' SIGHUP
trap 'mostrar_estado' SIGUSR1
trap 'echo "Signal SIGPIPE ignorada" >&2' SIGPIPE

trap EXIT: Limpieza garantizada al salir

trap ... EXIT es, con diferencia, el trap más usado. Se ejecuta siempre que el script termina: tanto si sale con exit 0, con exit 1, por una señal no capturada, o simplemente porque llegó al final.

#!/bin/bash
set -euo pipefail

ARCHIVO_TEMP=$(mktemp /tmp/miscript_XXXXXX)
LOCKFILE="/tmp/miscript.lock"

cleanup() {
    local exit_code=$?
    echo "[$(date)] Limpiando. Código de salida: $exit_code"
    rm -f "$ARCHIVO_TEMP" "$LOCKFILE"
}

trap cleanup EXIT

# Crear lock
echo "$$" > "$LOCKFILE"

# Cuerpo del script
echo "Procesando..."
# ... trabajo ...
echo "Hecho"

¿Por qué EXIT y no SIGTERM o SIGINT?

EXIT se ejecuta incluso si el script termina por una señal no capturada. Si pones un manejador solo para SIGTERM y el script recibe SIGQUIT, el manejador no se ejecuta. Pero EXIT se ejecuta siempre. Por eso el patrón profesional es:

trap 'limpiar_exit' EXIT
trap 'echo "Recibida señal..."; exit 1' SIGINT SIGTERM SIGHUP

El manejador de EXIT garantiza la limpieza. Los manejadores específicos de señal piden salida ordenada y dejan que EXIT haga la limpieza real.

Capturar el código de salida en EXIT

trap 'echo "Salgo con código $?"' EXIT

La variable $? dentro del trap de EXIT contiene el código de salida del script.

trap ERR: Captura de errores profunda

trap ERR se ejecuta cuando un comando devuelve un código distinto de cero. Pero hay matices:

  • Se ejecuta solo si el comando NO es la condición de un if, while, until, o está en una lista &&/|| — en esos casos no se ejecuta el trap ERR. Bash asume que ya estás manejando el error.
  • No se ejecuta dentro de un subshell a menos que tenga su propio trap ERR.

Trap ERR con pila de llamadas

#!/bin/bash
set -euo pipefail

trap_error_handler() {
    local line=$1
    local cmd=$2
    local code=$3
    echo "[ERROR] Línea $line: '$cmd' falló con código $code" >&2
    echo "[ERROR] Función: ${FUNCNAME[1]:-principal}" >&2
    echo "[ERROR] Script: $0" >&2
}

trap 'trap_error_handler $LINENO "$BASH_COMMAND" $?' ERR

funcion_que_falla() {
    ls /no/existe
}

funcion_que_falla
echo "Esto no se ejecuta por el set -e"

Salida:

[ERROR] Línea 14: 'ls /no/existe' falló con código 2
[ERROR] Función: funcion_que_falla
[ERROR] Script: ./test.sh

Modo verbose en trap ERR

DEBUG=${DEBUG:-false}

if [[ "$DEBUG" == "true" ]]; then
    trap 'echo "[ERR] $BASH_COMMAND (línea $LINENO)" >&2' ERR
fi

trap DEBUG: Depuración quirúrgica

DEBUG con pila completa

#!/bin/bash

trap 'echo "[DEBUG] Ln $LINENO | Func: ${FUNCNAME[@]:-principal} | Cmd: $BASH_COMMAND"' DEBUG

func_a() {
    local x=10
    func_b
}

func_b() {
    echo "Hola"
}

func_a

Esto muestra no solo la línea y el comando, sino toda la pila de llamadas.

DEBUG condicional por tramos

Puedes activar y desactivar trap DEBUG dinámicamente:

#!/bin/bash

debug_on() {
    trap 'echo "[DEBUG] $LINENO: $BASH_COMMAND"' DEBUG
}

debug_off() {
    trap '' DEBUG
}

echo "Sección sin debug..."
debug_on
echo "Sección con debug"
x=$(( 2 + 2 ))
echo "x = $x"
debug_off
echo "Vuelta a modo normal..."

trap RETURN: Rastreo de funciones

trap ... RETURN se ejecuta cada vez que una función retorna. Es ideal para logging de entrada/salida de funciones:

#!/bin/bash

trap 'echo "[RETURN] Saliendo de ${FUNCNAME[0]} (línea $LINENO)"' RETURN

saludar() {
    echo "Hola"
}

sumar() {
    local total=$(( $1 + $2 ))
    return $total
}

saludar
sumar 5 3
echo "Hecho"

Cada return dispara el trap, incluyendo el retorno implícito al final del script. Puedes usar esto para construir un perfilador de llamadas:

#!/bin/bash
declare -A TIEMPOS
INICIO=$(date +%s%N)

trap 'TIEMPOS["${FUNCNAME[1]:-main}"]=$(( $(date +%s%N) - INICIO ))' RETURN

func1() { sleep 0.2; }
func2() { sleep 0.3; }

func1
func2

for f in "${!TIEMPOS[@]}"; do
    echo "$f: $(( TIEMPOS[$f] / 1000000 ))ms"
done

Ignorar señales (trap » SIGNAL)

Puedes hacer que tu script ignore una señal completamente con una cadena vacía:

#!/bin/bash

# Ignora Ctrl+C durante una operación crítica
trap '' SIGINT

echo "Operación crítica en marcha — Ctrl+C no funciona"
sleep 5

# Restablece Ctrl+C al terminar
trap - SIGINT

echo "Ya puedes usar Ctrl+C de nuevo"
sleep 5

¿Para qué sirve ignorar señales? Para operaciones atómicas (escribir un archivo de configuración sin que te interrumpan), bloqueo de cancelación (mientras adquieres un lock), o sesiones de red (mientras envías datos críticos).

No puedes ignorar SIGKILL (9) ni SIGSTOP (19). El kernel no permite capturarlas ni ignorarlas. Esa es su función: ser el último recurso.

Restablecer señales (trap – SIGNAL)

Para devolver una señal a su comportamiento por defecto, usas trap - SEÑAL:

#!/bin/bash

# Capturo SIGINT para hacer limpieza
trap 'echo "Limpiando..."; rm -f /tmp/*.tmp; exit 1' SIGINT

sleep 10 &
PID=$!
wait $PID

# Ya no necesito capturar SIGINT, restablezco
trap - SIGINT

echo "A partir de aquí Ctrl+C mata directamente"
sleep 10

Propagación de señales en subshells

Una de las confusiones más comunes: los subshells heredan los traps del shell padre, pero con independencia.

#!/bin/bash

trap 'echo "Padre: recibí SIGINT"' SIGINT

# Este subshell NO hereda el trap
(
    echo "Subshell ejecutándose..."
    sleep 5
    echo "Subshell terminó"
) &

En este ejemplo, el subshell en background no hereda los traps del padre. Si quieres que un subshell tenga un manejador, debes instalarlo dentro de él.

Señales en procesos hijos vs. padre

Cuando haces comando & y luego matas al padre, los hijos en background no reciben la señal automáticamente. Para matarlos con el padre, necesitas un grupo de procesos:

#!/bin/bash
set -euo pipefail

trap 'echo "Terminando grupo..."; kill 0; exit' SIGINT SIGTERM

# Lanzar hijos en el mismo grupo de procesos
{
    while true; do echo "Hijo 1 trabajando..."; sleep 2; done
} &

{
    while true; do echo "Hijo 2 trabajando..."; sleep 3; done
} &

echo "Padre esperando. PID: $$"
wait

El comando kill 0 envía la señal a todos los procesos del mismo grupo.

El comando kill

El comando kill envía señales a procesos. Su nombre es engañoso: no solo mata, puede enviar cualquier señal.

# Sintaxis básica
kill [-SEÑAL] PID

# Enviar SIGTERM (la señal por defecto)
kill 1234

# Enviar señal específica
kill -SIGTERM 1234
kill -TERM 1234
kill -15 1234

# Las tres formas anteriores son equivalentes

Objetivos especiales

kill -TERM 0         # Envía a TODO el grupo de procesos actual
kill -TERM -1        # Envía a todos los procesos que puedas (¡cuidado!)
kill -TERM -1234     # Envía a todo el grupo de procesos 1234

kill con nombres de proceso

# Matar por nombre
kill $(pgrep -f "mi_demonio")
pkill -f "mi_demonio"        # Más directo
killall mi_demonio           # Mata todas las instancias

Verificar si un proceso existe

if kill -0 1234 2>/dev/null; then
    echo "El proceso 1234 existe"
else
    echo "El proceso 1234 NO existe"
fi

kill -0 no envía señal real; solo comprueba si el proceso existe y tienes permiso para señalarle.

Trap en funciones: ámbito y alcance

Los traps instalados dentro de una función afectan a todo el script, no solo a la función. Esto es diferente al ámbito de las variables locales:

#!/bin/bash

critico() {
    # Esto NO es local — afecta a todo el script
    trap '' SIGINT
    echo "Sección crítica..."
    sleep 5
    trap - SIGINT  # Restablece
}

echo "Ctrl+C funciona aquí"
critico
echo "Y aquí también"

Patrón seguro: guardar y restaurar

Para evitar efectos laterales, guarda el estado anterior del trap y restáuralo:

#!/bin/bash

funcion_con_su_propio_trap() {
    local old_trap
    old_trap=$(trap -p SIGINT)  # Guardar estado actual
    trap 'echo "Trap local en función"' SIGINT

    echo "Dentro de la función..."
    sleep 3

    eval "$old_trap"             # Restaurar estado anterior
}

trap 'echo "Trap global"' SIGINT
echo "Trap global activo"
funcion_con_su_propio_trap
echo "Trap global de nuevo"

Coordinación entre scripts con SIGUSR1/SIGUSR2

Una de las aplicaciones más potentes de las señales es la coordinación entre procesos. Con SIGUSR1 y SIGUSR2 puedes construir sistemas de señalización sencillos sin necesidad de colas de mensajes.

Procesador con rotación de logs

Imagina un script que procesa archivos continuamente. Un script de monitorización externo le dice «rota el log» (SIGUSR1) o «muestra estadísticas» (SIGUSR2).

#!/bin/bash
# procesador.sh — Demonio de procesamiento

PID_FILE="/tmp/procesador.pid"
CONTADOR=0
ROTAR_LOG=false

rotar_log() {
    ROTAR_LOG=true
    echo "[$$] Recibida orden de rotar log"
}

mostrar_estadisticas() {
    echo "=== ESTADÍSTICAS ==="
    echo "Archivos procesados: $CONTADOR"
    echo "PID: $$"
    echo "Uptime: $(ps -o etime= -p $$)"
    echo "===================="
}

trap rotar_log SIGUSR1
trap mostrar_estadisticas SIGUSR2
trap 'echo "[$$] Terminando..."; exit 0' SIGTERM SIGINT

echo "$$" > "$PID_FILE"

echo "Procesador iniciado (PID: $$)"

while true; do
    ((CONTADOR++))
    echo "Procesando archivo $CONTADOR..."

    if $ROTAR_LOG; then
        echo "[$(date)] Rotando log..."
        ROTAR_LOG=false
    fi

    sleep 2
done

Y el script que lo controla:

#!/bin/bash
# controlador.sh

PID_FILE="/tmp/procesador.pid"

if [[ ! -f "$PID_FILE" ]]; then
    echo "El procesador no está en ejecución" >&2
    exit 1
fi

PID=$(cat "$PID_FILE")

case "${1:-}" in
    rotar)
        echo "Solicitando rotación de log..."
        kill -SIGUSR1 "$PID"
        ;;
    stats)
        echo "Solicitando estadísticas..."
        kill -SIGUSR2 "$PID"
        ;;
    stop)
        echo "Deteniendo procesador..."
        kill -SIGTERM "$PID"
        ;;
    *)
        echo "Uso: $0 {rotar|stats|stop}"
        exit 1
        ;;
esac

Este patrón de script + controlador es la base de muchos sistemas de servicios en producción.

Patrón timeout con señal

Una aplicación clásica de SIGALRM es implementar un timeout para comandos que podrían colgarse:

#!/bin/bash

timeout_exec() {
    local timeout=$1
    local cmd=("${@:2}")

    (
        # Lanzar el comando en background
        "${cmd[@]}" &
        CMD_PID=$!

        # Sleep en paralelo como "alarma"
        (
            sleep "$timeout"
            kill -TERM "$CMD_PID" 2>/dev/null
        ) &
        SLEEP_PID=$!

        # Esperar al comando
        wait "$CMD_PID" 2>/dev/null
        CMD_EXIT=$?

        # Matar el sleep si el comando terminó antes
        kill "$SLEEP_PID" 2>/dev/null
        exit "$CMD_EXIT"
    )
}

Uso:

if timeout_exec 5 ping -c 1 google.com; then
    echo "Ping exitoso"
else
    echo "Ping timeout o falló"
fi

SIGHUP y recarga de configuración

La señal SIGHUP es la señal tradicional de «recarga tu configuración». Los demonios profesionales (nginx, sshd) capturan SIGHUP para releer sus archivos de configuración sin reiniciar.

#!/bin/bash
# servidor_config.sh — Demonio que recarga configuración con SIGHUP

CONFIG_FILE="servidor.conf"
declare -A CONFIG
PID_FILE="/tmp/servidor_config.pid"
RUNNING=true

cargar_configuracion() {
    source "$CONFIG_FILE" 2>/dev/null || {
        echo "[ERROR] No se pudo cargar $CONFIG_FILE" >&2
        return 1
    }
    echo "[$(date)] Configuración cargada: PUERTO=$PUERTO, LOG_LEVEL=$LOG_LEVEL, MAX_CONN=$MAX_CONN"
}

recargar_config() {
    echo "[$(date)] Recibido SIGHUP — Recargando configuración..."
    cargar_configuracion
}

terminar() {
    echo "[$(date)] Terminando servidor..."
    RUNNING=false
}

trap recargar_config SIGHUP
trap terminar SIGTERM SIGINT
trap 'rm -f "$PID_FILE"' EXIT

# Configuración por defecto
PUERTO=8080
LOG_LEVEL="info"
MAX_CONN=100

cargar_configuracion
echo "$$" > "$PID_FILE"

echo "Servidor iniciado (PID: $$)"
echo "Recarga: kill -SIGHUP $$"

while $RUNNING; do
    echo "[$(date)] Procesando en puerto $PUERTO (level=$LOG_LEVEL, max=$MAX_CONN)..."
    sleep 3
done

Y el archivo de configuración (servidor.conf):

# servidor.conf
PUERTO=9090
LOG_LEVEL="debug"
MAX_CONN=200

Para probarlo:

chmod +x servidor_config.sh
./servidor_config.sh &
# Edita servidor.conf cambiando valores
kill -SIGHUP $(cat /tmp/servidor_config.pid)
kill -SIGTERM $(cat /tmp/servidor_config.pid)

Patrón demonio con manejo de señales

Los demonios en Bash requieren un conjunto completo de prácticas de señalización para ser fiables ante cualquier interrupción. Te recomiendo usar esta plantilla como base:

#!/bin/bash
# plantilla_demonio.sh — Plantilla de demonio profesional

set -euo pipefail

NOMBRE="$(basename "$0")"
PID_FILE="/tmp/${NOMBRE}.pid"
LOG_FILE="/tmp/${NOMBRE}.log"
RUNNING=true
PAUSA=5
CONTADOR=0

log() {
    echo "[$(date +'%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE"
}

limpiar_pid() {
    rm -f "$PID_FILE"
}

recargar() {
    log "Recibida señal de recarga (SIGHUP)"
    log "Configuración recargada"
}

mostrar_estado() {
    log "=== ESTADO ==="
    log "PID: $$"
    log "Contador: $CONTADOR"
    log "Tiempo activo: $(ps -o etime= -p $$ 2>/dev/null || echo 'N/A')"
    log "=============="
}

terminar() {
    log "Recibida señal de terminación (${1:-})"
    RUNNING=false
}

# Configurar manejadores
trap 'terminar SIGTERM' SIGTERM
trap 'terminar SIGINT' SIGINT
trap 'terminar SIGQUIT' SIGQUIT
trap recargar SIGHUP
trap mostrar_estado SIGUSR1
trap 'log "Debug activado" ; set -x' SIGUSR2
trap limpiar_pid EXIT

# Crear PID file
echo "$$" > "$PID_FILE"
log "Demonio $NOMBRE iniciado (PID: $$)"

# Bucle principal
while $RUNNING; do
    ((CONTADOR++))
    log "Iteración $CONTADOR — trabajando..."

    # Simular trabajo
    sleep "$PAUSA"
done

log "Demonio finalizado normalmente (total iteraciones: $CONTADOR)"

Limpieza de archivos temporales y locks con trap EXIT

El patrón definitivo para scripts que crean archivos temporales, locks y recursos efímeros:

#!/bin/bash
# limpieza_segura.sh

set -euo pipefail

# Arrays para rastrear recursos creados
declare -a ARCHIVOS_TEMP
declare -a DIRECTORIOS_TEMP
declare -a LOCKS_CREADOS

crear_temp() {
    local tmp
    tmp=$(mktemp /tmp/miscript_XXXXXX)
    ARCHIVOS_TEMP+=("$tmp")
    echo "$tmp"
}

crear_dir_temp() {
    local dir
    dir=$(mktemp -d /tmp/miscript_dir_XXXXXX)
    DIRECTORIOS_TEMP+=("$dir")
    echo "$dir"
}

adquirir_lock() {
    local lockfile=$1
    if ! mkdir "$lockfile" 2>/dev/null; then
        echo "Error: no se pudo adquirir lock $lockfile (ya existe PID $(cat "$lockfile/pid" 2>/dev/null))" >&2
        return 1
    fi
    echo "$$" > "$lockfile/pid"
    LOCKS_CREADOS+=("$lockfile")
    echo "$lockfile"
}

cleanup_completo() {
    local exit_code=$?
    echo "[$(date)] Iniciando limpieza global (código de salida: $exit_code)..."

    # Liberar locks
    for lock in "${LOCKS_CREADOS[@]}"; do
        if [[ -d "$lock" ]]; then
            rm -rf "$lock"
            echo "  Lock liberado: $lock"
        fi
    done

    # Borrar directorios temporales
    for dir in "${DIRECTORIOS_TEMP[@]}"; do
        if [[ -d "$dir" ]]; then
            rm -rf "$dir"
            echo "  Directorio temporal borrado: $dir"
        fi
    done

    # Borrar archivos temporales
    for tmp in "${ARCHIVOS_TEMP[@]}"; do
        if [[ -f "$tmp" ]]; then
            rm -f "$tmp"
            echo "  Archivo temporal borrado: $tmp"
        fi
    done

    echo "[$(date)] Limpieza completada"
    exit "$exit_code"
}

trap cleanup_completo EXIT
trap 'echo "Señal recibida — saliendo ordenadamente..."; exit 1' SIGINT SIGTERM SIGHUP

# === USO ===
LOCK=$(adquirir_lock "/tmp/miscript.lock")
TEMP1=$(crear_temp)
TEMP2=$(crear_temp)
DIR1=$(crear_dir_temp)

echo "Recursos creados:"
echo "  Lock: $LOCK"
echo "  Temp1: $TEMP1"
echo "  Temp2: $TEMP2"
echo "  Dir: $DIR1"

# Simular trabajo
echo "Escribiendo datos temporales..."
echo "datos importantes" > "$TEMP1"
echo "más datos" > "$TEMP2"

# Simular error
if (( RANDOM % 3 == 0 )); then
    echo "Error simulado..."
    exit 1
fi

echo "Script completado con éxito"

Este patrón es a prueba de balas: tanto si el script termina bien, mal, o lo matan con SIGTERM/SIGINT/SIGHUP, los recursos se limpian.

Funciones profesionales

setup_cleanup: Registro centralizado de limpieza

# Registra un callback de limpieza que se ejecuta al salir
declare -a _CLEANUP_HOOKS

setup_cleanup() {
    local hook=$1
    _CLEANUP_HOOKS+=("$hook")
}

_run_cleanup_hooks() {
    local exit_code=$?
    for hook in "${_CLEANUP_HOOKS[@]}"; do
        $hook "$exit_code" 2>/dev/null || true
    done
}

trap _run_cleanup_hooks EXIT

# Uso:
# setup_cleanup 'rm -f /tmp/mi_archivo'
# setup_cleanup 'my_cleanup_function'

trap_error_handler: Manejador de errores completo

trap_error_handler() {
    local line=$1
    local cmd=$2
    local code=$3
    local func="${FUNCNAME[1]:-main}"

    echo "[ERROR] $(date)" >&2
    echo "[ERROR] Script: $0" >&2
    echo "[ERROR] Línea: $line" >&2
    echo "[ERROR] Función: $func" >&2
    echo "[ERROR] Comando: $cmd" >&2
    echo "[ERROR] Código: $code" >&2
    echo "[ERROR] Pila: ${FUNCNAME[@]}" >&2
}

trap 'trap_error_handler $LINENO "$BASH_COMMAND" $?' ERR

signal_handler: Instalación segura de múltiples señales

signal_handler() {
    local handler=$1
    shift
    local signal

    for signal in "$@"; do
        trap "$handler" "$signal"
    done
}

# Uso:
# signal_handler 'echo "Adiós"; exit' SIGINT SIGTERM SIGQUIT
# signal_handler recargar_config SIGHUP

timeout_exec: Ejecución con timeout

timeout_exec() {
    local timeout=$1
    shift
    local -a cmd=("$@")
    local exit_code=0

    (
        "${cmd[@]}" &
        local pid=$!
        trap 'kill "$pid" 2>/dev/null; exit 124' SIGALRM
        (sleep "$timeout" && kill -ALRM $$ 2>/dev/null) &
        wait "$pid" 2>/dev/null
        exit $?
    )

    return $?
}

# Uso:
# if timeout_exec 5 ping -c 1 8.8.8.8; then
#     echo "Respuesta recibida"
# else
#     echo "Timeout"
# fi

Script completo: servicio_monitoreo.sh

Este script implementa un demonio de monitoreo completo que maneja SIGTERM (terminación ordenada), SIGHUP (recarga de configuración), SIGUSR1 (generar informe inmediato), SIGUSR2 (rotar log) y usa trap EXIT para limpieza garantizada.

#!/bin/bash
# servicio_monitoreo.sh — Demonio de monitoreo con manejo completo de señales

set -euo pipefail

# === Configuración por defecto ===
NOMBRE="servicio_monitoreo"
DIR_MONITOREO="/tmp/monitor"
LOG_DIR="/var/log/${NOMBRE}"
LOG_FILE="${LOG_DIR}/${NOMBRE}.log"
PID_FILE="/tmp/${NOMBRE}.pid"
CONFIG_FILE="/etc/${NOMBRE}.conf"
INTERVALO=5
MAX_ARCHIVOS=100
DEBUG=false

# === Variables de estado ===
RUNNING=true
CONTADOR_PROCESADOS=0
CONTADOR_ERRORES=0
ARCHIVOS_ENCONTRADOS=0
FECHA_INICIO=$(date '+%Y-%m-%d %H:%M:%S')
ROTAR_LOG=false
declare -a ARCHIVOS_PROCESADOS

# === Funciones ===

log() {
    local nivel=$1
    shift
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] [${nivel}] [PID:$$] $*" >> "$LOG_FILE"

    if [[ "$nivel" == "ERROR" ]] || [[ "${DEBUG:-false}" == "true" ]]; then
        echo "[${nivel}] $*" >&2
    fi
}

cargar_configuracion() {
    if [[ -f "$CONFIG_FILE" ]]; then
        source "$CONFIG_FILE"
        log "INFO" "Configuración cargada: INTERVALO=$INTERVALO, MAX_ARCHIVOS=$MAX_ARCHIVOS, DEBUG=$DEBUG"
    else
        log "WARN" "Archivo de configuración $CONFIG_FILE no encontrado. Usando valores por defecto."
    fi
}

inicializar_directorios() {
    mkdir -p "$DIR_MONITOREO" "$LOG_DIR"
}

limpiar_recursos() {
    local exit_code=$?
    log "INFO" "Iniciando limpieza (código de salida: $exit_code)..."

    if [[ -f "$PID_FILE" ]]; then
        rm -f "$PID_FILE"
        log "INFO" "PID file eliminado"
    fi

    if $ROTAR_LOG; then
        log "INFO" "Rotación de log pendiente completada"
    fi

    log "INFO" "Limpieza completada. Archivos procesados: $CONTADOR_PROCESADOS"
}

generar_informe() {
    local tiempo_activo
    tiempo_activo=$(ps -o etime= -p $$ 2>/dev/null || echo "N/A")

    {
        echo "=========================================="
        echo "INFORME DEL SERVICIO DE MONITOREO"
        echo "=========================================="
        echo "PID:                $$"
        echo "Inicio:             $FECHA_INICIO"
        echo "Tiempo activo:      $tiempo_activo"
        echo "Archivos evaluados: $ARCHIVOS_ENCONTRADOS"
        echo "Archivos procesados: $CONTADOR_PROCESADOS"
        echo "Errores:            $CONTADOR_ERRORES"
        echo "Directorio monitor: $DIR_MONITOREO"
        echo "Archivos recientes:"
        for f in "${ARCHIVOS_PROCESADOS[@]: -5}"; do
            echo "  - $f"
        done
        echo "=========================================="
    } | tee -a "$LOG_FILE"
}

procesar_archivo() {
    local archivo=$1
    local nombre_base
    nombre_base=$(basename "$archivo")

    log "INFO" "Procesando archivo: $nombre_base"

    if wc -l "$archivo" > "/dev/null" 2>&1; then
        local lineas
        lineas=$(wc -l < "$archivo")
        log "INFO" "Archivo $nombre_base procesado: $lineas líneas"
        ARCHIVOS_PROCESADOS+=("$nombre_base")
        return 0
    else
        log "ERROR" "No se pudo procesar $nombre_base"
        return 1
    fi
}

rotar_log_ahora() {
    if [[ -f "$LOG_FILE" ]]; then
        local backup="${LOG_FILE}.$(date '+%Y%m%d%H%M%S')"
        cp "$LOG_FILE" "$backup"
        : > "$LOG_FILE"  # Vaciar el log original
        log "INFO" "Log rotado: $backup"
    fi
}

mostrar_estadisticas() {
    generar_informe
}

recargar_config() {
    log "INFO" "SIGHUP recibido — Recargando configuración"
    cargar_configuracion
}

terminar() {
    local signal=$1
    log "INFO" "Señal $signal recibida. Finalizando servicio..."
    RUNNING=false
}

tarea_periodica() {
    # Esta función se ejecuta en cada iteración del bucle principal
    log "DEBUG" "Explorando $DIR_MONITOREO..."

    local encontrados=0
    while IFS= read -r -d '' archivo; do
        ((ARCHIVOS_ENCONTRADOS++))
        if (( ARCHIVOS_ENCONTRADOS > MAX_ARCHIVOS )); then
            log "WARN" "Límite de archivos alcanzado ($MAX_ARCHIVOS)"
            break
        fi

        if procesar_archivo "$archivo"; then
            ((CONTADOR_PROCESADOS++))
        else
            ((CONTADOR_ERRORES++))
        fi

        encontrados=$(( encontrados + 1 ))
    done < <(find "$DIR_MONITOREO" -maxdepth 1 -type f -name "*.txt" -print0 2>/dev/null)

    if (( encontrados > 0 )); then
        log "INFO" "Iteración completada: $encontrados archivos nuevos"
    fi

    if $ROTAR_LOG; then
        rotar_log_ahora
        ROTAR_LOG=false
    fi
}

# === Configuración de señales ===

trap 'terminar SIGTERM' SIGTERM
trap 'terminar SIGINT' SIGINT
trap recargar_config SIGHUP
trap 'ROTAR_LOG=true' SIGUSR1
trap mostrar_estadisticas SIGUSR2
trap limpiar_recursos EXIT

# === Inicialización ===

# Cargar configuración
if [[ "$1" == "-c" && -n "${2:-}" ]]; then
    CONFIG_FILE="$2"
    shift 2
fi

cargar_configuracion
inicializar_directorios

# Verificar que no haya otra instancia
if [[ -f "$PID_FILE" ]]; then
    local old_pid
    old_pid=$(cat "$PID_FILE")
    if kill -0 "$old_pid" 2>/dev/null; then
        log "ERROR" "Ya hay una instancia ejecutándose (PID: $old_pid)"
        exit 1
    else
        log "WARN" "PID file obsoleto eliminado (PID: $old_pid ya no existe)"
    fi
fi

echo "$$" > "$PID_FILE"

log "INFO" "=== Servicio de Monitoreo iniciado ==="
log "INFO" "Directorio: $DIR_MONITOREO"
log "INFO" "Intervalo: ${INTERVALO}s"
log "INFO" "Log: $LOG_FILE"
log "INFO" "PID: $$"
log "INFO" "Comandos de control:"
log "INFO" "  kill -SIGTERM $$   — Detener"
log "INFO" "  kill -SIGHUP $$    — Recargar config"
log "INFO" "  kill -SIGUSR1 $$   — Rotar log"
log "INFO" "  kill -SIGUSR2 $$   — Estadísticas"

# Crear archivo de ejemplo para probar
echo "archivo de prueba" > "${DIR_MONITOREO}/ejemplo_inicial.txt"

# === Bucle principal ===
while $RUNNING; do
    tarea_periodica
    sleep "$INTERVALO"
done

log "INFO" "Servicio detenido. Total procesados: $CONTADOR_PROCESADOS, errores: $CONTADOR_ERRORES"
exit 0

Errores comunes

Error 1: Olvidar que EXIT se ejecuta SIEMPRE

# Doble mensaje de salida
trap 'echo "Terminado"' EXIT
trap 'echo "Terminado por señal"; exit 1' SIGTERM

# Un solo mensaje — EXIT maneja la limpieza, SIGTERM solo pide salida
trap 'limpiar; echo "Adiós"' EXIT
trap 'exit 1' SIGTERM SIGINT

Cuando pones exit dentro de un trap de señal, y también tienes un trap ... EXIT, ambos se ejecutan. No dupliques mensajes ni limpiezas.

Error 2: Asumir que los traps se heredan en subshells

# Este subshell no tiene el trap
trap 'echo "Adiós"' EXIT
( sleep 5; echo "Subshell termina sin el trap" )

Error 3: Usar variables globales sin proteger en señales

# Posible condición de carrera
CONTADOR=0
trap '((CONTADOR++))' SIGUSR1

# Si SIGUSR1 llega mientras se modifica CONTADOR en otro lugar, hay race condition

Error 4: No restablecer traps después de ignorar una señal

# Ignoras SIGINT para toda la ejecución
trap '' SIGINT
# ... mucho código ...
# Olvidas restablecer: Ctrl+C nunca funciona

# Bloque seguro
trap '' SIGINT
# sección crítica
trap - SIGINT  # Restableces

Error 5: Capturar SIGKILL o SIGSTOP

# No funciona: SIGKILL no se puede capturar
trap 'echo "No me mates"' SIGKILL
trap '' SIGKILL

kill -9 $$  # Mata igualmente

Error 6: Asumir que kill -9 es lo mismo que kill -15

kill 1234     # envía SIGTERM — el proceso puede capturarlo y limpiar
kill -9 1234  # SIGKILL — no puede capturarse, el proceso muere al instante sin limpiar

Siempre prefiere kill (SIGTERM) primero. Guarda SIGKILL como último recurso.

Error 7: No manejar SIGPIPE en tuberías largas

# Si el pipe se rompe, el script puede morir silenciosamente
comando_producir_datos | head -n 5
# comando_producir_datos recibe SIGPIPE cuando head cierra el pipe

# Manejar SIGPIPE
trap '' SIGPIPE  # O capturarlo y registrar

Error 8: No usar kill -0 para verificar existencia de procesos

# Propenso a errores si el PID se recicla
if ps -p "$PID" > /dev/null 2>&1; then
    echo "Proceso existe"
fi

# kill -0 es más eficiente y correcto
if kill -0 "$PID" 2>/dev/null; then
    echo "Proceso existe"
fi

Error 9: Hacer demasiado dentro del manejador de señal

# Demasiado código en el manejador
trap 'rm -rf /tmp/*; sync; kill 0; logger "Adiós"; exit 1' SIGTERM

# Mínimo: solo cambiar una flag
RUNNING=true
trap 'RUNNING=false' SIGTERM

# El bucle principal comprueba la flag y hace la limpieza real

Los manejadores de señal deberían ser rápidos y simples. No hagas E/S pesada ni operaciones largas dentro del trap. Usa una flag para señalizar y maneja la lógica en el bucle principal.

Tabla resumen de señales

  • SIGINT (2): Capturable. Capturar Ctrl+C para preguntar confirmación o limpiar.
  • SIGTERM (15): Capturable. Petición de terminación educada (por defecto en kill).
  • SIGHUP (1): Capturable. Recarga de configuración sin reiniciar.
  • SIGUSR1 (10): Capturable. Señal de usuario libre (rotar log, acción personalizada).
  • SIGUSR2 (12): Capturable. Señal de usuario libre (estadísticas, acción personalizada).
  • SIGQUIT (3): Capturable. Terminación forzada con core dump (Ctrl+\).
  • SIGPIPE (13): Capturable. Escribir en pipe roto.
  • SIGALRM (14): Capturable. Timeout / alarma temporizada.
  • SIGKILL (9): No capturable. Último recurso.
  • SIGSTOP (19): No capturable. Pausa forzosa.

Tabla resumen de tipos de trap

  • trap 'código' SEÑAL: Cuando llega la señal. Manejadores personalizados.
  • trap 'código' EXIT: Cuando el script termina (por cualquier razón). Limpieza garantizada.
  • trap 'código' ERR: Cuando un comando devuelve error (excepto en if/while/until/&&/||). Captura de errores.
  • trap 'código' DEBUG: Antes de cada comando. Depuración paso a paso.
  • trap 'código' RETURN: Cuando una función retorna. Perfilado, logging de funciones.
  • trap '' SEÑAL: Nunca (ignora la señal). Secciones críticas no interrumpibles.
  • trap - SEÑAL: Comportamiento por defecto. Restablecer señal tras ignorarla.

Conclusión

Las señales y los traps son el sistema nervioso de tus scripts. Sin ellos, tu script es una caja negra que solo sabe ejecutar código de principio a fin. Con ellos, tu script reacciona al entorno se limpia cuando lo matan, recarga configuración sin reiniciar, se coordina con otros procesos, y respeta al usuario que pulsa Ctrl+C.

Principios clave:

  1. EXIT es tu red de seguridad: instala siempre un trap ... EXIT para limpiar recursos. Cubre todos los casos de salida.
  2. Señales específicas para flujo: usa SIGTERM para detener, SIGHUP para recargar, SIGUSR1/SIGUSR2 para acciones personalizadas.
  3. Manejadores ligeros: el código del trap debe ser mínimo. Usa una flag booleana y maneja la lógica real en el bucle principal.
  4. SIGKILL no se captura: no intentes trap 'algo' SIGKILL. No funciona.
  5. Guarda y restaura traps: si cambias un trap dentro de una función, guarda el estado anterior con trap -p SEÑAL y restáuralo al salir.
  6. Los subshells no heredan traps: instala traps explícitamente dentro de subshells si los necesitas.
  7. Usa kill -0 para comprobar si un proceso existe sin enviarle señal.

Las señales transforman tu script de un programa secuencial a un sistema reactivo. El salto cualitativo es enorme: dejas de escribir scripts que «se ejecutan y terminan» y empiezas a escribir procesos que «viven, responden y se comunican». Eso es lo que separa un script avanzado de uno profesional.

En el próximo capítulo veremos cómo consumir APIs REST desde Bash. Te enseñaré a consumir servicios web, procesar JSON y automatizar interacciones con tus servicios favoritos directamente desde el shell.


Más información.

Páginas de manual

  • signal(7) — Lista completa de señales en Linux
  • sigaction(2) — Llamada al sistema subyacente a trap
  • kill(1) — El comando kill
  • bash(1) — SIGNALS — Sección de señales en el manual de Bash

Enlaces externos

Deja una respuesta