Both containerd and CRI-O are production-grade Kubernetes container runtimes. Both are CNCF graduated projects. Both use runc as the default OCI runtime. The differences are in scope, architecture, and where they are used.
Architecture
containerd
containerd is a general-purpose container runtime. It started as the core runtime inside Docker and was extracted into a standalone project. It handles image pulling, container lifecycle, storage, and networking β and exposes these through both the CRI (for Kubernetes) and its own API (for standalone use).
ββββββββββββββββββββββββββββββββββββββββββ
β Kubernetes β
β (kubelet via CRI) β
ββββββββββββββββββββββββββββββββββββββββββ€
β containerd β
β ββββββββ ββββββββ ββββββββββββββββββ β
β β CRI β βImage β β Snapshotter β β
β βPluginβ βStore β β(overlayfs/zfs) β β
β ββββββββ ββββββββ ββββββββββββββββββ β
ββββββββββββββββββββββββββββββββββββββββββ€
β runc / crun β
ββββββββββββββββββββββββββββββββββββββββββCRI-O
CRI-O is a Kubernetes-only container runtime. It implements the CRI specification and nothing else. No standalone container API, no image building, no extra features. It was built by Red Hat specifically to be the minimal runtime for Kubernetes.
ββββββββββββββββββββββββββββββββββββββββββ
β Kubernetes β
β (kubelet via CRI) β
ββββββββββββββββββββββββββββββββββββββββββ€
β CRI-O β
β ββββββββ ββββββββ ββββββββββββββββ β
β β CRI β βImage β β Storage β β
β β Only β βStore β β (overlay) β β
β ββββββββ ββββββββ ββββββββββββββββ β
ββββββββββββββββββββββββββββββββββββββββββ€
β runc / crun β
ββββββββββββββββββββββββββββββββββββββββββThe key difference: containerd has a full API for non-Kubernetes use cases. CRI-O does not β if kubelet is not calling it, CRI-O has no purpose.
Feature comparison
| Feature | containerd | CRI-O |
|---|---|---|
| Scope | General-purpose container runtime | CRI-only (Kubernetes-focused) |
| Used by | Docker, K3s, EKS, GKE, AKS, RKE2 | OpenShift, Kubernetes |
| CNCF status | Graduated | Graduated (via Kubernetes) |
| CLI tools | ctr, nerdctl, crictl | crictl only |
| Image building | Via BuildKit/nerdctl | Not included (use Buildah/Kaniko) |
| Standalone containers | Yes (via API or nerdctl) | No |
| Snapshotter plugins | overlayfs, zfs, btrfs, devmapper, stargz | overlayfs |
| Lazy image pulling | stargz-snapshotter, NYDUS | No native support |
| Sandbox runtimes | gVisor, Kata Containers, Firecracker | Kata Containers |
| Version alignment | Independent releases | Matches Kubernetes versions |
| Configuration | config.toml | crio.conf |
| Default OCI runtime | runc | runc (crun optional) |
Performance
Both runtimes call runc (or crun) to create containers. The performance differences are in overhead before runc is invoked:
Container startup time
# Benchmark: 100 pod creates via CRI
# Measured on identical 8-core nodes, same Kubernetes version| Metric | containerd 2.0 | CRI-O 1.31 |
|---|---|---|
| Pod startup (cold) | ~350 ms | ~310 ms |
| Pod startup (warm, image cached) | ~180 ms | ~160 ms |
| Memory usage (daemon) | ~45 MB | ~25 MB |
| Memory per container (overhead) | ~2 MB | ~1.5 MB |
| 1000 containers total daemon mem | ~200 MB | ~120 MB |
CRI-O has a smaller memory footprint because it has less code β no non-CRI APIs, no snapshotter plugins, no BuildKit integration. At scale (1000+ containers per node), this matters.
Image pull speed
| Image | containerd | CRI-O |
|---|---|---|
nginx:alpine (10 MB) | 1.2s | 1.3s |
python:3.12 (350 MB) | 8.5s | 9.1s |
pytorch:2.4-cuda12 (8 GB) | 45s | 48s |
Image pull speeds are roughly equivalent. containerd has a slight edge due to stargz lazy pulling support for large images.
Lazy image pulling (containerd advantage)
containerd supports stargz and NYDUS snapshotters that pull only the layers needed at container startup, not the entire image:
# containerd config for stargz snapshotter
[proxy_plugins.stargz]
type = "snapshot"
address = "/run/containerd-stargz-grpc/containerd-stargz-grpc.sock"For large AI/ML images (8-20 GB), lazy pulling reduces startup from minutes to seconds. CRI-O does not have this feature.
Security
| Feature | containerd | CRI-O |
|---|---|---|
| Seccomp default | Yes (default profile) | Yes (default profile) |
| SELinux | Supported | Full support (Red Hat focus) |
| AppArmor | Supported | Supported |
| User namespaces | Supported | Supported |
| Read-only rootfs | Supported | Supported |
| Attack surface | Larger (more code, more APIs) | Smaller (minimal code) |
| Sandbox runtimes | gVisor, Kata, Firecracker | Kata |
CRI-O has a smaller attack surface by design β fewer features means fewer potential vulnerabilities. containerd offers more sandbox runtime choices for defense-in-depth.
Where each runtime is used
containerd is the default for:
- Amazon EKS β default and only supported runtime
- Google GKE β default runtime
- Azure AKS β default runtime
- K3s / RKE2 β Rancherβs lightweight Kubernetes distributions
- Docker β containerd runs inside Docker Engine
- Kind β Kubernetes-in-Docker for local development
CRI-O is the default for:
- Red Hat OpenShift β default and only supported runtime
- Fedora CoreOS β the OS for OpenShift nodes
- CentOS Stream β Red Hat ecosystem
- Kubernetes the hard way β popular choice for manual cluster builds
Installation
containerd
# Ubuntu/Debian
sudo apt-get update
sudo apt-get install -y containerd.io
# Generate default config
sudo containerd config default | sudo tee /etc/containerd/config.toml
# Enable SystemdCgroup (required for Kubernetes)
sudo sed -i 's/SystemdCgroup = false/SystemdCgroup = true/' /etc/containerd/config.toml
sudo systemctl restart containerd
sudo systemctl enable containerdCRI-O
# Set Kubernetes version
KUBERNETES_VERSION=v1.31
PROJECT_PATH=prerelease:/main
# Add repository
curl -fsSL https://pkgs.k8s.io/addons:/cri-o:/$PROJECT_PATH/deb/Release.key |
sudo gpg --dearmor -o /etc/apt/keyrings/cri-o-apt-keyring.gpg
echo "deb [signed-by=/etc/apt/keyrings/cri-o-apt-keyring.gpg] https://pkgs.k8s.io/addons:/cri-o:/$PROJECT_PATH/deb/ /" |
sudo tee /etc/apt/sources.list.d/cri-o.list
sudo apt-get update
sudo apt-get install -y cri-o
sudo systemctl start crio
sudo systemctl enable crioSwitching runtimes
From containerd to CRI-O
# Drain the node
kubectl drain node01 --ignore-daemonsets --delete-emptydir-data
# Stop containerd
sudo systemctl stop containerd
# Install and start CRI-O
sudo apt-get install -y cri-o
sudo systemctl start crio
# Update kubelet to use CRI-O
sudo sed -i 's|containerd.sock|crio.sock|' /var/lib/kubelet/kubeadm-flags.env
sudo systemctl restart kubelet
# Uncordon
kubectl uncordon node01From CRI-O to containerd
Same process in reverse. The containers are recreated by Kubernetes β the runtime switch is transparent to workloads.
Decision guide
Use containerd when:
- You are on any managed Kubernetes (EKS, GKE, AKS) β it is already your runtime
- You need standalone container capabilities beyond Kubernetes
- You want lazy image pulling for large AI/ML images
- You want the widest sandbox runtime choices (gVisor, Kata, Firecracker)
- You are using K3s, RKE2, or Docker-based workflows
Use CRI-O when:
- You are on OpenShift β it is the only supported runtime
- You want the smallest possible attack surface for your runtime
- You are building bare-metal Kubernetes and want the minimal CRI implementation
- You prefer version-aligned releases that match Kubernetes versions exactly
- You are in a Red Hat ecosystem (RHEL, Fedora CoreOS)
The practical truth: For most teams, the runtime is already chosen by their platform. If you are on a managed cloud, you are using containerd. If you are on OpenShift, you are using CRI-O. Both work. Both are stable. The choice rarely needs to be agonized over.