cgroup v2 memory.high vs memory.max: Throttling Versus OOM

In cgroup v2, memory.high throttles and adds reclaim pressure, while memory.max is the hard limit that can lead to OOM. Covers memory.events and memory.oom.group from the kernel docs.

Linuxcgroup v2memoryOOMoperations

When you cap memory for a container or a service, cgroup v2 gives you two files: memory.high and memory.max. Both sound like “don’t go past this,” but the kernel reacts differently when each one is crossed. One slows processes down. The other can kill them.

This post sticks to one source, the Linux kernel’s “Control Group v2” document (Linux 7.3.0 documentation on docs.kernel.org, checked 2026-10-20). It covers how the two boundaries differ and the files that report what happened: memory.events, memory.events.local, and memory.oom.group. System-wide overcommit is a separate topic, covered in Linux memory overcommit.

Start with memory.current

The most basic of the memory interface files is memory.current. It is a read-only file on non-root cgroups, and the document defines it as “the total amount of memory currently being used by the cgroup and its descendants.” A child cgroup’s usage is included in its parent’s value.

A few ground rules apply to the files below:

  • All memory amounts are in bytes. If you write a value that isn’t aligned to PAGE_SIZE, it may be rounded up to the closest PAGE_SIZE multiple when read back.
  • memory.high and memory.max both default to max, meaning no limit.
  • These files don’t exist on the root cgroup. By the document’s conventions, the root cgroup is exempt from resource control.
  • Tracked usage covers userland memory (page cache and anonymous memory), kernel data structures such as dentries and inodes, and TCP socket buffers. The document itself says the tracking is “not completely water-tight.”

memory.high: cross it and things slow down

The document calls memory.high the “memory usage throttle limit.” When a cgroup’s usage goes over it, the cgroup’s processes are throttled and put under heavy reclaim pressure. The Controller Issues and Remedies section is more specific: allocations are forced into direct reclaim to work off the excess.

Two sentences carry most of the weight:

  1. Going over the high limit never invokes the OOM killer.
  2. Under extreme conditions, the limit may be breached.

The document describes those extreme conditions as many concurrent allocations combined with a complete breakdown of reclaim progress within the group. Even then, the design prefers satisfying the allocation from slack in other groups or the rest of the system over killing the group, and leaves containment of that spillover to memory.max.

So a memory.high value set too aggressively doesn’t terminate processes. It causes gradual performance degradation instead. The document says the user can watch for this and adjust until they find the smallest memory footprint that still gives acceptable performance.

memory.max: if usage can’t be reduced, OOM

memory.max is the “memory usage hard limit,” and the document calls it the main mechanism for limiting a cgroup’s memory usage. If usage reaches this limit and can’t be reduced, the OOM killer is invoked in the cgroup.

The hard limit still comes with documented caveats:

  • Under certain circumstances, usage may go over the limit temporarily.
  • In the default configuration, regular order-0 allocations always succeed unless the OOM killer picks the current task as a victim.
  • Some kinds of allocations don’t invoke the OOM killer. The caller may retry them differently, return -ENOMEM to userspace, or silently ignore the failure, as with disk readahead.

One more passage matters if you ever change memory.max on a live cgroup. A write to memory.max first sets the limit to prevent new charges, then reclaims and OOM-kills until the new limit is met, or until the task writing to memory.max is itself killed. Writing a value below current usage can trigger OOM kills right away.

Side by side

memory.high memory.max
Described as Throttle limit Hard limit
When crossed Throttling and heavy reclaim pressure OOM killer invoked in the cgroup if usage can’t be reduced
OOM killer Never invoked Invoked (some allocation types excepted)
Can it be exceeded? Yes, under extreme conditions Temporarily, under certain circumstances
Default max max
Related events high in memory.events max, oom, oom_kill in memory.events

memory.high is not a kill switch

Setting only memory.high and expecting usage to stay under it misreads the design. The document says the high limit “should be used in scenarios where an external process monitors the limited cgroup to alleviate heavy reclaim pressure.”

The Usage Guidelines section points the same way. Because breaching the high limit throttles the cgroup instead of triggering the OOM killer, a management agent has ample opportunity to monitor and act, for example by granting more memory or terminating the workload. That section also calls memory.high the main mechanism to control memory usage, and says overcommitting high limits (their sum exceeding available memory) is a viable strategy. In practice the roles split: memory.max is the hard ceiling, memory.high is the everyday control knob.

The document also warns that usage numbers alone don’t answer the question. A workload that writes data from the network to a file can use all available memory, yet run just as well with much less. Whether a workload needs more memory depends on how much it suffers from the shortage, which is a matter of pressure. The memory interface list includes a memory.pressure file that shows pressure stall information for memory. PSI itself is covered in Linux Pressure Stall Information.

memory.events and memory.events.local

To see whether boundaries were hit, read memory.events. It is a read-only flat-keyed file on non-root cgroups, and a value change generates a file modified event. In this saved version (7.3.0), it defines these fields:

Field Documented meaning
low Times the cgroup was reclaimed due to high memory pressure even though usage was under the low boundary. Usually a sign that the low boundary is over-committed
high Times processes were throttled and routed to direct reclaim because the high boundary was exceeded. Expected for a cgroup whose usage is capped by the high limit rather than by global pressure
max Times usage was about to go over the max boundary. If direct reclaim fails to bring it down, the cgroup goes to OOM state
oom Times usage reached the limit and an allocation was about to fail. Not raised when the OOM killer isn’t an option, such as failed high-order allocations or when the caller asked not to retry
oom_kill Processes belonging to this cgroup killed by any kind of OOM killer
oom_group_kill Times a group OOM has occurred
sock_throttled Times network sockets associated with this cgroup were throttled

high, max, oom, and oom_kill count different stages. A steadily rising high can simply mean memory.high is doing its job. max counts attempts that ran into the hard limit, oom counts allocations that came close to failing, and oom_kill counts processes that actually died. Note that oom_kill is defined as kills by “any kind of OOM killer,” so it can include processes killed by an OOM that this cgroup’s memory.max didn’t cause.

Every field in memory.events is hierarchical. Events in descendant cgroups show up in the ancestor’s counts, and an event down the hierarchy can generate a file modified event on the ancestor’s file. To see only what happened at a given cgroup, read memory.events.local. It has the same format, but its fields are local rather than hierarchical, and its file modified events reflect only local events.

Say parent P has children A and B, and an OOM kill happens only in A. oom_kill goes up in P/memory.events but not in P/memory.events.local. When hunting for the child that’s in trouble, you can narrow the scope with the parent’s memory.events and then pinpoint it with each child’s memory.events.local.

There is one exception. If cgroup2 is mounted with the memory_localevents option, memory.events is populated only with data for the current cgroup, without subtrees. The document calls this legacy behavior; without the option, subtree counts are included. The option is system-wide and can only be set on mount or changed through remount from the init namespace.

memory.oom.group: all or nothing, with exceptions

memory.oom.group is a read-write file on non-root cgroups with a default of 0. When set, the OOM killer treats the cgroup as an indivisible workload: all tasks in the cgroup, and in its descendants if it isn’t a leaf, are killed together or not at all. The point is to avoid partial kills that leave a workload half-alive.

The document spells out two limits:

  • Tasks with OOM protection (oom_score_adj set to -1000) are an exception and are never killed. “All” leaves them out.
  • When the OOM killer is invoked in a cgroup, it won’t kill any tasks outside that cgroup, regardless of the memory.oom.group values of ancestor cgroups.

memory.oom.group decides what dies together once an OOM happens. It doesn’t prevent the OOM. To confirm that a group OOM actually took place, check oom_group_kill in memory.events.

A read-only inspection pass

The commands below only read files. <pid>, <cgroup2-mount>, and <cgroup-path> are placeholders; replace them for your environment.

# 1. Find the process's cgroup path (the v2 entry looks like "0::<cgroup-path>")
cat /proc/<pid>/cgroup

# 2. Join the cgroup2 mount point and the path
CG="<cgroup2-mount><cgroup-path>"

# 3. Current usage and both boundaries
cat "$CG/memory.current"
cat "$CG/memory.high"
cat "$CG/memory.max"

# 4. Hierarchical vs local events
cat "$CG/memory.events"
cat "$CG/memory.events.local"

# 5. Group OOM setting and pressure information
cat "$CG/memory.oom.group"
cat "$CG/memory.pressure"

/proc/<pid>/cgroup may have several lines if legacy cgroups are in use, but the cgroup v2 entry always has the form 0:: followed by the path. To check whether the cgroup still has live processes under it, read the populated field in cgroup.events (1 if any, otherwise 0).

memory.peak is also worth reading: it reports the maximum usage recorded for the cgroup and its descendants. Writing any non-empty string to it, though, resets the value to current usage for later reads through that same file descriptor. During an inspection, read it and leave it alone.

Writes belong in a lab

The following example assumes a disposable lab environment, such as a throwaway test VM. Don’t run it against a production cgroup. As shown above, writing a memory.max below current usage can trigger OOM kills immediately.

# Lab only: run under a test parent that already has the memory controller enabled
cat "<lab-parent>/cgroup.subtree_control"   # check that "memory" is listed
mkdir "<lab-parent>/memtest"
echo 268435456 > "<lab-parent>/memtest/memory.high"   # 256 MiB, in bytes
echo 536870912 > "<lab-parent>/memtest/memory.max"    # 512 MiB
echo <test-pid> > "<lab-parent>/memtest/cgroup.procs"
cat "<lab-parent>/memtest/memory.events"

The document’s rules on privileges and delegation apply here:

  • A controller has to be enabled in the parent’s cgroup.subtree_control before children can use it, and a non-root cgroup can distribute domain resources to its children only when it has no processes of its own.
  • The resource control files in a directory control the distribution of the parent’s resources, so a delegatee isn’t supposed to write them at its delegation point. Delegating to a less privileged user means granting write access to the directory and its cgroup.procs, cgroup.threads, and cgroup.subtree_control files only.
  • To migrate a process with a non-root euid, the writer needs write access to the destination’s cgroup.procs and to the cgroup.procs of the common ancestor of the source and destination cgroups.
  • Stateful resources such as memory don’t move with a process when it migrates. The document discourages frequent migration as a way to apply different resource limits.

Limits and version note

This post was checked on 2026-10-20 against the Linux 7.3.0 “Control Group v2” document on docs.kernel.org. The files and fields you see depend on your kernel version, build options, and mount options, and a service manager or container runtime may wrap this interface, expose it differently, or manage it on your behalf. On your own systems, check the documentation for your kernel version and for the tools you use. This post doesn’t say which kernel version introduced any particular feature.

Sources

  • Linux kernel documentation (7.3.0), “Control Group v2”, checked 2026-10-20. Sections used: Mounting, Processes, Controlling Controllers, Delegation, Guidelines, Interface Files (Conventions), Core Interface Files, Memory Interface Files, Usage Guidelines, Controller Issues and Remedies.

Back to the blog