containerd vs CRI-O (2026): Kubernetes Runtime Decision Guide

containerd vs CRI-O (2026): Kubernetes Runtime Decision Guide

containerd vs CRI-O: The 2026 Kubernetes Runtime Decision Guide

When dockershim was removed from Kubernetes in v1.24, the question stopped being “how do I keep Docker” and became “which CRI runtime do I standardise on.” Four years later, containerd vs CRI-O is still the decision that quietly shapes your node footprint, your upgrade cadence, your attack surface, and how painful debugging gets at 3 a.m. Both are CNCF graduated projects. Both implement the Container Runtime Interface faithfully. Both delegate the actual container creation to an OCI runtime like runc or crun. Yet they were built by different communities with genuinely different philosophies, and those philosophies leak into everyday operations in ways that matter.

This guide is written for engineers who already run Kubernetes and want a defensible, evidence-based choice rather than a vendor slide. We will walk the kubelet-to-runc call path, compare architecture and scope, dig into where the two diverge in practice, and end with a decision matrix and a checklist.

What this covers: the CRI/OCI plumbing, containerd’s plugin-and-snapshotter model versus CRI-O’s minimal scope, version coupling, image handling, footprint, security posture, tooling, distro defaults, the gotchas that bite during upgrades, and a clear recommendation for each situation.

Context and Background

A Kubernetes node does not run containers directly. The kubelet speaks the Container Runtime Interface (CRI), a gRPC contract standardised so the control plane never has to know which runtime sits underneath. The runtime, in turn, does not usually create the container’s namespaces and cgroups itself; it calls a lower-level OCI runtime — historically runc, increasingly crun or a sandboxed runtime like gVisor’s runsc or Kata Containers. So there are two standard interfaces stacked on top of each other: CRI facing up toward the kubelet, and the OCI runtime spec facing down toward the kernel. containerd and CRI-O both live in the middle layer, translating CRI calls into OCI runtime invocations, managing images, and wiring up the CNI network.

Before v1.24, the kubelet shipped with an in-tree shim called dockershim that let it talk to the Docker Engine. Docker was never a CRI runtime; the shim adapted it. Removing dockershim, documented at length in the Kubernetes project’s dockershim removal FAQ, did not remove the ability to run any image you built with Docker — OCI images are portable — it simply forced clusters onto a genuine CRI implementation. The two mainstream answers are containerd and CRI-O. (Docker Engine itself uses containerd internally, which is why “we removed Docker” caused so much needless panic.)

Both projects graduated within the CNCF: containerd in 2019, CRI-O in July 2023. That graduation status matters for procurement and risk reviews because it signals independent governance, security processes, and production adoption at scale. If you are also weighing standalone container engines for local development, our companion piece on Podman vs Docker rootless containers covers that adjacent decision; here we stay strictly on the Kubernetes node runtime. The rest of this article assumes you have already committed to CRI and just need to settle the containerd vs CRI-O question for your own environment.

Architecture and Scope

Direct answer: the architecture is where containerd vs CRI-O stops being interchangeable. containerd is a general-purpose container runtime whose Kubernetes support is one plugin among many, giving it a broad ecosystem and standalone tooling; CRI-O is a purpose-built CRI runtime that does exactly what the kubelet needs and nothing else, giving it a smaller surface and tight version alignment. Neither is “better” in the abstract — the right pick depends on whether you value breadth or minimalism.

Kubelet to OCI runtime call path for containerd and CRI-O

Figure 1: Both runtimes receive CRI gRPC calls from the kubelet and end at an OCI runtime, but containerd routes through a shim while CRI-O uses a conmon monitor process.

Figure 1 long description: The kubelet connects to a CRI gRPC socket. That socket fans out to two runtimes. The containerd path goes kubelet to CRI plugin to containerd shim v2 to an OCI runtime such as runc or runsc, which starts the container process. The CRI-O path goes kubelet to the CRI-O daemon to a conmon monitor to an OCI runtime such as runc or crun, which starts its own container process. The shapes are structurally similar; the difference is the intermediate process model and the fact that the CRI plugin is one of many containerd plugins whereas CRI-O is the whole daemon.

containerd: a general-purpose runtime with CRI as a plugin

containerd predates the CRI standard. It was extracted from Docker as a reusable core for pulling images, managing snapshots, and supervising container processes, and it is embedded in far more than Kubernetes — Docker Desktop, cloud build systems, edge agents, and CI runners all use it. Kubernetes support arrives through the CRI plugin, which since containerd 2.0 runs in “sandboxed” mode by default, modelling the pod sandbox as a first-class object. This plugin architecture is the single most important thing to understand about containerd. The daemon exposes a gRPC API, and services such as the content store, the snapshotter, the runtime service, and the CRI integration are all plugins with stable identifiers like io.containerd.grpc.v1.cri.

containerd architecture with plugins snapshotters and clients

Figure 2: containerd exposes a gRPC API consumed by multiple clients and NRI plugins, with pluggable snapshotters and a shim-based runtime service.

Figure 2 long description: Multiple command-line clients — nerdctl, ctr, and crictl — and NRI plugins connect into the containerd gRPC API. The API delegates to a CRI plugin, a content store, a snapshotter service, and a runtime service. The snapshotter service can be backed by overlayfs, EROFS, or a stargz lazy-pull snapshotter. The runtime service drives a shim v2 process, which in turn invokes an OCI runtime such as runc, runsc, or Kata. The diagram emphasises that CRI is only one consumer of a broader engine.

Two containerd capabilities stand out in 2026. First, snapshotters are swappable. The default is overlayfs, but you can plug in EROFS for read-only efficiency or a lazy-pulling snapshotter such as stargz or SOCI that starts containers before the whole image is downloaded — a meaningful win for large images and fast autoscaling. Second, the Node Resource Interface (NRI) lets node-level plugins adjust container resources and security context at creation time, which is how projects layer in NUMA-aware pinning, custom device injection, or policy without forking the runtime. CDI (Container Device Interface) is enabled by default since 2.0, which matters for GPU and specialised hardware; if that is your world, our guide to Kubernetes GPU sharing with MIG, time-slicing, and MPS leans heavily on that plumbing.

nerdctl: containerd’s standalone superpower

containerd ships a low-level CLI called ctr that is deliberately unfriendly — it is a debugging tool, not a user interface. The ecosystem answer is nerdctl, a Docker-compatible CLI that talks directly to containerd. With nerdctl you get docker run-style ergonomics, Compose support, rootless mode, image building via BuildKit, and lazy-pull snapshotters, all against the same containerd instance your cluster uses. This is a genuine differentiator: the runtime under your pods is the same engine you can drive by hand for reproduction and forensics. As of the 2.x line, nerdctl maintains compatibility across containerd releases from 1.6 through 2.1, so the tooling does not force a lockstep upgrade.

CRI-O: purpose-built for Kubernetes, and only Kubernetes

CRI-O was born in the Kubernetes incubator in 2016 as Red Hat’s answer to the question “what is the minimum runtime a kubelet needs.” It does not try to be a general container engine. There is no standalone build system, no Compose, no ambition to run outside a Kubernetes node. It implements the CRI, manages images and storage through the containers/image and containers/storage libraries (the same libraries behind Podman and Buildah), sets up networking through CNI, and supervises each container with a small monitor process called conmon (now increasingly conmon-rs, a Rust rewrite). That is the whole scope.

containerd broad platform scope versus CRI-O minimal surface

Figure 3: containerd serves Kubernetes plus standalone tooling, image building, and embedding, while CRI-O deliberately serves only the Kubernetes CRI path.

Figure 3 long description: On the left, a box labelled containerd broad platform contains four responsibilities: Kubernetes CRI, nerdctl standalone engine, image build and push, and non-Kubernetes embedding in applications. On the right, a box labelled CRI-O minimal surface contains only two items: Kubernetes CRI, and crictl for debugging. A kubelet node at the bottom connects into both the containerd CRI entry and the CRI-O CRI entry, showing that from the kubelet’s perspective the two are interchangeable while their surrounding scope differs sharply.

The philosophical point is that CRI-O treats “small” as a feature. Fewer moving parts means fewer things to patch, fewer configuration knobs to misconfigure, and a tighter mapping between the runtime version and the Kubernetes version it was tested against. Debugging is done with crictl, the CRI-generic client that also works against containerd, so operators moving between the two keep the same low-level toolbelt.

The kubelet-to-OCI-runtime call path

Both runtimes end in the same place. The kubelet issues RunPodSandbox, CreateContainer, and StartContainer gRPC calls. containerd’s CRI plugin turns those into content-store pulls, snapshotter mounts, and a shim v2 process that execs runc (or runsc, or Kata). CRI-O turns the same calls into containers/storage mounts and a conmon-supervised runc (or crun) invocation. The upshot is that from the pod’s perspective — and from the perspective of every image, seccomp profile, and CNI plugin — the two are behaviourally interchangeable at the CRI boundary. The differences are in the surrounding machinery, not in what the container ultimately becomes.

One nuance worth flagging is the OCI runtime beneath each. Historically both defaulted to runc, the Go implementation maintained under the OCI umbrella. The Red Hat ecosystem, and therefore CRI-O in many deployments, increasingly favours crun, a C implementation that starts faster and uses less memory per container — differences that compound on dense nodes running thousands of short-lived pods. containerd defaults to runc but is equally happy driving crun, runsc, or Kata through its shim, selected per RuntimeClass. So even the “lowest” layer is a choice rather than a fixed property of the runtime, and you can converge both runtimes on the same OCI backend if you want identical container semantics across a heterogeneous fleet.

Where They Differ in Practice

Direct answer: in day-to-day operations, containerd vs CRI-O comes down to five axes — version coupling, image and snapshotter flexibility, footprint, security defaults, and ecosystem breadth. containerd wins on flexibility and tooling; CRI-O wins on version predictability and minimalism.

CRI conformance and Kubernetes version tracking

Both runtimes are CRI-conformant and pass the upstream conformance suite, so a passing conformance badge tells you nothing decisive. The meaningful difference is version coupling. CRI-O versions its releases to match the Kubernetes minor version: CRI-O 1.33 targets Kubernetes 1.33, 1.34 targets 1.34, and so on. That makes the compatibility question trivial to reason about and is a genuine operational comfort — you upgrade CRI-O in lockstep with the cluster and you know exactly what was tested together. (In practice the community has debated letting the numbers drift, and there are open issues about divergence, so treat “always identical” as the intent rather than an eternal guarantee.)

containerd decouples entirely. It follows its own time-based release cadence — 2.0 landed in late 2024, 2.1 in May 2025 as the first time-based release, and from 2.3 in April 2026 the project moved to a four-month cadence with releases in April, August, and December. A single containerd release supports a range of Kubernetes versions, so you upgrade the two independently. That is more flexible but shifts the burden onto you to check the support matrix rather than reading it off the version number.

Decision tree for choosing containerd or CRI-O

Figure 4: A short decision tree that routes most teams to containerd and Red Hat or minimal-surface teams to CRI-O.

Figure 4 long description: Starting from “choosing a CRI runtime,” the first decision asks whether you are on OpenShift or a Red Hat stack; if yes, use CRI-O. If no, ask whether you need nerdctl or non-Kubernetes workloads; if yes, use containerd. If no, ask whether you want a minimal surface pinned to Kubernetes; if yes, use CRI-O, otherwise use containerd. The tree biases toward containerd as the general default while giving CRI-O the OpenShift and minimalism branches.

Image handling and snapshotters

This is where the architectural difference becomes concrete. containerd’s swappable snapshotter model means you can adopt lazy pulling (stargz, SOCI) to cut cold-start latency on large images, or EROFS for compact read-only layers, without changing runtimes. CRI-O uses containers/storage with overlay as the standard driver and does not expose the same pluggable lazy-pull ecosystem; its storage story is deliberately more fixed. For most workloads the difference is invisible. For teams running multi-gigabyte ML images or aggressive autoscaling where seconds of pull time matter, containerd’s snapshotter flexibility is a real advantage. Both share the same registry auth, image GC, and OCI image format, so image portability is never the issue.

Footprint and resource use

CRI-O’s smaller scope generally translates to a slightly smaller resident footprint and fewer processes on an idle node, which is part of its appeal for high-density and edge nodes. containerd’s footprint is modest too — this is not Docker Engine — but it carries more surface because it is a general platform. Any headline latency or memory numbers you see online should be treated as illustrative: results swing wildly with kernel version, snapshotter, cgroup driver, and workload shape, and vendor benchmarks rarely control for all of them. Benchmark your own images on your own kernels before quoting a number. The honest summary is that both are efficient enough that runtime overhead is almost never the bottleneck; your bottleneck is the workload, and choosing between containerd vs CRI-O on raw efficiency alone is optimising the wrong variable.

Security posture

Both runtimes support the modern Kubernetes security primitives: seccomp (CRI-O pioneered fetching seccomp profiles from OCI registries as artifacts), AppArmor/SELinux, read-only rootfs, and user namespaces. Since Kubernetes 1.33 made user namespaces easier to adopt — set hostUsers: false on the pod spec — both containerd 2.x and recent CRI-O support the feature given a capable kernel, and it is one of the strongest available mitigations against container-escape classes like CVE-2022-0492. The cri-o-vs-containerd-security debate usually comes down to attack surface: CRI-O’s minimal scope means fewer code paths and dependencies to audit and patch, which is why security-conscious and regulated shops, and the whole OpenShift base, lean toward it. containerd counters with the ability to drop in a sandboxed OCI runtime (gVisor’s runsc, Kata Containers) per RuntimeClass, giving you stronger isolation exactly where you need it. Both support rootless operation, though rootless is far more common for standalone nerdctl than for production cluster nodes.

Ecosystem, tooling, and distro defaults

This is the least ambiguous axis. containerd is the default runtime on essentially every managed Kubernetes service — EKS, GKE, and AKS all ship it — and on most on-prem distributions. CRI-O is the default on OpenShift and Red Hat’s Kubernetes stack, and appears in some hardened and edge distributions. For day-to-day debugging both answer to crictl; only containerd additionally gives you nerdctl and ctr. If your platform team lives across many clouds, the gravity of containerd’s ubiquity is hard to overstate: the runtime your engineers already know is probably containerd.

Here is the head-to-head:

Dimension containerd CRI-O
Scope General-purpose engine; CRI is one plugin Purpose-built CRI runtime for Kubernetes only
K8s version coupling Decoupled; time-based releases support a K8s range Versioned to match the Kubernetes minor (1.33 to 1.33)
Image / snapshotter Swappable snapshotters: overlayfs, EROFS, stargz/SOCI lazy pull containers/storage with overlay; fixed, minimal
Footprint Modest; broader surface Slightly leaner; fewer processes on idle nodes
Security seccomp, userns, sandbox runtimes via RuntimeClass seccomp (OCI-artifact profiles), userns, minimal attack surface
Tooling crictl + nerdctl + ctr crictl only
Non-K8s use Yes — Docker, edge, CI, embedding No — Kubernetes nodes only
Distro adoption EKS, GKE, AKS, most on-prem OpenShift, Red Hat stack, some edge
Maturity CNCF graduated 2019; extremely widespread CNCF graduated 2023; production-proven

Trade-offs, Gotchas, and What Goes Wrong

Direct answer: most runtime pain is self-inflicted through version drift and stale config, not through runtime bugs. Know the three or four traps and upgrades stay boring.

The classic CRI-O gotcha is version pinning discipline. Because CRI-O tracks the Kubernetes minor version, an out-of-band node OS update that bumps CRI-O ahead of your control plane can create a mismatch — and real regressions have shipped between adjacent minors (for example, a documented break where systemd-in-container pods using hostUsers: false behaved differently across a CRI-O 1.34-to-1.35 jump). The fix is process, not code: pin CRI-O to the cluster’s Kubernetes minor in your package repository and upgrade them together. Never let unattended-upgrades move CRI-O on its own.

containerd’s classic trap is configuration migration. The daemon config has a version field, and it has moved forward across the 2.x line — version 2 config is still accepted, but version 3 became the norm and the format continues to evolve. Old copy-pasted /etc/containerd/config.toml files written for the v1 CRI plugin can silently fail to apply the settings you think they do after a major upgrade, because plugin IDs and sections were renamed. When you jump from containerd 1.x to 2.x, regenerate the default config with containerd config default and re-apply your customisations deliberately rather than carrying the old file forward. The 1.7 branch is end-of-life, so this migration is not optional for long.

A subtler containerd issue is snapshotter mismatch. If you enable a lazy-pull snapshotter on some nodes but not others, or change the default snapshotter without draining, you can end up with images pulled under one snapshotter and expected under another, producing confusing mount failures. Keep the snapshotter choice uniform per node pool.

For both runtimes, debugging goes through crictl. Learn crictl ps, crictl inspect, crictl logs, and crictl images — they work identically against containerd and CRI-O and are your first stop when a pod is stuck in ContainerCreating. Reaching for docker on a modern node just wastes time; on containerd, nerdctl is the ergonomic fallback, and on CRI-O there is no such thing by design.

Practical Recommendations

Pick containerd when you run on EKS, GKE, AKS, or a mainstream on-prem distro; when you want nerdctl for local reproduction and forensics; when you need lazy-pull snapshotters, CDI-driven GPU/device workflows, or sandboxed runtimes per RuntimeClass; or when you simply want the runtime with the largest talent pool and the least surprising defaults. For the overwhelming majority of teams in 2026, containerd is the safe default — it is already under your nodes whether you chose it or not.

Pick CRI-O when you run OpenShift or a Red Hat-aligned stack (you are already using it), when a minimal, tightly version-matched attack surface is a compliance or security requirement, or when you value the operational simplicity of upgrading the runtime in lockstep with Kubernetes. CRI-O rewards teams that want the runtime to be an invisible, boring, single-purpose component.

Checklist before you commit:

  • Confirm your distro or managed service’s default and whether switching is even supported.
  • Match the runtime to your Kubernetes version — lockstep for CRI-O, support-matrix check for containerd.
  • Standardise on crictl for debugging across the fleet; add nerdctl only on containerd.
  • Decide your snapshotter (containerd) or confirm overlay storage (CRI-O) per node pool, uniformly.
  • Enable user namespaces (hostUsers: false) and seccomp where the kernel allows.
  • Automate config regeneration on major upgrades instead of carrying old config files forward.
  • Pin runtime packages so unattended upgrades cannot drift the runtime ahead of the control plane.

If you are choosing an orchestrator at the same time, not just a runtime, our Kubernetes vs Nomad edge decision matrix frames that larger question — but once you are on Kubernetes, containerd vs CRI-O is the runtime call, and this guide is your map.

Frequently Asked Questions

Is containerd or CRI-O faster?

In controlled tests the two are close enough that runtime choice is rarely your performance bottleneck; workload, kernel, snapshotter, and cgroup driver dominate. CRI-O’s smaller footprint can help on high-density and edge nodes, while containerd’s lazy-pull snapshotters can cut cold-start latency for large images. Any headline latency figure you see should be treated as illustrative until you reproduce it on your own images and kernels. Benchmark before you believe.

Did removing dockershim mean my Docker images stopped working?

No. dockershim removal in Kubernetes 1.24 only removed the in-tree adapter that let the kubelet talk to Docker Engine. OCI images built with Docker remain fully portable and run unchanged on both containerd and CRI-O. Docker Engine itself uses containerd under the hood. The only thing that disappeared was the ability to use Docker Engine directly as the node runtime — you now run a genuine CRI implementation instead.

Can I run nerdctl with CRI-O?

No. nerdctl is a client for containerd specifically; it speaks containerd’s gRPC API and has no CRI-O equivalent, by design. On a CRI-O node you debug with crictl, the CRI-generic client that also works against containerd. If a Docker-style standalone CLI on the same engine as your pods is important to you, that is an argument in containerd’s favour, since nerdctl drives the exact runtime under your cluster.

Does CRI-O have to match my Kubernetes version exactly?

That is the design intent — CRI-O 1.33 targets Kubernetes 1.33, and you are expected to upgrade them together. The community has discussed letting the version numbers diverge, and adjacent minors have occasionally shipped behavioural changes, so pin CRI-O to your cluster’s minor version in your package repo and upgrade in lockstep. Do not let node OS auto-updates move CRI-O independently of the control plane.

Which runtime is more secure?

In the containerd vs CRI-O security comparison, both support seccomp, SELinux/AppArmor, user namespaces, and read-only rootfs. CRI-O’s minimal scope means a smaller attack surface and fewer dependencies to patch, which appeals to regulated and OpenShift environments. containerd can attach sandboxed OCI runtimes such as gVisor or Kata per RuntimeClass for stronger isolation where you need it. Security posture is more about how you configure the runtime — user namespaces, seccomp profiles, non-root — than about the badge on the box.

What is the default runtime on managed Kubernetes in 2026?

containerd. EKS, GKE, and AKS all default to containerd, as do most on-prem distributions. CRI-O is the default on OpenShift and Red Hat’s Kubernetes stack and appears in some hardened and edge distros. If you have not made an explicit choice, you are almost certainly already running containerd, which is one more reason it is the pragmatic default for multi-cloud platform teams.

Further Reading

By Riju — about

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply

Your email address will not be published. Required fields are marked *