Последнее время довольно много работаю с Ансиблом. Интересный и зачастую удобный инструмент автоматизации, но довольно специфический в обращении и требующий привыкания.
Одним из наиболее полезных источников по Ансиблу для меня является книга «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 всё это тоже написано, но постфактум-то всегда всё кажется очевидным, тем более, в примерах из книжки всё дефисами и регулировалось, что мешало быстрее напасть на след какого-то другого решения.
В конце концов получился такой шаблон с вложенным циклом (один перебирает группы очередей, а второй — сами очереди в них) и условиями. Условие для выборки входящих очередей основано на переменной srv:, заданной у всех входящих очередей — это индекс сервера в списке той группы серверов, на которую идёт установка. В шаблоне группа серверов фигурирует как переменная my_group, значение которой задаётся отдельно для каждого окружения в файлах group_vars, например, activemq_test, activemq_prod и т. д.
#jinja2: lstrip_blocks:True
{% for g in activemq %}
    <!-- {{ g.group |upper }} -->
{% for q in g.queues %}
{% if (q.srv is defined and ansible_hostname in groups[my_group][q.srv]) or (q.direction == "out") %}
    <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>
{% endif %}
{% 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
      srv: 0
    - direction: "in"
      bean: "RKFQM1"
      name_from: "T.OUT.CCL.RKF.NOTIFICATION"
      name_to: "T.OUT.CCL.RKF.NOTIFICATION"
      srv: 1
Чтобы параметр concurrentConsumers для той или иной очереди принимал наиболее частое значение «1», то переменную con_cons вообще не надо для неё указывать. А в какой строке будет слово activemq или имя внешнего подключения, определяется переменной direction. На серверах с индексами 0 и 1 в итоге будет по 2 очереди: исходящая будет на обоих, а входящая — с соответствующим индексом.
Теперь строки в очередях формируются сами по небольшому числу вводных и там меньше шансов наделать ошибок, бесконечно копируя примерно одни и те же параметры.
Перейдём к шагам выполнения задачи (обновлено 17.10.2025). Шаги разделены на основную часть и обработчики (handlers). Обработчики выполняются только при вызове из основной части, здесь это делается при изменении конфигурационного файла. Если же новый сформированный файл совпадает со старым, то дальнейшие шаги просто не выполняются. Это даёт удобную возможность запускать плейбук сразу на всех серверах целевой группы, и перезапуск ActiveMQ будет только там, где конфигурация изменилась. При каждом обновлении конфигурационного файла делается резервная копия старой конфигурации, чтобы иметь возможность автоматически откатить изменения, если служба не запустится или в логах есть ошибки. Хранится только одна свежая резервная копия, остальные удаляются. Файл резервной копии имеет имя следующего формата: activemq.xml.26721.2025-09-15@08:33:32~.
Основная часть (roles/tasks/main.yml):
- name: Deploy new config
  template:
    src: "activemq.xml.j2"
    dest: "{{ activemq_conf }}"
    mode: 0660
    backup: true
  notify: restart_mq
# Логи со сбоями, которые, возможно, раньше попали в артефакты системы CI,
# необходимо стереть с агента, на котором запущен Ansible, иначе они
# будут постоянно попадать в артефакты более новых запусков. Здесь надо указать
# run_once: true, иначе этот шаг будет выполняться столько раз,
# сколько будет серверов ActiveMQ, что совершенно бессмысленно.
- name: Remove error log from worker
  command: find . -maxdepth 1 -type f -name 'activemq*.log' -delete
  run_once: true
  delegate_to: localhost
# Добавление скриптов с командами остановки, запуска и проверки статуса сервиса systemd,
# который создаётся в ходе выполнения этого плейбука.
- name: Copy start/stop scripts
  copy:
    src: "{{ item }}"
    dest: "{{ activemq_dir }}/bin"
    owner: "{{ ansible_user }}"
    mode: 0770
  with_fileglob:
    - "*.sh"
Обработчики (roles/handlers/main.yml). Задачи, вызываемые из основной части, объединены в группу restart_mq, что позволяет выполнять множество задач обработчика одним вызовом notify.
### Start
# По идее, статус сервиса проверяется модулем `service_facts`, но он не умеет
# работать с сервисами пользователя, поэтому приходится задействовать шелл.
- name: Get service status
  shell: systemctl --user is-active activemq.service
  register: service_status
  ignore_errors: true
  listen: restart_mq
# Два стопа сделано для того, чтобы учесть ситуацию первого запуска,
# когда службы на сервере ещё нет и попытка остановить службу просто приведёт к ошибке,
# а остановить приложение всё равно необходимо.
- name: Stop ActiveMQ
  systemd:
    scope: user
    name: "activemq.service"
    state: stopped
  listen: restart_mq
  when: service_status.stdout == 'active'
- name: Stop ActiveMQ app
  command:
    cmd: "{{ activemq_bin }} stop"
    removes: "{{ activemq_pidfile }}"
  listen: restart_mq
  when: service_status.stdout != 'active'
# Стирать логи и старые бэкапы конфигурации тоже оказалось проще шеллом.
- name: Remove old config backups and all activemq logs
  shell: |
    find "{{ activemq_dir }}/conf" -maxdepth 1 -type f -name 'activemq.xml.*' -printf '%T@\t%p\0' |sort -z -k 1n,1 |head -z -n -1 |cut -z -f 2- |xargs -0 rm
    find "{{ activemq_dir }}/data" -maxdepth 1 -type f -name 'activemq*.log*' -delete
  ignore_errors: true
  listen: restart_mq
- name: Create units folder
  file:
    path: ~/.config/systemd/user
    state: directory
  listen: restart_mq
- name: Deploy new unit
  template:
    src: "activemq.service.j2"
    dest: "~/.config/systemd/user/activemq.service"
    mode: 0660
  listen: restart_mq
###################### Restore
# Задачи, относящиеся только к восстановлению, находятся посреди файла с обработчиками,
# потому что конструкция с объединением задач `listen:`, при её вызове из того же файла с обработчиками,
# не срабатывает. Поэтому приходится вызывать нужные задачи списком. Но дело в том, что задачи
# будут выполняться не в порядке их перечисления в `notify:`, а в порядке их расположения в файле,
# из-за этого пришлось поступить таким образом.
# 1. Stop
# 2. Copy error log and restore config
- name: Copy log and restore config
  shell: |
    cp "{{ activemq_log }}" "{{ activemq_error_log }}"
    cp $(find "{{ activemq_dir }}/conf" -name 'activemq.xml.*' |sort |tail -n1) "{{ activemq_conf }}"
# 3. Copy error log to worker
# Во время восстановления копируются логи со сбоями по пути, где их подберёт CI-система
# (в моём случае, Teamcity) в виде артефакта сборки, чтобы не лезть за ними на сервер.
- name: Copy error log to worker
  fetch:
    src: "{{ activemq_error_log }}"
    dest: "./activemq_error_{{ inventory_hostname }}.log"
    flat: true
# 4. Start
######################
- name: Start ActiveMQ
  systemd:
    scope: user
    name: "activemq.service"
    state: started
    daemon_reload: true
    enabled: true
  listen: restart_mq
- name: Wait
  pause:
    seconds: "{{ wait_sec }}"
  listen: restart_mq
# Отладочный шаг добавления в лог записи о сбое, чтобы можно было протестировать
# откат на предыдущую версию конфигурации. Для этого можно запустить плейбук
# с ключом `-e "fake_fail=true"`.
- name: Add fake fail to log
  lineinfile:
    dest: "{{ activemq_log }}"
    line: 'Failed: ERROR'
  when: fake_fail |bool == true
  listen: restart_mq
- name: Check ActiveMQ status
  shell: "{{ activemq_bin }} status"
  ignore_errors: true
  register: amq_status # It must contain "ActiveMQ is running"
  changed_when: '"is running" not in amq_status.stdout_lines |lower'
  listen: restart_mq
  notify:
    - Get service status
    - Stop ActiveMQ
    - Copy log and restore config
    - Copy error log to worker
    - Start ActiveMQ
- name: Check ActiveMQ logs
  command: cat "{{ activemq_log }}"
  register: log_status
  changed_when: '"failed" in log_status.stdout_lines |lower'
  listen: restart_mq
  notify:
    - Get service status
    - Stop ActiveMQ
    - Copy log and restore config
    - Copy error log to worker
    - Start ActiveMQ
С первого взгляда, конструкция довольно сложная и громоздкая, но когда в хозяйстве сотня-другая серверов, на которых приходится периодически обновлять и перезапускать что-то вручную, то такое решение будет просто спасением.