Right, so you’ve got this container image. It’s a neat little tarball with some metadata, all wrapped up according to the OCI spec. Wonderful. But a container image is not a container. It’s a blueprint. Something has to actually unpack that blueprint, wire up the kernel isolation features we talked about (the namespaces and cgroups), and run the process. That “something” is the container runtime. And this is where the landscape gets… interesting. Let’s untangle the wonderful hierarchy of tools that actually make docker run happen.

The Low-Level Runtimes: runc and crun

At the absolute bottom of the stack, doing the gritty work, are the low-level runtimes. Their job is brutally simple: take a root filesystem, a config.json file (which defines the namespaces, cgroups, mounts, and process to run), and… run it. That’s it.

The reference implementation is runc. It’s the core container runtime that Docker extracted out and donated to the Open Container Initiative (OCI). You can think of it as the executor. It has no daemon of its own; it’s just a CLI tool that creates and runs containers.

But runc is written in Go. For a minimal tool that needs to fork(2) and exec(2), that introduces a slight overhead. Enter crun, its leaner, meaner cousin written in C. It’s faster, has a smaller memory footprint, and is often the default on systems like Fedora and Ubuntu that care about performance. For most purposes, they are functionally identical and interchangeable.

Here’s the raw, unfiltered power of using runc directly. First, you need a rootfs. Let’s grab one.

# Create a simple root filesystem from a busybox image
mkdir rootfs
docker export $(docker create busybox) | tar -C rootfs -xvf -

Now, we need a config.json. runc can generate a template for us.

# Generate a default OCI runtime configuration file
runc spec

This creates a config.json file. It’s verbose, but it defines everything: which namespaces to create, the root filesystem path, the process to run (in this case, /bin/sh), and the cgroup settings. Now, we can run it.

# Run the container as a transient instance. Watch your shell prompt change.
sudo runc run my_busybox_container
# You're now inside a container. Type `exit` to leave and destroy it.

This is the absolute bedrock. But you see the problem, right? Managing images, creating rootfs directories, handling storage, and networking? Doing this manually is for masochists. That’s why we have high-level runtimes.

The High-Level Runtimes: containerd and CRI-O

These are the grown-ups in the room. High-level runtimes are daemons that manage the complete container lifecycle: downloading images, unpacking them, managing storage, handling networking, and ultimately spawning containers via a low-level runtime like runc or crun.

containerd is the industry heavyweight. It was also extracted from Docker and is now a CNCF project. It’s incredibly powerful and modular. Docker itself actually uses containerd under the hood! You can interact with it directly using the ctr command, but I must warn you—its UX is famously… sparse. It’s a tool for machines, not humans.

# Pull an image (note: the image name is... different)
sudo ctr images pull docker.io/library/nginx:latest

# Run a container from the image
sudo ctr containers create docker.io/library/nginx:latest nginx-container

# Start the task (containerd calls processes 'tasks')
sudo ctr tasks start nginx-container

See? Powerful, but not exactly user-friendly.

CRI-O is the specialist. Its entire reason for existence is to be the runtime for Kubernetes. CRI-O implements the Kubernetes Container Runtime Interface (CRI). It’s lean, focused, and designed specifically for security and performance in a K8s world. It doesn’t try to be a general-purpose tool; it’s a perfect fit for its specific job. If you’re running Kubernetes on a Red Hat or SUSE system, you’re probably using CRI-O.

How It All Fits Together: The Kubernetes Example

This is where the magic becomes clear. Let’s trace what happens when you run kubectl run nginx --image=nginx.

  1. kubectl talks to the Kubernetes API server.
  2. The API server talks to the kubelet on a worker node.
  3. The kubelet doesn’t know how to run containers. It speaks one protocol: the CRI (Container Runtime Interface).
  4. The kubelet calls the CRI implementation—either the containerd CRI plugin (which runs inside the containerd daemon) or the cri-o daemon.
  5. The high-level runtime (containerd or CRI-O) pulls the nginx image from a registry, manages it in its local storage, and creates a container configuration.
  6. The high-level runtime finally calls the low-level runtime (runc or crun) to actually execute the container.

So, the hierarchy is: kubelet (CRI) -> containerd/CRI-O (High-Level) -> runc/crun (Low-Level).

The beauty of the CRI is that it’s pluggable. Kubernetes doesn’t care if you use containerd or CRI-O; as long as something speaks the CRI protocol, it’ll work. This abstraction is why the ecosystem can innovate without being chained to a single vendor’s implementation. It’s a genuinely good design, and we should tip our hats to the architects who fought for it. The choice between containerd and CRI-O is often down to distribution defaults and personal taste—containerd has broader adoption, while CRI-O is razor-focused on Kubernetes. You can’t go wrong with either.