Backup & DRVIII · Backup Repositories: restic, Borg and Repository FailureRepositories
Repository corruption and the partial restore
What you'll learn
- Predict the two shapes a damaged repository produces: a partial restore that reports an error and a complete-looking one that reports nothing
- Read a restore summary as a set of per-file claims rather than a verdict on the recovery point
- Execute the first-hour response for a suspect repository: freeze retention, scope the damage, repair from an independent copy
- Grade every restore taken from a suspect repository file by file against a source-side reference
Prerequisites
Verified against restic 0.19.1 · BorgBackup 1.4.5 · rclone 1.75.0 · MinIO (S3-compatible object storage) RELEASE.2025-09-07T16-13-09Z · OpenZFS 2.4.1 · LVM2 2.03.31(2) · btrfs-progs 6.17.1 · PostgreSQL 18.6 · pgBackRest 2.59.1 · Kubernetes (k3s) and etcd k3s v1.36.3+k3s1, etcd 3.7.1 · Velero 1.18.2 · Docker Engine 29.7.2 · Proxmox Backup Server (documentation only) 4.0.10-1 · Ubuntu (host baseline) 26.04 LTS · 2026-08-28
The previous lesson sorted the verification commands by what each one actually reads, and left a damaged repository standing exactly where the reading pass condemned it. What that repository does next is the part most runbooks never describe, because it is not the part anyone expects. It does not refuse. It restores — quickly, into the directory it was pointed at, with the right names in the right places — and the shortfall appears either as one line among a dozen or as nothing at all. A repository that fails loudly is an inconvenience. A repository that succeeds partially is how wrong data reaches production.
Corruption does not refuse a restore; it edits one
Storage damage in a deduplicating repository has a failure domain, and the domain is much narrower than the repository. A modern backup repository stores content-addressed chunks: each file in a snapshot is recorded as an ordered list of chunk identifiers, and each chunk is written once into a pack file that may hold chunks belonging to many different files from many different snapshots. Nothing in that arrangement couples one chunk to another. When the bytes of one pack are altered, the tool loses exactly the chunks stored inside it, and every other chunk in the repository remains readable, verifiable and restorable.
The result is that a restore from a damaged repository does not degrade proportionally. It produces a directory tree in which some files are byte-exact and others are not, and the boundary between the two categories is drawn by which packs the file’s chunk list happens to touch — an implementation detail of deduplication that has no relationship at all to which files matter. The largest pack in a repository typically holds bulk file data rather than metadata, so the file most likely to be damaged is the biggest one, which in most estates is the database, the mailbox store or the media archive.
There are three outcomes a damaged repository can present, ranked by how much they tell the person doing the recovery. The safest is an outright refusal — an unreadable index, a missing key — which is loud and forces escalation before anything is delivered. The middle outcome is a partial restore that reports an error, which the restic capture below shows: the information is present, but it is one line in a stream of output. The worst is a restore that reports nothing at all and hands over an incomplete tree, which the tar capture shows, and which no care taken during the restore itself can detect.
Measured: six of seven entries, and one file that was not what it claimed
The setup is the one carried through this part of the course. A restic
repository held two snapshots of a 60.000 MiB three-file tree across seven pack
files. Its largest data pack was 17374653 bytes; ten bytes at the midpoint of
that pack were overwritten in place. Afterwards the file still occupied its
directory entry at 17374653 bytes, restic check reported no errors were found and exited 0, and restic check --read-data exited 1 naming pack
2c3be6d1c75844d268248b7a2a90e42f5d325095bd381b31c25bcc3d0179951f. That is the
state the repository was in when somebody asked it for their data back.
$ restic restore 3fe43af4 --target /work/restore2restoring snapshot 3fe43af4 of [/work/prod] at 2026-08-28 13:27:02.65376235 +0000 UTC by root@8211a08b55c3 to /work/restore2
ignoring error for /work/prod/db/data.bin: decrypting blob <data/9a6d59cf> from pack 2c3be6d1c75844d268248b7a2a90e42f5d325095bd381b31c25bcc3d0179951f failed: ciphertext verification failed
Summary: Restored 6 / 7 files/dirs (59.401 MiB / 60.000 MiB) in 0:00
Fatal: There were 1 errors
>>> exit code: 1
--- verifying whatever was restored, file by file ---
./app/app.conf: OK
./app/orders.csv: OK
./db/data.bin: FAILED
md5sum: WARNING: 1 computed checksum did NOT match
>>> verification exit code: 1Every line there is worth reading as a separate statement, because they are
separate statements and only one of them is a warning. ignoring error for /work/prod/db/data.bin is the tool announcing a decision: it hit an
unrecoverable chunk, it chose to continue with the rest of the work, and it told
you so. Summary: Restored 6 / 7 files/dirs (59.401 MiB / 60.000 MiB) is a
count of entries and bytes, not an assessment of correctness — six of the seven
directory entries in the snapshot completed, and the 0.599 MiB shortfall
measures the chunks that lived in the damaged pack, not the size of the file
that lost them. Fatal: There were 1 errors and exit code 1 are the only
aggregate signals in the whole run, and both are trivially discarded by a
wrapper script that pipes output to a log and moves on.
Then the comparison, which is the part that turns a suspicion into a fact. Two
files matched the checksums recorded from the source at 09:00 and one did not.
Note what md5sum had to do in order to report ./db/data.bin: FAILED together
with 1 computed checksum did NOT match — it opened that file and read it
through. The file was there. It had its name, it sat in the right directory, and
it was readable enough to hash. What it did not have was the contents anybody
wanted, and no property visible in a directory listing distinguishes it from the
two files that came back correct.
The second shape: a chain replayed with a member missing
The restic case at least reported something. The worse shape appears when the
missing piece is not a damaged object inside a repository but a whole archive
that the restore process was never given, and it is easiest to see in GNU tar’s
--listed-incremental format, where the levels of the chain are explicit files
with names an operator chooses.
$ tar --listed-incremental=snap.db -cf L0.tar src$ tar --listed-incremental=snap.db -cf L0.tar src (level 0, the full)
L0.tar: 10240 bytes
$ tar --listed-incremental=snap.db -cf L1.tar src (level 1)
L1.tar: 10240 bytes
$ tar --listed-incremental=snap.db -cf L2.tar src (level 2)
L2.tar: 10240 bytes
--- correct restore: replay L0, then L1, then L2 ---
orders.csv:
ORDER-1001,4500.00
ORDER-1002,1250.00
app.conf : config v2
--- now L1 is unreadable: retention removed it, or its media failed ---
after L0 only:
orders.csv: ORDER-1001,4500.00
app.conf : config v1
skipping L1 (missing) and applying L2:
orders.csv: ORDER-1001,4500.00
app.conf : config v2Compare the correct replay with the damaged one line by line. The correct one
produces an orders.csv holding both ORDER-1001,4500.00 and
ORDER-1002,1250.00, and an app.conf reading config v2. The replay that
skipped the missing level 1 produces an app.conf that also reads config v2 —
identical, correct, current — and an orders.csv holding only
ORDER-1001,4500.00. One row of business data is simply absent, and the absence
was announced by nothing.
This is worse than the restic case in every dimension that matters. There is no error line, no counter and no exit status to discard, because nothing went wrong: tar was handed two archives and it applied two archives, exactly as asked. Worse, the artefact most likely to be used as an informal sanity check — the configuration file, the one a human can read and recognise — came out correct and current, because level 2 happened to contain it. The plausible neighbour vouches for the corrupt one. An operator inspecting the restored directory sees a config file bearing the newest content and reasonably concludes the recovery is up to date.
The structural cause is that an incremental chain’s completeness is a property of the set of archives supplied to the restore, and nothing in the restore process is in a position to know what that set should have been. The chain metadata lives in the snapshot database on the backup side; the archives are ordinary files on the restore side, subject to retention policy, media failure and human selection. A chain of one has no such exposure; a chain of thirty has thirty opportunities for one member to be quietly absent when it is wanted.
The first hour with a repository under suspicion
The response is a sequence, and the order is what makes it a procedure rather than a set of good intentions. The first move is not diagnostic; it is to stop the processes that delete things.
Retention is the danger. In restic’s model, forget removes snapshots and
prune removes the data that no remaining snapshot references, and the upstream
documentation is explicit that a failed prune run can leave a repository
temporarily unusable. A retention job running against a repository you already
know to be damaged can therefore drop the older recovery point whose chunks
would have covered the hole, and it can do it on schedule, tonight, while the
incident channel is still arguing about scope.
REPO=/srv/backup/repo
PACK=2c3be6d1c75844d268248b7a2a90e42f5d325095bd381b31c25bcc3d0179951f
EVIDENCE=/var/lib/backup-incident/$(date -u +%Y%m%dT%H%M%SZ)
# 1. freeze everything that removes snapshots or reclaims space
systemctl disable --now restic-forget.timer restic-prune.timer
mkdir -p "$EVIDENCE"
# 2. record the damage before anything modifies the repository
restic -r "$REPO" check --read-data > "${EVIDENCE}/read-data.log" 2>&1
echo "$?" > "${EVIDENCE}/read-data.rc"
# 3. name the recovery points that contain unreadable data, changing nothing
restic -r "$REPO" repair snapshots --dry-run > "${EVIDENCE}/affected.log" 2>&1
printf 'pack %s reported damaged; retention frozen\n' "$PACK" \
>> "${EVIDENCE}/incident.log"
Step three is the scoping question, and it is the one people skip. A damaged pack does not damage “the backups”; it damages the specific recovery points whose files reference chunks inside it, and those may be one snapshot or every snapshot taken since the chunk was first written, because deduplication means a single stored chunk can be shared by months of daily backups. The dry-run form of the repair command answers that without altering anything, and the answer is what tells you whether an older recovery point is still a viable source.
Repair itself should replace the object rather than discard it, and that is only possible if a second, independent copy of the repository exists. In the capture, the original pack file was restored from an undamaged copy and the repository was then put through a complete reading pass.
$ restic check --read-datausing temporary cache in /tmp/restic-check-cache-2505547008
create exclusive lock for repository
load indexes
check all packs
check snapshots, trees and blobs
[0:00] 100.00% 2 / 2 snapshots
read all data
[0:00] 100.00% 7 / 7 packs
no errors were found
>>> exit code: 07 / 7 packs read, no errors were found, exit code 0 — the repository is back
to a state that supports a dated proof, and no recovery point was lost. That
outcome required an independent copy to exist before the incident, which is the
entire operational argument for the second copy restated as a repair procedure
rather than as a policy. Where no such copy exists, the documented paths remain:
the troubleshooting guide notes that re-running the backup tasks can heal older
snapshots when the source still holds the data, and restic repair packs
followed by restic repair snapshots --forget will return a clean repository at
the cost of the unreadable data.
Whatever route the repair took, one rule survives it. Until the repository has passed a complete reading pass, every restore taken from it is a suspect tree and has to be graded per file against a reference recorded outside it.
TARGET=/srv/recovery/restore/work/prod
MANIFEST=/var/lib/backup-verify/source-20260828T090000Z.sha256
VERDICT=/var/lib/backup-incident/per-file-verdict.txt
( cd "$TARGET" || exit 1; sha256sum -c "$MANIFEST" ) > "$VERDICT" 2>&1
CHECKED=$(grep -c ':' "$VERDICT")
FAILED=$(grep -c ': FAILED' "$VERDICT")
printf 'checked %s files, %s did not match the source manifest\n' \
"$CHECKED" "$FAILED"
grep ': FAILED' "$VERDICT" || true
What that produces is a list, which is what the business needs. “The restore failed” stops a recovery; “these two files did not match, everything else did” lets the application owner decide whether the service can start without them, whether the missing rows can be replayed from another system, and whether an older recovery point is worth the extra hours.
Production discipline
- Freeze retention before you diagnose anything.
forgetremoves snapshots andpruneremoves the data nothing else references; a scheduled run against a repository you already know to be damaged can delete the older recovery point that would have covered the hole. Disable the timers first, record that you did, and re-enable them only after the reading pass returns exit code 0. - Read the exit code, never the summary line. The measured restore printed
Summary: Restored 6 / 7 files/dirs (59.401 MiB / 60.000 MiB)and exited 1, and the shortfall of one entry was the database file. Any wrapper that logs output without capturing the status has thrown away the only aggregate signal the run produced. - Repair by replacing the object, and only then by discarding it. Restoring
the original pack from an independent copy returned the repository to
7 / 7 packsread andno errors were foundat exit code 0 with no recovery point lost.restic repair packsandrestic repair snapshots --forgetare the documented fallback and they make the loss permanent, so they come after every independent copy has been examined, never before. - Grade every restore from a suspect repository file by file. In the
capture, two files reported
OKand one reportedFAILEDagainst checksums recorded from the source at 09:00 — andmd5sumcould open and read the failed file, so nothing about its presence, name or readability marked it out. Only the comparison did. - Keep chains short and their members enumerable. With the level 1 archive
missing, replaying level 0 and level 2 produced a directory with the newest
app.confand anorders.csvholding one row instead of two, and reported no error at all. Every additional member in a chain is another object whose quiet absence produces a plausible, wrong recovery.
Cross-course references
- Ceph & Distributed Storage for Production Sysadmins — Part CXVIII (Data
Integrity Incident) runs this same procedure at cluster scale: a deep scrub
names an inconsistent object, the response freezes the operations that would
destroy the surviving copies, and repair means sourcing the object from a good
replica rather than deleting it. The reason this lesson insists on repairing
from an independent copy before running
restic repair packsis the reason that part insists on identifying the authoritative replica first. - Observability for Production Sysadmins — Part CIX (Incident Investigation Workflows) covers the query work behind step three of the procedure here. Mapping one damaged pack to the set of recovery points that reference chunks inside it is an investigation, not a lookup, and the value of the answer depends on having exported the restore exit codes and verification results that this lesson tells you to record.
- Linux for Production Sysadmins — Part XLIX (Restore) treats restore as an operational procedure with a defined handover, which is precisely where the per-file verdict list belongs: the deliverable at the end of a recovery from a suspect repository is not a directory, it is a directory plus the list of files that failed comparison against the source manifest.
Quiz
Knowledge check · 5 questions
Q1. A restore from a repository with one damaged pack ends with `Summary: Restored 6 / 7 files/dirs (59.401 MiB / 60.000 MiB)` and exit code 1. What is sitting in the target directory?
Q2. A tar listed-incremental chain has lost its level 1 archive. An operator replays level 0 and then level 2 into a clean directory. What does the process report?
Q3. A restore that completes with exit code 0 is by itself sufficient evidence that a suspect repository produced a correct tree.
Q4. A full reading pass has just reported one unreadable pack in the production repository. Which of these belong in the first hour of the response? Select all that apply.
Q5. An application owner is handed a recovery taken from a repository with one known-damaged pack. Write the handover statement, and say which evidence it must carry.
Passing score: 75%. Answers are checked in this browser.