Ansible playbook или role: что выбрать для проекта
Узнайте, когда достаточно одного YAML файла, а когда пора переходить к структуре ролей. Разбираем критерии сложности, порядок вызова через include_role и приоритеты переменных.
Ansible playbook и role: в чем разница
Ansible playbook — это файл, который вы запускаете с помощью ansible-playbook. Он сопоставляет группу хостов с задачами, которые необходимо выполнить. Ansible role — это каталог с фиксированной структурой, содержащий задачи, шаблоны, обработчики и переменные по умолчанию; playbook вызывает роль по имени. Синтаксис задач в обоих случаях идентичен, поэтому вопрос не в том, что можно реализовать. Вопрос в возможности повторного использования.
Начните с простого playbook. Один site.yml, содержащий список tasks:, — это подходящая форма для вашей первой автоматизации, и она остается эффективной дольше, чем ожидает большинство пользователей. Переходите к использованию ролей, когда один и тот же блок задач должен выполняться для второй группы хостов или когда файл вырастает примерно до 100 строк и вы перестаете быстро находить нужную задачу при прокрутке.
Если вы еще не написали свой первый playbook, начните с создания первого playbook для одного VPS и возвращайтесь, когда он начнет разрастаться.
Когда плоский playbook — верное решение
Плоский playbook подходит для задач, которые выполняются один раз, на одном хосте или если его никто больше не будет читать. Настройка одного сервера приложений или установка патчей перед окном обслуживания не требуют создания структуры каталогов. Роль добавляет семь каталогов и один уровень косвенности. Если единственный вызывающий объект — это playbook, лежащий рядом, такая косвенность ничего не дает, но заставляет вас каждый раз переходить по ссылке, чтобы увидеть, что именно выполняется.
Плоский playbook перестает быть верным решением в конкретный момент, который легко заметить. Вы копируете блок задач во второй playbook. Это копирование — сигнал. С этого момента каждое исправление придется вносить дважды, и однажды вы сделаете это только один раз.
Что на самом деле содержит каталог роли
roles/common/
defaults/main.yml
vars/main.yml
tasks/main.yml
handlers/main.yml
templates/99-hardening.conf.j2
files/
meta/main.ymltasks/main.ymlявляется точкой входа. Ansible запускает этот файл при вызове роли, при этом все остальные каталоги являются необязательными.defaults/main.ymlсодержит переменные, которые вызывающая сторона должна переопределить. Это источник с самым низким приоритетом в Ansible, поэтому практически любой другой источник имеет над ним преимущество.vars/main.ymlсодержит переменные, которые вызывающая сторона не должна переопределять. По приоритету они стоят выше переменных инвентаря, что является весьма строгим правилом. Используйте их редко.handlers/main.ymlсодержит задачи, запускаемые черезnotify. Обработчик (handler) выполняется один раз в конце плейбука, независимо от того, сколько задач отправили ему уведомление.files/содержит файлы, копируемые «как есть» модулемcopy, аtemplates/содержит шаблоны Jinja2, обрабатываемые модулемtemplate. Внутри роли вы ссылаетесь на них просто по имени файла без указания пути, так как Ansible в первую очередь ищет их в собственных каталогах роли.meta/main.ymlобъявляет зависимости роли и метаданные, которые считывает Ansible Galaxy.
Данная структура — это не вопрос стиля. Ansible ищет файлы именно по этим путям, поэтому шаблон, помещенный в roles/common/template/ (в единственном числе), просто не будет найден.
Создание общей роли с помощью ansible-galaxy init
mkdir -p ~/infra/roles
cd ~/infra
ansible-galaxy init --init-path roles commonЭта команда создает полный каркас в roles/common, включая каталоги, которые вы не будете использовать, и заглушки main.yml, содержащие только ---. Удалите те файлы, которые остаются пустыми. Пустой файл vars/main.yml не мешает работе Ansible, но он скрывает, какие именно файлы в роли действительно важны.
Теперь заполните файлы, которые выполняют работу. Сначала укажите значения по умолчанию, так как они являются публичным интерфейсом роли.
# roles/common/defaults/main.yml
---
common_packages:
- ufw
- fail2ban
- unattended-upgrades
common_admin_group: admins
common_permit_root_login: "no"
common_password_authentication: "no"Используйте кавычки для "no" и "yes". Ansible анализирует YAML с помощью PyYAML, который считывает «голое» значение no как логическое false. В результате в итоговом конфигурационном файле строка превращается в PermitRootLogin False, и sshd отклоняет её. Кавычки позволяют сохранить значение как строку.
# roles/common/tasks/main.yml
---
- name: Install the base packages
ansible.builtin.apt:
name: "{{ common_packages }}"
state: present
update_cache: true
cache_valid_time: 3600
- name: Create the admin group
ansible.builtin.group:
name: "{{ common_admin_group }}"
state: present
- name: Install the sshd hardening drop-in
ansible.builtin.template:
src: 99-hardening.conf.j2
dest: /etc/ssh/sshd_config.d/99-hardening.conf
owner: root
group: root
mode: "0644"
validate: /usr/sbin/sshd -t -f %s
notify: Restart sshd# roles/common/handlers/main.yml
---
- name: Restart sshd
ansible.builtin.service:
name: ssh
state: restarted# roles/common/templates/99-hardening.conf.j2
# Managed by Ansible. Local edits are overwritten on the next run.
PermitRootLogin {{ common_permit_root_login }}
PasswordAuthentication {{ common_password_authentication }}В Debian и Ubuntu юнит systemd называется ssh, а в системах семейства RHEL — sshd. Обработчик (handler), указывающий неверное имя, выдаст ошибку только тогда, когда шаблон действительно изменится, поэтому такие проблемы часто обнаруживаются спустя недели.
Строка validate — самая полезная часть этой задачи. Ansible преобразует шаблон во временный файл, подставляет путь к этому файлу вместо %s и выполняет команду. Файл назначения заменяется только в том случае, если команда завершается с кодом 0. Добавьте бессмысленную директиву в шаблон и запустите задачу снова: она завершится с ошибкой failed to validate, реальный файл /etc/ssh/sshd_config.d/99-hardening.conf останется нетронутым, и вы сохраните доступ к серверу. Учтите, что эта проверка тестирует не только синтаксис. Если sshd -t не может прочитать ключи хоста, команда завершается с кодом sshd: no hostkeys available -- exiting., и Ansible сообщает о той же ошибке failed to validate. Поэтому изучите msg модуля, прежде чем винить шаблон.
Как playbook вызывает роль
# site.yml
---
- name: Base configuration for every server
hosts: all
become: true
roles:
- common# inventory.ini
[local]
localhost ansible_connection=localansible-playbook -i inventory.ini site.ymlВыполнение play должно завершаться строкой failed=0 в сводке. Передавайте параметры в месте вызова с помощью расширенной формы; именно так одна роль обслуживает две группы хостов:
roles:
- role: common
common_admin_group: ops
common_permit_root_login: prohibit-passwordСуществует одно правило порядка выполнения, которое удивляет почти всех. Play может содержать pre_tasks, roles, tasks и post_tasks, но Ansible всегда выполняет их в фиксированном порядке, независимо от того, в какой последовательности вы записали их в файле. Поместите tasks: перед roles:, и роли всё равно будут выполнены первыми. Поэтому, если какое-то действие должно произойти до запуска роли, его следует поместить в pre_tasks:, а не в начало tasks:.
- name: Ordering demonstration
hosts: local
gather_facts: false
pre_tasks:
- name: Runs first
ansible.builtin.debug:
msg: pre
roles:
- common
tasks:
- name: Runs after the role
ansible.builtin.debug:
msg: task
post_tasks:
- name: Runs last
ansible.builtin.debug:
msg: postЧтобы вызвать роль из списка задач, а не через ключ roles:, используйте import_role или include_role.
tasks:
- name: Static, read when the playbook is parsed
ansible.builtin.import_role:
name: common
- name: Dynamic, resolved when the task runs
ansible.builtin.include_role:
name: postgres
when: "'db' in group_names"import_role является статическим. Ansible считывает роль на этапе парсинга, и её задачи становятся частью play, поэтому ansible-playbook --list-tasks site.yml отображает их, а тег, применённый к импорту, распространяется на каждую задачу внутри. include_role является динамическим. Ничего не считывается до момента выполнения задачи, что позволяет задавать имя роли через переменную или цикл. Платой за это является то, что такие задачи невидимы для --list-tasks и для --start-at-task.
Здесь кроется одна ловушка. Условие when: в задаче include_role вычисляется до того, как defaults/main.yml подключаемой роли попадает в область видимости. Если написать when: common_packages | length > 0 в include, выполнение остановится с ошибкой 'common_packages' is undefined, даже если эта переменная определена в самой подключаемой роли. Решение заключается в том, чтобы вынести переключатель за пределы роли: поместите его в group_vars/all.yml, где он будет доступен везде, а в defaults роли оставьте только те значения, которые роль использует сама.
Какой приоритет переменных: defaults, group_vars, vars, extra vars
Ansible описывает более двадцати уровней приоритета переменных. Четыре из них определяют почти все реальные сценарии, и ниже они приведены от самого слабого к самому сильному.
roles/<name>/defaults/main.ymlнаходится почти в самом низу. Почти всё, что вы зададите в другом месте, перекроет его, поэтому это идеальное место для настроек роли.group_vars/иhost_vars/находятся посередине. Здесь должны быть значения, специфичные для вашей инфраструктуры, они корректно переопределяют значения по умолчанию из роли.roles/<name>/vars/main.ymlнаходится вышеhost_vars. Значение, заданное здесь, нельзя переопределить из инвентаря. Оставьте это место для параметров, которые должны оставаться неизменными внутри роли, например, для имени пакета, которое должно совпадать с именем сервиса.- Параметр роли, переданный при вызове, перекрывает
vars/main.yml, а-eв командной строке перекрывает всё, включая параметры роли.
Вы можете увидеть этот процесс в действии за минуту. Задайте в небольшой роли одно значение по умолчанию и одну переменную роли, а затем установите те же имена в host_vars.
# roles/prec/defaults/main.yml
---
prec_tunable: from-defaults
prec_internal: from-defaults# roles/prec/vars/main.yml
---
prec_internal: from-rolevars# host_vars/localhost.yml
---
prec_tunable: from-hostvars
prec_internal: from-hostvars# roles/prec/tasks/main.yml
---
- name: Show which value survived
ansible.builtin.debug:
msg: "tunable={{ prec_tunable }} internal={{ prec_internal }}"ansible-playbook -i inventory.ini prec.yml
ansible-playbook -i inventory.ini prec.yml -e prec_internal=from-cliПервый запуск выведет tunable=from-hostvars internal=from-rolevars. Инвентарь перекрыл значение по умолчанию, но уступил переменной роли. Второй запуск выведет internal=from-cli, так как extra vars находятся на самом верху, и ничто ниже не может их изменить. Именно поэтому -e подходит для разового запуска, но является ошибкой в постоянном скрипте: эта переменная без предупреждения перекрывает любые решения, зафиксированные в вашем репозитории.
Рабочее правило: если вы хотите, чтобы значение можно было менять, помещайте его в defaults/. Размещение значения в vars/ говорит любому будущему пользователю роли, что инвентарь не сможет его изменить. Иногда это именно то, что вам нужно, но чаще всего — случайная ошибка.
Проверка идемпотентности роли: повторный запуск
Надежный playbook Ansible при повторном запуске должен приводить к тому же результату и сообщать, что изменений не произошло. Запустите playbook дважды и изучите итоговую сводку.
ansible-playbook -i inventory.ini site.yml
ansible-playbook -i inventory.ini site.ymlВторая сводка должна выглядеть так:
PLAY RECAP *********************************************************************
localhost : ok=4 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0changed=0 означает, что каждый модуль проверил текущее состояние и обнаружил, что работа уже выполнена. changed=2 при повторном запуске означает, что две задачи не могут определить текущее состояние, поэтому они будут бесконечно перезаписывать файлы и перезапускать службы. Обычно причина кроется в command или shell, так как Ansible не может знать, что именно выполнила произвольная команда.
# traps.yml
---
- name: Command modules do not know what they changed
hosts: local
gather_facts: false
tasks:
- name: This appends a line on every run
ansible.builtin.shell: "echo run >> /tmp/grow.txt"
- name: This appends a line only once
ansible.builtin.shell: "echo run >> /tmp/guarded.txt"
args:
creates: /tmp/guarded.txtЗапустите этот playbook дважды, затем посчитайте строки с wc -l /tmp/grow.txt /tmp/guarded.txt. /tmp/grow.txt содержит две строки, а /tmp/guarded.txt — одну. При втором запуске защищенная задача не выполнялась вовсе, а её результат содержит сообщение skipped, since /tmp/guarded.txt exists, поскольку creates предоставляет модулю видимый объект для предварительной проверки. Если команда не оставляет такого объекта, сохраните её вывод в переменную и примите решение самостоятельно с помощью changed_when.
ansible-playbook --check --diff site.yml позволяет спрогнозировать изменения без их применения, а --diff выводит точные строки, которые будут перезаписаны в шаблоне. При анализе вывода учитывайте один нюанс: задачи shell и command пропускаются в режиме проверки, поэтому план, который выглядит корректным, может скрывать выполнение действий.
Еще один столбец в сводке требует внимания: хост, к которому Ansible не смог подключиться, учитывается в unreachable, а не в failed, и ни одна из его задач не была выполнена. Поэтому заранее решите, должен ли один недоступный хост останавливать весь процесс, прежде чем применять эту роль к большому количеству машин.
Почему Ansible сообщает, что роль не найдена
Ansible ищет каталог roles/ рядом с файлом плейбука, а затем в roles_path. Поиск привязан к расположению плейбука, а не к текущему каталогу вашей оболочки.
ERROR! the role 'common' was not found in /home/deploy/lonely/roles:/home/deploy/lonelyЭто сообщение означает, что site.yml и roles/ разошлись, и Ansible выводит пути, по которым он пытался выполнить поиск. Храните их в одном каталоге. Запуск из родительского каталога допустим, так как учитывается именно путь к плейбуку:
ansible-playbook -i infra/inventory.ini infra/site.ymlСуществует менее очевидная версия этой проблемы. Ansible игнорирует ansible.cfg в текущем каталоге, если у этого каталога установлены права на запись для всех (world writable), так как любой пользователь системы может разместить там конфигурационный файл и изменить поведение вашего запуска.
[WARNING]: Ansible is being run in a world writable directory (/tmp/infra), ignoring it as an ansible.cfg source.В этом случае ваши настройки roles_path и inventory молча игнорируются, и поиск роли завершается ошибкой по причинам, не связанным с самими ролями. Команда ansible --version выводит путь к config file, который был фактически загружен, а ansible-config dump --only-changed показывает все настройки, отличающиеся от встроенных значений по умолчанию. Проверяйте оба параметра всякий раз, когда выполнение ведет себя так, будто ваша конфигурация отсутствует.
Совместное использование ролей: requirements.yml и фиксация версий
Роль, написанная кем-то другим, должна устанавливаться, а не копироваться. Объявите её один раз:
# requirements.yml
---
roles:
- name: postgres
src: https://github.com/example/ansible-role-postgres
scm: git
version: v1.4.0ansible-galaxy install -r requirements.yml -p galaxy_rolesВсегда указывайте version. Без этого параметра вы получите версию из ветки по умолчанию на момент выполнения команды, поэтому развертывание, работавшее в прошлом месяце, может сломаться без каких-либо изменений в вашем репозитории. Укажите roles_path для директории загрузки и исключите эту директорию из git:
# ansible.cfg
[defaults]
inventory = inventory.ini
roles_path = ./galaxy_rolesРоли в roles/ рядом с плейбуком по-прежнему будут найдены, так как этот путь всегда проверяется в дополнение к roles_path. Таким образом, ваши собственные роли остаются в системе контроля версий и проходят проверку, а сторонние роли представляют собой воспроизводимые загрузки, зафиксированные на определенном теге.
Когда роли перестают быть решением
Роль — это единица повторного использования в рамках одного запуска Ansible. Она не создает серверы или DNS-записи у вашего провайдера, и попытки заставить её это делать превращают плейбуки в нечто, что никто не хочет поддерживать. Перед началом работы стоит изучить разделение задач между Ansible и Terraform. Роль также не заменяет проектирование инвентаря: как только количество машин превышает несколько штук, то, как вы группируете серверы и обращаетесь к ним, становится важнее того, как разложены задачи.
Укрепление безопасности, которое устанавливает эта роль common, также требует принятия отдельных решений. Приведенный выше фрагмент конфигурации задает только две директивы, поэтому перед тем, как решать, что именно должно входить в роль для каждого вашего хоста, ознакомьтесь с тем, какие настройки SSH действительно стоит менять и как настроить автоматическую установку обновлений безопасности в Ubuntu.
FAQ
Когда стоит превращать Ansible playbook в роль?
Когда один и тот же блок задач должен выполняться в другом play или для другой группы хостов. Копирование задач между playbook — это сигнал к действию, так как с этого момента каждое исправление придется вносить дважды, и однажды вы обязательно забудете обновить одну из копий. Один playbook объемом около 100 строк, который всегда нацелен только на одну группу, ничего не выигрывает от превращения в роль, а дополнительные директории лишь усложняют чтение.
Выполняются ли роли раньше задач в том же play?
Да. Ansible сначала выполняет pre_tasks, затем всё, что указано в roles:, затем tasks:, и наконец post_tasks:; при этом порядок, в котором эти ключи записаны в файле, игнорируется. Если вы напишете tasks: выше roles:, это не заставит эти задачи выполниться первыми. Если что-то должно произойти до запуска роли, поместите это в pre_tasks:.
Почему значение из group_vars не переопределяет роль?
Проверьте, не задана ли переменная в vars/main.yml самой роли вместо defaults/main.yml. vars/ находится выше group_vars и host_vars в порядке приоритетов Ansible, поэтому инвентарь не может её переопределить. Перенесите переменную в defaults/main.yml, которая находится ближе к концу списка приоритетов и является правильным местом для параметров, которые должен иметь возможность изменять вызывающий код. Чтобы убедиться, что причина именно в приоритетах, а не в опечатке, запустите playbook с флагом -e name=value, который имеет высший приоритет среди всех источников.
Почему Ansible сообщает, что роль не найдена?
Поиск начинается относительно файла playbook, поэтому site.yml и roles/ должны находиться в одной директории. В сообщении об ошибке выводятся пути, по которым выполнялся поиск, например the role 'common' was not found in /home/deploy/lonely/roles:/home/deploy/lonely. Запуск playbook из родительской директории допустим, так как поиск следует по пути к самому playbook, а не по текущей рабочей директории вашей оболочки. Если вы используете roles_path из ansible.cfg, убедитесь, что файл был загружен с помощью ansible --version, так как Ansible игнорирует его, если рабочая директория доступна для записи всем пользователям.
Нужно ли использовать ansible-galaxy init для создания роли?
Нет. Роль — это просто набор директорий с ожидаемыми именами, поэтому mkdir -p roles/common/tasks вместе с файлом tasks/main.yml — это уже рабочая роль. ansible-galaxy init --init-path roles common экономит время и предоставляет полный каркас, включая meta/main.yml и шаблон README. Удаляйте пустые директории, так как наличие пустой vars/main.yml затрудняет понимание того, какие именно файлы в роли выполняют полезную работу.