Solucionando Problemas de Expansión de Variables en Bash de Manera Efectiva

Los scripts de Bash a menudo fallan debido a errores sutiles de expansión de variables. Esta guía completa analiza problemas comunes como el uso incorrecto de comillas, el manejo de valores no inicializados y la gestión del alcance de variables dentro de subcapas y funciones. Aprende técnicas esenciales de depuración (`set -u`, `set -x`) y domina modificadores de expansión de parámetros (como `${VAR:-default}`) para escribir scripts de automatización robustos, predecibles y a prueba de errores. Deja de depurar cadenas vacías misteriosas y comienza a escribir scripts con confianza.

Solucionando Problemas de Expansión de Variables en Bash de Manera Efectiva

Los errores de expansión de variables en Bash a menudo parecen un comportamiento aleatorio: una ruta con espacios se convierte en dos rutas, un comodín en un nombre de archivo se expande a la mitad del directorio, una variable establecida dentro de un bucle desaparece, o una variable de entorno faltante se convierte silenciosamente en una cadena vacía. El shell no es aleatorio. Sigue reglas de expansión que son fáciles de olvidar cuando te concentras en la tarea que se supone que debe hacer el script.

El modelo mental útil es este: Bash no simplemente reemplaza $nombre con texto y ejecuta el comando. Expande variables, puede dividir el resultado en palabras, puede expandir globos, y luego finalmente ejecuta un comando con la lista de argumentos resultante. La mayoría de las correcciones provienen de controlar esos pasos.

Las variables no establecidas se vuelven vacías a menos que las detengas

Por defecto, este script imprime un valor vacío y continúa:

printf 'Desplegando %s\n' "$APP_VERSION"

Si APP_VERSION era obligatorio, eso es un error. Usa la expansión de parámetros cuando la variable es obligatoria:

: "${APP_VERSION:?APP_VERSION debe estar establecida}"
printf 'Desplegando %s\n' "$APP_VERSION"

El : inicial es el comando de no operación. La expansión realiza la verificación. Si la variable no está establecida o está vacía, Bash imprime el mensaje y sale de un shell no interactivo.

Para valores opcionales, haz que el valor predeterminado sea obvio:

nivel_log=${LOG_LEVEL:-INFO}
intentos_reintento=${RETRY_COUNT:-3}

Los dos puntos importan. ${VAR:-default} usa el valor predeterminado cuando VAR no está establecido o está vacío. ${VAR-default} usa el valor predeterminado solo cuando VAR no está establecido. Esa distinción importa si una cadena vacía es un valor de configuración válido.

set -u también puede detectar variables no establecidas:

set -u

Es útil en muchos scripts, pero no es un sustituto de una validación clara. También puede sorprenderte cuando trabajas con parámetros posicionales opcionales, arreglos o variables que se verifican intencionalmente para su existencia. Usa ${1:-} cuando un argumento puede estar ausente:

modo=${1:-ayuda}

Pon entre comillas las variables a menos que quieras división y expansión de globos

Este es el problema de expansión más común:

archivo="Reporte Trimestral *.txt"
rm $archivo

Sin comillas, Bash primero expande $archivo, luego lo divide en espacios, luego trata * como un comodín. El comando puede recibir varios argumentos que no pretendías. Con comillas, recibe exactamente un argumento:

rm -- "$archivo"

El -- protege los comandos de valores que comienzan con un guión. Eso importa para nombres de archivo como -rf.

Usa comillas dobles para variables, sustituciones de comandos y la mayoría de las expansiones de parámetros:

cp "$archivo_origen" "$directorio_destino/"
printf 'Usuario: %s\n' "$nombre_usuario"

Las comillas simples son diferentes. Evitan la expansión por completo:

printf 'El directorio home es $HOME\n'   # imprime el texto literal
printf "El directorio home es $HOME\n"   # imprime el valor

Si ves un script construyendo cadenas como 'prefijo-$valor', eso es probablemente un error. Usa comillas dobles cuando el valor deba expandirse.

Los arreglos resuelven muchos problemas de construcción de argumentos

Muchos scripts Bash rotos provienen de almacenar varias opciones de comando en una sola cadena:

opciones="-a --delete --exclude *.tmp"
rsync $opciones "$origen/" "$destino/"

Eso depende de la división de palabras y puede romperse cuando un argumento de opción contiene espacios. Usa un arreglo:

opciones=(-a --delete --exclude '*.tmp')
rsync "${opciones[@]}" "$origen/" "$destino/"

"${opciones[@]}" expande cada elemento del arreglo como su propio argumento. Eso es exactamente lo que la mayoría de las construcciones de comandos necesitan.

Lo mismo aplica al recolectar nombres de archivo:

archivos=("$directorio_reportes"/*.txt)
for archivo in "${archivos[@]}"; do
  [[ -e $archivo ]] || continue
  procesar_reporte "$archivo"
done

La guarda [[ -e $archivo ]] || continue maneja el caso donde no coincidieron archivos y el globo permaneció literal, dependiendo de las opciones del shell.

La sustitución de comandos elimina las nuevas líneas finales

$(comando) captura la salida estándar, pero Bash elimina los caracteres de nueva línea finales. Generalmente está bien para una cadena de versión y es incorrecto para datos donde las nuevas líneas finales importan.

version=$(git describe --tags --always)
printf 'Versión: %s\n' "$version"

Para salida orientada a líneas, prefiere mapfile cuando necesites un arreglo:

mapfile -t nombres < <(find "$directorio_base" -maxdepth 1 -type f -name '*.log' -printf '%f\n')
for nombre in "${nombres[@]}"; do
  printf 'log=%s\n' "$nombre"
done

Evita for item in $(ls). Se rompe con espacios en blanco, caracteres glob y nombres de archivo inusuales. Itera sobre globos o usa find con delimitadores cuidadosos.

Las variables en tuberías pueden estar en una subcapa

Esto atrapa a las personas porque el bucle parece ejecutarse correctamente:

cuenta=0
printf '%s\n' a b c | while IFS= read -r linea; do
  cuenta=$((cuenta + 1))
done
printf 'cuenta=%s\n' "$cuenta"

En muchas configuraciones de Bash, el bucle while en una tubería se ejecuta en una subcapa. El incremento ocurre, pero la cuenta del shell padre no cambia.

Usa sustitución de procesos en su lugar:

cuenta=0
while IFS= read -r linea; do
  cuenta=$((cuenta + 1))
done < <(printf '%s\n' a b c)
printf 'cuenta=%s\n' "$cuenta"

O haz que la tubería produzca el valor que necesitas y captura ese valor directamente.

Las variables locales evitan sobrescrituras accidentales

Las variables en funciones de Bash son globales a menos que se declaren local. Esto puede convertir una función auxiliar en una fuente de extraños errores de expansión:

entorno=prod

cargar_configuracion() {
  entorno=dev
}

cargar_configuracion
printf '%s\n' "$entorno"  # dev

Usa local para valores temporales:

cargar_configuracion() {
  local entorno=dev
  printf 'valores predeterminados cargados para %s\n' "$entorno"
}

local es una característica de Bash. Está bien en scripts de Bash, pero es otra razón por la que el script no debe ejecutarse con sh.

Usa llaves cuando los nombres tocan otro texto

$prefijo_archivo significa una variable llamada prefijo_archivo, no $prefijo seguido de _archivo. Usa llaves para hacer claro el límite:

prefijo=app
printf '%s\n' "${prefijo}_archivo"

Las llaves también son necesarias para muchas operaciones de expansión de parámetros:

ruta=/var/log/nginx/access.log
printf 'dir=%s\n' "${ruta%/*}"
printf 'archivo=%s\n' "${ruta##*/}"

${ruta%/*} elimina el sufijo coincidente más corto. ${ruta##*/} elimina el prefijo coincidente más largo. Estos son útiles, pero no los uses en exceso cuando dirname o basename harían el script más claro para tu equipo.

Depura la expansión imprimiendo los argumentos reales

set -x muestra los comandos después de la expansión. Mejora el seguimiento con números de línea:

PS4='+ ${BASH_SOURCE}:${LINENO}: '
set -x
mv $archivo $directorio_destino
set +x

El seguimiento revelará si el comando se convirtió en mv Reporte Trimestral *.txt /tmp/salida o mv 'Reporte Trimestral *.txt' /tmp/salida. Mantén xtrace alejado de secretos.

Para una verificación manual más segura, imprime valores con %q:

printf 'archivo=%q\n' "$archivo" >&2
printf 'directorio_destino=%q\n' "$directorio_destino" >&2

%q hace visibles los espacios y caracteres especiales de una manera más fácil de leer que el simple echo.

Una lista de verificación práctica

Cuando una variable de Bash se expande incorrectamente, verifica estos en orden:

  1. ¿Se está ejecutando el script bajo Bash, no sh?
  2. ¿Está la variable realmente establecida? Usa ${VAR:?mensaje} para valores obligatorios.
  3. ¿Está cada expansión entre comillas a menos que la división sea intencional?
  4. ¿Estás usando un arreglo para múltiples argumentos?
  5. ¿Puso una tubería tu bucle en una subcapa?
  6. ¿Sobrescribió una función una variable global porque faltaba local?
  7. ¿Se necesitan llaves para separar el nombre de la variable del texto cercano?

Esas verificaciones son aburridas en el mejor sentido. Convierten la mayoría de los errores de expansión de "Bash es extraño" en una regla específica y reparable.

La expansión indirecta y las referencias a nombres merecen precaución adicional

Bash puede expandir una variable cuyo nombre está almacenado en otra variable:

nombre=APP_ENV
printf '%s\n' "${!nombre}"

Esto imprime el valor de APP_ENV. Es poderoso, pero hace que los scripts sean más difíciles de leer y puede volverse inseguro si el nombre de la variable proviene de la entrada del usuario. Si solo necesitas un mapeo de nombres a valores, un arreglo asociativo es más claro:

declare -A endpoints=(
  [dev]='https://dev.example.test'
  [prod]='https://api.example.com'
)

printf '%s\n' "${endpoints[$entorno]:?entorno desconocido}"

Bash también tiene referencias a nombres con declare -n, a menudo usadas en funciones auxiliares. Son útiles en scripts de estilo de biblioteca, pero pueden crear efectos secundarios sorprendentes. Úsalas solo cuando pasar un arreglo o variable por referencia simplifique genuinamente el código.

La eliminación de patrones no es coincidencia de expresiones regulares

Los operadores de expansión de parámetros como ${archivo%.log} y ${ruta##*/} usan patrones de shell, no expresiones regulares. Esa diferencia importa.

archivo='access.log'
printf '%s\n' "${archivo%.log}"

Esto elimina un sufijo .log. No significa "eliminar cualquier cosa que coincida con una expresión regular". Para verificaciones de expresiones regulares, usa [[ ... =~ ... ]]:

if [[ $puerto =~ ^[0-9]+$ ]]; then
  printf 'numérico\n'
fi

Incluso allí, pon comillas con cuidado. El lado derecho de =~ generalmente se deja sin comillas cuando quieres que se trate como una expresión regular. La variable del lado izquierdo no debería necesitar comillas dentro de [[ ]], porque [[ ]] no realiza división de palabras como lo hace [ ].

Exporta solo lo que los procesos hijos necesitan

Establecer una variable en Bash no la hace automáticamente disponible para los comandos que el script inicia:

APP_ENV=prod
./ejecutar-app

ejecutar-app no verá APP_ENV a menos que se exporte o se suministre en línea:

export APP_ENV=prod
./ejecutar-app

# o
APP_ENV=prod ./ejecutar-app

Esta es una fuente común de confusión cuando un script imprime el valor correcto pero un proceso hijo se comporta como si el valor faltara. La variable existe en el shell; nunca se colocó en el entorno para el hijo.

Lo contrario también es cierto: un proceso hijo no puede cambiar las variables del shell padre. Si un script auxiliar imprime export TOKEN=..., ejecutarlo normalmente no actualizará al llamante. Tendrías que obtenerlo (source), y obtenerlo debe reservarse para código de shell confiable.

Una revisión del mundo real antes de enviar

Antes de dar por terminado un script o configuración de contenedor, léelo una vez como si fueras la próxima persona que tiene que depurarlo a las 2 a.m. Eso cambia lo que notas. Un mensaje que tenía sentido mientras escribías el script puede ser ambiguo cuando aparece en un registro de CI. Un nombre de servicio de Docker que parecía obvio puede no coincidir con el nombre de variable en la aplicación. Un valor predeterminado de Bash puede ser seguro para desarrollo y peligroso para producción.

Me gusta hacer una prueba en seco corta con valores deliberadamente incómodos. Usa una ruta con espacios. Usa un valor opcional vacío. Prueba un nombre de archivo que comience con un guión. Ejecuta el script desde un directorio de trabajo diferente. Inicia el contenedor sin una variable de entorno esperada. Estas pruebas no son elegantes, pero detectan las suposiciones que generalmente se rompen primero.

También verifica el mensaje de error. Si la única salida es falló, el consejo del artículo no ha llegado a la implementación. Un error útil dice qué valor se usó, qué verificación falló y qué puede cambiar el operador. Eso no significa volcar cada variable de entorno o imprimir secretos. Significa ser específico donde la especificidad ayuda: la ruta de configuración, el nombre del comando faltante, el nombre de la red, el nombre de host del servicio o el puerto que el proceso intentó enlazar.

El hábito final es mantener los ejemplos cerca de la forma en que el sistema se ejecuta realmente. Si la producción usa Compose, prueba con Compose. Si un script es lanzado por systemd, pruébalo con systemd o con un entorno igualmente mínimo. Si se supone que un comando es seguro para copiar y pegar, incluye las comillas, los separadores -- y la validación en el ejemplo mismo. Los lectores copian patrones de trabajo más a menudo de lo que copian advertencias.

Esa revisión no es burocracia. Es cómo la automatización pequeña se mantiene aburrida. Aburrido es lo que quieres de los mensajes del shell, cargadores de configuración, expansión de variables, diagnósticos de contenedores y redes de Docker. Cuanto menos sorprendente sea el comportamiento, más fácil será para el próximo operador confiar en él.

Para la expansión de variables específicamente, agrega un hábito más a esa revisión: imprime el conteo de argumentos cuando un comando se comporte de manera extraña. Un pequeño ayudante puede hacer visible lo invisible:

mostrar_argumentos() {
  local i=1
  for arg in "$@"; do
    printf 'arg[%d]=%q\n' "$i" "$arg" >&2
    i=$((i + 1))
  done
}

mostrar_argumentos mv $archivo $directorio_destino
mostrar_argumentos mv "$archivo" "$directorio_destino"

La primera llamada muestra lo que recibiría el comando roto; la segunda muestra la versión corregida. Una vez que ves la lista de argumentos, los errores de comillas dejan de sentirse misteriosos.