🏠: работа

Ретрограды из 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 только при изменении конфигурации, а не каждый раз, но не всё сразу. Уже выглядит неплохо, а там доработаю при необходимости.

Последний бастион VMware

Сегодня на работе я выключил последнюю оставшуюся ноду после миграции всех виртуальных машин в кластер Hyper-V.

Последнее действие перед выключением Последнее действие перед выключением

Сервер был ProLiant DL380 G6, его уже давно пора было выводить из эксплуатации, но дело в том, что на нём крутилась пара серверов, «защищённых» USB-токеном. Несколько недель назад, наконец, купили железку, которая пробрасывает USB-устройства по сети — это местная аппаратная адаптация недорогого зарубежного решения VirtualHere.

Железка представляет собой keepalived-кластер из двух Banana Pi с веб-интерфейсом на ajenti и 32 портами USB, заключённых в едином корпусе:

# dmesg
[    0.000000] Booting Linux on physical CPU 0x0
[    0.000000] Linux version 5.15.93 (oe-user@oe-host) (arm-poky-linux-gnueabi-gcc (GCC) 9.3.0, GNU ld (GNU Binutils) 2.34.0.20200220) #1 SMP Thu Feb 9 10:26:48 UTC 2023
[    0.000000] CPU: ARMv7 Processor [410fc074] revision 4 (ARMv7), cr=10c5387d
[    0.000000] CPU: div instructions available: patching division code
[    0.000000] CPU: PIPT / VIPT nonaliasing data cache, VIPT aliasing instruction cache
[    0.000000] OF: fdt: Machine model: LeMaker Banana Pi
# lsb_release -a
LSB Version:    n/a
Distributor ID: poky
Description:    Poky (Yocto Project Reference Distro) 3.1.17
Release:        3.1.17
Codename:       dunfell

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

Веб-интерфейс

На компьютерах, которым нужно подключение USB, ставится клиент, который устанавливает свои драйверы и может работать как служба. На очень старых системах, например, Windows 2003, драйверы нужно ставить самостоятельно, вытащив их из клиента с помощью командной строки. Потом, подмонтировав ключ, надо сделать так, чтобы служба сервера, зависящая от ключа (например, 1C:Enterprise 8.3 Server Agent), стартовала позже службы rhclient (USB Hub Over Network USB Sharing) — для этого нужно её перевести в режим автоматического отложенного запуска. В старых системах, где функции отложенного запуска нет, можно перевести службу сервера в ручной режим запуска и настроить планировщик задач, чтобы после загрузки ОС выполнялся скрипт наподобие

ping 127.0.0.1 -n 30
net start hwserver

Вот как выглядят устройства USB на виртуальной машине Hyper-V, где крутится 1С (напоминаю, что Hyper-V не пробрасывает USB с хоста на виртуалки, и правильно делает):

А вот так ключи выглядят с клиента:

Доступ к самим ключам и к их администрированию через клиент может быть ограничен несколькими способами, я делал по IP-адресам и подсетям.

Собственно, возвращаясь к тому, с чего я начал — после переноса токенов на железку оставалось только мигрировать все виртуалки на Hyper-V и выключить ноду VMware, что я и проделал. Теперь, наконец, зоопарка систем виртуализации больше нет.

Автоматизация установки SSL-сертификата на Cisco ASA

Пересадить всю контору на сертификаты Let’s Encrypt оказалось не очень сложно — certbot на реверс-прокси работает, сертификаты после перевыпуска лепятся вместе со своими ключами и кладутся в нужный каталог скриптом, лежащим в /etc/letsencrypt/renewal-hooks/deploy, а в определённое время в cron срабатывает команда, которая при наличии новых сертификатов перечитывает конфигурацию сервера.

На некоторых сервисах, которые выставлены в интернет напрямую, например, сервера видеоконференций, работает свой бот и он обновляет сертификаты локально, но иногда есть нюансы — например, на сервере TrueConf 80-й порт, который требуется для обновления сертификата, занят, и приходится подкручивать /lib/systemd/system/certbot.timer на еженедельный запуск где-то в глухой ночи, а в юнит-файле /lib/systemd/system/certbot.service рисовать следующее, чтобы стопорить службу, занимающую порт, и после процедуры стартовать её заново:

ExecStart=/usr/bin/certbot -q renew \
--pre-hook 'systemctl stop trueconf-web' \
--deploy-hook 'cp /etc/letsencrypt/live/tconf.example.com/cert.pem /opt/trueconf/server/etc/webmanager/ssl/custom.crt && \
cp /etc/letsencrypt/live/tconf.example.com/privkey.pem /opt/trueconf/server/etc/webmanager/ssl/custom.key && \
chown trueconf:trueconf /opt/trueconf/server/etc/webmanager/ssl/custom.\*' \
--post-hook 'systemctl start trueconf-web'

Окно ввода логина и пароля VPN на веб-странице Cisco ASA

Последним бастионом оставался довольно почтенного возраста аппаратный шлюз Cisco ASA, который со всеми этими новомодными удостоверяющими центрами работать не умеет. Так как раньше покупался wildcard-сертификат на год и вставлялся туда руками, проблем его менять не было, кроме оскорбления здравого смысла при виде очередного рассовывания этого несчастного сертификата по всем серверам и раздумий, куда его ещё забыли скопировать. Но так как Let’s Encrypt выпускает сертификаты на 90 дней, ручная установка выглядит совсем уж неуместной, и автоматизация совершенно необходима.

Let’s Encrypt даёт возможность выпускать и wildcard, но для него нужно автоматизировать ещё и создание TXT-записей в DNS через API, что усложняло задачу. Для нашего nic.ru существует программа, но эта схема мне показалось чересчур усложнённой, к тому же, у компании не так много доменов, в случае компрометации сертификата не будет затронуто сразу всё, тем более, что в один сертификат можно поместить сразу несколько альтернативных имён (SAN), что позволяет сократить общее количество выпускаемых сертификатов.

После небольшого гуглежа я обнаружил в некотором роде бриллиант — инструмент для автоматизации работы с интерактивным вводом expect. То есть, пишешь, что должно появиться в командной строке, а потом то, что нужно ввести в ответ. Так как это не bash, а другой интерпретатор, основанный на языке TCL (Tool Command Language), у него свой шебанг — #!/usr/bin/expect -f.

Перед началом работы нужно попросить сетевиков, чтобы ASA пробрасывала 80-й порт на сервер, на котором будет стоять сертбот, потом нужно завести отдельную учётку для входа по SSH и дать этой учётке права на некоторые команды, об этом ниже.

На сервере, куда будет приходить 80-й порт и где будет происходить всё последующее, понадобятся 3 файла: скрипт сборки сертификата в нужный формат (pkcs12, закодированный в base64), запускаемый сертботом после его перевыпуска, который, в свою очередь, запускает скрипт expect. Третий файл — это пароль экспорта/импорта сертификата: можно пароль и так захардкодить в первые два файла, но это неудобно. Предполагается, что все действия делаются от учётки root.

# скрипт, выполняющийся при обновлении сертификата
touch /etc/letsencrypt/renewal-hooks/deploy/vpncert-asa.sh
chmod 700 /etc/letsencrypt/renewal-hooks/deploy/vpncert-asa.sh
# скрипт expect
touch /scripts/vpncert-install.exp
chmod 700 /scripts/vpncert-install.exp
# пароль экспорта сертификата для openssl
touch /scripts/vpncert-asa.txt
chmod 600 /scripts/vpncert-asa.txt
# Записать пароль в файл пароля
nano /scripts/vpncert-asa.txt

Содержимое vpncert-asa.sh:

#!/bin/bash

openssl pkcs12 -export \
-password file:/scripts/vpncert-asa.txt \
-in $RENEWED_LINEAGE/fullchain.pem \
-inkey $RENEWED_LINEAGE/privkey.pem \
-out /root/gate.pfx && \

openssl base64 -in /root/gate.pfx -out /root/gate.base64 && \

/scripts/vpncert-install.exp

vpncert-install.exp:

#!/usr/bin/expect -f

set timeout 5
set send_slow {10 .001}
set sshUser "sshuser"
set sshIP "192.168.1.254"
set sshPass "sshPass12345"
set exportPass [exec cat /scripts/vpncert-asa.txt]

spawn ssh -o KexAlgorithms=+diffie-hellman-group1-sha1 -o HostKeyAlgorithms=+ssh-rsa -o StrictHostKeyChecking=no $sshUser@$sshIP
expect "password:"
send -- "$sshPass\r"
expect ">"
send -- "enable\r"
expect "Password:"
send -- "$sshPass\r"
expect "#"
send -- "configure terminal\r"
expect "(config)#"
send -- "no crypto ca trustpoint ca_le\r"
expect {
  "]:" {send -- "yes\r"; exp_continue}
  "(config)#"
}
send -- "crypto ca trustpoint ca_le\r"
expect "trustpoint)#"
send -- "enrollment terminal\r"

expect "#"
send -- "exit\r"
expect "(config)#"

send -- "crypto ca import ca_le pkcs12 $exportPass\r"
expect "itself:"
send -- [exec cat /root/gate.base64]\n
send -s "quit\r"

# % The CA cert is not self-signed.
# % Do you also want to create trustpoints for CAs higher in the hierarchy? [yes/no]:
# OR
# % You already have RSA or ECDSA keys named ca_le.
# % If you replace them, all device certs issued using these keys
# % will be removed.
# % Do you really want to replace them? [yes/no]:
expect {
  "]:" {send -- "yes\r"; exp_continue}
  "(config)#"
}

send -- "ssl trust-point ca_le outside\r"

expect "(config)#"
send -- "exit\r"
expect "#"
send -- "exit\r"
expect eof

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

Вывод во время выполнения работы:

spawn ssh -o KexAlgorithms=+diffie-hellman-group1-sha1 -o HostKeyAlgorithms=+ssh-rsa -o StrictHostKeyChecking=no sshuser@192.168.1.254
sshuser@192.168.1.254's password:
User sshuser logged in to gate
Logins over the last 91 days: 54. Last login: 11:39:24 MSK Jan 9 2023 from 192.168.1.100
Failed logins since the last login: 0.
Type help or '?' for a list of available commands.
gate> enable
Password: **********
gate# configure terminal
gate(config)# no crypto ca trustpoint ca_le
WARNING: Removing an enrolled trustpoint will destroy all
certificates received from the related Certificate Authority.

Are you sure you want to do this? [yes/no]: yes
INFO: Be sure to ask the CA administrator to revoke your certificates.
gate(config)# crypto ca trustpoint ca_le
gate(config-ca-trustpoint)# enrollment terminal
gate(config-ca-trustpoint)# exit
gate(config)# crypto ca import ca_le pkcs12 verySecretPassword12345

Enter the base 64 encoded pkcs12.
End with the word "quit" on a line by itself:
MIIWzwIBAzCCFoUGCSqGSIb3DQEHAaCCFnYEghZyMIIWbjCCEOIGCSqGSIb3DQEH
BqCCENMwghDPAgEAMIIQyAYJKoZIhvcNAQcBMFcGCSqGSIb3DQEFDTBKMCkGCSqG
...
dt/zTsIeIqQq05PLFpOTIzBBMDEwDQYJYIZIAWUDBAIBBQAEIMyVqqTQhaaqlHOH
D3XnctPJR1TYytiVRCaVWuZHz+G0BAiEKmqw9Y6C4AICCAA=
quit
% You already have RSA or ECDSA keys named ca_le.
% If you replace them, all device certs issued using these keys
% will be removed.
% Do you really want to replace them? [yes/no]: yes

Trustpoint 'ca_le' is a subordinate CA and holds a non self-signed certificate.

Trustpoint CA certificate accepted.
WARNING: CA certificates can be used to validate VPN connections,
by default.  Please adjust the validation-usage of this
trustpoint to limit the validation scope, if necessary.
INFO: Import PKCS12 operation completed successfully.
gate(config)# ssl trust-point ca_le outside
gate(config)# exit
gate# exit

Logoff

Connection to 192.168.1.254 closed by remote host.
Connection to 192.168.1.254 closed.

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

Вечер дня сисадмина

После ужина обновил систему на своём сервере-неттопе, а потом решил ещё разик поковырять давно висевшее предупреждение в Nextcloud:

The reverse proxy header configuration is incorrect, or you are accessing Nextcloud from a trusted proxy. If not, this is a security issue and can allow an attacker to spoof their IP address as visible to the Nextcloud. Further information can be found in the documentation.

Сначала я попробовал добавить в параметр trusted_proxies конфигурации Nextcloud не имя контейнера реверс-прокси, а приватный диапазон IP, используемый Докером — 172.16.0.0/12; так много где советуют сделать, но это ничего не дало. В конце концов, проблема решилась заданием того же имени контейнера, но не единым параметром, а первым элементом массива, а также установкой ожидаемого заголовка пересылки.

docker exec -uwww-data nc-php php /var/www/html/cloud/occ config:system:set forwarded_for_headers 0 --value="X-Forwarded-For"
docker exec -uwww-data nc-php php /var/www/html/cloud/occ config:system:set trusted_proxies 0 --value="reverse-proxy"

Наконец-то:

Ободрённый успехом, я решил обновить контейнер с PHP 7.4 на PHP 8.0. Он у меня самосборный — это Alpine c последующей установкой необходимых пакетов и настройкой с помощью командной строки. Я поменял пакеты в Dockerfile на соответствующую версию и поправил пути, пересобрал контейнер, и вроде как заработало, но перестали выполняться команды, вызывающие php, подобные приведённым выше, и остановился cron, выполняющий фоновые задания. Ошибка была такая:

OCI runtime exec failed: exec failed: unable to start container process: exec: ″php″: executable file not found in $PATH: unknown

Полез внутрь контейнера. Команда php8 выполняется, а php — нет. Оказалось, что в дистрибутиве Alpine для php8 не готовы пакеты, поэтому символическая ссылка автоматически при установке не прописывается. Речь об этом шла год назад, не знаю, та же причина сейчас или нет, но результат всё равно один. Добавил ещё одну строчку в Dockerfile, после чего всё заработало:

ln -sf /usr/bin/php8 /usr/bin/php

Cron снова в порядке

Заодно напишу про упомянутый в прошлом тексте выпуск сертификатов Let’s Encrypt для HAProxy. Всё получилось и отлично работает. У Certbot есть каталог /etc/letsencrypt/renewal-hooks/deploy, и если туда положить скрипт, то он будет выполняться после каждого успешного перевыпуска сертификата. В данном случае нужно слепить полную цепочку сертификатов и закрытый ключ в одно целое, положив результат в каталог сертификатов HAProxy:

#!/bin/bash
cat $RENEWED_LINEAGE/{fullchain.pem,privkey.pem} > /etc/ssl/certs/haproxy/$(basename $RENEWED_LINEAGE).pem

# --deploy-hook DEPLOY_HOOK
#    Command to be run in a shell once for each
#    successfully issued certificate. For this command, the
#    shell variable $RENEWED_LINEAGE will point to the
#    config live subdirectory (for example,
#    "/etc/letsencrypt/live/example.com") containing the
#    new certificates and keys; the shell variable
#    $RENEWED_DOMAINS will contain a space-delimited list
#    of renewed certificate domains (for example,
#    "example.com www.example.com") (default: None)

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

#!/bin/bash
if [[ $(find /etc/ssl/certs/haproxy/* -mtime -1) ]]
then
systemctl reload haproxy.service
fi

В cron нужно добавить запуск этого скрипта раз в сутки, например, в 3 часа ночи:

echo -e "\n# Reload HAProxy if there are new certs\n0 3\t* * *\troot\t/scripts/haproxy-reload-if-new-certs.sh" >> /etc/crontab

Ну, с праздничком всех причастных и сочувствующих, спокойной ночи.