В этой статье мы рассмотрим память внутри контейнера Kubernetes. Какие есть основные типы памяти, как они управляются и какие коварные моменты с ними связаны. В этой статье вы узнаете ответы на интересные вопросы:

  • Какие метрики памяти считаются неправильно?

  • Сколько раз надо прочитать файл, чтобы он хорошо закешировался?

  • Какую память учитывает Out-of-memory killer?

И так, поехали. Стоит сказать, что в вопросах управления памятью K8S полагается на Control Groups (CGroups). Поэтому, давайте вспомним, что такое CGroups.

Control Groups

До внедрения CGroups, любому процессу были доступны все ресурсы системы. В нашем случае, ему была доступна вся свободная память. В плане дележки памяти творился хаос и OOM-killer иногда убивал не того. С внедрением CGroups, процессы разделили на группы, в которых можно было задать лимиты. И теперь каждый процесс принадлежит какой-то группе, так что его можно ограничить ресурсами его группы.

Группы имеют наследование. Есть одна корневая группа "/" - в ней доступна вся память системы. У нее есть группы-дочки: system, user и, в случае K8S, kubepods. Так вот, один контейнер - это одна группа где-то внутри группы kubepods". Чтобы выставить лимиты контейнера, K8S выставляет лимиты его группе. На этом работа K8S заканчивается. Дальше за соблюдением лимитов следит ядро Linux.

Ядро linux рассматривает каждую группу CGroups, как маленькую операционную систему: у каждой группы есть своя память приложений, свой файловый кеш, свой своп, и т.д. Сколько памяти занято под каждый тип, можно посмотреть в файле memory.stat внутри директории группы (/sys/fs/cgroup/some/group/). Для экспорта метрик памяти отлично подходят проекты "cAdvisor" и "kube-state-metrics" (KSM).

Метрики памяти

Так могут выглядеть метрики памяти, если приложение постоянно пишет в файл
Так могут выглядеть метрики памяти, если приложение постоянно пишет в файл

Какие метрики памяти чаще всего важны?

  • container_memory_rss - RSS, память приложения. Самая важная метрика.

  • container_memory_cache - файловый кеш. Читать файл из памяти всегда быстрее, чем с диска.

  • container_memory_usage_bytes - сколько всего памяти потребляет контейнер (а точнее, его группа в cgroup). Считается вся-вся-вся память. Первая коварная метрика.

  • container_memory_working_set_bytes - WSS, очень распространенная метрика. И это вторая коварная метрика.

  • kube_pod_container_resource_limits{resource="memory"} - лимит памяти группы. Он же - лимит памяти контейнера.

Сразу скажем про OOM-killer. Нам понадобится это знание дальше.
Формально, OOM-killer вызывается тогда, когда usage_bytes достигает лимита. Например, OOM-killer сработает, если в группе лимит 100 МБ и usage_bytes вырос до 100 МБ. Из графиков памяти можно сделать другой вывод, но на деле это работает именно так.

Метрика usage_bytes

usage_bytes явно питает теплые чувства к limit
usage_bytes явно питает теплые чувства к limit

И так, в чем коварство первой метрики, usage_bytes?
Начнем с мелкого вредительства: usage_bytes включает в себя файловый кеш.
Возможно, вы замечали, что если ваше приложение много пишет или читает с диска, график usage_bytes может "прилипнуть" к линии limit. Почему так происходит? Из за файлового кеша. ОС linux очень любит кешировать файлы. Эта любовь передалась и в CGroups. Файлы, открытые вашим приложением, будут кешироваться и кешироваться, пока есть свободная память. Ну а кеш считается частью "usage_bytes".

Здесь та же история, как с памятью ОС: вы запускаете команду "free" и вам показывают "Free memory: 0". Вся свободная память ушла в кеш. Хорошая новость: когда приложению нужна память, ОС легко отдает память из кеша. Но сумма занятой памяти при этом не меняется, поэтому usage_bytes остается болтаться около limit.

Это вызывает неудобство: метрика есть, а толку нет. Как понять, сколько памяти нужно контейнеру для работы? Чтобы оценить реальное потребление памяти, приходится смотреть на другие метрики: RSS, cache и WSS. Но WSS - та еще тёмная лошадка.

Not exact by design

5.5 usage_in_bytes
For efficiency, as other kernel components, memory cgroup uses some optimization to avoid unnecessary cacheline false sharing. usage_in_bytes is affected by the method and doesn't show 'exact' value of memory (and swap) usage, it's a fuzz value for efficient access. (Of course, when necessary, it's synchronized.) If you want to know more exact memory usage, you should use RSS+CACHE(+SWAP) value in memory.stat(see 5.2). [цитата из документации к cgroup‑v1]

Главное коварство usage_bytes в том, что это примерная метрика. Самой этой метрики нет в файле memory.stat. Её значение хранится в отдельном файле: memory.usage_in_bytes в CGroups v1 или memory.current в CGroups v2. И значение оттуда не равно точно сумме значений из memory.stat.
Вы не ослышались: usage_bytes чуть-чуть больше, чем сумма всех типов памяти.

Такая логика была сделана уже очень давно и была сделано специально, чтобы снизить нагрузку на ядро. Похоже, ядру слишком накладно считать сумму памяти на каждый чих. И, похоже, у разработчиков нет планов это менять. Единственная гарантия, которую мы имеем: rss+cache <= usage_in_bytes
То есть, "примерная" метрика usage_bytes обязательно будет больше или равна реальному потреблению. Она не покажет меньше, чем контейнер (или группа) реально потребляет. В моих опытах, даже когда память используется только под RSS и кеша нет, usage_bytes все равно будет немного больше. Насколько немного? Это может быть +5%. При лимите в 100МБ, это будет 5 МБ. Вроде бы, немного. Но может быть и больше. Вот по этой ссылке исследователь ловил до +20% (602 МБ от 3322 МБ).

Где пять мегабайт, Лебовски?
Где пять мегабайт, Лебовски?

Что это значит на практике?

На практике это значит, что если ваше приложение потребляет ровно 100 МБ памяти и вы ставите лимит 100 МБ, usage_bytes все равно может превысить этот лимит. Приложение заполнит 96 МБ, а usage_bytes уже перевалит за 101 МБ. В этот момент OOM Killer убьет ваше приложение. И дело тут не в кеше или какой-то еще памяти. Дело в том, что usage_bytes врет.

Если у вас есть графики памяти убитого контейнера, вы заметите, что приложение было убито, когда RSS еще не дошел до limit на несколько МБ. А если у вас большое приложение, RSS может не дойти на десятки, сотни мегабайт. Это возникает потому, что ОС по разному считает RSS и usage_bytes. Вам остается просто держать это в уме при планировании лимитов контейнера. Очень, очень коварная метрика.

WSS: lock, stock, and two smoking caches

Нотариально не заверенный скриншот консоли
Нотариально не заверенный скриншот консоли

Идем дальше: WSS. Что с этой метрикой не так? Чтобы ответить на этот вопрос, нужно чуть больше узнать про файловый кэш. А если точнее, из чего он состоит. CGroups учитывает файловый кэш внутри группы в виде двух метрик:

  • active_file - "часто используемые" данные (цитата из документации)

  • inactive_file - соответственно, "редко используемые" (тоже цитата)

Самый полезный кеш - это, конечно, active_file. Вам не понятно, что означают "часто" и "редко"? Мне тоже это было не понятно. Усиленное гугление дало ответ, что на практике, это работает так:

  • Если файл прочитали один раз, он сохраняется в inactive_file

  • Если файл прочитали два раза, он переносится из inactive_file в active_file

Звучит слишком просто? Настало время испытания поединком консолью!

# Создадим файл на 200 мегабайт
fallocate -l 200M /tmp/file

# Посмотрим на метрики кеша в системе
grep _file /sys/fs/cgroup/memory.stat
inactive_file 75755520
active_file 44736512
# Видим 75 МБ inactive и 44 МБ active

# Теперь прочитаем наш файл один раз
cat /tmp/file > /dev/null
grep _file /sys/fs/cgroup/memory.stat
inactive_file 285450240
active_file 44945408
# Опачки!
# inactive вырос с 75 до 285 МБ

# Прочитаем файл еще раз
cat /tmp/file > /dev/null
grep _file /sys/fs/cgroup/memory.stat
inactive_file 88842240
active_file 241553408
# Опачки, опачки!
# inactive уменьшился до 88 МБ, зато active вырос с 44 до 241 МБ

Как видите, все оказывается просто. Размер в 200 МБ не соблюдается в точности, потому что здесь работает не только механизм наполнения кеша, но и механизм очистки. А этот механизм куда сложнее. Стоит добавить, что в K8S у вас есть только одна метрика: container_memory_cache. Из нее нельзя понять, сколько приходится на active_cache, а сколько на inactive_cache. В этом могут помочь другие проекты, которые выгребают и экспортируют метрики всех групп CGroups на ноде.

Теперь точно про WSS

Видите суслика WSS? А он есть. Без кеша WSS точно равен usage.
Видите суслика WSS? А он есть. Без кеша WSS точно равен usage.

Теперь мы можем вернуться к WSS. Что с ней не так?
WSS - это искусственная метрика памяти. Она не отражает какую-то конкретную память. Её даже нет в memory.stat в CGroups v1. Её значение вычисляет Kubelet, который запущен на ноде. Kubelet вычисляет его по формуле, которая упрощенно выглядит так:
WSS = usage_bytes - inactive_file
То есть, суммарный объем памяти минус inactive_file. Заметьте - для вычисления WSS берется "примерный" usage_bytes. Поэтому и WSS получается "примерным" - привирает в бóльшую сторону.

Какой смысл имеет WSS?
В теории, эта метрика хорошо показывает, сколько памяти нужно приложению для нормальной работы. Что нужно приложению? В большинстве случаев, не много:

  • Память под RSS, чтобы запуститься;

  • Память под кеш, чтобы быстро работать с файлами;

Обычно это и есть вся потребляемая память. Такое потребление и пытается показать WSS. Поэтому метрика так и называется "working set", т.е. "рабочий набор". В простом случае формула для WSS могла бы быть такой:
WSS = RSS + active_file (+ swap, но какой в K8S swap...)
Собственно, это и предлагает нам документация по cgroups-v1.

Олдскульные мемы, куда без них
Олдскульные мемы, куда без них

Но разработчики K8S взяли другую формулу и стали считать через usage_bytes.
Могу только предположить, что это связано с тем, что в контейнере могут быть и другие типы памяти, которые я не упомянул: shared memory, mmap, swap, и т.д.

Поэтому, нужно всегда иметь в виду, что WSS вычисляется из usage_bytes, а usage_bytes нам немного врет. Соответственно, врать начинает и WSS - он начинает показывать больше, чем есть на самом деле. В итоге, когда вы пытаетесь оценить потребление памяти, вам врут уже две метрики.

Практические случаи

Как это выглядит на практике?
Допустим, у вас есть приложение, которое потребляет 100 МБ RSS и 0 МБ кеша. Метрика usage_bytes при этом будет показывать 105 МБ, WSS при этом будет равен usage_bytes.

Допустим, метрика кеша не нулевая, скажем 50 МБ кеша active_file. В теории, потребление памяти должно быть 150 МБ (100 МБ RSS и 50 МБ кеша). Но, в силу своего вранья, WSS и usage_bytes будут в районе 156 МБ.

Хороший график, как хороший мем, можно запостить дважды
Хороший график, как хороший мем, можно запостить дважды

Соответственно, на графиках вы увидите три линии:

  • RSS (самая адекватная метрика)

  • WSS (который всегда больше RSS и иногда равен usage)

  • usage (который всегда больше всех и иногда вплотную прижимается к limit)

Дело об убийстве контейнера

Это начинает вам мешать, когда вы расследуете убийство контейнера от нехватки памяти. На графике вы видите, что OOM-killer убивает приложение, когда WSS приближается к limit. У вас может сложиться мнение, что OOM-kill случается из за WSS. Ведь WSS успел подойти вплотную к limit, а RSS отстает на несколько мегабайт. Что ж, вот что происходит на самом деле:

  • Приложение потребляет память, следовательно RSS растет.

  • Свободная память приближается к нулю.

  • Метрика usage_bytes оказывается слишком близко к limit.

  • Чтобы освободить память, ОС начинает чистить кеш (в том числе и active_file).

  • Так как active_file уменьшается, WSS приближается к usage_bytes.

  • RSS продолжает расти, поэтому ОС вычищает вообще весь кеш в группе, чтобы не допустить OOM-killer.

  • inactive_file падает до нуля, поэтому WSS становится равным usage_bytes.

  • usage_bytes становится равным limit и вызывает OOM-killer.

Наблюдатель может сделать вывод, что OOM-kill произошел из за WSS. Ведь, когда случился OOM-killer, метрики WSS и usage_bytes успели достичь лимита, а RSS - нет. RSS не успел дойти ни до usage_bytes, ни до limit. Я надеюсь, теперь вам понятно, почему это только видимость. На самом деле, память кончается, когда ее съедает RSS. А usage_bytes и WSS просто всегда бегут впереди паровоза. Морали нет. Вам остается только выделять достаточно памяти RSS, чтобы usage не упирался в limit.

Краткое резюме

  • Любишь k8s - люби и cgroups.

  • Память контейнера - это память его группы в cgroups.

  • OOM-killer зависит от usage_bytes, но фактически к OOM приводит рост RSS.

  • Метрика usage_bytes считается не настолько точно, как хотелось бы.

  • Хороший файл стоит читать дважды.

  • На уровне контейнера, WSS задуман показывать сумму RSS и active_file. Но на практике делает это неточно.

  • С такими метриками легко впасть в заблуждение относительно причин OOM-killer.

Источники вдохновения

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


  1. pinoquinho
    29.05.2023 07:51
    +2

    Спасибо, очень годная статья. Вот каждый раз делаю cat /proc/meminfo (редко), или в метрики смотрю, и каждый раз приходится вспоминать, а что все эти слова значат. Особенно WSS.
    Напрашивается в начале статьи кратенько (свёрнуто) список этих самых типов памяти и расшифровочку. Да, и про библиотечки я б тоже послушал - грузятся ли они в память, а в какую. Чтоб добавил одну только эту статью в закладки, и каждый раз, когда надо, пошёл, посмотрел и всё сразу стало ясно. Да даже не в закладки, а прям с графаны ссылочку сюда тыц.


    1. lexore Автор
      29.05.2023 07:51

      Описание /proc/meminfo тянет на отдельную статью :-) Особенно, если давать не только расшифровку, но и физический смысл. Если говорить про метрики внутри K8S, то метрики из статьи покрывают 99% случаев.


  1. alex_hollden
    29.05.2023 07:51

    Статья хорошая, в начале вы пишите:

    Формально, OOM-killer вызывается тогда, когда usage_bytes достигает лимита

    А ниже по статье

    На самом деле, память кончается, когда ее съедает RSS

    Или я что то здесь не понял


    1. lexore Автор
      29.05.2023 07:51
      +1

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

      Формально, до края бочки доходит поплавок. Но доходит он только потому, что его толкает вода. Вода - это RSS. Поплавок - это usage.


  1. Gutt
    29.05.2023 07:51
    +1

    Отличная статья, спасибо! Я как разу же неделю не мог понять, почему же Rancher в своём встроенном дашборде использования ресурсов рисует мне конское использование памяти, хотя я вижу, что памяти скушано намного меньше, а всё остальное -- файловый кэш. Очевидно, от тупо рисует сумму usage_in_bytes во всех относящихся в подам cgroups.

    По поводу поведения oom_killer. И по моим наблюдениям, и по документации ядра уборщик Виктор oom_killer не сразу приступает к работе. Достижение лимита usage_in_bytes не должно мгновенно вызывать ситуацию OOM. Если немного подождать, то kswapd может успеть высвободить память. Об этом в той же документации говорится, киллер сначала много думает. Да, это объяснено дальше по тексту статьи, без погружения вглубь непонятно, что на самом деле у нас конкурентный процесс, когда мы не можем выделить память, но ждём ещё немного в надежде, что kswapd будет достаточно расторопен. Ещё в ядре 2.6, кстати, киллер особо не раздумывал, что доставляло немало беспокойства в эпоху засилья SLES 11 в моей конторе. Сейчас стало намного лучше.


    1. 13werwolf13
      29.05.2023 07:51

      Если немного подождать, то kswapd может успеть высвободить память

      вот только емип в доке к k8s написано что swap надо хоронить, потому что они не осилили допилить что-то там..

      так что формально вы правы, но для топикстартера пишущего статью с точки зрения пользователя k8s это всё "о птичках"..


      1. Gutt
        29.05.2023 07:51

        Кстати, забавная ситуация со swap. В документации к ванильному k8s написано, что он должен быть отключен, а вот в документации к RKE2 такого требования уже нет, и они пишут, что не видят причин, по которым от него нужно отказываться. И как это понимать?


        1. 13werwolf13
          29.05.2023 07:51

          Просто в suse инженеры а не смузихлёбы

          Впрочем и там инженеры вымирают...


      1. Gutt
        29.05.2023 07:51

        вот только емип в доке к k8s написано что swap надо хоронить, потому что они не осилили допилить что-то там..

        Да, и главное. Насколько я понимаю, освобождением страниц, занятых дисковым кэшем, занимается тоже kswapd. Что вполне логично, он ведь решает, что можно выгрузить на диск, так почему бы ему же и не решать, что "доступно где-то ещё".


  1. chemtech
    29.05.2023 07:51

    @lexore Спасибо за пост. Я пытаюсь найти информацию. Может вы подскажите. Как правильно высчитать сколько выставлять request cpu/memory для pod в k8s ? Выставлять значения, которые pod потребляет через 5 минут после старта? Или выставлять request cpu/memory средния в течение дня значения?


    1. lexore Автор
      29.05.2023 07:51

      Мне кажется, среднее в течение дня будет ближе к среднему потреблению вообще.


      1. sirmax123
        29.05.2023 07:51

        Почему среднее а не максимальное ?


        1. lexore Автор
          29.05.2023 07:51

          Для максимального предполагается использовать лимиты. Хотя реквесты и лимиты - это инструменты. Существуют разные подходы, как их использовать, например:

          • лимиты = реквесты = максимальное потребление

          • лимиты = реквесты * 2

          • и т.д.

          Ваши настройки зависят от того, какие у вас приложения и какие у вас условия работы приложений.


  1. artazar
    29.05.2023 07:51
    +1

    Благодарю, очень дотошный обзор, одна из лучших статей с доходчивым описанием работы памяти (после https://www.linuxatemyram.com/). Про коварность WSS до этого не знал.