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

https://developers.redhat.com/blog/2017/03/14/java-inside-docker/
  • Перевод
Многие разработчики знают, или должны знать, что Java-процессы, исполняемые внутри контейнеров Linux (среди них — docker, rkt, runC, lxcfs, и другие), ведут себя не так, как ожидается. Происходит это тогда, когда механизму JVM ergonomics позволяют самостоятельно задавать параметры сборщика мусора и компилятора, управлять размером кучи. Когда Java-приложение запускают без ключа, указывающего на необходимость настройки параметров, скажем, командой java -jar myapplication-fat.jar, JVM самостоятельно настроит некоторые параметры, стремясь обеспечить наилучшую производительность приложения.

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


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

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

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

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


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


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

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

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

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

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

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


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

Влияние неверной работы с памятью на 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


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






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

Итоги


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

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

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

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

Уважаемые читатели! Сталкивались ли вы с проблемами при работе с Java-приложениями в контейнерах Linux? Если сталкивались, расскажите пожалуйста о том, как вы с ними справлялись.
RUVDS.com 488,59
RUVDS – хостинг VDS/VPS серверов
Поделиться публикацией
Комментарии 16
  • +5
    JVM до сих пор не имеет средств, позволяющих определить, что она выполняется в контейнеризированной среде и учесть ограничения некоторых ресурсов, таких, как память и процессор.
    А должна? Если Java будет знать, что она в контейнере, то может получиться сильно связанная система, что не есть хорошо.
  • 0
    Спасибо, во время )))
    • +3
      Краткое содержание статьи: не ленитесь, используйте -Xmx в любых средах )
      • 0

        Старый добрый "хаваю памяти сколько хочу" :)
        Я думал, что почти все сталкивались с этим, если даже я пару раз натыкался.

        • +1
          JVM считает себя всех умней и все равно продолжает жрать память, даже если все пулы имеют ограничение сверху.

          Но скоро все исправиться и JVM можно будет заставить проверять cgoups лимиты
          • 0
            > Многие разработчики знают, или должны знать, что Java-процессы, исполняемые внутри контейнеров Linux (среди них — docker, rkt, runC, lxcfs, и другие), ведут себя не так, как ожидается.

            А что ожидается от виртуальной машины внутри контейнера? — это как бы по определению ортогональные понятия :-)
            • 0

              Вообще если приложение запускается на сервере, то -Xmx должен быть задан в обязательном порядке. Ограничение на metaspace тоже желательно задавать. Другие ограничения по расходу памяти в OpenJDK так же присутствуют (для буферо машинного кода jit, offheap память и т.д.). Но если это все задавать, то какой смысл дублировать это через докер?

              • 0
                Я не проверял последние докеры, но LXC версии 2.x, ограничения у которых тоже реализуется средствами cgroup, корректно определяют доступные ресурсы внутри контейнера:
                # main
                # free -m
                             total       used       free     shared    buffers     cached
                Mem:         32078      31807        271       2467       2165      21327
                
                # container
                # free -m
                             total       used       free     shared    buffers     cached
                Mem:         10240       5195       5044       2467          0          0
                
                


                Debian
                lxc 2.0.6-1~bpo8+1
                kernel 4.9.13-1~bpo8+1

                Посмотрите в сторону обновления докера и ядра.
                • 0

                  как-то так:


                  $ uname --kernel-release
                  4.8.0-42-generic
                  
                  $ docker version
                  Client:
                   Version:      17.03.0-ce
                   API version:  1.26
                   Go version:   go1.7.5
                   Git commit:   3a232c8
                   Built:        Tue Feb 28 08:01:32 2017
                   OS/Arch:      linux/amd64
                  
                  Server:
                   Version:      17.03.0-ce
                   API version:  1.26 (minimum version 1.12)
                   Go version:   go1.7.5
                   Git commit:   3a232c8
                   Built:        Tue Feb 28 08:01:32 2017
                   OS/Arch:      linux/amd64
                   Experimental: false
                  
                  $ docker run -it --memory="128M" debian:8 free -m
                  WARNING: Your kernel does not support swap limit capabilities or the cgroup is not mounted. Memory limited without swap.
                               total       used       free     shared    buffers     cached
                  Mem:         15925       4578      11347        160        236       1821
                  -/+ buffers/cache:       2519      13405
                  Swap:            0          0          0
                  
                  • +2
                    Покопался в вопросе и таки решил:
                    LXC информирует контейнер о доступных ресурсах с помощью LXCFS. Если для контейнера это работает из коробки, то для докера нужно в ручную задать перемонтирование соответствующих файлов из /var/lib/lxcfs/proc/ в /proc/, например:
                    # like docker-compose 
                    mem_limit: 512m
                    volumes:
                       - /var/lib/lxcfs/proc/meminfo:/proc/meminfo
                    

                    И ура:
                    # docker exec CT_name free -m
                                 total       used       free     shared    buffers     cached
                    Mem:           512         13        499       4322          0          0
                    


                    Естественно, lxcfs нужно поставить в систему и, возможно, в ручную смонтировать:
                    # mount | grep lxcfs
                    lxcfs on /var/lib/lxcfs type fuse.lxcfs (rw,nosuid,nodev,relatime,user_id=0,group_id=0,allow_other)
                    

                    У меня на сервере одновременно живёт и LXC и docker, потому лишних телодвижений делать не пришлось
                    • 0

                      да, в таком виде пашет:


                      $ docker run -it -v /var/lib/lxcfs/proc/meminfo:/proc/meminfo --memory="128M" debian:8 free -m
                      WARNING: Your kernel does not support swap limit capabilities or the cgroup is not mounted. Memory limited without swap.
                                   total       used       free     shared    buffers     cached
                      Mem:           128          2        125        557          0          2
                      -/+ buffers/cache:          0        127
                      Swap:            0          0          0
              • +2
                Добавлю к «верному решению проблемы».

                Вот такой вариант, как ниже (и в статье), лучше НЕ использовать, так как в зависимости от образа может привести к тому, что Ctrl-C и SIGTERM будет посылаться не java, а bash, что приводит к тому, что контейнер нельзя будет опустить — только убить. Например так себя ведут образы maven
                CMD java -XX:+PrintFlagsFinal -XX:+PrintGCDetails $JAVA_OPTIONS -jar java-container.jar
                


                Правильный вариант:

                CMD ["java", "-XX:+PrintFlagsFinal", "-XX:+PrintGCDetails", "-jar", "java-container.jar"]
                


                А куда же делся JAVA_OPTIONS, спросите вы?
                В джава есть специальная переменная окружения для этого. Называется JAVA_TOOL_OPTIONS. Можно передавать и через `-e`, либо использовать сразу в Dockerfile через `ENV`

                Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                Самое читаемое