Runner: persist /nix and stop depending on the job image #18
Labels
No labels
area:defold
area:docs
area:infra
area:protocol
area:testing
kind:bug
kind:chore
phase:breadth
phase:core
phase:hardening
phase:slice
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
yuris/spacetimedb-defold#18
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?
Conformance in CI (#7) needs two changes to the Forgejo runner's the orchestrator service definition. Neither lives in this repo, so this issue is the record of what to change and why.
Current definition, the relevant part:
1. Persist /nix across runs
This is the expensive one. The job image has no nix, so every run installs it and re-downloads ~2 GB of closure (JDK 25, bob's FHS env, the Defold engine, node, esbuild, chromium) only to throw it away. That is most of the ~5 minutes before run #10 died, and it makes disk exhaustion a live suspect.
First run populates the volume; later runs find a warm store.
conformance.ymlalready detects this case — it looks for/nix/var/nix/profiles/default/bin/nix, not justnixon PATH, because the store persists while the per-user profile does not.Watch for: the volume grows without bound. A periodic
nix-collect-garbage -d, or just deleting the volume when it gets large, is the whole maintenance story.2. Consider a nix-native label
An alternative to installing nix on every cold run:
Then
runs-on: nixand the install step becomes a no-op. Note two consequences before doing it:nixos/nixships no docker CLI, whichtools/conformance.shneeds to start the server. Already handled:pkgs.docker-clientis in the.#conformanceshell, so the CLI comes from the flake rather than the image.--extra-experimental-features "nix-command flakes"andsandbox = false(a nested nix sandbox inside a job container is unreliable), which is what the currentnix.confwriting already sets up.This is optional if change 1 lands — a warm store makes the installer cheap. Worth it mainly to stop depending on
catthehacker's contents at all.Not the problem
For the record, so it is not re-litigated: user namespaces are fine.
--cap-add SYS_ADMINplus unconfined seccomp and apparmor is what bubblewrap needs, confirmed by reproducing the flags locally (unshare --user→ YES,max_user_namespaces→ 111389). The only remaining userns risk is the host sysctl on the the orchestrator worker itself, which the topology block now prints.Acceptance
One manual
conformancerun after change 1, whose published log (/api/packages/yuris/generic/conformance-logs/run-N-1/conformance.log) shows either a pass or a specific, named failure — and a second run that reports/nix mounted: <fs>and completes setup noticeably faster.Docker is no longer used by conformance (see #7), which changes this issue: the job no longer needs the docker socket at all, so the image is free to be whatever is cheapest for nix.
That removes the objection to a nix-native label. Revised recommendation:
Then
runs-on: nixand the install step is a no-op. The two earlier caveats are both resolved:nixos/nixno longer matters — nothing shells out to docker.The
--volume forgejo_nix:/nixhalf is still the one that matters most: without it every cold run re-downloads the whole closure (JDK, bob, the Defold engine, chromium, and now the 56 MB SpacetimeDB tarball).conformance.ymlalready detects a warm store via/nix/var/nix/profiles/default/bin/nixand the topology block reports/nix mounted:so the change is verifiable from the published log.Watch for: the volume grows without bound. Periodic
nix-collect-garbage -d, or deleting the volume when it gets large, is the whole maintenance story.Tested it rather than guessed: a nix-native label works, and it already found a bug
Ran the full native conformance suite inside a bare
nixos/nixcontainer, using this runner's exact security options:So the answer is yes — and the numbers are the argument:
/nixvolume)The warm store is worth ~11x. Store size after one full run: 4.7 GB, which is the number to watch on the the orchestrator volume.
It caught a latent bug the workstation was hiding
The first container run failed at engine exec:
defold-patchset an rpath covering libgcc, zlib, openal, GL and X11 — but not libstdc++, which is in the engine's directDT_NEEDEDset. It passed locally for a stupid reason:It only resolved because the workstation's ambient
LD_LIBRARY_PATHhappened to contain agcc-libpath leaked from an unrelated shell. Exactly the "works on my machine" class this was supposed to prevent, and nix did not catch it, because the leak came from the environment rather than the closure. Fixed by addingstdenv.cc.cc.libto the rpath;env -i lddnow reports zero missing libraries.That is a concrete argument for the nix-native image beyond speed: a minimal container has no ambient library path to accidentally rescue a broken closure.
Which image
nixos/nixhas nix 2.35.2, git, bash, curl and tar — but no node, andactions/checkout@v4is a JavaScript action, so it cannot run there. Two ways out.Recommended — a purpose-built Forgejo runner image that bundles nix (flakes on) and node, so nothing else changes:
It ships bash, busybox, curl, git + LFS, node, cachix, attic and the Forgejo CLI, and the images are cosign-signed (joschi/forgejo-runner-nix-containers, derived from codeberg.org/nix-actions/container). Then
runs-on: nix-latestin both workflows and the install-nix step self-skips.Alternative — bare
nixos/nix(proven above) requires dropping JS actions, i.e. replacingactions/checkout@v4with a plaingit clone. Not done here: it only becomes necessary if a nodeless image is chosen, and the current step works on bothcatthehackerand the joschi image.Worth noting the mainstream pattern is the opposite trade — keep an act image and install nix per run via
cachix/install-nix-action(example). That is effectively what the hand-rolled installer step does today, and it pays the download cost every cold run, which is the thing the/nixvolume fixes.Also note
docker_hostand the socket mount are now only needed by other repos' jobs — conformance no longer touches docker at all (#7).Revised acceptance
/nixvolume.runs-on: nix-latest./nix mounted: <fs>and complete in well under a minute on the second run.Two runners: dedicate the nix label to one of them
There are two runners, each pinned to a different swarm node (
node.hostname == runner-node-a/runner-node-b). That makes dedicating one to nix the right call, for a reason that is specific to this setup:Docker volumes are node-local. The warm
/nixstore — measured at 4.7 GB after one full conformance run — exists only on the node whose daemon created it. If both runners advertised a nix label, jobs would alternate between nodes, each node would need its own 4.7 GB copy, and every job landing on the "other" node would be a cold 335s run instead of a warm 30s one. Confining the label to one runner keeps the store hot and the disk cost on one node.Change runner-2 only
Keep the ubuntu labels so general capacity is not halved — a
nix-latestjob can only go to runner-2 because it is the only runner advertising that label, while everything else still schedules on either.Two lines changed: one added label, one added
--volume. runner-1 stays exactly as it is.The volume needs no
volumes:entry in the compose file —docker run -v forgejo_nix_store:/nixthrough the mounted socket creates it on that node on first use. Declaring it in compose would subject it to the orchestrator's project-name prefixing, which is a different name than the one incontainer.options.Then in both workflows:
Mechanics worth knowing before making the change
daemon, it will use labels in the.runnerfile." This config declares labels explicitly, so a service restart is sufficient — no re-registration. If the new label does not appear at/admin/actions/runnersafter the restart, delete/data/.runnerfromforgejo_runner_data_2to force re-registration; theif [ ! -f /data/.runner ]guard will then re-run it.docker run <image>, so "container images, once downloaded, are never updated". Pin by digest for reproducibility, or setcontainer.force_pull: trueif you would rather track:latest.container.optionsapplies to every job on that runner, so ubuntu jobs on runner-2 will also see/nixmounted. Harmless, and it has a useful side effect:conformance.yml's install step probes/nix/var/nix/profiles/default/bin/nix, so even anubuntu-latestjob landing on runner-2 finds the warm store instead of running the installer.jobs.<id>.container.volumes— needscontainer.valid_volumesallowlisted on the runner (it defaults to[], meaning no workflow-specified volumes are permitted). Runner-sideoptionsis the smaller change.runner.capacitydefaults to 1, so conformance will queue behind any other job on runner-2 rather than run beside it.One inconsistency to check
The two services point at different instance addresses — runner-1 at
http://<forgejo-host>:5858, runner-2 athttp://<forgejo-host>:5858. If those are the same Forgejo reached over different node-local addresses, nothing to do. If they are genuinely two instances, label routing will not behave as described, because the job would never be offered to runner-2 at all.First run on the nix runner failed at container start — it is the image, not the workflow
Run #23: 88 seconds, no logs, no published artifact, no self-report comment. Both final steps are
if: always(), so their silence proves no step ran at all — the job container never came up.Cause, reproduced locally:
The runner keeps a job container alive with a command like
tail -f /dev/null. WithENTRYPOINT ["/bin/bash"]that becomes/bin/bash tail -f /dev/null, so bash tries to interpret the tail binary as a script and the container exits immediately. Images that work with act leaveEntrypointnull and put the shell inCmd:catthehacker/ubuntu:act-22.04/bin/bashnixos/nix:latestghcr.io/joschi/forgejo-nix/bin/bashcodeberg.org/nix-actions/containerSwitch to official nixos/nix
It leaves
Entrypointnull and the full suite already passes inside it. What it lacks is node, soactions/checkout@v4— a JavaScript action — cannot run there. Fixed ina2cdbb2: checkout is a plaingit clone, and the report step borrowsjqfrom the store since the image ships neither jq nor python3 (escaping re-verified against quotes and backslashes).The workflow now depends on nothing but git and nix, which keeps the image choice free and removes a third-party image from the CI path.
Label change on runner-2:
Pinned by digest because the runner never re-pulls an image once downloaded.
Delete the volume first
The failed container was created before it died, so
forgejo_nix_storeon runner-node-b may already have been seeded with the joschi image's/nix. Mounting that intonixos/nixshadows its own store with a foreign one whose default profile sits elsewhere — a confusing half-broken state. On runner-node-b, before redeploying:It is repopulated from the image on the next run.
Acceptance met — conformance is green in CI (#7 closed)
Runs #28 and #29 both pass on the
nix-latestrunner. The topology block confirms the warm store, which was the whole point of this issue:Two corrections to what this issue previously recommended, both now settled:
ghcr.io/joschi/forgejo-nix— itsENTRYPOINT ["/bin/bash"]kills the job container before any step runs. Officialnixos/nixpinned by digest.valid_volumesis mandatory, not optional. With it unset,sanitizeConfigsetshostConfig.Binds = []string{}and the--volumeincontainer.optionsis discarded silently — no error, no warm store, just permanently cold runs. This was the reasondocker volume lsshowed nothing after run #23.The working configuration is recorded in the closing comment on #7.
Left open here
Disk: the store is 4.7 GB and grows.
nix-collect-garbage -dinside a job, or deletingforgejo_nix_storeon runner-node-b when it gets large, is the whole maintenance story. Nothing is scheduled to do it.Optional:
CONFORMANCE_LOG_TOKENas a repo secret would let the full log upload to the package registry. The job token cannot — generic packages are owner-scoped, the token is repo-scoped — and the 120-line tail in the self-report comment has been sufficient for every failure so far.Both changes landed and are proven in production. Run #38's topology step:
/nix mounted:reports a real ext4 device rather than theno (image layer)fallback the probe prints otherwise, and the store holds 5512 paths that no run in this job downloaded. That is the acceptance criterion.conformance.ymlnow usesruns-on: nix-latest, and the install step short-circuits on/nix/var/nix/profiles/default/bin/nixexactly as the description anticipated: the store persists while the per-user profile does not.Setup is now ~2s of the run. The 100s total is conformance actually doing work — 51s native, 47s HTML5 — not environment assembly.
Amendments worth recording
Two things the description did not predict, both settled in #7 and now encoded in
conformance.yml:actions/checkout@v4had to go. It is a JavaScript action and needs node in the job image;nixos/nixships none. The one nix+node image available setsENTRYPOINT ["/bin/bash"], which mangles the runner's keep-alive command so the container dies before any step runs — an 88s failure with no logs whatsoever. Plaingit clonewithgithub.tokendepends on nothing but git and keeps the image choice free.pkgs.docker-clientbecause the harness needed a container for the server. It does not any more: the server and CLI are pinned release binaries from the flake, so the docker socket, the sibling-container port topology and user namespaces are all off the critical path. The--cap-add SYS_ADMINand unconfined seccomp/apparmor options are still in the runner definition and still fine, but conformance no longer relies on them.The "not the problem" section holds: user namespaces were never the fault.
Maintenance, as predicted
The
forgejo_nixvolume grows without bound. 36G of 74G used at run #38.nix-collect-garbage -dinside a job, or deleting the volume when it gets uncomfortable, remains the whole story — worth a look if a run ever fails on disk rather than on tests.Closing.