Как избежать побочных эффектов контейнеризации
Июль 06, 2022
Передовые практики современной разработки, особенно в связи с распространением облачных решений и микросервисов, предполагают использование Docker, Kubernetes и других контейнерных инструментов. Но контейнеризация — это сложная технология, и даже опытные разработчики совершают ошибки при работе с контейнерами. Из данной статьи вы узнаете, как избежать этих ошибок или устранить проблемы, с которыми вы, возможно, уже столкнулись.
- На что стоит обратить внимание
- Не увлекайтесь автоматическим созданием Docker-образов
- Правильно задайте параметр -Xmx
- Избегайте автоматического включения SerialGC
- Используйте новые версии OpenJDK
- Ограничивайте память в контейнере
- Заключение
На что стоит обратить внимание
Казалось бы, процесс контейнеризации прост: заворачиваем приложение в контейнер и деплоим в облако. Но впоследствии может оказаться, что производительность упала, а счета за облачные ресурсы возросли. Что произошло?
В мире Java-разработки есть ряд типичных ошибок, ведущих к ухудшению производительности, в том числе
- Ошибки в логике приложения
- Некорректная работа с базами данных
- Проблемы с многопоточностью
- Чрезмерное или недостаточное использование оперативной памяти
- Ненадлежащая серверная инфраструктура
Что касается контейнеризации, разработчикам следует обращать внимание на потребление памяти и инфраструктуру. Ниже приведены рекомендации относительно настройки контейнеров и JVM для достижения оптимальной производительности и потребления ресурсов. Но сначала нужно подчеркнуть, что скрупулезная настройка JVM не устранит стратегические ошибки. Как в алгоритмах локальная оптимизация не может победить асимптотику, так и в контейнеризации настройки контейнеров не исправят код с очевидной утечкой памяти или ненужными вычислениями. Поэтому не забывайте о профилировании приложений для предотвращения и своевременного устранения подобных ошибок.
Не увлекайтесь автоматическим созданием Docker-образов
Представим, что у вас уже есть корректно написанное Java-приложение, которое вы хотите максимально быстро запустить в контейнере и развернуть в производстве. В Java существуют инструменты, которые позволяют автоматически преобразовывать код приложения в контейнер, например, Packeto Buildpacks. Все, что от вас требуется — установить утилиту pack и следовать инструкциям с официального сайта разработчика.
Однако, запустив такое приложение, вы можете заметить, что оно использует гораздо меньше оперативной памяти и процессорных ядер, чем выделено в контейнере. Например, контейнер теоретически может работать с 4G памяти и 16 процессорами, но JVM доступно лишь 1G памяти и 2 потока.
Эту проблему уже исправили в новых версиях buildpack. Вы также можете исправить ее самостоятельно, вручную указав все параметры контейнера, что отключает автоматическое определение ядер калькулятором памяти:
docker run --rm --tty --publish 8080:8080 --env 'JAVA_TOOL_OPTIONS=-Xmx3072m -XX:ActiveProcessorCount=4' --memory=4G paketo-demo-app
Но мы рекомендуем всегда проверять эти параметры и указывать их правильно. Автоматическое создание контейнеров экономит время, но в результате вы получаете черный ящик, не всегда соответствующий вашим требованиям.
Правильно задайте параметр -Xmx
Можно обойтись без автоматической генерации контейнеров и создать образ вручную, правильно настроив все параметры, включая -Xmx. С его помощью устанавливается максимальный размер кучи, но чтобы указать верное значение, нужно сначала определить, сколько оперативной памяти использует приложение.
Ограничение оперативной памяти контейнера должно быть выше, чем размер кучи в JVM, а на самом сервере должно хватать физической оперативной памяти для запуска всех контейнеров. Если -Xmx меньше, чем нужно приложению, то оно упадет с ошибкой “OutOfMemoryError: Java heap space”.
Как определить, сколько памяти нужно приложению? Самый простой способ — включить логирование GC в параметрах JVM с помощью
-verbose:gc -XX:+PrintGCDetails
Тогда перед выходом из приложения в в консоли будет распечатаны значения total
и used
памяти.
Значение выделенной через -Xmx памяти должно быть больше, чем значение реально используемой памяти, указанное в логах GC. Разница зависит от пиковых значений. Необходимо выполнить нагрузочное тестирование, запустить приложение под профайлером (например, Java Flight Recorder) и проанализировать, как количество затрачиваемой оперативной памяти зависит от нагрузки.
Избегайте автоматического включения SerialGC
В Java есть интересная особенность: если вы ограничиваете приложение менее чем двумя гигабайтами оперативной памяти и менее чем двумя процессорами, то включается SerialGC.
SerialGC — наиболее старый и экономный по ресурсам сборщик мусора, лучше всего предназначенный для суровых условий запуска однопоточных приложений. При этом он самый медленный сборщик в Java.
Точные условия включения SerialGC описаны в функциях select_gc_ergonomically() и is_server_class_machine() в документации OpenJDK (источники на английском языке). Следует отметить, что даже при включении таких флагов, как -XX:+UseG1GC,
SerialGC активируется автоматически из-за слишком маленького установленного объема памяти. Поэтому даже из соображений экономии не следует указывать в -Xmx менее двух гигабайт.
Используйте новые версии OpenJDK
За последние годы в OpenJDK была проделана большая работа по улучшению поддержки контейнеров, было устранено много проблем.
Одной из таких известных проблем является несоответствие данных о памяти, отображаемых top
и free
внутри контейнера, указанным в параметре -memory
. Вместо этого утилиты показывают всю память хоста. Можете убедиться в этом, запустив контейнер в интерактивном режиме:
docker run
--interactive \
--tty \
--memory 10m \
openjdk:18-jdk-alpine
После этого выполните в консоли команду free -m
или top bn1
. Сколько бы вы не экспериментировали с флагами запуска, вы не увидите там желанные десять мегабайт. Вместо этого в консоль будет выводиться общее количество памяти на вашем компьютере.
Ранее JVM вела себя аналогичным образом (например, в версии 8u131). Запустите в консоли следующую команду:
docker run -m 1gb openjdk:8u131 java -XshowSettings:vm -version
А теперь сравните результат со свежей версией OpenJDK:
docker run -m 1gb openjdk:18-jdk-alpine java -XshowSettings:vm -version
При использовании старой версии в консоли отобразится общее количество памяти хоста. В случае с новой версией будет выведено значение, примерно равное четверти заявленного лимита памяти. То же относится не только к памяти, но и, например, к количеству процессоров:
Runtime.getRuntime().availableProcessors()
Все дело в флаге -XX:+UseContainerSupport
, который был внедрен в OpenJDK 10 и теперь включен по умолчанию.
Можете поэкспериментировать с этим флагом. Если вы его отключите, даже новые версии OpenJDK будут демонстрировать прежнее поведение:
docker run -m 1gb axiom-openjdk-alpine:latest java -XX:-UseContainerSupport -XshowSettings:vm -version
Это значит, что более новые версии OpenJDK знают о наличии контейнеров. Поэтому всегда стоит обновлять дистрибутив до актуальной версии для предотвращения аналогичных проблем.
Нет необходимости сразу обновляться до JDK 17, последнего LTS-релиза, хотя это желательно, так как последняя LTS-версия содержит много новых фич. Но если вы работаете с Java 8, то описанная выше проблема исправлена в версии 8u212. Мы рекомендуем использовать последний официальный OpenJDK релиз для той версии Java, которую вы используете.
В примерах выше используется “стандартный” образ с DockerHub. У него есть свои нюансы. Например, в описании указано, что этот образ устарел, и рекомендуется использовать сборки от конкретных компаний. Если вы разрабатываете российское ПО, то предложенные там варианты (Amazon, IBM, SAP) могут не соответствовать внутренним и внешним требованиям вашей организации. Вместо этого мы рекомендуем использовать Axiom JDK — сертифицированный дистрибутив с российской техподдержкой для коммерческих и государственных компаний. Обновления Axiom JDK всегда выходят вовремя, поэтому в вашем рантайме всегда будут устранены известные баги и уязвимости.
Ограничивайте память в контейнере
У контейнера тоже есть ограничение по памяти. Например, в Docker лимит указывается с помощью флага –memory. В Kubernetes более сложная система: отдельно указывается request
(подсказка для поиска наиболее подходящего сервера) и limit
(жесткое ограничение памяти для cgroups). В любом случае, следует выделять контейнеру больше памяти, чем использует приложение, но здесь есть некоторые нюансы, которые мы обсудим далее.
Как включить Native Memory Tracking
Не всю память, используемую приложением, можно увидеть в логе GC. Например, дополнительные ресурсы могут потратиться при использовании
- Off-heap memory (
ByteBuffer.allocateDirect
) - Внешних нативных библиотек, загруженных через
System.loadLibrary
- Сильной фрагментации кучи ввиду того, что malloc выделяет память блоками
и так далее.
При определении ограничений контейнера по памяти нужно учитывать не только- Xmx, но и общую память. Она проверяется с помощью Native Memory Tracking.
Сначала запустите приложение с флагом
-XX:NativeMemoryTracking=detail
Затем, чтобы увидеть количество памяти с учетом нативной части, запустите из командной строки команду
jcmd $pid VM.native_memory
где $pid
— это идентификатор Java-процесса. Его можно найти, запустив jcmd без параметров, или с помощью ps aux | grep java
.
Изменения можно отслеживать вызовом команды
jcmd $pid VM.native_memory detail.diff
Как отключить Swap Memory
После того, как вы определили точное значение памяти, необходимой приложению, можно приступить к настройке -Xmx. Если вы укажете параметр -Xmx выше, чем объем доступной физической памяти в контейнере или на сервере, это приведет к ухудшению производительности.
Причина заключается в том, что кучи в Java работают по-другому, чем в C/C++. Скрипты и нативные программы на C/C++ неплохо справляются с попаданием в swap. В Java нужно работать с единой кучей, где Java-объекты равномерно распределены по всему адресному пространству процесса. Если значительная часть объектов попадет в swap file, работа приложения существенно замедлится.
Как этого избежать? Правильно укажите значение -Xmx или отключите swap в хостовой операционной системе и в самом контейнере. Используйте команду swapoff -a
для хоста и удалите соответствующие записи из файла /etc/fstab
(sed -i '/ swap / s/^/#/' /etc/fstab
). В Docker укажите параметр --memory-swap
,равный значению параметра --memory
.
К счастью Kubelet по умолчанию не запустится с включенным swap file, если вы не разрешили это в явном виде посредством KUBELET_EXTRA_ARGS=--fail-swap-on=false
и параметра memorySwap в KubeletConfiguration.
Заключение
Подытоживая, успешная контейнеризация зависит от следующих действий:
- Определите, сколько памяти требуется вашему приложению
- На основании этих данных установите параметр -Xmx и ограничение оперативной памяти внутри контейнера
- Используйте последний релиз OpenJDK для вашей версии Java
- Правильно настройте систему, например, отключив swap
В этой статье мы описали основы указанных выше процессов. В дальнейшем мы более подробно разберем особенности контейнеризованных приложений: выбор Garbage Collector, использование Embedded Java и т.д. подпишитесь на нашу рассылку, чтобы не пропустить полезные материалы!