K8s needed Docker. Then it didn't.

k8scontainerinfrastructure

K8s needed Docker. Then it didn't. Taking Docker out of the call path ran from Kubernetes 1.5 (December 2016) to 1.24 (May 2022), and it left two throwaway translators behind.


Before CRI: why Docker was hardcoded

Kubernetes was open-sourced in 2014. In 2014, "container" mostly meant Docker. The Kubernetes team later put it this way: "there weren't really a lot of other options out there and Docker was the dominant tool for working with containers." So kubelet called the Docker API directly. No abstraction. No interface. kubelet knew how to talk to Docker the same way your code calls console.log. There was no layer in between.

This worked while Docker was the only runtime Kubernetes supported. Why build an abstraction for one implementation?


rkt shows up, K8s defines CRI

In December 2014, CoreOS announced rkt (pronounced "rocket"), a competing container runtime with a different architecture. rkt 1.0 shipped in February 2016. CoreOS wanted it to run under Kubernetes.

Supporting rkt meant writing a second set of runtime calls inside kubelet, and Kubernetes 1.3 (July 2016) did exactly that. The project was nicknamed "rktnetes". Now both Docker and rkt were wired into kubelet "through an internal and volatile interface", which the Kubernetes team said "incurs significant maintenance overhead". A third runtime would have meant a third integration.

So K8s did what any engineer does when two implementations exist: define an interface. Kubernetes 1.5 (December 2016) introduced CRI, the Container Runtime Interface, as alpha. A gRPC API. kubelet speaks CRI. Any runtime that implements CRI can plug in, with no per-runtime code in kubelet.

The 1.5 announcement listed the CRI implementations in progress: cri-o, rktlet (for rkt), frakti (VM-based runtimes), and a Docker CRI shim built into kubelet. containerd got its CRI implementation in 2017.

rkt pushed Kubernetes into defining CRI, but rkt itself faded. Red Hat announced it was acquiring CoreOS in January 2018, and Red Hat was already building CRI-O. The CNCF archived rkt in August 2019.


Docker Engine never spoke CRI

Docker also had its own orchestrator: Swarm. Same job as K8s, simpler interface. docker swarm init and you had a cluster. docker service scale web=5 and you had five replicas. No CRD, no etcd.

Docker Engine never implemented CRI. The Kubernetes team's 2022 summary: "Docker Engine's use as a container runtime predates CRI, and Docker Engine is not CRI-compatible." A search turned up no primary source explaining why Docker never added a CRI endpoint. Two things are on the record. Kubernetes shipped its own Docker shim in the same release that introduced CRI, so Docker users never waited on Docker for one. And Docker engineers did work on CRI support for containerd in 2017 (see Step 1 below).


K8s writes dockershim

K8s couldn't drop Docker. Docker was what most clusters ran on.

So in the same 1.5 release, kubelet got a built-in CRI shim for Docker: dockershim. It converted CRI calls into Docker API calls. The 1.5 announcement called it "the first step": "re-integrate Docker with kubelet using CRI." It lived inside the Kubernetes codebase. Kubernetes maintained it. Every CRI change meant updating dockershim too.

Era 0 (2014–2016): Docker calls written into kubelet (rkt calls too, from 1.3). No interface.

Era 1 (1.5, December 2016 onward): CRI exists. Docker Engine doesn't speak it. Kubernetes ships dockershim inside kubelet.

kubelet → dockershim → Docker Engine → containerd → runc → container

That's four hops before you reach runc, the part that actually creates the container.

The irony: since Docker 1.11 (April 2016), Docker Engine itself used containerd to manage containers and runc to create them. K8s was talking to Docker, who was talking to containerd.

In 2018 the Kubernetes team compared the two full paths. With 105 pods on a node, containerd 1.1 with its built-in CRI plugin ran against Docker 18.03 CE with dockershim. The containerd path had lower pod startup latency, 30.89% lower kubelet CPU, 68.13% lower container runtime CPU, and 11.30% and 12.78% lower memory (RSS) for kubelet and the runtime. The post doesn't say whether "container runtime" on the Docker side means dockerd alone or dockerd plus the containerd under it, so it's unclear which processes the 68.13% compares. The Docker path has dockershim and all of Docker Engine on top of what the containerd path has, and the software versions differ too. These percentages measure the gap between the two whole paths. The post didn't measure how much CPU and memory dockershim or Docker Engine used on its own.

Kubernetes' own 2022 retrospective says the shim "was never intended to be a permanent solution" and "introduced a lot of unnecessary complexity to the kubelet itself." It stayed in kubelet from 1.5 (December 2016) to 1.24 (May 2022), about five and a half years.


Cutting Docker out, in four steps

Step 1: cri-containerd skips Docker (2017, containerd 1.0)

If Docker Engine just hands the work to containerd, kubelet could talk to containerd directly.

Through 2017, engineers from Google, Docker, IBM, ZTE, and ZJU built cri-containerd, a standalone daemon that translated CRI calls into containerd calls. Its feature-complete alpha came out on September 25, 2017, and it ran on containerd 1.0 (December 2017).

Era 2: Docker Engine bypassed. But a new daemon added.

kubelet → cri-containerd (standalone daemon) → containerd → runc → container

Docker Engine was out of the path now, which was progress. But cri-containerd and containerd were still two daemons talking over gRPC: one more process to deploy and babysit.

Step 2: containerd absorbs the translator (containerd 1.1, April 2018)

In containerd 1.1, the cri-containerd code was refactored into a CRI plugin, built into containerd and enabled by default. The plugin calls containerd through direct function calls instead of gRPC. The separate daemon was no longer needed.

Era 3: One call. No middlemen.

kubelet → containerd (built-in CRI plugin) → runc → container

This is the path on every containerd-based node today. Three eras and two throwaway components (dockershim, cri-containerd) to arrive at "just talk to containerd directly."

Step 3: Red Hat starts from scratch (CRI-O)

While Docker was being taken out of the path, Red Hat built CRI-O from scratch. CRI-O 1.0 came out in October 2017. Purpose-built for K8s. No Docker legacy. No image build. No extras. Speaks CRI on top, speaks OCI on bottom. Does what K8s needs and nothing more.

Alternative path: kubelet → CRI-O → runc → container

OpenShift 4 has run on CRI-O in production since June 2019. containerd can do more things. CRI-O's pitch is that it shouldn't have to.

Step 4: K8s deletes dockershim (K8s 1.24, May 2022)

Kubernetes 1.20 (December 2020) deprecated dockershim, and 1.24 (May 2022) removed it. kubelet could no longer talk to Docker Engine on its own. Clusters still on Docker had to either switch to containerd or CRI-O, or install cri-dockerd to keep Docker as the runtime. On Nodes that dropped Docker, containers ran fine. What broke was docker.sock, which some CI setups used to build images.

The collateral damage: docker.sock

On a Node that used Docker Engine as its runtime, Docker Engine exposed /var/run/docker.sock. Some CI setups ran build jobs as Pods inside the cluster and mounted that socket to build images with the Node's Docker. The Kubernetes blog's name for this pattern is "Docker in Docker".

A Docker-runtime Node had two daemons:

  • Docker Engine → /var/run/docker.sock ← CI Pods mounted this
  • containerd → /run/containerd/containerd.sock ← Docker used this internally

After the Node switched to containerd as its runtime (and dropped Docker from the image), it had one:

  • containerd → /run/containerd/containerd.sock ← kubelet talks directly to this
  • Docker Engine → gone
  • docker.sock → gone

containerd has its own socket. But docker build doesn't speak containerd's API. It only talks to Docker's socket. Different program, different protocol.

docker build → looks for /var/run/docker.sock → file doesn't exist → fails

The problem wasn't a missing socket. It was that Docker was gone from the Node, and the docker CLI can't talk to anything else.

Three ways out:

  1. Keep Docker Engine on the Node and connect kubelet to it through cri-dockerd → docker.sock stays → pipeline unchanged
  2. Switch to kaniko → builds the image from a Dockerfile inside a container → no Docker daemon, no socket
  3. Switch to buildah → daemonless image builds, from Red Hat

The Kubernetes dockershim FAQ points to img, buildah, kaniko, and buildkit-cli-for-kubectl for building images without Docker. Google archived the GoogleContainerTools/kaniko repository in June 2025.

cri-dockerd kept pipelines working for teams that couldn't rewrite them overnight. But the call chain shows the detour:

kubelet → cri-dockerd → Docker Engine → containerd → runc

That's three hops to containerd; the containerd path takes one.


The three layers

Container management splits into three layers. Each layer has an interface. Each interface has multiple implementations.

High-level management     High-level runtime      Low-level runtime
(who gives orders)        (who manages lifecycle)  (who creates containers)
                │                    │                       │
                │       CRI          │          OCI          │
                │    (gRPC API)      │      (spec + API)     │
                ▼                    ▼                       ▼
Kubernetes ─────────→ containerd ──────────→ runc ─────→ container
crictl          ────→ CRI-O    ──────────→ kata-runtime → VM + container
docker          ────→ Docker   ──────────→ gVisor (runsc) → sandbox + container
podman          ────→ libpod

Every name in the left column:

  • Kubernetes / kubelet. talks CRI to whatever runtime is configured.
  • crictl. CLI debugging tool for CRI runtimes. Think of it as "docker ps for containerd." Speaks CRI directly. crictl ps shows K8s containers that docker ps can't see. It lives in kubernetes-sigs/cri-tools, a separate repo from Kubernetes.
  • docker. the Docker CLI. Talks to Docker Engine (dockerd) over Docker's own API. Nothing to do with CRI.
  • podman. Red Hat's Docker replacement for local development. Same CLI (podman build, podman run). Daemonless. each command runs its own process. Uses libpod as its container library. Not a K8s runtime. Not CRI. A developer tool for running containers without Docker.

CRI: the left interface

Container Runtime Interface. A gRPC API that kubelet speaks. Any high-level runtime that implements CRI can plug into K8s.

CRI only asks the minimum. It doesn't care what's behind the interface: Linux namespace, VM, sandbox. The 2016 CRI announcement says it directly: "For hypervisor-based runtimes, PodSandbox might represent a virtual machine." As long as you can "create an isolated environment, run a process, report status," you're a valid CRI implementation. That's why virtlet could run VMs pretending to be Pods.

The API splits into two gRPC services. Some of their calls:

RuntimeService (Pod and container lifecycle):

  • RunPodSandbox → create the sandbox (network namespace, etc.)
  • StopPodSandbox → stop it
  • CreateContainer → create a container inside a sandbox
  • StartContainer → start it
  • StopContainer → stop it
  • ListContainers → what's running?
  • ContainerStatus → is it healthy?
  • ExecSync → run a command inside a container
  • Attach → attach stdin/stdout
  • PortForward → forward a port

ImageService (image management):

  • PullImage → pull from registry
  • ListImages → what's cached?
  • RemoveImage → delete from cache
  • ImageStatus → size, digest, etc.

Full protobuf definition: kubernetes/cri-api/api.proto

kubelet doesn't care if it's talking to containerd or CRI-O. It calls RunPodSandbox, CreateContainer, StartContainer. The runtime handles the rest.

OCI: the right interface

Open Container Initiative. Three specs. Two of them matter here:

Image spec: how container images are packaged. Layers, manifests, digests. The OCI image spec work began in late 2015 "using the Docker v2.2 image format as a starting point." docker pull nginx and ctr image pull nginx get the same bytes. Docker's manifest v2 and OCI's manifest have minor structural differences. Registries and runtimes handle both transparently.

Runtime spec: how containers are created. A standard directory layout: config.json (namespaces, cgroups, mounts, env vars) plus a rootfs (the filesystem). Any low-level runtime that reads this format can run the container.

The third, the distribution spec (1.0 in 2021), defines the registry API.

containerd doesn't care if it calls runc or kata-runtime. It passes an OCI bundle. The low-level runtime reads config.json, sets up isolation, starts the process.


Every name explained

containerd

High-level runtime. Pulls images, manages snapshots (filesystem layers), calls runc to create containers, monitors their lifecycle. Doesn't create containers itself. Delegates to a low-level runtime.

containerd started inside Docker. With Docker 1.11 (April 2016), Docker moved container execution out of its daemon into two separate binaries, containerd and runc. In March 2017 Docker donated containerd to CNCF (the same foundation that hosts K8s). In February 2019 it graduated. Docker uses it. K8s uses it. Neither owns it.

runc

Low-level runtime. The default. Takes an OCI bundle, sets up Linux namespaces and cgroups, switches the root filesystem with pivot_root (plain chroot only with --no-pivot), then exec's the container process. Then exits.

That's the key: runc exits after creating the container. It does its one job and leaves. Keeping the container alive is someone else's problem.

containerd-shim

runc exits after creating the container. The shim stays, and because it registers itself as a child subreaper, the container's processes end up as its children. containerd can crash, restart, upgrade. The shim keeps the container alive. containerd starts it by running containerd-shim-runc-v2 start; that command launches the long-running shim in its own process group and exits, so the shim is no longer containerd's child.

v2 (containerd-shim-runc-v2, from the runtime v2 API in containerd 1.2) can serve every container in a Pod from one shim, grouped by the Pod's sandbox ID. Runtimes are named like io.containerd.runc.v2, and containerd maps that name to the containerd-shim-runc-v2 binary.

Deep dive: Inside containerd: How shim and runc Actually Work

CRI-O

High-level runtime. Red Hat's answer to containerd. Built specifically for K8s. No Docker legacy, no image build, no extras. Speaks CRI, calls OCI runtimes. Its minor versions follow Kubernetes minor versions (CRI-O 1.28 for K8s 1.28).

dockershim (dead)

Translator that lived in K8s source code. Converted CRI calls to Docker API calls. Removed in K8s 1.24. If you still need Docker as a runtime, use cri-dockerd instead.

cri-dockerd (the afterlife of dockershim)

In April 2021 Mirantis announced that the dockershim code had moved into a new repository, cri-dockerd, which Mirantis and Docker committed to maintain. Same translation logic. Different address. It sits in front of Docker Engine, so Docker Engine still has to be installed on the Node. The K8s 1.24 section above covers why it exists and why it's a detour.

The full routing picture

Three paths into runc. Every one ends at the same place.

                    ┌─ containerd (CRI plugin) → runc → container    ← mainstream
                    │
kubelet ── CRI ─────┤─ CRI-O → runc → container                     ← OpenShift
                    │
                    └─ cri-dockerd → Docker → containerd → runc      ← legacy detour

Low-level runtime alternatives

Containers are like apartments in a building. Every apartment (container) shares the same foundation (host kernel). runc builds apartments this way. Fast to move in, cheap to run. But if someone cracks the foundation, everyone's apartment is compromised.

The question: how do you give tenants better isolation?

Option 1: Lock the doors (seccomp, AppArmor)

Don't change the building. Just restrict what tenants can do. "You can use the kitchen and bathroom, but you can't touch the electrical panel or the gas pipes."

runc already supports this, and containerd ships a default seccomp profile. Under Kubernetes, though, a Pod runs with seccomp Unconfined unless it sets seccompProfile: RuntimeDefault or the kubelet runs with seccompDefault turned on.

Problem: the foundation is still shared. A crack in the foundation bypasses every locked door. You're controlling what tenants do, but you can't control what the building itself does when it breaks.

Option 2: Give everyone their own house (traditional VM)

Separate foundation for each tenant. Total isolation. But each "apartment" now boots its own kernel and needs its own memory reserved, so one server holds far fewer VMs than containers.

Option 3: gVisor (fake floor)

Google's approach. Build a fake floor between the apartment and the foundation. The tenant (app) thinks it's standing on the real foundation (Linux kernel). It's actually standing on gVisor's imitation floor (a user-space kernel called Sentry). Sentry intercepts every request to the real foundation and decides what to pass through.

Normal container:  app → host kernel (direct contact)
gVisor container:  app → Sentry (fake floor) → host kernel (filtered)

Safer. Slower (every request goes through an extra layer). Used for running untrusted code, like a cloud function platform where you don't know what users will upload.

Option 4: Kata (tiny house, real foundation)

Build a real, tiny house for each tenant. Each house has its own foundation (a lightweight VM with its own kernel). A crack in one foundation doesn't affect the others.

Normal container:  app → shared host kernel
Kata container:    app → guest kernel (own VM) → host kernel

Safest. Heaviest. Requires hardware virtualization support. Used when you absolutely cannot share a kernel (multi-tenant clouds, regulated workloads).

All of them plug into the same slot

gVisor (runsc) and Kata are both OCI runtimes. To use one, register it as a runtime handler in containerd's or CRI-O's config, create a RuntimeClass that names that handler, and set runtimeClassName in the Pod spec. For gVisor, the setup from its containerd quick start looks like this:

# /etc/containerd/config.toml
[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runsc]
  runtime_type = "io.containerd.runsc.v1"
apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata:
  name: gvisor
handler: runsc
---
# in the Pod spec
spec:
  runtimeClassName: gvisor

The app inside the container doesn't know the difference. Same image. The only change in the deployment YAML is that one field.


containerd namespace isolation

containerd isolates K8s workloads from Docker workloads using namespaces (not K8s namespaces, containerd's own concept).

$ sudo ctr namespaces ls
NAME    LABELS
k8s.io          # kubelet's containers
moby            # Docker's containers
  • kubelet → containerd → containerd-shim (k8s.io namespace) → runc → container
  • docker → containerd → containerd-shim (moby namespace) → runc → container

Two clients sharing one containerd. They can't see each other's containers. docker ps won't show K8s pods. crictl ps won't show Docker containers. Same runtime, but each side stays in its own world.


The timeline

YearEvent
2014Kubernetes open-sourced. Docker calls written into kubelet. No interface. CoreOS announces rkt (December)
2015Docker donates runc to the new Open Container Initiative (June). Kubernetes 1.0 (July)
2016Docker 1.11 runs containers through containerd and runc (April). K8s 1.3 adds rkt inside kubelet (July). K8s 1.5 introduces CRI as alpha, with dockershim built in (December)
2017Docker donates containerd to CNCF (March). cri-containerd alpha (September). CRI-O 1.0 (October). containerd 1.0 (December)
2018Red Hat announces it is acquiring CoreOS (January). containerd 1.1 with the CRI plugin built in (April). Docker for Mac ships Kubernetes in its stable channel (July)
2019containerd graduates from CNCF (February). OpenShift 4 runs on CRI-O (June). CNCF archives rkt (August). Docker sells its enterprise business to Mirantis (November)
2020K8s 1.20 deprecates dockershim (December). The announcement caused enough panic that Kubernetes published "Don't Panic: Kubernetes and Docker"
2021Mirantis announces cri-dockerd (April)
2022K8s 1.24 removes dockershim (May). Nodes that drop Docker Engine lose docker.sock. CI jobs that mounted it break

Eight years from "Docker is everything" to "Docker is optional." The interfaces, CRI and OCI, outlived the translators built to bridge them.


Where Docker ended up

Docker Engine never implemented CRI. Not in 2016. Not in 2018. As of October 2026 it still speaks only Docker's own API, and cri-dockerd is still a separate project (v0.4.9 was released in October 2026).

The industry routed around Docker. containerd got CRI support, and Kubernetes nodes stopped needing Docker Engine.

In November 2019 Docker sold its enterprise business to Mirantis. On November 13, Docker announced "the next chapter in the Docker story, one that's focused on developers." The focus moved to the other end of the pipeline: building images, not running them.

What Docker keptWhat Docker lost
Docker Desktop (developer local environment)Runtime on K8s clusters
Docker Hub (image registry)Docker Enterprise (sold to Mirantis in 2019; Swarm mode itself still ships in Docker Engine)
docker build (image construction)Docker Engine as the runtime most K8s clusters used
Docker Compose (local multi-container dev)dockershim (K8s deleted it, Mirantis picked up the pieces)

The current state of the industry:

Developer laptop:
  docker build → build image
  docker push → push to registry

Production K8s cluster:
  kubelet → containerd → runc → container
  (No Docker anywhere in this path.)

You build with Docker and run with containerd. Since K8s 1.24 deleted dockershim, kubelet talks straight to containerd, and nothing on a containerd Node needs Docker Engine anymore.

Docker donated its core

Docker donated runc to the OCI in 2015 and containerd to CNCF in 2017. That sounds like giving away the crown jewels. At containerd's CNCF graduation in 2019, Michael Crosby, a containerd maintainer and Docker engineer, described the goal as "to share a robust and extensible runtime."

Afterwards, Kubernetes nodes ran containerd directly, Docker Engine ran on containerd too, and the project belonged to CNCF, not to Docker or Google.


References