Привет, Хабр!

С лимитом памяти в контейнере все привыкли обращаться одинаково. Процесс дорос до limits.memory, ядро его убило, в kubectl describe появился OOMKilled. Через пару секунд контейнер уже поднялся заново. Приятного мало, зато понятно, что случилось.

В Linux с cgroup v2 у памяти есть второй порог, memory.high, и на нём ядро никого не убивает. Процесс, который за него вышел, отправляют на принудительное освобождение памяти, а если освобождать нечего, усыпляют.

В systemd этот порог давно живёт под именем MemoryHigh=. А 26 августа вышел Kubernetes 1.37, в котором Memory QoS стал бетой и включён по умолчанию. Сам по себе memory.high он пока не ставит. Для этого хватает одной строки memoryThrottlingFactor: 0.9 в конфиге kubelet, и её приводят в примерах настройки.

Обещание звучит интересно: контейнер не падает, а притормаживает и получает шанс прийти в себя.

Я прогнал это обещание на ядре 6.8, сначала на голой cgroup с одним memory.high без жёсткого потолка. 16 МБ, которые без порога выделяются за 70–110 мс, за порогом выделялись больше 2 минут. Соседний процесс в той же группе отвечал за 4 мс, а через 40 секунд стал отвечать за 1,4 секунды.

И никто не умер.

Два порога и один убийца

memory.max работает так, как от лимита и ждут. Группа упёрлась в потолок, ядро пытается отобрать у неё страницы, не смогло — вызывает OOM killer внутри группы.

С memory.high договорились иначе. Документация ядра описывает его как порог, за которым процессы группы тормозят и попадают под сильное давление освобождения памяти. И отдельно проговаривается, что переход за этот порог никогда не вызывает OOM killer. Там же сказано, для чего он придуман: для схем, где внешний процесс следит за группой и снимает с неё давление.

Внешний процесс в этой картине вполне конкретный. В systemd это systemd-oomd. Он смотрит на долю времени, когда все задачи группы одновременно ждали памяти. Для юнитов с ManagedOOMMemoryPressure=kill он по умолчанию начинает действовать, когда эта доля держится выше 60% дольше 30 секунд.

На практике порог так и собирают в связку:

  • мягкий лимит;

  • swap, куда есть что вытеснить;

  • сторож, который убьёт задыхающуюся группу.

С формулировками вокруг порога вышла отдельная история. До ядра 6.3 описание memory.high начиналось словами «основной механизм управления памятью cgroup», а memory.max называли последней линией защиты.

В 6.4 описание поменяли местами: основным механизмом стал memory.max, а к memory.high дописали оговорку про внешний процесс. Документация systemd по‑прежнему называет MemoryHigh= основным механизмом управления памятью юнита. А описание Memory QoS в Kubernetes до сих пор цитирует старый вариант, где memory.max — «final protection mechanism».

В Docker с ним почти не сталкиваются. --memory-reservation на cgroup v2 runc записывает в memory.low. Это защита от вытеснения, а не порог торможения.

Встречается он чаще всего в двух местах:

  • В systemd‑юнитах, где MemoryHigh= ставят как «мягкий лимит» для прожорливого сервиса.

  • В Kubernetes с Memory QoS.

У systemd сторож хотя бы есть. Kubelet по memory.high никого не убивает, а swap на нодах по умолчанию выключен.

16 МБ за 2 минуты

Для проверки хватает программы, которая растит анонимную память по мегабайту и отчитывается каждые 16 МБ:

static double now(){struct timespec t;clock_gettime(CLOCK_MONOTONIC,&t);return t.tv_sec+t.tv_nsec/1e9;}
int main(int argc,char**argv){
  long total=atol(argv[1]); double ts=now();
  setvbuf(stdout,NULL,_IOLBF,0);
  for(long mb=1;mb<=total;mb++){
    char*p=malloc(1<<20); memset(p,1,1<<20);
    if(mb%16==0){double t=now(); printf("%5ld МБ  шаг %8.3f с\n",mb,t-ts); ts=t;}
  }
}

Запускаем её в трёх группах:

  1. Без ограничений.

  2. С memory.max=128M.

  3. С memory.high=128M без жёсткого потолка.

Группа создаётся руками, без всяких контейнерных рантаймов:

mkdir /sys/fs/cgroup/soft
echo 128M > /sys/fs/cgroup/soft/memory.high
sh -c 'echo $$ > /sys/fs/cgroup/soft/cgroup.procs; exec timeout 300 hog 256'
cat /sys/fs/cgroup/soft/memory.events /sys/fs/cgroup/soft/memory.pressure

Первые два прогона ведут себя предсказуемо.

Без ограничений 256 МБ выделяются за 1,3 секунды. С memory.max последний отчёт приходит на 112 МБ, до 128 процесс не доживает: SIGKILL. В memory.events появляется oom_kill 1.

Третий прогон выглядит так:

  112 МБ  шаг    0.098 с
  128 МБ  шаг    0.071 с
  144 МБ  шаг   28.854 с
  160 МБ  шаг  124.223 с
  176 МБ  шаг  131.072 с
Terminated
high 3407     oom_kill 0
full avg10=99.31 avg60=96.55 avg300=61.37

До порога шаг в 16 МБ занимает 70 мс. Первые 16 МБ за порогом заняли полминуты, следующие — больше двух минут.

Через 5 минут процесс так и не набрал 200 МБ. Завершил его не OOM killer, а мой timeout.

Строка full avg10=99.31 значит, что последние 10 секунд все задачи группы 99% времени ждали памяти. Процессор простаивает: процесс не считает, а спит.

Откуда берутся 8 секунд на мегабайт

Ответ лежит в mm/memcontrol.c.

Память группе начисляется порциями по MEMCG_CHARGE_BATCH — это 64 страницы, или 256 КБ. Если после начисления группа оказалась выше memory.high, ядро помечает задачу. Перед возвратом в пользовательский режим она идёт освобождать память.

Если освободить не вышло, ей назначают штраф:

penalty_jiffies = max_overage * max_overage * HZ;
penalty_jiffies >>= MEMCG_DELAY_PRECISION_SHIFT;
penalty_jiffies >>= MEMCG_DELAY_SCALING_SHIFT;
/* ... */
penalty_jiffies = min(penalty_jiffies, MEMCG_MAX_HIGH_DELAY_JIFFIES); /* 2 * HZ */
/* ... */
psi_memstall_enter(&pflags);
schedule_timeout_killable(penalty_jiffies);

Штраф растёт с квадратом относительного перерасхода, пропорционален числу набранных страниц и ограничен 2 секундами на один возврат в пользовательский режим.

Когда программа набирает память обычными страничными отказами, такой возврат случается на каждую порцию, и выходит до 2 секунд на порцию.

В комментарии над этим кодом авторы приводят таблицу для порога в 100 МБ:

Потребление памяти

Время выделения

101 МБ

6 мс

110 МБ

639 мс

118 МБ и дальше

2000 мс

Мегабайт — это 4 порции, 4 порции по 2 секунды дают 8 секунд. У нас вышла 131 секунда на 16 МБ. Это 8,2 секунды на мегабайт, оставшиеся 3 секунды, судя по всему, уходят на попытки освобождения.

Почему ядро не освобождает память вместо сна, понятно из того, что лежит в группе.

Анонимные страницы без swap вытеснять некуда. Это куча программы, и кроме самой программы она никому не нужна. Попытки освобождения возвращают ноль, ядро повторяет их ещё 16 раз с тем же результатом и укладывает процесс спать.

Знать, что процесс тормозит, и понимать, почему ядро ведёт себя именно так, — разные вещи. Проверить свои знания и найти пробелы можно во вступительном тесте.

Где он спит, видно в /proc:

$ grep State /proc/$PID/status; cat /proc/$PID/wchan
State:  D (disk sleep)
mem_cgroup_handle_over_high

Состояние D, как у процесса, который ждёт диск. Load average от таких процессов растёт при пустом процессоре.

Сон убиваемый, schedule_timeout_killable. Обычные сигналы его не прерывают. SIGKILL прерывает сразу, и kill -9 срабатывает мгновенно.

С остановкой контейнера сложнее. Сначала приходит SIGTERM. Если приложение его перехватывает, обработчику придётся платить штраф за каждую новую порцию памяти, и остановка может дотянуть до SIGKILL по истечении grace period.

Сосед по контейнеру платит за чужую утечку

Перерасход считается на всю группу. А штраф платит тот, кто в этот момент попросил новую порцию.

Рядом с растущим процессом запускаем «сервис», который раз в 200 мс берёт у ядра свежий буфер на 256 КБ, заполняет его и отдаёт обратно, как сделал бы обработчик запроса:

for (int i = 1;; i++) {
    double t = now();
    char *p = mmap(0, 256 << 10, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
    memset(p, 1, 256 << 10);
    munmap(p, 256 << 10);
    printf("t=%6.1f с  запрос %3d  %8.1f мс\n", now() - t0, i, (now() - t) * 1000);
    usleep(200000);
}

Запросы до и после того, как в группу пришёл сосед:

t=   2.9 с  запрос  15       3.8 мс
t=   4.0 с  запрос  20      35.9 мс
t=   8.0 с  запрос  30     333.3 мс
t=  16.7 с  запрос  42     691.3 мс
t=  26.2 с  запрос  51    1017.2 мс
t=  41.9 с  запрос  62    1398.1 мс

На третьей секунде в группу пришёл растущий процесс, и через секунду сервис это почувствовал. Сам он не вырос ни на байт. Зато каждый его запрос теперь покупает 256 КБ по текущему курсу, а курс растёт вместе с перерасходом соседа.

Пропускная способность падает с 5 запросов в секунду меньше чем до одного.

Под удар попадает любой процесс, которому ядро начисляет новые страницы в эту группу:

  • новый буфер;

  • рост кучи;

  • новый поток со своим стеком;

  • страничный кэш при чтении файла;

  • сетевые буферы.

Процесс, который работает на уже полученной памяти, напрямую не платит. Но и его может задеть: освобождая память, ядро выбрасывает из группы файловые страницы, включая код самих программ, и их повторная подгрузка снова начисляется группе.

Новые процессы в группе тоже начинают с чистого листа.

Короткую команду, похожую на exec‑пробу, я запускал в той же группе по ходу эксперимента:

Перерасход памяти

Время выполнения

7%

80 мс

14%

70 мс

20%

2,1 секунды

Короткий процесс платит, только если его начисление открыло новую порцию, а не ушло из запаса, который ядро держит на каждом процессоре. Если открыло, при 20% это один полный штраф.

У liveness‑пробы в Kubernetes timeoutSeconds по умолчанию равен 1.

Kubernetes, к слову, обычно ставит memory.high на каждый контейнер, а не на под целиком. Сайдкар в соседнем контейнере штраф не платит.

Исключение — поды с ресурсами на уровне пода в spec.resources: для них kubelet 1.37 пишет порог и в cgroup пода, и тормозят все контейнеры сразу.

В Kubernetes под всё‑таки умрёт, только не сразу

Если у контейнера задан limits.memory, порог у kubelet не висит в воздухе. Рядом стоит memory.max, равный лимиту.

memory.high для Burstable‑контейнеров считается так:

requests.memory + memoryThrottlingFactor * (limits.memory - requests.memory)

С округлением вниз до страницы.

Контейнерам без лимита (Burstable без limits.memory и всем BestEffort) вместо лимита подставляется allocatable‑память ноды, и жёсткого потолка на уровне контейнера у них нет.

Guaranteed‑подам порог не ставится вовсе: у них запрос равен лимиту.

Контейнер с маленьким запросом, скажем 64Mi при лимите 1Gi, с фактором 0.9 получает порог в 928Mi, чуть выше 90% лимита. Перерасход до OOM доходит примерно до 10%.

По таблице из ядра это около 0,7 секунды на порцию у самого потолка. При запросе, близком к нулю, будет 11% и 0,8 секунды. Чем ближе запрос к лимиту, тем уже зазор и тем быстрее всё кончается.

Ставим memory.max и memory.high так, как это сделал бы kubelet для контейнера с лимитом и явно нулевым запросом.

Если запрос просто не указать, Kubernetes скопирует в него лимит, и порога не будет вовсе:

L=$((1024 * 1048576))
echo $L > memory.max
echo $(( L * 9 / 10 / 4096 * 4096 )) > memory.high   # 0.9 лимита с округлением до страницы

И засекаем, сколько живёт процесс, которому нужно вдвое больше лимита:

limits.memory   128Mi    убит через  17 с
limits.memory   256Mi    убит через  32 с
limits.memory   512Mi    убит через  60 с
limits.memory  1024Mi    убит через 122 с

Без memory.high каждый из четырёх умер бы, как только дотянулся до лимита.

С ним время до OOM растёт линейно, около 2 минут на каждый гибибайт лимита: перерасход считается в процентах, а число порций в зазоре между 90% и 100% пропорционально размеру лимита.

По прямой для контейнера с лимитом 8 ГиБ выходит больше четверти часа.

Всё это время под Running, OOMKilled в статусе нет, а каждое выделение новой памяти внутри него стоит до 0,8 секунды сна.

Рестарт может прийти с другой стороны. Liveness‑проба с timeoutSeconds: 1 начнёт падать, и kubelet перезапустит контейнер по пробе, а не по OOM.

Медленная утечка в такой схеме ещё неприятнее. Пока перерасход в пределах пары процентов, штраф по той же таблице измеряется миллисекундами.

Сервис не падает, а тормозит понемногу: сначала у него растёт p99, потом всё остальное.

Ночной OOMKilled с рестартом, который годами прятал утечку, приходит теперь сильно позже. Первой приходит жалоба на задержки, и искать её начинают в базе и в сети.

Бета, которую отменили из‑за зависаний

У Memory QoS длинная история.

  • В 1.22 появилась альфа.

  • В 1.27 поменяли формулу и подняли фактор по умолчанию с 0.8 до 0.9.

  • На 1.28 планировали бету, но отменили.

Причина записана в описании фичи. На проверке процесс, который выделяет память быстрее, чем ядро успевает её освобождать, упирался в memory.high и зависал вместо того, чтобы дойти до memory.max и умереть от OOM.

Исправлением там назван коммит из ядра 5.9 под заголовком «reclaim more aggressively before high allocator throttling».

Он заставляет ядро повторять освобождение памяти, пока оно приносит результат, и только потом усыплять процесс.

Для группы с файловым кэшем это заметная разница. Кэш можно выбросить, и лишнего сна не будет.

Для анонимной памяти без swap повторы ничего не дают, каждая попытка возвращает ноль. Сам штраф с потолком в 2 секунды появился раньше, в 5.4, и никуда не делся.

Все цифры выше сняты на 6.8 — на ядре, где «исправление» давно есть.

По сути обе стороны правы.

Ядро обещает, что процесс не зависнет навсегда, и он не зависает: 2 секунды сна, порция памяти, снова сон.

Kubernetes обещает, что контейнер дойдёт до memory.max, и он доходит.

Между этими обещаниями помещаются 2 минуты на каждый гибибайт лимита и сервис, у которого каждая новая порция памяти стоит до 0,8 секунды сна.

В 1.36 фичу выпустили третьей альфой и добавили в kubelet предупреждение для ядер до 5.9.

В 1.37 она стала бетой и включилась по умолчанию. Но memoryThrottlingFactor по умолчанию теперь null, и kubelet не пишет memory.high, пока значение не задано явно.

Авторы поста про бету объясняют это тем, что обновление не должно менять поведение работающих кластеров.

Торможение включается тремя строками:

apiVersion: kubelet.config.k8s.io/v1beta1
kind: KubeletConfiguration
memoryThrottlingFactor: 0.9

Если фактор уже был записан в конфиге kubelet, при обновлении до 1.37 он сохраняется, и торможение продолжает работать как раньше.

Фраза «в 1.37 безопасное умолчание» верна только для конфигов, где этой строки нет.

Где порог работает как задумано

Чтобы не создалось впечатление, что memory.high сломан, посмотрим на тот случай, под который его делали.

Читаем файл на 512 МБ через dd при пустом кэше, сначала в группе без порога, потом с memory.high=128M:

без порога        2,1–2,7 с    кэш группы 513 МБ    high 0
memory.high=128M  3,2–3,3 с    кэш группы 126 МБ    high 774

Порог сработал 774 раза, а замедление вышло примерно на секунду. Кэш прочитанного файла ядро спокойно выбрасывает, и группа держится на 126 МБ.

Со swap картина похожая. Тот же растущий процесс с порогом 128 МБ ведёт себя так:

на 8-й секунде    в памяти 127 МБ    в swap 163 МБ
384 МБ выделены за 11.88 с

На сон это не похоже: лишнее уходит в swap, и процесс движется со скоростью диска. Медленнее, чем без ограничений. Но процесс живёт.

В Kubernetes swap на нодах стабилен с 1.34. Но по умолчанию swapBehavior равен NoSwap, и поды его не видят.

Чтобы memory.high вытеснял, а не усыплял, ноде нужен LimitedSwap.

Помогает это только Burstable‑подам: Guaranteed и BestEffort в этом режиме swap не получают, хотя BestEffort‑контейнеры порог получают.

В описании Memory QoS вытеснение в swap вместо торможения записано как ожидаемое поведение.

Как отличить сон от тормозов

Снаружи такой контейнер похож на обычный медленный.

Процессор он почти не ест, и троттлинг по CPU ни при чём. Рестартов нет, OOMKilled нет. Потребление памяти стоит плато чуть выше memory.high.

Видно происходящее в двух файлах его cgroup и в списке процессов:

cat /sys/fs/cgroup/kubepods.slice/.../memory.events | grep -E '^(high|max|oom_kill) '
cat /sys/fs/cgroup/kubepods.slice/.../memory.pressure
ps -eo pid,stat,wchan:32,comm | grep over_high

Сам по себе счётчик high растёт и тогда, когда ядро спокойно выбрасывает кэш.

Контейнер платит штраф, если вместе с ним держится full avg10 выше нескольких процентов, а oom_kill остаётся нулём.

В описании Memory QoS порогом заметного торможения названо full avg10 > 5, в нашем прогоне было 99.

PSI‑метрики kubelet отдаёт стабильно с 1.36, а для счётчика high в описании фичи указана метрика container_memory_events_high_total.

В документации cAdvisor её пока нет, так что перед алертом стоит проверить, что ваш kubelet её отдаёт.

Алерт лучше завести до того, как кто‑то включит фактор.

Если включать memoryThrottlingFactor, то на нодах со swap и для подов, у которых память в основном файловый кэш: там у ядра есть что отобрать, и порог делает то, ради чего придуман.

Сервису, который растит кучу на ноде без swap, он ничего не даёт, кроме нескольких минут сна перед тем же OOMKilled.

На таких нодах лучше оставить умолчание из 1.37 — null.

Когда контейнер не падает, но задержки растут, а CPU почти простаивает, искать причину только в коде приложения бесполезно. Нужно понимать, что происходит на уровне контейнерного runtime и как ограничения ресурсов влияют на сервисы под нагрузкой.

Разобраться в смежных механизмах и инструментах можно на бесплатных открытых уроках:

  • 2 ноября, 19:00. «Оптимизация Nginx и Angie под высокие нагрузки» Записаться.

  • 10 ноября, 20:00. «CRI — обзор контейнерных runtime» Записаться.

Больше открытых уроков по Kubernetes, Linux, администрированию и другим IT-направлениям ищите в календаре мероприятий OTUS

Комментарии (0)