Files
felhom.eu/documentation/audits/SPIKE-dooplex-buildcache-2026-08-05.md
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

42 KiB
Raw Permalink Blame History

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 defectARG 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)

{
  "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 RUNs) 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.00.135.0 plus v0.35.0v0.39.0; hub 0.9.00.57.0 plus v0.7.2v0.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: andand 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.250.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 copiesfelhom-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).