Плейбук, которому можно доверить боевые серверы, проверен до запуска и после него
Каждая следующая проверка дороже и ловит то, что пропустила предыдущая:
| Проверка | Что ловит | Нужны узлы |
|---|---|---|
--syntax-check | Ошибки YAML, неизвестные модули | Нет |
ansible-lint | Плохие практики: shell вместо модуля, неидемпотентные задачи, права файлов, имена | Нет |
--check --diff | Что поменяется на конкретных узлах | Да, только чтение |
| Двойной прогон | Неидемпотентные задачи: второй прогон должен дать changed=0 | Да, тестовые |
| Плейбук проверок | Служба на самом деле работает и отвечает правильно | Да |
Первые две проверки ставят в конвейер CI на каждый коммит (курс Основы CI/CD), остальные — на тестовых узлах перед выкладкой в бой.
$ ansible-playbook site.yml --syntax-check
playbook: site.yml
Линтер ставится пакетом ansible-lint из Debian. Покажем, что он находит в плейбуке, написанном «как попало»:
$ cat bad.yml
- hosts: web
become: yes
tasks:
- name: install nginx
shell: apt-get install -y nginx
- apt: name=htop state=latest
- name: Страница
copy:
content: "test"
dest: /tmp/test.html
- name: Время сборки
command: date
$ ansible-lint bad.yml 2>&1 | grep -v -E "^\s*$|^Read documentation|^WARNING"
# Rule Violation Summary
1 command-instead-of-module profile:basic tags:command-shell,idiom
1 command-instead-of-shell profile:basic tags:command-shell,idiom
1 no-free-form profile:basic tags:syntax,risk
1 name profile:basic tags:idiom
1 name profile:basic tags:idiom
1 yaml profile:basic tags:formatting,yaml
1 name profile:basic tags:idiom
1 package-latest profile:basic tags:idempotency
1 risky-file-permissions profile:basic tags:unpredictability
2 no-changed-when profile:basic tags:command-shell,idempotency
4 fqcn profile:basic tags:formatting
Failed: 15 failure(s), 0 warning(s) on 1 files. Last profile that met the validation criteria was 'min'.
name[play]: All plays should be named.
bad.yml:1:3
yaml[truthy]: Truthy value should be one of [false, true]
bad.yml:2
command-instead-of-module: apt-get used in place of apt-get module
bad.yml:4 Task/Handler: install nginx
command-instead-of-shell: Use shell only when shell functionality is required.
bad.yml:4 Task/Handler: install nginx
name[casing]: All names should start with an uppercase letter.
bad.yml:4:13 Task/Handler: install nginx
no-changed-when: Commands should not change things if nothing needs doing.
bad.yml:4 Task/Handler: install nginx
fqcn[action-core]: Use FQCN for builtin module actions (shell).
bad.yml:5:7 Use `ansible.builtin.shell` or `ansible.legacy.shell` instead.
fqcn[action-core]: Use FQCN for builtin module actions (apt).
bad.yml:6:7 Use `ansible.builtin.apt` or `ansible.legacy.apt` instead.
name[missing]: All tasks should be named.
bad.yml:6 Task/Handler: apt name=htop state=latest
no-free-form: Avoid using free-form when calling module actions. (apt)
bad.yml:6 Task/Handler: apt name=htop state=latest
package-latest: Package installs should not use latest.
bad.yml:6 Task/Handler: apt name=htop state=latest
risky-file-permissions: File permissions unset or incorrect.
bad.yml:7 Task/Handler: Страница
fqcn[action-core]: Use FQCN for builtin module actions (copy).
bad.yml:8:7 Use `ansible.builtin.copy` or `ansible.legacy.copy` instead.
no-changed-when: Commands should not change things if nothing needs doing.
bad.yml:11 Task/Handler: Время сборки
fqcn[action-core]: Use FQCN for builtin module actions (command).
bad.yml:12:7 Use `ansible.builtin.command` or `ansible.legacy.command` instead.
Пятнадцать замечаний на двенадцать строк. Самые важные — не про стиль:
command-instead-of-module и no-changed-when — apt-get через shell: задача всегда changed, --check её пропускает, идемпотентности нет.package-latest — state: latest обновляет пакет при каждом прогоне до новой версии, то есть плейбук сегодня и через месяц делает разное.risky-file-permissions — файл без mode получит права по умолчанию, которые могут оказаться слишком широкими.fqcn, name, yaml[truthy] (yes вместо true) — единообразие, которое облегчает чтение и ревью.Каждое правило объяснено в документации ansible-lint, ссылка на неё печатается в выводе. Профили (min, basic, moderate, safety, shared, production) — наборы правил по строгости. Если правило в конкретном месте неприменимо, его отключают комментарием # noqa: имя-правила в строке задачи с объяснением, почему.
Проект ролей модулей 06–07 линтер тоже проверял — и нашёл в нём замечания: переменные ролей без префикса имени роли, become_user без become на том же уровне, имена задач со строчной буквы. Всё исправлено в коде курса, и теперь проект проходит проверку:
$ ansible-lint site.yml roles/ 2>&1 | grep -v -E "^\s*$|^WARNING"
Passed: 0 failure(s), 0 warning(s) on 13 files. Last profile that met the validation criteria was 'production'.
Идемпотентность проверяется просто: прогнать плейбук дважды и убедиться, что во втором прогоне ни на одном узле нет изменений. Итоговые строки PLAY RECAP удобно разбирать grep — так проверку можно поставить в конвейер:
$ ansible-playbook site.yml | grep -E "^\S+ +: ok="
db1 : ok=9 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
web1 : ok=9 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
web2 : ok=9 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
$ ansible-playbook site.yml | grep -E "^\S+ +: ok=" | grep -v "changed=0" || echo "повторный прогон ничего не изменил"
повторный прогон ничего не изменил
Если во втором прогоне что-то changed, ищите задачу: command/shell без changed_when или creates, шаблон с несортированным циклом, state: latest, update_cache без cache_valid_time.
Зелёный прогон говорит, что задачи выполнились, но не что сервис работает. Это проверяют отдельным плейбуком, который ничего не меняет:
---
- name: Проверки веб-узлов после настройки
hosts: web
gather_facts: false
tasks:
- name: Сайт отвечает
ansible.builtin.uri:
url: "http://127.0.0.1:{{ nginx_site_port | default(80) }}/"
return_content: true
register: page
- name: На странице заголовок сервиса
ansible.builtin.assert:
that:
- page.status == 200
- "'Сервис цен' in page.content"
success_msg: "{{ inventory_hostname }}: страница на месте"
fail_msg: "{{ inventory_hostname }}: ответ без заголовка сервиса"
- name: Конфигурация nginx без ошибок
ansible.builtin.command: nginx -t
become: true
register: nginx_t
changed_when: false
failed_when: "'syntax is ok' not in nginx_t.stderr"
assert проверяет условия и падает с понятным сообщением.failed_when задаёт, что считать ошибкой. nginx -t пишет результат в stderr; здесь задача падает, если в нём нет «syntax is ok», независимо от кода выхода.changed_when: false — проверка не меняет узел и не должна портить статистику changed.gather_facts: false — факты проверкам не нужны, прогон быстрее.$ ansible-playbook checks.yml
PLAY [Проверки веб-узлов после настройки] **************************************
TASK [Сайт отвечает] ***********************************************************
ok: [web1]
ok: [web2]
TASK [На странице заголовок сервиса] *******************************************
ok: [web1] => {
"changed": false,
"msg": "web1: страница на месте"
}
ok: [web2] => {
"changed": false,
"msg": "web2: страница на месте"
}
TASK [Конфигурация nginx без ошибок] *******************************************
ok: [web2]
ok: [web1]
PLAY RECAP *********************************************************************
web1 : ok=3 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
web2 : ok=3 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
Когда задача падает непонятно, увеличивают подробность вывода: -v — результаты задач полностью, -vv — ещё и пути к файлам ролей, -vvv — команды подключения, -vvvv — отладка самого SSH. На -vvv видно, к какому адресу, каким пользователем и с каким ключом Ansible подключается:
$ ansible web1 -m ping -vvv 2>&1 | grep -E "ESTABLISH SSH|SSH: EXEC ssh" | head -2 | cut -c1-160
<172.30.50.11> ESTABLISH SSH CONNECTION FOR USER: ansible
<172.30.50.11> SSH: EXEC ssh -C -o ControlMaster=auto -o ControlPersist=60s -o 'IdentityFile="/tmp/anspage.n7udbT/ex/.home/.ssh/ansible_ed25519"' -o KbdInteract
ssh с теми же параметрами, что в строке SSH: EXEC.ansible.builtin.debug: var=имя перед упавшей.--step спрашивает перед каждой задачей; --start-at-task начинает с нужной.no_log. Не публикуйте его целиком в чатах и задачах.По умолчанию задача выполняется на всех узлах сразу. Если обновление ломает сервис, сломаются все узлы одновременно. serial: 1 проводит весь play по одному узлу, max_fail_percentage: 0 останавливает прогон после первого сбоя, а block и rescue откатывают изменение на сломанном узле:
---
- name: Обновление страницы на веб-узлах по одному
hosts: web
become: true
serial: 1
max_fail_percentage: 0
vars:
release: "v2"
site_dir: /srv/www/site
tasks:
- name: Узел выведен из балансировки
ansible.builtin.debug:
msg: "{{ inventory_hostname }}: выведен из балансировки"
- name: Обновление с откатом при сбое
block:
- name: Страница новой версии
ansible.builtin.copy:
dest: "{{ site_dir }}/index.html"
content: "<h1>Сервис цен</h1><p>Версия {{ release }}</p>\n"
owner: www-data
group: www-data
mode: "0644"
backup: true
register: new_page
- name: Сайт отвечает новой версией
ansible.builtin.uri:
url: "http://127.0.0.1:{{ release_check_port | default(80) }}/"
return_content: true
timeout: 5
register: page
failed_when: page.status != 200 or release not in page.content
rescue:
- name: Откат страницы из резервной копии
ansible.builtin.copy:
src: "{{ new_page.backup_file }}"
dest: "{{ site_dir }}/index.html"
remote_src: true
owner: www-data
group: www-data
mode: "0644"
when: new_page.backup_file is defined
- name: Обновление остановлено
ansible.builtin.fail:
msg: "{{ inventory_hostname }}: новая версия не подтвердилась, страница возвращена, остальные узлы не трогаем"
- name: Узел возвращён в балансировку
ansible.builtin.debug:
msg: "{{ inventory_hostname }}: в балансировке, версия {{ release }}"
В host_vars узла web2 намеренно ошибка — проверка новой версии идёт не на тот порт:
# Ошибка в данных узла: проверка обновления идёт не на тот порт (опыт с rescue в модуле 08)
release_check_port: 8081
$ ansible-playbook rolling.yml
PLAY [Обновление страницы на веб-узлах по одному] ******************************
TASK [Gathering Facts] *********************************************************
ok: [web1]
TASK [Узел выведен из балансировки] ********************************************
ok: [web1] => {
"msg": "web1: выведен из балансировки"
}
TASK [Страница новой версии] ***************************************************
changed: [web1]
TASK [Сайт отвечает новой версией] *********************************************
ok: [web1]
TASK [Узел возвращён в балансировку] *******************************************
ok: [web1] => {
"msg": "web1: в балансировке, версия v2"
}
PLAY [Обновление страницы на веб-узлах по одному] ******************************
TASK [Gathering Facts] *********************************************************
ok: [web2]
TASK [Узел выведен из балансировки] ********************************************
ok: [web2] => {
"msg": "web2: выведен из балансировки"
}
TASK [Страница новой версии] ***************************************************
changed: [web2]
TASK [Сайт отвечает новой версией] *********************************************
[ERROR]: Task failed: Module failed: Status code was -1 and not [200]: Request failed: <urlopen error [Errno 111] Connection refused>
Origin: /tmp/anspage.n7udbT/ex/rolling.yml:27:11
25 register: new_page
26
27 - name: Сайт отвечает новой версией
^ column 11
fatal: [web2]: FAILED! => {"changed": false, "content": "", "elapsed": 0, "failed_when_result": true, "msg": "Status code was -1 and not [200]: Request failed: <urlopen error [Errno 111] Connection refused>", "redirected": false, "status": -1, "url": "http://127.0.0.1:8081/"}
TASK [Откат страницы из резервной копии] ***************************************
changed: [web2]
TASK [Обновление остановлено] **************************************************
[ERROR]: Task failed: Action failed: web2: новая версия не подтвердилась, страница возвращена, остальные узлы не трогаем
Origin: /tmp/anspage.n7udbT/ex/rolling.yml:46:11
44 when: new_page.backup_file is defined
45
46 - name: Обновление остановлено
^ column 11
fatal: [web2]: FAILED! => {"changed": false, "msg": "web2: новая версия не подтвердилась, страница возвращена, остальные узлы не трогаем"}
NO MORE HOSTS LEFT *************************************************************
NO MORE HOSTS LEFT *************************************************************
PLAY RECAP *********************************************************************
web1 : ok=5 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
web2 : ok=4 changed=2 unreachable=0 failed=1 skipped=0 rescued=1 ignored=0
$ ansible web -m shell -a "grep -o \"<h1>.*</h1>\" /srv/www/site/index.html | head -1; grep -o \"<p>.*</p>\" /srv/www/site/index.html"
web2 | CHANGED | rc=0 >>
<h1>Сервис цен</h1>
<p>Ответил узел web2.</p>
web1 | CHANGED | rc=0 >>
<h1>Сервис цен</h1>
<p>Версия v2</p>
web1 обновлён полностью: страница, проверка, возврат в балансировку.web2 проверка не прошла, выполнился rescue: страница возвращена из резервной копии, которую сделал backup: true, и прогон остановлен через fail. В PLAY RECAP — rescued=1.web1 на новой версии, web2 на старой.Задачи «выведен из балансировки» и «возвращён в балансировку» здесь — заглушки. В настоящем проекте это обращение к балансировщику (nginx, HAProxy, облачный балансировщик), чтобы во время обновления узел не получал запросы пользователей.
Для ролей, которыми пользуются многие проекты, есть Molecule: она создаёт временные узлы (контейнеры или виртуальные машины), применяет к ним роль, проверяет двойным прогоном и тестами и удаляет узлы. Получается то же, что мы делали в этом модуле руками, но одной командой molecule test и для нескольких дистрибутивов сразу. В пакетах Debian 13 Molecule нет, её ставят через pipx. Для начала достаточно связки «линтер + двойной прогон + плейбук проверок» на тестовых узлах — это закрывает большую часть ошибок.
--syntax-check и ansible-lint; их место — в конвейере на каждый коммитchanged=0assert; failed_when и changed_when задают правила явно-v к -vvv; подробный вывод может содержать секретыserial и max_fail_percentage; block/rescue откатывает сломанный узел