Melhores práticas de scripts bash: guia rápido para scripts bash confiáveis e eficientes

Melhores práticas de scripts bash: guia rápido para scripts bash confiáveis e eficientes
Papel de parede Shell por manapi

Debugging de scripts bash é como procurar uma agulha em um palheiro, ainda mais quando novas adições surgem em uma base de código existente sem uma consideração oportuna sobre estrutura, logging e confiabilidade. Nessas situações, você pode enfrentar problemas tanto devido a erros próprios quanto ao lidar com complexidades acumuladas de scripts.

Time Soluções em Nuvem Mail.ru Traduzido o artigo com recomendações que o ajudarão a escrever, depurar e manter seus scripts de forma mais eficaz. Acredite ou não, nada se compara à satisfação de escrever um código bash limpo, pronto para uso, que funciona sempre.

No artigo, o autor compartilha o que aprendeu nos últimos anos, bem como alguns erros comuns que o pegaram de surpresa. Isso é importante, porque todo desenvolvedor de software, em algum momento de sua carreira, trabalha com scripts para automatizar tarefas de trabalho rotineiras.

Manipuladores de saída

A maioria dos scripts bash que encontrei nunca usou um mecanismo eficiente de limpeza quando algo inesperado acontece durante a execução do script.

Surpresas podem surgir de fora, como receber um sinal do núcleo. O tratamento de tais casos é extremamente importante para que os scripts sejam suficientemente robustos para serem executados em sistemas de produção. Costumo usar manipuladores de saída para reagir a tais cenários:

function handle_exit() {
  \/\/ Adicione o código de limpeza aqui
  \/\/ por exemplo. rm -f "\/tmp\/${lock_file}.lock"
  \/\/ saia com um código de status apropriado
}
  
\/\/ trap <FUNCAO_MANIPULADORA> <LISTA DE SINAIS A SEREM TRAPADAS>
trap handle_exit 0 SIGHUP SIGINT SIGQUIT SIGABRT SIGTERM

trap é um comando embutido da shell que ajuda você a registrar uma função de limpeza que é chamada no caso de quaisquer sinais. No entanto, deve-se ter cuidado especial com manipuladores como SIGINT, que interrompem a execução do script.

Além disso, na maioria dos casos, você deve capturar apenas EXIT, mas a ideia é que você realmente pode ajustar o comportamento do script para cada sinal individual.

Funções embutidas set - término rápido em caso de erro

É muito importante reagir a erros assim que eles ocorrem e interromper a execução rapidamente. Não há nada pior do que continuar executando um comando como este:

rm -rf ${directory_name}/*

Note que a variável directory_name não está definida.

Para lidar com esses cenários, é importante usar funções embutidas set, como set -o errexit, set -o pipefail ou set -o nounset no início do script. Essas funções garantem que seu script encerre assim que encontrar qualquer código de saída diferente de zero, uso de variáveis não definidas, comandos incorretos passados por pipe, e assim por diante:

#!/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: funções embutidas, como set -o errexit, sairão do script assim que um código de retorno "não tratado" (exceto zero) aparecer. Portanto, é melhor implementar um tratamento de erros 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

Escrever scripts dessa forma faz com que você preste mais atenção ao comportamento de todos os comandos no script e antecipe a possibilidade de erro antes que ele o pegue de surpresa.

ShellCheck para identificar erros durante o desenvolvimento

Vale a pena integrar algo como ShellCheck nos seus pipelines de desenvolvimento e teste para verificar seu código bash quanto às melhores práticas.

Eu o uso em meus ambientes de desenvolvimento locais para obter relatórios sobre sintaxe, semântica e alguns erros no código que eu poderia ter perdido durante o desenvolvimento. É uma ferramenta de análise estática para seus scripts bash, e eu recomendo fortemente sua aplicação.

Usando seus códigos de saída

Os códigos de retorno no POSIX não são apenas zero ou um, mas zero ou um valor não zero. Use esses recursos para retornar códigos de erro personalizados (entre 201-254) para diferentes casos de erro.

Essas informações podem ser usadas então por outros scripts que envolvem o seu, para entender exatamente que tipo de erro ocorreu e reagir de forma apropriada:

#!/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: por favor, tenha especial cuidado com os nomes de variáveis que você define, para não acidentalmente sobrescrever variáveis de ambiente.

Funções de log

Uma boa e estruturada gestão de logs é importante para entender facilmente os resultados da execução do seu script. Assim como em outras linguagens de programação de alto nível, eu sempre utilizo em meus scripts bash funções de logging personalizadas, como __msg_info, __msg_error e assim por diante.

Isso ajuda a garantir uma estrutura de logging padronizada, fazendo alterações apenas em um 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"

Costumo procurar ter em meus scripts algum mecanismo __init, onde variáveis de logger e outras variáveis do sistema são inicializadas ou definidas com valores padrão. Essas variáveis também podem ser configuradas a partir de parâmetros da linha de comando durante a chamada do script.

Por exemplo, algo como:

$ ./run-script.sh --debug

Quando tal script é executado, há uma garantia de que as configurações gerais do sistema estão definidas com valores padrão, se forem obrigatórios, ou, pelo menos, inicializadas com algo adequado, se necessário.

Geralmente, baseio a decisão sobre o que inicializar e o que não inicializar em um compromisso entre a interface do usuário e os detalhes das configurações que o usuário pode/deve examinar.

Arquitetura para reutilização e estado limpo do sistema

Código modular/reutilizável

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

Mantenho um repositório separado que pode ser utilizado para inicializar um novo projeto/script bash que quero desenvolver. Tudo o que pode ser reutilizado pode ser salvo no repositório e acessado em outros projetos que desejam utilizar tais funcionalidades. Essa organização de projetos reduz significativamente o tamanho dos outros scripts, além de garantir que a base de código seja pequena e facilmente testável.

Assim como no exemplo acima, todas as funções de logging, como __msg_info, __msg_error e outras, como relatórios via Slack, estão contidas separadamente em common/* e são conectadas dinamicamente em outros scripts, como daily_database_operation.sh.

Deixe um sistema limpo para trás

Se você carregar algum recurso durante a execução de um script, é recomendável armazenar todos esses dados em um diretório comum com um nome aleatório, por exemplo /tmp/AlRhYbD97/*Você pode usar geradores de texto aleatório para escolher o nome do diretório:

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

Após a conclusão do trabalho, a limpeza desses diretórios pode ser garantida pelos manipuladores de exceção discutidos acima. Se não houver cuidado com a remoção de diretórios temporários, eles se acumulam, e em algum momento causam problemas inesperados no host, como disco cheio.

Uso de arquivos de bloqueio

Frequentemente, é necessário garantir que apenas uma instância do script seja executada no host a qualquer momento. Isso pode ser feito usando arquivos de bloqueio.

Normalmente, crio arquivos de bloqueio em /tmp/project_name/*.lock e verifico sua presença no início do script. Isso ajuda a encerrar o script corretamente e evitar alterações inesperadas no estado do sistema por outro script que esteja sendo executado em paralelo. Os arquivos de bloqueio não são necessários se você precisar que o mesmo script seja executado em paralelo neste host.

Medir e melhorar

Frequentemente, temos que trabalhar com scripts que executam por longos períodos, como operações diárias em bancos de dados. Essas operações geralmente incluem uma sequência de etapas: carregamento de dados, verificação de anomalias, importação de dados, envio de relatórios de status e assim por diante.

Nesses casos, eu sempre tento dividir o script em pequenos scripts separados e reportar seu estado e tempo de execução usando:

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

Mais tarde, posso ver o tempo de execução usando:

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

Isso me ajuda a identificar áreas problemáticas/lentas nos scripts que precisam de otimização.

Boa sorte!

O que mais ler:

  1. Go e caches de GPU.
  2. Exemplo de aplicativo orientado a eventos baseado em webhooks no armazenamento de objetos S3 da Mail.ru Cloud Solutions.
  3. Nosso canal no Telegram sobre transformação digital.

Fonte: habr.com

Compre hospedagem confiável para sites com proteção contra DDoS, servidores VPS VDS 🔥 Compre hospedagem confiável para sites com proteção contra DDoS, servidores VPS VDS | ProHoster