Reported symptoms
At 09:20 on 28 August a developer asks for a file they deleted from
/srv/app/exports on the 26th. The newest recovery point anyone can offer is
dated the 23rd.
The dashboard shows nine green tiles for each of the last four nights, the night of the 24th included. Nobody was paged, no ticket exists, and the estate’s written record of this incident begins with the failed restore, five days after the repository stopped accepting data.
rbdr-backup-01 carries one filesystem, /srv/rbdr-repo, and it reports zero
bytes available. The run on the night of the 24th lasted forty-one minutes and
stopped partway through. The three runs after it each finished in under a
minute and reported success.
Within ten minutes somebody proposes removing the oldest recovery points so tonight’s run has somewhere to write. Nobody has yet established why a repository under a fourteen-day retention policy grew for fourteen months.
Evidence provided
$ df -h /srv/rbdr-repoA 6.0T filesystem, zero available, 100 per cent used, holding nothing but the repository.
$ restic -r /srv/rbdr-repo snapshots3,412 snapshots, the oldest fourteen months old. The configured policy is
--keep-daily 14 --keep-weekly 8 --keep-monthly 12: at most 34 per host, 306
across the nine clients.
$ restic -r /srv/rbdr-repo snapshots --host rbdr-db-0123 August, for all nine clients. Nothing since.
$ restic -r /srv/rbdr-repo stats --mode raw-data5.4 TiB of blobs. The nine source paths total 470 GiB, and the estate’s own filesystem-used series shows those sources grew four per cent over the same fourteen months.
The data/ directory holds pack files stamped the night of the 24th that no
snapshot references. The Ansible role installing the restic forget --prune
timer targets the inventory group rbdr_prune_runner, whose only member is
rbdr-app-01 — decommissioned fourteen months ago when its workload moved to
rbdr-app-07. The nightly play has reported no hosts matched, exit code 0,
ever since.
The client wrapper is one line: restic backup "$SRC" | tee -a "$LOG". There
is no set -o pipefail. Monitoring holds three backup checks — per-client job
exit status, timer last-run age, repository reachability — and none of them
reads free space or recovery point age.
Work the evidence before reading on
- 3,412 snapshots against a policy ceiling of 306. Which hypothesis does that single number eliminate, and why can it not be explained by growth?
- The sources grew four per cent. The repository grew roughly fourfold. If the protected data did not put those bytes there, what did?
- Three nights reported success and created nothing. Where did the success come from?
- What are the pack files from the 24th, and what is supposed to remove them?
- Which two numbers, had either been on a dashboard, would have turned this into a capacity ticket on a Tuesday afternoon?
Root cause
The retention policy had never been applied
forget and prune are two operations. forget removes snapshots; restic’s
documentation states that afterwards “the data that was referenced by files in
this snapshot is still stored in the repository”, and that prune must be run
to clean up unreferenced data. Neither had ever run here.
The estate scheduled restic forget --prune from a client rather than from the
repository host. That client was decommissioned fourteen months ago. The
configuration-management role survived; its target did not, and a play matching
no hosts is not an error. It exited 0 nightly for fourteen months.
The aborted run left the packs from the 24th behind. restic documents this exactly: “Should you run out of space during the middle of a backup, there will be some additional data in the repository, but the snapshot will never be created as it would only be written at the very (successful) end of the backup operation. Previous snapshots will still be there and will still work.” The older recovery points were never at risk. The new one was never created.
The control watched the job, not the repository
Three checks existed and all three watched the job. None read a property of the repository. Free space sat at zero and the newest recovery point aged past five days without either fact reaching a person.
The mechanism that kept the tiles green is ordinary. The exit status of a
pipeline is the status of its last command unless pipefail is set. restic
exits 1 when it cannot write to the destination; tee exits 0 because writing
the log succeeded. The wrapper published tee’s number.
Resolution
Stop writes before anything else, then add space before removing anything.
prune needs scratch room to repack and rewrite the index.
REPO=/srv/rbdr-repo
for h in rbdr-app-02 rbdr-app-07 rbdr-db-01; do
ssh "$h" systemctl stop rbdr-backup.timer
done
lvextend -L +1T -r /dev/rbdr/repo
df -h "$REPO"
Then read the policy before executing it. Fourteen months of unapplied retention means the first real run removes thousands of snapshots at once, and the dry run is the only chance to catch a wrong flag.
REPO=/srv/rbdr-repo
restic -r "$REPO" forget --keep-daily 14 --keep-weekly 8 --keep-monthly 12 --dry-run
restic -r "$REPO" forget --keep-daily 14 --keep-weekly 8 --keep-monthly 12
restic -r "$REPO" prune
Reclamation happens at the prune step. If space is still too tight for it to
start, restic documents prune --max-repack-size 0, which reclaims whole
unused pack files without repacking, before the explicitly unsafe
--unsafe-recover-no-free-space.
Move the schedule onto rbdr-backup-01, which owns the repository, and repoint
the inventory group at a host that exists. Add set -o pipefail to the wrapper
and publish restic’s status. Then restart the client timers and run each client
by hand rather than waiting for tonight.
Verification
Free space on /srv/rbdr-repo is positive and stated as days of headroom at
the measured growth rate, not as a percentage.
restic snapshots returns a count at or below 306, and the newest snapshot for
each of the nine clients is dated tonight — read from the repository, not from
the scheduler.
The packs from the 24th are gone. restic check completes without error, which
verifies structural consistency and not the stored bytes; run restic check --read-data-subset 5% against a sample to confirm the data itself survived the
fill.
Restore the developer’s file from a recovery point newer than the 23rd onto a scratch path and compare it against a known copy. Until a restore has been performed, the repair is asserted rather than demonstrated.
Trip both new alerts on purpose and record the dates. An alert that has never fired is a configuration, not a control.
Prevention
Alert on free space and on newest recovery point age, both read from outside the backup software. Either would have fired on the morning of the 25th.
Schedule retention on the host that owns the repository. A client can be renamed, rebuilt or decommissioned; the repository host cannot vanish without taking the repository with it.
Make an empty host pattern a failure rather than a silence, add orphaned
schedules to the decommissioning checklist, put set -o pipefail in every
backup wrapper in the estate, and record the repository’s size, used and free
figures monthly so growth becomes a series somebody can extrapolate.