Layered Nix store: image as immutable lower, shared volume as writable upper #109
Labels
No labels
blocked
bug
duplicate
effort
high
effort
low
effort
medium
effort
minimal
enhancement
help wanted
invalid
pr
feedback
pr
reviewing
question
status
backlog
status
document
status
done
status
implement
status
pending
status
planning
status
reviewing
waiting
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
tepichord/milner#109
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Why
Every step that wants a Nix store mounts the same claim (
nix-store, classnix,/var/mnt/data/nix/shared) over/nix, which hides the image's own store. The volume therefore has to be filled by hand before anything can run: the one-shotnix-seedJob indocs/storage.md§10g, whichcp -an /nix/. /mnt/and then proves/bin/sh's target exists in the volume, because an unseeded volume does not fail asnix: not found— the pod cannot exec its entrypoint at all and Woodpecker reports nothing until the workflow deadline. Three consequences follow, and they are the whole of this issue:tests/test_static.pyholds the seed tag and every consuming step's tag equal (the "one image, six consumers" assertions) — a coupling that is correct today and becomes unnecessary under a layered store.baseline, so it cannot take the escape hatch the CI steps could.The shape
/nix/storebecomes an overlay: the image's own/nix/storeis the immutable lower, the durable directory is the writable upper. A fresh upper is then startable — the image is the base — and an image-tag bump needs no top-up at all.nix.confneedsextra-experimental-features = nix-command local-overlay-storeandstore =set before any nix use; themounthappens withmount -t overlay …(busybox/staticmountis enough).Recommended: the merge happens once, on the node, not in each pod. A privileged assembler (a DaemonSet, image
nixos/nix:<pin>, hostPath on the durable disk) creates the merged store at a stable mount point; consumers bind-mount that mount point at/nixwithmountPropagation: HostToContainer. Reasons:anniaisbaseline— added capabilities andprivilegedare forbidden there, andhostPathis forbidden too, so an in-pod overlay cannot be the mechanism for the pod that most wants the store. One mechanism for both consumers beats two.woodpecker-runtimeis alreadyprivileged, so a step could do its own overlay — that is the tempting shortcut, and it is rejected precisely because it cannot be extended toannia.The alternative — each pod overlays for itself with
SYS_ADMIN— is worth stating and rejecting in the plan rather than leaving implicit.What the plan must settle (not guess)
nix-class claim (a local-path PV is hostPath-shaped, so it may propagate) or requires an explicithostPathvolume to the assembler's mount point is the first thing to measure on the node, not assume. My recommendation: explicithostPath+mountPropagation: HostToContainer, because the assembler owns the mount and the propagation mode should be visible in the manifest. ThenixStorageClass then stays what it is for anything that genuinely wants a private durable directory, and stops being "the store".linkslive in the sharedstatedir; whether its locking covers two pods building at once is the central risk here. Settle it from the manual and a two-writer smoke test. If it does not hold, the shape changes to one upper per writer-group over the shared immutable lower, and the plan must say what that costs (the per-group warm cache is no longer shared).nix store gcfrom a consumer would try to delete into the overlay and, worse, a lower that changes under an existing upper corrupts it), so the owner of GC is the assembler, runningnix-collect-garbageon its nix, and a consumer running GC has to be ruled out explicitly.docs/storage.md§10e already excludes/var/mnt/data/nix/from backups as "reproducible from the package cache"; a layered store keeps that true — the upper is derived state — and the plan should say so rather than leave the exclusion looking like an accident.nixrefuses a newer DB. So the assembler's tag, the consumers' effective nix (which comes from the lower) and the store's schema version are one thing — the same "one image" rule as today, restated as a pin rule. Bump the assembler first, then consumers, in one change.privileged(thevault/woodpecker-runtime/local-path-storageprecedent) or an explicit PSA label on a new one,automountServiceAccountToken: false,nodeSelector: {node-role: worker}(the durable disk is only on the worker).What this deletes
nix-seedJob block indocs/storage.md§10g, the extraction snippet under it, and §10g's explanation of why the mount has to be seeded.tests/test_static.py's seed-tag assertions and its### 10g.section regex (the "one image, six consumers" rule) — the rule survives, the seed does not, so the test has to be re-pointed at the assembler's pin.annia-nixclaim, which is re-derived by this issue: the claim-at-/nixis the A-shape this replaces.Verification
A consumer pod whose upper is empty starts and finds its own
nix,bashandcurlunder the merged/nixwith no seed step;nix develop --command cargo --versionin a step succeeds; concurrent runs of two steps (a deliberate overlap) do not corrupt the store DB; apodman-less restart of the assembler re-establishes the mount without touching the upper; and a bump of the assembler's image tag, done as the pin rule requires, does not strand a single consumer.Out of scope
The store's contents (which closures are warm) and the flake pins. This changes where the store's bytes come from and who owns them, not what gets built.
Waiting until annia-infra#310 is complete
Naina:
Updated on 2026-10-06 based on maintainer feedback.
Overview
Replace the hand-seeded store-at-
/nixwith an overlay assembled once per node: the pinned image's/nixis the immutable lower, the durable directory the writable upper, merged by a privileged DaemonSet and consumed through the existingnix-class claim11b. §10g's seed Job dies, a fresh upper starts, and an image bump moves the lower without a hand-run top-up.Technical Approach
Spike first — settle the four load-bearing facts on the worker (gating).
(c) two deliberately overlapping steps against one upper: does the SQLite DB stay consistent? (d) is
check-mountoff (the consumer sees the assembler's bind mount, not an overlay)? If (a) is false, Shape A cannot serve Woodpecker (its backend sets nomountPropagation— B6) — stop and report rather than guess.A privileged assembler namespace.
The assembler DaemonSet — materialise the lower, mount the overlay once. A host DaemonSet cannot see a consumer pod's mount namespace (A7)4, so the lower must be materialised on the durable disk; the assembler copies its own pinned image's
/nix, and the pin rule makes it equal to the consumers'.Consumer contract — same mount path, overlay store URI.
The
stateDB path abaselinepod can reach is spike (b); candidates are a path inside the merged mount (/nix/var/nix) or a second mount of the durable directory5.State and reject the per-pod
SYS_ADMINalternative. It is the only way to overlay a consumer's own image store directly, butanniaisbaseline(noSYS_ADMIN, nohostPath— A4)2 and Woodpecker's backend cannot add capabilities; one node-level mechanism for CI and #25 beats two.GC belongs to the assembler, never a consumer.
A lower that changes under a live upper corrupts it66b; a test asserts no step command contains
store gcorcollect-garbage.The pin rule survives, re-pointed at the assembler's manifest.
Delete §10g and sweep every inbound reference.
The Job, its
awkextraction, the "seed before mounting" prose and the closing "one writer / avoid GC" note go; §10e's/var/mnt/data/nix/backup exclusion is re-justified as "the upper is derived state"99b.Re-point the static tests.
Amend stories 19/20 in this change (Hard Rule).
milner#25 —
annia-nixis a class-nixclaim mounted at/nixwith the sameNIX_CONFIG; a PVC is baseline-legal, so nothing aboutannia's PSA changes.Implementation Details
gitops/…/infrastructure/nix-assembler/{daemonset,gc-cronjob,kustomization}.yamlmount -t overlay+ daily GCgitops/…/infrastructure/namespaces/nix-assembler.yaml,…/infrastructure/kustomization.yaml.woodpecker/gates.yaml,.woodpecker/cleanup.yamlNIX_CONFIG; mount path unchanged1policy/k8s.regonix-assembler(rule 3)10docs/storage.md§10a/§10e/§10g,docs/nix.md§14b/§14c/§14d,docs/woodpecker-ci.md,storage/README.md,OPERATIONS.md,AGENTS.md,ARCHITECTURE.md,data/README.md,docs/durable-volumes.md,docs/logging.md/nixseeded -> overlay upper; bring-up loses the seedUSER_STORIES.mdtests/test_static.py,tests/test_docs.pyConstraints:
policy/k8s.regoneeds a carve-out and the "privileged tier is empty" claims10 in.woodpecker/gates.yaml/docs must read "empty exceptnix-assembler". This is Shape A's cost; it is stated, not hidden.local-overlay-storeis experimental and its RFC 0152 is still a draft (A1/A2)56 — name the stability risk indocs/nix.md §14d.Out-of-scope notes:
Acceptance Criteria
docs/nix.md §14b; the propagation path (class-nixclaim vs a new mechanism) is stated, not assumed — or the plan is stopped.nix develop --command python3 -m pytest tests/ -vpasses in theteststep on a fresh upper — no seed job, no hand-run step.nix,bashandcurlunder/nix.policy/carve-out is namespace-scoped tonix-assembler, and the docs say "the privileged tier is empty except the assembler".Testing Plan
tests/test_static.py: assembler-pin equality; whole-tree "nothing mounts over/nix" sweep; every mounting step carries the overlayNIX_CONFIG; no step runs GC; the replaced §10g guards.tests/test_docs.py: the story 19/20 guard and the §14b/§14d wording.conftest test --policy policy/stays clean with the namespace-scoped carve-out only.mainpipeline run after merge.Related Issues/PRs
/shared-nix); the unmerged PR #114 implemented that withdrawn shape.annia-nixclaim is re-derived here at/nixunder the overlay.Research Findings
local-overlayis spelledstore = local-overlay://?lower-store=…&upper-layer=…&state=…and needsextra-experimental-features = local-overlay-store; the upper DB is always${state}/db(A1).local-overlaystore deletes only the upper copy, but an open bug (#16269) makes it free nothing under--max-freed; the schema is forward-only, so the writing and readingnixmust be one version (A3).Bidirectionalpropagation is privileged-only and recommended only withhostPath; Baseline PSS forbids bothhostPathandSYS_ADMIN, soanniacannot use either directly (A4).nixos/nix:2.35.2ships nomountbinary (A6), and a host-level assembler cannot use another pod's/nixas its lower (A7) — hence the materialised lower.Pre-Mortem
/nixinside a Woodpecker step pod, because the backend cannot setmountPropagation(B6) and propagation is not recommended on PVC-backed volumes (A4), which is the plan's own "(a) is false" stop condition. The whole change is then spent on an unreachable layered store.local-overlay://contract is unworkable from abaselinepod —check-mount = falsedisables Nix's only check that the mount matches the configured lower/upper, the reachablestateDB path is left as spike (b) with no default, and one shared upper has no documented concurrency guarantee (RFC 0152 assumes a per-consumer upper). Two overlapping steps corrupt the upper's SQLite DB orlinks, which is precisely the case the plan admits it must measure.§10g/pin assertion fails as a test fault with no functional cause, and the requiredpolicy/k8s.regocarve-out plus a privilegednix-assemblernamespace break the "privileged tier is empty" invariant. A missed one of the ~28 inbound references, or a silently renumbered §10g, leaves the story 19/20 andtests/test_docs.pyguards red in the same merge.Naina:
Plan revision — the shared store becomes a cache at
/shared-nixThis replaces the technical approach in comment #10176. The decision is the
operator's, and it is absolute: the base image's
/nixis never mounted over.Everything below restates the plan in that shape; anything in #10176 not named as
superseded still holds.
Superseded, explicitly
mountPropagation, the consumerlocal-overlay://contract, the assembler pinrule, assembler-owned GC, the two-writer fallback, the Talos
extraMountspatch) — withdrawn.
nix-assembler/*, thepolicy/k8s.regocarve-outand
worker/main.tf— withdrawn; nothing privileged is needed.below. All three old hazards dissolve with the overlay (no submount to
propagate, no shared database, no store URI to spell wrong); two new ones take
their place.
local-overlay://— they are simply no longer load-bearing.what
docs/storage.md§10a has called thenixclass all along.Overview
/var/mnt/data/nix/sharedstops being a store and becomes a Nix binarycache. Every pod that wants it mounts the same claim at
/shared-nix. Apod's
/nixis its image's/nix, untouched. Reads go throughsubstituters;writes go through one publish at the end of a step. No assembler, no overlay, no
privileged workload, no shared database, no
mountPropagation, no experimentalstore features.
Why the new root changes the mechanism (the one constraint)
Store paths are the absolute strings
/nix/store/<hash>-<name>, and the storedirectory is compiled into the nix binary. A path realised in a directory
mounted at
/shared-nixis invisible to the processes that would run it, so aNix store can only be run from at
/nix/store— mounting there was never apreference. If
/nixbelongs to the image, the shared thing can only be readfrom and written to: a cache.
Technical approach
The cache. The host path
/var/mnt/data/nix/shared, thenixclass andthe claim (
woodpecker-runtime/nix-store) all keep their names; the contentschange. It becomes a binary cache —
nix-cache-info,narinfo/,nar/—written by
nix copy --to file:///shared-nix. The 6.4 GiB store sittingthere now is deleted: it is a cache, it starts cold, and the first pipeline
run warms it. (#10176's "migrating the seed is out of scope" becomes "the
old directory is removed".)
Reads. Consumers add one line to
NIX_CONFIG:extra-substituters = file:///shared-nix.nix develop/nix buildthentake the paths the cache holds from local disk into the pod's own store
instead of fetching them over the network or rebuilding them. Nothing else
about a step changes: same image, same
/etc/nix/nix.conf, same PATH.Writes. One script,
ci/publish-nix-cache.sh, run at the end of everystep that builds:
nix copy --to "file:///shared-nix" --all.--allis theright extent on purpose: the pod's store at the end of a run is what the
next pod wants — the dev shell and the tree's derivations (which the pod
substituted, and which a
post-build-hookwould not see) as well as what itbuilt. It is incremental — paths the cache already holds are skipped — so a
warm run copies almost nothing.
Signing, and who may publish. The cache is signed with a key generated
once into Vault (
secret/ci/nix-cache), so consumers keeprequire-sigs = trueand the cache.nixos.org keys are untouched. That is the§9k trio: the value in Vault, the path in the secret extension's AppRole
policy, and a third rule in
apps/woodpecker/secret-extension.yaml's ConfigMap(
milner-nix-cache-publish→ci/nix-cache,events: ["push", "manual"]). It also means a pull request substitutesbut never publishes — the boundary #110 draws for Zot, and what keeps
arbitrary PR code out of the cache. Named fallback if the key plumbing
proves fussy:
require-sigs = false, recorded as an accepted wideningrather than a silent one.
Where the pod's writes land. Its own
/nix/storeis the image's storeplus the container's writable layer, on the node's ephemeral filesystem,
deleted with the pod. Measured on the worker today: 976 GB available of
1.0 TB, 4% used (
df -h /in a pod:overlay … /), so per-step cost isaffordable — and the plan measures one real step's peak before and after.
GC and trimming.
nix store gcdoes not exist for a binary cache —proved:
nix store gc --store file:///tmp/cacheprobe→ "Garbage collectionnot supported by store 'file:///…'". So the cache is trimmed by a runbook
action: narinfo and its nar deleted together, oldest first, so no
narinfo is ever left without its nar; a trim costs a re-fetch, never
correctness. A pinned-root GC script is out of scope and named as the
upgrade path. §10e's backup exclusion is re-justified in its own words:
the class is a cache, and "reproducible from cache" is now literally what
it is.
§10g dies for a simpler reason. Nothing mounts a store at
/nix, sothere is nothing to seed. The seed Job, its
awkextraction and the"one image in six places" invariant go with it, and so does the
cross-consumer pin rule: a pod's image and its store are now the same
thing. What remains is the ordinary "no
:latest" policy.The experimental surface disappears. No
local-overlay-store, noread-only-local-store, no store URI, noextra-experimental-featuresbeyond the
nix-command flakesthe steps already ask for. That removes thelargest early-adopter risk of #10176 (its Pre-Mortem 3) outright.
Stories, in this change (Hard Rule: the story moves in the change that
contradicts it). Story 19's promise stands, its mechanism changes; story 20
is replaced by the rule this change implements.
Cascade:
docs/nix.md§14c (its table row, and the sentence"Nothing mounts it at runtime, and that is the design.", which now says
the opposite rule and is re-marked rather than reintroduced), §14b/§14d,
AGENTS.md's preview-console row, andtests/test_static.py'stest_nothing_at_runtime_mounts_the_shared_store—which stops being a preview-namespace test and becomes a sweep of every
pod spec under
gitops/, plus the seven docstrings that cite story 20.milner#25. Its
annia-nixclaim stays a class-nixclaim, mounted at/shared-nix, with the sameNIX_CONFIGline; its plan's "mount at/nix"lines are re-derived against this shape (issue #25's revision §7 already
calls itself provisional). A PVC is still baseline-legal and needs no
propagation, so nothing about annia's PSA changes.
Implementation details
.woodpecker/gates.yaml,.woodpecker/cleanup.yamlnix-storeat/shared-nixinstead of/nix; addextra-substituterstoNIX_CONFIG; every building step ends withci/publish-nix-cache.shci/publish-nix-cache.shnix copy --to "file:///shared-nix" --all, key read from the environment, prints a one-line verdict)gitops/…/apps/woodpecker/secret-extension.yamlmilner-nix-cache-publish→ci/nix-cache,events: ["push","manual"]gitops/…/infrastructure/nix-store/persistentvolumeclaim.yamlgitops/…/apps/annia/*nix-store-style claim, the/nixmount,NIX_CONFIG,HostToContainer. The enqueuer'sdeployment.yamlreturns to untouchedgitops/…/infrastructure/nix-assembler/*,policy/k8s.rego,worker/main.tfUSER_STORIES.mddocs/storage.mdnixrow and §10e's exclusion reworded to "cache"; §10g deleted with itsawksnippet; a trim action added to the runbookdocs/nix.md§14b/§14c/§14d,docs/woodpecker-ci.md§9m,OPERATIONS.md,AGENTS.md,storage/README.md/nixbecomes the cache at/shared-nix; bring-up loses the seed steptests/test_static.py,tests/test_docs.py/nix; every building step callsci/publish-nix-cache.sh; the step pods do not setreadOnlyRootFilesystem(the design needs a writable store)Acceptance criteria
gitops/mounts anything over/nix; a static test holds itfor the whole tree, not just preview namespaces (story 20).
nix develop --command python3 -m pytest tests/ -vpasses in theteststep with only the image's
/nixand/shared-nix— no seed, no mountover
/nix, no experimental store feature./shared-nix, with no network fetch and no rebuild of them; the numbers(time, bytes) are recorded in the docs.
ci/publish-nix-cache.shmakes the next run warm — proved by re-running astep with the network substituter removed (
--option substituters file:///shared-nix): it still succeeds.push/manualonly) and its steps still substitute from it.docs/nix.md§14c,AGENTS.mdand theseven docstrings agree; no document still says a store is mounted at
/nixor that the volume is seeded.docs/storage.md§10g is gone, §10e is re-justified as "a cache", and thetrim action is written down.
annia-nixclaim is a handle on the cache at/shared-nix,and nothing in #25's plan still mounts a store at
/nix.policy/carve-out was needed; thepipeline's privileged tier is still empty.
Testing plan
tests/test_static.py: the new "nothing at/nix" sweep; "every building steppublishes"; "steps keep a writable rootfs"; the replaced §10g guards; a
ci/publish-nix-cache.shpresence + single-implementation assertion.tests/test_docs.py: the story guard on the amended 19/20, and the §14c/§10ewording.
bytes, and that nothing is fetched from the network); a publish followed by a
second pod that reads it back; a step's peak ephemeral-disk use; the trim
action run once against a copy of the directory.
mainafter the merge — the first run is cold bydefinition, and its successor is the measurement that the cache warms.
Pre-mortem
The step succeeds, the story reads true, and every run quietly rebuilds from
the internet. Guard: one
ci/implementation, a static test that everybuilding step calls it, and the acceptance criterion that re-runs with the
network substituter removed — a run that cannot substitute locally fails
loudly.
unpack into a fresh store per pod, not a zero-cost mount, and the pipeline
could get slower while looking correct. Guard: measure one real step before
and after and put both numbers in the docs; if substitution dominates, the
answer is a Nix daemon on a node-level socket (named, not designed here) —
not a quiet regression.
it and nothing prunes unless the runbook is run. Guard: the trim action, a
size check in the same runbook, and a
durecorded after the first week.Related
resolved by construction: nothing mounts
/nix, soapps/annia/deployment.yamlneeds no mount, noNIX_CONFIGand no extraclaim, and the deletion of
annia-nix-pvc.yamlstands. That claim belongs tomilner#25.
consumer, and publishing reachable only from
push/manual.annia-nixre-derived here),docs/storage.md§10a/§10e/§10g,docs/nix.md§14,docs/woodpecker-ci.md§9k/§9m, stories 19 and 20.Research findings (verified today)
nix copy --to file:///tmp/cacheprobe <path>writes a real binary cache(
.narinfo+nar/, 425 files for one derivation closure), andnix path-info --store file:///tmp/cacheprobe <path>reads it back.nix store gc --store file:///…is refused for a binary cache — the trimaction is a runbook, not a Nix command.
/is the node's containerd overlay with 976 GB of 1.0 TB free, sothe per-pod copies land on space that exists.
docs/storage.md§10a already describes the class as a build cache, notbacked up, "reproducible from cache" — the design is the repo's own wording
applied literally.
extra-substituters/trusted-public-keysspelling and therequire-sigsinteraction are standard Nix but not yet exercised by apipeline step here unverified; the implementer confirms both in
the first run, which is also acceptance criterion 2's run.
Superseded by
milner/nix-baseThis issue's goal — stop hand-filling the store volume, stop mounting over
/nix,and stop every step paying for the toolchain — is now met by an image rather than by
a shared volume, so the shape proposed here is not the one being built.
What the measurements settled (a full pull request on this branch, pipeline #186,
killed at the 60-minute cap):
/nixis the only zero-copy shape, and it costsverification (any PR pod can write a path every later step runs), cross-pod GC
safety, and the failure this issue documents — an empty volume does not fail as
nix: not found, the pod cannot exec its entrypoint at all./shared-nixverified by a signature is safe, but afresh pod copies its closure in: 573 paths, 4.6 GB, 435 s per step while the
cache is empty — measured in
l0-render's log, 573copying path … from 'https://cache.nixos.org'lines. Not a number to build on.SDL2 ~1.1 GB each, zenity ~1 GB, libvirt, minikube, tailscale. The tools the steps
actually invoke are 147 paths / 902 MB.
So:
milner/nix-base— one image this repository builds and publishes, carrying thepinned nix, the shell a step's
commandsget, jq/curl/git/cacert, andinstall-ci-env, a program that installs a repository's own dev shell into theprofile. A consumer's Dockerfile is
FROM …:tag,COPY . /src/,RUN install-ci-env /src; a consumer's CI step can skip the Dockerfile and runinstall-ci-env ../nixis never mounted over, no step names a substituter, andthere is no signing key.
Closing this in favour of that change once it lands; the reasons recorded here (why a
volume over
/nixfails, why/nixbelongs to the image) are being kept indocs/nix.md.