Podstawy automatyzacji z Ansible: Zmienne i szablony Jinja2
Zmienne służą do przechowywania różnego rodzaju wartości i danych. To co się w nich znajduje, zwykle jest zależne od położenia i kontekstu, w jakim z nich korzystamy. Zmienne mogą być zarówno całkiem niezależnymi bytami, jak i wspólnie z innymi zmiennymi w grupie opisywać czy też parametryzować dany obiekt. Do tego celu możemy wykorzystać strukturę słownika (ang. dictionary), który grupuje zmienne nazywane kluczami i umożliwia przypisywanie do nich wartości.
Przykład takiej struktury słownika w języku YAML możemy zobaczyć poniżej:
device:
name: R1
ipv4_address: 10.8.104.1
ipv6_address: 2001:db8:c1sc0::a
Zmienne muszą zaczynać się od litery, a w nazwie mogą posiadać dodatkowo cyfry oraz znak podkreślenia "_". YAML umożliwia odwoływanie się do zmiennych wewnątrz słownika przy użyciu kropki "." lub nawiasów kwadratowych "[]":
device.ipv6_address
device['ipv6_address']
Powyżej, w obu przypadkach odwołaliśmy się do wartości "2001:db8:c1sc0::a". O ile drugi zapis jest bezpieczniejszy, to pierwszy jest częściej stosowany ze względu na wygodę. Jeżeli zdecydujemy się na zapis z kropką ".", to warto zapoznać się ograniczeniami opisanymi w dokumentacji Ansible. My polecamy stosowanie tego drugiego, który wykorzystuje nawiasy kwadratowe "[]".
Zmienne w pliku INI
Ze zmiennych najczęściej korzysta się do parametryzowania hostów i grup inwentarza. W ten sposób, mogą być one obsługiwane przez Ansible, w oparciu o wartości przypisane do różnych zmiennych. Jako, że najczęściej statyczny inwentarz tworzony jest w formacie pliku INI, to też tylko do niego się w tej sekcji odnosimy.
Kiedy chcemy przypisać zmienne bezpośrednio do hosta, to podaje się je w formacie "key=value", w tym samym wierszu co definicja hosta. Oto przykład inwentarza ze zmiennymi przypisanymi bezpośrednio do hostów:
[web_servers]
web1 ansible_host=10.8.232.121 ansible_port=2222
web2:2222 ansible_host=10.8.232.122
10.8.232.123:2222
10.8.232.124 ansible_port=2222
W powyższym przykładzie warto zwrócić uwagę na nazwę "web1" i "web2". Jeżeli podane nazwy nie są nazwami FQDN (Fully Qualified Domain Name) lub nazwami rozwiązywalnymi na adres IP, to powinniśmy skorzystać ze zmiennej "ansible_host". Wskazuje ona adres IP, do którego będzie zestawiane połączenie.
Domyślnie, zostanie podjęta próba nawiązania połączenia SSH na port 22. Jeżeli chcemy zmienić numer portu, to trzeba dołączyć go po dwukropku ":" do nazwy lub adresu IP hosta albo posłużyć się zmienną "ansible_port".
Zmienne najczęściej definiuje się w ramach grup. Są one wtedy ulokowane w specjalnej sekcji o nazwie grupy z sufiksem ":vars". W sekcji dla zmiennych podaje się jedną zmienną per wiersz. Używanie zmiennych na poziomie grupy nie wyklucza możliwości definiowania innych zmiennych, czy nawet nadpisania tych samych zmiennych, na poziomie hosta. Zostało to pokazane poniżej:
[web_servers:vars]
ansible_port=2222
[web_servers]
web1 ansible_host=10.8.232.121
web2 ansible_host=10.8.232.122
10.8.232.123
10.8.232.124:22
W naszym przykładzie widać definicję zmiennej "ansible_port" dla wszystkich hostów grupy "web_servers". Niemniej, dla hosta "10.8.232.124" zostanie użyty inny port jako, iż zostało to nadpisane na poziomie hosta.
Definicje zmiennych mogą znajdować się na różnych poziomach, obejmując wszystkie hosty "all", grupy nadrzędne lub podrzędne, a także pojedynczego hosta. W ten sposób określają czy też obejmują one szerszy lub węższy zakres hostów. Należy pamiętać, że wyższy priorytet ma definicja zmiennej w ramach bardziej specyficznego zakresu. Dopiero, kiedy jej nie ma, sprawdzane są dostępne definicje w szerszych zakresach. Inaczej mówiąc, o ile grupy podrzędne dziedziczą wartości zmiennych z grup nadrzędnych, to zawsze obowiązuje bardziej precyzyjna definicja. Stąd, na poziomie grupy podrzędnej można nadpisać zmienne grup nadrzędnych. W przypadku, kiedy ten sam host należy do wielu grup tego samego poziomu, grupy układane są alfabetycznie i każda kolejna grupa na liście nadpisuje zmienne wcześniejszych. Da się też dla każdej z grup ręcznie ustawić priorytet.
Nie można zapomnieć o wielu inwentarzach. Tam również mogą powielać się definicje hostów oraz grup, a także ich zmiennych. Zarówno polecenie "ansible", jak i "ansible-playbook" umożliwia podanie kilku opcji "-i" lub "--inventory", a jako ich argument można wskazać katalog z wieloma plikami inwentarzy (statycznych i dynamicznych jednocześnie).
W przypadku, kiedy stosujemy kilka inwentarzy, każdy kolejny inwentarz na liście nadpisuje zmienne poprzedniego. Kiedy podany jest katalog, to dla jego plików stosowana jest kolejność alfabetyczna, jak w przypadku grup. Stąd, zalecane jest wtedy rozpoczynanie nazw plików inwentarzy od numerów, dzięki czemu całość jest bardziej klarowna i przewidywalna.
Zmienne w group_vars/ i host_vars/ oraz format YAML
W przypadku bardziej złożonych środowisk oraz dużej ilości zarządzanych węzłów, zalecane jest trzymanie zmiennych poza inwentarzem, w oddzielnych plikach lub podkatalogach per grupa oraz per host. Do tego celu stosowany jest odpowiednio katalog "group_vars/" (zmienne grup) i katalog "host_vars/" (zmienne hostów). Ansible stara się znaleźć te katalogi w oparciu o lokalizację pliku inwentarza (polecenie "ansible") lub bieżącego katalogu (polecenie "ansible-playbook"). Nazwy znajdujących się tam plików lub podkatalogów odpowiadają nazwom grup lub hostów. W przypadku, kiedy stosujemy podkatalogi o nazwach grup lub hostów, nazwy znajdujących się w nich plików nie muszą już odpowiadać nazwą grup lub hostów. Zawartość plików ze zmiennymi musi być zgodna ze składnią YAML. Pliki nie muszą posiadać żadnego rozszerzenia, ale mogą posiadać rozszerzenie ".yml" lub ".yaml".
W naszym przykładzie plik "group_vars/web_servers" powinien mieć zawartość:
---
ansible_port: 2222
Warto zwrócić uwagę na trzy kreski "---" oraz typowy dla YAML sposób mapowania "key: value". W ten sam sposób mogą być także tworzone zmienne wewnątrz playbook-a. Da się także w jego wnętrzu wskazywać całe pliki i katalogi ze zmiennymi.
Dodatkowe zmienne mogą zostać przekazane jako argumenty dla opcji "-e" lub "--extra-vars" poleceń "ansible-playbook" oraz "ansible". Możliwości jest dużo, ale na szczęście ich biegła znajomość nie jest nam na ten moment potrzebna.
Pełna lista źródeł zmiennych i ich priorytetów dostępna jest w dokumentacji Ansible.
Red Hat Ansible Automation Platform zawiera w sobie szereg dodatkowych komponentów, które sprawiają, iż nawet najbardziej złożona automatyzacja może być wdrażana wygodnie, efektywnie i skalowalnie w ramach średnich i większych organizacji oraz przedsiębiorstw. Zainteresowanych tą platformą
Jesteśmy oficjalnym partnerem firmy Red Hat i za naszym pośrednictwem można zakupić ich produkty na polskim rynku.
System szablonów Jinja2
Odwoływanie się do zmiennych realizowane jest poprzez system szablonów Jinja2, znany wielu z języka Python. Jest on bardzo elastyczny, obsługuje wewnątrz zarówno wyrażenia warunkowe, jak i pętle. Do wartości zmiennej, w Jinja2 odwołujemy się poprzez umieszczenie jej nazwy we wnętrzu podwójnych nawiasów klamrowych "{{ }}".
W poprzednim artykule wspomnieliśmy o możliwości wykorzystania zebranych informacji na temat zarządzanych węzłów, do budowy bardziej uniwersalnych playbooków. Informacje te nazywane są faktami (ang. facts). Są one zdobywane w trakcie wykonywania zadania "Gathering Facts". Jest ono domyślnie zawsze uruchamiane na początku każdego zestawu zadań ("play"). W jego trakcie, Ansible tworzy dla każdego z hostów odpowiednie zmienne, do których potem można się odwoływać wewnątrz playbooka i szablonów Jinja2. Ilość tych zmiennych jest bardzo duża.
Najłatwiej wyświetlić je wszystkie za pomocą omówionego wcześniej modułu "ansible.builtin.setup", polecenia ad-hoc.
W playbooku zwykle odnosimy się do wybranych faktów, gdy chcemy uwarunkować wykonanie danego zadania od wartości, jaka się w nich znajduje. Zdarza się też, że zebrane fakty wykorzystywane są jako wartości konfiguracyjne dla różnych usług lub do budowy plików. Fakty można również wykorzystać do generowania powiadomień zarówno na wyjściu playbooka, jak i takich wysyłanych drogą e-mail czy do pokoju Cisco Webex.
Do wyświetlania wartości zmiennych, w tym faktów, w trakcie działania playbooka służy moduł "ansible.builtin.debug":
[msleczek@vm0-net projekt_A]$ cat playbook.yaml
---
- hosts: all
tasks:
- name: "Informacje o hostach"
ansible.builtin.debug: "msg='Host: {{ansible_hostname}}, OS: {{ansible_os_family}}, IPv4: {{ansible_default_ipv4.address}}.'"
...
[msleczek@vm0-net projekt_A]$
Do wyświetlania informacji możemy użyć jednej z dwóch opcji modułu "ansible.builtin.debug": "var" lub "msg".
Wykluczają się one wzajemnie, więc trzeba wybrać:
- "var" wyświetla tylko wartość podanej zmiennej i nie wymaga używania nawiasów klamrowych "{{ }}" dookoła nazwy zmiennej.
- "msg" wyświetla przygotowaną przez nas wiadomość, w której jeżeli używane są zmienne, to musimy je jawnie umieścić wewnątrz podwójnych nawiasów klamrowych "{{ }}" (zgodnie z formatem Jinja2).
Moduł "ansible.builtin.debug" udostępnia też parametr "verbosity", który określa przy jakim minimalnym poziomie debugowania, informacja ta zostanie dla nas wyświetlona. Domyślnie "verbosity" ustawione jest na 0, co powoduje wyświetlanie informacji przy każdym wywołaniu playbooka. Rezultat uruchomienia powyższego playbooka z modułem "ansible.builtin.debug", widać poniżej:
[msleczek@vm0-net projekt_A]$ ansible-playbook playbook.yaml
PLAY [all] **********************************************************************************
TASK [Gathering Facts] **********************************************************************
ok: [10.8.232.123]
ok: [10.8.232.124]
ok: [10.8.232.121]
ok: [10.8.232.122]
TASK [Informacje o hostach] *****************************************************************
ok: [10.8.232.121] => {
"msg": "Host: vm1-net, OS: RedHat, IPv4: 10.8.232.121."
}
ok: [10.8.232.122] => {
"msg": "Host: vm2-net, OS: RedHat, IPv4: 10.8.232.122."
}
ok: [10.8.232.123] => {
"msg": "Host: vm3-net, OS: RedHat, IPv4: 10.8.232.123."
}
ok: [10.8.232.124] => {
"msg": "Host: vm4-net, OS: RedHat, IPv4: 10.8.232.124."
}
PLAY RECAP **********************************************************************************
10.8.232.121 : ok=2 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
10.8.232.122 : ok=2 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
10.8.232.123 : ok=2 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
10.8.232.124 : ok=2 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
[msleczek@vm0-net projekt_A]$
Dalej prześledzimy bardziej złożony przypadek wykorzystania zmiennych oraz szablonu Jinja2. Będziemy pracować na bardzo prostym inwentarzu, który zawiera definicję tylko 4 hostów. Jego zawartość widać poniżej:
[msleczek@vm0-net projekt_A]$ cat inventory
10.8.232.121
10.8.232.122
10.8.232.123
10.8.232.124
[msleczek@vm0-net projekt_A]$
Żaden z tych 4 hostów nie ma uruchomionej usługi HTTP:
[msleczek@vm0-net projekt_A]$ for IP_ADDRESS in `cat inventory`; do curl http://$IP_ADDRESS; echo; done;
curl: (7) Failed to connect to 10.8.232.121 port 80: No route to host
curl: (7) Failed to connect to 10.8.232.122 port 80: No route to host
curl: (7) Failed to connect to 10.8.232.123 port 80: No route to host
curl: (7) Failed to connect to 10.8.232.124 port 80: No route to host
[msleczek@vm0-net projekt_A]$
Zadaniem naszego playbooka będzie konfiguracja tych 4 hostów do pracy jako serwery webowe. Aby to było możliwe, muszą one posiadać zainstalowany pakiet "httpd", odpowiednio przygotowany plik "index.html", włączoną usługę "httpd" oraz otwarty odpowiedni port. Plik "index.html" jest parametryzowany, jako że każdy z serwerów webowych ma udostępniać nieco inną treść. Do tego celu wykorzystaliśmy szablon Jinja2. Pliki szablonów Jinja2 powinny posiadać rozszerzenie ".j2".
[msleczek@vm0-net projekt_A]$ cat index.html.j2
Strona www na serwerze {{ ansible_default_ipv4.address }} z systemem {{ ansible_os_family }}.
Serwer ten nazywa się {{ ansible_hostname }}.
[msleczek@vm0-net projekt_A]$
W miejscu zmiennych szablonu Jinja2 "index.html.j2" powinny docelowo znaleźć się odpowiednie wartości, które zostaną zebrane w trakcie zadania "Gathering Facts". Dla każdego z hostów mogą być one różne. Do obsługi szablonów Jinja2 stosowany jest moduł "ansible.buitlin.template". Do instalacji potrzebnego oprogramowania moduł "ansible.buitlin.dnf", do uruchomienia usługi moduł "ansible.buitlin.service", a do otwarcia stosownego portu w zaporze sieciowej moduł "ansible.posix.firewalld". Zachęcam na tym etapie do zapoznania się w wynikiem polecenia "ansible-doc" dla tych modułów. Oto nasz playbook:
[msleczek@vm0-net projekt_A]$ cat web_servers.yaml
---
- name: START WEB SERVERS
hosts: all
tasks:
- name: PACKAGE INSTALLATION
ansible.builtin.dnf:
name: httpd
state: present
- name: ADD index.html file
ansible.builtin.template:
src: index.html.j2
dest: /var/www/html/index.html
- name: START HTTPD SERVICE
ansible.builtin.service:
name: httpd
state: started
enabled: true
- name: OPEN HTTP TRAFFIC
ansible.posix.firewalld:
service: http
permanent: true
immediate: true
state: enabled
- name: TEST WEB SERVERS
hosts: localhost
become: false
gather_facts: false
tasks:
- name: GET WEB SERVERS LIST
ansible.builtin.command: "/usr/bin/cat ./inventory"
register: web_servers
- name: CONNECT TO WEBPAGE
ansible.builtin.uri:
url: http://{{item}}
return_content: true
status_code: 200
loop: "{{web_servers.stdout_lines}}"
[msleczek@vm0-net projekt_A]$
Playbook składa się z dwóch zbiorów zadań ("playów"). Pierwszy zajmuje się konfiguracją serwerów webowych, a drugi weryfikacją tego. Drugi zbiór wykonywany jest na "localhost" i ma wyłączone zadanie "Gathering Facts". W pierwszym zadaniu przypisaliśmy do zmiennej "web_servers" zawartość naszego pliku inwentarza "./inventory". W tym celu zarejestrowaliśmy wynik odpowiedniego polecenia pod tą zmienną. Służy do tego parametr "register".
W drugim zadaniu, drugiego zbioru zadań używamy modułu "ansible.builtin.uri" w celu nawiązania połączenia HTTP kolejno, do każdego z naszych serwerów webowych. Sukces tego zadania jest uwarunkowany otrzymanym kodem HTTP. Domyślnie, kod 200 oznacza sukces. Wykonanie tych testów ze stacji zarządzającej "localhost" daje pewność, że usługa "httpd" działa i dostępna jest z zewnątrz (otwarty został odpowiedni port na zaporze sieciowej).
Nowo zarejestrowana zmienna "web_servers" składa się z wielu wierszy. Do każdego z nich jest dostęp poprzez listę "web_servers.stdout_lines". Kiedy podamy tą listę jako argument wyrażenia "loop", to moduł "ansible.builtin.uri" zostanie wykonany tyle razy, ile na liście znajduje się wartości. W każdym kolejnym wykonaniu modułu "ansible.builtin.uri", zmienna "item" będzie przyjmowała wartość kolejnej wartości z listy, będącej argumentem wyrażenia "loop". Jest to pierwszy raz, kiedy wykorzystaliśmy w playbooku pętlę (ang. loops). Pętlami zajmiemy się bardziej w kolejnym artykule.
[msleczek@vm0-net projekt_A]$ ansible-playbook web_servers.yaml
PLAY [START WEB SERVERS] ********************************************************************
TASK [Gathering Facts] **********************************************************************
ok: [10.8.232.122]
ok: [10.8.232.123]
ok: [10.8.232.124]
ok: [10.8.232.121]
TASK [PACKAGE INSTALLATION] *****************************************************************
changed: [10.8.232.122]
changed: [10.8.232.123]
changed: [10.8.232.124]
changed: [10.8.232.121]
TASK [ADD index.html file] ******************************************************************
changed: [10.8.232.122]
changed: [10.8.232.124]
changed: [10.8.232.121]
changed: [10.8.232.123]
TASK [START HTTPD SERVICE] ******************************************************************
changed: [10.8.232.122]
changed: [10.8.232.124]
changed: [10.8.232.121]
changed: [10.8.232.123]
TASK [OPEN HTTP TRAFFIC] ********************************************************************
changed: [10.8.232.122]
changed: [10.8.232.123]
changed: [10.8.232.124]
changed: [10.8.232.121]
PLAY [TEST WEB SERVERS] *********************************************************************
TASK [GET WEB SERVERS LIST] *****************************************************************
changed: [localhost]
TASK [CONNECT TO WEBPAGE] *******************************************************************
ok: [localhost] => (item=10.8.232.121)
ok: [localhost] => (item=10.8.232.122)
ok: [localhost] => (item=10.8.232.123)
ok: [localhost] => (item=10.8.232.124)
PLAY RECAP **********************************************************************************
10.8.232.121 : ok=5 changed=4 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
10.8.232.122 : ok=5 changed=4 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
10.8.232.123 : ok=5 changed=4 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
10.8.232.124 : ok=5 changed=4 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
localhost : ok=2 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
[msleczek@vm0-net projekt_A]$
Widać w "PLAY RECAP", że wszystkie zadania zakończyły się sukcesem.
Zatem możemy powtórzyć nasz poprzedni test i sprawdzić zwracaną przez serwery webowe treść.
[msleczek@vm0-net projekt_A]$ for IP_ADDRESS in `cat inventory`; do curl http://$IP_ADDRESS; echo; done;
Strona www na serwerze 10.8.232.121 z systemem RedHat.
Serwer ten nazywa się vm1-net.
Strona www na serwerze 10.8.232.122 z systemem RedHat.
Serwer ten nazywa się vm2-net.
Strona www na serwerze 10.8.232.123 z systemem RedHat.
Serwer ten nazywa się vm3-net.
Strona www na serwerze 10.8.232.124 z systemem RedHat.
Serwer ten nazywa się vm4-net.
[msleczek@vm0-net projekt_A]$
Przybliżyliśmy tutaj zaledwie podstawy zmiennych i szablonów Jinja2. Są one kluczowym elementem playbooków i będziemy do nich często powracać w kolejnych artykułach.
Zapraszamy do
Przed kolejną porcją wiedzy zachęcamy do przećwiczenia i utrwalenia tej poznanej tutaj. Skorzystaj z naszych ćwiczeń!