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')