Skip to main content

Pull an Image With Plain HTTP: Registries Are Content-Addressed

A registry is a web server that answers by hash; pull an image with nothing but HTTP and every layer becomes a request you can see.

Metadata​

FieldValue
TypeBuild
LanguagesPython
PrerequisitesImages Are Just Layers
Time75 minutes
Toolsdocker, crane, skopeo, curl
Quiz · pre

Before you start

One question to find out what you already know. No score pressure.

1 What does `docker pull alpine:3.22` actually transfer over the network?

The Problem​

ImagePullBackOff. The event says manifest unknown. The image exists; you pushed it ten minutes ago from your laptop and ran it there. A different pod says unauthorized: authentication required even though the secret is "definitely configured". A third fails with toomanyrequests at 3 a.m. during a node rollout, and a fourth starts but dies with exec format error.

Four errors, one protocol. Every one of them is a specific HTTP request to the registry failing in a specific way: the token request, the manifest lookup, the platform selection in a multi-arch index, the blob download. People who have seen the requests read these errors in seconds. People who have only used docker pull see a black box.

In the previous lesson you took an image apart on disk. Now you fetch one from the network with nothing but HTTP, verify every byte by its hash, and turn the layers into a root filesystem you can run.

The Concept​

A registry implements the OCI Distribution API: a handful of HTTP endpoints under /v2/. Everything is content-addressed: blobs (layers, configs) are named by the SHA-256 of their bytes, a manifest lists blobs by digest, and a tag is a mutable pointer to a manifest digest. An image index (Docker: manifest list) maps platforms to manifests, which is how one tag serves amd64 and arm64.

Three consequences: pulls are incremental because digests the client already has are skipped; a tag can change but a digest cannot, so image@sha256:… is the reproducible reference; and any cache, mirror or proxy in between can be trusted exactly as far as the digests let it.

Build It​

code/main.py is registry_pull, a client with no dependencies beyond the standard library. The HTTP function is injected, so the tests run against an in-memory fake registry that implements the token challenge, an index, a manifest and blobs.

Step 1: parse a reference​

alpine:3.22 means registry-1.docker.io/library/alpine:3.22; ghcr.io/org/app@sha256:… pins a digest. The rules are small but easy to get wrong, which is why they get tests:

def parse_reference(text):
digest = None
if "@" in text:
text, digest = text.split("@", 1)
first, _, rest = text.partition("/")
if rest and ("." in first or ":" in first or first == "localhost"):
registry, path = first, rest
else:
registry, path = "registry-1.docker.io", text
tag = None
if ":" in path.rsplit("/", 1)[-1]:
path, tag = path.rsplit(":", 1)
if registry == "registry-1.docker.io" and "/" not in path:
path = f"library/{path}"
return Reference(registry, path, tag or ("latest" if not digest else None), digest)

Step 2: the token dance​

The first request comes back 401 with a WWW-Authenticate header. Parse realm, service and scope, fetch a token, retry with Authorization: Bearer:

def parse_challenge(header):
params = dict(re.findall(r'(\w+)="([^"]*)"', header))
realm = params.get("realm")
query = {k: v for k, v in params.items() if k in ("service", "scope")}
return realm + "?" + urllib.parse.urlencode(query)

Anonymous pulls use the same flow; the token simply has an anonymous subject with pull scope on one repository.

Step 3: manifest, index, platform​

Ask for every media type you understand in Accept. If what comes back has a manifests list, it is an index: pick the entry for your platform and fetch that manifest by digest, verifying the body against the digest you asked for:

def select_platform(index, os_name="linux", arch="amd64"):
for entry in index.get("manifests", []):
platform = entry.get("platform") or {}
if platform.get("os") == os_name and platform.get("architecture") == arch:
return entry
raise LookupError(f"no manifest for {os_name}/{arch}")

Step 4: blobs, verified​

def fetch_blob(self, ref, digest):
status, _, body = self._get(ref, f"blobs/{digest}", ["*/*"])
verify_digest(body, digest)
return body

verify_digest raises on mismatch. The test suite includes a tampering registry that appends one byte to a layer; the pull must fail.

Step 5: layers to a root filesystem, safely​

Apply the layers in order with the whiteout rules from the previous lesson, and refuse any entry whose normalised path escapes the target directory. This is where real runtimes have had CVEs:

def _safe_join(root, name):
target = os.path.normpath(os.path.join(root, name))
if not target.startswith(os.path.abspath(root)):
raise ValueError(f"layer entry escapes rootfs: {name}")
return target

Permission bits have to make the trip too. Python's tarfile creates files as 0644 and directories as 0700 unless it is asked to apply attributes, which would leave /bin/busybox without its executable bit and /tmp without its sticky bit; the rootfs would unpack and then refuse to run. apply_layer() sets each regular file's mode as it goes and records directory modes to apply once every layer is in place, because a read-only directory from one layer would otherwise block the files a later layer puts inside it.

Step 6: run it​

python3 code/main.py manifest alpine:3.22 --verbose              # watch the 401, the token, the index, the manifest
python3 code/main.py manifest alpine:3.22 --platform linux/arm64 # a different digest
python3 code/main.py rootfs alpine:3.22 --dest /tmp/alpine --json
ls /tmp/alpine/rootfs; cat /tmp/alpine/rootfs/etc/os-release

Then prove the rootfs is real by running it, with the container runtime from the first lesson or with Docker's import:

tar -C /tmp/alpine/rootfs -c . | docker import - hand-pulled:alpine
docker run --rm hand-pulled:alpine /bin/busybox echo "hello from a hand-pulled rootfs"
🧪 Run the tests for this lesson

The tests are the specification for Build It. They pass on macOS and Linux without root; only the namespace and cgroup calls themselves need a Linux box.

git clone https://github.com/nomadicmehul/CloudCaptain.git
cd CloudCaptain/curriculum/phases/05-containers/03-registry-pull
python3 -m unittest discover -s code/tests -v

Use It​

The tools people reach for are the same requests with better ergonomics:

docker pull alpine:3.22
docker manifest inspect alpine:3.22 | head -40 # the index: one entry per platform
docker image inspect alpine:3.22 --format '{{index .RepoDigests 0}}'
crane manifest alpine:3.22 | jq '.mediaType' # crane: github.com/google/go-containerregistry
crane digest alpine:3.22
skopeo inspect --raw docker://alpine:3.22 | jq '.manifests[].platform'
skopeo inspect docker://alpine:3.22 | jq '{Architecture, Os, Layers}'

Do one request by hand to make it concrete:

TOKEN=$(curl -s "https://auth.docker.io/token?service=registry.docker.io&scope=repository:library/alpine:pull" | python3 -c 'import json,sys; print(json.load(sys.stdin)["token"])')
curl -s -H "Authorization: Bearer $TOKEN" \
-H "Accept: application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json" \
https://registry-1.docker.io/v2/library/alpine/manifests/3.22 | python3 -m json.tool | head -30

Now the four errors from The Problem, each reproduced on purpose:

ErrorReproduceWhich request failed
manifest unknowncrane manifest alpine:no-such-tagmanifest GET: the tag does not exist in that repository
unauthorizedcrane manifest ghcr.io/some-org/private-imagetoken has no pull scope, or the pull secret is missing on the pod
toomanyrequestsmany anonymous pulls from one IPthe token or manifest GET; anonymous pulls are rate-limited, authenticate or mirror
exec format errordocker run --platform linux/arm64 alpine uname -m on an amd64 host without emulationnothing failed: the index pick was for the wrong architecture

In Kubernetes the same protocol runs inside containerd on each node, which is why kubectl describe pod events quote these exact messages, why imagePullSecrets matter per service account, and why pinning image@sha256:… turns a mutable tag into a verifiable artifact.

Quiz · check

Check your understanding

Three questions on what you just built and used.

1 The first request to the registry returns 401 with a `WWW-Authenticate: Bearer realm=…` header. What happens next in your client?
2 For `alpine:3.22` the manifest endpoint returns an *index* with several entries. Why?
3 Why does the client verify the SHA-256 of every blob, even over HTTPS?

Ship It​

One artifact in outputs/:

  • skill-registry-triage.md: a skill that maps every ImagePullBackOff message to the request that failed (token, authorization, manifest, platform, blob, TLS), replays it with crane, skopeo or three curl calls, and fixes it at the right layer (pull secrets, digest pinning, multi-platform builds, mirrors).
📦 Artifacts from this lesson
  • Skill skill-registry-triage.md
    Install: .claude/skills/registry-triage/SKILL.md (Cursor: .cursor/skills/, Codex: .codex/skills/)
Or install every skill from the curriculum at once: npx skills add nomadicmehul/CloudCaptain

Exercises​

Easy​

Add --platform auto-detection from platform.machine() (x86_64 → amd64, arm64/aarch64 → arm64) so python3 main.py rootfs alpine:3.22 does the right thing on any laptop. Tests first.

Medium​

Add a local blob cache: before downloading, check ~/.cache/registry_pull/<digest>; after verifying, store it. Then pull alpine:3.22 and alpine:latest and show which blobs were skipped. This is what makes layer sharing real.

Hard​

Implement the push side: POST /v2/<repo>/blobs/uploads/, PATCH/PUT the bytes with the digest, then PUT the manifest, against a local registry (docker run -d -p 5000:5000 registry:2). Verify with crane manifest localhost:5000/... that what you pushed is byte-identical to what you pulled, then explain in one paragraph why a registry can deduplicate across repositories without trusting the client.

Quiz · post

Lock it in

Two questions that connect this lesson to the next one. Both right marks the lesson complete.

1 A pod shows ImagePullBackOff and the event says `manifest unknown` for `myapp:v3` which you pushed from your laptop. Most likely cause?
2 A layer entry in a tar is named `../../etc/passwd`. What must a correct extractor do?

Key Terms​

TermCommon misconceptionPrecise definition
Digest"A checksum for error detection."sha256: of a blob or manifest's bytes; the blob's name in the registry, so content cannot change without its reference changing.
Manifest"The image."A JSON document listing the config digest and the layer digests (with sizes and media types) for one platform.
Image index"An extra manifest Docker Hub adds."A JSON document mapping platforms to manifest digests; what a multi-arch tag points to.
Tag"A version."A mutable name for a manifest digest; it can move. Pin digests when reproducibility matters.
Bearer token flow"Only for private images."The 401-challenge, token, retry sequence every pull performs; anonymous pulls get an anonymous token scoped to one repository.
Pull-through cache / mirror"A speed optimisation."A registry that fetches and stores upstream blobs by digest; it removes rate limits and upstream outages from your critical path.
Path traversal in layers"Only a problem for tar."An archive entry whose normalised path leaves the extraction root; runtimes must reject it, as yours does.

Further Reading​