Hitesh Sahu
Hitesh SahuHitesh Sahu
  1. Home
  2. ›
  3. posts
  4. ›
  5. …

  6. ›
  7. 1 2 Image Internals

Loading ⏳
Fetching content, this won’t take long…


💡 Did you know?

🍌 Bananas are berries, but strawberries are not.

🍪 This website uses cookies

No personal data is stored on our servers however third party tools Google Analytics cookies to measure traffic and improve your website experience. Learn more

Loading ⏳
Fetching content, this won’t take long…


💡 Did you know?

🍌 Bananas are berries, but strawberries are not.
kubernetes

    AI-AgenticAI

    AI-DeepLearning

    AI-GenAI

    AI-Infrastructure

    AI-Machine-Learning

    AI-Math

    AWS

    Azure

    Hobbies

    kubernetes
    • Kubernetes: Control Loops, Scheduling, and GPUs

    • Image Internals: From OCI Layers to a Running Container

    • Container Internals: What a Container Really Is

    • Kubernetes Pod Internals: What a Pod Really Is

    • Kubernetes API Server Internals

    • etcd Architecture Explained

    • Kubernetes Scheduler Internals

    • Kubernetes Informers & Controllers Explained

    • Kubernetes Networking: Pods, Services, Ingress, and CNI

    • Kubernetes Storage: PV, PVC, StorageClass, and CSI

    • Helm: Kubernetes Package Manager

    • Cloud Native Observability: Prometheus, Grafana, OpenTelemetry, and Tracing

    • Kubernetes Resource Allocation: Requests, Limits, QoS, and Quotas

    • GPU Scheduling in Kubernetes: Device Plugins, GPU Operator & MIG

    • NVIDIA Network Operator: InfiniBand, SR-IOV, RDMA, and Multus

    • Dynamic Resource Allocation: The Future of GPU Scheduling in Kubernetes

    • Kubernetes Performance at Scale

    • Optimizing AI Inference at Scale: The Full Stack

    • Kueue: Kubernetes-Native Job Queuing and Quota Management

    • Multi-Node Distributed Training on Kubernetes

    • Kubernetes Topology Manager: NUMA-Aware GPU Scheduling

    • NVIDIA NIM: Optimized Inference Microservices on Kubernetes

    • GPU Autoscaling on Kubernetes: KEDA, HPA, and Cluster Autoscaler

    • Fine-Tuning LLMs: LoRA, QLoRA, PEFT, and NeMo on Kubernetes

    • Flash Attention: Fast, Memory-Efficient Attention for LLMs

    • Kubernetes and Cloud Native Certification Path

    • kubernetes Index


    Management

    Programming

    Terraform

    Z_Appendix

    0-root

Cover Image for Image Internals: From OCI Layers to a Running Container
kubernetes

Image Internals: From OCI Layers to a Running Container

The complete journey of a container image — OCI manifest, content-addressable layers, registry pull flow, containerd snapshots, OverlayFS rootfs assembly, pod sandbox creation, and how a container process finally starts inside a pod.

Kubernetes
Containers
OCI
Docker
containerd
OverlayFS
Next →

Container Internals: What a Container Really Is

When you run kubectl apply, a YAML file becomes a running process on a node. That process started from bytes stored in a registry. Understanding every step between those two points — image pull, layer assembly, snapshot creation, pod sandbox, namespace join — is what separates someone who uses containers from someone who can debug them at any layer of the stack.


The Big Picture

Registry (image bytes)
       │
       │ 1. Manifest fetch + layer pull
       ▼
containerd image store
       │
       │ 2. Snapshot (OverlayFS assembly)
       ▼
OCI bundle (rootfs + config.json)
       │
       │ 3. Pod sandbox (pause container)
       ▼
Network namespace + IP assigned
       │
       │ 4. Container joins sandbox
       ▼
runc creates namespaces, cgroups, pivot_root
       │
       │ 5. exec()
       ▼
Your process running as PID 1 inside the container

Step 1: What an Image Actually Is

An OCI image is not a single file. It is three things stored in a content-addressable store:

The Manifest

The manifest is the index card — it says what layers make up the image and where to find the config.

{
  "schemaVersion": 2,
  "mediaType": "application/vnd.oci.image.manifest.v1+json",
  "config": {
    "mediaType": "application/vnd.oci.image.config.v1+json",
    "digest": "sha256:a1b2c3...",
    "size": 7023
  },
  "layers": [
    {
      "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
      "digest": "sha256:d4e5f6...",
      "size": 31379766
    },
    {
      "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
      "digest": "sha256:a7b8c9...",
      "size": 25165824
    }
  ]
}

The Config

The config is the runtime metadata — what command to run, what env vars to set, what the working directory is, what user to run as:

{
  "architecture": "amd64",
  "os": "linux",
  "config": {
    "Env": ["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"],
    "Cmd": ["nginx", "-g", "daemon off;"],
    "WorkingDir": "/",
    "User": "",
    "ExposedPorts": {"80/tcp": {}}
  },
  "rootfs": {
    "type": "layers",
    "diff_ids": [
      "sha256:layer1-uncompressed-digest...",
      "sha256:layer2-uncompressed-digest..."
    ]
  },
  "history": [...]
}

The Layers

Each layer is a gzip-compressed tar archive containing the filesystem diff for that build step. A layer can add files (new path), modify files (whiteout + new version), or delete files (.wh. whiteout marker).

# A whiteout file tells OverlayFS to hide the file from lower layers
.wh.etc/passwd        # deletes etc/passwd from the merged view
.wh..wh..opq          # opaque whiteout — hides entire directory from lower layers

Step 2: Content-Addressable Storage

Every layer, config, and manifest is identified by its SHA-256 digest. The digest is a hash of the content itself — change one byte, get a completely different digest.

This means:

Tag → Manifest digest → Config digest + Layer digests

Tags are mutable pointers. Digests are immutable.

# Pull by digest — always the exact same bytes
docker pull nginx@sha256:1234abcd...

# Pull by tag — could be different bytes tomorrow
docker pull nginx:1.25

# See the digest of a local image
docker images --digests nginx
# nginx  1.25  sha256:1234abcd...  2 weeks ago  187MB
# On disk, containerd stores everything under its content store
ls /var/lib/containerd/io.containerd.content.v1.content/blobs/sha256/
# d4e5f6...  ← layer 1 compressed tarball
# a7b8c9...  ← layer 2 compressed tarball
# a1b2c3...  ← config JSON

Two images sharing a base layer reference the exact same bytes on disk — no duplication.


Step 3: Registry Pull Flow

When a node needs an image it doesn't have, kubelet tells containerd to pull it:

Kubelet
  │  CRI: PullImage(nginx:1.25)
  ▼
containerd
  │
  ├─ 1. Resolve tag → registry API: GET /v2/nginx/manifests/1.25
  │     Headers: Authorization: Bearer <token>
  │     Response: manifest JSON + digest header
  │
  ├─ 2. Check local content store for each layer digest
  │     → Layer already present? Skip. Missing? Download.
  │
  ├─ 3. For each missing layer:
  │     GET /v2/nginx/blobs/sha256:<digest>
  │     Stream to /var/lib/containerd/.../ingest/<random>
  │     Verify: sha256(downloaded bytes) == digest from manifest
  │     Move to content store on success
  │
  └─ 4. Unpack config JSON, store in metadata DB (bbolt)
# Watch a pull happen with verbose output
containerd --log-level debug &
ctr images pull docker.io/library/nginx:1.25

# Or via crictl on a Kubernetes node
crictl pull nginx:1.25

Layer Deduplication at Pull Time

# Pull nginx — downloads 3 layers
ctr images pull nginx:1.25
# layer 1: sha256:abc...  31MB  pulled
# layer 2: sha256:def...  25MB  pulled
# layer 3: sha256:ghi...  1MB   pulled

# Pull nginx patch version — only pulls the changed layer
ctr images pull nginx:1.26
# layer 1: sha256:abc...  31MB  exists ← skipped, same bytes
# layer 2: sha256:def...  25MB  exists ← skipped
# layer 3: sha256:xyz...  1MB   pulled ← only this changed

Step 4: Snapshots — Assembling the Filesystem

containerd does not directly mount OverlayFS. It uses a snapshotter — a pluggable interface for managing the filesystem state of a container.

The default snapshotter on Linux is overlayfs.

Snapshot Chain

Each image layer becomes a snapshot — an immutable point-in-time view of the filesystem up to that layer:

Snapshot 0: ubuntu base layer     ← lowerdir[2]
     │
     ▼
Snapshot 1: + python installed    ← lowerdir[1]
     │
     ▼
Snapshot 2: + app code copied     ← lowerdir[0]
     │
     ▼
Active Snapshot (container)       ← upperdir (read-write)
# See snapshots on a node
ctr snapshots ls
# KEY                               PARENT          KIND
# sha256:abc...                                     Committed   ← layer 0
# sha256:def...        sha256:abc...                Committed   ← layer 1
# sha256:ghi...        sha256:def...                Committed   ← layer 2
# my-container-active  sha256:ghi...                Active      ← rw layer

The OverlayFS Mount

When a container starts, containerd assembles the snapshot chain into an OverlayFS mount:

mount -t overlay overlay \
  -o lowerdir=/snapshots/ghi/fs:/snapshots/def/fs:/snapshots/abc/fs,\
     upperdir=/snapshots/active/fs,\
     workdir=/snapshots/active/work \
  /run/containerd/io.containerd.runtime.v2.task/<id>/rootfs
What the container process sees at /
│
├── /bin, /lib, /usr          ← from ubuntu layer (lowerdir[2])
├── /usr/local/lib/python3.11 ← from python layer (lowerdir[1])
├── /app/                     ← from app layer (lowerdir[0])
└── /tmp/, /var/log/          ← writes go here (upperdir), ephemeral

Every read from a lower layer is zero-copy — the kernel serves it directly from the read-only snapshot. Every write triggers a copy-up to upperdir first.


Step 5: OCI Bundle — The Runtime's Input

Before calling runc, containerd produces an OCI bundle: a directory with two things:

/run/containerd/io.containerd.runtime.v2.task/<pod>/<container>/
├── rootfs/          ← the OverlayFS merged mount (the container's filesystem)
└── config.json      ← OCI Runtime Spec (namespaces, cgroups, mounts, process)

The config.json is generated by containerd from:

  • The image config (env, cmd, user, working dir)
  • The pod spec (resource limits, security context, volume mounts)
  • The pod sandbox namespaces (NET, IPC, UTS to join)
{
  "ociVersion": "1.0.2",
  "process": {
    "user": {"uid": 1000, "gid": 1000},
    "args": ["nginx", "-g", "daemon off;"],
    "env": ["PATH=/usr/local/sbin:..."],
    "cwd": "/"
  },
  "root": {"path": "rootfs", "readonly": false},
  "namespaces": [
    {"type": "pid"},
    {"type": "mount"},
    {"type": "network", "path": "/proc/12345/ns/net"}  ← join pause container's NET ns
  ],
  "linux": {
    "resources": {
      "memory": {"limit": 536870912},
      "cpu": {"quota": 200000, "period": 100000}
    },
    "seccomp": {...},
    "maskedPaths": ["/proc/acpi", "/sys/firmware"]
  }
}

Step 6: Pod Sandbox — The Pause Container

A pod is not a container. A pod is a shared execution environment — a set of namespaces that multiple containers all join. The holder of those namespaces is the pause container.

# See the pause container on any node
crictl ps | grep pause
# <id>   registry.k8s.io/pause:3.9   12h   Running   POD  my-pod

What the Pause Container Does

kubelet creates pod sandbox
       │
       ▼
containerd pulls pause:3.9 (if not cached)
       │
       ▼
runc starts pause container with:
  - New NET namespace  → gets veth pair, assigned pod IP by CNI
  - New IPC namespace  → SysV IPC and POSIX shared memory scope
  - New UTS namespace  → pod hostname
       │
       ▼
pause process runs: for(;;) pause();   ← does nothing, holds namespaces open
       │
       ▼
CNI plugin wires pod IP into the NET namespace

The pause container runs an infinite loop doing nothing (pause() syscall). Its only job is to keep the namespaces alive. If an app container crashes and restarts, the pod IP is preserved because the NET namespace belongs to the pause container, not to the app container.

# Confirm: pause container holds the NET namespace
PAUSE_PID=$(crictl inspect <pause-id> | jq -r '.info.pid')
ls -la /proc/$PAUSE_PID/ns/net
# lrwxrwxrwx ... net -> net:[4026532300]

APP_PID=$(crictl inspect <app-id> | jq -r '.info.pid')
ls -la /proc/$APP_PID/ns/net
# lrwxrwxrwx ... net -> net:[4026532300]   ← same inode — same namespace

Step 7: runc Creates the Container

containerd hands the OCI bundle to containerd-shim-runc-v2, which calls runc:

runc create <container-id> --bundle /path/to/bundle
       │
       ├─ 1. Read config.json
       │
       ├─ 2. clone() — create new process in new namespaces
       │     CLONE_NEWPID  → new PID namespace (container gets PID 1)
       │     CLONE_NEWNS   → new MNT namespace
       │     (NET/IPC/UTS: join pause container's ns via "path" in config.json)
       │
       ├─ 3. Set up cgroup — write limits to /sys/fs/cgroup/...
       │     memory.max  = limits.memory
       │     cpu.max     = limits.cpu quota/period
       │
       ├─ 4. pivot_root — make the OverlayFS rootfs the new / inside MNT ns
       │
       ├─ 5. Mount special filesystems inside the new root:
       │     /proc  → new procfs (scoped to container's PID namespace)
       │     /sys   → sysfs (partially masked)
       │     /dev   → devtmpfs (minimal device set)
       │     /etc/resolv.conf → bind mount from kubelet
       │     /var/run/secrets/kubernetes.io/serviceaccount → projected volume
       │
       ├─ 6. Drop capabilities to configured set
       │     Apply seccomp filter
       │     Apply AppArmor profile
       │
       └─ 7. exec() → replace runc with nginx process
              Now: nginx is PID 1 inside its own PID namespace
              Sees: only OverlayFS filesystem, only pod's network, only its cgroup

Step 8: End-to-End — kubectl apply to Running Process

User: kubectl apply -f deployment.yaml
         │
         ▼
1.  API Server validates and writes Pod spec to etcd

2.  Scheduler watches for unscheduled pods
    → Runs filter + score → assigns node
    → Writes pod.spec.nodeName to etcd

3.  Kubelet on that node watches for pods assigned to it
    → Admits the pod (resource fits allocatable)

4.  Kubelet calls containerd CRI: RunPodSandbox()
    → containerd starts pause container
    → CNI plugin creates veth pair, assigns pod IP

5.  Kubelet calls containerd CRI: PullImage() for each container
    → containerd checks local content store
    → Downloads missing layers, verifies digests
    → Creates snapshot chain (OverlayFS)

6.  Kubelet calls containerd CRI: CreateContainer()
    → containerd generates OCI config.json
    → config.json references pause container namespaces

7.  Kubelet calls containerd CRI: StartContainer()
    → containerd-shim calls runc
    → runc: clone(), cgroup setup, pivot_root, exec()
    → nginx starts as PID 1 inside the container

8.  Kubelet runs liveness/readiness probes
    → On success: sets pod.status = Running
    → API Server writes status to etcd
    → kubectl get pods shows Running

Image Layer to Filesystem: What You Actually See

# Inspect the layer structure of a local image
docker inspect python:3.11-slim | jq '.[0].RootFS.Layers'
# [
#   "sha256:layer1...",   ← debian:bookworm-slim base
#   "sha256:layer2...",   ← apt-get install python3
#   "sha256:layer3...",   ← pip defaults
#   "sha256:layer4..."    ← python runtime setup
# ]

# Walk the actual OverlayFS mount for a running container
CONTAINER_ID=$(docker ps -q --filter name=myapp)
OVERLAY=$(docker inspect $CONTAINER_ID | jq -r '.[0].GraphDriver.Data.MergedDir')
ls $OVERLAY
# bin  dev  etc  home  lib  proc  root  sys  tmp  usr  var  app
# See what a container has written to its rw layer
UPPER=$(docker inspect $CONTAINER_ID | jq -r '.[0].GraphDriver.Data.UpperDir')
find $UPPER -type f 2>/dev/null
# ./var/log/nginx/access.log    ← nginx wrote this
# ./tmp/somefile                ← app wrote this
# → both disappear on container removal

Image Size vs Runtime Size

Metric Meaning How to check
Image size Sum of all compressed layer tarballs docker images nginx
Virtual size Sum of all uncompressed layers (what OverlayFS exposes) docker images --format "{{.VirtualSize}}"
Container size Bytes written to upperdir (rw layer only) docker ps -s
Shared layers Bytes shared with other containers (from lowerdir) docker system df -v
# How much disk is really being used?
docker system df
# TYPE            TOTAL    ACTIVE   SIZE      RECLAIMABLE
# Images          12       3        4.2GB     2.8GB (66% reclaimable)
# Containers      3        3        128MB     0B
# Local Volumes   5        2        9.1GB     5.2GB

# Remove dangling layers (untagged, unreferenced)
docker image prune

Linux Concepts: Image to Container

Stage Linux Mechanism What Happens
Layer storage Content-addressable filesystem, SHA-256 Each layer stored once, identified by digest
Layer download HTTP chunked transfer, splice(2) Stream from registry to disk, verify digest
Layer extraction tar + gzip, write(2) Decompress tarball into snapshot directory
Snapshot chain Hardlinks + directory tree Each layer's snapshot points to parent
Filesystem assembly OverlayFS mount() Stacks snapshots into unified rootfs view
Copy-on-Write Page fault → copy-up Modified file copied from lowerdir to upperdir
Whiteout Special .wh. files OverlayFS hides deleted files from lower layers
Namespace creation `clone(CLONE_NEWPID CLONE_NEWNS)`
Namespace joining setns(fd) with /proc/<pid>/ns/net Container joins pause container's NET namespace
Root change pivot_root(new_root, put_old) Container's / becomes the OverlayFS merged dir
Resource limits Write to /sys/fs/cgroup/ CPU quota and memory max applied before exec
Process start exec(path, argv, envp) runc replaced by container binary, becomes PID 1
Pod IP Veth pair + bridge/VXLAN + CNI pause container NET ns gets an IP from the CNI plugin

🧠 Interview Questions

Q: Two containers in the same pod share a network. How is this implemented at the kernel level? Both containers have a "network" entry in their OCI config.json pointing to /proc/<pause-pid>/ns/net. When runc calls setns(fd, CLONE_NEWNET) for each app container, they all join the same network namespace inode. From the kernel's perspective, they are the same network stack — same eth0, same IP, same routing table, same loopback.

Q: If I delete a file inside a container, does it free up disk space? No — if the file was in a lower image layer. Deleting it writes a .wh.<filename> whiteout marker to the upperdir. The original bytes in the lowerdir are still on disk. Disk is only freed when the image itself is removed (docker rmi). The whiteout actually costs a tiny amount of space.

Q: I have 20 containers all running the same nginx:1.25 image. How much disk do the image layers use? Exactly the same as one container — the layers are shared. All 20 containers share the same lowerdir snapshots. Each container has its own upperdir (written bytes only). docker system df shows shared vs unique bytes.

Q: A container restarts due to OOMKill. Does it get a new IP? No. The IP belongs to the pause container's NET namespace, which was not restarted. The app container process died, runc re-creates it, and it rejoins the same NET namespace via setns(). The pod IP is stable across container restarts — only pod deletion and recreation triggers a new IP.

Q: What is the difference between docker commit and a Dockerfile layer? Both produce a new image layer. docker commit snapshots the container's current upperdir into a new read-only layer appended to the image. A Dockerfile RUN instruction does the same thing automatically for each build step. The result is identical OCI layers — docker commit is just manual layer creation. Both approaches produce layers with the same OverlayFS representation.

Q: How does Kubernetes know if an image pull failed vs a container crash? Image pull failures surface as ErrImagePull → ImagePullBackOff in pod.status.containerStatuses[].state.waiting.reason. Container crashes appear as CrashLoopBackOff in the same field with exitCode populated. The distinction: image pull is a CRI PullImage failure; crash is a post-StartContainer process exit. kubectl describe pod shows Events with the exact error at each stage.


Related Posts

  • Container Internals — Linux namespaces, cgroups, OverlayFS, and OCI spec in detail
  • Kubernetes Pod Internals — pod lifecycle, pause container, and init containers
  • Kubernetes Resource Allocation — how requests and limits map to cgroup values
  • GPU Scheduling in Kubernetes — how nvidia-container-toolkit injects GPU devices at container start
Hitesh Sahu
Written by Hitesh Sahu, a passionate developer and blogger.

Fri Jul 24 2026

Share This on

Next →

Container Internals: What a Container Really Is

kubernetes/1-2-Image-Internals
Let's work together
+49 176-2019-2523
hiteshkrsahu@gmail.com
WhatsApp
Skype
Munich 🥨, Germany 🇩🇪, EU
Playstore
Hitesh Sahu's apps on Google Play Store
Need Help?
Let's Connect
Navigation
  Home/About
  Skills
  Work/Projects
  Lab/Experiments
  Contribution
  Awards
  Art/Sketches
  Thoughts
  Contact
Links
  Sitemap
  Legal Notice
  Privacy Policy

Made with

NextJS logo

NextJS by

hitesh Sahu

| © 2026 All rights reserved.