Skip to content

Commit 32785b4

Browse files
committed
Read cgroup v2 CPU quota for accurate container worker count
os.process_cpu_count() only checks sched_getaffinity, not cgroup CPU quotas, so containers still see the host CPU count. Now reads /proc/self/cgroup to resolve the process's cgroup path, then parses cpu.max for the actual quota. Uses ceiling division so fractional vCPUs (e.g. 1.5) round up. Can be removed when min Python is 3.14.
1 parent da50902 commit 32785b4

File tree

1 file changed

+46
-1
lines changed

1 file changed

+46
-1
lines changed

plain/plain/cli/server.py

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,51 @@
77
from plain.cli.options import SettingOption
88

99

10+
def _get_cpu_count() -> int:
11+
"""Get the number of CPUs available, respecting cgroup limits in containers.
12+
13+
os.process_cpu_count() only checks sched_getaffinity, not cgroup CPU quotas,
14+
so containers often see the host's full CPU count. This reads the cgroup v2
15+
quota file to detect the actual limit.
16+
17+
Can be removed when minimum Python version is 3.14+ (cpython#120078).
18+
"""
19+
cpu_count = os.process_cpu_count() or 1
20+
21+
# Resolve the process's own cgroup path for nested cgroup v2 hierarchies
22+
# (e.g. systemd units, containers without a private cgroup namespace)
23+
cgroup_dir = "/sys/fs/cgroup"
24+
try:
25+
with open("/proc/self/cgroup") as f:
26+
for line in f:
27+
# cgroup v2 entries have the form "0::<path>"
28+
parts = line.strip().split(":", 2)
29+
if (
30+
len(parts) == 3
31+
and parts[0] == "0"
32+
and parts[1] == ""
33+
and parts[2] != "/"
34+
):
35+
cgroup_dir = f"/sys/fs/cgroup{parts[2]}"
36+
break
37+
except (FileNotFoundError, IndexError, OSError):
38+
pass
39+
40+
# Check cgroup v2 CPU quota (Docker, Kubernetes, Railway, etc.)
41+
try:
42+
with open(f"{cgroup_dir}/cpu.max") as f:
43+
parts = f.read().strip().split()
44+
if len(parts) >= 2 and parts[0] != "max":
45+
quota = int(parts[0])
46+
period = int(parts[1])
47+
cgroup_cpus = max(1, -(-quota // period)) # ceiling division
48+
cpu_count = min(cpu_count, cgroup_cpus)
49+
except (FileNotFoundError, ValueError, OSError):
50+
pass
51+
52+
return cpu_count
53+
54+
1055
@click.command()
1156
@click.option(
1257
"--bind",
@@ -82,7 +127,7 @@ def server(
82127

83128
# 0 = auto (CPU count, cgroup-aware)
84129
if workers == 0:
85-
workers = os.process_cpu_count() or 1
130+
workers = _get_cpu_count()
86131

87132
from plain.server import ServerApplication
88133

0 commit comments

Comments
 (0)