Vai al contenuto

Quando restic check ha mentito: tre giorni per capire un repo non recuperabile

Quando restic check ha mentito: tre giorni per capire un repo non recuperabile

Quando restic check ha mentito: tre giorni per capire un repo non recuperabile

Lunedì 13 gennaio 2026, alle 7:42 del mattino. Apro il laptop e trovo un ticket aperto da un cliente di hosting su una testata che gestisco: un editor ha sovrascritto per errore, la sera prima, una cartella di asset di una sezione monografica. Servono 80 GB di file e il dump del database accessorio della stessa redazione, riportati allo snapshot di sabato mattina. Niente di drammatico, ordinaria amministrazione di un disaster recovery di livello 3 (cliente non bloccato, dataset isolato, restore mirato su un nodo di staging prima di rimettere in linea). Avvio restic restore puntato al repository B2 EU del cliente, target una VM di staging, parametri di selezione precisi.

Errore. Pack file corrotto. Il restore si interrompe a circa il 18% del volume.

Riavvio. Stesso errore, stesso pack file. Cambio nodo di staging per escludere problemi locali. Stesso errore. È la prima volta in due anni di operatività che vedo un restic restore fallire su un repository che il check notturno marcava come OK. La giornata cambia forma. I tre giorni successivi sono il motivo di questo post.

Il sospetto iniziale: rete

La prima ipotesi è banale e la batto subito: rete intermittente verso B2 EU. Il nodo di staging è su un provider diverso da quello principale, peering buono ma non ottimo, e i pack file di restic sono blob da 4-16 MB serviti via HTTPS. Un drop di pacchetti in mezzo a un download di pack si manifesta come hash MAC che non torna, e restic lo segnala come pack corrotto.

Test di rete: mtr continuo verso il bucket per dieci minuti, packet loss zero, jitter sub-millisecondo. Sposto il restore su un nodo Hetzner con peering europeo eccellente verso Backblaze B2 EU: stesso pack file, stesso errore. La rete non c’entra. Il pack è corrotto sul lato server.

A questo punto comincio a sentire un mal di testa che conosco. Se il pack è davvero corrotto sul provider, ho due possibilità: oppure quel singolo pack è un problema isolato (succede), oppure ne ho di più e il check notturno mi sta mentendo.

Il primo restic check mirato

Lancio restic check sul repository del cliente, con i parametri che fa girare il cron notturno. Output:

using temporary cache in /tmp/restic-check-...
created new cache in /tmp/restic-check-...
create exclusive lock for repository
load indexes
check all packs
check snapshots, trees and blobs
no errors were found

OK. Tutto verde. Stessa identica cosa che ho letto, ogni mattina, in tutti i log di check delle ultime sei settimane. Eppure il pack file con cui sto provando a fare restore torna con MAC mismatch.

Per un’ora cerco di capire se sto sbagliando io qualcosa lato restore. Riprovo con altri snapshot del cliente, snapshot più vecchi: errori sparsi, su pack diversi, ma persistenti. Riprovo con snapshot recenti: stessi errori, sui medesimi pack. La rete non c’entra. Il bucket non c’entra. Il problema è sul repository.

Apro la documentazione di restic e leggo, riga per riga, la sezione Checking integrity and consistency. La frase che mi colpisce, e che ho probabilmente letto tre volte negli anni senza interiorizzarla, è questa: by default, the check only verifies the structure of the repository, not the content of pack files. Per controllare il contenuto serve --read-data, che scarica e verifica ogni pack file end-to-end. Esiste anche --read-data-subset, che ne controlla un sottoinsieme.

Vado a guardare il cron del cliente. Il check girava in versione senza --read-data--read-data-subset. Solo restic check. Cioè: validava la struttura di indice, le relazioni snapshot-tree-blob, ma non leggeva mai un singolo byte di pack. Per oltre un anno, ogni notte, mi era stato detto no errors su un repository di cui nessuno verificava davvero il contenuto.

La verifica con –read-data

Lancio restic check --read-data sul repository. È una operazione lenta e cara: scarica tutti i pack (egress B2 verso il nodo di staging), ne ricalcola il MAC, e compara con quello atteso dall’header. Su un repository da circa 3 TB, sei ore di esecuzione, decine di gigabyte di egress.

Output, qualche ora dopo:

load indexes
check all packs
check snapshots, trees and blobs
read all data
pack <pack-id-1>: not matching MAC: ciphertext verification failed
pack <pack-id-2>: not matching MAC: ciphertext verification failed
pack <pack-id-3>: not matching MAC: ciphertext verification failed
Fatal: repository contains errors

Tre pack file corrotti, su decine di migliaia. Una percentuale microscopica in volume, ma sufficiente a rendere non recuperabili tutti gli snapshot che facciano riferimento ai blob contenuti in quei tre pack. E il check leggero, quello senza --read-data, su questa categoria di errori è cieco di nascita: l’index conosce i pack come presenti, non li legge, e dichiara l’intero repo OK. Il bug non è in restic. Il bug è in chi ha messo a cron il check senza capire cosa stesse davvero validando. Cioè io, fine 2024, quando ho disegnato la prima versione di quella procedura.

La causa lato provider

Mi metto in contatto con il supporto Backblaze e racconto la cosa con i pack-id e i timestamp. La risposta arriva nel giro di 24 ore: il provider conferma che nei 90 giorni precedenti, sui blob coinvolti, c’è stato un evento di replication interno della loro infrastruttura di storage. La meccanica è quella tipica dei sistemi di erasure coding distribuito: a fronte di un set di shard di un blob in cui una parte risulta non più consistente, il sistema ricostruisce header e index a partire dai pezzi sani e marca il blob come presente. Se la finestra temporale di reconciliation copre la totalità del blob, il contenuto torna integro. Se invece resta una porzione di blob corrotta nei dati, l’header dice blob OK ma i byte sotto non superano il MAC.

Su decine di migliaia di pack, sono capitati a tre. Su uno qualunque dei principali provider object storage del mercato si vedono pattern simili una manciata di volte all’anno. È raro, ma non zero. Il fatto che i blob siano ridondati e cifrati lato restic non rende l’evento impossibile: rende solo molto improbabile che impatti più di una percentuale piccola del repo.

La domanda interessante è: perché io non l’ho intercettato? La risposta è che il mio check leggero non avrebbe mai potuto. La firma del problema è il MAC che non torna sul contenuto, e il contenuto non veniva letto.

Il fix sulla procedura di check

Riscrivo la procedura di verifica integrità su tutti i repository B2 EU della fleet. Tre livelli, scheduli diversi, esiti che bloccano cose diverse.

Settimanale: restic check --read-data completo. Pesante (sul repository da 3 TB del cliente coinvolto, circa sei ore di wall-clock con un nodo dedicato e larghezza di banda piena), e con un costo di egress B2 non trascurabile. Lo schedulo nel weekend, su un nodo che non fa altro in quella finestra. L’esito è bloccante: se il check completo fallisce, alert su Discord (vedo i miei post precedenti su Production per la pipeline Prometheus + notifier interno) e sospensione delle nuove finestre di backup finché il problema non è chiaro. Niente backup di un repo che non sappiamo recuperare.

Giornaliero: restic check --read-data-subset 5%. Leggero (sul repo da 3 TB, intorno ai 75 minuti), schedulato nelle ore notturne. Legge un campione casuale del 5% dei pack ogni notte. Su orizzonte di un mese, statisticamente, copre buona parte del repository. È un compromesso tra costo di run e probabilità di catch di un singolo pack corrotto.

Strutturale: restic check senza flag, sempre giornaliero. Resta come fast check di sanity sull’index e sui riferimenti snapshot-tree. Lo tengo perché è veloce e perché in alcuni failure mode (cancellazione accidentale di un blob, index disallineato dopo un forget --prune interrotto) è utile.

E poi, l’alert che mi mancava: se il check completo --read-data non gira con successo da più di otto giorni, alert escalation. La regola operativa è che il full read è il vero termometro del repository, e otto giorni è il massimo che mi accetto. Sopra quella soglia, considero il repository non verificato fino a prova contraria, e questo cambia il livello di confidenza con cui posso promettere un restore al cliente.

Il restore reale al cliente

Mentre il diagnostic procede, devo comunque rimettere in piedi la cartella di asset del cliente. La fortuna è che ho una rete di sicurezza in più: ogni nodo che fa da source dei backup ha snapshot ZFS locali con retention 24h-7d. Il nodo di origine del dataset coinvolto aveva ancora uno snapshot ZFS della mattina di sabato, prima della sovrascrittura.

Procedura: clone dello snapshot ZFS, mount read-only, rsync selettivo della cartella di asset interessata verso il nodo di produzione, validazione lato applicativo. Quattro ore wall-clock dal momento in cui ho deciso di passare al fallback ZFS al momento in cui il cliente ha visto i suoi asset di nuovo online. Zero dati persi. Il cliente non ha saputo, e non gli interessa, che il path principale era stato il backup off-site B2 e che è stato bypassato.

Il punto, ed è il punto di questo postmortem, è che il fallback ZFS è stato la cintura di sicurezza che ha evitato il problema reale. Se quel cliente avesse avuto bisogno di uno snapshot di tre settimane prima, avrei dovuto andare a estrarre da un repo i cui pack di quella retention erano nei tre marcati corrotti. Non sarei stato in grado di servire il restore. Avrei dovuto andare a chiedere al cliente di ricostruire la cartella da fonti sue, ammesso che esistessero.

Numeri

Il bilancio di tre giorni di lavoro, con i numeri puliti.

  • 3 giorni wall-clock di indagine (di cui circa 16 ore effettive di lavoro mio: il resto è stato attesa di esecuzione check completi e risposte di supporto)
  • 4 ore di delay dal momento dell’apertura ticket al restore completato per il cliente (target di SLA interno: 6 ore)
  • 0 dati persi al cliente (grazie allo snapshot ZFS locale)
  • 3 pack file corrotti su decine di migliaia, in un singolo repository
  • 6 ore di esecuzione del primo restic check --read-data completo sul repo da 3 TB
  • 75 minuti di esecuzione del check subset 5% giornaliero sullo stesso repo
  • 2 settimane di refinement della procedura di alerting prima di considerarla a regime
  • 1 nuova soglia operativa: 8 giorni massimi senza un full --read-data con esito verde

Il costo di egress B2 della settimana di indagine e dei primi cicli di nuovo check completo è stato nell’ordine delle decine di euro. Niente di drammatico, ma un costo che entra in modo permanente nei fissi mensili dell’infrastruttura di backup. Lo accetto: il valore di un alert affidabile sul backup ripristinabile è incomparabilmente più alto di qualche decina di euro mese.

Cosa rifarei diversamente

Tre cose, in ordine di importanza.

Una: avrei dovuto leggere l’output di restic check con più attenzione fin dal primo giorno. La parola OK è ingannevole quando il default non legge i dati. Restic non sta mentendo: sta dicendo, alla lettera, la struttura del repository è coerente. Non dice il contenuto è leggibile end-to-end. Quella è una promessa diversa, che richiede un flag esplicito. Se devi gestire backup in produzione per clienti che possono fare disaster recovery, devi sapere a memoria la differenza tra check strutturale e check con read. Io l’ho imparata sul campo, in tre giorni. Avrei voluto impararla a freddo, su una documentazione, sei mesi prima.

Due: avrei dovuto schedulare il --read-data completo come parte del setup iniziale, non come step aggiuntivo opzionale. Quando ho disegnato la prima versione del cron di check, fine 2024, l’ho fatto col cervello a check struttura e basta perché il read costa. Era una micro-ottimizzazione che ha eroso la mia stessa garanzia di restore. Il read completo costa, ma è l’unica differenza tra backup verificato e backup promesso. La regola che ho scritto a futura memoria, da imporre a ogni futuro setup di repository di backup: il read completo è una settimanale fissa, non un’opzione.

Tre: il fallback ZFS l’ho usato per fortuna, non per design. Lo snapshot ZFS della mattina di sabato c’era perché la retention locale è da 24 ore con buffer fino a 7 giorni, non perché avessi pensato a uno scenario in cui il backup off-site è inservibile e il primary va salvato dal local snapshot. La regola che ho aggiornato è che la retention locale ZFS deve essere sufficiente a coprire il tempo medio di rilevazione di un problema sul backup off-site. Otto giorni di retention locale, esplicitamente, come cintura di sicurezza per il caso in cui il check off-site sia mancato. Non un di più, ma un disegno.

Cosa porto a casa

Il backup non è quello che hai schedulato, è quello che hai testato. Il check non è quello che gira, è quello che legge i dati. OK è una parola che va guardata con sospetto: quando arriva da un sistema di verifica, bisogna sapere esattamente cosa sta dicendo OK. La differenza tra struttura coerente e contenuto leggibile è la differenza tra essere fortunati e essere sicuri.

Ogni runbook di backup che ho riscritto a gennaio, sui repository di Romiltec e dei tenant editoriali gestiti, ha tre livelli di check con tre scheduli e tre esiti diversi. E un alert specifico sul fatto che il check pesante non sia girato verde da troppo tempo. Non è una difesa contro tutti i fallimenti possibili: è una difesa contro il fallimento silenzioso, che è quello che fa più paura, perché ti scopri scoperto solo quando ne hai davvero bisogno.

Quel lunedì 13 gennaio, alle 7:42, mi sono svegliato pensando facciamo un restore di routine. Sono andato a letto sapendo che la mia procedura di backup era più fragile di quanto credessi. Tre giorni dopo, era più solida di quanto fosse mai stata. Su questo lavoro non ci sono medaglie: c’è un repo che adesso il check legge davvero, e un sysadmin che ha aggiornato il proprio modello mentale di backup verificato.

Articoli correlati