containerd
containerd is the daemon that actually runs your containers — the layer directly beneath kubelet, reached over the Container Runtime Interface (CRI), that pulls images, sets up a Pod's sandbox, and hands each container off to a shim and runc to start. Every other page in this course treats it as a fact of the architecture; this page treats it as a tool with its own daemon, its own config file, its own CLI clients, and its own failure modes worth knowing by name. That means the plugin architecture containerd is actually built from, why Docker Engine and Kubernetes parted ways when "dockershim" was removed, the handful of settings in /etc/containerd/config.toml that quietly break a node when they're wrong, a genuinely surprising gotcha in how containerd isolates its own state (it has "namespaces," and they are not Kubernetes namespaces), the three-way split between ctr, crictl, and nerdctl, and how to use crictl to debug a node directly when kubectl itself has nothing to say.
Think of a busy restaurant kitchen. The front-of-house server (kubectl/the API server) takes your order and posts it on the rail. The expediter at each station (kubelet) reads tickets off that rail and calls them out. For years, there was a translator standing between the expediter and the actual line cook, rewriting every ticket into the line cook's private shorthand before the cook could start — that translator was dockershim. Eventually every line cook in the building learned to read the standard ticket format directly (CRI), so the translator got let go — one less person in the chain, nothing about the food changed. The line cook who's been doing the actual cooking the entire time, translator or not, is containerd. And the cook's own two hands, physically searing and plating one dish — that's runc.
What containerd actually is
☺ Like you're 10: It's not one big program — it's a small core plus a set of plug-in pieces, each one owning exactly one job: fetching images, storing layers, or starting processes.
containerd began life as the piece of Docker Engine that actually managed the container lifecycle, was spun out and donated to the CNCF in 2017, and graduated as an independent, top-level CNCF project in 2019 — the same governance track Kubernetes itself went through. Architecturally it's a single daemon (containerd, usually running as a systemd unit) exposing one gRPC API over a Unix socket, normally /run/containerd/containerd.sock, with almost everything behind that API implemented as a plugin: a content store (the shared, content-addressed blob store every pulled image layer lands in), one or more snapshotters (usually overlayfs on Linux, which turns those immutable layers into a writable root filesystem for a container without copying them), a runtime v2 shim manager, an image service, an events subsystem other plugins can subscribe to, and — the one this whole course cares about — the CRI plugin, which is what lets kubelet talk to containerd at all.
Every one of the four clients at the top of that diagram is a peer speaking to the same daemon over the same socket — kubectl and the API server are nowhere in this picture at all, which is exactly why a node's containerd keeps running fine through an API server outage and is precisely what makes it useful for debugging one.
Why Docker Engine and Kubernetes parted ways
☺ Like you're 10: Docker Engine and containerd were never really two competitors — Docker Engine always ran on containerd underneath. Kubernetes just stopped needing the extra layer on top.
Before Kubernetes 1.20, kubelet talked to full Docker Engine through a built-in translation layer called dockershim, because Docker Engine itself never spoke CRI — it predates CRI entirely. That shim was deprecated in 1.20 and removed outright in Kubernetes 1.24 (2022). The framing that briefly went around — "Kubernetes drops Docker support" — overstated it: containerd was Docker Engine's own runtime the whole time, so once the shim was gone, kubelet simply talked to the same containerd directly instead of routing through dockerd first. Nothing about the image format changed; an image built with docker build is a standard OCI image and runs identically whether containerd is fronted by Docker Engine or not. This course's DevOps sibling covers the full Docker Engine → containerd → runc pipeline, including the shim-survives-a-restart detail, from the build side — see the DevOps Docker tool guide — and the Platform Engineering course frames CRI as one of three pluggable contracts (alongside CNI and CSI) in Kubernetes as the Substrate. This course's own Cluster Architecture, Installation & Configuration blueprint page introduces all three extension interfaces at CKA depth; this page goes one layer deeper on containerd specifically.
It's possible, but it needs a replacement shim: cri-dockerd, a community-maintained (Mirantis) project that reintroduces a CRI-compliant translation layer in front of Docker Engine, the same job dockershim used to do in-tree. It's a legitimate option for teams with tooling genuinely built around docker.sock, but it's one more moving part with its own release cadence to track — not the path kubeadm, most managed Kubernetes offerings, or this course assume by default.
The CRI plugin: how kubelet actually talks to containerd
☺ Like you're 10: kubelet doesn't call containerd's private API — it sends one standard, documented request format, and any runtime that understands that format can sit behind it.
CRI is a gRPC contract with two services: RuntimeService (start/stop/list Pods and containers, exec into one, fetch logs) and ImageService (pull, list, and remove images). containerd's CRI plugin implements both against the same socket every other client uses. kubelet is configured with --container-runtime-endpoint=unix:///run/containerd/containerd.sock (the default on nodes provisioned by kubeadm) and, from that point on, CRI is the only way kubelet talks to a runtime — there is no other supported code path since dockershim's removal.
One CRI concept worth knowing by name: every Pod gets a PodSandbox before any of its containers start — in practice, on Linux, an actual container running the tiny pause image, which does nothing but hold open the network namespace (and IPC/UTS namespaces) that every container in that Pod then joins. That's the mechanism, not an implementation detail — it's the entire reason containers in one Pod share localhost and can see each other's ports without any Service in between. The pause image containerd uses for this is configured, not hardcoded, which is the first line of the next section.
The config you actually write: /etc/containerd/config.toml
☺ Like you're 10: One text file decides which cgroup style containerd uses, which tiny "pause" image every Pod gets built on top of, and where it's allowed to pull images from.
containerd's config is TOML, versioned with version = 2 at the top of the file. Most nodes start from a generated default and edit a handful of keys rather than writing one from scratch:
# generate a default config, then edit it — never hand-write from a blank file $ containerd config default | sudo tee /etc/containerd/config.toml $ sudo systemctl restart containerd
# /etc/containerd/config.toml — the handful of keys that matter most on a Kubernetes node
version = 2
[plugins."io.containerd.grpc.v1.cri"]
sandbox_image = "registry.k8s.io/pause:3.9" # must match what kubelet/kubeadm expects
[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc]
runtime_type = "io.containerd.runc.v2"
[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc.options]
SystemdCgroup = true # must match kubelet's --cgroup-driver
[plugins."io.containerd.grpc.v1.cri".registry]
config_path = "/etc/containerd/certs.d" # preferred over the older inline [registry.mirrors]Two of those keys are the ones that actually break nodes when they're wrong, and both are covered in depth in the gotchas below: SystemdCgroup has to agree with whatever cgroup driver kubelet itself is using, and sandbox_image has to be an image that's actually reachable and matches what the rest of the control plane expects a Pod's sandbox to be running. The registry block controls where image pulls actually go — mirrors, insecure registries, and pull credentials for a private registry all live here (or, in the newer and now-preferred form, in per-host hosts.toml files under the config_path directory, one subdirectory per registry hostname).
containerd's own namespaces — not the Kubernetes kind
☺ Like you're 10: The word "namespace" means something completely different here than it does everywhere else in this course — mixing the two up is how ctr images ls shows you an empty list on a node that's clearly running dozens of Pods.
containerd multiplexes multiple independent clients over one daemon using its own concept of a namespace — a way to keep one client's images, containers, and content store entries from colliding with another's. It has nothing to do with a Kubernetes Namespace object. By convention, the CRI plugin puts absolutely everything it manages — every image kubelet pulls, every container kubelet starts — into one containerd namespace called k8s.io. crictl always talks CRI, so it's automatically scoped to k8s.io and this never comes up. ctr, the low-level client, defaults to a namespace called default instead — which is why ctr images ls on a perfectly healthy Kubernetes node routinely prints nothing at all.
$ ctr namespace ls # see every containerd namespace that has anything in it $ ctr images ls # empty on a k8s node — wrong (default) namespace $ ctr -n k8s.io images ls # this is the one kubelet actually populated $ ctr -n k8s.io containers ls $ ctr -n k8s.io tasks ls # running processes, one per container
"Which namespace" is a question you have to ask twice on a Kubernetes node, at two completely different layers: a Kubernetes Namespace scopes API objects like Pods and Services inside etcd and the API server, while a containerd namespace scopes images and containers inside one node's local containerd daemon. They share a name by coincidence of vocabulary, not by design — and the one link between them is that the CRI plugin happens to name its single containerd namespace k8s.io, not any particular Kubernetes Namespace.
Day-to-day commands: ctr, crictl, and nerdctl
☺ Like you're 10: Three different remote controls for the same TV, built for three different people — containerd's own developers, a Kubernetes operator debugging a node, and someone who just wants Docker-shaped muscle memory to keep working.
| Tool | Speaks | Built for | Reach for it when |
|---|---|---|---|
ctr | containerd's native gRPC API directly | containerd's own maintainers and low-level debugging | You need to see something CRI deliberately doesn't expose — ships in the box, but the CLI itself says as much: it's explicitly "not for humans" in day-to-day ops |
crictl | CRI (RuntimeService + ImageService) — the same surface kubelet uses | Kubernetes operators inspecting or debugging the exact thing kubelet sees | Anything node-level: is this Pod's sandbox actually up, what does the runtime think is running, reading a container's logs without going through the API server |
nerdctl | containerd's API, wrapped in Docker-CLI-compatible commands | Anyone who wants docker build/run/compose ergonomics on top of containerd, no Docker Engine installed | Local dev or CI on a containerd-only box; it adds real capabilities Docker Engine doesn't ship, like lazy image pulling and rootless mode |
# crictl config once, so you don't retype the endpoint every time $ sudo tee /etc/crictl.yaml <<'EOF' runtime-endpoint: unix:///run/containerd/containerd.sock image-endpoint: unix:///run/containerd/containerd.sock timeout: 10 EOF # nerdctl, for comparison — docker-shaped commands, no docker.sock anywhere $ nerdctl run -d --name web -p 8080:80 nginx:1.27 $ nerdctl compose up -d
Note what's genuinely absent from that table: kubectl. It isn't a fourth option here because it doesn't talk to containerd at all — it talks to the API server, which talks to kubelet on the right node, which talks to containerd over CRI. Every hop in that chain is a place something can be unreachable while containerd itself is perfectly healthy, which is exactly the situation the next section is built for.
crictl for direct node-level debugging
☺ Like you're 10: When the phone lines to the control room are down, you don't give up — you walk straight into the engine room and ask the engine what it's actually doing.
Cluster Architecture, Installation & Configuration and Troubleshooting already introduce crictl as the tool for exactly one scenario: the API server itself is down, so nothing routed through it — including kubectl — can tell you anything. This section is the fuller node-level workflow those pages point back to. SSH onto the node in question, then work from the runtime's own view of the world instead of the cluster's:
# the read loop, entirely local to this one node $ crictl pods # every PodSandbox this node's runtime knows about $ crictl pods --name checkout # find the sandbox for a specific Pod by name $ crictl ps -a # every container, any state — Exited included $ crictl images # what's actually cached locally, right now # once you've found the container you care about $ crictl inspect| grep -A4 '"pid"' # the container's PID on this host, from the runtime's own record $ crictl logs --tail=100 -f # stdout/stderr, no kube-apiserver in the path at all $ crictl exec -it sh # a shell inside it, same caveat as kubectl exec: needs one to exist $ crictl stats # live CPU/memory, straight from the runtime, no metrics-server needed # cleanup, when a stopped container or a stale image is the actual problem $ crictl rm $ crictl rmi
The correlating step people skip is tying a Pod you already know about (from a stale kubectl describe pod output, or a UID you copied down before the API server went away) back to a specific sandbox: crictl inspectp prints the sandbox's own metadata, including the Pod's UID and namespace, which is how you confirm you're looking at the right one before you start reading logs or attaching a shell.
Gotchas and failure modes
☺ Like you're 10: Most containerd problems on a Kubernetes node trace back to one of a small handful of named causes — worth memorizing the names so you recognize the symptom instantly next time.
Cgroup driver mismatch. kubelet and containerd both need to agree on whether they're managing cgroups through systemd or the raw cgroupfs — kubeadm-provisioned clusters default both to systemd since 1.22 and configure them to match automatically, but a manually assembled node, an old runbook, or a copy-pasted config from a pre-1.22 guide can still leave them disagreeing. The result isn't subtle: kubelet fails to start, or Pods on that node behave inconsistently under resource pressure because two different cgroup managers are fighting over the same hierarchy. Fix it in one place — SystemdCgroup in config.toml — and confirm kubelet's own --cgroup-driver (or its config file equivalent) agrees.
Pause/sandbox image drift. sandbox_image in config.toml has to point at an image kubelet and containerd both expect and that's actually reachable from the node. A stale pin, a registry migration (the default moved from k8s.gcr.io to registry.k8s.io around the 1.25 era), or a firewalled registry shows up as Pods stuck indefinitely at sandbox creation — before any of your own containers are even attempted, which makes it a confusing one to diagnose from kubectl describe pod alone. crictl pull <the configured sandbox_image> run directly on the node isolates the problem to exactly this cause in seconds.
Registry config v1 vs. v2 confusion. Older guides show private-registry auth as an inline [plugins."io.containerd.grpc.v1.cri".registry.mirrors] block; newer containerd versions deprecate that path in favor of per-host hosts.toml files under config_path. Mixing the two forms, or following a tutorial written for the other one, produces pulls that silently succeed against the public registry while quietly ignoring the mirror or credential you thought you configured.
containerd surviving a restart doesn't mean containers survive a reboot. Because each container's containerd-shim-runc-v2 is its own independent process, sudo systemctl restart containerd to pick up a config change does not kill running containers — the shims keep the containers alive and simply reconnect once containerd comes back. A full node reboot is a different story: nothing about that shim survives the kernel itself going down, so every container on that node restarts along with it.
Because kubelet's image garbage collection only ever asks containerd about the k8s.io namespace (it's talking CRI, which is always scoped there), anything you pulled by hand with a bare ctr images pull — landing in the default namespace by default — is completely invisible to kubelet's disk-pressure image eviction. It just sits there, consuming node disk, until someone finds it with ctr -n default images ls and removes it by hand. If you're pulling something outside the normal Pod-creation path for testing, pull it with crictl (or explicitly ctr -n k8s.io) so it's at least visible to the same accounting kubelet uses.
On a kind or minikube node (or any Linux box with containerd installed): run ctr images ls and confirm it prints nothing, then run ctr -n k8s.io images ls and watch every image your Pods actually pulled appear. Pick one running Pod, find its container ID with crictl ps, and pull its logs with crictl logs — then compare against what kubectl logs shows for the same Pod; they should match, because kubectl logs is, underneath several hops, reading the same thing. Finally, open /etc/containerd/config.toml, find SystemdCgroup and sandbox_image, and confirm you can explain in one sentence each what breaks if either one is wrong.
Foxy: kubectl's just hanging. Not erroring, not timing out — hanging. Something's wrong and nothing's telling me what.
Timmy the Turtle: That's the API server being unreachable, not the cluster being dead. Different problem, different tool.
Benny the Beaver: SSH onto the node and ask containerd directly — crictl ps -a, crictl pods. It doesn't care whether the API server's up.
Gizmo the Gremlin: Or just ctr images ls and panic when it's empty — clearly the node lost every image, reimage the whole thing! 🤑
Benny the Beaver: It didn't lose anything, Gizmo — ctr defaults to the default namespace and kubelet's images live in k8s.io. ctr -n k8s.io images ls, and they're all still there.
Professor Owl: Which is the whole lesson, really: two things sharing the word "namespace" doesn't mean they share a meaning. Ask which layer you're actually standing in before you trust an empty result.
Foxy: Found it, anyway — sandbox image can't be pulled, registry's rate-limiting us. Not a node problem at all.
1. Name the four core pieces of containerd's plugin architecture covered here, and which one is the one kubelet actually talks to. 2. What changed, concretely, when "dockershim" was removed in Kubernetes 1.24 — and what stayed exactly the same? 3. What is a PodSandbox, and what image is it, concretely, on a Linux node? 4. Name the two config.toml settings most likely to break a node when they're wrong, and what each one has to agree with. 5. What does "namespace" mean inside containerd, and how is that different from a Kubernetes Namespace? Which one does the CRI plugin always use? 6. Why does crictl keep working when kubectl can't reach anything? 7. Does restarting the containerd daemon kill running containers? Does rebooting the node?
Check your answers
- The content store (image layers/blobs), the snapshotters (usually overlayfs, turning layers into a writable root filesystem), the runtime v2 shim manager (one shim per running container, calling down to runc), and the CRI plugin — the CRI plugin is the one kubelet talks to, over gRPC on the containerd Unix socket.
- kubelet stopped routing through Docker Engine's dockershim translation layer and started talking to containerd directly over CRI. What stayed the same: the image format (still standard OCI, built with
docker buildor anything else) and the fact that containerd was doing the actual container lifecycle work the entire time, translator or not. - A PodSandbox is the shared network (and IPC/UTS) namespace every container in a Pod joins, created before any of the Pod's own containers start. On Linux it's an actual running container built from the
pauseimage, whose only job is to hold those namespaces open. SystemdCgroup, which has to match kubelet's own cgroup driver (systemd, by default and convention since 1.22), andsandbox_image, which has to be an image both kubelet and containerd agree is the sandbox image and that's actually reachable from the node — get either wrong and kubelet fails to start, or every Pod on the node hangs at sandbox creation.- A containerd namespace isolates one client's images/containers/content from another's inside one node's local containerd daemon — it has nothing to do with a Kubernetes Namespace, which scopes API objects inside etcd and the API server. The CRI plugin always uses the containerd namespace named
k8s.io;ctrdefaults todefaultinstead, which is why it looks "empty" on a busy node until you pass-n k8s.io. - Because
crictltalks CRI directly to containerd over the node's local Unix socket — it never toucheskube-apiserverat all, so an outage anywhere in the kubectl → API server → kubelet chain has no effect on it, as long as you can still SSH onto the node itself. - Restarting the containerd daemon does not kill running containers — each one's
containerd-shim-runc-v2process is independent and simply reconnects once containerd is back. Rebooting the node does: nothing about those shims survives the kernel itself going down, so every container on that node restarts along with it.