OCI Image Specification
What a container image actually is — not a VM disk, but a stack of tarballs with metadata
What Is a Container Image?
Key Mental Model
A container image is not a VM disk image. There is no bootloader, no kernel, no init system. It is a stack of read-only filesystem layers plus metadata describing how to run them.
- Each layer is a tar archive of filesystem changes (files added, modified, or deleted)
- Layers are immutable and content-addressable — identified by their
sha256digest - Layers are stacked bottom-up: base layer first, each subsequent layer applied on top
- The image also carries a config JSON with runtime instructions (entrypoint, env vars, etc.)
- Full disk: bootloader + kernel + OS + app
- Single monolithic file (qcow2, vmdk)
- Typically gigabytes
- Must boot an entire OS to use
- Contains its own kernel
- Userspace only: libraries + app + config
- Stack of tar layers + JSON config
- Typically megabytes
- Runs as a process on the host kernel
- Shares the host kernel
OCI Image Spec Components
The OCI Image Specification defines four components that together describe a container image:
Image Index (fat manifest)
{
"schemaVersion": 2,
"manifests": [
{
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"digest": "sha256:abc123...",
"platform": { "architecture": "amd64", "os": "linux" }
},
{
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"digest": "sha256:def456...",
"platform": { "architecture": "arm64", "os": "linux" }
}
]
}Manifest (single platform)
{
"schemaVersion": 2,
"config": {
"mediaType": "application/vnd.oci.image.config.v1+json",
"digest": "sha256:cfg789...",
"size": 1470
},
"layers": [
{ "digest": "sha256:layer1...", "size": 27098756 },
{ "digest": "sha256:layer2...", "size": 1024 },
{ "digest": "sha256:layer3...", "size": 5432100 }
]
}Config JSON (simplified)
{
"architecture": "amd64",
"os": "linux",
"config": {
"Env": ["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin"],
"Entrypoint": ["/app/server"],
"Cmd": ["--port=8080"],
"WorkingDir": "/app",
"ExposedPorts": { "8080/tcp": {} },
"User": "1000:1000",
"Labels": { "maintainer": "team@example.com" }
},
"rootfs": {
"type": "layers",
"diff_ids": [
"sha256:aaa...",
"sha256:bbb...",
"sha256:ccc..."
]
},
"history": [
{ "created": "2024-01-15T10:00:00Z", "created_by": "/bin/sh -c #(nop) ADD file:abc... in /" },
{ "created": "2024-01-15T10:01:00Z", "created_by": "RUN apt-get update" },
{ "created": "2024-01-15T10:02:00Z", "created_by": "COPY . /app" }
]
}Content-Addressable Storage
The Hash IS the Address
Every blob in OCI (configs, layers) is stored and retrieved by its sha256 digest. The content hash is the identifier — there is no separate naming scheme.
Immutability
If the content changes, the hash changes. You cannot modify a blob in place — you can only create a new one with a new digest.
Deduplication
If two images share the same layer (same content = same hash), it is stored exactly once on disk and in the registry.
Integrity
After downloading a blob, you hash it and compare to the expected digest. If they don't match, the blob is corrupt or tampered with.
$ docker inspect --format='{{.Id}}' nginx:latest
sha256:a8758716bb6aa4d90071160d27028fe4eaee7ce8166221a97d30440c8eac2be6
$ docker inspect --format='{{json .RootFS.Layers}}' nginx:latest | python3 -m json.tool
[
"sha256:5d4427c15c33...",
"sha256:7e87a0e6d47c...",
"sha256:3c86b8f2e0a2..."
]
Note
Tags are mutable pointers. nginx:latest is a tag that points to a manifest digest.
The tag can be reassigned to point to a different digest at any time. This is why pinning by digest
(nginx@sha256:abc123...) is safer for reproducible deployments.
Layer Mechanics
Each layer is a tar archive representing filesystem changes relative to the layers below it. Layers are applied bottom-up: the base layer goes down first, then each subsequent layer is overlaid on top.
Layer Operations
Add / Modify
New or changed files are simply included in the tar archive. When extracted on top of previous layers, they appear as new files or overwrite existing ones.
Delete (Whiteout)
A special whiteout file named .wh.<filename> marks a file as deleted.
The union filesystem knows to hide the original file.
Delete Dir (Opaque)
A special file .wh..wh..opq inside a directory marks the entire directory
as opaque — all contents from lower layers are hidden.
Whiteout Example
app/
.wh.config.yml ← whiteout: deletes config.yml from Layer 1
server ← new binary replaces Layer 2's version
app/
server
static/
index.html
bin/ lib/ usr/ etc/
app/
config.yml ← this file gets deleted by Layer 3's whiteout
Final merged view: The container sees /app/server (from Layer 3),
/app/static/index.html (from Layer 2), all of /bin /lib /usr /etc (from Layer 1),
but not /app/config.yml (deleted by Layer 3's whiteout).
Layer Sharing
Because layers are content-addressable, multiple images can share common layers. This is the primary mechanism for storage and transfer efficiency.
myapp:v1
COPY app code (unique to myapp:v1)
RUN pip install (unique to myapp:v1)
RUN apt-get install python3 SHARED
RUN apt-get update SHARED
ubuntu:22.04 base SHARED
otherapp:v2
COPY different code (unique to otherapp:v2)
RUN npm install (unique to otherapp:v2)
RUN apt-get install python3 SHARED
RUN apt-get update SHARED
ubuntu:22.04 base SHARED
Tip
Storage savings: Layers 1, 2, and 3 are stored only once on disk. When pulling otherapp:v2
after myapp:v1, Docker skips downloading the 3 shared layers entirely — only the unique top layers are fetched.
Registries: OCI Distribution Spec
A registry is an HTTP API for storing and retrieving OCI images. The OCI Distribution Spec defines the protocol.
Pull Sequence
Resolve Tag
GET /v2/<name>/manifests/<tag>
Registry returns manifest digestDownload Manifest
Parse manifest JSON to get config digest + layer digests
Check Local Cache
Compare layer digests against locally stored blobs — skip layers already present
Download Blobs
GET /v2/<name>/blobs/<digest>
Fetch only missing layers + config
Authentication Flow
- Client sends
GET /v2/library/nginx/manifests/latest - Registry responds
401 Unauthorizedwith header:Www-Authenticate: Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:library/nginx:pull" - Client requests token from auth service with the specified scope
- Client retries original request with
Authorization: Bearer <token>header
Common Registries
| Registry | URL | Auth | Notes |
|---|---|---|---|
| Docker Hub | registry-1.docker.io |
Docker ID / token | Default for docker pull; rate-limited for anonymous pulls |
| GHCR | ghcr.io |
GitHub PAT / GITHUB_TOKEN | Tied to GitHub repos/orgs; free for public images |
| AWS ECR | <acct>.dkr.ecr.<region>.amazonaws.com |
IAM / aws ecr get-login-password |
Per-region, private by default; token expires in 12h |
| GCR / Artifact Registry | gcr.io / <region>-docker.pkg.dev |
GCP service account | GCR is legacy; Artifact Registry is the replacement |
| Harbor | Self-hosted | LDAP / OIDC / local | OSS registry with RBAC, vulnerability scanning, replication |
Warning
Docker Hub rate limits (anonymous): 100 pulls per 6 hours per source IP. Authenticated free accounts get 200 pulls. CI pipelines should use authenticated pulls or mirror images to a private registry.