Guia de sobrevivência Bash e Shell Script (Sério!)
Esse vai para devs que já sabem o básico do Shell Script e Bash e querem subir de nível. Me incluo nisso também. De fato, só estou escrevendo isso porque tenho estudado muito sobre o assunto.

Você vai ler por sua conta e risco. Perceba que o autor desse post não tem nenhum desses nomes: Erich Gamma, Richard Helm, Ralph Johnson e John Vlissides. Então você está vivendo perigosamente.
Tá parei!
Não sei se foi assim com você também, mas eu tive um caso com Bash e Shell Script.
Como tinha acesso ao root do servidor, depois que aprendi #!/bin/bash e
echo "hello world", fui só ladeira abaixo.
Eu te juro que lembro de pesquisar no Google algo como: “Por que alguns scripts
têm #!/bin/bash e outros #!/bin/sh”. (pode ir pesquisar, te espero)
Se você está estranhando o acesso ao servidor, antigamente usávamos tudo direto
mesmo (pelo menos EU, usava). Digitava ssh user@host e usava um programa
chamado FileZilla na porta 21 (seco, inseguro, direto). Segurança? 🤬
Mas estou divagando.
As coisas mudaram (um pouco). Melhorei meu conhecimento (um pouco, também) e sigo estudando. Então considere que você está lendo minhas notas de estudo. Espero que elas sejam úteis pra você, como estão sendo pra mim.
Do que pretendo falar? (caramba velho, você já está no artigo, só rolar, mas…)
- Padrões de tratamento de erro que separam script de brinquedo de script de produção
- Técnicas de manipulação de variáveis que substituem vários dos seus hábitos
com
sedeawk - Mistérios de controle de processo que causam o famoso bug do “ué, por que minha variável ficou vazia?”
- Padrões do mundo real pra parsing de argumentos, config, logs e testes
- Armadilhas de segurança e como não ser atropelado por elas
Eu poderia muito bem corrigir as frases acima. Elas não têm meu tom de escrita. Mas vou manter do jeito que veio da IA por alguns motivos.
Primeiro, este post está sendo escrito faz um tempo e eu não lembrava o que tinha escrito. Segundo, eu não sabia falar tecnicamente o que eram cada uma das coisas que escrevi. Então, fique com uma pitada de IA neste texto.
Eu já enrolei e brinquei demais. E, não comenta com ninguém, mas faço isso pra espantar público desqualificado. Aquele povo só queria copiar algo daqui e sair. Prefiro você, que está disposto a aprender assim como eu.
Já que eles já foram, então agora temos trabalho.
Fundamentos e redes de segurança: escrevendo scripts Bash sem susto
Essa parte aqui é o kit de sobrevivência. Não é a parte “olha como eu sou avançado”. É mais como um básico inicial, mas essencial.
Tratamento de erro sem autoengano
Considero o script abaixo:
#!/bin/bash
cd /tmp/build
rm -rf *
cp -r /src/output/* .
tar czf release.tar.gz *
echo "Pronto!"
Enquanto tudo está bonito, ele funciona.
O problema aparece no dia em que /tmp/build não existe. Nesse caso, o cd
falha e o rm -rf * roda no diretório errado. Parabéns, agora você apagou tudo
do diretório atual. Seu release vai ficar lindo!
O primeiro freio de mão costuma ser este:
set -euo pipefail
Ajuda bastante. Mas eu acabei descobrindo que isso não resolve tudo.
set -e (errexit) manda o script parar quando um comando falha. Show! Só
que ele tem alguns comportamentos estranhos.
Um dos meus favoritos é este aqui:
# Isso engole erros silenciosamente:
local result=$(might_fail)
# Isso captura o erro direito:
local result
result=$(might_fail)
Até ontem, as duas linhas acima não teriam diferença na minha cabeça.
No primeiro caso, o local devolve 0 e esconde a falha do comando. Você segue
com uma variável vazia e ainda acha que está tudo certo.
Outra pegadinha estranha: set -e fica meio “desligado” em alguns contextos,
como comandos usados em if. Então não confia cegamente nele. Entende onde ele
ajuda e onde ele faz pose.
set -u (nounset) transforma variável não definida em erro. Sem isso,
$UNSET_VAR vira string vazia e a festa continua. Em Bash antigo, arrays vazios
podem dar umas respostas mais esquisitas, então vale testar se você ainda
precisa suportar versão jurássica. A maioria dos servidores modernos suporta
versões novas do Bash (e para de ficar mantendo script legado, a IA resolve eles
pra você em segundos).
set -o pipefail corrige outro clássico: pipeline que “passa” porque só o
último comando foi bem. Sem ele, um curl pode falhar e o jq ainda sair com
cara de inocente. Só lembra que head -n1 e amigos podem encerrar o pipe cedo e
gerar SIGPIPE no processo anterior. Quando isso for esperado, trate o caso de
forma explícita.
O modo estrito moderno
Se eu pudesse escolher, prefiro já começar assim (mas não olhe meus dotfiles, só confia no que falo 🤣):
#!/usr/bin/env bash
set -Euo pipefail
shopt -s inherit_errexit # Bash 4.4+
trap 's=$?; echo "$0: Erro na linha $LINENO: $BASH_COMMAND" >&2; exit $s' ERR
Os extras fazem diferença.
set -Eajuda otrap ERRa alcançar função e subshell.inherit_errexitevita outra daquelas “gentilezas” do Bash com$(...).- O
trappelo menos te diz onde deu pepino, em vez de deixar só um silêncio constrangedor e “indebugável” (não copia, acabei de inventar a palavra).
Olha o contraste:
#!/bin/bash
DATA=$(curl -s https://api.example.com/data)
echo "$DATA" | jq '.users[] | .email' > emails.txt
wc -l emails.txt
echo "Exportacao concluida"
Se a API estiver fora, esse script ainda consegue terminar sorrindo e dizendo “exportação concluída”. Maravilhoso. Assim você esperava…
Talvez assim fique melhor. Faça o teste:
#!/usr/bin/env bash
set -Euo pipefail
shopt -s inherit_errexit
trap 's=$?; echo "FALHOU na linha $LINENO: $BASH_COMMAND (saida $s)" >&2; exit $s' ERR
DATA=$(curl -sf https://api.example.com/data)
echo "$DATA" | jq -e '.users[] | .email' > emails.txt
LINES=$(wc -l < emails.txt)
echo "Exportacao concluida: $LINES emails"
curl -f para de fingir que HTTP quebrado é sucesso. jq -e também ajuda a
falhar quando o resultado vem vazio ou null. E o trap te devolve um erro que
dá pra investigar sem precisar abrir uma sessão espírita.
POSIX vs recursos específicos do Bash (ou zsh)
Antes de usar array, [[ ]], mapfile e companhia, decide uma coisa: seu
script é sh ou é Bash? Quando não temos experiencia com isso, achamos que
shell é tudo igual.
#!/bin/sh não significa “shell genérico mágico”. No macOS, costuma cair num
Bash 3.2 antigo. Em Debian e Ubuntu, normalmente é dash. Em Alpine, geralmente
é ash. Cada um tem suas manias, e nenhum deles jura compatibilidade com os
bashismos que a gente usa sem pensar.
O bug clássico é este: você testa localmente com Bash, sobe a mesma coisa num
container Alpine e toma um Syntax error: "(" unexpected na cara.
#!/bin/sh
# Parece inocente. Quebra em praticamente qualquer lugar que nao seja bash.
NAMES=("Alice" "Bob" "Charlie")
for name in "${NAMES[@]}"; do
if [[ "$name" == "Bob" ]]; then
echo "Bob encontrado!"
fi
done
MSG="Hello\tWorld"
echo -e "$MSG"
curl -s https://example.com &>/dev/null
Aí já tem array, [[ ]], echo -e e &>/dev/null. Tudo coisa que pode
explodir ou se comportar diferente fora do Bash.
Se a ideia for ficar em POSIX, algo assim já é bem mais honesto:
#!/bin/sh
set -- "Alice" "Bob" "Charlie"
for name in "$@"; do
case "$name" in
Bob) echo "Bob encontrado!" ;;
esac
done
printf 'Hello\tWorld\n'
curl -s https://example.com >/dev/null 2>&1
Vou falar dele mais adiante, mas o shellcheck pode te ajudar a escrever
scripts que funciona de acordo com o
shebang.
O jeito que eu tenho usado é simples:
- Se o shebang é
#!/bin/sh, escreve POSIX sem jeitinho. - Se você quer recurso do Bash, assume isso logo e usa
#!/usr/bin/env bash. - Também tenho me aventurado no Mac com
#!/usr/bin/env zsh(e já munda muito do Bash para o zsh).
Pra testar, shellcheck -s sh e dash script.sh já evitam bastante dor de
cabeça.
ShellCheck ajuda bastante
Se eu tivesse que te recomendar uma ferramenta só pra escrever shell script menos torto, seria o ShellCheck. Ele não pega só estilo. Ele pega bug que morde.
Um aviso famoso é o SC2115:
# ShellCheck avisa: SC2115
# Use "${var:?}" para garantir que isso nunca expanda para /*
rm -rf "$STEAMROOT/"*
Se $STEAMROOT vier vazio, isso pode virar rm -rf /*. Não é paranoia. Já
aconteceu com o cliente Linux da Steam. A forma segura é exigir que a variável
exista de verdade com ${STEAMROOT:?}.
Cinco avisos que vivem aparecendo:
- SC2086: coloca aspas nas variáveis.
- SC2155: separa declaração de atribuição.
- SC2164: não trata
cdcomo decoração. - SC2046: coloca aspas em substituição de comando também.
- SC2162: usa
read -r.
Se quiser integrar no projeto:
# .shellcheckrc
shell=bash
enable=all
severity=style
source-path=SCRIPTDIR
Às vezes também vem warning chato, aí eu só desativo mesmo. Às vezes ele não
enxerga o arquivo que você está usando para source e eu também não estou muito
animado a configurar (nem sei se dá pra configurar).
# shellcheck disable=SC1091
. "${HOME}/dotfiles/zsh/config/functions"
No CI, eu faria algo assim:
- name: ShellCheck
run: find . -name '*.sh' -print0 | xargs -0 shellcheck --severity=warning
E no pre-commit:
# .pre-commit-config.yaml
repos:
- repo: https://github.com/koalaman/shellcheck-precommit
rev: v0.10.0
hooks:
- id: shellcheck
Quando NÃO usar Bash
Bash é ótimo como cola. Como linguagem de propósito geral, pode ser mais trabalhoso do que o necessário.
Eu gosto de Bash quando a tarefa é:
- ligar comando com comando
- mover arquivo, chamar processo, organizar pipeline
- fazer automação curta
- ser entrypoint, script de deploy ou etapa de CI
Eu já começo a desconfiar quando aparece isso aqui:
- estrutura de dados mais séria
- parsing de JSON, YAML ou XML virando rotina
- HTTP com retry, autenticação e tratamento de erro mais chato
- muito
awkesedsegurando a arquitetura nas costas - regra de negócio crescendo dentro do shell
Não existe número mágico, mas quando um script começa a crescer e a lógica vai ficando mais cheia de desvio, vale parar e perguntar: “isso aqui ainda é Bash ou eu só estou teimando?”.
Às vezes 200 linhas em Bash ficam ótimas. Às vezes 50 já passaram da conta. O critério, pra mim, é menos tamanho e mais sofrimento.
Hoje, eu tendo a gostar mais do script Bash que sabe seu lugar: orquestra bem, falha cedo e passa o trabalho pesado pra ferramenta certa. Python é um ótimo substituto do Bash para lógicas mais complexas, mas aí já envolve subir o interpretador. Você escolhe.
Variáveis e expansões avançadas: além de $var
Muita gente aprende name="world" e echo "$name" e para ali. Eu também fiquei
um bom tempo nessa. Só que Bash começa a ficar menos sofrido quando você usa as
expansões direito e para de abrir subprocesso por qualquer coisinha.
Arrays: pare de fingir com string separada por espaço
Isso aqui é a minha cara:
files="file1.txt file2.txt file3.txt"
… mas em minha defesa, eu nem sabia que o Bash tinha arrays. Olha só:
# Declaracao
files=("report Q1.csv" "data 2024.json" "notes.txt")
# Iteracao - o jeito mais seguro
for f in "${files[@]}"; do
echo "Processando: $f"
done
Esse "${files[@]}" com aspas não é frescura. É o que impede report Q1.csv de
virar dois argumentos.
Se precisar de mapa, tem array associativo:
declare -A http_codes=(
[200]="OK"
[404]="Not Found"
[500]="Internal Server Error"
)
echo "${http_codes[404]}" # Not Found
echo "${!http_codes[@]}" # Chaves: 200 404 500
echo "${#http_codes[@]}" # Quantidade: 3
Sem declare -A, o Bash faz o favor de interpretar chave string como expressão
aritmética. Adivinha o tipo de bug bonito que sai daí.
Exemplo mais útil:
declare -A status_counts
while IFS= read -r line; do
# Extrai o status HTTP (quinto campo do common log format)
status="${line##* \" }"
status="${status%% *}"
(( status_counts[$status]++ ))
done < access.log
# Relatorio
for code in "${!status_counts[@]}"; do
printf "%-6s %d\n" "$code" "${status_counts[$code]}"
done | sort -t' ' -k2 -rn
Quando o dado vai continuar vivo dentro do script, isso costuma ser melhor do que transformar tudo em texto e depois parsear de novo.
Pra carregar arquivo em array:
# Seguro: lida com espacos e nao faz glob
mapfile -t lines < input.txt
# Tambem funciona (alternativa para bash 3.x)
IFS=$'\n' read -r -d '' -a lines < input.txt || true
# Evite isto: globbing + word splitting destroem seus dados
lines=( $(cat input.txt) ) # NAO FACA ISSO
mapfile resolve isso. O $(cat file) é o tipo de coisa que parece funcionar
até o dia em que a entrada vem com espaço, * ou qualquer outra surpresinha
simpática.
Expansão de parâmetros
Aqui começa a parte divertida do Bash. Não divertida no sentido “uau, que linguagem elegante”. Divertida no sentido “pera, eu realmente não preciso abrir um processo pra isso?”.
Só pra constar, eu não sei isso de cor. Toda vez que preciso de algo assim: Google. O problema é você não conhecer e nem saber o que pesquisar. Olha que massa:
filepath="/home/deploy/app/config.tar.gz"
echo "${filepath##*/}" # config.tar.gz (basename, remove prefixo de forma gulosa)
echo "${filepath%/*}" # /home/deploy/app (dirname, remove sufixo de forma curta)
echo "${filepath%%.*}" # /home/deploy/app/config (remove TODAS as extensoes)
echo "${filepath%.gz}" # /home/deploy/app/config.tar (remove so a ultima extensao)
Sua colinha de bolso:
#corta da esquerda%corta da direita- uma vez = menor correspondência
- dobrado = maior correspondência
Renomear extensão fica simples. E eu estava precisando disso agora mesmo pra fazer isso:
# Agora suas imagens .png com fundo transparente tem um fundo preto.
# De nada 🥸 (a pasta mess aí é pra me lembrar de limpar minha própria bagunça)
for f in "${HOME}"/Desktop/mess/fotos/*.png; do
########################## só isso pra trocar a extensão ↓ ↓
magick "$f" -background black -alpha remove -alpha off "${f%.png}.jpg"
done
Busca e substituição também:
msg="ERROR: connection failed: timeout: host=db.prod"
echo "${msg//:/ -}" # Substitui todos os : por -
echo "${msg//:/}" # Remove todos os :
echo "${msg/#ERROR/WARN}" # Substitui so se estiver no inicio
echo "${msg/%prod/staging}" # Substitui so se estiver no fim
E tem as expansões de default, que eu uso bastante. Você seria obrigado a usar isso se fosse fazer um script direito, como vimos anteriormente. Se uma variável não tiver valor, dá erro e ele nem roda. Isso resolve:
# ${var:-default} - usa o default se var estiver vazia OU nao definida
db_host="${DB_HOST:-localhost}"
# ${var:=default} - mesma coisa, mas TAMBEM atribui o valor a var
: "${CACHE_TTL:=3600}" # Define CACHE_TTL se ainda nao existir
# ${var:+alternate} - usa alternate so se var EXISTIR e nao estiver vazia
auth_header="${API_TOKEN:+Authorization: Bearer $API_TOKEN}"
# ${var:?message} - sai com erro se var estiver vazia ou nao definida
: "${DATABASE_URL:?DATABASE_URL precisa estar definida}"
:= é útil pra inicializar. :? é ótimo pra falhar cedo sem escrever aquele
bloco if [ -z ... ] pela milésima vez.
Se você estiver num Bash 4+, ainda ganha conversão de caixa:
name="john DOE"
echo "${name^^}" # JOHN DOE (maiusculas)
echo "${name,,}" # john doe (minusculas)
echo "${name^}" # John DOE (capitaliza o primeiro caractere)
É pouca coisa? Sim. Mas já corta subprocesso besta com tr.
O inferno das aspas
Se eu tivesse que escolher uma coisa pra martelar em shell script, seria aspas.
Metade dos bugs estranhos de Bash parece ter sido criada num laboratório só pra
te lembrar disso. Sabe aquele tipo de regra cheio de ifs? Sãs as aspas no Bash
(principalmente as duplas).
#!/bin/bash
cleanup() {
local dir=$1
for file in $(ls "$dir"); do
rm "$dir/$file"
done
}
cleanup "/tmp/my app/cache"
Esse script é uma fábrica de problema. $(ls "$dir") faz word splitting. Aí o
arquivo com espaço vira dois. E você só percebe quando apagou a coisa errada ou
quando nada funciona no servidor “sem motivo”.
Pro tip: Se você está começando agora, já saiba disso: rm não tem volta.
Começa com echo rm algumacoisa. E veja o comando que vai ser executado
primeiro. Eu faço isso toda hora. Aqui o histórico do meu zsh que não me deixa
mentir:
: 1776691649:0;for f in ~/Desktop/mess/fotos/*.*; do echo magick "$f" -background black -alpha remove -alpha off "${f%(.png|.jpg)}.jpg" ; done
: 1776691666:0;for f in ~/Desktop/mess/fotos/*.*; do magick "$f" -background black -alpha remove -alpha off "${f%(.png|.jpg)}.jpg" ; done
O que? Achou que era mentira, né? Te peguei!
O mínimo aceitável fica assim:
cleanup() {
local dir=$1
for file in "$dir"/*; do
[[ -f "$file" ]] && rm -- "$file"
done
}
Três regras que ajudam muito:
- Coloca aspas em expansão de variável.
- Usa
"$@"pra repassar argumentos. - Prefere
[[ ]]a[ ]quando estiver em Bash.
Sobre "$@" vs $*, olha isso:
# Suponha que o script foi chamado assim: ./script "hello world" "foo bar"
echo "--- \$* ---"
for arg in $*; do echo " [$arg]"; done
# [hello] [world] [foo] [bar] - 4 args. Quebrado.
echo "--- \"\$*\" ---"
for arg in "$*"; do echo " [$arg]"; done
# [hello world foo bar] - 1 arg. Tambem quebrado.
echo "--- \"\$@\" ---"
for arg in "$@"; do echo " [$arg]"; done
# [hello world] [foo bar] - 2 args. Correto.
"$@" preserva os argumentos do jeito que eles vieram. "$*" tem seus usos,
mas quase nunca é o que você quer.
E sobre [[ ]]:
# Fragil - quebra com valores vazios ou com espacos
[ "$var" = "" ] && echo "vazia"
# Robusto - sem ginastica com aspas
[[ -z $var ]] && echo "vazia"
# Bonus: regex (so em [[ ]])
[[ "$email" =~ ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ ]] && echo "valido"
Variáveis indiretas e nameref: dinamismo sem eval
Quando você precisa usar o nome de uma variável guardado dentro de outra, eval
costuma ser a primeira ideia ruim que aparece.
# PERIGOSO - nunca faca isso com entrada nao confiavel
key="PATH"
eval "echo \$$key" # Funciona, mas eval interpreta codigo arbitrario
Funciona? Funciona. E também abre a porta pra você executar coisa que não devia.
Pra ler o valor de outra variável, usa expansão indireta:
key="HOME"
echo "${!key}" # /home/deploy - expande a variavel cujo nome esta em $key
Se precisar mesmo de referência, aí entra declare -n:
declare -n ref="HOME"
echo "$ref" # /home/deploy
ref="/opt/app" # Isso ALTERA $HOME (cuidado!)
Caso real:
load_config() {
local config_file="$1"
while IFS='=' read -r key value; do
# Ignora comentarios e linhas em branco
[[ "$key" =~ ^[[:space:]]*# ]] && continue
[[ -z "$key" ]] && continue
# Remove espacos
key="${key## }" ; key="${key%% }"
value="${value## }" ; value="${value%% }"
# Exporta como variavel de ambiente em maiusculas
declare -g "${key^^}=$value"
done < "$config_file"
}
# config.env:
# db_host = postgres.prod
# db_port = 5432
# cache_ttl = 3600
load_config "config.env"
echo "$DB_HOST" # postgres.prod
echo "$CACHE_TTL" # 3600
Sem eval, sem source, sem transformar config em código executável.
Outra situação boa pra nameref é “retorno múltiplo”:
# Uma funcao que devolve o valor em variaveis escolhidas pelo chamador
parse_semver() {
local version="$1"
declare -n major_ref="$2"
declare -n minor_ref="$3"
declare -n patch_ref="$4"
major_ref="${version%%.*}"
local rest="${version#*.}"
minor_ref="${rest%%.*}"
patch_ref="${rest#*.}"
}
parse_semver "3.14.2" maj min pat
echo "$maj.$min.$pat" # 3.14.2
É um jeito limpo de fazer a função escrever no que o chamador escolheu, sem arquivo temporário e sem gambiarra global.
Nota de versão: arrays associativos exigem Bash 4.0+, conversão de caixa (
^^,,,) exige 4.0+,mapfileexige 4.0+, edeclare -nexige 4.3+. O macOS ainda vem com Bash 3.2 por causa da novela da GPLv3. Então, se você precisa suportar macOS puro, ou instala Bash novo combrew install bash, ou segura a onda com recursos mais antigos.
Controle de processos e sinais: aqui o shell começa a aprontar
Quando Bash fica estranho de verdade, geralmente não é por causa de variável. É
por causa de processo. A variável sumiu? Talvez ela tenha nascido num subshell.
O Ctrl-C não limpou nada? Talvez você não amarrou os sinais. O nohup não
virou daemon? Porque ele nunca prometeu isso.
Substituição de processo: quando pipe não resolve
Pipe é ótimo quando o próximo comando quer stdin. Nem sempre ele quer.
Nesses casos, a substituição de processo quebra um galho enorme:
<(list) # um pseudo-arquivo legivel com o stdout de list
>(list) # um pseudo-arquivo gravavel alimentando o stdin de list
O Bash te entrega algo via /dev/fd ou FIFO pra você plugar em comando que
espera caminho de arquivo.
Exemplo clássico:
comm -3 <(sort prod-packages.txt) <(sort staging-packages.txt)
Ou com diff:
diff -u <(sort before.txt) <(sort after.txt)
Sem arquivo temporário perdido em /tmp, sem cleanup extra só pra comparar duas
coisas.
O >(...) também é útil:
tar -cf - project \
| tee >(sha256sum > project.tar.sha256) \
> project.tar
Você gera o tar uma vez só e ainda calcula o hash no caminho.
Agora, o bug clássico:
last=""
printf '%s\n' alpha beta | while IFS= read -r line; do
last=$line
done
printf 'last=%s\n' "$last"
Se isso te devolveu variável vazia, bem-vindo ao clube. O loop rodou num subshell por causa do pipeline. O shell pai não ficou sabendo de nada.
Quando o estado precisa sobreviver, eu prefiro assim:
last=""
while IFS= read -r line; do
last=$line
done < <(printf '%s\n' alpha beta)
printf 'last=%s\n' "$last"
Ou, se já for arquivo:
while IFS= read -r line; do
last=$line
done < input.txt
Tem lastpipe? Tem. Eu basearia convenção de time nisso? Nem a pau.
Se a lógica mexe em estado que você ainda vai usar, eu evitaria pipear pra dentro dela (mais uma palavra inventada neste post).
Traps: cleanup de verdade
Se o script cria temporário, lock, socket ou processo em background, cleanup deixa de ser detalhe bem rápido. Se você deixa pra pensar nisso no fim, normalmente já deu tempo de se arrepender.
Um padrão que costuma me servir:
#!/usr/bin/env bash
set -euo pipefail
tmpdir=$(mktemp -d)
worker_pid=""
cleanup() {
[[ -n "$worker_pid" ]] && kill "$worker_pid" 2>/dev/null || true
rm -rf "$tmpdir"
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
(
while :; do
date +%s >>"$tmpdir/heartbeat.log"
sleep 2
done
) &
worker_pid=$!
printf 'worker=%s temp=%s\n' "$worker_pid" "$tmpdir"
wait "$worker_pid"
Se o script só limpa no caminho feliz, ele não limpa. Só teve sorte.
Coisas que eu tento não esquecer:
EXITé onde eu quase sempre penduro teardown.INTeTERMmerecem atenção.ERRajuda no diagnóstico, mas não substitui fluxo pensado.SIGKILLnão negocia com ninguém.
E não, o Bash não vira supervisor só porque você colocou um & no final.
Guardou PID? Sabe quem precisa morrer? Sabe o que acontece se o shell ignorar um
sinal? Isso tudo importa.
Job control: bom no terminal, outra história em script
No shell interativo, job control é uma mão na roda:
$ rsync -av --info=progress2 big-tree/ backup:/srv/backup/
^Z
[1]+ Stopped rsync -av --info=progress2 big-tree/ backup:/srv/backup/
$ bg %1
[1]+ rsync -av --info=progress2 big-tree/ backup:/srv/backup/ &
$ jobs
[1]+ Running rsync -av --info=progress2 big-tree/ backup:/srv/backup/ &
$ fg %1
rsync -av --info=progress2 big-tree/ backup:/srv/backup/
Pra esse tipo de uso, ele quebra um galho bonito. Você pausa, manda pro background, traz de volta. Beleza.
O tropeço vem quando a gente começa a tratar isso como gerenciamento de serviço. Não é.
Se quiser manter o job vivo depois do logout:
bg %1
disown -h %1
Ou já inicia com nohup:
nohup rsync -av --info=progress2 big-tree/ backup:/srv/backup/ \
>rsync.log 2>&1 &
Lembra do detalhe: nohup não manda pro background sozinho. O & continua
necessário.
Se o processo realmente importa, eu tenderia a jogar isso pra systemd, tmux,
screen, systemd-run ou outro supervisor de verdade. disown e nohup
resolvem um problema bem mais simples.
Subshells vs grupos de comando
Essa diferença parece boba até o dia em que você toma uma rasteira:
(commands) # roda em um subshell
{ commands; } # roda no shell atual
Com parênteses, mudança de diretório, variável e opções do shell morrem ali dentro. Com chaves, elas continuam valendo.
(cd /tmp; printf 'dentro de (): %s\n' "$PWD")
printf 'depois de (): %s\n' "$PWD"
{ cd /tmp; printf 'dentro de {}: %s\n' "$PWD"; }
printf 'depois de {}: %s\n' "$PWD"
E é a mesma ideia por trás do contador que fica em 0:
count=0
printf '%s\n' a b c | while IFS= read -r _; do
((count++))
done
echo "$count" # 0
count=0
while IFS= read -r _; do
((count++))
done < <(printf '%s\n' a b c)
echo "$count" # 3
O resumo que eu tento guardar é este:
- usa
(...)quando quiser isolamento - usa
{ ...; }quando quiser manter efeito colateral - evita pipeline quando a lógica precisa alterar estado do shell atual
Parece detalhe. Não é. É o tipo de detalhe que decide se o script é previsível ou se vai ter comportamento de poltergeist.
Padrões do mundo real: Bash de produção
Quando eu penso em Bash de produção, eu não penso em “script elegante”. Eu penso em script previsível. Aquele troço chato, explícito e um pouco paranoico, mas que não inventa surpresa ruim.
Parsing de argumentos sem gambiarra
getopts já resolve muita coisa. Não é framework de CLI, não vai te dar
subcomando bonitinho, mas pra muito script real ele basta.
O problema é que muita gente acha que parsing acaba quando você conseguiu ler
-v e -o. Não acaba. Parsing também é validar aridade, formato e tudo aquilo
de que o resto do script depende.
Esse é o tipo de padrão que eu gosto de ter por perto:
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'USAGE'
Usage: report-sync [-v] [-o file] [-c config] job_name source_dir
USAGE
}
die() {
printf 'ERROR: %s\n' "$*" >&2
exit 1
}
verbose=0
output_file=
config_file=
job_name=
source_dir=
normalize_args() {
local normalized=()
while (($#)); do
case "$1" in
--verbose) normalized+=(-v) ;;
--output)
(($# >= 2)) || die "--output requires a file path"
normalized+=(-o "$2")
shift
;;
--config)
(($# >= 2)) || die "--config requires a path"
normalized+=(-c "$2")
shift
;;
--help) normalized+=(-h) ;;
--)
shift
while (($#)); do
normalized+=("$1")
shift
done
break
;;
*) normalized+=("$1") ;;
esac
shift
done
printf '%s\0' "${normalized[@]}"
}
parse_args() {
local -a argv=()
while IFS= read -r -d '' token; do
argv+=("$token")
done < <(normalize_args "$@")
set -- "${argv[@]}"
OPTIND=1
while getopts ':vo:c:h' opt; do
case "$opt" in
v) verbose=1 ;;
o) output_file=$OPTARG ;;
c) config_file=$OPTARG ;;
h)
usage
exit 0
;;
:)
die "Option -$OPTARG requires an argument"
;;
\?)
usage >&2
die "Unknown option: -$OPTARG"
;;
esac
done
shift $((OPTIND - 1))
(($# == 2)) || die 'Expected job_name and source_dir'
job_name=$1
source_dir=$2
[[ $job_name =~ ^[a-zA-Z0-9._-]+$ ]] || die "Invalid job name: $job_name"
[[ -d $source_dir ]] || die "Source directory not found: $source_dir"
if [[ -n ${output_file:-} ]]; then
local parent_dir
parent_dir=$(dirname -- "$output_file")
[[ -d $parent_dir ]] || die "Output directory does not exist: $parent_dir"
fi
}
parse_args "$@"
Algumas escolhas aí eu manteria, pelo menos hoje:
- normalizar opção longa na mão em vez de depender de
getoptexterno - validar logo depois do parsing
- falhar cedo quando caminho, nome ou formato vierem errados
Se o script começa a pedir subcomando, config aninhada e um monte de modo mutuamente exclusivo, é um bom sinal de que talvez já tenha passado da hora de sair do Bash.
Arquivos de configuração: dados, não código surpresa
Eu evito source pra config sempre que posso. source não lê “dados”. Ele
executa shell dentro do shell atual. Se isso é o que você quer, beleza. Se não
é, você acabou de transformar configuração em código executável sem nem
perceber.
Pra config simples, KEY=VALUE ainda é um formato honesto. Fácil de versionar,
de sobrepor com variável de ambiente e de entender sem sofrimento.
Este loader aqui segue essa ideia e já recusa sintaxe shell no valor:
#!/usr/bin/env bash
set -euo pipefail
readonly DEFAULT_CONFIG="$HOME/.myapprc"
readonly SYSTEM_CONFIG="/etc/myapp.conf"
config_file_for_current_run=
find_config_file() {
local candidate
for candidate in "${MYAPP_CONFIG:-}" "$DEFAULT_CONFIG" "$SYSTEM_CONFIG"; do
[[ -n ${candidate:-} && -f $candidate ]] || continue
config_file_for_current_run=$candidate
return 0
done
return 1
}
die() {
printf 'ERROR: %s\n' "$*" >&2
exit 1
}
load_config_file() {
local file=$1 line lineno=0 key value
[[ -f $file ]] || die "Config is not a regular file: $file"
while IFS= read -r line || [[ -n $line ]]; do
((lineno++))
[[ $line =~ ^[[:space:]]*($|#) ]] && continue
[[ $line =~ ^[[:space:]]*([A-Z_][A-Z0-9_]*)=(.*)$ ]] \
|| die "Sintaxe de config nao suportada em $file:$lineno"
key=${BASH_REMATCH[1]}
value=${BASH_REMATCH[2]}
[[ ! $value =~ [\`\$\(\)\;\&\|\<\>] ]] \
|| die "Recusando sintaxe shell em $file:$lineno"
value=${value##[[:space:]]}
value=${value%$'\r'}
if [[ $value =~ ^"(.*)"$ ]]; then
value=${BASH_REMATCH[1]}
elif [[ $value =~ ^\'(.*)\'$ ]]; then
value=${BASH_REMATCH[1]}
fi
printf -v "$key" '%s' "$value"
export "$key"
done < "$file"
}
if find_config_file; then
load_config_file "$config_file_for_current_run"
fi
Eu deixei ele rígido de propósito. Se alguém quiser array, objeto aninhado e meia dúzia de regra de escape, melhor trocar de formato logo em vez de inventar uma mini linguagem no shell.
Pra JSON, usa jq:
api_base_url=$(jq -er '.api_base_url' "$config_json")
concurrency=$(jq -er '.concurrency // 4' "$config_json")
Pra mim, costuma funcionar bem assim:
- arquivo de config pros defaults estáveis
- variável de ambiente pros overrides do deploy e pros segredos
Logging e debug sem virar bagunça
Log bom em shell, pra mim, tem duas regras:
- humano lê em
stderr - dado da pipeline vai limpo em
stdout
Misturou os dois, você transformou um script reutilizável num bicho irritante.
Uma camada simples já ajuda bastante:
LOG_LEVEL=${LOG_LEVEL:-info}
LOG_FILE=${LOG_FILE:-}
level_number() {
case "$1" in
debug) printf '10\n' ;;
info) printf '20\n' ;;
warn) printf '30\n' ;;
error) printf '40\n' ;;
*) return 1 ;;
esac
}
log() {
local level=${1:?missing log level}
shift
local current threshold ts line
current=$(level_number "${level,,}") || return 1
threshold=$(level_number "${LOG_LEVEL,,}") || threshold=20
(( current < threshold )) && return 0
printf -v ts '%(%Y-%m-%dT%H:%M:%S%z)T' -1
printf -v line '%s %-5s %s' "$ts" "${level^^}" "$*"
printf '%s\n' "$line" >&2
[[ -n ${LOG_FILE:-} ]] && printf '%s\n' "$line" >> "$LOG_FILE"
}
Com isso, você ganha nível, timestamp e arquivo de log sem sujar stdout.
Quando eu quero espelhar stream, ainda gosto de tee:
exec 2> >(tee -a "$LOG_FILE" >&2)
set -x ajuda, mas a saída crua costuma ser barulhenta demais. O truque abaixo
deixa o trace num descritor separado e com contexto que presta:
exec 9>>"${TRACE_FILE:-/tmp/report-sync.trace}"
export BASH_XTRACEFD=9
export PS4='+ ${BASH_SOURCE##*/}:${LINENO}:${FUNCNAME[0]:-main}: '
set -x
# bloco suspeito aqui
set +x
É o tipo de coisa que vale guardar porque economiza um bom tempo quando o script começa a se comportar como se estivesse possuído.
Testando script Bash sem sofrimento
Dá pra testar shell script, sim. E eu acho que vale bastante quando o script começa a lidar com flag, config, arquivo ou efeito colateral.
Se for um scriptinho de 15 linhas em volta de um comando, talvez nem precise. Agora, se ele apaga coisa, faz parsing e conversa com produção, eu prefiro ter algum teste.
bats me
atende bem porque mantém tudo no ecossistema do shell.
Um teste útil, pro loader de config acima, seria algo assim:
#!/usr/bin/env bats
load '../lib/config.sh'
@test "load_config_file rejeita sintaxe shell em valores" {
config="$BATS_TEST_TMPDIR/bad.conf"
printf 'OUTPUT_FILE=$(touch /tmp/nope)\n' > "$config"
run load_config_file "$config"
[ "$status" -eq 1 ]
[[ "$output" == *"Recusando sintaxe shell"* ]]
}
Esse teste protege uma fronteira que, pra mim, importa bastante: config tem que continuar sendo dado.
Pra mockar comando externo, eu gosto do jeito simples: diretório temporário no
começo do PATH e executável falso lá dentro. Menos frágil do que tentar
inventar mágica com alias.
Pra mim, testabilidade em Bash costuma andar junto com design menos bagunçado. Quando parsing, config e efeito colateral ficam separados em funções pequenas, o teste deixa de ser castigo.
Segurança e produção: onde Bash para de ser brincadeira
Quando o script começa a rodar em servidor, CI, container ou qualquer lugar com entrada menos controlada, a história muda rápido. O que no notebook era só “script útil” vira superfície de ataque sem pedir licença.
As armadilhas de segurança
Injeção de comando: o botão vermelho do eval
Se você monta comando a partir de entrada de usuário, está perigosamente perto de entregar um shell de presente.
O script vulnerável:
#!/bin/bash
# "Visualizador simples" de logs - NAO USE ISSO
echo "Digite o nome do arquivo:"
read -r filename
eval "cat logs/$filename"
O atacante pode meter um ;, um $(...) ou qualquer outra gracinha. E, às
vezes, nem precisa ser algo espalhafatoso pra causar estrago.
Pra mim, aqui não adianta tentar “sanitizar melhor”. O que ajuda de verdade é parar de tratar dado como código:
#!/bin/bash
read -r filename
# Valida: somente letras, numeros, traco, ponto e underscore
if [[ ! "$filename" =~ ^[a-zA-Z0-9._-]+$ ]]; then
echo "Nome de arquivo invalido" >&2
exit 1
fi
# Usa a variavel como DADO, nao como CODIGO
# Sem eval, sem expansao desprotegida, sem montar comando na mao
cat -- "logs/$filename"
O -- evita interpretar nome como opção. O regex barra ../../etc/passwd e
coisa parecida. Aqui a ideia é simples: cada camada corta um pedaço do problema.
Quando bate vontade de usar eval, eu paro e desconfio. Na maior parte das
vezes dá pra fazer melhor. Quando eu realmente preciso montar comando, costumo
ir de array:
cmd=(find "$directory" -name "*.log" -mtime +"$days")
"${cmd[@]}"
Corrida com arquivo temporário: mktemp ou arrependimento
# VULNERAVEL: nome previsivel, condicao de corrida
tmpfile="/tmp/myapp-$$"
echo "$data" > "$tmpfile"
Esse padrão com PID no nome parece “único o bastante” até o dia em que alguém cria um symlink ali antes de você. Aí o seu script grava dado onde não devia e a brincadeira acaba.
Um padrão bem melhor:
#!/bin/bash
set -euo pipefail
tmpdir=$(mktemp -d "${TMPDIR:-/tmp}/myapp.XXXXXXXXXX")
trap 'rm -rf -- "$tmpdir"' EXIT
# Agora use $tmpdir com seguranca - aleatorio, dono voce, modo 0700
echo "$data" > "$tmpdir/output.txt"
mktemp cria de forma atômica e com permissão restritiva. O trap limpa
depois. E diretório temporário costuma ser mais prático do que arquivo
temporário solto.
Segredos: o que NÃO fazer
# Tudo isso aqui esta errado:
PASSWORD="hunter2" # Hardcoded no codigo
curl -u "admin:$PASSWORD" "$url" # Visivel em /proc/$pid/cmdline
echo "$SECRET" | docker login --stdin # Melhor, mas cuidado com historico do shell
export DB_PASS="secret" # Herdado por TODO processo filho
Em shell, segredo espalha fácil demais. Vai pro histórico, vai pro ps, vai pro
ambiente do processo filho, vai parar onde você não queria.
Quando der, eu prefiro ler de arquivo/FD ou deixar a plataforma injetar só no momento do uso:
# Le segredo de file descriptor, sem tocar disco nem aparecer em ps
db_password=$(< /run/secrets/db_password)
PGPASSWORD="$db_password" psql -h "$host" -U "$user" "$dbname" <<< "$query"
Quando o Bash fica lento (e quando isso não importa)
Bash costuma ir melhor abrindo processo do que processando dado em loop apertado. Quando o script fica lento, muitas vezes o problema é um festival de fork.
A versão lenta: 3 forks externos por iteração.
# Processando 10.000 linhas: ~45 segundos
while IFS= read -r line; do
name=$(echo "$line" | cut -d',' -f1)
value=$(echo "$line" | cut -d',' -f2)
echo "$name: $value"
done < data.csv
A versão rápida: zero fork.
# Processando 10.000 linhas: ~0.3 segundos
while IFS=, read -r name value _rest; do
printf '%s: %s\n' "$name" "$value"
done < data.csv
A diferença está aí: o primeiro exemplo cria processo demais. O segundo deixa o shell fazer o que ele consegue fazer sem sair abrindo ferramenta externa em cada linha.
Pra paralelismo de verdade, muitas vezes é melhor sair do loop manual:
# Processa arquivos em paralelo, 8 por vez
find . -name '*.log' -print0 | xargs -0 -P 8 -I{} gzip {}
# GNU parallel com barra de progresso
find . -name '*.csv' | parallel --bar -j+0 'process_file {}'
A regra prática que eu tento lembrar é: shell lento quase sempre tem loop com fork demais.
CI/CD: códigos de saída são sua API
CI é binário: saiu 0, passou. Qualquer outra coisa, falhou. Então o script que
roda ali precisa ser bem honesto com código de saída:
#!/bin/bash
set -euo pipefail
Sem pipefail, o pipeline pode mascarar erro feio e o build passar com cara de
vencedor.
ShellCheck no CI
ShellCheck no CI é um daqueles freios baratos que compensam muito:
# .github/workflows/lint.yml
name: Shell Lint
on: [push, pull_request]
jobs:
shellcheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run ShellCheck
run: |
find . -name '*.sh' -print0 | xargs -0 shellcheck --severity=warning
- name: Run BATS tests
run: |
sudo apt-get install -y bats
bats tests/
Com ShellCheck e algum teste em Bats, eu já fico bem mais tranquilo com o script.
Docker: o problema do PID 1
Docker com shell tem uma pegadinha bem comum: PID 1. Se a sua app vira filha do Bash e o Bash não repassa sinal direito, o container morre de forma feia.
Um entrypoint que eu usaria com mais tranquilidade em produção:
#!/bin/bash
set -euo pipefail
# --- Configuracao via ambiente ---
: "${APP_PORT:=8080}"
: "${APP_ENV:=production}"
: "${GRACEFUL_SHUTDOWN_TIMEOUT:=30}"
# --- Espera dependencias ---
wait_for_db() {
local retries=30
while ! pg_isready -h "$DB_HOST" -p "${DB_PORT:-5432}" -q; do
retries=$((retries - 1))
if [[ $retries -le 0 ]]; then
echo "ERROR: Banco de dados indisponivel apos 30 tentativas" >&2
exit 1
fi
sleep 1
done
}
# --- Tratamento de sinais ---
shutdown_handler() {
echo "Sinal de encerramento recebido, drenando conexoes..."
# Da tempo pro load balancer remover a instancia
sleep 2
kill -TERM "$child_pid" 2>/dev/null || true
wait "$child_pid"
exit_code=$?
echo "Aplicacao saiu com codigo $exit_code"
exit "$exit_code"
}
trap shutdown_handler SIGTERM SIGINT
# --- Init ---
wait_for_db
echo "Iniciando app em :${APP_PORT} (env=${APP_ENV})"
# --- Launch: exec substitui o shell SE nao houver pos-processamento ---
# Quando voce precisa encaminhar sinais (como no tratador acima), NAO use exec.
# Em vez disso, jogue o processo para background e espere.
/usr/local/bin/myapp --port "$APP_PORT" &
child_pid=$!
wait "$child_pid"
Daqui, eu guardo basicamente isto:
execsubstitui o shell pelo processo final quando você não precisa de lógica extra.background + waitentra quando você realmente precisa interceptar sinal.- Alpine não te dá Bash por padrão.
--initcomtiniàs vezes já resolve sem precisar reinventar nada.
Padrões de deploy
Publicando sem surpresa
Pra script standalone, eu gosto de falhar cedo se a versão do Bash não for a necessária:
#!/bin/bash
# Checagem de versao no topo - falha rapido, nao no meio do caminho
if [[ "${BASH_VERSINFO[0]}" -lt 4 ]]; then
echo "ERROR: Bash 4+ obrigatorio (encontrado ${BASH_VERSION})" >&2
exit 1
fi
Idempotência ajuda bastante
Script de produção roda de novo. Retry, cron, recuperação, operador apertando seta pra cima sem querer pensar muito. Então idempotência ajuda bastante:
# Idempotente: cria so se nao existir
mkdir -p /opt/myapp/data
id -u appuser &>/dev/null || useradd -r -s /bin/false appuser
# Idempotente: move so se a origem existir
[[ -f "$src" ]] && mv -- "$src" "$dst"
Quando parar de usar Bash
Bash é bom pra colar programa, mover arquivo, orquestrar etapa. Começa a ficar ruim quando:
- você está parseando JSON/YAML o tempo todo
- você precisa de estrutura de dados de verdade
- o script cresce e ninguém mais quer encostar
- recovery de erro e concorrência viram parte do problema
Nessa hora, chamar Python ou Go não é derrota. Muitas vezes é só bom senso.
Conclusão: script chato costuma ser o melhor script
Como eu falei no começo, isso aqui continua sendo nota de estudo. Só que é o tipo de nota de estudo que já me poupou de algumas burradas, então resolvi juntar tudo num lugar só.
Pra mim, Bash fica melhor quando a gente para de procurar truque esperto e
começa a desconfiar das coisas certas. Do set -e, do pipeline inocente, do
eval, do temporário malfeito, do loop que abre processo demais e jura que está
tudo sob controle.
No fim, o tipo de script Bash de que eu mais gosto é aquele que você roda daqui a três meses sem sentir vontade de xingar o seu eu do passado. Se ele falha cedo, fala onde doeu, não mistura dado com código e não sai fazendo gracinha em silêncio, eu já fico feliz.
Se ele funcionar no notebook, no CI e dentro do container sem virar um caso de polícia, melhor ainda. E se em algum momento você perceber que está forçando a barra e tentando transformar Bash em linguagem de aplicação, passa o bastão. Eu mesmo prefiro isso a insistir por teimosia.
Valeu. Até o próximo (esse aqui foi sofrido pra escrever 🙏)