Patching, backup & uptimebeginner5 min lezen

Back-ups controleren die "geslaagd" zeggen: versheid, integriteit en een restore-test die zichzelf logt

Een back-upjob die exit 0 geeft, bewijst dat de job draaide — niet dat er iets bruikbaars in het archief zit. Drie controles die je automatiseert: is de nieuwste back-up vers genoeg (mtime-check), is het archief leesbaar (restic/borg check) en kun je er echt iets uit terugzetten (maandelijkse restore-test met logregel). Plus de valkuil van rsync die mtimes bewaart.

Inhoud
  1. Stap 1: versheid — is er vannacht écht iets bijgekomen?
  2. Stap 2: integriteit — is het archief leesbaar?
  3. Stap 3: de restore-test die zichzelf logt
  4. Stap 4: de dagelijkse check, met alert
  5. Valkuilen
  6. Wat je hiermee nog niet hebt
  7. Zo doet monsys het
  8. FAQ

Elke organisatie die data heeft verloren, had back-ups. De job draaide elke nacht, de mail zei "Backup completed successfully", en op de dag dat het nodig was bleek het archief leeg, drie weken oud, versleuteld met een sleutel die niemand had, of gewoon niet te openen. Dit artikel automatiseert de drie vragen die je eigenlijk elke dag zou moeten stellen: is hij vers, is hij intact, en kan ik er iets uit halen?

Stap 1: versheid — is er vannacht écht iets bijgekomen?

De eenvoudigste controle werkt voor elke back-uptool, ook voor de cron met tar van tien jaar geleden: kijk naar het nieuwste bestand op de bestemming.

# Nieuwste bestand onder het back-uppad, met leeftijd in uren
DEST=/srv/backup            # of een mount van je NAS, of de restic/borg-repo-map
newest=$(find "$DEST" -type f -printf '%T@ %p\n' 2>/dev/null | sort -n | tail -1)
ts=${newest%% *}; file=${newest#* }
age_h=$(( ( $(date +%s) - ${ts%.*} ) / 3600 ))
echo "newest: $file  age: ${age_h}h"
[ "$age_h" -gt 26 ] && echo "STALE: geen nieuwe back-up in ${age_h} uur"

De drempel van 26 uur is bewust: een dagelijkse job plus twee uur speling. Voor een job die elk uur draait, neem je 3.

Bij restic en borg is er een betere bron dan mtime — de snapshot-lijst van de repository zelf:

# restic
restic -r "$DEST" snapshots --latest 1 --json | jq -r '.[0] | "\(.time) \(.hostname) \(.paths|join(","))"'
# borg
borg list "$DEST" --last 1 --format '{time} {hostname} {name}{NL}'

Vergelijk de tijd met nu, en let ook op de hostname: een repo waar per ongeluk een andere host in schrijft, ziet er vers uit terwijl jouw host al weken niet meer back-upt.

Stap 2: integriteit — is het archief leesbaar?

"Er staat een bestand van 40 GB" zegt niets over de inhoud. restic en borg kunnen hun eigen repository verifiëren; doe het wekelijks op een steekproef en maandelijks volledig.

# restic: structuur + 5 % van de data echt lezen (wekelijks)
restic -r "$DEST" check --read-data-subset=5%
# restic: alles (maandelijks; kan uren duren op grote repo's)
restic -r "$DEST" check --read-data

# borg
borg check --verify-data "$DEST"          # volledig
borg check "$DEST"                          # alleen metadata, snel

Voor tar/zip-archieven zonder eigen check: tar -tzf archief.tgz >/dev/null leest het hele archief en faalt bij corruptie. Voor database-dumps: pg_restore --list dump.pgc >/dev/null (PostgreSQL custom format) of gzip -t dump.sql.gz (alleen de compressielaag).

Stap 3: de restore-test die zichzelf logt

Dit is de stap die iedereen overslaat, en de enige die telt. Eén keer per maand zet je iets echts terug op een andere plek en controleer je dat het klopt. Niet de hele server — een gerichte set die representatief is: de config van je belangrijkste service en een database-dump.

sudo tee /usr/local/sbin/restore-test.sh >/dev/null <<'EOF'
#!/usr/bin/env bash
# Maandelijkse restore-test. Zet een bekende set terug in een tijdelijke map,
# vergelijkt met het origineel, en logt het resultaat met datum.
set -u
DEST=/srv/backup
LOG=/srv/inventory/restore-tests.log
NTFY="https://ntfy.example.be/backup"
T=$(mktemp -d /tmp/restore-test.XXXX)
HOST=$(hostname -s)
result=OK; detail=""

# 1. Config-bestanden: terugzetten en byte-voor-byte vergelijken met live
if restic -r "$DEST" restore latest --target "$T" --include /etc/nginx --include /etc/ssh/sshd_config.d >/dev/null 2>&1; then
  if ! diff -rq /etc/nginx "$T/etc/nginx" >/dev/null 2>&1; then
    result=FAIL; detail+="nginx-config verschilt van live; "
  fi
else
  result=FAIL; detail+="restic restore faalde; "
fi

# 2. Database-dump: terugzetten in een wegwerp-database en tellen
if [ -x /usr/bin/psql ]; then
  dump=$(ls -t "$T"/srv/dumps/*.pgc 2>/dev/null | head -1)
  if [ -n "$dump" ]; then
    sudo -u postgres createdb restoretest 2>/dev/null
    if sudo -u postgres pg_restore -d restoretest "$dump" >/dev/null 2>&1; then
      tables=$(sudo -u postgres psql -tAc "select count(*) from information_schema.tables where table_schema='public'" restoretest)
      [ "${tables:-0}" -gt 0 ] || { result=FAIL; detail+="pg_restore: 0 tabellen; "; }
    else
      result=FAIL; detail+="pg_restore faalde; "
    fi
    sudo -u postgres dropdb restoretest 2>/dev/null
  fi
fi

rm -rf "$T"
line="$(date -Is) restore-test $HOST $result ${detail:-config+db teruggezet en vergeleken}"
echo "$line" | sudo tee -a "$LOG" >/dev/null
[ "$result" = FAIL ] && echo "$line" | curl -s -H "Title: RESTORE-TEST FAILED $HOST" -H "Priority: urgent" --data-binary @- "$NTFY" >/dev/null
echo "$line"
EOF
sudo chmod 0755 /usr/local/sbin/restore-test.sh
echo '0 4 1 * * root /usr/local/sbin/restore-test.sh' | sudo tee /etc/cron.d/restore-test

De logregel — 2026-10-01T04:00:12+02:00 restore-test web-01 OK config+db teruggezet en vergeleken — is precies wat een auditor bedoelt met "bewijs van hersteltests". Twaalf regels per jaar, per host.

Pas de --include-paden en de dump-locatie aan je situatie aan; het principe is: iets terugzetten dat je kunt vergelijken, en een database die je kunt bevragen.

Stap 4: de dagelijkse check, met alert

Stap 1 en 2 in één script dat elke ochtend draait en alleen meldt wat mis is:

sudo tee /usr/local/sbin/backup-check.sh >/dev/null <<'EOF'
#!/usr/bin/env bash
DEST=/srv/backup; MAX_H=26
NTFY="https://ntfy.example.be/backup"
HOST=$(hostname -s); MSG=()

# Versheid via de repo zelf (restic); valt terug op mtime als restic ontbreekt
if command -v restic >/dev/null; then
  last=$(restic -r "$DEST" snapshots --latest 1 --json 2>/dev/null | jq -r '.[0].time // empty')
  [ -n "$last" ] && age_h=$(( ( $(date +%s) - $(date -d "$last" +%s) ) / 3600 )) || age_h=9999
else
  ts=$(find "$DEST" -type f -printf '%T@\n' 2>/dev/null | sort -n | tail -1); ts=${ts%.*}
  age_h=$(( ( $(date +%s) - ${ts:-0} ) / 3600 ))
fi
[ "$age_h" -gt "$MAX_H" ] && MSG+=("STALE: laatste back-up ${age_h}h oud (max ${MAX_H}h)")

# Integriteit, alleen op zondag (steekproef)
if [ "$(date +%u)" = 7 ] && command -v restic >/dev/null; then
  restic -r "$DEST" check --read-data-subset=5% >/tmp/restic-check.log 2>&1 || MSG+=("CHECK FAILED: $(tail -1 /tmp/restic-check.log)")
fi

# Ruimte op de bestemming
pct=$(df --output=pcent "$DEST" 2>/dev/null | tail -1 | tr -dc '0-9')
[ "${pct:-0}" -gt 85 ] && MSG+=("bestemming ${pct}% vol")

if [ ${#MSG[@]} -gt 0 ]; then
  printf '%s\n' "${MSG[@]}" | curl -s -H "Title: backup@$HOST" -H "Priority: high" --data-binary @- "$NTFY" >/dev/null
fi
EOF
sudo chmod 0755 /usr/local/sbin/backup-check.sh
echo '15 7 * * * root /usr/local/sbin/backup-check.sh' | sudo tee /etc/cron.d/backup-check

Valkuilen

  • rsync bewaart mtimes. rsync -a kopieert bestanden mét hun oorspronkelijke wijzigingsdatum. Een mtime-check op een rsync-bestemming ziet dus de datum van het bronbestand, niet van de kopie. Gebruik voor rsync-back-ups een marker: rsync ... && date -Is > "$DEST/.last-run" en check dát bestand.
  • De job die "slaagt" met nul bestanden. Een verkeerd gemount pad, een lege Docker-volume-map, een --exclude die te breed is: exit 0, 12 KB archief. Controleer naast de leeftijd ook de grootte van de laatste snapshot (restic stats latest) en alarmeer bij een daling van meer dan 50 % tegenover de vorige.
  • Retentie die alles opruimt. restic forget --keep-daily 7 --prune zonder --keep-monthly betekent: na een week zonder nieuwe back-ups is de repo leeg. Combineer altijd met een versheidscheck, anders merkt niemand het.
  • De sleutel op dezelfde server. Een versleutelde restic-repo waarvan het wachtwoord alleen in /etc/restic/password op de gebackupte server staat, is bij een ransomware-incident waardeloos. Bewaar de sleutel op een tweede plek (wachtwoordkluis, papier in een kluis).
  • Databases als bestanden. Een kopie van /var/lib/postgresql van een draaiende database is met grote kans inconsistent. Gebruik pg_dump/pg_basebackup, mariadb-dump --single-transaction, of een snapshot op filesystem-niveau met een consistente state.
  • Restore-test op productie. Het script zet terug in /tmp en een wegwerp-database. Nooit restore latest --target /. Het klinkt vanzelfsprekend tot iemand het om 04:00 anders doet.

Wat je hiermee nog niet hebt

  • Overzicht per host. Dertig hosts, dertig restore-tests.log. "Welke hosts hebben in Q3 geen geslaagde restore-test?" is een script over ssh.
  • Alerts die stoppen als het is opgelost. De ntfy-push komt elke dag terug tot iemand het fixt, en dan nog een dag omdat de check om 07:15 draait.
  • Bewijs voor ISO 27001 A.8.13. De logregel is goed; een auditor wil hem in een ondertekende, onveranderbare vorm, en over de hele fleet, en over twaalf maanden.

Zo doet monsys het

In monsys registreer je per host een backup watch: het pad waar de back-up terechtkomt en de maximale leeftijd. De agent controleert het pad elke 30 minuten (stat-only, hij opent de archieven niet — dus ook versleutelde repo's werken zonder leesrechten) en rapporteert de nieuwste mtime en grootte. De hub alerteert bij stale (warning) en bij twee keer stale (critical), gededupliceerd tot één open alert die vanzelf sluit als de back-up weer loopt. De control ISO 27001 A.8.13 telt automatisch de watches die binnen hun max-leeftijd zitten en neemt dat op in het maandelijkse audit pack. Zie backup-verificatie in de docs. De restore-test blijft mensenwerk — maar de logregel ervan kun je als bewijs koppelen.

FAQ

Hoe vaak moet ik een restore-test doen?

Maandelijks voor een gerichte set (config + database), en één keer per jaar een volledige herstel van een echte service naar een testomgeving, met de klok erbij. Dat laatste is ook de enige manier om je RTO te kennen in plaats van te schatten.

Is een check van de back-uptool niet genoeg?

restic check bewijst dat de repository intern consistent is. Het bewijst niet dat de juiste paden zijn geback-upt, dat de database-dump bruikbaar is, of dat je de sleutel nog hebt. Daar is de restore-test voor.

Wat als mijn back-up in de cloud staat (S3, Backblaze, Hetzner Storage Box)?

Alle commando's werken hetzelfde: restic en borg spreken die backends rechtstreeks. De mtime-check uit stap 1 werkt niet op object storage; gebruik daar de snapshot-lijst van de tool. En test de restore vanaf de cloud-locatie, niet vanaf een lokale cache — de bandbreedte is onderdeel van je RTO.

Geschreven door het monsys-team — sysadmins die dit dagelijks doen.

Zelf gedaan? Laat monsys het bijhouden.

Alles uit deze how-to draait in monsys als doorlopende check, met historie, alerts en audit-bewijs. 5 servers gratis, EU-gehost in België, geïnstalleerd in 60 seconden.