Skip to content
Menu

Virtualization6 min read

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 sha256 digest
  • 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.)
VM Disk Image
  • Full disk: bootloader + kernel + OS + app
  • Single monolithic file (qcow2, vmdk)
  • Typically gigabytes
  • Must boot an entire OS to use
  • Contains its own kernel
Container Image
  • 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
Optional "fat manifest" — points to manifests for different architectures (amd64, arm64, etc.)
Manifest
Lists the config digest and layer digests for one specific platform
Config
JSON blob: entrypoint, cmd, env, workdir, exposed ports, labels, layer diff IDs, history
Layers
Ordered list of tar.gz archives — each is a set of filesystem changes

Image Index (fat manifest)

bash
{
  "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)

bash
{
  "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)

bash
{
  "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.

console
console

    $ 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

Layer Stack (bottom-up)
Layer 3 (top) — app update
text
bash

        app/

          .wh.config.yml ← whiteout: deletes config.yml from Layer 1

          server ← new binary replaces Layer 2's version
      
Layer 2 — install app
text
bash

        app/

          server

          static/

            index.html
      
Layer 1 (base) — ubuntu:22.04
text
bash

        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.

Local Image Store

myapp:v1

Layer 5: COPY app code (unique to myapp:v1)
Layer 4: RUN pip install (unique to myapp:v1)
Layer 3: RUN apt-get install python3 SHARED
Layer 2: RUN apt-get update SHARED
Layer 1: ubuntu:22.04 base SHARED

otherapp:v2

Layer 6: COPY different code (unique to otherapp:v2)
Layer 5: RUN npm install (unique to otherapp:v2)
Layer 3: RUN apt-get install python3 SHARED
Layer 2: RUN apt-get update SHARED
Layer 1: 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

  1. Resolve Tag

    GET /v2/<name>/manifests/<tag>
    Registry returns manifest digest

  2. Download Manifest

    Parse manifest JSON to get config digest + layer digests

  3. Check Local Cache

    Compare layer digests against locally stored blobs — skip layers already present

  4. Download Blobs

    GET /v2/<name>/blobs/<digest>
    Fetch only missing layers + config

Authentication Flow

  1. Client sends GET /v2/library/nginx/manifests/latest
  2. Registry responds 401 Unauthorized with header: Www-Authenticate: Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:library/nginx:pull"
  3. Client requests token from auth service with the specified scope
  4. 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.

Solidnines — solidnines.com