Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 142 additions & 0 deletions lib/OperatingSystems/Linux.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down
24 changes: 24 additions & 0 deletions tests/data/linux_cgroup_v1_memory_stat
Original file line number Diff line number Diff line change
@@ -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
20 changes: 20 additions & 0 deletions tests/data/linux_cgroup_v2_memory_stat
Original file line number Diff line number Diff line change
@@ -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
121 changes: 118 additions & 3 deletions tests/lib/LinuxTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> $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();

Expand All @@ -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')
Expand Down
Loading