В этой статье мы рассмотрим память внутри контейнера 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?
Начнем с мелкого вредительства: 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 - это искусственная метрика памяти. Она не отражает какую-то конкретную память. Её даже нет в 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.