Lesson 16 / 16
Restore Drill
Proving that the backup actually works: real files are backed up, deleted, and restored; being byte-for-byte identical is verified with a digest; five defects seen only through the drill are counted; restore duration is split into five components and compared against the recovery target; and drill frequency is scanned alongside the uncertainty that accumulates between drills.
Published Updated
In this lesson
- Backs up and restores real files, and verifies they are byte-for-byte identical with a digest comparison
- Counts the defects seen only through the drill and compares them with what the backup step saw
- Splits restore duration into five components and compares it against the recovery time objective
- Scans drill frequency against its own cost and the uncertainty that accumulates between two drills
Contents
The previous lesson tied backup coverage, frequency, and retention to numbers; those numbers were read from a backup set. The item the default leaves is the quietest one: the backup step returns successful. The archive is produced, its size is written, a record is kept — and that record says nothing about whether the backup can actually be restored. The measure is proof of reversibility: a digest comparing the byte in production with the byte that comes back.
The Drill Really Runs
- HB31 — The fictional regional measurement network’s production tree is really written to disk: five items, their contents from our own generator, seed 51703; the temp directory is deleted at the end of the run.
- HB32 — The archive container is our own format; the password and salt are fictional.
- HB33 — Three of the five defects are really injected (an out-of-scope item, a flipped byte, a different key); wrong order and missing permission are modeled.
- HB34 — The recovery time objective is input from the Resilience and Reliability course: 240 minutes. Download time derives from the previous lesson’s 649.8 GiB full backup and a fictional 80 MiB/s line; the other four components are modeled.
- HB35 — The production set gets three config changes a month and one item a quarter; one drill is six hours of one person’s time, and runs in a separate restore environment.
- HB36 — Time is measured but not printed; it is only passed through a band check.
// drill/container.mjs — the archive container is our own format: manifest + sequential contents. // The password and salt are fictional, no real secret is written. import { readFileSync, writeFileSync, readdirSync, mkdirSync } from "node:fs"; import { join } from "node:path"; import { gzipSync, gunzipSync } from "node:zlib"; import { createCipheriv, createDecipheriv, createHash, scryptSync } from "node:crypto"; export const SEED = 51703; // we write the generator ourselves, the seed is visible let x = SEED >>> 0; export const generate = (n) => { const b = Buffer.allocUnsafe(n); for (let i = 0; i < n; i += 4) { x = (Math.imul(x, 1103515245) + 12345) >>> 0; b.writeUInt32LE(x, i); } return b; }; export const digest = (b) => createHash("sha256").update(b).digest("hex"); const salt = Buffer.from("mock-salt-example-invalid"); export const KEY = scryptSync("mock-backup-password-example-invalid", salt, 32); export const OTHER = scryptSync("mock-other-password-example-invalid", salt, 32); const IV = Buffer.from("mock-iv-12b"); // 12 bytes, fixed and fictional export function pack(dir, skip = []) { // skip: item left out of the plan const names = readdirSync(dir).sort().filter((a) => !skip.includes(a)); const body = names.map((a) => readFileSync(join(dir, a))); const manifest = Buffer.from(JSON.stringify(names.map((a, i) => [a, body[i].length]))); const head = Buffer.allocUnsafe(4); head.writeUInt32LE(manifest.length, 0); return Buffer.concat([head, manifest, ...body]); } export function unpack(archive, dir) { const n = archive.readUInt32LE(0); let p = 4 + n; mkdirSync(dir, { recursive: true }); for (const [a, size] of JSON.parse(archive.subarray(4, 4 + n).toString())) { writeFileSync(join(dir, a), archive.subarray(p, p + size)); p += size; } } export function encrypt(archive) { const s = createCipheriv("aes-256-gcm", KEY, IV); return { body: Buffer.concat([s.update(gzipSync(archive)), s.final()]), tag: s.getAuthTag() }; } export function decrypt({ body, tag }, key = KEY) { try { // a corrupt archive or the wrong key falls through const d = createDecipheriv("aes-256-gcm", key, IV).setAuthTag(tag); return gunzipSync(Buffer.concat([d.update(body), d.final()])); } catch { return null; } }
// drill/drill.mjs — REALLY runs: a fictional production tree is written to disk, archived, // DELETED, and restored; being byte-for-byte identical is verified with a digest. Time is not printed. import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, readdirSync, rmSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { SEED, generate, digest, pack, unpack, encrypt, decrypt, OTHER } from "./container.mjs"; const ITEM = [["metric.data", 262144], ["work-order.data", 131072], ["rate.data", 65536], ["service.config", 2048], ["zone.record", 1024]]; // fictional measurement network tree const CONTENT = ITEM.map(([name, n]) => [name, generate(n)]); const root = mkdtempSync(join(tmpdir(), "drill-")); const production = join(root, "production"); const state = () => readdirSync(production).sort().map((a) => [a, digest(readFileSync(join(production, a)))]); function run({ skip = [], corrupt = false, key = undefined }) { rmSync(production, { recursive: true, force: true }); mkdirSync(production, { recursive: true }); for (const [name, b] of CONTENT) writeFileSync(join(production, name), b); const before = state(); let arc = encrypt(pack(production, skip)); if (corrupt) arc = { ...arc, body: Buffer.concat([arc.body.subarray(0, 64), // one byte is flipped Buffer.from([arc.body[64] ^ 1]), arc.body.subarray(65)]) }; rmSync(production, { recursive: true }); // production is deleted: return comes from the archive const container = decrypt(arc, key); if (container) unpack(container, production); const after = new Map(container ? state() : []); return { before, opened: !!container, archiveBytes: arc.body.length, same: before.filter(([name, o]) => after.get(name) === o).length }; } const t0 = process.hrtime.bigint(); const healthy = run({}); const sec = Number(process.hrtime.bigint() - t0) / 1e9; const bytes = CONTENT.reduce((a, [, b]) => a + b.length, 0); const back = new Map(state()); console.log(`${ITEM.length} items, ${bytes} bytes (seed ${SEED}); archive ${healthy.archiveBytes} bytes`); console.log("item | prod digest (first 16) | same on return"); console.log("--------------------|---------------------------|-----------------"); for (const [name, o] of healthy.before) console.log(`${name.padEnd(19)} | ${o.slice(0, 16).padStart(25)} | ` + `${(back.get(name) === o ? "yes" : "no").padStart(16)}`); // MODEL: if order breaks, how many steps come up with empty data const DEPENDS = { secret: [], data: ["secret"], config: [], service: ["data", "config"] }; const WRONG_ORDER = ["service", "data", "config", "secret"]; const empty = WRONG_ORDER.filter((a, i) => DEPENDS[a].some((b) => WRONG_ORDER.indexOf(b) > i)).length; // MODEL: required/granted permissions for the restore identity const REQUIRED = { "backup-store": "read", "object-store": "write", "secret-store": "read", "service-manager": "start" }; const GRANTED = { "backup-store": "read", "secret-store": "read" }; const missing = Object.keys(REQUIRED).filter((k) => GRANTED[k] !== REQUIRED[k]); console.log("\ntrial | opened | match | missing | backup step"); console.log("------------------------------|----------|---------|-----------|------------------"); let found = 2; // two MODEL defects to start for (const [name, option] of [["healthy drill", {}], ["missing item (config)", { skip: ["service.config"] }], ["corrupt archive (one byte)", { corrupt: true }], ["undecryptable cipher", { key: OTHER }]]) { const r = run(option), n = r.before.length; if (r.same < n) found += 1; console.log(`${name.padEnd(29)} | ${(r.opened ? "yes" : "no").padStart(8)} | ` + `${(r.same + "/" + n).padStart(7)} | ${String(n - r.same).padStart(9)} | ` + `${(r.same < n ? "looked successful" : "successful").padStart(16)}`); } console.log(`wrong order (MODEL) | ${empty}/${WRONG_ORDER.length} steps open with empty data` + `${" ".repeat(2)}| looked successful`); console.log(`missing permission (MODEL) | ${missing.length} items: ${missing.join(", ")} | looked successful`); console.log(`\ndrill found ${found} defects; seen in the backup step 0`); const mib = bytes / 1024 / 1024, [LOW, HIGH] = [0.5, 20000]; // MiB/s band; the raw measurement is not printed console.log(`backup+delete+restore speed within ${LOW}-${HIGH} MiB/s band: ` + `${mib / sec >= LOW && mib / sec <= HIGH ? "yes" : "no"}`); rmSync(root, { recursive: true, force: true }); // the temp tree is deleted
5 items, 461824 bytes (seed 51703); archive 462107 bytes item | prod digest (first 16) | same on return --------------------|---------------------------|----------------- metric.data | 3edf42c01d4f883f | yes rate.data | 434443d05cecee54 | yes service.config | 4ead0d5566a44d75 | yes work-order.data | 5c4512d9a8016d51 | yes zone.record | b7c0e693cd032524 | yes trial | opened | match | missing | backup step ------------------------------|----------|---------|-----------|------------------ healthy drill | yes | 5/5 | 0 | successful missing item (config) | yes | 4/5 | 1 | looked successful corrupt archive (one byte) | no | 0/5 | 5 | looked successful undecryptable cipher | no | 0/5 | 5 | looked successful wrong order (MODEL) | 2/4 steps open with empty data | looked successful missing permission (MODEL) | 2 items: object-store, service-manager | looked successful drill found 5 defects; seen in the backup step 0 backup+delete+restore speed within 0.5-20000 MiB/s band: yes
The first table is this lesson’s proof: the production tree was written, archived, deleted, and brought back from the archive alone; digests came out identical for all five of the five items. Not a sentence, five comparisons.
The last column of the second table is the finding. The config left out of scope does not come back, but the archive still opens; the archive with one flipped byte and the archive encrypted with a different key do not open at all, and all five items go missing at once. In the wrong order, two of the four steps open with empty data, and the identity is missing two permissions. All five of the five defects were invisible in the backup step.
Duration Splits Into Components
// drill/duration.mjs — restore duration splits into components, is compared against the target, and // drill frequency is scanned. MODEL; download time derives from 05's full backup size. const TARGET = 240; // minutes; input from the previous course const FULL_GIB = 649.8, LINE_MIBS = 80; // full backup size; line speed is fictional const DOWNLOAD = (FULL_GIB * 1024) / LINE_MIBS / 60; const COMPONENT = { // [undrilled, drilled] minutes find: [95, 12], download: [DOWNLOAD, DOWNLOAD], extract: [65, 38], verify: [40, 15], "start service": [85, 22], }; const g = (x, n, d = 1) => x.toFixed(d).padStart(n); const sum = (i) => Object.values(COMPONENT).reduce((a, b) => a + b[i], 0); const SHARE = TARGET / Object.keys(COMPONENT).length; // equal share console.log(`target ${TARGET} min; full backup ${FULL_GIB} GiB, line ${LINE_MIBS} MiB/s, equal share ${SHARE} min.`); console.log("\ncomponent | undrilled | drilled | percent of target"); console.log("----------------|-------------|------------|---------------------"); for (const [name, [y, t]] of Object.entries(COMPONENT)) console.log(`${name.padEnd(15)} | ${g(y, 11)} | ${g(t, 10)} | ${(g((100 * t) / TARGET, 6) + "%").padStart(20)}`); console.log(`${"total".padEnd(15)} | ${g(sum(0), 11)} | ${g(sum(1), 10)} | ` + `${(g((100 * sum(1)) / TARGET, 6) + "%").padStart(20)}`); const over = Object.entries(COMPONENT).filter(([, [, t]]) => t > SHARE).map(([a]) => a); console.log(`${over.length} components exceed the equal share in drilled time: ${over.join(", ")}`); const CHANGE_RATE = 3 / 30 + 1 / 90; // daily change: 3 configs a month, 1 item a quarter const ITEM = 16, HOURS = 6; // inventory; one drill is six hours console.log("\nfrequency | yearly | yearly hours | unverified change | restore | target"); console.log("-----------------|----------|----------------|--------------------------|--------------|-----------"); for (const days of [90, 180, 365, null]) { const n = days ? Math.floor(365 / days) : 0; const uncertain = days ? days * CHANGE_RATE : 365 * CHANGE_RATE + ITEM; const dur = days ? sum(1) : sum(0); console.log(`${(days ? days + " days" : "never").padEnd(16)} | ${String(n).padStart(8)} | ` + `${String(n * HOURS).padStart(14)} | ${(g(uncertain, 4) + (days ? " items" : " items+set")).padStart(25)}` + ` | ${g(dur, 12)} | ${(dur > TARGET ? "exceeded" : "held").padStart(10)}`); }
target 240 min; full backup 649.8 GiB, line 80 MiB/s, equal share 48 min. component | undrilled | drilled | percent of target ----------------|-------------|------------|--------------------- find | 95.0 | 12.0 | 5.0% download | 138.6 | 138.6 | 57.8% extract | 65.0 | 38.0 | 15.8% verify | 40.0 | 15.0 | 6.3% start service | 85.0 | 22.0 | 9.2% total | 423.6 | 225.6 | 94.0% 1 components exceed the equal share in drilled time: download frequency | yearly | yearly hours | unverified change | restore | target -----------------|----------|----------------|--------------------------|--------------|----------- 90 days | 4 | 24 | 10.0 items | 225.6 | held 180 days | 2 | 12 | 20.0 items | 225.6 | held 365 days | 1 | 6 | 40.6 items | 225.6 | held never | 0 | 0 | 56.6 items+set | 423.6 | exceeded
In the undrilled arrangement, restore takes 423.6 minutes, missing the target by 183.6 minutes; in the drilled arrangement it drops to 225.6 minutes and holds the target with a 14.4-minute margin. What the drill shortens is knowledge: finding the right point drops from 95 minutes to 12, starting services from 85 to 22 — rehearsed steps.
The download column does not change at all: 138.6 minutes, 57.8 percent of the target, and the only component that exceeds the equal share. This time does not come from a lack of knowledge, it comes from pushing 649.8 GiB through an 80 MiB/s line; the drill measures it but does not shorten it. The way to hit the target is not more drilling, it is moving the backup closer to where it will be restored. The frequency scan puts the cost and the alternative side by side: a drill every ninety days is twenty-four hours a year and 10 items of uncertainty, a drill once a year is six hours and 40.6 items. Never drilling costs zero, the uncertainty is all sixteen items, and duration misses the target.
Summary
- The backup step returned successful for all five of the five defects; all of them were seen only in the drill: an out-of-scope item, a corrupt archive, an undecryptable cipher, wrong order, missing permission.
- Proof is a digest comparison: five items were written, archived, deleted, restored, and digests came out identical.
- Restore duration is 423.6 minutes undrilled, 225.6 minutes drilled; the target holds at 94 percent full. The drill shortens four components and does not touch download: 138.6 minutes is 57.8 percent of the target and the only component exceeding the equal share.
- Drill frequency is a cost-uncertainty trade-off: a drill every ninety days is 24 hours and 10 items a year, never drilling is 0 hours and all sixteen items.
Course Wrap-Up
| Lesson | What the default leaves open | Closed / wrongly closed | New surface closing introduces |
|---|---|---|---|
| Web Server Roles | 18 items | 13 closed / 4 legitimate jobs | 3 new items |
| Reverse Proxy Configuration | 4 pieces of missing info | 4 resolved / 2 items hidden | spoofed header, 4 requests |
| TLS Termination | 19 weak items | 19 closed / 65 devices | certificate at 6 points |
| Certificate Automation | 1,700 hours window | 0 hours / 12 unnecessary renewals | 4 new items |
| Cache Server | 179 leaked requests | 0 leaks / 2x load | escaping broadcast, silent window |
| Service Mesh | 40 touches for 40 services | 508 work items / expensive at 3 services | 162 sidecar items |
| DNS Management | 4 dead, 2 takeover-able records | to 5 minutes / 288x queries | identity writing to the zone |
| Sender Verification Records | unowned sending is free | 5/6 spoofed / 2 legitimate dropped | 254 excess authorized addresses |
| Domain Policy Record | 61 spoofed reach | 49 blocked / 43 legitimate separated | 12 compliant impersonations |
| Mail Delivery Issues | 12-point ceiling | ceiling lifted / 1,078 messages lost | recipient decision unmeasurable |
| Firewall Configuration | 100 items, 107,265 attempts | 10 rows all / 2 flows | external row, 20,650 attempts |
| Remote Access Hardening | 2,340 triples, 16,505 attempts | 16,505 closed / 0 legitimate | 34 key copies |
| Secret Management Systems | 36 items, infinite window | 2.5 minutes / 9 items lifetime-less | single point, 18 instances |
| Patch Management | 60 items, 28.94 days | to 1.22 days / 4 rollbacks, 82 outages | 15 items, patch agent |
| Backup Strategy | 13 items out of scope | to 3 items / services still 2/4 | readable surface doubled |
| Restore Drill | 5 defects invisible, 423.6 min | 5 found / 225.6 min | 24 hours, 2 missing permissions |
No row in the second column shows a trace of a decision: the default is not a decision, it is the decision nobody made. In the third column, wrong closing fills fourteen of the sixteen rows: no step is free. In the fourth column, three types of surface repeat: a new identity or write authorization in five lessons, a single point carrying the whole load in four lessons, a new layer that itself needs configuring in four lessons. Every defense brings its own surface — a closed item does not disappear, it changes shape.
Seven courses in, every layer has been built and hardened separately. Every row in the table is a sequence of decisions: which port, which threshold, which item. But does every team have to rebuild all of this from scratch? Platform Engineering starts with this question.
To keep your progress and take notes, Log in
My notes
Log in to take notes.