
Runtime
Containers vs VMs: Which Boundary Are You Trusting?
Containers share a kernel; VMs do not. Where runc, gVisor, Kata and full VMs put the boundary, what an escape reaches, and what NIST actually says.
Most comparisons of containers and VMs answer a performance question — containers start faster, use less memory — and then get cited in a security control. A control owner writes "workloads are isolated using container technology" into a system security plan and an auditor accepts it. Nobody asked what the control rests on: when something gets out, what does it get out *into*?
The question behind the question
NIST SP 800-190, the Application Container Security Guide, is where compliance conversations land — and it declines the framing outright. Its §2.2 states that organisations do not need to make a choice between containers and VMs: keep VMs to partition hardware, containers to package apps. The nesting is the recommendation, not a compromise. A September 2026 arXiv survey agrees: no virtualization technology is secure by default, and the right comparison is between hardened configurations under a defined threat model.
So the well-formed question is a boundary claim a team can defend: *this workload's compromise must not reach the host kernel, here is the mechanism that stops it, the evidence it was in force, and what it costs.* It answers differently for a batch job on your own data than for arbitrary tenant code — and both can share nodes, one pod at a time.
What a shared kernel actually shares
A container is a process. What makes it feel like a machine — a private process tree, mount table, network stack and memory ceiling — is namespaces and cgroups, features of the host kernel. Every syscall it issues is serviced by the same code serving every other container on the node, and every boundary confining it is a structure that kernel keeps.
That design is why containers are cheap. SP 800-190 states the consequence plainly: although containers strongly isolate resources in software, §3.5.2 holds that a shared kernel invariably results in a larger inter-object attack surface than seen with hypervisors, even for container-specific operating systems, and that container-runtime isolation is not as high as a hypervisor's. Its §2.2 puts it in boundary terms: containers do not offer as clear and concrete a security boundary as a VM, because they share a kernel and can run with varying capabilities and privileges on a host.
Containers are not insecure; the boundary is simply wide and soft-edged. A hypervisor's interface to a guest is a minimal set of virtual devices and instruction traps; a kernel's interface to a process is the whole syscall table, /proc, /sys and every visible device's ioctls. It also separates application from system containers: an init system changes packaging, not the boundary.
Four boundaries, not two
Between those poles sit two production-grade open-source projects that are neither. The ladder below is editorial: no standard publishes this taxonomy, and it omits a fifth boundary — the WebAssembly runtimes that replace the process model.
Rung 2 — gVisor: a kernel written in user space
gVisor's first defence-in-depth principle is categorical and carries its cost in the same breath: no system call is passed through directly to the host. Every supported call has an independent implementation in the Sentry, gVisor's user-space kernel, which the project argues is unlikely to share the host's vulnerabilities — so, in its words, all kernel features used by applications require an implementation within the Sentry. Compatibility is therefore a function of how much of Linux is reimplemented. gVisor is equally direct about hardware: do not assume the mere use of virtualization hardware makes a system more or less secure, and it does not generally protect against side channels.
Its performance guide states that syscall interception principally impacts applications that are system call bound, the impact falling the more work an application does per call. It also argues against its own adoption in one case: a sandbox may provide minimal benefit for a trusted database, whose user data is already inside it.
Rung 3 — Kata Containers: a kernel per pod
Kata Containers describes itself as a secure runtime using lightweight VMs that feel and perform like standard Linux containers but provide stronger workload isolation using hardware virtualization technology as a second layer of defence. The detail that matters comes a sentence later: the environment the Kata agent creates is equivalent to a runc one, and cgroups and namespaces are created *inside the VM by the guest kernel*. A breakout lands in a kernel that exists for that pod alone, with a VMM and hypervisor still in front of the host.
The VMM is a real choice with consequences. Kata's hypervisor comparison — not prescriptive or authoritative by its own admission, maintained on main — records, as of October 2026, that Cloud Hypervisor, Firecracker, Dragonball and StratoVirt support none of GPU, Intel TDX or AMD SEV-SNP while QEMU supports all three. That is which Kata runtimes carry the support, not what those VMMs can do generally — but for a Kata deployment it is the constraint. If the roadmap includes GPUs or confidential computing, the lighter VMMs are off the table.
Firecracker shows what minimising that interface buys: its FAQ states it provides only 6 emulated devices, which with a streamlined kernel loading process enables startup under 125 ms and a footprint under 5 MiB — its own figures for the VMM process, not an independent benchmark, and not the cost of a Kata pod, which carries a guest kernel too.
Rung 4 — the full guest VM, and why it differs in kind
The fourth rung is the one most teams already have. A full guest VM is not a runtime Kubernetes can select; it is a machine — reached by placing the workload on other hardware or in another cluster, or as a KubeVirt `VirtualMachine`. Often the right answer — but one the next section's mechanism cannot reach.
Failure modes: blast radius and preconditions
The temptation is to count CVEs, which gets the answer wrong in both directions. November 2025 gave the shared-kernel rung a bad week, but the maintainers published one set of three advisories whose patches shipped together in runc 1.2.8, 1.3.3 and 1.4.0-rc.3. One disclosure, one fix, three records.
- CVE-2025-31133 — present in all known versions: when masking files with the container's
/dev/null, runc did not verify the bind-mount source was a real/dev/nullinode, yielding an arbitrary mount gadget or amaskedPathsbypass. Three maintainer ranges, each with its own fix:<= 1.2.7in 1.2.8;>= 1.3.0-rc.1, <= 1.3.2in 1.3.3;>= 1.4.0-rc.1, <= 1.4.0-rc.2in 1.4.0-rc.3. 1.1.x and earlier were never patched. 1.3.2 is newer than 1.2.8 and still vulnerable. - CVE-2025-52565 — the same mistake against a different target: the
/dev/pts/$nbind-mount onto/dev/console, in runc 1.0.0-rc3 and later. It happens *after*pivot_root(2), so it cannot write host files directly — but *before*maskedPathsandreadonlyPathsare applied, so it can hand the attacker a writable/proc/sysrq-triggeror/proc/sys/kernel/core_pattern. - CVE-2025-52881 — redirected
/procwrites via a racing container with shared mounts.
Two things to extract. The target: runc's v1.3.3 notes state all three allow full container breakouts by bypassing runc's restrictions for writing to arbitrary `/proc` files — procfs belonging to the *host* kernel, exactly what the shared-kernel rung exposes and rungs 2 and 3 do not. The preconditions, which are *not* shared: CVE-2025-31133 and CVE-2025-52881 both need an attacker able to race containers sharing mounts on the node — runc verified a standard Dockerfile under docker buildx build suffices. CVE-2025-52565 is the console bind-mount above, reached through a malicious container config — so a node that never runs buildx is not safe from it. The 31133 and 52565 advisories name user namespaces with the host root user unmapped — those procfs files are Unix-DAC gated; 52881 names rootless runc instead. LSMs are partial, not absent: the default Docker/Podman AppArmor profile blocks writes to most maskedPaths targets under 31133, but not /dev/console under 52565 — which default containers-selinux does mitigate. Both advisories add that 52881 bypasses LSM labels, so AppArmor is no mitigation for the set. On config.json: an attacker who could rewrite it arbitrarily already had those mounts, but maskedPaths is applied by almost all higher-level runtimes — so this flaw hands the same host procfs files to anyone who can merely spawn containers. NVD's primary CVSS 3.1 for CVE-2025-31133 is 7.8, its **high** attack complexity that vector's judgement; GitHub's CNA rates it 7.3 on CVSS 4.0 at AC:L, with AT:P and UI:A.
Now the other rung, read the same way. CVE-2026-53359 is a use-after-free in the Linux kernel's KVM x86 shadow-paging code, published on the NVD on 2026-07-04 with a CNA-supplied secondary CVSS 3.1 score of 8.8 and changed scope. Red Hat scores it 7.8 with high attack complexity, states an attacker needs privileged access inside the guest VM to trigger it by manipulating host page directory entries, and gives a mitigation: disable nested virtualization. Two scores, one flaw: carry both, attributed.
The hypervisor rung is not invulnerable — only narrower: tighter preconditions, a vendor-stated mitigation, an escape landing on the hypervisor rather than a kernel shared with every tenant. A boundary is a probability argument, not a guarantee, so it belongs beside runtime threat detection and a vulnerability pipeline that knows your node's runc version before an advisory does.
Selecting the boundary in Kubernetes
Kubernetes selects rungs 1 to 3 per pod: the RuntimeClass resource has two significant fields, metadata.name and handler, and a pod names one through spec.runtimeClassName. If that RuntimeClass is missing, or the CRI cannot run the handler, the pod enters the Failed terminal phase.
RuntimeClass assumes homogeneous nodes; real clusters are not. scheduling fixes that: its nodeSelector merges with the pod's own at admission — the *intersection*, a conflict rejecting the pod — and its tolerations are unioned, so sandboxed nodes can be tainted to keep everything else off. overhead.podFixed, stable since v1.24, declares the sandbox's per-pod cost, so the scheduler stops pricing a Kata pod like a runc pod — a figure to measure, not copy. Kata's deploy chart sets the qemu shim to 250m and 320Mi, and explains why: on aarch64, QEMU's own anonymous RSS is ~160Mi before any guest RAM, and with sandbox_cgroup_only the VMM runs inside the pod cgroup, so a 160Mi overhead lets it be OOM-killed for small-memory-limit pods. The chart applies 320Mi on every architecture, over-provisioning x86 by ~160Mi. Its sizing guide adds that podFixed must cover guest- *and* host-side overhead, and that Kata cannot ship one correct value: it depends on hypervisor, file sharing, VM image and hardware. gVisor publishes no runsc figure. SpinKube shows the same shape; overhead is specific to boundaries that cost real memory.
apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata:
name: gvisor
handler: runsc # must match a CRI runtime handler on the node
scheduling:
nodeSelector:
sandbox.example.com/runtime: runsc # INTERSECTED with the pod's own nodeSelector
tolerations:
- key: sandbox.example.com/runtime
operator: Equal
value: runsc
effect: NoSchedule
overhead:
podFixed:
cpu: 50m # PLACEHOLDER -- gVisor publishes no podFixed
memory: 64Mi # PLACEHOLDER -- measure it
---
apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata:
name: kata-qemu
handler: kata-qemu
scheduling:
nodeSelector:
sandbox.example.com/runtime: kata
tolerations:
- key: sandbox.example.com/runtime
operator: Equal
value: kata
effect: NoSchedule
overhead:
podFixed:
cpu: 250m # kata-deploy's qemu-shim values.
memory: 320Mi # chart value; 160Mi risks an aarch64 OOM-kill.
---
apiVersion: v1
kind: Pod
metadata:
name: tenant-job
namespace: untrusted
spec:
runtimeClassName: kata-qemu # the entire boundary decision, in one field
containers:
- name: job
image: registry.example.com/tenant/job:1.4.2
The handler resolves to a table on the node, and that path moved between containerd majors. containerd's CRI configuration guide documents both: 2.x uses version = 3 under io.containerd.cri.v1.runtime, 1.x uses version = 2 under io.containerd.grpc.v1.cri, version 2 still accepted by 2.x and converted automatically. A table under the wrong plugin URI is never registered: the RuntimeClass resolves to nothing and the pod lands in Failed, nothing in the spec saying why. CRI-O keeps handlers under [crio.runtime.runtimes.<name>] in /etc/crio/crio.conf.
version = 3
[plugins."io.containerd.cri.v1.runtime".containerd.runtimes.runsc]
runtime_type = "io.containerd.runsc.v1"
[plugins."io.containerd.cri.v1.runtime".containerd.runtimes.kata-qemu]
runtime_type = "io.containerd.kata.v2"
# Handler NAME pins no config. Without ConfigPath: KATA_CONF_FILE if set, else a default.
[plugins."io.containerd.cri.v1.runtime".containerd.runtimes.kata-qemu.options]
ConfigPath = "/opt/kata/share/defaults/kata-containers/configuration-qemu.toml"
# ...
version = 2
[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runsc]
runtime_type = "io.containerd.runsc.v1"
[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.kata-qemu]
runtime_type = "io.containerd.kata.v2"
# ...
What you can actually show an auditor
Two NIST documents come up, and conflating them is how a control argument falls apart under questioning. SP 800-190 speaks to this choice: the §3.5.2 and §2.2 sentences above, plus its rule to only group containers with the same purpose, sensitivity, and threat posture on a single host OS kernel. Its §4.3.4 names the mechanism — segmentation by multiple physical servers, though modern hypervisors also provide strong enough isolation to mitigate the risk. That endorses hypervisor segmentation generically, from a 2017 document that predates every sandboxed runtime named here and ranks none.
SP 800-53 Rev 5 does *not* answer the question, despite being cited as if it does. SC-39 requires maintaining a separate execution domain for each executing system process, its discussion noting that process isolation technologies — including sandboxing or virtualization — logically separate software and firmware from other software, firmware and data. SC-39(1) adds that hardware-based separation is generally less susceptible to compromise, naming hardware memory management. Neither ranks runc, gVisor, Kata or a full VM.
What you *can* produce is evidence the *request* was constrained and recorded: a policy rejecting any pod in a regulated namespace that does not name a sandboxed handler. Every rejection is a logged attempt; every admission records which boundary was asked for — not which one the node delivered. That second half is a runtime check, below.
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: require-sandboxed-runtime
spec:
validationFailureAction: Enforce
rules:
- name: regulated-namespaces-must-name-a-sandbox
match:
any:
- resources:
kinds: [Pod]
namespaceSelector:
matchLabels:
data-classification: regulated
validate:
message: "Pods here must select a sandboxed boundary: gvisor or kata-qemu."
pattern:
spec:
runtimeClassName: "gvisor|kata-qemu"
The exit ramp, and the long game
Build on this mechanism, not around it: three of the four rungs stay reversible. Same OCI image, same pod spec, one field different — move a workload from runc to gVisor to Kata and back without a rebuild. That is optionality with a measurable cost: overhead.podFixed, the compatibility surface, the VMM's feature table. The one-way door is the fourth rung: a full guest VM is a placement change or a KubeVirt object, not a handler swap. And verify what the pod got, not what the spec asked. Only runc reports the node's own kernel release: Kata reports the pod's guest kernel, gVisor its own advertised release — 4.19.0-gvisor by default, 5.15.0-gvisor only when the RDMA proxy is enabled *and* the OCI spec lists a /dev/infiniband/uverbs device — not a build tag. A mismatch under gVisor is the sandbox working, not a missing handler.
#!/usr/bin/env bash
set -euo pipefail
NS=untrusted POD=tenant-job
NODE=$(kubectl -n "$NS" get pod "$POD" -o jsonpath='{.spec.nodeName}')
# What the spec asked for.
kubectl -n "$NS" get pod "$POD" -o jsonpath='{.spec.runtimeClassName}'; echo
# What it got -- compare release tokens, not /proc/version lines:
# runc -> the node's own Kata -> the pod's guest kernel
# gVisor -> 4.19.0-gvisor (5.15.0-gvisor only if RDMA proxy + uverbs device)
kubectl -n "$NS" exec "$POD" -- uname -r
kubectl get node "$NODE" -o jsonpath='{.status.nodeInfo.kernelVersion}'; echo
# The CRI runtime (containerd:// or cri-o://) -- NOT runc's version.
kubectl get node "$NODE" -o jsonpath='{.status.nodeInfo.containerRuntimeVersion}'; echo
# runc's version needs a node shell. Per release line, never one upper bound:
# 1.2 -> fixed in 1.2.8 (<= 1.2.7 affected)
# 1.3 -> fixed in 1.3.3 (1.3.0-rc.1 .. 1.3.2 affected)
# 1.4 -> fixed in 1.4.0-rc.3 (1.4.0-rc.1, 1.4.0-rc.2 affected)
kubectl debug "node/$NODE" -it --image=busybox --profile=sysadmin -- chroot /host sh -c 'PATH=/usr/local/sbin:/usr/sbin:/usr/bin:/sbin runc --version'
Standardise the seam, not the runtime: one OCI image any handler can run, a RuntimeClass per boundary, node pools labelled and tainted to match, an admission policy recording the decision. Then the runtime becomes a revisable choice — the right posture, because the facts underneath move. Kata's hypervisor table will not say the same thing in two years, and a new advisory will land on some rung, as in November 2025 and July 2026.
So own the selection mechanism, not the conclusion. A team that can move a workload between boundaries in a pull request has turned an architectural commitment into an operational one — and can answer the only version of "containers or VMs" an auditor, a regulator or an incident actually asks: not which is safer in the abstract, but which boundary this workload is behind, why, and what happens when it fails.
§FAQ/Common questions
Frequently asked
What is the real difference between a Docker container and a virtual machine?
A container is a process on the host kernel; a virtual machine has a kernel of its own, on a hypervisor. Everything that makes a container feel like a machine — its process tree, mount table, network stack and memory ceiling — is namespaces and cgroups, objects the host kernel maintains. NIST SP 800-190 states the consequence directly: a shared kernel invariably results in a larger inter-object attack surface than seen with hypervisors, and containers do not offer as clear and concrete a security boundary as a VM. That is not a verdict that containers are unsafe. An escape from a container lands on the host kernel; an escape from a VM lands on the hypervisor — different blast radii for the same word, "isolation".
Is a container escape actually realistic, or is it theoretical?
It is documented. In November 2025 the runc maintainers published one set of three advisories — CVE-2025-31133, CVE-2025-52565 and CVE-2025-52881 — patched together in runc 1.2.8, 1.3.3 and 1.4.0-rc.3. runc's release notes state all three allow full container breakouts by bypassing runc's restrictions for writing to arbitrary /proc files. Not three independent failures, but their preconditions differ. CVE-2025-31133 and CVE-2025-52881 need an attacker who can race containers sharing mounts on the node, which runc verified a standard Dockerfile under docker buildx build suffices to do; CVE-2025-52565 is the /dev/pts/$n bind-mount onto /dev/console, applied after pivot_root but before maskedPaths and readonlyPaths, in runc 1.0.0-rc3 and later. The 31133 and 52565 advisories name user namespaces with the host root user unmapped as the mitigation; 52881 names rootless runc. The default Docker and Podman AppArmor profile blocks most maskedPaths writes under 31133 but not /dev/console under 52565, which default containers-selinux does mitigate; both advisories note that CVE-2025-52881 bypasses LSM labels, so AppArmor does not hold for the set. NVD scores CVE-2025-31133 7.8 on CVSS 3.1, that vector carrying high attack complexity; GitHub's CVSS 4.0 score of 7.3 is AC:L with AT:P and UI:A. The hypervisor side has records too — KVM's CVE-2026-53359 — but Red Hat states it needs privileged access inside the guest VM and is mitigated by disabling nested virtualization.
How do gVisor and Kata Containers differ, and when should I use each?
gVisor intercepts system calls and services them in the Sentry, a kernel written in user space; its documentation states no system call is passed through directly to the host, so every kernel feature an application uses must be reimplemented in the Sentry. Kata Containers runs each pod in a lightweight VM, so the cgroups and namespaces confining the workload are created inside the VM by the guest kernel, not the host's. Reach for gVisor when the workload is ordinary, not syscall-bound, and a narrower host interface is wanted cheaply; reach for Kata for hardware virtualization as a second layer, or when compatibility with unusual kernel features outweighs per-pod overhead.
How do I choose the container isolation boundary per workload in Kubernetes?
Through RuntimeClass. The resource has two significant fields — metadata.name and handler — and a pod selects one with spec.runtimeClassName; if the RuntimeClass does not exist or the CRI cannot run the handler, the pod enters the Failed terminal phase. In a heterogeneous cluster, set scheduling.nodeSelector (intersected with the pod's own at admission) and scheduling.tolerations (unioned) so sandboxed pods land only on nodes carrying the handler, and declare overhead.podFixed — stable since v1.24 — so the scheduler accounts for the sandbox cost. The handler resolves to a table in /etc/containerd/config.toml: io.containerd.cri.v1.runtime on containerd 2.x, io.containerd.grpc.v1.cri on 1.x.
Does NIST SP 800-53 require VMs instead of containers for sensitive workloads?
No, and citing it that way will not survive questioning. SC-39 requires a separate execution domain for each executing system process, its discussion noting that process isolation technologies — sandboxing or virtualization among them — logically separate software and firmware from other software, firmware and data. SC-39(1) adds that hardware-based separation is generally less susceptible to compromise, naming hardware memory management. Both are generic and rank none of runc, gVisor, Kata or a full VM. The document that speaks to this choice is SP 800-190: its §4.3.4 recommends a given host kernel run containers of a single sensitivity level, segmentation provided by multiple physical servers or by hypervisors — a grouping control carried by node pools, nodeSelector and taints, separate from which handler a pod may name.
Further reading
- LXC and Incus: system containers versus application containers
- KubeVirt: running VM workloads on Kubernetes after VMware
- Confidential computing on Kubernetes: remote attestation and TEEs
- WebAssembly on Kubernetes: SpinKube, runwasi, and wasmCloud
- Falco and Tetragon: runtime threat detection with eBPF
- vcluster: hard multi-tenancy and cluster consolidation
- Vulnerability management platforms versus a self-hosted scanning pipeline
Executive Briefing
Thirty minutes to clarify your infrastructure risk
Walk us through your vendor footprint and regulatory constraints. We will tell you honestly where sovereignty creates leverage — and where it does not. No pitch deck. No obligation.