Skip to content

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. overlayfs is the default and works on any modern Linux kernel; devmapper, btrfs, zfs, native, and stargz (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 execs runc 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:

[plugins.'io.containerd.cri.v1.images'.registry]
  config_path = "/etc/containerd/certs.d"

(1.x / version-2 config: [plugins."io.containerd.grpc.v1.cri".registry].)

/etc/containerd/certs.d/
└── docker.io/
    └── hosts.toml
# /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)

[plugins.'io.containerd.cri.v1.images'.pinned_images]
  sandbox = 'registry.k8s.io/pause:3.10.2'

(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 run -d -p 8080:80 nginx
nerdctl build -t myapp:latest .
nerdctl compose up

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