ci: GC the Docker host per-run instead of on a nightly cron - #454
Conversation
The leak fix in the previous commit stops *new* orphans, but nothing
reclaims what has already accumulated — an orphaned volume has no container
left, so no amount of `docker rm -fv` will ever touch it. Something still
has to sweep.
A scheduled janitor is the wrong shape for that. The host fills during the
working day, and a 05:30 UTC cron cannot help a run that dies at 16:03 —
by the time it fires the damage is twelve hours old and several runs have
already gone red. Sweep after every run instead: ci.yml fires on every push
and PR, so the host gets cleaned many times a day.
Add a `docker-host-gc` composite action, used by the three cleanup jobs
(ci.yml, ci-extended.yml, hts.yml) and by ui-tests-matrix's `build` gate —
the latter *before* its matrix, since that suite pulls the Playwright image
on top of ES/PG/Mongo and needs the headroom up front.
Two deliberate departures from a naive prune:
- Volumes are matched on the 64-hex-char anonymous-volume name shape
rather than swept with `docker volume prune -af`. This host also runs
long-lived services, and `-a` includes *named* volumes — a prune racing
a service redeploy, while its named volume is briefly unreferenced,
would delete that service's data with no undo. The hex shape removes
exactly the testcontainer leak and structurally cannot match a name a
human chose. It is also stable across the Docker 23 change to what
`volume prune` means without `-a`.
- Volumes are freed first and every step is non-fatal. On a host already
at its quota, image deletion itself fails — containerd needs a meta.db
write to do it — while volume removal frees the same filesystem without
one. Free the bulk first and the rest starts working again.
Every filter is chosen to be safe while other runs are in flight: a live
container's volumes are by definition referenced, and the image/cache/network
prunes are age- and size-guarded so a concurrent run's freshly pulled image
is never pulled out from under it.
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
|
Nice — the volumes-first ordering and the 64-hex-shape match are both exactly right, and they match what I measured on the host earlier today from a benchmark dispatch: Volumes were 93% of the reclaimable space and containers were ~0. So "free the bulk first and the rest starts working again" is not just a theory about that incident — it's the shape of the host right now. One gap: this wires the action into ci.yml, ci-extended.yml, hts.yml and ui-tests-matrix.yml, but not Two ways to close it, no strong preference:
I'd suggest (1). #453 stays independent: it fixes the benchmark's own leak (PG data was in the container's writable layer with no volume at all, so nothing could reclaim it) plus a start-of-job reaper for its orphans. Your GC is the complementary end-of-run sweep, and the two don't overlap — mine targets Worth noting for whoever runs it: neither PR reclaims the existing 22 GB retroactively, so the host still wants one manual |
Companion to #452/#454, which fixed the volume leak that choked the Docker host and produced the nightly ES signature (#449: readiness curl passes, then the mapped port drops SYNs while ES stalls on fsync). What made that incident expensive was the blindness: nothing recorded the ES container's fate. On a wait-for-HFS failure the step now dumps the container state, OOM flag, log tail, and a live reachability probe. Also defensive, and logged either way: NO_PROXY names the backend hosts exactly, because reqwest does not honor CIDR entries the way the curl probes do — a proxy-configured runner would otherwise time out HFS's ES traffic while every probe passes. Closes #449
Follow-up to #452. That PR stopped new volume orphans; this one reclaims the backlog and keeps it reclaimed.
Why not a scheduled janitor
An already-orphaned volume has no container left, so no amount of
docker rm -fvwill ever touch it. Something still has to sweep.A nightly cron is the wrong shape for that job. The host fills during the working day, and a 05:30 UTC sweep cannot help a run that dies at 16:03 — by the time it fires the damage is twelve hours old and several runs have already gone red.
So the sweep is a per-run step instead.
ci.ymlfires on every push and PR, which means the host gets cleaned many times a day rather than once a night.The change
A
docker-host-gccomposite action, used by:ci.ymlci-extended.ymlhts.ymlui-tests-matrix.ymlThe three cleanup jobs had no checkout step, so each gets a cone-mode sparse checkout of
.github/actionsonly — keeps them as cheap as they were.Two deliberate departures from a naive prune
Volumes are matched on the 64-hex-char anonymous-volume name shape, not swept with
docker volume prune -af.This host also runs long-lived services.
-aincludes named volumes, so a prune racing a service redeploy — at the moment that service's named volume is briefly unreferenced — would delete its data, with no undo. The hex shape removes exactly the testcontainer leak and structurally cannot match a name a human chose. It is also stable across the Docker 23 change to whatvolume prunemeans without-a.Volumes are freed first, and every step is non-fatal.
On a host already at its quota, image deletion itself fails — containerd needs a meta.db write to do it — while volume removal frees the same filesystem without one. The 2026-07 incident hit exactly this: image prunes erroring out while orphaned volumes held the bulk of usage. Free the bulk first and the rest starts working again.
Concurrency safety
The host is shared by every concurrent run, which is the constraint shaping each filter:
until=48hprotects anything pulled recently, including an image a concurrent run has pulled but not yet started a container from. It also preserves the warm pull cache the nightly suites depend on, so this doesn't trade a disk problem for a slowness problem.Every step is
|| true, so one failure can't abort the rest of the sweep or fail the cleanup job.Verification
actionlintdiffed before/after: no new findings.Shell logic was exercised under GitHub's exact strict mode (
bash --noprofile --norc -eo pipefail) including the daemon-unreachable path — every guard returns exit 0, so the GC can never fail a cleanup job.Relationship to #451
This supersedes #451's
docker-host-janitor.yml. #451 should not be closed outright — it also carriesci(ui-matrix): route the Elasticsearch self-traffic around the runner proxy, an unrelated real fix worth keeping. Trim it to that commit.Note for the reviewer
The 64-hex-char assumption is worth one read-only confirmation on the host before merge:
Anything that prints is a named volume the GC will never touch — it should list only real services, and nothing testcontainers created.