- PVSM.RU - https://www.pvsm.ru -

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

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

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

Надо отметить, что некоторые приложения, которые при работе полагаются на данные, полученные из среды выполнения, созданы до появления 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 Гб памяти

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

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

Для того, чтобы воспроизвести ситуацию, в которой система останавливает процесс после превышения заданного лимита памяти, можно запустить WildFly Application Server [8] в контейнере с ограничением памяти в 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, [9] приложение Java запускается с параметрами XX:+PrintFlagsFinal и -XX:+PrintGCDetails, заданными в Dockerfile [10]. Это позволяет нам прочесть исходные параметры механизма 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) в документации [11]по 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 можно почитать здесь [12].

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

Разработчики, не понимающие сути происходящего, склонны полагать, что вышеописанная проблема заключается в том, что окружение не даёт достаточно памяти для исполнения 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 [13] позволяет нам задавать переменную окружения, которая определяет дополнительные параметры для JVM. Взгляните на следующую строку:

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

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

Задавать переменные среды для 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 [15]. Образ fabric8/java-jboss-openjdk8-jdk [16] задействует скрипт [17], который выясняет ограничения контейнера и использует 50% доступной памяти как верхнюю границу. Обратите внимание на то, что вместо 50% можно использовать другое значение. Кроме того, этот образ позволяет включать и отключать отладку, диагностику, и многое другое. Взглянем на то, как выглядит Dockerfile [18] для приложения 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, например). Тут [19] можно найти подробности.

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

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

Автор: RUVDS.com

Источник [20]


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/java/250699

Ссылки в тексте:

[1] docker: https://github.com/docker/docker

[2] rkt: https://coreos.com/rkt/

[3] runC: https://github.com/opencontainers/runc/

[4] lxcfs: https://linuxcontainers.org/lxcfs/manpages/man1/lxcfs.1.html

[5] Image: https://habrahabr.ru/company/ruvds/blog/324756/

[6] cgroups: https://en.wikipedia.org/wiki/Cgroups

[7] этот материал: https://fabiokung.com/2014/03/13/memory-inside-linux-containers/

[8] WildFly Application Server: http://wildfly.org/

[9] Spring Boot,: https://github.com/redhat-developer-demos/java-container

[10] Dockerfile: https://github.com/redhat-developer-demos/java-container/blob/master/Dockerfile.openjdk#L5

[11] документации : https://docs.oracle.com/javase/8/docs/technotes/guides/vm/gc-ergonomics.html

[12] здесь: https://docs.docker.com/engine/reference/run/#user-memory-constraints

[13] Dockerfile: https://github.com/redhat-developer-demos/java-container/blob/master/Dockerfile.openjdk-env#L5

[14] МиБ: https://ru.wikipedia.org/wiki/%D0%94%D0%B2%D0%BE%D0%B8%D1%87%D0%BD%D1%8B%D0%B5_%D0%BF%D1%80%D0%B8%D1%81%D1%82%D0%B0%D0%B2%D0%BA%D0%B8

[15] Fabric8: https://fabric8.io/

[16] fabric8/java-jboss-openjdk8-jdk: https://hub.docker.com/r/fabric8/java-jboss-openjdk8-jdk/

[17] скрипт: https://github.com/fabric8io-images/java/blob/master/images/jboss/openjdk8/jdk/container-limits

[18] Dockerfile: https://github.com/redhat-developer-demos/java-container/blob/master/Dockerfile.fabric8

[19] Тут: http://hg.openjdk.java.net/jdk9/jdk9/hotspot/rev/5f1d1df0ea49

[20] Источник: https://habrahabr.ru/post/324756/