Contents · Docker

pkgproxy design: Docker

The Docker Hub registry mirror. Part of the pkgproxy design, whose index says which file holds each § number.

19. Docker Hub registry mirror

The handler is internal/registryproxy; pkgproxyctl setup docker configures a client.

19.1 Scope: anonymous Docker Hub pulls

A Docker daemon’s registry-mirrors setting makes it try a mirror before Docker Hub, for docker.io images only — it has no effect on ghcr.io, quay.io or any other registry, so those are out of scope here (they would need per-registry containerd hosts.toml / podman registries.conf configuration, a different client-side mechanism). And only anonymous pulls of public images are supported. Together these keep the design small: one fixed upstream, no credentials anywhere, and no question of whether serving cached content to a client is authorized — everything cached is public.

Pushes, tags/list and the catalog are not served (404). The referrers API (/v2/<name>/referrers/<digest>) is relayed to the upstream registry uncached, since the set of referrers of a digest grows as signatures and attestations are pushed; a registry without the API answers 404 and the client falls back to the referrers tag schema. Otherwise: docker pull and podman pull need only the version check, manifests and blobs.

19.2 Why the mirror lives at /v2/, not under a base path

Unlike the go and LXD sides, whose clients are given a URL with a path, Docker validates each mirror as a bare scheme://host[:port] and rejects one with a path component. So the registry API is served at the root, sharing the /v2/ prefix with the snap store (/v2/snaps/..., /v2/assertions/..., /v2/download/...). registryproxy.Matches tells them apart by shape: the version check itself (/v2/), or a path ending in /manifests/<ref> / /blobs/<digest> / /referrers/<digest> after a grammar-valid repository name. No snap store path has such a component, and any other /v2/... path goes to the snap side. The gateway checks Docker after the go and LXD sides and before snap, and sends snap only its explicit prefixes (/v2, plus /api/v1 and /v1, which snapd also sends to the store URL it is pointed at): a path no side claims (a disabled side’s base path, a typo) gets a 404 from the gateway itself instead of being relayed to the snap store.

19.3 Authentication: pkgproxy holds the token, the client never does

Docker Hub requires a Bearer token even for public images. pkgproxy answers GET /v2/ with 200 itself, so the client concludes this mirror needs no authentication and never sends credentials (or asks us for a token). On a cache miss pkgproxy handles the registry’s 401 + WWW-Authenticate: Bearer realm=...,service=...,scope=... challenge the standard way, requesting an anonymous token from the named realm (auth.docker.io) and caching it per repository until it expires. The realm comes from the configured upstream’s own response, so it is as trusted as the upstream. The http.Client drops the Authorization header whenever a redirect (blob downloads are redirected to a CDN) leaves the registry’s host.

19.4 Cache layout and policy

Key Content Policy
blobs/sha256/<hex> layer / config blob immutable; hash-verified while streaming in; forever
manifests/sha256/<hex> manifest / index document immutable; hash-verified before it is cached; forever; media type kept in ObjectMeta.ContentType (the document’s own mediaType field if the registry sent no Content-Type)
tags/<name>/_/<tag>/<id> the digest the tag last resolved to mutable; trusted for -docker-revalidate-ttl, then revalidated

Blobs and manifests are keyed by digest alone, not by repository: the digest fully determines the bytes, so two repositories (or tags) sharing a layer share one entry. A consequence is that a cached digest is served under any repository name, including one that does not exist upstream or does not hold that digest: a request for /v2/<name>/blobs/<digest> is answered from the cache without asking whether <name> has it. A real registry would say BLOB_UNKNOWN there, but the bytes are the ones the digest names either way, and since only public content is cached (§19.1) nothing is revealed that the client could not pull from Hub itself; asking the registry on every hit would cost the round trip caching is meant to save. The _ path component in tag keys cannot be a repository name component (those start alphanumeric), so keys of different repositories cannot collide on the filesystem. <id> is a hash of the set of media types the client’s Accept headers accept, because a registry may answer one tag with a different manifest (index vs. single-platform, schema 2 vs. OCI) depending on what the client accepts. It is the set — split on commas, trimmed, lower-cased, parameters dropped, q=0 types excluded, sorted and deduplicated — not the raw header text: dockerd, containerd and podman spell the same set differently (one header per type or one comma-separated header, different orders and spacing), registries choose by membership in the set rather than by order or weight, and a pointer missed because of spelling costs a full manifest GET against Hub’s pull rate limit. The client’s headers are still sent upstream as they came.

Tags are resolved with HEAD. A tag with no pointer, or an expired one, is resolved with HEAD /v2/<name>/manifests/<tag> and its Docker-Content-Digest; Docker Hub does not count HEAD requests against the anonymous pull rate limit (the main practical reason to cache at all), so the common “tag unchanged” case costs no pull, and neither does a tag that resolves to a manifest already cached (pulled by digest, through another tag, or for another Accept set). Only a digest not cached is fetched with a GET, by digest. A HEAD that names no digest, or fails other than by saying the tag is unknown, falls back to a GET of the tag on a first resolution. If the registry cannot be reached during revalidation, the stale resolution is served with a warning, as the other sides do. If it reports the tag gone with a 404, the client gets a 404 and falls back to Docker Hub itself. A 401 or 403 is ambiguous: to an anonymous client a repository that was deleted, one that was made private, and a token the registry turned down all look the same, so a tag that resolved before keeps being served from its stale pointer (with the warning, and asked again on every request until the registry answers otherwise). With nothing cached, a 401 or 403 is a 404 to the client.

Blob streaming. A GET miss on a blob starts a fill, the same design and code as the apt side’s pool/** fills (§8.1; internal/fill): the download runs detached from any client, and every GET for the blob while it runs, Range resumes included, follows the bytes as they are written. If the fill cannot be tailed (no Content-Length upstream), requests wait for it and are served from the Store. A HEAD on a miss is answered from the registry’s own HEAD without caching. Content that does not hash to its digest is never committed, and the blob’s last byte is held back from tailing readers until the digest has been verified and the blob committed: a corrupt blob therefore never reaches a client as a complete body (its response is cut short instead), and a client that has read a whole blob knows it is cached. Unlike the apt fill there is no drain: where apt hands the clients already following a fill the origin’s unverified bytes (§8 step 7), here they get a cut-short response. Manifest and tag fetches, which are shared between concurrent requests by singleflight, are likewise detached from the context of the request that started them.

19.5 Client enablement

pkgproxyctl setup docker prints guidance on a terminal, and a script for sudo sh otherwise: choose /var/snap/docker/current/config/daemon.json (Docker snap) or /etc/docker/daemon.json, create it if absent or merge in place with sed -z -E (pkgproxy first, other settings and mirrors kept; no jq, which Ubuntu Core lacks in a form that can write the file; the result is checked and the file left alone if the layout was not understood), restart the daemon. -undo removes only pkgproxy’s entry, and the registry-mirrors key when no mirror is left in it, so that undoing a setup that added the only mirror leaves no empty list behind. Docker accepts an http:// mirror, so no TLS is involved. The daemon restart (root) is why, unlike setup lxd-remote (§17.13), this command needs root rather than refusing it.

19.6 Flags and explicit boundaries

-docker-upstream names the one upstream (off disables the side), and -docker-revalidate-ttl is how long a tag pointer is trusted (§18).

Not done: non-Hub registries, authenticated pulls, garbage collection.

19.6.1 Stats

Blob serves are recorded in internal/stats as type "docker" (§13.5), keyed <repository>@<digest> — Docker’s own digest-reference syntax — so the report names the image a layer was pulled for rather than a bare, anonymous blobs/sha256/<hex> cache key. A layer shared by several images is therefore counted once per repository it is pulled through, even though it is stored once; the bytes-saved total still adds up, since each hit is counted exactly once, under the repository that asked for it. Hit/fetch classification follows §13.2: a cached blob is a hit, the request that started a fill is a fetch, and a request that joined another’s fill is a hit. Bytes saved are what the hits sent, as for every kind: a HEAD answered from the cache is a hit that saves nothing, and a Range request saves the range it got. A HEAD for a blob that is not cached is answered by the registry and not counted. Manifests and tag resolution are not recorded: they are a few KB each and would only crowd layers out of a bytes-saved ranking. pkgproxyctl stats shortens the digest in the KEY column to 12 hex digits, the form docker images shows.