Модуль 08 — Проверка и отладка

Плейбук, которому можно доверить боевые серверы, проверен до запуска и после него

Прогресс курса Модуль 8 из 9

Что вы освоите в этом модуле

01

Лестница проверок

Каждая следующая проверка дороже и ловит то, что пропустила предыдущая:

ПроверкаЧто ловитНужны узлы
--syntax-checkОшибки YAML, неизвестные модулиНет
ansible-lintПлохие практики: shell вместо модуля, неидемпотентные задачи, права файлов, именаНет
--check --diffЧто поменяется на конкретных узлахДа, только чтение
Двойной прогонНеидемпотентные задачи: второй прогон должен дать changed=0Да, тестовые
Плейбук проверокСлужба на самом деле работает и отвечает правильноДа

Первые две проверки ставят в конвейер CI на каждый коммит (курс Основы CI/CD), остальные — на тестовых узлах перед выкладкой в бой.

$ ansible-playbook site.yml --syntax-check

playbook: site.yml
02

ansible-lint

Линтер ставится пакетом 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.

Пятнадцать замечаний на двенадцать строк. Самые важные — не про стиль:

Каждое правило объяснено в документации 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'.
03

Двойной прогон

Идемпотентность проверяется просто: прогнать плейбук дважды и убедиться, что во втором прогоне ни на одном узле нет изменений. Итоговые строки 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.

04

Плейбук проверок: assert, failed_when, changed_when

Зелёный прогон говорит, что задачи выполнились, но не что сервис работает. Это проверяют отдельным плейбуком, который ничего не меняет:

---
- 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"
$ 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   
05

Отладка

Когда задача падает непонятно, увеличивают подробность вывода: -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
06

Обновление по одному узлу с откатом

По умолчанию задача выполняется на всех узлах сразу. Если обновление ломает сервис, сломаются все узлы одновременно. 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>

Задачи «выведен из балансировки» и «возвращён в балансировку» здесь — заглушки. В настоящем проекте это обращение к балансировщику (nginx, HAProxy, облачный балансировщик), чтобы во время обновления узел не получал запросы пользователей.

07

Molecule

Для ролей, которыми пользуются многие проекты, есть Molecule: она создаёт временные узлы (контейнеры или виртуальные машины), применяет к ним роль, проверяет двойным прогоном и тестами и удаляет узлы. Получается то же, что мы делали в этом модуле руками, но одной командой molecule test и для нескольких дистрибутивов сразу. В пакетах Debian 13 Molecule нет, её ставят через pipx. Для начала достаточно связки «линтер + двойной прогон + плейбук проверок» на тестовых узлах — это закрывает большую часть ошибок.

Ключевые выводы модуля

Запомните главное

  • До узлов — --syntax-check и ansible-lint; их место — в конвейере на каждый коммит
  • Идемпотентность доказывает двойной прогон с changed=0
  • Работоспособность доказывает плейбук проверок с assert; failed_when и changed_when задают правила явно
  • Отладка — от -v к -vvv; подробный вывод может содержать секреты
  • Боевые узлы — serial и max_fail_percentage; block/rescue откатывает сломанный узел
Практические задания — на учебном портале. Задания, критерии оценивания, сдача работ и оценки преподавателя — в курсе на portal.nevabit.ru. Учётную запись выдаёт преподаватель.
Модуль 07: Ansible Vault Все модули Итоговый проект