Files
felhom.eu/documentation/audits/SPIKE-dooplex-buildcache-2026-08-05.md
T
admin c917251eeb
gates / gates (push) Successful in 7s
docs: P6 EXECUTED — containerd store moved to SSD2; reboot deferred (R-209, R-209a)
Operator ruled "proceed" after reading the pre-analysis; the storageReserved
condition CC made the move conditional on was applied with it. The pre-analysis
is retained verbatim as §11-bis so the ruling can be audited against what it
was given.

MOVED WITH ZERO LOSS, verified BEFORE the original was touched on four
independent observables: 550,891 = 550,891 filesystem entries; 448 = 448
trusted.overlay xattrs (load-bearing — overlayfs stacking rides them, which is
why rsync -X is not optional); 37,243 = 37,243 hardlinks; byte-identical
meta.db sha256. Verified again after: identical image/tag/volume ID SETS,
cache 2.782 GB / 38 records, ceiling still 30 GiB, pg 4 DBs / 31 tables /
175,135,767 B, redis DBSIZE 2437. End-to-end proof was a real build on the
relocated store, rc=0 — not an inventory count.

k3s was never at risk, and that was established BEFORE stopping anything: it
runs a separate containerd, so Gitea, the registry, the hub, PBS, Longhorn and
~160 pods stayed up. Only the two jarr-* dev containers were affected.

TRAP FOUND WHILE PROVING THE GUARD, and it is the reusable part:
RequiresMountsFor on a path with NO mount unit is a SILENT NO-OP — containerd
started normally against an absent-but-unmounted path. A typo'd guard buys
nothing and says nothing: the built-but-never-wired shape again. The guard was
therefore verified positively at the unit level (Requires= AND
After=mnt-ssd_2.mount on both units), and refusal proven with a genuinely
absent DEVICE via a temporary synthetic .mount unit — /mnt/ssd_2 hosts 12 live
Longhorn replicas and must never be unmounted, and editing fstab on a
production host risks emergency mode at boot. Result:
"Job containerd.service/start failed with result 'dependency'", is-active
inactive. It refused rather than creating an empty store on the root disk.

storageReserved on SSD2 0 -> 80 GB; still Schedulable=True at 76.34%.

NOT PROVEN, AND NOT GLOSSED (R-209a): the operator ruled DO NOT REBOOT, so the
move has not survived a reboot — which is this task's own standard for
validated. The MECHANISM is proven; the CONSEQUENCE (that a real boot mounts
/mnt/ssd_2 before containerd starts, in this host's actual ordering) is not,
and mount-ordering reasoning is exactly the class this project has been burned
by. So: the pre-move tree stays MOVED ASIDE, not deleted (34.3 GB, which is why
/ sits at 54% and not lower), and felhom-store-postboot-check.service — oneshot,
enabled, dry-run PASS at install — validates it automatically at the next boot,
asserting positively that /var/lib/containerd does NOT exist. Read
/var/log/felhom-store-postboot-check.log after the next boot; on PASS the
rollback copy can go and ~34 GB returns.

Gates: repo_gates.py --fast — all OK, rc=0 (run separately from this commit).
2026-08-05 10:27:40 +02:00

827 lines
42 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# SPIKE — DooPlex build-cache containment (cap at 30 GB, relocate to SSD2)
**Date:** 2026-08-05 · **Host:** DooPlex (192.168.0.180) — **Tier 2, protected**, not a drill target
**Class:** Spike. §1, §2, §3, §4, §13-verify, §15 apply; §5–§12 skipped (no production Go code written).
**Anchor:** `dooplex_report.md` (2026-08-05 read-only disk audit). Every number in it was treated as a
claim to re-measure, not a fact to carry forward.
> Unit convention: **GB = decimal (10⁹)**, because `df -B1`, `du -B1` and Prometheus emit raw bytes.
> `docker buildx inspect` renders **GiB** (2³⁰) — where that matters it is labelled.
---
## Verdict
**The mechanism is confirmed, with one correction and one refutation, and the follow-up is safe to spec.**
- **Confirmed:** a `builder.gc` policy in `/etc/docker/daemon.json` **is** honoured under the containerd
worker, and it **does** evict. Proven by naming a 440 MB cache record present at one build and absent
two builds later — not by the absence of an error.
- **Corrected:** it is honoured **only in the `policy` array form**. The flat form
(`{"gc": {"reservedSpace": …}}`) is **silently ignored** — the daemon starts, logs nothing, and keeps
its built-in defaults. `dockerd --validate` is worthless here: it returned `configuration OK` for a
bogus key **and** for a config that then crashed the daemon.
- **Refuted:** the audit's proposal to change Docker's `data-root` would **not** move the build cache. It
moves 0.62 GB. The 181.4 GB belongs to the **system containerd**, relocated via `root` in
`/etc/containerd/config.toml`.
- **The prune is done and it was the whole rescue.** `/` went **86% → 53% used**, and Longhorn's default
disk went **`Schedulable=False (DiskPressure)``Schedulable=True`**. The live consequence is cleared.
- **The largest single finding is not the cap.** 90.5 GB of the 157 GB was one layer re-downloading on
every build, and the root cause is a **one-line Dockerfile ordering defect**`ARG VERSION` declared
above the module-download step. Fixing it removes the source; the cap only bounds the symptom.
- **P6 (the SSD2 move) was NOT executed** — it stops at the operator, as specified. The pre-analysis
says the move is safe as measured but carries a real Longhorn risk, and the prune removed its urgency.
---
## 1. Confirmed baselines
| Repo | `main` @ commit at spike start | Drift vs the task's stated baseline |
|---|---|---|
| `homelab-manifests` | `5188058d0e43` | none — matched |
| `felhom.eu` | `0dbd954fec90` | none — matched |
Both trees clean (`git status --porcelain` empty) and `HEAD == origin/main` before any commit.
**Register:** highest allocated at baseline was **R-204**, re-confirmed in `OPEN-ITEMS.md`. **R-205**
onward were free and are allocated here (R-205 … R-211).
**One path correction:** `homelab-manifests` is **not** in `/mnt/5_hdd/felhom.eu/git/` with the other
repos — it lives at **`/home/kisfenyo/git/homelab-manifests`**.
---
## 2. P0 — arrival state, expected vs measured
Measured live before anything was changed. **Values that matched are recorded, not omitted.**
| Fact | Expected (audit) | **Measured** | |
|---|---|---|---|
| Root fs size | 477,393,182,720 B | **477,393,182,720 B** | match |
| Root fs used | 387.3 GB | **387,332,755,456 B** | match (+10.8 MB in 2 h) |
| Root fs avail | 65.7 GB | **65,734,934,528 B** | match |
| Root fs percent | 86% | **86%** | match |
| Docker server version | 29.6.2 | **29.6.2** (build `3d80467`) | match |
| Storage driver | overlayfs, containerd snapshotter | **`overlayfs`, `driver-type: io.containerd.snapshotter.v1`** | match |
| Docker Root Dir | `/var/lib/docker`, ~0.6 GB | **`/var/lib/docker`, 618,541,056 B** | match |
| containerd root | `/var/lib/containerd`, ~181.4 GB | **181,394,223,104 B** | match |
| Build cache total / reclaimable | 157 GB / 147.8 GB | **157 GB / 147.8 GB**, 1446 records | match |
| Images / containers | 345 images, 2 running | **345 unique** (350 tag lines), **2 running** | match |
| `/etc/docker/daemon.json` | does not exist | **does not exist** (`/etc/docker/` exists, empty) | match |
| `/etc/buildkit/` | does not exist | **does not exist** | match |
| SSD2 device / mount / fs / free | ~240 GB, ~203 GB free | **`/dev/sde2`, `/mnt/ssd_2`, ext4, 235,148,750,848 B fs, 203,034,353,664 B free** | match |
| SSD1 Longhorn | `Schedulable=False (DiskPressure)` | **`Schedulable=False`, `DiskPressure`, 18.85% avail** | match |
| HDD2 Longhorn | also False | **`Schedulable=False`, `DiskPressure`, 24.91% avail** | match |
| Housekeeping timer | active, last Sun 2026-08-02 | **active**, last **2026-08-02 05:35:10 CEST**, next 08-09, `ExecMainStatus=0` | match |
**Prohibition honoured:** `node-housekeeping.sh` was **not run**, in any mode. Its state was read from
systemd, from the textfile on disk, and from Prometheus. The textfile's mtime is still
`2026-08-02 05:35:11` — the 8-week reclaim history is intact.
**What the audit did not measure — the SSD2 Longhorn record** (`Crucial-SSD-240G`,
`/mnt/ssd_2/data/longhorn`):
| field | value |
|---|---:|
| `storageMaximum` | 235,148,750,848 |
| `storageAvailable` | 214,958,080,000 (**91.41%**) |
| `storageScheduled` | 139,586,437,120 |
| `storageReserved` | **0** |
| condition | **`Schedulable=True`** |
| replicas | **12** |
**Two things stand out and both matter for P6.** SSD2 is the **only** Longhorn disk reserving **zero**
(HDD2 and HDD4 each reserve 500 GB). And `storageScheduled` (139.6 GB) exceeds actual usage
(`df`: 20,094,939,136 B) by **6.9×** — Longhorn is heavily overcommitted on this disk.
---
## 3. P1 — which daemon owns the bytes
> **The single configuration change that relocates the build cache is `root = "<path>"` in
> `/etc/containerd/config.toml`, owned by `containerd.service` — the SYSTEM containerd
> (PID 2698677, socket `/run/containerd/containerd.sock`).**
**The audit's `data-root` proposal is refuted, and the split is measured:**
| tree | size | moved by `data-root`? |
|---|---:|---|
| `/var/lib/docker` | **618,541,056 B** (volumes 321 MB, **buildkit metadata 282 MB**, containers 14 MB) | yes |
| `/var/lib/containerd` | **181,394,223,104 B** (overlayfs snapshots **170,357,870,592 B**, content 10.98 GB) | **no** |
`dockerd` runs as `/usr/bin/dockerd -H fd:// --containerd=/run/containerd/containerd.sock` with **no
systemd drop-ins**. BuildKit keeps its *record database* under Docker Root Dir (0.28 GB) but its *layer
bytes* in containerd's snapshotter — which is why `data-root` looks plausible and is wrong.
In `/etc/containerd/config.toml` the `root` key is **present but commented out**, so it defaults to
`/var/lib/containerd`. Uncommenting it is the whole change.
**k3s is unaffected — confirmed, not assumed.** k3s runs a **separate** containerd instance with its own
socket (`/run/k3s/containerd/containerd.sock`) and its own root
(`/var/lib/rancher/k3s/agent/containerd`, **79,669,473,280 B**). Nothing in this spike touches it.
---
## 4. P2 — which GC key schema this release accepts
**`dockerd --validate` exists on 29.6.2 and is not an oracle for this subtree.** Measured:
| candidate | `--validate` | reality |
|---|---|---|
| `defaultKeepStorage` (old flat form) | `configuration OK` | — |
| `policy` array | `configuration OK` | **honoured** |
| `reservedSpace` / `maxUsedSpace` / `minFreeSpace` flat | `configuration OK` | **silently ignored** |
| **`totallyBogusKey`** | **`configuration OK`** | proves the validator does no field checking |
| `policy` with `"filter": [three values]` | **`configuration OK`** | **crashed the daemon on restart** |
> **The real oracle is `docker buildx inspect`, which renders the *effective* policy.** That is an
> assertion of effect; `--validate` and "no error in the log" are both assertions of absence, and both
> were measured to lie here.
**The flat form is silently ignored.** With `{"gc":{"enabled":true,"reservedSpace":"1GB","maxUsedSpace":"2GB","minFreeSpace":"10GB"}}`
applied and the daemon restarted, `buildx inspect` still reported the built-in defaults, and
`journalctl -u docker.service` carried **no warning, no deprecation notice and no error**.
**The `filter` key takes ONE value per policy entry, not an array.** An array is fatal at startup:
```
error initializing buildkit: error creating buildkit instance: filters expect only one value
```
The daemon then entered a systemd restart loop. **This is also how the two `unless-stopped` dev
containers ended up stopped** — and they did **not** come back when the daemon later started cleanly;
they needed a manual `docker start`. A follow-up that templates this file must **restart and verify**,
never validate-and-assume.
**Documented statement on containerd-worker applicability:** none found in `dockerd --help` or the
installed docs. Recorded as **unknown from documentation**, and settled empirically by P5.
**The default policy is not "no policy".** Before any change, `buildx inspect` reported built-in rules
with **Reserved 41.91 GiB / Max Used 331.6 GiB / Min Free 82.89 GiB**. The audit's "there is no BuildKit
GC policy on this host" is *almost* right — there was one, sized so large it could never bind: the cache
at 157 GB was less than half the 331.6 GiB ceiling.
### The validated `daemon.json`, verbatim (final state, left in place deliberately)
```json
{
"builder": {
"gc": {
"enabled": true,
"policy": [
{ "reservedSpace": "10GB", "maxUsedSpace": "30GB", "minFreeSpace": "20GB" },
{ "all": true, "reservedSpace": "10GB", "maxUsedSpace": "30GB", "minFreeSpace": "20GB" }
]
}
}
}
```
Effective policy confirmed live (note the **GiB** rendering — `30GB` in JSON becomes a 30 GiB rule):
```
GC Policy rule#0: All: false Reserved Space: 10GiB Max Used Space: 30GiB Min Free Space: 20GiB
GC Policy rule#1: All: true Reserved Space: 10GiB Max Used Space: 30GiB Min Free Space: 20GiB
```
**No deprecation warning was emitted** for any accepted form.
---
## 5. P3 — the prune: claimed, actual, settle
Pre-checked: no build in progress, P0 and P7 captured first.
| | value |
|---|---:|
| **Claimed by `docker builder prune -af`** | **156.9 GB** (1444 records deleted, 86 s wall-clock) |
| **Actual filesystem reclaim** | **150,353,829,888 B (150.35 GB)** |
| **Time to settle** | **synchronous — full reclaim visible at t+0** |
Settle poll (t+0 already showed +150,354,477,056 B; two consecutive readings <50 MB apart at t+60s;
the residual 0.4 MB and 0.3 MB steps are ordinary background writes, not lagging reclaim):
| t | avail | step delta |
|---|---:|---:|
| 0 s | 215,947,522,048 | — |
| 30 s | 215,947,141,120 | 380,928 |
| 60 s | 215,946,874,880 | 266,240 |
**The 6.5 GB gap between claimed and actual is not an error** — it is the layers still shared with
retained images (`Shared: 8.6 GB` before the prune). **A weekly job must measure the filesystem, not
trust the command's `Total:`.**
**Unlike containerd's image GC, this returns bytes synchronously**, so the follow-up needs no
`settle_imagefs` equivalent — but the settle poll is what *established* that, and it cost 60 seconds.
| | before | after |
|---|---:|---:|
| `/` used | 387,474,644,992 B (**86%**) | 240,301,133,824 B (**53%**) |
| `/` avail | 65,593,044,992 B | 215,946,874,880 B |
| `/var/lib/containerd` | 181,537,714,176 B | 31,160,053,760 B |
| build cache | 156.9 GB / 1443 records | **0 B / 0 records** |
| images | 345 | **345** (untouched) |
| dev containers | 2 healthy | **2 healthy** |
### The live consequence, cleared
Longhorn re-evaluated within 60 s:
| disk | before | after |
|---|---|---|
| `default-disk-…` (SSD1, `/var/lib/longhorn/`) | `Schedulable=False (DiskPressure)`, 89,967,820,800 B, **18.85%** | **`Schedulable=True`**, 240,228,761,600 B, **50.32%** |
| `HDD2` | `Schedulable=False (DiskPressure)`, 24.91% | **unchanged — still False, 24.91%** |
**HDD2 is untouched by this work and remains unschedulable.** It is a separate item, stated here so it
is not read as fixed by association.
---
## 6. P5 — proving the ceiling is enforced
**Rollback stated before the first mutation** and never needed beyond the recovery below:
`sudo rm /etc/docker/daemon.json && sudo systemctl restart docker` — the file did not exist, so removal
is a complete revert.
Test ceiling **2 GB** (below one build's working set, deliberately, so eviction is reachable).
### Build-by-build totals
| step | build cache total | reclaimable | records |
|---|---:|---:|---:|
| baseline (post-prune) | 0 B | 0 B | 0 |
| controller 1 | 2.583 GB | 2.583 GB | 22 |
| hub 1 | **3.703 GB** ← peak | 3.703 GB | 37 |
| controller 2 | 3.525 GB | 3.525 GB | 37 |
| hub 2 | 3.051 GB | 3.051 GB | 38 |
| controller 3 | 2.817 GB | 2.817 GB | 36 |
| hub 3 | **2.782 GB** | 2.782 GB | 38 |
**The total stops rising and trends down** — the cap binds. Records plateau at 3638.
### The named eviction — the assertion that matters
Set-differencing the record IDs, **4 records present at controller-build-1 were absent by
controller-build-3**, including the exact class that accumulated 208 copies:
| ID | size | created | description |
|---|---:|---|---|
| **`9ak8egnxrg6rkibic2zo44u1`** | **440 MB** | 07:28:34 | **`mount / from exec /bin/sh -c go mod download \|\| true`** |
| `kkic530irq4em04kywzwrsjp` | 214.6 MB | 07:28:37 | `… go build -ldflags …` |
| `yvityhta4wppwa8cxs525c3g` | 58.83 MB | 07:28:36 | `… go mod tidy` |
| `mvttjfrimnt1ttt0ogry75ue` | 49.8 MB | 07:28:35 | `[builder 5/7] COPY . .` |
Across the whole sequence: `h1→h3` evicted **17** records, `c2→c3` evicted **10**.
### Tolerance, stated honestly
Steady state settled at **2.782.82 GB against a 2 GiB (2.147 GB) rule — about 30% over**, with a peak
of **3.70 GB (~72% over)** mid-sequence.
**This overshoot is an artefact of the test, not a property of the cap, and should not be extrapolated to
30 GB.** A single controller build produces ~2.6 GB of cache in one shot, so a 2 GB ceiling was set
*below one build's working set* — GC cannot get under it without deleting the cache it just created. At
30 GB the same working set is ~9% of the ceiling and GC has ample room.
### GC is build-triggered, not timer-driven
Six minutes idle, polled each minute: **total unchanged at 2.782 GB, records unchanged at 38.** The
cache does not shrink on its own. A burst of builds can overshoot and *stay* overshot until the next
build — which is the argument for keeping a weekly `docker builder prune` as a backstop (R-206), rather
than relying on the cap alone.
### Then the ruled 30 GB
Applied, restarted, and confirmed by the effective policy (§4). Both dev containers came back healthy
and **their data is intact — asserted positively, not by absence of complaint**:
| observable | before | after |
|---|---:|---:|
| postgres `count(*) from pg_database` | 4 | **4** |
| postgres `pg_database_size('jarr_dev')` | 175,135,767 | **175,135,767** |
| postgres public tables | 31 | **31** |
| redis `DBSIZE` | 2437 | **2437** |
| docker volumes | 8 | **8, identical names** |
---
## 7. P7 — why the module-download layer re-runs
**Root cause found and proven: `ARG VERSION` / `ARG GIT_COMMIT` are declared in the builder stage
*above* the module-download step, so a new version value invalidates every `RUN` beneath them.**
### The evidence chain
**1. Every record was used exactly once.** All **208** `go mod download` records (206 at audit time, +2
from this spike's builds) carried **`Usage count: 1`**, spanning 2026-07-06 → 2026-08-05, totalling
91.36 GB at 438440 MB each. Not one was ever reused.
**2. Two builds through `build.sh`, byte-identical tree, both missed.**
| step | build 1 | build 2 |
|---|---|---|
| `#8 WORKDIR /build` | CACHED | CACHED |
| `#9 COPY go.mod ./` | **CACHED** | **CACHED** |
| **`#10 RUN go mod download \|\| true`** | **executed** | **executed** |
| `#11 COPY . .` | executed | executed |
| `#12 RUN go mod tidy` | executed | executed |
| `#13 RUN … go build …` | executed | executed |
| `#14/#15/#16` (stage-1 `RUN`s) | CACHED | CACHED |
`go.mod` was **byte-identical** (`d8ded3f0798c…`) in the repo and in the workspace, mtime unchanged
(`2026-07-24 11:54:08`).
**3. `build.sh`'s churn was ruled out by fingerprinting.** Content-hashing all 737 files across
`rm -rf` + `cp -a` + host-side `go mod tidy`: **`tidy` changes nothing**, and the reproduced tree is
**content-identical** to the live workspace. `go.mod`'s inode number was even identical across the churn.
**4. A direct `docker build` on the untouched workspace cached *everything*** — including
`RUN go mod download` — proving BuildKit's cache is healthy and the Dockerfile is cacheable.
**5. The decisive isolation.** Same workspace, same command, only the build-arg value varied:
| run | `--build-arg VERSION` | `COPY go.mod ./` | **`RUN go mod download`** |
|---|---|---|---|
| E1 | **new** (`spike-p7e`) | CACHED | **executed** |
| E2 | **same** (`spike-p7e`) | CACHED | **CACHED** |
**The mechanism, and it explains the `#9 CACHED / #10 executed` signature exactly:** a `COPY`'s cache key
is content-based and carries no environment, so it hits; a `RUN`'s key includes the stage **environment**,
which contains every `ARG` declared above it. Since every real build passes a fresh `VERSION` (and often
a fresh `GIT_COMMIT`), the module-download layer is invalidated **on every build, without exception**
206 builds, 206 retained 440 MB snapshots, 90.5 GB.
**Both Dockerfiles have it.** `felhom.eu/hub/Dockerfile` declares `ARG VERSION` / `ARG BUILD_TIME` above
`COPY go.mod go.sum*``RUN go mod download`. **And because both produce byte-identical `buildx du`
description strings, the 208 records are a COMBINED count** and must not be attributed to one project.
**Fix shape — not applied here, filed as R-208:** move the `ARG` declarations down to just above the
final `go build`. One line per Dockerfile. **This is worth more than the cap and the move combined:** the
cap bounds the symptom at 30 GB; this removes the source.
---
## 8. P4 — the image store, enumerated (nothing deleted)
345 unique images across 350 tag lines (5 double-tagged), **0 dangling**. Only two are held by running
containers: `redis:7-alpine` and `postgres:16-alpine`. `docker image prune -a` would remove **343**.
**Size the prize before spending a decision on it.** Per-tag sizes sum to 139.29 GB, but that
double-counts shared layers. `docker system df` puts the **real dedup'd footprint at 31.02 GB, of which
27.02 GB is reclaimable** — roughly a fifth of what the build-cache prune already returned.
| GB (per-tag sum) | tags | repository |
|---:|---:|---|
| 118.44 | **207** | `gitea.dooplex.hu/admin/felhom-controller` |
| 7.25 | **107** | `gitea.dooplex.hu/admin/felhom-hub` |
| 4.34 | 5 | `gitea.dooplex.hu/admin/jarr` |
| 1.69 | 1 | `ghcr.io/seanmorley15/adventurelog-backend` |
| 0.94 | 1 | `ghcr.io/papra-hq/papra` |
| 0.72 | 1 | `codewithcj/sparkyfitness_server` |
| 0.72 | 1 | `postgis/postgis` |
| 0.59 | 3 | `gitea.dooplex.hu/admin/sparkyfitness-export` |
| (23 more, each ≤0.53 GB) | | |
### Registry presence — which are recoverable
Queried against the live Gitea registry v2 API:
| repo | tags in registry | tags local | **local-only (NOT re-pullable)** |
|---|---:|---:|---:|
| `felhom-controller` | 76 | 207 | **131** |
| `felhom-hub` | 45 | 107 | **62** |
**The local-only tags are all old.** Controller `0.39.0``0.135.0` plus `v0.35.0``v0.39.0`; hub
`0.9.0``0.57.0` plus `v0.7.2``v0.13.0`. **Everything from controller `0.136.0` and hub `0.58.0`
upward is in the registry** and therefore recoverable by `docker pull`.
**This is the list the operator still owes a ruling on (R-210).** CC's view: not worth doing for the
space — ~27 GB against 199 GB now free. The only real benefit is dropping unrecoverable clutter.
---
## 9. P8 — what else writes to Docker
**The bounded list of build triggers on the host Docker daemon is: interactive/manual runs only.**
- **18 systemd timers** enumerated — none invokes docker. (`node-housekeeping`, `dooplex-backup`,
`apt-daily*`, `sysstat*`, `logrotate`, `fstrim`, `e2scrub_all`, `smart-fancontrol`, …)
- **Root crontab: empty** (comments only). **User crontab: one entry**, `sync-hetzner-backups.sh`,
writes under `/mnt/5_hdd`.
- **`/etc/cron.d`: 4 real files** — anacron, e2scrub_all, sysstat, zfsutils. None touches docker.
**The hole the audit left open is now closed with positive evidence.** The audit concluded "no CI runner
exists on this host" from systemd/process/crontab alone — but a Gitea Actions runner **does** exist as a
**cluster pod** (R-168, 2026-08-02). It cannot reach the host daemon:
- **162 pods swept across all namespaces: `0` mount any `docker`/`containerd` hostPath.**
- `act-runner` (`gitea-system`, image `felhom-act-runner:0.1.0`) has one volume — a PVC at `/data`.
`privileged: false`, `allowPrivilegeEscalation: false`, **all capabilities dropped**, no docker socket.
- Its Dockerfile states the absence is deliberate: *"DELIBERATELY NOT INSTALLED: a Go toolchain, Node, or
Docker … There is no container runtime in here on purpose."*
- `gates.yml` runs exactly one command — `python3 scripts/<repo>_gates.py --fast` — plus a git fetch and
a failure-only Resend alarm. **No `docker build` anywhere.**
**So the audit's ~7 builds/day attribution to interactive use is confirmed**, and now on a complete
enumeration rather than a partial one.
---
## 10. P9 — the monitoring rule fix (shipped)
**File:** `homelab-manifests/mon-system/prometheus-rules.yaml`, alert
`RootFsPressureDespiteHousekeeping`. **Commit `6808a4b`.**
**Change:** `and`**`and on(instance)`**. A bare `and` requires identical label sets; the left vector
carries `{device,fstype,instance,job,mountpoint}` and the right — a node_exporter *textfile* metric —
carries only `{instance,job}`. Nothing ever matched.
### Red-proof (required, and both numbers recorded)
**Threshold substituted `0.25` → `0.5`, and the reason is stated rather than hidden:** P3's prune had
already taken `/` to 44.6% available, so the shipped threshold's left side is now legitimately false. The
defect is in **label matching**, not in the threshold, so the proof is run at a threshold where both
sides are individually true — which is exactly the condition the original rule was meant to catch.
| expression | `result_count` |
|---|---:|
| left side alone (`avail/size < 0.5`) | **1** |
| right side alone (`time() last_success < 1209600`) | **1** |
| **as shipped — bare `and`** | **0** |
| **fixed — `and on(instance)`** | **1** |
For completeness, at the shipped 0.25 threshold **both** forms return 0 today — because the left side is
false now, not because the fix failed.
**Annotation rewritten.** It pointed at Longhorn replicas/snapshots (~10% of the growth). It now names
`docker buildx du` first, records the 30 GB cap in `/etc/docker/daemon.json`, and notes that a reverted
or unapplied cap looks exactly like this alert firing — so the alert text carries its own diagnosis.
### Deployment — GitOps, no `kubectl set image`, no live ConfigMap edit
ArgoCD app `monitoring` (auto-sync **off**): hard-refresh → `OutOfSync` at `6808a4be54a7` → deliberate
sync → **`Synced` / `Healthy`**, operation phase **`Succeeded`**.
**Verified in Prometheus, not assumed from ArgoCD:**
```
query : … < 0.25 and on (instance) time() - node_housekeeping_last_success_timestamp_seconds < 1209600
state : inactive health : ok lastError : (none)
```
`inactive` is now the **correct** answer — `/` really is fine at 44.6% available.
> **A near-miss worth recording: I invented a full commit SHA from ArgoCD's truncated display and the
> sync failed with `upload-pack: not our ref`.** It failed loudly and cost one retry. It would not have
> failed loudly had the fabricated SHA happened to exist.
### The finding P9 turned up on the way — R-211
**There is no config-reloader.** The `mon-system/prometheus` Deployment runs **one** container
(`prom/prometheus:v3.12.0`) with no reloader sidecar. After the sync the updated rules file was present
**inside the pod** (`grep -c "and on(instance)"` → 3 on the mounted symlink) while the Prometheus rules
API **still served the old expression for 4+ minutes**, with no error anywhere. It took effect only after
an explicit `POST /-/reload` (HTTP 200; `--web.enable-lifecycle` was already set).
**The consequence is general:** every rule edit in this repo has silently not applied until something
happened to restart the pod. `Synced/Healthy` was true and beside the point. Filed as **R-211**.
---
## 11. P6 — the SSD2 move: **EXECUTED 2026-08-05 on operator authorisation**
> **Executed after the operator ruled "proceed", having read the pre-analysis below. The
> `storageReserved` condition CC named was applied with it. The operator subsequently ruled
> explicitly: DO NOT REBOOT DooPlex — so the reboot validation is DEFERRED, and what that leaves
> unproven is stated in §11.9 rather than glossed.**
### 11.0 Execution record
**Blast radius, established before anything was stopped:** k3s runs a **separate** containerd
(`/run/k3s/containerd/containerd.sock`), so **Gitea, the registry, the hub, PBS, Longhorn and all
~160 pods stayed up throughout**. The only workloads affected were the two `jarr-*` dev containers.
`ctr -n moby containers list` confirmed exactly those two on the system containerd.
| # | step | result |
|---|---|---|
| 1 | stop `docker.service`, `docker.socket`, `containerd.service` | all `inactive` |
| 2 | `mkdir /mnt/ssd_2/containerd` (mode 711, root:root) | ok |
| 3 | `rsync -aHAX --numeric-ids` | **550,891 files, 33,276,370,041 B, 4 m 58 s** |
| 4 | `mv /var/lib/containerd → /var/lib/containerd.pre-move-2026-08-05` | **moved aside, never deleted** |
| 5 | `root = '/mnt/ssd_2/containerd'` in `/etc/containerd/config.toml` (backup kept) | one-line diff |
| 6 | `RequiresMountsFor=/mnt/ssd_2` drop-ins on **both** `containerd.service` and `docker.service` | resolved into both units |
| 7 | start + verify | see below |
| 8 | `storageReserved = 80 GB` on SSD2 | applied |
**`-X` is load-bearing** — overlayfs snapshots carry `trusted.overlay.*` xattrs, and losing them
would silently corrupt layer stacking.
### 11.1 Copy verified BEFORE the original was touched
Four independent observables, all matching:
| check | source | destination |
|---|---:|---:|
| filesystem entries | 550,891 | **550,891** |
| `trusted.overlay` xattrs | 448 | **448** |
| files with link count >1 (hardlinks) | 37,243 | **37,243** |
| `io.containerd.metadata.v1.bolt/meta.db` sha256 | `c60ecfb4ee134255…` | **`c60ecfb4ee134255…`** |
(Byte totals differ by +14 MB — ext4 block allocation, not content.)
### 11.2 Nothing was lost
| | pre-move | post-move |
|---|---:|---:|
| unique images | 345 | **345 — identical ID set** |
| tag lines | 350 | **350 — identical set** |
| docker volumes | 8 | **8 — identical set** |
| build cache | 2.782 GB / 38 records | **2.782 GB / 38 records** |
| GC ceiling | 30 GiB | **30 GiB, still in force** |
| postgres databases | 4 | **4** |
| postgres `jarr_dev` bytes | 175,135,767 | **175,135,767** |
| postgres public tables | 31 | **31** |
| redis `DBSIZE` | 2437 | **2437** |
**End-to-end proof, not just inventory:** a real `./build.sh` run against the relocated store
completed `rc=0`, produced an image, and grew the cache 2.782 → 4.012 GB. The scratch tag was removed
and `:latest` restored to the anchor digest afterwards.
### 11.3 The store is demonstrably on SSD2
```
containerd config dump → root = '/mnt/ssd_2/containerd'
du /mnt/ssd_2/containerd → 35,500,183,552 B
/var/lib/containerd → does not exist
```
### 11.4 The guard — and a trap found while proving it
**First attempt did NOT refuse, and that is a finding worth more than the test.** With
`RequiresMountsFor=/mnt/absent-guard-test-2026-08-05` — a path with **no mount unit at all**
containerd **started normally**. `RequiresMountsFor` on a path systemd has no mount unit for is a
**silent no-op**. So a typo in the guard path buys nothing and says nothing: the same
*built-but-never-wired* shape this project has shipped repeatedly.
**So the guard was verified positively, at the unit level:**
```
containerd.service Requires=mnt-ssd_2.mount After=mnt-ssd_2.mount
docker.service Requires=mnt-ssd_2.mount
```
**Then the refusal itself was proven with a genuinely absent DEVICE.** `/mnt/ssd_2` hosts **12 live
Longhorn replicas** and must never be unmounted, and editing `fstab` on a production host risks
emergency mode at boot — so a **temporary synthetic `.mount` unit** pointing at a nonexistent UUID
was used instead, with a temporary `RequiresMountsFor` on the real `containerd.service`:
```
A dependency job for containerd.service failed.
systemd: Dependency failed for containerd.service - containerd container runtime.
systemd: containerd.service: Job containerd.service/start failed with result 'dependency'.
containerd is-active: inactive
```
**containerd REFUSED to start rather than creating an empty store on the root disk** — exactly the
required behaviour, and exactly the signature the ep0 runbook describes. All test artifacts were
removed; only `10-ssd2-mount.conf` remains on each unit and the guard resolves to `/mnt/ssd_2` alone.
### 11.5 Longhorn — the condition CC made the move conditional on
`storageReserved` on SSD2 raised **0 → 80,000,000,000 B**, covering the containerd tree so Longhorn's
scheduler can no longer oversubscribe into the space the store needs.
| disk | available | reserved | `Schedulable` |
|---|---:|---:|---|
| **Crucial-SSD-240G (SSD2)** | 179,516,211,200 (**76.34%**) | **80,000,000,000** | **True** |
| default-disk (SSD1) | 236,978,176,000 (49.64%) | 143,217,954,816 | **True** |
| HDD4 | 1,793,798,963,200 (60.82%) | 536,870,912,000 | True |
| HDD2 | 2,470,864,486,400 (24.91%) | 536,870,912,000 | **False** — pre-existing, untouched |
SSD2 remains schedulable with the reserve applied. **Rollback: patch `storageReserved` back to 0.**
### 11.6 A standing post-boot assertion was installed
`felhom-store-postboot-check.service` (oneshot, `After=docker.service`, enabled) runs
`/usr/local/sbin/felhom-store-postboot-check.sh` at **every** boot and logs to
`/var/log/felhom-store-postboot-check.log`. It asserts, with **positive** observables:
- `/mnt/ssd_2` is mounted;
- containerd's effective `root` is `/mnt/ssd_2/containerd`;
- **`/var/lib/containerd` does NOT exist** — i.e. no empty store was recreated on the root disk, which
is the precise trap the guard exists to prevent;
- docker sees ≥100 images;
- both dev containers are running.
Dry-run at install time: **RESULT: PASS** on all six lines. It was proven working *before* being
relied upon.
### 11.7 Disk position after the move
| | value |
|---|---:|
| `/` | 224 GB used, **199 GB free, 54%** |
| `/mnt/ssd_2` | 52 GB used, **157 GB free, 25%** |
| store on SSD2 | 35,500,183,552 B |
| **rollback copy retained on `/`** | **34,299,498,496 B** |
**`/` did not drop below 54% because the rollback copy is deliberately still there.** Reclaiming it
returns ~34 GB — see §11.9.
### 11.8 Rollback, complete and one sequence
1. `sudo systemctl stop docker docker.socket containerd`
2. `sudo mv /var/lib/containerd.pre-move-2026-08-05 /var/lib/containerd`
3. `sudo cp /etc/containerd/config.toml.pre-move-2026-08-05 /etc/containerd/config.toml`
4. `sudo rm -rf /etc/systemd/system/{containerd,docker}.service.d/10-ssd2-mount.conf`
5. `sudo systemctl disable --now felhom-store-postboot-check.service && sudo rm /etc/systemd/system/felhom-store-postboot-check.service /usr/local/sbin/felhom-store-postboot-check.sh`
6. `sudo kubectl patch nodes.longhorn.io -n longhorn-system dooplex --type merge -p '{"spec":{"disks":{"Crucial-SSD-240G":{"storageReserved":0}}}}'`
7. `sudo systemctl daemon-reload && sudo systemctl start containerd docker`
8. `sudo rm -rf /mnt/ssd_2/containerd`
### 11.9 **NOT PROVEN: the reboot. Stated, not glossed.**
**The operator ruled explicitly that DooPlex must not be rebooted, so the reboot validation did not
happen.** Uptime is unbroken (7 weeks 6 days, since 2026-06-10) — verified, not assumed.
**By this task's own standard — *"a move that has not survived a reboot has not been validated"* — the
move is NOT fully validated,** and it would be dishonest to close it as if it were. Precisely:
- **PROVEN (mechanism):** the guard is wired into both units (`Requires=` + `After=mnt-ssd_2.mount`),
and containerd **refuses to start** when a required mount's device is genuinely absent.
- **NOT PROVEN (consequence):** that a real boot sequence mounts `/mnt/ssd_2` before containerd starts
*on this host, in the real ordering*. Mount-ordering reasoning is exactly the class this project has
been burned by, and the mechanism proof does not substitute for it. `CLAUDE.md` prefers the
consequence over the mechanism; here only the mechanism is available.
**Two consequences follow, and both are deliberate:**
1. **The rollback copy `/var/lib/containerd.pre-move-2026-08-05` (34.3 GB) STAYS** until a reboot has
validated the move. Deleting it now would trade a cheap 34 GB for the only cheap way back.
2. **The next reboot — whenever it happens, planned or not — validates this automatically.** The
post-boot service writes `RESULT: PASS` or `RESULT: FAIL` to
`/var/log/felhom-store-postboot-check.log`. **Read that file after the next boot**; on `PASS`, the
rollback copy can be removed and ~34 GB returned to `/`.
Tracked as **R-209a**.
---
## 11-bis. P6 — the pre-analysis the ruling was made on
*(Retained as written, before execution, so the ruling can be audited against what it was given.)*
**First, the framing changed underneath this probe.** P3's prune took `/` to 53% used and un-stuck
SSD1's Longhorn disk. **The move is no longer a rescue; it is a deliberate choice.** Per standing rule 4,
CC's disagreement is stated rather than absorbed: **the cap alone resolved the problem the move was meant
to solve, and the move carries a Longhorn risk the cap does not.**
### Mechanism — three candidates
| | mechanism | survives package upgrade | expressible in Ansible | visible reading the host cold |
|---|---|---|---|---|
| **(a)** | **`root = "/mnt/ssd_2/…"` in `/etc/containerd/config.toml`** | **yes** — dpkg conffile, local edits preserved | **yes** — a template, the file is already managed-shaped | **yes** — one line in the daemon's own config |
| (b) | systemd mount unit / fstab bind onto `/var/lib/containerd` | yes | yes | **weak** — invisible unless you check `findmnt` |
| (c) | symlink | fragile | yes | **weak**, and overlayfs mount semantics through a symlink are unproven here |
**Recommendation: (a).** It is the only one that a person reading the host cold can *see*, and the key
already exists in the file (commented out).
### The boot-order trap, and its guard
If containerd or Docker starts before `/mnt/ssd_2` is mounted, the daemon **creates an empty store on the
root disk** and 345 images appear to have vanished.
**Guard:** `RequiresMountsFor=/mnt/ssd_2` on **`containerd.service`** and **`docker.service`**. Neither
has it today — `containerd.service` carries only `After=network.target dbus.service`.
> **`RequiresMountsFor` RE-MOUNTS; it does not refuse.** systemd pulls the mount unit in and *starts* it,
> so an unmounted-but-available device is silently re-mounted and everything looks fine. It refuses only
> when the device is genuinely **unavailable**. **Testing it by unmounting proves nothing** — it must be
> tested by pointing at an absent device. This is the ep0 datastore lesson
> (`RUNBOOK-ep0-datastore-volume-2026-07-27.md`), earned on this project.
**And a move that has not survived a reboot has not been validated.** Mount-ordering reasoning is exactly
the class this project has been burned by.
### The Longhorn consequence — the part that decides it
`Crucial-SSD-240G`, `storageMaximum` 235,148,750,848, **25% floor = 58,787,187,712**:
| scenario | resulting `storageAvailable` | as % | vs the 25% floor |
|---|---:|---:|---:|
| today, untouched | 214,958,080,000 | 91.41% | +66.4 pp |
| move cache only (30 GB) | 184,958,080,000 | 78.66% | **+53.7 pp** |
| move whole containerd tree today (34.3 GB) | 180,658,569,216 | 76.83% | +51.8 pp |
| **move at steady state (~31.5 GB images + 30 GB cache ≈ 65 GB)** | 149,958,080,000 | **63.77%** | **+38.8 pp** |
**As measured, the move is safe by a wide margin — nowhere near the 10-pp caution band.**
**But the worst case is not comfortable, and it is not hypothetical enough to omit.** `storageScheduled`
on SSD2 is **139,586,437,120** while actual usage is **20,094,939,136** — a 6.9× overcommit. If those 12
volumes ever inflate to their scheduled size:
| scenario | available | as % | vs floor |
|---|---:|---:|---|
| + cache only (30 GB) | 65,562,313,728 | 27.88% | +2.9 pp — inside the caution band |
| **+ steady state (65 GB)** | 30,562,313,728 | **13.00%** | **12.0 pp → `Schedulable=False`** |
That is precisely the failure that just took SSD1 out of service.
> **Recommendation: proceed only together with setting Longhorn's `storageReserved` on SSD2 to cover the
> containerd tree (~80 GB).** SSD2 reserving **zero** while HDD2 and HDD4 each reserve 500 GB is an
> anomaly in its own right, and it is what lets Longhorn oversubscribe into the space the cache needs.
> Without that, CC recommends **not moving** — the cap has already done the job.
### The sequence, prepared and not executed
Each step with its rollback. **Do not run without the operator.**
| # | step | rollback |
|---|---|---|
| 1 | `systemctl stop docker docker.socket containerd` | `systemctl start containerd docker` |
| 2 | `mkdir -p /mnt/ssd_2/containerd` | `rmdir` |
| 3 | `rsync -aHAX --numeric-ids /var/lib/containerd/ /mnt/ssd_2/containerd/` (small now — ~34 GB, and ~2.8 GB of it is cache) | delete the copy; the original is untouched |
| 4 | `mv /var/lib/containerd /var/lib/containerd.pre-move` (**move aside, never delete**) | `mv` back |
| 5 | set `root = '/mnt/ssd_2/containerd'` in `/etc/containerd/config.toml` | restore the commented line |
| 6 | drop-in `RequiresMountsFor=/mnt/ssd_2` on `containerd.service` **and** `docker.service`; `systemctl daemon-reload` | delete the drop-ins |
| 7 | `systemctl start containerd docker`; **verify `docker images` = 345, both dev containers up, `du` confirms the store's real path** | steps 5→4→1 in reverse |
| 8 | set SSD2 `storageReserved` ≈ 80 GB in the Longhorn node CR | set back to 0 |
| 9 | **`reboot`, then re-verify everything in step 7** | as above |
| 10 | test the guard by pointing at an **absent** device — an unmount alone proves nothing | restore fstab |
| 11 | only after a clean reboot + a full build: `rm -rf /var/lib/containerd.pre-move` | — none; do this last |
---
## 12. Teardown
**Nothing was provisioned.** No machine, no VM, no LXC guest, no hub record, no customer, no Longhorn
volume, no PVC. There is nothing to decommission.
**Cleaned up:**
- The six candidate `daemon.json` files written for P2 (in the session scratch dir, never in
`/etc/docker/`) — **removed**.
- The scratch workspace fingerprint tree (`ws_sim`) — **removed**.
- **Nine scratch image tags** created by the probe builds — `felhom-spike:{p7c,e1,e2}`,
`felhom-controller:{spike-p7a,spike-p7b,spike-p7d,evict-c1,evict-c2,evict-c3}`,
`felhom-hub:{evict-h1,evict-h2,evict-h3}`**all removed**.
- `felhom-controller:latest` **restored** to `sha256:bdcd4a4ae8e006f1…` (0.198.0), byte-matching the
anchor captured before the first build. Image count back to **345**, the P0 baseline.
**Intentional state left in place, recorded here so it does not read as a leftover:**
**`/etc/docker/daemon.json` exists**, with the content in §4. It did not exist before this session.
Removing it plus a daemon restart is a complete revert.
**Not cleaned up, deliberately:** the ~2.78 GB of build cache from the eviction sequence. It is the
working cache under a live 30 GB policy, not scratch.
---
## 13. Final state
| | at arrival | **now** |
|---|---:|---:|
| `/` used | 387,332,755,456 B (**86%**) | **240,301,133,824 B (54%)** |
| `/` available | 65,734,934,528 B | **212,766,556,160 B** |
| `/var/lib/containerd` | 181,394,223,104 B | **34,299,510,784 B** |
| build cache | 157 GB / 1446 records | **2.782 GB / 38 records, capped at 30 GiB** |
| images | 345 | **345** |
| dev containers | 2 healthy | **2 healthy, data verified intact** |
| SSD1 Longhorn | **`Schedulable=False (DiskPressure)`** | **`Schedulable=True`, 50.32%** |
| `RootFsPressureDespiteHousekeeping` | **dead — could never fire** | **live, `health=ok`, red-proven** |
| growth driver | unbounded, ~5.25 GB/day | **capped; source diagnosed (R-208)** |
---
## 14. Observations — noticed, deliberately not acted on
1. **`build.sh` exists in two copies** — `felhom-controller/controller/build.sh` (in git) and
`/mnt/5_hdd/felhom.eu/build/felhom-controller/build.sh` (the operative one). They differ by **one
comment line**. The hub solved this with a symlink into the repo; the controller has not. Not fixed —
out of scope, but it is a drift channel.
2. **`build.sh` tags `:latest` on every build**, including throwaway version tags. A spike build silently
moved `latest` off 0.198.0; it was restored from an anchor captured beforehand. A `--no-latest` flag,
or tagging `latest` only on `--push`, would remove the trap.
3. **The default BuildKit GC policy is derived from disk size**, so a bigger root disk would have raised
the 331.6 GiB ceiling too. The cap needs to be explicit no matter how much disk is added.
4. **`node-housekeeping.sh`'s own `avail_bytes()` uses `df … | tail -1`** — the truncating-instrument
shape this project has been bitten by twice. It is *correct* here (`df` on one path emits exactly one
data row), so it was left alone, but it is worth knowing it is there.
5. **HDD2 remains `Schedulable=False (DiskPressure)`** at 24.91%, a hair under the 25% floor with 2.47 TB
free and its 500 GB reserve satisfied. Untouched by this work, and not fixed by it.
6. **The `mon-system` Prometheus has `--web.enable-admin-api`** as well as `--web.enable-lifecycle`. The
admin API permits series deletion. Noted, not acted on.
7. **Longhorn on SSD2 is overcommitted 6.9×** (139.6 GB scheduled vs 20.1 GB used). That is normal for
thin provisioning, but combined with `storageReserved = 0` it is what makes the P6 worst case reach
the floor.
---
## 15. Register
Allocated this session: **R-205** (CLOSED — the monitoring rule, shipped), **R-206** (Ansible: cap +
prune + narrowed Docker ban), **R-207** (the `DRY_RUN` guard), **R-208** (the `ARG`-ordering cache miss —
the largest remaining win), **R-209** (WAITING-ON-OPERATOR — whether to move to SSD2 at all),
**R-210** (WAITING-ON-OPERATOR — the image list), **R-211** (no Prometheus config-reloader).