Java и ограничения памяти в контейнерах: LXC, Docker и OpenVZ
Большое количество репостов и лайков показывает, что данная тема довольно популярна среди Java-разработчиков.
Поэтому, хотелось бы более подробно проанализировать данную проблему и определить возможные пути ее решения.
Проблема
Мэтт описывает свое ночное «путешествие» в контейнере 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 Гб оперативной памяти.
Перейдите к Jelastic SSH gate (1), выберите ранее созданную тестовую среду (2), и выберите контейнер (3). Находясь внутри, можете проверить доступную память с помощью инструмента free (4).
Как мы можем видеть, ограничение памяти равно 1 Гб, определенному ранее. Теперь проверим инструмент top.
Все работает должным образом. Для двойной проверки, мы повторим тест Мэтта, связанного с вопросом эвристического поведения Java, описанного в его статье.
Как и следовало ожидать, мы получаем MaxHeapSize = 268435546 (~ 256 Мб), что составляет ¼ от оперативной памяти контейнера в соответствии со стандартным поведением динамической памяти Java.
В чем секрет нашего решения? Конечно же, в правильном сочетании «ингредиентов». В нашем случае, это сочетание технологий OpenVZ и Docker, которое дает больший контроль с точки зрения безопасности и изоляции, а также возможность использовать такие функции как живая миграция и гибернация контейнеров. Ниже приведена высокоуровневая схема контейнера Docker в Jelastic.
В OpenVZ каждый контейнер имеет виртуализированный вид псевдо-файловой системы /proc. В частности, /proc/meminfo внутри контейнера является «специальной» версией, показывающей информацию о каждом контейнере, а не хоста. Поэтому, когда такие инструменты, как top и free работают внутри контейнера, они показывают оперативную память и использование своп с ограничениями, специфичными для данного конкретного контейнера.
Стоит отметить, что своп внутри контейнеров не реальный, а виртуальный (отсюда и название всей технологии — VSwap). Основная идея состоит в том, что когда контейнер с активированным VSwap превышает заданное ограничение оперативной памяти, некоторая часть из его памяти переходит в так называемый кэш свопа. Никакого реального перекачивания не происходит, а это означает, что нет необходимости в вводе/выводе, если, конечно же, нет недостатка глобальной оперативной памяти. Кроме того, контейнер, который использует VSwap, и имеющий превышение ограничения оперативной памяти, «наказывается» замедлением, изнутри это выглядит, как будто происходит реальная подкачка. Эта технология приводит к контролю памяти контейнера и использования свопа.
Такая реализация позволяет запускать Java и другие системы без необходимости адаптировать приложения под Jelastic PaaS. Но если вы не используете Jelastic, возможным обходным путем будет указывать размер динамической памяти для виртуальной машины Java и не зависеть от эвристики (согласно советам Мэтта). Для остальных языков требуется более глубокое исследование. Пожалуйста, свяжитесь с нами, если вы можете поделиться своим опытом в этом направлении, и мы будем рады расширить эту статью.