🏠: it

Ретрограды из 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]

Формирование конфигурационного файла шаблоном Ansible

Последнее время довольно много работаю с Ансиблом. Интересный и зачастую удобный инструмент автоматизации, но довольно специфический в обращении и требующий привыкания.

Одним из наиболее полезных источников по Ансиблу для меня является книга «Mastering Ansible», где в достаточно краткой форме рассматриваются приёмы его использования, более сложные, чем в большинстве интернет-роликов и статей. Но и в роликах порой встречаются жемчужины, например, «Ansible for Network Configuration Templates» Дэвида Малера. Ну, а начинал я знакомство с забавной, но от этого не делающейся менее полезной, серии видео «Ansible на русском языке» на канале ADV-IT.

Возникла задача: автоматизировать формирование конфигурационного файла Apache ActiveMQ, где содержится несколько сотен очередей, перезапустить сервис с новыми настройками, а в случае сбоя запуска или ошибок в логе вернуть всё как было.

Очереди в конфигурационном файле activemq.xml выглядели так:

<!-- ESFL.CLNS -->
<route>
    <from uri="activemq:queue:IN_RQ_CLNTSEARCH?concurrentConsumers=1"/>
    <to uri="F1SIPQM01-BEAN:queue:IN.RQ.CLNTSEARCH_01.02.03?exchangePattern=InOnly"/>
</route>
<route>
    <from uri="F1SIPQM01-BEAN:queue:OUT.RS.CLNTSEARCH_01.02.03.RKF?disableReplyTo=true&concurrentConsumers=5"/>
    <to uri="activemq:queue:OUT_RS_CLNTSEARCH?exchangePattern=InOnly"/>
</route>

Особенности конфигурации:

  • Первая строка-комментарий — это название группы очередей. Нужно для ориентирования.
  • Если с activemq начинается строка from, то это исходящая очередь, если to — входящая. В другой строке на этом месте прописывается имя внешнего подключения.
  • Параметр concurrentConsumers присутствует во всех строках from. В большинстве случаев имеет значение «1», но могут быть варианты.
  • Параметр disableReplyTo=true должен быть в строке from, если очередь входящая.
  • Параметр exchangePattern=InOnly должен быть во всех строках to.

В формировании конфигурационных файлов неоценимую помощь оказывают шаблоны Ансибла, использующие язык jinja2 (который я называю то жижей-два, то ниндзей-два), где могут применяться блоки с условиями, циклы, макросы и т. п. Идея решения моей задачи заключается в том, чтобы цикл в шаблоне добавлял очереди в конфигурацию, а значения брались из групповых переменных (group_vars). Это удобно, когда нужно один и тот же шаблон конфигурации распространять на разные окружения. К примеру, в файле activemq_test.yml будет один набор переменных, а в файле activemq_prod.yml — другой, и тогда всё будет зависеть от простого членства сервера в той или иной группе в инвентори-файле.

По непонятной мне причине, разработчики Ансибла сделали так, что строки с описанием логики, которые не должны никак отражаться в формируемом конечном файле, оставляют там лишние пустые строки и пробелы. Это делает формирование файлов, чувствительных к отступам, типа YAML, очень сложным. У меня был XML, которому отступы безразличны, но, тем не менее, хотелось сделать разметку красиво и ровно. Я долго мучился с синтаксисом
{%- -%}, расставляя и убирая дефисы, но сделать как следует всё равно не выходило. Провозившись с этим довольно долгое время, я уже было плюнул и хотел оставить как есть, но в обстоятельной статье Jinja2 Tutorial - Part 3 - Whitespace control, где вся эта чехарда с пустыми строками и пробелами досконально разбирается, нашёл прекрасный совет:

Always render with trim_blocks and lstrip_blocks options enabled.

Оказалось, что по умолчанию в Ансибле включена только опция trim_blocks. Когда я добавил в шаблон первой строкой опцию, включающую lstrip_blocks, логические строки перестали оказывать какое-либо влияние на форматирование, и я, наконец, смог без помех отрегулировать все отступы.

#jinja2: lstrip_blocks:True

Впрочем, в официальной документации по jinja2 всё это тоже написано, но постфактум-то всегда всё кажется очевидным, тем более, в примерах из книжки всё дефисами и регулировалось, что мешало быстрее напасть на след какого-то другого решения.

В конце концов получился такой шаблон с вложенным циклом (один перебирает группы, а второй — очереди в них) и условиями:

#jinja2: lstrip_blocks:True
{% for g in activemq %}

    <!-- {{ g.group |upper }} -->
{% for q in g.queues %}
    <route>
        <from uri="{{ (q.bean |upper + '-BEAN') if q.direction == "in" else 'activemq' }}:queue:{{ q.name_from |upper }}?{{ 'disableReplyTo=true&' if q.direction == "in" }}concurrentConsumers={{ q.con_cons | default('1') }}"/>
        {% if q.name_to != "stop" %}
        <to uri="{{ (q.bean |upper + '-BEAN') if q.direction == "out" else 'activemq' }}:queue:{{ q.name_to |upper }}?exchangePattern=InOnly"/>
        {% else %}
        <stop/>
{% endif %}
    </route>
{% endfor %}
{% endfor %}

Там, где это необходимо, значения переменных автоматически приводятся к верхнему регистру и выполняют все предварительные условия формирования правильных ссылок очередей. Имена внешних подключений всегда оканчиваются на -BEAN, поэтому, чтобы не писать это окончание в каждой переменной, оно прибавляется к её значению уже в шаблоне.

Переменные к шаблону выглядят так:

activemq:
  - group: "esFl.ClnS"
    queues:
    - direction: "out"
      bean: "f1sipqm01"
      name_from: "in_rq_clntsearch"
      name_to: "in.rq.clntsearch_01.02.03"

    - direction: "in"
      bean: "F1SiPqm01"
      name_from: "OuT.rS.CLntSeARch_01.02.03.rKf"
      name_to: "oUt_Rs_ClntsEaRcH"
      con_cons: 5

Чтобы параметр concurrentConsumers для той или иной очереди принимал наиболее частое значение «1», то переменную con_cons вообще не надо для неё указывать. А в какой строке будет слово activemq или имя внешнего подключения, определяется переменной direction.

Теперь строки в очередях формируются сами по небольшому числу вводных и там меньше шансов наделать ошибок, бесконечно копируя примерно одни и те же параметры.

Перейдём к шагам выполнения задачи:

- name: Stop ActiveMQ
  command:
    cmd: "{{ activemq_bin }} stop"
    removes: "{{ activemq_pidfile }}"

- name: Remove logs and config backups
  file:
    path: "{{ item }}"
    state: absent
  with_fileglob:
    - "{{ activemq_log }}*"
    - "{{ activemq_conf }}.*"

- name: Deploy new config
  template:
    src: "activemq.xml.j2"
    dest: "{{ activemq_conf }}"
    owner: "{{ deploy_user }}"
    mode: 0660
    backup: true

- name: Start ActiveMQ
  command: "{{ activemq_bin }} start"

- name: Wait
  pause:
    seconds: 30

#- name: Add fail to log
#  lineinfile:
#    dest: "{{ activemq_log }}"
#    line: 'Failed: ERROR'

- name: Check ActiveMQ status
  command: "{{ activemq_bin }} status"
  ignore_errors: true
  register: amq_status # It must contain "ActiveMQ is running"

- name: Check ActiveMQ log
  command: cat "{{ activemq_log }}"
  register: log_status

- name: Restore old config file if any fail
  when: '("failed" in log_status.stdout_lines |lower) or ("is running" not in amq_status.stdout_lines |lower)'
  block:
  - name: Stop ActiveMQ if it's running
    command:
      cmd: "{{ activemq_bin }} stop"
      removes: "{{ activemq_pidfile }}"

  - name: Copy log and restore config
    shell: |
      cp "{{ activemq_log }}" /tmp/activemq_error.log
      cp $(find {{ activemq_dir }}/conf -name 'activemq.xml.*') "{{ activemq_conf }}"

  - name: Start ActiveMQ
    command: "{{ activemq_bin }} start"

При каждом запуске удаляются файлы резервных копий конфигурации, поэтому копия только одна. Файл резервной копии имеет имя такого вида: activemq.xml.26721.2025-09-15@08:33:32~. В середине закомментирован отладочный шаг добавления в лог записи, инициирующей сбой и откат на предыдущую версию. Восстановление объединено в блок, чтобы было удобно запускать его целиком по условию. В блоке восстановления копируется лог по пути, где его подберёт CI-система (в моём случае Teamcity) в виде артефакта сборки, чтобы не лезть за ним на сервер.

Можно, наверное, сделать ещё какие-нибудь проверки — например, перезапускать ActiveMQ только при изменении конфигурации, а не каждый раз, но не всё сразу. Уже выглядит неплохо, а там доработаю при необходимости.

Прощай, OSZone!

14 мая 2007 года я зарегистрировался на forum.oszone.net, где начал предлагать сообществу свои сборки бесплатных программ. Я тогда работал сисадмином в небольшой компании по продаже хлопка и текстиля и с интересом изучал разные аспекты своей не так давно выбранной профессии.

Моё первое сообщение на форуме

Форум был интересным и я довольно помногу там сидел, особенно в разделах автоустановки и компьютерного железа (на само железо денег не было, ну так хоть поговорить о нём. Помню, в то время я брал на Будённовском рынке журнал с ценами и постоянно составлял себе воображаемую конфигурацию системного блока из самых привлекательных по цене компонентов). 12 декабря того же года я стал модератором раздела «Видео и аудио: обработка и кодирование», и в этом статусе оставался до самого финала.

Жизнь тогда, в конце 2000-х — начале 2010-х годов, кипела, общение было очень интенсивным, интернет был свободным пространством, постоянно появлялись новые темы и шло обсуждение самых разных аспектов компьютерной тематики и не только. Постепенно, со сменой работ, обучением и набором опыта, мои интересы переключились с клиентских машин и железа на серверные ОС, Active Directory и автоматизацию, а последние лет шесть-семь я в основном отслеживал темы в разделе «Скриптовые языки администрирования Windows», где любил решать задачи на Powershell.

К концу 2010-х годов активность на форуме заметно снизилась, но люди по-прежнему более-менее регулярно писали там и создавали темы. Перелом произошёл, как и во всех других областях, в 2022 году. Народу стало мало, иногда новых вопросов в разделе скриптов не появлялось неделями. Форум стагнировал и было понятно, что времена расцвета позади, но когда 23 июля 2025 года появилось объявление о его закрытии, всё равно это было как обухом по голове.

За 18 лет форум стал привычным и родным, я заходил туда каждый день, а теперь на его месте будет пустота. Всё сообщество, его 24-летняя история и вся совокупность знаний канут в Лету. Интернет, который можно читать и редактировать как открытую книгу и который доступен всем, уходит в прошлое, уступая место изолированным островкам чатов и узким окошкам каналов в мессенджерах, куда не добираются поисковые системы. Это очень грустно.

Тем не менее, просматривая старые темы и листая страницы форума, я испытываю тёплые и ностальгические чувства, и я очень благодарен создателям и участникам форума за эти замечательные годы.
Прощай, OSZone, спасибо за всё!

HAProxy + acme.sh в Docker

Попользовавшись реверс-прокси Traefik для своего домашнего сервера, я решил вернуться на HAProxy как более функциональное решение, в частности, в области ограничений на количество запросов от клиентов, а то некоторые адреса и подсети буквально бомбардируют мой сайт запросами, и хочется иметь автоматический механизм, охлаждающий пыл таких товарищей. Вопрос стоял в способе выпуска и обновления SSL-сертификата: Traefik умеет сам этим заниматься, а вот для HAProxy нужен какой-то сторонний механизм.

Сначала я думал сделать второй контейнер с Certbot, который будет заниматься обновлением сертификата и класть результат в общий с HAProxy том, где он и будет подхватываться. Но возникают вопросы:

  1. Как сделать запуск обновления периодическим?
  2. Как дать знать реверс-прокси, что пора перезапуститься или перечитать конфигурацию, чтобы новый сертификат начал использоваться?

Вариант завязываться с кроном на хосте с командами docker run мне не нравится, хотелось бы всё держать внутри Докера. Другой вариант, когда в контейнере запущен бесконечный цикл в терминале, также не представляется мне удовлетворительным:

while :; do
  <команда обновления сертификата>
  sleep 24h
done

Некоторое время назад я прочёл статью HAProxy and Let’s Encrypt: Improved Support in acme.sh о настройке совместной работы Хапрокси и скрипта по работе с выпуском и обновлением сертификатов acme.sh. По сравнению с моим прошлым решением здесь новшеством является то, что после выпуска сертификата вообще не нужно ни перечитывать конфигурацию, ни тем более перезапускать сервер — сертификат начинает работать сразу же «на горячую». Для запуска Хапрокси в Докере я использую образ haproxy:lts-alpine, а в образах Alpine есть свой crond. По умолчанию он отключён и умеет работать только от пользователя root, но это не проблема — crond я уже использую в других контейнерах, например, для выполнения фоновых заданий в Nextcloud и для напоминаний о днях рождения родственников в Webtrees. В этом направлении я и решил действовать — контейнер с реверс-прокси будет заниматься также и обновлением сертификата. Да, это не очень правильно, потому что в одном контейнере, по-хорошему, должен быть запущен только один процесс; но остальные пути решения выглядят либо хуже, либо слишком громоздко.

В вышеупомянутой статье сначала создаётся отдельный пользователь acme, но здесь я не вижу в этом особого смысла. В исходном образе уже есть учётка haproxy с UID 99, её и будем использовать. Что касается самого скрипта acme.sh, то в этом случае проще всего поставить его из пакета, а не возиться с ручной установкой из git-репозитория. Ещё дополнительно пригодится утилита su-exec, которая нужна для выполнения команд от имени haproxy из-под root. Почему su-exec — она занимает всего 10 КБ и, в отличие от sudo, который я по привычке попробовал использовать, хорошо работает в контейнере, не требуя какой-то дополнительной настройки. Sudo давал мне ошибку

haproxy  | haproxy is not in the sudoers file.
haproxy  | This incident has been reported to the administrator.

После установки утилит надо ещё прописать в cron задание по обновлению сертификата. В файл скрипта прописывается шебанг и команда на обновление сертификата (о ней чуть позже), дальше скрипт делается исполняемым. Размещается скрипт в каталоге /etc/periodic/daily, что означает, что он будет выполняться раз в сутки.

Вот весь Dockerfile, собирающий образ для запуска. Строка CMD активирует крон и запускает реверс-прокси при старте контейнера.

FROM haproxy:lts-alpine
WORKDIR /var/lib/haproxy
ARG renew_script=/etc/periodic/daily/cert_renew
USER root

RUN apk add acme.sh su-exec && \
echo '#!/bin/sh' > $renew_script && \
echo "su-exec haproxy acme.sh --renew -d example.com --renew-hook \"acme.sh --deploy -d example.com --deploy-hook haproxy\"" >> $renew_script && \
chmod ug+x $renew_script

CMD crond && su-exec haproxy haproxy -f /usr/local/etc/haproxy/haproxy.cfg

Acme.sh можно настраивать с помощью переменных окружения, что для контейнеров является наиболее предпочтительным способом (см. The twelve-factor app, глава «Конфигурация»). Пропишем эти переменные в docker-compose.yml:

services:
  haproxy:
    # Здесь лежит Dockerfile
    build: ./haproxy
    container_name: haproxy
    hostname: haproxy
    restart: always
    sysctls:
      net.ipv4.ip_unprivileged_port_start: 0
    environment:
      # Настройка развёртывания сертификатов для HAProxy
      DEPLOY_HAPROXY_HOT_UPDATE: yes
      DEPLOY_HAPROXY_STATS_SOCKET: "/tmp/api.sock"
      DEPLOY_HAPROXY_PEM_PATH: "/certs"
      # Рабочий каталог acme.sh, где хранятся настройки, сертификаты и т. д.
      LE_WORKING_DIR: "/acme.sh"
    ports:
      - 80:80
      - 443:443
      - 8404:8404
    volumes:
      - /etc/timezone:/etc/timezone:ro
      - /etc/localtime:/etc/localtime:ro
      - ./haproxy/cfg:/usr/local/etc/haproxy
      - ./haproxy/certs:/certs
      - ./haproxy/acme.sh:/acme.sh

Необходимо дать права на каталоги хоста группе или пользователю haproxy (UID/GID 99), чтобы acme.sh мог записывать туда свои данные изнутри контейнера.

sudo chown -R $USER:99 ./haproxy/{certs,acme.sh}

Дальше нужно запустить контейнер с реверс-прокси и зарегистрировать учётку для работы acme.sh с Let’s Encrypt. Все команды docker exec обязательно нужно выполнять, указывая пользователя haproxy, потому что в нашем обновлённом докер-образе, если не указано иначе, все команды будут выполняться под суперпользователем root.

docker compose up -d --build
docker exec -u haproxy haproxy acme.sh --register-account --server letsencrypt -m youremail@example.com

Полученное значение ACCOUNT_THUMBPRINT необходимо скопировать и прописать его в свой файл haproxy.cfg как переменную, а также строку в веб-фронтенд. Всё это позволит Хапрокси корректно отвечать на запрос проверки принадлежности сервера перед выдачей сертификата (ACME challenge).

global
  stats socket /tmp/api.sock user haproxy group haproxy mode 660 level admin expose-fd listeners
  setenv ACCOUNT_THUMBPRINT 'lCufto4sDRTHdmWL0EugFywGV54hBCuTTXvwifi65R4'

frontend web
    bind :80
    bind :443 ssl crt /certs strict-sni
    http-request return status 200 content-type text/plain lf-string "%[path,field(-1,/)].${ACCOUNT_THUMBPRINT}\n" if { path_beg '/.well-known/acme-challenge/' }

Перечитываем конфигурацию Хапрокси и переходим к выпуску и установке сертификата.

docker kill -s HUP haproxy

# Выпуск сертификата. Лог полезен для выяснения причин потенциальных проблем.
docker exec -u haproxy haproxy acme.sh --issue --stateless --server letsencrypt -d example.com --log /acme.sh/debug.log

# После успешного выпуска развёртываем сертификат в каталог для Хапрокси.
# Так как переменные DEPLOY уже заданы в docker-compose.yml, больше никаких параметров задавать не нужно.
docker exec -u haproxy haproxy acme.sh --deploy -d example.com --deploy-hook haproxy

[Mon Jul 14 14:34:02 MSK 2025] The domain 'example.com' seems to already have an ECC cert, let's use it.
[Mon Jul 14 14:34:02 MSK 2025] Deploying PEM file
[Mon Jul 14 14:34:02 MSK 2025] Moving new certificate into place
[Mon Jul 14 14:34:02 MSK 2025] Update existing certificate '/certs/example.com.pem' over HAProxy stats socket.
[Mon Jul 14 14:34:02 MSK 2025] Success

Всё, новый сертификат установлен и работает! Теперь про его перевыпуск, который был прописан в крон в докерфайле. Строка выглядит так:

su-exec haproxy acme.sh --renew -d example.com --renew-hook "acme.sh --deploy -d example.com --deploy-hook haproxy"

Т. е., от root запускается скрипт, а дальше команда уже выполняется от пользователя haproxy. Если сертификату больше 60 дней, то он перевыпускается; если меньше, то пишется следующее:

[Mon Jul 14 10:00:40 MSK 2025] The domain 'example.com' seems to already have an ECC cert, let's use it.
[Mon Jul 14 10:00:40 MSK 2025] Renewing: 'example.com'
[Mon Jul 14 10:00:40 MSK 2025] Switching back to https://acme-v02.api.letsencrypt.org/directory
[Mon Jul 14 10:00:40 MSK 2025] Renewing using Le_API=https://acme-v02.api.letsencrypt.org/directory
[Mon Jul 14 10:00:40 MSK 2025] Skipping. Next renewal time is: 2025-09-10T14:21:52Z
[Mon Jul 14 10:00:40 MSK 2025] Add '--force' to force renewal.

Параметр --force, принудительно перевыпускающий сертификат вне зависимости от его срока действия, я рекомендую никогда не использовать. Вас могут заблокировать за чрезмерно частые обращения к сервису, да и смысла часто перевыпускать сертификаты никакого. Если нужно проводить какие-то тесты и эксперименты, пользуйтесь тестовыми серверами Let’s Encrypt (staging servers), где ограничения гораздо мягче. В acme.sh тестовые сервера настраиваются указанием --server letsencrypt_test при регистрации учётки и выпуске сертификата.

Параметр --renew-hook в строке перевыпуска сертификата — это команда, срабатывающая только тогда, когда сертификат перевыпущен. Здесь это команда развёртывания сертификата в каталог, где его увидит реверс-прокси.

Желаю успехов.

Systemd для непривилегированного пользователя

Уже год я работаю девопсом в банке. Это первая работа за много лет, где у меня нет полных прав в системе, то есть, делать что-либо под учёткой root и пользоваться sudo я не могу. Одновременно с этим на серверах могут быть дополнительные ограничения, например, может не быть доступа к планировщику cron, даже к личному через crontab -e. Также, в число моих обязанностей входит развёртывание собранных приложений (в основном это запуск файлов .jar), соответственно, также под непривилегированными учётками. Часто люди пытаются запускать такие приложения в фоне прямо в консольной сессии:

nohup "java -jar be-app-project-${VERSION}.jar --server.port=9090 &"

А потом, чтобы остановить приложение, приходится искать его идентификатор процесса (PID), используя порой вот такие громоздкие конструкции (видел в одном пайплайне развёртывания):

ps -ef | grep be-app-project-*.jar | grep -v grep | awk '$1=="project" {print $2}' | xargs kill

Можно, конечно, для отлавливания PID пользоваться pgrep, что несколько улучшит вид вышеприведённой строки, но общей кривизны решения это не меняет, к тому же, если пытаться такие практики применять в системах CI/CD типа Gitlab или Teamcity, то раннер может не запустить фоновый процесс и будет бесконечно висеть или выдаст ошибку.

Решением всех этих проблем является systemd, сервисы, таймеры и прочие сущности (юниты) которого будут работать в пространстве пользователя, т. е., для управления ими не требуется никакого повышения привилегий. Более того, отпадёт необходимость ухищряться в фоновом запуске приложения из консольной сессии и последующего отлавливания PID. Да и в целом грех не использовать systemd — это современная, гибкая и удобная система, основными препятствиями к освоению которой являются косность мышления, предубеждение против новых решений и нежелание учиться новому. С этим надо бороться по мере сил. С запуском непривилегированных юнитов есть один небольшой нюанс, о нём ниже.

Начнём с замены крона, опишу самый минимум. Предположим, каждые 3 минуты нужно запускать скрипт, который будет добавлять текущее время в текстовый файл. Создаём сам скрипт, например, по пути /home/user/scripts/add-time.sh, в котором обязательно нужно указать шебанг, чтобы скрипт гарантированно ассоциировался с bash.

#!/bin/bash
date +'%F %T' >> ~/add-time.txt

Затем сделать его исполняемым.

user@k3:~$ chmod u+x /home/user/scripts/add-time.sh

Теперь приступим к созданию службы, или сервиса, или демона. Все команды systemctl нужно будет запускать с параметром --user, что указывает на работу не c системными, а с пользовательскими юнитами. Следующая команда создаёт сервис add-time.service в каталоге ~/.config/systemd/user, где будут храниться все сервисы, таймеры и прочие юниты пользователя. Если этого каталога нет, он будет создан автоматически. Эту же команду можно использовать и для редактирования уже существующего юнита.

user@k3:~$ systemctl edit add-time --user --full --force

# Вставить в открывшийся редактор следующий текст и выйти с сохранением.
# В ExecStart= указывается абсолютный путь к скрипту. Скрипт должен быть исполняемым, иначе не заработает.
[Service]
ExecStart=/home/user/scripts/add-time.sh

Удобство команды systemctl edit в том, что после неё не нужно перечитывать список служб командой systemctl --user daemon-reload. Теперь создаём таймер, запускающий одноимённую службу с заданной периодичностью.

user@k3:~$ systemctl edit add-time.timer --user --full --force

# Вставить в открывшийся редактор следующий текст и выйти с сохранением.
[Timer]
OnCalendar=*:0/3

[Install]
WantedBy=timers.target

Таймер в systemd исключительно гибкий, он более функционален, чем cron. Например, можно запускать пропущенные задания, если сервер в нужный момент не работал, запускать задания через определённое время после запуска системы или в зависимости от активности/неактивности сервиса, настраивать точность срабатывания таймера для распределения нагрузки, когда в одно и то же время задание начинается на многих серверах и т. д. Секция [Install] здесь нужна для того, чтобы таймер можно было прописать в автозагрузку, т. е. он продолжит срабатывать после перезапуска сервера.

Теперь запустим таймер, пропишем его в автозагрузку и проверим его состояние. Видно, что таймер запускает сервис add-time.service и до его запуска осталась 1 мин 48 сек.

# Запуск
user@k3:~$ systemctl --user start add-time.timer
# Автозагрузка
user@k3:~$ systemctl --user enable add-time.timer
Created symlink /home/user/.config/systemd/user/timers.target.wants/add-time.timer → /home/user/.config/systemd/user/add-time.timer.
# Статус
user@k3:~$ systemctl --user status add-time.timer
● add-time.timer
     Loaded: loaded (/home/user/.config/systemd/user/add-time.timer; enabled; vendor preset: enabled)
     Active: active (waiting) since Wed 2025-04-16 15:46:08 MSK; 3s ago
    Trigger: Wed 2025-04-16 15:48:00 MSK; 1min 48s left
   Triggers: ● add-time.service

Apr 16 15:46:08 k3 systemd[952]: Started add-time.timer.

Через некоторое время видно результат в текстовом файле:

user@k3:~$ cat add-time.txt
2025-04-16 15:48:43
2025-04-16 15:51:43
2025-04-16 15:54:43
2025-04-16 15:57:43
2025-04-16 16:00:43
2025-04-16 16:03:43

Теперь про тот небольшой нюанс запуска пользовательских непривилегированных сервисов systemd, о котором я упоминал. Дело в том, что такие сервисы работают только тогда, когда пользователь работает в системе. Если он не зашёл, его сервисы не работают. К счастью, можно включить возможность выполнения сервисов пользователя вне зависимости от его логина. Сделать это нужно один раз, но команда включения при повторном применении ошибок не даёт, поэтому её можно спокойно использовать без предварительной проверки, например, в плейбуках Ansible.

# "Linger: no" - службы работать без входа в систему не будут
user@k3:~$ loginctl user-status
user (1000)
           Since: Sat 2025-04-12 12:24:26 MSK; 4 days ago
           State: active
        Sessions: *2
          Linger: no
    [...]

# Включить для текущего пользователя
user@k3:~$ loginctl enable-linger $USER

# Теперь порядок
user@k3:~$ loginctl user-status
user (1000)
           Since: Sat 2025-04-12 12:24:26 MSK; 4 days ago
           State: active
        Sessions: *2
          Linger: yes
    [...]

Ну а что касается развёртывания приложений, то там просто делается юнит-файл сервиса и кладётся в нужное место любым удобным способом, например, тем же Ansible, используя шаблоны, когда можно заполнять какие-то части файла из переменных или по условию.

[Unit]
Description=Project App Backend {{ backend_maven_version }}

[Service]
Restart=always
Environment=REPORT_PATH={{ backend_report_directory }}
ExecStart=/usr/bin/java -Xrs -jar {{ backend_lib_directory }}/be-app-project-{{ backend_maven_version }}.jar

[Install]
WantedBy=multi-user.target

Шаги копирования шаблона и запуска службы в плейбуке.

- name: "Copy unit file"
  template:
    src: "{{ service_name }}.service.j2"
    dest: "~/.config/systemd/user/{{ service_name }}.service"

- name: "Start service"
  systemd:
    scope: user
    daemon_reload: true
    name: "{{ service_name }}"
    state: started
    enabled: true

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