fix(runner-image): retire old bake generations, and detect the leak the quota gauge cannot see #346
Inga granskare
Etiketter
Inga etiketter
area/backups
area/ci
area/control-panel
area/identity
area/infra
area/observability
area/payments
area/security
area/storage
area/web
blocked
needs-info
needs-triage
ready-for-implementation
type
bug
type
chore
type
docs
type
epic
type
feature
type
task
wontfix
Ingen milstolpe
Inget projekt
Inga tilldelade
1 deltagare
Notiser
Förfallodatum
Inget förfallodatum satt.
Beroenden
Inga beroenden satta
Referens
bitborg/bitborg-infra!346
Läser in…
Hänvisa till i nytt ärende
Ingen beskrivning angiven.
Ta bort grenen "fix/boot-volume-leak-detector"
Borttagning av en gren är permanent. Även om den borttagna grenen kan fortsätta existera en kort tid innan den faktiskt tas bort, kan det INTE ångras i de flesta fall. Vill du fortsätta?
Refs #320 — does not close it; reclaiming the stranded volumes is still an operator action.
The producer is identified, which was #320's blocking question. These are image-bake
leftovers, not runner-job leaks. Baking the runner image snapshots the staging VM's boot volume;
the snapshot holds a reference, so Cinder refuses to delete the volume, and the sweep then correctly
skips it — it excludes any volume with snapshots precisely so it cannot destroy image or backup
infrastructure. Each stranded volume pairs one-to-one with a bake snapshot, and the timestamps line
up. The sweep is working as designed.
Root cause confirmed in
scripts/bake-runner-image.sh: its exit trap ran a volume delete underset +e, which Cinder always refuses once the volume has been snapshotted, and--replacedeletedonly the old image, never its snapshot or volume.
Part 1 — make this class of leak visible
The existing quota gauge cannot see it: ~160 GB over five weeks disappears inside a band that swings
by several hundred GB during normal CI churn, so a slow leak always reads clean. Adds a direct signal
with three mutually-exclusive classes:
snapshot_pinned— unattached and snapshot-referenced: unsweepable by design, one per retainedbake generation. This is the #320 signal.
sweepable— the sweep's own target; should return to zero within one window.named— deliberate retention, shown but never alerted, so out-of-scope waste cannot make the newalerts permanently noisy.
Every class publishes
-1for unknown, never0, when a read fails — a failed read must not read as"clean", which is the failure mode that hid this. The survey reuses listings the sweep already makes.
Part 2 — stop it at the source
The bake now prunes generations after promoting and verifying the new image, keeping a
configurable number (default 2 = live plus one back). Two hard safety conditions: a snapshot
referenced by any image's block-device-mapping is never deleted, which protects the live pair
automatically rather than by remembering to; and a volume is deleted only if Cinder reports it
available and unnamed — the same two conditions the sweep requires. Adds
--prune-only,--dry-runand--no-prune.Expect one alert to fire on first deploy
RunnerSnapshotPinnedVolumesHighwill trip until the reclaim is done, because the standing countexceeds the retention threshold. That is intentional: calibrating the threshold to accept today's
waste would repeat the original mistake. Raise it temporarily if you want quiet until then.
Operator actions after merge
scripts/bake-runner-image.sh --prune-only --dry-runfirst — it prints which snapshot each imageboots from. One pair is load-bearing and deleting it leaves the image unbootable.
predrill-*pair will show asnamed— visible, un-alerted, out of scope here.Verification
33 checks pass, up from 17.
ruff, shellcheck, Prettier, markdownlint all clean;ansible-lint0 failures across 191 files; gitleaks clean over 272 commits. The alert template renders and parses,
and both expressions parse against the metrics backend. The prune path was exercised offline against
a stub CLI: live snapshot kept, in-use volume refused, named volume refused, clean leftovers
reclaimed. No OpenStack mutation was performed.
`os_volume_gb_used` is a quota trend that swings ~380 GiB with ordinary CI churn, so a leak of ~36 GiB/week disappears into the noise band and reads clean for weeks — which is how eight stranded 20 GiB volumes accumulated unnoticed. Unattached is a state, not a trend: in a project where every legitimate disk is named and attached, a volume in Cinder state `available` is either in-flight, waste, or deliberately kept. The controller now surveys those volumes from the listings the orphan sweep already makes (no extra Cinder reads) and publishes them by class: - sweepable unnamed, no snapshot — the sweep's own target, back to 0 within an hour on a healthy project - snapshot_pinned a snapshot references it, so Cinder refuses to delete it and the sweep skips it BY DESIGN — one per retained runner-image bake generation - named deliberately retained by an operator; shown, never alerted Conflating the first two is what hid the leak: the sweep logged a refusal on every restart while `orphan_volumes_swept` sat at 0, and nothing separated "nothing to sweep" from "volumes it is forbidden to sweep". Every class publishes -1 when the Cinder read fails, so an unknown never reads as clean. Adds RunnerOrphanVolumesUnswept (sweep not reclaiming) and RunnerSnapshotPinnedVolumesHigh (bake generations piling up), both with configurable thresholds, plus a dashboard panel and 16 new logic checks. Refs #320One bake of the `gitborg-runner` image leaves a builder boot volume, a Cinder snapshot of it, and a Glance image that boots FROM that snapshot. The snapshot pins the volume — Cinder will not delete a volume that has one — so the exit trap's `volume delete` always failed, and the runner-controller's orphan sweep refuses snapshotted volumes on purpose (so it can never destroy image or backup infrastructure). Nothing else ever pruned them, so every bake stranded 20 GiB. The bake now retires generations itself after promoting and verifying the new image: newest-first, keeping RETAIN_GENERATIONS (default 2 = the live image plus one to roll back to), deleting the snapshot, waiting for it to actually go, then its volume. Retention is now a stated decision rather than whatever accumulated. Two safety lines, both hard: - a snapshot referenced by ANY image's block_device_mapping is never deleted (that is what the live image boots from — deleting it breaks all CI); - a volume is deleted only if Cinder reports it `available` AND unnamed, the same two conditions the controller's sweep requires. Adds --prune-only (operator reclaim path), --dry-run and --no-prune, and stops the exit trap from pretending to delete a volume it cannot. Also corrects the misleading `runner_controller_os_boot_volume_size` comment: the image's block_device_mapping declares volume_size 20 and wins, so the 40 is inert. Refs #320554b6c0181e57fdaec68e57fdaec686f348fc1f0Rebased base caught up:
main(now including #345) is merged in, and the branch is 0 behind.#345 landed the capacity-formula fix and pickup-latency instrumentation in the same four files this
branch touches. Three files conflicted; every conflict was additive on both sides, so nothing was
dropped and no behaviour was invented.
controller.py— kept both sides.VOLUME_CLASSESand_render_volume_class_seriessitalongside the latency block, and
write_metricscarriesavailable_volumes,available_volume_gband
latencyin its signature and at its call site. Its body — which auto-merged rather thanconflicting, so it was checked separately — emits both metric families.
test_controller_logic.py— git had aligned the two new test groups on shared))scaffolding,so a line-wise resolution would have interleaved them into something that parsed but tested nonsense.
Rebuilt from the three versions instead: main's file, plus this branch's
_volhelper and its whole#320 group inserted as a unit. 63/63 checks pass — 17 pre-existing + 30 from #345 + 16 from here,
which is the arithmetic you would expect if nothing was lost.
bitborg-runners.json— both sides used panel id 13, which is a genuine collision since idsmust be unique. Kept #345's pickup-latency row at 13–19 and renumbered this branch's volume
timeseries to 20, placed at
y=36— after the latency graph, before the Controller logs row,which shifts down by its height. Verified afterwards: ids unique across 20 panels, and no two panels
overlap on the 24-column grid.
One thing worth flagging, because it would have been a silent loss: taking main's copy of the
dashboard wholesale dropped this branch's additions to the on-call text panel. Both PRs had appended
their alert names to the same sentence. The panel now lists all four —
RunnerQueueStalled,RunnerPickupSlow,RunnerOrphanVolumesUnswept,RunnerSnapshotPinnedVolumesHigh.Also confirmed intact after the auto-merges: all three alert rules, all three thresholds in
monitoring/defaults, the bake script, and this branch's correction to the misleadingrunner_controller_os_boot_volume_sizecomment.Checks on the merged tree: 63/63 controller tests ·
ansible-lint0 failures over 191 files(profile
production) ·site.yml --syntax-checkclean · shellcheck clean · Prettier clean ·markdownlint 0 issues.
ruffcould not be run locally (not installed on this machine) — the mergeintroduced no new long lines (longest is 115 against a 120 limit, identical to main) and no
blank-line or trailing-whitespace changes, but CI is the authority there.
The operator actions in the description are unchanged: apply the two roles, then reclaim the stale
pairs with
--prune-only --dry-runfirst, and expectRunnerSnapshotPinnedVolumesHighto fire untilthat is done.