Найти в Дзене
File Energy

Точное ограничение памяти процессов через memory cgroups в Linux

Сервис падает ночью, когда никто не смотрит. Утром в логах OOMKilled, перезапуск, всё снова работает. Следующую ночь история повторяется. Казалось бы, дай процессу больше памяти, и проблема решена. Но это не решение, а капитуляция. Правильный ответ лежит глубже: в понимании того, как ядро Linux реально ограничивает память, как оно считает то, что считает, и почему "жёсткий лимит" ведёт себя иначе, чем следует из названия. Memory cgroups, контрольные группы памяти, существуют в Linux с 2008 года. За это время механизм прошёл через капитальный ремонт: cgroups v1 с её фрагментированными иерархиями уступила место cgroups v2 с единым деревом управления, стабилизированным в ядре 4.15 и ставшим стандартом де-факто начиная с серии 5.x. Kubernetes с версии 1.25 перешёл на v2 как на основную платформу, а с версии 1.31 поддержка v1 официально помечена как устаревшая. Это не просто смена интерфейса. Это принципиально иная модель учёта памяти, и разобраться с ней стоит один раз, чтобы потом не гада
Оглавление

Сервис падает ночью, когда никто не смотрит. Утром в логах OOMKilled, перезапуск, всё снова работает. Следующую ночь история повторяется. Казалось бы, дай процессу больше памяти, и проблема решена. Но это не решение, а капитуляция. Правильный ответ лежит глубже: в понимании того, как ядро Linux реально ограничивает память, как оно считает то, что считает, и почему "жёсткий лимит" ведёт себя иначе, чем следует из названия.

Memory cgroups, контрольные группы памяти, существуют в Linux с 2008 года. За это время механизм прошёл через капитальный ремонт: cgroups v1 с её фрагментированными иерархиями уступила место cgroups v2 с единым деревом управления, стабилизированным в ядре 4.15 и ставшим стандартом де-факто начиная с серии 5.x. Kubernetes с версии 1.25 перешёл на v2 как на основную платформу, а с версии 1.31 поддержка v1 официально помечена как устаревшая. Это не просто смена интерфейса. Это принципиально иная модель учёта памяти, и разобраться с ней стоит один раз, чтобы потом не гадать над поведением системы.

Как ядро считает память: страницы, LRU и всё что не RSS

Прежде чем управлять лимитами, важно понять, что именно измеряется. Распространённое заблуждение состоит в том, что memory cgroup ограничивает RSS, то есть объём физически занятых страниц анонимной памяти процесса. На самом деле учитывается значительно больше.

В cgroups v2 контроллер памяти отслеживает несколько категорий. Во-первых, пользовательская память: анонимные страницы (heap, stack, mmap без файла) и page cache, то есть закэшированные файловые данные. Во-вторых, ядерные структуры данных, которые были выделены в контексте процессов этой группы: dentries, inodes, слябовый аллокатор. В-третьих, буферы TCP-сокетов. Всё это суммируется в memory.current, и именно это число сравнивается с лимитами.

Особого внимания заслуживает page cache. Когда процесс читает файл, ядро кэширует прочитанные страницы. Эти страницы засчитываются в счётчик cgroup, которая первой к ним обратилась, по принципу "first-touch". Это означает, что процесс, активно читающий большие файлы, может "выбрать" весь лимит page cache, даже если реальное потребление анонимной памяти невелико. На практике это часто удивляет: сервис с небольшим heap неожиданно упирается в лимит из-за кэширования файлов конфигурации или журналов.

Проверить текущий расклад можно через memory.stat:

# Полная разбивка по типам памяти в cgroup
cat /sys/fs/cgroup/myapp/memory.stat

# Ключевые поля:
# anon — анонимные страницы (heap, stack, mmap)
# file — файловый page cache
# kernel — память ядерных структур
# sock — буферы сокетов
# shmem — разделяемая память
# pgfault — количество page fault (мягких)
# pgmajfault — количество major fault (обращения к диску)

Разница между pgfault и pgmajfault рассказывает многое о поведении процесса. Большое число major fault при высокой нагрузке означает, что страницы вытесняются на диск и потом подгружаются обратно, то есть процесс живёт в постоянном трэшинге.

Четыре файла лимитов и что за ними стоит

Cgroups v2 предоставляет четыре управляющих файла для памяти, и каждый из них имеет строго своё назначение. Понять разницу между ними критически важно, потому что неправильный выбор порождает либо ложную защиту, либо избыточные OOM-убийства.

memory.min устанавливает жёсткую гарантию: ядро не будет изымать страницы у этой cgroup при системном давлении памяти, пока использование не упадёт ниже этого порога. Это защита критических сервисов от вытеснения при нехватке памяти на узле. Kubernetes использует это значение для подов класса Guaranteed, приравнивая его к resources.requests.memory.

memory.low работает похожим образом, но мягче. При системном давлении ядро старается не трогать эту cgroup, однако при крайней нехватке ресурсов всё же может изъять страницы. Это "хорошая воля" ядра, а не контракт.

memory.high это пороговое значение троттлинга. Когда потребление cgroup превышает его, ядро начинает агрессивно рекламировать страницы и замедлять выделение памяти процессам группы. Когда процесс внутри cgroup пытается выделить больше памяти, его выполнение приостанавливается, пока ядро освобождает пространство. Для приложения это выглядит как высокая задержка, зависания или остановки. Важно: memory.high не убивает процессы. Это именно замедление, и сервис продолжает работать, просто медленнее. Приложение может работать плохо задолго до того, как оно будет убито OOM-киллером.

memory.max это твёрдая крыша. Если потребление памяти cgroup достигает этого лимита и не может быть снижено, в группе вызывается OOM-киллер. Процесс будет убит. Именно это поведение стоит за статусом OOMKilled в Kubernetes.

Практическая конфигурация с комментариями:

# Создать новую cgroup для сервиса
mkdir /sys/fs/cgroup/myapp

# Включить контроллеры памяти
echo "+memory" > /sys/fs/cgroup/cgroup.subtree_control

# Гарантировать минимум памяти (ядро не вытеснит ниже этой границы)
echo "256M" > /sys/fs/cgroup/myapp/memory.min

# Порог троттлинга — выше начинается замедление
echo "512M" > /sys/fs/cgroup/myapp/memory.high

# Жёсткий лимит — OOM-киллер при превышении
echo "768M" > /sys/fs/cgroup/myapp/memory.max

# Поместить процесс в cgroup
echo $$ > /sys/fs/cgroup/myapp/cgroup.procs

Трэшинг как побочный эффект неправильных лимитов

Установить memory.high и думать, что всё под контролем, опасно. Поскольку memory.high в определённой степени носит рекомендательный характер и не гарантирует, что cgroup никогда не превысит этот порог, на практике чаще используют memory.max. Но и здесь есть тонкость.

Когда лимит памяти установлен слишком низко относительно реального рабочего набора данных процесса, система попадает в петлю: ядро вытесняет страницы на своп или выбрасывает page cache, процесс тут же запрашивает их снова, ядро снова вытесняет. Это классический трэшинг, при котором процессор занят не полезной работой, а бесконечным перемещением страниц между оперативной памятью и диском. В метриках это видно как устойчиво высокий pgmajfault.

Зазор между memory.high и memory.max является возможностью для трэшинга через "интенсивное давление рекламирования". Чем больше разрыв между этими двумя значениями, тем дольше система может находиться в состоянии медленного вытеснения, прежде чем OOM-киллер наконец вмешается и разрешит ситуацию. Иногда быстрая смерть предпочтительнее медленной агонии.

Чтобы заранее оценить реальный рабочий набор, удобно читать memory.current под нагрузкой, а не в покое. Ядро при отсутствии давления "придерживает" страницы. Истинный рабочий набор виден только тогда, когда система вынуждена что-то вытеснять:

# Текущее потребление памяти cgroup (в байтах)
cat /sys/fs/cgroup/myapp/memory.current

# История событий давления: hits по memory.high и OOM
cat /sys/fs/cgroup/myapp/memory.events

# Ключевые счётчики memory.events:
# high — сколько раз превышался memory.high
# max — сколько раз достигался memory.max
# oom — сколько раз срабатывал OOM
# oom_kill — сколько процессов было убито OOM-киллером

Если high в memory.events растёт непрерывно, сервис работает в режиме постоянного троттлинга. Это не аварийная ситуация, но производительность деградирована. Либо лимит нужно поднять, либо приложение нужно переработать.

PSI как диагностический инструмент нового поколения

Одним из принципиальных улучшений cgroups v2 стал механизм PSI, Pressure Stall Information. До его появления понять, испытывает ли cgroup нехватку памяти или нет, было непросто: нужно было косвенно судить по задержкам и счётчикам page fault. PSI добавил прямой измерительный инструмент.

PSI показывает, какой процент времени задачи в cgroup были заблокированы в ожидании памяти. Значение some означает, что хотя бы одна задача стояла в очереди. Значение full означает, что все задачи группы были заблокированы одновременно, то есть полезная работа полностью остановилась:

# Метрики давления памяти для cgroup
cat /sys/fs/cgroup/myapp/memory.pressure

# Пример вывода:
# some avg10=2.50 avg60=1.20 avg300=0.80 total=184218
# full avg10=0.00 avg60=0.00 avg300=0.00 total=0
#
# avg10/avg60/avg300 — среднее за 10с / 60с / 300с (в процентах)
# total — суммарное время в микросекундах

Значение some avg10=2.50 означает, что за последние 10 секунд 2,5% времени хотя бы одна задача ждала памяти. Для большинства продуктивных сервисов приемлемый порог some лежит ниже 5%. Если full начинает расти, это критический сигнал.

PSI позволяет строить триггеры на стороне приложения. Через интерфейс cgroup.events или через прямое чтение memory.pressure можно реагировать на давление заранее: сбрасывать внутренние кэши, снижать параллелизм, освобождать буферы, до того как ядро начнёт убивать процессы.

Управление через systemd и учёт свопа

На большинстве современных дистрибутивов прямое редактирование файлов в /sys/fs/cgroup используется редко: системные сервисы живут внутри иерархии systemd, которая сама управляет cgroup-деревом. Для служб, запускаемых через systemd, лимиты памяти задаются в unit-файлах:

[Service]
# Мягкая гарантия — ядро не вытеснит ниже этой границы
MemoryMin=256M

# Порог троттлинга
MemoryHigh=512M

# Жёсткий лимит
MemoryMax=768M

# Лимит на своп (0 — своп запрещён для этого сервиса)
MemorySwapMax=0

ExecStart=/usr/bin/myservice

Параметр MemorySwapMax=0 заслуживает отдельного внимания. Отключение свопа для основной рабочей нагрузки дало ряд преимуществ: более постепенный режим отказа при нехватке памяти и дополнительное пространство для инструментов мониторинга, таких как oomd. Когда своп запрещён, процесс при нехватке памяти получает OOM немедленно, а не уходит в медленное вытеснение на диск. Для задержкочувствительных сервисов это зачастую предпочтительно.

В cgroups v2 учёт свопа стал отдельным и прозрачным. Файл memory.swap.current показывает, сколько памяти сервис сбросил на своп, а memory.swap.max ограничивает это значение независимо от memory.max. Комбинированный счётчик memory+swap из cgroups v1 заменён реальным раздельным контролем над свапом. Это упрощает анализ: теперь сразу видно, живёт ли процесс в оперативной памяти или частично вытеснен.

Иерархия лимитов и защита от переопределения

Важное свойство cgroups v2, которое v1 реализовывала непоследовательно: ограничения в иерархии строго наследуются сверху вниз, и дочерняя группа не может превысить лимит родительской. Ограничения, установленные ближе к корню иерархии, не могут быть переопределены дочерними группами.

Это означает, что если на уровне системного среза system.slice установлен лимит 4 Гбайт, то сумма фактических потреблений всех сервисов внутри этого среза не может превысить 4 Гбайт, вне зависимости от того, что написано в unit-файлах отдельных сервисов. Это делает иерархическое планирование ресурсов предсказуемым: оператор кластера задаёт верхний предел, а оркестратор раздаёт квоты внутри него, не рискуя случайно выйти за физические границы.

Убедиться в том, что система работает на cgroups v2, и увидеть полную структуру иерархии можно так:

# Проверить версию cgroups
cat /sys/fs/cgroup/cgroup.controllers

# Если файл существует — система на cgroups v2
# Пример вывода:
# cpuset cpu io memory hugetlb pids rdma misc

# Посмотреть структуру иерархии cgroup для конкретного процесса
cat /proc/<PID>/cgroup

# Пример вывода для cgroups v2:
# 0::/system.slice/myservice.service

Понимание memory cgroups превращает управление памятью из гадания в инженерию. Разница между memory.high и memory.max, природа трэшинга при заниженных лимитах, PSI как ранний индикатор давления, правила учёта page cache, всё это складывается в цельную картину. OOMKilled в логах перестаёт быть загадочным приговором и становится диагнозом с конкретным лечением.

https://fileenergy.com/linux