Description
Title: runsc does not honor OCI LinuxResources.Memory.Swap when joining the pod cgroup, leaving K8s LimitedSwap unenforced for gVisor pods
Repro environment
- gVisor:
release-20260413.0
- Kubernetes: v1.35.5 (via k3s)
- runtime:
containerd-shim-runsc-v1
- cgroup v2,
cgroupfs driver (also tested with systemd driver — same behavior)
- kubelet config:
apiVersion: kubelet.config.k8s.io/v1beta1
kind: KubeletConfiguration
failSwapOn: false
memorySwap:
swapBehavior: LimitedSwap
- Node has 128 GiB encrypted swap configured (
vm.swappiness=10, zswap enabled).
What I expected
Per the K8s LimitedSwap documentation,
each Burstable container should be given a proportional swap budget:
swap.max = (container.memory.request / node.memory.capacity) * node.swap.total
For a Burstable Pod with requests.memory: 384Mi, limits.memory: 1Gi
on a 251 GiB / 128 GiB node, the expected memory.swap.max is ~195 MiB.
What I observed
For a Pod with runtimeClassName: gvisor, no memory.swap.max is
applied anywhere the runsc sandbox can see:
$ cat /sys/fs/cgroup/kubepods/burstable/pod<uid>/memory.swap.max
max
$ cat /sys/fs/cgroup/kubepods/burstable/pod<uid>/<container-id>/memory.swap.max
max
For a Pod scheduled with the default runc runtime, the same spec
correctly receives the calculated swap budget at the container cgroup:
$ cat /sys/fs/cgroup/kubepods/burstable/pod<uid>/<container-id>/memory.swap.max
204472320 # ~195 MiB, matches LimitedSwap formula
Root cause (proposed)
This appears to be a natural consequence of the design in
google/gvisor#6007,
which made the runsc sandbox join the pod-level cgroup rather than
the per-container cgroup. The kubelet (and containerd's runc shim) writes
memory.swap.max to the container cgroup — but for gVisor, the
sandbox process never actually enters that cgroup, so the kernel never
enforces the LimitedSwap budget for the workload.
runsc itself already has code to write memory.swap.max
(runsc/cgroup/cgroup_v2.go memory2.set())
— it just isn't invoked here because the pod-level cgroup is set up by
the kubelet/CRI, and the swap field of OCI LinuxResources.Memory isn't
propagated to it.
Impact
memory.max at the pod level still enforces the memory ceiling, so a
single Pod cannot exceed its configured limit. But because
memory.swap.max = max, a gVisor Pod under memory pressure can consume
an unbounded share of the node's swap pool, starving its neighbors —
defeating the noisy-neighbor isolation that LimitedSwap was added to
provide.
Suggested fix paths
-
When runsc joins the pod-level cgroup, also apply the swap field
from the OCI LinuxResources.Memory it received, by extending
memory2.set()'s caller into the pod-cgroup setup path.
-
Alternatively (simpler interim), have runsc emit a config knob
(e.g. --apply-pod-cgroup-swap-max) that, when enabled, writes the
per-pod swap budget computed from container requests.
Workaround
For anyone hitting this today: a small Kubernetes DaemonSet that watches
gVisor Pods on its node and writes the LimitedSwap-formula value
(memory.swap.max = container.requests.memory / node.memory * node.swap)
directly to the pod cgroup using the standard K8s informer pattern. The
DaemonSet needs pods: [get, list, watch] RBAC plus privileged hostPath
access to /sys/fs/cgroup. Same math kubelet uses for runc, just applied
at the pod-level cgroup where the runsc sandbox actually lives. Happy
to share the implementation if useful — let me know.
Once upstream applies the swap field from the OCI spec when joining the
pod cgroup, the workaround can be removed cleanly.
Steps to reproduce
No response
runsc version
docker version (if using docker)
uname
No response
kubectl (if using Kubernetes)
repo state (if built from source)
No response
runsc debug logs (if available)
Description
Title: runsc does not honor
OCI LinuxResources.Memory.Swapwhen joining the pod cgroup, leaving K8s LimitedSwap unenforced for gVisor podsRepro environment
release-20260413.0containerd-shim-runsc-v1cgroupfsdriver (also tested withsystemddriver — same behavior)vm.swappiness=10, zswap enabled).What I expected
Per the K8s LimitedSwap documentation,
each Burstable container should be given a proportional swap budget:
For a Burstable Pod with
requests.memory: 384Mi, limits.memory: 1Gion a 251 GiB / 128 GiB node, the expected
memory.swap.maxis ~195 MiB.What I observed
For a Pod with
runtimeClassName: gvisor, nomemory.swap.maxisapplied anywhere the runsc sandbox can see:
For a Pod scheduled with the default
runcruntime, the same speccorrectly receives the calculated swap budget at the container cgroup:
Root cause (proposed)
This appears to be a natural consequence of the design in
google/gvisor#6007,
which made the runsc sandbox join the pod-level cgroup rather than
the per-container cgroup. The kubelet (and containerd's runc shim) writes
memory.swap.maxto the container cgroup — but for gVisor, thesandbox process never actually enters that cgroup, so the kernel never
enforces the LimitedSwap budget for the workload.
runsc itself already has code to write
memory.swap.max(runsc/cgroup/cgroup_v2.go
memory2.set())— it just isn't invoked here because the pod-level cgroup is set up by
the kubelet/CRI, and the swap field of
OCI LinuxResources.Memoryisn'tpropagated to it.
Impact
memory.maxat the pod level still enforces the memory ceiling, so asingle Pod cannot exceed its configured limit. But because
memory.swap.max = max, a gVisor Pod under memory pressure can consumean unbounded share of the node's swap pool, starving its neighbors —
defeating the noisy-neighbor isolation that LimitedSwap was added to
provide.
Suggested fix paths
When
runscjoins the pod-level cgroup, also apply the swap fieldfrom the OCI
LinuxResources.Memoryit received, by extendingmemory2.set()'s caller into the pod-cgroup setup path.Alternatively (simpler interim), have runsc emit a config knob
(e.g.
--apply-pod-cgroup-swap-max) that, when enabled, writes theper-pod swap budget computed from container requests.
Workaround
For anyone hitting this today: a small Kubernetes DaemonSet that watches
gVisor Pods on its node and writes the LimitedSwap-formula value
(
memory.swap.max = container.requests.memory / node.memory * node.swap)directly to the pod cgroup using the standard K8s informer pattern. The
DaemonSet needs
pods: [get, list, watch]RBAC plus privileged hostPathaccess to
/sys/fs/cgroup. Same math kubelet uses for runc, just appliedat the pod-level cgroup where the runsc sandbox actually lives. Happy
to share the implementation if useful — let me know.
Once upstream applies the swap field from the OCI spec when joining the
pod cgroup, the workaround can be removed cleanly.
Steps to reproduce
No response
runsc version
docker version (if using docker)
uname
No response
kubectl (if using Kubernetes)
repo state (if built from source)
No response
runsc debug logs (if available)