Reported symptoms
Someone asks for a file deleted from app-prod-11. The newest recovery point
for that host is 43 days old. They say the file was open on their screen last
Thursday.
The dashboard for that host is green and has been every night since, including
the run that finished six hours ago. The notification channel holds six weeks of
nightly backup finished.
Widened to the estate, twelve of forty-one Linux hosts have recovery points between 41 and 43 days old. The other twenty-nine are under 26 hours. Nothing sits between the two clusters.
The twelve are not a rack, a subnet, a hypervisor, an OS release or a schedule. Four grouping attempts return nothing.
A migration completed the same week the recovery points stop, and somebody proposes closing this as twelve hosts decommissioned and never cleaned up.
Evidence provided
$ restic -r "$REPO" snapshots --json | jq -r 'group_by(.hostname)[] | "\(.[0].hostname) \([.[].time] | max)"' | sortTwelve hosts return a timestamp 41 to 43 days old. Twenty-nine return one from last night. The gap between the clusters is empty.
Those twelve are exactly the hosts whose restic environment file names access
key ID rbdr-key-app. The other twenty-nine name one of the estate’s two other
keys. The correlation holds in both directions.
$ ansible linux_prod -m shell -a 'grep -h AWS_ACCESS_KEY_ID /etc/rbdr-backup.env'The destination’s request log shows nightly authentication failures from all
twelve source addresses, one burst per host per night, continuing up to and
including last night. The last request it answered successfully from any of them
is 42 days old. The credential register gives the expiry date of rbdr-key-app
as the following morning.
The wrapper those hosts run, recovered unchanged from app-prod-11:
#!/bin/bash
set -e
REPO="s3:https://objects.example.net/rbdr-estate-backup"
LOG=/var/log/rbdr-backup.log
restic -r "$REPO" backup /etc /srv /var/lib 2>&1 | tee -a "$LOG"
restic -r "$REPO" forget --keep-daily 14 --keep-weekly 8 --prune 2>&1 | tee -a "$LOG"
logger -t rbdr-backup "nightly backup finished on $(hostname -s)"
systemctl show rbdr-backup.service --property=ExecMainStatus returns 0 for
every invocation in the window.
Meanwhile the load balancer has all twelve in the active pool with current request counts, their monitoring agents have reported without a gap for six weeks, configuration management applied on all twelve inside the last day, and no change request proposes decommissioning any of them.
Work the evidence before reading on
- Twelve ages within two days of each other, and an empty gap to the healthy twenty-nine. What kind of cause produces a cluster that tight, and what kind produces a spread?
- The proposal is that these hosts were decommissioned. Which single piece of evidence kills that outright — and why is it stronger than the load balancer entry?
- The service exited 0 every night for six weeks. Which command’s status is that, and what happened to restic’s?
- Line two is
set -e. Why did it not stop the script when the backup failed?
Root cause
Dispose of the decommissioning theory first, because it explains the ages and nothing else. The destination is logging authentication failures from all twelve addresses, on schedule, up to last night — and a decommissioned host is silent, not turned away at the door. The twelve are also in the active load-balancer pool serving production traffic, with agents that never stopped reporting. These machines are alive, busy, and trying to back up every night.
The credential expired, so the repository was never opened
rbdr-key-app reached its recorded expiry 42 days ago. From the next scheduled
run onward restic could not authenticate, the repository was never opened, and
no snapshot was written.
That is an all-or-nothing failure, which is why the ages cluster instead of drifting. A slow disk, a growing dataset or a flaky network staggers hosts across days; a shared credential stops every host holding it on the same night. The tight cluster is the fingerprint of a shared dependency, and the exact overlap with one key names which dependency it was.
The wrapper reported on its notification, not on its backup
Resolution
Quantify the exposure before changing anything. Name the twelve hosts, record how far back each one reaches, and give the service owners the number. Then ask the retrospective question: did anything on those hosts need recovering in the last six weeks and quietly get written off?
Issue a replacement key, scoped as the old one was, and record its expiry in the register the day it is issued. Distribute it through whatever already owns secrets on these hosts rather than editing twelve environment files by hand.
Prove it on one host, in the foreground, reading the status yourself:
$ sudo systemctl start rbdr-backup.service && systemctl show rbdr-backup.service --property=ExecMainStatus --property=ResultThen stagger the rest. Each host sends six weeks of accumulated change on its first successful run, and twelve of those at once is a capacity event at the destination. Spread them over several nights and watch repository growth. Do not delete old recovery points to free space for the catch-up: that trades a gap you have measured for one you have not.
Repair the wrapper in the same change, because the credential is only half the fault:
#!/bin/bash
set -euo pipefail
REPO="s3:https://objects.example.net/rbdr-estate-backup"
LOG=/var/log/rbdr-backup.log
HOST=$(hostname -s)
status=0
restic -r "$REPO" backup /etc /srv /var/lib >>"$LOG" 2>&1 || status=$?
if [ "$status" -eq 0 ]; then
restic -r "$REPO" forget --keep-daily 14 --keep-weekly 8 --prune >>"$LOG" 2>&1 || status=$?
fi
logger -t rbdr-backup "nightly backup on $HOST exited $status"
exit "$status"
Verification
Recovery point age, read from the repository, is under threshold for all forty-one hosts, with the twelve named individually rather than hidden inside an estate average.
A file restored from a new snapshot on one of the twelve comes back with the expected content. The listing shows the destination accepted a write; only the restore shows it returns bytes.
The wrapper reports failure when the backup fails — demonstrated, not read:
$ sudo env AWS_ACCESS_KEY_ID=rbdr-not-a-key /usr/local/sbin/rbdr-backup.sh; echo "wrapper exited $?"It exits non-zero, systemd records that status, and the notification line carries
it instead of the word finished.
The age alert fires against a host deliberately held past its threshold, and the expiry alert fires against a key deliberately set to expire shortly. Both are tested by tripping them, not by reading the rule.
Prevention
Alert on recovery point age, per system and per copy, derived from the repository listing. It is the only signal here that moved: the ages began rising the morning the key expired and never stopped. A failing run and a run that never happened both raise an age; neither reliably produces a failure event.
Put credential expiry in the recovery inventory. Every key, token and certificate the backup path depends on needs an owner, a recorded expiry and an alert weeks ahead. This is the one outage in the category that is knowable in advance, and it still ran unnoticed for six weeks.
Make shared dependencies visible. Twelve hosts failing together is obvious the moment a dashboard can group by credential, and unreadable when the only groupings are rack, subnet and schedule.
Rotate restore rehearsals across credential groups, not just service tiers. One restore from any of these twelve, on any day in six weeks, would have closed this on day one.