OCI Images and crane: How Container Images Actually Work
The container runtime is what you interact with. But the image — the artifact that gets built, pushed, and pulled — is governed by a specification most developers never read. Understanding it demystifies a surprising amount of the plumbing: why image pulls are fast, how multi-platform images work, why digests are immutable, and how tools like crane can copy images between registries without ever touching your disk.
The Pre-Standard World
Docker shipped its first public release in March 2013, along with a proprietary image format and a proprietary registry protocol. A “Docker image” was a layered filesystem: a stack of tarballs, each representing filesystem changes relative to the previous layer. When a container started, the runtime merged these layers using a union filesystem (like OverlayFS) to produce a single coherent root filesystem. Docker’s registry — Docker Hub — spoke a protocol no one else implemented.
By 2015, the container ecosystem had fragmented. CoreOS was building rkt with its own App Container (appc) format. Red Hat, Google, and others had competing visions for how images should work. Images built for Docker couldn’t run on rkt without conversion.
In June 2015, Docker and CoreOS announced they would collaborate under the Open Container Initiative (OCI), a project under the Linux Foundation. Docker donated its image format and runtime specification to seed the project.
Two specifications emerged:
- OCI Image Specification: what an image looks like on disk and in a registry
- OCI Distribution Specification: how images are pushed, pulled, and stored in a registry
A third spec — the OCI Runtime Specification — defines how a container runtime executes a container from an unpacked image bundle, but that’s outside the scope of this post.
What an OCI Image Is
An OCI image is a set of content-addressable blobs organized through a hierarchy of JSON documents. There is no single “image file.” There is a directed acyclic graph of references, where each node is identified by the SHA-256 hash of its content.
Image Index (multi-platform)
├── sha256:aaa... Image Manifest (linux/amd64)
│ ├── sha256:bbb... Config
│ └── Layers
│ ├── sha256:ccc... (base OS tarball)
│ ├── sha256:ddd... (dependency layer)
│ └── sha256:eee... (app layer)
└── sha256:fff... Image Manifest (linux/arm64)
├── sha256:ggg... Config
└── Layers
├── sha256:hhh...
└── sha256:iii...
Everything is addressed by digest — sha256: followed by the hex-encoded SHA-256 hash of the blob’s content. This means:
- Blobs are immutable by definition. Changing content changes the digest, which changes the parent reference.
- Layers are shared across images. If two images have the same base OS layer, the same blob serves both.
- Tampering is immediately detectable. Any modification changes the hash.
Let’s walk through each component.
Image Index
The image index (also called a “manifest list”) is the top-level document for multi-platform images. It lists image manifests alongside their platform information. When you docker pull ubuntu:22.04 on an Apple M2, the daemon fetches the index, selects the linux/arm64 manifest, and pulls that.
{
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.index.v1+json",
"manifests": [
{
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"digest": "sha256:abc123...",
"size": 1234,
"platform": { "os": "linux", "architecture": "amd64" }
},
{
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"digest": "sha256:def456...",
"size": 1098,
"platform": { "os": "linux", "architecture": "arm64", "variant": "v8" }
}
]
}
The image index is optional for single-platform images. Many images you encounter are still just a manifest with no index wrapping them.
Image Manifest
The image manifest describes a single-platform image. It references a config blob and an ordered list of layer blobs.
{
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"config": {
"mediaType": "application/vnd.oci.image.config.v1+json",
"digest": "sha256:bbb...",
"size": 7023
},
"layers": [
{
"mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
"digest": "sha256:ccc...",
"size": 29536256
},
{
"mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
"digest": "sha256:ddd...",
"size": 18756608
}
]
}
The mediaType on each layer tells the runtime what format the blob is in:
application/vnd.oci.image.layer.v1.tar+gzip— gzip-compressed tarball (most common)application/vnd.oci.image.layer.v1.tar+zstd— zstd-compressed (faster decompression, better ratio)application/vnd.oci.image.layer.v1.tar— uncompressed
Image Config
The config blob contains the runtime metadata: environment variables, entrypoint, working directory, user, exposed ports, and the history of commands that produced each layer.
{
"architecture": "amd64",
"os": "linux",
"config": {
"Env": ["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"],
"Entrypoint": ["/app/server"],
"WorkingDir": "/app",
"ExposedPorts": { "8080/tcp": {} }
},
"rootfs": {
"type": "layers",
"diff_ids": [
"sha256:uncompressed-layer-1-hash...",
"sha256:uncompressed-layer-2-hash..."
]
},
"history": [
{
"created": "2024-01-15T10:00:00Z",
"created_by": "/bin/sh -c apt-get install -y curl"
}
]
}
One subtlety worth noting: rootfs.diff_ids contains SHA-256 hashes of the uncompressed layer tarballs, while the manifest references the compressed blobs by their compressed hash. The runtime verifies the compressed hash on pull, then verifies the uncompressed hash after applying each layer.
Layers
Each layer is a tarball containing the filesystem diff relative to the layers below it. The runtime stacks them using a union filesystem like OverlayFS:
Layer 3 (app code): /app/server [ADD]
Layer 2 (deps): /usr/local/lib/libssl.so [ADD]
Layer 1 (base OS): /bin/, /etc/, /lib/, /usr/ [ADD]
Union mount view: /bin/, /etc/, /lib/, /usr/,
/usr/local/lib/libssl.so,
/app/server
Deletions are represented by whiteout files — a file named .wh.<filename> signals that the named file should be hidden in the merged view. Deleting an entire directory uses an opaque whiteout (.wh..wh..opq) that hides all lower-layer contents of that directory.
This is why combining commands in a single Dockerfile RUN instruction matters:
# Good: apt cache exists only inside this layer
RUN apt-get install -y curl && apt-get clean
# Bad: apt cache is baked into layer 1, even if layer 2 deletes it
RUN apt-get install -y curl
RUN apt-get clean
The cache in layer 1 is immutable. A rm -rf in a later layer doesn’t shrink the image — it just adds a whiteout.
The OCI Distribution Specification
The image specification describes the content. The distribution specification describes the protocol for moving it between a client and a registry.
The distribution spec is a REST API. The key endpoints:
| Endpoint | Method | Purpose |
|---|---|---|
/v2/<name>/manifests/<reference> | GET | Pull a manifest by tag or digest |
/v2/<name>/manifests/<reference> | PUT | Push a manifest |
/v2/<name>/blobs/<digest> | GET | Pull a blob |
/v2/<name>/blobs/uploads/ | POST | Initiate a blob upload |
/v2/<name>/blobs/uploads/<uuid> | PUT | Complete a blob upload |
/v2/<name>/tags/list | GET | List tags |
/v2/ | GET | Registry version check / auth challenge |
A tag like ubuntu:22.04 is a mutable pointer to a digest. The registry stores a mapping from tag name to manifest digest. Pushing a new image with the same tag updates the pointer; the old manifest and its blobs remain until garbage collected.
A digest reference like ubuntu@sha256:abc123... is immutable. If the manifest content changes, the digest changes.
Cross-Repo Blob Mounting
Cross-repo blob mounting is the mechanism that makes registry-to-registry copies fast. A client can tell a registry: “I know blob sha256:abc... already exists in repository base/ubuntu. Mount it into myapp without transferring the bytes.”
POST /v2/myapp/blobs/uploads/?from=base/ubuntu&mount=sha256:abc...
If the registry allows the mount, it returns 201 Created immediately — no data transferred. This is how layers are shared across images in the same registry, and how tools like crane can copy a 1 GB image in seconds when most of its layers already exist at the destination.
OCI Artifacts (Image Spec v1.1)
OCI Image Spec v1.1, released in March 2024, extended the format beyond container images. The key insight: a registry is just a content-addressable blob store with a manifest protocol. You can store anything in it.
OCI artifacts use the standard image manifest structure, with arbitrary mediaType values that identify the artifact type. In the wild:
- Helm charts:
application/vnd.helm.chart.content.v1.tar+gzip - SBOMs:
application/vnd.cyclonedx+json,application/spdx+json - Cosign signatures:
application/vnd.dev.cosign.simplesigning.v1+json - OPA policy bundles, WASM modules, attestations
v1.1 also added a subject field to manifests — a reference from an artifact to the image it annotates. This creates a graph of relationships: an image manifest can have a linked SBOM, a linked signature, and linked attestations, all discoverable through the registry’s referrers API:
Image Manifest (your-app:latest)
│
├── (subject) ── SBOM Manifest
│ └── SBOM blob (CycloneDX JSON)
│
└── (subject) ── Signature Manifest
└── Signature blob (Cosign)
crane
crane is a command-line tool for working with OCI images and registries. It’s part of Google’s go-containerregistry library, which provides a pure-Go implementation of both the OCI image spec and distribution spec.
The defining characteristic of crane: no Docker daemon required. Every operation talks directly to registries over HTTPS. This has significant practical implications.
Why “No Daemon” Matters
The Docker daemon is a long-running root process that owns the local image cache, manages pull/push operations, and handles container execution. Most image tools — including docker itself — go through this daemon. docker tag needs a local copy of the image. docker pull writes to the daemon’s storage. docker inspect reads from it.
crane bypasses all of this. It implements the OCI distribution protocol natively in Go, which means:
- No root required — registry operations don’t need elevated privileges
- No Docker socket — works in environments where the socket isn’t mounted (common in CI runners)
- Lightweight — no daemon startup overhead, no local image cache
- Genuinely server-side copies — copying between registries doesn’t route bytes through your machine
docker copy (naive):
Your machine ◄── pull ── Source Registry
Your machine ──── push ──► Destination Registry
(full image transits your disk)
crane copy:
crane ──► Source Registry: "give me the manifest"
crane ──► Destination Registry: "mount blob sha256:abc from source"
crane ──► Destination Registry: "PUT manifest"
(only new blobs transit the network; layers already at destination are mounted)
crane Use Cases
Inspecting Images Without Pulling Them
The most common use case: get metadata about an image without downloading layers.
# Raw manifest JSON
crane manifest ubuntu:22.04
# Image config (entrypoint, env, labels, history)
crane config ubuntu:22.04
# Stable, content-addressable identifier for a tag
crane digest ubuntu:22.04
# sha256:77906da86b60585ce12215807090eb327e7386c8fafb5402369e421f44eff17e
# List all tags for a repository
crane ls ubuntu
# List platforms in a multi-platform image
crane manifest ubuntu:22.04 | jq '.manifests[].platform'
The crane digest command is particularly useful in CI pipelines. Instead of pinning to ubuntu:22.04 (mutable — the tag can be reassigned), you pin to ubuntu@sha256:77906... (immutable — the digest is the hash of the manifest). Use crane digest to check periodically whether the tag has been updated to a new digest, signaling a new base image to test against.
Copying Images Between Registries
crane’s copy command performs a registry-to-registry copy using cross-repo blob mounting. Layers that already exist at the destination are mounted — not retransferred.
# Copy a single image
crane copy ubuntu:22.04 myregistry.internal.com/base/ubuntu:22.04
# Copy a specific platform
crane copy --platform linux/arm64 ubuntu:22.04 myregistry.internal.com/base/ubuntu:22.04-arm64
# Copy all tags in a repository
crane copy --all-tags ubuntu myregistry.internal.com/base/ubuntu
A common scenario: your production Kubernetes cluster is restricted to an internal registry (for audit logging, CVE scanning, or network policy). A nightly CI job uses crane copy to mirror approved base images from Docker Hub. The operation is fast because the base OS layer — typically the largest — is only transferred once; subsequent copies mount it.
Tagging Without Pulling
Moving or adding tags in Docker requires pulling the image locally. crane makes it a registry API call that never touches your disk.
# Add a semantic version tag to an image (no local pull)
crane tag myregistry.internal.com/myapp:latest myregistry.internal.com/myapp:v1.2.3
# Remove a tag
crane delete myregistry.internal.com/myapp:old-branch
Under the hood, crane fetches the manifest for the source tag and PUTs it under the new tag reference. The blobs are never involved.
Appending Layers Programmatically
crane can construct images by appending layer tarballs to a base image — no Dockerfile, no build daemon.
# Package the application
tar -C ./dist -czf app.tar.gz .
# Append as a new layer on top of a distroless base
crane append \
--base gcr.io/distroless/static:latest \
--new_layer app.tar.gz \
--new_tag myregistry.internal.com/myapp:latest
For truly minimal images (a single static binary), you can build from scratch:
crane append \
--new_layer myapp.tar.gz \
--new_tag myregistry.internal.com/myapp:latest
This pattern is common in Bazel-based builds and other hermetic build systems that produce artifacts as tarballs and want to assemble container images as a deterministic composition of those artifacts — without a Dockerfile or a Docker daemon in the critical path.
Mutating Image Metadata
crane mutate modifies the image config without touching or rebuilding the layers. Useful for fixing metadata after the fact, or for build systems that separate the “build artifact” step from the “add OCI metadata” step.
# Change the entrypoint
crane mutate \
--entrypoint /app/newserver \
myregistry.internal.com/myapp:latest
# Add OCI standard labels
crane mutate \
--label org.opencontainers.image.version=1.2.3 \
--label org.opencontainers.image.revision=$(git rev-parse HEAD) \
myregistry.internal.com/myapp:latest
# Set environment variables
crane mutate \
--env NODE_ENV=production \
myregistry.internal.com/myapp:latest
Each mutate operation creates a new config blob and a new manifest, then pushes them. The layer blobs are untouched — their digests stay identical.
Flattening an Image
crane flatten merges all layers into a single layer. This eliminates the cost of OverlayFS layer stacking at container startup and can meaningfully reduce cold start time for images with many thin layers.
crane flatten myregistry.internal.com/myapp:latest \
-t myregistry.internal.com/myapp:latest-flat
Note: flattening loses the build history embedded in each layer and makes it harder to share layer data with other images. It’s a trade-off — fewer layers means faster startup, but less sharing.
Exporting Filesystems
crane export extracts the flattened root filesystem of an image to a tarball, without running a container or touching the Docker daemon.
# Export to a file
crane export ubuntu:22.04 ubuntu-rootfs.tar
# Pipe to stdout
crane export ubuntu:22.04 - | tar -xO ./etc/os-release
Practical uses:
- Vulnerability scanning in CI: scan the image filesystem without pulling to a daemon
- Extracting binaries: pull a tool from an official image without running a container
- Auditing: inspect what’s actually in an image before deploying it
Rebasing Images
crane rebase is the operation that matters most for security patching. Given an image built on top of a base, and a new version of that base, it produces a new image on the updated base — without running a single Dockerfile instruction.
crane rebase \
--original myregistry.internal.com/myapp:latest \
--old_base ubuntu:20.04 \
--new_base ubuntu:22.04 \
-t myregistry.internal.com/myapp:rebased
crane identifies the layers that came from the old base image, discards them, and prepends the layers from the new base. The application layers above the base are preserved unchanged. The resulting image is built on the patched base in seconds.
The practical impact: when a CVE is found in your base OS image, you can rebase every affected application image in a few seconds and push the results. No re-running builds, no re-running tests on build artifacts you didn’t change. The application code is identical — only the base has changed.
Authentication
crane respects Docker’s credential store. If you’ve run docker login, crane uses the same credentials:
# Log in (stored in Docker config)
crane auth login myregistry.internal.com -u user -p password
# Inspect stored credentials
crane auth get myregistry.internal.com
In CI environments without a Docker config:
crane -u "$REGISTRY_USER" -p "$REGISTRY_PASSWORD" \
copy myapp:latest myregistry.internal.com/myapp:latest
crane also respects the DOCKER_CONFIG environment variable pointing to a config directory, which makes it straightforward to inject credentials as mounted secrets in Kubernetes jobs.
Putting It Together
OCI’s content-addressable, layered format solves a genuinely hard distribution problem: how do you move large artifacts efficiently across a distributed system where clients have overlapping base images and bandwidth is expensive?
The answer is digest-addressed blobs with cross-repo mounting. Every layer is referenced by its SHA-256 hash. Every registry tracks which blobs it has. Blob mounting allows transferring only the delta. The result: pulling a new version of your application image, which shares a 30 MB base OS layer with the previous version, transfers only the application layers — the base is mounted from what’s already there.
crane surfaces this protocol directly, without the indirection of a local daemon and cache.
| Operation | docker | crane |
|---|---|---|
| Get manifest | docker inspect (after full pull) | crane manifest (no pull) |
| Get config | docker inspect (after full pull) | crane config (no pull) |
| Copy between registries | pull + tag + push (bytes transit your disk) | crane copy (server-side with blob mounting) |
| Add a tag | docker tag (requires local image) | crane tag (registry API call only) |
| Append a layer | Dockerfile + build | crane append |
| Rebase on new base | Dockerfile rebuild | crane rebase |
| Export filesystem | docker export (requires a running container) | crane export (no daemon) |
The OCI specs turned container images into an open, interoperable standard. crane makes them programmable — a registry is just an API, and every image operation is an HTTP call.
Comments
Came here from LinkedIn or X? Join the conversation below — all discussion lives here.