Cgroups v2: обмежуємо пам'ять, CPU та PIDs¶
Written by:
Igor Gorovyy
DevOps Engineer Lead & Senior Solutions Architect
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
