20

Redirecciones en Bash. Domina el Flujo de Datos

Vistas: 36
Tutorial: Scripts en Bash
Redirecciones en Bash. Domina el Flujo de Datos

En el capítulo anterior viste cómo las expresiones regulares te permiten buscar y transformar texto con una precisión casi quirúrgica. Pero te propongo una reflexión: ¿de qué sirve buscar patrones si no controlas de dónde vienen los datos ni adónde van? La respuesta a esa pregunta son las redirecciones. Cada proceso en Linux, incluidos tus scripts de Bash, nace con tres canales de comunicación abiertos: la entrada estándar, la salida estándar y la salida de error. Durante años yo usaba Bash sin prestar atención a esto. Escribía comando > archivo porque funcionaba, pero sin entender realmente qué estaba pasando. Y claro, cuando algo fallaba, no sabía por qué. La cuestión es que las redirecciones son el mecanismo que tiene Bash para controlar de dónde vienen los datos y adónde van. Puedes redirigir la entrada de un comando para que no venga del teclado, sino de un archivo. Puedes redirigir su salida para que no vaya a la pantalla, sino a un archivo, a otro comando o incluso a un descriptor que tú mismo crees.

Los descriptores de archivo (file descriptors, FD) son números enteros que representan canales de E/S abiertos. Piensa en ellos como manijas que tu script agarra para leer o escribir datos. Los tres primeros (0, 1, 2) ya vienen asignados; los demás (del 3 al 9 y más allá con {var}) los abres tú.

Cuando entiendes las redirecciones en profundidad, tu forma de escribir scripts cambia por completo. Dejas de ver la entrada y la salida como algo fijo y empiezas a manipularlas con la misma libertad con la que manipulas variables.

Los tres descriptores estándar

Todo proceso Unix recibe tres descriptores al nacer:

  • 0: stdin — Entrada estándar (teclado) — Lectura
  • 1: stdout — Salida estándar (pantalla) — Escritura
  • 2: stderr — Salida de error (pantalla) — Escritura

Aunque los tres apunten al mismo sitio (la terminal) en una sesión interactiva, son canales independientes. Esto significa que puedes redirigir uno sin afectar a los otros.

# stdout va a un archivo, stderr sigue yendo a la terminal
ls /exists /no-existe > salida.txt
# 'ls /no-existe: No such file or directory' se muestra en pantalla

Esa independencia es la base de todo el sistema de logging y depuración que construiremos más adelante. Te puedo asegurar que una vez que interiorizas esto, empiezas a ver los scripts de otra manera. Yo recuerdo la primera vez que entendí que podía tener stdout y stderr yendo a sitios distintos… me pareció magia. Y no lo es: es diseño del sistema.

Concepto clave: cuando haces echo "hola" > archivo, Bash abre el archivo, lo asigna al descriptor 1 (stdout) del comando echo, y el comando escribe ahí. El echo nunca sabe que está escribiendo en un archivo; para él sigue siendo stdout.

Redirecciones básicas: >, >>, <

> — Redirigir salida (sobrescribe)

Conecta stdout del comando a un archivo, creándolo o sobrescribiéndolo:

echo "Primera línea" > salida.txt
echo "Segunda línea" > salida.txt
# salida.txt solo contiene "Segunda línea"

El truncado ocurre antes de que el comando se ejecute. Bash abre el archivo, lo vacía, y entonces lanza el comando.

>> — Redirigir salida (añade)

Igual que >, pero añade al final en lugar de sobrescribir:

echo "Línea 1" >> salida.txt
echo "Línea 2" >> salida.txt
# salida.txt contiene ambas líneas

< — Redirigir entrada

Conecta un archivo como entrada del comando:

wc -l < salida.txt        # Cuenta líneas del archivo
sort < desordenado.txt    # Ordena el contenido del archivo
grep "error" < log.txt    # Busca en el archivo

La diferencia entre wc -l salida.txt y wc -l < salida.txt es sutil pero real. En el primer caso el comando recibe el nombre del archivo y decide cómo leerlo. En el segundo, Bash se lo pasa ya abierto como stdin, y el comando ni siquiera sabe su nombre.

wc -l salida.txt    # Output: 10 salida.txt
wc -l < salida.txt  # Output: 10          (sin nombre de archivo)

Redirigir stderr: 2>, 2>&1, &>

El descriptor 2 (stderr) es independiente del 1 (stdout). Redirigirlo es exactamente igual, pero usando el número:

# Errores a un archivo, salida normal a otro
find / -name "*.conf" 2> errores.txt > aciertos.txt

# Errores a /dev/null (los descartas)
find / -name "*.conf" 2> /dev/null

# Ambos al mismo archivo (dos formas)
comando > salida.txt 2>&1
comando &> salida.txt       # Sintaxis abreviada (Bash >= 4)
comando >& salida.txt       # Forma aún más corta

El orden importa. Cuando ves comando > archivo 2>&1, el shell procesa las redirecciones de izquierda a derecha:

  1. > archivo — redirige stdout (1) a archivo
  2. 2>&1 — redirige stderr (2) al mismo sitio que stdout (1), que en ese momento ya es archivo

Si inviertes el orden, el resultado cambia. Y esto es una faena, porque es de esos errores que no dan mensaje, simplemente se comportan de forma incorrecta. Te puedo asegurar que más de una vez he estado media hora debugando un script pensando «pero si esto debería funcionar…» para descubrir que había puesto el 2>&1 antes que la redirección al archivo.

# Orden incorrecto
comando 2>&1 > archivo
# Paso 1: 2>&1 — stderr va al stdout original (terminal)
# Paso 2: > archivo — stdout va a archivo
# Resultado: stderr sale por terminal, stdout va a archivo

# Orden correcto
comando > archivo 2>&1
# Paso 1: > archivo — stdout va a archivo
# Paso 2: 2>&1 — stderr va a archivo (donde está stdout ahora)

Memorízala: primero el destino del 1, luego duplicas el 2 hacia el 1.

&> y >& — Atajos

Bash ofrece atajos que redirigen ambos a la vez y eliminan la confusión del orden:

comando &> archivo     # stdout + stderr → archivo
comando &>> archivo    # stdout + stderr → archivo (añadiendo)

Son equivalentes a > archivo 2>&1 pero en un solo token y sin ambigüedad.

Heredocs y herestrings

Here document (<<)

Un here document (heredoc) alimenta stdin de un comando con texto literal incluido en el propio script:

cat << EOF
Esto es un here document.
Puede tener varias líneas.
Las variables como $HOME se expanden.
EOF

El delimitador EOF puede ser cualquier palabra. Todo lo que hay entre las dos marcas se convierte en stdin del comando.

Comillas en el delimitador: si pones el delimitador entre comillas, las expansiones (variables, comandos) se desactivan:

cat << 'EOF'
$HOME no se expande
$(echo "esto tampoco")
EOF

Tabulador con <<-: si usas <<-, Bash ignora los tabuladores iniciales en cada línea del heredoc y en la línea del delimitador final:

if true; then
    cat <<- FIN
    Esta línea tiene un tabulador al inicio.
    El delimitador FIN también.
    FIN
fi

Esto te permite indentar heredocs dentro de bloques if, for, funciones, etc., manteniendo el código limpio.

Here string (<<<)

Un here string pasa una cadena como stdin del comando:

grep "error" <<< "$(journalctl -u nginx --since today)"
bc <<< "2 + 3 * 4"

Es equivalente a echo "cadena" | comando, pero sin crear un subshell y más eficiente.

Descriptores personalizados con exec

Hasta ahora hemos redirigido descriptores para un solo comando. Pero exec te permite redirigir para todo el resto del script. Es como cambiar el cableado eléctrico de tu casa: a partir de ese momento, todo lo que antes iba a un sitio ahora va a otro.

exec 3>/archivo — Abrir descriptor de escritura

exec 3> debug.txt
echo "Mensaje normal"           # stdout → terminal
echo "Mensaje debug" >&3       # va a debug.txt a través del FD 3
echo "Otro mensaje debug" >&3
exec 3>&-                       # Cierra el descriptor 3

El descriptor 3 es un tercer canal que apunta a debug.txt. Puedes escribir en él cuando quieras y cerrarlo cuando termines.

exec 3<&0 — Duplicar stdin

Guarda una copia del stdin original antes de redirigirlo:

exec 3<&0          # Guarda stdin original en FD 3
exec < datos.txt   # Redirige stdin a archivo
while read linea; do
    echo "Leído: $linea"
done
exec 0<&3          # Restaura stdin original
exec 3>&-          # Cierra FD 3

Este patrón te viene bien cuando un script necesita leer de un archivo pero luego volver a preguntar al usuario.

exec 5>&1 — Duplicar stdout

Guarda una referencia al stdout original para poder escribir en pantalla incluso después de redirigir todo:

exec 5>&1                      # Guarda stdout original en FD 5
exec > log_completo.txt        # Todo stdout va ahora al archivo

echo "Esto va al archivo"
echo "Esto también"

# Pero queremos mostrar algo en pantalla
echo "Progreso: 50%" >&5      # Esto sale por terminal gracias a FD 5

exec 1>&5                      # Restaura stdout
exec 5>&-                      # Cierra FD 5
echo "Esto vuelve a la terminal"

Este es el patrón que usan los scripts profesionales que escriben un log completo pero muestran progreso al usuario.

{var}>archivo — Asignación automática de descriptor

En Bash 4.1+, puedes dejar que Bash elija el número de descriptor:

exec {fd}>salida.log
echo "Hola" >&$fd
echo "Mundo" >&$fd
exec {fd}>&-          # Cierra el descriptor
echo "Descriptor usado: $fd"  # Muestra, ej: 10

Bash asigna el descriptor disponible más bajo (a partir del 10). Es más robusto que elegir números fijos, porque evita colisiones con otros descriptores abiertos.

Archivos especiales: /dev/* y /dev/tcp

/dev/null — El agujero negro

Todo lo que escribes en /dev/null desaparece. Es el sumidero universal:

comando > /dev/null           # Descarta stdout
comando 2> /dev/null          # Descarta errores
comando &> /dev/null          # Descarta todo

Usos típicos: silenciar salidas de comandos en scripts, descartar mensajes de advertencia que no te interesan, o probar solo el código de retorno.

/dev/zero y /dev/urandom

  • /dev/zero — produce infinitos bytes cero (útil para crear archivos vacíos de tamaño fijo)
  • /dev/urandom — produce bytes aleatorios (no bloqueante, suficiente para la mayoría de usos)
# Crear un archivo de 1 MB lleno de ceros
dd if=/dev/zero of=basura.bin bs=1024 count=1024

# Generar una contraseña aleatoria de 32 caracteres
tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 32

/dev/tcp/host/puerto — Conexiones TCP nativas

Esto te va a sorprender: Bash puede abrir conexiones TCP directamente, sin necesidad de curl, wget o nc. Lo sé, suena a ciencia ficción, pero funciona. La primera vez que lo vi en un script pensé que era un truco sucio, pero no: es una funcionalidad que viene compilada en Bash si tu distribución la incluye.

Y te puede ahorrar más de un disgusto. ¿Cuántas veces has estado en un servidor minimizado que no tiene curl y necesitas hacer un health check rápido? /dev/tcp al rescate.

# Leer la página principal de google.com
exec 3<>/dev/tcp/google.com/80
echo -e "GET / HTTP/1.0\r\nHost: google.com\r\n\r\n" >&3
cat <&3
exec 3>&-

Esta característica está disponible si Bash se compiló con soporte de red (--enable-net-redirections). En sistemas modernos suele estarlo.

# Health check simple
if exec 3<>/dev/tcp/localhost/8080 2>/dev/null; then
    echo "Servidor en puerto 8080 responde"
    exec 3>&-
else
    echo "Servidor caído o no responde" >&2
fi

/dev/tcp también soporta UDP con /dev/udp/host/puerto.

BASH_XTRACEFD — Trace a un descriptor propio

Cuando activas set -x (modo trace), Bash imprime cada comando antes de ejecutarlo. Por defecto va a stderr (descriptor 2). ¿Y qué problema hay con eso? Pues que si estás debugando un problema de producción, tu stderr se llena de trazas y no ves los errores reales. La primera vez que descubrí BASH_XTRACEFD pensé que era una broma. ¿Poder enviar el trace a un archivo aparte? Sí, existe, y es tan útil como parece.

Con BASH_XTRACEFD puedes redirigir ese trace a cualquier otro descriptor:

#!/bin/bash

# Abrimos un archivo de trace
exec 7> trace_depuracion.log

# Redirigimos el trace al FD 7
BASH_XTRACEFD=7

set -x  # Activa el modo trace

# A partir de aquí, todo el trace va a trace_depuracion.log
nombre="Lorenzo"
echo "Hola, $nombre"
for i in 1 2 3; do
    echo "Número $i"
done

set +x  # Desactiva el modo trace

exec 7>&-  # Cerramos el descriptor

Esto es increíblemente útil en producción porque:

  1. Separa el trace de los errores reales (ambos iban a stderr antes)
  2. Puedes tener un archivo de depuración gigante sin ensuciar la salida normal
  3. Si el archivo de trace no existe o hay problemas de permisos, Bash simplemente ignora el trace

Combinado con niveles de verbosidad:

if [[ "${DEBUG:-0}" -ge 2 ]]; then
    exec 7> /tmp/${0##*/}_trace.log
    BASH_XTRACEFD=7
    set -x
fi

Así activas el trace completo solo cuando DEBUG=2, y todo va limpio a su archivo.

Redirigir bucles, funciones y bloques completos

Puedes redirigir la entrada y salida de estructuras enteras, no solo de comandos individuales. Dale caña a esto, que es mucho más elegante que redirigir cada comando por separado.

Redirigir un bucle for

for archivo in *.txt; do
    wc -l "$archivo"
done > resumen.txt

Todo el stdout del bucle va a resumen.txt.

Redirigir un while read

while IFS= read -r linea; do
    echo "Procesando: $linea"
done < datos.txt > salida.txt 2> errores.txt

datos.txt alimenta stdin, el stdout va a salida.txt, y cualquier error a errores.txt.

Redirigir una función

mi_funcion() {
    echo "Inicio de función"
    ls /directorio_que_no_existe
    echo "Fin de función"
}

mi_funcion > salida.txt 2> errores.txt

La redirección se aplica a todo lo que la función hace internamente. Es una forma limpia de aislar la salida de un bloque lógico.

Bloque { } sin subshell

{
    echo "Paso 1"
    echo "Paso 2"
    echo "Paso 3"
} > bloque.log 2>&1

A diferencia de ( ), las llaves { } no crean un subshell, así que las variables que definas dentro persisten fuera.

Descriptores personalizados para logging

Combinando lo que hemos visto, puedes construir un sistema de logging que use diferentes descriptores para diferentes niveles:

#!/bin/bash

# Configuración de niveles
LOG_LEVEL=${LOG_LEVEL:-info}  # Valores: debug, info, warn, error

# Abrimos descriptores personalizados
exec 5> /tmp/script_debug.log     # Nivel DEBUG
exec 6> /tmp/script_info.log      # Nivel INFO
exec 7> /tmp/script_error.log     # Nivel ERROR

# Función de log multi-descriptor
log() {
    local nivel="$1"
    local mensaje="$2"
    local timestamp
    timestamp=$(date '+%Y-%m-%d %H:%M:%S')

    case "$nivel" in
        debug)
            [[ "$LOG_LEVEL" == "debug" ]] && echo "[$timestamp] [DEBUG] $mensaje" >&5
            ;;
        info)
            echo "[$timestamp] [INFO] $mensaje" >&6
            echo "[$timestamp] [INFO] $mensaje"   # También a stdout
            ;;
        warn)
            echo "[$timestamp] [WARN] $mensaje" >&6
            echo "[$timestamp] [WARN] $mensaje" >&2
            ;;
        error)
            echo "[$timestamp] [ERROR] $mensaje" >&7
            echo "[$timestamp] [ERROR] $mensaje" >&2
            ;;
    esac
}

log info "Script iniciado"
log debug "Variable X vale: $X"

# Al final, cerramos descriptores
exec 5>&- 6>&- 7>&-

Cada nivel de log tiene su propio archivo. Los mensajes DEBUG solo aparecen si LOG_LEVEL=debug. Los ERROR van a su archivo dedicado para revisión rápida.

tee con sustitución de procesos

tee parte la salida en dos: una va al archivo, la otra al stdout original. Combinado con la sustitución de procesos (>()), tee se vuelve una navaja suiza:

# Escribir log y comprimir al vuelo
./script_pesado | tee >(gzip -c > salida.log.gz) >(grep "ERROR" > errores.log)

Cada >() es un destino adicional. La salida del script va a:

  1. stdout (para verla en terminal)
  2. salida.log.gz comprimida
  3. errores.log solo con las líneas que contienen «ERROR»

Otro ejemplo clásico:

# Simular múltiples canales de log con tee y >()
./proceso_largo 2>&1 | tee >(grep -i "^error" > canal_errores.txt) \
                           >(grep -i "^warn" > canal_warns.txt) \
                           >(grep -iv "^(error|warn)" > canal_info.txt)

tee con >() es probablemente la forma más expresiva de bifurcar el flujo de datos en Bash.

Funciones profesionales

log_with_level — Log multi-nivel con descriptor dinámico

# Log con nivel de verbosidad y descriptor dinámico
# Uso: log_with_level <nivel> <mensaje>
# Niveles: DEBUG=0, INFO=1, WARN=2, ERROR=3
log_with_level() {
    local nivel="$1"
    local mensaje="$2"
    local verbosity="${VERBOSITY:-1}"
    local ts
    ts=$(date '+%Y-%m-%d %H:%M:%S')

    # Mapa numérico de niveles
    case "$nivel" in
        DEBUG) (( verbosity >= 0 )) && echo "[$ts] [DEBUG] $mensaje" >&5 ;;
        INFO)  (( verbosity >= 1 )) && echo "[$ts] [INFO] $mensaje" >&6
               (( verbosity >= 1 )) && echo "[$ts] [INFO] $mensaje" ;;
        WARN)  (( verbosity >= 2 )) && echo "[$ts] [WARN] $mensaje" >&6
               (( verbosity >= 2 )) && echo "[$ts] [WARN] $mensaje" >&2 ;;
        ERROR) (( verbosity >= 3 )) && echo "[$ts] [ERROR] $mensaje" >&7
               (( verbosity >= 3 )) && echo "[$ts] [ERROR] $mensaje" >&2 ;;
        *)     echo "[$ts] [????] $mensaje" >&2 ;;
    esac
}

# Configuración típica
VERBOSITY=2
exec 5>>/tmp/app_debug.log
exec 6>>/tmp/app_info.log
exec 7>>/tmp/app_error.log

log_with_level INFO "Aplicación iniciada"
log_with_level DEBUG "Config cargada: $(cat config.yml | wc -l) líneas"
log_with_level WARN "Espacio en disco bajo: 85%"
log_with_level ERROR "No se pudo conectar a la base de datos"

exec 5>&- 6>&- 7>&-

redirect_block — Ejecuta un bloque de código con redirecciones temporales

# Ejecuta un callback con redirecciones aisladas
# Uso: redirect_block <callback> <stdout_file> <stderr_file>
redirect_block() {
    local callback="$1"
    local stdout_file="${2:-/dev/null}"
    local stderr_file="${3:-/dev/null}"

    # Guardamos los descriptores originales
    exec 8>&1
    exec 9>&2

    # Redirigimos para el bloque
    exec > "$stdout_file"
    exec 2> "$stderr_file"

    # Ejecutamos el callback
    "$callback"

    # Restauramos
    exec 1>&8
    exec 2>&9
    exec 8>&-
    exec 9>&-
}

# Uso:
mi_proceso() {
    echo "Esto va al archivo"
    ls /no/existe
    echo "Esto también"
}

redirect_block mi_proceso /tmp/salida.txt /tmp/errores.txt

exec_isolated — Aísla un comando con descriptores limpios

# Ejecuta un comando con su propio juego de descriptores,
# garantizando que no hereda ninguno del padre excepto 0/1/2
# Uso: exec_isolated <comando> [args...]
exec_isolated() {
    if [[ $# -eq 0 ]]; then
        echo "Uso: exec_isolated <comando> [args...]" >&2
        return 1
    fi

    local cmd=("$@")

    # Abrimos /dev/null en descriptores del 3 al 9
    # para que el comando hijo no herede nada
    local fd
    for fd in {3..9}; do
        eval "exec ${fd}< /dev/null"
    done

    # Ejecutamos el comando
    "${cmd[@]}"
    local exit_code=$?

    # Cerramos los descriptores extra
    for fd in {3..9}; do
        eval "exec ${fd}<&-" 2>/dev/null
    done

    return $exit_code
}

# Uso: ejecuta un comando sin heredar nuestros FDs internos
# exec_isolated mi_script_hijo.sh

fd_logger — Logger dinámico con socket a descriptor

# Logger que recibe un número de descriptor como argumento
# y escribe en él con formato consistente
# Uso: fd_logger <fd> <severidad> <mensaje>
fd_logger() {
    local fd="$1"
    local severity="$2"
    shift 2
    local message="$*"
    local ts
    ts=$(date -u '+%Y-%m-%dT%H:%M:%SZ')

    if [[ ! -e "/proc/$$/fd/$fd" ]]; then
        echo "[$ts] [ERROR] [fd_logger] Descriptor $fd no está abierto" >&2
        return 1
    fi

    # Escribimos en el descriptor si está abierto y es válido
    echo "[$ts] [${severity}] $message" >&"$fd"
}

# Uso:
exec 5>/tmp/mi_app.log
fd_logger 5 "INFO" "Servicio iniciado"
fd_logger 5 "ERROR" "Fallo de conexión"
exec 5>&-

Script completo: gestor_logs_pro.sh

Este script profesional integra todo lo aprendido. Te muestro la estructura principal para que no te pierdas entre tanto código; el esquema es este:

#!/usr/bin/env bash
set -euo pipefail

VERBOSITY=1
MODO_DEBUG=false
LOG_DIR="/tmp"

_inicializar_descriptores() {
    local log_base="${LOG_DIR}/gestor_$$"
    exec 5>> "${log_base}_debug.log"
    exec 6>> "${log_base}_app.log"
    exec 7>> "${log_base}_error.log"
    exec 8>&1
    exec 9>&2
}

log() {
    local nivel="$1"; shift
    local ts; ts=$(date '+%Y-%m-%d %H:%M:%S')
    case "$nivel" in
        DEBUG) (( VERBOSITY <= 0 )) && echo "[$ts] [DEBUG] $*" >&5 ;;
        INFO)  (( VERBOSITY <= 1 )) && echo "[$ts] [INFO] $*" >&6
               (( VERBOSITY <= 1 )) && echo "[$ts] [INFO] $*" ;;
        WARN)  (( VERBOSITY <= 2 )) && echo "[$ts] [WARN] $*" >&6
               (( VERBOSITY <= 2 )) && echo "[$ts] [WARN] $*" >&2 ;;
        ERROR) (( VERBOSITY <= 3 )) && echo "[$ts] [ERROR] $*" >&7
               (( VERBOSITY <= 3 )) && echo "[$ts] [ERROR] $*" >&2 ;;
        FATAL) echo "[$ts] [FATAL] $*" >&7; echo "[$ts] [FATAL] $*" >&2; exit 1 ;;
    esac
}

_cleanup() { exec 5>&- 6>&- 7>&- 8>&- 9>&- 2>/dev/null; exit 1; }
trap '_cleanup' INT TERM

main() {
    _inicializar_descriptores
    log INFO "Script iniciado"
    # Aquí va tu lógica
    log INFO "Script completado"
}

Cómo usarlo:

chmod +x gestor_logs_pro.sh

# Ejecución normal
./gestor_logs_pro.sh

# Modo DEBUG completo (trace a archivo separado)
./gestor_logs_pro.sh -v 0 -d

# Solo errores
./gestor_logs_pro.sh -v 3

Cada nivel de log tiene su propio archivo y el trace de depuración va aparte.

Errores comunes

Error 1: Confundir >& con >

# ❌ Esto NO envía stderr a archivo
comando >& archivo

# ❌ Esto redirige stderr al stdout ACTUAL (no a archivo)
comando > archivo 2>&   # Falta el 1: debe ser 2>&1, no 2>&

# ✅ Formas correctas
comando > archivo 2>&1
comando &> archivo

>& sin número después es ambiguo. Bash interpreta comando >& archivo como comando > archivo 2>&1 solo si archivo no es un número.

⚠️ Atención, que esto es trampa mortal: si el archivo se llama 1, comando >&1 no redirige stdout y stderr a un archivo llamado 1, sino que redirige stdout adonde apunte el descriptor 1 (que suele ser la terminal). No es lo mismo >& archivo que >&1. El primero es un nombre de archivo, el segundo es un descriptor. Y Bash los distingue por el número. Aquí te puedes tirar un buen rato sin entender qué pasa.

Error 2: Cerrar el descriptor equivocado

# ❌ Cierra stdout, no el FD 3
exec 3>&1   # Duplica FD 1 en FD 3
exec 1>&-   # CIERRA stdout — el script se queda sin salida

# ✅ Cerrar FD 3 correctamente
exec 3>&-

Regla: &N significa «duplicar desde/hacia el descriptor N». &- significa «cerrar el descriptor».

Error 3: Fuga de descriptores

# ❌ Descriptor abierto pero nunca cerrado
abrir_log() {
    exec 5> /tmp/log.txt
}
abrir_log
# ... 500 líneas de código ...
# FD 5 sigue abierto hasta que el script termina

# ✅ Cerrar siempre
abrir_log() {
    exec 5> /tmp/log.txt
}
abrir_log
# ... código ...
exec 5>&-

Cada descriptor abierto consume recursos del kernel. En scripts largos o que se ejecutan miles de veces (cron, workers), las fugas se acumulan y pueden agotar el límite de descriptores del proceso (ulimit -n).

Error 4: Conflictos con tabuladores en heredocs

# ❌ Error si mezclas espacios y tabuladores
if true; then
    cat <<- EOF
    Línea con tabulador
    Línea con espacios  # Esto NO funciona con <<-
    EOF
fi

# ✅ Solución: todos tabuladores o todos espacios
if true; then
    cat <<- EOF
    Línea con tabulador
    Otra línea con tabulador
    EOF
fi

<<- solo elimina tabuladores, no espacios. Si tu editor inserta espacios (p.ej. expandtab en vim), el heredoc falla.

Error 5: Asumir que exec > archivo es temporal

# ❌ Después de este exec, TODO stdout va a archivo para SIEMPRE
exec > /tmp/log.txt
echo "Esto va al archivo"
# ... el resto del script no escribe en terminal nunca más

# ✅ Guardar y restaurar
exec 3>&1                    # Guardar stdout original
exec > /tmp/log.txt          # Redirigir
echo "Esto va al archivo"
exec 1>&3                    # Restaurar
exec 3>&-                    # Cerrar guardado

exec sin comando cambia las redirecciones del shell actual de forma permanente hasta que las cambies de nuevo o el shell termine.

Error 6: Redirigir /dev/tcp sin verificar soporte

# ❌ Si Bash no tiene soporte de red, esto falla
exec 3<>/dev/tcp/localhost/8080

# ✅ Verificar primero
if [[ -r /dev/tcp ]]; then
    # Bash con soporte de red — /dev/tcp existe virtualmente
    exec 3<>/dev/tcp/localhost/8080 2>/dev/null && {
        echo "Conectado" >&3
        exec 3>&-
    } || echo "No se pudo conectar"
else
    echo "Bash sin soporte de red — usa curl o nc" >&2
fi

Error 7: Olvidar redirigir stderr en bucles

# ❌ Solo stdout va al archivo, stderr sale por terminal
for f in *.txt; do
    grep "error" "$f"
done > resultados.txt

# ✅ Capturar ambos
for f in *.txt; do
    grep "error" "$f"
done > resultados.txt 2> errores.txt

# ✅ O ambos al mismo archivo
for f in *.txt; do
    grep "error" "$f"
done &> todo.txt

Error 8: No cerrar heredoc con el delimitador correcto

# ❌ El delimitador debe estar SOLO en la línea, sin espacios
cat << EOF
contenido
 EOF     # Espacio antes — no se reconoce como fin

# ✅ Correcto
cat << EOF
contenido
EOF

Además, si usas <<-, el delimitador final debe tener exactamente un tabulador al inicio.

Error 9: Espacio entre > y & cambia el significado

# ✅ Esto funciona (sin espacio después de >)
comando 2>&1

# ❌ Esto NO es 2>&1 — es redirigir FD 2 a un archivo llamado "&1"
comando 2> &1   # Error: Bash espera nombre de archivo después de >

La diferencia: 2>&1 es un solo token (redirección con duplicado). 2> &1 es «redirigir stderr a archivo llamado &1«.

Error 10: No redirigir el pipe correctamente con múltiples descriptores

# ❌ El pipe captura solo stdout, stderr se escapa
comando | tee log.txt

# ✅ Capturar ambos en el pipe
comando 2>&1 | tee log.txt

# ❌ No funciona: primero el pipe captura stdout, luego se duplica stderr
comando | tee log.txt 2>&1   # 2>&1 aquí aplica a tee, no a comando

# ✅ Con sustitución de procesos
comando > >(tee log.txt) 2>&1

Tabla resumen

  • cmd > archivo: Redirigir stdout (sobrescribe)
  • cmd >> archivo: Redirigir stdout (añade)
  • cmd < archivo: Redirigir stdin
  • cmd 2> archivo: Redirigir stderr
  • cmd &> archivo: stdout + stderr al mismo archivo
  • cmd 2>&1: Duplicar stderr hacia stdout
  • exec N>&-: Cerrar descriptor N
  • cmd << EOF ... EOF: Heredoc (texto multilínea como stdin)
  • cmd << 'EOF' ... EOF: Heredoc sin expansión
  • cmd <<- TAB... EOF: Heredoc indentado (ignora tabs iniciales)
  • cmd <<< "texto": Here string
  • exec N> archivo: Abrir descriptor escritura
  • exec N< archivo: Abrir descriptor lectura
  • exec M>&N: Duplicar descriptor (FD M apunta al mismo sitio que FD N)
  • exec {var}>archivo: Descriptor automático (Bash asigna FD >= 10)
  • /dev/tcp/host/puerto: Red TCP nativa
  • BASH_XTRACEFD=N: Trace propio (redirige set -x a un FD específico)
  • > /dev/null: Descartar salida
  • cmd | tee archivo: Bifurcar salida
  • tee >(cmd): Bifurcar con proceso
  • { cmds; } > archivo: Redirigir bloque

Resumen

En este capítulo has visto como dominar el flujo de datos en Bash. Desde las redirecciones básicas >, >>, < hasta los descriptores personalizados con exec, pasando por heredocs, /dev/tcp, BASH_XTRACEFD y tee con sustitución de procesos.

Al final, no se trata más que de entender que la entrada y salida de tus scripts no es algo fijo. Puedes redirigirla, bifurcarla, duplicarla, aislarla y combinarla de formas que harán tus scripts mucho más profesionales.

Reglas de oro:

  1. El orden importa en 2>&1: primero redirige stdout, luego duplica stderr hacia stdout.
  2. Siempre cierra los descriptores que abras para evitar fugas de recursos.
  3. Usa {var}>archivo en Bash 4.1+ para evitar colisiones de números.
  4. exec sin comando es permanente hasta que lo cambies o el shell termine.
  5. Guarda una copia del stdout original (con exec N>&1) antes de redirigir globalmente.

En el próximo capítulo veremos las señales y traps en Bash. Verás cómo tus scripts pueden reaccionar a eventos externos, limpiar recursos y comportarse como ciudadanos de primera clase en el sistema.


Más información,

Deja una respuesta