Перейти до змісту

Cgroups v2: обмежуємо пам'ять, CPU та PIDs

Cgroups v2: обмежуємо пам'ять, CPU та PIDs

Written by:

Igor Gorovyy
DevOps Engineer Lead & Senior Solutions Architect

LinkedIn


Namespace'и ізолюють, але не обмежують. Процес в окремому PID namespace все одно може з'їсти всю пам'ять машини. Для обмежень потрібні cgroups (control groups) - механізм ядра Linux для контролю ресурсів.

Це четверта частина серії Sheep & Shepherd. Попередні частини: namespaces дали контейнеру свій погляд на систему, re-exec обійшов конфлікт Go з threading-моделлю, а pivot_root дав свій корінь файлової системи. Далі: OverlayFS робить цю файлову систему дешевою для створення.

Cgroups v1 vs v2

Cgroups v1 мали окрему ієрархію для кожного контролера (memory, cpu, pids). Це було заплутано і мало race conditions. Cgroups v2 — єдина ієрархія, де всі контролери під одним деревом. Використовую v2.

graph TD
    subgraph "cgroups v2"
        ROOT["/sys/fs/cgroup"]
        SHEEP["sheep/"]
        C1["container_abc123/"]
        C2["container_def456/"]

        ROOT --> SHEEP
        SHEEP --> C1
        SHEEP --> C2

        C1 --> M1["memory.max = 256M"]
        C1 --> P1["pids.max = 100"]
        C1 --> CPU1["cpu.max = 50000 100000"]

        C2 --> M2["memory.max = 512M"]
        C2 --> P2["pids.max = 200"]
        C2 --> CPU2["cpu.max = max 100000"]
    end

Як Sheep налаштовує cgroups

Після старту контейнерного процесу (через re-exec pattern з частини 2) батьківський процес додає його PID в cgroup:

const cgroupBase = "/sys/fs/cgroup"

func setupCgroups(c *Container, pid int) error {
    cgroupPath := filepath.Join(cgroupBase, "sheep", c.ID)
    os.MkdirAll(cgroupPath, 0755)

    // Додаємо процес в cgroup
    writeFile(filepath.Join(cgroupPath, "cgroup.procs"),
        strconv.Itoa(pid))

    // Вмикаємо контролери
    controllers := "+memory +pids +cpu"
    parentCtrl := filepath.Join(cgroupBase, "sheep",
        "cgroup.subtree_control")
    writeFile(parentCtrl, controllers)

    // Ліміт пам'яті
    if c.Config.Memory > 0 {
        writeFile(filepath.Join(cgroupPath, "memory.max"),
            strconv.FormatInt(c.Config.Memory, 10))
    }

    // Ліміт PIDs
    if c.Config.PidsLimit > 0 {
        writeFile(filepath.Join(cgroupPath, "pids.max"),
            strconv.FormatInt(c.Config.PidsLimit, 10))
    }

    // CPU квота
    if c.Config.CPUQuota > 0 {
        quota := fmt.Sprintf("%d 100000", c.Config.CPUQuota)
        writeFile(filepath.Join(cgroupPath, "cpu.max"), quota)
    }

    // CPU вага (shares)
    if c.Config.CPUShares > 0 {
        weight := (c.Config.CPUShares * 10000) / 262144
        if weight < 1 { weight = 1 }
        writeFile(filepath.Join(cgroupPath, "cpu.weight"),
            strconv.FormatInt(weight, 10))
    }

    return nil
}

Все через файли. Cgroups v2 - це віртуальна файлова система. Записав число в файл - встановив ліміт.

memory.max - ліміт пам'яті

Записую кількість байтів у memory.max. Якщо контейнер спробує використати більше — ядро вб'є процес (OOM killer).

# Запускаємо контейнер з лімітом 256MB
sheep run --name test -m 256m minimal /bin/sh

CLI парсить суфікси:

func parseMemory(s string) int64 {
    multiplier := int64(1)
    if strings.HasSuffix(s, "g") || strings.HasSuffix(s, "G") {
        multiplier = 1024 * 1024 * 1024
        s = s[:len(s)-1]
    } else if strings.HasSuffix(s, "m") || strings.HasSuffix(s, "M") {
        multiplier = 1024 * 1024
        s = s[:len(s)-1]
    } else if strings.HasSuffix(s, "k") || strings.HasSuffix(s, "K") {
        multiplier = 1024
        s = s[:len(s)-1]
    }
    v, _ := strconv.ParseInt(s, 10, 64)
    return v * multiplier
}

pids.max - ліміт процесів

Fork-бомба - класична атака, коли процес безкінечно створює дочірні процеси. pids.max обмежує кількість процесів в cgroup. Коли ліміт досягнутий, fork() повертає помилку.

sheep run --pids-limit 100 minimal /bin/sh

cpu.max - CPU квота

Формат: $QUOTA $PERIOD в мікросекундах. Якщо cpu.max = 50000 100000, контейнер отримує 50ms з кожних 100ms - тобто 50% одного ядра.

sheep run --cpu-quota 50000 minimal /bin/sh

cpu.weight - відносна вага

Docker використовує --cpu-shares (від 2 до 262144). Cgroups v2 використовує cpu.weight (від 1 до 10000). Потрібна конверсія:

weight := (c.Config.CPUShares * 10000) / 262144

Вага працює тільки коли є конкуренція за CPU. Якщо машина вільна, контейнер з вагою 1 отримає стільки ж CPU, скільки контейнер з вагою 10000.

Прибирання після зупинки

Коли контейнер зупиняється, видаляю його cgroup:

func cleanupCgroups(c *Container) {
    cgroupPath := filepath.Join(cgroupBase, "sheep", c.ID)
    os.RemoveAll(cgroupPath)
}

subtree_control - дивись, яка штука

Cgroups v2 вимагає явно ввімкнути контролери для дочірніх cgroups. Без запису +memory +pids +cpu в cgroup.subtree_control батьківської cgroup, файли memory.max не з'являться в дочірніх.

Це частий підводний камінь при роботі з cgroups v2 - все працює, але лімітів немає, бо забув ввімкнути контролери.

Чого не вистачає

Моя реалізація спрощена. У Docker є ще: - memory.swap.max для обмеження swap - cpu.max з burst для короткочасних сплесків - io.max для обмеження дискового I/O - OOM score adjustment

Для навчального проекту трьох контролерів достатньо.

Спробуй сам

# Запусти контейнер з лімітом пам'яті:
sudo ./sheep run --name cg-test -m 128m --pids-limit 50 minimal /bin/sh
# Перевір cgroup на хості:
cat /sys/fs/cgroup/sheep/*/memory.max
cat /sys/fs/cgroup/sheep/*/pids.max

Ресурси обмежені. Далі - OverlayFS, який дає кожному контейнеру свою файлову систему без копіювання гігабайтів (поверх pivot_root, який ми зробили в частині 3).

Попередня: pivot_root | Наступна: OverlayFS