Administração Linux
Scripts em Bash: scripts seguros com argumentos, loops, funções e traps
Intermediário10 min de leituraAtualizado
Esta página foi traduzida do inglês com a ajuda de IA e conferida com o original. Ler a versão em inglês
Um script de shell é código de produção que só por acaso parece uma lista de comandos, e a maioria dos seus bugs vem do hábito do shell de seguir em frente depois de uma falha. Este guia mostra a estrutura de um script em que dá para confiar: um preâmbulo estrito, argumentos interpretados, condições testadas, funções com códigos de saída de verdade, limpeza feita por um trap e um linter no fluxo de trabalho. Ele termina com um script de backup completo e comentado.
O que você vai aprender
- Estruturar um script com shebang, um preâmbulo estrito e funções bem separadas
- Interpretar opções e argumentos posicionais e validá-los antes de fazer qualquer trabalho
- Escolher a construção de teste correta e escrever loops que resistem a espaços e listas vazias
- Garantir a limpeza e mensagens de erro úteis com trap e os handlers ERR e EXIT
- Passar os scripts pelo ShellCheck e decidir quando o Bash é a ferramenta errada
O que separa um punhado de comandos digitados uma vez de um script do qual outras pessoas dependem é o tratamento de erros. O shell interativo é tolerante: um comando falha, você vê a mensagem e tenta outra coisa. Um script não tem ninguém olhando e, por padrão, o shell simplesmente segue para a próxima linha. É assim que um script de backup apaga o backup anterior depois que o novo não conseguiu ser criado, e que um script de deploy reinicia um serviço com um arquivo de configuração que nunca chegou a ser escrito.
Este guia trata de escrever o segundo tipo de script de forma deliberada. Ele pressupõe que você já conhece a linha de comando — expansões, aspas, redirecionamento, código de saída — do guia de fundamentos do shell, e foca nas construções que transformam comandos em um programa: modo estrito, tratamento de argumentos, controle de fluxo, funções, arrays, traps de limpeza e linting.
A estrutura de um script
Um script começa com um shebang, a sequência de dois caracteres #! seguida do interpretador que o kernel deve executar. #!/usr/bin/env bash encontra o Bash onde quer que ele esteja, em vez de supor /bin/bash, o que faz diferença em sistemas onde ele fica em /usr/local. Não escreva #!/bin/sh a menos que o script seja estritamente POSIX: no Debian e no Ubuntu, /bin/sh é o Dash, que não tem arrays, nem [[, nem local -n, e cujo echo se comporta de outro jeito.
#!/usr/bin/env bash
#
# deploy.sh — install a release into /opt and switch the current symlink.
# Usage: deploy.sh [-n] [-d DIR] VERSION
Torne-o executável com chmod +x deploy.sh e execute-o como ./deploy.sh. Rodar bash deploy.sh ignora completamente o shebang, e é exatamente assim que um script Bash acaba sendo interpretado pelo Dash em um job de CI e falhando em uma construção que funciona em qualquer outro lugar.
Coloque no topo um bloco de comentários dizendo o que o script faz e como ele é chamado. Daqui a seis meses, esse bloco será a documentação.
O preâmbulo estrito
Três opções do shell, ativadas juntas, mudam o padrão de "seguir em frente aconteça o que acontecer" para "parar ao primeiro sinal de problema".
set -euo pipefail
IFS=$'\n\t'
-eencerra o script quando um comando retorna um código diferente de zero que não está sendo testado.-utrata a expansão de uma variável não definida como erro, pegando erros de digitação e configuração ausente em vez de substituir silenciosamente por uma string vazia. É isso que impederm -rf "$PREFIX/data"de virarrm -rf /data.-o pipefailfaz um pipeline retornar o código do último estágio que falhou, e não apenas o do último estágio, de modo quecurl … | tar -xdeixa de reportar sucesso quando o download falhou.- Restringir o
IFSa quebra de linha e tabulação tira o espaço do conjunto de caracteres de separação de palavras, reduzindo o estrago que uma expansão sem aspas pode causar. É opcional e muda o comportamento de algumas construções idiomáticas, então adote-o conscientemente.
Saiba o que o -e não pega. Um comando dentro de if, while, && ou ! está sendo testado, então sua falha é esperada e não causa a saída. local var=$(cmd) mascara o código de saída de cmd atrás do local, que sempre tem sucesso; por isso, separe os dois:
local sha
sha=$(sha256sum "$file" | cut -d' ' -f1) # now a failure here stops the script
Quando um comando realmente tem permissão para falhar, deixe isso explícito com || true para que a intenção fique visível, e quando quiser o código de saída sem encerrar o script, capture-o:
grep -q "^enabled=1" "$conf" || echo "warning: feature disabled"
if ! systemctl is-active --quiet nginx; then
echo "nginx is not running" >&2
fi
Argumentos e opções
Os parâmetros posicionais são $1, $2 e assim por diante; $# é a quantidade, "$@" expande todos eles como palavras separadas entre aspas, e $0 é o nome do próprio script. "$@" com as aspas é uma das poucas coisas no Bash que quase nunca estão erradas, enquanto $* junta tudo em uma única string e quase nunca é o certo.
Para qualquer coisa além de um ou dois argumentos, interprete as opções com getopts. Ele lida com opções curtas, agrupamento e opções com valor, e por ser um builtin não há nada para instalar.
usage() {
cat >&2 <<'USAGE'
Usage: deploy.sh [-n] [-d DIR] VERSION
-n dry run: print what would happen, change nothing
-d DIR installation root (default /opt)
USAGE
exit 64
}
dry_run=false
root=/opt
while getopts ':nd:h' opt; do
case "$opt" in
n) dry_run=true ;;
d) root=$OPTARG ;;
h) usage ;;
:) printf 'option -%s requires an argument\n' "$OPTARG" >&2; usage ;;
?) printf 'unknown option -%s\n' "$OPTARG" >&2; usage ;;
esac
done
shift $((OPTIND - 1))
[[ $# -eq 1 ]] || usage
version=$1
Os dois-pontos iniciais em ':nd:h' colocam o getopts em modo de erro silencioso, para que suas próprias mensagens sejam usadas; dois-pontos depois de uma letra indicam que aquela opção recebe um valor. shift $((OPTIND - 1)) remove as opções já interpretadas, deixando os argumentos posicionais no lugar.
Valide tudo antes de fazer qualquer coisa destrutiva. Um script que confere suas entradas no início e depois executa é muito mais fácil de entender do que um que descobre na metade do caminho que falta um diretório.
[[ -d $root ]] || { printf 'no such directory: %s\n' "$root" >&2; exit 66; }
[[ $version =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { printf 'bad version: %s\n' "$version" >&2; exit 65; }
command -v rsync >/dev/null || { echo 'rsync is required' >&2; exit 69; }
Saia com 0 em caso de sucesso e com um código diferente de zero distinto para cada classe de falha; os valores de /usr/include/sysexits.h (64 uso, 65 dados, 66 sem entrada, 69 indisponível) são uma convenção razoável quando você precisa de mais do que "deu pau". Seja qual for sua escolha, mande os diagnósticos para a saída de erro com >&2, para que quem passar a sua saída por um pipe receba apenas a saída.
Condições
O Bash oferece três construções de teste, e as diferenças importam.
| Construção | Use para |
|---|---|
[[ … ]] | tudo, em Bash: sem separação de palavras dentro, suporta =~, &&, || e casamento de padrões com == |
[ … ] | apenas scripts POSIX; toda expansão precisa de aspas |
(( … )) | aritmética inteira: (( count > 3 )), (( i++ )) |
[[ -f $config ]] # exists and is a regular file
[[ -d $dir && -w $dir ]] # directory, writable
[[ -s $log ]] # exists and is non-empty
[[ -z ${API_TOKEN:-} ]] # unset or empty, safe under set -u
[[ $name == web-* ]] # glob match
[[ $line =~ ^([0-9]{3})\ (.*)$ ]] && code=${BASH_REMATCH[1]}
(( retries < max_retries ))
Dentro de [[ ]] não há separação de palavras, então [[ -f $config ]] é seguro mesmo quando o caminho contém espaços — um dos poucos lugares onde omitir as aspas não tem problema. À direita de == e =~, as aspas têm o efeito oposto: com aspas é literal; sem aspas, é padrão ou regex.
case é mais limpo do que uma cadeia de comparações quando se compara um valor com vários padrões:
case "$environment" in
prod|production) replicas=6 ;;
stag*) replicas=2 ;;
dev) replicas=1 ;;
*) printf 'unknown environment: %s\n' "$environment" >&2; exit 65 ;;
esac
Loops que não quebram com espaços
Iterar sobre um glob ou um array é seguro; iterar sobre a saída do ls não é.
for file in /var/log/app/*.log; do # safe; use shopt -s nullglob for empty matches
gzip -9 -- "$file"
done
for host in "${hosts[@]}"; do
ssh "$host" 'uptime'
done
for i in {1..5}; do
curl -fsS "https://api.example.com/health" && break
sleep $(( i * 2 )) # simple exponential backoff
done
Ler um arquivo linha a linha tem uma única forma correta, e cada parte dela é essencial:
while IFS= read -r line; do
printf 'got: %s\n' "$line"
done < "$input_file"
IFS= impede que os espaços em branco no início e no fim sejam removidos, -r impede que barras invertidas sejam interpretadas como escapes, e o redirecionamento no done mantém o loop no shell atual. Esse último ponto é a origem de um dos bugs mais comuns do Bash: mandar a saída para um loop com um pipe (cat file | while read …) faz o loop rodar em um subshell, então qualquer variável que ele define some depois. Use um redirecionamento, ou uma substituição de processo quando a entrada for um comando:
count=0
while IFS= read -r unit; do
(( count++ ))
done < <(systemctl list-units --type=service --state=failed --no-legend | awk '{print $1}')
printf 'failed units: %d\n' "$count"
Para nomes de arquivo que podem conter absolutamente qualquer coisa, combine find -print0 com read -d '':
while IFS= read -r -d '' path; do
rm -v -- "$path"
done < <(find /tmp/build -type f -name '*.tmp' -print0)
Funções
Uma função agrupa etapas, dá um nome a elas e permite testá-las isoladamente. Os argumentos chegam como $1, $2, "$@", exatamente como no próprio script; return define um código de saída, e o que ela escreve no stdout é a forma de retornar um valor.
log() { printf '%s [%s] %s\n' "$(date -Is)" "$1" "${*:2}" >&2; }
require_service() {
local unit=$1
if ! systemctl is-active --quiet "$unit"; then
log ERROR "$unit is not running"
return 1
fi
}
current_release() {
readlink -f /opt/app | sed 's|.*/||'
}
release=$(current_release)
require_service postgresql || exit 69
Declare toda variável interna como local; caso contrário ela é global, e mais cedo ou mais tarde duas funções vão colidir. Mande as linhas de log para a saída de erro, como a log acima faz, para que uma função cujo stdout é capturado por substituição de comando não seja poluída pelo próprio log — um bug sutil que produz valores bizarros em quem a chama.
return só aceita valores entre 0 e 255, então uma função não consegue retornar um número, uma string ou uma lista por esse caminho. Imprima e capture.
Arrays
Os arrays são o motivo para exigir Bash em vez do sh POSIX. Eles guardam uma lista de valores com as fronteiras intactas, algo que strings não conseguem fazer.
rsync_opts=(--archive --delete --human-readable)
[[ $dry_run == true ]] && rsync_opts+=(--dry-run)
rsync "${rsync_opts[@]}" "$src/" "$dest/"
hosts=(web01 web02 db01)
printf 'host count: %d\n' "${#hosts[@]}"
printf '%s\n' "${hosts[@]}"
mapfile -t failed < <(systemctl list-units --state=failed --no-legend | awk '{print $1}')
"${array[@]}" com aspas expande para uma palavra por elemento, preservando os espaços; ${array[*]} junta tudo em uma única string. mapfile -t (também chamado de readarray) lê linhas para um array sem as quebras de linha finais e é muito mais seguro do que arr=$(cmd) seguido de separação de palavras.
Arrays associativos funcionam como um pequeno armazenamento chave-valor e precisam ser declarados:
declare -A port_of=( [http]=80 [https]=443 [postgres]=5432 )
for name in "${!port_of[@]}"; do
printf '%-10s %s\n' "$name" "${port_of[$name]}"
done
Here-documents e arquivos gerados
Um here-document envia um bloco de texto literal para a entrada padrão de um comando. Colocar ou não aspas no delimitador decide se o corpo é expandido:
cat > /etc/myapp/config.ini <<EOF
[server]
host = ${bind_address}
port = ${port}
EOF
sudo tee /etc/systemd/system/myapp.service > /dev/null <<'UNIT'
[Unit]
Description=My application
UNIT
<<'EOF' com aspas é literal — nada dentro é expandido, que é o que você quer ao gerar um script ou uma configuração cheia de cifrões $. <<EOF sem aspas expande variáveis e substituições de comando. <<-EOF também remove as tabulações iniciais (não os espaços), permitindo indentar o corpo junto com o código ao redor.
Nunca escreva um arquivo de configuração no próprio lugar com um redirecionamento enquanto um serviço o está lendo. Escreva em um arquivo temporário no mesmo diretório e coloque-o no lugar com mv: dentro de um mesmo sistema de arquivos, o mv é atômico, então os leitores veem o arquivo antigo ou o novo, nunca um arquivo escrito pela metade.
Limpeza com traps
Um trap executa um comando quando o shell recebe um sinal ou está prestes a sair. É assim que um script limpa a própria bagunça, quer tenha dado certo, falhado ou sido interrompido.
workdir=$(mktemp -d)
cleanup() {
local status=$?
rm -rf -- "$workdir"
(( status == 0 )) || log ERROR "failed with status $status at line $BASH_LINENO"
exit "$status"
}
trap cleanup EXIT
trap 'log WARN "interrupted"; exit 130' INT TERM
EXIT dispara em todos os caminhos de saída, o que faz dele o lugar certo para remover diretórios temporários, liberar um lock ou restaurar um arquivo de configuração. Capture $? na primeira instrução do handler, antes que qualquer outra coisa o sobrescreva.
Um padrão complementar impede que duas cópias de um script periódico rodem ao mesmo tempo:
exec 9> /var/lock/backup.lock
flock -n 9 || { echo 'another run is in progress' >&2; exit 75; }
O lock fica com o descritor de arquivo e é liberado automaticamente quando o processo termina, inclusive se ele for morto — não sobra nenhum arquivo de lock obsoleto para limpar. O flock também existe como wrapper de comandos, algo prático em uma unidade timer: flock -n /var/lock/backup.lock /usr/local/bin/backup.sh.
Um exemplo completo
Todas as peças juntas, em um script que você poderia agendar a partir de um timer do systemd:
#!/usr/bin/env bash
#
# backup-db.sh — dump a PostgreSQL database, compress it, upload it and prune old copies.
set -euo pipefail
readonly DB=${DB:-orders}
readonly DEST=${DEST:-/srv/backups}
readonly KEEP_DAYS=${KEEP_DAYS:-14}
readonly STAMP=$(date +%Y%m%dT%H%M%S)
log() { printf '%s %s\n' "$(date -Is)" "$*" >&2; }
workdir=$(mktemp -d)
trap 'rc=$?; rm -rf -- "$workdir"; exit "$rc"' EXIT
exec 9> /var/lock/backup-db.lock
flock -n 9 || { log 'another backup is running'; exit 75; }
for tool in pg_dump zstd sha256sum; do
command -v "$tool" >/dev/null || { log "missing required tool: $tool"; exit 69; }
done
dump=$workdir/$DB-$STAMP.sql.zst
log "dumping $DB"
pg_dump --format=plain --no-owner "$DB" | zstd -T0 -19 -o "$dump" -q
# Guard against an empty or truncated dump before anything is pruned.
size=$(stat -c %s "$dump")
(( size > 1024 )) || { log "dump is only $size bytes; aborting"; exit 65; }
sha256sum "$dump" > "$dump.sha256"
install -d -m 0750 "$DEST"
mv -- "$dump" "$dump.sha256" "$DEST/"
log "stored $DEST/$(basename "$dump") ($size bytes)"
# Only prune once the new backup is safely in place.
deleted=0
while IFS= read -r -d '' old; do
rm -f -- "$old" "$old.sha256"
(( ++deleted ))
done < <(find "$DEST" -maxdepth 1 -name "$DB-*.sql.zst" -mtime "+$KEEP_DAYS" -print0)
log "pruned $deleted old backup(s)"
Repare na ordem: verificar as ferramentas, pegar o lock, gerar o novo artefato, conferir se ele é plausível, movê-lo para o lugar de forma atômica e só então apagar os antigos. Um script que faz a limpeza primeiro e o dump depois vai acabar deixando você sem backup nenhum.
Linting, testes e formatação
O ShellCheck é, de longe, a ferramenta de maior retorno no desenvolvimento em shell. Ele analisa um script estaticamente e aponta expansões sem aspas, cat desnecessários, variáveis perdidas em subshells, uso incorreto de [ e dezenas de outros bugs reais, cada um com uma página no wiki explicando o raciocínio.
sudo apt install shellcheck # or: dnf install ShellCheck
shellcheck -x -S style deploy.sh # -x follows sourced files
shfmt -i 2 -ci -w deploy.sh # consistent formatting
bash -n deploy.sh # syntax check without executing
bash -x deploy.sh 1.2.3 # trace every command as it runs
Rode o ShellCheck no CI junto com as demais verificações — ele se encaixa naturalmente nos pipelines descritos em entrega contínua. Quando um aviso estiver realmente errado, silencie só aquela ocorrência com um comentário # shellcheck disable=SC2086 e uma nota explicando o motivo, em vez de desativar a verificação globalmente.
Para o comportamento, o bats oferece um pequeno framework de testes, mas dá para ir muito longe apenas escrevendo funções que recebem argumentos e imprimem resultados, e chamando-as a partir de um script de teste. Adicione set -x temporariamente, ou PS4='+ ${BASH_SOURCE}:${LINENO}: ' antes dele, para obter traces que mostram arquivo e linha.
Erros comuns
- Expansões sem aspas.
cp $src $dstquebra no primeiro caminho com espaço. Coloque aspas em tudo e deixe o ShellCheck achar as que escaparem. - Fazer parsing do
ls. A saída dele é para humanos. Use um glob,find -print0oumapfile. - Mandar dados para um loop
whilecom pipe. O loop roda em um subshell e suas variáveis somem. Use redirecionamento ou substituição de processo. - Supor que o
cddeu certo.cd "$dir" && rm -rf ./*é seguro; a mesma coisa em duas linhas semset -eé um desastre. local x=$(cmd)comset -e. A falha fica escondida. Declare e atribua separadamente.- Montar caminhos sem verificação.
rm -rf "$root/$name"com$namevazio apaga a árvore inteira.set -usomado a uma validação explícita evita isso. - Escrever 800 linhas de Bash. Nesse tamanho a linguagem está trabalhando contra você; migre para Python, Go ou uma ferramenta de gerenciamento de configuração.
Principais lições
- Comece todo script com
#!/usr/bin/env bash,set -euo pipefaile um comentário dizendo o que ele faz — e conheça os casos que o-enão consegue pegar. - Interprete as opções com
getopts, valide todas as entradas logo no início, escreva os diagnósticos no stderr e saia com códigos que tenham significado. - Prefira
[[ … ]]para testes e(( … ))para aritmética; usecaseao comparar um valor com vários padrões. - Itere sobre globs e arrays, alimente
while readcom redirecionamento ou substituição de processo, e use-print0/read -d ''para nomes de arquivo arbitrários. - Mantenha as variáveis de função como
local, faça o log no stderr e retorne valores imprimindo-os, não viareturn. trap … EXITgarante a limpeza em todos os caminhos, eflocksobre um descritor de arquivo impede execuções sobrepostas sem arquivos de lock obsoletos.- Deixe as operações destrutivas por último: criar, verificar, mover para o lugar de forma atômica e só então limpar.
- Rode o ShellCheck em todo script e trate os apontamentos dele como falhas de build, não como sugestões.
Fontes e leituras complementares
Perguntas frequentes
- O set -e torna meu script seguro?
- Ajuda, mas tem brechas documentadas. Um comando que falha dentro de uma condição, à esquerda de um && ou em uma função chamada a partir de um if não provoca a saída. Uma substituição de comando dentro de uma atribuição mascara o código de saída, a menos que você declare e atribua em linhas separadas. Encare o preâmbulo estrito como uma rede de segurança que complementa um tratamento de erros cuidadoso, e não como um substituto dele.
- Por que meu loop while perde as variáveis que definiu?
- Cada estágio de um pipeline roda em um subshell, então um loop à direita de um pipe altera uma cópia do ambiente que desaparece quando o pipeline termina. Alimente o loop com um redirecionamento ou uma substituição de processo — done < file, ou done < <(command) — e ele vai rodar no shell atual, onde as atribuições sobrevivem. O Bash também oferece shopt -s lastpipe para shells não interativos.
- Quando devo parar de usar Bash e migrar para Python?
- O Bash é excelente para executar programas e conectar suas entradas e saídas. Ele é fraco em estruturas de dados, aritmética, manipulação de strings, tratamento de erros ao longo de muitas etapas e em tudo que precise de testes. Quando um script passa de algumas centenas de linhas, precisa de dados aninhados, processa JSON ou XML a sério ou precisa de testes unitários, uma linguagem de programação de verdade vai ficar mais curta e muito mais fácil de manter.
- Como lidar com nomes de arquivo que contêm espaços ou quebras de linha?
- Coloque aspas em toda expansão, use arrays em vez de strings separadas por espaços e nunca faça parsing da saída do ls. Com o find, use -print0 junto com xargs -0, ou -exec cmd {} +, que passa os argumentos diretamente sem envolver nenhum shell. Para ler uma lista linha a linha, use IFS= read -r -d '' com entrada separada por caracteres nulos, para que até uma quebra de linha dentro de um nome seja tratada corretamente.