- PVSM.RU - https://www.pvsm.ru -
Многие разработчики знают, или должны знать, что Java-процессы, исполняемые внутри контейнеров Linux (среди них — docker [1], rkt [2], runC [3], lxcfs [4], и другие), ведут себя не так, как ожидается. Происходит это тогда, когда механизму JVM ergonomics позволяют самостоятельно задавать параметры сборщика мусора и компилятора, управлять размером кучи. Когда Java-приложение запускают без ключа, указывающего на необходимость настройки параметров, скажем, командой java -jar myapplication-fat.jar, JVM самостоятельно настроит некоторые параметры, стремясь обеспечить наилучшую производительность приложения.
[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 Мб.

Результаты выполнения команды free -h
Похожий результат получается даже в кластере Kubernetes / OpenShift. Я запустил группу контейнеров Kubernetes с ограничением памяти, используя такую команду:
kubectl run mycentos –image=centos -it –limits=’memory=512Mi’
При этом кластеру было назначено 15 Гб памяти. В итоге общий объём памяти, о котором сообщила система, составил 14 Гб.

Исследование кластера с 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 для того, чтобы проверить ограничения.

Данные о контейнере
Через несколько секунд исполнение контейнера WildFly будет прервано, появится сообщение:
*** JBossAS process (55) received KILL signal ***
Выполним такую команду:
docker inspect mywildfly -f ‘{{json .State}}
Она сообщит о том, что контейнер был остановлен из-за возникновения ситуации OOM (Out Of Memory, нехватка памяти). Обратите внимание на то, что состояние контейнера — это OOMKilled=true.

Анализ причины остановки контейнера
В демоне 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)
Всё это может навести нас, по меньшей мере, на два вопроса:
Для того, чтобы с этим разобраться, сначала надо вспомнить, что говорится о максимальном размере кучи (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

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