O ensaio precisa ser automatizado por um motivo que não é preguiça: um procedimento manual só é executado enquanto alguém se lembra dele, e a memória organizacional dura menos que a rotatividade do time. O ensaio automatizado roda toda semana sem que ninguém decida rodar, e a primeira semana em que ele falha é a semana em que o backup parou de funcionar, e não o dia do incidente. O desenho mínimo tem quatro etapas: provisionar um ambiente descartável, restaurar a cópia mais recente dentro dele, rodar consultas de verificação contra o resultado, e destruir o ambiente registrando o tempo total.
A etapa que diferencia um ensaio útil de um teatro de conformidade é a terceira. Restaurar e verificar apenas que o banco sobe prova pouco, porque um banco vazio também sobe. As consultas de verificação precisam checar completude, comparando a contagem de tabelas e de objetos com a origem, checar integridade referencial, procurando registros órfãos que denunciam captura inconsistente, e checar atualidade, confirmando que o registro mais recente do backup está dentro da janela de perda aceitável. Esse último item é o que detecta o caso mais silencioso de todos, que é o backup que roda com sucesso mas sobre uma réplica que parou de receber dados há três semanas.
#!/usr/bin/env bash
# scripts/ensaio-restauracao.sh
# Ensaio semanal de restauracao. Roda em agendador, sem intervencao humana.
# Falhar aqui e o objetivo: e mais barato do que falhar durante o incidente.
set -euo pipefail
BUCKET="${BACKUP_BUCKET:?bucket de backup nao definido}"
RPO_HORAS="${RPO_HORAS:-24}"
INSTANCIA="ensaio-restauracao-$(date +%Y%m%d%H%M%S)"
INICIO=$(date +%s)
# Ambiente descartavel e sempre destruido, inclusive quando o ensaio falha.
# Sem esta linha, um ensaio que falha deixa lixo caro rodando ate alguem ver.
limpar() {
docker rm -f "$INSTANCIA" >/dev/null 2>&1 || true
rm -f "$ARQUIVO" 2>/dev/null || true
}
trap limpar EXIT
# 1. Buscar a copia mais recente pelo armazenamento, e nao por um nome
# previsivel: nome previsivel esconde o caso em que o job parou de gerar
# arquivo novo e o ensaio segue restaurando o mesmo backup antigo.
ARQUIVO=$(mktemp /tmp/backup-XXXXXX.dump)
CHAVE=$(aws s3api list-objects-v2 --bucket "$BUCKET" --prefix "postgres/" \
--query 'sort_by(Contents, &LastModified)[-1].Key' --output text)
if [ "$CHAVE" = "None" ] || [ -z "$CHAVE" ]; then
echo "FALHA: nenhum backup encontrado em $BUCKET" >&2
exit 1
fi
# Idade do arquivo: um backup legivel porem velho falha o objetivo de perda
# aceitavel do mesmo jeito que um backup corrompido. Esta checagem pega o
# job que morreu silenciosamente ha semanas com o painel ainda verde.
MODIFICADO=$(aws s3api head-object --bucket "$BUCKET" --key "$CHAVE" \
--query 'LastModified' --output text)
IDADE_HORAS=$(( ( $(date +%s) - $(date -d "$MODIFICADO" +%s) ) / 3600 ))
if [ "$IDADE_HORAS" -gt "$RPO_HORAS" ]; then
echo "FALHA: backup mais recente tem ${IDADE_HORAS}h, limite e ${RPO_HORAS}h" >&2
exit 1
fi
aws s3 cp "s3://$BUCKET/$CHAVE" "$ARQUIVO" --quiet
# 2. Restaurar num banco efemero, isolado do ambiente real.
docker run -d --name "$INSTANCIA" -e POSTGRES_PASSWORD=ensaio \
-p 55432:5432 postgres:16 >/dev/null
until docker exec "$INSTANCIA" pg_isready -q; do sleep 1; done
# --exit-on-error e essencial: sem ele, pg_restore ignora erros de objeto
# individual e termina com codigo zero, e o ensaio aprova um backup parcial.
PGPASSWORD=ensaio pg_restore --host=localhost --port=55432 --username=postgres \
--dbname=postgres --no-owner --exit-on-error "$ARQUIVO"
# 3. Verificacao de conteudo. Um banco vazio tambem sobe: o que prova a
# restauracao sao as consultas abaixo, nao o processo ter iniciado.
PGPASSWORD=ensaio psql --host=localhost --port=55432 --username=postgres \
--dbname=postgres --variable=ON_ERROR_STOP=1 --quiet <<'SQL'
DO $
DECLARE
total_tabelas int;
pedidos_orfaos int;
registro_mais_novo timestamptz;
BEGIN
-- Completude: contagem conferida contra o inventario esperado da origem.
SELECT count(*) INTO total_tabelas
FROM information_schema.tables WHERE table_schema = 'public';
IF total_tabelas < 42 THEN
RAISE EXCEPTION 'Completude: % tabelas restauradas, esperado ao menos 42',
total_tabelas;
END IF;
-- Consistencia: orfao denuncia captura sem transacao unica, em que cada
-- tabela foi lida num instante diferente.
SELECT count(*) INTO pedidos_orfaos
FROM orders o LEFT JOIN customers c ON c.id = o.customer_id
WHERE c.id IS NULL;
IF pedidos_orfaos > 0 THEN
RAISE EXCEPTION 'Consistencia: % pedidos sem cliente', pedidos_orfaos;
END IF;
-- Atualidade: pega a origem que parou de receber dados com o job verde.
SELECT max(created_at) INTO registro_mais_novo FROM orders;
IF registro_mais_novo < now() - interval '36 hours' THEN
RAISE EXCEPTION 'Atualidade: registro mais novo e de %', registro_mais_novo;
END IF;
END $;
SQL
# 4. O tempo do ensaio e a estimativa honesta do tempo de retorno. Publicar
# como metrica permite alertar quando a restauracao passa a nao caber no
# prazo, o que acontece de forma gradual conforme a base cresce.
DURACAO=$(( $(date +%s) - INICIO ))
echo "ensaio_restauracao_segundos $DURACAO"
echo "OK: restauracao verificada em ${DURACAO}s a partir de $CHAVE"
Duas escolhas no script merecem destaque porque são exatamente onde os ensaios caseiros costumam falhar em silêncio. A primeira é a opção que faz o restaurador parar no primeiro erro: sem ela, a ferramenta reporta problemas por objeto individual e ainda assim termina com código zero, de forma que um backup em que metade dos objetos não restaurou é aprovado pelo ensaio. A segunda é buscar a cópia mais recente consultando o armazenamento em vez de montar um nome previsível a partir da data, porque o nome previsível esconde justamente o caso em que o job parou de gerar arquivos novos: o ensaio continua restaurando com sucesso o mesmo arquivo de três semanas atrás e continua reportando verde.