Mohamed Ali Djemal · blog

containerd and OCI Runtimes: What Runs Your Containers?

If you have used Docker or Kubernetes, you have almost certainly encountered names like containerd, runc, and OCI. But how do these components interact when you type docker run or nerdctl run? What actually creates the isolation on Linux?

Containers are not lightweight virtual machines—they are ordinary Linux processes isolated by kernel primitives (namespaces, cgroups, and chroot/pivot_root). To manage these primitives efficiently and reliably at scale, modern container infrastructure is split into distinct, modular layers.

In this guide, we will unpack each layer of the container stack, explore the tools used to interact with them directly, and trace what happens behind the scenes from command invocation to process execution.


The Big Picture: How the Stack Fits Together

Before diving into individual components, let’s examine the architectural hierarchy:

containerd and OCI Runtimes: What Runs Your Containers?


1. What is containerd?

containerd is an industry-standard container runtime daemon that manages the complete container lifecycle on a host. Originally developed by Docker Inc. in 2014, it was donated to the Cloud Native Computing Foundation (CNCF) in 2017 and is now a graduated, battle-tested project.

It is designed to be minimal, robust, and embeddable into larger orchestration systems or developer platforms like Docker.

Core Responsibilities

  • Image Management: Pulls images from OCI/Docker registries, validates cryptographic digests, unpacks image layers, and manages storage snapshotters (such as overlayfs).
  • Container Lifecycle Management: Creates, starts, pauses, stops, and deletes containers.
  • Root Filesystem Snapshots: Prepares the union mount (overlay2) representing the container’s writable and read-only layers.
  • Content Store: Manages immutable content-addressed image blobs and metadata on local storage.
  • gRPC API: Exposes a high-performance gRPC interface over a UNIX domain socket (/run/containerd/containerd.sock).

2. How to Interact with containerd

Because containerd is a daemon designed primarily for programmatic consumption, choosing the right CLI tool depends on whether you are debugging the runtime internals or running general-purpose containers.

which CLI tool to select?

1. ctr — Native containerd Debugging Tool

ctr is bundled directly with the containerd binary release. It speaks containerd’s native internal gRPC API directly. It was built specifically for containerd developers to test features, not as a general-purpose Docker replacement.

One crucial concept in containerd is namespaces (distinct from Linux kernel namespaces). containerd isolates images and containers into logical groups:

  • moby: used by Docker
  • k8s.io: used by Kubernetes
  • default: the default namespace when using ctr
Basic ctr commands
# Pull an image into the default namespace
sudo ctr images pull docker.io/library/alpine:latest
# List images in the default namespace
sudo ctr images ls
# Run an interactive container with host networking
sudo ctr run --rm -t --net-host docker.io/library/alpine:latest debug-box sh
# List active container tasks (processes)
sudo ctr tasks ls

2. nerdctl — Docker-Compatible CLI for containerd

nerdctl is a community project maintained under the containerd GitHub organization. It aims to provide the exact same user experience and CLI syntax as docker, but talks directly to containerd.

It supports:

  • Full docker run, docker ps, docker exec syntax
  • Building images via BuildKit (nerdctl build)
  • Multi-container applications via nerdctl compose
  • Inspecting and managing containers across custom namespaces
Basic nerdctl commands
# Run an ephemeral container
sudo nerdctl run -it --rm alpine sh
# Run a detached Nginx server with port mapping
sudo nerdctl run -d --name web -p 8080:80 nginx:alpine
# Build an image using BuildKit
sudo nerdctl build -t my-microservice:v1 .
# Inspect running containers
sudo nerdctl ps

3. What is an OCI Runtime?

While containerd manages high-level concerns like pulling images and mounting filesystems, it does not create container processes itself. That responsibility belongs to a low-level OCI runtime.

The Open Container Initiative (OCI) is an open governance body that standardizes two primary specifications:

  1. OCI Image Specification: How container images are packaged, tarred, and indexed via manifest layers.
  2. OCI Runtime Specification: How a container is unpacked into an OCI Bundle on disk and executed by the operating system.

What is an OCI Bundle?

An OCI runtime does not know anything about registries, tags, or tar archives. It only expects a directory on the local filesystem called an OCI Bundle, which contains two things:

  1. rootfs/: A directory containing the complete unpacked filesystem of the container (often assembled via an overlayfs mount).
  2. config.json: A standard JSON file describing:
    • Command and arguments to execute (process.args)
    • Environment variables (process.env)
    • Linux namespaces to create (pid, network, mount, ipc, uts, user)
    • Resource limits (cgroups for memory, CPU, pids)
    • Capabilities to drop or retain (e.g. CAP_NET_RAW, CAP_SYS_ADMIN)
    • Mount points (/proc, /sys, /dev/pts)
Sample config.json excerpt
{
"ociVersion": "1.0.2",
"process": {
"terminal": false,
"user": { "uid": 0, "gid": 0 },
"args": ["nginx", "-g", "daemon off;"],
"env": ["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"]
},
"root": {
"path": "rootfs",
"readonly": true
},
"linux": {
"namespaces": [
{ "type": "pid" },
{ "type": "network" },
{ "type": "ipc" },
{ "type": "uts" },
{ "type": "mount" }
],
"resources": {
"memory": {
"limit": 536870912
}
}
}
}

runc: The Reference OCI Runtime

runc is the universal reference implementation of the OCI runtime specification. Written in Go with minimal C bindings (libcontainer), it is a small binary (~10 MB) whose sole job is to turn an OCI bundle into an isolated process.

When runc executes:

  1. Reads config.json: Validates security configurations, paths, and resource limits.
  2. Calls clone() with namespace flags: Creates a child process isolated in new Linux namespaces (CLONE_NEWPID, CLONE_NEWNET, CLONE_NEWNS, etc.).
  3. Applies cgroups: Adds the new process PID to the appropriate cgroup directory (e.g., /sys/fs/cgroup/memory/...) to enforce memory and CPU limits.
  4. Mounts rootfs and pivots root: Mounts the bundle’s rootfs and calls pivot_root (or chroot) to trap the process inside its own filesystem tree.
  5. Drops Linux Capabilities: Drops dangerous capabilities (CAP_SYS_ADMIN, CAP_SYS_RAWIO) according to the security profile.
  6. Calls execve(): Overwrites the current process memory with the target container entrypoint (e.g., nginx). The target binary is now running as PID 1 inside the container’s PID namespace.
  7. Exits: Once the target process is running, runc terminates!

Why is the containerd-shim Necessary?

If runc exits immediately after launching the container process, who keeps track of the container?

This is where the containerd-shim (specifically containerd-shim-runc-v2) plays a critical role. For every container, containerd launches a tiny, long-lived shim process.

The shim serves three vital functions:

  1. Daemonless Containers: It acts as the subreaper and parent of the container process. If containerd crashes or restarts during an upgrade, running containers do not terminate.
  2. I/O Handling: It holds open the standard file descriptors (stdin, stdout, stderr) and FIFO pipes, preventing them from blocking or closing when containerd restarts.
  3. Exit Code Propagation: When the container process finally exits, the shim collects the exit status code and informs containerd over an internal RPC channel.

4. Putting It All Together: The Lifecycle of a Container

Let’s trace the complete journey when you run a container:

Terminal window
nerdctl run -d --name web nginx
  1. Client Request: The client tool (nerdctl or Docker CLI) sends a container creation request to containerd via the gRPC socket (containerd.sock).
  2. Image Preparation: containerd checks its local content store. If the image is missing, it pulls the OCI image from the registry, verifies hashes, and unpacks the layers into its storage snapshotter (e.g., overlayfs).
  3. Bundle Creation: containerd creates the OCI bundle directory containing rootfs/ (the stacked overlay filesystem) and config.json (the process arguments, environment variables, namespace definitions, and resource limits).
  4. Shim Launch: containerd spawns containerd-shim-runc-v2 for this container instance.
  5. Process Spawning (runc): The shim invokes runc create and runc start. runc configures Linux kernel namespaces, places the process into the appropriate cgroups, executes pivot_root, drops Linux capabilities, and calls execve() to run Nginx.
  6. Handoff & Monitoring: runc exits cleanly. The shim remains alive as the container’s parent process, capturing logs (stdout/stderr), maintaining pipes, and awaiting termination signals or exit codes.

Summary Cheat Sheet

ComponentLayerPurposeKey Example / Tool
containerdHigh-level runtime daemonManages images, snapshots, content stores, and container lifecyclescontainerd.sock, ctr
nerdctlGeneral-purpose CLIDocker-compatible CLI talking directly to containerdnerdctl run -d nginx
ctrInternal debug CLILow-level debugging CLI bundled with containerdsudo ctr images ls
containerd-shimProcess monitorDecouples containers from daemon; persists stdio and exit codescontainerd-shim-runc-v2
runcLow-level OCI runtimeReads OCI bundle (config.json + rootfs) and calls clone()runc (OCI reference)

Understanding this separation of concerns makes diagnosing container issues straightforward:

  • If images fail to pull or layers won’t mount →\rightarrow check containerd and snapshotters.
  • If a container crashes on startup with permission or syscall errors →\rightarrow inspect runc and OCI config.json capabilities.
  • If a container process disappears or exits without logs →\rightarrow inspect the shim and host cgroup OOM logs.