Ansible настраивает серверы по SSH: агент на них ставить не нужно, хватает Python. Список машин лежит в inventory, а шаги настройки — в playbook на YAML. Главное свойство — повторный запуск: playbook описывает нужное состояние, поэтому второй прогон на уже настроенном сервере ничего не меняет. Ниже — путь от установки до playbook с nginx на двух серверах Debian 12, с живым выводом и разбором ошибок.
Второй запуск ничего не меняет
Установка
Ansible ставят только на управляющую машину — ту, с которой запускаешь команды. В Debian и Ubuntu он есть в репозитории:
sudo apt install ansible
ansible --version | head -1
ansible [core 2.14.18]
На серверах нужны три вещи: SSH, Python 3 и пользователь, которому разрешён sudo. В примере это пользователь deploy на машинах web1 и web2, а управляющая машина входит к нему по ключу. Как настроить вход по ключу, разобрано в материале о подключении по SSH.
Inventory: список серверов
Inventory — файл со списком машин и групп. В квадратных скобках имя группы, в строке — имя машины и её адрес, а секция [web:vars] задаёт переменные для всей группы:
[web]
web1 ansible_host=203.0.113.11
web2 ansible_host=203.0.113.12
[web:vars]
ansible_user=deploy
Первое подключение упирается в ключи хостов, как и у обычного ssh. В терминале ssh спросит про ключ каждой машины, а без терминала, например в CI, подключение сразу падает:
ansible web -i inventory.ini -m ping
web2 | UNREACHABLE! => {
"changed": false,
"msg": "Failed to connect to the host via ssh: Host key verification failed.",
"unreachable": true
}
web1 | UNREACHABLE! => {
"changed": false,
"msg": "Failed to connect to the host via ssh: Host key verification failed.",
"unreachable": true
}
Ключи можно собрать заранее командой ssh-keyscan -H 203.0.113.11 203.0.113.12 >> ~/.ssh/known_hosts. Она доверяет тому, что ответило по сети, поэтому отпечатки стоит сверить с теми, что показывают сами серверы. После этого модуль ping проверяет связь и Python на каждой машине:
ansible web -i inventory.ini -m ping
web1 | SUCCESS => {
"ansible_facts": {
"discovered_interpreter_python": "/usr/bin/python3"
},
"changed": false,
"ping": "pong"
}
web2 | SUCCESS => {
"ansible_facts": {
"discovered_interpreter_python": "/usr/bin/python3"
},
"changed": false,
"ping": "pong"
}
Разовые команды
Без playbook Ansible выполняет одну команду сразу на всей группе. Ключ -a передаёт её модулю command:
ansible web -i inventory.ini -a "hostname"
web1 | CHANGED | rc=0 >>
web1
web2 | CHANGED | rc=0 >>
web2
CHANGED здесь не значит, что что-то сломали. Модули command и shell не знают, что делает запущенная команда, поэтому всегда отвечают «изменено». Для настройки серверов берут модули, которые понимают состояние: apt, template, service, lineinfile.
Первый playbook
Playbook описывает, что должно быть на серверах. Этот ставит nginx, кладёт конфигурацию страницы статуса и следит, чтобы nginx работал:
- name: Настроить веб-серверы
hosts: web
become: true
tasks:
- name: Установить nginx
ansible.builtin.apt:
name: nginx
state: present
update_cache: true
- name: Положить конфигурацию страницы статуса
ansible.builtin.template:
src: templates/status.conf.j2
dest: /etc/nginx/conf.d/status.conf
mode: "0644"
notify: Перезагрузить nginx
- name: Запустить nginx и включить автозапуск
ansible.builtin.service:
name: nginx
state: started
enabled: true
handlers:
- name: Перезагрузить nginx
ansible.builtin.service:
name: nginx
state: reloaded
hosts: web — на какой группе работать. become: true выполняет задачи через sudo. state: present у apt значит «пакет должен стоять», а не «поставь его ещё раз». Шаблон templates/status.conf.j2 — обычный файл конфигурации, в который Ansible подставляет переменные:
server {
listen 8080;
location /status {
return 200 "ok {{ inventory_hostname }}\n";
}
}
notify вызывает обработчик из секции handlers, только если задача что-то изменила: nginx перезагружается после правки конфигурации, а не при каждом запуске. Первый прогон:
ansible-playbook -i inventory.ini site.yml
PLAY [Настроить веб-серверы] ***************************************************
TASK [Gathering Facts] *********************************************************
ok: [web2]
ok: [web1]
TASK [Установить nginx] ********************************************************
changed: [web1]
changed: [web2]
TASK [Положить конфигурацию страницы статуса] **********************************
changed: [web2]
changed: [web1]
TASK [Запустить nginx и включить автозапуск] ***********************************
changed: [web2]
changed: [web1]
RUNNING HANDLER [Перезагрузить nginx] ******************************************
changed: [web1]
changed: [web2]
PLAY RECAP *********************************************************************
web1 : ok=5 changed=4 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
web2 : ok=5 changed=4 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
Оба сервера отвечают со своим именем, которое подставил шаблон:
curl -s http://203.0.113.11:8080/status
curl -s http://203.0.113.12:8080/status
ok web1
ok web2
Повторный запуск
Тот же playbook второй раз:
ansible-playbook -i inventory.ini site.yml
PLAY [Настроить веб-серверы] ***************************************************
TASK [Gathering Facts] *********************************************************
ok: [web1]
ok: [web2]
TASK [Установить nginx] ********************************************************
ok: [web1]
ok: [web2]
TASK [Положить конфигурацию страницы статуса] **********************************
ok: [web1]
ok: [web2]
TASK [Запустить nginx и включить автозапуск] ***********************************
ok: [web1]
ok: [web2]
PLAY RECAP *********************************************************************
web1 : ok=4 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
web2 : ok=4 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
changed=0 и нет строки RUNNING HANDLER: всё уже в нужном состоянии, менять нечего. Это свойство называют идемпотентностью, и оно держится не на Ansible целиком, а на каждой задаче. Задача через shell его ломает. Вот такая строчка дописывает переменную в /etc/environment:
- name: Дописать APP_ENV через shell
ansible.builtin.shell: echo "APP_ENV=production" >> /etc/environment
Каждый прогон отвечает changed и добавляет строку заново. После двух запусков их две:
ansible web -i inventory.ini -a "grep -c APP_ENV /etc/environment"
web1 | CHANGED | rc=0 >>
2
web2 | CHANGED | rc=0 >>
2
Модуль lineinfile сначала ищет строку по регулярному выражению и меняет файл, только если её нет или она другая:
- name: Задать APP_ENV
ansible.builtin.lineinfile:
path: /etc/environment
regexp: "^APP_ENV="
line: APP_ENV=production
Первый запуск с ним отвечает changed, второй — ok и changed=0 в итоге. Лишние копии, оставшиеся от shell, убирает тот же модуль с state=absent.
Главная мысль: хороший playbook можно запускать сколько угодно раз. Если второй прогон снова пишет
changed, ищи задачу наshellилиcommand.
Проверка без изменений: —check и —diff
Флаг --check прогоняет playbook, ничего не меняя, а --diff показывает, что именно поменялось бы. Допустим, в шаблоне текст ответа стал ok {{ inventory_hostname }} v2, а запуск ограничен одной машиной через --limit:
ansible-playbook -i inventory.ini site.yml --check --diff --limit web1
TASK [Положить конфигурацию страницы статуса] **********************************
--- before: /etc/nginx/conf.d/status.conf
+++ after: /home/dev/.ansible/tmp/ansible-local-5793xhrxqpg/tmpekmw5jz6/status.conf.j2
@@ -1,6 +1,6 @@
server {
listen 8080;
location /status {
- return 200 "ok web1\n";
+ return 200 "ok web1 v2\n";
}
}
changed: [web1]
Это отрывок вывода, остальные задачи ответили ok. На сервере ничего не поменялось: страница статуса отвечает по-старому.
curl -s http://203.0.113.11:8080/status
ok web1
Секреты: ansible-vault
Пароли в playbook не пишут открытым текстом. ansible-vault encrypt_string шифрует значение, и результат вставляют в переменные как есть:
ansible-vault encrypt_string --vault-password-file vault-pass.txt "app_password" --name db_password
db_password: !vault |
$ANSIBLE_VAULT;1.1;AES256
64646338323163623763356437613564333539336433373030313032356534643265323837373235
3162613437666134383630323564383665643634643961310a356161323234633664323938363461
39373333343766643436623334613133316462303461363666366362326439333230373832653737
3530396264643662350a623435653932396139396132333161396430643463346562306161383439
3736
При запуске playbook тот же пароль передают через --vault-password-file vault-pass.txt или вводят по запросу с --ask-vault-pass. Файл с паролем от vault в репозиторий не кладут — его место в .gitignore.
Ошибки, на которых спотыкаются
Host key verification failed. Ключа сервера нет в ~/.ssh/known_hosts, а спросить некого. Собери ключи через ssh-keyscan и сверь отпечатки.
Permission denied (publickey,password). Ansible вошёл не тем пользователем или ключ не лежит на сервере:
web1 | UNREACHABLE! => {
"changed": false,
"msg": "Failed to connect to the host via ssh: admin@203.0.113.11: Permission denied (publickey,password).",
"unreachable": true
}
Missing sudo password. Задаче нужен become, а пользователю на сервере sudo разрешён только с паролем:
web1 | FAILED! => {
"msg": "Missing sudo password"
}
Пароль спрашивает флаг -K (--ask-become-pass), или правило sudo для этого пользователя меняют на вход без пароля.
couldn’t resolve module/action. Опечатка в имени модуля — здесь ansible.builtin.ap вместо ansible.builtin.apt:
ERROR! couldn't resolve module/action 'ansible.builtin.ap'. This often indicates a misspelling, missing collection, or incorrect module path.
The error appears to be in '/home/dev/infra/typo-module.yml': line 6, column 7, but may
be elsewhere in the file depending on the exact syntax problem.
The offending line appears to be:
tasks:
- name: Установить nginx
^ here
Указатель стоит на начале задачи, а не на самом модуле: ищи опечатку во всей задаче.
did not find expected key. Сломан отступ в YAML. Ansible показывает строку, где споткнулся разбор:
ERROR! We were unable to read either as JSON nor YAML, these are the errors we got from each:
JSON: Expecting value: line 1 column 1 (char 0)
Syntax Error while loading YAML.
did not find expected key
The error appears to be in '/home/dev/infra/typo-yaml.yml': line 9, column 8, but may
be elsewhere in the file depending on the exact syntax problem.
The offending line appears to be:
name: nginx
state: present
^ here
No package matching. Модуль apt не нашёл пакет, обычно из-за опечатки в имени:
fatal: [web1]: FAILED! => {"changed": false, "msg": "No package matching 'ngnix' is available"}
Флаг -u ничего не меняет. Пользователь из inventory, ansible_user, важнее флага командной строки: у значений из командной строки самый низкий приоритет. Здесь -u admin проигнорирован:
ansible web1 -i inventory.ini -u admin -a whoami
web1 | CHANGED | rc=0 >>
deploy
Переопределить пользователя на один запуск можно через -e ansible_user=admin: у дополнительных переменных приоритет выше.
Частые вопросы
Чем Ansible лучше bash-скрипта по SSH
Bash-скрипт выполняет команды, Ansible приводит сервер к описанному состоянию. Скрипт, запущенный дважды, повторит всё дважды, а playbook на втором прогоне ничего не тронет. К тому же Ansible работает сразу с группой машин из inventory и показывает итог по каждой.
Нужно ли ставить Ansible на серверы
Нет. На серверах нужны SSH и Python, всё остальное Ansible привозит с собой на время выполнения задачи. Поэтому его и называют безагентным.
Что делать со службами на сервере
Модуль service запускает, останавливает и включает службы так же, как systemctl, только на всей группе сразу и с проверкой состояния.
Где потренироваться
Ansible — тема в карте навыков DevOps, рядом с Terraform и Kubernetes. Проверить себя можно в тесте по DevOps: 8 вопросов, баллы видны без регистрации.