Управляющие конструкции в шаблоне: for, if, set
Двойные фигурные скобки {{ }} выводят значение. Фигурная скобка со знаком процента {% %} - это управляющая конструкция, она ничего не печатает сама, а управляет потоком. Базовый пример - сгенерировать секцию upstream для nginx из списка серверов. Переменная в груповых vars:
Код: Выделить всё
backends:
- { host: 10.0.0.11, port: 8080, weight: 5 }
- { host: 10.0.0.12, port: 8080, weight: 3 }
- { host: 10.0.0.13, port: 8080, backup: true }
Код: Выделить всё
upstream app {
{% for b in backends %}
server {{ b.host }}:{{ b.port | default(8080) }} weight={{ b.weight | default(1) }}{% if b.backup | default(false) %} backup{% endif %};
{% endfor %}
}
Внутри цикла Jinja2 даёт служебную переменную loop: loop.index (нумерация с 1), loop.index0 (с 0), loop.first, loop.last. Удобно, когда между элементами нужен разделитель, а после последнего - нет:
Код: Выделить всё
{% for srv in dns_servers %}nameserver {{ srv }}
{% endfor %}
{# либо в одну строку через join, см. ниже #}
Код: Выделить всё
{% set workers = (ansible_processor_vcpus | default(2)) * 2 %}
worker_processes {{ workers }};

Ansible jinja2 фильтры, которые реально пригодятся
Фильтр - это преобразование значения через вертикальную черту: {{ value | filter }}. В Ansible filters делятся на стандартные Jinja2 и добавленные самим Ansible. Разберём те, что встречаются в боевых ролях постоянно.
default - подставить значение, если переменная не определена. Хитрость: пустая строка или false по умолчанию НЕ считаются "не определено". Чтобы подменять и их (например, пустую строку), добавь второй аргумент true: {{ myvar | default("fallback", true) }}.
ternary - тернарный оператор без if: {{ (state == "present") | ternary("enabled", "disabled") }}. Читается как "условие ? а : б".
map - применить фильтр или вытащить атрибут у каждого элемента списка. Собрать все host из списка словарей:
Код: Выделить всё
{{ backends | map(attribute='host') | list }}
{# -> ['10.0.0.11', '10.0.0.12', '10.0.0.13'] #}
{{ dns_servers | map('regex_replace', '$', ' # managed') | list }}
Код: Выделить всё
{{ backends | rejectattr('backup', 'defined') | map(attribute='host') | list }}
{{ users | selectattr('shell', 'equalto', '/bin/bash') | list }}
Код: Выделить всё
{{ "web01.dc.example.com" | regex_replace('^([^.]+)\\..*$', '\\1') }}
{# -> web01 #}
{{ iface | regex_replace('[^a-zA-Z0-9]', '_') }}
combine - слить словари (правый перекрывает левый). Базовые настройки плюс оверрайды для конкретного хоста:
Код: Выделить всё
{{ defaults_cfg | combine(host_overrides, recursive=true) }}
Код: Выделить всё
{% for item in limits | dict2items %}
{{ item.key }} hard nofile {{ item.value }}
{% endfor %}
Фильтры можно и нужно сцеплять в конвейер: map -> select -> sort -> unique -> join. Длинная цепочка читается как pipeline в shell и заменяет десяток строк ручной логики.
Макросы, include и блоки: убираем повторы
Если внутри шаблона повторяется один и тот же кусок разметки - его выносят в макрос. Это как функция: объявил один раз, вызываешь с аргументами. Jinja2 макросы особенно хороши, когда генерируешь однотипные секции конфига.
Код: Выделить всё
{% macro vhost(name, root, port=80) %}
server {
listen {{ port }};
server_name {{ name }};
root {{ root }};
}
{% endmacro %}
{% for site in sites %}
{{ vhost(site.name, site.root, site.port | default(80)) }}
{% endfor %}
Код: Выделить всё
{% import 'macros.j2' as m %}
{{ m.vhost('api.local', '/srv/api') }}
Плагины lookup: данные снаружи в момент рендера
Фильтр преобразует то, что у тебя уже есть. А ansible lookup - это плагин, который ИДЁТ за данными наружу: читает файл, переменную окружения, выполняет команду, генерирует пароль. Запускается lookup на управляющей машине (там, где крутится ansible), а не на целевом хосте - это частый источник путаницы. Синтаксис: {{ lookup('plugin', 'аргумент') }}.
Самые ходовые плагины ansible lookup:
- file - вернуть содержимое файла: {{ lookup('ansible.builtin.file', '/etc/hostname') }}.
- env - значение переменной окружения управляющей машины: {{ lookup('ansible.builtin.env', 'HOME') }}.
- pipe - выполнить команду локально и взять её stdout: {{ lookup('ansible.builtin.pipe', 'git rev-parse --short HEAD') }}.
- password - сгенерировать пароль и закешировать его в файл, чтобы при повторном прогоне он не менялся (идемпотентность): {{ lookup('ansible.builtin.password', '/tmp/secrets/db chars=ascii_letters,digits length=20') }}.
- template - отрендерить вложенный j2-шаблон в строку и вернуть результат (удобно для генерации фрагмента "на лету").
Код: Выделить всё
- name: Сгенерировать пароль приложения
ansible.builtin.copy:
content: "{{ lookup('ansible.builtin.password', '/srv/creds/app length=24') }}"
dest: /etc/app/secret
owner: app
mode: '0400'
# после файла можно дёрнуть restorecon/SELinux-контекст при необходимости
Маленькая кросс-дистрибутивная пометка. Шаблоны и lookup от дистрибутива не зависят - это логика на управляющей машине. А вот что ты в них подставляешь, отличается: путь к пакетному менеджеру (dnf на RHEL/Fedora/RED OS против apt на Ubuntu/Debian/Astra), имена сервисов systemd, дефолтные shell у пользователей. Эту разницу обычно прячут в переменные через факт ansible_os_family и фильтр default, а сам шаблон остаётся один.
Типичные грабли
- Лишние пустые строки в отрендеренном файле из-за переводов строк после {% ... %}. Лечи минусом {%- -%} или включи lstrip_blocks/trim_blocks в шапке шаблона.
- default не срабатывает на пустой строке и false - они "определены". Нужен default("x", true).
- Обратные слеши в regex_replace внутри YAML съедаются. Пиши \\1 для обратной ссылки, бери regex в одинарные кавычки.
- ipaddr "не найден" - забыли установить коллекцию ansible.utils. Это не часть ansible-core.
- lookup ушёл за файлом, а файла нет на УПРАВЛЯЮЩЕЙ машине, и ты ищешь его на целевом хосте. lookup всегда читает локально.
- map(attribute=...) на списке, где у части элементов нет этого ключа, падает. Сначала отфильтруй selectattr('key', 'defined').
Руками, без подсматривания:
- Опиши в group_vars список из 3 бэкендов (host, port, опциональный weight) и сгенерируй upstream-секцию шаблоном с циклом for и фильтром default.
- Добавь четвёртый бэкенд с флагом backup: true и выведи его с суффиксом backup через {% if %}.
- Напиши макрос vhost(name, root, port=80) и сгенерируй им два виртуальных хоста из списка sites.
- Через lookup('ansible.builtin.pipe', ...) подставь в файл текущий git-хеш каталога, а через lookup password сгенерируй пароль с кешированием в файл - прогони плейбук дважды и убедись, что пароль не поменялся (идемпотентность).
- Возьми список FQDN и фильтром regex_replace оставь только короткое имя хоста.
- Чем {{ }} отличается от {% %} в Jinja2 и зачем нужен минус в {%- -%}?
- Почему {{ x | default("y") }} не подставит "y", когда x - пустая строка, и как это обойти?
- В чём принципиальная разница между фильтром и lookup, и на какой машине выполняется lookup?
- Какой фильтр соберёт из списка словарей значения одного поля, а какой превратит словарь в список пар для цикла?
Jinja2 в Ansible - это не просто подстановка значений, а полноценный язык генерации конфигов: циклы и условия убирают копипасту, set прячет вычисления внутри шаблона, макросы и include дают переиспользование, а богатый набор Ansible filters (map, select/reject, combine, dict2items, regex_replace, ipaddr) превращает сырые данные в готовый конфиг одной цепочкой. lookup добавляет к этому подтягивание данных снаружи. На EX294 ровно это и проверяют: умеешь ли ты не вписать значение руками, а описать данные и сгенерировать из них конфиг. Кто уверенно гоняет for/if и фильтры в шаблонах - тот пишет роли, которые не разваливаются при добавлении пятого сервера.