Skip to content
Stribog

Runtime

All writing

containerd vs Docker: What Kubernetes Actually Talks To

Docker Engine runs on containerd; since 1.24 the kubelet speaks only CRI. What dockershim's removal changed, and what each layer shows an auditor.

Stribog14 min read

Most pages that compare containerd and Docker stop at one sentence, "Docker is built on containerd", followed by a feature table. That sentence is true, and it is also where the useful part starts. The runtime layer became invisible once Kubernetes stopped talking to Docker, and an invisible layer is the one an upgrade plan forgets and an audit misses. This article draws the two stacks, walks through what the dockershim removal really changed on a node, shows how to find out what your nodes run, and then covers the parts comparison tables skip: where registry trust lives in each layer, how Docker's containerd image store connects to build attestations, containerd's pull-time verifier hook, and a runtime support calendar that is moving under you this quarter. Versions and dates are as of October 2026.

Two layers, not two rivals

Docker's own documentation states the relationship plainly: Docker Engine uses containerd for managing the container lifecycle, which includes creating, starting and stopping containers, and by default containerd uses runc as its container runtime. So on a machine with Docker installed, the stack reads top to bottom as the docker CLI, then dockerd (with BuildKit inside it for builds), then containerd, then runc, which makes the actual kernel calls to set up namespaces and cgroups.

A Kubernetes node on containerd has a shorter stack. The kubelet talks gRPC to containerd's built-in CRI plugin, the Container Runtime Interface implementation that ships inside containerd, and containerd again hands the container to runc. There is no dockerd anywhere in that path. The bottom two boxes are the same software; the tops are different products for different jobs.

  • Docker Engine is a developer and build toolchain: a CLI people type into, an API that tools such as Compose call, BuildKit for builds, and an image store on the build host.
  • containerd is a runtime daemon with a gRPC API. It manages images, snapshots and container lifecycle for whichever client calls it, and it graduated from the CNCF in February 2019, the fifth project to do so after Kubernetes, Prometheus, Envoy and CoreDNS.
  • runc is the low-level OCI runtime under both. Swapping it for a sandboxed runtime is a separate decision, covered in containers vs VMs.
Docker Engine and a Kubernetes node share containerd and runc at the bottom. Above that they are different tools: a build toolchain on one side, the kubelet's CRI client on the other, joined only by the registry.

Read the diagram as a statement of where each concern lives. Build-time concerns, such as BuildKit, attestations and the developer's image cache, sit on the left. Run-time concerns, such as the kubelet's CRI calls, registry mirrors for nodes and pull gates, sit on the right. The registry is the only thing both sides touch, which is why it is the right place to put your trust anchor, as the Harbor and Zot registry guide argues at length.

What the dockershim removal actually changed

Early Kubernetes talked to Docker through dockershim, an adapter compiled into the kubelet that translated CRI calls into Docker API calls. The Kubernetes project's 2022 FAQ gives the timeline: the removal was announced as part of the v1.20 release, and the v1.24 release actually removed the dockershim. A second change followed: Kubernetes from v1.26 only works with v1 of the CRI API, and a runtime that does not support the v1 API will not register as a node.

What did not change matters as much. The same FAQ is explicit that images produced by docker build work with all CRI implementations, and that all existing images still work exactly the same, because they are OCI images and every CRI runtime pulls OCI images. It is equally explicit that developers using Docker on their own machines are unaffected: nothing changes for local Docker, whatever runtime a cluster uses. The removal was a node-side change, not a developer-side one.

That leaves three paths for a node after 1.24:

  1. containerd with its CRI plugin: the shortest stack, with no Docker component on the node.
  2. CRI-O: a runtime built specifically to implement the Kubernetes CRI.
  3. Docker Engine behind cri-dockerd: the FAQ records that Mirantis and Docker committed to maintaining a replacement adapter for Docker Engine, named cri-dockerd, after the in-tree dockershim was removed. It keeps dockerd on the node and adds a hop: kubelet to cri-dockerd to dockerd to containerd.

The third path exists for clusters that genuinely depend on Docker Engine on the node. It is an adapter path, not a recommendation: it puts two extra daemons between the kubelet and the runtime that does the work, and each is another component to patch and another log to collect. For most teams the honest reading of the removal is that Kubernetes formalised what was already true, that the node needs containerd, and stopped carrying a translation layer to reach it.

Find out what your nodes are really running

Start from the cluster. kubectl get nodes -o wide prints a CONTAINER-RUNTIME column with the runtime and its version, such as containerd://… or docker://…. On the node itself, the kubelet's --container-runtime-endpoint names the socket it calls; Kubernetes' guide gives unix:///run/containerd/containerd.sock as the containerd endpoint and notes that the older --container-runtime flag is not available from v1.27. Then use the CRI's own client, crictl, rather than docker, to see what the kubelet sees.

bash
#!/usr/bin/env bash
# Runtime triage. The first command runs from a workstation; the rest run on the node.
set -euo pipefail

kubectl get nodes -o wide          # CONTAINER-RUNTIME column, e.g. containerd://2.3.x

# Which socket does the kubelet call?
tr '\0' ' ' < "/proc/$(pgrep -o kubelet)/cmdline" \
  | grep -o -- '--container-runtime-endpoint=[^ ]*' \
  || echo 'not set as a flag; check the kubelet config file'

sudo crictl ps                      # containers the kubelet manages
sudo crictl images                  # images the kubelet pulled

sudo ctr images ls                  # default namespace: often empty on a node
sudo ctr --namespace k8s.io images ls
sudo nerdctl --namespace k8s.io ps -a
Node triage on a containerd node. crictl reads its endpoint from /etc/crictl.yaml (below); ctr and nerdctl need the k8s.io namespace to see Kubernetes' images and containers.
yaml
# /etc/crictl.yaml
runtime-endpoint: unix:///run/containerd/containerd.sock
image-endpoint: unix:///run/containerd/containerd.sock
timeout: 10
debug: false
Adapted from the Kubernetes crictl guide, which uses the /var/run path; on typical distributions /var/run is a symlink to /run. Debug is off here for day-to-day use.

The last three lines of the triage script explain the most common "missing images" confusion. containerd partitions its clients into namespaces: per its documentation, underlying image content is shared by content address, but image names and metadata are separate per namespace. Kubernetes uses the k8s.io namespace, which is why the nerdctl README tells you to run nerdctl --namespace k8s.io ps -a to list Kubernetes containers. A plain ctr images ls on a node looks in the default namespace and can come back empty while the kubelet has dozens of images cached.

Where the two layers break differently

The Kubernetes FAQ lists what to check when changing CRI implementations, and the list reads as an inventory of every place Docker leaked out of its layer: logging configuration; runtime resource limitations; node provisioning scripts that call docker or use Docker Engine via its control socket; configuration of registry-mirrors and insecure registries; and support scripts or daemons that expect Docker Engine and run outside Kubernetes, such as monitoring or security agents. Logging and resource limits need re-checking on the new runtime too; the four below break most visibly.

  • Provisioning scripts calling docker become crictl for inspection, or nerdctl --namespace k8s.io where a Docker-shaped CLI is needed.
  • Workloads mounting docker.sock are the ones that break silently. The FAQ points to the Detector for Docker Socket (DDS), a kubectl plugin that checks whether active workloads mount docker.sock as a volume. Any hit is a pod that had root-equivalent access to the node; the fix is to remove the mount, not to re-create it against containerd's socket.
  • registry-mirrors in daemon.json move to per-registry hosts.toml files, covered in the next section.
  • Monitoring and security agents that read Docker's API need versions that speak CRI or containerd. Check before the node swap, not after.

containerd has failure modes of its own. Kubernetes' setup guide warns that containerd installed from an RPM or .deb package may ship with the CRI integration plugin disabled, and that cri must not appear in disabled_plugins in /etc/containerd/config.toml. The symptom is a kubelet that cannot reach a runtime even though containerd is running. The second trap is the cgroup driver. The kubelet and the runtime must agree, and on a systemd host that means the systemd driver. On Kubernetes 1.34+ with containerd 2.x the kubelet takes the driver from the runtime, so the containerd setting is the one that decides. The same guide calls changing the driver on a node that has already joined a cluster a sensitive operation: pods created under one driver's semantics can fail when their sandboxes are re-created under the other. Its advice, where automation makes it feasible, is to replace or reinstall the node rather than flip the setting in place, because a kubelet restart may not clear the errors.

Configuring containerd as the node runtime

containerd 2.0 introduced configuration version 3 and renamed several plugin IDs, which is the most common reason a 1.x config pasted onto a 2.x node quietly does nothing. The CRI runtime settings moved from io.containerd.grpc.v1.cri to io.containerd.cri.v1.runtime, and registry settings now sit under io.containerd.cri.v1.images. A minimal 2.x fragment that sets the systemd cgroup driver and points registry configuration at a directory looks like this:

toml
# /etc/containerd/config.toml (containerd 2.x)
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

[plugins.'io.containerd.cri.v1.images'.registry]
  config_path = '/etc/containerd/certs.d'
containerd 2.x plugin IDs with config version 3. On 1.x the runc options live under io.containerd.grpc.v1.cri instead; do not mix the two. containerd 2.3+ prints version = 4 from config default and migrates version 3 on startup. SystemdCgroup = true is the driver containerd 2.x reports to the kubelet through the RuntimeConfig CRI call; on Kubernetes 1.34+ the kubelet uses that reported driver and ignores cgroupDriver in the KubeletConfiguration. On containerd 1.x the kubelet falls back to cgroupDriver, whose default is still cgroupfs, so those nodes also need cgroupDriver: systemd; Kubernetes' container-runtimes page says that fallback is removed in 1.38.

config_path replaces Docker's registry-mirrors. containerd's CRI plugin looks for a hosts.toml per registry host under that directory, and its documentation marks the older registry.mirrors and registry.configs settings as deprecated, used only when config_path is not set. The hosts.toml format arrived in containerd v1.5 and applies to ctr, containerd image-service clients and CRI clients alike, but each client has to be pointed at the directory. The kubelet and crictl pull through the CRI plugin, which reads config_path; ctr uses the same files only when invoked with --hosts-dir /etc/containerd/certs.d, so a mirror test run with a bare ctr images pull bypasses the file the kubelet uses. To send Docker Hub pulls through a self-hosted proxy cache with upstream as the fallback:

toml
# /etc/containerd/certs.d/docker.io/hosts.toml
server = 'https://registry-1.docker.io'

[host.'https://registry.internal.example.com/v2/dockerhub-proxy']
  capabilities = ['pull', 'resolve']
  ca = '/etc/containerd/certs.d/docker.io/registry-internal-ca.crt'
  override_path = true
Mirror first, upstream server as fallback. override_path tells containerd the API root is in the URL path, as hosts.md shows for path-based mirrors. Host, path and CA file are illustrative.

Two choices in that file are policy, not plumbing. Granting resolve lets the mirror turn a tag into a digest, so only grant it to a host you trust as much as the upstream; containerd's docs show pull-only as the lower-trust option. And the server line is the fallback when every host entry fails, which is why this file is mirror-first with public fallback, not an air-gap config. Deleting server does not remove the fallback: containerd's docs say an omitted server defaults to the image's registry host namespace, and for docker.io that is filled in as https://registry-1.docker.io. For an air-gapped node, set the internal mirror itself as server and list no public host; only the mirror is then used.

Images and the supply-chain story at each layer

This is where the two layers stop being interchangeable. On the build side, Docker Engine has two image stores: the classic graph-driver store and the containerd image store. Docker's documentation says the containerd image store is the default storage backend for Docker Engine 29.0 and later on fresh installations, while upgraded daemons keep the legacy overlay2 store until it is enabled. The difference is not cosmetic. Docker lists working with images that include provenance and SBOM attestations as a containerd-store feature, because attestations use image indices the classic store does not support. Its build documentation is blunter: the classic store cannot hold an image with attestations, and a build with the default docker driver fails if the daemon still uses the classic store.

json
{
  "features": {
    "containerd-snapshotter": true
  }
}
Add this key to an existing /etc/docker/daemon.json on an upgraded build host; replacing the file drops other settings. Switching backends hides, but does not delete, images and containers created under the other one.

Two caveats keep this honest. Docker notes that the containerd image store is not available when user-namespace remapping (userns-remap) is on, so a hardened build host using it cannot take this route with the default driver. And the attestation limit is specific to that default driver: Docker's documentation notes that the docker-container, kubernetes and remote BuildKit drivers produce attestations regardless of the daemon's image store. Either way, the evidence is created at build time and travels to the registry inside the image index, where a signing and admission chain can use it; the supply chain guide covers SBOMs, Sigstore and Kyverno enforcement end to end.

On the node side, containerd offers a pull hook. Its default bindir image-verifier plugin runs every executable in a configured directory before an image is pulled, passing the image reference as -name, the resolved digest as -digest, and a descriptor on stdin. Exit 0 allows the pull; any other exit code blocks it; all verifiers must allow, an AND across the directory. A verifier that times out or fails to exec returns an error and no judgement, not an allow.

bash
#!/usr/bin/env bash
# /opt/containerd/image-verifier/bin/10-digest-allowlist
# Allows a pull only if its resolved digest is listed in the allowlist file.
set -euo pipefail

allowlist=/etc/containerd/allowed-digests.txt
name=''
digest=''

while [ "$#" -gt 0 ]; do
  case "$1" in
    -name) name="$2"; shift 2 ;;
    -digest) digest="$2"; shift 2 ;;
    -stdin-media-type) shift 2 ;;
    *) shift ;;
  esac
done

cat > /dev/null   # drain the descriptor JSON containerd writes to stdin

if [ -n "$digest" ] && grep -qxF -- "$digest" "$allowlist"; then
  echo "allowed: ${name}@${digest}"
  exit 0
fi

echo "blocked: ${name}@${digest} is not in ${allowlist}"
exit 1
A minimal verifier. It is enabled by setting bin_dir under the io.containerd.image-verifier.v1.bindir plugin; a missing allowlist file makes grep fail, so this verifier blocks rather than allows.

Put the layers together and each contributes different evidence. The build host contributes provenance and SBOMs, if its image store or driver can hold them. The registry contributes the signed artefact and the digest everything else refers to. The node contributes its hosts.toml trust configuration, its CRI plugin settings and, where used, a verifier. An auditor asking "what ran, and where did it come from" needs all three, and the build host's tooling only answers the first.

Exit ramps: CRI as the contract

The quiet benefit of the dockershim removal is that the node runtime is now behind a published interface. The kubelet only speaks CRI v1, and images are OCI. That means moving a node pool from containerd to CRI-O, or back, is a node-replacement exercise: build a new node image with the other runtime, cordon, drain, retire the old pool. Workloads, manifests and images do not change. Where clusters still run Docker Engine behind cri-dockerd, leaving it is the same exercise, plus the docker.sock and agent inventory above.

Tooling has an exit ramp too. nerdctl describes itself as a Docker-compatible CLI for containerd, so engineers keep familiar verbs on hosts without Docker, and it adds cosign integration with nerdctl pull --verify=cosign and nerdctl push --sign=cosign. Build hosts and developer laptops can keep Docker Engine, and self-hosted developer environments can standardise it, without the node ever depending on it. That is optionality expressed as layering: each side can change without asking the other.

The same contract is how other runtimes plug in. Sandboxed runtimes arrive as runc alternatives through RuntimeClass, and WebAssembly arrives as containerd shims, covered in SpinKube and runwasi. An immutable node OS such as Talos Linux pushes the same idea further: the node, runtime included, is versioned as one image instead of drifting package by package.

The long game: the runtime calendar is yours now

With Docker out of the node, the runtime is a dependency with its own release calendar, and that calendar is moving. As of October 2026, containerd's release table shows:

  • 1.7: end of life on 30 September 2026. Nodes still on it are already unsupported.
  • 2.0: extended support through March 2027 by two named maintainers, focused on Kubernetes 1.33 via GKE; changes outside that use may not be accepted.
  • 2.1: end of life on 3 July 2026.
  • 2.2: active until 6 November 2026, nine days after this article's date.
  • 2.3: LTS until 30 April 2028.
  • 2.4: started 16 September 2026, active until 16 May 2027.

Two more facts from the same document shape the plan. containerd commits to always having a supported version for every supported Kubernetes version, and its compatibility matrix pairs Kubernetes 1.36 with containerd 2.3.0+ or 2.2.0+, and 1.37 with 2.4.0+ or 2.3.0+. And upgrades between sequential LTS releases are supported; the document's own example is that a direct upgrade from 1.7 to 2.3 will be tested and supported. For a fleet still on 1.7, that makes 2.3 the obvious landing point: one jump, an LTS window into 2028, and coverage of the current Kubernetes minors. One jump is not a config-compatibility promise: the same document offers no compatibility guarantees across major versions and asks operators to clear 1.6/1.7 deprecation warnings before moving. The jump also crosses the config-version and plugin-ID change, so the config in this article belongs in the same change set.

The broader point is ownership. When Docker sat on the node, its release cadence hid the runtime's. Now containerd is a versioned component with end-of-life dates, a config format that changed between majors, and trust settings that decide what your nodes will pull. Put it in the inventory, pin its version in the node image, and treat its upgrades like the kubelet's. The comparison in the Docker vs LXC article asks which kind of container you need; this one answers which daemon runs it, and the answer to both is only durable when you own the layer.

§FAQ/Common questions

Frequently asked

Is containerd a replacement for Docker?

Not on a developer machine. Docker Engine uses containerd underneath to manage containers, and adds the CLI, the API, BuildKit and build tooling on top. On a Kubernetes node, containerd replaces Docker because the kubelet only needs a CRI runtime, and containerd ships a CRI plugin.

Do images built with docker build still run on containerd?

Yes. The Kubernetes project's dockershim FAQ states that images produced by docker build work with all CRI implementations and that existing images work exactly the same, because they are OCI images.

Why does ctr images ls show nothing on a Kubernetes node?

containerd keeps image names and metadata separate per namespace, and Kubernetes uses the k8s.io namespace. Run ctr --namespace k8s.io images ls, crictl images, or nerdctl --namespace k8s.io images to see what the kubelet pulled.

Can I still use Docker Engine as the runtime for Kubernetes?

Only through an adapter. The in-tree dockershim was removed in Kubernetes 1.24, and the Kubernetes FAQ points to cri-dockerd, maintained by Mirantis and Docker, as the external adapter. It adds daemons between the kubelet and containerd, so most clusters use containerd or CRI-O directly.

Which containerd version should a node run in late 2026?

As of October 2026, containerd 1.7 is end of life, 2.2 ends on 6 November 2026, and 2.3 is LTS until 30 April 2028. containerd states that a direct upgrade from 1.7 to 2.3 will be tested and supported, and lists 2.3.0+ as compatible with Kubernetes 1.36 and 1.37.

containerd vs dockerdockershim removal kubernetes 1.24cri-dockerd docker engine kubernetescontainerd CRI plugin config.tomlcrictl vs docker cli node debuggingcontainerd namespaces k8s.io

Executive Briefing

Thirty minutes to clarify your infrastructure risk

Walk us through your vendor footprint and regulatory constraints. We will tell you honestly where sovereignty creates leverage — and where it does not. No pitch deck. No obligation.