Backup com dead-man switch — e o ensaio de restore que precisa saber falhar
Existe um modo de falha de backup que nenhum log de erro registra: o job que para de rodar. Ele não falha. Falhar seria bom, porque falha aparece em log. Ele é descarregado do agendador numa atualização do sistema, ou o disco de destino sai do lugar, ou a credencial expira, e a partir daí não acontece mais nada. Nenhuma linha nova no log. Nenhum alerta, porque o alerta que você configurou dispara quando o job falha, e um job que não roda não falha.
Foi exatamente assim que um job de backup noturno desta infraestrutura ficou 14 dias parado em agosto de 2026, descarregado do launchd, o agendador do sistema. Ninguém notou. O log não tinha erro nenhum: tinha uma ausência, e ausência não aciona nada.
Este post é o desenho que fecha esse buraco, em três peças. A primeira é o backup que puxa, não empurra. A segunda é o dead-man switch, o sinal invertido que transforma silêncio em alarme. A terceira, que quase todo mundo pula, é o ensaio de restore que roda de verdade. Ele traz uma verificação negativa: um passo que precisa falhar para o ensaio passar.
Peça 1: o backup puxa, e a cópia de fora é a que importa
O desenho de base: uma VPS roda os serviços e produz dumps locais a cada 6 horas (Postgres, SQLite com .backup() online, tarballs de configuração). Uma máquina fora do datacenter, no caso um Mac, puxa esses dumps toda madrugada via rsync, o utilitário que copia arquivos entre máquinas.
Puxar em vez de empurrar tem uma consequência de segurança que vale explicitar: a VPS não tem credencial nenhuma para escrever na máquina de backup. Um invasor que compromete a caixa consegue destruir os dumps locais dela. As cópias de fora ele não alcança. Na mesma linha, o rsync roda com --ignore-existing e nunca com --delete: um snapshot que já chegou não é sobrescrito nem removível pela origem. A poda é local, por idade (60 dias), feita pelo lado que puxa.
As três camadas completas, com o que cada uma sobrevive:
| Camada | Onde | Sobrevive a |
|---|---|---|
| Dumps na própria VPS, a cada 6h | mesmo disco do banco | corrupção lógica; não a perda do disco |
| Snapshots do provedor (7 rotativos) | mesmo provedor | perda do disco; não a perda da conta |
| Pull noturno para fora | outra máquina, outro lugar | perda do provedor inteiro |
A terceira camada é a única cópia fora do provedor. Por isso é a única que carrega o dead-man switch. As outras duas têm quem as vigie: o próprio provedor e um cron na caixa. A de fora depende de uma máquina doméstica e de um agendador que já provou que sabe morrer em silêncio.
Um detalhe que paga o custo de existir: os arquivos comprimidos recém-chegados passam por gunzip -t antes de contarem como backup. Um .gz truncado por queda de conexão é apagado na hora. Um backup corrompido descoberto no dia do restore não é um backup: é uma pegadinha com data marcada.
Peça 2: o dead-man switch — silêncio vira alarme
A correção para “o job que não roda não falha” é inverter a direção do sinal. Em vez de o job avisar quando algo dá errado, ele avisa quando dá certo. No fim de cada rodada bem-sucedida, um curl bate numa URL de healthcheck, o endereço que recebe esse sinal de vida. O serviço do outro lado espera esse ping numa cadência conhecida (uma vez por dia, com 6 horas de tolerância). Se o ping não chega, ele alerta.
A diferença é estrutural: o alerta não depende mais de o job estar vivo para ser emitido. Job descarregado do agendador, máquina desligada, rede fora, script travado antes do fim: todos esses modos de falha colapsam no mesmo sintoma observável, o silêncio. Inclusive os que ninguém previu. E silêncio agora tem dono.
Dois cuidados de implementação:
- A URL do check fica fora do git. Ela é, na prática, uma credencial: quem a conhece consegue silenciar o alarme pingando no seu lugar. Mora num arquivo ignorado. Se o arquivo não existe, o script avisa em voz alta (“dead-man switch inativo”) em vez de seguir calado.
- O mesmo padrão, invertido, roda dentro da VPS. Um guardião em cron a cada 10 minutos checa disco (≥85%), containers unhealthy, containers parados que tinham restart policy, e um healthcheck profundo da aplicação. Se tudo está bem, pinga OK; se algo falha, pinga a variante de falha com o motivo no corpo. E se a caixa inteira morrer? Silêncio. O serviço de healthcheck converte esse silêncio em alerta em no máximo uma hora e meia. A VPS morta não consegue avisar que morreu, e o desenho não precisa que ela consiga.
A regra geral que esses dois casos ilustram: quem vigia não pode depender da coisa vigiada para emitir o alarme. Todo o resto é detalhe de implementação.
Peça 3: o ensaio de restore — trimestral, descartável, e com falha de propósito
Backup sem restore testado é uma hipótese com bom marketing. A frase é batida; o que é menos batido é o formato de um ensaio que realmente informa alguma coisa.
O ensaio desta infraestrutura roda num script só, na máquina de backup, contra um Postgres descartável. É um container efêmero numa porta alta, derrubado por trap na saída, que nunca encosta na produção. Ele pega os artefatos da última rodada de backup, os mesmos arquivos que o restore de verdade usaria, não uma cópia preparada. Depois restaura os bancos, roda as migrações e confere.
O último ensaio completo: 37 verificações, 0 falhas, dados restaurados em menos de um minuto, contagem de linhas conferida tabela a tabela, 2.855 linhas, 0 divergências.
Os números que saem do ensaio são os que nenhum dashboard de backup mostra.
- RTO medido, não estimado, o tempo até voltar ao ar: restaurar os dados leva segundos. A caixa inteira, do zero, é estimada em 35–60 minutos. Essa estimativa está escrita ao lado do número medido, marcada como estimativa.
- RPO real, o tanto de dado que se perde entre uma cópia e outra: os dumps na caixa dão 6 horas. A cópia de fora, que depende do pull noturno, pode chegar a ~30 horas. É o número honesto, e é maior do que a intuição sugere.
A falha de propósito
A parte do desenho que eu mais recomendo copiar: o ensaio contém verificações que exigem uma falha para passar.
A mais importante envolve credenciais cifradas no banco. O ensaio restaura e decifra uma credencial com a chave correta, vinda do tarball de configuração do backup. Isso prova que a chave também está no backup. Depois ele troca a chave por uma errada e exige que a decifragem levante exceção. Se a chave errada decifrar, o ensaio aborta com erro: a credencial não estava cifrada de verdade, e o “sucesso” do restore estaria escondendo um vazamento.
É o mesmo princípio do teste que você vê falhar antes de deixá-lo passar, aplicado a infraestrutura. Uma verificação que nunca foi vista reprovando não distingue “seguro” de “não checado”.
Na mesma linha, o ensaio falha duro quando um insumo essencial não existe: sai com erro, não com aviso. O comentário no próprio script resume a filosofia. O ensaio existe para dizer se dá para voltar. Sem o tarball de variáveis de ambiente, o dump volta com colunas ilegíveis, e um ensaio que “passa com ressalvas” nessa condição estaria certificando um restore que não funciona.
Outras guardas na mesma categoria. Insumo com mais de 30 horas reprova, porque o RPO prometido foi quebrado e é o ensaio que deve contar isso. Postgres de destino mais velho que o de origem reprova. Schema restaurado com menos tabelas que o esperado reprova.
O ensaio que mentia
E o achado que justifica tudo isso: a primeira versão do próprio script de ensaio mentia. Um docker exec -i dentro de um while read consumia o stdin do loop, a entrada padrão que alimentava a lista de tabelas. A lista era engolida na primeira iteração. O script conferia uma tabela e reportava “0 divergências” com toda a confiança do mundo.
O instrumento de verificação tem os mesmos direitos que qualquer código: ele quebra, ele mente, ele precisa ser verificado. A correção veio acompanhada da contagem explícita (“N tabelas conferidas”) no relatório. “0 divergências em 54 tabelas” e “0 divergências” são frases muito diferentes, e a segunda esconde exatamente o bug que aconteceu.
O checklist
- A cópia que importa é a que a origem não alcança. Pull, não push; sem
--delete; poda por idade no lado que puxa. - Teste a integridade na chegada (
gunzip -tou equivalente). Backup corrompido descoberto no restore não é backup. - Dead-man switch em todo job agendado que importa. O job pinga no sucesso; o silêncio alerta. A URL do ping é credencial: fora do git.
- O vigia de dentro pinga falha com motivo; a morte da caixa vira silêncio. E o silêncio já tem quem o escute.
- Ensaie o restore numa cadência fixa, contra infraestrutura descartável, com os artefatos reais. Publique RTO e RPO medidos, não desejados.
- Inclua verificações negativas. A chave errada tem que falhar. O insumo ausente tem que reprovar. Um ensaio incapaz de falhar não é um ensaio: é uma cerimônia.
- Desconfie do instrumento. O primeiro bug que o ensaio encontra costuma ser no próprio ensaio, e isso não é um vexame: é o processo funcionando.
Infraestrutura que escala sem quebrar o orçamento
Conta de cloud fora de controle? Opero a minha própria numa VPS única, sem porta aberta, com deploy automático e rollback por healthcheck. O desenho inteiro está publicado aqui.
Ver os posts de infraestrutura →