The Runtime Nobody Thinks About Until It Breaks
Somewhere beneath your pods, beneath kubelet, beneath all the YAML you’ve been arguing about — there’s a small binary doing the actual work of running containers. Nobody installs it by hand. Nobody talks about it at the company all-hands. It just sits there, faithfully babysitting your workloads, until one day it doesn’t, and suddenly you’re at 2 AM trying to figure out why crictl ps shows a sandbox in an unknown state.
That binary is your CRI runtime. After Kubernetes 1.24 ripped out dockershim with zero remorse, the two contenders standing in the ring are containerd and cri-o. This is the comparison nobody asked for but everyone who runs a self-built cluster eventually needs.
What Even Is CRI
Two-minute primer, then we move on.
CRI stands for Container Runtime Interface — a gRPC API that kubelet uses to talk to the container runtime. Kubelet says “start this pod.” The CRI runtime says “on it.” It handles pulling images, creating sandboxes, running containers, and reporting lifecycle events back up the chain.
One layer below CRI is runc (or crun, or kata-containers). That’s the low-level OCI runtime that actually talks to the kernel — namespaces, cgroups, the whole dance. Both containerd and cri-o are just CRI-shaped wrappers that eventually call down to runc. They’re not the thing that makes the container; they’re the thing that decides when and how to ask runc to do it.
So: kubelet → CRI socket → containerd (or cri-o) → runc → kernel
That’s the chain. Now let’s talk about the two middle pieces.
containerd
containerd started its life inside Docker — it was the part of the Docker Engine that actually managed container execution. Docker Inc. donated it to the CNCF in 2017. It graduated as a CNCF project in 2019. Today it is the default CRI runtime for pretty much every managed Kubernetes offering on the planet: EKS, AKS, GKE, k3s. It’s the safe, boring, universally-supported choice, and “safe, boring, universally-supported” is high praise in infrastructure software.
Architecture: containerd is a general-purpose container runtime. It doesn’t care that you’re running Kubernetes. It would happily run containers for you on a laptop, inside a CI system, or as the backend for a custom container orchestrator you built because you hated yourself that week. This is both a feature and the source of its larger footprint.
The core daemon exposes a CRI plugin (used by kubelet) and also a lower-level containerd API used by tools like Docker Desktop, nerdctl, and BuildKit. It has a snapshotter subsystem that supports multiple storage backends: overlayfs, btrfs, zfs, and the increasingly important lazy-loading snapshotter for things like Stargz and eStargz images.
CLIs: containerd ships with ctr (low-level, kind of unpleasant) and plays nicely with nerdctl (a Docker-compatible CLI if you want to poke at the runtime outside of Kubernetes). Inside Kubernetes you’ll mostly use crictl regardless of runtime.
Configuration lives in /etc/containerd/config.toml. The important bits:
version = 2
[plugins."io.containerd.grpc.v1.cri"] sandbox_image = "registry.k8s.io/pause:3.10"
[plugins."io.containerd.grpc.v1.cri".containerd] snapshotter = "overlayfs"
[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc] runtime_type = "io.containerd.runc.v2"
[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc.options] SystemdCgroup = trueThat SystemdCgroup = true line matters. Get it wrong and your node will have all the stability of a shopping cart with a broken wheel.
Footprint: The containerd binary is around 50–80 MB. The daemon idles at roughly 50–100 MB RSS on a lightly loaded node. With a full plugin ecosystem loaded, it’s more. Not outrageous, but it’s carrying more machinery than a Kubernetes-only runtime needs.
cri-o
cri-o was built by Red Hat from the ground up to do exactly one thing: implement the CRI spec and nothing else. No Docker API, no BuildKit, no snapshotters for lazy image pulling. Just kubelet-to-runc translation, done cleanly.
The name is a hint: CRI-O, where O stands for OCI. The whole point was to give Kubernetes a minimal, purpose-built runtime that tracks the Kubernetes release cycle instead of having its own independent release cadence.
This is the runtime that ships in OpenShift and RHCOS (Red Hat CoreOS). If you’ve used OpenShift, you’ve used cri-o, probably without noticing. That’s kind of the goal.
Architecture: cri-o sits between kubelet and runc, and that’s basically it. It handles image pulling (via containers/image), image storage (via containers/storage), and CNI network setup. It deliberately delegates image builds to other tools — that’s not its problem.
Configuration lives in /etc/crio/crio.conf (or dropins under /etc/crio/crio.conf.d/):
[crio] log_level = "info"
[crio.runtime] cgroup_manager = "systemd" default_runtime = "runc" conmon_cgroup = "pod"
[crio.runtime.runtimes.runc] runtime_path = "/usr/bin/runc" runtime_type = "oci"
[crio.image] pause_image = "registry.k8s.io/pause:3.10" pause_image_auth_file = ""
[crio.network] network_dir = "/etc/cni/net.d/" plugin_dirs = ["/opt/cni/bin/"]Release tracking: cri-o’s version numbers mirror Kubernetes versions. cri-o 1.30 is for Kubernetes 1.30. This is refreshingly sane compared to managing compatibility matrices across independent release trains.
Footprint: The cri-o binary is around 40–60 MB. Daemon RSS at idle is typically 30–60 MB — measurably leaner. On a cluster with 50 nodes this doesn’t move the needle much. On a Raspberry Pi cluster it might actually matter.
Head-to-Head
Image Management
containerd wins here and it’s not close. The snapshotters are genuinely powerful — overlayfs is rock solid, and if you’re playing with image streaming (Stargz, lazy pulling) for large ML workloads, you want containerd’s storage backend. cri-o’s image handling works fine for normal Kubernetes use, but it’s intentionally minimal. No exotic snapshotters, no lazy pulling, no party tricks.
CRI Conformance
Both pass the CRI conformance tests. cri-o arguably tracks the spec more tightly since that’s literally the only thing it does. containerd’s CRI plugin has occasionally lagged slightly behind upstream CRI changes, though this has improved significantly as containerd has matured.
Operability & Debugging
crictl works with both — it’s the go-to CLI for debugging pods at the runtime layer:
# Works the same against either runtimecrictl pscrictl podscrictl logs <container-id>crictl inspect <container-id>crictl pull nginx:alpineThe socket path differs by default:
- containerd:
/run/containerd/containerd.sock - cri-o:
/var/run/crio/crio.sock
Configure crictl to point at the right one:
runtime-endpoint: unix:///run/containerd/containerd.sockimage-endpoint: unix:///run/containerd/containerd.socktimeout: 10debug: falseLog access is similar — both write container logs to /var/log/pods/. Sandbox lifecycle debugging is slightly more verbose with cri-o (it exposes more internal state via crictl inspectp), which is either useful or alarming depending on your mood.
Distro & Installer Support
| Setup | Default Runtime |
|---|---|
| kubeadm | containerd |
| k3s | containerd (embedded) |
| RKE2 | containerd |
| OpenShift | cri-o |
| RHCOS / Fedora CoreOS | cri-o |
| EKS, AKS, GKE | containerd |
| Talos OS | containerd |
If you’re building with kubeadm, you’re getting containerd unless you go out of your way. If you’re deploying OpenShift, cri-o is already there. The choice is mostly made for you by the distribution.
Installing on a kubeadm Cluster
containerd:
apt-get install -y containerdmkdir -p /etc/containerdcontainerd config default | tee /etc/containerd/config.toml# Edit config.toml: set SystemdCgroup = truesystemctl enable --now containerdThen in your kubeadm config:
apiVersion: kubeadm.k8s.io/v1beta3kind: InitConfigurationnodeRegistration: criSocket: unix:///run/containerd/containerd.sock---apiVersion: kubelet.config.k8s.io/v1beta1kind: KubeletConfigurationcgroupDriver: systemdcri-o:
# Add the cri-o repo (version must match your k8s version)KUBERNETES_VERSION=v1.30OS=xUbuntu_22.04
echo "deb [signed-by=/usr/share/keyrings/libcontainers-archive-keyring.gpg] \ https://download.opensuse.org/repositories/devel:/kubic:/libcontainers:/stable/${OS}/ /" \ > /etc/apt/sources.list.d/devel:kubic:libcontainers:stable.list
echo "deb [signed-by=/usr/share/keyrings/libcontainers-crio-archive-keyring.gpg] \ https://download.opensuse.org/repositories/devel:/kubic:/libcontainers:/stable:/cri-o:/${KUBERNETES_VERSION}/${OS}/ /" \ > /etc/apt/sources.list.d/devel:kubic:libcontainers:stable:cri-o:${KUBERNETES_VERSION}.list
apt-get update && apt-get install -y cri-o cri-o-runcsystemctl enable --now crioThen in your kubeadm config:
apiVersion: kubeadm.k8s.io/v1beta3kind: InitConfigurationnodeRegistration: criSocket: unix:///var/run/crio/crio.sock---apiVersion: kubelet.config.k8s.io/v1beta1kind: KubeletConfigurationcgroupDriver: systemdThe cri-o install story is slightly more involved — repo setup requires version pinning that’s easy to get wrong if your distro version string doesn’t match exactly. containerd’s package is usually in the default repo. Small thing, real friction.
The Cgroup Driver Trap
Both runtimes support systemd and cgroupfs cgroup drivers. On modern Linux with systemd, you want systemd. Always. If kubelet and the runtime disagree on the cgroup driver, your node will join the cluster, look healthy, and then quietly fail to run pods under memory pressure in ways that are deeply unpleasant to debug.
Set systemd everywhere and don’t think about it again:
- containerd:
SystemdCgroup = trueinconfig.toml - cri-o:
cgroup_manager = "systemd"incrio.conf - kubelet:
cgroupDriver: systemdin KubeletConfiguration
Switching Runtimes on an Existing Cluster
You can do it. You should do it on one node at a time. You should probably not do it in production unless you have a very good reason, because the short version of the procedure is:
# On the node you're migratingkubectl drain <node> --ignore-daemonsets --delete-emptydir-data# Stop kubelet, stop old runtime, install new runtime, update criSocket in kubelet configsystemctl stop kubelet# ... install and configure new runtime ...systemctl start kubeletkubectl uncordon <node>The longer version involves confirming sandbox images match, confirming cgroup driver config is consistent, and having a rollback plan for when the node comes back up and crictl shows everything in an unknown state because you typo’d the socket path.
Don’t migrate runtimes on a live cluster on a Friday afternoon. This message brought to you by experience.
k3s Users: Close This Tab
If you’re running k3s, containerd is already embedded and managed by k3s itself. The socket is at /run/k3s/containerd/containerd.sock. You don’t install containerd separately, you don’t touch config.toml for most use cases, and you interact with it via:
k3s crictl psk3s crictl podsk3s crictl logs <id>There is no decision to make here. k3s made it for you. Go configure something else.
The Verdict
Choose containerd if you’re building your own cluster with kubeadm, running k3s, or using any managed cloud Kubernetes. It’s the default everywhere, the documentation is better, the ecosystem is richer, and when something goes wrong at 2 AM there are approximately ten times more Stack Overflow posts to guide you through it.
Choose cri-o if you’re deploying OpenShift (it’s already chosen for you), running RHCOS or Fedora CoreOS (same), or you have a specific reason to want a Kubernetes-only runtime with a smaller attack surface and a version number that matches your cluster version number. The minimal surface area argument is real — cri-o does less and therefore has less that can go wrong. It’s just that containerd’s ecosystem support is a bigger practical advantage for most home lab and self-built cluster use cases.
The philosophical difference is real: cri-o is a Kubernetes appliance, containerd is a general-purpose container runtime that happens to be very good at Kubernetes. For most people, that distinction doesn’t matter. For Red Hat, it matters a lot.
Either way, you’re still calling runc at the bottom. The containers don’t know the difference.