Skip to main content

What is an OCI bundle? Understanding config.json and container runtimes

· 7 min read
Maintainer, NativeHub

Container images get all the attention, but the thing a low-level runtime actually consumes is not an image. It is a bundle: a directory on disk containing an extracted root filesystem and a single JSON file. Understanding that file is the clearest possible explanation of what a container is.

Image and bundle are different things

The Open Container Initiative publishes three separate specifications, and it is worth being precise about which does what:

SpecificationDescribesConsumed by
Image specThe distributable artifact: layers, manifest, config, indexcontainerd, CRI-O, Podman
Runtime specThe bundle: a rootfs directory plus config.jsonrunc, crun, runsc, Kata
Distribution specThe registry HTTP API for push and pullGHCR, ECR, Docker Hub, Harbor

The step between them is the one people miss. A container manager pulls an image, unpacks its layers into a single directory, merges the image's config with any runtime overrides you supplied, and writes the result as config.json. Only then does it hand the directory to a runtime.

image (registry) → unpack layers + merge config → bundle (on disk) → runc → process

An image can produce many bundles; a bundle is a single container's complete instructions.

Making one by hand

This is the fastest way to make the abstraction real. It takes about a minute on any Linux host with Docker and runc installed.

mkdir -p /tmp/bundle/rootfs && cd /tmp/bundle

# 1. Get a root filesystem. `docker export` flattens an image's layers into a tar.
docker export "$(docker create alpine:3.21)" | tar -C rootfs -xf -

# 2. Generate a default config.json
runc spec

ls -la
# config.json rootfs/

That is the entire bundle. Two entries:

  • rootfs/ — the filesystem the container will see as /.
  • config.json — everything else.
sudo runc run demo
# / # cat /etc/os-release

No image, no daemon, no registry. A directory and a JSON file.

Inside config.json

The spec version generated by runc spec is long but every section has a clear purpose. Here is an annotated tour of the parts that matter.

ociVersion and process

{
"ociVersion": "1.2.0",
"process": {
"terminal": true,
"user": { "uid": 0, "gid": 0, "additionalGids": [] },
"args": ["sh"],
"env": ["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", "TERM=xterm"],
"cwd": "/",
"noNewPrivileges": true,
"rlimits": [{ "type": "RLIMIT_NOFILE", "hard": 1024, "soft": 1024 }]
}
}

process.args is the container's PID 1 — this is where a Dockerfile's ENTRYPOINT and CMD end up after being concatenated. noNewPrivileges sets the kernel's no_new_privs bit, which is exactly what Kubernetes' allowPrivilegeEscalation: false produces.

capabilities

"capabilities": {
"bounding": ["CAP_CHOWN", "CAP_NET_BIND_SERVICE", "CAP_KILL"],
"effective": ["CAP_CHOWN", "CAP_NET_BIND_SERVICE", "CAP_KILL"],
"permitted": ["CAP_CHOWN", "CAP_NET_BIND_SERVICE", "CAP_KILL"],
"inheritable": [],
"ambient": []
}

Linux capabilities split root's power into ~40 separate privileges. A "root" process in a container is normally root with most of them removed — which is why containerised root cannot load kernel modules (CAP_SYS_MODULE) or change the system clock (CAP_SYS_TIME).

The five sets are not interchangeable:

SetMeaning
boundingThe ceiling — nothing outside this can ever be acquired
permittedWhat the process may enable
effectiveWhat is active for permission checks right now
inheritablePreserved across execve() of a file with matching bits
ambientPreserved across execve() of an unprivileged binary

Kubernetes' capabilities.drop: ["ALL"] empties all of these. CAP_NET_BIND_SERVICE is the one people add back, to bind port 80 as a non-root user.

root and hostname

"root": {
"path": "rootfs",
"readonly": false
},
"hostname": "runc"

root.path is relative to the bundle directory. readonly: true is what Kubernetes' readOnlyRootFilesystem: true sets.

mounts

"mounts": [
{ "destination": "/proc", "type": "proc", "source": "proc" },
{
"destination": "/dev",
"type": "tmpfs",
"source": "tmpfs",
"options": ["nosuid", "strictatime", "mode=755", "size=65536k"]
},
{
"destination": "/sys",
"type": "sysfs",
"source": "/sys",
"options": ["nosuid", "noexec", "nodev", "ro"]
},
{
"destination": "/data",
"type": "bind",
"source": "/var/lib/myapp",
"options": ["rbind", "rw"]
}
]

Every Kubernetes volume mount, every docker -v, every ConfigMap projection ultimately becomes an entry in this array. /proc, /sys, and /dev are mounted fresh inside the container's mount namespace so it sees its own process table and device nodes rather than the host's.

linux.namespaces

"linux": {
"namespaces": [
{ "type": "pid" },
{ "type": "network" },
{ "type": "ipc" },
{ "type": "uts" },
{ "type": "mount" },
{ "type": "cgroup" }
]
}

This is the heart of it. Each entry with no path means "create a new one." An entry with a path means "join this existing namespace":

{ "type": "network", "path": "/proc/1234/ns/net" }

That single line is how a Kubernetes Pod works. Every container in a Pod joins the same network namespace — the pause container's — which is why they share an IP address and reach each other on localhost. It is also what docker run --network=container:other and kubectl debug --target produce.

linux.resources

"resources": {
"memory": { "limit": 268435456 },
"cpu": { "shares": 1024, "quota": 100000, "period": 100000 },
"pids": { "limit": 512 }
}

These are written straight into the cgroup filesystem. A Kubernetes limits.memory: 256Mi becomes memory.limit here, and exceeding it is what triggers the kernel OOM killer and the OOMKilled status you see in kubectl describe.

CPU is expressed as a quota per period: 100000/100000 is one full core, 50000/100000 is half a core, and exceeding it results in throttling rather than a kill.

linux.seccomp

"seccomp": {
"defaultAction": "SCMP_ACT_ERRNO",
"architectures": ["SCMP_ARCH_X86_64"],
"syscalls": [
{
"names": ["accept", "read", "write", "openat", "close"],
"action": "SCMP_ACT_ALLOW"
}
]
}

A syscall allow-list enforced by the kernel. The default action is to deny; listed syscalls are permitted. Docker's default profile blocks around 44 syscalls, which is what Kubernetes' seccompProfile: { type: RuntimeDefault } applies.

linux.uidMappings

"uidMappings": [
{ "containerID": 0, "hostID": 100000, "size": 65536 }
],
"gidMappings": [
{ "containerID": 0, "hostID": 100000, "size": 65536 }
]

User-namespace remapping: UID 0 inside the container is UID 100000 on the host. Root in the container is an unprivileged user outside it, so a container escape lands on a nobody account. This is what "rootless containers" means, and Kubernetes exposes it as hostUsers: false.

The lifecycle a runtime implements

The runtime spec defines operations, not just a file format. runc implements each as a subcommand:

OperationWhat happens
createNamespaces and cgroups are set up, the rootfs is pivoted into, and the process is started but held before execve
startThe held process is released and execve runs process.args
stateReport the container's status and PID
killDeliver a signal to the container process
deleteTear down the cgroup and remove state
sudo runc create demo
sudo runc state demo # "status": "created"
sudo runc start demo # now "running"
sudo runc list
sudo runc kill demo TERM
sudo runc delete demo

The split between create and start is what makes hooks possible. Between the two, a createRuntime or createContainer hook can run on the host with the container's namespaces already in place — which is precisely how a CNI plugin attaches a network interface to a container before its process ever executes.

"hooks": {
"createRuntime": [
{ "path": "/usr/local/bin/setup-network.sh", "args": ["setup-network.sh"] }
],
"poststop": [
{ "path": "/usr/local/bin/cleanup.sh", "args": ["cleanup.sh"] }
]
}

Seeing the real thing on a Kubernetes node

Every running container on a node has a bundle. On a node using containerd:

# List containers in the kubelet's namespace
sudo ctr -n k8s.io containers list

# Dump the actual OCI spec containerd generated for one
sudo ctr -n k8s.io containers info <container-id> | jq '.Spec'

Compare that output against the Pod's YAML and the mapping is direct — securityContext fields appear under process and linux, volume mounts appear in mounts, and resource limits appear under linux.resources. Every Kubernetes field you set is a translation into this document.

Why this is worth knowing

  • It demystifies containers. There is no magic: a JSON file describes namespaces, cgroups, mounts, and a process, and a runtime asks the kernel for exactly that.
  • It explains Kubernetes fields. runAsNonRoot, readOnlyRootFilesystem, capabilities.drop, seccompProfile — each one is a line in this document, and knowing which line tells you exactly what it does and does not protect.
  • It explains why runtimes are swappable. crun, gVisor, and Kata read the same config.json. They differ only in how they satisfy it — a C implementation, a user-space kernel, or a microVM.
  • It makes debugging possible. When a container behaves unexpectedly, dumping its actual spec answers the question far faster than guessing at the layers above.

Further reading