По профилю работы DevOps-инженером я часто занимаюсь автоматизацией установки и настройки разнообразных IT-систем в различных средах: от контейнеров до облака. Приходилось работать со многими системами, основанными на Java-стеке: от небольших (вроде Tomcat), до масштабных (Hadoop, Cassandra и др.).
При этом почти каждая такая система, даже самая простая, почему-то имела сложную неповторимую систему запуска. Как минимум, это были многострочные shell-скрипты, как в Tomcat, а то и целые фреймворки, как в Hadoop. Мой нынешний "пациент" из этой серии, вдохновивший меня на написание этой статьи — хранилище артефактов Nexus OSS 3, скрипт запуска которого занимает ~400 строк кода.
Непрозрачность, избыточность, запутанность startup-скриптов создает проблемы даже при ручной установке одного компонента на локальной системе. А теперь представьте, что набор таких компонентов и сервисов нужно запаковать в Docker-контейнер, попутно написав еще один слой абстракции для мало-мальски адекватного оркестрирования, развернуть в Kubernetes-кластере и реализовать этот процесс в виде CI/CD-пайплайна...
Короче говоря, давайте на примере упомянутого Nexus 3 разберемся, как вернуться из лабиринта shell-скриптов к чему-то более похожему на java -jar <program.jar>
, учитывая наличие удобных современных DevOps-инструментов.
Откуда такая сложность?
Если в двух словах, то в древние времена, когда при упоминании UNIX не переспрашивали: "в смысле, Linux?", не было Systemd и Docker и др., для управления процессами использовались переносимые shell-скрипты (init-скрипты) и PID-файлы. Init-скрипты задавали необходимые настройки окружения, которые в разных UNIX-ах были свои, и, в зависимости от аргументов, запускали процесс или перезапускали/останавливали его с помощью ID из PID-файла. Подход простой и понятный, но эти скрипты переставали работать при каждой нестандартной ситуации, требуя ручного вмешательства, не позволяли запустить несколько копий процесса… но не суть.
Так вот, если внимательно посмотреть на упомянутые выше startup-скрипты в Java-проектах, то можно в них разглядеть явные признаки этого доисторического подхода, включая даже упоминания SunOS, HP-UX и других UNIX-ов. Как правило, такие скрипты делают примерно следующее:
- используют синтаксис POSIX shell со всеми его костылями для UNIX/Linux-переносимости
- определяют версию и релиз ОС через
uname
,/etc/*release
и т.п. - ищут JRE/JDK в укромных уголках файловой системы и выбирают наиболее "подходящую" версию по хитрым правилам, иногда еще и специфичным для каждой ОС
- рассчитывают числовые параметры JVM, например, размер памяти (
-Xms
,-Xmx
), количество потоков GC и др. - оптимизируют JVM через
-XX
-параметры с учетом специфики выбранной версии JRE/JDK - отыскивают свои компоненты, библиотеки, пути к ним по окружающим директориям, конфигурационным файлам и т.п.
- настраивают окружение: ulimits, переменные среды и т.п.
- генерируют CLASSPATH циклом типа:
for f in $path/*.jar; do CLASSPATH="${CLASSPATH}:$f"; done
- парсят аргументы командной строки:
start|stop|restart|reload|status|...
- собирают Java-команду, которую в итоге нужно выполнить, из перечисленного выше
- и, наконец, выполняют эту Java-команду. Зачастую при этом явно или неявно используются все те же пресловутые PID-файлы,
&
,nohup
, специальные TCP-порты и прочие трюки из прошлого столетия (см. пример из Karaf)
Упомянутый скрипт запуска Nexus 3 — подходящий пример такого скрипта.
По сути, вся перечисленная выше скриптовая логика, как бы, пытается заменить системного администратора, который бы установил и настроил все вручную под конкретную систему от начала до конца. Но вообще любые требования самых разнообразных систем учесть, в принципе, невозможно. Поэтому получается, наоборот, головная боль, как для разработчиков, которым нужно поддерживать эти скрипты, так и для системных инженеров, которым в этих скриптах потом нужно разбираться. С моей точки зрения, системному инженеру гораздо проще один раз разобраться в параметрах JVM и настроить ее как надо, чем каждый раз при установке новой системы разбираться в тонкостях ее startup-скриптов.
Что же делать?
У — про — щать! KISS и YAGNI нам в руки. Тем более, что на дворе 2018-й год, а это значит, что:
- за очень редким исключением, UNIX == Linux
- задача управления процессами решена как для отдельного сервера (Systemd, Docker), так и для кластеров (Kubernetes и т.п.)
- появилась куча удобных инструментов управления конфигурациями (Ansible и др.)
- в администрирование пришла и уже основательно закрепилась тотальная автоматизация: вместо ручной настройки хрупких неповторимых "серверов-снежинок" теперь можно автоматически собирать унифицированные репродуцируемые виртуальные машины и контейнеры с помощью целого ряда удобных инструментов, включая упомянутые выше Ansible и Docker
- повсеместно используются инструменты сбора runtime-статистики, как для самой JVM (пример), так и для Java-приложения (пример)
- и, самое главное, появились специалисты: системные и DevOps-инженеры, которые умеют использовать перечисленные выше технологии и понимают, как правильно установить JVM на конкрентной системе и впоследствии подстроить ее с учетом собранной runtime-статистики
Так что давайте снова пройдемся по функционалу startup-скриптов еще раз с учетом перечисленных пунктов, не пытаясь при этом делать работу за системного инженера, и уберем оттуда все "лишнее".
синтаксис POSIX shell⇒/bin/bash
определение версии ОС⇒ UNIX == Linux, если есть ОС-специфичные параметры, можно описать их в документациипоиск JRE/JDK⇒ у нас единственная версия, и это OpenJDK (ну или Oracle JDK, если уж очень нужно),java
и компания есть в стандартном системном путирасчет числовых параметров JVM, тюнинг JVM⇒ это можно описать в документации по скалированию приложенияпоиск своих компонентов и библиотек⇒ описать структуру приложения и способы ее настройки в документациинастройка окружения⇒ описать в документации требования и особенностигенерация CLASSPATH⇒-cp path/to/my/jars/*
или даже, вообще, Uber-JARпарсинг аргументов командной строки⇒ аргументов не будет, т.к. обо всем, кроме запуска, позаботится менеджер процессов- сборка Java-команды
- выполнение Java-команды
В итоге, нам нужно просто собрать и выполнить Java-команду вида java <opts> -jar <program.jar>
с помощью выбранного менеджера процессов (Systemd, Docker и т.п.). Все параметры и опции (<opts>
) мы оставляем на усмотрение системного инженера, который подстроит их под конкретную среду. Если список опций <opts>
довольно длинный, можно вновь вернуться к идее startup-скрипта, но, в этом случае, максимально компактного и декларативного, т.е. не содержащего никакой программной логики.
Пример
В качестве примера давайте посмотрим, как можно упростить скрипт запуска Nexus 3.
Самый простой вариант, чтобы не залезать в дебри этого скрипта — просто запустить его в реальных условиях (./nexus start
) и посмотреть на результат. Например, можно найти полный список аргументов запущенного приложения в таблице процессов (через ps -ef
), или запустить скрипт в режиме отладки (bash -x ./nexus start
), чтобы наблюдать весь процесс его выполнения и в самом конце — команду запуска.
/usr/java/jdk1.8.0_171-amd64/bin/java -server -Dinstall4j.jvmDir=/usr/java/jdk1.8.0_171-amd64 -Dexe4j.moduleName=/home/nexus/nexus-3.12.1-01/bin/nexus -XX:+UnlockDiagnosticVMOptions -Dinstall4j.launcherId=245 -Dinstall4j.swt=false -Di4jv=0 -Di4jv=0 -Di4jv=0 -Di4jv=0 -Di4jv=0 -Xms1200M -Xmx1200M -XX:MaxDirectMemorySize=2G -XX:+UnlockDiagnosticVMOptions -XX:+UnsyncloadClass -XX:+LogVMOutput -XX:LogFile=../sonatype-work/nexus3/log/jvm.log -XX:-OmitStackTraceInFastThrow -Djava.net.preferIPv4Stack=true -Dkaraf.home=. -Dkaraf.base=. -Dkaraf.etc=etc/karaf -Djava.util.logging.config.file=etc/karaf/java.util.logging.properties -Dkaraf.data=../sonatype-work/nexus3 -Djava.io.tmpdir=../sonatype-work/nexus3/tmp -Dkaraf.startLocalConsole=false -Di4j.vpt=true -classpath /home/nexus/nexus-3.12.1-01/.install4j/i4jruntime.jar:/home/nexus/nexus-3.12.1-01/lib/boot/nexus-main.jar:/home/nexus/nexus-3.12.1-01/lib/boot/org.apache.karaf.main-4.0.9.jar:/home/nexus/nexus-3.12.1-01/lib/boot/org.osgi.core-6.0.0.jar:/home/nexus/nexus-3.12.1-01/lib/boot/org.apache.karaf.diagnostic.boot-4.0.9.jar:/home/nexus/nexus-3.12.1-01/lib/boot/org.apache.karaf.jaas.boot-4.0.9.jar com.install4j.runtime.launcher.UnixLauncher start 9d17dc87 '' '' org.sonatype.nexus.karaf.NexusMain
Вначале применим к ней пару простых приемов:
- поменяем
/the/long/and/winding/road/to/my/java
наjava
, ведь она есть в системном пути - поместим список Java-параметров в отдельный массив, отсортируем его и уберем дубликаты
JAVA_OPTS = (
'-server'
'-Dexe4j.moduleName=/home/nexus/nexus-3.12.1-01/bin/nexus'
'-Di4j.vpt=true'
'-Di4jv=0'
'-Dinstall4j.jvmDir=/usr/java/jdk1.8.0_171-amd64'
'-Dinstall4j.launcherId=245'
'-Dinstall4j.swt=false'
'-Djava.io.tmpdir=../sonatype-work/nexus3/tmp'
'-Djava.net.preferIPv4Stack=true'
'-Djava.util.logging.config.file=etc/karaf/java.util.logging.properties'
'-Dkaraf.base=.'
'-Dkaraf.data=../sonatype-work/nexus3'
'-Dkaraf.etc=etc/karaf'
'-Dkaraf.home=.'
'-Dkaraf.startLocalConsole=false'
'-XX:+LogVMOutput'
'-XX:+UnlockDiagnosticVMOptions'
'-XX:+UnlockDiagnosticVMOptions'
'-XX:+UnsyncloadClass'
'-XX:-OmitStackTraceInFastThrow'
'-XX:LogFile=../sonatype-work/nexus3/log/jvm.log'
'-XX:MaxDirectMemorySize=2G'
'-Xms1200M'
'-Xmx1200M'
'-classpath /home/nexus/nexus-3.12.1-01/.install4j/i4jruntime.jar:/home/nexus/nexus-3.12.1-01/lib/boot/nexus-main.jar:/home/nexus/nexus-3.12.1-01/lib/boot/org.apache.karaf.main-4.0.9.jar:/home/nexus/nexus-3.12.1-01/lib/boot/org.osgi.core-6.0.0.jar:/home/nexus/nexus-3.12.1-01/lib/boot/org.apache.karaf.diagnostic.boot-4.0.9.jar:/home/nexus/nexus-3.12.1-01/lib/boot/'
)
java ${JAVA_OPTS[*]} com.install4j.runtime.launcher.UnixLauncher start 9d17dc87 '' '' org.sonatype.nexus.karaf.NexusMain
Теперь можно идти в глубину.
Install4j — это такой графический Java-инсталлятор. Похоже, что он используется для начальной установки системы. На сервере он нам не нужен, убираем.
Договоримся о размещении компоненты и данные Nexus на файловой системе:
- поместим само приложение в
/opt/nexus-<version>
- для удобства создадим символическую ссылку
/opt/nexus -> /opt/nexus-<version>
- сам скрипт разместим вместо исходного как
/opt/nexus/bin/nexus
- все данные нашего Nexus будут лежать на отдельной файловой системе, смонтированной как
/data/nexus
Само создание каталогов и ссылок — удел систем управления конфигурациями (на все про все 5-10 строчек в Ansible), поэтому оставим эту задачу системным инженерам.
Пусть наш скрипт при запуске меняет рабочий каталог на /opt/nexus
— тогда мы сможем поменять пути к компонентам Nexus на относительные.
Опции вида -Dkaraf.*
— это настройки Apache Karaf, OSGi-контейнера, в который, очевидно, "запакован" наш Nexus. Поменяем karaf.home
, karaf.base
, karaf.etc
и karaf.data
соответственно размещению компонентов, по возможности используя относительные пути.
Видя, что CLASSPATH состоит из списка jar-файлов, которые лежат в одном каталоге lib/
, заменим весь этот список на lib/*
.
Поменяем java
на exec java
, чтобы наш скрипт на запускал java
как дочерний процесс (менеджер процессов этот дочерний процесс просто не увидит), а "заменял" себя на java
(описание exec).
Посмотрим, что нас получилось:
#!/bin/bash
JAVA_OPTS=(
'-Xms1200M'
'-Xmx1200M'
'-XX:+UnlockDiagnosticVMOptions'
'-XX:+LogVMOutput'
'-XX:+UnsyncloadClass'
'-XX:LogFile=/data/nexus/log/jvm.log'
'-XX:MaxDirectMemorySize=2G'
'-XX:-OmitStackTraceInFastThrow'
'-Djava.io.tmpdir=/data/nexus/tmp'
'-Djava.net.preferIPv4Stack=true'
'-Djava.util.logging.config.file=etc/karaf/java.util.logging.properties'
'-Dkaraf.home=.'
'-Dkaraf.base=.'
'-Dkaraf.etc=etc/karaf'
'-Dkaraf.data=/data/nexus/data'
'-Dkaraf.startLocalConsole=false'
'-server'
'-cp lib/boot/*'
)
cd /opt/nexus
&& exec java ${JAVA_OPTS[*]} org.sonatype.nexus.karaf.NexusMain
Итого всего 25 строчек вместо >400, прозрачно, понятно, декларативно, никакой лишней логики. При необходимости этот скрипт легко превратить в темплейт для Ansible/Puppet/Chef и добавить туда только ту логику, которая нужна для конкретной ситуации.
Этот скрипт можно использовать в качестве ENTRYPOINT в Dockerfile или вызывать в unit-файле Systemd, заодно подстроив там ulimits и другие системные параметры, например:
[Unit]
Description=Nexus
After=network.target
[Service]
Type=simple
LimitNOFILE=1048576
ExecStart=/opt/nexus/bin/nexus
User=nexus
Restart=on-abort
[Install]
WantedBy=multi-user.target
Заключение
Какие выводы можно сделать из этой статьи? В принципе, все сводится к паре пунктов:
- У каждой системы свое предназначение, т.е., не нужно забивать гвозди молотком.
- Простота (KISS, YAGNI) рулит — реализовывать только то, что нужно для данной конкретной ситуации.
- И самое главное: круто, что есть IT-специалисты разного профиля. Давайте будем взаимодействовать и делать наши IT-системы проще, понятнее и лучше! :)
Спасибо за внимание! Буду рад обратной связи и конструктивной дискуссии в комментариях.
Автор: tagirb