Docker accepts --pids-limit under every runtime. Not every runtime enforces it.
I run code that AI agents write inside sandboxes, through a small service I built called plimsoll. One limit it puts on every sandbox is the number of processes, so a fork bomb in agent code stays a problem for that one sandbox. In Docker that is one flag: --pids-limit.
Docker accepts the flag whichever runtime runs the container. What the runtime does with it is a separate question, and I hadn't asked it until I tried Kata as a runtime. So I ran the same test under the three runtimes on my machine: runc (Docker's default), gVisor and Kata Containers. Start a container with --pids-limit 64, read the limit from inside, then try to start 200 sleep processes.
RUNTIME=runc # or runsc (gVisor), or kata
docker run --rm --runtime=$RUNTIME --pids-limit 64 node:22-alpine node -e '
const fs = require("fs"), { spawn } = require("child_process");
for (const f of ["/sys/fs/cgroup/pids.max", "/sys/fs/cgroup/pids/pids.max"])
if (fs.existsSync(f)) console.log(f, "=", fs.readFileSync(f, "utf8").trim());
let ok = 0, failed = 0, why = "";
for (let i = 0; i < 200; i++) {
let c; try { c = spawn("sleep", ["60"]); } catch (e) { failed++; why = why || e.code; continue; }
c.on("spawn", () => ok++);
c.on("error", e => { failed++; why = why || e.code; });
}
setTimeout(() => { console.log(`started ${ok} of 200, failed ${failed} ${why}`); process.exit(0); }, 3000);'
(I first wrote this in plain sh. Under runc the shell died at the limit, because a failed fork is fatal to it, so it never printed a count. Use something that survives the failure.)
Docker 29.1.3, gVisor release-20260907.0, Kata 4.2.0, one laptop:
| Runtime | pids.max inside | Started | At the limit |
|---|---|---|---|
| runc | 64 | 57 of 200 | EAGAIN, and the program carries on |
| gVisor | max | 14 | ENOMEM, then the whole container exits with status 2 and prints nothing |
| Kata | max | 200 of 200 | no limit at all |
runc does what you'd expect. The count is under 64 because node's own threads are counted against the same limit. Under gVisor the probe prints nothing past the first line; the 14 comes from a version that logs each spawn as it happens.
Kata doesn't apply it. The limit never reaches the VM, so inside it there is none. This is known upstream: kata-containers#4887 ↗ was closed as not planned ("there is no way to provide this value to the agent"), and #13824 ↗, open since September, reports the same on 4.1.0 with Cloud Hypervisor. To be fair to Kata, the code is inside a VM, so a fork bomb eats that VM's memory and CPUs, not the host's. But if your sandbox promises its users 64 processes, the promise is false. My Kata setup is the one Kata documents for Docker (its deprecated Go shim, "tested only with QEMU"), and I saw the same under Cloud Hypervisor.
gVisor does apply it, but not where you can see it, and not with the budget you think. From inside, gVisor shows its own emulated cgroup files in the old v1 layout, and /sys/fs/cgroup/pids/pids.max says max. /proc/self/cgroup names a cgroup for the container that doesn't exist in that tree. The real limit is on the host, on the cgroup of the whole sandbox, and that cgroup also holds gVisor's own tasks: about 30 when idle, plus about two host tasks for every process the guest starts. At a limit of 64 the guest got 14 processes. At 256 it got 112, where runc gives 192 or more.
The failure isn't clean either. The spawn that crosses the line fails with ENOMEM, not EAGAIN, and a few spawns later the whole container exits with status 2 and no output: the program above never prints its last line. At 64 that happened in 6 runs out of 6. At 256, in an earlier measurement, it happened in 2 of 4.
You may read that under gVisor the host cgroup doesn't bound guest processes at all, since they are tasks inside gVisor's kernel. On gVisor's default platform, systrap, that isn't what I measured: the host limit is what stopped the guest. I haven't tried the KVM platform.
What I do about it
Prove the limit where it is enforced, and refuse to run if you can't.
plimsoll now does that at startup. Under runc it starts a throwaway container and requires pids.max inside to equal the configured limit. Under gVisor the inside is useless, so it starts one more container, takes the PID Docker reports for it, finds that process's cgroup on the host (refusing unless the cgroup path names that container) and reads pids.max there. If the number isn't the configured one, or can't be read, the daemon doesn't start. Kata fails this check, which was one of the reasons I didn't add it as a runtime.
A test also holds each runtime to a minimum number of processes at the default limit, so if a gVisor release makes each process cost more host tasks, the build fails instead of users finding out.
The embarrassing part: my first version of the check read pids.max inside the container under every runtime. Every gVisor daemon then refused to start, because gVisor's file says max. That's how I learned where gVisor actually keeps the limit.
If you size a process limit for gVisor, leave room for gVisor: at 256, a guest gets well under half.
The check is checkPidsLimit and hostPidsMaxPath in sandbox/docker_smoke.go ↗. plimsoll is Apache-2.0: https://github.com/plimsollmark/plimsoll ↗