🏠: systemd

Ретрограды из Apache и ужасы нашего systemd

Cейчас на работе столкнулся с тем, что запускать Apache ActiveMQ с помощью Ansible нормально не получается (он останавливается, но обратно не стартует), и потребовалось перевести его на systemd-сервис. Запускается, конечно, ActiveMQ по классике костылестроения: вызывается штатный скрипт bin/activemq в 700 строк с аргументами start или stop, и создаётся PID-файл с номером процесса.

Ранее точно такой же запуск я встретил в Apache Tomcat. Та же песня со скриптами, но там ещё есть чудовищный лог-файл catalina.out и сопутствующие логи, для которых я делал правила logrotate.

Главное, Apache и не пытается эту проблему решить, их всё, видимо, устраивает. На дворе 2025 год, systemd есть везде, его преимущества уже понятны, кажется, всем, но в компании Apache до сих пор пребывают в реальности начала 2000-х и продолжают писать «очень функциональные скрипты инициализации», которые «делают ненужными сервисы», создают PID-файлы и предпочитают свой «Poor Man’s Daemon Supervisor»™ из спичек и желудей. Конечно, и startup.sh с shutdown.sh из Томката никуда не делись, все так и продолжают управлять этим приложением по старинке и совать скрипты в юнит-файлы systemd. Ладно, хотите своих «функциональных скриптов» — шут с вами, но хоть документацию по созданию нормального сервиса оставьте! Нет, дорогие коллеги, у нас только традиционные ценности:


Сообщение на сайте с документацией по Apache ActiveMQ

Недавно, ища способы создать нормальный сервис для Томката, я обнаружил блестящую статью, написанную ещё в прошлом десятилетии, мой примерный и чуть сокращённый перевод которой привожу ниже (с поправкой на то, что некоторые ссылки на примеры в оригинале уже не работают и т. п.). Там разбирается дикое нагромождение костылей при запуске Томката и корявые, но, тем не менее, широко распространённые способы решить проблему перевода этого приложения под управление systemd.

Не сказать, чтобы я был большим специалистом в java-приложениях, но, во всяком случае, после прочтения статьи хотя бы становится понятно, как правильно делать сервисы для них и в каком направлении двигаться. Лучше поздно, чем никогда, большое спасибо автору. У него на сайте есть ещё много интересных материалов на странице «Frequently Given Answers». Будем учиться делать правильно и красиво, пока есть возможность. Итак, статья:

Ужасы systemd: оборачивание Apache Tomcat во множество бесполезных слоёв

Jonathan de Boyne Pollard
Wrapping Apache Tomcat in many pointless extra layers

В ролях

В составе дистрибутива Tomcat в каталоге bin/ есть файлы startup.sh и shutdown.sh. Многие используют эти скрипты для запуска и остановки Томката, когда они настраивают сервис systemd. Выглядит это примерно так:

[Unit]
Description=Apache Tomcat 8
After=network.target

[Service]
Type=oneshot
ExecStart=/usr/tomcat8/bin/startup.sh
ExecStop=/usr/tomcat8/bin/shutdown.sh
RemainAfterExit=yes
User=tomcat8
Group=tomcat8

[Install]
WantedBy=multi-user.target

Мэтт Куинн идёт дальше и пишет ещё один скрипт, объединяющий эти два, чтобы вызывать его с параметрами start и stop.

[Unit]
Description=Tomcat 7 service
After=network.target

[Service]
WorkingDirectory=/usr/local/tomcat/bin
RemainAfterExit=yes
ExecStart=/bin/sh systemd-tomcat start
Type=forking
User=tomcat
Group=tomcat

[Install]
WantedBy=multi-user.target

Скрипт systemd-tomcat:

#!/bin/sh

case $1 in
  start)
    /usr/local/tomcat/bin/startup.sh
    ;;
  stop)
    /usr/local/tomcat/bin/shutdown.sh
    ;;
  *)
    echo "Unkown action '$1'"
    ;;
 esac

Также, есть люди, использующие скрипт daemon.sh в том же каталоге bin/, думая, что так лучше:

[Unit]
Description=Apache Tomcat Web Application Container
After=network.target

[Service]
Type=forking
ExecStart=/opt/java/tomcat/bin/daemon.sh start
ExecStop=/opt/java/tomcat/bin/daemon.sh stop

[Install]
WantedBy=multi-user.target

Всё это никуда не годится.

В чём проблема

Первый юнит-файл, приведённый выше, настраивает тип сервиса как oneshot, что неприменимо к Томкату, который должен постоянно работать после того, как будет запущен — это не сценарий, который отработал и больше не висит в процессах. Так как такой тип сервиса, очевидно, не работал как положено, автор добавил костыль в виде RemainAfterExit=true, который заставляет systemd продолжать рассматривать сервис как активный после завершения всех его процессов.

Простая истина, на которую указал Адам Янг ещё в 2011 году, заключается в том, что Томкат — это Java-приложение. Чтобы запустить его, нужно задать несколько переменных окружения и запустить /usr/bin/java с классом org.apache.catalina.startup.Bootstrap, параметром start и некоторыми прочими аргументами Java, которые представляют собой всего лишь устаревшие способы передачи переменных окружения в приложение. «Но», — можно спросить — «что тогда делают startup.sh, shutdown.sh и daemon.sh?» Ответ заключается в том, что это просто обёртки, и они не только практически совершенно излишни, но и в некоторых случаях значительно усложняют конструкцию и делают её неустойчивой.

Если посмотреть на содержимое скриптов startup.sh и shutdown.sh, то становится понятно, что они оба запускают третий скрипт catalina.sh с соответствующим аргументом start или stop. Если вспомнить подход Мэтта Куинна, то он становится абсолютно избыточным: его собственный общий скрипт вызывает startup.sh и shutdown.sh, а те, в свою очередь, вызывают другой общий скрипт catalina.sh. «Значит, можно просто убрать эти две лишних ступени и вызывать catalina.sh напрямую?» Нет, это не так. Catalina.sh содержит очень много лишнего (логика для Cygwin, MacOS и т. д., не имеющая отношения к systemd) или неприменимого, потому что к этому файлу обращение всегда идёт через startup.sh и shutdown.sh. Если отбросить всё это и оставить только суть, то catalina.sh:

  • Читает setenv.sh (если он есть) и setclasspath.sh, чтобы задать переменные окружения.
  • Устанавливает несколько собственных переменных окружения.
  • Для старта приложения запускается процесс /usr/bin/java с переменными и аргументом start, а также пишется PID-файл, использующийся для страховки от двойного запуска.
  • Для остановки приложения запускается процесс /usr/bin/java с переменными и аргументом stop, идёт ожидание остановки процесса и стирается PID-файл.

Оказывается, catalina.sh — тоже посредник! Это ни что иное, как «диспетчер служб для бедных», написанный (как всегда, плохо) на скриптах командной оболочки. Но у нас уже есть гораздо лучший диспетчер — systemd, под управлением которого мы и пытаемся изначально запустить Томкат, так что всё это ни к чему. Совершенно не нужен бессмысленный PID-файл — верный спутник костылей из скриптов оболочки; это опасный, шаткий и ненадежный механизм. Нормальные менеджеры сервисов не нуждаются в этом. Они просто запоминают ID процесса, который сами и порождают.

Вернёмся опять к скрипту М. Куинна, который вызывает штатные скрипты Томката как дочерние процессы вместо их непосредственного исполнения. Есть 3 модели, которым должен следовать демон, управляемый systemd, и этот подход не соответствует ни одной из них.

  1. Type=simple — процесс, порождаемый systemd, является демоном. Но здесь это эфемерный процесс, завершающийся, как только catalina.sh start запустит свой дочерний процесс и запишет PID-файл.
  2. Type=forking — демоном является дочерний процесс, непосредственно порождённый, в свою очередь, процессом, порождаемым systemd. Опять же, не в этом случае. Здесь дочерний процесс — это startup.sh, и остаётся пройти ещё один дочерний процесс и выход из родительского для достижения реального демона.
  3. Type=notify — гораздо более гибкая модель в плане определения, какой процесс в конечном итоге будет демоном. Но для работы этого режима нужен механизм прямой связи между systemd и демоном — протокол sd_notify, который в Томкате не реализован.

Непосредственный вызов catalina.sh мог бы соответствовать варианту Type=forking, но здесь всё равно остаётся кривой, ненадёжный и совершенно ненужный механизм с PID-файлом. Можно сделать лучше: использовать сервис Type=simple, где нет никаких PID-файлов, с которыми запускаемое Java-приложение никак не связано.

Ещё один ужас catalina.sh — это логирование. Потоки стандартного вывода и вывода ошибок демона перенаправлены в лог-файл, что является заведомо плохим методом: невозможно ни ограничить размер лога, ни сделать ротацию, ни сжать его. Файл лога растёт до бесконечности, пока демон работает. Нелепо так делать при наличии systemd, который может направлять вывод в разные файлы журнала, при этом они будут ограничены по размеру и могут быть ротированы.

Что касается последнего способа с вызовом daemon.sh, то это аналогичный случай для другой грустной истории. Дело в том, что Apache в своей документации рекомендует использовать jsvc для создания демона. Но, как выясняется, официальная документация не всегда верна и актуальна.

Как сделать правильно

Правильный подход убирает все нагромождения. Единственное, что нужно сделать — и это некоторые люди, применяющие вышеописанные подходы, уже сделали — создать файл, например, /etc/default/tomcat, со всеми локальными настройками переменных окружения, описывающих вещи типа «где на этой неделе находится среда выполнения Java».

CATALINA_HOME=/usr/share/tomcat
CATALINA_BASE=/usr/share/tomcat
CATALINA_TMPDIR=/var/tmp/tomcat
JAVA_HOME=/usr/share/java/jre-x.y.z

Systemd умеет читать переменные окружения из файла и запускать Java-приложение. Если приложение вызывается с параметром start, то оно становится демоном. Systemd будет корректно отслеживать PID безо всякого создания дурацких PID-файлов, организует логирование потоков стандартного вывода и вывода ошибок, а также обеспечит одновременный запуск только одного демона.

[Unit]
Description=Apache Tomcat Web Application Container

[Service]
User=tomcat
Group=tomcat
EnvironmentFile=-/etc/default/tomcat
ExecStart=/usr/bin/env ${JAVA_HOME}/bin/java \
$JAVA_OPTS $CATALINA_OPTS \
-classpath ${CLASSPATH} \
-Dcatalina.base=${CATALINA_BASE} \
-Dcatalina.home=${CATALINA_HOME} \
-Djava.endorsed.dirs=${JAVA_ENDORSED_DIRS} \
-Djava.io.tmpdir=${CATALINA_TMPDIR} \
-Djava.util.logging.config.file=${CATALINA_BASE}/conf/logging.properties \
-Djava.util.logging.manager=org.apache.juli.ClassLoaderLogManager \
org.apache.catalina.startup.Bootstrap \
start
ExecStop=/usr/bin/env ${JAVA_HOME}/bin/java \
$JAVA_OPTS \
-classpath ${CLASSPATH} \
-Dcatalina.base=${CATALINA_BASE} \
-Dcatalina.home=${CATALINA_HOME} \
-Djava.endorsed.dirs=${JAVA_ENDORSED_DIRS} \
-Djava.io.tmpdir=${CATALINA_TMPDIR} \
-Djava.util.logging.config.file=${CATALINA_BASE}/conf/logging.properties \
-Djava.util.logging.manager=org.apache.juli.ClassLoaderLogManager \
org.apache.catalina.startup.Bootstrap \
stop

[Install]
WantedBy=multi-user.target

Эти 32 строки юнит-файла заменяют почти 800 строк вложенных друг в друга скриптов, и то — 32 строки набралось только потому, что они были разделены для удобства чтения.

Может быть, в будущем авторы Томката, наконец, заметят, что Адам Янг писал в 2011 году, и научат свой Томкат просто самостоятельно читать эти чёртовы переменные непосредственно из окружения — ведь программы, написанные на Java, могут это делать. Также, возможно, люди найдут способ, чтобы сама Java всегда находилась в одном и том же месте. В таком будущем всё станет гораздо проще.

[Unit]
Description=Apache Tomcat Web Application Container

[Service]
User=tomcat
Group=tomcat
EnvironmentFile=-/etc/default/tomcat
ExecStart=/usr/bin/java $JAVA_OPTS $CATALINA_OPTS org.apache.catalina.startup.Bootstrap start
ExecStop=/usr/bin/java $JAVA_OPTS org.apache.catalina.startup.Bootstrap stop

[Install]
WantedBy=multi-user.target

Так как вместо вызова конечного файла мы вызываем /usr/bin/java (или /usr/bin/env ${JAVA_HOME}/bin/java для того, чтобы узнать, где на этой неделе установлена java), в логах systemd из-за этого имя демона отображается как «java» или «env». Исправить эту ситуацию можно, указав опцию SyslogIdentifier=tomcat в разделе [Service].

Бонус-трек

Оказывается, запуск Томката под daemontools (набор инструментов для управления UNIX-сервисами, появившийся задолго до systemd — прим. перев.) даёт подсказку, как запустить его и под systemd. Там catalina.sh запускается не с параметром start, а с параметром run — в этом случае catalina.sh напрямую становится демоном, а не создаёт его.

Этот путь с run, очевидно, предназначен для систем, сделанных для запуска (exec) демонов (как семейство daemontools), а не для их создания (spawn), и тут нет возни с PID-файлом и путаницы с логированием, потому что демоны, работающие в daemontools с 1997 года, не занимались подобным. Тем не менее, этот подход можно применять и для systemd.

[Unit]
Description=Apache Tomcat Web Application Container

[Service]
User=tomcat
Group=tomcat
ExecStart=/usr/share/tomcat/bin/catalina.sh run

[Install]
WantedBy=multi-user.target

P. S. Сервис для ActiveMQ я создал. Для этого я скопировал файл bin/setenv в conf/env и оставил там только переменные. Недостающими параметрами я «нарастил» переменную ACTIVEMQ_OPTS в последней строке.

# Active MQ installation dirs
ACTIVEMQ_HOME="/home/user/apache-activemq-6.1.7"
ACTIVEMQ_BASE="$ACTIVEMQ_HOME"
ACTIVEMQ_CONF="$ACTIVEMQ_BASE/conf"
ACTIVEMQ_DATA="$ACTIVEMQ_BASE/data"
ACTIVEMQ_TMP="$ACTIVEMQ_BASE/tmp"

ACTIVEMQ_OPTS_MEMORY="-Xms64M -Xmx1G"
ACTIVEMQ_OPTS="$ACTIVEMQ_OPTS_MEMORY -Djava.util.logging.config.file=logging.properties -Djava.security.auth.login.config=$ACTIVEMQ_CONF/login.config"
ACTIVEMQ_OUT="/dev/null"
ACTIVEMQ_SUNJMX_START="$ACTIVEMQ_SUNJMX_START -Dcom.sun.management.jmxremote"
ACTIVEMQ_SUNJMX_CONTROL=""
ACTIVEMQ_QUEUEMANAGERURL="--amqurl tcp://localhost:61616"
ACTIVEMQ_SSL_OPTS=""
ACTIVEMQ_KILL_MAXSECONDS=30
ACTIVEMQ_USER=""
JAVACMD="auto"

ACTIVEMQ_OPTS="$ACTIVEMQ_OPTS -Djava.awt.headless=true -Djava.io.tmpdir=tmp -Dactivemq.classpath=${ACTIVEMQ_CONF}: -Djolokia.conf=file:$ACTIVEMQ_CONF/jolokia-access.xml --add-reads=java.xml=java.logging"

Юнит-файл с указанием файла с переменными:

[Unit]
Description=ActiveMQ
After=network.target

[Service]
Type=exec
EnvironmentFile=/home/user/apache-activemq-6.1.7/conf/env
SyslogIdentifier=activemq
ExecStart=/usr/bin/java -jar /home/user/apache-activemq-6.1.7/bin/activemq.jar start
ExecStop=/usr/bin/java -jar /home/user/apache-activemq-6.1.7/bin/activemq.jar stop
Restart=on-failure
RestartSec=3s

[Install]
WantedBy=multi-user.target

Результат: служба работает без скриптов и PID-файлов! Ошибки в логе не в счёт, просто тестовое приложение на моей домашней виртуалке совершенно не настроено.

user@k1:~/ansible$ systemctl --user status activemq --no-pager
● activemq.service - ActiveMQ
     Loaded: loaded (/home/user/.config/systemd/user/activemq.service; enabled; preset: enabled)
     Active: active (running) since Sun 2025-10-12 10:58:11 MSK; 2min 31s ago
   Main PID: 9913 (java)
      Tasks: 49 (limit: 4604)
     Memory: 170.9M (peak: 171.4M)
        CPU: 6.372s
     CGroup: /user.slice/user-1000.slice/user@1000.service/app.slice/activemq.service
             └─9913 /usr/bin/java -jar /home/user/apache-activemq-6.1.7/bin/activemq.jar start

Oct 12 10:58:14 k1 activemq[9913]:         at org.apache.activemq.console.command.AbstractCommand.execute(AbstractCommand.java:63) [activemq-console-6.1.7.jar:6.1.7]
Oct 12 10:58:14 k1 activemq[9913]:         at org.apache.activemq.console.command.ShellCommand.runTask(ShellCommand.java:154) [activemq-console-6.1.7.jar:6.1.7]
Oct 12 10:58:14 k1 activemq[9913]:         at org.apache.activemq.console.command.AbstractCommand.execute(AbstractCommand.java:63) [activemq-console-6.1.7.jar:6.1.7]
Oct 12 10:58:14 k1 activemq[9913]:         at org.apache.activemq.console.command.ShellCommand.main(ShellCommand.java:104) [activemq-console-6.1.7.jar:6.1.7]
Oct 12 10:58:14 k1 activemq[9913]:         at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method) ~[?:?]
Oct 12 10:58:14 k1 activemq[9913]:         at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77) ~[?:?]
Oct 12 10:58:14 k1 activemq[9913]:         at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) ~[?:?]
Oct 12 10:58:14 k1 activemq[9913]:         at java.base/java.lang.reflect.Method.invoke(Method.java:569) ~[?:?]
Oct 12 10:58:14 k1 activemq[9913]:         at org.apache.activemq.console.Main.runTaskClass(Main.java:262) [activemq.jar:6.1.7]
Oct 12 10:58:14 k1 activemq[9913]:         at org.apache.activemq.console.Main.main(Main.java:115) [activemq.jar:6.1.7]