Skip to content

registry: bound zot storage growth and lift the 20Gi lab cap off the estate registry - #1091

Open
mdheller wants to merge 1 commit into
mainfrom
fix/zot-storage-exhaustion
Open

registry: bound zot storage growth and lift the 20Gi lab cap off the estate registry#1091
mdheller wants to merge 1 commit into
mainfrom
fix/zot-storage-exhaustion

Conversation

@mdheller

Copy link
Copy Markdown
Member

What is red

Four image builds have been failing on mainsocioprophet-web, workspace-caldav, workspace-smtp, workspace-mail. Every one of them is a matrix leg that pushes to registry.socioprophet.ai; every leg that pushes to GAR is green. They fail identically, at docker/build-push-action@v6:

failed to push registry.socioprophet.ai/<image>:latest: unknown: blob upload unknown to registry

The image builds fine every time — layers export, manifest exports, manifest list exports. Only the push breaks, which is why the Dockerfiles look innocent.

Root cause

Client-side BLOB_UPLOAD_UNKNOWN is a symptom. zot's own log during those builds has the cause:

XMinioStorageFull: Storage backend has reached its minimum free drive threshold.
  Please delete a few objects to proceed. status code: 507
... unexpected error, removing .uploads/ files

workspace-minio-pvc is 20Gi, 100% used, 193M free. MinIO refuses blob commits below its minimum-free-drive threshold, zot tears down the in-flight .uploads/ staging session, and buildx's next chunk arrives at a session that no longer exists.

Two things filled it, and both are fixed here.

1. Nothing ever expired. storage.gc was true, but GC only reclaims untagged/dangling blobs — and with no retention policy, no tag ever becomes untagged, so GC had nothing to collect. Meanwhile CI pushes latest + sha-<commit> + a cosign .sig on every commit to main, and dedupe: false is mandatory for the S3 driver, so nothing is shared between tags. Measured on the live volume:

repo on disk tags blobs
workspace-smtp 7.3G 408 3,250
workspace-mail 5.4G 408 3,252
workspace-caldav 1.7G 410 2,659
socioprophet-web 499M 412 2,664
pull-through cache (ollama, searxng, library, gitea) 4.6G

14.9G of first-party images, none of it ever expired, on a 20G volume. gcInterval was also unset, which leaves periodic GC disabled outright.

2. A lab cap became the estate ceiling. infra/k8s/workspace-minio/overlays/p0-lab/kustomization.yaml patched the PVC from the base's 100Gi down to 20Gi. That cap is from when this MinIO served only the workspace. zot now uses the same MinIO as the estate registry's blob store, so every image push in the estate lands on it.

The fix

  • infra/k8s/zot/base/config.json — adds storage.retention: keep latest, keep anything pulled within 90d, keep the 100 most recently pushed tags per repo, delete untagged manifests and their referrers. Plus gcInterval: 6h so reclamation runs without a human. This is the load-bearing change; headroom alone would just postpone the same outage.
  • infra/k8s/workspace-minio/overlays/p0-lab/kustomization.yaml — drops the 20Gi shrink patch so base's 100Gi applies.
  • infra/k8s/zot/base/kustomization.yaml — config moves from a hand-written ConfigMap to a configMapGenerator over a real config.json.

That last one matters more than it looks. zot reads its config once at startup and does not hot-reload. As a plain ConfigMap, editing the config changed the mounted file but left the running process on its old in-memory policy until someone remembered kubectl rollout restart deploy/zot — this repo has been bitten by exactly that before. Without the generator, the retention policy added here would have been declared in Git and never actually read. The generator's name hash (zot-config-<hash>) changes the pod spec on every config edit, so Kubernetes rolls the pod and the config is genuinely loaded.

Verification

Validated against the pinned binary, not a local approximation:

$ zot v2.1.2 verify /etc/zot/config.json     # source file
{"level":"info","message":"config file is valid"}          exit 0

$ zot v2.1.2 verify /etc/zot/config.json     # kustomize-rendered output
{"level":"info","message":"config file is valid"}          exit 0

And the negative, so "valid" means something — a typo'd retention key:

$ zot v2.1.2 verify  (mostRecentlyPushedCount -> mostRecentlyPushedCountTYPO)
{"level":"error","error":"1 error(s) decoding:\n\n* 'Storage.Retention.Policies[0].KeepTags[2]'
 has invalid keys: mostrecentlypushedcounttypo"}            exit 1

The retention block is genuinely parsed, not accepted and dropped. kubectl kustomize renders clean for both zot/overlays/p0-lab and zot/overlays/p1-single-site, and the Deployment's configMap reference is rewritten to the hashed name automatically.

What merging does, and the one ordering note

Merge = deploy (ArgoCD auto-syncs). On sync:

  1. workspace-minio-pvc expands 20Gi -> 100Gi (online, non-destructive; standard-rwo has allowVolumeExpansion: true). This is what unblocks the red builds — re-run images after the PVC shows 100Gi.
  2. zot-config-<hash> is created, the old zot-config is pruned, and the Deployment rolls. zot is a single replica, so expect a few seconds where pulls through the registry fail. First GC pass then reclaims the expired tags.

Reviewer's call, please: retention will expire tags the cluster is not pulling. The pulledWithin: 2160h (90d) rule is there to protect the sha-* tags currently deployed, and zot is only ~14 days old so everything it has ever served is inside that window today. The residual risk is an image that runs >90d without a single pull and has fallen outside the 100 most recent pushes — that would be expired and would ImagePullBackOff on a later reschedule. If that is too tight for anything you know of, widen pulledWithin or pin the image by digest.

Not fixed here

  • The other three red builds on mainmemoryd, liberty-stack-readout, tritfabric-consumption-api — are a different failure: cosign sign fails with fetching ambient OIDC credentials: invalid character 'u' looking for beginning of value. That 'u' is GitHub's Unicorn! 5xx page where the Actions OIDC token endpoint's JSON should be. Transient GitHub, same signature as the 2026-07-16 incident. Retry, no code change.
  • :latest is still pushed to zot on every commit, which is both the moving-tag trap this repo has flagged before and a direct contributor to the growth above. Out of scope here.

…estate registry

Every image build that targets registry.socioprophet.ai has been failing on main —
socioprophet-web, workspace-caldav, workspace-smtp, workspace-mail — with:

    failed to push registry.socioprophet.ai/<image>:latest:
      unknown: blob upload unknown to registry

The images build fine; only the push breaks. The real error is server-side. zot's log
during those builds:

    XMinioStorageFull: Storage backend has reached its minimum free drive threshold
    (507) ... unexpected error, removing .uploads/ files

MinIO's volume is 100% full (20Gi, 193M free), so blob commits are refused, zot tears
down the in-flight upload session, and the next chunk from buildx lands on a session
that no longer exists — which the client reports as BLOB_UPLOAD_UNKNOWN.

Two things put it there, and both are fixed here.

1. Nothing ever expired. storage.gc was true, but GC only reclaims untagged/dangling
   blobs, and with no retention policy no tag ever becomes untagged — so GC had nothing
   to collect. CI pushes latest + sha-<commit> + a cosign .sig on every commit to main,
   and dedupe is false (mandatory for the S3 driver), so nothing is shared between tags.
   Four repos reached ~410 tags / ~3,250 blobs each and consumed 14.9G of the 20G.

   config.json now carries a storage.retention policy: keep latest, keep anything pulled
   within 90d (this is what protects the sha-* tags the cluster is running), keep the 100
   most recently pushed tags per repo, delete untagged manifests and their referrers.
   gcInterval: 6h so reclamation actually runs — it was unset, which leaves periodic GC
   disabled entirely.

2. The p0-lab overlay pinned the shared MinIO PVC to 20Gi, overriding the base's 100Gi.
   That cap dates from when this MinIO served only the workspace; zot now uses the same
   MinIO as the estate registry's blob store. Dropped, so base's 100Gi applies.

The config is also moved from a hand-written ConfigMap to a configMapGenerator over a
real config.json. zot reads its config once at startup and does not hot-reload, so as a
plain ConfigMap an edit changed the mounted file but left the running process on its old
in-memory policy until someone remembered to restart it by hand — the retention policy
added here would have been declared and never read. The generator's name hash rolls the
pod whenever the config changes.

Both the source and the kustomize-rendered config were validated against the pinned
binary (zot v2.1.2 verify → "config file is valid", exit 0). Checked the negative too: a
typo'd retention key exits 1 with "Storage.Retention.Policies[0].KeepTags[2] has invalid
keys", so the block is genuinely parsed rather than accepted and ignored.
Copilot AI review requested due to automatic review settings July 30, 2026 03:38

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Note

Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.

This PR addresses failing pushes to registry.socioprophet.ai by preventing unbounded zot/MinIO storage growth and removing the lab overlay’s 20Gi PVC cap that became the estate registry’s effective ceiling.

Changes:

  • Add zot retention + periodic GC configuration (storage.retention, gcInterval) to bound registry growth.
  • Switch zot config delivery from a static ConfigMap manifest to a configMapGenerator so config updates trigger a rollout.
  • Remove the p0-lab patch that shrank workspace-minio-pvc to 20Gi; rely on the base 100Gi claim.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
infra/k8s/zot/base/kustomization.yaml Removes hand-written ConfigMap resource and generates it from config.json to force rollouts on config change.
infra/k8s/zot/base/configmap.yaml Deletes the previously embedded JSON ConfigMap manifest.
infra/k8s/zot/base/config.json Introduces retention policies and scheduled GC to stop storage growth and reclaim space.
infra/k8s/zot/README.md Documents why space filled, how retention/GC work, and how to validate config changes.
infra/k8s/workspace-minio/overlays/p0-lab/kustomization.yaml Drops the overlay patch that capped MinIO PVC at 20Gi, restoring base sizing.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +25 to +33
"storageDriver": {
"name": "s3",
"rootdirectory": "/zot",
"region": "us-east-1",
"regionendpoint": "workspace-minio.socioprophet.svc.cluster.local:9000",
"bucket": "zot",
"secure": false,
"skipverify": true
}
Comment on lines +27 to +29
# kubectl -n socioprophet create configmap zot-cfg-verify --from-file=config.json=config.json
# kubectl -n socioprophet run zot-verify --restart=Never --image=ghcr.io/project-zot/zot-linux-amd64:v2.1.2 \
# --overrides='{"spec":{"containers":[{"name":"zot","image":"ghcr.io/project-zot/zot-linux-amd64:v2.1.2","args":["verify","/etc/zot/config.json"],"volumeMounts":[{"name":"c","mountPath":"/etc/zot"}]}],"volumes":[{"name":"c","configMap":{"name":"zot-cfg-verify"}}]}}'
Comment thread infra/k8s/zot/README.md
- CI pushes `latest` + `sha-<commit>` + a cosign `.sig` on **every** commit to `main`.

`gc: true` alone does **not** bound this: GC only reclaims *untagged/dangling* blobs, and without a
retention policy no tag is ever untagged, so GC finds nothing to do. On 2026-07-30 that reached its
@mdheller

Copy link
Copy Markdown
Member Author

Live-cluster state for whoever merges this — merging it resolves an active ArgoCD sync failure.

The volume could not wait for CI, so workspace-minio-pvc was expanded on the cluster ahead of this PR (authorised remediation):

kubectl patch pvc workspace-minio-pvc -n socioprophet --type merge \
  -p '{"spec":{"resources":{"requests":{"storage":"100Gi"}}}}'

It expanded online in ~25s, no pod restart: /dev/nvme0n2 99G 20G 79G 20% /data (was 20G 20G 194M 100%). An 8 MiB write to the zot bucket that previously returned XMinioStorageFull now succeeds at 107 MiB/s.

As predicted in the PR description, Git still declaring 20Gi leaves ws-workspace-minio fighting it — syncPolicy.automated has selfHeal: true, so it retries the shrink and fails:

sync: OutOfSync  health: Healthy  phase: Failed
msg: PersistentVolumeClaim "workspace-minio-pvc" is invalid:
     spec.resources.requests.storage: Forbidden: field can not be less than
     status.capacity (retried 5 times)

Harmless (a shrink is rejected by the API server, nothing is destroyed) but it will keep failing every sync until this lands, and a permanently-failing app masks real sync errors. No action needed on this PR — it is correct as written; it just needs to merge. Currently blocked on ~49 queued checks.

Two things verified on the live cluster that bear on the config here:

  1. gcInterval was indeed unset on the running pod, so periodic GC was disabled — the diagnosis in this PR is correct.
  2. The configMapGenerator approach is the right call. Worth knowing the failure it prevents is real and adjacent: separately, patching a PVC's size and its annotations as two consecutive requests races the CSI external-resizer (VolumeResizeFailed: the object has been modified) and leaves the volume needing a pod restart to finish the filesystem resize. Same family of "the declared change never actually took effect" bug.

Capacity side is handled separately in #1105 (bounded PVC autoscaling) — it does not touch infra/k8s/zot/** or the minio overlay, so there is no overlap with this lane. It enrolls workspace-minio-pvc with a 250Gi ceiling as a backstop; the retention policy here remains the primary bound.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants