Ansible w małym zespole: do czego go używać i jak poukładać repozytorium
Playbook pisany przez jedną osobę rośnie do kilku tysięcy linii i przejmuje zadania, do których Ansible nie służy. Gdzie go używać, gdzie nie i jak ułożyć repozytorium, żeby następca zrozumiał je w godzinę.
Typowa historia automatyzacji w małym zespole: jedna osoba pisze playbook do konfiguracji serwerów WWW, potem dopisuje do niego tworzenie maszyn wirtualnych, wdrażanie manifestów do klastra przez shell: kubectl apply i kilkadziesiąt zadań command z logiką w Jinja2. Po dwóch latach plik ma trzy tysiące linii, a autor zmienia pracę. Następca uruchamia go w trybie --check, dostaje czterdzieści zadań „changed” na serwerach, które działają poprawnie, i zaczyna od zera.
Oba problemy mają wspólną przyczynę: zabrakło dwóch decyzji na początku. Pierwsza dotyczy tego, do czego zespół używa Ansible, a do czego nie. Druga dotyczy struktury repozytorium, która pozwala zrozumieć kod bez rozmowy z autorem.
Do czego Ansible, a do czego inne narzędzie
Ansible łączy się z maszyną przez SSH lub WinRM, wykonuje listę zadań i kończy pracę. Z tego modelu wynika, gdzie sprawdza się dobrze, a gdzie zaczyna przeszkadzać.
| Zadanie | Ansible | Lepsze narzędzie lub uwaga |
|---|---|---|
| Pakiety, pliki konfiguracyjne, usługi, konta, reguły firewalla | Tak | Rdzeń zastosowań; moduły są idempotentne |
| Zadania operacyjne na wielu maszynach: rotacja certyfikatów, aktualizacje w ustalonej kolejności | Tak | serial i --limit dają kontrolę nad kolejnością i zasięgiem |
| Integracje przez API: rejestr obrazów, system zgłoszeń, platforma CI | Tak | Moduł ansible.builtin.uri albo dedykowana kolekcja |
| Windows Server tam, gdzie GPO nie sięga | Tak | WinRM i kolekcja ansible.windows; ustawienia stacji zostają w GPO |
| Utrzymywanie stanu w czasie i korekta dryfu | Nie | Kontroler, który stale uzgadnia stan |
| Codzienne zarządzanie zasobami Kubernetes | Nie | Manifesty w Git i kontroler GitOps |
| Tworzenie maszyn, sieci i wolumenów | Nie | Terraform lub OpenTofu z planem zmian i stanem |
| Złożona logika i transformacje danych | Nie | Skrypt w Pythonie wywołany jako jedno zadanie |
Ansible może przygotować klaster albo zainstalować w nim kontroler GitOps, ale nie powinien prowadzić go na co dzień: moduł kubernetes.core.k8s nie wykrywa dryfu i nie zostawia historii zmian w repozytorium. Szerzej o tym podziale piszę w tekście GitOps a skrypt deployu.
Układ repozytorium
Struktura katalogów jest pierwszą dokumentacją. Układ, który sprawdza się w zespołach kilkuosobowych:
ansible.cfg
collections/requirements.yml kolekcje z przypiętymi wersjami
inventories/
prod/
hosts.yml maszyny i grupy
group_vars/
all/vars.yml zmienne wspólne
all/vault.yml sekrety, zaszyfrowane
web.yml różnice dla grupy web
host_vars/
srv-web-02.corp.example.com.yml
test/
hosts.yml
group_vars/
playbooks/
web.yml cienki playbook: grupa i role
roles/
nginx/
defaults/main.yml wszystkie zmienne roli
tasks/main.yml
handlers/main.yml
templates/nginx.conf.j2
README.md co robi, czego wymaga, przykład
.ansible-lint
README.md jak uruchomić, gdzie są sekrety
Playbook nie zawiera zadań, tylko grupę docelową i listę ról. Logika żyje w rolach:
- name: Serwery WWW
hosts: web
become: true
roles:
- chrony
- nginx
[defaults]
# Domyślnie środowisko testowe; produkcja wymaga jawnego -i
inventory = inventories/test/hosts.yml
roles_path = roles
Testowe inventory jako domyślne to tani bezpiecznik: polecenie uruchomione bez -i trafia w maszyny testowe, a produkcja wymaga świadomego -i inventories/prod/hosts.yml. Wpis roles_path jest potrzebny, bo playbooki leżą w podkatalogu, a Ansible bez niego szuka ról w katalogu roles/ obok pliku playbooka i w ścieżkach domyślnych.
collections:
- name: ansible.windows
version: "2.5.0"
- name: community.general
version: "9.4.0"
Kolekcje instaluje się poleceniem ansible-galaxy collection install -r collections/requirements.yml. Podniesienie wersji jest osobną zmianą w repozytorium, z własnym przeglądem, a nie skutkiem ubocznym instalacji na nowej stacji roboczej.
Rola: jedna odpowiedzialność, zmienne z prefiksem
Rola instaluje i konfiguruje jedną rzecz: serwer WWW, agenta monitoringu, synchronizację czasu. Rola „serwer aplikacyjny”, która robi dwadzieścia rzeczy, nie nadaje się do ponownego użycia ani do testowania. Każda zmienna, której rola używa, ma wartość domyślną i komentarz w defaults/main.yml. To jest kontrakt roli: kto chce jej użyć, czyta jeden plik.
# Liczba procesów roboczych; auto oznacza liczbę rdzeni
nginx_worker_processes: auto
# Maksymalny rozmiar żądania, np. przesyłanego pliku
nginx_client_max_body_size: 10m
# Witryny do skonfigurowania; lista nadpisywana w group_vars
nginx_sites: []
# Usuwa domyślną witrynę dostarczaną z pakietem
nginx_remove_default_site: true
- name: Zainstaluj nginx
ansible.builtin.package:
name: nginx
state: present
- name: Wgraj konfigurację główną
ansible.builtin.template:
src: nginx.conf.j2
dest: /etc/nginx/nginx.conf
owner: root
group: root
mode: "0644"
notify: Przeładuj nginx
- name: Uruchom usługę i włącz ją przy starcie
ansible.builtin.service:
name: nginx
state: started
enabled: true
Handler Przeładuj nginx w handlers/main.yml przeładowuje usługę tylko wtedy, gdy szablon faktycznie się zmienił. Pełne nazwy modułów (ansible.builtin.template zamiast template) usuwają wątpliwość, z której kolekcji pochodzi moduł.
Zmienne: trzy miejsca
Ansible ma ponad dwadzieścia poziomów pierwszeństwa zmiennych. W praktyce wystarczą trzy, plus zasada, by nie używać pozostałych:
defaults/main.ymlw roli: wartości rozsądne dla większości przypadków.group_vars/w inventory: różnice między środowiskami i grupami serwerów.host_vars/: wyjątki dla pojedynczych maszyn, każdy z komentarzem, dlaczego istnieje.
nginx_client_max_body_size: 50m
nginx_sites:
- name: intranet
server_name: intranet.corp.example.com
# Na tym serwerze działa też usługa raportowa; nginx dostaje mniej procesów
nginx_worker_processes: 2
Zmienne w vars: playbooka, w zadaniu albo podane przez -e są dopuszczalne tylko tymczasowo, na czas diagnozy. Zadanie wygląda tak samo w każdym środowisku, a różnice opisuje inventory.
Sekrety: Vault albo menedżer sekretów
Hasła i klucze nie leżą w repozytorium jawnym tekstem. Akceptowalne są dwie drogi.
Ansible Vault. Plik group_vars/all/vault.yml jest zaszyfrowany w całości, a zmienne w nim mają prefiks vault_. Jawny plik vars.yml obok tylko się do nich odwołuje, dzięki czemu wyszukiwanie po nazwie zmiennej działa bez odszyfrowania:
# Wartość znajduje się w zaszyfrowanym vault.yml
app_db_password: "{{ vault_app_db_password }}"
Plik tworzy się i edytuje poleceniami ansible-vault create i ansible-vault edit, a playbook uruchamia z --ask-vault-pass albo z --vault-password-file wskazującym plik spoza repozytorium.
Zewnętrzny menedżer sekretów odpytywany w czasie wykonania przez wtyczkę lookup z odpowiedniej kolekcji. Ta droga jest lepsza, gdy z tych samych sekretów korzysta więcej niż jedno narzędzie, bo wartość zmienia się w jednym miejscu. Wybór opisuję szerzej w tekście Sekrety w automatyzacji i CI/CD.
Niezależnie od wyboru zadania, które przetwarzają sekret, mają no_log: true:
- name: Wgraj plik środowiskowy aplikacji z hasłem do bazy
ansible.builtin.template:
src: app.env.j2
dest: /etc/app/app.env
owner: root
group: app
mode: "0640"
no_log: true
Ma to znaczenie zwłaszcza przy --diff: bez no_log różnica w szablonie pokazałaby hasło na ekranie i w logu zadania. no_log ukrywa też treść błędu, więc przy diagnozie wyłącza się go tymczasowo na maszynie testowej, nigdy w produkcji.
Idempotentność: drugie uruchomienie bez zmian
Playbook uruchomiony dwa razy z rzędu ma za drugim razem zgłosić zero zmian. Moduły package, template czy service zapewniają to same. Problem tworzą command i shell, które dla Ansible zawsze oznaczają zmianę, chyba że powie mu się inaczej. Narzędzia app-init i app-migrate to w przykładzie własne polecenia aplikacji:
# Brak modułu do tej operacji; creates pomija zadanie, gdy plik już istnieje
- name: Zainicjuj katalog danych aplikacji
ansible.builtin.command:
cmd: /opt/app/bin/app-init --data-dir /var/lib/app
creates: /var/lib/app/.initialized
# Tylko odczyt; wykonuje się także w trybie --check
- name: Sprawdź stan migracji schematu
ansible.builtin.command: /opt/app/bin/app-migrate --status
register: app_migrate_status
changed_when: false
check_mode: false
- name: Wykonaj oczekujące migracje
ansible.builtin.command: /opt/app/bin/app-migrate --apply
when: "'pending' in app_migrate_status.stdout"
PLAY RECAP *********************************************************************
srv-web-01.corp.example.com : ok=12 changed=0 unreachable=0 failed=0 skipped=1 rescued=0 ignored=0
Każde changed w drugim przebiegu to zadanie do poprawki. Co jakiś czas warto też uruchomić playbook na czystej maszynie: playbook, który działa tylko na serwerze już częściowo skonfigurowanym, nie jest idempotentny, tylko przypadkowo zbieżny.
Tryb próbny i podgląd inventory
--check uruchamia playbook bez wprowadzania zmian, a --diff pokazuje, co dokładnie zmieniłoby się w plikach. To podstawowe narzędzie przeglądu przed wdrożeniem, najlepiej zawężone do jednego serwera:
ansible-playbook -i inventories/prod/hosts.yml playbooks/web.yml \
--check --diff --limit srv-web-01.corp.example.com
ansible-inventory pokazuje, jak Ansible widzi inventory, bez łączenia się z maszynami:
ansible-inventory -i inventories/prod/hosts.yml --graph
@all:
|--@ungrouped:
|--@web:
| |--srv-web-01.corp.example.com
| |--srv-web-02.corp.example.com
|--@windows:
| |--srv-app-01.corp.example.com
Z opcją --host srv-web-02.corp.example.com to samo polecenie wypisuje zmienne z group_vars i host_vars po scaleniu, czyli odpowiada na pytanie, która wartość wygrała. Wartości domyślnych ról tam nie ma, bo role nie są częścią inventory. Jeśli inventory zawiera pliki Vault, potrzebne jest też --ask-vault-pass.
ansible-lint i syntax-check w CI
Minimum, które da się utrzymać bez osobnego środowiska testowego: przy każdej zmianie w repozytorium uruchamiają się ansible-playbook --syntax-check oraz ansible-lint. Przykładowe zadanie dla GitLab CI:
ansible-lint:
stage: test
image: python:3.12
before_script:
# Wersje przykładowe; przypnij te, których używa zespół
- pip install --no-cache-dir ansible-core==2.17.5 ansible-lint==24.9.2
- ansible-galaxy collection install -r collections/requirements.yml
script:
- ansible-playbook -i inventories/test/hosts.yml --syntax-check playbooks/*.yml
- ansible-lint
# Start od profilu basic, po uporządkowaniu kodu moderate, potem production
profile: basic
Profil basic wyłapuje błędy i najczęstsze złe praktyki, nie zasypując starszego repozytorium setkami uwag. Po uporządkowaniu kodu podnosi się go do moderate, a potem do production, za każdym razem jako osobną zmianę.
Sprawdź u siebie
Czy w repozytorium nie ma niezaszyfrowanych plików Vault i jawnych haseł w zmiennych:
# Pliki vault.yml bez nagłówka szyfrowania; pusty wynik jest dobry
grep -L '^\$ANSIBLE_VAULT' inventories/*/group_vars/*/vault.yml
# Zmienne wyglądające na sekrety, które nie odwołują się do vault_
grep -rniE '(password|passwd|secret|token)[a-z_]*\s*:' inventories/ roles/ | grep -v 'vault_'
Każda nazwa pliku z pierwszego polecenia i każda linia z drugiego to sekret do przeniesienia, a jeśli trafił do historii Git, także do wymiany na nowy.
Gdzie w rolach są zadania, które mogą psuć idempotentność albo tryb próbny:
grep -rnE '(ansible\.builtin\.)?(command|shell):' roles/ playbooks/
grep -rn 'check_mode: false' roles/ playbooks/
Przy każdym command i shell powinno być creates, removes albo changed_when. Każde check_mode: false musi dotyczyć zadania, które tylko odczytuje.
Czy playbook na skonfigurowanym serwerze nie zgłasza zmian. --check niczego nie zmienia poza zadaniami z check_mode: false, dlatego najpierw wykonaj poprzedni punkt:
ansible-playbook -i inventories/prod/hosts.yml playbooks/web.yml \
--check --diff --limit srv-web-01.corp.example.com
Dobry wynik to changed=0 i brak sekcji z różnicami. Każde changed oznacza ręczną zmianę na serwerze albo zadanie, które nie jest idempotentne.
Ile uwag zgłasza ansible-lint dla obecnego profilu:
ansible-lint
Cel to zero uwag. W starszym repozytorium zacznij od reguł, które łamie najwięcej plików, i poprawiaj je partiami.