Ansible na produkcji: idempotencja, wykrywanie dryftu i bezpieczne zmiany falami
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: 2dzieli inwentarz na paczki po dwa hosty. Przy ośmiu serwerach zmieniane są naraz najwyżej dwa.max_fail_percentage: 0zatrzymuje wdrożenie po pierwszym zadaniu, na którym host zostaje uznany za błędny. Błąd wewnątrzblockłapierescue, więc tutaj dzieje się to dopiero przy jawnymfail, 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 -ttestuje całą konfigurację razem z wyrenderowanym plikiem. Fragment zconf.dz blokiemupstreamnie jest samodzielną poprawną konfiguracją, więc opcjavalidatemodułutemplatetu nie zadziała. Nadaje się do plików, które nginx potrafi przetestować samodzielnie, jaknginx.conf.- Handlery wykonują się standardowo na końcu każdej sekcji playa (
pre_tasks,rolesitasks,post_tasks), dla każdej paczki osobno.meta: flush_handlersprzeładowuje nginx przed health checkiem, który idzie przez nginx nanginx_port. Inaczej sprawdzałbyś jeszcze starą konfigurację. backup: truezostawia kopię z datą w nazwie i zwraca jej ścieżkę wbackup_file. Sekcjarescueprzywraca kopię, przeładowuje nginx i jawnie oznacza host jako błędny, żebymax_fail_percentagezatrzymał wdrożenie. Jeśli pliku wcześniej nie było, kopii też nie ma, więcrescueusuwa nowy plik i nginx przeładowuje się bez niego. Host i tak kończy jako błędny, więc wdrożenie staje.reloadedzamiastrestarted: 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_whenprzy każdym zadaniucommandishell, afailed_whentam, gdzie kod wyjścia nic nie mówi.- Żadnego
ignore_errors: truew zadaniach infrastrukturalnych. Błąd ma zatrzymać play, żeby host został w stanie, który widać w raporcie. Do jawnej obsługi błędu jestblock/rescue. become: truetylko tam, gdzie potrzebny jest root.seriali świadomie dobranymax_fail_percentageprzy wszystkim, co dotyka więcej niż jednego hosta.