Overview

Kubernetes v1.24 officially removed dockershim, meaning Docker Engine is no longer used as the K8s container runtime. Instead, CRI-compliant (Container Runtime Interface) runtimes are used, with containerd being the de facto standard choice.

Many ops teams perceive containerd as merely a “Docker replacement,” but its architecture, image management, and CLI tools differ significantly from Docker. This article provides a deep dive into the CRI specification, containerd architecture, daily operations, and migration from Docker.

Based on containerd v2.0 and Kubernetes v1.30. Reference: containerd Official Documentation

CRI Specification

What Is CRI

CRI (Container Runtime Interface) is a container runtime interface specification defined by K8s. Instead of calling container runtimes directly, K8s communicates with CRI-compliant runtimes via gRPC:

kubelet → CRI gRPC → Container runtime (containerd/CRI-O) → Container

CRI Interface Definition

CRI defines two core services:

ServiceFunctionKey Methods
RuntimeServiceContainer and Pod lifecycleRunPodSandbox, CreateContainer, StartContainer, StopContainer
ImageServiceImage managementListImages, PullImage, RemoveImage, ImageStatus
// Simplified CRI interface definition
service RuntimeService {
    rpc RunPodSandbox(RunPodSandboxRequest) returns (RunPodSandboxResponse);
    rpc StopPodSandbox(StopPodSandboxRequest) returns (StopPodSandboxResponse);
    rpc RemovePodSandbox(RemovePodSandboxRequest) returns (RemovePodSandboxResponse);
    rpc CreateContainer(CreateContainerRequest) returns (CreateContainerResponse);
    rpc StartContainer(StartContainerRequest) returns (StartContainerResponse);
    rpc StopContainer(StopContainerRequest) returns (StopContainerResponse);
    rpc RemoveContainer(RemoveContainerRequest) returns (RemoveContainerResponse);
    rpc ListContainers(ListContainersRequest) returns (ListContainersResponse);
    rpc ContainerStats(ContainerStatsRequest) returns (ContainerStatsResponse);
    rpc ListPodSandbox(ListPodSandboxRequest) returns (ListPodSandboxResponse);
}

service ImageService {
    rpc ListImages(ListImagesRequest) returns (ListImagesResponse);
    rpc PullImage(PullImageRequest) returns (PullImageResponse);
    rpc RemoveImage(RemoveImageRequest) returns (RemoveImageResponse);
    rpc ImageStatus(ImageStatusRequest) returns (ImageStatusResponse);
}

Why dockershim Was Removed

Docker Engine doesn’t natively support CRI. K8s used dockershim as an adapter to call Docker. This caused several issues:

IssueDescription
Extra adapter layerdockershim added a layer of calls, reducing efficiency
Maintenance burdenK8s maintained dockershim code, but Docker updated frequently
Feature limitationsdockershim couldn’t leverage advanced CRI features
Extra processesDocker Engine included dockerd, containerd, docker-proxy, etc.

After removing dockershim, using containerd directly eliminates the middle layer:

# Old architecture (Docker)
kubelet → dockershim → dockerd → containerd → containerd-shim → runc → Container

# New architecture (containerd)
kubelet → CRI Plugin → containerd → containerd-shim → runc → Container

Docker vs containerd vs CRI-O

Three Major Runtime Comparison

FeatureDocker EnginecontainerdCRI-O
CRI supportNeeds dockershimNative supportNative support
ArchitectureMulti-process (dockerd + containerd)Single processSingle process
Image formatDocker ImageOCI ImageOCI Image
CLIdockernerdctl / ctrcrictl
Image storageIndependentIndependentIndependent
Network managementdocker-proxyCNICNI
Build capabilitydocker buildnerdctl build / buildkitbuildah
Use caseDevelopmentK8s productionOpenShift
Resource overheadHighLowLow

Architecture Comparison

# Docker Engine architecture
dockerd (HTTP API + image build + volume management)
  └── containerd (container lifecycle)
        └── containerd-shim (container process management)
              └── runc (OCI runtime)

# containerd architecture
containerd (CRI + container lifecycle + image management)
  └── containerd-shim (container process management)
        └── runc (OCI runtime)

# CRI-O architecture
cri-o (CRI + container lifecycle + image management)
  └── conmon (container process monitoring)
        └── runc (OCI runtime)

Resource Overhead Comparison

MetricDockercontainerdCRI-O
Resident memory~150MB~50MB~40MB
Disk usage~200MB~80MB~70MB
Startup time~2s~0.5s~0.5s
Image pull speedBaseline10-15% faster10-15% faster

Values are approximate; actual performance depends on node configuration and workload.

containerd Architecture

Core Components

containerd
├── Containerd API    (gRPC API for external calls)
├── Containerd Runtime
│   ├── Tasks Manager   (Container task management)
│   ├── Runtime Shim    (OCI runtime shim)
│   └── runc            (OCI low-level runtime)
├── Containerd Images
│   ├── Pull / Push     (Image pull/push)
│   ├── Image Store     (Image storage)
│   └── Content Store   (Image content storage)
├── Containerd Snapshots
│   └── Snapshotter     (Snapshot management, overlayfs/btrfs)
├── Containerd Metadata
│   └── BoltDB          (Metadata storage)
└── Containerd Events   (Event system)

containerd Configuration File

# /etc/containerd/config.toml
version = 2

root = "/var/lib/containerd"
state = "/run/containerd"
oom_score = 0

[grpc]
  address = "/run/containerd/containerd.sock"
  uid = 0
  gid = 0

[metrics]
  address = "127.0.0.1:8080"
  grpc_histogram = false

[debug]
  address = ""
  uid = 0
  gid = 0
  level = "info"

# CRI plugin configuration
[plugins."io.containerd.grpc.v1.cri"]
  # sandbox_image = "registry.k8s.io/pause:3.9"
  sandbox_image = "registry.cn-hangzhou.aliyuncs.com/google_containers/pause:3.9"

  # Container runtime
  [plugins."io.containerd.grpc.v1.cri".containerd]
    default_runtime_name = "runc"
    snapshotter = "overlayfs"

    [plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc]
      runtime_type = "io.containerd.runc.v2"
      [plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc.options]
        SystemdCgroup = true        # Use systemd cgroup

  # Image pull configuration
  [plugins."io.containerd.grpc.v1.cri".registry]
    config_path = "/etc/containerd/certs.d"   # Image registry config directory

  # Image GC policy
  [plugins."io.containerd.grpc.v1.cri".image_decryption]
    key_model = "node"

  # Log configuration
  [plugins."io.containerd.grpc.v1.cri".containerd.log]
    max_size = "100MB"
    max_files = 3

systemd cgroup Driver

K8s v1.26+ requires the container runtime to use the systemd cgroup driver:

[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc.options]
  SystemdCgroup = true

Without this configuration, mismatched cgroup drivers between kubelet and containerd causes instability.

Image Management

Image Pull Acceleration Configuration

# Method 1: Configure mirrors in config.toml (legacy, deprecated)
[plugins."io.containerd.grpc.v1.cri".registry.mirrors."docker.io"]
  endpoint = ["https://mirror.ccs.tencentyun.com", "https://registry-1.docker.io"]

[plugins."io.containerd.grpc.v1.cri".registry.mirrors."registry.k8s.io"]
  endpoint = ["https://registry.cn-hangzhou.aliyuncs.com/google_containers"]
# Method 2: hosts.toml approach (containerd 1.7+, recommended)
mkdir -p /etc/containerd/certs.d/docker.io
cat > /etc/containerd/certs.d/docker.io/hosts.toml << 'EOF'
server = "https://registry-1.docker.io"

[host."https://mirror.ccs.tencentyun.com"]
  capabilities = ["pull", "resolve"]

[host."https://docker.1panel.live"]
  capabilities = ["pull", "resolve"]
EOF

# k8s.io images
mkdir -p /etc/containerd/certs.d/registry.k8s.io
cat > /etc/containerd/certs.d/registry.k8s.io/hosts.toml << 'EOF'
server = "https://registry.k8s.io"

[host."https://registry.cn-hangzhou.aliyuncs.com/google_containers"]
  capabilities = ["pull", "resolve"]
EOF

# Private registry authentication
mkdir -p /etc/containerd/certs.d/harbor.example.com
cat > /etc/containerd/certs.d/harbor.example.com/hosts.toml << 'EOF'
server = "https://harbor.example.com"

[host."https://harbor.example.com"]
  capabilities = ["pull", "resolve"]
  ca = "/etc/containerd/certs.d/harbor.example.com/ca.crt"
  # Or skip TLS verification (not recommended for production)
  # skip_verify = true
EOF
# Point to certs.d directory in config.toml
[plugins."io.containerd.grpc.v1.cri".registry]
  config_path = "/etc/containerd/certs.d"

Private Registry Authentication

# Method 1: K8s Secret (used when Pod pulls images)
kubectl create secret docker-registry harbor-secret \
  --docker-server=harbor.example.com \
  --docker-username=admin \
  --docker-password=Harbor12345 \
  --docker-email=admin@example.com \
  -n production

# Reference in Pod
# imagePullSecrets:
# - name: harbor-secret
# Method 2: Node-level authentication (shared by all Pods)
# Create auth directory
mkdir -p /etc/containerd/certs.d/harbor.example.com

# Login to generate auth file
crictl login harbor.example.com -u admin -p Harbor12345

# Or write manually
cat > /var/lib/kubelet/config.json << 'EOF'
{
  "auths": {
    "harbor.example.com": {
      "auth": "YWRtaW46SGFyYm9yMTIzNDU="
    }
  }
}
EOF

Namespaces

containerd Namespaces

containerd uses namespaces to isolate containers and images from different clients:

NamespaceUserDescription
k8s.iokubeletK8s-managed containers and images
mobyDockerDocker Engine-managed containers (if Docker is installed)
defaultnerdctl / ctrDefault namespace
buildkitbuildkitdImages used during build
# List all namespaces
ctr namespaces list

# Operate in specified namespace
ctr -n k8s.io images list
ctr -n k8s.io containers list

Important: K8s-managed containers and images are in the k8s.io namespace. When using ctr, add -n k8s.io or you won’t see K8s resources.

CLI Tools

ctr: containerd Native CLI

ctr is containerd’s built-in CLI—most feature-complete but less user-friendly:

# Image operations
ctr -n k8s.io images list                          # List images
ctr -n k8s.io images pull docker.io/library/nginx:1.25-alpine  # Pull image
ctr -n k8s.io images push harbor.example.com/myapp:v1          # Push image
ctr -n k8s.io images delete myapp:v1                            # Delete image
ctr -n k8s.io images export app.tar myapp:v1                    # Export image
ctr -n k8s.io images import app.tar                             # Import image

# Container operations
ctr -n k8s.io containers list                       # List containers
ctr -n k8s.io tasks list                            # List running tasks

crictl: K8s Container Debugging Tool

crictl is a CRI debugging tool maintained by the K8s community, aligned with K8s concepts:

# View Pods
crictl pods

# View containers
crictl ps
crictl ps -a                          # Including stopped
crictl ps --name nginx                # Filter by name

# View images
crictl images
crictl images --digests               # Show digests

# View container logs
crictl logs <container-id>
crictl logs -f <container-id>         # Follow logs

# Execute command in container
crictl exec -it <container-id> sh

# View container status
crictl inspect <container-id>
crictl stats

# Pull image
crictl pull nginx:1.25-alpine
# /etc/crictl.yaml
runtime-endpoint: unix:///run/containerd/containerd.sock
image-endpoint: unix:///run/containerd/containerd.sock
timeout: 10
debug: false

nerdctl: Docker-Compatible CLI

nerdctl is a Docker-compatible CLI developed by the containerd community, recommended as a Docker CLI replacement:

# Basic docker-compatible commands
nerdctl ps                             # List containers
nerdctl images                         # List images
nerdctl pull nginx:1.25-alpine         # Pull image
nerdctl run -d --name nginx -p 80:80 nginx:1.25-alpine  # Run container
nerdctl exec -it nginx sh              # Enter container
nerdctl logs -f nginx                  # View logs
nerdctl stop nginx                     # Stop container
nerdctl rm nginx                       # Remove container
nerdctl rmi nginx:1.25-alpine          # Remove image

# Build images (requires buildkitd)
nerdctl build -t myapp:v1 .
nerdctl build -t myapp:v1 --platform linux/amd64,linux/arm64 .

# compose support
nerdctl compose up -d
nerdctl compose down

# Specify namespace
nerdctl --namespace=k8s.io ps
nerdctl --namespace=default ps

Tool Comparison

Featurectrcrictlnerdctl
Image managementYesYesYes
Container managementYesRead-onlyYes
Container creationYesNoYes
Image buildingNoNoYes
composeNoNoYes
Log viewingNoYesYes
execNoYesYes
K8s alignedNoYesNo
Docker compatibleNoNoYes

Recommendation: Use crictl for daily ops (aligned with K8s concepts), nerdctl as Docker CLI replacement, ctr for low-level debugging.

Snapshotter

Role of the Snapshotter

The snapshotter manages the layering of container filesystems. Each image layer pulled creates a snapshot; running a container creates a read-write layer on top.

Supported Snapshotters

SnapshotterDescriptionUse Case
overlayfsDefault, based on OverlayFSGeneral (recommended)
btrfsBased on Btrfs filesystemRequires Btrfs support
zfsBased on ZFS filesystemRequires ZFS support
nativeSimple file copyTest environments
stargzLazy-loading imagesLarge image fast startup
nydusImage acceleration (Ant Group open source)Large image fast startup

overlayfs Configuration

[plugins."io.containerd.grpc.v1.cri".containerd]
  snapshotter = "overlayfs"
# View overlayfs mounts
mount | grep overlay

# View image layers
ctr -n k8s.io snapshots list

Stargz / Nydus Lazy Loading

Traditional image pulling downloads the entire image—large images (GB-level) pull slowly. Stargz and Nydus solve this with lazy loading:

# Enable stargz snapshotter
[plugins."io.containerd.snapshotter.v1.stargz"]
  root_path = "/var/lib/containerd-stargz-grpc"
# Traditional image pull: full download → start container (slow)
# Lazy loading: metadata download → start container → download on demand (fast)

Performance Comparison

Image Pull Performance

# Test conditions: 1GB image, 100Mbps internal network
Docker:        ~85s  (includes dockerd scheduling overhead)
containerd:    ~72s  (direct CRI call)
CRI-O:         ~73s  (direct CRI call)
containerd + stargz: ~15s (lazy loading, on-demand pull)

Container Startup Performance

# Test conditions: nginx:alpine, 100 concurrent containers
Docker:        ~8s
containerd:    ~5s
CRI-O:         ~5s

Memory Usage

# Idle state
Docker (dockerd + containerd): ~150MB
containerd:                     ~50MB
CRI-O:                          ~40MB

Migrating from Docker to containerd

Migration Assessment

Check ItemDescription
Image buildingDocker Build replaced by BuildKit / nerdctl build
CLI toolsdocker commands replaced by crictl / nerdctl
Image registryAuthentication configuration differs
Monitoring scriptsScripts depending on docker CLI need updating
Log drivercontainerd doesn’t support Docker’s log drivers
docker.sockApplications depending on Docker socket need modification

Migration Steps

# 1. Install containerd
apt-get update && apt-get install -y containerd

# 2. Generate default config
containerd config default > /etc/containerd/config.toml

# 3. Modify config (SystemdCgroup + image acceleration)
# Edit /etc/containerd/config.toml, ensure:
# SystemdCgroup = true
# config_path = "/etc/containerd/certs.d"

# 4. Configure image acceleration
mkdir -p /etc/containerd/certs.d/docker.io
cat > /etc/containerd/certs.d/docker.io/hosts.toml << 'EOF'
server = "https://registry-1.docker.io"
[host."https://mirror.ccs.tencentyun.com"]
  capabilities = ["pull", "resolve"]
EOF

# 5. Restart containerd
systemctl restart containerd
systemctl enable containerd

# 6. Modify kubelet config
# /var/lib/kubelet/kubeadm-flags.env
KUBELET_KUBEADM_ARGS="--container-runtime=remote --container-runtime-endpoint=unix:///run/containerd/containerd.sock"

# 7. Restart kubelet
systemctl restart kubelet

# 8. Verify
kubectl get nodes -o wide
# Verify CONTAINER-RUNTIME column shows containerd://

Image Migration

# Export Docker image
docker save -o myapp.tar myapp:v1

# Import to containerd
ctr -n k8s.io images import myapp.tar

# Or use nerdctl
nerdctl -n k8s.io load -i myapp.tar

Script Migration

# Docker command → containerd equivalent mapping

# List containers
docker ps                    → crictl ps
# List images
docker images                → crictl images
# Pull image
docker pull nginx            → crictl pull nginx
# View logs
docker logs <id>             → crictl logs <id>
# Enter container
docker exec -it <id> sh      → crictl exec -it <id> sh
# Inspect container
docker inspect <id>          → crictl inspect <id>
# Container stats
docker stats                 → crictl stats
# Build image
docker build -t app .        → nerdctl build -t app .
# Run container
docker run -d nginx          → nerdctl run -d nginx
# Remove image
docker rmi nginx             → crictl rmi nginx

Daily Operations

Image GC Configuration

# K8s image GC is managed by kubelet, not containerd config
# /var/lib/kubelet/config.yaml
imageGCHighThresholdPercent: 85    # Trigger GC when disk usage exceeds 85%
imageGCLowThresholdPercent: 80     # Stop GC at 80%
imageMinimumGCAge: 2m              # Image must exist at least 2min before GC

Log Management

containerd doesn’t have Docker’s rich log drivers. Container logs are managed by kubelet, output to /var/log/pods/:

# View container logs
crictl logs <container-id>

# K8s Pod log path
ls /var/log/pods/<namespace>_<pod-name>_<pod-uid>/

# Log rotation config (kubelet)
# /var/lib/kubelet/config.yaml
containerLogMaxSize: "100Mi"
containerLogMaxFiles: 5

Performance Tuning

# containerd performance tuning
version = 2

# Increase gRPC concurrency
[grpc]
  max_concurrent_streams = 1000

# Adjust image pull concurrency
[plugins."io.containerd.grpc.v1.cri"]
  max_concurrent_downloads = 5       # Concurrent image downloads (default 3)

# overlayfs performance
[plugins."io.containerd.snapshotter.v1.overlayfs"]
  upperdir_capability = true

Troubleshooting

# Check containerd status
systemctl status containerd

# View containerd logs
journalctl -u containerd -f

# Check CRI status
crictl info

# View containerd metrics
curl -s http://localhost:8080/metrics | grep containerd

# Common issues
# 1. Pod creation failure - ImagePullBackOff
crictl images | grep <image>       # Check if image exists
ctr -n k8s.io images pull <image>  # Manual pull test

# 2. Container startup failure
crictl inspect <container-id>      # View container details
journalctl -u containerd | grep <container-id>  # View logs

# 3. Slow image pull
cat /etc/containerd/certs.d/*/hosts.toml  # Check image acceleration config

Summary

containerd has become the de facto standard container runtime for K8s. Key takeaways:

  1. CRI is the specification, containerd is the implementation: Understanding CRI helps understand how K8s interacts with runtimes.
  2. Three CLIs serve different purposes: crictl for K8s ops (primarily read-only), nerdctl as Docker CLI replacement, ctr for low-level debugging.
  3. Image acceleration must be configured: In environments with restricted access to Docker Hub, configure image acceleration using the certs.d directory approach (hosts.toml); the legacy configuration method is deprecated.
  4. SystemdCgroup must be enabled: K8s requires consistent cgroup drivers; without it, instability occurs.
  5. Namespace isolation: K8s containers and images are in the k8s.io namespace; add -n k8s.io when operating.
  6. Migration needs planning: Migrating from Docker to containerd requires assessing applications depending on Docker socket, monitoring scripts, etc.—don’t switch directly.
  7. Use lazy loading for large images: Stargz / Nydus can significantly accelerate large image startup, suitable for CI/CD scenarios.

containerd’s design is simpler and more efficient than Docker, but it also means some Docker conveniences (like docker build, rich log drivers) require alternative solutions. Understanding these differences is key to smooth migration.

References & Acknowledgments

This article referenced the following materials during writing. We thank the original authors for their contributions:

  1. containerd Official Documentation — Containerd, referenced for containerd Official Documentation