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