backend
artykuł··zaktualizowano ·6 min czytania

Ansible na produkcji: idempotencja, wykrywanie dryftu i bezpieczne zmiany falami

#ansible #devops #infrastructure #automation #idempotency #configuration-management

Playbook pisze się i testuje zwykle na czystej maszynie. Na produkcji trafia na maszyny w takim stanie, w jakim akurat są: z plikiem konfiguracyjnym poprawionym ręcznie w trakcie awarii, z pakietem podniesionym przez unattended-upgrades, po poprzednim uruchomieniu przerwanym w połowie. Liczy się wtedy to, czy doprowadzi host z nieznanego stanu do zadeklarowanego i czy powie, co po drodze musiał zmienić.

Idempotentny to nie to samo co zbieżny

Idempotencja mówi, że drugie uruchomienie niczego już nie zmieni. Zbieżność mówi, że z dowolnego stanu startowego dojdziesz do docelowego. Zadanie może mieć pierwszą cechę bez drugiej: lineinfile, który pilnuje jednej linii, nie zauważy drugiej, dopisanej obok ręcznie.

Większość modułów ma to w standardzie, bo porównuje stan bieżący z docelowym (template, copy, service ze state: started, apt ze state: present), więc drugie uruchomienie z tymi samymi argumentami raportuje ok. Dla command i shell już nie, bo Ansible nie wie, co one robią. Raportują changed przy każdym uruchomieniu, chyba że podasz creates albo removes (wtedy zadanie nie wykona się, gdy wskazany plik już jest albo go nie ma) lub sam określisz, kiedy coś się zmieniło:

- name: Run database migrations
  ansible.builtin.command: php artisan migrate --force
  args:
    chdir: /var/www/app
  register: migrate
  changed_when: "'Nothing to migrate' not in migrate.stdout"

Bez changed_when każde uruchomienie pokazuje co najmniej jedną zmianę, a licznik zmian, najtańszy sygnał dryftu, przestaje cokolwiek znaczyć.

Jak zobaczyć dryft

Kiedy Ansible po cichu naprawia host, ginie informacja, że ktoś go zmienił. Weźmy usługę, której ktoś przy debugowaniu ręcznie wyłączył autostart (systemctl disable):

- name: Ensure application service is running
  ansible.builtin.service:
    name: invoicer
    state: started
    enabled: true

Zadanie przywraca stan i raportuje changed. Z raportu nie wynika, czy to pierwsza instalacja, czy cofnięcie ręcznej zmiany. Jeśli chcesz to wiedzieć, odczytaj stan przed zmianą:

- name: Read current enablement state
  ansible.builtin.command: systemctl is-enabled invoicer
  register: svc_enabled
  changed_when: false
  failed_when: false
  check_mode: false

- name: Report manual override
  ansible.builtin.debug:
    msg: "invoicer is '{{ svc_enabled.stdout }}' on {{ inventory_hostname }}, expected 'enabled'"
  when: svc_enabled.stdout != 'enabled'

- name: Converge service state
  ansible.builtin.service:
    name: invoicer
    state: started
    enabled: true

check_mode: false jest tu potrzebne, bo zadania command są w check mode domyślnie pomijane. Bez tego odczyt nie wykona się przy próbnym uruchomieniu.

Na całej flocie dryft najprościej wykryć próbnym uruchomieniem puszczanym z harmonogramu:

ansible-playbook site.yml --check --diff

Jeśli wszystkie playbooki mają poprawne changed_when, a w zadaniach nie ma bezwarunkowych restartów, każdy host z changed>0 w podsumowaniu różni się od tego, co deklarują, a diff pokazuje, w których liniach. Dokładne to nie jest. Check mode nie przewidzi wyniku zadań zależnych od wcześniejszego zadania, które coś by zmieniło, a moduły bez obsługi check mode są pomijane. Dostajesz listę hostów, którym warto się przyjrzeć.

Zmiany falami z rollbackiem

Załóżmy, że wdrożenie przerwało się w trakcie aktualizacji konfiguracji upstreamów nginx. Część floty działa na starej konfiguracji, część na nowej. Trzeba doprowadzić wszystkie hosty do jednej wersji, po kilka naraz, i zatrzymać się przy pierwszym hoście, który się wyłoży:

- name: Converge nginx upstream config
  hosts: api_servers
  become: true
  serial: 2
  max_fail_percentage: 0

  handlers:
    - name: Reload nginx
      ansible.builtin.service:
        name: nginx
        state: reloaded

  tasks:
    - name: Deploy, verify, roll back on failure
      block:
        - name: Check whether upstream config exists
          ansible.builtin.stat:
            path: /etc/nginx/conf.d/upstream.conf
          register: upstream_before

        - name: Render upstream config
          ansible.builtin.template:
            src: templates/upstream.conf.j2
            dest: /etc/nginx/conf.d/upstream.conf
            owner: root
            group: root
            mode: '0644'
            backup: true
          register: upstream_conf
          notify: Reload nginx

        - name: Test full nginx configuration
          ansible.builtin.command: nginx -t
          changed_when: false

        - name: Reload now instead of at the end of the play
          ansible.builtin.meta: flush_handlers

        - name: Wait for health endpoint
          ansible.builtin.uri:
            url: "http://127.0.0.1:{{ nginx_port }}/health"
            status_code: 200
            timeout: 5
          register: health
          until: health.status == 200
          retries: 5
          delay: 2

      rescue:
        - name: Restore previous config
          ansible.builtin.copy:
            src: "{{ upstream_conf.backup_file }}"
            dest: /etc/nginx/conf.d/upstream.conf
            remote_src: true
            mode: '0644'
          when: upstream_conf.backup_file is defined

        - name: Remove config that did not exist before
          ansible.builtin.file:
            path: /etc/nginx/conf.d/upstream.conf
            state: absent
          when: not upstream_before.stat.exists

        - name: Reload nginx with restored config
          ansible.builtin.service:
            name: nginx
            state: reloaded

        - name: Mark host as failed
          ansible.builtin.fail:
            msg: "upstream.conf rolled back on {{ inventory_hostname }}"

Co robią poszczególne elementy:

  • serial: 2 dzieli inwentarz na paczki po dwa hosty. Przy ośmiu serwerach zmieniane są naraz najwyżej dwa.
  • max_fail_percentage: 0 zatrzymuje wdrożenie po pierwszym zadaniu, na którym host zostaje uznany za błędny. Błąd wewnątrz block łapie rescue, więc tutaj dzieje się to dopiero przy jawnym fail, a do tego czasu drugi host z paczki zwykle przechodzi cały blok z nową konfiguracją. Kolejna paczka już nie rusza. Bez tego Ansible przerywa dopiero wtedy, gdy padną wszystkie hosty w paczce, więc przy błędzie na jednym z dwóch kolejna paczka by ruszyła.
  • nginx -t testuje całą konfigurację razem z wyrenderowanym plikiem. Fragment z conf.d z blokiem upstream nie jest samodzielną poprawną konfiguracją, więc opcja validate modułu template tu nie zadziała. Nadaje się do plików, które nginx potrafi przetestować samodzielnie, jak nginx.conf.
  • Handlery wykonują się standardowo na końcu każdej sekcji playa (pre_tasks, roles i tasks, post_tasks), dla każdej paczki osobno. meta: flush_handlers przeładowuje nginx przed health checkiem, który idzie przez nginx na nginx_port. Inaczej sprawdzałbyś jeszcze starą konfigurację.
  • backup: true zostawia kopię z datą w nazwie i zwraca jej ścieżkę w backup_file. Sekcja rescue przywraca kopię, przeładowuje nginx i jawnie oznacza host jako błędny, żeby max_fail_percentage zatrzymał wdrożenie. Jeśli pliku wcześniej nie było, kopii też nie ma, więc rescue usuwa nowy plik i nginx przeładowuje się bez niego. Host i tak kończy jako błędny, więc wdrożenie staje.
  • reloaded zamiast restarted: przy reloadzie nowe połączenia trafiają do nowych workerów, a stare dokańczają żądania w toku, więc reload ich nie przerywa. Przy błędnej konfiguracji nginx zostaje przy starej.

Ten playbook nie zdejmuje hostów z load balancera. Przy samym przeładowaniu nginx zwykle nie trzeba. Jeśli restartujesz aplikację, zdejmij host z balancera w pre_tasks i przywróć go w post_tasks.

Sekrety

Sekrety należące do playbooka trzymaj w zaszyfrowanym pliku zmiennych, do którego odwołuje się zwykły plik. Prefiks vault_, konwencja z dokumentacji Ansible, od razu pokazuje, skąd pochodzi wartość:

# group_vars/prod/vars.yml
db_password: "{{ vault_db_password }}"

# group_vars/prod/vault.yml, zaszyfrowany poleceniem: ansible-vault encrypt group_vars/prod/vault.yml
vault_db_password: "..."

Sekrety współdzielone z innymi systemami lepiej czytać w trakcie uruchomienia z menedżera sekretów przez lookup plugin (dostarczają je kolekcje amazon.aws i community.hashi_vault), żeby wartość żyła w jednym miejscu.

Każde zadanie, które renderuje albo przekazuje sekret, potrzebuje no_log: true. Ukrywa ono wynik zadania w logach i w wyjściu --diff. Jeśli chcesz wyłączyć tylko diff, użyj diff: false:

- name: Render database config
  ansible.builtin.template:
    src: templates/database.env.j2
    dest: /var/www/app/.env.database
    owner: www-data
    mode: '0640'
  no_log: true

owner i mode ustawiaj jawnie, nawet jeśli wydają się oczywiste. W CookieDomain, moim systemie zgód cookies, skrypt wdrożenia tworzył plik konfiguracyjny przez mktemp, czyli z prawami 600. mv te prawa zachował, a rsync -a przeniósł je na produkcję. Serwer WWW działa jako www-data i nie mógł pliku przeczytać, więc require w bootstrapie padał, a produkcja i dev zwracały 500 na każdej trasie. W logach aplikacji nie było nic, fatal był tylko w logu nginxa. Naprawiłem to przez chmod 644 przed mv, rsync --chmod=F644,D755 i is_readable() zamiast is_file(). Dla pliku z sekretami 644 to za dużo, bo przeczyta go każdy użytkownik systemu. Wtedy lepsze jest 0640 z grupą serwera WWW albo właściciel www-data, jak w przykładzie wyżej. Ponowne wdrożenie nic nie dawało, bo każde tworzyło plik tak samo.

Jeśli sekret trafił już do repo otwartym tekstem, przepisanie historii gita nie wystarczy, bo zostaje w klonach i w cache CI. Najpierw rotacja sekretu, potem sprzątanie.

Testy przed produkcją

Do ról służy Molecule: stawia kontener albo maszynę wirtualną, nakłada rolę, uruchamia ją drugi raz, żeby sprawdzić idempotencję, i odpala weryfikator. Od Molecule 3.0 domyślnym weryfikatorem jest Ansible, więc Testinfra trzeba ustawić jawnie. Z nią testy sprawdzają stan, w jakim host faktycznie się znalazł, niezależnie od tego, co zaraportował Ansible:

# molecule/default/tests/test_nginx.py
def test_nginx_running_and_enabled(host):
    nginx = host.service("nginx")
    assert nginx.is_running
    assert nginx.is_enabled


def test_nginx_config_valid(host):
    assert host.run("nginx -t").rc == 0

Na hoście z systemd is_running i is_enabled pytają systemd. W zwykłym kontenerze bez niego Testinfra przechodzi na polecenie service i linki w /etc/rc?.d/, więc sprawdza coś innego niż systemd na produkcji. Żeby testować systemd, potrzebny jest obraz z systemd albo maszyna wirtualna.

Ręcznie pisane skrypty wdrożenia i sprawdzenia łatwo pomylić. Przy jednym z moich wdrożeń docker kill okazał się w ogóle nie testować restart policy, potrzebny był kill -9 na PID procesu z poziomu hosta. Inny mój skrypt deploy uruchamiał composer install --no-dev i tym samym kasował PHPUnit, zanim doszło do testów. A w skrypcie puszczanym przez ssh ... bash -s zwykły read ze stdin zjadł resztę skryptu i do .env trafiły śmieci.

Przed uruchomieniem na produkcji zrób próbę na jednym reprezentatywnym hoście. Trwa krótko i pokazuje dokładne diffy szablonów:

ansible-playbook site.yml --check --diff --limit 'api_servers[0]'

Zanim zatwierdzisz zmianę w playbooku

  • changed_when przy każdym zadaniu command i shell, a failed_when tam, gdzie kod wyjścia nic nie mówi.
  • Żadnego ignore_errors: true w zadaniach infrastrukturalnych. Błąd ma zatrzymać play, żeby host został w stanie, który widać w raporcie. Do jawnej obsługi błędu jest block/rescue.
  • become: true tylko tam, gdzie potrzebny jest root.
  • serial i świadomie dobrany max_fail_percentage przy wszystkim, co dotyka więcej niż jednego hosta.
koniec artykułu