From 7a3df4f83d921c95a5aa3e5cf7fa50a60f283a77 Mon Sep 17 00:00:00 2001 From: Frank Karlitschek Date: Wed, 9 Sep 2026 15:12:01 +0200 Subject: [PATCH] fix(linux): report container memory limits instead of the host's /proc/meminfo is not namespaced, so inside Docker, Kubernetes or AIO the Monitoring page and the OCS API reported the host's RAM rather than the memory Nextcloud is actually allowed to use. On a 2 GiB container running on a 64 GiB host, that is off by a factor of 32. Where a cgroup memory limit applies, read the limit and usage from it instead, preferring the v2 unified hierarchy and falling back to v1. Reclaimable page cache is discounted from the usage so that MemAvailable keeps the meaning it has in /proc/meminfo, and swap is taken from memory.swap.* on v2 and derived from the memsw counters on v1. A cgroup that is not more restrictive than the host is ignored, which covers both "max" on v2 and the near-PHP_INT_MAX sentinel on v1, so hosts without a limit keep reporting exactly what they reported before. Signed-off-by: Frank Karlitschek Assisted-by: Claude Code:claude-opus-5 Co-Authored-By: Claude Opus 5 --- lib/OperatingSystems/Linux.php | 142 +++++++++++++++++++++++++ tests/data/linux_cgroup_v1_memory_stat | 24 +++++ tests/data/linux_cgroup_v2_memory_stat | 20 ++++ tests/lib/LinuxTest.php | 121 ++++++++++++++++++++- 4 files changed, 304 insertions(+), 3 deletions(-) create mode 100644 tests/data/linux_cgroup_v1_memory_stat create mode 100644 tests/data/linux_cgroup_v2_memory_stat diff --git a/lib/OperatingSystems/Linux.php b/lib/OperatingSystems/Linux.php index d1a3c35b..f0dd1222 100644 --- a/lib/OperatingSystems/Linux.php +++ b/lib/OperatingSystems/Linux.php @@ -20,6 +20,9 @@ class Linux implements IOperatingSystem { private const AF_INET = 2; private const AF_INET6 = 10; + /** Mount point of both the cgroup v2 unified hierarchy and the v1 controllers. */ + private const CGROUP_ROOT = '/sys/fs/cgroup'; + #[\Override] public function supported(): bool { return true; @@ -94,9 +97,148 @@ public function getMemory(): Memory { } } + $this->applyCgroupMemory($data); + return $data; } + /** + * A container still sees the host's /proc/meminfo, so a memory limited + * cgroup would be reported as if the whole machine were available to + * Nextcloud. Where a cgroup limits us further, report that instead. + * + * Tries the cgroup v2 unified hierarchy first and falls back to v1. + */ + private function applyCgroupMemory(Memory $data): void { + if ($this->applyCgroupV2Memory($data)) { + return; + } + + $this->applyCgroupV1Memory($data); + } + + /** + * @return bool whether a limit was found and applied + */ + private function applyCgroupV2Memory(Memory $data): bool { + $limit = $this->readCgroupBytes(self::CGROUP_ROOT . '/memory.max'); + if ($limit === null || !$this->isConstrainingLimit($limit, $data->getMemTotal())) { + return false; + } + + $current = $this->readCgroupBytes(self::CGROUP_ROOT . '/memory.current'); + if ($current === null) { + return false; + } + + $cache = $this->readCgroupStat(self::CGROUP_ROOT . '/memory.stat', 'inactive_file') ?? 0; + $this->setCgroupMemory($data, $limit, $current, $cache); + + $swapLimit = $this->readCgroupBytes(self::CGROUP_ROOT . '/memory.swap.max'); + $swapCurrent = $this->readCgroupBytes(self::CGROUP_ROOT . '/memory.swap.current'); + if ($swapLimit !== null && $swapCurrent !== null) { + $data->setSwapTotal($this->bytesToMebibytes($swapLimit)); + $data->setSwapFree($this->bytesToMebibytes(max(0, $swapLimit - $swapCurrent))); + } + + return true; + } + + private function applyCgroupV1Memory(Memory $data): void { + $root = self::CGROUP_ROOT . '/memory'; + + $limit = $this->readCgroupBytes($root . '/memory.limit_in_bytes'); + if ($limit === null || !$this->isConstrainingLimit($limit, $data->getMemTotal())) { + return; + } + + $current = $this->readCgroupBytes($root . '/memory.usage_in_bytes'); + if ($current === null) { + return; + } + + $cache = $this->readCgroupStat($root . '/memory.stat', 'total_inactive_file') ?? 0; + $this->setCgroupMemory($data, $limit, $current, $cache); + + // memsw accounts memory and swap together, so the swap share is whatever + // is left once the memory limit is taken off. + $memswLimit = $this->readCgroupBytes($root . '/memory.memsw.limit_in_bytes'); + $memswUsage = $this->readCgroupBytes($root . '/memory.memsw.usage_in_bytes'); + if ($memswLimit !== null && $memswUsage !== null && $memswLimit > $limit) { + $swapTotal = $memswLimit - $limit; + $swapUsed = max(0, $memswUsage - $current); + $data->setSwapTotal($this->bytesToMebibytes($swapTotal)); + $data->setSwapFree($this->bytesToMebibytes(max(0, $swapTotal - $swapUsed))); + } + } + + /** + * Page cache is reclaimable, so it counts towards available memory the same + * way MemAvailable accounts for it in /proc/meminfo, while MemFree does not. + */ + private function setCgroupMemory(Memory $data, int $limit, int $current, int $cache): void { + $used = max(0, $current - $cache); + + $data->setMemTotal($this->bytesToMebibytes($limit)); + $data->setMemFree($this->bytesToMebibytes(max(0, $limit - $current))); + $data->setMemAvailable($this->bytesToMebibytes(max(0, $limit - $used))); + } + + /** + * A cgroup without a memory limit reports "max" on v2 and a value near + * PHP_INT_MAX on v1. Either way a limit that is not below the host's memory + * adds nothing to what /proc/meminfo has already told us. + */ + private function isConstrainingLimit(int $limitBytes, int $hostTotal): bool { + if ($hostTotal <= 0) { + // The host total is unknown, so the cgroup is the better source. + return true; + } + + return $limitBytes < $hostTotal * 1024 * 1024; + } + + /** + * Read a cgroup file holding a single byte count. + * + * @return int|null null when the file is missing or holds no plain number, + * which is how cgroup v2 spells "no limit" ("max"). + */ + private function readCgroupBytes(string $filename): ?int { + try { + $value = $this->readContent($filename); + } catch (RuntimeException) { + return null; + } + + if (preg_match('/^\d+$/', $value) !== 1) { + return null; + } + + return (int)$value; + } + + /** + * Read a single counter out of a cgroup memory.stat file. + */ + private function readCgroupStat(string $filename, string $key): ?int { + try { + $stat = $this->readContent($filename); + } catch (RuntimeException) { + return null; + } + + if (preg_match('/^' . preg_quote($key, '/') . ' (\d+)$/m', $stat, $matches) !== 1) { + return null; + } + + return (int)$matches[1]; + } + + private function bytesToMebibytes(int $bytes): int { + return intdiv($bytes, 1024 * 1024); + } + #[\Override] public function getTime(): string { try { diff --git a/tests/data/linux_cgroup_v1_memory_stat b/tests/data/linux_cgroup_v1_memory_stat new file mode 100644 index 00000000..6cdc9848 --- /dev/null +++ b/tests/data/linux_cgroup_v1_memory_stat @@ -0,0 +1,24 @@ +cache 536870912 +rss 1073741824 +rss_huge 0 +shmem 0 +mapped_file 134217728 +dirty 1048576 +writeback 0 +swap 268435456 +pgpgin 12345678 +pgpgout 12340000 +inactive_anon 0 +active_anon 1073741824 +inactive_file 402653184 +active_file 134217728 +unevictable 0 +hierarchical_memory_limit 2147483648 +hierarchical_memsw_limit 2684354560 +total_cache 536870912 +total_rss 1073741824 +total_swap 268435456 +total_inactive_anon 0 +total_active_anon 1073741824 +total_inactive_file 402653184 +total_active_file 134217728 diff --git a/tests/data/linux_cgroup_v2_memory_stat b/tests/data/linux_cgroup_v2_memory_stat new file mode 100644 index 00000000..bc78a797 --- /dev/null +++ b/tests/data/linux_cgroup_v2_memory_stat @@ -0,0 +1,20 @@ +anon 1073741824 +file 536870912 +kernel 67108864 +kernel_stack 2097152 +pagetables 8388608 +percpu 0 +sock 0 +shmem 0 +file_mapped 134217728 +file_dirty 1048576 +file_writeback 0 +swapcached 0 +anon_thp 0 +inactive_anon 1069547520 +active_anon 4194304 +inactive_file 402653184 +active_file 134217728 +unevictable 0 +slab_reclaimable 33554432 +slab_unreclaimable 16777216 diff --git a/tests/lib/LinuxTest.php b/tests/lib/LinuxTest.php index acfe1e5a..ec765c40 100644 --- a/tests/lib/LinuxTest.php +++ b/tests/lib/LinuxTest.php @@ -33,10 +33,30 @@ protected function setUp(): void { ->getMock(); } - public function testGetMemory(): void { + /** + * Serve the given files through readContent, and behave like a host where + * every other path is missing, which is what readContent does for a file + * that does not exist. + * + * @param array $files + */ + private function mockFiles(array $files): void { $this->os->method('readContent') - ->with('/proc/meminfo') - ->willReturn(file_get_contents(__DIR__ . '/../data/linux_meminfo')); + ->willReturnCallback(static function (string $filename) use ($files): string { + if (!isset($files[$filename])) { + throw new RuntimeException('Unable to read: "' . $filename . '"'); + } + + return $files[$filename]; + }); + } + + private function fixture(string $name): string { + return (string)file_get_contents(__DIR__ . '/../data/' . $name); + } + + public function testGetMemory(): void { + $this->mockFiles(['/proc/meminfo' => $this->fixture('linux_meminfo')]); $memory = $this->os->getMemory(); @@ -63,6 +83,101 @@ public function testGetMemoryInvalidData(): void { $this->assertEquals(new Memory(), $this->os->getMemory()); } + public function testGetMemoryWithCgroupV2Limit(): void { + $this->mockFiles([ + '/proc/meminfo' => $this->fixture('linux_meminfo'), + '/sys/fs/cgroup/memory.max' => '2147483648', + '/sys/fs/cgroup/memory.current' => '1610612736', + '/sys/fs/cgroup/memory.stat' => $this->fixture('linux_cgroup_v2_memory_stat'), + '/sys/fs/cgroup/memory.swap.max' => '1073741824', + '/sys/fs/cgroup/memory.swap.current' => '268435456', + ]); + + $memory = $this->os->getMemory(); + + // Limited to 2 GiB, so the host's 15947 MB must not show up anywhere. + $this->assertEquals(2048, $memory->getMemTotal()); + $this->assertEquals(512, $memory->getMemFree()); + // 384 MB of the 1536 MB in use is reclaimable page cache. + $this->assertEquals(896, $memory->getMemAvailable()); + $this->assertEquals(1024, $memory->getSwapTotal()); + $this->assertEquals(768, $memory->getSwapFree()); + } + + public function testGetMemoryWithCgroupV2Unlimited(): void { + $this->mockFiles([ + '/proc/meminfo' => $this->fixture('linux_meminfo'), + '/sys/fs/cgroup/memory.max' => 'max', + '/sys/fs/cgroup/memory.current' => '1610612736', + ]); + + $memory = $this->os->getMemory(); + + $this->assertEquals(15947, $memory->getMemTotal()); + $this->assertEquals(7495, $memory->getMemAvailable()); + } + + public function testGetMemoryWithCgroupV1Limit(): void { + $this->mockFiles([ + '/proc/meminfo' => $this->fixture('linux_meminfo'), + '/sys/fs/cgroup/memory/memory.limit_in_bytes' => '2147483648', + '/sys/fs/cgroup/memory/memory.usage_in_bytes' => '1610612736', + '/sys/fs/cgroup/memory/memory.stat' => $this->fixture('linux_cgroup_v1_memory_stat'), + '/sys/fs/cgroup/memory/memory.memsw.limit_in_bytes' => '2684354560', + '/sys/fs/cgroup/memory/memory.memsw.usage_in_bytes' => '1879048192', + ]); + + $memory = $this->os->getMemory(); + + $this->assertEquals(2048, $memory->getMemTotal()); + $this->assertEquals(512, $memory->getMemFree()); + $this->assertEquals(896, $memory->getMemAvailable()); + // memsw covers memory and swap together, leaving 512 MB of swap. + $this->assertEquals(512, $memory->getSwapTotal()); + $this->assertEquals(256, $memory->getSwapFree()); + } + + public function testGetMemoryWithCgroupV1Unlimited(): void { + $this->mockFiles([ + '/proc/meminfo' => $this->fixture('linux_meminfo'), + // What the kernel reports for "no limit" on cgroup v1. + '/sys/fs/cgroup/memory/memory.limit_in_bytes' => '9223372036854771712', + '/sys/fs/cgroup/memory/memory.usage_in_bytes' => '1610612736', + ]); + + $memory = $this->os->getMemory(); + + $this->assertEquals(15947, $memory->getMemTotal()); + $this->assertEquals(7495, $memory->getMemAvailable()); + } + + public function testGetMemoryIgnoresLimitAboveHostMemory(): void { + $this->mockFiles([ + '/proc/meminfo' => $this->fixture('linux_meminfo'), + '/sys/fs/cgroup/memory.max' => '34359738368', + '/sys/fs/cgroup/memory.current' => '1610612736', + ]); + + $memory = $this->os->getMemory(); + + $this->assertEquals(15947, $memory->getMemTotal()); + } + + public function testGetMemoryWithCgroupLimitButNoStat(): void { + $this->mockFiles([ + '/proc/meminfo' => $this->fixture('linux_meminfo'), + '/sys/fs/cgroup/memory.max' => '2147483648', + '/sys/fs/cgroup/memory.current' => '1610612736', + ]); + + $memory = $this->os->getMemory(); + + // Without memory.stat no cache can be discounted, so available equals free. + $this->assertEquals(2048, $memory->getMemTotal()); + $this->assertEquals(512, $memory->getMemFree()); + $this->assertEquals(512, $memory->getMemAvailable()); + } + public function testGetCpu(): void { $this->os->method('readContent') ->with('/proc/cpuinfo')