Основы Ansible, без которых ваши плейбуки — комок слипшихся макарон

02:04

Я делаю много ревью для чужого кода на Ансибл и много пишу сам. В ходе анализа ошибок (как чужих, так и своих), а так же некоторого количества собеседований, я понял основную ошибку, которую допускают пользователи Ансибла — они лезут в сложное, не освоив базового.

Для исправления этой вселенской несправедливости я решил написать введение в Ансибл для тех, кто его уже знает. Предупреждаю, это не пересказ манов, это лонгрид в котором много букв и нет картинок.

Ожидаемый уровень читателя — уже написано несколько тысяч строк ямла, уже что-то в продакшене, но "как-то всё криво".

Названия

Главная ошибка пользователя Ansible — это не знать как что называется. Если вы не знаете названий, вы не можете понимать то, что написано в документации. Живой пример: на собеседовании, человек, вроде бы заявлявший, что он много писал на Ансибле, не смог ответить на вопрос "из каких элементов состоит playbook'а?". А когда я подсказал, что "ожидался ответ, что playbook состоит из play", то последовал убийственный комментарий "мы этого не используем". Люди пишут на Ансибле за деньги и не используют play. На самом деле используют, но не знают, что это такое.

Так что начнём с простого: как что называется. Может быть вы это знаете, а может и нет, потому что не обратили внимания, когда читали документацию.

ansible-playbook исполняет playbook. Playbook — это файл с расширением yml/yaml, внутри которого что-то такое:

---
- hosts: group1
  roles:
    - role1

- hosts: group2,group3
  tasks:
    - debug:

 

Мы уже поняли, что весь этот файл — плейбука. Мы можем показать где тут роли (roles), где таски (tasks). Но где тут play? И чем отличается play от role или playbook?

В документации это всё есть. И это пропускают. Начинающие — потому что там слишком много и всё сразу не запомнишь. Опытные — потому что "тривиальные вещи". Если вы опытный — перечитывайте эти страницы хотя бы раз в пол-года, и ваш код станет классом лучше.

Итак, запоминайте: Playbook — это список, состоящий из play и import_playbook
Вот это — одна play:

- hosts: group1
  roles:
    - role1

и вот это тоже ещё одна play:

- hosts: group2,group3
  tasks:
    - debug:

Что же такое play? Зачем она?

Play — это ключевой элемент для playbook, потому что play и только play связывает список ролей и/или тасок с списком хостов, на которых их надо выполнять. В глубоких недрах документации можно найти упоминание про delegate_to, локальные lookup-плагины, network-cli-специфичные настройки, jump-хосты и т.д. Они позволяют слегка поменять место исполнения тасок. Но, забудьте про это. У каждой из этих хитрых опций есть очень специальные применения, и они точно не являются универсальными. А мы говорим про базовые вещи, которые должны знать и использовать все.

Если вы хотите "что-то" исполнить "где-то" — вы пишете play. Не роль. Не роль с модулями и делегейтами. Вы берёте и пишете play. В которой, в поле hosts вы перечисляете где исполнять, а в roles/tasks — что исполнять.

Просто же, да? А как может быть иначе?

Одним из характерных моментов, когда у людей возникает желание сделать это не через play, это "роль, которая всё настраивает". Хочется иметь роль, которая настраивает и сервера первого типа, и сервера второго типа.

Архетипичным примером является мониторинг. Хочется иметь роль monitoring, которая настроит мониторинг. Роль monitoring назначается на хосты мониторинга (в соотв. play). Но, выясняется, что для мониторинга нам надо поставить пакеты на хосты, которые мы мониторим. Почему бы не использовать delegate? А ещё надо настроить iptables. delegate? А ещё надо написать/поправить конфиг для СУБД, чтобы мониторинг пускала. delegate! А если креатив попёр, то можно сделать делегацию include_role во вложенном цикле по хитрому фильтру на список групп, а внутри include_role можно ещё делать delegate_to снова. И понеслось...

Благое пожелание — иметь одну-единственную роль monitoring, которая "всё делает" — ведёт нас кромешный ад из которого чаще всего один выход: всё переписать с нуля.

Где тут случилась ошибка? В тот момент, когда вы обнаружили, что для выполнения задачи "x" на хосте X вам надо пойти на хост Y и сделать там "y", вы должны были выполнить простое упражнение: пойти и написать play, которая на хосте Y делает y. Не дописывать что-то в "x", а написать с нуля. Пусть даже с захардкоженными переменными.

Вроде бы, в абзацах выше всё сказано правильно. Но это же не ваш случай! Потому что вы хотите написать переиспользуемый код, который DRY и похож на библиотеку, и нужно искать метод как это сделать.

Вот тут вот притаилась ещё одна грубая ошибка. Ошибка, которая превратила множество проектов из терпимо написанных (можно лучше, но всё работает и легко дописать) в совершенный ужас, в котором даже автор не может разобраться. Оно работает, но упаси боже что-то поменять.

Эта ошибка звучит так: роль — это библиотечная функция. Эта аналогия сгубила столько хороших начинаний, что просто грустно смотреть. Роль — не библиотечная функция. Она не может делать вычисления и она не может принимать решения уровня play. Напомните мне, какие решения принимает play?

Спасибо, вы правы. Play принимает решение (точнее, содержит в себе информацию) о том, какие таски и роли на каких хостах выполнять.

Если вы делегируете это решение на роль, да ещё и с вычислениями, вы обрекаете себя (и того, кто ваш код будет пытаться разобрать) на жалкое существование. Роль не решает где ей выполняться. Это решение принимает play. Роль делает то, что ей сказали, там, где ей сказали.

Почему заниматься программированием на Ансибле опасно и чем COBOL лучше Ансибла мы поговорим в главе про переменные и jinja. Пока что скажем одно — каждое ваше вычисление оставляет за собой нестираемый след из изменения глобальных переменных, и вы ничего с этим не можете сделать. Как только два "следа" пересеклись — всё пропало.

Замечание для въедливых: роль, безусловно, может влиять на control flow. Есть delegate_to и у него есть разумные применения. Есть meta: end host/play. Но! Помните, мы учим основы? Забыли про delegate_to. Мы говорим про самый простой и самый красивый код на Ансибл. Который легко читать, легко писать, легко отлаживать, легко тестировать и легко дописывать. Так что, ещё раз:

play и только play решает на каких хостах что исполняется.

В этом разделе мы разобрались с противостоянием play и role. Теперь поговорим про отношения tasks vs role.

Таски и Роли

Рассмотрим play:

- hosts: somegroup
  pre_tasks:
    - some_tasks1:
  roles:
     - role1
     - role2
  post_tasks:
     - some_task2:
     - some_task3:

Допустим, вам надо сделать foo. И выглядит это как foo: name=foobar state=present. Куда это писать? в pre? post? Создавать role?

… И куда делись tasks?

Мы снова начинаем с азов — устройство play. Если вы плаваете в этом вопросе, вы не можете использовать play как основу для всего остального, и ваш результат получается "шатким".

Устройство play: директива hosts, настройки самой play и секции pre_tasks, tasks, roles, post_tasks. Остальные параметры для play нам сейчас не важны.

Порядок их секций с тасками и ролями: pre_tasksrolestaskspost_tasks. Поскольку семантически порядок исполнения между tasks и roles не понятен, то best practices говорит, что мы добавляем секцию tasks, только если нет roles. Если есть roles, то все прилагающиеся таски помещаются в секции pre_tasks/post_tasks.

Остаётся только то, что семантически всё понятно: сначала pre_tasks, потом roles, потом post_tasks.

Но мы всё ещё не ответили на вопрос: а куда вызов модуля foo писать? Надо ли нам под каждый модуль писать целую роль? Или лучше иметь толстую роль подо всё? А если не роль, то куда писать — в pre или в post?

Если на на эти вопросы нет аргументированного ответа, то это признак отсутствия интуиции, то есть те самые "шаткие основы". Давайте разбираться. Сначала контрольный вопрос: Если у play есть pre_tasks и post_tasks (и нет ни tasks, ни roles), то может ли что-то сломаться, если я первую таску из post_tasks перенесу в конец pre_tasks?

Разумеется, формулировка вопроса намекает, что сломается. Но что именно?

… Хэндлеры. Чтение основ открывает важный факт: все хэндлеры flush'атся автоматом после каждой секции. Т.е. выполняются все таски из pre_tasks, потом все хэндлеры, которые были notify. Потом выполняются все роли и все хэндлеры, которые были notify в ролях. Потом post_tasks и их хэндлеры.

Таким образом, если вы таску перетащите из post_tasks в pre_tasks, то, потенциально, вы выполните её до выполнения handler'а. например, если в pre_tasks устанавливается и конфигурируется веб-сервер, а в post_tasks в него что-то засылается, то перенос этой таски в секцию pre_tasks приведёт к тому, что в момент "засылания" сервер будет ещё не запущен и всё сломается.

А теперь давайте ещё раз подумаем, а зачем нам pre_tasks и post_tasks? Например, для того, чтобы выполнить всё нужное (включая хэндлеры) до выполнения роли. А post_tasks позволит нам работать с результатами выполнения ролей (включая хэндлеры).

Въедливый знаток Ansible скажет нам, что есть meta: flush_handlers, но зачем нам flush_handlers, если мы можем положиться на порядок исполнения секций в play? Более того, использование meta: flush_handlers может нам доставить неожиданного с повторяющимися хэндлерами, сделать нам странные варнинги в случае использования when у block и т.д. Чем лучше вы знаете ансибл, тем больше нюансов вы сможете назвать для "хитрого" решения. А простое решение — использование натурального разделения между pre/roles/post — не вызывает нюансов.

И, возвращаемся, к нашему 'foo'. Куда его поместить? В pre, post или в roles? Очевидно, это зависит от того, нужны ли нам результаты работы хэндлера для foo. Если их нет, то foo не нужно класть ни в pre, ни в post — эти секции имеют специальный смысл — выполнение тасок до и после основного массива кода.

Теперь ответ на вопрос "роль или таска" сводится к тому, что уже есть в play — если там есть tasks, то надо дописать в tasks. Если есть roles — надо делать роль (пусть и из одной task). Напоминаю, tasks и roles одновременно не используются.

Понимание основ Ансибла даёт обоснованные ответы на, казалось бы, вопросы вкусовщины.

Таски и роли (часть вторая)

Теперь обсудим ситуацию, когда вы только начинаете писать плейбуку. Вам надо сделать foo, bar и baz. Это три таски, одна роль или три роли? Обобщая вопрос: в какой момент надо начинать писать роли? В чём смысл писать роли, когда можно писать таски?… А что такое роль?

Одна из грубейших ошибок (я про это уже говорил) — считать, что роль — это как функция в библиотеке у программы. Как выглядит обобщённое описание функции? Она принимает аргументы на вход, взаимодействует с side causes, делает side effects, возвращает значение.

Теперь, внимание. Что из этого можно сделать в роли? Вызвать side effects — всегда пожалуйста, это и есть суть всего Ансибла — делать сайд-эффекты. Иметь side causes? Элементарно. А вот с "передать значение и вернуть его" — вот тут-то и нет. Во-первых, вы не можете передать значение в роль. Вы можете выставить глобальную переменную со сроком жизни размером в play в секции vars для роли. Вы можете выставить глобальную переменную со сроком жизни в play внутри роли. Или даже со сроком жизни плейбуки (set_fact/register). Но вы не можете иметь "локальные переменные". Вы не можете "принимать значение" и "возвращать его".

Из этого вытекает главное: нельзя на ansible написать что-то и не вызвать сайд-эффекты. Изменение глобальных переменных — это всегда side effect для функции. В Rust, например, изменение глобальной переменной — это unsafe. А в Ансибл — единственный метод повлиять на значения для роли. Обратите внимание на используемые слова: не "передать значение в роль", а "изменить значения, которые использует роль". Между ролями нет изоляции. Между тасками и ролями нет изоляции.

Итого: роль — это не функция.

Что же хорошего есть в роли? Во-первых, у роли есть default values (/default/main.yaml), во-вторых у роли есть дополнительные каталоги для складывания файлов.

Чем же хороши default values? Тем, что в пирамиде Маслоу довольно извращённой таблице приоритетов переменных у Ансибла, role defaults — самые неприоритетные (за вычетом параметров командной строки ансибла). Это означает, что если вам надо предоставить значения по-умолчанию и не переживать что они перебъют значения из инвентори или групповых переменных, то дефолты роли — это единственное правильное место для вас. (Я немного вру — есть ещё |d(your_default_here), но если говорить про стационарные места — то только дефолты ролей).

Что ещё хорошего в ролях? Тем, что у них есть свои каталоги. Это каталоги для переменных, как постоянных (т.е. вычисляемых для роли), так и для динамических (есть такой то ли паттерн, то ли анти-паттерн — include_vars вместе с {{ ansible_distribution }}-{{ ansible_distribution_major_version }}.yml.). Это каталоги для files/templates/. Ещё, оно позволяет иметь роли свои модули и плагины (library/). Но, в сравнении с тасками у playbook'и (у которой тоже всё это может быть), польза тут только в том, что файлы свалены не в одну кучу, а несколько раздельных кучек.

Ещё одна деталь: можно пытаться делать роли, которые будут доступны для переиспользования (через galaxy). После появления коллекций распространение ролей можно считать почти забытым.

Таким образом, роли обладают двумя важными особенностями: у них есть дефолты (уникальная особенность) и они позволяют структурировать код.

Возвращаясь к исходному вопросу: когда делать таски а когда роли? Таски в плейбуке чаще всего используются либо как "клей" до/после ролей, либо как самостоятельный строительный элемент (тогда в коде не должно быть ролей). Груда нормальных тасок в перемешку с ролями — это однозначная неряшливость. Следует придерживаться конкретного стиля — либо таски, либо роли. Роли дают разделение сущностей и дефолты, таски позволяют прочитать код быстрее. Обычно в роли выносят более "стационарный" (важный и сложный) код, а в стиле тасок пишут вспомогательные скрипты.

Существует возможность делать import_role как таску, но если вы такое пишете, то будьте готовы к объяснительной для собственного чувства прекрасного, зачем вы это хотите делать.

Въедливый читатель может сказать, что роли могут импортировать роли, у ролей может быть зависимость через galaxy.yml, а ещё есть страшный и ужасный include_role — напоминаю, мы повышаем навыки в базовом Ансибле, а не в фигурной гимнастике.

Хэндлеры и таски

Давайте обсудим ещё одну очевидную вещь: хэндлеры. Умение их правильно использовать — это почти искусство. В чём разница между хэндлером и таской?

Так как мы вспоминаем основы, то вот пример:

- hosts: group1
  tasks:
    - foo:
      notify: handler1
  handlers:
     - name: handler1
       bar:

У роли handler'ы лежат в rolename/handlers/main.yaml. Handler'ы шарятся между всеми участниками play: pre/post_tasks могут дёргать handler'ы роли, а роль может дёргать handler'ы из плей. Однако, "кросс-ролевые" вызовы handler'ов вызывают куда больший wtf, чем повтор тривиального handler'а. (Ещё один элемент best practices — стараться не делать повторов имён handler'ов).

Основное различие в том, что таска выполняется (идемпотентно) всегда (плюс/минус теги и when), а хэндлер — по изменению состояния (notify срабатывает только если был changed). Чем это чревато? Например, тем, что при повторном запуске, если не было changed, то не будет и handler. А почему может быть так, что нам нужно выполнить handler когда не было changed у порождающей таски? Например, потому что что-то сломалось и changed был, а до хэндлера выполнение не дошло. Например, потому что сеть временно лежала. Конфиг поменялся, сервис не перезапущен. При следующем запуске конфиг уже не меняется, и сервис остаётся со старой версией конфига.

Ситуация с конфигом не решаемая (точнее, можно самим себе изобрести специальный протокол перезапуска с файловыми флагами и т.д., но это уже не 'basic ansible' ни в каком виде). Зато есть другая частая история: мы поставили приложение, записали его .service-файл, и теперь хотим его daemon_reload и state=started. И натуральное место для этого, кажется, хэндлер. Но если сделать его не хэндлером а таской в конце тасклиста или роли, то он будет идемпотентно выполняться каждый раз. Даже если плейбука сломалась на середине. Это совершенно не решает проблемы restarted (нельзя делать таску с атрибутом restarted, т.к. теряется идемпотентность), но однозначно стоит делать state=started, общая стабильность плейбуки возрастает, т.к. уменьшается количество связей и динамического состояния.

Ещё одно положительное свойство handler'а состоит в том, что он не засоряет вывод. Не было изменений — нет лишних skipped или ok в выводе — легче читать. Оно же является и отрицательным свойством — если опечатку в линейно исполняемой task'е вы найдёте на первый же прогон, то handler'ы будут выполнены только при changed, т.е. при некоторых условиях — очень редко. Например, первый раз в жизни спустя пять лет. И, разумеется, там будет опечатка в имени и всё сломается. А второй раз их не запустить — changed-то нет.

Отдельно надо говорить про доступность переменных. Например, если вы notify для таски с циклом, то что будет в переменных? Можно аналитическим путём догадаться, но не всегда это тривиально, особенно, если переменные приходят из разных мест.

… Так что handler'ы куда менее полезны и куда более проблемны, чем кажется. Если можно что-то красиво (без выкрутас) написать без хэндлеров лучше делать без них. Если красиво не получается — лучше с ними.

Въедливый читатель справедливо отмечает, что мы не обсудили listen, что handler может вызывать notify для другого handler'а, что handler может включать в себя import_tasks (который может делать include_role c with_items), что система хэндлеров в Ансибле тьюринг-полная, что хэндлеры из include_role прелюбопытнейшим образом пересекаются с хэндлерами из плей и т.д. — всё это явно не "основы").

Хотя есть один определённый WTF, который на самом деле фича, и о котором надо помнить. Если у вас таска выполняется с delegate_to и у неё есть notify, то соответствующий хэндлер выполняется без delegate_to, т.е. на хосте, на котором назначена play. (Хотя у хэндлера, разумеется, может быть delegate_to тоже).

Отдельно я хочу сказать пару слов про reusable roles. До появления коллекций была идея, что можно сделать универсальные роли, которые можно ansible-galaxy install и поехал. Работает на всех ОС всех вариантов во всех ситуациях. Так вот, моё мнение: это не работает. Любая роль с массовым include_vars, поддержкой 100500 случаев обречена на бездны corner case багов. Их можно затыкать массированным тестированием, но как с любым тестированием, либо у вас декартово произведение входных значений и тотальная функция, либо у вас "покрыты отдельные сценарии". Моё мнение — куда лучше, если роль линейная (цикломатическая сложность 1).

Чем меньше if'ов (явных или декларативных — в форме when или форме include_vars по набору переменных), тем лучше роль. Иногда приходится делать ветвления, но, повторю, чем их меньше, тем лучше. Так что вроде бы хорошая роль с galaxy (работает же!) с кучей when может быть менее предпочтительна, чем "своя" роль из пяти тасок. Момент, когда роль с galaxy лучше — когда вы что-то начинаете писать. Момент, когда она становится хуже — когда что-то ломается, и у вас есть подозрение, что это из-за "роли с galaxy". Вы её открываете, а там пять инклюдов, восемь таск-листов и стопка when'ов… И в этом надо разобраться. Вместо 5 тасок линейным списком, в котором и ломаться-то нечему.

В следующих частях

  • Немного про инвентори
  • групповые переменные, host_group_vars plugin, hostvars. Как из спагетти связать Гордиев узел. Scope и precedence переменных, модель памяти Ansible. "Так где же всё-таки хранить имя пользователя для базы данных?".
  • jinja: {{ jinja }} — nosql notype nosense мягкий пластилин. Оно всюду, даже там, где вы его не ожидаете. Немного про !!unsafe и вкусный yaml.

 

отсюда