containerd’s gRPC API is how you actually talk to the daemon, not through docker or nerdctl commands.

Let’s say you want to create a container. You’re not just telling containerd "make me a container." You’re sending a structured message over a network socket.

// This is a simplified Go example
package main

import (
	"context"
	"log"

	"github.com/containerd/containerd/api/types/mount"
	"github.com/containerd/containerd/api/types/plugin"
	"github.com/containerd/containerd/pkg/clientconn"
	"github.com/containerd/containerd/v2/core/namespaces"
	"github.com/containerd/containerd/v2/core/runtime/v2/tasks"
	"github.com/containerd/containerd/v2/pkg/cri"
	"google.golang.org/grpc"
)

func main() {
	// Connect to the containerd API socket
	conn, err := clientconn.NewClientConn(context.Background(), containerd.DefaultAddress)
	if err != nil {
		log.Fatalf("failed to connect to containerd: %v", err)
	}
	defer conn.Close()

	// Create a new tasks client
	taskClient := tasks.NewTasksClient(conn)

	// Define container configuration
	containerID := "my-programmatic-container"
	imageName := "docker.io/library/alpine:latest"
	namespace := "default" // Or your custom namespace

	// Pull the image (this is a separate API call, usually done first)
	// ... image pull logic ...

	// Create the container
	// This is where the magic happens: we send a CreateTask request
	resp, err := taskClient.CreateTask(context.Background(), &tasks.CreateTaskRequest{
		Container: &tasks.ContainerConfig{
			ID:      containerID,
			Image:   imageName,
			Runtime: cri.RuntimeName, // Using the CRI runtime
			// Other configurations like mounts, env vars, etc. go here
			Mounts: []mount.Mount{
				{
					Type:    "bind",
					Source:  "/tmp/mydata",
					Target:  "/data",
					Options: []string{"rw", "bind"},
				},
			},
		},
		// Task options, e.g., for checkpointing or specific runtime configurations
		Options: nil,
	})
	if err != nil {
		log.Fatalf("failed to create task: %v", err)
	}

	log.Printf("Container created successfully: %+v", resp.GetTask())

	// Now you can start the container, exec commands, etc.
	// ... start, exec, delete logic ...
}

The CreateTask call is the core. You’re not just asking for a container; you’re providing a full ContainerConfig. This config defines everything: the image to use (alpine:latest), the runtime (cri), any mounts (/tmp/mydata to /data), environment variables, labels, and more. It’s a detailed blueprint.

This API is the foundation for tools like nerdctl and even parts of docker itself. When you run nerdctl run -v /tmp/mydata:/data alpine, nerdctl is making a series of gRPC calls to containerd under the hood: pulling the image, creating the container with the specified mounts, and then starting it.

The system is built around a client-server model. The containerd daemon runs as a server, exposing its functionality via gRPC. Your client application (like a custom script or a higher-level tool) connects to this daemon over a Unix domain socket, typically /run/containerd/containerd.sock.

The key components you interact with via the API are:

  • tasks: For managing the lifecycle of containers (create, start, stop, delete, exec).
  • images: For managing container images (pull, push, list, delete).
  • namespaces: For isolating container resources.
  • leases: For managing resource ownership to prevent accidental deletion.
  • content: For low-level access to image layers and blobs.

When you call CreateTask, containerd doesn’t just magically conjure a container. It uses its configured runtimes (like io.containerd.runc.v2 for Runc or io.containerd.runw.v2 for Kata Containers) to actually create the OCI runtime specification. This spec is a JSON file that describes the container’s filesystem, process, network, and capabilities. The runtime then uses this spec to launch the isolated process.

The most surprising thing about the containerd API is its granular control over the OCI runtime specification. You can, for instance, programmatically modify the config.json that containerd generates before it’s passed to the low-level runtime. This allows for deep customization of container security profiles, resource limits, and device mappings without needing to fork containerd itself. You’re essentially hand-crafting the blueprint for the container’s isolation and execution environment.

The next step you’ll likely want to explore is how to execute commands inside a running container using the Exec API, which is crucial for scripting and debugging.

Want structured learning?

Take the full Containerd course →