Zaktualizowano 9 min czytania

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ę.

Rys. 1. Ansible wykonuje zadania i kończy pracę; wszystko, co wymaga ciągłego stanu lub planu zmian, potrzebuje innego narzędzia.

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ć.

ZadanieAnsibleLepsze narzędzie lub uwaga
Pakiety, pliki konfiguracyjne, usługi, konta, reguły firewallaTakRdzeń zastosowań; moduły są idempotentne
Zadania operacyjne na wielu maszynach: rotacja certyfikatów, aktualizacje w ustalonej kolejnościTakserial i --limit dają kontrolę nad kolejnością i zasięgiem
Integracje przez API: rejestr obrazów, system zgłoszeń, platforma CITakModuł ansible.builtin.uri albo dedykowana kolekcja
Windows Server tam, gdzie GPO nie sięgaTakWinRM i kolekcja ansible.windows; ustawienia stacji zostają w GPO
Utrzymywanie stanu w czasie i korekta dryfuNieKontroler, który stale uzgadnia stan
Codzienne zarządzanie zasobami KubernetesNieManifesty w Git i kontroler GitOps
Tworzenie maszyn, sieci i wolumenówNieTerraform lub OpenTofu z planem zmian i stanem
Złożona logika i transformacje danychNieSkrypt 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:

  1. defaults/main.yml w roli: wartości rozsądne dla większości przypadków.
  2. group_vars/ w inventory: różnice między środowiskami i grupami serwerów.
  3. 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.