cgroups v2 Resource Limits in AI Sandbox Environments
Agents breach cgroup v2's resource limits in ways traditional workloads never do.

Resource limits under cgroup v2 were built for workloads that behave. A web server serves requests at a fairly constant rate. A batch job chews through a known dataset. An AI coding agent does neither: it thinks, it calls a tool, it waits, it thinks again, and somewhere in that loop memory can spike 15 times over baseline in under two seconds. This piece walks through what cgroup v2's CPU, memory, and I/O controls actually do, where their design assumptions come apart under agentic workloads, and what configuration choices hold up when they don't.
What cgroup v2's CPU, memory, and I/O controls actually do, and where each one's model breaks down under agent conditions
cgroup v2 replaced the fragmented, per-controller trees of v1 with a single unified hierarchy rooted at /sys/fs/cgroup. Most modern distributions ship it by default now: Ubuntu 21.10 and later, Debian 11 and later, Fedora 31 and later, RHEL and Rocky 9 and later. That unification is genuinely useful. It also means every resource type gets folded into the same tree, which matters later when it comes to designing hierarchies around tool calls rather than containers.
CPU control happens through two mechanisms that behave very differently. cpu.max sets a quota and period, essentially a hard microsecond budget per cycle. Blow through it and the cgroup gets throttled idle, even if the host machine has cycles sitting unused elsewhere. Many container runtimes expose this file through their CPU limit flags. cpu.weight, by contrast, is a proportional share that only matters under contention. There's no ceiling and no throttle when the host is otherwise idle. The difference sounds academic until a small quota-period combination starts producing throttle edges that show up as measurable stalls, exactly the kind of pause that compounds badly across an agent making many sequential tool calls.
Memory control is a two-level design. memory.high is a soft ceiling: cross it and the kernel triggers reclaim and throttling, not a kill. It buys time for something upstream to react. memory.max is the hard ceiling, and breaching it after reclaim fails means the cgroup OOM killer steps in and SIGKILLs a process inside the group. memory.min and memory.low act as protection floors, shielding a cgroup from aggressive reclaim under host-wide pressure. Worth flagging early: memory accounting includes more than just heap allocations. A container running with a comfortable 512 MiB heap can still get OOM-killed if other usage eats the rest of the allocation.
io.max sets bandwidth and IOPS ceilings per device, relevant the moment an agent starts running package installs or writing coverage data from a test suite. pids.max caps the number of processes and threads a cgroup can hold, which matters enormously for untrusted code where a fork bomb would otherwise take the whole host down with it.
Then there's Pressure Stall Information: cpu.pressure, memory.pressure, io.pressure. PSI reports the percentage of time tasks spent stalled waiting on a resource, and it catches contention that raw utilization numbers miss entirely. A CPU sitting at 80% utilization with high pressure readings means tasks are queuing behind each other, not actually running efficiently. That distinction turns out to matter a great deal once the workload stops being predictable, which is precisely the condition agents create.
All of these controls were calibrated against serverless functions, microservice replicas, batch jobs: workloads with a resource shape known ahead of time, or at least stable enough to average out. Agents don't offer that stability. That's the seam this whole piece is really about.
The three structural mismatches between cgroup v2's design assumptions and AI agent workload reality
Research examining this problem directly, the AgentCgroup paper from Zheng et al. (arXiv:2602.09345), analyzed 144 tasks from the SWE-rebench benchmark across two different LLM backends and named three specific mismatches between how cgroup v2 was designed to work and how agents actually behave. Worth treating this as the organizing frame for everything downstream, because each mismatch maps to a different practical failure mode.
The first is granularity. A single container-level limit governs both the long-lived agent runtime (roughly 185 MB of stable baseline) and the transient subprocess spawned when the agent kicks off a pytest run. Those two things share one budget even though they play completely different roles. Size memory.max to the observed peak and the paper found over 90% of allocated memory sits wasted, since peak demand shows up less than 2% of the time. Size it to the average instead, and OOM kills start firing during ordinary tool bursts, destroying whatever state the agent had accumulated: conversation history, partial file edits, LLM context built up over the task.
The second mismatch is responsiveness. Memory bursts in these workloads last one to two seconds, with rapid change rates that outpace user-space response times. PSI-driven remediation tools like systemd-oomd assume there's a usable window between the pressure signal firing and some corrective action landing. For a burst that lasts one to two seconds, that window mostly doesn't exist. By the time a user-space controller reads the PSI spike and decides to act, the burst has already ended, or the kernel's OOM killer has already fired on its own.
The third is adaptability. Memory demand varies 20 times across different tasks and 1.8 times across repeated runs of the identical task, according to the paper's measurements. There's no stable baseline to build a predictive limit against. Traditional workload managers assume kill-and-restart is an acceptable fallback when limits get breached. For a stateful agent, restarting means discarding all accumulated context and starting the task over from nothing. Retry loops make this worse: progressive memory accumulation across iterations means a budget that comfortably covers iteration one can trigger an OOM by iteration five.
One thing worth sitting with: soft limits don't rescue this either. memory.high triggers kernel reclaim indiscriminately, and reclaim can't tell the difference between the stable 185 MB framework baseline and a short-lived tool subprocess allocation. So the reclaim ends up putting GC pressure on the long-lived runtime, the exact part of the system that needs to stay healthy across the whole task.
How to configure CPU limits for agents without introducing throttle-induced latency spirals
CPU isn't the primary constraint on how many agents can run concurrently on a host. Memory is, according to the AgentCgroup findings. But a badly configured CPU limit still produces visible, measurable latency damage, and it's worth getting right regardless.
The trap is the quota-period combination. A small quota paired with a short period produces a throttle edge at every period boundary, and those edges surface as measurable stalls. An agent making many sequential tool calls accumulates those stalls into real, user-visible latency inflation by the end of a run.
Practical guidance here leans toward cpu.weight as the default control rather than a hard cpu.max quota for most agent workloads. Agents benefit from bursting when the host has spare capacity, and weight-based sharing achieves fairness across tenants without introducing hard throttle edges into the middle of a tool call. Reserve cpu.max for situations where strict isolation is a real requirement, adversarial multi-tenancy or regulatory boundaries where a guaranteed ceiling matters more than the latency cost of hitting it. When cpu.max is genuinely necessary, size the period wide enough that a throttle reset doesn't land inside an individual tool call's execution window. A tool call that spans a period boundary eats a forced idle stall it didn't need to.
For monitoring, cpu.pressure's avg10 figure tells a more honest story than raw utilization. A CPU pegged at 100% with zero pressure is fine, nothing is queuing. The same CPU at 80% utilization with elevated pressure is a contention problem building underneath the surface number.
Agents also tend to run for a long time, executing many sequential tasks back to back rather than one short burst. Under sustained multi-tenant load, that raises a real risk of CPU weight starvation for lower-priority cgroups getting crowded out. Setting explicit cpu.weight floors on high-priority agent cgroups prevents that deprioritization from creeping in unnoticed. And cpu.stat's throttled_usec field is worth checking regularly: it directly quantifies how much wall-clock time an agent lost to throttling, which is a more useful number than almost anything else on this list.
Memory limit strategies that hold through tool-call bursts without killing the agent runtime
Memory is where the binary choice problem gets sharpest. Set memory.max to the observed peak, and the allocation sits mostly idle, wasted on capacity the workload only needs a sliver of the time. Set it to the average, and the routine bursts that happen constantly start triggering OOM kills. Neither number is wrong, exactly. Both are just answering a question the workload doesn't actually ask in that form.
Building limits around the two-layer structure the characterization data reveals is a better starting point. Layer one is the stable framework baseline, around 185 MB for the long-lived agent runtime, and it needs protection from reclaim under pressure. Layer two is the tool-call burst envelope: highly variable, dependent on the task at hand, and genuinely unpredictable from run to run. Those two layers need different treatment, not one shared number.
memory.min or memory.low can protect that framework baseline directly, keeping it shielded from host-wide pressure even while a burst subprocess is actively eating memory elsewhere in the same cgroup. For the burst layer itself, memory.high works better as a managed soft ceiling. Over-committing here, where the sum of high limits across cgroups exceeds physically available memory, is a reasonable bet, because breaching memory.high throttles rather than kills. That gives either a management layer or the burst itself time to resolve naturally. The caveat: reclaim triggered by memory.high still can't distinguish framework baseline from subprocess allocation on its own, so pairing it with protection floors matters. Without that pairing, reclaim can end up squeezing exactly the memory that shouldn't move.
Swap deserves a direct decision rather than a default. Setting memory.swap.max to zero avoids the quiet, hard-to-diagnose slowdown that swap-backed memory produces. For agent workloads where tool-call latency compounds across a task, no swap generally beats swap that hides a memory problem while throughput degrades underneath it.
memory.events is worth watching closely, specifically the oom and oom_kill counters under real load. Budgets should get tuned from that observed event data over time, not locked in from a first estimate and left alone. And page cache accounting is easy to forget: buffers count toward memory.max, so an agent doing heavy file work, git operations, package installs, can hit an OOM even when heap usage looks comfortably within budget. Finally, pids.max deserves its own cap independent of memory limits. Agents that spawn many short-lived subprocesses, compilers, test runners, can exhaust PID space during a burst in a way that has nothing to do with memory at all.
Why tool-call granularity cgroup hierarchies change what is possible for memory control
Here's the structural fix underneath all of the section above: stop treating the agent runtime and its tool subprocesses as one thing sharing one budget. The AgentCgroup paper's core observation is that the 185 MB runtime and whatever subprocess it just spawned currently sit inside the same container-level cgroup limit, despite holding completely different kinds of memory. One holds expensive, accumulated state. The other owns a transient allocation that disappears the moment the tool call finishes.
A cgroup v2 parent-child hierarchy built around tool-call boundaries, rather than around the container as a whole, changes what's possible here. The parent node covers the agent's total budget. Each tool call gets its own child node with its own sub-limit underneath that parent. A git status call and a pytest run can carry entirely different constraints while both stay inside the same overall allocation.
That structure changes recovery behavior too. Crossing a soft limit can freeze or throttle just the tool subtree while the parent agent process stays alive and its state stays intact. If termination becomes necessary, the tool subtree can be killed on its own, atomically, without touching the conversation history and edits the runtime is holding above it.
AgentCgroup implements this using eBPF, specifically sched_ext for CPU scheduling and memcg_bpf_ops for memory, giving in-kernel enforcement at syscall granularity that reacts faster than any user-space PSI-driven controller could manage. The paper reports 29% lower P95 latency for high-priority workloads under multi-tenant memory contention using this approach. The project is open-source under GPL-2.0, available at github.com/eunomia-bpf/agentcgroup, with experiments run on Linux under cgroup v2.
Teams not ready to adopt an eBPF-based controller don't need to wait for that tooling to get the core benefit, though. The hierarchy principle itself, a runtime node held separate from tool-call child nodes, is implementable using standard cgroup v2 filesystem operations without any of AgentCgroup's in-kernel machinery. The granularity gain lives at the configuration layer as much as the enforcement layer.
One limitation worth naming honestly: eBPF and cgroup controllers operating at syscall granularity can observe things like open() and execve() calls and cgroup-level counters, but they can't attribute a sequence of syscalls back to a semantic, agent-level action. Anyone needing cryptographic tamper evidence or full auditability of what an agent did, as opposed to what resources it consumed, needs a separate layer for that. Resource control and audit logging are solving different problems, even though they sit near each other in the stack.
I/O limits and their interaction with the file-heavy operations agents run most often
Coding agents spend a surprising amount of their runtime touching disk rather than reasoning. Package installs, git clones and checkouts on large repositories, test suites writing coverage data, compilers producing intermediate build artifacts: all of it lands as I/O, and io.max is the control governing bandwidth and IOPS ceilings per block device for a given cgroup.
The interaction worth watching is how bursty this I/O tends to be, in a pattern that echoes the memory story from earlier sections. A package install can produce a short, intense burst of small file writes as it unpacks dependencies. A test suite writing coverage data does something similar at the end of a run. Neither of these looks like a steady, well-behaved I/O stream that a static bandwidth ceiling was designed around; they look like spikes layered on top of otherwise quiet periods, not unlike the memory bursts driving the case for tool-call granularity in the section above.
An io.max ceiling set too conservatively turns a five-second package install into something considerably slower, adding latency to a step of the agent loop that's already competing with model inference time for the user's patience. Set too loosely, and one agent's dependency install can crowd out I/O bandwidth for every other tenant on the same disk. The right number depends heavily on what kind of file operations the agent's typical toolchain actually runs, which argues for measuring real workload I/O patterns before locking in a ceiling, rather than picking one from a generic multi-tenant hosting playbook that assumed steadier demand than agents produce.
The broader point connects back to where this piece started. Every control examined here, CPU quotas, memory ceilings, I/O bandwidth limits, was built with an assumption of workload predictability baked into its defaults. Agent workloads don't offer that predictability, not because the tooling is immature, but because the reason-act-observe loop genuinely produces resource demand that shifts by an order of magnitude from one task to the next. Configuring cgroup v2 for this class of workload means starting from that irregularity rather than fighting it.


