Zajmuję się wiele recenzjami obcego kodu w Ansible i dużo sam piszę. Analizując błędy (zarówno cudze, jak i swoje), a także przeprowadzając pewną liczbę rozmów kwalifikacyjnych, zrozumiałem podstawowy błąd, który popełniają użytkownicy Ansible — wchodzą w złożoności, nie opanowawszy podstaw.
Aby naprawić tę kosmiczną niesprawiedliwość, postanowiłem napisać wprowadzenie do Ansible dla tych, którzy już go znają. Ostrzegam, to nie jest streszczenie przewodników, to długi tekst, w którym jest dużo liter i żadnych obrazków.
Oczekiwany poziom czytelnika - napisano już kilka tysięcy linii YAML, coś już jest w produkcji, ale 'jakoś wszystko jest krzywe'.
Nazwy
Głównym błędem użytkownika Ansible jest nieznajomość nazw. Jeśli nie znasz nazw, nie możesz rozumieć tego, co napisano w dokumentacji. Przykład z życia: na rozmowie kwalifikacyjnej osoba, która wydawała się mówić, że dużo pisała w Ansible, nie potrafiła odpowiedzieć na pytanie 'z czego składa się playbook?'. A kiedy podpowiedziałem, że 'oczekiwano odpowiedzi, że playbook składa się z play', usłyszałem zabójczy komentarz 'tego nie używamy'. Ludzie piszą w Ansible za pieniądze i nie używają play. W rzeczywistości używają, ale nie wiedzą, co to jest.
Zaczynajmy więc od prostego: jak co nazywa się. Może to już wiesz, a może nie, bo nie zwróciłeś uwagi czytając dokumentację.
ansible-playbook wykonuje playbook. Playbook to plik z rozszerzeniem yml/yaml, wewnątrz którego jest coś takiego:
---
- hosts: group1
roles:
- role1
- hosts: group2,group3
tasks:
- debug:Już zrozumieliśmy, że cały ten plik to playbook. Możemy pokazać, gdzie są role (roles), gdzie są zadania (tasks). Ale gdzie tu jest play? I czym różni się play od role czy playbooka?
W dokumentacji to wszystko jest. I to jest pomijane. Początkujący — bo jest za dużo i nie zapamiętasz wszystkiego naraz. Doświadczeni — bo 'trywialne rzeczy'. Jeśli jesteś doświadczony — przeczytaj te strony przynajmniej raz na pół roku, a twój kod stanie się o klasę lepszy.
Zatem zapamiętaj: Playbook to lista, składająca się z play i import_playbook.
To jest jedna play:
- hosts: group1
roles:
- role1A to jest jeszcze jedna play:
- hosts: group2,group3
tasks:
- debug:Czym jest play? Po co ona jest?
Play to kluczowy element dla playbooka, ponieważ play i tylko play łączy listę ról i/lub zadań z listą hostów, na których mają być wykonywane. W głębokich zakamarkach dokumentacji można znaleźć wzmiankę o delegate_to, lokalne wtyczki lookup, specyficzne ustawienia network-cli, jump-hosty itd. Umożliwiają one drobną zmianę miejsca wykonania zadań. Ale zapomnij o tym. Każda z tych sprytnych opcji ma bardzo specyficzne zastosowania i z pewnością nie są one uniwersalne. A my mówimy o podstawowych rzeczach, które powinni znać i używać wszyscy.
Jeśli chcesz "coś" wykonać "gdzieś" — piszesz play. Nie rolę. Nie rolę z modułami i delegatami. Po prostu piszesz play. W której w polu hosts wymieniasz, gdzie wykonać, a w roles/tasks — co wykonać.
Proste, prawda? A jak może być inaczej?
Jednym z wyraźnych momentów, kiedy ludzie mają ochotę zrobić to nie przez play, jest "rola, która wszystko konfiguruje". Chcesz mieć rolę, która konfiguruje i serwera pierwszego typu, i serwery drugiego typu.
Archetypowym przykładem jest monitorowanie. Chcesz mieć rolę monitoring, która skonfiguruje monitorowanie. Rola monitoring jest przypisywana do hostów monitorujących (w odpowiednim play). Ale okazuje się, że do monitorowania musimy zainstalować pakiety na hostach, które monitorujemy. Czemu nie użyć delegata? A jeszcze musimy skonfigurować iptables. Delegate? A jeszcze musimy napisać/poprawić konfigurację dla DBMS, aby monitorowanie działało. Delegate! A jeśli wena przyjdzie, można zrobić delegację include_role w zagnieżdżonej pętli według sprytnego filtru na liście grup, a wewnątrz include_role można jeszcze zrobić delegate_to znowu. I zaczęło się...
Słuszne pragnienie — mieć jedną jedyną rolę monitoring, która "wszystko robi" — prowadzi nas do piekła, z którego najczęściej jest tylko jedno wyjście: wszystko przepisać od nowa.
Gdzie popełniono błąd? W momencie, gdy odkrywasz, że aby wykonać zadanie "x" na hoście X musisz iść do hosta Y i tam zrobić "y", powinieneś był wykonać proste ćwiczenie: pójść i napisać play, która na hoście Y robi y. Nie dopisywać czegoś w "x", a napisać od nowa. Niech nawet będą z hardkodowanymi zmiennymi.
Wydaje się, że w powyższych akapitach wszystko zostało powiedziane poprawnie. Ale to nie jest twój przypadek! Ponieważ chcesz napisać kod wielokrotnego użytku, który jest DRY i przypomina bibliotekę, i trzeba szukać metody, jak to zrobić.
Tutaj czai się jeszcze jeden poważny błąd. Błąd, który przemienił wiele projektów z dość dobrze napisanych (można lepiej, ale wszystko działa i łatwo to rozbudować) w absolutny koszmar, w którym nawet autor nie potrafi się odnaleźć. To działa, ale chroń Boże, żeby coś zmienić.
Ten błąd brzmi tak: rola to funkcja biblioteczna. Ta analogia zniszczyła tyle dobrych inicjatyw, że po prostu smutno na to patrzeć. Rola to nie funkcja biblioteczna. Nie może wykonywać obliczeń ani podejmować decyzji na poziomie play. Przypomnij mi, jakie decyzje podejmuje play?
Dziękuję, masz rację. Play podejmuje decyzję (dokładniej, zawiera informacje) o tym, jakie zadania i role mają być wykonywane na jakich hostach.
Jeśli delegujesz tę decyzję na rolę, a jeszcze z obliczeniami, skazujesz siebie (i tego, kto będzie próbował zrozumieć twój kod) na żałosne istnienie. Rola nie decyduje, gdzie ma być wykonywana. Decyzję o tym podejmuje play. Rola robi to, co jej powiedziano, tam, gdzie jej powiedziano.
Dlaczego programowanie w Ansible jest niebezpieczne i dlaczego COBOL jest lepszy od Ansible omówimy w rozdziale o zmiennych i jinja. Na razie powiedzmy jedno - każde twoje obliczenie pozostawia nieusuwalny ślad w postaci zmiany zmiennych globalnych, i nic z tym nie możesz zrobić. Jak tylko dwa "ślady" się przecinają - wszystko poszło.
Uwaga dla dociekliwych: rola z pewnością może wpływać na kontrolę przepływu. Istnieją delegate_to i ma sensowne zastosowania. Są meta: end host/play. Ale! Pamiętaj, uczymy się podstaw? Zapomnieliście o delegate_to. Mówimy o najprostszej i najpiękniejszej strukturze kodu w Ansible. Który jest łatwy do przeczytania, łatwy do napisania, łatwy do debugowania, łatwy do testowania i łatwy do rozszerzenia. Tak więc, jeszcze raz:
play i tylko play decyduje, na jakich hostach co ma być wykonywane.
W tej sekcji wyjaśniliśmy opozycję między play a rolą. Teraz porozmawiajmy o relacjach tasks vs role.
Zadania i role
Rozważmy play:
- hosts: somegroup
pre_tasks:
- some_tasks1:
roles:
- role1
- role2
post_tasks:
- some_task2:
- some_task3:Załóżmy, że musisz zrobić foo. I wygląda to tak: foo: name=foobar state=present. Gdzie to napisać? w pre? post? Tworzyć rolę?
… A gdzie się podziały zadania?
Znowu zaczynamy od podstaw - struktura play. Jeśli pływasz w tym temacie, nie możesz używać play jako podstawy dla wszystkiego innego, a twój wynik jest ‘chwiejący się’.
Urządzenie play: dyrektywa hosts, ustawienia samego play oraz sekcje pre_tasks, tasks, roles, post_tasks. Pozostałe parametry dla play nie są teraz dla nas ważne.
Kolejność ich sekcji z zadaniami i rolami: pre_tasks, roles, tasks, post_tasks. Ponieważ semantycznie kolejność wykonania między tasks i roles nie jest jasna, najlepsze praktyki mówią, że dodajemy sekcję tasks, tylko jeśli nie ma roles. Jeśli jest roles, to wszystkie przylegające zadania umieszczane są w sekcjach pre_tasks/post_tasks.
Pozostaje tylko to, co semantycznie jest jasne: najpierw pre_tasks, potem roles, potem post_tasks.
Ale wciąż nie odpowiedzieliśmy na pytanie: gdzie wywołać moduł foo napisać? Czy musimy pisać całą rolę dla każdego modułu? A może lepiej mieć jedną dużą rolę dla wszystkiego? A jeśli nie rola, to gdzie pisać — w pre czy w post?
Jeśli na te pytania nie ma uzasadnionej odpowiedzi, to jest to oznaka braku intuicji, czyli te "krucha fundamenty". Zajmijmy się tym. Najpierw pytanie kontrolne: Jeśli play ma pre_tasks i post_tasks (i nie ma ani tasks, ani roles), to czy coś może się zepsuć, jeśli przeniosę pierwsze zadanie z post_tasks na koniec pre_tasks?
Oczywiście, sformułowanie pytania sugeruje, że coś się zepsuje. Ale co dokładnie?
… Handlery. Odkrycie podstawowe ujawnia ważny fakt: wszystkie handlery są automatycznie czyszczone po każdej sekcji. Tzn. wykonują się wszystkie zadania z pre_tasks, a potem wszystkie handlery, które były notify. Następnie wykonują się wszystkie role i wszystkie handlery, które były notify w rolach. Następnie post_tasks i ich handlery.
W ten sposób, jeśli przeniesiesz zadanie z post_tasks do pre_tasks, to potencjalnie wykonasz je przed wykonaniem handlera. Na przykład, jeśli w pre_tasks jest instalowany i konfigurowany serwer www, a w post_tasks coś jest do niego przesyłane, to przeniesienie tego zadania do sekcji pre_tasks doprowadzi do tego, że w momencie "przesyłania" serwer nie będzie jeszcze uruchomiony i wszystko się zepsuje.
A teraz pomyślmy jeszcze raz, po co nam pre_tasks i post_tasks? Например, для того, чтобы выполнить всё нужное (включая хэндлеры) до выполнения роли. А post_tasks pozwoli nam pracować z wynikami wykonania ról (w tym handlerów).
Zawzięty znawca Ansible powie nam, że jest meta: flush_handlers, ale po co nam flush_handlers, skoro możemy polegać na kolejności wykonania sekcji w play? Co więcej, użycie meta: flush_handlers może przynieść nam niespodziewane powtarzające się handlery, a w przypadku użycia when u block i tym podobne. Im lepiej znasz Ansible, tym więcej niuansów możesz wskazać do "przemyślanego" rozwiązania. A proste rozwiązanie — użycie naturalnego podziału między pre/roles/post — nie powoduje żadnych niuansów.
Wracamy do naszego ‘foo’. Gdzie go umieścić? W pre, post czy w roles? Oczywiste, że to zależy od tego, czy potrzebujemy wyników pracy handlera dla foo. Jeśli nie, to foo nie powinno być umieszczane ani w pre, ani w post — te sekcje mają specjalne znaczenie — wykonanie zadań przed i po głównym bloku kodu.
Teraz odpowiedź na pytanie "rola czy zadanie" sprowadza się do tego, co już jest w play — jeśli są tam tasks, to należy dopisać do tasks. Jeśli są roles — trzeba stworzyć rolę (nawet jeśli składa się z jednego zadania). Przypominam, że tasks i roles nie są używane jednocześnie.
Zrozumienie podstaw Ansible’a daje uzasadnione odpowiedzi na, wydawałoby się, pytania o charakterze subiektywnym.
Zadania i role (część druga)
Teraz omówimy sytuację, gdy dopiero zaczynasz pisać playbook. Musisz zrobić foo, bar i baz. To trzy zadania, jedna rola czy trzy role? Generalizując pytanie: w którym momencie należy zacząć pisać role? Jaki jest sens pisania ról, skoro można pisać zadania?… Czym jest rola?
Jednym z największych błędów (już o tym wspomniałem) jest mylenie roli z funkcją w bibliotece programu. Jak wygląda ogólne opisanie funkcji? Przyjmuje argumenty na wejściu, oddziałuje na side causes, wywołuje side effects, zwraca wartość.
Teraz uwaga. Co z tego można zrobić w roli? Wywołać side effects — zawsze proszę, to jest sedno całego Ansible’a — robić side effects. Mieć side causes? Elementarne. Ale z "przekazać wartość i zwrócić ją" — tu już nie. Po pierwsze, nie możesz przekazać wartości do roli. Możesz ustawić zmienną globalną, która ma czas życia równy play w sekcji vars dla roli. Możesz ustawić zmienną globalną z czasem życia w play wewnątrz roli. Albo nawet z czasem życia playbooka (set_fact/register). Ale nie możesz mieć "zmiennych lokalnych". Nie możesz "przyjmować wartości" i "zwracać jej".
Z tego wynika najważniejsze: nie można w Ansible napisać czegoś i nie wywołać side effects. Zmiana zmiennych globalnych — to zawsze side effect dla funkcji. W Rust, na przykład, zmiana zmiennej globalnej to unsafe. A w Ansible — jedyny sposób, aby wpłynąć na wartości dla roli. Zwróć uwagę na stosowane słowa: nie "przekazać wartość do roli", a "zmienić wartości, które używa rola". Między rolami nie ma izolacji. Między zadaniami a rolami nie ma izolacji.
Podsumowując: rola — to nie funkcja.
Co dobrego jest w roli? Po pierwsze, rola ma wartości domyślne (/default/main.yaml), po drugie, rola ma dodatkowe katalogi do przechowywania plików.
Czym więc są dobre wartości domyślne? Tym, że w mocno zniekształconej tabeli priorytetów zmiennych według Maslowa, domyślne wartości roli — są najbardziej nisko priorytetowe (z wyjątkiem parametrów wiersza poleceń Ansible). To oznacza, że jeśli musisz dostarczyć wartości domyślne i nie martwić się, że zastąpią wartości z inwentarza lub zmiennych grupowych, to domyślne wartości roli to jedyne właściwe miejsce dla Ciebie. (Troszkę kłamię — jest jeszcze |d(your_default_here), ale jeśli mówimy o stałych miejscach — to tylko domyślne wartości ról).
Co jeszcze jest dobre w rolach? To, że mają swoje katalogi. Są to katalogi dla zmiennych, zarówno stałych (tzn. obliczanych dla roli), jak i dynamicznych (jest taki wzorzec lub antywzorzec — include_vars wraz z {{ ansible_distribution }}-{{ ansible_distribution_major_version }}.yml.). To są katalogi dla files/, templates/. Dodatkowo pozwala to na posiadanie własnych modułów i wtyczek w rolach (library/). Jednak w porównaniu do zadań w playbookach (które również mogą mieć wszystko to), korzyść polega tylko na tym, że pliki nie są wrzucone do jednego przysłowiowego worka, a do kilku oddzielnych woreczków.
Jeszcze jeden szczegół: można próbować tworzyć role, które będą dostępne do ponownego użycia (przez galaxy). Po wprowadzeniu kolekcji dystrybucja ról może być uważana za prawie zapomnianą.
W ten sposób role mają dwie ważne cechy: mają domyślne wartości (unikalna cecha) i pozwalają strukturyzować kod.
Wracając do pierwotnego pytania: kiedy używać zadań, a kiedy ról? Zadania w playbookach najczęściej służą albo jako "klej" przed/po rolach, albo jako samodzielny element budowlany (wtedy w kodzie nie powinno być ról). Gromadzenie normalnych zadań w połączeniu z rolami — to zdecydowana niechlujność. Należy trzymać się konkretnego stylu — albo zadania, albo role. Role dają rozdzielenie bytów i domyślne wartości, zadania pozwalają szybciej przeczytać kod. Zazwyczaj w roli umieszcza się bardziej "stały" (ważny i skomplikowany) kod, zaś w stylu zadań pisze się skrypty pomocnicze.
Można zrobić import_role jako zadanie, ale jeśli to robisz, przygotuj się na wyjaśnienie dla własnego poczucia estetyki, dlaczego chcesz to robić.
Dociekliwy czytelnik może powiedzieć, że role mogą importować role, role mogą mieć zależności przez galaxy.yml, a ponadto istnieje coś strasznego i przerażającego. include_role Przypominam, że rozwijamy umiejętności w podstawowym Ansible, a nie w gimnastyce artystycznej.
Handlerzy i zadania
Porozmawiajmy o jeszcze jednej oczywistej kwestii: handlerzy. Umiejętność ich odpowiedniego użycia to niemal sztuka. Jaka jest różnica między handlerem a zadaniem?
Ponieważ przypominamy sobie podstawy, oto przykład:
- hosts: group1
tasks:
- foo:
notify: handler1
handlers:
- name: handler1
bar:W roli handler’y znajdują się w rolename/handlers/main.yaml. Handler’y są współdzielone pomiędzy wszystkimi uczestnikami play: pre/post_tasks mogą wywoływać handler’y roli, a rola może wywoływać handler’y z play. Jednak "cross-role" wywołania handler’ów wywołują dużo większe zdziwienie niż powtórzenie trywialnego handlera. (Jeszcze jeden element najlepszych praktyk — staraj się nie powtarzać nazw handlerów).
Podstawowa różnica polega na tym, że zadanie jest zawsze wykonywane (idempotentnie) (plus/minus tagi i when), a handler — w zależności od zmiany stanu (notify działa tylko, jeśli było zmienione). Co z tego wynika? Na przykład, że przy kolejnym uruchomieniu, jeśli nie było zmiany, to nie będzie również handlera. A dlaczego może być tak, że musimy uruchomić handler, kiedy w zadaniu macierzystym nie było zmiany? Na przykład dlatego, że coś się zepsuło i zmiana była, ale do handlera wykonanie nie dotarło. Na przykład, ponieważ sieć chwilowo zawiodła. Konfiguracja zmieniła się, usługa nie została uruchomiona ponownie. Przy następnym uruchomieniu konfiguracja już się nie zmienia, a usługa pozostaje z starą wersją konfiguracji.
Sytuacja z konfiguracją jest niemożliwa do rozwiązania (dokładniej, można samemu wymyślić specjalny protokół ponownego uruchamiania z flagami plików itd., ale to już nie jest 'podstawowy ansible' w żadnej formie). Jednak istnieje inna częsta historia: zainstalowaliśmy aplikację, zapisaliśmy jej .service-plik, i teraz chcemy go daemon_reload i stan=uruchomiony. Naturalnym miejscem dla tego wydaje się być handler. Ale jeśli zrobisz z niego task na końcu listy zadań lub rolę, to będzie wykonywany idempotentnie za każdym razem. Nawet jeśli playbook się zepsuje w połowie. To nie rozwiązuje problemu restarted (nie można zrobić tasku z atrybutem restarted, ponieważ traci się idempotentność), ale zdecydowanie warto ustawić state=started, ogólna stabilność playbooków wzrasta, ponieważ zmniejsza się liczba zależności i dynamicznych stanów.
Jeszcze jedną pozytywną cechą handlera jest to, że nie zaśmieca on wyjścia. Nie było zmian — brak dodatkowych skipped lub ok w wyjściu — łatwiej to przeczytać. To samo jest także negatywną cechą — jeśli błąd w liniowo wykonywanej tasku znajdziesz już przy pierwszym przebiegu, to handlerzy będą wykonani tylko przy changed, tzn. w określonych warunkach — bardzo rzadko. Na przykład, pierwszy raz w życiu po pięciu latach. I, oczywiście, tam będzie literówka w nazwie i wszystko się zepsuje. A drugi raz ich nie można uruchomić — changed nie występuje.
Osobno trzeba porozmawiać o dostępności zmiennych. Na przykład, jeśli używasz notify dla tasku z pętlą, to co będzie w zmiennych? Można to dedukować analitycznie, ale nie zawsze jest to trywialne, zwłaszcza jeśli zmienne pochodzą z różnych źródeł.
… Tak że handlerzy są znacznie mniej użyteczni i bardziej problematyczni, niż się wydaje. Jeśli można coś napisać ładnie (bez wygibasów) bez handlerów, lepiej to zrobić bez nich. Jeśli nie da się tego zrobić ładnie — lepiej z nimi.
Skrupulatny czytelnik słusznie zauważa, że nie omówiliśmy listen, że handler może wywoływać notify dla innego handlera, że handler może zawierać import_tasks (który może wykonywać include_role z with_items), że system handlerów w Ansiblu jest turingowo kompletny, że handlerzy z include_role ciekawie przetwarzają się z handlerami z play itp. — wszystko to jest zdecydowanie nie "podstawami").
Chociaż jest jeden określony WTF, który tak naprawdę jest funkcją, o której należy pamiętać. Jeśli masz task, który wykonuje się z delegate_to i ma notify, to odpowiedni handler jest wykonywany bez delegate_to, tzn. na hoście, na którym przypisana jest play. (Chociaż handler może mieć również delegate_to to).
Osobno chcę powiedzieć kilka słów o reusable roles. Do czasu pojawienia się kolekcji istniała idea, że można stworzyć uniwersalne role, które można ansible-galaxy install i pojechał. Działa na wszystkich systemach operacyjnych w każdej sytuacji. Moje zdanie: to nie działa. Każda rola z masową include_vars, wsparciem 100500 przypadków jest skazana na bezdenne otchłanie błędów. Można je łatać masowym testowaniem, ale jak w każdym teście, albo macie iloczyn kartezjański wartości wejściowych i funkcję totalną, albo "pokryte są konkretne scenariusze". Moje zdanie — znacznie lepiej, gdy rola jest liniowa (złożoność cyklomatyczna 1).
Im mniej ifów (jawnych lub deklaratywnych — w formie when lub formie include_vars na zestaw zmiennych), tym lepiej rola. Czasami trzeba robić rozgałęzienia, ale, powtarzam, im ich mniej, tym lepiej. Tak więc wydaje się, że dobra rola z galaxy (działa przecież!) z mnóstwem when może być mniej preferowana niż "swoja" rola z pięcioma zadaniami. Moment, w którym rola z galaxy jest lepsza — kiedy zaczynasz coś pisać. Moment, w którym staje się gorsza — kiedy coś się psuje, i masz podejrzenie, że to przez "rolę z galaxy". Otwierasz ją, a tam pięć inclusji, osiem list zadań i stos when‘ów… I musisz to zrozumieć. Zamiast 5 zadań w liniowej liście, w której nie ma nawet z czego się zepsuć.
W następnych częściach
- Trochę o inwentarzu, zmiennych grupowych, pluginie host_group_vars, hostvars. Jak związki spaghetti z Grodziskim węzłem. Zakres i precedencja zmiennych, model pamięci Ansible. "A gdzie zatem przechowywać nazwę użytkownika dla bazy danych?".
jinja: {{ jinja }}— nosql notype nosense miękka plastelina. Jest wszędzie, nawet tam, gdzie się go nie spodziewasz. Trochę o!!unsafei smacznym yaml.
Źródło: habr.com
