Mejores prácticas de secuencias de comandos Bash: una guía rápida para secuencias de comandos Bash confiables y de alto rendimiento

Mejores prácticas de secuencias de comandos Bash: una guía rápida para secuencias de comandos Bash confiables y de alto rendimiento
Fondo de pantalla de concha de manapi

Depurar scripts bash es como buscar una aguja en un pajar, especialmente cuando aparecen nuevas incorporaciones en el código base existente sin una consideración oportuna de las cuestiones de estructura, registro y confiabilidad. Puede encontrarse en este tipo de situaciones debido a sus propios errores o al gestionar pilas complejas de guiones.

Equipo Soluciones en la nube Mail.ru tradujo un artículo con recomendaciones que le ayudarán a escribir, depurar y mantener mejor sus scripts. Lo creas o no, nada supera la satisfacción de escribir código bash limpio y listo para usar que funcione en todo momento.

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

Manejadores de trampas

La mayoría de los scripts de bash que he encontrado nunca utilizan un mecanismo de limpieza efectivo cuando sucede algo inesperado durante la ejecución del script.

Pueden surgir sorpresas desde el exterior, como recibir una señal del núcleo. Manejar estos casos es extremadamente importante para garantizar que los scripts sean lo suficientemente confiables 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

trap es un comando integrado del shell que le ayuda a registrar una función de limpieza que se llama en caso de cualquier señal. Sin embargo, se debe tener especial cuidado con manipuladores como SIGINT, lo que hace que el script se cancele.

Además, en la mayoría de los casos sólo deberías coger EXIT, pero la idea es que puedas personalizar el comportamiento del script para cada señal individual.

Funciones de conjunto integradas: terminación rápida en caso de error

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

rm -rf ${directory_name}/*

Tenga en cuenta que la variable directory_name no determinado.

Es importante utilizar funciones integradas para manejar tales escenarios. settales como set -o errexit, set -o pipefail o set -o nounset al comienzo del guión. Estas funciones garantizan que su secuencia de comandos se cerrará tan pronto como encuentre cualquier código de salida distinto de cero, uso de variables no definidas, comandos no válidos pasados ​​a través de una tubería, 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: funciones integradas como set -o errexit, saldrá del script tan pronto como haya un código de retorno "sin formato" (que no sea cero). Por lo tanto, es mejor introducir un manejo de errores personalizado, por ejemplo:

#!/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

Escribir guiones de esta manera te obliga a ser más cuidadoso con el comportamiento de todos los comandos del guión y anticipar la posibilidad de un error antes de que te tome por sorpresa.

ShellCheck para detectar errores durante el desarrollo

Vale la pena integrar algo como ShellCheck en sus procesos de desarrollo y pruebas para comparar su código bash con las mejores prácticas.

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

Usando sus propios códigos de salida

Los códigos de retorno en POSIX no son solo cero o uno, sino cero o un valor distinto de cero. Utilice estas funciones para devolver códigos de error personalizados (entre 201 y 254) para varios casos de error.

Esta información luego puede ser utilizada por otros scripts que incluyan 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 de registro

Un registro hermoso y estructurado es importante para comprender fácilmente los resultados de su secuencia de comandos. Al igual que con otros lenguajes de programación de alto nivel, siempre uso funciones de registro nativas en mis scripts bash, como __msg_info, __msg_error y 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"

Normalmente trato de tener algún tipo de mecanismo en mis scripts. __init, donde dichas variables del registrador y otras variables del sistema se inicializan o se establecen en valores predeterminados. Estas variables también se pueden configurar desde las opciones 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, garantiza que la configuración de todo el sistema se establezca en los valores predeterminados si es necesario, o al menos se inicialice a algo apropiado si es necesario.

Normalmente baso la elección de qué inicializar y qué no hacer en un equilibrio entre la interfaz de usuario y los detalles de las configuraciones en las que el usuario puede/debe profundizar.

Arquitectura para reutilización y estado limpio 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 bash que quiero desarrollar. Todo lo que pueda reutilizarse se puede almacenar en un repositorio y recuperarlo en otros proyectos que quieran utilizar esa funcionalidad. Organizar proyectos de esta manera reduce significativamente el tamaño de otros scripts y también garantiza que la base del código sea pequeña y fácil de probar.

Como en el ejemplo anterior, todas las funciones de registro como __msg_info, __msg_error y otros, como los informes de Slack, se encuentran por separado en common/* y conectarse dinámicamente en otros escenarios como daily_database_operation.sh.

Deja atrás un sistema limpio

Si está cargando algún recurso mientras se ejecuta el script, se recomienda almacenar todos esos datos en un directorio compartido con un nombre aleatorio, p. /tmp/AlRhYbD97/*. Puede utilizar generadores de texto aleatorios para seleccionar el nombre del directorio:

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

Una vez finalizado el trabajo, se puede realizar la limpieza de dichos directorios en los manejadores de ganchos comentados anteriormente. Si no se cuidan los directorios temporales, se acumulan y en algún momento causan problemas inesperados en el host, como un disco lleno.

Usando 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.

Normalmente creo archivos de bloqueo en /tmp/project_name/*.lock y verifique su presencia al comienzo del guión. Esto ayuda a que el script finalice correctamente y evite cambios inesperados en el estado del sistema debido a 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 necesitamos trabajar con scripts que se ejecutan durante largos períodos de tiempo, como las operaciones diarias de bases de datos. Estas operaciones suelen implicar una secuencia de pasos: cargar datos, comprobar si hay anomalías, importar datos, enviar informes de estado, etc.

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

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

Posteriormente 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 scripts que necesitan optimización.

¡Buena suerte!

Qué más leer:

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

Fuente: habr.com

Añadir un comentario