Mejores prácticas para la creación de scripts bash: una guía rápida para la creación de scripts bash fiable y eficaz



Fondo de pantalla de Shell por manapi



Depurar scripts de bash es como buscar una aguja en un pajar, especialmente cuando aparecen nuevas adiciones en una base de código existente sin considerar oportunamente los problemas de estructura, registro y confiabilidad. Puede encontrarse en tales situaciones debido a sus propios errores y cuando maneja mezclas complejas de guiones.



El equipo de Mail.ru Cloud Solutions hatraducido un artículo con pautas que lo ayudarán a escribir, depurar y mantener mejor sus scripts. Lo crea o no, nada supera la satisfacción de escribir un código bash limpio y listo para usar que funcione en todo momento.



En este artículo, el autor comparte lo que ha aprendido en los últimos años, así como algunos errores comunes que lo han tomado desprevenido. Esto es importante porque cada desarrollador de software, en algún momento de su carrera, trabaja con scripts para automatizar las tareas de trabajo rutinarias.



Manejadores de trampas



La mayoría de los scripts de bash con los que me he encontrado nunca han utilizado un mecanismo de limpieza eficiente cuando sucede algo inesperado durante la ejecución del script.



Pueden surgir cosas inesperadas del exterior, por ejemplo, recibir una señal del kernel. Manejar estos casos es extremadamente importante para garantizar que los scripts sean lo suficientemente robustos para ejecutarse en sistemas de producción. A menudo uso controladores de salida para responder a escenarios como este:



function handle_exit() {
  // Add cleanup code here
  // for eg. rm -f "/tmp/${lock_file}.lock"
  // exit with an appropriate status code
}
  
// trap <HANDLER_FXN> <LIST OF SIGNALS TO TRAP>
trap handle_exit 0 SIGHUP SIGINT SIGQUIT SIGABRT SIGTERM


trapEs un comando de shell integrado que le ayuda a registrar una función de limpieza que se llamará en caso de señales. Sin embargo, se debe tener especial cuidado con controladores como los SIGINTque interrumpen el script.



Además, en la mayoría de los casos solo debería captar EXIT, pero la idea es que realmente pueda personalizar el comportamiento del script para cada señal individual.



Establecer funciones integradas: salida rápida en caso de error



Es muy importante reaccionar ante los errores tan pronto como se produzcan y detener la ejecución rápidamente. Nada podría ser peor que continuar con un comando como este:



rm -rf ${directory_name}/*


Tenga en cuenta que la variable directory_nameno está definida.



Para manejar estos escenarios, es importante utilizar funciones integradas setcomo set -o errexit, set -o pipefailo set -o nounsetal principio del script. Estas funciones garantizan que su secuencia de comandos salga tan pronto como encuentre cualquier código de salida distinto de cero, variables indefinidas, comandos canalizados no válidos, etc.



#!/usr/bin/env bash

set -o errexit
set -o nounset
set -o pipefail

function print_var() {
  echo "${var_value}"
}

print_var

$ ./sample.sh
./sample.sh: line 8: var_value: unbound variable


Nota: las funciones integradas como, por ejemplo, set -o errexitsaldrán del script tan pronto como aparezca un código de retorno "sin formato" (distinto de cero). Por lo tanto, es mejor introducir un manejo de errores personalizado como:



#!/bin/bash
error_exit() {
  line=$1
  shift 1
  echo "ERROR: non zero return code from line: $line -- $@"
  exit 1
}
a=0
let a++ || error_exit "$LINENO" "let operation returned non 0 code"
echo "you will never see me"
# run it, now we have useful debugging output
$ bash foo.sh
ERROR: non zero return code from line: 9 -- let operation returned non 0 code


Scripts como este le obliga a tener más cuidado con el comportamiento de todos los comandos en el script y a anticipar la posibilidad de que ocurra un error antes de que sea tomado por sorpresa.



ShellCheck para detectar errores durante el desarrollo



Vale la pena integrar algo como ShellCheck en sus canales de desarrollo y prueba para validar su código bash para las mejores prácticas.



Lo uso en mis entornos de desarrollo local para obtener informes sobre sintaxis, semántica y algunos errores de código que podría haber pasado por alto durante el desarrollo. Es una herramienta de análisis estático para sus scripts de bash y recomiendo usarla.



Usando sus códigos de salida



Los códigos de retorno POSIX no son solo cero o uno, sino cero o distintos de cero. Utilice estas funciones para devolver códigos de error personalizados (entre 201-254) para diferentes casos de error.



Esta información puede ser utilizada por otros scripts que envuelven el suyo para comprender exactamente qué tipo de error ocurrió y reaccionar en consecuencia:



#!/usr/bin/env bash

SUCCESS=0
FILE_NOT_FOUND=240
DOWNLOAD_FAILED=241

function read_file() {
  if ${file_not_found}; then
    return ${FILE_NOT_FOUND}
  fi
}


Nota: Tenga especial cuidado con los nombres de las variables que defina para evitar anular accidentalmente las variables de entorno.



Funciones del registrador



Un registro agradable y estructurado es importante para comprender fácilmente los resultados de la ejecución de su script. Al igual que con otros lenguajes de programación de alto nivel, siempre utilizo mis propias funciones de registro en mis scripts de bash como __msg_info, __msg_errory así sucesivamente.



Esto ayuda a proporcionar una estructura de registro estandarizada al realizar cambios en un solo lugar:



#!/usr/bin/env bash

function __msg_error() {
    [[ "${ERROR}" == "1" ]] && echo -e "[ERROR]: $*"
}

function __msg_debug() {
    [[ "${DEBUG}" == "1" ]] && echo -e "[DEBUG]: $*"
}

function __msg_info() {
    [[ "${INFO}" == "1" ]] && echo -e "[INFO]: $*"
}

__msg_error "File could not be found. Cannot proceed"

__msg_debug "Starting script execution with 276MB of available RAM"


Por lo general, trato de tener algún tipo de mecanismo en mis scripts __initen el que dichas variables del registrador y otras variables del sistema se inicialicen o se establezcan en valores predeterminados. Estas variables también se pueden configurar desde los parámetros de la línea de comandos durante la invocación del script.



Por ejemplo, algo como:



$ ./run-script.sh --debug


Cuando se ejecuta un script de este tipo, se garantiza que la configuración de todo el sistema se establece en sus valores predeterminados, si es necesario, o al menos se inicializa con algo apropiado, si es necesario.



Por lo general, baso mi elección de qué inicializar y qué no ser una compensación entre la interfaz de usuario y los detalles de la configuración en la que el usuario puede / debe profundizar.



Arquitectura para reutilizar y limpiar el estado del sistema



Código modular / reutilizable



├── framework
│   ├── common
│   │   ├── loggers.sh
│   │   ├── mail_reports.sh
│   │   └── slack_reports.sh
│   └── daily_database_operation.sh


Mantengo un repositorio separado que puedo usar para inicializar un nuevo proyecto / script de bash que quiero desarrollar. Todo lo que se pueda reutilizar se puede almacenar en el repositorio y recuperar en otros proyectos que deseen utilizar esta funcionalidad. Esta organización de proyectos reduce significativamente el tamaño de otros scripts y también asegura que el código base sea pequeño y fácilmente comprobable.



Como en el ejemplo anterior, todas las funciones de registro, como __msg_info, __msg_errory otras, como los informes de Slack, se mantienen por separado common/*y se conectan dinámicamente a otros escenarios, como daily_database_operation.sh.



Deja un sistema limpio atrás



Si descarga algún recurso en escenarios de tiempo de ejecución, se recomienda almacenar todos estos datos en un directorio compartido con un nombre aleatorio, por ejemplo /tmp/AlRhYbD97/*. Puede utilizar generadores de texto aleatorios para elegir un nombre de directorio:



rand_dir_name="$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 16 | head -n 1)"


Una vez finalizado el trabajo, la limpieza de dichos directorios se puede proporcionar en los manipuladores de gancho discutidos anteriormente. Si no se ocupa de eliminar los directorios temporales, se acumulan y en algún momento causan problemas inesperados en el host, como un disco lleno.



Usar archivos de bloqueo



A menudo es necesario asegurarse de que solo se ejecute una instancia de un script en un host en un momento dado. Esto se puede hacer usando archivos de bloqueo.



Por lo general, creo archivos de bloqueo /tmp/project_name/*.locky verifico su presencia al comienzo del script. Esto ayuda a finalizar correctamente el script y evitar cambios inesperados en el estado del sistema por otro script que se ejecuta en paralelo. Los archivos de bloqueo no son necesarios si necesita que el mismo script se ejecute en paralelo en un host determinado.



Medir y mejorar



A menudo tenemos que trabajar con scripts que se ejecutan durante un largo período de tiempo, como las operaciones diarias de bases de datos. Estas operaciones suelen incluir una secuencia de pasos: cargar datos, comprobar anomalías, importar datos, enviar informes de estado, etc.



En tales casos, siempre trato de dividir el script en pequeños scripts separados e informar su estado y tiempo de ejecución con:



time source "${filepath}" "${args}">> "${LOG_DIR}/RUN_LOG" 2>&1


Más tarde puedo ver el tiempo de ejecución con:



tac "${LOG_DIR}/RUN_LOG.txt" | grep -m1 "real"


Esto me ayuda a identificar áreas problemáticas / lentas en los scripts que necesitan optimización.



¡Buena suerte!



Qué más leer:



  1. Go y cachés de GPU.
  2. Un ejemplo de una aplicación basada en webhook impulsada por eventos en el almacenamiento de objetos Mail.ru Cloud Solutions S3.
  3. Nuestro canal de telegramas sobre transformación digital.



All Articles