Why Is My Container Using More Memory Than the Process?

Necco Ceresani
Necco Ceresani··21 min read

Fifteen years building engineering platforms, currently focused on advanced AI infrastructure at yeet. I love turning the deeply complex topics into something everyone can understand. I relate deeply with the core yeet philosophy that you can just build things.

Quick answer. The container is not using more memory than the process; the kernel is charging your cgroup for things no process reports. Page cache, TCP socket buffers, page tables, kernel stacks, percpu data and slab are all counted in memory.current and none of them appear in RSS. Read the breakdown before assuming a leak: cat /sys/fs/cgroup/<path>/memory.stat and compare the anon field against file, sock, slab and kernel. If file explains the gap, you are looking at reclaimable cache and nothing is wrong.

Most of what I know about this came from being wrong about it in public. I build kernel-side tooling, and the question that produced kmemtrace arrived from someone running thousands of microVMs whose hosts reported many gigabytes more in use than the sum of every process on them. My first three explanations were confident and incomplete. I do not operate a large container fleet, so take the capacity advice here as coming from someone who builds the instruments rather than someone who sets your limits. What I can say with confidence is that the disagreement between a process and its cgroup is almost never a bug in either. They are counting different things, and only one of them is consulted when something has to die.

My app reports 400MB of heap and the cgroup says 1.2GB. Which one is lying?

Neither. They measure different sets of bytes, and the cgroup's set is strictly larger.

Your application's heap metric reports memory its allocator requested and is tracking. RSS, the number top and ps show, reports resident anonymous and file-mapped pages for that process. memory.current reports, in the kernel's own words, "the total amount of memory currently being used by the cgroup and its descendants", and the cgroup v2 documentation is explicit that "all major memory usages by a given cgroup are tracked", including userland page cache and anonymous memory, kernel data structures such as dentries and inodes, and TCP socket buffers.

That last clause is where the gap lives. When your service reads a 700MB file, the kernel caches those pages and charges them to your cgroup. No process reports them, because no process allocated them. When your service holds ten thousand connections, the socket buffers are charged to your cgroup. Your allocator never saw them. The heap number is correct about the heap and silent about everything else.

The reason this matters is not accounting neatness. memory.current is the number compared against memory.max, so it is the number that decides whether your container is killed. Being right about the heap is no defense.

How do I read memory.stat to find what is using my container memory?

Six fields explain nearly every case. Read them in this order, because the first one accounts for most of the surprises:

cat /sys/fs/cgroup/system.slice/docker-<id>.scope/memory.stat | head -20
FieldWhat it holdsReclaimable
file"Amount of memory used to cache filesystem data, including tmpfs and shared memory"Yes, dropped under pressure
anon"Amount of memory used in anonymous mappings such as brk(), sbrk(), and mmap(MAP_ANONYMOUS)"No, must be swapped or freed
sock"Amount of memory used in network transmission buffers"Partially, under socket pressure
slab"Amount of memory used for storing in-kernel data structures"Partly, the reclaimable half
pagetables"Amount of memory allocated for page tables"No
kernel_stack"Amount of memory allocated to kernel stacks"No

The kernel documentation defines kernel as "amount of total kernel memory, including (kernel_stack, pagetables, percpu, vmalloc, slab)", so that single field is the sum of the kernel-side rows rather than a separate category. percpu is "amount of memory used for storing per-cpu kernel data structures", which grows with core count rather than with your workload.

The distinction that decides what to do next is the third column. Reclaimable memory is memory the kernel will take back rather than kill for, and the memory management documentation is direct about it: "the most notable categories of the reclaimable pages are page cache and anonymous memory", where pages "that can be freed at any time, either because they cache the data available elsewhere" are reclaimable. A container sitting at 95% of its limit entirely in file is not in danger. The same container at 95% in anon and pagetables is one allocation from an OOM kill.

My container got OOM killed but the host had free memory. How?

Because the limit that killed it was its own memory.max, not the machine's RAM. A cgroup hitting its ceiling triggers an OOM event inside that cgroup regardless of how much memory the host has spare, which is why free on the node is misleading during a container OOM investigation.

The kernel's cgroup v2 documentation describes memory.max as the "memory usage hard limit" that invokes the OOM killer when exceeded, against memory.high as a "memory usage throttle limit" that causes throttling above the threshold rather than killing. That difference is worth exploiting: a workload with memory.high set below memory.max gets slowed and reclaimed against before anything is killed, which turns a hard failure into a soft one you can alert on.

Confirm where the kill happened before investigating anything else:

cat /sys/fs/cgroup/<path>/memory.max /sys/fs/cgroup/<path>/memory.current
grep oom /sys/fs/cgroup/<path>/memory.events

memory.events carries an oom counter and an oom_kill counter. A non-zero oom_kill in that specific cgroup is proof the kill happened at the container limit. If those counters are zero and a process still died, the kill came from the host-level OOM killer under global pressure, which is a different investigation with a different fix.

The second-order confusion here catches people repeatedly. The cgroup limit decides when an OOM event fires; the choice of which process to kill inside it is scored heavily by RSS. So a container can be killed over memory it never allocated as anonymous pages, while the victim is simply the biggest process present. The thing that died is often not the thing responsible.

Is my container actually under memory pressure, or just full of cache?

memory.pressure answers this and container memory usage does not. Usage tells you how many bytes are charged; pressure tells you how much time was lost to getting them.

Pressure Stall Information is exported per cgroup in memory.pressure and system-wide at /proc/pressure/memory. The kernel documentation defines the two lines precisely: "the 'some' line indicates the share of time in which at least some tasks are stalled on a given resource", while "the 'full' line indicates the share of time in which all non-idle tasks are stalled on a given resource simultaneously", which it characterizes as a thrashing state where CPU cycles are actually wasted. Each line carries avg10, avg60 and avg300 percentages plus a total in microseconds.

cat /sys/fs/cgroup/<path>/memory.pressure

A container at 98% of its limit with some avg60=0.00 is fine. It has filled up with reclaimable cache and nothing is waiting. The same container reporting full avg60=12.4 is losing an eighth of its time to memory stalls, and that is true whether or not it is near the limit.

Reaching for pressure rather than usage changes what you alert on, and it is the single highest-value change available here. Usage alerts fire constantly on healthy containers full of cache, and teams learn to ignore them. A full pressure alert fires when work is actually being lost.

free says 20GB used but my processes only add up to 12GB. Where is the rest?

Some memory has no named line anywhere, and on a host running many VMs or sandboxes it can be the largest single slice. The way to find it is subtraction: take MemTotal from /proc/meminfo and subtract every bucket you can name.

free reports three numbers, and "used" is a subtraction rather than a measurement, so everything the kernel keeps for itself lands inside it. /proc/meminfo breaks most of that out across roughly fifty lines, but there is no line that says "here is the memory nothing attributes". Reserved hugepages are a clean example: free counts them as used, and no per-process line attributes them, so they read as anonymous consumption. CMA regions reserved for DMA are invisible to normal accounting in the same way. Pinned guest pages and driver-private allocations are the residual that remains after every named line is accounted for.

kmemtrace does that reconciliation live, splitting all of RAM four ways (free, userspace, kernel-named, and unaccounted) such that the four always sum to MemTotal:

curl -fsSL https://yeet.cx | sh
yeet run github:yeet-src/kmemtrace -- --vms 8000

--vms N divides every kernel bucket by your VM, agent or container count, which turns "2.2 GB of page tables" into "285 KB per VM" and makes the number something you can act on rather than something that alarms you. Note there is no sudo: the daemon performs any privileged work, so the command you type is unprivileged.

Worth stating plainly, since it is unusual for tooling in this space: kmemtrace uses no eBPF at all. It reads /proc/meminfo through yeet's system graph and does the subtraction the standard tools skip. The value is in the reconciliation, not the mechanism, and pretending otherwise would be dressing up arithmetic.

Why does the kernel charge page cache to my container and not to the host?

Because whoever first touches a page owns it, and your container touched it. The kernel caches file data on read so that subsequent reads avoid disk, as the memory management documentation puts it: "whenever a file is read, the data is put into the page cache to avoid expensive disk access on the subsequent reads." The charge lands on the cgroup that caused the read.

This produces two behaviors that look like bugs and are not. A container that reads a large file once carries that cache until something needs the memory, so a batch job's cgroup can sit at its limit long after the job's active work is done. And two containers reading the same file share the physical pages while the charge stays with whichever touched it first, which means identical workloads can report different usage depending on start order.

Neither is worth fixing, and attempting to usually makes things worse. Dropping caches to make a dashboard look better throws away work the kernel did on your behalf and buys a slower next read. The correct response is to read file separately from anon and stop treating their sum as the health signal.

Should I increase my Kubernetes memory limit because of page cache?

Usually no, and doing it fleet-wide is a common expensive mistake. Because page cache is reclaimable, a limit sized to the workload's actual working set still works: the kernel drops cache before it kills anything, so the cache filling the gap between working set and limit is not at risk.

Three cases justify raising a limit, and they are distinguishable from cache with the fields above:

  1. anon is growing without bound across restarts. That is a real leak in the application, and raising the limit buys time rather than fixing anything, but it may be the right trade while you find it.
  2. sock is large and your connection count is legitimate. A service holding many connections with large buffers carries a real, non-reclaimable-on-demand charge that the application will never report. This is a sizing correction, not a workaround.
  3. pagetables and kernel_stack scale with a count you control. Thousands of threads or vCPUs cost real kernel memory per unit, which is exactly the per-unit number kmemtrace --vms is for.

What does not justify it is memory.current sitting near memory.max with file explaining the difference and memory.pressure reporting nothing. That is a container doing its job. Raising limits in response wastes capacity across every replica and hides the workloads that have a real problem, because now nothing is near its limit and the signal is gone.

What do Kubernetes requests and limits actually set on the cgroup?

A container's limits.memory becomes its cgroup memory.max, which is why everything above applies unchanged inside Kubernetes. What changes is that a second scheduler-level number, requests.memory, sits alongside it and does something entirely different.

The distinction that causes trouble: requests.memory is used for scheduling and for the node's overcommit accounting, while limits.memory is the number the kernel enforces at runtime. A pod is scheduled somewhere based on requests, and killed based on limits. Setting requests low and limits high packs more pods per node and makes each one likelier to be killed under contention.

kubectl top pod reports the working-set number rather than memory.current, which is why it can disagree with what you read from memory.stat directly. When the two disagree, the cgroup file is authoritative, because it is what the kernel consults. Read it inside the container or from the node:

kubectl exec <pod> -- cat /sys/fs/cgroup/memory.stat | head -12

The other Kubernetes-specific trap is that a pod's cgroup contains every container in it, sidecars included. A service mesh sidecar holding socket buffers charges them to the same pod-level cgroup as your application, so a pod OOM can originate in a container nobody was looking at.

Which tool should I reach for: memory.stat, kmemtrace, or Prometheus?

Five, and the ordering is by how much you already know about the problem.

ToolWhat it answersScopeRetention
cat memory.statWhich bucket holds the bytes, right nowOne cgroupNone
cat memory.pressureWhether fullness is costing timeOne cgroupRolling 10/60/300s averages
kmemtraceWhat the named buckets miss entirelyWhole hostNone, live view
systemd-cgtopWhich cgroup is largest, liveAll cgroups on a hostNone
Prometheus with cAdvisorWhen usage started climbingWhole fleetWhatever you configured

The one that ends most investigations is the first, and it costs nothing. Reading memory.stat before anything else is what separates "we have a leak" from "we have cache" and it takes one command.

Where each loses: none of the first four have any memory of last Tuesday, so a question about when a trend started belongs to your metrics stack and nothing here replaces it. kmemtrace reads host-wide /proc/meminfo and does not break its residual down per cgroup, so it answers "where did the host's RAM go" rather than "which container is responsible". And cAdvisor's per-container gauges are usually derived from the same cgroup files, so a disagreement between a dashboard and memory.stat is a collection or aggregation artifact rather than new information.

The bottom line: read the breakdown before you believe the total

Start with memory.stat and compare anon against file. If file explains the gap, nothing is wrong and the container is holding reclaimable cache that the kernel will drop before it kills anything. If anon is climbing across restarts, that is a leak in your application and no limit change fixes it. If sock, pagetables or kernel_stack carry the weight, the charge is real, non-obvious and worth sizing for, and kmemtrace --vms gives you the per-unit number to size with. Check memory.pressure before treating any of it as urgent, because a container at 98% with zero pressure is a container doing its job. Confirm an OOM kill in memory.events in the specific cgroup rather than trusting free on the host, since the two limits fail differently. And keep Prometheus for the timeline: every tool here answers what is true now, and the question of when it started is one none of them can take.

Frequently asked questions

Does page cache count toward a container memory limit?

Yes. The kernel's cgroup v2 documentation states that all major memory usages are tracked, including page cache and anonymous memory, kernel data structures such as dentries and inodes, and TCP socket buffers. Page cache appears in memory.stat as the file field and is included in memory.current, which is the number checked against memory.max. It is reclaimable, so under pressure the kernel drops it rather than invoking the OOM killer, which is why a container can sit near its limit indefinitely without dying.

What is the difference between RSS and memory.current?

RSS is per-process resident anonymous and mapped memory, which is roughly the anon field of memory.stat. memory.current is the total for the cgroup and all its descendants, and it also includes page cache, socket buffers, kernel stacks, page tables, percpu data and slab. The gap between them is not a leak; it is everything the kernel charged to your container that no process reports as its own.

Why did my container get OOM killed when top showed plenty of free memory?

Because the limit that killed it was the cgroup's memory.max, not the machine's total RAM. A container can be OOM killed on a host with 100GB free if its own cgroup hit its limit. Check memory.max and memory.events in the container's cgroup rather than free or top on the host, and read the oom_kill counter in memory.events to confirm the kill happened at that level.

Is high page cache in a container a problem?

Usually not, because page cache is reclaimable: the kernel drops it under pressure rather than killing the process. It becomes a problem when it hides the number you wanted, since memory.current near memory.max looks alarming and may be almost entirely cache. Subtract the file field of memory.stat before deciding whether a container is actually near its ceiling.

What does memory.stat kernel include?

The kernel documentation defines the kernel field as the amount of total kernel memory, including kernel_stack, pagetables, percpu, vmalloc and slab. Each of those is also reported separately: kernel_stack is memory allocated to kernel stacks, pagetables is memory for page tables, percpu is per-cpu kernel data structures and slab is in-kernel data structures. None of these appear in any process's RSS, and all of them are charged to the cgroup.

How do I see which cgroup is using the most memory?

Read memory.current across the cgroup tree under /sys/fs/cgroup and sort. Because memory.current includes descendants, comparing a parent against its children tells you whether usage sits in one leaf or is spread across many. systemd-cgtop gives a live version of the same view without writing a loop.

Do socket buffers count against a container memory limit?

Yes. The cgroup v2 documentation lists TCP socket buffers among tracked memory, and memory.stat reports them in the sock field. A service holding many connections with large buffers can carry a substantial charge that never appears in application heap metrics, which is a common cause of a gap between what a service reports and what its cgroup reports.

What is memory.pressure and how is it different from memory usage?

memory.pressure reports Pressure Stall Information: how much time tasks spent stalled waiting on memory, rather than how many bytes are in use. The some line indicates the share of time in which at least some tasks are stalled on a given resource, and the full line indicates the share of time in which all non-idle tasks are stalled simultaneously. Usage tells you how full the container is; pressure tells you whether that fullness is costing you anything.

Why does free show memory as used when nothing is running?

Because "used" is a subtraction rather than a measurement. It is total memory minus what the kernel counts as available, so everything the kernel holds for itself lands in it: page tables, slab caches, kernel stacks, reserved hugepages and DMA regions. /proc/meminfo breaks most of it into named lines, and the memory that is hardest to see is whatever remains after those lines are subtracted from the total.

Does the OOM killer look at RSS or at cgroup usage?

Both, at different stages. The cgroup limit decides when an OOM event happens: memory.current reaching memory.max triggers it for that cgroup. Which process gets killed inside it is then chosen by a score weighted heavily by RSS. That combination is why a container can be killed because of memory it did not allocate as anonymous pages, while the process killed is simply the largest one present.

How do I find memory that nothing accounts for on a Linux host?

Subtract every named bucket in /proc/meminfo from MemTotal and look at what is left. The residual covers pinned guest pages, driver-private allocations and DMA pools, none of which have a named line. kmemtrace does that reconciliation live and reports the unaccounted slice as its own number, which is the difference between knowing memory is missing and knowing how much.

Should I set a memory limit that accounts for page cache?

Set the limit for the workload's actual working set and let cache be reclaimed. Because page cache is reclaimable, a limit sized only to anonymous memory still works: the kernel drops cache before it kills anything. The mistake is reading memory.current near the limit as an emergency when the file field explains it, and then raising limits across a fleet in response to cache that was never at risk.

Sources

  • Control Group v2 (kernel documentation, 2026) — the authoritative definition of every field cited here: memory.current is "the total amount of memory currently being used by the cgroup and its descendants" and "all major memory usages by a given cgroup are tracked", including page cache and anonymous memory, kernel data structures such as dentries and inodes, and TCP socket buffers; defines anon as memory in anonymous mappings such as brk(), sbrk() and mmap(MAP_ANONYMOUS), file as memory caching filesystem data including tmpfs and shared memory, kernel as total kernel memory including kernel_stack, pagetables, percpu, vmalloc and slab, and sock as memory used in network transmission buffers; distinguishes memory.max as the hard limit invoking the OOM killer from memory.high as a throttle limit and memory.min as hard protection against reclaim.
  • Pressure Stall Information (kernel documentation, 2026) — exported through /proc/pressure/memory and per-cgroup memory.pressure; "the 'some' line indicates the share of time in which at least some tasks are stalled on a given resource" while "the 'full' line indicates the share of time in which all non-idle tasks are stalled on a given resource simultaneously", described as a thrashing state where CPU cycles are wasted; each line reports avg10, avg60 and avg300 percentages over 10, 60 and 300 second windows plus a total in microseconds, which the documentation notes enables detection of latency spikes that percentage averages hide.
  • Concepts overview: memory management (kernel documentation, 2026) — the grounding for reclaim behavior: "whenever a file is read, the data is put into the page cache to avoid expensive disk access on the subsequent reads", written data is marked dirty and synchronized back when the kernel repurposes those pages; pages "that can be freed at any time, either because they cache the data available elsewhere" are reclaimable, and "the most notable categories of the reclaimable pages are page cache and anonymous memory"; reclaim runs asynchronously through kswapd or synchronously as pressure rises.
  • kmemtrace (yeet-src, 2026) — reads /proc/meminfo through yeet's system graph rather than using eBPF, and reconciles all of RAM into four slices (free, userspace, kernel-named and unaccounted) that always sum to MemTotal; --vms N divides every kernel bucket by a VM, agent or container count to convert host totals into per-unit numbers; documents the buckets with no per-process attribution, including HugePages which free counts as used with no line attributing them, and CMA regions reserved for DMA that are invisible to normal accounting; the residual isolates pinned guest pages and driver-private allocations.
  • Assign Memory Resources to Containers and Pods (Kubernetes docs, 2026) — the mapping from limits.memory to the enforced cgroup ceiling and from requests.memory to the scheduler's placement decision; documents that a container exceeding its memory limit becomes a candidate for termination and may be restarted, which is the Kubernetes-level expression of the cgroup OOM behavior described in the cgroup v2 documentation.
  • proc(5) man page (man-pages project, 2026) — the field definitions for /proc/meminfo, including MemTotal, MemAvailable, Slab, SReclaimable, SUnreclaim, PageTables, KernelStack and HugePages_Total; the source for treating free's "used" column as a derived subtraction rather than a measured quantity, and the reference for which meminfo lines have no corresponding per-process attribution.
  • cgroups(7) man page (man-pages project, 2026) — the cgroup v2 hierarchy model, the unified filesystem layout under /sys/fs/cgroup, and the rule that controller files apply to a cgroup and its descendants, which is why comparing a parent's memory.current against its children localizes usage to a leaf.

Related resources