What is an OCI bundle? Understanding config.json and container runtimes
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:
| Specification | Describes | Consumed by |
|---|---|---|
| Image spec | The distributable artifact: layers, manifest, config, index | containerd, CRI-O, Podman |
| Runtime spec | The bundle: a rootfs directory plus config.json | runc, crun, runsc, Kata |
| Distribution spec | The registry HTTP API for push and pull | GHCR, 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:
| Set | Meaning |
|---|---|
bounding | The ceiling — nothing outside this can ever be acquired |
permitted | What the process may enable |
effective | What is active for permission checks right now |
inheritable | Preserved across execve() of a file with matching bits |
ambient | Preserved 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:
| Operation | What happens |
|---|---|
create | Namespaces and cgroups are set up, the rootfs is pivoted into, and the process is started but held before execve |
start | The held process is released and execve runs process.args |
state | Report the container's status and PID |
kill | Deliver a signal to the container process |
delete | Tear 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
- Docker vs containerd vs runc — who produces this bundle and who consumes it.
- Kubernetes security basics — the
securityContextfields that map onto these sections. - The OCI runtime specification itself — genuinely readable, and shorter than you expect.