Skip to content

Limit a Service Without Stress-Testing the Pi

A logger that accidentally creates too many workers can disrupt other programs without stealing a single file. Resource limits address that failure mode; they are not a substitute for file or network isolation.

Outcome: create a few waiting threads in a transient service, observe additional thread creation denied, and inspect that service's cgroup controls. Complete the filesystem lab. Use the Pi as an ordinary user, Python 3, a working systemd user manager and cgroup v2 with delegated CPU and pids controllers. Memory control is discussed separately because it is unavailable on the verification Pi. No sudo, package installation or persistent unit is needed.

Match the limit to the failure

Problem Relevant control What it does not establish
Too many processes or threads TasksMax Which files a worker may read
Excessive service memory use MemoryMax Confidentiality or successful recovery after an OOM kill
A CPU-hungry worker CPUQuota Reserved CPU time or a latency guarantee
A service that never finishes RuntimeMaxSec Correct output or safe file access

The systemd resource-control manual describes these independent controls. Tasks include threads, not just process IDs. CPU quota is relative to one CPU: 25% is not a quarter of the whole four-core Pi. MemoryMax=64M represents 67,108,864 bytes of cgroup-accounted memory, not a promise of that much free Python heap; swap has a separate control.

For this exercise the workers wait rather than compute, memory allocation stays small, and the script tries at most 12 threads. It is not a fork bomb, memory-exhaustion test or CPU benchmark.

Inspect the prerequisites

See the cgroup hierarchy

Imagine this illustrative, simplified hierarchy (actual unit names differ):

1
2
3
4
5
6
user.slice
  user-1000.slice
    user@1000.service
      app.slice
        run-example.service
          Python main thread and its worker threads

The new service receives its own cgroup below the user manager. A parent can constrain the whole subtree; a child cannot raise an ancestor's ceiling by asking for a larger limit. Several individually limited workers can still compete under their shared parent's budget. The kernel's cgroup v2 introduction explains the hierarchy; systemd manages it here, so do not create controller directories or move processes by hand.

Check the available controllers

Run these read-only commands:

1
2
3
systemctl --user is-system-running
stat -fc %T /sys/fs/cgroup
cat /sys/fs/cgroup/cgroup.controllers

Expect cgroup2fs and CPU/pids support for this lab. The root's controller list alone does not prove delegation to your user service; the lab checks its own control files. If startup or readback fails, stop. Do not edit cgroup files, change the whole user slice, or disable host protections to make the example work.

The verification Pi reported cpuset cpu io pids at the root and cpu pids at the user manager: no memory controller. A preliminary attempt requesting MemoryMax started but had no memory.max to read. That is not an enforced memory limit. The published lab therefore requires only CPU/pids; it neither changes boot options nor claims memory isolation on this device.

Run the bounded task-limit lab

Copy the whole command. It limits only its new transient unit. The thread-creation test has a fixed attempt count, waiting threads have timeouts, and the unit itself has a 10-second runtime limit.

systemd-run --user --wait --pipe --collect --service-type=exec \
    -p RuntimeMaxSec=10s -p TimeoutStopSec=2s \
    -p NoNewPrivileges=yes -p TasksMax=8 \
    -p CPUQuota=25% \
    /usr/bin/python3 -c '
from pathlib import Path
import threading

entries = Path("/proc/self/cgroup").read_text().splitlines()
relative = next(line[3:] for line in entries if line.startswith("0::"))
group = Path("/sys/fs/cgroup") / relative.lstrip("/")

value = (group / "pids.max").read_text().strip()
if value != "8":
    raise SystemExit("Unexpected pids.max: %s" % value)
print("Configured: pids.max=8")

quota, period = (group / "cpu.max").read_text().split()
if quota == "max" or int(quota) * 4 != int(period):
    raise SystemExit("Expected a CPU quota of one quarter of one CPU")
print("Configured: CPU quota is one quarter of one CPU")
print("Memory controller visible: %s" % (group / "memory.max").is_file())

def denied_count():
    events = (group / "pids.events").read_text().splitlines()
    return int(next(line.split()[1] for line in events
                    if line.startswith("max ")))

before = denied_count()
release = threading.Event()
workers = []
blocked = False
try:
    for _ in range(12):
        worker = threading.Thread(target=release.wait, args=(4,))
        try:
            worker.start()
        except RuntimeError:
            if denied_count() <= before:
                raise
            blocked = True
            break
        workers.append(worker)
finally:
    release.set()
    for worker in workers:
        worker.join(timeout=1)

if not workers or not blocked:
    raise SystemExit("Task-limit denial was not demonstrated")
print("Allowed: %d waiting worker threads" % len(workers))
print("Blocked: extra thread; pids controller recorded a denial")
print("PASS: bounded task-limit exercise")
'

The Pi check allowed seven workers alongside the main Python thread, then denied the next thread. Do not depend on exactly seven on every environment: helpers, inherited constraints or implementation differences can change the count. The important evidence is that some workers ran, extra creation failed, the cgroup recorded a new denial, all workers were released, and the command returned success.

A Python RuntimeError alone is insufficient; that could have another cause. This example also checks the pids controller's event counter. A failed start, missing controller, unexpected quota or timeout is not a passing test.

Separate configuration checks from enforcement tests

This lab exercises task denial and reads back CPU quota, without saturating the CPU. It does not set or exercise a memory/swap limit. It therefore proves no CPU throughput, RAM safety margin or OOM-recovery behavior. Parent cgroups can impose tighter limits, and other workloads still affect available resources. See the kernel's cgroup v2 documentation for the hierarchy and controller interfaces.

On a separately reviewed system with a usable memory controller, MemoryMax maps to memory.max; MemorySwapMax separately controls swap. Verify controller availability, the actual service's files and the application's failure behavior before relying on them. A property accepted by a manager is not enough. Enabling a missing controller or changing Pi boot configuration is outside this exercise.

For a real service, measure normal demand before choosing limits. Eight tasks could prevent a legitimate camera or numerical library from starting. Too small a memory limit can kill a useful worker; too small a CPU quota can make requests time out. Define how callers notice failure and whether restarting is safe before enabling an automatic restart policy. Resource settings do not make secrets unreadable, remove a writable mount or revoke a network connection.

Verify completion and undo

--wait reports the outcome; --collect unloads the completed transient unit. It installs nothing and no configuration needs to be reverted. If a run hangs, the runtime limit stops that unit. For manual inspection or stopping, use only the exact generated unit name printed by systemd-run, as described in the first restriction lab.

The lab creates no sample files and leaves no worker loop running. Inspect active user services if necessary:

systemctl --user list-units --type=service --state=running --no-pager

Do not stop unrelated services shown in the list. A journal record for a finished run is not a live process. For more resource-control options, use the existing cgroups and systemd-run reference.

Checkpoint: does a 64 MiB limit prevent reading a tiny SSH private key? Why can a service with a CPU quota still miss a deadline?

Next: understand Docker's isolation boundary, then restore and verify a backup. Return to Service Isolation.

The complete command was checked on the Pi listed in verification scope. Ubuntu has not been device-tested, and no host-wide resource limit was changed.

Donate