cgroups v2 jako natywny prymityw izolacji procesów — bez Dockera

Podatek abstrakcji: koszt kontenera, którego nie potrzebujesz

Gdy zespół sięga po Dockera wyłącznie po to, żeby nałożyć limit CPU i pamięci na jeden proces — pipeline danych, demona monitoringu, schedulowanego joba — płaci podatek abstrakcji. cgroups v2 (Control Groups w wersji 2) dostarcza ten sam prymityw izolacji natywnie z kernela, bez daemonów, bez warstw obrazów, bez ceremonii OCI. Container runtime (containerd, runc) musi zainicjalizować środowisko z namespacem, zamontować OverlayFS i skonfigurować wirtualną sieć — nawet jeśli jedynym celem jest throttling jednego wątku.Zunifikowane w kernelu 4.5 i domyślnie aktywne na Arch Linux od kernela 5.8+, cgroups v2 eliminuje cały ten narzut operacyjny dla scenariuszy resource governance pojedynczego procesu.Ten artykuł rozkłada architekturę zunifikowanej hierarchii cgroups v2, jej integrację z modelem transient units w systemd oraz programatyczne sterowanie przez Python — dając pełną izolację zasobów bez narzutu stosu kontenerowego.

cgroups v2: Fundamentalna zmiana architektury względem v1

Krytycznym błędem cgroups v1 była fragmentaryczna hierarchia per-kontroler. Limity CPU żyły w /sys/fs/cgroup/cpu/, limity pamięci w /sys/fs/cgroup/memory/ — niezależne drzewa zdolne do generowania konfliktujących przypisań zasobów i operacyjnie niestabilne przy atomowej migracji procesów między kontrolerami.cgroups v2 narzuca jedną, zunifikowaną hierarchię zakorzenioną w /sys/fs/cgroup/. Wszystkie kontrolery — cpu, memory, io, pids — działają w jednym spójnym drzewie. Kluczowy niezmiennik architektoniczny: proces może należeć dokładnie do jednej cgrupy.

Weryfikacja zunifikowanej hierarchii

# Weryfikacja aktywnego trybu cgroups v2
$ mount | grep cgroup
cgroup2 on /sys/fs/cgroup type cgroup2 (rw,nosuid,nodev,noexec,relatime,nsdelegate,memory_recursiveprot)

# Inspekcja dostępnych kontrolerów
$ cat /sys/fs/cgroup/cgroup.controllers
cpuset cpu io memory hugetlb pids rdma misc
Jeśli output zawiera cgroup2 jako typ FS i zunifikowany zestaw kontrolerów — system jest w pełni zmigrowany. Na systemach hybrydowych parametr kernela cgroup_no_v1=all wymusza tryb wyłącznie v2.

Bezpośrednie zarządzanie cyklem życia cgrupy

Zarządzanie cgroupami sprowadza się do manipulacji systemem plików. Tworzenie cgrupy = tworzenie katalogu. Przypisanie procesu = zapis PID do cgroup.procs. Ograniczanie zasobów = zapis wartości do plików interfejsu kontrolera.

Tworzenie i konfiguracja cgrupy

# Tworzenie cgrupy dla izolowanego workloadu
$ mkdir /sys/fs/cgroup/devmindset-worker

# Włączenie kontrolerów CPU i pamięci dla tej podgałęzi
$ echo "+cpu +memory" > /sys/fs/cgroup/devmindset-worker/cgroup.subtree_control

# Cap CPU: 20% jednego rdzenia (quota/period w mikrosekundach)
$ echo "20000 100000" > /sys/fs/cgroup/devmindset-worker/cpu.max

# Twardy limit pamięci: 256 MiB
$ echo $((256 * 1024 * 1024)) > /sys/fs/cgroup/devmindset-worker/memory.max

# Przypisanie bieżącego procesu shella do cgrupy
$ echo $$ > /sys/fs/cgroup/devmindset-worker/cgroup.procs
Interfejs cpu.max przyjmuje pary $QUOTA $PERIOD, bezpośrednio mapując na throttling pasma CFS (Completely Fair Scheduler). Każdy proces przekraczający 20ms w oknie 100ms zostanie ograniczony na poziomie schedulera — bez żadnego daemona w userspace.

Integracja z systemd: Transient units jako prymityw izolacji

W produkcyjnych środowiskach na systemach z systemd, systemd-run jest idiomatyczną abstrakcją nad cgroups v2. Uruchamia proces wewnątrz transient scope lub unit serwisowego, dziedzicząc model delegacji cgroup z systemd.
# Uruchomienie izolowanego procesu z ograniczeniami zasobów
$ systemd-run 
    --scope 
    --unit=devmindset-worker 
    --property=CPUQuota=20% 
    --property=MemoryMax=256M 
    --property=IOWeight=10 
    /usr/bin/python3 /opt/workers/pipeline_runner.py

# Live resource accounting
$ systemctl status devmindset-worker.scope
$ cat /sys/fs/cgroup/system.slice/devmindset-worker.scope/cpu.stat
Flaga --scope tworzy transient unit ograniczony do bieżącej sesji, --service tworzy pełny serwis z zarządzaniem cyklem życia przez systemd. IOWeight mapuje na wagę schedulera I/O CFQ/BFQ, realizując proporcjonalną alokację przepustowości I/O.

Programatyczne sterowanie przez Python

Dla dynamicznego zarządzania workloadami — uruchamiania izolowanych workerów w runtime, regulacji quotów na podstawie telemetrii, implementacji mechanizmów backpressure — Python z pathlib dostarcza czysty, idiomatyczny dostęp do interfejsu cgroupfs bez zewnętrznych zależności.
from __future__ import annotations

import os
import subprocess
from pathlib import Path
from typing import Final

# Micro-Rationale: pathlib.Path zamiast konkatenacji stringów —
# bezpieczna typowo manipulacja FS, O(1) budowanie ścieżek, zero zewnętrznych deps.
CGROUP_ROOT: Final[Path] = Path("/sys/fs/cgroup")


class CgroupV2Controller:
    """Zarządza pojedynczą hierarchią cgroups v2 dla izolacji procesów.

    Wymusza quota CPU i twardy limit pamięci na nazwanej cgroup.
    Implementuje protokół context managera dla deterministycznego sprzątania.
    """

    def __init__(
        self,
        name: str,
        cpu_quota_percent: int = 25,
        memory_max_mib: int = 256,
    ) -> None:
        if not 1 <= cpu_quota_percent <= 100:
            raise ValueError(f"cpu_quota_percent musi być w [1, 100], otrzymano {cpu_quota_percent}")
        if memory_max_mib < 16:
            raise ValueError(f"memory_max_mib musi być >= 16 MiB, otrzymano {memory_max_mib}")

        self.name = name
        self._cpu_quota_percent = cpu_quota_percent
        self._memory_max_bytes = memory_max_mib * 1024 * 1024
        self._cgroup_path = CGROUP_ROOT / name

    def _write(self, interface: str, value: str) -> None:
        """Zapisuje wartość do pliku interfejsu cgroup.

        Args:
            interface: Nazwa pliku interfejsu cgroup (np. 'cpu.max').
            value: Wartość do zapisania.

        Raises:
            PermissionError: Brak CAP_SYS_ADMIN.
            OSError: Interfejs cgroup niedostępny.
        """
        target = self._cgroup_path / interface
        try:
            target.write_text(value, encoding="utf-8")
        except PermissionError as exc:
            raise PermissionError(
                f"Brak uprawnień do zapisu w {target}. "
                "Uruchom jako root lub z CAP_SYS_ADMIN."
            ) from exc
        except OSError as exc:
            raise OSError(f"Błąd zapisu '{value}' do {target}: {exc}") from exc

    def create(self) -> "CgroupV2Controller":
        """Tworzy katalog cgrupy i konfiguruje limity zasobów.

        Returns:
            Self — dla fluent chaining.

        Raises:
            FileExistsError: Cgroup o tej nazwie już istnieje.
        """
        try:
            self._cgroup_path.mkdir(parents=False, exist_ok=False)
        except FileExistsError:
            raise FileExistsError(f"Cgroup '{self.name}' już istnieje w {self._cgroup_path}")

        self._write("cgroup.subtree_control", "+cpu +memory")

        # Quota CPU: $QUOTA $PERIOD w mikrosekundach (CFS bandwidth throttling)
        period_us = 100_000
        quota_us = int(period_us * self._cpu_quota_percent / 100)
        self._write("cpu.max", f"{quota_us} {period_us}")

        # Twardy limit pamięci — OOM killer odpala przy przekroczeniu
        self._write("memory.max", str(self._memory_max_bytes))

        return self

    def assign_pid(self, pid: int) -> None:
        """Przenosi proces do tej cgrupy przez zapis do cgroup.procs.

        Args:
            pid: PID procesu docelowego.

        Raises:
            ProcessLookupError: Proces o podanym PID nie istnieje.
        """
        try:
            os.kill(pid, 0)  # Sonda: rzuca wyjątek jeśli PID nieważny
        except ProcessLookupError:
            raise ProcessLookupError(f"Nie znaleziono procesu o PID {pid}.")
        self._write("cgroup.procs", str(pid))

    def assign_current_process(self) -> None:
        """Przenosi wywołujący proces do tej cgrupy."""
        self.assign_pid(os.getpid())

    def destroy(self) -> None:
        """Usuwa katalog cgrupy. Wszystkie procesy muszą być wcześniej zmigrowane.

        Raises:
            OSError: Cgroup nadal zawiera procesy.
        """
        procs = (self._cgroup_path / "cgroup.procs").read_text().strip()
        if procs:
            raise OSError(
                f"Nie można usunąć cgroup '{self.name}': "
                f"aktywne PIDs: {procs.splitlines()}"
            )
        self._cgroup_path.rmdir()

    def __enter__(self) -> "CgroupV2Controller":
        return self.create()

    def __exit__(self, *_: object) -> None:
        try:
            self.destroy()
        except OSError:
            pass  # Best-effort cleanup; logować w produkcji


# --- Przykład użycia ---
if __name__ == "__main__":
    worker_proc = subprocess.Popen(["/usr/bin/python3", "/opt/workers/cpu_intensive_task.py"])

    with CgroupV2Controller(
        name="devmindset-worker",
        cpu_quota_percent=20,
        memory_max_mib=256,
    ) as cgroup:
        cgroup.assign_pid(worker_proc.pid)
        worker_proc.wait()

Porównanie narzutu: Docker vs. bezpośrednie cgroups v2

WymiarDocker (runc)Bezpośrednie cgroups v2
Daemon runtimecontainerd + dockerdBrak
Latencja startu procesu~80–150ms (OCI init)< 1ms (mkdir + write)
Izolacja systemu plikówWymagany mount OverlayFSNie dotyczy
Namespace sieciowyWirtualna karta sieciowa + iptablesNie dotyczy
Narzut pamięci~15–30 MiB per kontenerZero
Interfejs kernelaPośredni (runc → seccomp → cgroup)Bezpośredni
Przypadek użyciaPełna izolacja aplikacji, przenośnośćResource governance jednego procesu
Granica decyzji jest klarowna: jeśli wymaganie to zarządzanie zasobami dla zaufanego procesu, bezpośrednie cgroups v2 eliminuje cały stos Dockera. Jeśli wymaganie obejmuje izolację systemu plików, izolację sieciową lub dystrybucję opartą na obrazach — kontenery pozostają właściwą abstrakcją.

Podsumowanie: Dobór właściwego prymitywu izolacji

cgroups v2 nie jest alternatywą dla Dockera — to mechanizm, na którym Docker jest zbudowany. Sięgnięcie po cgroups v2 bezpośrednio oznacza operowanie na właściwym poziomie abstrakcji dla danego problemu. Na zahartowanym systemie Arch Linux z zestawem długo działających workerów, background scraperów lub demonów inferencji ML, 150-liniowy kontroler Python i trzy flagi systemd-run dostarczają pełną izolację runtime bez operacyjnego narzutu stosu orkiestracji kontenerów.Inżynierowie rozumiejący prymitywy kernela, na których zbudowane są ich narzędzia, zawsze będą debugować szybciej, profilować dokładniej i projektować architektury bardziej efektywnie niż ci, którzy operują wyłącznie przez warstwy abstrakcji.

Skoro schodzimy do prymitywów kernela bez warstwy Dockera, ten sam poziom abstrakcji — syscalle i kolejki współdzielone z jądrem — rozbiera tekst o tym, kiedy epoll przestaje wystarczać wobec io_uring. Czym jest proces, który tymi cgroupami izolujesz, pokazuje z kolei materiał o tym, co fork() naprawdę robi pod spodem.

Piotr Karasiński
Piotr Karasiński — samouk oprogramowania, pasjonat GNU/Linux i architektury systemów. Pisze o warstwie między „działa" a „rozumiem dlaczego działa" na devmindset.dev.

Dodaj komentarz