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? Если сталкивались, расскажите пожалуйста о том, как вы с ними справлялись.
Комментарии (15)
SirEdvin
24.03.2017 19:40Старый добрый "хаваю памяти сколько хочу" :)
Я думал, что почти все сталкивались с этим, если даже я пару раз натыкался.
Busla
25.03.2017 12:29> Многие разработчики знают, или должны знать, что Java-процессы, исполняемые внутри контейнеров Linux (среди них — docker, rkt, runC, lxcfs, и другие), ведут себя не так, как ожидается.
А что ожидается от виртуальной машины внутри контейнера? — это как бы по определению ортогональные понятия :-)
vektory79
25.03.2017 20:07Вообще если приложение запускается на сервере, то -Xmx должен быть задан в обязательном порядке. Ограничение на metaspace тоже желательно задавать. Другие ограничения по расходу памяти в OpenJDK так же присутствуют (для буферо машинного кода jit, offheap память и т.д.). Но если это все задавать, то какой смысл дублировать это через докер?
Lelik13a
27.03.2017 05:00Я не проверял последние докеры, но 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
Посмотрите в сторону обновления докера и ядра.Borz
27.03.2017 08:20как-то так:
$ 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
Lelik13a
27.03.2017 09:39+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, потому лишних телодвижений делать не пришлосьBorz
27.03.2017 11:50да, в таком виде пашет:
$ 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
vaniaPooh
bisor
поддержу. в этом же фишка JVM, она не должна знать что находится «под ней».
sshikov
В самом начале поста написано, что многие родные утилиты тоже ведут себя, скажем так, странно.
sasah
http://www.oracle.com/technetwork/java/javaseproducts/documentation/8u121-revision-builds-relnotes-3450732.html
Changes in Java SE 8u121 b34
[linux] Experimental support for cgroup memory limits in container (ie Docker) environments
http://bugs.java.com/view_bug.do?bug_id=8175898