DEV Community

Cover image for I Gave the Container 64 MiB, Hugepages Took 400 MiB
Mustafa ERBAY
Mustafa ERBAY

Posted on Originally published at mustafaerbay.com.tr

I Gave the Container 64 MiB, Hugepages Took 400 MiB

Ask someone what they are buying when they pass --memory=64m to a container and the answer is
usually the same: that container cannot go above 64 MiB, and if it tries, the kernel kills it.
I said that sentence for years too. There is one path where it simply is not true, and that path
is not exotic: databases, the JVM, QEMU, DPDK — they all walk through the same door.

In a container limited to 64 MiB, a program faulted in 400 MiB of hugepages. It finished, exit
code zero. At the sampling moment memory.current read 1,527,808 bytes — one and a half
megabytes — and the max, oom and oom_kill counters in memory.events were all zero. The
same container asking for the same 400 MiB as ordinary anonymous memory took a SIGKILL.

This article is about where that difference comes from, why the fix the kernel has shipped since
6.7 returns success while doing nothing, and why — once the fix is genuinely on — exceeding the
limit produces an endless stall instead of an OOM kill. That last part is a known bug recorded on
the kernel mailing list; we get there too. Everything here comes from one machine, my own lab.

One distinction up front: this is about hugetlb, the pre-allocated page pool. Transparent
huge pages (THP) are a different mechanism entirely — they are anonymous memory, they are
reclaimable, and memory.max does count them. THP has its own traps, which
an earlier article
covers; none of the numbers below apply to it.

The lab, and setting up the pool

Docker Desktop's linuxkit virtual machine on a Mac: kernel 6.10.14-linuxkit, aarch64,
MemTotal 5,036,592 kB, cgroup v2. A September article recorded the same lab as 7.8 GB; Docker
Desktop's VM memory is adjustable and I shrank it in between, which is why the number differs.
The enabled controllers, straight from cgroup.controllers:
cpuset cpu io memory hugetlb pids rdma. The fifth entry, hugetlb, is the protagonist of this
story.

Under /sys/kernel/mm/hugepages/ there are four sizes — 64kB, 2048kB, 32768kB, 1048576kB —
and Hugepagesize is 2048 kB. The hugepage pool starts empty, so the first job is to build it:

echo 256 > /proc/sys/vm/nr_hugepages   # 256 x 2 MiB = 512 MiB
Enter fullscreen mode Exit fullscreen mode

Afterwards the relevant lines of /proc/meminfo say this:

Counter Before the pool After the pool Delta
MemFree 1,684,804 kB 1,159,732 kB −525,072 kB
MemAvailable 3,891,820 kB 3,366,820 kB −525,000 kB
HugePages_Total 0 256 +256
HugePages_Free 0 256 +256
Hugetlb 0 kB 524,288 kB +524,288 kB

The first oddity shows up right here. Building the pool subtracted 512 MiB from both MemFree
and MemAvailable, so the system has closed that memory to normal allocation. At the same moment
HugePages_Free reports "256 pages free", which matches its documented definition: pages in the
pool that are not yet allocated. Both counters are correct, but they speak different languages:
one says "this memory is closed to normal allocation now", the other says "nobody has taken a
page out of this pool yet". Which of the two your capacity plan reads decides whether you spend
the same supposedly free 512 MiB twice.

The same 400 MiB, requested two ways

The lab program is ten lines of C: mmap some memory, write one byte into every page, then wait.
The only variable is whether the mmap flags carry MAP_HUGETLB.

int flags = MAP_PRIVATE | MAP_ANONYMOUS | (huge ? MAP_HUGETLB : 0);
char *p = mmap(NULL, len, PROT_READ | PROT_WRITE, flags, -1, 0);
Enter fullscreen mode Exit fullscreen mode

Both arms ran under docker run --memory=64m --memory-swap=64m, so memory.max was
67,108,864 bytes. The results:

Arm Outcome memory.current hugetlb.2MB.current memory.events
400 MiB anonymous SIGKILL, rc=137 — 0 max 35, oom 1, oom_kill 1
400 MiB MAP_HUGETLB finished, rc=0 1,527,808 419,430,400 max 0, oom 0, oom_kill 0

hugetlb.2MB.current on the second row is exactly 400 MiB. The kernel sees that memory, counts
it, and even writes it into a ledger of its own. At the sampling moment HugePages_Free had
dropped from 256 to 56; 200 pages really were consumed. It is just that memory.max never reads
that ledger.

The anonymous arm is the familiar story: mmap returned successfully, because reserving address
space is free; the bill arrived when the pages were touched. Thirty-five hits against the limit,
one OOM, one kill.

Why the memory controller skips hugetlb

When the cgroup v2 documentation lists what the memory controller tracks, it names three things:
userland memory (page cache and anonymous memory), kernel data structures, and TCP socket
buffers. Hugetlb is not on that list — and that is design, not oversight. Hugetlb has its own
controller: hugetlb.<size>.max for the hard limit, hugetlb.<size>.current for usage, and
hugetlb.<size>.events, whose max key counts allocations that failed because of the limit.
The documentation says that limit is enforced during page fault.

So on the kernel side there is no gap at all; two quotas, two knobs. The gap is in the tooling.
docker run --help has no flag related to hugepages — I went looking, zero matches. Docker's
resource-constraints page offers --memory, --memory-swap and --memory-reservation for
memory; there is no counterpart for a hugetlb quota. If Docker is your interface, you cannot cap
a container's hugepage appetite through it. Kubernetes closed that gap: hugepages are requested
as a separate resource such as hugepages-2Mi, requests must equal limits, and the page states
plainly that unlike CPU or memory, hugepages do not support overcommit. The same page adds one
more condition: when you request hugepages, you must request either memory or CPU as well.
Hugepages being a distinct resource is no accident; a separate ledger has to be requested
separately.

The charge order is worth a look as well, because it decides which ledger blows first. On the
6.10 kernel in my lab, alloc_hugetlb_folio tries memcg first (line 3107) and the hugetlb
controller afterwards (line 3161):

    memcg = get_mem_cgroup_from_current();
    memcg_charge_ret = mem_cgroup_hugetlb_try_charge(memcg, gfp, nr_pages);
    if (memcg_charge_ret == -ENOMEM) {
        mem_cgroup_put(memcg);
        return ERR_PTR(-ENOMEM);
    }
Enter fullscreen mode Exit fullscreen mode

That function changed both its name and its place in 6.14: it is now
mem_cgroup_charge_hugetlb(folio, gfp) and it is called after the page has been taken from
the pool (mm/hugetlb.c line 2990 in v7.2). So on 6.14 and later the order is reversed. The
diagram below describes the 6.10 kernel these runs covered; on a newer kernel, read it with the
order swapped.

Diagram

The fix has existed since 6.7, and it is called memory_hugetlb_accounting

The kernel ships a cgroup v2 mount option to close this gap: memory_hugetlb_accounting. Its
definition is short — count HugeTLB usage towards the cgroup's overall memory usage for the
memory controller — and the reason it is not the default is written down too: it is new behavior
that could regress existing setups, so it has to be opted into explicitly.

Since which release? Grepping the source tree is the fastest answer:

for v in v6.5 v6.6 v6.7; do
  printf "%s: " "$v"
  curl -s ".../$v/Documentation/admin-guide/cgroup-v2.rst" | grep -c memory_hugetlb_accounting
done
# v6.5: 0 / v6.6: 0 / v6.7: 1
Enter fullscreen mode Exit fullscreen mode

It landed in 6.7 and it is still there in today's kernel. The documentation lists four caveats.
The first says the pool belongs to nobody and charging happens when a page is actually used. The
third says hugetlb memory also affects the memory.low and memory.min protections, so that
tuning has to be revisited. The fourth says pages used while the option is off are never tracked,
even if you turn it on later.

The second one is what this article is really about:

Failure to charge a HugeTLB folio to the memory controller results in SIGBUS. This could
happen even if the HugeTLB pool still has pages available (but the cgroup limit is hit and
reclaim attempt fails).

That describes my scenario exactly. Not its outcome.

I fitted the flag three times; the kernel heard it once

After reading the documentation the next step looks obvious: remount cgroup2 with the option.
It took three attempts, and the first two returned zero from mount while doing nothing at all.

Attempt one — from inside a privileged container, entering the host mount namespace with
nsenter -t 1 -m:

REMOUNT OK
cgroup2 /sys/fs/cgroup cgroup2 rw,nosuid,nodev,noexec,relatime 0 0
Enter fullscreen mode Exit fullscreen mode

Mount succeeded, option absent. Re-running the 400 MiB hugepage test gave: memory.current
1,273,856, hugetlb.2MB.current 419,430,400, rc=0. Behavior had not budged.

Attempt two — with --cgroupns=host. Now the container sits in the host's cgroup namespace
(cgroup:[4026531835], byte-identical to pid 1's), but still in its own mount namespace. mount
again returned zero, and /proc/mounts again showed no option.

Attempt three — both at once: the host cgroup namespace and the host mount namespace.

BEFORE: cgroup2 /sys/fs/cgroup cgroup2 rw,nosuid,nodev,noexec,relatime 0 0
REMOUNT OK
AFTER : cgroup2 /sys/fs/cgroup cgroup2 rw,nosuid,nodev,noexec,relatime,memory_hugetlb_accounting 0 0
Enter fullscreen mode Exit fullscreen mode

The reason is in the source. In kernel/cgroup/cgroup.c, the function that applies root flags
opens with a single condition:

static void apply_cgroup_root_flags(unsigned int root_flags)
{
    if (current->nsproxy->cgroup_ns == &init_cgroup_ns) {
Enter fullscreen mode Exit fullscreen mode

And its caller never reports an error:

static int cgroup_reconfigure(struct fs_context *fc)
{
    struct cgroup_fs_context *ctx = cgroup_fc2context(fc);

    apply_cgroup_root_flags(ctx->flags);
    return 0;
}
Enter fullscreen mode Exit fullscreen mode

If you are not in the initial cgroup namespace, the function returns without applying a single
flag, and reconfigure unconditionally reports success. nsdelegate, favordynmods,
memory_localevents, memory_recursiveprot — all of them sit behind the same door. The silence
in attempt two has a different cause: a remount through the bind mount in your own mount
namespace does not reconfigure the superblock, it only touches that mount's own flags.

That door is in the same place in today's kernel: line 2043 of cgroup.c in v7.2. This is not
something you can file under "old kernels used to do that".

The practical takeaway is one sentence: the exit code of mount cannot tell you whether the
flag was fitted.
The only verification point is the superblock options on the /proc/mounts
line. On kernel 6.13 and later there is a second piece of evidence — a hugetlb line appears in
memory.stat. In my 6.10 lab that line never showed up; the NR_HUGETLB counter was added to
the source tree in 6.13. So on kernels between 6.7 and 6.12 the accounting works, memory.current
rises, and nothing tells you what raised it. If you live in that range, a number that looks like
unexplained growth can keep you busy for days.

Accounting on: 40 MiB of hugepages inside memory.current

With the flag genuinely fitted, a calmer run: memory.max at 512 MiB, request 40 MiB of
hugepages.

Counter Value
memory.max 536,870,912
memory.current 43,376,640
hugetlb.2MB.current 41,943,040
outcome rc=0

40 MiB is exactly 41,943,040 bytes, and memory.current now contains it; the 1,433,600 bytes in
between are the program's own anonymous memory, its shell, its libraries. The arithmetic checks
out, the mechanism works. The very same arm kept memory.current at one and a half megabytes
while the flag was off. The difference is a single mount option.

Exceed the limit: the docs say SIGBUS, the run showed a stall

Now for that second caveat. Accounting on, memory.max at 64 MiB, and the program trying to
fault in 400 MiB of hugepages. The expectation is SIGBUS — a clean "Bus error" and a process
that ends. What actually happens:

t memory.current hugetlb.2MB.current memory.events max PSI full avg10
2 s 66,449,408 65,011,712 894,142 2.17
4 s 66,527,232 65,011,712 1,958,158 3.41
6 s 66,596,864 65,011,712 2,927,497 4.60
8 s 66,428,928 65,011,712 3,655,762 5.94

hugetlb.2MB.current is stuck at 65,011,712, which is 31 pages, and it does not move. Thirty-two
pages would come to 67,108,864 bytes on their own — exactly the memory.max value, to the byte;
add the program's one and a half megabytes of anonymous memory and the limit is already
exceeded. Stopping at 31 is the arithmetic proof that the limit is the binding constraint.

The max counter inside memory.events climbed by 2,761,620 in six seconds — roughly 460,000
limit hits per second. oom and oom_kill are both zero. The process neither dies nor
progresses. In a longer first attempt the counter reached 6.4 million in 15 seconds, PSI reached
8.15, and five minutes later the process was still sitting there.

Where it sits came from /proc/<pid>/stack:

[<0>] shrink_node+0x110/0xd18
[<0>] do_try_to_free_pages+0xf8/0x5b0
[<0>] try_to_free_mem_cgroup_pages+0xfc/0x250
[<0>] try_charge_memcg+0x128/0x648
[<0>] mem_cgroup_hugetlb_try_charge+0x40/0x78
[<0>] alloc_hugetlb_folio+0xac/0x700
[<0>] hugetlb_fault+0x8e0/0xb68
[<0>] handle_mm_fault+0x260/0x290
[<0>] do_page_fault+0x12c/0x4b0
Enter fullscreen mode Exit fullscreen mode

The whole chain in one frame: hugetlb page fault, charge attempt, memcg reclaim. There is nothing
to reclaim, because almost everything inside the cgroup is hugetlb and hugetlb is not
reclaimable.

Three pieces of source explain the loop. First, __GFP_RETRY_MAYFAIL is added to the charge's
gfp mask at the call site — htlb_alloc_mask(h) does not carry that flag, it is OR'ed into the
gfp variable at line 3104 of mm/hugetlb.c. Second, after sixteen rounds of reclaim
try_charge_memcg sees the flag and jumps to the failure path without ever invoking the memcg
OOM killer:

    if (gfp_mask & __GFP_RETRY_MAYFAIL)
        goto nomem;
Enter fullscreen mode Exit fullscreen mode

Third, the -ENOMEM that comes back is translated into OOM rather than SIGBUS at the fault
layer. vmf_error in include/linux/mm.h has three branches: -ENOMEM to VM_FAULT_OOM,
-EHWPOISON to VM_FAULT_HWPOISON, everything else to SIGBUS. A hugetlb charge failure lands
in the first one.

What happens when VM_FAULT_OOM reaches the architecture's fault handler is spelled out by the
comment itself (arch/arm64/mm/fault.c):

    if (fault & VM_FAULT_OOM) {
        /*
         * We ran out of memory, call the OOM killer, and return to
         * userspace (which will retry the fault, or kill us if we got
         * oom-killed).
         */
        pagefault_out_of_memory();
        return 0;
    }
Enter fullscreen mode Exit fullscreen mode

There is a subtlety here: the comment is out of date. pagefault_out_of_memory no longer calls
the OOM killer; the out_of_memory(&oc) line was removed in 5.16 (present in 5.15, gone in
5.16). Today the function only tries mem_cgroup_oom_synchronize(true) and, when that fails,
prints this:

    if (__ratelimit(&pfoom_rs))
        pr_warn("Huh VM_FAULT_OOM leaked out to the #PF handler. Retrying PF\n");
Enter fullscreen mode Exit fullscreen mode

So the kernel has a name for this situation. A look at dmesg found it there: across the stall
window the count went from 20 to 40, meaning 20 fresh lines from this run — all stamped inside a
single millisecond slice, because the print is rate limited.

[ 3024.405336] Huh VM_FAULT_OOM leaked out to the #PF handler. Retrying PF
[ 3024.405366] Huh VM_FAULT_OOM leaked out to the #PF handler. Retrying PF
Enter fullscreen mode Exit fullscreen mode

mem_cgroup_oom_synchronize cannot help either, because when the charge jumps to nomem the
task's memcg OOM state was never set up. Nobody is left to break the loop. The x86 side behaves
the same way: arch/x86/mm/fault.c makes the same pagefault_out_of_memory() call in user mode
and its comment is near-identical. This is not an ARM peculiarity.

This is a known bug and the patch is waiting on the list

Everything above came out of my own runs, but presenting the finding as my own discovery would be
unfair: the mechanism is on record on the kernel mailing list. Ackerley Tng's
mm: hugetlb: Return -ENOSPC on memcg charge failure,
dated 22 July 2026 and marked Cc: stable, describes the situation in almost the same sentences:
because HugeTLB allocations are high-order and use __GFP_RETRY_MAYFAIL, they bypass the OOM
killer, the page fault handler gets VM_FAULT_OOM, and that leads to an infinite loop of
retrying the fault. The proposed fix is to return -ENOSPC, because that maps to
VM_FAULT_SIGBUS and terminates the process cleanly.

The important part: the patch has not landed in mainline. In v7.2, line 2998 of mm/hugetlb.c
still returns -ENOMEM on a charge failure, and vmf_error is unchanged. So the gap between the
documentation and the code is still open, and the behavior in these runs still holds on today's
kernel. The direction of that gap is the bad one: you restart a container that got killed,
whereas a container retrying reclaim hundreds of thousands of times a second sits there sweeping
its neighbors' page cache along the way.

So where does SIGBUS actually come from

To avoid blaming the documentation unfairly, the SIGBUS path got its own run. Two arms, both with
a 512 MiB pool, a 600 MiB request and memory.max at 2 GiB:

Arm Outcome
MAP_HUGETLB mmap fails immediately: "Out of memory", rc=1
`MAP_HUGETLB \ MAP_NORESERVE`

In the first arm SIGBUS never gets its turn, because a MAP_HUGETLB mapping asks the pool for a
reservation at mmap time; if the pool cannot cover it, the call fails right there with ENOMEM.
That is the exact opposite of ordinary anonymous mmap and its habit of always succeeding and
billing you later. MAP_NORESERVE skips that gate, and the expected SIGBUS arrives once the pool
runs dry. My program prints progress every 100 MiB, so the last line was 500 MiB; the exact point
is somewhere between 500 MiB and the pool's 512 MiB boundary, and nothing here resolves it finer.

So there are three distinct failure shapes, and they are easy to confuse: with anonymous memory
mmap succeeds and the OOM killer strikes while touching; with reserved hugetlb mmap fails up
front; with unreserved hugetlb you get SIGBUS. With accounting on and the limit binding, a fourth
shape appears: none of the above happens, and the process spins.

Questions for your own setup

  1. Does cat /sys/fs/cgroup/cgroup.controllers list hugetlb? If not, half of this article does not concern you.
  2. If grep Hugetlb /proc/meminfo is above zero, somebody built a pool. That memory is already subtracted from MemAvailable, and your monitoring may not be showing it as "in use".
  3. Does the grep cgroup2 /proc/mounts line contain memory_hugetlb_accounting? A successful mount command proves nothing.
  4. If you run a hugepage workload, that container's real memory ceiling is not memory.max — it is memory.max plus the hugetlb quota. If you never wrote the second one, there is no ceiling.
  5. When you do write that quota, prefer the reservation family. The kernel's own documentation is explicit: reservation limits are superior to page fault limits, because they are enforced at reservation time and never cause the application to get SIGBUS if the memory was reserved beforehand. The cgroup v2 files are there even though the v2 document does not list them — in my lab, hugetlb.<size>.rsvd.max and .rsvd.current exist for every size.
  6. On the monitoring side, memory.current alone is not enough. With the flag off, collect hugetlb.<size>.current separately; with it on and a kernel older than 6.13, you will not find any line telling you what is inside memory.current.
  7. If you are thinking of turning the flag on, exercise the overrun in a test environment first, watching dmesg as well. The "Huh VM_FAULT_OOM leaked out" line is this story's signature.

One warning: every run above sits on a private, anonymous MAP_HUGETLB mapping. The JVM's
-XX:+UseLargePages and PostgreSQL's huge_pages=on go through shared memory (shmget with
SHM_HUGETLB), and the Kubernetes page adds a separate requirement for that path — a
supplemental group matching vm.hugetlb_shm_group. Reservation accounting works differently on
the shared path, so do not carry these numbers over to it.

Nothing changed on the server that hosts this blog, and that too is a result of the runs. The
picture there: kernel 6.8.0-146-generic, MemTotal 98,873,668 kB, HugePages_Total 0, Hugetlb
0 kB, nr_hugepages 0. No service touches hugepages, there is no pool, so there is no flag worth
turning on. The interesting part is the /proc/mounts line:

cgroup2 /sys/fs/cgroup cgroup2 rw,nosuid,nodev,noexec,relatime,nsdelegate,memory_recursiveprot 0 0
Enter fullscreen mode Exit fullscreen mode

nsdelegate and memory_recursiveprot are visible there — so the verification method works, and
the absence of memory_hugetlb_accounting really does mean it is off. 6.8 supports the flag, but
had I enabled it I would not have seen the hugetlb line in memory.stat, because that line was
added in 6.13. The flag went on only in the lab, and came back off when the runs ended. A
measurement that produces no change is a result as well; the io.max and nomerges side ended
in a similar place.

What I did not prove

Nothing here covers the 1 GiB page size; the virtual machine had no contiguous memory to spare
for that pool. Shared hugetlbfs file mappings stayed out of scope — reservation accounting runs
separately there, and it may not behave like the private anonymous path above.
hugetlb.<size>.rsvd.max got verified only to the extent that the files exist; no limit was set
and its behavior went unmeasured. I cannot prove the stall is infinite; the longest window here
was five minutes, and progress in that window was zero, but five minutes is not enough to say
"forever". How the memory.low and memory.min protections interact with hugetlb accounting is
also unmeasured; the documentation says they interact, and I produced no numbers. One more thing
worth stating plainly: all of this ran with a 2 MiB page size on a single-NUMA-node virtual
machine. On a multi-node server, pool distribution is a story of its own.

The sentence left over is this: a container's memory ceiling is not the number written in the
memory.max file — it is the sum of every ledger that container draws memory from. And a flag
being accepted by the kernel does not mean it was applied; acceptance shows up in an exit code,
application only on a /proc/mounts line. From here on I will look at every unmeasured flag with
a little more suspicion. Looking for a container's memory ceiling in memory.max turns out to be
like
looking for cgroup pressure in an average:
right file, wrong question.

Official Sources

Top comments (0)