Tutorial  on  Kubernetes

Kubernetes: Runtime Class

Kubernetes RuntimeClass is a cluster-level resource that provides a way to choose different container runtime configurations for your workloads.

It acts as an abstraction layer between Kubernetes and the underlying container runtime (via the Container Runtime Interface (CRI)), allowing you to select which OCI runtime should run your workloads based on your application's requirements.

In this tutorial, you will learn:

  • What RuntimeClasses are and why they matter
  • How to create and configure RuntimeClasses
  • How to schedule workloads with specific runtime requirements

🐛 Reporting issues

If you encounter any issues throughout the tutorial, please report them here.

Lab environment

This tutorial uses a multi-node Kubernetes cluster configured with different container runtimes:

This setup allows you to explore how different runtime configurations work in a real Kubernetes environment.

The following table summarizes which OCI runtimes are available for each container runtime in the lab environment:

runccrunrunsc
containerd
CRI-O
Docker

Container runtime vs OCI runtime

Before diving into RuntimeClasses, it's important to understand the distinction between container runtimes and OCI runtimes.

If you are familiar with this topic, feel free to skip this section.

Container runtimes are high-level systems that provide several essential services to Kubernetes:

  1. 🔄 Container Lifecycle Management: Creating, starting, stopping, and deleting containers
  2. 📦 Image Management: Pulling, storing, and managing container images from registries
  3. 💾 Storage Management: Handling container filesystems and volume mounts
  4. 🌐 Network Management: Coordinating with CNI plugins for container networking
  5. Runtime Management: Interfacing with low-level runtimes

Kubernetes (specifically, the kubelet) communicates with container runtimes via the Container Runtime Interface (CRI) to utilize these services.

OCI runtimes, on the other hand, focus on one specific task: performing the lowest-level container execution while adhering to the Open Container Initiative (OCI) Runtime Specification.

Different OCI runtimes offer varying characteristics:

  • runc - The reference implementation that provides a good balance of performance and compatibility
  • crun - A fast, memory-efficient runtime written in C
  • runsc (gVisor) - Provides enhanced security through application kernel isolation
Example: how containerd and runc interact

Example: how containerd and runc interact

Runtime Class

Container runtimes typically use a default OCI runtime to run containers. However, most container runtimes also allow you to override this default and choose a different OCI runtime for specific workloads.

To make this configurable from Kubernetes, container runtimes register their available OCI runtimes as runtime handlers in the Node status via the CRI.

These runtime handlers can be referenced in the handler field of a RuntimeClass:

apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata:
  name: runc
handler: runc
Why runtime handler?

You might be wondering: why does Kubernetes use the concept of a runtime handler instead of simply referring to the OCI runtime directly?

The reason is that container runtimes don't have to name their handlers after the underlying OCI runtime. A runtime handler represents a configured instance of an OCI runtime within a container runtime.

Here is an example of how an OCI runtime can be configured in different container runtimes:

containerd
CRI-O
Docker
/etc/containerd/config.toml

[plugins."io.containerd.cri.v1.runtime".containerd.runtimes.myrunc]
runtime_type = "io.containerd.runc.v2"

[plugins."io.containerd.cri.v1.runtime".containerd.runtimes.myrunc.options]
BinaryName = "/usr/local/sbin/custom-runc"
SystemdCgroup = true
/etc/crio/crio.conf

[crio.runtime.runtimes.mycrun]
runtime_path = "/usr/local/sbin/custom-crun"

# ...
/etc/docker/daemon.json
{
    "runtimes": {
        "mycrun": {
            "path": "/usr/local/bin/custom-crun"
        }
    }
}

Each node reports its available runtime handlers in its status, which you can inspect with kubectl:

kubectl get nodes -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{range .status.runtimeHandlers[*]}{"\""}{.name}{"\", "}{end}{"\n"}{end}'

The output should look similar to this, matching the runtime matrix from the Lab environment section:

control-plane   "", "crun", "runc", "runsc",
worker-containerd       "", "crun", "runc", "runsc",
worker-cri-o    "crun", "", "runc",
worker-docker   "", "io.containerd.runc.v2", "runc",

💡 The empty string ("") represents the default runtime handler that container runtimes use when no RuntimeClass is specified.

Creating a RuntimeClass

Since runc is available on all worker nodes, it makes an ideal first example for creating a RuntimeClass:

cat <<EOF | kubectl apply -f -
apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata:
  name: runc
handler: runc
EOF

Since Pods are the smallest deployable unit in Kubernetes, RuntimeClass is always specified at the Pod level using the runtimeClassName field. This means every container in the Pod will run using the specified runtime handler.

Create a Pod using the runc RuntimeClass:

kubectl run podinfo-runc \
    --image=ghcr.io/stefanprodan/podinfo \
    --port=9898 \
    --overrides='{"spec": {"runtimeClassName": "runc"}}'

Since runc is available on all nodes, the scheduler can place this Pod on any node in the cluster:

kubectl get pod podinfo-runc -o wide

Scheduling

By default, RuntimeClass assumes a homogeneous cluster where all nodes expose the same runtime handlers.

In a heterogeneous cluster where different nodes support different runtimes, you can add scheduling rules to a RuntimeClass. This ensures that Pods are scheduled only onto nodes that support the specified runtime handler.

Node selector

Node selectors are the simplest way to control scheduling in Kubernetes. They restrict workloads to nodes that have a specified set of labels.

When a RuntimeClass defines a node selector, it is combined with any node selector on the Pod during admission. The result is effectively an intersection: the Pod will only be scheduled on nodes that satisfy both sets of label requirements.

💡 Node selectors are combined by the RuntimeClass admission controller, which must be enabled for RuntimeClass to function properly.

For this example, you'll use crun as the runtime handler since it's only supported on two worker nodes:

  • containerd
  • CRI-O

To ensure that Pods with this RuntimeClass are scheduled only on the supported nodes, add a label to each node that the RuntimeClass will use as a selector:

kubectl label node worker-containerd crun=true
kubectl label node worker-cri-o crun=true

Create the RuntimeClass with a node selector:

cat <<EOF | kubectl apply -f -
apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata:
  name: crun
handler: crun
scheduling:
  nodeSelector:
    crun: "true"
EOF

Testing this RuntimeClass requires a bit more effort since you need to verify that Pods are scheduled only onto the correct nodes.

Here is the plan:

  1. Create a Deployment (with 3 or more replicas)
  2. Assign the RuntimeClass to it
  3. Add a topology spread constraint to distribute Pods evenly across nodes

Expected outcome: All Pods should be scheduled only on nodes labeled with crun=true.

💡 The last step is important: if the node selection is incorrect, Pods may be scheduled on nodes that don't support the crun runtime handler (effectively failing the test).

Create the Deployment manifest:

kubectl create deploy podinfo-crun \
    --image=ghcr.io/stefanprodan/podinfo \
    --port=9898 \
    --replicas=3 \
    --dry-run=client \
    -o yaml > podinfo-crun.yaml

Assign the RuntimeClass to it:

yq -i '.spec.template.spec.runtimeClassName = "crun"' podinfo-crun.yaml

Add a topology spread constraint:

yq -i '.spec.template.spec.topologySpreadConstraints = [{
  "maxSkew": 1,
  "topologyKey": "kubernetes.io/hostname",
  "whenUnsatisfiable": "DoNotSchedule",
  "labelSelector": {"matchLabels": {"app": .spec.selector.matchLabels.app}}
}]' podinfo-crun.yaml

Finally, apply the Deployment:

kubectl apply -f podinfo-crun.yaml

Verify that Pods are scheduled on the appropriate nodes (containerd or CRI-O):

kubectl get pod -l app=podinfo-crun -o wide

Tolerations

Taints and tolerations provide a way to control which Pods can be scheduled on which nodes.

Taints are markers you apply to nodes that instruct the scheduler to keep Pods away unless those Pods explicitly tolerate the taint. This makes it possible to dedicate nodes for specific workloads—for example, reserving certain nodes for Pods that require a particular runtime (often used together with RuntimeClasses).

In the following example, you'll use the runsc runtime. Only the containerd node supports this runtime, so you'll add a taint to that node and configure Pods that require runsc to tolerate it. This ensures that no other Pods are scheduled there.

Keep in mind that tolerating a taint alone doesn't guarantee placement on the containerd node. To achieve that, you'll also need to combine tolerations with a node selector that matches the containerd node.

First, label the node:

kubectl label node worker-containerd runsc=true

Next, taint the node so that only workloads that require the runsc runtime handler can be scheduled on it:

kubectl taint node worker-containerd runsc:NoExecute

Tainting nodes immediately prevents Pods from being scheduled onto them unless they tolerate the taint.

The NoExecute effect will immediately evict existing Pods from nodes that don't tolerate the taint. Make sure you understand these implications before using taints and tolerations in a production environment.

cat <<EOF | kubectl apply -f -
apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata:
  name: runsc
handler: runsc
scheduling:
  nodeSelector:
    runsc: "true"
  tolerations:
    - key: runsc
      operator: Exists
EOF

Create the Deployment manifest:

kubectl create deploy podinfo-runsc \
    --image=ghcr.io/stefanprodan/podinfo \
    --port=9898 \
    --replicas=3 \
    --dry-run=client \
    -o yaml > podinfo-runsc.yaml

Assign the RuntimeClass to it:

yq -i '.spec.template.spec.runtimeClassName = "runsc"' podinfo-runsc.yaml

Add a topology spread constraint:

yq -i '.spec.template.spec.topologySpreadConstraints = [{
  "maxSkew": 1,
  "topologyKey": "kubernetes.io/hostname",
  "whenUnsatisfiable": "DoNotSchedule",
  "labelSelector": {"matchLabels": {"app": .spec.selector.matchLabels.app}}
}]' podinfo-runsc.yaml

Finally, apply the Deployment:

kubectl apply -f podinfo-runsc.yaml

Verify that Pods are scheduled on the appropriate node (containerd):

kubectl get pod -l app=podinfo-runsc -o wide

What's next?

This tutorial has covered the fundamentals of RuntimeClasses, but there is still plenty more to explore:

References

💡 To dive deeper into the concepts covered in this tutorial, check out the resources below.

Kubernetes resources

Level up your Server Side game — Join 11,000 engineers who receive insightful learning materials straight to their inbox