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).
42 KiB
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 -B1and Prometheus emit raw bytes.docker buildx inspectrenders 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.gcpolicy in/etc/docker/daemon.jsonis 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
policyarray form. The flat form ({"gc": {"reservedSpace": …}}) is silently ignored — the daemon starts, logs nothing, and keeps its built-in defaults.dockerd --validateis worthless here: it returnedconfiguration OKfor a bogus key and for a config that then crashed the daemon. - Refuted: the audit's proposal to change Docker's
data-rootwould not move the build cache. It moves 0.62 GB. The 181.4 GB belongs to the system containerd, relocated viarootin/etc/containerd/config.toml. - The prune is done and it was the whole rescue.
/went 86% → 53% used, and Longhorn's default disk wentSchedulable=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 VERSIONdeclared 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 bycontainerd.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;--validateand "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 36–38.
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.78–2.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 438–440 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.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:
0mount anydocker/containerdhostPath. act-runner(gitea-system, imagefelhom-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.ymlruns exactly one command —python3 scripts/<repo>_gates.py --fast— plus a git fetch and a failure-only Resend alarm. Nodocker buildanywhere.
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
storageReservedcondition 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_2is mounted;- containerd's effective
rootis/mnt/ssd_2/containerd; /var/lib/containerddoes 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
sudo systemctl stop docker docker.socket containerdsudo mv /var/lib/containerd.pre-move-2026-08-05 /var/lib/containerdsudo cp /etc/containerd/config.toml.pre-move-2026-08-05 /etc/containerd/config.tomlsudo rm -rf /etc/systemd/system/{containerd,docker}.service.d/10-ssd2-mount.confsudo 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.shsudo kubectl patch nodes.longhorn.io -n longhorn-system dooplex --type merge -p '{"spec":{"disks":{"Crucial-SSD-240G":{"storageReserved":0}}}}'sudo systemctl daemon-reload && sudo systemctl start containerd dockersudo 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_2before 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.mdprefers the consequence over the mechanism; here only the mechanism is available.
Two consequences follow, and both are deliberate:
- 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. - The next reboot — whenever it happens, planned or not — validates this automatically. The
post-boot service writes
RESULT: PASSorRESULT: FAILto/var/log/felhom-store-postboot-check.log. Read that file after the next boot; onPASS, 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.
RequiresMountsForRE-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
storageReservedon 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.jsonfiles 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:latestrestored tosha256: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
build.shexists 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.build.shtags:lateston every build, including throwaway version tags. A spike build silently movedlatestoff 0.198.0; it was restored from an anchor captured beforehand. A--no-latestflag, or tagginglatestonly on--push, would remove the trap.- 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.
node-housekeeping.sh's ownavail_bytes()usesdf … | tail -1— the truncating-instrument shape this project has been bitten by twice. It is correct here (dfon one path emits exactly one data row), so it was left alone, but it is worth knowing it is there.- 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. - The
mon-systemPrometheus has--web.enable-admin-apias well as--web.enable-lifecycle. The admin API permits series deletion. Noted, not acted on. - 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 = 0it 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).