Java и Docker: это должен знать каждый

в 9:50, , рубрики: docker, java, linux, Блог компании RUVDS.com, Разработка под Linux

Многие разработчики знают, или должны знать, что Java-процессы, исполняемые внутри контейнеров Linux (среди них — docker, rkt, runC, lxcfs, и другие), ведут себя не так, как ожидается. Происходит это тогда, когда механизму JVM ergonomics позволяют самостоятельно задавать параметры сборщика мусора и компилятора, управлять размером кучи. Когда Java-приложение запускают без ключа, указывающего на необходимость настройки параметров, скажем, командой java -jar myapplication-fat.jar, JVM самостоятельно настроит некоторые параметры, стремясь обеспечить наилучшую производительность приложения.
Java и Docker: это должен знать каждый - 1
В этом материале мы поговорим о том, что необходимо знать разработчику перед тем, как он займётся упаковкой своих приложений, написанных на Java, в контейнеры Linux.

Мы рассматриваем контейнеры в виде виртуальных машин, настраивая которые можно задать число виртуальных процессоров и объём памяти. Контейнеры больше похожи на механизм изоляции, где ресурсы (процессор, память, файловая система, сеть, и другие), выделенные некоему процессу, изолированы от других. Подобная изоляция возможна благодаря механизму ядра Linux cgroups.

Надо отметить, что некоторые приложения, которые при работе полагаются на данные, полученные из среды выполнения, созданы до появления cgroups. Утилиты вроде top, free, ps, и даже JVM, не оптимизированы для исполнения внутри контейнеров, фактически — сильно ограниченных процессов Linux. Посмотрим, что происходит, когда программы не учитывают особенности работы в контейнерах и выясним, как избежать ошибок.

Постановка проблемы

В демонстрационных целях я создал демон docker в виртуальной машине с 1 Гб ОЗУ, используя такую команду:

docker-machine create -d virtualbox –virtualbox-memory ‘1024’ docker1024

Далее, я выполнил команду free -h в трёх различных дистрибутивах Linux, исполняющихся в контейнере, использовав ограничения в 100 Мб, заданные ключами -m и --memory-swap. В результате все они показали общий объём памяти в 995 Мб.

Java и Docker: это должен знать каждый - 2

Результаты выполнения команды free -h

Похожий результат получается даже в кластере Kubernetes / OpenShift. Я запустил группу контейнеров Kubernetes с ограничением памяти, используя такую команду:

kubectl run mycentos –image=centos -it –limits=’memory=512Mi’

При этом кластеру было назначено 15 Гб памяти. В итоге общий объём памяти, о котором сообщила система, составил 14 Гб.

Java и Docker: это должен знать каждый - 3

Исследование кластера с 15 Гб памяти

Для того, чтобы понять причины происходящего, советую прочесть этот материал об особенностях работы с памятью в контейнерах Linux.

Надо понимать, что ключи Docker (-m, --memory и --memory-swap), и ключ Kubernetes (--limits) указывают ядру Linux на необходимость остановки процесса, если он пытается превысить заданный лимит. Однако, JVM ничего об этом не знает, и когда она выходит за рамки подобных ограничений, ничего хорошего ждать не приходится.

Для того, чтобы воспроизвести ситуацию, в которой система останавливает процесс после превышения заданного лимита памяти, можно запустить WildFly Application Server в контейнере с ограничением памяти в 50 Мб, воспользовавшись такой командой:

docker run -it –name mywildfly -m=50m jboss/wildfly

Теперь, в процессе работы контейнера, можно выполнить команду docker stats для того, чтобы проверить ограничения.

Java и Docker: это должен знать каждый - 4

Данные о контейнере

Через несколько секунд исполнение контейнера WildFly будет прервано, появится сообщение:

*** JBossAS process (55) received KILL signal ***

Выполним такую команду:

docker inspect mywildfly -f ‘{{json .State}}

Она сообщит о том, что контейнер был остановлен из-за возникновения ситуации OOM (Out Of Memory, нехватка памяти). Обратите внимание на то, что состояние контейнера — это OOMKilled=true.

Java и Docker: это должен знать каждый - 5

Анализ причины остановки контейнера

Влияние неверной работы с памятью на Java-приложения

В демоне Docker, который исполняется на машине с 1 ГБ памяти (ранее созданной командой docker-machine create -d virtualbox –virtualbox-memory ‘1024’ docker1024), но с памятью контейнера, ограниченной 150-ю мегабайтами, что кажется достаточным для приложения Spring Boot, приложение Java запускается с параметрами XX:+PrintFlagsFinal и -XX:+PrintGCDetails, заданными в Dockerfile. Это позволяет нам прочесть исходные параметры механизма JVM ergonomics и узнать подробности о запусках сборки мусора (GC, Garbage Collection).

Попробуем это сделать:

$ docker run -it --rm --name mycontainer150 -p 8080:8080 -m 150M rafabene/java-container:openjdk

Я подготовил конечную точку по адресу /api/memory/, которая загружает в память JVM строковые объекты для имитации операции, потребляющей большой объём памяти. Выполним такой вызов:

$ curl http://`docker-machine ip docker1024`:8080/api/memory

Конечная точка ответит примерно следующим образом:

Allocated more than 80% (219.8 MiB) of the max allowed JVM memory size (241.7 MiB)

Всё это может навести нас, по меньшей мере, на два вопроса:

  • Почему размер максимальной разрешённой памяти JVM равен 241.7 МиБ?
  • Если ограничение памяти контейнера составляет 150 Мб, почему он позволил Java выделить почти 220 Мб?

Для того, чтобы с этим разобраться, сначала надо вспомнить, что говорится о максимальном размере кучи (maximum heap size) в документации по JVM ergonomics. Там сказано, что максимальный размер кучи составляет 1/4 размера физической памяти. Так как JVM не знает, что исполняется в контейнере, максимальный размер кучи будет близок к 260 Мб. Учитывая то, что мы добавили флаг -XX:+PrintFlagsFinal при инициализации контейнера, можно проверить это значение:

$ docker logs mycontainer150|grep -i MaxHeapSize
uintx MaxHeapSize := 262144000 {product}

Теперь надо понять, что когда в командной строке Docker используется параметр -m 150M, демон Docker ограничит размеры памяти и swap-файла 150-ю мегабайтами. В результате процесс сможет выделить 300 мегабайт, что и объясняет, почему наш процесс не получил сигнал KILL от ядра Linux.

Об особенностях различных комбинаций параметров ограничения памяти (--memory) и swap-файла (--swap) в командной строке Docker можно почитать здесь.

Увеличение объёма памяти как пример неверного решения проблемы

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

Предположим, мы предоставили демону не 1 Гб памяти, а 8 Гб. Для его создания подойдёт такая команда:

docker-machine create -d virtualbox –virtualbox-memory ‘8192’ docker8192

Следуя той же идее, ослабим ограничение контейнера, дав ему не 150, а 800 Мб памяти:

$ docker run -it --name mycontainer -p 8080:8080 -m 800M rafabene/java-container:openjdk

Обратите внимание на то, что команда curl http://`docker-machine ip docker8192`:8080/api/memory в таких условиях даже не сможет выполниться, так как вычисленный параметр MaxHeapSize для JVM в окружении с 8 Гб памяти будет равен 2092957696 байт (примерно 2 Гб). Проверить это можно такой командой:

docker logs mycontainer|grep -i MaxHeapSize

Java и Docker: это должен знать каждый - 6

Проверка параметра MaxHeapSize

Приложение попытается выделить более 1.6 Гб памяти, что больше, чем лимит контейнера (800 Мб RAM и столько же в swap-файле), в результате процесс будет остановлен.

Ясно, что увеличение объёма памяти и позволение JVM устанавливать собственные параметры — далеко не всегда правильно при выполнении приложений в контейнерах. Когда Java-приложение исполняется в контейнере, мы должны устанавливать максимальный размер кучи самостоятельно (с помощью параметра --Xmx), основываясь на нуждах приложениях и ограничениях контейнера.

Верное решение проблемы

Небольшое изменение в Dockerfile позволяет нам задавать переменную окружения, которая определяет дополнительные параметры для JVM. Взгляните на следующую строку:

CMD java -XX:+PrintFlagsFinal -XX:+PrintGCDetails $JAVA_OPTIONS -jar java-container.jar

Теперь можно использовать переменную окружения JAVA_OPTIONS для того, чтобы сообщать системе о размере кучи JVM. Этому приложению, похоже, хватит 300 Мб. Позже можно взглянуть в логи и найти там значение 314572800 байт (300 МиБ).

Задавать переменные среды для Docker можно, используя ключ -e:

$ docker run -d --name mycontainer8g -p 8080:8080 -m 800M -e JAVA_OPTIONS='-Xmx300m' rafabene/java-container:openjdk-env

$ docker logs mycontainer8g|grep -i MaxHeapSize
uintx    MaxHeapSize := 314572800       {product}

В Kubernetes переменную среды можно задать, воспользовавшись ключом –env=[key=value]:

$ kubectl run mycontainer --image=rafabene/java-container:openjdk-env --limits='memory=800Mi' --env="JAVA_OPTIONS='-Xmx300m'"

$ kubectl get pods
NAME                          READY  STATUS    RESTARTS AGE
mycontainer-2141389741-b1u0o  1/1    Running   0        6s

$ kubectl logs mycontainer-2141389741-b1u0o|grep MaxHeapSize
uintx     MaxHeapSize := 314572800     {product}

Улучшаем верное решение проблемы

Что если размер кучи можно было бы рассчитать автоматически, основываясь на ограничениях контейнера?

Это вполне достижимо, если использовать базовый образ Docker, подготовленный сообществом Fabric8. Образ fabric8/java-jboss-openjdk8-jdk задействует скрипт, который выясняет ограничения контейнера и использует 50% доступной памяти как верхнюю границу. Обратите внимание на то, что вместо 50% можно использовать другое значение. Кроме того, этот образ позволяет включать и отключать отладку, диагностику, и многое другое. Взглянем на то, как выглядит Dockerfile для приложения Spring Boot:

FROM fabric8/java-jboss-openjdk8-jdk:1.2.3

ENV JAVA_APP_JAR java-container.jar
ENV AB_OFF true

EXPOSE 8080

ADD target/$JAVA_APP_JAR /deployments/

Теперь всё будет работать так, как нужно. Независимо от ограничений памяти контейнера, наше Java-приложение всегда будет настраивать размер кучи в соответствии с параметрами контейнера, не основываясь на параметрах демона.

Java и Docker: это должен знать каждый - 7
Java и Docker: это должен знать каждый - 8
Java и Docker: это должен знать каждый - 9

Использование разработок Fabric8

Итоги

JVM до сих пор не имеет средств, позволяющих определить, что она выполняется в контейнеризированной среде и учесть ограничения некоторых ресурсов, таких, как память и процессор. Поэтому нельзя позволять механизму JVM ergonomics самостоятельно задавать максимальный размер кучи.

Один из способов решения этой проблемы — использование образа Fabric8 Base, который позволяет системе, основываясь на параметрах контейнера, настраивать размер кучи автоматически. Этот параметр можно задать и самостоятельно, но автоматизированный подход удобнее.

В JDK9 включена экспериментальная поддержка JVM ограничений памяти cgroups в контейнерах (в Docker, например). Тут можно найти подробности.

Надо отметить, что здесь мы говорили о JVM и об особенностях использования памяти. Процессор — это отдельная тема, вполне возможно, мы ещё её обсудим.

Уважаемые читатели! Сталкивались ли вы с проблемами при работе с Java-приложениями в контейнерах Linux? Если сталкивались, расскажите пожалуйста о том, как вы с ними справлялись.

Автор: RUVDS.com

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js