Здравствуйте, уважаемые хабравчане! Сегодня я хотел бы рассказать о том, как «скормить» Java-приложение докеру, как при этом лучше действовать, а чего делать не стоит. Я занимаюсь разработкой на Java более 10 лет, и последние года три провёл в самом тесном общении с Docker, так что у меня сложилось определённое представление о том, что он может и чего не может. Но ведь гипотезы надо проверять на практике, не так ли?

Я представил весь процесс как старую добрую компьютерную игру с тёплым ламповым пиксель-артом.

Начнем мы, как и полагается любой игре, с некоторого брифинга. В качестве вводной возьмем немного рекламы докера.

На сайте докера можно ознакомиться с рядом рекламных посулов – а именно, с обещанием увеличить скорость разработки и развертывания аж в 13 раз и повысить портативность в разработке (в частности, избавиться о сакраментального «работает на моей машине»). Но соответствует ли это реальности?

Сейчас мы попробуем доказать/опровергнуть эти утверждения.

Level 1


Так как мы находимся в игре, то начнем, как и положено, с самого простого уровня.

Какова наша миссия на первом уровне? Наверное, для многих это что-то очень тривиальное и понятное: мы должны «завернуть» в Docker примитивнейшее Java-приложение.



Для этого нам понадобится простой Java-класс, который выводит сакраментальное Hello JavaMeetup! Также для того чтобы создать docker-образ нам понадобится Dockerfile. По синтаксису он предельно прост – в качестве базового образа используем java:8, добавляем наш Java-класс (команда ADD), компилируем его (при помощи команды RUN) и указываем команду, которая выполнится при запуске контейнера (команда CMD).

HelloWorld.java

public class HelloWorld {
 public static void main (String[] args) {
  System.out.println("Hello World!");
 }
}

Dockerfile

FROM java:8
ADD HelloWorld.java .
RUN javac HelloWorld.java
CMD ["java", "HelloWorld"]

Docker commands:

$ docker build -t java-app:demo .
$ docker images
$ docker run java-app:demo


Чтобы все это дело собрать, нам понадобится, по сути, одна команда – это docker build. При сборке указываем имя нашего образа и тег, который мы ему присваиваем (таким образом мы сможем версионировать различные сборки нашего приложения). Далее убедимся, что мы собрали образ, выполнив команду docker images. Для того чтобы запустить наше приложение выполним команду docker run.

Ура, всё прошло прекрасно, и мы молодцы… Или нет?



Да, миссию мы выполнили. Но баллы с нас снять есть за что. За что, спросите вы, и как избежать подобных промашек в следующий раз?

  • Базовый образ докера, который мы использовали, заявлен как нерекомендуемый (deprecated) и не поддерживается сообществом докера. Даже на DockerCon17 многим из мира Java EE знакомый Arun Gupta рекомендовал использовать в качестве базового образа openjdk (на что нам также намекают описание и даты обновлений образов https://hub.docker.com/_/openjdk/ ).
  • Для уменьшения размера лучше использовать образы на основе Alpine – образы на основе данного дистрибутива самые легковесные.
  • Компилируем при помощи образа jdk, запускаем с помощью jre (бережем место на диске, оно нам еще понадобится).

Вот теперь, можно считать, первый уровень пройден успешно. Поднимаемся на второй.

Полезная ссылка для прохождения первого уровня

Level 2




Имея дело с Java, мы, скорее всего, будем использовать Maven или Gradle. Поэтому было бы удобно как-то интегрировать наши системы сборки с Docker, чтобы иметь единую среду для сборки проекта и образов докера.

К счастью для нас, большинство плагинов уже написано — как для Maven, так и для Gradle.



Наиболее популярны плагины Maven для Docker fabric8io и spotify. Для Gradle мы можем использовать плагин Бенджамина Мушко – одного из разработчиков Gradle и автора книги «Gradle in Action».

Чтобы подключить докер в систему сборки приложения, в gradle-конфигурации достаточно создать несколько задач, которые будут собирать и запускать наши контейнеры, а также указать некую общую информацию из разряда — какой образ использовать в качестве базового и какое имя дать собранному образу.

Не будем многословными: возьмём плагин bmuschko/gradle-docker-plugin и Gradle (поклонники Maven и любители XML, подождите немного!).

Выполним наше первое задание, но теперь с помощью данного плагина. Основные части build.gradle, которые нам понадобятся:

docker {
  javaApplication {
    baseImage = 'openjdk:latest'
    tag = 'java-app:gradle'
  }
}

task createContainer(type: DockerCreateContainer) {
    dependsOn dockerBuildImage    
    targetImageId { dockerBuildImage.getImageId() }
}

task startContainer(type: DockerStartContainer) {    
    dependsOn createContainer 
    targetContainerId { createContainer.getContainerId() }
}

Запускаем команду gradle startContainer и видим сборку нашего образа и даже запуск контейнера. Но вместо желанного сообщения «Hello JavaMeetup!» получаем уведомление об успешном билде!



Мы где-то ошиблись? Не совсем, просто надо перенаправить вывод нашего контейнера в консоль:

task logContainer(type: DockerLogsContainer, dependsOn: startContainer) {
    targetContainerId { startContainer.getContainerId() }
    follow = true
    tailAll = true
    onNext {
        message -> logger.quiet message.toString() 
    }
}

Запускаем команду gradle logContainer и… Ура, заветное сообщение и пройденный уровень.



Вот, собственно говоря, и все. Нам даже не нужен Dockerfile (но лишним он не будет — мало ли, Gradle не окажется под рукой).

Двигаемся дальше!

Level 3




Скорее всего, в реальной жизни наше приложение будет делать что-то похитрее, чем вывод на экран «хелло ворлд». Поэтому на следующем уровне мы узнаем, как запустить сложное приложение – Spring веб-приложение, которое выведет нам какие-нибудь записи из базы.

Для того, чтобы поднять базу и само приложение, мы воспользуемся Docker Compose. Для начала создадим новый файл (очередной новый конфигурационный файл, вздохнете Вы, но нас же это не остановит?) – docker-compose.yml. В нем мы просто пропишем сервисы для поднятия образа базы и образа приложения. Docker Compose сам найдёт в текущей директории yml-файл и поднимет или соберет нужные нам контейнеры и образы.



Что бы все это дело запустилось, мы предварительно соберем образ. В данном примере использован maven-плагин для Docker(ура, XML!) от fabric8io – поэтому для начала выполним команду mvn install:

<plugin>
    <groupId>io.fabric8</groupId>
    <artifactId>docker-maven-plugin</artifactId>
    <version>0.20.1</version>
    <configuration>
        <images>
            <image>
                <name>app</name>
                <build>
                    <dockerFileDir>${project.basedir}/src/main/docker</dockerFileDir>
                    <assembly>
                        <mode>dir</mode>
                        <targetDir>/app</targetDir>
                        <descriptor>${project.basedir}/src/main/docker/assembly.xml</descriptor>
                    </assembly>
                </build>
            </image>
        </images>
    </configuration>
    <executions>
        <execution>
            <id>build</id>
            <phase>install</phase>
            <goals>
                <goal>build</goal>
            </goals>
        </execution>
    </executions>
</plugin>


Подождем пока наш проект и образ докера соберутся, перейдем в директорию с yml файликом и запустим команду docker-compose up -d.


Проверим, что оба наши контейнера запущены выполнив команду docker ps.

Дабы убедиться, что наше веб-приложение работает и достает что-то из базы, мы можем напрямую что-то изменить в базе, а затем перейти по адресу http://localhost:8080/ и увидеть желаемые данные.

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

У нас есть еще бонусный уровень. На нем мы немножко (совсем чуть-чуть) поиграем в Docker Swarm — а если быть точным, то в Docker Swarm Mode.

Bonus Level




Docker Swarm Mode не особенно-то сложен – это просто кластер из машинок, на которых стоит Docker. Для пользователя этот кластер выглядит как одна машина, и все команды работают почти так же, как если бы этого Docker Swarm’a не было.

В swarm-режиме можно запускать несколько экземпляров нашего приложения — для распределения нагрузки, например. Также здесь появляется такая абстракция как стек: с помощью Docker Swarm мы можем деплоить целую связку приложений как единое целое. И, аналогично обычному масштабированию, мы можем разворачивать несколько реплик стека.

Docker-команды в swarm mode:

$ docker service create --name japp --publish 8080:80 --replicas 3 java-app:demo
$ docker stack deploy -c docker-compose.yml javahelloworld

По сути, синтаксис команд предельно прост и напоминает создание обычных контейнеров.
Мы можем так же использовать docker compose:

$ docker-compose scale jm-app=3

Ну что же, за последние три уровня мы вроде как добились портативности java-приложений. Настало время перейти на последний уровень и попробовать подтвердить или опровергнуть утверждение о том, что Docker делает фразу «работает на моей машине» более не актуальной.

Final Level




Представим, что у нас тяжеловесное приложение. Либо большое количество микросервисов, которые могут находиться на одной машине. В этом случае Java-приложение (если быть точным, то JVM) непременно схватится с нашим маленьким синим китом в борьбе за ресурсы хостовой машины. Об этом, кстати, хорошо рассказано вот в этой статье.



На данном уровне будет меньше всего примеров кода, но будут разные конфигурации запуска докер-контейнера. Основными средствами изоляции процессов и ресурсов, используемыми Docker, являются cgroups и namespaces. Но основная проблема заключается в том, что джаве на все это немножко по барабану. Она у нас прожорливая и немного жадная, видит, что ресурсов на самом деле больше, даже если мы задаем ограничения по памяти при создании контейнера с java-приложением при помощи флага — memory. В этом можно убедиться просто выполнив команду free внутри контейнера. Отсюда следует довольно общая рекомендация для Java 8 задавать параметр –Xmx, а параметр –memory делать как минимум в два раза большим, чем –Xmx. Приятная новость с полей Java 9 – там поддержку cgroups добавили.



Промоделировать утечку памяти в джаве довольно просто. Мы просто возьмём уже готовый образ valentinomiazzo/jvm-memory-test и будем запускать с различными параметрами размера кучи и --memory для докера.


В первом случае памяти контейнеру у нас выдано меньше, чем java приложению, и мы получаем невнятную ошибку. А хотелось бы получить OutOfMemoryException. Если проинспектировать «убитый» контейнер, то можно заметить, что он был убит OOMKiller, а это может привести к непредсказуемым последствиям, зависанию java-процесса, неправильному закрытию ресурсов и всяким другим обидным вещам (я встречал даже kernel-panic). Не самое приятное, что может случиться.

Повышаем ставки, даем побольше памяти контейнеру. В этот раз можем словить OutOfMemoryException и после инспектирования убедиться, что OOMKiller наш контейнер не трогал, и от всех вышеперечисленных бед мы избавлены.


Последний уровень пройден, попробуем подытожить.

Resume


Итак, что же мы получили в результате, пройдя все уровни нашей игры? Что насчёт обещаний Docker свернуть нам горы?

Портативность не так хороша, как нам хотелось бы, но Java 9 вроде как обещает эти проблемы решить. С повышением гибкости все уже поприятнее: с докером мы получаем воспроизводимую конфигурацию окружения в коде, причём недалеко от основного кода. За такими вещами проще следить, нежели за тем, кто, что и когда подправил, заменил или испортил где-то под рутом. Да и в целом можно добиться неплохого сокращения ресурсов за счет возможности запускать множество контейнеров на одной машине — при тестировании это может быть критично.

То есть, я бы сказал, что для тестирования и/или разработки докер подходит идеально. А вот при работе в production нужно быть осторожнее, поскольку нагрузка в этом случае может оказаться гораздо выше. А получить падение по вине докера – это уж совсем неприятно.

Ну и напоследок — те самые флаги, которые нужны для того, чтобы подружить Java 9 с докером!



Game Over


Полезные ссылки для прохождения последнего уровня:

https://hackernoon.com/crafting-perfect-Java-docker-build-flow-740f71638d63
https://jaxenter.com/nobody-puts-Java-container-139373.html
https://github.com/valentinomiazzo/docker-jvm-memory-test

P.S. Все упомянутые и приведённые выше примеры можно найти здесь:
github.com/alexff91/Java-meetup-2018

Комментарии (30)


  1. acmnu
    28.02.2018 16:12
    +1

    Я не понимаю эту логику: зачем из gradle, запускать docker. Gradle, как Maven, сам по себе требует изоляции в CI, иначе вы становитесь зависимы от версии стоящей на CI узле (ну или машине разработчика).


    На мой взгляд логичный путь это:


    • запуск gradle/maven/ant в специальном сборочном образе. На выходе tgz с приложением, готовым к развороту.
    • запуск docker build с командой распаковки tgz.


    1. osigida
      01.03.2018 00:11

      а можно пример/подробнее как все это сделать красиво в мавене?


      1. acmnu
        01.03.2018 10:23

        Что сделать в Maven? А то я как-то не понял вопрос.


        1. osigida
          01.03.2018 12:25

          пример мавен проекта, что красиво складывает проект и депенденси в архив.


          1. acmnu
            01.03.2018 12:38

            Красиво, это не про maven, а по сути такое есть во многих открытых проектах. Например посмотрите проекты на apache.org.


            1. osigida
              02.03.2018 01:25

              дык о том и речь, с докер плагином можно все более менее красиво оформить. а как вот иначе?


              1. acmnu
                02.03.2018 10:15
                +2

                У нас явно разное представление о прекрасном :). Это зависит от того что вы используете в CI. На хабре даже была стать как такие вещи на make делать.


      1. Borz
        02.03.2018 09:55

        поиск вам в руки и результат в глаза


        1. osigida
          02.03.2018 22:41

          Мда, где то я вдел этого автора ;-)
          Тем не менее у этого подхода есть и свои минусы. Товарищъ выше говорит о вполне интересной альтернативе, когда проект собирает только джарники, а дальше они сдаются следующему шагу, который пакует докер или еще чего непотребного делает.
          Такой подход должен лучше ложится на современные представления о CI/CD.

          Ну да ладно, покумекаю на досуге.


          1. Borz
            03.03.2018 10:10

            именно. А что в вашем варианте не так? Поменяйте фазу для сборки docker-образа с package на install и получите что вам нужно — фаза package для сборки Jar, а фаза Install для сборки docker-образа. Install это же не только установка jar-файла в репозитарий Maven...


            А чтобы прям совсем разные запуски были — разнесите по профилям запуск сборки Jar и сборки Docker файла. Тогда можете остаться на одной фазе package, но в одном случае соберётся jar, а в другом docker


    1. ggo
      01.03.2018 10:15
      +1

      Gradle, как Maven, сам по себе требует изоляции в CI, иначе вы становитесь зависимы от версии стоящей на CI узле (ну или машине разработчика).

      gradlew (mvnw)… же…

      хотя зависимость от окружения все равно в тяжелых случаях остается.
      да и не в тяжелых, те же url'ы до maven-репозитариев (у суровых контор все закрыто)


      1. acmnu
        01.03.2018 10:21

        те же url'ы до maven-репозитариев (у суровых контор все закрыто)

        Вопрос не в закрытости, а в скорости. Дело в том, что среднее приложение на java тянет так много при сборке, что лучше ставить кеширующий Artifactory. Это может на порядок увеличивать скорость сборки.
        А вот локальный кеш Maven скорее помеха, когда у вас много чего собирается, многими машинами. В среднем этот кеш будет просто лежать мертвым грузом.


      1. acmnu
        01.03.2018 10:27

        gradlew (mvnw)… же…
        хотя зависимость от окружения все равно в тяжелых случаях остается.

        К тому же замусоривается CI узел, поскольку методов изоляции действий там не так много. Ещё плюсом запуска Gradle в Docker мне видится унификация и примитивность настройки CI узла: все что нужно это docker.


        1. alexff91 Автор
          01.03.2018 11:06

          Из своего опыта работы с Jenkins и Teamcity могу сказать, что сборка в докер-контейнерах обычно протекает немного медленнее, а если используются обычные агенты с предустановленными maven и gradle, с правильными настройками очистки/сброса кэша, то особых проблем с сборкой не замечал. Тут уже в зависимости от нужд надо решать, лучшая изоляция или чуть большая скорость. По поводу кеширующего Artifactory — при большом количестве агентов и сборок может быть высокая нагрузка на сеть и в итоге будет потеря в скорости сборки. Если количество сборок небольшое, то Ваш вариант с раздельными сборкой артефакта приложения в контейнере и упаковки его в отдельный образ — отличное решение.
          По поводу локальной сборки на машине разработчика — лично мне удобно иметь локально установленный maven и gradle и запускать сборки из ide, локально собирать в отдельном докер-контейнере мне кажется излишним.


          1. acmnu
            01.03.2018 11:17

            Медленее чем что? Чем поднятый Gradle Server? Да есть такое дело. Он действительно после прогрева работает бодрее.
            Ещё вариант, что у вас идет запись внутри контейнера, а это всегда медленно (из-за файловой системы). Надо маунтить временные папки (тот ещё гемор, честно говоря, поскольку сначала надо составить список). Либо вести сборку строго в tmpfs. Вот в этом случае скорость будет космической.


            По поводу кеширующего Artifactory — при большом количестве агентов и сборок может быть высокая нагрузка на сеть и в итоге будет потеря в скорости сборки.

            Зависит от степени синхронности запусков, но да, есть такое дело. В любом случае качать из интернетов каждый раз не айс.


  1. jsirex
    01.03.2018 10:56

    Level 1.
    Официальный openjdk приносит с собой 600 МБ (jre — 450 МБ).
    * Если на базе дебиана сделать — то итог 737 мб.
    * Если на базе алпайна — то 639 мб.
    Сильно выйграть не получиться. А это мы ещё апп внутрь не заливали.

    Сделать контейнер меньше всегда хочется и приветствуется. Но никогда не понимал попытку сэкономить на спичках.
    Как не читаешь на эту тему что-нибудь: так все работают в гуглах, фейсбуках, нетфликсах и у всех 10к+ машин и 100к+ контейнеров. Как дело доходит до практики — 3 сервера и 5 контейнеров :).


    1. acmnu
      01.03.2018 11:09

      Дело не столько в занимаемом месте, сколько во времени скачивания, а значит во времени холодного старта.


      Если правильно подойти к проблеме, то можно экономить за счет общих базовых слоев, но надо продумывать CD и вообще цикл жизни контейнера.


      1. jsirex
        01.03.2018 11:23

        Да, из-за скачивания холодный старт может быть на 1-2 секунды дольше. Например, вместо 12 секунд будет 14.

        У самого есть такая проблема: оптимизировать по каплям, где всё просто и понятно, когда что-то и так работает нормально. Не смотреть в сторону того, что создаёт 90% проблем потому, что там сложнее и чуть менее понятнее.


        1. acmnu
          01.03.2018 11:31

          Проблема не в самих 2 секундах, а в линейном росте задержки по числу контейнеров. Это концептуальная проблема построения кластеров, которая решатеся, только с помощью bittorrent. Мне приходилось работать над софтом, который должен разворачивать 2000 нод за разумное время. И там все было очень плохо.


    1. alexff91 Автор
      01.03.2018 11:22

      Ну, вообще цифры немного другие:
      openjdk 8-jdk-alpine 3b1fdb34c52a 10 hours ago 101MB
      openjdk 8-jre-alpine 5699ac7295f9 12 hours ago 81.4MB


      В 6-7 раз меньший размер образа позволяет запускать агенты сборки с меньшим объемом памяти, экономить место локально, по сравнению с 700~800 Мб — довольно большая разница.


      1. jsirex
        01.03.2018 11:27

        Я смотрел из статьи:
        openjdk 8 db77212ffe05 13 days ago 737MB
        openjdk jre e956268fd4ed 13 days ago 538MB

        Alpine меньше, но не из-за того что «alpine vs debian», т.к. между ними разница в 95 мб
        Могу только осторожно предпложить, что не каждый java проект заведётся на этом alpine образе


        1. acmnu
          01.03.2018 11:32

          Думаю что заведется, там скорее всего выкинули docs/examples/tests.


          1. jsirex
            01.03.2018 11:50

            И ещё чего-нибудь из этого списка (конечно, не всем нужен какой-нибудь gtk):

            Скрытый текст
            — libasound2 (>= 1.0.16)
            — libatk1.0-0 (>= 1.12.4)
            — libc6 (>= 2.7)
            — libcairo2 (>= 1.2.4)
            — libfontconfig1 (>= 2.11)
            — libfreetype6 (>= 2.2.1)
            — libgcc1 (>= 1:4.2)
            — libgdk-pixbuf2.0-0 (>= 2.22.0)
            — libglib2.0-0 (>= 2.35.9)
            — libgtk2.0-0 (>= 2.24.0)
            — libpango-1.0-0 (>= 1.22.0)
            — libpangocairo-1.0-0 (>= 1.14.0)
            — libpangoft2-1.0-0 (>= 1.14.0)
            — libstdc++6 (>= 4.1.1)
            — libx11-6
            — libxext6
            — libxi6
            — libxml2 (>= 2.7.4)
            — libxrender1
            — libxslt1.1 (>= 1.1.25)
            — libxtst6
            — libxxf86vm1


        1. alexff91 Автор
          01.03.2018 11:47

          Spring Boot и обычные jar-ники работают нормально, если нужно что-то хитрее — например обычный Tomcat, то тоже проблем по идее не будет.


  1. antosha4e
    01.03.2018 22:26

    Проблемы с памятью в докере можно решить и в 8 версии Java: Java SE support for Docker CPU and memory limits
    Главное взять нужный образ, например такой: openjdk:8-jre-alpine


    1. alexff91 Автор
      01.03.2018 22:39

      Да, Вы правы, спасибо за апдейт!
      Так же нашел интересную информацию, что все не так просто с этими флагами: blog.csanchez.org/2017/05/31/running-a-jvm-in-a-container-without-getting-killed


  1. Sheff2018
    01.03.2018 22:26

    А кто-нибудь пробовал собирать image c Oracle Server JRE по официальной инструкции Оракла? (см. Oracle blog)
    На сколько я понял, готовый имидж скачать нельзя, но в статье есть ссылка (в FAQ) как подключить его в свой образ (Link)
    К слову, получается, что использование Oracle JRE никак не нарушает лицензию

    Выдержка из блога
    Are there any licensing considerations for Oracle Java SE that are unique to Docker?
    No. Docker is a containerization platform and there are no unique or special restrictions in the license for use or redistribution as compared to any operating system, virtualization or packaging format. The Oracle JDK is widely used and adopted in the Docker ecosystem.


  1. relgames
    03.03.2018 02:42

    Почему --memory должен быть в 2 раза больше -Xmx?


    1. alexff91 Автор
      03.03.2018 10:56

      Данная рекомендация актуальна, если не используются флаги
      -XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeap
      В 2 раза больше, потому что при запуске с командой -m 150M докер-демон выделит так же 150 Мб под swap.
      https://developers.redhat.com/blog/2017/03/14/java-inside-docker/


      Но, в целом, увеличение памяти в 2 раза не всегда спасает, т.к. может быть зарезервировано больше памяти и лучше использовать данные флаги(они доступны с версии Java SE 8u131 и в 9-й версии Java) или комбинации ограничений для докер-контейнера по swap и memory.


      1. relgames
        03.03.2018 17:08

        Если -m 150m выделит оперативной 150, то почему Xmx должен быть 75? Почему не 140, например?