Перейти к содержимому
← Блог

cgroup v1 удалён. Переходите правильно.

systemd 258 вырезал cgroup v1 подчистую, и теперь ваши хосты грузятся в единую иерархию независимо от того, просил их об этом кто-нибудь или нет. Вот аудит, пофайловый перевод настроек и четыре утверждения об этой миграции, которые просто неверны.

·28 мин чтения
  • Linux
  • cgroups
  • systemd
  • Контейнеры

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

Обложка из трёх панелей. Левая панель с заголовком "cgroup v1 — своя иерархия на каждый контроллер" перечисляет шесть отдельных деревьев по контроллерам: memory, cpu,cpuacct, blkio, devices, net_cls и freezer, с подписью "процесс находится в каждом из них". Центральная колонка с заголовком "что меняет смысл" содержит шесть пар соответствий: memory.limit_in_bytes становится memory.max, cpu.cfs_quota_us вместе с периодом становятся cpu.max, blkio.weight становится io.weight, memory.memsw.limit становится ограничением только подкачки, а не памяти вместе с подкачкой, cpu.shares 1024 становится cpu.weight 100, а net_cls и devices заменяются на eBPF без всякого контроллера. Правая панель с заголовком "cgroup v2 — одна единая иерархия" показывает единое дерево, где процессы находятся только на листьях, и четыре уровня памяти: memory.min, memory.low, memory.high и memory.max.
Структурное изменение одной картинкой: отдельное дерево на каждый контроллер схлопывается в одно, процессы допускаются только на листьях, а память вырастает из единственного жёсткого лимита в четыре уровня с по-настоящему разным поведением.

Дальше — миграция в том виде, в каком она реально идёт на живых парках машин: как определить иерархию хоста и не попасться на гибридную раскладку, пофайловая таблица соответствий с директивами 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 в значении по умолчанию trueKubernetes
--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Июнь 2024257 всё ещё уважает этот флаг, так что 257 — последний выпуск, где можно попросить v1 и получить его
systemd 258Поддержка cgroup v1 удалена полностью, вместе с аварийным рычагом; планка ядра поднята до 5.4, рекомендуется 5.7Сентябрь 2025Вот он, дедлайн. Единая иерархия — единственный режим в коде
Kubernetes 1.35cgroup v1 объявлен устаревшим; kubelet по умолчанию не стартует на узле с v1Декабрь 2025Переключается через failCgroupV1: false. Это не удаление
Kubernetes 1.38+Самый ранний выпуск, в котором KEP-5573 удалит кодНе запланированоВремени у вас больше, чем следует из заголовков
Docker Engine 29.0cgroup 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 v1cgroup v2Директива systemdПримечание
memory.limit_in_bytesmemory.maxMemoryMax=Переименование. Смысл тот же: жёсткий лимит, OOM-kill при превышении
memory.soft_limit_in_bytesmemory.highMemoryHigh=Улучшение. Мягкий лимит в основном игнорировался; memory.high реально тормозит
memory.low / memory.minMemoryLow= / MemoryMin=Новое. Пороги защиты, аналога в v1 нет
memory.memsw.limit_in_bytesmemory.swap.maxMemorySwapMax=Другой смысл. memsw — это память+подкачка; здесь только подкачка
memory.usage_in_bytesmemory.currentПереименование
memory.failcntmemory.eventsЛучше: отдельные счётчики для low, high, max, oom и oom_kill
cpu.sharescpu.weightCPUWeight=Другая шкала. Умолчание 1024 становится умолчанием 100; см. пересчёт
cpu.cfs_quota_us + cpu.cfs_period_uscpu.maxCPUQuota= + CPUQuotaPeriodSec=Два файла становятся одним, запись вида "$MAX $PERIOD". CPUQuota= задаёт только половину — квоту
cpuacct.usagecpu.statТеперь включает nr_throttled и throttled_usec
blkio.weightio.weightIOWeight=Переименование, но учёт под ним наконец корректный
blkio.throttle.*_bps_deviceio.maxIOReadBandwidthMax= и т. д.Один файл с вложенными ключами вместо четырёх; теперь считает и буферизованные записи
pids.maxpids.maxTasksMax=Без изменений
freezer.statecgroup.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_killmemory.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 crun

Rootless-контейнеры — единственное место, где 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
Инструменты, пишущие в контрольные группы, принадлежащие systemdsystemd молча навязывает свои настройки при любом изменении юнитаsystemctl set-property или drop-in-файл
Маркировка трафика на основе net_clsКонтроллер удалён, файла-замены нетeBPF, привязанный к пути cgroup, с матчингом из nftables
Белые списки devices.allowКонтроллер заменён типом программы eBPFDeviceAllow= в юните или 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, пока хосты не уехали у вас из-под ног
Аплайнс или вендорский агент, который вы не меняетеГрафик вендора, а он не вашПолучить ответ письменно и изолировать хост, если ответ не устраивает
  1. Сначала аудит, потом планы. Прогоните скрипт по парку на каждом хосте и сохраните вывод. Вы ищете две вещи: хосты, всё ещё сидящие на v1 или гибриде, и захардкоженные пути v1 в /etc, /opt и /usr/local. Второй список почти всегда длиннее первого и является настоящей работой.
  2. Чините инструменты первыми, пока обе иерархии ещё существуют. Всё, что читает /sys/fs/cgroup, должно уметь обе раскладки — или читать значение через systemctl show. Сделав это до переезда хостов, вы сможете проверить исправление на том, ради чего оно и написано.
  3. Переводите лимиты по смыслу, а не по названию. Две строки таблицы соответствий меняют поведение: подкачка, где memory.swap.max — это подкачка отдельно, и вес CPU, чья шкала отличается от шкалы долей больше чем в десять раз. Все остальные строки — переименования.
  4. Двигайте рантаймы и хосты в одном окне. Параметр ядра, драйвер Docker, SystemdCgroup в containerd, cgroupDriver у kubelet — все четыре, одна перезагрузка, потом проверка и только затем следующая партия.
  5. Заберите выигрыш. Подключите 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, — спор идёт с заголовком, а не с первоисточником.

  1. 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"
  2. 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
  3. 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
  4. 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
  5. 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
  6. 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
  7. 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
  8. 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
  9. 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
  10. 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
  11. 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
  12. 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
  13. systemctl(1) — set-property, and the fact that it applies changes immediately and stores them on disk for future boots unless --runtime is passed
  14. 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
  15. 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
  16. systemd-cgtop(1) — top control groups by resource usage, which is the per-cgroup view that plain top cannot give you
  17. 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
  18. 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
  19. 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
  20. 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
  21. 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
  22. 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
  23. 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
  24. Kubernetes — Understand PSI metrics: pressure stall information read from cpu.pressure, memory.pressure and io.pressure, which exist only under cgroup v2
  25. 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
  26. 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
  27. 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
  28. 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
  29. 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
  30. 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
  31. 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
  32. 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

Было полезно?