Один плейбук для всех узлов, а различия между ними — в данных
В модуле 03 имя пакета и порт были вписаны в задачи. Если для базы нужен другой набор пакетов, а у одного веб-узла другой порт, придётся копировать задачи. Вместо этого логику пишут один раз, а различия выносят в переменные. Переменные инвентаря удобнее всего хранить в каталогах рядом с инвентарём:
$ find group_vars host_vars -type f | sort
group_vars/all.yml
group_vars/db.yml
group_vars/web.yml
host_vars/web2.yml
Файл group_vars/ИМЯ.yml — переменные группы, host_vars/ИМЯ.yml — переменные узла; all — общие для всех. Ansible подхватывает их сам, подключать не нужно.
# Для всех узлов
node_role: общий
role_packages: [htop, curl]
admin_users:
- name: ivanova
comment: Иванова Анна, эксплуатация
groups: [sudo]
- name: petrov
comment: Петров Олег, разработка
# Для группы web — перекрывает all
node_role: веб-сервер
role_packages: [htop, curl, nginx]
http_port: 80
# Только для web2 — перекрывает группу
http_port: 8080
Что получилось у двух веб-узлов после слияния всех уровней:
$ ansible-inventory --host web1
{
"admin_users": [
{
"comment": "Иванова Анна, эксплуатация",
"groups": [
"sudo"
],
"name": "ivanova"
},
{
"comment": "Петров Олег, разработка",
"name": "petrov"
}
],
"ansible_host": "172.30.50.11",
"http_port": 80,
"node_role": "веб-сервер",
"role_packages": [
"htop",
"curl",
"nginx"
]
}
$ ansible-inventory --host web2
{
"admin_users": [
{
"comment": "Иванова Анна, эксплуатация",
"groups": [
"sudo"
],
"name": "ivanova"
},
{
"comment": "Петров Олег, разработка",
"name": "petrov"
}
],
"ansible_host": "172.30.50.12",
"http_port": 8080,
"node_role": "веб-сервер",
"role_packages": [
"htop",
"curl",
"nginx"
]
}
node_role и role_packages группы web перекрыли значения из all, у web2 порт из host_vars перекрыл порт группы, а список администраторов пришёл из all без изменений. Чем точнее уровень, тем он сильнее.
Мест, где можно задать переменную, в Ansible больше двадцати. Упрощённо, от слабых к сильным:
| Где | Для чего |
|---|---|
defaults роли | Значения по умолчанию, которые пользователь роли переопределяет (модуль 06) |
group_vars/all → group_vars/группа → host_vars/узел | Данные инвентаря: что отличает группы и узлы |
vars в play, vars роли | Константы плейбука и роли |
register, set_fact | Значения, вычисленные во время прогона |
-e в командной строке | Сильнее всего: разовые значения для одного запуска |
Практическое правило: держите каждую переменную в одном месте. Если одно имя задано на трёх уровнях, через полгода никто не скажет, откуда взялось значение. Проверка — ansible-inventory --host для данных инвентаря и задача debug для всего остального.
Переменная из -e перекрывает всё, в том числе host_vars:
$ ansible-playbook vars.yml --tags port -e http_port=9000
PLAY [Переменные, факты, условия и циклы] **************************************
TASK [Gathering Facts] *********************************************************
ok: [db1]
ok: [web1]
ok: [web2]
TASK [Порт nginx] **************************************************************
ok: [web1] => {
"msg": "nginx на web1 слушает порт 9000"
}
skipping: [db1]
ok: [web2] => {
"msg": "nginx на web2 слушает порт 9000"
}
PLAY RECAP *********************************************************************
db1 : ok=1 changed=0 unreachable=0 failed=0 skipped=1 rescued=0 ignored=0
web1 : ok=2 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
web2 : ok=2 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
---
- name: Переменные, факты, условия и циклы
hosts: all
become: true
vars:
maint_file: /etc/maintenance
tasks:
- name: Что известно об узле
ansible.builtin.debug:
msg: >-
{{ inventory_hostname }} — {{ node_role }}:
{{ ansible_facts['distribution'] }} {{ ansible_facts['distribution_version'] }},
адрес {{ ansible_facts['default_ipv4']['address'] }}
- name: Пакеты роли установлены
ansible.builtin.apt:
name: "{{ role_packages }}"
state: present
update_cache: true
cache_valid_time: 3600
- name: Учётные записи администраторов
ansible.builtin.user:
name: "{{ item.name }}"
comment: "{{ item.comment }}"
groups: "{{ item.groups | default([]) }}"
append: true
shell: /bin/bash
loop: "{{ admin_users }}"
loop_control:
label: "{{ item.name }}"
- name: Есть ли отметка обслуживания
ansible.builtin.stat:
path: "{{ maint_file }}"
register: maint
- name: Узел на обслуживании — сообщить
ansible.builtin.debug:
msg: "{{ inventory_hostname }} на обслуживании (есть {{ maint_file }}, {{ maint.stat.size }} байт)"
when: maint.stat.exists
- name: Порт nginx
ansible.builtin.debug:
msg: "nginx на {{ inventory_hostname }} слушает порт {{ http_port }}"
when: "'web' in group_names"
tags: [port]
{{ … }} — это выражения шаблонизатора Jinja2. Если значение целиком состоит из подстановки, его берут в кавычки: name: "{{ role_packages }}", иначе YAML примет { за начало словаря.msg: >- — длинная строка в YAML на нескольких строках; переводы строк заменяются пробелами.ansible_facts['distribution'] — факты, собранные в начале play. В старых плейбуках встречается запись ansible_distribution — это те же факты в виде отдельных переменных.group_names — список групп текущего узла, inventory_hostname — его имя в инвентаре. Это «волшебные» переменные: Ansible задаёт их сам.Перед прогоном отметим один узел как находящийся на обслуживании:
$ ansible db1 -b -m copy -a "dest=/etc/maintenance content=\"замена диска 25.10\n\"" | grep -E "\| |\"changed\""
db1 | CHANGED => {
"changed": true,
$ ansible-playbook vars.yml
PLAY [Переменные, факты, условия и циклы] **************************************
TASK [Gathering Facts] *********************************************************
ok: [db1]
ok: [web1]
ok: [web2]
TASK [Что известно об узле] ****************************************************
ok: [web1] => {
"msg": "web1 — веб-сервер: Debian 13.7, адрес 172.30.50.11"
}
ok: [web2] => {
"msg": "web2 — веб-сервер: Debian 13.7, адрес 172.30.50.12"
}
ok: [db1] => {
"msg": "db1 — база данных: Debian 13.7, адрес 172.30.50.21"
}
TASK [Пакеты роли установлены] *************************************************
changed: [web1]
changed: [db1]
changed: [web2]
TASK [Учётные записи администраторов] ******************************************
changed: [web1] => (item=ivanova)
changed: [db1] => (item=ivanova)
changed: [web2] => (item=ivanova)
changed: [web1] => (item=petrov)
changed: [web2] => (item=petrov)
changed: [db1] => (item=petrov)
TASK [Есть ли отметка обслуживания] ********************************************
ok: [web1]
ok: [web2]
ok: [db1]
TASK [Узел на обслуживании — сообщить] *****************************************
skipping: [web1]
skipping: [web2]
ok: [db1] => {
"msg": "db1 на обслуживании (есть /etc/maintenance, 30 байт)"
}
TASK [Порт nginx] **************************************************************
ok: [web1] => {
"msg": "nginx на web1 слушает порт 80"
}
skipping: [db1]
ok: [web2] => {
"msg": "nginx на web2 слушает порт 8080"
}
PLAY RECAP *********************************************************************
db1 : ok=6 changed=2 unreachable=0 failed=0 skipped=1 rescued=0 ignored=0
web1 : ok=6 changed=2 unreachable=0 failed=0 skipped=1 rescued=0 ignored=0
web2 : ok=6 changed=2 unreachable=0 failed=0 skipped=1 rescued=0 ignored=0
Повторный прогон — changed=0, плейбук идемпотентен:
$ ansible-playbook vars.yml | tail -5
PLAY RECAP *********************************************************************
db1 : ok=6 changed=0 unreachable=0 failed=0 skipped=1 rescued=0 ignored=0
web1 : ok=6 changed=0 unreachable=0 failed=0 skipped=1 rescued=0 ignored=0
web2 : ok=6 changed=0 unreachable=0 failed=0 skipped=1 rescued=0 ignored=0
Задача «Учётные записи администраторов» выполняется для каждого элемента списка admin_users; текущий элемент — item. Элементы здесь — словари, и поля берутся через точку: item.name, item.comment. У Петрова нет поля groups, поэтому оно взято с фильтром default([]) — без него задача упала бы на неопределённом значении.
loop_control: label оставляет в выводе только имя, а не весь словарь — с паролями или ключами в словаре это ещё и вопрос безопасности. Модули, которые умеют принимать список, лучше вызывать без цикла: apt с name: "{{ role_packages }}" ставит все пакеты одной операцией, а не по одной на каждый.
register сохраняет результат задачи в переменную. Что в нём лежит, видно, если вызвать модуль отдельно — у stat это словарь stat со свойствами файла:
$ ansible db1 -m stat -a path=/etc/maintenance | grep -E "\| |\"exists\"|\"size\"|\"pw_name\""
db1 | SUCCESS => {
"exists": true,
"pw_name": "root",
"size": 30,
В плейбуке результат проверки сохранён в maint, и следующая задача выполняется только при when: maint.stat.exists. На web1 и web2 файла нет — задача skipping. Условие when пишется без {{ }}: это уже выражение Jinja2. Условия по фактам выглядят так же: when: ansible_facts['distribution'] == 'Debian'.
Пара «проверить — сделать по результату» встречается постоянно: узнать версию установленной программы и обновить, если старая; проверить, инициализирована ли база, и инициализировать один раз. Именно так делают идемпотентными задачи на command: проверка через register, действие — по when.
$ ansible web1 -m debug -a "msg={{ db_password }}" | tail -4
web1 | FAILED! => {
"changed": false,
"msg": "Task failed: Finalization of task args for 'ansible.builtin.debug' failed: Error while resolving value for 'msg': 'db_password' is undefined"
}
Ansible не подставляет пустую строку вместо неизвестной переменной, а останавливает задачу. Это защита: опечатка в имени переменной не превратится в пустой пароль или пустой путь в rm. Когда значение действительно необязательное, это говорят явно фильтром default: {{ http_port | default(80) }}.
group_vars и host_varsall; -e сильнее всегоansible-inventory --host и debugwhen скобки {{ }} не нужныloop + item; списки пакетов — одной задачей без цикла; label скрывает лишнее в выводеregister + when — «проверить, потом сделать»; необязательные значения — через default