Как киту съесть Java-приложение и не подавиться
Здравствуйте, уважаемые хабравчане! Сегодня я хотел бы рассказать о том, как «скормить» Java-приложение докеру, как при этом лучше действовать, а чего делать не стоит. Я занимаюсь разработкой на Java более 10 лет, и последние года три провёл в самом тесном общении с Docker, так что у меня сложилось определённое представление о том, что он может и чего не может. Но ведь гипотезы надо проверять на практике, не так ли?
Я представил весь процесс как старую добрую компьютерную игру с тёплым ламповым пиксель-артом.
Начнем мы, как и полагается любой игре, с некоторого брифинга. В качестве вводной возьмем немного рекламы докера.
На сайте докера можно ознакомиться с рядом рекламных посулов – а именно, с обещанием увеличить скорость разработки и развертывания аж в 13 раз и повысить портативность в разработке (в частности, избавиться о сакраментального «работает на моей машине»). Но соответствует ли это реальности?
Сейчас мы попробуем доказать/опровергнуть эти утверждения.
Level 1
Так как мы находимся в игре, то начнем, как и положено, с самого простого уровня.
Какова наша миссия на первом уровне? Наверное, для многих это что-то очень тривиальное и понятное: мы должны «завернуть» в Docker примитивнейшее Java-приложение.
Для этого нам понадобится простой Java-класс, который выводит сакраментальное Hello JavaMeetup! Также для того чтобы создать docker-образ нам понадобится Dockerfile. По синтаксису он предельно прост – в качестве базового образа используем java:8, добавляем наш Java-класс (команда ADD), компилируем его (при помощи команды RUN) и указываем команду, которая выполнится при запуске контейнера (команда CMD).
FROM java:8 ADD HelloWorld.java . RUN javac HelloWorld.java CMD ["java", "HelloWorld"]
$ 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:
io.fabric8 docker-maven-plugin 0.20.1 app $/src/main/docker dir /app $/src/main/docker/assembly.xml build install build
Подождем пока наш проект и образ докера соберутся, перейдем в директорию с 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
Полезные ссылки для прохождения последнего уровня:
P.S. Все упомянутые и приведённые выше примеры можно найти здесь:
github.com/alexff91/Java-meetup-2018