| Tested on | Rocky Linux 10.2 (Red Quartz) — kubeadm worker node with containerd |
|---|---|
| Package | kubectl 1.36.3containerd 2.2.5runsc 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.
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:
Pod spec.runtimeClassName
↓
RuntimeClass.handler (for example runsc)
↓
containerd CRI runtime handler
↓
runsc / gVisor sandboxImportant boundaries:
- RuntimeClass selects an existing handler; it does not install binaries on the node.
- The
handlerstring must match the runtime name in containerd configuration. - Only Pods that set
spec.runtimeClassNameuse the alternate runtime; everything else keeps the defaultrunchandler. RuntimeClasshas been stable innode.k8s.io/v1since 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:
uname -mx86_64gVisor publishes builds for x86_64 and aarch64. Other architectures need a different isolation strategy.
Check containerd and the OS baseline:
containerd --versioncontainerd github.com/containerd/containerd/v2 2.2.5 1.el10_2head -3 /etc/os-releaseNAME="Rocky Linux"
VERSION="10.2 (Red Quartz)"
RELEASE_TYPE="stable"From the control plane, confirm the cluster version:
export KUBECONFIG=/etc/kubernetes/admin.conf
kubectl versionClient Version: v1.36.3
Server Version: v1.36.3gVisor 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:
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-binConfirm the release:
runsc --versionrunsc version release-20260721.0
spec: 1.2.1Do 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:
install -d -m 0755 /etc/containerd/conf.d
cat > /etc/containerd/runsc.toml <<'EOF'
[runsc_config]
systemd-cgroup = "true"
EOFVerify the main containerd configuration imports the drop-in directory:
grep '^imports' /etc/containerd/config.tomlimports = ["/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:
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"
EOFThe 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:
systemctl restart containerd
systemctl restart kubeletConfirm containerd is active:
systemctl is-active containerdactiveProve the merged configuration was loaded:
containerd config dump |
grep -A12 'runtimes.runsc'[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:
crictl info | grep -A6 '"runsc"'"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:
kubectl apply -f - <<'EOF'
apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata:
name: gvisor
handler: runsc
EOFList the object:
kubectl get runtimeclass gvisorNAME HANDLER AGE
gvisor runsc 10sThe 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:
kubectl label node worker01 sandbox.gvisor.io/enabled=true --overwriteExtend the RuntimeClass with scheduling constraints so only eligible nodes receive sandboxed Pods:
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
EOFHow scheduling merges:
RuntimeClass.scheduling.nodeSelectoris combined with the Pod’s ownspec.nodeSelector.RuntimeClass.scheduling.tolerationsappend to Pod tolerations—useful when sandbox nodes carry a dedicated taint such assandbox.gvisor.io/gvisor-only:NoSchedule.- Pods without
runtimeClassName: gvisorignore 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:
kubectl create namespace gvisor-labkubectl 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"]
EOFkubectl 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"]
EOFIf 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:
tolerations:
- key: node.kubernetes.io/network-unavailable
operator: Exists
effect: NoScheduleWatch scheduling:
kubectl wait -n gvisor-lab \
--for=condition=Ready \
pod/busybox-runc pod/busybox-gvisor \
--timeout=120skubectl get pods -n gvisor-lab -o wideNAME 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:
kubectl describe pod -n gvisor-lab busybox-gvisor | grep -E 'Runtime Class|Node:'Node: worker01/192.168.56.109
Runtime Class Name: gvisorOn 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:
POD_ID=$(crictl pods \
--namespace gvisor-lab \
--name busybox-gvisor \
-q | head -1)
crictl inspectp "$POD_ID" |
grep -E '"runtimeHandler"|"runtimeType"'"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:
kubectl exec -n gvisor-lab busybox-runc -- uname -aLinux 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/Linuxkubectl exec -n gvisor-lab busybox-gvisor -- uname -aLinux busybox-gvisor 4.19.0-gvisor #1 SMP Sun Jan 10 15:06:54 PST 2016 x86_64 GNU/LinuxThe gVisor Pod reports the emulated 4.19.0-gvisor kernel string even though the host runs Linux 6.12.
Read /proc/version:
kubectl exec -n gvisor-lab busybox-runc -- cat /proc/versionLinux 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 2026kubectl exec -n gvisor-lab busybox-gvisor -- cat /proc/versionLinux version 4.19.0-gvisor #1 SMP Sun Jan 10 15:06:54 PST 2016dmesg inside the sandbox is not host kernel output—it is a lab signal only, not cryptographic proof of isolation:
kubectl exec -n gvisor-lab busybox-runc -- dmesg 2>&1 | head -1dmesg: klogctl: Operation not permittedkubectl exec -n gvisor-lab busybox-gvisor -- dmesg 2>&1 | head -3[ 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:
kubectl exec -n gvisor-lab busybox-gvisor -- ls /bin
dev
etc
home
proc
root
sys
tmp
usr
varNetworking 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:
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
EOFHow overhead affects scheduling:
- The scheduler adds
overhead.podFixedto 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:
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"]
EOFkubectl get pod -n gvisor-lab gvisor-overhead-test \
-o jsonpath='{.spec.overhead}{"\n"}'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:
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"]
EOFkubectl 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"]
EOFTry mounting the BPF filesystem:
kubectl exec -n gvisor-lab cap-mount-runc -- sh -c 'mkdir -p /tmp/bpf && mount -t bpf bpf /tmp/bpf && echo SUCCESS'SUCCESSkubectl exec -n gvisor-lab cap-mount-gvisor -- sh -c 'mkdir -p /tmp/bpf && mount -t bpf bpf /tmp/bpf' 2>&1mount: mounting bpf on /tmp/bpf failed: No such deviceThe gVisor sandbox rejects the mount even with CAP_SYS_ADMIN. Check containerd logs on the worker if the Pod fails to start entirely:
journalctl -u containerd --since "5 min ago" | grep -i runsc | tail -5When 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
- Encrypt Kubernetes Pod Traffic with Cilium WireGuard
- Enable Istio Mutual TLS with PeerAuthentication
- Build Secure Minimal Container Images
References
- Kubernetes RuntimeClass
- Configure containerd for gVisor
- gVisor installation
- containerd CRI config (version 3)
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.

