Run Sandboxed Containers with RuntimeClass and gVisor

Install gVisor runsc on a Kubernetes worker, register a containerd runtime handler, create a RuntimeClass with scheduling constraints, and compare sandboxed Pods against default runc workloads.

Published

Updated

Read time 12 min read

Reviewed byDeepak Prasad

Kubernetes RuntimeClass selecting gVisor runsc handler through containerd to sandbox Pod workloads on a dedicated worker node
Tested on Rocky Linux 10.2 (Red Quartz) — kubeadm worker node with containerd
Package kubectl 1.36.3
containerd 2.2.5
runsc release-20260721.0
Applies to Ubuntu, Debian, Kali Linux, Linux Mint, Pop!_OS, Raspberry Pi OS, elementary OS, Zorin OS, Parrot OS, MX Linux, RHEL, Rocky Linux, AlmaLinux, Oracle Linux, CentOS Stream, Fedora
Kubernetes cluster
Cert prep CKS
Lab environment Multi-node kubeadm cluster with containerd and SSH node access — install Kubernetes with kubeadm
Privilege root on worker nodes to install runsc and edit containerd; cluster-admin kubectl to create RuntimeClass and test Pods
Scope RuntimeClass concepts, gVisor isolation model, prerequisite checks, runsc install, containerd 2.x runtime handler registration, RuntimeClass YAML with scheduling constraints, sandboxed Pod verification, runc versus gVisor comparison, Pod overhead accounting, documented limitation demo, and troubleshooting. Does not cover general containerd installation, Kata Containers, full performance benchmarking, exploit demonstrations, or complete multi-tenancy architecture.
Related guides Harden Linux nodes for Kubernetes
Kubernetes security architecture

RuntimeClass lets you pick a container runtime handler per Pod without changing the cluster default. gVisor (runsc) adds a userspace kernel that intercepts syscalls, which reduces direct host-kernel exposure for untrusted workloads. This walkthrough installs runsc on one kubeadm lab worker, registers a containerd handler, creates a RuntimeClass, and compares sandboxed Pods against ordinary runc Pods on the same node.

IMPORTANT
This guide covers RuntimeClass selection and gVisor (runsc) on Linux workers with containerd. It does not install containerd or replace your default CRI runtime cluster-wide. gVisor trades compatibility and performance for isolation. Keep a runc node pool for workloads that need full kernel features.

What RuntimeClass does

RuntimeClass is a cluster-scoped API object. It maps a Kubernetes name to a CRI runtime handler that containerd already knows about:

text
Pod spec.runtimeClassName
RuntimeClass.handler (for example runsc)
containerd CRI runtime handler
runsc / gVisor sandbox

Important boundaries:

  • RuntimeClass selects an existing handler; it does not install binaries on the node.
  • The handler string must match the runtime name in containerd configuration.
  • Only Pods that set spec.runtimeClassName use the alternate runtime; everything else keeps the default runc handler.
  • RuntimeClass has been stable in node.k8s.io/v1 since Kubernetes 1.20. Pod overhead became stable in Kubernetes 1.24.

gVisor isolation model

gVisor runs application code inside a sandbox process (runsc) that implements many Linux APIs in userspace:

  • A userspace application kernel (Sentry) handles syscalls instead of passing every call straight to the host kernel.
  • Syscall interception limits which host kernel interfaces untrusted code can reach.
  • Reduced host-kernel exposure helps when you run third-party or batch jobs on shared nodes, complementing namespace policy and network rules, not replacing them.
  • Compatibility and performance trade-offs appear in startup time, syscall-heavy apps, and features such as eBPF or certain device nodes.

Treat gVisor as a defense-in-depth layer for classified untrusted workloads, not as proof that a Pod is “on a different machine.”


Verify prerequisites

Prepare one dedicated worker first. Sandboxing every node at once makes rollback harder when an application needs runc.

On the worker where you plan to install gVisor:

bash
uname -m
output
x86_64

gVisor publishes builds for x86_64 and aarch64. Other architectures need a different isolation strategy.

Check containerd and the OS baseline:

bash
containerd --version
output
containerd github.com/containerd/containerd/v2 2.2.5 1.el10_2
bash
head -3 /etc/os-release
output
NAME="Rocky Linux"
VERSION="10.2 (Red Quartz)"
RELEASE_TYPE="stable"

From the control plane, confirm the cluster version:

bash
export KUBECONFIG=/etc/kubernetes/admin.conf
kubectl version
output
Client Version: v1.36.3
Server Version: v1.36.3

gVisor also expects a 64-bit Linux host with cgroup support compatible with your containerd cgroup driver. This lab uses systemd cgroup with containerd 2.x and config version 3 (imports from /etc/containerd/conf.d/). If your nodes run containerd 1.x, the TOML plugin path differs. Adjust the snippet in the next section accordingly.


Install gVisor

Download the pinned multi-file release from the official gVisor storage bucket. July 2026 releases ship runsc, the containerd shim, and required gvisor-bin/ sidecars in one archive—do not rely on the older two-binary workflow that triggers automatic sidecar downloads at runtime:

bash
set -e

GVISOR_RELEASE=20260721.0
ARCH=$(uname -m)
URL="https://storage.googleapis.com/gvisor/releases/release/${GVISOR_RELEASE}/${ARCH}"

cd /tmp
curl -fsSLO "${URL}/gvisor.tar.bz2"
curl -fsSLO "${URL}/gvisor.tar.bz2.sha512"

sha512sum -c gvisor.tar.bz2.sha512
tar -tjf gvisor.tar.bz2 | head

sudo tar -xjf gvisor.tar.bz2 -C /usr/local/bin

test -x /usr/local/bin/runsc
test -x /usr/local/bin/containerd-shim-runsc-v1
test -d /usr/local/bin/gvisor-bin

Confirm the release:

bash
runsc --version
output
runsc version release-20260721.0
spec: 1.2.1

Do not run runsc install on this containerd-only worker. That command configures Docker; it does not generate /etc/containerd/runsc.toml.


Configure the containerd runtime handler

containerd 2.x on this lab uses CRI plugin path io.containerd.cri.v1.runtime and loads drop-ins from /etc/containerd/conf.d/. Create the gVisor shim configuration that the handler references. On this systemd-cgroup lab:

bash
install -d -m 0755 /etc/containerd/conf.d

cat > /etc/containerd/runsc.toml <<'EOF'
[runsc_config]
  systemd-cgroup = "true"
EOF

Verify the main containerd configuration imports the drop-in directory:

bash
grep '^imports' /etc/containerd/config.toml
output
imports = ["/etc/containerd/conf.d/*.toml"]

When that import is absent, /etc/containerd/conf.d/runsc.toml is ignored. Either add the import or place the runtime stanza directly in /etc/containerd/config.toml. Containerd 2.x uses configuration version 3 and the io.containerd.cri.v1.runtime plugin path shown below.

Add a handler named runsc that matches what Kubernetes will reference:

bash
cat <<'EOF' > /etc/containerd/conf.d/runsc.toml
[plugins."io.containerd.cri.v1.runtime".containerd.runtimes.runsc]
  runtime_type = "io.containerd.runsc.v1"

[plugins."io.containerd.cri.v1.runtime".containerd.runtimes.runsc.options]
  TypeUrl = "io.containerd.runsc.v1.options"
  ConfigPath = "/etc/containerd/runsc.toml"
EOF

The handler name runsc must equal RuntimeClass.handler later. On containerd 1.x, the plugin key is historically plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runsc—verify with containerd config dump on your node before copying this file.

Restart containerd and kubelet on the worker:

bash
systemctl restart containerd
systemctl restart kubelet

Confirm containerd is active:

bash
systemctl is-active containerd
output
active

Prove the merged configuration was loaded:

bash
containerd config dump |
  grep -A12 'runtimes.runsc'
output
[plugins.'io.containerd.cri.v1.runtime'.containerd.runtimes.runsc]
          runtime_type = 'io.containerd.runsc.v1'

          [plugins.'io.containerd.cri.v1.runtime'.containerd.runtimes.runsc.options]
            ConfigPath = '/etc/containerd/runsc.toml'
            TypeUrl = 'io.containerd.runsc.v1.options'

Verify the CRI layer registered the handler:

bash
crictl info | grep -A6 '"runsc"'
output
"runsc": {
            "runtimeType": "io.containerd.runsc.v1",
            "runtimePath": "",
            "podAnnotations": [],
            "containerAnnotations": [],
            "options": {
              "ConfigPath": "/etc/containerd/runsc.toml",

The runtimeType value io.containerd.runsc.v1 confirms containerd will delegate sandbox creation to gVisor for Pods that request handler runsc.


Create RuntimeClass

From the control plane, define a RuntimeClass that points at handler runsc:

bash
kubectl apply -f - <<'EOF'
apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata:
  name: gvisor
handler: runsc
EOF

List the object:

bash
kubectl get runtimeclass gvisor
output
NAME     HANDLER   AGE
gvisor   runsc     10s

The handler field is the only runtime wiring Kubernetes needs at this layer. Installation and containerd configuration remain node operations.


Restrict scheduling

Label the prepared worker so you can target it explicitly:

bash
kubectl label node worker01 sandbox.gvisor.io/enabled=true --overwrite

Extend the RuntimeClass with scheduling constraints so only eligible nodes receive sandboxed Pods:

bash
kubectl apply -f - <<'EOF'
apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata:
  name: gvisor
handler: runsc
scheduling:
  nodeSelector:
    sandbox.gvisor.io/enabled: "true"
    kubernetes.io/hostname: worker01
EOF

How scheduling merges:

  • RuntimeClass.scheduling.nodeSelector is combined with the Pod’s own spec.nodeSelector.
  • RuntimeClass.scheduling.tolerations append to Pod tolerations—useful when sandbox nodes carry a dedicated taint such as sandbox.gvisor.io/gvisor-only:NoSchedule.
  • Pods without runtimeClassName: gvisor ignore these constraints and keep scheduling on any suitable node.

In production, replace the hostname pin with a pool label and optional taint/toleration pair so only gVisor-aware workloads land on sandbox nodes.


Run a sandboxed Pod

Create a test namespace and two Pods on the prepared worker—one default runc, one gVisor:

bash
kubectl create namespace gvisor-lab
bash
kubectl apply -f - <<'EOF'
apiVersion: v1
kind: Pod
metadata:
  name: busybox-runc
  namespace: gvisor-lab
spec:
  nodeSelector:
    kubernetes.io/hostname: worker01
  containers:
  - name: busybox
    image: registry.k8s.io/e2e-test-images/busybox:1.29-4
    command: ["sleep", "3600"]
EOF
bash
kubectl apply -f - <<'EOF'
apiVersion: v1
kind: Pod
metadata:
  name: busybox-gvisor
  namespace: gvisor-lab
spec:
  runtimeClassName: gvisor
  nodeSelector:
    kubernetes.io/hostname: worker01
  containers:
  - name: busybox
    image: registry.k8s.io/e2e-test-images/busybox:1.29-4
    command: ["sleep", "3600"]
EOF

If Calico has not finished node setup, worker01 may carry node.kubernetes.io/network-unavailable:NoSchedule. Either wait for the taint to clear or add this toleration to test Pods:

yaml
tolerations:
- key: node.kubernetes.io/network-unavailable
  operator: Exists
  effect: NoSchedule

Watch scheduling:

bash
kubectl wait -n gvisor-lab \
  --for=condition=Ready \
  pod/busybox-runc pod/busybox-gvisor \
  --timeout=120s
bash
kubectl get pods -n gvisor-lab -o wide
output
NAME             READY   STATUS    RESTARTS   AGE   IP             NODE       NOMINATED NODE   READINESS GATES
busybox-gvisor   1/1     Running   0          42s   192.168.5.34   worker01   <none>           <none>
busybox-runc     1/1     Running   0          45s   192.168.5.35   worker01   <none>           <none>

Describe the sandboxed Pod to confirm Kubernetes selected the runtime class:

bash
kubectl describe pod -n gvisor-lab busybox-gvisor | grep -E 'Runtime Class|Node:'
output
Node:                 worker01/192.168.56.109
Runtime Class Name:   gvisor

On the worker, inspect the Pod sandbox through CRI. busybox-gvisor is the Pod name; the container is named busybox. inspectp is the appropriate CRI operation for the sandbox where the runtime handler is selected:

bash
POD_ID=$(crictl pods \
  --namespace gvisor-lab \
  --name busybox-gvisor \
  -q | head -1)

crictl inspectp "$POD_ID" |
  grep -E '"runtimeHandler"|"runtimeType"'
output
"runtimeHandler": "runsc",
    "runtimeType": "io.containerd.runsc.v1",

That pair is the ground truth that containerd started the sandbox with gVisor, not runc.


Compare normal and sandboxed workloads

Exec into each Pod and compare what the application sees.

Kernel identity from uname:

bash
kubectl exec -n gvisor-lab busybox-runc -- uname -a
output
Linux busybox-runc 6.12.0-211.34.1.el10_2.x86_64 #1 SMP PREEMPT_DYNAMIC Tue Jul 14 23:43:25 UTC 2026 x86_64 GNU/Linux
bash
kubectl exec -n gvisor-lab busybox-gvisor -- uname -a
output
Linux busybox-gvisor 4.19.0-gvisor #1 SMP Sun Jan 10 15:06:54 PST 2016 x86_64 GNU/Linux

The gVisor Pod reports the emulated 4.19.0-gvisor kernel string even though the host runs Linux 6.12.

Read /proc/version:

bash
kubectl exec -n gvisor-lab busybox-runc -- cat /proc/version
output
Linux version 6.12.0-211.34.1.el10_2.x86_64 (mockbuild@...) (gcc (GCC) 14.2.1 20250110, GNU ld version 2.41-58.el10) #1 SMP PREEMPT_DYNAMIC Tue Jul 14 23:43:25 UTC 2026
bash
kubectl exec -n gvisor-lab busybox-gvisor -- cat /proc/version
output
Linux version 4.19.0-gvisor #1 SMP Sun Jan 10 15:06:54 PST 2016

dmesg inside the sandbox is not host kernel output—it is a lab signal only, not cryptographic proof of isolation:

bash
kubectl exec -n gvisor-lab busybox-runc -- dmesg 2>&1 | head -1
output
dmesg: klogctl: Operation not permitted
bash
kubectl exec -n gvisor-lab busybox-gvisor -- dmesg 2>&1 | head -3
output
[   0.000000] Starting gVisor...
[   0.410841] Waiting for children...
[   0.476509] Forking spaghetti code...

Basic filesystem layout still looks familiar—both Pods list standard root directories:

bash
kubectl exec -n gvisor-lab busybox-gvisor -- ls /
output
bin
dev
etc
home
proc
root
sys
tmp
usr
var

Networking from a minimal Pod still reaches cluster DNS and Services; gVisor implements a network stack in userspace, so latency and throughput differ from runc even when curl succeeds. Measure on your workloads instead of assuming parity.

Manifest and API checks with curl are covered in the curl command guide.


Configure Pod overhead

The values below demonstrate Kubernetes overhead accounting; they are not calibrated gVisor defaults. Measure the additional host and Pod-cgroup footprint on your own nodes before selecting production values. Kubernetes uses overhead on the RuntimeClass; admission copies it into new Pods and rejects a Pod that tries to predefine the field.

Patch the RuntimeClass:

bash
kubectl apply -f - <<'EOF'
apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata:
  name: gvisor
handler: runsc
overhead:
  podFixed:
    memory: "120Mi"
    cpu: "250m"
scheduling:
  nodeSelector:
    sandbox.gvisor.io/enabled: "true"
    kubernetes.io/hostname: worker01
EOF

How overhead affects scheduling:

  • The scheduler adds overhead.podFixed to Pod resource requests when placing the workload.
  • Without overhead, sandboxed Pods may overcommit memory on a node because the kubelet only sees container requests, not gVisor’s extra footprint.
  • Overhead does not replace container resources.requests—set both.

The existing busybox-gvisor Pod was created before overhead was added, so create a new Pod and prove admission populated the value:

bash
kubectl apply -f - <<'EOF'
apiVersion: v1
kind: Pod
metadata:
  name: gvisor-overhead-test
  namespace: gvisor-lab
spec:
  runtimeClassName: gvisor
  restartPolicy: Never
  containers:
  - name: busybox
    image: registry.k8s.io/e2e-test-images/busybox:1.29-4
    command: ["sleep", "300"]
EOF
bash
kubectl get pod -n gvisor-lab gvisor-overhead-test \
  -o jsonpath='{.spec.overhead}{"\n"}'
output
map[cpu:250m memory:120Mi]

This output proves Kubernetes applied the RuntimeClass overhead; it does not prove that those numbers equal gVisor’s real resource consumption. After deploying a representative application under runtimeClassName: gvisor, compare kubectl top pod against the same image on runc and adjust podFixed until node capacity planning matches reality.


Test unsupported behavior

Pick one documented limitation and reproduce it in the lab so teams know when to keep runc. BPF filesystem mounts are a practical example: gVisor does not expose the same BPF device surface as the host.

Deploy a Pod with CAP_SYS_ADMIN so mount is permitted at the capability layer:

bash
kubectl apply -f - <<'EOF'
apiVersion: v1
kind: Pod
metadata:
  name: cap-mount-runc
  namespace: gvisor-lab
spec:
  nodeSelector:
    kubernetes.io/hostname: worker01
  tolerations:
  - key: node.kubernetes.io/network-unavailable
    operator: Exists
    effect: NoSchedule
  containers:
  - name: busybox
    image: registry.k8s.io/e2e-test-images/busybox:1.29-4
    command: ["sleep", "3600"]
    securityContext:
      capabilities:
        add: ["SYS_ADMIN"]
EOF
bash
kubectl apply -f - <<'EOF'
apiVersion: v1
kind: Pod
metadata:
  name: cap-mount-gvisor
  namespace: gvisor-lab
spec:
  runtimeClassName: gvisor
  nodeSelector:
    kubernetes.io/hostname: worker01
  tolerations:
  - key: node.kubernetes.io/network-unavailable
    operator: Exists
    effect: NoSchedule
  containers:
  - name: busybox
    image: registry.k8s.io/e2e-test-images/busybox:1.29-4
    command: ["sleep", "3600"]
    securityContext:
      capabilities:
        add: ["SYS_ADMIN"]
EOF

Try mounting the BPF filesystem:

bash
kubectl exec -n gvisor-lab cap-mount-runc -- sh -c 'mkdir -p /tmp/bpf && mount -t bpf bpf /tmp/bpf && echo SUCCESS'
output
SUCCESS
bash
kubectl exec -n gvisor-lab cap-mount-gvisor -- sh -c 'mkdir -p /tmp/bpf && mount -t bpf bpf /tmp/bpf' 2>&1
output
mount: mounting bpf on /tmp/bpf failed: No such device

The gVisor sandbox rejects the mount even with CAP_SYS_ADMIN. Check containerd logs on the worker if the Pod fails to start entirely:

bash
journalctl -u containerd --since "5 min ago" | grep -i runsc | tail -5

When you hit a failure like this, route the workload to runc, split cluster services by isolation class, or consult the gVisor compatibility guide for syscall-level workarounds—do not assume elevated capabilities fix sandbox gaps.


Troubleshooting

Symptom Likely cause Fix
Pod stays Pending with FailedScheduling No node matches RuntimeClass nodeSelector or missing toleration for sandbox taint Label the worker, fix scheduling stanza, or add Pod tolerations
FailedCreatePodSandBox mentioning unknown runtime handler Handler name mismatch or containerd not restarted Align handler: with containerd config key; restart containerd and kubelet
Pod runs but crictl inspectp shows runc Wrong or missing runtimeClassName Set spec.runtimeClassName: gvisor and confirm kubectl get runtimeclass
Image pull errors on test Pods Registry or clock skew on the node Fix NTP; use mirrored images such as registry.k8s.io/e2e-test-images/busybox:1.29-4
Application crashes only under gVisor Unsupported syscall or device Test on runc; check gVisor release notes and runtime logs
Node memory pressure with sandbox Pods Missing overhead on RuntimeClass Measure footprint and set overhead.podFixed

What's Next


References


Summary

RuntimeClass bridges Kubernetes Pod specs and containerd runtime handlers. You installed runsc on a dedicated worker, registered handler runsc under containerd’s CRI plugin, and created a RuntimeClass that only schedules onto labeled nodes. Sandboxed Pods started through io.containerd.runsc.v1, while sibling Pods on the same kubelet kept the default runc path.

The comparison sections are the decision data: gVisor Pods report an emulated kernel, fake dmesg output, and different syscall behavior. BPF mounts failed in the sandbox even with CAP_SYS_ADMIN. Treat those signals as compatibility hints, not as a substitute for workload testing. Add overhead so the scheduler reserves gVisor’s extra memory and CPU, and keep a runc pool for observability agents, eBPF tooling, and other host-kernel-dependent services.

For broader isolation strategy, continue with multi-tenancy and workload isolation when namespaces are enough versus when you need dedicated nodes or separate clusters. Layer seccomp and SecurityContext constraints on both runtime classes where they still apply.


Frequently Asked Questions

1. Does a RuntimeClass install gVisor or containerd?

No. RuntimeClass is a Kubernetes API object that tells the kubelet which CRI runtime handler name to pass to containerd for a Pod. You install runsc and register the handler on worker nodes first, then reference that handler from RuntimeClass.handler and Pod spec.runtimeClassName.

2. What is the difference between runtimeClassName and the default runc runtime?

Pods without runtimeClassName use the containerd default runtime, usually runc, which shares the host kernel. Pods with runtimeClassName set to a gVisor RuntimeClass start through runsc, which emulates a kernel in userspace and intercepts many syscalls. Compatibility and performance differ even when the same image runs in both modes.

3. Why does my gVisor Pod stay Pending?

Common causes are a missing nodeSelector or toleration from RuntimeClass scheduling, no worker with the runsc handler configured, or a taint on the only eligible node such as node.kubernetes.io/network-unavailable while Calico is still starting. kubectl describe pod shows FailedScheduling with the exact reason.

4. Can every container image run under gVisor?

No. gVisor does not implement every Linux syscall or device node the host kernel exposes. System daemons, eBPF tooling, certain mounts, and hardware-specific workloads often fail or behave differently. Classify workloads and keep a runc node pool for incompatible applications.
Deepak Prasad

R&D Engineer

Founder of GoLinuxCloud with more than 15 years of expertise in Linux, Python, Go, Laravel, DevOps, Kubernetes, Git, Shell scripting, OpenShift, AWS, Networking, and Security. With extensive experience, he excels across development, DevOps, networking, and security, delivering robust and efficient solutions for diverse projects.

  • Go (programming language)
  • Python (programming language)
  • DevOps
  • Computer Security
  • Cloud Computing
  • Kubernetes
  • Linux
  • Ansible (software)