Runtime Internals: cgroups, runc, and Where containerd Fits
What actually isolates and limits a container, from the kernel feature up to the daemon Kubernetes talks to.
“Container” describes a set of Linux kernel features wired together, not a kernel object of its own. This guide walks the stack from the bottom: the kernel feature that limits resources, the tool that assembles a container from it, and the daemon that manages that tool at scale.
cgroups: resource control at the kernel level
Control groups (cgroups) are a Linux kernel feature, dating back to 2008, for allocating and limiting system resources, CPU, memory, I/O, among a set of processes. A resource type under this system is called a controller (or subsystem), and the processes governed by it are organized into a hierarchy that mirrors a filesystem: a cgroup is a directory, and a process joins it by writing its PID into that directory.
Two rules shape how cgroups compose:
- A single hierarchy can have multiple controllers attached to it, but a given controller (CPU, say) can only be attached to one hierarchy at a time.
- A process belongs to exactly one cgroup within a given hierarchy. Moving it to a different cgroup in that same hierarchy removes it from the old one; it’s never in two places in the same hierarchy simultaneously.
cgroups exist in two versions, v1 and v2, differing mainly in how hierarchies and controllers relate to each other; v2 unifies controllers under a single hierarchy rather than allowing the more flexible (and more complex) arrangement v1 permitted. Modern distributions and Kubernetes default to v2.
On their own, cgroups only manage resource limits. They say nothing about filesystems, image formats, or namespacing a process’s view of the network or process tree, all of which are what actually make something feel like an isolated container rather than just a resource-limited one.
runc: assembling a container from kernel primitives
runc is the reference implementation of the OCI runtime spec: a low-level tool whose entire job is taking a filesystem and a configuration file and turning them into a running, isolated process. It needs two things to run a container:
- A root filesystem (
rootfs): the container’s filesystem contents, extracted from an image. - A config.json: generated with
runc spec, describing everything about how the process should run, Linux capabilities, mount points, which namespaces to create or share, resource limits to apply via cgroups.
mkdir my-container && cd my-container
mkdir rootfs
# populate rootfs from an image's exported filesystem layers
runc spec
runc run my-container-id
If you’ve written a Kubernetes Pod spec, config.json’s shape will look familiar: capabilities, mounts, and namespace configuration are the same underlying concepts Kubernetes exposes through its own API, just one layer closer to the kernel. runc is intentionally narrow: it starts and stops one container’s process according to that config. It has no concept of pulling images, of a long-running daemon, or of talking to Kubernetes at all.
containerd: managing runc at scale
containerd is the daemon that does everything around runc: pulling and unpacking images, managing the storage snapshots those images are extracted into, tracking running containers, and exposing a gRPC API that both direct clients and Kubernetes (via CRI) can use. It is the component the kubelet actually talks to.
containerd doesn’t run your container’s process directly, it delegates that to a shim, a small long-lived process that sits between containerd and the container’s actual process. The shim’s job is to keep the container running independently of containerd itself, so that restarting or upgrading the containerd daemon doesn’t kill every running container on the node. containerd 1.x shipped with three shim implementations; containerd 2.0 removed the two oldest, leaving runc-based shim v2 as the only one, which had been the default for years already.
kubelet ── CRI (gRPC) ──▶ containerd ── shim ──▶ runc ──▶ container process
│
└── manages images, snapshots, storage
Because this is a layered architecture, containerd isn’t locked to runc specifically. A shim can front an entirely different execution engine, gVisor for stronger sandboxing, Kata Containers for VM-level isolation, and so on, while containerd’s image management, CRI implementation, and everything Kubernetes-facing stays the same.
Putting the whole path together
A Pod’s container, end to end: the kubelet sends a CRI request to containerd. containerd pulls the image if it isn’t already present, unpacks it into a snapshot on the node’s filesystem, and hands off to a shim. The shim invokes runc with a generated config.json pointing at that snapshot as the rootfs. runc creates the Linux namespaces, applies the cgroup limits from the Pod’s resource requests and limits, and starts the process. From that point, the shim supervises the running process and reports its status back up through containerd to the kubelet, which is what kubectl get pods is ultimately reading.
Each layer solves one problem: cgroups limit resources, runc assembles an OCI-compliant container from a rootfs and a config, shims keep that container supervised independently of the daemon, and containerd orchestrates all of it and exposes the API Kubernetes needs. None of these pieces do each other’s job, which is exactly what makes it possible to swap runc for a different low-level runtime without touching anything above the shim layer.