cgroup v1 удалён. Переходите правильно.
systemd 258 вырезал cgroup v1 подчистую, и теперь ваши хосты грузятся в единую иерархию независимо от того, просил их об этом кто-нибудь или нет. Вот аудит, пофайловый перевод настроек и четыре утверждения об этой миграции, которые просто неверны.
- Linux
- cgroups
- systemd
- Контейнеры
Большинство миграций начинается с решения. Эта начинается с факта: в сентябре 2025 года systemd 258 полностью удалил поддержку cgroup v1 вместе с аварийным рычагом SYSTEMD_CGROUP_ENABLE_LEGACY_FORCE=1, который версия 256 добавила годом раньше. Единая иерархия теперь монтируется при загрузке на каждой машине с этим systemd или новее, и поддерживаемого способа попросить о чём-то другом больше нет. Что бы ни думала о контрольных группах ваша контейнерная платформа, операционная система под ней уже проголосовала.

Дальше — миграция в том виде, в каком она реально идёт на живых парках машин: как определить иерархию хоста и не попасться на гибридную раскладку, пофайловая таблица соответствий с директивами systemd рядом, два перевода, которые молча меняют поведение, если просто перенести числа, формула веса CPU, которую в 2025 году тихо заменили в OCI-рантаймах, и честный разбор того, что Docker и Kubernetes сделали и чего не сделали, — потому что именно в этом пункте написано больше всего неверного, а действия по такой информации стоят вам либо простоя, либо года лишней паники.
Вы этого не выбирали, и в этом всё дело
Симптомы неприятно косвенные: ничто не объявляет себя проблемой контрольных групп. Агент мониторинга начинает отдавать нули. Лимит памяти у контейнера перестаёт применяться. Rootless-нагрузка, отлично работавшая на старой машине, отказывается стартовать. Скрипт, восемь лет читавший /sys/fs/cgroup/memory/memory.usage_in_bytes, начинает писать «файл не найден» в лог, который никто не открывает. Всё это — одно и то же событие с разных сторон: пути переехали, семантика под ними изменилась, и ничто не упало достаточно громко, чтобы остановить деплой.[sd258]
| Что вы видите | Что это обычно значит | Где разбирается |
|---|---|---|
| Агент мониторинга показывает ноль памяти или CPU по всем контейнерам | Он читает /sys/fs/cgroup/memory/… или cpu,cpuacct, которых в единой иерархии нет | Таблица соответствий |
write error: Device or resource busy при добавлении PID в контрольную группу | Ограничение «никаких процессов внутри»: эта группа уже делегирует контроллеры потомкам | Три правила |
| Контейнер свопит гораздо больше прежнего при тех же лимитах | memory.memsw.limit_in_bytes перенесли в memory.swap.max, а это подкачка отдельно | Семантика памяти |
Rootless Podman принимает --memory и не применяет его | systemd не делегировал контроллер памяти пользовательскому менеджеру | Контейнеры |
| Под нагрузкой контейнерам достаётся меньше CPU, чем раньше | Линейный перевод долей в вес давал запросу в один CPU вес 39 при умолчании 100 | Вес CPU |
kubelet не стартует после обновления образа узла | Узел на cgroup v1, kubelet версии 1.35 или новее, failCgroupV1 в значении по умолчанию true | Kubernetes |
--oom-kill-disable молча ничего не делает | На cgroup v2 отбрасывается. Аналога нет и не планируется | Контейнеры |
От одной привычки стоит отказаться раньше всего остального. Если ваш инструмент пишет напрямую в /sys/fs/cgroup на systemd-хосте, эту миграцию он не переживёт в том виде, который вас устроит. Деревом владеет systemd и заново навязывает своё представление о нём при любом изменении юнита; в собственном документе о делегировании апстрим прямо говорит: один писатель на поддерево — это правило, а не пожелание. В v1 нарушать его обычно сходило с рук. В v2, с его строгим делегированием сверху вниз, — уже нет.[sddeleg]
В какой иерархии этот хост на самом деле
Начните с установления истины, потому что фольклорные команды врут вполне определённым и устойчивым образом. Проверка, задокументированная в Kubernetes, — правильная, и это одна команда: спросить тип файловой системы у /sys/fs/cgroup. В единой иерархии эта точка монтирования и есть файловая система cgroup2, поэтому stat печатает cgroup2fs. В v1 — и, что важнее, в старой гибридной раскладке тоже — это tmpfs с каталогами контроллеров внутри.[k8scg]
# The only check that cannot lie. /sys/fs/cgroup is a tmpfs under v1 and under
# the old hybrid layout, and a cgroup2 filesystem under the unified hierarchy.
stat -fc %T /sys/fs/cgroup/
# cgroup2fs -> unified, cgroup v2 only
# tmpfs -> cgroup v1, or hybrid: v2 mounted under a v1 tmpfs
# Why `mount | grep cgroup2` is not enough: hybrid mounts a cgroup2 hierarchy
# too, at /sys/fs/cgroup/unified, with no controllers attached to it. Grepping
# for the string finds it and tells you the opposite of the truth.
mount | grep -E '^cgroup' | sed 's/ (.*//'
# cgroup2 on /sys/fs/cgroup type cgroup2 <- unified: good
# cgroup2 on /sys/fs/cgroup/unified type cgroup2 <- hybrid: not good
# What the kernel will actually let you control here. Under the unified
# hierarchy this file exists at the top of the tree and lists the controllers.
# Under hybrid it is not here at all - it is one level down, at
# /sys/fs/cgroup/unified/cgroup.controllers, and it is empty. Its absence from
# the top level is the clearest single tell that you are not on v2.
cat /sys/fs/cgroup/cgroup.controllers 2>/dev/null
# cpuset cpu io memory hugetlb pids rdma misc
# Where a given process ended up. Under v2 there is exactly one line and it
# starts with `0::`. More than one line means controllers are still split.
cat /proc/self/cgroup
# 0::/user.slice/user-1000.slice/session-3.scope
# And the two userspace pieces that have to agree with the kernel:
systemctl --version | head -1
docker info --format 'driver={{.CgroupDriver}} version={{.CgroupVersion}}' 2>/dev/nullИменно из-за гибрида mount | grep cgroup2 не просто неполон, а активно вводит в заблуждение: гибридная раскладка монтирует настоящую иерархию cgroup2 в /sys/fs/cgroup/unified, но без единого подключённого контроллера. Grep находит совпадение, вы делаете вывод, что у вас v2, и каждый настроенный после этого лимит приземляется на контроллер v1. Второй признак — /proc/self/cgroup: в v2 в нём ровно одна строка, начинающаяся с 0::, а в v1 или гибриде — по строке на каждый контроллер. Прогоните аудит ниже по всему парку до того, как что-то планировать: по моему опыту, ответ никогда не бывает одинаковым.[cgman]
#!/usr/bin/env bash
# Fleet audit. Read-only: it changes nothing. Run it before you plan anything,
# because the answer is almost never uniform across a real estate of servers.
set -u
host=$(hostname -s)
ver=$(stat -fc %T /sys/fs/cgroup/ 2>/dev/null)
case "$ver" in
cgroup2fs) mode=unified ;;
tmpfs) [ -d /sys/fs/cgroup/unified ] && mode=hybrid || mode=legacy ;;
*) mode=unknown ;;
esac
kernel=$(uname -r)
sd=$(systemctl --version 2>/dev/null | awk 'NR==1{print $2}')
printf '%-16s mode=%-8s kernel=%-14s systemd=%s\n' "$host" "$mode" "$kernel" "$sd"
# --- the things that will break, rather than the things that will complain ---
# a) v1-only controllers with no v2 equivalent. If anything you run writes to
# these, it needs an eBPF replacement, not a path change.
for c in net_cls net_prio devices; do
[ -d "/sys/fs/cgroup/$c" ] && echo " uses v1-only controller: $c"
done
# b) anything with a hardcoded v1 path. This is the single most common cause of
# a migration failing three weeks later in an agent nobody remembered.
grep -rIl --exclude-dir=.git \
-e '/sys/fs/cgroup/memory/' \
-e '/sys/fs/cgroup/cpu,cpuacct/' \
-e 'memory.limit_in_bytes' \
-e 'cpu.cfs_quota_us' \
/etc /opt /usr/local 2>/dev/null | sed 's/^/ hardcoded v1 path: /'
# c) kernel command line pinning the old hierarchy. On systemd 258 and later
# this parameter no longer does anything, which is its own kind of trap:
# the host silently boots unified and the runbook still says otherwise.
grep -o 'systemd\.unified_cgroup_hierarchy=[01]' /proc/cmdline \
| sed 's/^/ kernel cmdline: /'
grep -o 'SYSTEMD_CGROUP_ENABLE_LEGACY_FORCE=1' /proc/cmdline \
| sed 's/^/ legacy force flag (removed in systemd 258): /'
# d) container runtimes and their drivers, which have to match the kernel
command -v docker >/dev/null && docker info 2>/dev/null \
| grep -E 'Cgroup (Driver|Version)' | sed 's/^/ /'
command -v podman >/dev/null && podman info --format \
' podman cgroupVersion={{.Host.CgroupsVersion}} manager={{.Host.CgroupManager}}' 2>/dev/null
[ -f /var/lib/kubelet/config.yaml ] && \
grep -E '^(cgroupDriver|failCgroupV1):' /var/lib/kubelet/config.yaml | sed 's/^/ kubelet /'Запускайте это на каждом хосте, а не на «представительном». Машины, которые в 2026 году всё ещё сидят на v1, — почти по определению те, которые никто не трогал: аплайнс, сборочный агент, поднятый руками в 2019-м, узел базы данных, намеренно исключённый из прогона системы управления конфигурацией. Именно там вас поджидает захардкоженный путь
/sys/fs/cgroup/memory/, и именно там никто не заметит, что он сломался.
Кто что удалил, а кто только пообещал
В этой истории участвуют четыре проекта, и графики у них совершенно разные — это главный источник путаницы вокруг миграции. systemd двинулся первым и жёстче всех. Версия 256 в июне 2024 года перестала загружать cgroup v1 по умолчанию, оставив аварийный рычаг. Версия 258 в сентябре 2025 года удалила код — в примечаниях к выпуску сказано недвусмысленно: поддержка cgroup v1 (иерархий «legacy» и «hybrid») удалена, а cgroup v2 всегда будет монтироваться при загрузке системы. Тот же выпуск поднял минимальную планку ядра до 5.4, рекомендуя 5.7.[sdnews][sd256]
| Проект | Что произошло на самом деле | Когда | Что это значит для вас |
|---|---|---|---|
| systemd 256 | Перестал загружать cgroup v1 по умолчанию; добавлен SYSTEMD_CGROUP_ENABLE_LEGACY_FORCE=1 | Июнь 2024 | 257 всё ещё уважает этот флаг, так что 257 — последний выпуск, где можно попросить v1 и получить его |
| systemd 258 | Поддержка cgroup v1 удалена полностью, вместе с аварийным рычагом; планка ядра поднята до 5.4, рекомендуется 5.7 | Сентябрь 2025 | Вот он, дедлайн. Единая иерархия — единственный режим в коде |
| Kubernetes 1.35 | cgroup v1 объявлен устаревшим; kubelet по умолчанию не стартует на узле с v1 | Декабрь 2025 | Переключается через failCgroupV1: false. Это не удаление |
| Kubernetes 1.38+ | Самый ранний выпуск, в котором KEP-5573 удалит код | Не запланировано | Времени у вас больше, чем следует из заголовков |
| Docker Engine 29.0 | cgroup v1 объявлен устаревшим; версия удаления не назначена | Ноябрь 2025 | Только устаревание. Документация говорит, что поддержка сохраняется до мая 2029 года |
| runc / crun | Линейный перевод долей в вес заменён логарифмическим | В течение 2025 | Меняет приоритет CPU на уже перенесённых узлах |
Остальные три сделали не то, что вы, скорее всего, о них читали, и для планирования эта разница принципиальна. Kubernetes объявил cgroup v1 устаревшим в версии 1.35 — kubelet теперь по умолчанию отказывается стартовать на узле с v1, но это умолчание задаётся полем конфигурации, которое можно переключить, а KEP-5573 прямым текстом говорит, что удаление кода будет сделано не раньше 1.38, — и никакой даты к этому порогу не привязано. Docker объявил cgroup v1 устаревшим в Engine v29.0, выпущенном в ноябре 2025 года, вообще не назначив версию удаления; собственная страница устаревших возможностей Docker говорит, что поддержка сохраняется до мая 2029 года, — именно тогда заканчивается жизненный цикл корпоративных сборок, которым она ещё нужна. Итого: ОС уже удалила, оркестраторы только объявили. Планируйте по ОС.[kep5573][dockdep]
| Дистрибутив | Иерархия по умолчанию | Примечание |
|---|---|---|
| Fedora 31 и новее | Единая (v2) | Первый массовый дистрибутив, сделавший переход; Fedora 43 наследует удаление из systemd 258 |
| Debian 11 и новее | Единая (v2) | В Debian 13 идёт systemd 257, так что это последний с хоть каким-то legacy-путём |
| Ubuntu 21.10 и новее | Единая (v2) | 22.04 LTS и новее — то, что вы, скорее всего, ещё эксплуатируете |
| RHEL 9 и новее | Единая (v2) | В RHEL 9.4 v1 официально объявлен устаревшим; RHEL 10 в v1 не загрузится вовсе |
| SLES 15 SP6 и новее | Единая (v2) | В SP3–SP5 по умолчанию был гибрид — та самая раскладка, которая обманывает grep |
| Всё, что старше | v1 или гибрид | Заодно уже и без поддержки. Вопрос контрольных групп тут не самый срочный |
А значит, реальный дедлайн на конкретной машине — это версия systemd, которую поставляет её дистрибутив, а не дорожная карта какой-либо контейнерной платформы. Большинство парков уже давно живут в единой иерархии, и никто этого не заметил: Fedora — с 31, Debian — с 11, Ubuntu — с 21.10, RHEL — с 9. Работа сосредоточена в том, что осталось за бортом, и в инструментах, которые по-прежнему рассчитывают на пути v1 независимо от того, что там на хосте.[rhel10][moby51111]
Три правила, ломающие ручные раскладки
От v1 модель v2 отличают три структурных правила, и каждое из них ломает раскладку, собранную руками под v1. Первое — сама единая иерархия: у процесса одна позиция в одном дереве, и все контроллеры читают именно её; поэтому /proc/self/cgroup и усох с дюжины строк до одной. Второе — делегирование сверху вниз: контроллер существует в дочерней контрольной группе, только если родитель явно передал его через cgroup.subtree_control. Третье — то самое, которое действительно больно.[kdoc][knoint]
# DEMONSTRATION ONLY. This writes into the cgroup tree by hand, which is the
# exact thing the rest of this article tells you not to do on a systemd host.
# Read it to understand the rules, then set limits through systemd.
# Rule 1 - one tree. Under v1 a process had a position in each controller's
# hierarchy independently, which is why /proc/PID/cgroup had a dozen lines.
# Under v2 it has one, and every controller reads the same position.
cd /sys/fs/cgroup
mkdir -p demo/worker demo/batch
# Rule 2 - a controller only exists in a child if the parent hands it down.
# cgroup.controllers is what you HAVE; cgroup.subtree_control is what you GIVE.
cat demo/cgroup.controllers # what the parent has delegated so far
echo '+cpu +memory +io' > cgroup.subtree_control # root delegates to demo
cat demo/cgroup.controllers # cpu io memory
echo '+cpu +memory' > demo/cgroup.subtree_control # demo delegates to its kids
ls demo/worker/ | grep -E '^(cpu|memory)\.' # the knobs now exist
# Rule 3 - no internal processes. A cgroup may hold processes, or hand
# resources to children, never both. This is the rule that breaks hand-built
# v1 layouts, and it fails at write() time with a very unhelpful error.
echo $$ > demo/cgroup.procs
# bash: echo: write error: Device or resource busy
#
# ... because demo already has subtree_control set. Processes live on leaves:
echo $$ > demo/worker/cgroup.procs # fine
# The root cgroup is exempt from rule 3, which is why the mistake survives
# testing at the top level and only shows up one directory down.Ограничение «никаких процессов внутри» гласит, что некорневая контрольная группа может либо содержать процессы, либо раздавать ресурсы потомкам, но никогда и то и другое сразу. Оно существует, чтобы убрать реальную неоднозначность v1, где собственные процессы родителя конкурировали с его потомками без определённого правила. На практике это значит, что раскладка v1 с лимитами на каждом уровне дерева не переводится: процессы надо спустить на листья, а промежуточные узлы оставить пустыми. И падает всё это максимально неинформативно — голое write error: Device or resource busy в ответ на echo в cgroup.procs, без единого намёка на то, какое из двух правил вы нарушили. Корневая группа из-под ограничения выведена — ровно поэтому ошибка спокойно переживает быструю проверку на верхнем уровне.[kdeleg]
Таблица соответствий, файл за файлом
Ниже — соответствия, с директивой systemd рядом с каждой парой, потому что на любом systemd-хосте выставлять надо именно директиву. Имена меняются чаще, чем значения, но три строки меняют не написание, а смысл — они отмечены. Обратите особое внимание, что cpu.max схлопывает два файла v1 в один файл из двух значений, а шкала веса CPU — это не шкала долей CPU: одни только умолчания различаются в десять раз.[sdresctl]
| cgroup v1 | cgroup v2 | Директива systemd | Примечание |
|---|---|---|---|
memory.limit_in_bytes | memory.max | MemoryMax= | Переименование. Смысл тот же: жёсткий лимит, OOM-kill при превышении |
memory.soft_limit_in_bytes | memory.high | MemoryHigh= | Улучшение. Мягкий лимит в основном игнорировался; memory.high реально тормозит |
| — | memory.low / memory.min | MemoryLow= / MemoryMin= | Новое. Пороги защиты, аналога в v1 нет |
memory.memsw.limit_in_bytes | memory.swap.max | MemorySwapMax= | Другой смысл. memsw — это память+подкачка; здесь только подкачка |
memory.usage_in_bytes | memory.current | — | Переименование |
memory.failcnt | memory.events | — | Лучше: отдельные счётчики для low, high, max, oom и oom_kill |
cpu.shares | cpu.weight | CPUWeight= | Другая шкала. Умолчание 1024 становится умолчанием 100; см. пересчёт |
cpu.cfs_quota_us + cpu.cfs_period_us | cpu.max | CPUQuota= + CPUQuotaPeriodSec= | Два файла становятся одним, запись вида "$MAX $PERIOD". CPUQuota= задаёт только половину — квоту |
cpuacct.usage | cpu.stat | — | Теперь включает nr_throttled и throttled_usec |
blkio.weight | io.weight | IOWeight= | Переименование, но учёт под ним наконец корректный |
blkio.throttle.*_bps_device | io.max | IOReadBandwidthMax= и т. д. | Один файл с вложенными ключами вместо четырёх; теперь считает и буферизованные записи |
pids.max | pids.max | TasksMax= | Без изменений |
freezer.state | cgroup.freeze | — | Переименование; пишите 1 или 0 |
devices.allow / devices.deny | — (eBPF) | DeviceAllow= | Контроллера нет. Заменён на BPF_PROG_TYPE_CGROUP_DEVICE |
net_cls.classid / net_prio.* | — (eBPF) | — | Ни контроллера, ни файла-замены. Используйте eBPF на путях cgroup |
| — | cpu.pressure / memory.pressure / io.pressure | — | Новое. PSI: причина мигрировать, а не цена миграции |
У трёх контроллеров v1 в v2 нет вообще никакого аналога, и именно эта строка превращает механический перенос в инженерную задачу. net_cls и net_prio просто удалили, а не переписали: классификация и шейпинг трафика по контрольным группам теперь делаются программами eBPF, привязанными к путям cgroup v2, с соответствующей поддержкой в iptables и nftables. Контроллер devices ушёл тем же путём: вместо файла-белого-списка программа eBPF типа BPF_PROG_TYPE_CGROUP_DEVICE получает старший и младший номера, тип устройства и тип доступа и возвращает разрешение или -EPERM. Если ваша платформа пользовалась чем-то из этого напрямую, закладывайте настоящую работу, а не замену путей.[cgman][bpfdev]
Память — там, где сменилась семантика
Память — то место, где небрежная миграция наносит тихий ущерб: числа по-прежнему подходят, а поведение уже другое. В v1 у вас были один жёсткий лимит и мягкий, который большинство ядер фактически игнорировали. В v2 уровней четыре, и убить что-либо может только один из них: memory.max — жёсткий лимит, при превышении срабатывает OOM-killer внутри контрольной группы; memory.high — тормоз, загоняющий группу в интенсивный reclaim и, словами самого ядра, никогда не вызывающий OOM killer; memory.low — защита по мере возможности; memory.min — жёсткая защита, которая не отбирается вообще никогда.[kmem][kv1mem]
# --- cgroup v1: two numbers, and the second one is not what people think ---
# memory.limit_in_bytes = 2G -> hard limit on memory
# memory.memsw.limit_in_bytes = 3G -> hard limit on memory PLUS swap
# (so: 2G RAM + up to 1G of swap)
# memory.soft_limit_in_bytes = 1G -> best-effort, and widely ignored
#
# --- cgroup v2: four memory tiers, plus a separate swap cap ---------------
cd /sys/fs/cgroup/demo/worker
echo 2G > memory.max # hard limit. Over this, OOM kill inside the cgroup.
echo 1800M > memory.high # throttle. Over this, heavy reclaim - never an OOM kill.
echo 512M > memory.low # best-effort protection. Reclaimed only as a last resort.
echo 256M > memory.min # hard protection. Never reclaimed, at all.
echo 1G > memory.swap.max # SWAP ONLY. Not memory+swap. Read that twice.
# The migration trap, stated as arithmetic:
# v1: memsw.limit=3G with limit=2G -> 2G RAM, 1G swap
# v2: memory.swap.max=3G -> memory.max RAM, 3G swap
# Copying 3G across gives the workload three times the swap it used to have.
# The correct translation is (memsw.limit - limit), and if that is zero you
# want memory.swap.max=0, not "unset".
# What is actually happening, rather than what you configured:
cat memory.current # bytes in use right now
cat memory.events # low high max oom oom_kill oom_group_kill
# low 0
# high 148 <- throttled 148 times: memory.high is doing work
# max 0
# oom 0
# oom_kill 0 <- and it never had to kill anything
cat memory.pressure # PSI: how much time was lost waiting on memory
# some avg10=0.42 avg60=0.31 avg300=0.11 total=9214430
# full avg10=0.00 avg60=0.00 avg300=0.00 total=118221
# memory.high plus memory.events is the pair that turns "the box OOMs at 3am"
# into a number you can alert on before it happens. v1 could not do this.| Файл v2 | Что делает | Может убить? | Ближайший аналог в v1 |
|---|---|---|---|
memory.min | Жёсткая защита: память ниже этой отметки не отбирается ни при каком давлении | Косвенно | Нет |
memory.low | Защита по мере возможности: отбирается, только когда незащищённого не осталось | Нет | Нет |
memory.high | Тормоз: выше этого группа попадает под интенсивный reclaim, а её процессы замедляются | Нет | memory.soft_limit_in_bytes, с натяжкой |
memory.max | Жёсткий лимит: выше него и без возможности освободить память отрабатывает OOM-killer внутри группы | Да | memory.limit_in_bytes |
memory.swap.max | Потолок только для подкачки, независимо от memory.max | Косвенно | memory.memsw.limit_in_bytes минус лимит памяти |
memory.events | Счётчики: как часто срабатывало каждое из перечисленного, включая oom_kill | — | memory.failcnt, без разбивки |
Ловушка называется memory.swap.max, и о ней стоит сказать прямо, потому что перенести число один в один — самое естественное действие. В v1 memory.memsw.limit_in_bytes ограничивал память плюс подкачку вместе; в v2 memory.swap.max ограничивает подкачку отдельно. Контейнеру с limit=2G, memsw=3G полагалось 2 ГБ ОЗУ и 1 ГБ подкачки. Выставьте memory.swap.max=3G — и вы только что выдали ему втрое больше подкачки, чем было, причём отказ проявится не падением, а машиной, которая под нагрузкой становится всё медленнее так, что ни на одном графике памяти это не видно. Правильный перевод — разность двух чисел v1, а если разность нулевая, вам нужен явный 0, а не пустой файл. Взамен вы получаете memory.events, который разносит по отдельным счётчикам то, что v1 сваливал в один failcnt, и memory.pressure, у которого в v1 нет аналога вообще: измерение простоев, позволяющее поднять алерт задолго до того, как отработает OOM-killer.[psi]
Квота CPU и контроллер I/O, который наконец считает
CPU аккуратно распадается на две идеи, которые v1 смешивал. cpu.weight — это относительная доля оспариваемого процессорного времени; на простаивающей машине он не делает ровным счётом ничего и является правильным инструментом для приоритетов. cpu.max — абсолютный потолок, записываемый как "$MAX $PERIOD" в микросекундах, взамен двух отдельных файлов v1, соотношение которых регулярно понимали наоборот. Файл cpu.stat стоит завести в мониторинг: nr_throttled и throttled_usec отвечают на вопрос «сервис тормозит, потому что мы его сами ограничили?», который иначе снаружи установить почти невозможно.[kio]
# --- CPU: two knobs, and only one of them is a limit ---------------------
cd /sys/fs/cgroup/demo/worker
# Weight: relative share of contended CPU. Default 100, range 1-10000.
# It does nothing at all while the machine is idle.
echo 200 > cpu.weight
# Quota: an absolute ceiling, written as "$MAX $PERIOD" in microseconds.
# 150000 out of every 100000us = 1.5 CPUs. "max" removes the ceiling.
echo '150000 100000' > cpu.max
echo 20000 > cpu.max.burst # allow short bursts above quota (v2 only)
# The v1 equivalents were three files, and the period was easy to forget:
# cpu.shares = 200 -> but the scale was different: default 1024
# cpu.cfs_quota_us = 150000
# cpu.cfs_period_us = 100000
# Throttling, which is the number people actually need and rarely find:
cat cpu.stat
# usage_usec 918422311
# nr_periods 41822
# nr_throttled 219 <- how often the quota was hit
# throttled_usec 411920 <- and how much time was lost to it
# --- IO: blkio became io, and the numbers finally mean something ----------
# v1's blkio.throttle.* only saw direct IO; buffered writes were charged to
# whatever kernel thread flushed them, so the accounting was fiction.
echo '259:0 rbps=104857600 wbps=52428800 riops=max wiops=2000' > io.max
echo 'default 100' > io.weight
cat io.stat
# 259:0 rbytes=2841579520 wbytes=1120043008 rios=48211 wios=22103 dbytes=0 dios=0
cat io.pressure
# some avg10=1.94 avg60=0.88 avg300=0.31 total=41822193
# Device numbers, because io.max will not take a path:
lsblk -no MAJ:MIN,NAME /dev/nvme0n1| Файл v2 | Тип | Умолчание | Для чего |
|---|---|---|---|
cpu.weight | Относительный | 100 (диапазон 1–10000) | Приоритет при конкуренции. На простаивающей машине эффекта нет |
cpu.max | Абсолютный | max 100000 | Потолок. 150000 100000 — это 1,5 CPU |
cpu.max.burst | Абсолютный | 0 | Разрешает короткие превышения квоты вместо мгновенного троттлинга |
cpu.stat | Только чтение | — | nr_throttled и throttled_usec: доказательство, что потолок мешает |
io.weight | Относительный | default 100 | Относительная доля дискового времени, по устройству или в целом |
io.max | Абсолютный | не задан | rbps, wbps, riops, wiops на MAJ:MIN |
io.latency | Целевой | не задан | Защищает целевую задержку, а не цифру пропускной способности |
Контроллер I/O — та часть v2, которая является настоящим улучшением, а не переименованием. Троттлинг blkio в v1 видел только прямой ввод-вывод; буферизованные записи списывались на тот поток ядра, который в итоге их сбрасывал, так что учёт записи по контрольным группам был, мягко говоря, художественным вымыслом. Контроллер io в v2 понимает writeback и относит его обратно на ту группу, которая испачкала страницы. Одно это изменение делает io.max и io.weight достойными настройки на узле базы данных, где эквивалентные настройки v1 по большей части настраивать не имело смысла. io.latency и io.cost идут дальше, защищая целевую задержку нагрузки, а не её полосу, — и если непонятно, с чего начинать, io.latency из этих двух менее инвазивен.[kdoc]
Изменение веса CPU, о котором вам не сообщили
Этот сюжет заслуживает отдельного раздела, потому что он сдвинул приоритет CPU у всех контейнеризованных нагрузок, и сделал это в компоненте, чей журнал изменений почти никто не читает. Kubernetes всегда выводил доли CPU из запроса по формуле milliCPU × 1024 / 1000, так что контейнер, просивший один CPU, получал 1024 доли. Дальше OCI-рантайм переводил доли в вес v2, и исходный перевод был линейным по всему диапазону долей ядра [2, 262144]. Посчитайте это для 1024 долей — получится вес 39, при умолчании cgroup v2, равном 100.[k8scpu][runcissue]
# A quiet change that moved every containerised workload's CPU priority, with
# no release note in most people's changelog because it happened in the OCI
# runtime rather than in the orchestrator.
# Kubernetes derives shares from the CPU request, and always has:
# cpu.shares = milliCPU * 1024 / 1000
# request 1000m -> 1024 shares request 100m -> 102 shares
# runc then converted shares to a v2 weight. The original conversion was
# linear over the kernel's [2, 262144] share range:
# weight = 1 + ((shares - 2) * 9999) / 262142
python3 -c 'print(1 + ((1024 - 2) * 9999) // 262142)'
# 39
#
# 39. Against a cgroup v2 default of 100. Every container asking for a full CPU
# was scheduled at roughly a third of the weight of anything not in a
# container - including the kubelet and the runtime themselves.
# The replacement is log-based, and is chosen so that one CPU lands on the
# default rather than well below it:
python3 - <<'PY'
import math
def weight(shares):
if shares == 0: return 0
if shares <= 2: return 1
if shares >= 262144: return 10000
l = math.log2(shares)
return math.floor(10 ** ((l*l + 125*l) / 612.0 - 7/34) + 0.99)
for req, sh in (("100m",102), ("500m",512), ("1",1024), ("4",4096), ("16",16384)):
print("%-6s shares=%-6d weight=%d" % (req, sh, weight(sh)))
PY
# 100m shares=102 weight=17
# 500m shares=512 weight=59
# 1 shares=1024 weight=100
# 4 shares=4096 weight=303
# 16 shares=16384 weight=942
#
# 1024 lands on exactly 100 because the curve is fitted through three fixed
# points: 2 -> 1, 1024 -> 100, and 262144 -> 10000.
# Check what your nodes are doing, because this depends on the runtime version
# and not on the Kubernetes version. The new conversion ships in runc 1.3.2 and
# later, and in crun 1.23 and later:
runc --version; crun --version 2>/dev/null
cat /sys/fs/cgroup/kubepods.slice/*/*/cpu.weight 2>/dev/null | sort -n | uniq -cСледствие: контейнер, запросивший целый CPU, конкурировал примерно с третью приоритета всего, что не в контейнере, на том же узле, включая системные демоны и сам kubelet. Исправление заменяет линейное отображение логарифмической кривой, проведённой через три закреплённые точки — 2 доли в вес 1, 1024 в 100 и 262144 в 10000, — так что запрос в один CPU теперь попадает ровно в умолчание cgroup v2. Оно поставляется в runc 1.3.2 и новее и в crun 1.23 и новее. Отсюда следуют две легко упускаемые вещи. Во-первых, это изменение рантайма, а не Kubernetes: оно приезжает с обновлением runc или crun, которое может и не совпасть с обновлением кластера, так что смотреть надо на версию рантайма, а не кластера. Во-вторых, оно меняет относительные приоритеты нагрузок на уже перенесённых узлах, так что если вы замеряли поведение CPU на v2 до этого изменения, ваш замер протух.[runcpr]
Делайте это через systemd, а не через /sys
На любом systemd-хосте правильный интерфейс — это systemd, а не файловая система. Это не вопрос вкуса. systemd создаёт дерево контрольных групп и заново применяет собственное представление о ресурсных настройках юнита при каждой его перезагрузке, перезапуске или переконфигурации, — так что значение, которое вы записали в memory.max через echo, живёт ровно до следующего постороннего изменения, после чего исчезает без единой строки в логе. Документ апстрима о делегировании формулирует правило прямо: один писатель на поддерево. Плюс через systemd вы бесплатно получаете сохранение настроек между перезагрузками, чего правка руками не даёт никогда.[sddeleg][sdresctl]
# Writing into /sys/fs/cgroup by hand works exactly until systemd next touches
# that unit, at which point your values are overwritten without warning.
# systemd owns the tree; ask it, and the setting also survives a reboot.
# Try a limit on something already running, for this boot only:
systemctl set-property --runtime nginx.service MemoryHigh=1G IOWeight=50
# Make it permanent. This writes a drop-in for you - under
# /etc/systemd/system.control/nginx.service.d/, not /etc/systemd/system/, which
# is why hand-searching for your setting in the obvious place turns up nothing.
# No daemon-reload needed.
systemctl set-property nginx.service MemoryMax=2G MemoryHigh=1800M CPUWeight=200
# Or write the drop-in yourself, which is what you want in configuration
# management: /etc/systemd/system/nginx.service.d/50-resources.conf
#
# [Service]
# MemoryMax=2G # -> memory.max
# MemoryHigh=1800M # -> memory.high
# MemoryMin=256M # -> memory.min
# MemorySwapMax=0 # -> memory.swap.max
# CPUWeight=200 # -> cpu.weight
# CPUQuota=150% # -> cpu.max (150% of one CPU)
# IOWeight=50 # -> io.weight
# IOReadBandwidthMax=/dev/nvme0n1 100M
# TasksMax=512 # -> pids.max
#
# Note CPUQuota is a percentage of ONE CPU, not of the machine: 150% is 1.5
# cores. This is the systemd unit that trips people most often.
# Put a limit on a command you are about to run, without writing a unit:
systemd-run --scope --user -p MemoryMax=4G -p CPUQuota=200% -- ./import-job.sh
# And look at the tree systemd actually built, not the one you configured:
systemd-cgls --unit nginx.service
systemd-cgtop --order=memory --iterations=1
# Reading the values back. Note that there is no CPUQuota property to query:
# the unit-file setting CPUQuota= is exposed as CPUQuotaPerSecUSec, and asking
# for the name you wrote is the usual reason this returns nothing.
systemctl show nginx.service -p MemoryMax -p MemoryHigh -p CPUQuotaPerSecUSecТри команды закрывают почти всю повседневную работу. systemctl set-property применяет лимит немедленно и записывает его на диск для будущих загрузок, если только вы не передали --runtime, чтобы сделать его временным. systemd-run --scope -p … оборачивает лимитом команду, которую вы собираетесь запустить, — честный способ ограничить разовый импорт или бэкап вместо того, чтобы надеяться. А systemd-cgtop показывает потребление ресурсов по контрольным группам, а не по процессам, — именно этот вид вам и нужен, когда контейнерный хост загружен, а top показывает двести процессов и ноль структуры. Одна директива ловит всех: CPUQuota= — это процент от одного CPU, так что 150% означает полтора ядра, а не 150% машины.[sdctl][sdcgtop]
Docker, Podman и rootless-лимиты, которые заработали
Для контейнеров новости в основном хорошие, потому что перевод берёт на себя рантайм. --memory, --cpus, --memory-reservation и --pids-limit означают ровно то же, что и раньше; просто теперь они приземляются на memory.max, cpu.max, memory.low и pids.max. На хосте с единой иерархией Docker по умолчанию использует драйвер контрольных групп systemd и приватное пространство имён cgroup — оба умолчания правильные. Исключение, о котором стоит знать, — --oom-kill-disable: собственная документация Docker говорит, что на v2 он отбрасывается. Не переводится, не сопровождается предупреждением — отбрасывается. Аналога в v2 нет по замыслу, так что всё, что на него опиралось, нужно переосмысливать, а не переносить.[dockrun]
# --- Docker ---------------------------------------------------------------
docker info --format 'version={{.CgroupVersion}} driver={{.CgroupDriver}}'
# version=2 driver=systemd <- the defaults on a unified host
# Most flags are unchanged, because the daemon translates them for you:
docker run --memory 2g --memory-reservation 1g --cpus 1.5 --pids-limit 512 nginx
# -> memory.max memory.low cpu.max pids.max
# Two that are not:
# --oom-kill-disable is discarded on cgroup v2. Not translated - discarded.
# There is no v2 equivalent, by design.
# --kernel-memory removed from the Engine in v23.0. It is gone, not moved.
# Setting the driver explicitly, in /etc/docker/daemon.json. Use systemd unless
# something specific stops you: it is the default on v2 and it is the only
# option that keeps one writer per subtree.
# { "exec-opts": ["native.cgroupdriver=systemd"] }
# --- Podman rootless: this is the part that only works on v2 --------------
# Under v1, an unprivileged user could not be given controllers at all, so
# rootless resource limits silently did nothing. Under v2 they work, but only
# once systemd delegates the controllers to the user manager:
#
# /etc/systemd/system/user@.service.d/delegate.conf
# [Service]
# Delegate=cpu cpuset io memory pids
#
sudo systemctl daemon-reload # then log out and back in
# Verify from inside the user session, before blaming the container:
cat /sys/fs/cgroup/user.slice/user-$(id -u).slice/cgroup.controllers
# cpuset cpu io memory pids <- if memory is missing, --memory does nothing
podman info --format '{{.Host.CgroupsVersion}} {{.Host.CgroupManager}} {{.Host.OCIRuntime.Name}}'
# v2 systemd crunRootless-контейнеры — единственное место, где v2 не налог, а возможность. Делегирование контроллеров v1 непривилегированному пользователю никогда не считалось безопасным, поэтому большинство rootless-реализаций просто не поддерживали ограничение ресурсов на хосте с v1. В v2 безопасное делегирование поддерева заставляет их работать по-настоящему — но только после того, как systemd делегирует контроллеры пользовательскому менеджеру, а это drop-in для user@.service и перелогин; именно этот пропущенный шаг стоит почти за каждым сообщением «rootless Podman игнорирует --memory». Проверьте cgroup.controllers внутри своего пользовательского слайса, прежде чем винить контейнер: если memory там не перечислен, ни один переданный флаг применён не будет. Для делегирования cpuset дополнительно нужен systemd 244 или новее.[podman][crun]
Kubernetes: что правда, а что вам повторяют
Теперь то, о чём чаще всего сообщают неверно, — аккуратно. Kubernetes не удалял cgroup v1. Документация помечает его устаревшим начиная с v1.35, и практическое следствие в том, что kubelet по умолчанию отказывается стартовать на узле с cgroup v1. Это умолчание — поле KubeletConfiguration failCgroupV1, и установка его в false возвращает прежнее поведение. KEP-5573 — предложение, которое в итоге и выполнит удаление, — говорит, что удаление будет сделано не раньше 1.38. Если какой-то пост сообщил вам, что cgroup v1 вырезали в 1.36, он ошибался, а разница между «устарел, но переключается» и «удалён» — это разница между плановой миграцией и испорченными выходными.[k8scg][kep5573]
# What the cluster thinks it is standing on. Run this first; mixed node pools
# are the normal case, not the exception.
kubectl get nodes -o custom-columns=\
'NODE:.metadata.name,KERNEL:.status.nodeInfo.kernelVersion,'\
'RUNTIME:.status.nodeInfo.containerRuntimeVersion,OS:.status.nodeInfo.osImage'
# The kernel version alone does not tell you the hierarchy. Ask each node.
# Note the -it: without it, kubectl debug does not attach, the output goes to
# the debug pod's log instead of your terminal, and you are left with one
# orphaned pod per node.
kubectl get nodes -o name | while read -r n; do
printf '%-40s ' "${n#node/}"
kubectl debug "$n" -it --image=busybox --profile=general -- \
stat -fc %T /host/sys/fs/cgroup/ 2>/dev/null || echo '(debug unavailable)'
done
# Clean up afterwards - the debug pods are not removed for you:
kubectl delete pod -l app.kubernetes.io/managed-by=kubectl-debug 2>/dev/null
# On the node itself - the three files that have to agree:
stat -fc %T /sys/fs/cgroup/ # cgroup2fs
grep -E '^(cgroupDriver|failCgroupV1):' /var/lib/kubelet/config.yaml
grep -A2 'runc.options' /etc/containerd/config.toml # SystemdCgroup = true
# --- what is actually true about Kubernetes and cgroup v1 -----------------
# cgroup v1 is DEPRECATED as of v1.35, not removed. The kubelet refuses to
# start on a v1 node by default, and that default is overridable:
#
# /var/lib/kubelet/config.yaml
# apiVersion: kubelet.config.k8s.io/v1beta1
# kind: KubeletConfiguration
# cgroupDriver: systemd
# failCgroupV1: false # <- the escape hatch. Buys time, not a fix.
#
# KEP-5573 states the code removal will happen no earlier than 1.38.
# The v2-only features you get in exchange, and how to see them:
NODE=$(kubectl get nodes -o jsonpath='{.items[0].metadata.name}')
kubectl get --raw "/api/v1/nodes/$NODE/proxy/metrics/cadvisor" \
| grep -E '^container_pressure_(cpu|memory|io)_' | head
# container_pressure_memory_stalled_seconds_total{...}
# container_pressure_memory_waiting_seconds_total{...}Требования к узлу скромные, и их стоит проверить, а не предполагать: ядро 5.8 или новее, containerd v1.4+ либо CRI-O v1.20+ и настроенный у kubelet и рантайма драйвер контрольных групп systemd — именно он, а не просто одинаковый. Последнее условие раньше было постоянным источником узлов, работающих наполовину, потому что зависело от согласия двух конфигурационных файлов; начиная с v1.34 kubelet спрашивает драйвер напрямую у CRI-рантайма, что закрывает весь этот класс проблем для всех, у кого рантайм достаточно свежий. Взамен за миграцию вы получаете набор возможностей, существующих только в v2:[k8sdriver][k8spsi]
- Метрики PSI, GA в v1.36. kubelet читает
cpu.pressure,memory.pressureиio.pressureпо каждой контрольной группе и отдаёт их через Summary API и эндпоинт метрик cAdvisor. В v1 этих файлов нет вовсе, так что бэкпортировать такую возможность нельзя — это довод в пользу миграции. - Memory QoS. kubelet умеет выставлять
memory.highисходя изmemoryThrottlingFactor, чтобы контейнер сначала попал под жёсткий reclaim, а не сразу под нож, и отдельно от этого, в режимеmemoryReservationPolicy: TieredReservation, — ещё иmemory.minдля подов класса Guaranteed иmemory.lowдля Burstable. По состоянию на v1.36 это alpha и выключено по умолчанию, так что относитесь к нему как к тому, что надо тестировать, а не как к тому, на что можно опираться, — но ради такой формы четырёхуровневая модель памяти и задумывалась. - Rootless- и user-namespace-нагрузки, у которых лимиты действительно применяются. Всё сказанное в разделе про Podman относится и к узлу Kubernetes, и именно поэтому выход пространств имён пользователей в GA и cgroup v2 — связанные истории, а не совпадение.
- Честный учёт I/O по подам. Буферизованные записи относятся на ту группу, которая их вызвала, и потребление диска на под превращается из числа, за которое приходится оправдываться, в число, по которому можно действовать.
Одно планировочное замечание, которое легко упустить в разнородном парке. Поскольку принуждает к переходу systemd, а не Kubernetes, пулы узлов мигрируют сами по мере обновления базовых образов, задолго до любого решения на уровне кластера. Это нормально, но означает, что можно получить кластер, где половина узлов на v2, а половина нет, и одни и те же нагрузки ведут себя по памяти и CPU заметно по-разному, — и ни один алерт вам об этом не сообщит. Проверяйте узлы, а не выводите иерархию из версии кластера.[k8sqos]
Включаем v2 там, где его ещё нет
Если у вас ещё остались хосты на v1 или гибриде, вот механическая часть, и она короткая. Сначала заметьте: на systemd 258 и новее включать нечего и выключать нечего — единая иерархия является единственным режимом, который поддерживает код. Но старый параметр ядра на таких хостах всё же уберите. Сам systemd 258 его больше не обрабатывает, однако если его по-прежнему обрабатывает initrd и монтирует иерархию v1, то PID 1 отказывается стартовать и сообщает, что устаревшую опцию командной строки надо убрать, — отказ куда более честный, чем молчаливый, и всё же загрузка, которую придётся чинить с консоли. Всё написанное ниже относится только к хостам, достаточно старым, чтобы у них ещё был выбор, — и на них выбор стоит делать в окне обслуживания, в котором заодно переезжают и контейнерные рантаймы, потому что узел, загрузившийся в v2 с драйвером cgroupfs, — это узел, который возвращается в интересном состоянии.[sd258]
# Only needed on hosts old enough to still default to v1 or hybrid. On
# systemd 258 and later there is nothing to enable: unified is the only mode.
# 1. Check you can. Kubernetes wants kernel 5.8+; systemd 258 needs 5.4 as an
# absolute floor and recommends 5.7. Below that, upgrade the OS instead.
uname -r
# 2. Set the kernel parameter. Debian and Ubuntu. The grep guard matters:
# without it, running this twice adds the parameter twice.
grep -q 'systemd.unified_cgroup_hierarchy' /etc/default/grub || \
sudo sed -i 's/^GRUB_CMDLINE_LINUX="/&systemd.unified_cgroup_hierarchy=1 /' \
/etc/default/grub
sudo update-grub
# RHEL, Fedora, Rocky, Alma - grubby, and note ALL rather than the running
# kernel, or the setting vanishes at the next kernel update:
sudo grubby --update-kernel=ALL --args="systemd.unified_cgroup_hierarchy=1"
# 3. Line up the container runtimes in the SAME maintenance window. A node
# that reboots into v2 with a cgroupfs driver is a node that does not come
# back cleanly.
# /etc/docker/daemon.json -> "exec-opts": ["native.cgroupdriver=systemd"]
# /etc/containerd/config.toml -> SystemdCgroup = true
# /var/lib/kubelet/config.yaml -> cgroupDriver: systemd
sudo reboot
# 4. Verify, in this order. If step one disagrees with step three, stop.
stat -fc %T /sys/fs/cgroup/ # cgroup2fs
cat /sys/fs/cgroup/cgroup.controllers # non-empty
systemctl --failed
docker info --format '{{.CgroupVersion}}/{{.CgroupDriver}}' # 2/systemd
# Rolling back is removing the parameter and rebooting - but only while your
# systemd is older than 258. After that the parameter is inert and the only
# way back is downgrading the OS, which is not a rollback plan.| Что ломается | Почему | Что делать вместо этого |
|---|---|---|
Скрипты, читающие /sys/fs/cgroup/memory/… | Каталогов по контроллерам в единой иерархии не существует | Читать плоские пути v2 или спрашивать значение через systemctl show |
| Инструменты, пишущие в контрольные группы, принадлежащие systemd | systemd молча навязывает свои настройки при любом изменении юнита | systemctl set-property или drop-in-файл |
Маркировка трафика на основе net_cls | Контроллер удалён, файла-замены нет | eBPF, привязанный к пути cgroup, с матчингом из nftables |
Белые списки devices.allow | Контроллер заменён типом программы eBPF | DeviceAllow= в юните или eBPF-программа для устройств |
| Раскладки с лимитами на каждом уровне дерева | Ограничение «никаких процессов внутри» | Спустить процессы на листья; промежуточные группы держать пустыми |
--oom-kill-disable | На v2 отбрасывается, по замыслу | Подобрать корректный memory.max и брать memory.high для раннего предупреждения |
| Лимиты подкачки, перенесённые дословно | memory.swap.max — это подкачка отдельно, а не память плюс подкачка | Задать разность двух чисел v1 или явный 0 |
Две заметки про откат, поскольку никто не планирует миграцию без него. Пока ваш systemd — 257 или старше, откат — это снять параметр ядра и перезагрузиться, и он действительно дешёвый. Как только вы на 258 или новее, поддерживаемого пути назад нет вовсе, кроме понижения версии операционной системы, а это не план отката, а переустановка. Выстраивайте очередь по парку соответственно: сначала хосты, у которых путь к отступлению ещё есть, извлеките из них уроки и только потом двигайте те, у которых его нет.[dockrun]
Проверять, а не надеяться
Проверка — не вопрос вкуса. Смысл скрипта ниже в том, что каждая строка либо печатает OK, либо объясняет себя, а код возврата равен числу провалов, так что его можно повесить прямо на то, что выполняется после перезагрузки. Две части в нём отрабатывают своё место. Первая печатает опись всех запущенных юнитов, у которых MemoryMax действительно выставлен, и её вы сверяете со списком того, что собирались настроить: если юнита в выводе нет, значит его drop-in лежит не в том каталоге, а обнаружить это, не зная желаемого состояния, невозможно в принципе. Вторая обходит всё дерево в поисках троттлинга, потому что растущий nr_throttled на сервисе, на который никто не жалуется, — классический признак слишком туго переведённого лимита.[sdcgls]
#!/usr/bin/env bash
# Post-migration verification. Every check either prints OK or explains
# itself; nothing here is judged by eye. Exit code is the number of failures.
fail=0
chk() { if eval "$2" >/dev/null 2>&1; then printf 'OK %s\n' "$1";
else printf 'FAIL %s\n' "$1"; fail=$((fail+1)); fi; }
chk 'unified hierarchy' '[ "$(stat -fc %T /sys/fs/cgroup/)" = cgroup2fs ]'
chk 'controllers available' '[ -s /sys/fs/cgroup/cgroup.controllers ]'
chk 'memory controller' 'grep -qw memory /sys/fs/cgroup/cgroup.controllers'
chk 'io controller' 'grep -qw io /sys/fs/cgroup/cgroup.controllers'
chk 'single cgroup line' '[ "$(wc -l < /proc/self/cgroup)" -eq 1 ]'
chk 'no v1 leftovers mounted' '! mount | grep -q "type cgroup "'
chk 'no failed units' '[ -z "$(systemctl list-units --state=failed --no-legend)" ]'
chk 'PSI available' '[ -r /sys/fs/cgroup/cpu.pressure ]'
# Limits are actually applied, rather than merely configured. A unit whose
# MemoryMax reads "infinity" after you set it is a unit whose drop-in is in
# the wrong place - a very common outcome of hand-editing.
for u in $(systemctl list-units --type=service --state=running \
--no-legend --plain | awk '{print $1}'); do
m=$(systemctl show "$u" -p MemoryMax --value)
[ "$m" != "infinity" ] && printf ' %-34s MemoryMax=%s\n' "$u" "$m"
done
# Nothing is being silently throttled. nr_throttled climbing on a service that
# is not busy means cpu.max is too tight, and it will not appear in load
# average. Search the whole tree, not just the top-level slices: throttling
# happens on the leaf that holds the process.
find /sys/fs/cgroup -name cpu.stat -exec \
awk '/^nr_throttled/ && $2>0 {print FILENAME": "$0}' {} + 2>/dev/null
# Containers agree with the host.
command -v docker >/dev/null && \
chk 'docker on v2/systemd' '[ "$(docker info -f "{{.CgroupVersion}}/{{.CgroupDriver}}")" = 2/systemd ]'
printf '\n%d failure(s)\n' "$fail"; exit "$fail" Запустите его и до миграции, и сохраните вывод. Большая часть того, что потом валят на переход между иерархиями, была правдой и раньше, и единственный способ это знать — измерить. Если nr_throttled на том сервисе рос уже на прошлой неделе, новая иерархия тут ни при чём.
В каком порядке это делать
Решение, сжато. Честный итог такой: большинство уже мигрировало без всякого проекта, и осталось не хостами, а инструментами — скриптами, агентами и дашбордами, которые до сих пор читают пути v1 и будут тихо возвращать нули, пока кто-нибудь не проверит. Kubernetes даёт вам время как минимум до 1.38, Docker — заметно больше, но ни одна из этих дат не является вашим дедлайном. Ваш дедлайн — момент, когда очередное обновление базового образа перетащит systemd через 258, а на большинстве парков это уже произошло.[kep5573]
| Если у вас… | То дедлайн… | А работа — это… |
|---|---|---|
Везде уже показывается cgroup2fs | Уже прошёл, тихо и незаметно | Только инструменты: найти пути v1, которые ещё читаются, и починить их |
| Горстка старых хостов на v1 или гибриде | Когда до них доедет следующее обновление ОС | Параметр ядра плюс драйверы рантаймов, по одному окну обслуживания на каждый |
| Узлы Kubernetes на разнородных базовых образах | По мере обновления образов узлов, а не при обновлении кластера | Аудит по каждому узлу; не выводить иерархию из версии кластера |
| Docker-хосты без оркестратора | В Engine v29.0 объявлен устаревшим; до удаления годы | Со стороны Docker несрочно, но systemd всё равно сдвинет хост раньше |
| Своя обвязка, пишущая в файлы cgroup напрямую | Сейчас, и это и есть настоящий проект | Переписать на интерфейсы systemd, пока хосты не уехали у вас из-под ног |
| Аплайнс или вендорский агент, который вы не меняете | График вендора, а он не ваш | Получить ответ письменно и изолировать хост, если ответ не устраивает |
- Сначала аудит, потом планы. Прогоните скрипт по парку на каждом хосте и сохраните вывод. Вы ищете две вещи: хосты, всё ещё сидящие на v1 или гибриде, и захардкоженные пути v1 в
/etc,/optи/usr/local. Второй список почти всегда длиннее первого и является настоящей работой. - Чините инструменты первыми, пока обе иерархии ещё существуют. Всё, что читает
/sys/fs/cgroup, должно уметь обе раскладки — или читать значение черезsystemctl show. Сделав это до переезда хостов, вы сможете проверить исправление на том, ради чего оно и написано. - Переводите лимиты по смыслу, а не по названию. Две строки таблицы соответствий меняют поведение: подкачка, где
memory.swap.max— это подкачка отдельно, и вес CPU, чья шкала отличается от шкалы долей больше чем в десять раз. Все остальные строки — переименования. - Двигайте рантаймы и хосты в одном окне. Параметр ядра, драйвер Docker,
SystemdCgroupв containerd,cgroupDriverу kubelet — все четыре, одна перезагрузка, потом проверка и только затем следующая партия. - Заберите выигрыш. Подключите
memory.highиmemory.events, выведите PSI на дашборд и включите лимиты I/O по контрольным группам, которые в v1 не имело смысла выставлять. У этой миграции не будет никакой фичи в конце, пока вы её не заберёте.
Эта история стоит рядом с тремя другими частями того же сдвига, и они складываются: перевод SysV-скриптов и rc.local в юниты systemd, потому что оба изменения приезжают в одних и тех же выпусках systemd и на те же серверы; обновление сервера с Ubuntu 24.04 до 26.04, где большинство парков реально пересечёт черту; и ломающие изменения в Docker Engine 29, чьё объявление cgroup v1 устаревшим — контейнерная половина этого сюжета. А если вы взвешиваете, сколько всей этой сложности вам вообще нужно, когда Kubernetes не нужен — другая сторона того же спора.
Частые вопросы
Как понять, cgroup v1 у меня или v2?
Выполните stat -fc %T /sys/fs/cgroup/. Если печатается cgroup2fs — у вас единая иерархия; если tmpfs — v1 или гибридная раскладка. Не используйте mount | grep cgroup2: гибрид монтирует иерархию cgroup2 в /sys/fs/cgroup/unified без единого подключённого контроллера, поэтому grep находит совпадение и сообщает вам ровно противоположное истине. Второе подтверждение — /proc/self/cgroup, где в v2 ровно одна строка, начинающаяся с 0::.
Правда ли, что Kubernetes 1.36 удалил cgroup v1?
Нет. cgroup v1 объявлен устаревшим начиная с Kubernetes v1.35, и практический эффект в том, что kubelet по умолчанию отказывается стартовать на узле с cgroup v1. Это умолчание — поле KubeletConfiguration failCgroupV1, установка его в false возвращает прежнее поведение. KEP-5573, предложение, которое в итоге выполнит удаление, говорит, что удаление произойдёт не раньше v1.38. Несколько широко разошедшихся постов утверждают иное; авторитет здесь — KEP.
memory.swap.max — это то же самое, что memory.memsw.limit_in_bytes?
Нет, и это самое разрушительное заблуждение во всей миграции. В cgroup v1 memory.memsw.limit_in_bytes ограничивал память и подкачку вместе, так что группа с limit=2G и memsw=3G могла использовать 2 ГБ ОЗУ плюс 1 ГБ подкачки. В cgroup v2 memory.swap.max ограничивает подкачку отдельно. Перенеся 3G как есть, вы выдаёте втрое больше прежнего разрешения на подкачку. Правильный перевод — разность двух значений v1, а там, где эта разность нулевая, задайте явный 0.
Чем memory.high отличается от memory.max?
memory.max — жёсткий лимит: когда группа его достигает и не может освободить память, внутри неё отрабатывает OOM-killer. memory.high — тормоз: превышение загоняет группу под интенсивное давление reclaim и замедляет её процессы, причём документация ядра прямо говорит, что выход за него никогда не вызывает OOM killer. На практике memory.high ставят несколько ниже memory.max и вешают алерт на счётчик high в memory.events — это даёт предупреждение до того, как что-то будет убито. В cgroup v1 эквивалентного механизма не было.
Почему после перехода на cgroup v2 контейнерам стало доставаться меньше CPU?
Из-за перевода долей в вес, а не из-за самого v2. Kubernetes выводит cpu.shares из запроса CPU как milliCPU × 1024 / 1000, так что запрос в один CPU давал 1024 доли. Исходный перевод в OCI-рантаймах линейно отображал это на диапазон весов v2 и давал 39 при умолчании cgroup v2, равном 100, — то есть контейнеры конкурировали примерно с третью приоритета процессов вне контейнеров. Логарифмическая замена ставит 1024 доли ровно на 100 и поставляется в runc 1.3.2 и новее и в crun 1.23 и новее. Смотрите версию рантайма, а не версию Kubernetes: это приезжает с обновлением образа узла или рантайма, а не управляющего слоя.
Можно ли ещё принудительно включить cgroup v1 через systemd.unified_cgroup_hierarchy=0?
Только на systemd 257 и старше, и там нужны сразу оба параметра в командной строке ядра: systemd.unified_cgroup_hierarchy=0 и SYSTEMD_CGROUP_ENABLE_LEGACY_FORCE=1. systemd 256 перестал загружать cgroup v1 по умолчанию и завёл этот аварийный рычаг; 257 его всё ещё уважает; systemd 258 удалил поддержку cgroup v1 целиком, вместе с рычагом. На 258 и новее сам systemd этот параметр больше не обрабатывает — но его лучше убрать, а не оставлять: если его по-прежнему обрабатывает ваш initrd и монтирует иерархию v1, PID 1 откажется запускаться и потребует убрать устаревшую опцию командной строки.
Чем заменили контроллеры devices, net_cls и net_prio?
Во всех трёх случаях — eBPF. Контроль доступа к устройствам теперь реализуется программой eBPF типа BPF_PROG_TYPE_CGROUP_DEVICE, которая получает старший и младший номера, тип устройства и тип доступа и возвращает разрешение или -EPERM; на systemd-хосте этим за вас управляет директива юнита DeviceAllow=. У классификации и приоритизации трафика в v2 нет ни контроллера, ни интерфейсного файла-замены: вы навешиваете eBPF-программу на путь cgroup и матчите по ней из iptables или nftables. Это те три строки таблицы соответствий, которые требуют инженерной работы, а не замены путей.
Почему rootless Podman игнорирует лимит памяти на cgroup v2?
Почти всегда потому, что systemd не делегировал контроллер памяти вашему пользовательскому менеджеру. Создайте drop-in для user@.service со строкой Delegate=cpu cpuset io memory pids, перечитайте конфигурацию systemd, затем выйдите из сессии и войдите заново. Проверьте командой cat /sys/fs/cgroup/user.slice/user-$(id -u).slice/cgroup.controllers: если memory в этом списке нет, ни один переданный Podman флаг применён быть не может. Для делегирования именно cpuset нужен systemd 244 или новее. В cgroup v1 делегирование контроллеров пользователю без прав root не считалось безопасным, и большинство rootless-реализаций его не поддерживало, — так что это возможность, которую v2 добавляет, а не регрессия, которую v2 приносит.
Нужно ли менять мои команды Docker?
В основном нет. --memory, --cpus, --memory-reservation и --pids-limit сохраняют смысл и переводятся в memory.max, cpu.max, memory.low и pids.max. Важны два исключения: --oom-kill-disable на cgroup v2 отбрасывается и аналога не имеет, а --kernel-memory убрали из Engine ещё в v23.0. На хосте с единой иерархией Docker по умолчанию берёт драйвер контрольных групп systemd и приватное пространство имён cgroup — оба умолчания лучше не трогать, если только что-то конкретное не вынуждает.
Есть ли выигрыш в производительности или это чистые издержки миграции?
Выигрыш настоящий, и он сосредоточен в вводе-выводе и в наблюдаемости. Контроллер blkio в v1 учитывал только прямой ввод-вывод, поэтому буферизованные записи списывались на тот поток ядра, который их сбрасывал, а лимиты записи по контрольным группам были в основном декоративными; контроллер io в v2 понимает writeback и относит его правильно, что делает io.max и io.latency достойными настройки на хостах баз данных и сборочных машинах. Сверх того, PSI — cpu.pressure, memory.pressure, io.pressure — существует только в v2, и это разница между «мы знаем, что машина под давлением» и «мы узнали, когда что-то умерло».
Среда выполнения под этим живёт по тем же часам: containerd 1.7 покидает расширенную поддержку в сентябре 2026 года, а для Kubernetes 1.36 матрица поддержки проекта перечисляет только 2.3.0+ и 2.2.0+ — записи 1.x нет вовсе. переход с containerd 1.7 на 2.x — это переписывание конфигурации на версию 3, конверсия реестров, из-за которой не загружается CRI-плагин, и объяснение, почему целиться стоит только в 2.3.
Плоскость данных Service на тех же узлах живёт по своим часам: Kubernetes 1.37 объявил режим ipvs в kube-proxy устаревшим и спрятал его за feature gate, 1.40 выключает его по умолчанию, а 1.43 удаляет код. переход kube-proxy с IPVS на nftables — это порог ядра 5.13, поведение NodePort, которое меняется без предупреждения, и оставшийся kube-ipvs0, который поглощает трафик, если его никто не убрал.
Замечание на уровне релиза, потому что баланс 1.37 не такой, каким его описывают: пады действительно могут застрять в ContainerCreating из-за перехода SELinuxMount в GA, тогда как отказ на cgroup v1 появился в 1.35, ограничение статических подов - в 1.34, а обрыв с containerd ещё впереди, в 1.38. что действительно ломается при обновлении до Kubernetes 1.37 разделяет эти три колонки и даёт аудит, который нужно провести до обновления, а не после.
Источники
Сначала первоисточники: документация ядра описывает каждый упомянутый здесь интерфейсный файл, а собственные примечания к выпускам и предложения по улучшению самих проектов — единственное надёжное свидетельство того, что и когда было удалено. Там, где эта статья противоречит вторичным публикациям — в первую очередь по Kubernetes и Docker, — спор идёт с заголовком, а не с первоисточником.
- Linux kernel — Control Group v2: the normative document. Every interface file, default value and range quoted in this article was checked here, including the fact that memory.max defaults to "max" and cpu.max defaults to "max 100000"
- Control Group v2 — Memory interface files: memory.min, memory.low, memory.high and memory.max, and the sentence that going over memory.high never invokes the OOM killer. This is the four-tier model cgroup v1 did not have
- Control Group v2 — IO interface files: io.weight, io.max with its rbps/wbps/riops/wiops keys, io.latency and io.cost. The v2 io controller is also the first one that accounts for writeback correctly
- Control Group v2 — No Internal Process Constraint: non-root cgroups can only distribute resources to children when they hold no processes of their own. This single rule is what breaks hand-rolled v1 layouts on contact
- Control Group v2 — Delegation: the model that makes rootless containers possible, and the reason a delegated subtree must not be allowed to write its own resource-control files
- Linux kernel — Memory Resource Controller (cgroup v1): the source for what memory.limit_in_bytes and memory.memsw.limit_in_bytes actually meant, which is the only way to see how different memory.swap.max is
- cgroups(7) — the manual page, including the statement that there is no direct equivalent of the net_cls and net_prio controllers, and that iptables gained support for eBPF filters hooking on cgroup v2 pathnames instead
- BPF_PROG_TYPE_CGROUP_DEVICE — the eBPF program type that replaced the v1 devices controller: it receives major, minor, device type and access type, and returns allow or -EPERM
- systemd — NEWS: the upstream changelog and the authoritative statement of what happened in which release. The v258 section carries both the cgroup v1 removal and the kernel baseline bump quoted here
- systemd v258 release notes — "Support for cgroup v1 ('legacy' and 'hybrid' hierarchies) has been removed", and the bump of the minimum kernel baseline to v5.4 with v5.7 recommended
- systemd v256 release notes — the release that stopped booting cgroup v1 by default and introduced the SYSTEMD_CGROUP_ENABLE_LEGACY_FORCE=1 escape hatch that v258 then took away
- systemd.resource-control(5) — MemoryMax=, MemoryHigh=, MemoryLow=, MemoryMin=, MemorySwapMax=, CPUWeight=, CPUQuota=, IOWeight=, IOReadBandwidthMax= and TasksMax=: the directive names for every interface file in the conversion table
- systemctl(1) — set-property, and the fact that it applies changes immediately and stores them on disk for future boots unless --runtime is passed
- systemd-run(1) — --scope and --property=, the pair that lets you put a limit on a command you are about to run without writing a unit file first
- systemd-cgls(1) — recursively show control group contents: the fastest way to see the tree systemd actually built, as opposed to the one you think you configured
- systemd-cgtop(1) — top control groups by resource usage, which is the per-cgroup view that plain top cannot give you
- systemd — Control Group APIs and Delegation: upstream's own rules for who owns which part of the tree, and why writing into systemd's cgroups from outside systemd is a bug rather than a technique
- Kubernetes — About cgroup v2: the requirements (kernel 5.8 or later, containerd v1.4+, cri-o v1.20+, systemd cgroup driver), the stat -fc %T check, and the deprecation notice marking cgroup v1 deprecated as of v1.35
- KEP-5573, Remove cgroup v1 support — the document that says removal "will be done no earlier than 1.38". Worth reading before believing any headline that says Kubernetes has already removed it
- Kubernetes blog — New Conversion from cgroup v1 CPU Shares to v2 CPU Weight: why a container requesting 1 CPU ended up below the default weight on v2, and the replacement formula
- runc pull request 4785 — the dependency bump that pulls the new shares-to-weight conversion into runc. The conversion itself lives in the opencontainers/cgroups library, which is where the change reaches everyone regardless of orchestrator
- runc issue 4772 — the report behind that change: the linear conversion gave 1024 shares a weight of 39 against a default of 100, so containers lost CPU to everything not in a container
- Kubernetes blog — Autoconfiguration for Node Cgroup Driver Goes GA: the kubelet now asks the CRI runtime which cgroup driver it uses instead of trusting two files to agree
- Kubernetes — Understand PSI metrics: pressure stall information read from cpu.pressure, memory.pressure and io.pressure, which exist only under cgroup v2
- Kubernetes blog — Tiered Memory Protection with Memory QoS: the kubelet writing memory.high and, under memoryReservationPolicy, memory.min and memory.low. None of this has a cgroup v1 equivalent
- Docker — Runtime metrics: the cgroup v2 requirements (containerd v1.4+, kernel v4.15+ with v5.2+ recommended), the default driver being systemd on v2 and cgroupfs on v1, and the sentence that --oom-kill-disable is discarded on v2
- Docker Engine — Deprecated features: the table row recording that support for cgroup v1 was deprecated in Engine v29.0, with no removal version set, and that the kernel memory limit was removed back in v23.0
- moby issue 51111 — the proposal to deprecate cgroup v1 while maintaining it until the enterprise distributions that still need it reach end of life. This is why Docker's deadline is much later than systemd's
- Rootless Containers — cgroup v2: the systemd user-manager Delegate= drop-in that gives an unprivileged user real cpu, memory, io and pids limits, and the note that delegating cpuset needs systemd 244 or newer
- crun — the OCI runtime with native cgroup v2 support and the default on current Podman installations, which matters because runc reached v2 later and older builds handle it badly
- Red Hat Enterprise Linux 10 release notes — the release where systemd no longer supports booting in cgroup v1 mode at all, for readers whose deadline is an enterprise distribution rather than upstream
- Linux kernel — PSI, Pressure Stall Information: what the numbers in cpu.pressure, memory.pressure and io.pressure mean, and why "some" and "full" are different questions
Было полезно?