containerd doesn’t actually do anything itself; it’s a shim-and-runtime orchestrator that delegates almost all its work to plugins.
Let’s see this in action. Imagine you want to pull an image. When containerd receives the PullImage RPC, it doesn’t have a PullImage function. Instead, it looks at its configuration, finds the cri plugin (if you’re using Kubernetes), and calls the PullImage method on that plugin. The cri plugin then might call the PullImage method on the images plugin, which in turn might call the PullImage method on the content plugin, and so on.
# Example containerd config snippet
plugins = [
"io.containerd.grpc.v1.cri",
"io.containerd.content.v1.content",
"io.containerd.images.v1.images",
"io.containerd.metadata.v1.metadata",
"io.containerd.runtime.v1.linux",
"io.containerd.runtime.v2.task",
"io.containerd.internal.v1.debug",
"io.containerd.gc.v1.scheduler",
"io.containerd.snapshotter.v1.overlayfs"
]
At its core, containerd is a gRPC server that exposes a set of APIs. When you interact with containerd (e.g., via ctr or the Kubernetes CRI interface), you’re talking to these gRPC endpoints. Each endpoint is managed by a specific plugin. The plugins array in containerd’s configuration file (/etc/containerd/config.toml by default) is the manifest of what’s available. Each string in that array is a unique identifier for a plugin. When containerd starts, it iterates through this list, finds the corresponding plugin implementation, and registers its gRPC services.
The mental model here is a central dispatcher that routes requests to specialized handlers. The io.containerd.grpc.v1.cri plugin handles all Kubernetes-specific concerns, like translating CRI requests into lower-level containerd operations. The io.containerd.content.v1.content plugin is responsible for fetching and storing image layers (blobs). The io.containerd.images.v1.images plugin manages image metadata, mapping image names and tags to specific content blobs. The io.containerd.runtime.v2.task plugin is what actually interacts with the OCI runtime (like runc) to create, start, and stop containers.
The beauty of this is extensibility. If you wanted to add support for a new OCI runtime, you wouldn’t modify containerd’s core. You’d write a new plugin that implements the necessary interfaces and register it in the plugins array. For example, a hypothetical io.containerd.runtime.v2.firecracker plugin could be added to manage containers using Firecracker microVMs.
When a plugin is initialized, it often depends on other plugins. For instance, the cri plugin needs access to the content plugin to pull images and the images plugin to manage image metadata. This dependency is managed through a shared context that containerd provides during plugin initialization. Each plugin registers its services with this context, and other plugins can then look up and call these services.
The snapshotter plugins (like io.containerd.snapshotter.v1.overlayfs) are crucial for efficient image layering. When you pull an image, its layers are stored as blobs by the content plugin. The snapshotter then takes these blobs and creates a mountable filesystem layer. When you create a container, multiple image layers are stacked, and the snapshotter handles the union filesystem creation, meaning only unique layers are downloaded and stored, and common layers are shared across containers.
The most surprising thing is how little actual "runtime" logic is in the containerd binary itself; it’s primarily a plugin loader and gRPC server, acting as a sophisticated RPC router to its various plugin components. The actual execution of a container is delegated to a separate shim process managed by a runtime plugin, isolating the container’s execution environment from the main containerd daemon.
The next step in understanding this system is to dive into how these plugins communicate with each other beyond just RPC calls, specifically how they manage shared state and resources.