Недавно была опубликована информативная статья Мэтта Уильямса о Java в Docker и существующих ограничениях памяти. Автор поднимает интересную тему о скрытой проблеме ограничения памяти, с которой пользователи могут столкнуться во время работы с контейнерами.

Большое количество репостов и лайков показывает, что данная тема довольно популярна среди Java-разработчиков.image

Поэтому хотелось бы более подробно проанализировать данную проблему и определить возможные пути ее решения.

Проблема


Мэтт описывает свое ночное «путешествие» в контейнере Docker со стандартным поведением памяти JVM. Он обнаружил, что ограничения RAM отображаются некорректно внутри контейнера. В результате, приложение Java, или любое другое, видит общий объем ресурсов оперативной памяти, выделенной для всей хост-машины, а JVM не может указать, сколько ресурсов было предоставлено родительскому контейнеру для работы. Это приводит к ошибке OutOfMemoryError, вызванной неправильным поведением динамической памяти JVM в контейнере.

Фабио Кунг, из Heroku, подробно описал основные причины возникновения этой проблемы в своей недавней статье "Память внутри контейнеров Linux. Или почему в контейнере Linux не работает free и top?"

Большинство инструментов Linux, предоставляющих метрики ресурсов системы, были созданы в то время, когда cgroups еще не существовали (например: free и top, как у procps). Они обычно читают метрики памяти из файловой системы proc: /proc/meminfo, /proc/vmstat, /proc/PID/smaps и других.

К сожалению, /proc/meminfo, /proc/vmstat и пр. не находятся в контейнерах. Это означает, что они не управляются cgroup. Они всегда отображают количество памяти хост-системы (физической или виртуальной машины) в целом, что является бесполезным для современных контейнеров Linux (Heroku, Docker и т.д.). Процессы внутри контейнера, необходимые для определения количества памяти, требуемой им для работы, не могут полагаться на free, top и др.; они подлежат ограничениям, налагаемыми cgroups и не могут использовать всю имеющуюся память хост-системы.

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

Решение


Сообщество “Открытые контейнеры” инициирует работы по улучшению runC для замещения файлов /proc. LXC также создает файловую систему lxcfs, которая позволяет контейнерам иметь виртуализированные файловые системы cgroup и виртуализованный вид файлов /proc. Так что этот вопрос находится под пристальным вниманием системных администраторов контейнера. Я считаю, что упомянутые усовершенствования могут помочь решить эту проблему на базовом уровне.

Мы также столкнулись с той же проблемой в Jelastic и уже нашли способы ее решения для наших пользователей. Поэтому мы хотели бы рассказать детали реализации.

Прежде всего, давайте вернемся к мастеру установки Jelastic, выберем провайдера услуг для тестовой учетной записи и создадим контейнер Java Docker с заранее заданными ограничениями памяти — например, 8 клаудлет, которые эквивалентны 1 Гб оперативной памяти.

image

Перейдите к Jelastic SSH gate (1), выберите ранее созданную тестовую среду (2), и выберите контейнер (3). Находясь внутри, можете проверить доступную память с помощью инструмента free (4).

image

Как мы можем видеть, ограничение памяти равно 1 Гб, определенному ранее. Теперь проверим инструмент top.

image

Все работает должным образом. Для двойной проверки, мы повторим тест Мэтта, связанного с вопросом эвристического поведения Java, описанного в его статье.

image

Как и следовало ожидать, мы получаем MaxHeapSize = 268435546 (~ 256 Мб), что составляет 1/4 от оперативной памяти контейнера в соответствии со стандартным поведением динамической памяти Java.

В чем секрет нашего решения? Конечно же, в правильном сочетании «ингредиентов». В нашем случае, это сочетание технологий OpenVZ и Docker, которое дает больший контроль с точки зрения безопасности и изоляции, а также возможность использовать такие функции как живая миграция и гибернация контейнеров. Ниже приведена высокоуровневая схема контейнера Docker в Jelastic.

image

В OpenVZ каждый контейнер имеет виртуализированный вид псевдо-файловой системы /proc. В частности, /proc/meminfo внутри контейнера является «специальной» версией, показывающей информацию о каждом контейнере, а не хоста. Поэтому, когда такие инструменты, как top и free работают внутри контейнера, они показывают оперативную память и использование своп с ограничениями, специфичными для данного конкретного контейнера.

Стоит отметить, что своп внутри контейнеров не реальный, а виртуальный (отсюда и название всей технологии — VSwap). Основная идея состоит в том, что когда контейнер с активированным VSwap превышает заданное ограничение оперативной памяти, некоторая часть из его памяти переходит в так называемый кэш свопа. Никакого реального перекачивания не происходит, а это означает, что нет необходимости в вводе/выводе, если, конечно же, нет недостатка глобальной оперативной памяти. Кроме того, контейнер, который использует VSwap, и имеющий превышение ограничения оперативной памяти, «наказывается» замедлением, изнутри это выглядит, как будто происходит реальная подкачка. Эта технология приводит к контролю памяти контейнера и использования свопа.

Такая реализация позволяет запускать Java и другие системы без необходимости адаптировать приложения под Jelastic PaaS. Но если вы не используете Jelastic, возможным обходным путем будет указывать размер динамической памяти для виртуальной машины Java и не зависеть от эвристики (согласно советам Мэтта). Для остальных языков требуется более глубокое исследование. Пожалуйста, свяжитесь с нами, если вы можете поделиться своим опытом в этом направлении, и мы будем рады расширить эту статью.
Поделиться с друзьями
-->

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


  1. Saffron
    28.07.2016 16:10
    +1

    Поздравляю, вы похвалились тем, что смогли решить проблему для себя. А для других можете дать какой-нибудь практический совет? Только /proc подменять или можно jvm оттюнить?


    1. sirus
      29.07.2016 10:17

      • всегда указывать XMX для JVM.
      • решение более общего характера
      • ждать пока нативные докеры нормально допилять


    1. ihormanchik
      01.08.2016 16:11

      в случае LXC еще вариант замонтировать cgroup ресурс из хоста внутрь контейнера на RO и почитывать его значение сразу с приложения, но это ugly, по-хорошему нужно ждать пока в LXC допилят виртуальный /proc/meminfo