В этой части мы перестаём говорить о простом и приятном и начинаем говорить о трудном. Переменные в Ansible: scope, precedence, рекурсивная интерполяция. Для тех, кто дочитает до конца, маленький бонус: упрощённая таблица приоритетов, с которой можно жить. Предыдущие части: 1, 2.
Обычно рассказ про переменные в Ансибл начинают с чего-то очень простенького, что создаёт у читателя иллюзию, что переменные в Ансибл — это как в любом другом языке программирования. Мутабельные или немутабельные, локальные и глобальные. Это не так.
Это не так.
У Ансибла возникла уникальная модель переменных (модель памяти?), которую надо учить с нуля. И рассматривать мы её начнём с того места, где значения используются (обычно переменные Ансибла рассматривают с того места, откуда они появляются). Почему? Потому что при рассказе в этом направлении у нас образуется направленный граф, который куда легче уложить в голову.
Обратите внимание — я сказал "значения", потому что "переменные" — это всего лишь имена к значениям. У переменных свой глубокий внутренний мир, и про него во второй части.
В рамках этого рассказа я буду использовать понятия "появляется" и /"используется". Появляется — это то место, где значение было задано. А "попадает" — это то место, где значение начинает влиять на работу Ансибла, точнее, на его сайд-эффекты — на реальный процесс исполнения модулей в целевой системе. Пока переменные перекладываются из места на место в секции vars
, значения никуда не "попадают" и на окружающий мир никак не влияют.
Это первая важная мысль, которую надо помнить в голове: пока значение не было использовано чем-то, влияющим на окружающий мир, оно может содержать в себе любые синтаксические ошибки и ссылки на несуществующие переменные и т.д., и это никого не смутит. Почему так — читайте дальше.
Итак, где значения используются?
- В параметрах к модулю.
- copy: src=foo.conf dest=/etc/foo.conf
. В этом примереfoo.conf
и/etc/foo.conf
— значения. Они записываются в параметры модуля в целевой системе. В процессе записи, всё, что было в значении, приводится в буквальную форму. Например, на этом этапе вычисляется jinja, числа/строки приводятся к тому формату, который ожидает от них модуль (например, если модуль ожидает строку, а вы передали число, именно на этом этапе Ансибл выдаст предупреждение, что число было сконвертировано в строку). - При выполнении модуля (на самом деле это action plugin) 'template' или соответствующего lookup plugin'а по имени template. Они используют значения из переменных в шаблоне в момент своего исполнения (для lookup plugin когда этот момент наступит совсем не очевидно, потому произойдёт это только когда интерполируется значение, упоминающее этот плагин).
- В параметрах play. Любые значения в параметрах play (не путать с переменными уровня play) будут использованы буквально в момент начала play. Например, если у вас jinja2 в
gather_facts
, то она будет вычислена именно в момент начала выполнения play. Аналогично —hosts
,remote_user
и другие. - Магические переменные Ансибла, которые влияют на процесс исполнения модуля. В основном, это
ansible_host
,ansbile_user
,ansible_extra_ssh_args
,ansible_transport
и т.д. Значения из этих переменных вычисляются буквально для каждой таски каждый раз перед выполнением таски.
Вооружённые этим знанием мы уже можем попытаться догадываться, что за фигня происходит в этом коде:
- name: Do not do this
file: path=hello.txt state=touch
loop: '{{ groups.all }}'
vars:
ansible_host: '{{ hostvars[item].ansible_host }}'
Этот ужас обычно невозможно объяснить начинающим. С правильным подходом к понятию "используется" он становится чуть меньшим ужасом (но всё ещё остаётся примером того, как не надо писать).
Что тут происходит?
- Переменная
ansible_host
всегда (в пределах этой таски) равна строке{{ hostvars[item].ansible_host }}
. Ноль анализа содержимого. Если это валидный yaml, то годится. - Выполняется цикл
loop
. Цикл использует значение{{ groups.all }}
. Это значение — jinja. Производится интерполяция строки, оно и превращается в список имён хостов. loop читает этот список, выполняет таску нужное количество раз, выставляя для каждого запуска таски переменную item в значение "следующий элемент из списка". - Модуль использует значения
hello.txt
иtouch
. Ввнутри нет jinja, так что значения остаются просто сами собой. - Перед каждым вызовом модуля
file
значение переменнойansible_host
используется для вычисления буквального параметра для транспорта ssh — куда подключаться. И вот тут вот обнаруживается, что внутриansible_host
находится Jinja, и происходит интерполяция. Каждый раз, когда надо выполнить модуль (т.е. много раз в течение одного loop). Оказывается, что jinja ссылается на переменную item, а её значение разное на каждом проходе цикла (т.е. каждый раз item содержит в себе что-то другое). В результате вычисляется значение, которое используется для подключения для каждого нового прохода цикла. Итог? Извращённая делегация. Не делайте так, но понимайте как оно устроено.
Обратите внимание — этот принцип применим ко всем случаям. Например, переменная в инвентори может ссылаться на факт, который будет выставлен где-то далеко в недрах роли. Если значение из этой переменной не будет использовано до выполнения set_fact
, то всё ок (кроме здравого смысла), и такое Ансибл съедает без особых проблем.
Это же открывает возможности неявной параметризации. Так, например (и это уже почти best practice) можно задать в групповых переменных IP-адрес для фильтрации:
allow_access: '{{ ansible_default_ipv4.address }}'
При том, что у каждого хоста факты появляются уже в процессе выполнения setup (или gathering_facts), переменная allow_access мирно хранит в себе выражение Jinja до момента, пока не потребуется использовать это значение — и только тогда Ансибл пошевелится выяснить, что же это за переменная такая и чему она равна.
Jinja
Второй важный аспект работы Ансибла — это использование Jinja (языка шаблонизации). В подавляющем большинстве случаев все строковые значения, которые используются, проходят через механизм интерполяции (шаблонизации), за вычетом нескольких мест (см ниже). Интерполяция выполняется следующим образом: в каком-то месте ожидается использование строки. Перед использованием строка обрабатывается шаблонизатором. Если шаблонизатор что-то поменял, что получившаяся строка передаётся в шаблонизатор ещё раз. И до тех пор, пока шаблонизатор не перестанет менять строку. После этого строка передаётся в то место, которое его использует (параметр модуля, магическая переменная для запуска модуля и т.д.). Другими словами, шаблонизатор применяет Jinja рекурсивно, пока есть что менять.
Рассмотрим простой пример:
- debug:
msg: '{{ message }}'
vars:
foo: 'foo'
foobar: '{{ foo + "bar" }}'
message: 'This is {{ foobar }}'
В момент, когда строковое значение передаётся как параметр 'msg' в модуль 'debug' (это не модуль, а action plugin, но для наших целей это не важно), строка {{ message }}
прогоняется через шаблонизатор. Тот видит усы ({{
и }}
) и заменяет их содержимым переменной message. Получается значение This is {{foobar }}
. Дальше это значение прогоняется через шаблонизатор ещё раз получается This is {{ foo + "bar" }}
. После ещё одного раунда тест становится This is foobar
. После ещё одного раундна интерполяции текст не поменялся, т.е. интерполяция закончена. Получившаяся строка уходит в параметр msg модулю debug.
Въедливый читатель может сказать, что на самом деле это рекурсивная интерполяция, а не циклическая, но с точки зрения рассказа это влияет только на проблемы бесконечной рекурсии.
Теперь поговорим про большое WTF, которое подстерегает любого "программиста на ямле".
- hosts: localhost
tasks:
- debug: msg={{foo}}
vars:
foo: '{{ foo + 1 }}'
vars:
foo: 1
Мне пришлось писать полную play, потому что мне надо было "задать" foo в двух местах. Сначала на уровне play, потом на уровне таски. Что происходит в debug? Он ожидает в msg строку. Эта строка проходит через шаблонизатор, который делает строку {{ foo + 1 }}
, потом {{ foo + 1 }} + 1
и т.д. Почти мговенно ансибл достигает глубины рекурсии и говорит, что дальше нельзя.
А теперь смотрите на эту магию:
- hosts: localhost
tasks:
- set_fact:
foo: '{{ foo + 1 }}'
- debug: msg={{foo}}
vars:
foo: 1
Этот код выведет число "два"? Почему? Потому что set_fact
-это модуль и он использует переменную foo. А ещё он создаёт более приоритетную переменную foo, которая потом и используется. Этот код куда более трудный для понимания, чем кажется, потому что старое foo (со значением 1) никуда не исчезает, просто у новой foo из фактов приоритет выше. Но, об этом в следующем разделе.
Пока что я хочу дать вам интуицию, что как только строка попадает в параметры модуля, она используется. И именно в этот момент происходит интерполяция.
Разберём ещё один пример на развитие интуиции.
- hosts: localhost
vars:
foo: '{{ bar * 2 }}'
tasks:
- debug: var=foo
loop: [1,2,3]
vars:
bar: '{{ item + 10 }}'
Я специально написал vars для play наверху (иногда так делают для наглядности). Но ещё это докидывает WTF для человека, который не до конца уверен в происходящем.
Что тут случается? Мы делаем итерацию по списку из чисел 1, 2, 3. У нас в таске присутствуют переменные:
foo
со значением '{{ bar * 2 }}'
и bar
со значением '{{ item + 10 }}'
. Обратите внимание, что на этапе vars (как на уровне play, так и на уровне таски), всем (включая ансибл) пофигу, что в этих переменных. Строка и строка. Совершенно не важно, что они "рядом", что они задаются в каком-то специальном порядке.
Важными эти переменные становятся только в момент выполнения модуля debug. Значение foo рекурсивно разворачивается в {{ bar *2 }}
, которое разворачивается в {{ (item + 10) * 2 }}
. Итоговое значение которое в зависимости от item (меняется loop'ом) становится 22, потом 24, потом 26.
Переменные могут приходить из разных мест — переменные инвентори, переменные групп, дефолты ролей, факты, переменные таск и плейбук и т.д. За вычетом scope/precedence (см ниже), откуда бы ни пришла переменная, она будет подставляться в строку в момент использования строки.
Вот вам ещё один смешной пример:
- hosts: localhost
vars:
foo: '{{ bar }}'
tasks:
- debug: var=foo
vars:
bar: 'one value'
- debug: var=foo
vars:
bar: 'another value'
С учётом вышесказанного точно понятно что происходит. Значение строки используется в момент вызова модуля, происходит рекурсивная интерполяция, в ходе интерполяции используются разные значения bar для первой и второй таски. Mystery solved.
Блочный Jinja
Так как все значения параметров для модулей проходят интерполяцию, то шаблоны (точнее, jinja) можно использовать в любых параметрах любых модулей. Можно использовать любую jinja. Не только {{ такую }}
, но и {% if True %} "такую" {%endif %}
. Что открывает возможность иногда сильно упростить код. В сочетании с многострочными строками yaml это даже читаемо.
- foo_module:
username: <
{% for user in myusers %}
{% if user.lower() in good and user.upper() in other %}
{{ user }}
{% endif %}
{% endfor %}
Ровно тот же метод используется в 'content' для модуля file
. На этот раз пример просто копипаст из одного из моих продакшенов:
- name: Configure sfcapd systemd service
become: true
copy:
content: |
[Unit]
Description=sflow capture service
[Service]
Type=simple
ExecStart=/usr/bin/sfcapd sfcapd -w -T{{ sflow_extensions }} -p {{ sflow_port }} -B {{ sflow_buffer_size }} -l {{ sflow_data_path }} -b {{ sflow_host }}
Restart=always
[Install]
WantedBy=multi-user.target
dest: /etc/systemd/system/sfcapd.service
notify:
- restart sfcapd
Что мы тут получаем? Экономию человеческого времени на беготне между 100500 файлов в разных каталогах. Файл маленький, тривиальный. Можно template
, можно copy
с content
. Что читаемее, то используем.
Мы почти разобрались со значениями. Осталось три вопроса.
Если вам в какой-то момент времени нужно иметь строку с усами (например, это шаблон prometheus или любого другого приложения на Go), то вы можете запретить интерполяцию строки. Это делается с использованием смеси чёрной yaml-магии и чёрной магии Ансибла.
В Ansible есть два типа строк: safe и unsafe. safe-строки — это обычные строки, которые интерполируются. unsafe строки оставляются как есть и шаблонизатор никогда к ним не прикасается.
Например:
- debug:
msg: !unsafe'{{ inventory_hostname }}'
Выведет на экран строку "как есть", то есть {{ inventory_hostname }}
.
Когда я узнал про эту фичу, в моей жизни исчезла большая боль и неудобство.
Второе — когда значения строк интерполируются, а когда нет? Я думал писать список таких случаев, а потом понял, что действует одно простое правило: не интерполируются ключи словарей на любом уровне вложенности ямла. Нельзя использовать шаблоны в именах модулей, ключах словарей в переменных, именах переменных (которые по сути те же ключи).
Типы
Я старательно обходил этот вопрос так долго, как можно, потому что теперь из милых странностей Ансибла мы погружаемся в бездны оппортунистической типа-типизации, после которых PHP начинает казаться вершиной теории типов.
Давайте начнём с боевого примера.
---
- hosts: localhost
gather_facts: false
tasks:
- name: Case1
debug: var=item
loop: '{{ [1,2,3,4] }}'
- name: Case2
debug: var=item
loop: '{{ foo + bar }}'
- name: Case3
debug: var=item
loop: ' {{ [9,10] }}'
vars:
foo: '[5,6'
bar: '7,8]'
Case1 выглядит как слегка извращённая Jinja без особенностей. Ну да, внутри jinja2 список, мы его выводим. Могли бы так же написать и без усов, а с использованием списков yaml'а.
Case2 должен вызвать первый вопрос от типонаци (типонаци — это как граммарнаци, но с любовью к строгой типизации) — мы взяли две строки, объединили их, но почему-то считаем, что это список. Запахло PHP.
Case3 должен вызвать духовное удовлетворение у типонаци — Ансибл ругается на ошибку, потому что на входе строка вместо списка.
Но комбинация Case2 и Case3 — это то место, где Ансибл Ужасен с большой буквы. Суровая страшная правда жизни состоит в том, что всё, что приходит из переменных Ансибла является строкой в начале. А в конце (перед началом использования) Ансибл пробует это прочитать как json. И если ему удаётся, то он интерпретирует его как получившийся тип (из json'а).
А вот эти два кейса можно считать точкой завершения разумного и предсказуемого в Ansible:
- name: Case6, space at the end
debug: var=item
loop: '{{ [15, 16] }} '
- name: Case7, space at the start
debug: var=item
loop: ' {{ [15, 16] }}'
Case6 работает, потому что в loop список (не смотря на пробел в конце), а Case7 не работает, потому что у него пробел в начале. Почему? Потому. Жри что дали.
… Список таких неочевидных вещей очень большой. Наверное, я потом напишу отдельный пост, состоящий только из WTF'ных автотипизаций ансибла.
Байка: пока я писал этот кусочек, я экспериментировал с разными микросниппетами Ансибла. 5 человек, пищущие на Ансибле для каждого сниппета выдавали разные прогнозы, и ни разу никто не угадал что произойдёт. Я сохранил эти сниппеты для отдельной статьи "хаха, это ансибл". Но поверьте мне, типо-типизация — это жуть.
Основное, что нужно запомнить из этого раздела: настоящими типами обладают только буквальные значения yaml, всё остальное проходит через оппортунистическую типизацию.
Scope переменных и "слои" переменных
У переменных Ансибла есть срок жизни. В отличие от типа-"типизации", scope вполне понятен и чаще всего не вызывает вопросов. Перменные Ансибла живут:
- Вечно, если они заданы в inventory и group_vars.
- До окончания play, если заданы в ролях или переменных play. (Вот зачем важно знать, что такое "play" — без этого вы не знаете, как долго ваши переменные существуют).
- До окончания task, если это переменные таски.
- До окончания плейбуки, если это факт. (именно этим отличаются переменные и факты — факты живут дольше).
Я игнорирую модуль include_role
в своих рассказах, потому что взаимоотношение сроков жизни и приоритетов переменных для include_role и всего остального настолько запутанно (и меняется от версии к версии), что можно сказать только одно — не используйте include_role. Да и вообще, include любого типа.
Scope на самом деле независимы — переменные в одном scope не заменяют переменные другого scope:
---
- hosts: localhost
gather_facts: false
vars:
foo: 2
tasks:
- name: Case1
debug: var=foo
vars:
foo: 1
- name: Case2
debug: var=foo
Пока действуют переменные уровня play, вы видите foo=1, как только scope закончился, вы начинаете видеть foo из scope play, где foo равно 2. (Про precedence мы говорим чуть ниже). В нормальном режиме переменные не "появляются" и не "исчезают", они декларативно заданы (и известны в своём неинтерпретированном состоянии до начала выполнения плейбуки). Есть несколько источников "динамических" переменных — include_vars
, set_fact
, register
и т.д.
Variable precedence
Это источник невероятных приключений и веселья. Больше веселья доставляет только include_role
и типа-"типизация". У Ансибла, если в каком-то месте доступны переменные из нескольких scope, существует набор правил, какие переменные будут использоваться. Эти правила называются variable precedence и это одна из самых плохо сделанных частей Ансибла (что признают даже его разработчики — но исправлять они не могут, потому что любое прикосновение к этой области сломает самым непонятным образом уже написанные плейбуки).
Изначально тезис звучал просто: переменные, приходящие из разных источников имеют разный приоритет. Например, если в инвентори сказано, что http_port: 8088
, то это приоритетнее, чем http_port: 80
в дефолтах роли. Переменные из комадной строки ансибла -e
самые приоритетные и т.д. И всё было бы просто, если бы не путаница с тем, чьи групповые перменные находятся в group_vars/
.
Предположим, у вас есть проект, и в каждом файле есть переменная foo
inventory.yaml # [all:vars] foo=inventory
group_vars/all.yaml # foo: g_v_a
playbook.yaml
У вас есть внутри playbook.yaml таска
- debug: var=foo
Что она выведет?
… Совершенно не важно, сколько вы будете зубрить variable precedence из документации Ансибла, потому что вы не знаете, что такое group_vars тут.
Чтобы разобраться со всем этим, давайте обсудим одну малоизвестную, но критически важную деталь: плагин host_group_vars
дока.
host_group_vars
В определённые моменты времени (из которых нас интересует момент загрузки инвентори и момент чтения плейбуки) Ансибл запускает плагин host_group_vars. Плагин смотрит в файловую систему в поисках файлов, в которых могут быть group vars или host vars. Он это делает в контексте сущности, для которой делается загрузка. Если group_vars/all.yaml читается в контексте инвентори, то получается переменная инвентори. Если в контексте playbook, получается переменная playbook.
Таким образом, если инвентори и плейбука лежат в одном каталоге, то group_vars читаются ими обоими. Но побеждает тот, кому полагается по variable precedence (playbook), так что содержимое внешних group_vars победит.
Но это ещё не всё. У вас одни и те же group_vars могут быть как снаружи, так и внутри файла инвентори. В этом случае побеждает тот, кто прочитан последний. Сначала читается инвентори, потом данные плагина для этой инвентори, потом следующая инвентори, потом данные плагина второй инвентори и т.д. Потом читаются переменные плейбуки.
На всё это накладывается черезполосица разных приоритетов group_vars/other, group_vars/all, host_vars и т.д. Попытка это всё удержать в голове обычно заканчивается либо ложной картиной мира, либо отчаянием.
Ниже я попробую выписать элементы здравомыслия, которые сложились в моей практике по этому вопросу, но за их пределами — тут нет красивого решения и всюду неожиданности.
При этом приходится писать много переменных в инвентори. Чтобы не провоцировать precedence WTF, есть такое правило: никогда не создавать перекрытие по именам между групповыми переменными (инвентори и плейбук), никогда не создавать перекрытия между hostvars между инвентори и плейбукой.
Разумная таблица приоритетов
Примерно такая же, как тут, но без WTF'ов.
- role/defaults — всегда проигрывает. Идеальное место для размещения переменных, которые надо переопределять. Дефолты ролей видны в соответствующих pre/post tasks.
- group_vars/all — переменные группы all наименее приоритетные
- group_vars/other_groups
- host_vars
- результат выполнения gather_facts: true (host_facts)
- Переменные уровня play
- Переменные уровня block
- Переменные уровня task
- set_fact/register.
set_fact
ещё и живёт дольше, чем плей (т.е. может передавать данные между разными play). - -e побеждает всех и всегда.
Обратите внимание, что некоторые элементы пропущены (не используйте их). Для каждого уровня запрещены конфликты и "нюансы". Результат куда проще для запоминания и осознания и предсказания.
Пример конфликта: одна и та же переменная в group_vars/all
внутри файла инвентори и в файле group_vars/other_group.yaml
.
Динамические чудеса, которые нам в гит попушил матёрый чудак
Существует множество механизмов для надругательства над переменными, которые не вошли в эти статьи. Несколько видов include'ов (переменных, ролей, тасок), динамические инвентори, возможность делать import из include'а, add_host
, позволяющий создавать переменные разного типа на ходу и т.д. Вы можете делать set_fact из делегации. Вы можете создать кастомный модуль, который будет добавлять переменные под видом фактов. Вы можете выставлять переменные уровня jinja, живущие до окончания интерполяции куска строки. Вы можете подгрузить ещё один комплект group_host_vars (плагином) по ходу исполнения play. Вы можете всё это написать, но вы не хотите это ни сопровождать, ни читать.
Keep it clean, keep it simple.