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'
  • -e encerra o script quando um comando retorna um código diferente de zero que não está sendo testado.
  • -u trata 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 impede rm -rf "$PREFIX/data" de virar rm -rf /data.
  • -o pipefail faz um pipeline retornar o código do último estágio que falhou, e não apenas o do último estágio, de modo que curl … | tar -x deixa de reportar sucesso quando o download falhou.
  • Restringir o IFS a 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çãoUse 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 $dst quebra 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 -print0 ou mapfile.
  • Mandar dados para um loop while com pipe. O loop roda em um subshell e suas variáveis somem. Use redirecionamento ou substituição de processo.
  • Supor que o cd deu certo. cd "$dir" && rm -rf ./* é seguro; a mesma coisa em duas linhas sem set -e é um desastre.
  • local x=$(cmd) com set -e. A falha fica escondida. Declare e atribua separadamente.
  • Montar caminhos sem verificação. rm -rf "$root/$name" com $name vazio apaga a árvore inteira. set -u somado 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 pipefail e um comentário dizendo o que ele faz — e conheça os casos que o -e nã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; use case ao comparar um valor com vários padrões.
  • Itere sobre globs e arrays, alimente while read com 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 via return.
  • trap … EXIT garante a limpeza em todos os caminhos, e flock sobre 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.