Image Registry — shared, platform-hosted, token-authed¶
Custom-endpoint images pushed with plain docker push (full layer dedup),
served from one in-cluster registry, with no public-registry dependency
and no per-user setup — your existing platform token is your registry
login. Access is enforced by docker token auth, not network trust: you can
only touch images under your own namespace.
Status: built and unit-tested (shared registry lifecycle + GC, token issuer, quota, node pull auth, deploy integration, SDK). Live-verify checklist in §10. File map:
image_registry/.
1. Why this exists, and why it's shared¶
A custom endpoint (spec={"image": ...}) used to require a public registry
(Docker Hub, etc.) — wrong for a self-hosted GPU platform. We want in-house
images with docker-native UX (dedup, multi-arch) and zero external dependency.
An earlier design gave each user their own registry:2 pod backed by their
own S3 bucket. We rejected it — the reasoning is the crux of this feature:
| Per-user registry (rejected) | Shared registry (this) | |
|---|---|---|
| Storage of the multi-GB worker-base layers | once per user (each bucket re-stores them) | once, globally (dedup across all users) |
| Onboarding | user must supply an S3 bucket + keys | nothing — push immediately |
| Credentials in flight | S3 keys travel laptop→platform→kuberay→Secret→pod | none exist — no user S3 creds anywhere |
| Pods at 100 users | 100+ idle registry:2 pods (eats the node pod budget) |
1 |
| Access isolation | ❌ none (router proxies any namespace) | ✅ token auth per namespace |
The deciding insight: a registry is a stateless protocol server in front of a
storage backend, and one backend can serve everyone when repos are namespaced
by path (<ns_id>/repo). Dedup then makes the shared bucket cheaper in total
than N private buckets, because the base layers every image is FROM are stored
once instead of N times. Isolation, which per-user pods never actually gave, is
provided by real registry auth.
The platform's side of the trade: it now owns image storage (bounded by a per-user quota) and garbage collection. Both are handled here.
2. Architecture¶
laptop ── docker push ─▶ registry (1 shared registry:2 pod) ─▶ platform MinIO
(TLS in prod) NodePort MASTER_VPN_IP:30500 gridweave-registry bucket
token auth ▲ global layer dedup
GPU node ─ containerd ───pull───────┘
(over WireGuard mesh) │ 401 + realm
▼
platform_service /v1/registry/token
(RS256 JWT scoped to <ns_id>/…, verified by the
registry against a cert it was handed at ensure)
Three moving parts:
- registry (
kuberay_service) — oneregistry:2Deployment + NodePort Service, storage backend = platform MinIO. Runs docker token auth with a cert whose private key lives inplatform_service. Scaled to zero only during GC. No per-user objects, ever. - token issuer (
platform_service) — the docker auth realm. Verifies the caller's platform credential and signs a short-lived JWT granting exactly the scopes they're allowed (own namespace push/pull/delete, shared repos pull, nodes pull-any, admin all). Also walks the registry to compute usage for quota and drives GC. - platform MinIO — the one place image bytes live (
gridweave-registrybucket). No lifecycle expiry; only GC deletes blobs.
Nodes trust exactly one address, set once at join, and carry a pull-only token that containerd presents automatically.
3. Data flow¶
3a. Push (zero setup)¶
gridweave.push_image("myapp:v1")→ SDK runsdocker login <host>with the caller's platform token (via stdin), thendocker tag+docker push <host>/<ns_id>/myapp:v1.- Registry 401s with the realm → docker fetches a token from
platform_service, which grantspush,pullon<ns_id>/myapp(unless over quota → push withheld) → docker retries and streams only new layers to MinIO.
3b. Deploy¶
serve(spec={"image": "<host>/<ns_id>/myapp:v1", "port": 8080}).- Gateway checks the ref is in the caller's namespace (or a shared platform
repo) and the registry is
ready, then swaps in the endpoint worker (existing custom-image path,imagePullPolicy: Always). - Node's containerd pulls via its pull-only token — registry → MinIO, only layers the node lacks. Everything downstream (proxy sidecar, GPU, billing) unchanged.
4. Components & responsibilities¶
| Component | Where | Responsibility |
|---|---|---|
| Registry lifecycle + GC job | kuberay_service/routers/registry.py (single k8s writer) |
one registry:2 Deployment+Service+Secret+ConfigMap; ensure/status/gc/delete |
| Token issuer + quota + GC orchestration | platform_service/registry.py |
sign RS256 auth tokens, walk usage, prune untagged, schedule GC |
| Registry HTTP API | platform_service/routers/registry.py |
/v1/registry/token (realm), /config, DELETE /image, POST /gc |
| Deploy integration | job_gateway/serve.py |
rewrite/verify spec.image, ensure registry ready, pull policy |
| Node trust + pull auth | deploy/join-node.sh |
write registries.yaml with the node's pull token |
| SDK | client_sdk/registry.py |
push_image(), registry_login(), delete_image(), registry_status() |
Single-writer rule: only kuberay_service mutates k8s. platform_service
calls it over HTTP (admin token) — never touches k8s directly.
Naming¶
- Registry objects:
gridweave-registry(Deployment/Service),gridweave-registry-secrets,gridweave-registry-config· namespacegridweave. - Image ref:
<host>/<ns_id>/<repo>:<tag>wherens_id = <sanitized-user>-<8 hex>. - Shared platform repos:
<host>/gridweave/<repo>(e.g.worker-base) — every account may pull, only admin may push.ns_idalways ends in an 8-hex-digest segment, so no user namespace can ever collide withgridweave/.
5. API & interfaces¶
HTTP (platform)¶
| Method | Path | Effect |
|---|---|---|
| GET | /v1/registry/token |
docker auth realm. HTTP Basic (password = any platform token); returns an RS256 JWT scoped to the caller's grants. |
| GET | /v1/registry/config |
{ns_id, registry_host, state, usage_bytes, quota_bytes, push_example}. No secrets, no setup. |
| DELETE | /v1/registry/image?ref= |
delete a tag's manifest from the caller's namespace (blobs reclaimed by GC). |
| POST | /v1/registry/gc (admin) |
prune untagged manifests + run the blob sweep now. |
kuberay-service (internal, admin-token)¶
| Method | Path | Effect |
|---|---|---|
| POST | /v1/registry/ensure |
create/roll the registry with the auth cert. |
| GET | /v1/registry/status |
{state: absent\|starting\|ready, node_port}. |
| POST | /v1/registry/gc |
scale to 0 → registry garbage-collect Job → scale to 1. |
| DELETE | /v1/registry |
tear down the registry objects (S3 blobs untouched). |
SDK¶
gridweave.auth("gw_...") # your normal platform token
ref = gridweave.push_image("myapp:v1") # docker login+tag+push → returns ref
gridweave.serve(spec={"image": ref, "port": 8080}, name="myapp")
gridweave.registry_status() # {ns_id, state, usage_bytes, quota_bytes, ...}
gridweave.delete_image("myapp:v1") # remove a tag; GC reclaims the bytes
push_image refuses over quota and surfaces docker errors verbatim (adds an
insecure-registries hint on the HTTP/HTTPS mismatch).
Image-ref contract¶
spec.image accepts a normal public ref (unchanged) or a ref whose host is
this platform's registry — the gateway detects the latter, checks ownership +
readiness, and pulls fresh.
6. Security model¶
Access control — docker token auth (not network trust). The registry
(auth.token in its config) rejects every request lacking a valid JWT and
redirects to the realm. platform_service signs those JWTs with an RS256
key; the registry verifies them against the matching cert it was handed at
ensure (mounted as rootcertbundle). Grants (platform_service/registry.py
resolve_access):
| Caller | Grant |
|---|---|
| user / provider | push+pull+delete on <ns_id>/*; pull on gridweave/*; push withheld while over quota |
worker node (node-puller) |
pull on any repo (nodes run every user's containers) |
| admin | everything, incl. registry:catalog (the usage walker) |
| foreign namespace | empty grant — request denied at the registry |
A cross-user pull/push is refused at the registry, at the node, and fast-failed at the gateway — three independent layers.
The JWT kid. distribution keys its cert bundle by the libtrust
fingerprint of the public key; the token's kid header must equal it exactly
or every login fails. Computed in registry.libtrust_kid (base32 of the first
240 bits of SHA-256 over the DER SubjectPublicKeyInfo, colon-grouped by four)
and covered by an end-to-end signing test.
Credentials.
- No user S3 credentials exist — the backend is the platform's own storage.
The entire class of "user S3 key leaks" is deleted, not mitigated.
- The signing private key lives in one Postgres row (registry_signing_key);
only the cert ever leaves platform_service (to kuberay, into the pod).
- The registry's S3 access uses the platform's existing root creds via
secretKeyRef — never inline in a pod spec.
- The node pull token is a long-lived node-puller JWT written to
/etc/rancher/k3s/registries.yaml (chmod 600); a re-join refreshes it.
Transport
| Leg | Dev/test | Prod |
|---|---|---|
| Push (laptop → registry) | HTTP over the mesh :30500 |
TLS at registry.<domain> (Caddy/Traefik) — the one leg that must be encrypted |
| Pull (node → registry) | HTTP over WireGuard mesh (already encrypted) | same |
| Registry → MinIO | in-cluster | in-cluster / TLS to external S3 |
Prod push over a proxied CDN will break. Docker uploads layers as large monolithic
PATCHbodies; Cloudflare's proxied (orange-cloud) path caps request bodies (~100 MB) and GPU layers exceed that.registry.<domain>must be a DNS-only (grey-cloud) A record with Caddy/Let's Encrypt terminating TLS — or push over the VPN. See Image registry TLS.
Multi-arch — a real registry serves manifest lists: push linux/amd64 +
linux/arm64 and each node pulls its match. Prune/GC are manifest-list-aware
(children are kept), so this stays safe across GC.
7. Configuration¶
| Var | Service | Meaning |
|---|---|---|
REGISTRY_HOST |
gateway | host in a platform spec.image ref (MASTER_VPN_IP:30500); empty → feature off |
REGISTRY_URL |
platform | how platform reaches the registry (walk usage, prune) |
REGISTRY_NODEPORT |
kuberay + join | NodePort (default 30500); nodes trust MASTER_VPN_IP:<this> |
REGISTRY_S3_BUCKET |
kuberay + platform + minio-init | image bucket (default gridweave-registry, no ILM expiry) |
REGISTRY_S3_ENDPOINT |
kuberay | optional override. By default the registry pod's MinIO endpoint is derived from the deploy's S3_ENDPOINT so one .env works on every topology (see below) — set this only for an unusual backend |
REGISTRY_TOKEN_REALM |
kuberay | auth realm baked into the registry config (default http://MASTER_VPN_IP:8100/v1/registry/token) |
REGISTRY_QUOTA_GB |
platform | per-user referenced-bytes cap (default 20; 0 = unlimited) |
REGISTRY_GC_INTERVAL_HOURS |
platform | scheduled GC cadence (default 168 = weekly; 0 = off) |
S3 creds for the registry come from the existing S3_ACCESS_KEY/S3_SECRET_KEY.
One .env, every topology. The in-cluster registry pod needs a MinIO
endpoint it can route to, and that address differs by deploy shape. Rather than
make you set a second value, kuberay derives it from the S3_ENDPOINT the rest
of the stack already uses (resolve_s3_endpoint):
| Deploy | S3_ENDPOINT |
Registry pod uses |
|---|---|---|
| single-VM | http://minio:9000 (docker host, not pod-resolvable) |
rewritten → http://MASTER_VPN_IP:9000 (master's MinIO on the mesh) |
| multi-VM | http://10.100.0.2:9000 (data box, mesh IP) |
as-is |
| external R2/S3 | https://…r2.cloudflarestorage.com |
as-is |
| real AWS S3 | (empty) | empty (region-addressed) |
So single-VM and multi-VM deploy and test identically — no per-case flags.
8. Storage lifecycle: quota & GC¶
Quota is enforced at token issuance: a push scope is withheld (pull still
granted) once the user's referenced bytes cross REGISTRY_QUOTA_GB. Usage is
computed by walking the registry API (catalog → tags → manifests, deduping
layers by digest), cached briefly, and fails open — an accounting blip never
blocks a push (a broken registry blocks it anyway). Shared base layers count
toward each referencing user (fairness), though storage holds them once.
GC runs weekly (and on demand via POST /v1/registry/gc):
1. Prune untagged manifests through the registry API — the leftovers of tag
overwrites. Manifest-list children are kept, so this is arch-safe (we do
not use distribution's buggy --delete-untagged).
2. Blob sweep — kuberay scales the registry to 0 (distribution's GC is
only safe with no concurrent writes), runs a registry garbage-collect Job
against the same config, and scales back to 1. Downtime is minutes; the
last_gc_at timestamp is persisted so frequent redeploys can't starve it.
9. Failure modes¶
| Situation | Surfacing | Handling |
|---|---|---|
| Registry not ready at deploy | status != ready |
gateway 400 "registry isn't ready yet" |
| Push over quota | token endpoint withholds push |
docker denied; SDK pre-check says "quota exceeded — delete_image()" |
| Cross-namespace pull/push | empty grant → registry 403 | denied at registry + gateway |
| Image/tag missing | manifest 404 | "not found in the registry — did you push_image()?" |
| Node can't reach registry | pull timeout | "node can't reach the registry over the mesh" |
| Push to proxied CDN | 413/reset mid-layer |
make the DNS record grey-cloud (§6) |
| Registry down during GC | pulls fail for minutes | expected; scheduled off-hours, auto-recovers |
10. Live-verify checklist (master-test)¶
Unit tests cover object construction, grant logic, signing, and gateway
handling. These need a real cluster:
1. POST /v1/registry/ensure brings the pod Ready on the NodePort; the auth
cert lands in the pod and the realm is reachable from a node.
2. docker login + push with a platform token works; a second user's token
cannot push/pull the first's repo (403).
3. A node pulls a deployed image over the mesh using its registries.yaml token.
4. Dedup: push v1, change one layer, push v2 → only the changed layer
uploads; redeploy → node pulls only the changed layer.
5. Quota: set a low REGISTRY_QUOTA_GB, confirm push is refused past it.
6. GC: POST /v1/registry/gc prunes untagged + reclaims blobs, registry
returns to Ready.
11. Follow-ups¶
- Scale-to-zero + lazy wake for the idle registry (it's one small pod, so low priority).
- Buy-not-build line: if quota UI, retention policy, and RBAC keep growing, Harbor is the off-the-shelf answer — but it's ~6 components; don't start there.
- Image export for data-ownership customers (skopeo copy to their bucket) — the honest replacement for the rejected per-user-S3 path.
12. Related¶
- Custom-endpoint deploy path:
docs/serve/README.md - Cluster/k8s single-writer boundary:
docs/kuberay_service - Node join & containerd:
deploy/join-node.sh