containerd¶
Who this page is for: in plain English, containerd is the program on each node that actually starts and stops your containers. This page is production depth, aimed at readers already comfortable with Pods, Services, and Deployments. New to Kubernetes? Start with the Beginner track and kubelet and the Container Runtime first.
containerd is the container runtime that actually runs your containers. It sits below the kubelet, speaks the Container Runtime Interface (CRI), and is responsible for pulling images, managing storage snapshots, and launching the OS-level process that becomes your container - via runc or another OCI-compatible runtime.
It's easy to forget containerd exists, because for years Docker hid it. Docker Engine has always used containerd internally; when Kubernetes deprecated and removed dockershim (fully gone as of Kubernetes 1.24), most clusters switched to talking to containerd directly instead of going through Docker at all. containerd graduated from the CNCF in 2019 and is now the default runtime for GKE, EKS, AKS, and kubeadm-based clusters.
containerd's major-version line has moved on since the dockershim removal: containerd 2.0 shipped in late 2024 and the 2.x series is the actively developed line as of 2026, while the 1.7 branch is an LTS line whose support window is winding down - check the project's releases page and RELEASES.md support policy for the current dates before you plan an upgrade. If you're standing up new nodes, you're almost certainly on containerd 2.x - which matters for the config.toml layout described below.
Where containerd sits¶
flowchart TD
Kubelet[kubelet] -->|CRI gRPC\nimage/runtime service| CRIPlugin[containerd\nCRI plugin]
CRIPlugin --> Core[containerd core\nimages, content, snapshots]
Core --> Content[(Content store\nlayer blobs)]
Core --> Snapshot[(Snapshotter\noverlayfs/devmapper)]
Core --> Shim[containerd-shim-runc-v2]
Shim --> Runc[runc\nOCI runtime]
Runc --> Container[container process]
CRIPlugin -.RuntimeClass: kata/gvisor.-> ShimAlt[containerd-shim-kata-v2\nor gvisor shim]
ShimAlt --> Sandbox[sandboxed VM/gVisor]
kubelet never talks to runc or manages processes directly - it delegates everything through the CRI gRPC API exposed by containerd's CRI plugin, over a Unix socket (usually /run/containerd/containerd.sock).
containerd vs Docker vs CRI-O vs cri-dockerd¶
| containerd | CRI-O | Docker Engine + cri-dockerd | |
|---|---|---|---|
| CRI-native | yes (built-in plugin) | yes, purpose-built for CRI | no - needs the cri-dockerd shim |
| Scope | runtime only | runtime only | full dev-facing daemon (build, compose, networking) |
| Maintained by | CNCF (graduated 2019) | CNCF (incubating since 2019), close ties to Kubernetes SIG-Node / Red Hat | Mirantis (cri-dockerd) + Docker Inc |
| Typical use | default in GKE/EKS/AKS/kubeadm | OpenShift and RHEL-family clusters | you specifically need docker build/docker CLI on nodes |
| CLI for humans | ctr (debug only), nerdctl |
crictl only |
docker |
| OCI runtime underneath | runc (default), Kata, gVisor, etc. | runc, Kata, etc. | runc (via containerd) |
The practical takeaway: since Kubernetes 1.20 deprecated dockershim (removed in 1.24), "Docker doesn't work with Kubernetes anymore" is a myth - Docker-built images are OCI images and run fine on containerd or CRI-O. What went away is the daemon-shim path; the artifact format didn't change.
Core architecture¶
containerd is organized as a set of composable subsystems, each with its own gRPC API:
- Content store - content-addressable storage for image layer blobs, indexed by digest. Layers are shared across images that reference the same digest.
- Snapshotters - manage the filesystem layers that back a container's root filesystem.
overlayfsis the default and works on any modern Linux kernel;devmapper,btrfs,zfs,native, andstargz(lazy-pull) exist for specific storage backends. - Shim (
containerd-shim-runc-v2) - a small, independent process launched per container (or per pod's containers, depending on grouping). The shim execsrunc create/start, then stays resident as the container's parent process, reaping it and reporting exit status. - runc - the actual OCI runtime. It reads an OCI runtime spec (
config.json), sets up namespaces/cgroups, and execs the container's entrypoint.
Why the shim model matters¶
The shim's job is to decouple container lifecycle from the containerd daemon's lifecycle. Because the shim - not containerd itself - is the container's reaper, you can restart or upgrade the containerd daemon without killing running containers. This is the same design goal Docker's --live-restore chased, but containerd has it by default because the shim architecture makes it structural, not optional.
containerd (daemon)
└── containerd-shim-runc-v2 (per pod, persists across daemon restarts)
└── runc (exits after container start)
└── container process (reparented to shim)
Namespaces inside containerd¶
containerd has its own concept of namespaces - unrelated to Linux or Kubernetes namespaces - used to isolate groups of images/containers within one daemon. Kubernetes uses the k8s.io namespace exclusively:
ctr namespaces list
# NAME LABELS
# k8s.io
# List images the way containerd sees them (note the required namespace flag)
ctr -n k8s.io images list
ctr -n k8s.io containers list
If you run ctr images list without -n k8s.io, you'll get an empty (or wrong) result - it defaults to the default namespace, which Kubernetes never uses. This trips up nearly everyone the first time they use ctr on a node.
crictl: the tool you actually want¶
crictl is the CRI-focused debugging CLI maintained by SIG-Node. Unlike ctr, it speaks the CRI API the same way kubelet does, so what you see is what kubelet sees.
# Configure once so you don't need --runtime-endpoint every time
cat <<EOF | sudo tee /etc/crictl.yaml
runtime-endpoint: unix:///run/containerd/containerd.sock
image-endpoint: unix:///run/containerd/containerd.sock
timeout: 10
EOF
crictl ps # running containers (like docker ps)
crictl ps -a # include exited containers
crictl pods # pod sandboxes -- the pause container's world
crictl images # images pulled on this node
crictl inspect <container-id> # full container spec + status
crictl inspectp <pod-id> # pod sandbox details (network namespace, etc.)
crictl logs -f <container-id> # container stdout/stderr
crictl exec -it <container-id> sh
crictl stats # live CPU/memory per container, node-local
A realistic node-debugging session¶
# Pod stuck in ImagePullBackOff -- find the sandbox and check events
crictl pods --name my-app -s NotReady
# Container crash-looping -- get the exit code and last logs
crictl ps -a --name my-app
crictl inspect <container-id> | grep -A5 '"reason"'
crictl logs --tail 50 <container-id>
# Runtime itself seems unhealthy
sudo systemctl status containerd
crictl info # runtime status, config, features
journalctl -u containerd -n 100 --no-pager
crictl is CRI-runtime-agnostic - the same commands work unchanged against CRI-O. That portability is why it's the tool taught for the CKA and CKS exams.
config.toml essentials¶
containerd's config lives at /etc/containerd/config.toml. containerd 2.x defaults to the version 3 schema (version = 3), which also renamed the CRI plugin IDs - io.containerd.grpc.v1.cri split into io.containerd.cri.v1.runtime (runtime/pod behavior) and io.containerd.cri.v1.images (image/registry behavior). Config files written for the old version 2 schema (containerd 1.x, plugin ID io.containerd.grpc.v1.cri) still work - containerd auto-converts them on load, and containerd config migrate will write out the v3 equivalent for you. A few settings matter far more than the rest.
SystemdCgroup - the most common bootstrap failure¶
version = 3
[plugins.'io.containerd.cri.v1.runtime'.containerd.runtimes.runc]
runtime_type = 'io.containerd.runc.v2'
[plugins.'io.containerd.cri.v1.runtime'.containerd.runtimes.runc.options]
SystemdCgroup = true
(On containerd 1.x / version-2 config, the same setting lives at [plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc.options].)
If your node uses systemd as the cgroup driver (the default and recommended choice since Kubernetes 1.22, and mandatory guidance from the docs since then), SystemdCgroup must be true here, and kubelet's cgroupDriver must also be systemd. A mismatch between kubelet and containerd's cgroup driver is one of the most common "node won't join the cluster" / "pods stuck in ContainerCreating" bugs in the wild.
Since Kubernetes 1.34 (GA), this class of mismatch can be avoided entirely: the KubeletCgroupDriverFromCRI feature lets kubelet ask containerd (via CRI) which cgroup driver it's using and adopt that automatically, instead of relying on kubelet's own cgroupDriver field matching containerd's config by hand. It requires containerd 2.0+ (or CRI-O 1.28+) on the node side. Until every node in your fleet is on a new enough runtime, keep setting both sides explicitly.
Registry mirrors and auth (config_path)¶
Modern containerd (1.5+) uses a directory-based registry config instead of inline TOML tables. On containerd 2.x / version-3 config, this setting moved under the new images plugin:
(1.x / version-2 config: [plugins."io.containerd.grpc.v1.cri".registry].)
# /etc/containerd/certs.d/docker.io/hosts.toml
server = "https://registry-1.docker.io"
[host."https://mirror.gcr.io"]
capabilities = ["pull", "resolve"]
[host."https://my-private-mirror.internal"]
capabilities = ["pull", "resolve"]
ca = "/etc/containerd/certs.d/docker.io/ca.crt"
This lets you point every node at a pull-through cache (Harbor, GCR mirror, ECR pull-through) without rewriting image references in every manifest - containerd rewrites the pull transparently based on the image's registry host.
Sandbox image (pause container)¶
(1.x / version-2 config uses a flatter key: [plugins."io.containerd.grpc.v1.cri"] with sandbox_image = "registry.k8s.io/pause:3.10".)
Every pod gets one sandbox container (the "pause" container) that holds the shared network namespace for the pod. If this image can't be pulled, every pod on the node fails to start - check crictl pods and this setting first when a node can't schedule anything.
Image pull architecture and GC¶
containerd pulls images into the content store as content-addressable blobs, then the CRI plugin unpacks them into a snapshot using the configured snapshotter. Layers already present (by digest) are never re-downloaded or re-unpacked, which is why pods sharing a base image start fast on a node that already has it cached.
crictl images # what's cached on this node
crictl rmi <image-id> # remove a specific image
crictl rmi --prune # remove all unreferenced images
kubelet itself runs periodic image garbage collection based on disk usage thresholds (imageGCHighThresholdPercent / imageGCLowThresholdPercent in kubelet config, historically ~85%/80%). When disk usage crosses the high threshold, kubelet asks containerd (via CRI) to delete unused images, oldest-last-used first, until usage drops below the low threshold. This is independent of crictl rmi and runs automatically - manual image cleanup is rarely needed except when debugging disk pressure.
RuntimeClass: multiple runtimes per node¶
containerd supports registering more than one OCI-runtime handler and letting workloads pick one via Kubernetes' RuntimeClass object. This is how gVisor and Kata Containers sandboxing get wired in.
# config.toml (version 3 / containerd 2.x plugin IDs;
# use "io.containerd.grpc.v1.cri" in place of "io.containerd.cri.v1.runtime" on containerd 1.x)
[plugins.'io.containerd.cri.v1.runtime'.containerd.runtimes.runc]
runtime_type = "io.containerd.runc.v2"
[plugins.'io.containerd.cri.v1.runtime'.containerd.runtimes.gvisor]
runtime_type = "io.containerd.runsc.v1"
[plugins.'io.containerd.cri.v1.runtime'.containerd.runtimes.kata]
runtime_type = "io.containerd.kata.v2"
apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata:
name: gvisor
handler: gvisor # must match the runtime name in config.toml
---
apiVersion: v1
kind: Pod
metadata:
name: untrusted-workload
spec:
runtimeClassName: gvisor
containers:
- name: app
image: example/app
- runc - default, shares the host kernel via namespaces/cgroups. Fast, but a kernel exploit inside the container reaches the host.
- gVisor (
runsc) - intercepts syscalls in a userspace kernel; strong isolation, some syscall/performance overhead, no true VM boundary. - Kata Containers - runs each pod's containers inside a lightweight VM (via QEMU/Cloud Hypervisor/Firecracker), giving hardware-level isolation at higher startup latency and memory overhead.
Use RuntimeClass to run multi-tenant or untrusted workloads (CI runners executing arbitrary code, PaaS platforms running customer code) under gVisor or Kata, while trusted internal workloads stay on runc for performance.
nerdctl: a Docker-compatible CLI on top of containerd¶
ctr is intentionally low-level and meant for debugging, not daily use - it doesn't do image build, compose, or convenient networking. nerdctl fills that gap: a Docker-CLI-compatible client built directly on containerd, supporting nerdctl build, nerdctl compose up, rootless mode, and encrypted/lazy-pulling images - useful on dev machines or CI runners that use containerd without Docker installed at all.
nerdctl talks to containerd's default namespace by default, not k8s.io - pass --namespace k8s.io if you need to inspect what Kubernetes is running with it.
Common production mistakes¶
| Mistake | Fix |
|---|---|
SystemdCgroup = false (or default false pre-1.22 templates) while kubelet uses cgroupDriver: systemd |
Set both to systemd; mismatch causes cgroup errors and node instability |
Running ctr commands without -n k8s.io and concluding "no images exist" |
Always pass -n k8s.io when inspecting what Kubernetes is using |
Manually deleting images with ctr images rm while pods reference them |
Use crictl rmi and let kubelet's image GC manage lifecycle |
| Assuming Docker Hub image builds "won't work" post-dockershim removal | OCI images work identically; only the daemon-shim integration was removed |
Forgetting to restart containerd after editing config.toml |
sudo systemctl restart containerd - config isn't hot-reloaded |
Debugging with docker ps on a containerd-only node |
It won't exist; use crictl ps instead |
Putting registry credentials inline in old-style [plugins."io.containerd.grpc.v1.cri".registry.configs] |
Migrate to config_path + per-host hosts.toml, the supported path since 1.5 |
Copying a 1.x config.toml onto a containerd 2.x node and assuming the plugin IDs still apply |
v2 configs still load (auto-converted), but new options and any hand-edited paths should target the v3 IDs (io.containerd.cri.v1.runtime / io.containerd.cri.v1.images); run containerd config migrate to see the v3 equivalent |
Source Links¶
- containerd project site
- containerd on GitHub
- containerd releases
- containerd release and support policy (RELEASES.md)
- containerd 2.0 configuration reference
- containerd registry host configuration (
hosts.toml) - CNCF project page: containerd
- CNCF project page: CRI-O and the CNCF landscape
- Kubernetes: Container Runtimes
- Kubernetes: Debugging Kubernetes nodes with crictl
- Kubernetes: Dockershim removal FAQ
- Kubernetes: RuntimeClass
- nerdctl
- gVisor and Kata Containers
Related Concepts¶
- kubelet and the Container Runtime - how kubelet drives containerd via CRI
- Control Plane
- Troubleshooting
- CKA Exam Guide - crictl and runtime debugging are exam topics
- CKS Exam Guide - RuntimeClass sandboxing and node security
- Security