
Shell wallpaper by manapi
Depurar scripts bash é como procurar uma agulha em um palheiro, especialmente quando novas adições aparecem na base de código existente sem consideração oportuna de problemas de estrutura, registro e confiabilidade. Você pode se encontrar em tais situações por causa de seus próprios erros e ao gerenciar misturas complexas de scripts.
A equipe Mail.ru Cloud Solutions traduziu um artigo com diretrizes que o ajudarão a escrever, depurar e manter seus scripts melhor. Acredite ou não, nada supera a satisfação de escrever um código bash limpo e pronto para usar que funciona sempre.
Neste 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 rotineiras de trabalho.
Manipuladores de armadilhas
A maioria dos scripts bash que encontrei nunca usou um mecanismo de limpeza eficiente quando algo inesperado acontece durante a execução do script.
Coisas inesperadas podem surgir de fora, por exemplo, receber um sinal do kernel. Lidar com esses casos é extremamente importante para garantir que os scripts sejam robustos o suficiente para serem executados em sistemas de produção. Costumo usar gerenciadores de saída para responder a cenários 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
É um comando shell embutido que ajuda a registrar uma função de limpeza a ser chamada em caso de quaisquer sinais. No entanto, cuidado especial deve ser tomado com manipuladores como aqueles SIGINT
que interrompem o script.
Além disso, na maioria dos casos, você deve apenas capturar
EXIT
, mas a ideia é que você pode realmente personalizar o comportamento do script para cada sinal individual.
Definir funções integradas - Saída rápida em caso de erro
É muito importante reagir aos erros assim que eles ocorrerem e interromper a execução rapidamente. Nada poderia ser pior do que continuar com um comando como este:
rm -rf ${directory_name}/*
Observe que a variável
directory_name
é indefinida.
Para lidar com tais situações, é importante usar funções internas
set
, como set -o errexit
, set -o pipefail
ou set -o nounset
no início do script. Essas funções garantem que seu script saia assim que encontrar qualquer código de saída diferente de zero, variáveis indefinidas, comandos canalizados inválidos 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 integradas como
set -o errexit
sairão do script assim que aparecer um código de retorno "bruto" (diferente de zero). Portanto, é melhor introduzir o 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
Um script como esse força você a ter mais cuidado com o comportamento de todos os comandos do script e a antecipar a possibilidade de um erro ocorrer antes que seja pego de surpresa.
ShellCheck para detectar erros durante o desenvolvimento
Vale a pena integrar algo como ShellCheck em seus pipelines de desenvolvimento e teste para validar seu código bash para melhores práticas.
Eu o uso em meus ambientes de desenvolvimento local para obter relatórios sobre sintaxe, semântica e alguns erros de código que eu poderia ter perdido durante o desenvolvimento. É uma ferramenta de análise estática para seus scripts bash e é altamente recomendável usá-la.
Usando seus códigos de saída
Os códigos de retorno POSIX não são apenas zero ou um, mas zero ou diferente de zero. Use esses recursos para retornar códigos de erro personalizados (entre 201-254) para diferentes casos de erro.
Essas informações podem então ser usadas por outros scripts que envolvem os seus para entender exatamente que tipo de erro ocorreu e reagir de acordo:
#!/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
}
Observação: tenha cuidado especial com os nomes das variáveis que você define para evitar a substituição acidental das variáveis de ambiente.
Funções de logger
Um registro bom e estruturado é importante para compreender facilmente os resultados da execução do script. Tal como acontece com outras linguagens de programação de alto nível, eu sempre usar minhas próprias funções de log em meus scripts bash, como
__msg_info
, __msg_error
e assim por diante.
Isso ajuda a fornecer uma estrutura de registro padronizada, fazendo alterações em apenas 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"
Normalmente tento ter algum tipo de mecanismo em meus scripts
__init
onde tais variáveis de logger e outras variáveis de sistema são inicializadas ou definidas para valores padrão. Essas variáveis também podem ser definidas a partir de parâmetros de linha de comando durante a chamada do script.
Por exemplo, algo como:
$ ./run-script.sh --debug
Quando tal script é executado, é garantido que as configurações de todo o sistema sejam definidas para seus padrões, se necessário, ou pelo menos inicializado com algo apropriado, se necessário.
Eu geralmente baseio minha escolha do que inicializar e do que não ser uma compensação entre a interface do usuário e os detalhes da configuração que o usuário pode / deve aprofundar.
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
Eu mantenho um repositório separado que posso usar para inicializar um novo projeto / script bash que desejo desenvolver. Qualquer coisa que possa ser reutilizada pode ser armazenada no repositório e recuperada em outros projetos que desejam usar esta funcionalidade. Essa organização de projetos reduz bastante o tamanho de outros scripts e também garante que a base de código seja pequena e facilmente testável.
Como no exemplo acima, todas as funções de registro, como
__msg_info
, __msg_error
e outras, como relatórios do Slack, mantidas separadamente common/*
e se conectam dinamicamente a outros cenários, como daily_database_operation.sh
.
Deixe um sistema limpo para trás
Se você carregar alguns recursos enquanto o script está em execução, é recomendável armazenar todos esses dados em um diretório compartilhado com um nome aleatório, por exemplo
/tmp/AlRhYbD97/*
. Você pode usar geradores de texto aleatórios para escolher um nome de 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 de tais diretórios pode ser fornecida nos manipuladores de gancho discutidos acima. Se você não cuidar de excluir diretórios temporários, eles se acumulam e, em algum ponto, causam problemas inesperados no host, como um disco cheio.
Usando arquivos de bloqueio
Freqüentemente, você precisa garantir que apenas uma instância de um script esteja sendo executada em um host a qualquer momento. Isso pode ser feito usando arquivos de bloqueio.
Normalmente, crio arquivos de bloqueio
/tmp/project_name/*.lock
e verifico sua presença no início do script. Isso ajuda a encerrar corretamente o script e evitar alterações inesperadas do estado do sistema por outro script executado em paralelo. Arquivos de bloqueio não são necessários se você precisar que o mesmo script seja executado em paralelo em um determinado host.
Meça e melhore
Freqüentemente, temos que trabalhar com scripts que são executados por um longo período de tempo, como operações diárias de banco 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, sempre tento quebrar o script em pequenos scripts separados e relatar seu estado e tempo de execução com:
time source "${filepath}" "${args}">> "${LOG_DIR}/RUN_LOG" 2>&1
Mais tarde, posso ver o tempo de execução com:
tac "${LOG_DIR}/RUN_LOG.txt" | grep -m1 "real"
Isso me ajuda a identificar áreas problemáticas / lentas em scripts que precisam de otimização.
Boa sorte!
O que mais ler: