Ansible: шаблоны Jinja2 и обработчики handlers
Изучите настройку nginx через шаблоны Jinja2 и использование handlers для перезагрузки сервиса при изменениях. Проверьте идемпотентность playbook при повторном запуске.
Что шаблоны и обработчики Ansible добавляют в ваш первый playbook
Шаблоны и обработчики Ansible — это два компонента, которые превращают статический playbook в полезный инструмент. Шаблон формирует конфигурационный файл на основе ваших переменных, поэтому один файл подходит для любого хоста. Обработчик запускается только тогда, когда задача действительно внесла изменения, поэтому служба перезагружается только при реальном изменении конфигурации, а в остальное время её работа не прерывается.
Это руководство продолжает тему, на которой остановилось ваше первое руководство по Ansible playbook на VPS. У вас уже есть сценарий, который устанавливает пакет и запускает службу. Всё, что описано ниже, выполняется на одной машине, так как сценарий нацелен на localhost через локальное соединение. Вам не нужен второй сервер, чтобы выполнить эти действия. Тот же сценарий будет работать с реальными хостами из инвентаря без изменения задач, а в последнем разделе описано, что именно в этом случае меняется.
Настройка рабочей директории
sudo apt update
sudo apt install -y ansible nginx
ansible --version
mkdir -p ~/ansible-templates/templates
cd ~/ansible-templatesnginx используется здесь только потому, что это реальный сервис с файлом конфигурации и командой перезагрузки, что полностью соответствует требованиям примера. ansible --version выводит версию ansible-core и используемый интерпретатор Python. Запомните оба значения. В плейбуке ниже используются полные имена модулей, такие как ansible.builtin.template, для которых требуется Ansible 2.10 или новее; любой актуальный пакет из дистрибутива соответствует этому требованию.
Создайте inventory.ini:
[local]
localhost ansible_connection=local ansible_python_interpreter="{{ ansible_playbook_python }}"ansible_connection=local указывает Ansible выполнять каждую задачу как локальный процесс, вместо открытия SSH-сессии к самому себе. Второй параметр — это не просто украшение. Когда вы записываете localhost в файл инвентаризации, он становится обычным хостом и теряет интерпретатор, который Ansible автоматически предоставляет для неявного localhost. В результате происходит обращение к механизму обнаружения интерпретатора, и Ansible может выбрать другой Python, отличный от того, который выполняет плейбук. ansible_playbook_python — это интерпретатор, запускающий ansible-playbook прямо сейчас, что обеспечивает синхронизацию между ними.
Создайте ansible.cfg:
[defaults]
inventory = inventory.iniБез этого файла вам придётся передавать -i inventory.ini при каждом выполнении команды. Если инвентаризация отсутствует, Ansible выводит [WARNING]: provided hosts list is empty, only localhost is available. Note that the implicit localhost does not match 'all', и плейбук с hosts: all не находит совпадений. Ещё один важный момент касательно ansible.cfg: Ansible игнорирует его, если он находится в директории, доступной для записи всем пользователям, поэтому храните проект в своей домашней директории. Файл инвентаризации содержит больше, чем просто список хостов, и это минимально необходимый вариант для выполнения задачи.
Сравнение template и copy: когда что использовать
ansible.builtin.copy передает файл в исходном виде. ansible.builtin.template сначала обрабатывает файл через Jinja2 и передает результат. В документации модуля template описывается как «виртуальный модуль, который полностью реализован как action plugin и выполняется на контроллере». Это влечет за собой важное следствие: рендеринг происходит на той машине, где вы запустили ansible-playbook. Целевой хост никогда не видит ваши переменные, и наличие Jinja2 на нем не требуется.
Используйте copy, если файл идентичен для всех хостов. Используйте template, как только хотя бы одно значение начинает отличаться в зависимости от хоста или когда вам требуется цикл {% for %} либо блок {% if %}. У модуля copy есть параметр content:, и переменные внутри него подставляются так же, как и в любом другом аргументе задачи, но в нем нельзя использовать циклы и условные операторы. Поэтому всё, что имеет сложную структуру, должно оформляться как шаблон. Оба модуля поддерживают одинаковые опции работы с файлами, так как используют общие фрагменты документации, поэтому owner, group, mode, backup и validate работают в них одинаково.
Напишите шаблон: одна переменная, один цикл
Сохраните это как templates/app.conf.j2:
# {{ ansible_managed }}
upstream {{ app_name }}_backend {
{% for backend in app_backends %}
server {{ backend.host }}:{{ backend.port }} weight={{ backend.weight }};
{% endfor %}
}
server {
listen {{ app_listen_port }};
server_name {{ app_server_name }};
location / {
proxy_pass http://{{ app_name }}_backend;
proxy_set_header Host $host;
}
}Два типа тегов Jinja2 выполняют здесь всю работу. {{ ... }} — это выражение, которое выводит свое значение. {% ... %} — это инструкция, которая сама по себе ничего не выводит. app_backends представляет собой список словарей, поэтому backend.host считывает один ключ из каждой записи, а цикл записывает по одной строке server для каждой записи, сколько бы вы их ни определили.
Одна деталь насчет пробельных символов, так как она часто удивляет тех, кто знаком с Jinja2 по другим проектам. Ansible по умолчанию устанавливает trim_blocks в значение yes, чего сама Jinja2 не делает, поэтому символ новой строки сразу после тега {% ... %} удаляется, и цикл не оставляет после себя пустую строку. Ansible оставляет lstrip_blocks в значении no, поэтому любые пробелы, которые вы поставите перед тегом {%, сохраняются и появляются в итоговом файле. Если в выводе появляются лишние отступы, установите lstrip_blocks: true в задаче шаблонизации.
{{ ansible_managed }} по умолчанию отображается как буквальный текст Ansible managed. Оставьте всё как есть. Люди часто переопределяют ansible_managed в ansible.cfg, чтобы добавить дату, и как только они это делают, итоговый файл меняется при каждом запуске, задача сообщает об изменениях при каждом запуске, а сервис перезагружается при каждом запуске. Эта единственная настройка разрушает свойство, которому посвящено остальное руководство. Расширение .j2 — это лишь соглашение, и Ansible его не проверяет.
Playbook
Сохраните это как site.yml:
- name: Render an nginx site from a template
hosts: local
become: true
vars:
app_name: learn
app_listen_port: 8080
app_server_name: learn.example.com
app_backends:
- host: 127.0.0.1
port: 9001
weight: 3
- host: 127.0.0.1
port: 9002
weight: 1
tasks:
- name: Install nginx
ansible.builtin.apt:
name: nginx
state: present
update_cache: true
cache_valid_time: 3600
- name: Render the site configuration
ansible.builtin.template:
src: templates/app.conf.j2
dest: "/etc/nginx/conf.d/{{ app_name }}.conf"
owner: root
group: root
mode: '0644'
backup: true
notify: nginx config changed
- name: Make sure nginx is enabled and running
ansible.builtin.service:
name: nginx
state: started
enabled: true
handlers:
- name: Test the nginx configuration
ansible.builtin.command:
cmd: /usr/sbin/nginx -t
changed_when: false
listen: nginx config changed
- name: Reload nginx
ansible.builtin.service:
name: nginx
state: reloaded
listen: nginx config changedmode: '0644' взято в кавычки намеренно. В документации к опциям файлов указано, что восьмеричные числа следует заключать в кавычки, «чтобы Ansible получал строку и мог самостоятельно преобразовать её из строки в число». Без кавычек YAML-парсер считывает 0644 как обычное число, и вы можете получить права доступа, которые не планировали устанавливать.
notify: nginx config changed обозначает тему, а не обработчик (handler). Оба обработчика содержат listen: nginx config changed, поэтому один вызов notify активирует их оба. Если позже добавить третий обработчик с той же строкой listen, задачу с шаблоном изменять не потребуется. cache_valid_time: 3600 предотвращает повторное обращение к зеркалам пакетов, если запуск происходит чаще одного раза в час.
Запустите один раз, затем изучите вывод
ansible-playbook site.ymlЕсли sudo запрашивает пароль, добавьте -K, и Ansible предложит ввести его.
Сначала изучите строки для каждой задачи, затем PLAY RECAP в нижней части. Каждая задача выводит changed:, если Ansible пришлось выполнить действие, или ok:, если хост уже находился в требуемом состоянии; итоговая сводка суммирует эти счетчики по каждому хосту. Только после завершения всех задач в плейбуке вы получите RUNNING HANDLER [Test the nginx configuration], за которым следует RUNNING HANDLER [Reload nginx].
Теперь проверьте саму машину, не полагаясь только на вывод:
sudo cat /etc/nginx/conf.d/learn.conf
sudo /usr/sbin/nginx -t
curl -sI http://127.0.0.1:8080/nginx -t выводит nginx: configuration file /etc/nginx/nginx.conf test is successful, если собранная конфигурация прошла проверку синтаксиса. curl возвращает строку состояния Nginx, и 502 Bad Gateway является здесь правильным ответом, так как блок server активен, а порты 9001 или 9002 никем не прослушиваются. sudo tail /var/log/nginx/error.log указывает причину простыми словами: connect() failed (111: Connection refused) while connecting to upstream.
Запустите его второй раз, чтобы подтвердить идемпотентность
ansible-playbook site.ymlЭтот запуск является определяющим, поэтому сравните его вывод с первым построчно. Задача по шаблонизации теперь должна выводить ok: там, где она выводила changed:, и ни один из обработчиков не должен появиться в выводе.
Механизм прост и его стоит знать, так как именно по нему вы проводите отладку. template создает файл на управляющем узле и сравнивает контрольную сумму результата с контрольной суммой файла, который уже находится по пути dest. Совпадение содержимого, владельца и прав доступа означает, что выполнять нечего, поэтому задача сообщает ok, в результате чего notify не срабатывает, а обработчик не запускается. Обработчики срабатывают только при changed и ни при каких других условиях.
Проверьте также обратный сценарий. Измените weight: 3 на weight: 1 в vars и запустите плейбук снова: задача по шаблонизации сообщит changed, оба обработчика выполнятся, а sudo cat /etc/nginx/conf.d/learn.conf отобразит новое значение.
Если при втором идентичном запуске всё равно сообщается об изменениях, значит, результат рендеринга нестабилен. Сначала поищите в выводе что-либо, зависящее от времени, так как это наиболее частая причина, и обычно виноват настроенный пользователем ansible_managed. После этого убедитесь, что mode и owner в задаче соответствуют тем, что фактически установлены на диске, поскольку несоответствие этих параметров считается изменением, даже если байты идентичны.
Просмотр изменений перед их применением
ansible-playbook site.yml --check --diff--check запускает playbook без внесения изменений на хост. --diff выводит список того, что изменила бы каждая задача, что для template означает построчное сравнение разницы между сгенерированным шаблоном и файлом на диске. Вместе они отвечают на вопрос «что сделает этот запуск», не выполняя его. Режим проверки имеет свои нюансы, особенно для задач, результат которых зависит от предыдущей задачи, которую режим проверки фактически не выполнял.
Почему обработчики ожидают завершения play
Документация по обработчикам (handlers) прямо указывает: «По умолчанию обработчики запускаются после выполнения всех задач в конкретном play. Уведомленные обработчики выполняются автоматически после каждой из следующих секций в указанном порядке: pre_tasks, roles/tasks и post_tasks».
Причина заключается в пакетной обработке. Если play создает четыре конфигурационных файла для одного сервиса, этот сервис должен перезапуститься один раз, в самом конце, когда все четыре файла уже на месте. Перезапуск после каждого файла привел бы к четырем перезапускам, причем три из них загрузили бы неполную конфигурацию. Та же страница четко формулирует гарантию: «Многократное уведомление одного и того же обработчика приведет к его выполнению только один раз, независимо от того, сколько задач его уведомили».
Порядок выполнения также фиксирован: «Обработчики выполняются в том порядке, в котором они определены в секции handlers, а не в том порядке, в котором они указаны в инструкции notify». Именно поэтому Test the nginx configuration находится выше Reload nginx в playbook. Тест выполняется первым, потому что он записан первым, и ничто в строке notify на это не влияет.
Как выполнять обработчики досрочно и как выполнять их после сбоя
Иногда для выполнения последующих задач в рамках одного play требуется, чтобы сервис уже работал с новой конфигурацией. В такой точке принудительно выполните уведомленные обработчики с помощью модуля meta, который, согласно документации, заставляет «Ansible выполнить все задачи-обработчики, которые были уведомлены к этому моменту».
- name: Run the notified handlers now instead of at the end of the play
ansible.builtin.meta: flush_handlers
- name: Wait for the new listener to accept connections
ansible.builtin.wait_for:
host: 127.0.0.1
port: 8080
timeout: 10Если убрать строку meta, задача wait_for будет выполнена в то время, когда nginx всё ещё использует старую конфигурацию. При первом запуске прослушиватель на порту 8080 вообще отсутствует, поэтому задача ожидает полные 10 секунд и завершается с ошибкой.
Второй случай — это сбой. «Если задача уведомляет обработчик, но другая задача позже в этом же play завершается с ошибкой, по умолчанию обработчик на этом хосте не выполняется, что может привести хост в непредсказуемое состояние». Таким образом, play, который создает конфигурационный файл, а затем прерывается на несвязанной задаче, оставляет новый файл на диске, в то время как в работающем сервисе загружена старая конфигурация. Переопределите это поведение с помощью --force-handlers в командной строке или с помощью force_handlers: true в самом play. Тот же переключатель доступен как force_handlers = True в разделе [defaults] файла ansible.cfg, а также в виде переменной окружения ANSIBLE_FORCE_HANDLERS. Значение по умолчанию — False.
Имена обработчиков конфликтуют, и «проигравший» молчит
В документации указано правило: «Каждый обработчик должен иметь глобально уникальное имя. Если определено несколько обработчиков с одинаковым именем, только последний загруженный в play может быть вызван и выполнен». Обработчики, определённые внутри роли, не ограничены областью видимости этой роли. Они добавляются в единый глобальный список обработчиков для всего play, поэтому две роли, каждая из которых определяет Restart nginx, приведут к тому, что имя будет указывать только на один из них. Решающим фактором станет порядок загрузки, а не роль, из которой вы отправили уведомление.
Проверьте это правило, прежде чем полагаться на него. Сохраните следующий код как handlers-dup.yml:
- name: Two handlers, one name
hosts: local
gather_facts: false
tasks:
- name: Notify the duplicated name
ansible.builtin.command:
cmd: /bin/true
changed_when: true
notify: Duplicated handler
handlers:
- name: Duplicated handler
ansible.builtin.file:
path: /tmp/dup-first
state: touch
mode: '0644'
- name: Duplicated handler
ansible.builtin.file:
path: /tmp/dup-second
state: touch
mode: '0644'rm -f /tmp/dup-first /tmp/dup-second
ansible-playbook handlers-dup.yml
ls -l /tmp/dup-first /tmp/dup-secondPlay выполняется успешно, RUNNING HANDLER [Duplicated handler] появляется один раз, а ls выводит строку для /tmp/dup-first и ls: cannot access '/tmp/dup-second': No such file or directory для другого. Выполняется тот обработчик, который был записан первым, а не последним загруженным, что противоречит предсказанию в документации.
Эту разницу важно понимать, так как правило в документации относится к блокам обработчиков, а не к строкам в файле. Обработчики, поступающие из разных мест (одна роль, затем другая), являются отдельными блоками, и последующий блок действительно перекрывает предыдущий. Обычный список handlers: в play — это единый блок, и поиск внутри блока выполняется сверху вниз, останавливаясь на первом совпавшем имени. Таким образом, внутри одного файла срабатывает первое определение, а второе становится недоступным, тогда как между ролями перекрытие работает так, как описано в документации. В любом случае вы никогда не сможете вызвать оба, и ни один из этих вариантов не является надёжным решением.
Существует два чистых способа решения проблемы. Добавляйте к имени каждого обработчика префикс, специфичный для его роли, или используйте квалифицированную форму уведомления role_name : handler_name, которую документация предлагает как способ «гарантировать, что уведомляется обработчик из роли, а не обработчик с тем же именем извне». Пробелы вокруг двоеточия являются частью этого синтаксиса. Эта проблема становится актуальной, как только вы начинаете подключать роли, написанные не вами.
Ещё одно правило с той же страницы: «Избегайте использования переменных в имени обработчика. Поскольку имена обработчиков обрабатываются шаблонизатором на раннем этапе, Ansible может не иметь значения для имени обработчика в этот момент». Обработчик с именем Restart {{ service_name }} приведёт к сбою всего play, если переменная не определена в момент обработки имени. Использование фиксированных строк для имён обработчиков и их группировка с помощью listen позволяют избежать этой проблемы.
validate: отказ от установки поврежденного конфигурационного файла
validate выполняет команду для проверки отрендеренного файла перед тем, как Ansible переместит его в целевое расположение. В документации сказано: «Команда проверки, запускаемая перед копированием обновленного файла в конечное место назначения. Для проверки используется путь к временному файлу, который передается через %s; этот маркер должен присутствовать, как показано в примерах ниже. Кроме того, команда выполняется безопасно, поэтому функции оболочки, такие как раскрытие путей и конвейеры, работать не будут».
Из этого текста следуют два правила. Использование %s является обязательным, и строка validate без него приведет к ошибке выполнения задачи validate must contain %s. Оболочка (shell) не используется, поэтому конвейеры, перенаправление ввода-вывода, подстановка имен файлов (globbing) и && не работают. Только одна команда и один аргумент в виде файла.
Официальные примеры модуля демонстрируют два случая, когда это работает корректно:
- name: Copy a new sudoers file into place, after passing validation with visudo
ansible.builtin.template:
src: /mine/sudoers
dest: /etc/sudoers
validate: /usr/sbin/visudo -cf %s
- name: Update sshd configuration safely, avoid locking yourself out
ansible.builtin.template:
src: etc/ssh/sshd_config.j2
dest: /etc/ssh/sshd_config
owner: root
group: root
mode: '0600'
validate: /usr/sbin/sshd -t -f %s
backup: yesОба примера работают, так как каждая утилита проверки принимает один файл и анализирует его самостоятельно. visudo -cf считывает файл sudoers. sshd -t -f считывает полный sshd_config.
Почему валидация не может проверить файл nginx в этом руководстве
Добавьте validate: /usr/sbin/nginx -t -c %s в шаблон задачи выше, и задача завершится ошибкой. Сообщение указывает на причину:
nginx: [emerg] "upstream" directive is not allowed here in <ansible temporary path>:2nginx -t -c ожидает полную конфигурацию, которая начинается на верхнем уровне с блоков events и http. Файл, который генерирует этот плей, является фрагментом, включаемым в блок http с помощью include /etc/nginx/conf.d/*.conf; внутри /etc/nginx/nginx.conf. Сам по себе, вне этого контекста, upstream действительно является директивой, расположенной не в том месте, поэтому nginx отклоняет файл, который является абсолютно корректным в месте своего фактического размещения. Проверяющей утилите передали фрагмент и потребовали обработать его как полноценную конфигурацию.
Рабочее решение уже приведено в плейбуке. Установите фрагмент, а затем проверьте собранную конфигурацию в обработчике (handler), определенном перед обработчиком перезагрузки. Поскольку обработчики выполняются в порядке их определения, nginx -t видит реальный /etc/nginx/nginx.conf с включенным в него вашим фрагментом, и ошибка на этом этапе прерывает выполнение плейбука до вызова systemctl reload. Четко осознавайте последствия: поврежденный файл остается на диске в момент сбоя проверки, а nginx продолжает использовать последнюю загруженную конфигурацию до тех пор, пока кто-либо не перезапустит его.
Именно поэтому backup: true оправдывает свое применение. Он записывает копию предыдущего файла рядом с оригиналом перед перезаписью, присваивая ей имя basename.PID.YYYY-MM-DD@HH:MM:SS~, поэтому в директории появляются записи вида learn.conf.4127.2026-08-20@11:42:09~. Запустите sudo ls -l /etc/nginx/conf.d/ после внесения изменений, и вы обнаружите такой файл.
Эта деталь с именованием важнее, чем кажется. Резервная копия безвредна в /etc/nginx/conf.d/, так как основная конфигурация включает только conf.d/*.conf, а имя резервной копии заканчивается тильдой. Она не является безвредной в директории, которая подключается с помощью простого *, а в Debian и Ubuntu /etc/nginx/nginx.conf включает /etc/nginx/sites-enabled/* именно таким образом. При использовании шаблона в sites-enabled с backup: true nginx загрузит резервную копию как второй активный блок server, поэтому данный плей записывает файл в conf.d.
Запуск того же сценария на реальных хостах инвентаря
Измените hosts: local на используемое вами имя группы, и больше ничего в сценарии менять не потребуется. Шаблон обрабатывается отдельно для каждого хоста, поэтому app_listen_port и app_backends могут подставляться из group_vars и host_vars, в то время как сам файл шаблона остаётся единственным. В этом и заключается преимущество использования переменных вместо жестко заданных значений в файле.
Две вещи всё же изменятся. become: true теперь требует пароль sudo на каждом целевом хосте, если у вас не настроен беспарольный sudo, поэтому добавьте -K. Любой секрет в этом шаблоне, будь то пароль базы данных или API-токен, не должен храниться в открытом виде в vars: в файле, который вы отправляете в репозиторий. Зашифруйте эти значения с помощью Ansible Vault и ссылайтесь на них по имени точно так же, как вы делаете это сейчас, поскольку шаблону не важно, откуда поступила переменная.
Когда сценарий перерастает один сервис, для vars:, templates/ и handlers: уже подготовлено стандартное место. Перенос их туда — это и есть основная цель разделения на playbook и роль.
FAQ
Почему мой обработчик (handler) в Ansible не сработал?
Почти всегда это происходит потому, что задача, которая должна была его вызвать, вернула ok, а не changed. Обработчики срабатывают только при изменении состояния (change), и ни при каких других условиях. Поэтому задача с шаблоном, результат рендеринга которого совпадает с уже существующим файлом на диске, не вызывает никаких обработчиков. После этого проверьте четыре момента. Строка в notify должна в точности совпадать с именем обработчика в name или темой listen, включая регистр и пробелы. Если последующая задача на этом хосте завершилась с ошибкой, она подавляет уведомленные обработчики, если не передан флаг --force-handlers. Обработчик, определенный в другом play, недоступен из текущего. И, наконец, задача, пропущенная из-за условия when, никогда не отправляет уведомление.
Почему мой playbook сообщает об изменениях при каждом запуске?
Результат рендеринга нестабилен между запусками. Самая частая причина — наличие временной метки в выводе; кастомизированная строка ansible_managed, включающая дату, приводит именно к этому. Далее проверьте mode и owner в задаче: если они не совпадают с параметрами файла, уже находящегося на диске, Ansible исправляет их и сообщает об изменении, даже если содержимое идентично. Запустите ansible-playbook site.yml --check --diff, чтобы понять, в чем именно причина, так как --diff покажет вам разницу, которую задача пытается внести.
В чем разница между template и copy в Ansible?
ansible.builtin.copy отправляет файл без изменений. ansible.builtin.template сначала обрабатывает его через Jinja2 на управляющем узле (controller), а затем отправляет результат, поэтому переменные и циклы разрешаются до того, как файл попадет на целевой хост. Используйте copy для файлов, которые должны быть побайтово идентичны везде. Используйте template для всего, что различается в зависимости от хоста. Они поддерживают одинаковые опции файлов, поэтому mode, owner, backup и validate работают одинаково в обоих случаях.
Как запустить обработчик в середине play?
Добавьте ansible.builtin.meta: flush_handlers в качестве задачи в том месте, где вы хотите их выполнить. Это запустит все обработчики, уведомленные к этому моменту, после чего play продолжится в обычном режиме. Используйте это, когда последующая задача в том же play зависит от сервиса, уже работающего с новой конфигурацией, например, wait_for на порту, который появляется только после перезагрузки. Это поддерживаемый способ запуска обработчика до завершения play.
Можно ли использовать validate с фрагментом конфигурации nginx?
Не с помощью nginx -t -c %s. Эта команда ожидает полную конфигурацию, начинающуюся с блоков верхнего уровня events и http, поэтому она отклоняет фрагмент conf.d с сообщением вроде "upstream" directive is not allowed here. Фрагмент валиден внутри блока http, но не сам по себе. Установите файл, а затем выполните nginx -t для собранной конфигурации в обработчике, определенном перед обработчиком перезагрузки. Обработчики выполняются в порядке их определения, поэтому некорректная конфигурация прервет выполнение play до попытки перезагрузки. Установите backup: true в задаче с шаблоном, чтобы предыдущий файл остался на месте для отката.