Правя много ревюта на чужд код в Ansible и много пиша сам. По време на анализа на грешки (как на чужди, така и на свои), а също и по време на няколко интервюта, разбрах основната грешка, която правят потребителите на Ansible — навлизат в сложните неща, без да владеят основите.
За да коригирам тази вселенска несправедливост, реших да напиша въведение в Ansible за тези, които вече го познават. Предупреждавам, това не е преразказ на ръководствата, а дълъг текст, изпълнен с думи и без картинки.
Очакваното ниво на читателя — вече е написано няколко хиляди реда YAML, вече нещо е в продукция, но "всичко е малко криво".
Имена
Главната грешка на потребителя на Ansible е да не знае какво как се нарича. Ако не знаете имената, не можете да разбирате това, което е написано в документацията. Жив пример: на интервю, човек, който уж е заявявал, че много е писал на Ansible, не можа да отговори на въпроса "от какви елементи се състои playbook?". Когато подсказах, че "очаквахме отговор, че playbook се състои от play", последва унищожителен коментар "ние това не използваме". Хора пишат на Ansible за пари и не използват play. Всъщност ги използват, но не знаят какво е това.
Така че, да започнем с простото: какво как се нарича. Може би го знаете, а може и да не знаете, защото не сте обърнали внимание, когато четохте документацията.
ansible-playbook изпълнява playbook. Playbook — това е файл с разширение yml/yaml, вътре в който нещо такова:
---
- hosts: group1
roles:
- role1
- hosts: group2,group3
tasks:
- debug:Вече разбрахме, че целият този файл е playbook. Можем да покажем къде е 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. Ролята прави това, което ѝ е казано, там, където ѝ е казано.
Защо програмирането с Ansible е опасно и защо COBOL е по-добър от Ansible, ще обсъдим в глава за променливите и jinja. Дотогава да кажем само едно — всяко ваше изчисление оставя незаличим следа от промяна на глобални променливи, и вие нищо не можете да направите по въпроса. Щом двата "следа" се пресекат — всичко е загубено.
Забележка за внимателните: ролята безусловно може да влияе на потока на управление. Има delegate_to и има разумни приложения. Има meta: end host/play. Но! Помнете, учим основите? Забравихме за delegate_to. Говорим за най-простия и най-красив код на Ansible. Който е лесен за четене, лесен за писане, лесен за отстраняване на грешки, лесен за тестване и лесен за дописване. Така че, отново:
play и само play решава на кои хостове какво ще се изпълнява.
В този раздел разгледахме противопоставянето на play и role. Сега ще поговорим за отношенията между tasks и 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? Да създадете роля?
… И къде отидоха задачите?
Отново започваме от основите — структурата на play. Ако не сте наясно с този въпрос, не можете да използвате play като основа за всичко останало, и резултатът ви ще бъде "шатък".
Устройство play: директива hosts, настройки самой play и секции pre_tasks, tasks, roles, post_tasks. Остальные параметры для play нам сейчас не важны.
Порядок их секций с тасками и ролями: pre_tasks, roles, tasks, post_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 — трябва да направим роля (дори и от една задача). Напомням, tasks и roles не се използват едновременно.
Разбирането на основите на Ansible дава основателни отговори на, изглеждащите като въпроси на вкус.
Задачи и роли (част 2)
Сега да обсъдим ситуацията, когато току-що започвате да пишете плейбук. Трябва да направите foo, bar и baz. Това три задачи, една роля или три роли? Обобщавайки въпроса: в кой момент трябва да започнете да пишете роли? Какъв е смисълът да пишете роли, когато можете да пишете задачи?… А какво е роля?
Една от грубите грешки (за която вече говорих) — да считате, че роля — това е като функция в библиотека на програмата. Как изглежда обобщеното описание на функция? Тя приема аргументи на вход, взаимодействува с side causes, прави side effects, връща стойност.
Сега, внимание. Какво от това може да се направи в роля? Да повикате side effects — всегда, това е същността на Ansible — да правите сайд-ефекти. Да имате side causes? Элементарно. А с "да предадете стойност и да я върнете" — е, ето тук не. Първо, не можете да предадете стойност в роля. Можете да зададете глобална променлива с продължителност живот в play в секцията vars за ролята. Можете да зададете глобална променлива в play вътре в ролята. Или дори с продължителност на плейбук.set_fact/register). Но не можете да имате "локални променливи". Не можете "да приемате стойност" и "да я връщате".
Оттук следва основното: не можете да напишете нещо в ansible и да не предизвикате сайд-ефекти. Промяната на глобални променливи — това винаги е side effect за функция. В Rust, например, промяната на глобална променлива — това е unsafe. А в Ansible — единственият метод да повлияете на стойностите за ролята. Обърнете внимание на използваните думи: не "да предадете стойност в роля", а "да промените стойностите, които използва ролята". Между ролите няма изолация. Между задачите и ролите няма изолация.
В обобщение: роля — това не е функция.
Какво доброто има в ролите? Първо, ролята разполага с default values (/default/main.yaml), и второ, ролята има допълнителни директории за съхранение на файлове.
Какво е добро в default values? Това е, че в доста изкривената таблица с приоритети на променливите по Маслоу, defaults на ролята са с най-нисък приоритет (освен параметрите от командния ред на Ansible). Това означава, че ако трябва да предоставите стойности по подразбиране и не искате да се притеснявате, че ще надмогнат стойностите от инвентара или груповите променливи, то дефолтите на ролята са единственото правилно място за вас. (Лъжа малко — има и |d(your_default_here), но ако говорим за стационарни места — само дефолтите на ролите).
Какво друго е добро в ролите? Те разполагат със свои каталози. Това са каталози за променливи, както постоянни (т.е. изчислени за ролята), така и за динамични (има един такъв патерн или анти-патерн — include_vars вместо {{ ansible_distribution }}-{{ ansible_distribution_major_version }}.yml.). Това са каталози за files/, templates/. Освен това, те позволяват на ролите да имат свои модули и плъгини (library/). Но, в сравнение с задачите в playbook (който също може да съдържа всичко това), ползата тук е само в това, че файловете не са натрупани на едно място, а разделени в няколко купчинки.
Още една подробност: можете да се опитате да правите роли, които ще бъдат достъпни за повторна употреба (чрез galaxy). След появата на колекции, разпространението на роли може да се смята почти за забравено.
Следователно, ролите имат две важни характеристики: те имат дефолти (уникална характеристика) и те позволяват структуриране на кода.
Връщайки се на първоначалния въпрос: кога да използвате задачи и кога роли? Задачите в playbook обикновено се използват или като "лепило" преди/след ролите, или като самостоятелни строителни блокове (тогава в кода не трябва да има роли). Разбъркан набор от нормални задачи смесени с роли — това е определено небрежно. Трябва да се следва конкретен стил — или задачи, или роли. Ролите предоставят разделение на съществата и дефолти, задачите позволяват по-бързо прочитане на кода. Обикновено в ролите се изнасят по "стационарния" (важен и сложен) код, а в стил задачите се пишат помощни скриптове.
Има възможност да се прави import_role като задача, но ако пишете нещо такова, бъдете подготвени да обясните на себе си защо искате да го направите.
Критичен читател може да каже, че роли могат да импортират роли, ролите могат да имат зависимости чрез galaxy.yml, а също така има и страшно и ужасно include_role — напомням, че подобряваме уменията си в основния Ansible, а не в художествената гимнастика.
Хендлери и задачи
Нека обсъдим още една очевидна тема: хендлерите. Умението да ги използваме правилно е почти изкуство. Каква е разликата между хендлер и задача?
Тъй като си припомняме основите, ето един пример:
- hosts: group1
tasks:
- foo:
notify: handler1
handlers:
- name: handler1
bar:В ролята хендлерите се намират в rolename/handlers/main.yaml. Хендлерите се споделят между всички участници в плей: pre/post_tasks могат да повикват хендлерите на ролята, а ролята може да повиква хендлерите от плей. Обаче, "крос-ролевите" извиквания на хендлери предизвикват значително повече недоумение отколкото повторението на тривиален хендлер. (Още един елемент на добрите практики — да се избягва повторен избор на имена на хендлери).
Основната разлика е, че задачата се изпълнява (идемпотентно) винаги (плюс/минус тагове и when), а хендлер — при промяна на състоянието (notify сработва само ако е имало промяна). Какви последствия има това? Например, при повторно стартиране, ако не е имало промяна, то хендлерът също няма да сработи. А защо може да се случи да се наложи да изпълним хендлер, когато не е имало промяна в поредната задача? Например, защото нещо е счупено и е имало промяна, а изпълнението не е стигнало до хендлера. Например, защото мрежата временно е била извън строя. Конфигурацията е променена, услугата не е рестартирана. При следващото стартиране конфигурацията вече не се променя, а услугата остава със старата версия на конфигурацията.
Ситуацията с конфигурацията не е решима (по-точно, може да си измислите специален протокол за рестартиране с файлови флагове и т.н., но това вече не е ‘основен ansible’ в никакъв вид). Но има друга честа история: инсталираме приложение, записваме го в .service-файл и сега искаме да го daemon_reload и state=started. И естественото място за това изглежда като хендлер. Но ако го направите не хендлер, а задача в края на списъка с задачи или роля, той ще се изпълнява идемпотентно всеки път. Дори ако плейбукът се е провалил по средата. Това изобщо не решава проблема с restarted (не може да се прави задача с атрибут restarted, защото се губи идемпотентността), но определено си струва да направите state=started, общата стабилност на плейбуките нараства, тъй като се намалява количеството на връзките и динамичното състояние.
Още едно положително свойство на хендлера е, че той не замърсява извода. Нямаше промени — няма излишни skipped или ok в извода — по-лесно се чете. То също така е и отрицателно свойство — ако откриете правописна грешка в линейно изпълняваната задача при първото й изпълнение, хендлерите ще се изпълнят само при changed, т.е. при определени условия — много рядко. Например, за първи път в живота след пет години. И, разбира се, там ще има правописна грешка в името и всичко ще се развали. А втори път не могат да се стартират — changed-то няма.
Поотделно трябва да се говори за достъпността на променливите. Например, ако уведомите задача с цикъл, какво ще бъде в променливите? Може да се досетите аналитично, но не винаги е тривиално, особено ако променливите идват от различни места.
… Така че хендлерите са много по-малко полезни и много по-проблематични, отколкото изглежда. Ако може да се напише нещо красиво (без извращения) без хендлери, по-добре е да се направи без тях. Ако не може да стане красиво — по-добре с тях.
Придирчивият читател обосновано отбелязва, че не сме обсъдили listen, че хендлерят може да извика notify за друг хендлер, че хендлерят може да включва import_tasks (който може да направи include_role с 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. Как от спагети да свържете Гордиев възел. Обхват и предимство на променливите, модел на паметта в Ansible. "Но къде всъщност да съхранявам името на потребителя за базата данни?".
jinja: {{ jinja }}— nosql notype nosense меко тесто. То е навсякъде, дори там, където не го очаквате. Малко за!!unsafeи вкусен yaml.
Източник: habr.com
