Container Runtimes — containerd, CRI-O, and runc
Learn Container Runtimes — containerd, CRI-O, and runc with plain-English explanations and real examples..
20+ years shipping production infrastructure and CI/CD at scale. Notes here come from systems that actually shipped.
- ✓Kubernetes cluster (v1.24+), kubectl, crictl, containerd or CRI-O installed, basic understanding of Linux namespaces and cgroups, familiarity with YAML and TOML configuration.
Think of Container Runtimes — containerd, CRI-O, and runc like a tool in your DevOps toolkit — once you understand what it does and when to reach for it, managing Kubernetes clusters becomes second nature.
Welcome to Container Runtimes — containerd, CRI-O, and runc. We'll break this down from first principles.
The Container Runtime Stack: From CLI to Kernel
When you run docker run or kubectl apply, a chain of software components transforms your image into an isolated process. At the top, container engines (Docker, Podman, crictl) translate user commands into API calls. These calls hit a high-level runtime like containerd or CRI-O, which manages image pulling, storage, and lifecycle. The high-level runtime then instructs a low-level OCI runtime (typically runc) to create the container using Linux kernel features: namespaces for isolation, cgroups for resource limits, and seccomp/AppArmor for security. Understanding this stack is critical for debugging performance issues and security breaches. In production, a misconfigured runtime can lead to container escapes or resource starvation. The OCI (Open Container Initiative) specifications ensure interoperability: any OCI-compliant runtime can run any OCI image, but real-world differences in implementation matter.
runc: The Kernel Whisperer
runc is the reference implementation of the OCI runtime spec, written in Go. It's the default low-level runtime for Docker, containerd, and CRI-O. runc takes an OCI bundle (a filesystem root and config.json) and uses clone(2) with CLONE_NEW* flags to create namespaces, then applies cgroups via cgroupfs or systemd. It sets up the root filesystem, mounts proc/sys, and finally exec(2)s the container's init process. Despite its simplicity, runc has had critical CVEs (e.g., CVE-2019-5736) allowing container escape via /proc/self/exe. In production, always run the latest runc version and consider using crun (a C implementation) for faster startup. runc's performance is adequate for most workloads, but under high churn (thousands of containers per minute), crun can reduce CPU overhead by 30%.
containerd: The Industry Standard High-Level Runtime
containerd is a high-level container runtime that manages the full lifecycle: image transfer, storage, container execution, and network attachment. It was extracted from Docker and is now the default runtime in Kubernetes (since v1.24, dockershim removal). containerd communicates with runc via the OCI runtime API and exposes its own gRPC API (the CRI plugin) for Kubernetes. It supports snapshotters (overlayfs, devicemapper, etc.) for efficient image layering. In production, containerd's configuration file (/etc/containerd/config.toml) allows tuning of garbage collection, snapshotter, and registry mirrors. A common misconfiguration is setting SystemdCgroup = true when the init system is not systemd, causing cgroup errors. containerd's performance is excellent: it can handle thousands of container creations per minute with low overhead.
CRI-O: Lightweight and Kubernetes-Native
CRI-O is a lightweight CRI implementation designed specifically for Kubernetes, with no dependency on Docker. It uses runc (or crun) as the OCI runtime and supports container storage via containers/storage. CRI-O's architecture is simpler than containerd's: it directly implements the CRI without an extra gRPC layer. This reduces memory footprint and startup latency. CRI-O is the default runtime in OpenShift and is gaining traction in other Kubernetes distributions. Its configuration is done via /etc/crio/crio.conf. A key feature is support for container runtimes that require special hardware (e.g., GPU, SR-IOV) via runtime classes. In production, CRI-O's smaller attack surface is appealing, but its smaller community means fewer third-party tools. For standard workloads, both containerd and CRI-O are viable; choose CRI-O if you value minimalism and direct Kubernetes integration.
Choosing Between containerd and CRI-O
The choice between containerd and CRI-O often comes down to ecosystem and operational preferences. containerd is the default in Docker and Kubernetes, with extensive documentation and community support. It offers advanced features like image encryption, transfer service, and a rich plugin system. CRI-O is simpler, with a smaller codebase and fewer dependencies, which can reduce the attack surface and troubleshooting complexity. Performance-wise, both are comparable; microbenchmarks show CRI-O has slightly lower memory usage (50-100 MB vs 100-150 MB for containerd). However, containerd's snapshotter architecture can be more efficient for certain storage backends. In production, consider your team's familiarity: if you already run Docker, containerd is a natural fit. If you're building a minimal Kubernetes distribution, CRI-O may be preferable. Both support runtime classes for GPU or kata-containers.
crictl stats to monitor container resource usage. For raw runtime performance, benchmark container creation latency with hyperfine 'crictl run ...'.Low-Level Runtime Alternatives: crun, youki, gVisor
While runc is the default low-level runtime, alternatives exist for specific use cases. crun is a C implementation that starts containers faster and uses less memory than runc (written in Go). youki is a Rust implementation focusing on safety and performance. gVisor is a user-space kernel that provides an additional security layer by intercepting system calls. For production, crun is a drop-in replacement for runc and can improve startup latency by 30-50%. youki is still maturing but shows promise for memory safety. gVisor is used in multi-tenant environments where strong isolation is required, but it incurs a performance penalty (10-50% depending on workload). When using these runtimes, configure them via runtime classes in Kubernetes. For example, to use gVisor for untrusted workloads, define a RuntimeClass and specify it in the pod spec.
Configuring Runtime Classes in Kubernetes
RuntimeClasses allow you to select different container runtimes for different pods. This is essential for running trusted workloads with runc and untrusted workloads with gVisor or kata-containers. To use a RuntimeClass, you must first install the runtime handler (e.g., runsc for gVisor) on the nodes and configure the CRI runtime to recognize it. For containerd, add a runtime entry in config.toml under [plugins."io.containerd.grpc.v1.cri".containerd.runtimes]. For CRI-O, add a section under [crio.runtime.runtimes]. Then create a RuntimeClass resource and reference it in pod specs. In production, use RuntimeClasses to isolate multi-tenant workloads or to run legacy applications that require specific kernel versions. A common pitfall is forgetting to install the runtime handler on all nodes, causing pods to stay in 'Pending' state.
Debugging Container Runtime Issues
Container runtime issues often manifest as pods stuck in 'ContainerCreating' or 'CrashLoopBackOff'. Common causes include: runtime daemon not running, misconfigured cgroup driver, incompatible OCI runtime, or image pull failures. Use crictl to inspect containers: crictl ps -a shows all containers, crictl inspect <id> gives details. For containerd, use ctr (containerd's CLI) for low-level debugging. For CRI-O, crio-status provides health info. Check logs: journalctl -u containerd or journalctl -u crio. A frequent production issue is the runtime daemon running out of file descriptors under high load. Increase limits in systemd service files. Another is cgroup inconsistency: if kubelet uses cgroupfs but the runtime uses systemd, containers fail to start. Ensure both use the same driver.
SystemdCgroup = true in containerd config or align kubelet to cgroupfs.max_container_d_log_line_size and set max_concurrent_downloads to prevent image pull storms.Security Considerations for Container Runtimes
Container runtimes are a critical security boundary. A vulnerability in runc can lead to container escape, compromising the host. Best practices: keep runtimes updated, use user namespaces (where supported), enable seccomp and AppArmor profiles, and avoid running containers as root. For high-level runtimes, restrict access to the runtime socket (e.g., /run/containerd/containerd.sock) — only kubelet and trusted users should have access. Use read-only root filesystems and drop capabilities. In multi-tenant clusters, consider using gVisor or kata-containers for additional isolation. Regularly audit runtime configurations: check that no_new_privs is set, and that maskedPaths and readonlyPaths are configured in the OCI spec. CRI-O has a smaller codebase, which may reduce the attack surface, but both are actively maintained.
docker-socket-proxy if necessary.Performance Tuning for High-Density Clusters
In high-density clusters (100+ pods per node), container runtime performance becomes critical. Key metrics: container creation latency, image pull throughput, and memory overhead. For containerd, tune max_concurrent_downloads (default 3) to speed up image pulls. Use overlayfs snapshotter for best performance. For CRI-O, adjust pids_limit and log_size_max. Consider using crun instead of runc for faster startup. Enable cgroup v2 for better resource accounting. Monitor runtime metrics: containerd exposes Prometheus metrics at the configured address. Watch for high goroutine counts or file descriptor leaks. In extreme cases, pre-pull images using daemonsets or node image caching. Also, set appropriate resource requests/limits to avoid OOM kills of the runtime daemon.
Upgrading Container Runtimes in Production
Upgrading container runtimes requires careful planning to avoid cluster downtime. For Kubernetes, the runtime is a node-level component; upgrades typically involve cordoning and draining nodes. Before upgrading, check release notes for breaking changes (e.g., cgroup driver changes, config format changes). For containerd, major version upgrades may require config migration (e.g., v1 to v2 config). For CRI-O, the config format is more stable. Always test on a non-production cluster first. Use rolling updates: upgrade one node at a time, monitor pod stability, and rollback if issues arise. Keep a backup of the runtime configuration. In critical clusters, consider having a standby runtime installed (e.g., both containerd and CRI-O) but only one active. This allows quick switchover if the primary fails.
Future Trends: Wasm Runtimes and MicroVMs
The container runtime landscape is evolving. WebAssembly (Wasm) runtimes like WasmEdge and wasmtime can run lightweight sandboxed modules alongside containers. MicroVM runtimes like Firecracker (used by AWS Fargate) and kata-containers provide VM-level isolation with container-like speed. These are often integrated via runtime classes. For example, you can run regular containers with runc and untrusted code with kata-containers. Wasm runtimes are particularly interesting for serverless and edge computing due to their fast startup (microseconds) and small footprint. However, they have limited system call support. In production, consider using Wasm for compute-heavy, stateless workloads. MicroVMs are suitable for multi-tenant environments where strong isolation is required. The CRI runtime ecosystem is adapting: containerd already supports Wasm via shims.
| File | Command / Code | Purpose |
|---|---|---|
| check-runtimes.sh | kubectl get nodes -o jsonpath='{.items[*].status.nodeInfo.containerRuntimeVersio... | The Container Runtime Stack |
| runc-create-container.sh | mkdir mycontainer && cd mycontainer | runc |
| version = 2 | containerd | |
| [crio.runtime] | CRI-O | |
| compare-runtimes.sh | systemctl stop containerd | Choosing Between containerd and CRI-O |
| runtimeclass-gvisor.yaml | apiVersion: node.k8s.io/v1 | Low-Level Runtime Alternatives |
| containerd-config-runtimeclass.toml | version = 2 | Configuring Runtime Classes in Kubernetes |
| debug-runtime.sh | systemctl is-active containerd | Debugging Container Runtime Issues |
| pod-security-context.yaml | apiVersion: v1 | Security Considerations for Container Runtimes |
| tune-containerd.sh | cat < | Performance Tuning for High-Density Clusters |
| upgrade-containerd.sh | kubectl cordon | Upgrading Container Runtimes in Production |
| wasm-pod.yaml | apiVersion: v1 | Future Trends |
Key takeaways
Interview Questions on This Topic
What is the difference between containerd and CRI-O?
Frequently Asked Questions
20+ years shipping production infrastructure and CI/CD at scale. Notes here come from systems that actually shipped.
That's Kubernetes. Mark it forged?
5 min read · try the examples if you haven't