Guide · Container Runtime

MCP Tools for containerd — gRPC API, task lifecycle, and snapshot management

containerd is the container runtime that Kubernetes and Docker both use under the hood. While most developers interact with Docker or Kubernetes, building MCP tools directly against the containerd gRPC API is necessary when you are managing container workloads at the infrastructure layer — building custom Kubernetes-adjacent systems, managing containers on bare-metal nodes without Kubernetes, or writing deep-inspection tools that the higher-level APIs don't expose. containerd's API is more granular than Docker's: where Docker's docker run chains five or six internal steps, containerd exposes each step as a separate API call. The three concepts that matter most are the full image-to-task lifecycle (pull → snapshot → container → task → start — five distinct operations), snapshot management (the mechanism containerd uses for copy-on-write layers), and health probe depth (task status RUNNING does not mean the process inside is functional — it means the containerd shim has the process reference, not that the process is responsive).

TL;DR

Connect to containerd's gRPC socket at /run/containerd/containerd.sock. Use namespaces to isolate workloads (k8s.io for Kubernetes-managed containers, a custom namespace for MCP-managed ones). The full lifecycle from image to running process is: images.pull()snapshots.prepare() (creates the writable layer) → containers.create() (registers metadata) → tasks.create() (configures the process) → task.start() (begins execution). For health probes, check task.status() for RUNNING state, then verify the process inside is functional with a task-level exec. Register the container's HTTP endpoint with AliveMCP for external probing since containerd has no built-in health check equivalent to Docker's HEALTHCHECK.

containerd architecture: namespaces, images, snapshots, containers, and tasks

containerd organizes all resources into namespaces. A namespace is an isolation boundary — images, containers, and snapshots in one namespace are invisible to another. Kubernetes uses the k8s.io namespace; Docker uses moby. For MCP-managed workloads, create a dedicated namespace (e.g., mcp) to avoid accidental interference with Kubernetes-managed containers on the same node.

The five resource types and their relationships:

Node.js MCP tools interact with containerd via the @containerd/containerd client or directly over gRPC using @grpc/grpc-js with generated protobuf stubs. The containerd client library is simpler:

import { Client } from '@containerd/containerd';

const client = new Client('/run/containerd/containerd.sock', {
  namespace: 'mcp'
});

// List all containers in the 'mcp' namespace
const containers = await client.containers();
for (const c of containers) {
  console.log(c.id, c.labels);
}

The full lifecycle: pull → snapshot → container → task → start

containerd requires each lifecycle step to be called explicitly. This is more verbose than Docker's run but allows precise control over each step and clean recovery when a step fails.

Step 1: Pull the image

const image = await client.pull('docker.io/library/nginx:alpine', {
  resolved: true   // wait until all layers are downloaded
});
// image.name() returns the fully-qualified image reference
// image.size() returns total uncompressed layer size in bytes

Step 2: Create a snapshot (writable layer)

The snapshotter creates the container's writable root filesystem. You must explicitly unpack the image into the snapshotter before creating a snapshot from it:

// Unpack the image layers into the default snapshotter
await image.unpack(defaultSnapshotter);

// The snapshot key must be unique — use the container ID you plan to assign
const snapshotKey = `mcp-container-${Date.now()}`;
// 'prepare' creates an active (writable) snapshot on top of the image layers
// This does NOT copy all data — it creates a CoW overlay
const mounts = await client.snapshotService('overlayfs').prepare(
  snapshotKey,
  image.target.digest   // parent layer chain
);

Step 3: Create the container (metadata only)

const container = await client.newContainer('my-nginx', {
  image,
  snapshotter: 'overlayfs',
  snapshot: snapshotKey,
  runtime: { name: 'io.containerd.runc.v2' },
});

Step 4: Create a task (configure process)

const task = await container.newTask({
  stdin: process.stdin,     // or null for no stdin
  stdout: process.stdout,   // or a file stream
  stderr: process.stderr,
});

Step 5: Start the task (begin execution)

await task.start();
// The task's init process is now running
// task.pid() returns the host PID of the init process in the containerd shim
console.log('Task started with shim PID:', task.pid());

The full five-step lifecycle means that errors at each step are distinct. If pull fails, no snapshot or container exists. If prepare fails, the container and task don't exist. If task.start() fails, the container record exists and the snapshot exists — both must be cleaned up explicitly with task.delete() and container.delete() and snapshotService.remove(snapshotKey). Always implement cleanup in the reverse order of creation.

Snapshot management: committed vs active, and cleanup patterns

containerd's snapshotter manages two types of snapshots. An active snapshot is writable — it is prepared from a parent (the image layers) and is used exclusively by one container's filesystem. A committed snapshot is read-only — you create a committed snapshot from an active one using commit(), which is how container image layers are created. For MCP tools that only run containers, you will primarily work with active snapshots.

Snapshot naming matters for cleanup. containerd does not automatically associate a snapshot with a container by name — you must track the relationship yourself. A practical pattern is to name the active snapshot after the container ID with a prefix:

// Naming convention: {namespace}-{containerId}-rootfs
const snapshotKey = `mcp-${containerId}-rootfs`;

// List all active snapshots in the mcp namespace
const snapshotter = client.snapshotService('overlayfs');
for await (const snap of snapshotter.list()) {
  console.log(snap.key, snap.kind, snap.created_at);
}

// Cleanup: remove active snapshot after task and container are deleted
// Order matters: task → container → snapshot
await task.delete({ force: true });    // kills the process, removes shim
await container.delete();              // removes metadata
await snapshotter.remove(snapshotKey); // removes the writable layer

A common leak pattern: MCP tools that create containers but fail before starting the task leave active snapshots behind. These snapshots hold disk space but have no associated container metadata (the container record was never committed or was deleted). Implement a periodic garbage collection routine that lists all snapshots with the MCP prefix and checks whether the corresponding container still exists — if not, remove the orphaned snapshot.

exec processes: running commands inside a task

containerd's task.exec() creates a new process inside the task's namespace, analogous to Docker's exec. The exec process shares the task's mount namespace, network namespace, and PID namespace. It does not share the task's lifecycle — the exec process can exit independently of the init process, and the init process can exit while an exec process is still running (though the task will transition to the STOPPED state, which prevents new exec calls).

// Run a command inside a running task
async function execInTask(task, cmd) {
  const execId = `exec-${Date.now()}`;
  const process = await task.exec(execId, {
    args: cmd,               // e.g., ['/bin/sh', '-c', 'cat /proc/version']
    env: [],
    cwd: '/',
    terminal: false,
    stdout: true,
    stderr: true,
  });

  // Collect output
  let stdout = '', stderr = '';
  process.stdout?.on('data', d => stdout += d);
  process.stderr?.on('data', d => stderr += d);

  await process.start({});  // actually start the exec process
  const status = await process.wait();  // wait for exit

  return {
    exitCode: status.exitCode,
    stdout,
    stderr,
  };
}

Exec processes must be explicitly deleted after they exit: call process.delete() to clean up the exec metadata in containerd. Failing to delete completed exec processes accumulates metadata in the containerd store. A task can have multiple concurrent exec processes — containerd does not limit the count, but the host kernel limits the total number of processes. For MCP tools that run many concurrent exec operations on the same task, implement a semaphore or queue to bound the concurrency.

Health probe depth: task RUNNING is not process health

containerd's task status has four states: CREATED, RUNNING, STOPPED, and PAUSED. RUNNING means the containerd shim holds a reference to the init process and the process has not yet exited. It does not mean the process is responsive, the network stack is ready, or that the application inside is healthy.

async function taskHealth(task) {
  const status = await task.status();
  // status.status: RUNNING, STOPPED, PAUSED, CREATED
  // status.exitStatus: the exit code if STOPPED

  if (status.status !== TaskState.RUNNING) {
    return {
      healthy: false,
      reason: `task_${status.status.toLowerCase()}`,
      exitCode: status.exitStatus,
    };
  }

  // Task is RUNNING — verify the process inside is functional
  // Option 1: exec a health check command
  try {
    const result = await execInTask(task, ['sh', '-c', 'wget -qO- http://localhost:8080/health || exit 1']);
    if (result.exitCode !== 0) {
      return { healthy: false, reason: 'health_check_failed', stderr: result.stderr };
    }
    return { healthy: true };
  } catch (e) {
    return { healthy: false, reason: 'exec_failed', error: e.message };
  }
}

Unlike Docker, containerd has no built-in HEALTHCHECK equivalent. There is no health.status field in the task metadata. All health checking beyond "is the task RUNNING?" must be implemented by the MCP tool itself — either via exec-based probes or via external HTTP checks registered with AliveMCP. For containers running HTTP servers, register the container's published port with AliveMCP so that monitoring continues even when the MCP server is unavailable.

A task that becomes STOPPED unexpectedly (exit code != 0) does not restart automatically — there is no containerd equivalent of Docker's restart policy. MCP tools managing long-lived services via containerd must implement their own restart logic: watch for STOPPED events using the containerd events API (client.events()), filter for task exit events, and restart on non-zero exit codes according to your policy.

Watching containerd events for reactive monitoring

containerd emits events for all lifecycle transitions: image pull completion, task creation, task start, task exit, and snapshot operations. MCP tools can subscribe to these events for reactive monitoring rather than polling:

// Subscribe to containerd events in the mcp namespace
const events = client.events({
  filters: ['namespace=mcp', 'topic~=task']
});

for await (const event of events) {
  if (event.topic === '/tasks/exit') {
    const data = await event.decode();
    console.log(`Task ${data.id} exited with code ${data.exit_status}`);
    // Handle: cleanup, restart, alert
    await handleTaskExit(data.id, data.exit_status);
  }
  if (event.topic === '/tasks/oom') {
    const data = await event.decode();
    console.log(`Task ${data.id} was OOM-killed`);
    // OOM kills have exit code 137 (128 + SIGKILL=9)
  }
}

The events stream is persistent and low-latency. It is more efficient than polling task.status() for detecting exits. For MCP tools that register containers with AliveMCP, also emit an event-driven notification when a task exits unexpectedly — don't wait for AliveMCP's 60-second probe interval to surface the failure.

Frequently asked questions

When should I use the containerd API directly instead of the Docker Engine API?

Use the containerd API directly when you need capabilities that Docker's API doesn't expose, or when Docker is not the runtime layer in your environment. Specific cases: (1) You are building tools for a Kubernetes node (where containerd is the CRI runtime) and need to inspect or manage containers outside of Kubernetes' own control plane — for example, a diagnostic tool that checks why a specific pod container's OCI spec was generated with unexpected settings. (2) You need to work with snapshots directly — Docker does not expose snapshot management, but containerd lets you create committed snapshots from containers (building custom image layers without a Dockerfile build). (3) Your workload uses a non-standard OCI runtime (gVisor, Kata Containers, Firecracker-containerd) that is configured as a containerd runtime plugin — you need to specify the runtime by name at task creation time. (4) Performance-critical path: containerd's API avoids the JSON serialization overhead of Docker's HTTP API layer for very high-frequency operations. For most MCP tool use cases — managing development environments, running test workloads, inspecting service containers — the Docker Engine API is simpler and has better library support.

How do containerd namespaces relate to Kubernetes namespaces?

They are completely different concepts that happen to share the name "namespace." A containerd namespace is an isolation boundary within a single containerd daemon — it groups images, containers, snapshots, and events so that consumers (Docker, Kubernetes, your MCP tool) don't see each other's resources. A Kubernetes namespace is a cluster-level resource grouping concept that exists entirely within Kubernetes' control plane and has nothing to do with containerd. The relationship: when Kubernetes runs a pod container, the containerd client inside kubelet creates the container in the k8s.io containerd namespace. If you use the containerd API to list containers in the k8s.io namespace, you see all pod containers on that node. For MCP-managed containers that should not be visible to Kubernetes (or interfere with Kubernetes' view of the node), use a separate containerd namespace. The default namespace is used by ctr (the containerd CLI). The moby namespace is used by Docker. Pick a unique name for MCP-managed containers.

How do I use containerd snapshots to implement container checkpointing?

containerd supports CRIU-based container checkpointing: saving the full process memory state to disk so a container can be restored later (or on a different machine). The steps are: (1) Call task.checkpoint() — this triggers CRIU to dump the process memory, open file descriptors, and network connections to a checkpoint directory. Returns an OCI image that contains the checkpoint data. (2) To restore, pull the checkpoint image and pass the --checkpoint flag when creating the task — this creates a restore task instead of a normal start. Checkpointing requires the CRIU binary on the host (apt install criu on Ubuntu), kernel support for process checkpointing (CONFIG_CHECKPOINT_RESTORE), and elevated privileges (CRIU needs access to /proc for process memory inspection). Checkpointing is not supported by all OCI runtimes — it requires runc with CRIU integration. For MCP tools, checkpoint-restore enables snapshot semantics for running processes: save the state of a long-running computation, migrate it, and resume from the exact point of interruption.

What is the difference between containerd shims and OCI runtimes?

The OCI runtime (runc, gVisor, Kata) actually creates and manages the container process. The containerd shim is a long-lived process that sits between containerd and the OCI runtime: it keeps the container running even if the containerd daemon restarts (enabling live daemon upgrades without killing running containers), it holds the container's I/O streams open, and it collects the container's exit status when the process terminates. When you call task.start(), containerd launches a shim process (containerd-shim-runc-v2 for the default runtime), which then invokes runc to execute the OCI runtime spec. The shim persists for the lifetime of the task. For MCP tools, the practical implication is that task.pid() returns the shim's PID, not the container's init process PID. To get the container's PID 1, use task.exec() to run cat /proc/1/status or check /proc/{shim_pid}/net/unix to find the shim's socket and trace it to the container's PID namespace.

How do I monitor containerd-managed containers with AliveMCP?

containerd does not expose container health via HTTP — there is no equivalent to Docker's HEALTHCHECK or Kubernetes' readiness probes. For MCP tools managing containers via containerd, implement health monitoring by: (1) Subscribing to containerd events (client.events()) to detect task exits, OOM kills, and task creation failures. (2) Running periodic exec-based health probes inside running tasks and exposing the aggregate health state as an HTTP endpoint on the MCP server. (3) Registering each container's application-level HTTP endpoint with AliveMCP if the container runs an HTTP service — AliveMCP will probe the endpoint every 60 seconds regardless of the containerd task state, providing an external health signal that is independent of the containerd daemon's availability. The combination of event-driven detection (fast, low-latency for abrupt failures) and AliveMCP HTTP probing (external, independent) covers both process-level and application-level health gaps that containerd's API does not address.

Further reading

Know when your MCP server is down — before users do

AliveMCP probes your server's MCP endpoint every minute, detects protocol errors and transport failures, and pages you before users notice.

Start monitoring free