chore(deploy): prune old git-* image tags after a healthy deploy - #115
Merged
Merged
Conversation
Every deploy tags all 6 images twice — :$TAG and :git-$SHA — and nothing ever
removed the :git-* half, so each deploy added a permanent set. ~100 stale tags
had built up on sv-2, whose root disk reached 86%. `docker builder prune` does
not touch these: they are tagged images, not builder cache.
Adds step 6, which runs only after the deploy is confirmed healthy and prunes
per repo. Four protections, cheapest first:
1. Only :git-* tags are candidates; :$TAG, :staging, :companymain are out of
scope by construction.
2. An image referenced by any container, running OR stopped, in any project is
skipped. Stopped ones count — `docker start` needs the image on disk.
3. An image that a non-:git- tag of the same repo resolves to is skipped, so
the :staging / :companymain images survive while no container references
them. Scoped per repo so plane-backend':staging cannot protect a
plane-frontend tag that shares an image ID.
4. The newest $KEEP_GIT_TAGS tags (new env var, default 5) are skipped, so a
rollback to a recent build needs no rebuild.
docker rmi is called WITHOUT -f: if any rule missed a case, the daemon refuses
rather than yanking an image out from under a live container. A refusal is
logged as kept and never retried with -f.
Staging and production deploy on the same host and share these repositories,
which is why rules 2 and 3 exist rather than a simple keep-the-newest-N.
The whole step is best-effort (`prune_git_tags || true`) so a prune that cannot
run never fails a deploy that just went green. KEEP_GIT_TAGS is validated as
numeric, because a typo would otherwise make every comparison fail and the step
would silently prune nothing forever while the log said it ran.
Container images are resolved via `docker inspect` on each container id rather
than `docker ps --format {{.Image}}`: the latter reports the reference the
container was STARTED with, which stops matching once that tag is pruned, while
{{.Image}} is always the canonical sha256.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Adds step 6 to
deployments/selfhost/deploy.sh: after the deploy is confirmedhealthy, prune old
:git-*image tags per repo. Untagging is the only removalthe step performs — nothing sweeps the dangling layers it orphans.
Why
Every deploy tags all 6 images twice —
:$TAGand:git-$SHA— and nothing everremoved the
:git-*half, so each deploy added a permanent set. ~100 stale tagshad built up on sv-2, whose root disk reached 86%.
docker builder prune(step 5) does not touch them: these are tagged images, not builder cache.
Protections
Staging (
:staging) and production (:companymain) deploy on the same hostand share these repositories, so a naive keep-the-newest-N would eventually take
out the other environment's image. Four rules, cheapest first:
:git-*tags are candidates:$TAG,:staging,:companymainare out of scope by constructiondocker compose up -drecreates from the image on disk;docker startfails on a pruned onegit-tag of the same repo resolves to:staging/:companymainsurvive while no container references them (between deploys, or afterdocker compose down)$KEEP_GIT_TAGS(new env var, default 5)Rule 3 is scoped per repo by the
repo:prefix, soplane-backend:stagingcannotprotect a
plane-frontendtag that happens to share an image ID.docker rmiis called without-f: if any rule above missed a case, thedaemon refuses rather than yanking an image out from under a live container. A
refusal is logged as
keptand never retried with-f.No
docker image prune— scope, and redundant anywayUntagging is the only removal this step performs. There is deliberately no
docker image prunecall here.sv-2 shares one docker daemon with Jenkins and Unity builds, so a
daemon-wide dangling sweep deletes their orphaned layers too — not this
deploy's to reclaim, and outside what a Plane deploy should be touching on a
shared host.
It is also unnecessary:
docker rmiof an image's last tag already removes theimage, so every tag this step untags is gone regardless. Leaving a few dangling
layers behind is harmless; taking out a co-tenant build's cache is not.
The whole step is best-effort (
prune_git_tags || true) so a prune that cannotrun never fails a deploy that just went green.
Two design notes
docker inspect, notdocker ps --format {{.Image}}. The brief suggestedps --format;{{.Image}}there reports the reference the container wasstarted with — a tag, a short id, or a full sha depending on how it was
created. That reference stops matching the moment the prune it guards against
removes that tag, which is exactly when the guard is needed. Reading
.Imagefrom each container's own config via
docker inspectis always the canonicalsha256:and keeps resolving afterwards. Same containers, same set, more robustresolution — noted here because it deviates from the letter of the brief.
KEEP_GIT_TAGSis validated as numeric. A non-numeric value makes every-lecomparison fail, and since the step is best-effort that failure is silent —the prune would no-op forever while the log said it ran. A
WARN+ fallback to 5turns that into a visible message.
Verification
bash -nclean.shellcheck -S styleclean (0 findings) ondeploy.sh, v0.10.0.The prune was exercised against a fake
dockershim — a stub onPATHserving canned
docker images/ps -aq/inspectoutput and loggingrmicalls, with
rmimutating its own image list so a laterimagesreflects theremoval (which is what proves the prune is bounded, not merely that it issued
some
rmicalls). The two functions are extracted verbatim from the shippeddeploy.shbyawk, so this tests the real code and not a transcription.Fixture: 72
:git-*tags across the 6 repos (12 each), plus the non-git tagsboth environments deploy from, plus 4 containers — 2 running (production), 1
stopped staging container, 1 stopped container in an unrelated project.
Expectation is derived independently from the fixture, and asserted by name,
so any rule that is wrong changes the removed set rather than the count.
Four held-back tags are worth calling out — each is older than the newest 5 and
survives only because of a protection rule:
plane-frontend:git-000000a:companymainimageplane-backend:git-0000fa8:stagingimageplane-backend:git-0000faaplane-admin:git-00007db36 assertions, 0 failures. Covered: exact untagged set by name · newest 5 of
every repo survive · co-tenant
:staging/:companymainimages survive ·stopped-container images survive ·
rminever gets-f· no daemon-wideimage prune, ever · idempotent on a second run · a refusedrmiislogged as kept, not force-retried, and never aborts the step · non-numeric
KEEP_GIT_TAGSwarns and falls back to 5 · empty daemon (no images, nocontainers) exits 0 ·
KEEP_GIT_TAGS=2prunes deeper as expected.The "no daemon-wide sweep" assertion was falsified before being trusted:
re-adding
docker image prune -fto a copy ofdeploy.shturns it red(
1 failed, exit 1). It is a real gate, not decoration — which required oneharness change. The shim's
imagebranch now logs the invocation rather thanexiting 127: an unknown-subcommand error would be swallowed by the function's
own
|| true, so the assertion would have passed no matter whatdeploy.shdid.
Two further bugs were found and fixed in the test harness (not the shipped
code) while getting to a trustworthy run — both worth recording because each
would have produced a green suite that proved nothing:
docker inspect's container id from$2when the real call putsit in
$3, so every lookup returned nothing and rule 2 was never exercised— the suite was passing with a silently-dead in-use guard. A
container ids resolvedself-check now guards the guard.image prunebranch double-printed the subcommand, so the oldimage prune -fassertion was reading a malformed line. (That assertion isgone; the branch is now the detector described above.)
Not done
Not merging — this is a PR for review. No
gh pr merge.🤖 Generated with Claude Code