✻ Урок 7.5 · Тема 7: IaC: Terraform и Ansible
Ansible: плейбуки, handlers и Jinja2
Содержание урока
Зачем это нужно
Ad-hoc команды из прошлого урока хороши для разовой проверки, но настройка сервера это десятки шагов, и их нужно повторять на новой ВМ без ручной памяти. Плейбук (playbook) записывает эти шаги в текстовый файл: его читает человек, хранит git, запускает CI. На работе плейбук пишут для установки Docker (программа для запуска приложений в изолированных «коробках», тема 4), раскладки конфигов (настроек приложения), создания пользователей, а на собеседовании спрашивают «почему у тебя changed=1 при каждом запуске» и «почему handler не перезапустил сервис».
Два слова из этого абзаца, которые встретятся сразу:
changed- отметка Ansible «эта задача что-то поменяла на сервере». Если на втором запуске сноваchanged, задача не идемпотентна (урок 7.4): как сотрудник, который каждый день «заново красит» уже покрашенную стену;- handler (обработчик) - задача-«звонок консьержу»: выполняется только если другая задача что-то изменила. Обычно это перезапуск сервиса после смены конфига. Без него сервис либо не узнает о новых настройках, либо перезапускался бы при каждом запуске и обрывал пользователей. Подробно разберём ниже.
Шаг проекта: в ~/notes/infra/ansible/ (папку с ansible.cfg и inventory.yml ты создал в уроке 7.4) появляются site.yml (главный файл, который по очереди запускает остальные), каталог playbooks/ (base.yml, docker.yml, config.yml: по одному плейбуку на этап настройки) и шаблон templates/notes.env.j2 (текст файла настроек с «пустыми местами» под значения; расширение .j2 говорит, что он для Jinja2, о ней ниже).
Что нужно знать
- Урок 7.4: инвентарь, модули, ad-hoc: инвентарь,
ansible.cfg,become(выполнение с правами root через sudo), идемпотентность (повторный запуск не меняет результат), формат YAML (структура задаётся отступами). - Урок 1.3: пользователи и права: владелец и режим файла (
640 root:notes). - Урок 1.8: systemd: что значит перезапустить сервис.
- Урок 4.5: Compose и PostgreSQL: зачем на сервере Docker и файл
.env(текстовый файл настроек видаключ=значение, из которого приложение читает пароли и адреса). - Урок 6.3: «Заметки» на ВМ: что мы раньше ставили руками на этой ВМ.
Картина целиком
Ad-hoc команда это устное поручение: «поставь htop». Плейбук это рабочая инструкция на бумаге: пронумерованные шаги, кто их выполняет и в каком порядке. Инструкцию можно передать новому сотруднику, проверить, повторить.
Ещё есть бланк документа: договор, где имя и сумма вписываются в пустые места. Это шаблон Jinja2: текст конфига с «пустыми местами» {{ имя }}, куда Ansible подставляет значения. А звонок консьержу («конфиг поменялся, перезапусти сервис») это handler: он срабатывает только когда что-то реально изменилось.
Вот как всё это связано:
flowchart TD
S["site.yml<br>точка входа: порядок запуска"] --> B["playbooks/base.yml<br>play 1: пакеты, пользователь, каталоги"]
B --> D["playbooks/docker.yml<br>play 2: репозиторий и Docker"]
D --> C["playbooks/config.yml<br>play 3: конфиг из шаблона"]
V["переменные: vars, -e"] --> T["templates/notes.env.j2<br>бланк с пустыми местами"]
C --> T
T -->|"рендер на твоём компьютере"| F["/etc/notes/notes.env на сервере"]
F --> Q{"файл изменился?"}
Q -->|"да"| H["notify: handler<br>один раз в конце play"]
Q -->|"нет"| K["ok, handler молчит"]
За урок ты разберёшь каждый кусок: из чего состоит плейбук (play, task), как читать его вывод, как работают переменные и шаблоны, когда срабатывает handler, как ограничить запуск и как проверить плейбук до боя.
Теория
Плейбук: play, tasks, модули
Плейбук это YAML-файл, внутри которого список игр (plays). Игра (play) связывает группу серверов с набором действий: «на этих хостах сделай вот это». Действия называются задачами (tasks). Задача вызывает один модуль (урок 7.4) с параметрами и описывает желаемое состояние: state: present значит «пакет должен быть», а не «поставь пакет». Поэтому второй запуск ничего не меняет: модуль сравнивает факт с описанием и молчит, если они совпали.
Аналогия: спектакль. Плейбук это сценарий, play это действие, task это реплика, модуль это актёр, который умеет одну роль. Оговорка: в спектакле реплики нельзя пропустить, а модуль Ansible пропускает работу, если она уже сделана.
- name: Пример # имя игры: печатается в выводе
hosts: all # к кому применять (группа из инвентаря)
become: true # выполнять от root (sudo) для всей игры
tasks: # список задач: выполняются по порядку сверху вниз
- name: Пакет htop установлен # имя задачи: по нему читают вывод
ansible.builtin.apt: # модуль
name: htop # параметры модуля
state: present
Разбор. Строка - name: Пример с дефисом начинает элемент списка (одну игру). hosts: all берётся из инвентаря: all все хосты, notes группа. become: true на уровне игры действует на все её задачи, на одну задачу его можно поставить отдельно. tasks список: каждая задача тоже начинается с дефиса. Всегда пиши name: без имени вывод нечитаем, а линтер (программа проверки стиля) ругается.
Порядок. Задачи идут строго по очереди. Если задача упала на хосте, остальные задачи для этого хоста не выполняются. Плейбук со списком игр выполняет игры одна за другой.
Как читать вывод плейбука. Пример запуска config.yml (реальный, с Ubuntu 24.04):
PLAY [Конфиг Заметок] **********************************************************
TASK [Gathering Facts] *********************************************************
ok: [notes-vm]
TASK [Файл окружения из шаблона] ***********************************************
changed: [notes-vm]
RUNNING HANDLER [Сообщить о смене конфига] *************************************
ok: [notes-vm] => {
"msg": "Конфиг изменился, приложению нужен перезапуск"
}
PLAY RECAP *********************************************************************
notes-vm : ok=3 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
PLAY [...]начало игры, в скобках еёname.TASK [Gathering Facts]служебная задача: Ansible собирает факты (урок 7.4) в начале каждой игры сам.ok: [notes-vm]илиchanged: [notes-vm]: результат задачи на хосте (в скобках имя хоста из инвентаря).RUNNING HANDLERэто handler (о нём ниже).PLAY RECAPитоговая таблица по каждому хосту.okсколько задач отработало (включаяchanged),changedсколько что-то изменило,failedсколько упало,unreachableдо скольких не достучались,skippedсколько пропущено условием. Здесьok=3: сбор фактов, задача шаблона и handler.
Главный признак здорового плейбука: второй запуск даёт changed=0. Это проверка идемпотентности.
Прикинь сам: В плейбуке три задачи, а вторая упала на хосте
notes-vm. Выполнится ли третья на этом хосте?
Нет: задачи идут строго по очереди, и после падения на хосте остальные для него не выполняются. Что было сделано до падения, остаётся на сервере.
Осторожно: ok в итоговой таблице включает changed. Поэтому в recap ok=5 changed=3 значит «пять задач прошли, три из них что-то изменили», а не «пять без изменений».
Главное: плейбук это список игр, игра связывает хосты с задачами, задача вызывает модуль и описывает желаемое состояние; здоровый плейбук на втором запуске даёт
changed=0.
Проверь понимание: чем
state: presentв модулеaptотличается от командыapt install?
Ответ
apt install это действие: оно каждый раз что-то делает. state: present это описание итога: модуль проверяет, стоит ли пакет, и ставит только при необходимости. Поэтому повторный запуск безопасен и показывает ok, а не changed.
Чтобы не вписывать значения в каждую задачу, их выносят в переменные.
Переменные и Jinja2
Значения, которые меняются (порт, уровень логов, пароль, версия), нельзя вписывать в каждую задачу: изменение придётся искать по десяти файлам. Их выносят в переменные (variables), а в тексте оставляют «пустое место».
Jinja2 (Джинджа) это шаблонизатор: программа, которая берёт текст с пустыми местами и подставляет значения. Пустое место пишется в двойных фигурных скобках: {{ имя }}. Аналогия: письмо с пропусками «Уважаемый {{ имя }}», которое рассылают по списку. Оговорка: в Jinja2 можно не только подставлять, но и применять фильтры (функции через |, например {{ port | default(8080) }} подставит 8080, если переменная не задана) и писать условия.
Как это работает по шагам:
- Ты объявляешь переменную: в
varsигры, вgroup_vars, в ключе-e. - Ansible встречает
{{ имя }}в параметре задачи или в файле-шаблоне. - Он вычисляет выражение и подставляет значение. Для параметров задач это происходит на управляющей машине, для шаблона тоже: модуль
ansible.builtin.templateрендерит (собирает готовый текст) файл.j2на твоём компьютере, а на сервер отправляет готовый текст. - Если переменной нет, Ansible останавливается с ошибкой
'имя' is undefined.
Последнее это защита: лучше упасть, чем молча записать в конфиг пустой пароль. Если пустое значение допустимо, пиши {{ имя | default('') }}.
Кавычки. В YAML значение, которое начинается с {{, обязательно берут в кавычки: path: "{{ item.path }}". Иначе YAML решит, что { начинает словарь, и файл не разберётся (did not find expected key). Внутри строки, где {{ не первый символ, кавычки не нужны, но безопаснее ставить.
Откуда берутся переменные:
varsв игре:vars: {notes_port: 8080};group_vars/<группа>.ymlиhost_vars/<хост>.ymlрядом с инвентарём (урок 7.4);- факты (facts): значения, собранные автоматически, например
ansible_facts['distribution_release']даёт имя релиза Ubuntu (nobleдля 24.04); - ключ
-e(extra vars) в командной строке.
Приоритет (упрощённо, от слабого к сильному): значения по умолчанию, group_vars, host_vars, vars в игре, параметр -e. Побеждает более сильный источник. -e побеждает всё, поэтому им удобно переопределять значение на один запуск.
flowchart LR
A["умолчания"] --> B["group_vars"] --> C["host_vars"] --> D["vars в игре"] --> E["-e в команде<br>сильнее всех"]
Слева самый слабый источник, справа самый сильный.
Пример: в vars игры задано notes_log_level: INFO. В шаблоне строка LOG_LEVEL={{ notes_log_level }}.
| Как запустил | Что выйдет в файле |
|---|---|
ansible-playbook config.yml |
LOG_LEVEL=INFO (из vars) |
ansible-playbook config.yml -e notes_log_level=DEBUG |
LOG_LEVEL=DEBUG (-e сильнее vars) |
Прикинь сам: Ты задал
notes_port: 8080вvars, а запустил с-e notes_port=9090. Какой порт попадёт в шаблон?
9090: ключ -e сильнее всех остальных источников. Но только на этот запуск, следующий без -e вернёт 8080.
Осторожно: -e действует только на этот запуск. Если запустить потом без него, значение вернётся к INFO, и Ansible перезапишет файл обратно. Постоянное изменение делай в файле, а не в -e.
Главное: Jinja2 подставляет значения в
{{ имя }}, нет переменной значит ошибкаis undefined; приоритет от слабого к сильному: умолчания,group_vars,host_vars,vars,-e.
Проверь понимание: ты задал
notes_port: 8080вvarsплейбука и запустил с-e notes_port=9090. Какой порт попадёт в шаблон?
Ответ
- Переменная из
-eимеет наивысший приоритет.
Теперь посмотрим, как выглядит сам файл шаблона.
Шаблоны: как выглядит файл .j2
Шаблон это обычный текстовый файл с расширением .j2 и пустыми местами. Возьмём наш templates/notes.env.j2:
STORE={{ notes_store }}
LOG_LEVEL={{ notes_log_level }}
DATABASE_URL=postgresql://notes:{{ notes_db_password }}@db:5432/notes
При значениях notes_store: postgres, notes_log_level: INFO, notes_db_password: CHANGE_ME модуль template создаст на сервере:
STORE=postgres
LOG_LEVEL=INFO
DATABASE_URL=postgresql://notes:CHANGE_ME@db:5432/notes
Слева от {{ и справа от }} всё копируется как есть. Каждая строка ключ=значение это формат файла окружения, который читает сервис notes (урок 1.8).
Кроме подстановки Jinja2 умеет циклы и условия: блоки в {% ... %}. Например, {% for s in servers %}server {{ s }};{% endfor %} выведет строку для каждого сервера. Такие блоки оставляют лишние пустые строки, и для их подавления используют знак - внутри тегов ({%- ... -%}); в модуле template Ansible включает trim_blocks (убирает перевод строки сразу после тега блока) по умолчанию. Мы пользуемся только подстановкой.
Модуль template идемпотентен: он рендерит текст, сравнивает с файлом на сервере (по контрольной сумме) и переписывает только при отличии. Совпало: ok. Отличается: changed, и вызывается handler. Если в шаблон попадёт что-то, меняющееся при каждом запуске (дата, случайное число), задача будет changed всегда: это частая причина «плейбук не идемпотентен».
Прикинь сам: В шаблон добавили строку
UPDATED={{ ansible_date_time.iso8601 }}. Что станет со вторым запуском?
Дата меняется каждый раз, поэтому файл каждый раз отличается: задача всегда changed, handler всегда срабатывает.
Главное: модуль
templateрендерит файл на твоём компьютере, сравнивает по контрольной сумме и переписывает только при отличии.
Когда файл изменился, сервису надо об этом сообщить: для этого нужны handlers.
Handlers: реакция на изменение
После смены конфига сервис нужно перезапустить, чтобы он прочитал новый файл. Но перезапускать при каждом запуске плейбука нельзя: пользователи получат обрывы. Нужно «перезапусти, только если конфиг изменился».
Handler (обработчик) это задача, которая выполняется только если её позвала (notify) другая задача, вернувшая changed. Аналогия: консьерж в доме. Ты оставляешь ему записку «если ремонтники что-то поменяли в подъезде, вызови уборку». Если ремонтники ничего не меняли, консьерж ничего не делает. Оговорка: консьерж сделает уборку один раз в конце дня, сколько бы записок ему ни оставили.
Как это устроено:
- Задача с
notify: <имя handler>отрабатывает. - Если её результат
changed, Ansible запоминает: этот handler нужно выполнить. - Остальные задачи игры продолжают идти.
- В конце игры запомненные handlers выполняются, каждый по разу.
- Если задача вернула
ok, handler не запоминается.
Три правила, на которых ломаются:
- Handler запускается один раз в конце игры, сколько бы задач его ни вызвали.
- Если задача вернула
ok(файл не менялся), handler не запускается. - Если игра упала раньше, накопленные handlers не выполняются (флаг
--force-handlersилиmeta: flush_handlersменяют это).
tasks:
- name: Конфиг
ansible.builtin.template:
src: notes.env.j2
dest: /etc/notes/notes.env
notify: Перечитать конфиг # имя handler: должно совпасть символ в символ
handlers:
- name: Перечитать конфиг
ansible.builtin.debug:
msg: "конфиг изменился"
flowchart TD
A["задача с шаблоном"] --> Q{"результат changed?"}
Q -->|"да"| N["notify: Ansible запоминает handler"]
Q -->|"нет, ok"| X["handler не запоминается"]
N --> E["конец игры: handler выполняется один раз"]
Пример: реальный прогон нашего config.yml, два раза подряд:
| Запуск | Задача с шаблоном | Handler | changed в recap |
|---|---|---|---|
| Первый (файла ещё нет) | changed |
выполнился | 1 |
| Второй (всё совпало) | ok |
не показан вовсе | 0 |
Во втором запуске в выводе нет блока RUNNING HANDLER: задача вернула ok, и записки для консьержа не было.
Прикинь сам: Три задачи изменили файлы и все вызвали один handler. Сколько раз он выполнится?
Один раз, в конце игры. Это защита от трёх перезапусков подряд.
Осторожно: Первое: имя в notify должно совпасть с name handler символ в символ, включая регистр. Если не совпало, Ansible завершает игру с The requested handler 'Reload' was not found in either the main handlers list nor in the listening handlers list. Второе: --check не мешает handler показаться в выводе, но реально он ничего не делает, потому что и задача ничего не сделала.
Главное: handler выполняется один раз в конце игры, и только если вызвавшая его задача вернула
changed; имя вnotifyсовпадает сnamehandler символ в символ.
Проверь понимание: три задачи изменили файлы и все вызвали один handler. Сколько раз он выполнится?
Ответ
Один раз, в конце игры. Это защита от трёх перезапусков подряд.
Остальные приёмы, которые встречаются в каждом плейбуке: условия, циклы и теги.
Условия, циклы, register, теги
Четыре приёма, которые встретишь в каждом плейбуке.
Условие when пропускает задачу (skipped), если выражение ложно. Пример: when: ansible_facts['os_family'] == 'Debian' (запустить только на Debian и Ubuntu). Внутри when скобки {{ }} не нужны: там уже выражение.
Цикл loop повторяет задачу для каждого элемента списка. Текущий элемент доступен как item. В задании 1 задача создаёт три каталога: вместо трёх копий одна задача с loop и таблицей значений { path, owner, group, mode }. В выводе каждая итерация видна отдельной строкой changed: [notes-vm] => (item={...}).
register сохраняет ответ модуля в переменную. В задании 2 register: dpkg_arch сохраняет ответ dpkg --print-architecture, и потом {{ dpkg_arch.stdout }} даёт его текст (amd64 или arm64). stdout это поле ответа: то, что напечатала команда.
Теги (tags: [config]) это метки на задачах. --tags config запускает только помеченные задачи, --limit хост ограничивает хосты, --list-tasks показывает задачи без запуска, --start-at-task "имя задачи" начинает с указанной задачи (удобно после падения посреди плейбука). Теги нужно ставить заранее: ограничить запуск по тегу, которого нет, нельзя.
Отдельно про command и shell: они всегда возвращают changed, потому что Ansible не знает, что сделала команда (урок 7.4). Лечится тремя способами: creates: (пропустить, если файл уже есть), changed_when: false (для команд, которые только читают, как dpkg --print-architecture), либо заменой на специализированный модуль.
Прикинь сам: Задача с
command: touch /tmp/flagна втором запуске сноваchanged. Что добавить?
args: {creates: /tmp/flag}: тогда Ansible увидит файл и пропустит задачу как ok. Лучше заменить command модулем file с state: touch.
Осторожно: changed_when: false не «отключает» задачу, а заявляет, что она ничего не меняет. Если такая команда что-то меняет, Ansible об этом не скажет.
Главное:
whenпропускает задачу,loopповторяет,registerсохраняет ответ модуля, теги ограничивают запуск;changed_when: falseзаявляет «команда только читает», а не отключает задачу.
Проверь понимание: как заставить
command: touch /tmp/flagпоказыватьokпри втором запуске?
Ответ
Добавить args: {creates: /tmp/flag}, или заменить на модуль ansible.builtin.file с state: touch и modification_time: preserve, или задать changed_when: false, если команда только читает.
Теперь заглянем под капот: что стоит за строкой changed: [notes-vm].
Что происходит на сервере во время задачи
Полезно один раз увидеть, что стоит за строкой changed: [notes-vm], потому что от этого зависит, как ты читаешь ошибки. Возьмём задачу «Файл окружения из шаблона» и пройдём её по шагам:
sequenceDiagram
participant P as твой компьютер
participant S as сервер notes-vm
P->>P: 1-2. читает плейбук, подставляет переменные в шаблон
P->>S: 3. SSH-вход как ubuntu
P->>S: 4. копирует модуль template и готовый текст
P->>S: 5. запускает модуль через sudo
Note over S: сравнивает контрольные суммы<br>разные: пишет файл, права 0640<br>одинаковые: ничего не трогает
S-->>P: 6. JSON: changed true или false
P->>P: 7. если changed, запоминает handler
Отсюда несколько следствий, которые пригодятся при поиске ошибок.
- Ошибки в шаблоне и переменных (
is undefined, кривой YAML) возникают на шагах 1 и 2, ещё на твоём компьютере: до сервера дело не доходит, и сервер остаётся нетронутым. - Ошибки входа (
UNREACHABLE,Permission denied (publickey)) возникают на шаге 3. Плейбук тут вообще ни при чём. - Ошибки прав (
are you root?,Missing sudo password) возникают на шаге 5: вход есть, а повысить права нельзя. - Ошибки модуля (нет каталога, нет группы) приходят из шага 5 внутри ответа JSON, и Ansible печатает
FAILEDс текстом.
Так, увидев ошибку, ты сначала определяешь, на каком шаге она случилась, и не ищешь причину не там. Аналогия: у посылки есть этапы «упаковали, отправили, доставили, вручили»: когда посылка не дошла, важно понять, на каком этапе она застряла. Оговорка: посылку не проверить на промежуточном этапе, а Ansible можно: ключ -v печатает больше подробностей, а -vvv показывает даже SSH-команды.
Прикинь сам: Ты видишь
is undefinedиPermission denied (publickey). На каком шаге возникла каждая ошибка?
is undefined на шагах 1-2, ещё на твоём компьютере: сервер не тронут. Permission denied (publickey) на шаге 3, это вход по SSH и к плейбуку отношения не имеет.
Осторожно: «плейбук упал» не значит «сервер в плохом состоянии». Задачи до упавшей уже выполнены и остались применёнными: Ansible не откатывает изменения. Поэтому после падения плейбук просто исправляют и запускают снова: идемпотентные задачи пройдут как ok, а работа продолжится с места ошибки.
Главное: определи, на каком шаге сломалось: шаблон и переменные, вход, права или сам модуль; Ansible не откатывает уже выполненные задачи.
Раз права появляются на шаге 5, посмотрим, где их лучше включать.
become и порядок прав: на каком уровне ставить
become: true можно поставить на трёх уровнях: на всю игру, на одну задачу или (реже) в ansible.cfg. Чем выше уровень, тем шире права. Сравни:
- name: Пример уровней
hosts: all
# become: true # вариант 1: на всю игру, все задачи от root
tasks:
- name: Читаем файл пользователя # вариант 2: root нужен не везде
ansible.builtin.command: cat /home/ubuntu/.bashrc
changed_when: false
- name: Ставим пакет # root только здесь
ansible.builtin.apt:
name: htop
state: present
become: true
Правило: если почти все задачи меняют систему (как в наших base.yml, docker.yml, config.yml), ставь become: true на игру. Если игра в основном читает и только один шаг меняет систему, ставь become на эту задачу. Это принцип наименьших привилегий (least privilege): каждому шагу столько прав, сколько ему нужно. Забытый become даёт ошибку Permission denied или are you root? (сценарий 3 ниже), лишний become ошибки не даёт, но расширяет возможный вред при опечатке.
Прикинь сам: В игре одна задача меняет систему, остальные только читают. Где ставить
become: true?
На саму задачу: каждому шагу столько прав, сколько ему нужно.
Главное:
becomeставят на игру, если почти все задачи меняют систему, и на отдельную задачу, если меняет одна; принцип наименьших привилегий.
Идемпотентность тоже нужно вписать в плейбук, вот чек-лист.
Как писать плейбук, который безопасно запускать снова
Идемпотентность не приходит сама, её нужно «вписать». Короткий чек-лист, по которому ты проверяешь каждую задачу:
- Есть ли готовый модуль?
apt,user,file,copy,template,systemd_serviceумеют проверять состояние. Если есть, берёшь его, а неcommand. - Если нужен
command, что сообщит, что работа уже сделана? Добавьcreates:(файл, который команда создаёт) илиchanged_when: false(команда только читает). - Нет ли в шаблоне того, что меняется каждый раз (дата, случайный токен)? Такие значения делают задачу
changedпри каждом запуске. - Совпадают ли имена в
notifyиhandlers? Иначе плейбук падает. - Второй запуск даёт
changed=0? Всегда запускай дважды, пока отлаживаешь. Это самая дешёвая и самая честная проверка.
Разобранный пример. Есть задача:
- name: Создать флаг
ansible.builtin.command: touch /var/lib/notes/initialized
Первый запуск: changed, файл создан. Второй: снова changed, потому что command не знает, что делает touch. Исправляем: args: {creates: /var/lib/notes/initialized}. Теперь Ansible перед запуском проверяет, есть ли файл: есть, значит задача пропускается с ok, второй запуск даёт changed=0. Ещё лучше заменить на модуль file с state: touch, тогда никакие хитрости не нужны.
Прикинь сам: Плейбук запустили дважды: в первый раз
changed=4, во второйchanged=1. Что делать?
Найти задачу, которая снова changed: чаще всего это command без creates или шаблон с датой. Пока changed не станет нулём, плейбук не идемпотентен.
Осторожно: сообщение ok не значит «Ansible проверил, что всё хорошо». Оно значит «модуль не нашёл, что менять». Если модуль неправильно определяет состояние (плохой creates), ошибка тихо остаётся.
Главное: всегда запускай плейбук дважды: второй запуск должен давать
changed=0;okзначит «менять нечего», а не «всё хорошо».
Когда плейбуков несколько, их собирают в один файл.
import_playbook и site.yml
Когда плейбуков несколько, их не запускают по одному. Файл-точка входа (принято называть site.yml) подключает остальные командой import_playbook в нужном порядке. Это правило верхнего уровня: import_playbook стоит в списке игр файла, а не внутри tasks. Пути в нём считаются от каталога самого site.yml.
- import_playbook: playbooks/base.yml # 1: пакеты, пользователь, каталоги
- import_playbook: playbooks/docker.yml # 2: Docker
- import_playbook: playbooks/config.yml # 3: конфиг (ему нужны каталог и группа notes из шага 1)
Порядок важен: третий шаг зависит от первого. Если поменять их местами, шаблон упадёт, потому что каталога /etc/notes и группы notes ещё нет.
Прикинь сам: Что случится, если в
site.ymlпоменять местамиbase.ymlиconfig.yml?
Шаблон упадёт: каталога /etc/notes и группы notes ещё нет, их создаёт base.yml.
Главное:
site.ymlподключает плейбуки командойimport_playbookв нужном порядке; порядок важен, потому что шаги зависят друг от друга.
Перед боевым запуском плейбук стоит проверить.
Проверка и безопасный запуск
Плейбук на реальной ВМ страшно запускать вслепую. Перед запуском три шага:
ansible-playbook --syntax-check: ловит опечатки в YAML и неверные ключи, никуда не подключаясь.ansible-lint: линтер (pip install ansible-lint), находит плохие практики: короткие имена модулей безansible.builtin.,commandвместо модуля, задачи без имени, относительные пути (на нашsrc: ../templates/...он ворчит, это допустимо для учебного проекта).--check --diff: сухой прогон (урок 7.4): показывает, что изменилось бы, и построчную разницу файлов. Ограничение:commandиshellв нём пропускаются, поэтому по нему нельзя судить о задачах-командах.
Затем запуск на одном хосте (--limit), потом на остальных.
Главное: три шага перед запуском:
--syntax-check,ansible-lint,--check --diff; затем один хост через--limit, потом остальные.
Проверь понимание: сухой прогон показал
changed=0, а на деле после запуска что-то изменилось. Как это возможно?
Ответ
Изменение делала задача command или shell: в --check они пропускаются, и Ansible не может предсказать их эффект. Кроме того, задача может зависеть от результата предыдущей, которая в сухом прогоне ничего не сделала.
Прикинь сам: Сухой прогон показал
changed=0, а после запуска что-то изменилось. Как так вышло?
Изменение сделала задача command или shell: в --check они пропускаются. Или шаг зависел от предыдущего, который в сухом прогоне ничего не сделал.
Когда базовое понятно, добавим к шаблонам фильтры.
Фильтры Jinja2: как подправить значение по дороге в шаблон
Значение переменной не всегда лежит в нужном виде: список надо склеить в одну строку, пустое значение заменить запасным, регистр поменять. Писать для этого отдельные переменные на каждый случай неудобно. Фильтр (filter) это маленькая функция, которая обрабатывает значение прямо в месте подстановки. Пишется через вертикальную черту: {{ значение | фильтр }}.
Конвейер на кухне: помидор идёт по ленте, его моют, режут, кладут в салат. Каждая станция делает одно действие, и станции можно ставить цепочкой. Оговорка: на ленте станции порядок важен, и в Jinja2 тоже: a | upper | default('x') и a | default('x') | upper дают разное, если a не задана.
Значение слева от | передаётся фильтру справа, результат подставляется в текст. Фильтры можно соединять цепочкой слева направо. Самые частые:
| Фильтр | Что делает | Пример и результат |
|---|---|---|
default('x') |
подставляет запасное, если переменной нет | {{ port \| default(8080) }} даёт 8080, если port не задана |
upper / lower |
регистр | {{ 'info' \| upper }} даёт INFO |
join(', ') |
склеивает список в строку | {{ ['a','b','c'] \| join(', ') }} даёт a, b, c |
length |
длина списка или строки | {{ ['a','b','c'] \| length }} даёт 3 |
int |
превращает строку в число | {{ '8080' \| int }} даёт 8080 как число |
Пример: Допустим, в переменных есть notes_hosts: [db1, db2], а notes_log_level не задана. Строки шаблона:
DB_HOSTS={{ notes_hosts | join(',') }}
LOG_LEVEL={{ notes_log_level | default('INFO') | upper }}
DB_COUNT={{ notes_hosts | length }}
Разбор. Первая строка: список [db1, db2] склеен через запятую, получилось db1,db2. Вторая: переменной нет, поэтому default подставил INFO, а upper оставил его заглавными. Третья: в списке два элемента. Итоговый файл на сервере:
DB_HOSTS=db1,db2
LOG_LEVEL=INFO
DB_COUNT=2
Прикинь сам: Что окажется в файле после строки
PORTS={{ [80, 443] | join(' ') }}?
PORTS=80 443: список склеен через пробел.
Осторожно: Вертикальная черта здесь не pipe из bash (урок 1.2). Внешне похоже: «передали дальше». Но в bash между программами идёт поток текста, а в Jinja2 значение обрабатывается функцией внутри Ansible. Ещё путают default с пустотой: default('x') срабатывает, если переменная не определена, а пустую строку считает значением. Для пустой строки пишут default('x', true).
Главное: фильтр обрабатывает значение через
|прямо в месте подстановки;defaultсрабатывает, только если переменная не определена, а не когда она пустая.
Проверь понимание: что окажется в файле после строки
PORTS={{ [80, 443] | join(' ') }}?
Ответ
PORTS=80 443. Список из двух чисел склеен через пробел (числа превращаются в текст при склейке).
Фильтры меняют значение, а условия решают, нужна ли задача вообще.
Условия when: как задача решает, выполняться ли ей
Один и тот же плейбук запускают на разных серверах: на одном Ubuntu, на другом Rocky, на третьем Docker уже стоит. Без условий пришлось бы писать отдельные плейбуки под каждый случай. when позволяет одной задаче самой решать, нужна ли она на этом хосте.
Светофор на переходе: шаг вперёд делаешь, только если горит зелёный. Оговорка: светофор смотрит на свет прямо сейчас, а when смотрит на значения, которые Ansible знал в начале игры (факты) или получил от прошлых задач.
К задаче дописывается строка when: выражение. Ansible считает выражение для каждого хоста отдельно. Истина: задача выполняется. Ложь: хост получает skipped и идёт дальше. Внутри when не нужны двойные скобки, там и так выражение. Условия склеиваются словами and, or, not; несколько строк списком считаются как and.
Пример: Задача ставит Docker только на Ubuntu, у которой известно имя релиза:
- name: Подключить репозиторий Docker
ansible.builtin.copy:
content: "deb ... {{ ansible_facts['distribution_release'] }} stable"
dest: /etc/apt/sources.list.d/docker.list
when:
- ansible_facts['distribution'] == 'Ubuntu'
- ansible_facts['distribution_release'] == 'noble'
Допустим, в инвентаре две машины: notes-vm (Ubuntu 24.04, релиз noble) и old-vm (Ubuntu 22.04, релиз jammy). Результат:
TASK [Подключить репозиторий Docker] *******************************************
changed: [notes-vm]
skipped: [old-vm]
Разбор. Для notes-vm оба условия истинны (Ubuntu и noble), задача сработала. Для old-vm первое условие истинно, второе ложно (jammy не равно noble), значит skipped. В итоговой таблице skipped=1 появится у old-vm.
Прикинь сам: У задачи
when: ansible_facts['memtotal_mb'] < 1024, на хосте 2048 МБ. Что напечатает Ansible?
skipped: [хост]: выражение ложно. Это не ошибка.
Осторожно: skipped не ошибка и не «сломалось». Это честное «условие не выполнено». Второе заблуждение: писать when: "{{ условие }}". В старых версиях это работало с предупреждением, в новых (ansible-core 2.19 и выше) может быть ошибкой: скобки там лишние, пиши без них.
Главное:
whenсчитается для каждого хоста отдельно, внутри него скобки{{ }}не нужны;skippedозначает «условие не выполнено».
Проверь понимание: задача имеет
when: ansible_facts['memtotal_mb'] < 1024. На хосте 2048 МБ памяти. Что напечатает Ansible?
Ответ
skipped: [хост]. Выражение 2048 < 1024 ложно, поэтому задача пропущена.
Осталось защитить пароли, которые участвуют в шаблонах.
Секреты в плейбуке: Vault и no_log
Плейбук лежит в git, а пароль БД в git класть нельзя (урок 3.4). Но и без пароля конфиг не создать. Нужен способ хранить секрет в репозитории так, чтобы прочитать его мог только тот, у кого есть ключ.
Письмо в запечатанном сейфе: сейф можно хранить в общей комнате, открыть его может только владелец кода. Оговорка: если код от сейфа записан на стикере на сейфе (пароль Vault лежит в том же репозитории), защиты нет.
Ansible Vault это встроенный механизм шифрования. Команда ansible-vault encrypt_string 'секрет' --name notes_db_password печатает блок с зашифрованным значением, который вставляют в group_vars. При запуске плейбук получает пароль Vault отдельно (--ask-vault-pass запросит его с клавиатуры, --vault-password-file берёт из файла, которого нет в git), расшифровывает значение в памяти и подставляет в шаблон. В репозитории лежит только шифртекст.
Второй риск: Ansible печатает в вывод параметры задач, и пароль из шаблона может попасть в лог. Параметр no_log: true у задачи запрещает печатать её параметры и результат, вместо них будет censored.
Пример: Задача с шаблоном, в котором есть пароль БД:
- name: Файл окружения
ansible.builtin.template:
src: notes.env.j2
dest: /etc/notes/notes.env
mode: "0640"
no_log: true # не печатать содержимое и diff: там пароль
Без no_log режим --diff напечатал бы файл с паролем в терминал и в лог CI. С no_log при ошибке ты увидишь censored вместо подробностей, и отлаживать придётся осторожнее: на время отладки no_log временно отключают на тестовом стенде.
Прикинь сам: Пароль зашифрован Vault и лежит в git, а пароль от Vault записан в
vault.txtрядом. Что это даёт?
Почти ничего: кто получил репозиторий, получил и ключ. Файл с паролем Vault хранят вне репозитория.
Осторожно: Vault защищает секрет в репозитории, но не на сервере: в /etc/notes/notes.env пароль лежит открытым текстом, и защищают его правами 0640 (урок 1.3). Второе: no_log прячет секрет из вывода Ansible, но не из state Terraform и не из истории shell.
Главное: Vault защищает секрет в репозитории,
no_log: trueпрячет его из вывода; на сервере пароль лежит открытым текстом и защищается правами0640.
Проверь понимание: пароль зашифрован Vault и лежит в git, но пароль от Vault записан в
vault.txtрядом. Что это даёт?
Ответ
Почти ничего: тот, кто получил репозиторий, получил и ключ. Файл с паролем Vault должен лежать вне репозитория (в .gitignore или в менеджере секретов) и передаваться при запуске.
Последнее: как запустить плейбук не целиком, а частично.
Как ограничить запуск: limit, tags и start-at-task
Плейбук на 10 серверов не всегда нужно гнать целиком. Иногда надо обновить конфиг на одной машине, иногда перезапустить с упавшего шага, иногда просто посмотреть список шагов.
Обход охранника: можно пройти весь объект, можно только один этаж, можно только проверить пожарные выходы. Маршрут один, выбираешь часть. Оговорка: если пропустишь шаг, от которого зависят следующие, получишь ошибку.
Три независимых «фильтра», их можно комбинировать:
| Флаг | Что ограничивает | Пример |
|---|---|---|
--limit |
хосты | --limit notes-vm запускает только на этом хосте |
--tags |
задачи | --tags config выполняет только помеченные tags: [config] |
--start-at-task |
начало | --start-at-task "Файл окружения из шаблона" начинает с этой задачи |
--list-tasks |
ничего не запускает | печатает список задач, которые выполнились бы |
Пример: В инвентаре 10 хостов, плейбук site.yml с тегом config у задачи шаблона. Нужно обновить конфиг только на notes-vm:
ansible-playbook site.yml --limit notes-vm --tags config --check --diff
Разбор. --limit notes-vm сужает хосты с десяти до одного. --tags config оставляет только задачи с тегом config. --check --diff просит сухой прогон (урок 7.4): показать изменения, не применяя. Убедившись, что ожидаемое совпало, убираешь --check --diff и запускаешь по-настоящему. Так из 10 хостов x N задач запускается одна.
Прикинь сам: Нужно узнать, какие задачи пойдут на
notes-vm, ничего не запуская. Какие флаги?
ansible-playbook site.yml --limit notes-vm --list-tasks: флаг --list-tasks только печатает задачи.
Осторожно: Тег не ограничивает хосты, а --limit не ограничивает задачи: это разные оси. И ещё: задача без тегов при --tags config не выполнится, даже если от неё зависит следующая. Поэтому теги расставляют осмысленно и после --tags проверяют результат.
Главное:
--limitограничивает хосты,--tagsзадачи,--start-at-taskначало,--list-tasksничего не запускает; это разные оси.
Проверь понимание: ты хочешь узнать, какие задачи пойдут на
notes-vm, но ничего не запускать. Какие флаги?
Ответ
ansible-playbook site.yml --limit notes-vm --list-tasks. Флаг --list-tasks только печатает задачи и ничего не выполняет.
Соответствие AWS
| Что делаем в Ansible | Аналог в AWS | Замечание |
|---|---|---|
| Плейбук по SSH | AWS Systems Manager Run Command, State Manager | SSM ходит через агента, без открытого порта 22 |
| Группа хостов в inventory | Теги EC2 и Resource Groups | динамический inventory строится по тегам |
Шаблон .j2 с параметрами |
User data (cloud-init) с переменными | user data срабатывает при первом старте, Ansible можно запускать повторно |
| Handler и перезапуск | Перезапуск через SSM или замена инстанса в Auto Scaling | при immutable-подходе сервер меняют, а не правят |
Практика
Везде дальше рабочий каталог ~/notes/infra/ansible/, а inventory.yml и ansible.cfg из урока 7.4 уже настроены. Ansible ставился в venv или pipx: версия ansible-core 2.21.4 (в новом терминале активируй venv: source ~/.venvs/ansible/bin/activate). Целевая машина это ВМ notes-vm (облако или Multipass, Ubuntu 24.04 или 26.04). Плейбуки написаны для hosts: all, чтобы не зависеть от имени группы в твоём инвентаре.
cd ~/notes/infra/ansible
ansible --version | head -1
ansible all -m ansible.builtin.ping
mkdir -p playbooks templates
Разбор. head -1 оставляет первую строку. ansible all -m ...ping проверяет вход на все хосты. mkdir -p создаёт каталоги для плейбуков и шаблонов (-p: без ошибки, если они уже есть).
ansible [core 2.21.4]
notes-vm | SUCCESS => {
"ansible_facts": {
"discovered_interpreter_python": "/usr/bin/python3.12"
},
"changed": false,
"ping": "pong"
}
Как читать вывод: первая строка версия, дальше SUCCESS и pong значит «вход и Python работают». Если видишь UNREACHABLE, вернись в урок 7.4.
Задание 1. Первый плейбук: база сервера и идемпотентность
Цель: описать плейбуком пакеты, системного пользователя notes и каталоги «Заметок» и убедиться, что повторный запуск ничего не меняет.
Предскажи: что покажет PLAY RECAP при первом запуске и при втором? Сколько задач будут changed во второй раз?
Ответ
Первый запуск: changed равен числу задач, которые что-то создали (пакеты, пользователь, каталоги). Второй: changed=0, все задачи ok. Если во втором запуске changed не ноль, в плейбуке есть неидемпотентная задача.
Шаги:
- Создай
playbooks/base.yml:
- name: Базовая настройка сервера для Заметок
hosts: all
become: true # без этого apt и useradd упадут: нужен root
tasks:
- name: Индекс пакетов свежий
ansible.builtin.apt:
update_cache: true
cache_valid_time: 3600 # не чаще раза в час
- name: Базовые пакеты установлены
ansible.builtin.apt:
name:
- ca-certificates
- curl
- jq
state: present
- name: Системный пользователь notes существует
ansible.builtin.user:
name: notes
system: true
shell: /usr/sbin/nologin
create_home: false
- name: Каталоги Заметок созданы
ansible.builtin.file:
path: "{{ item.path }}"
state: directory
owner: "{{ item.owner }}"
group: "{{ item.group }}"
mode: "{{ item.mode }}"
loop:
- { path: /opt/notes, owner: root, group: root, mode: "0755" }
- { path: /var/lib/notes, owner: notes, group: notes, mode: "0750" }
- { path: /etc/notes, owner: root, group: notes, mode: "0750" }
Разбор по задачам. Индекс пакетов: update_cache обновляет список доступных пакетов (аналог apt update), cache_valid_time: 3600 не обновлять, если обновляли меньше часа (3600 секунд) назад: без этого каждый запуск был бы changed. Пакеты: name со списком ставит все три за один вызов apt. ca-certificates нужен для HTTPS, curl для запросов, jq для разбора JSON. Пользователь: system: true создаёт системную учётную запись (для служб, а не для людей), shell: /usr/sbin/nologin запрещает вход в неё, create_home: false не создаёт домашний каталог. Каталоги: loop берёт три строки из списка, и на каждой item.path, item.owner и остальные поля подставляются в параметры. Запись { path: ..., owner: ... } это словарь в одну строку. Режим "0750" в кавычках, иначе YAML прочитает его как число.
- Проверь синтаксис и запусти дважды:
ansible-playbook playbooks/base.yml --syntax-check
ansible-playbook playbooks/base.yml
ansible-playbook playbooks/base.yml
Что должно получиться: первый запуск (фрагмент; changed зависит от того, что уже было на ВМ):
TASK [Каталоги Заметок созданы] ************************************************
changed: [notes-vm] => (item={'path': '/opt/notes', 'owner': 'root', 'group': 'root', 'mode': '0755'})
changed: [notes-vm] => (item={'path': '/var/lib/notes', 'owner': 'notes', 'group': 'notes', 'mode': '0750'})
changed: [notes-vm] => (item={'path': '/etc/notes', 'owner': 'root', 'group': 'notes', 'mode': '0750'})
PLAY RECAP *********************************************************************
notes-vm : ok=5 changed=3 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
В конце второго запуска:
PLAY RECAP *********************************************************************
notes-vm : ok=5 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
ИИ: если плейбук упал с непонятной ошибкой, вставь нейросети текст ошибки и задачу, на которой он остановился (без паролей): она подскажет причину, а верить ей до первого запуска
--check --diffне нужно.
Как читать вывод: ok=5 это сбор фактов плюс четыре задачи. Во втором запуске все они ok, changed=0: идемпотентность подтверждена. Если на твоей ВМ curl и jq уже стояли, в первом запуске changed будет меньше.
Объясни себе:
- Почему у
aptстоитcache_valid_time, и что было бы без него на втором запуске? - Что делает
loopи откуда берётсяitem? - Почему пользователь
notesсоздан без домашнего каталога и сnologin?
Типичные ошибки:
Task failed: Missing sudo password(в старых версияхFAILED! => {"msg": "Missing sudo password"}): у пользователя ВМ нетNOPASSWDв sudo, аbecomeвключён. Настрой sudo без пароля или запусти с--ask-become-pass.E: Could not get lock /var/lib/dpkg/lock-frontend: на свежей ВМ фоном идётunattended-upgrades(автообновления). Подожди пару минут и повтори.ERROR! We were unable to read either as JSON nor YAML: отступы пробелами, табы в YAML запрещены.
Задание 2. Docker из официального apt-репозитория
Цель: поставить Docker Engine на ВМ так, как это делают в проде: ключ, репозиторий с проверкой подписи, пакеты, автозапуск.
Что здесь нового. Пакеты Docker Ubuntu из своих репозиториев не даёт (или даёт устаревшие), поэтому подключаем репозиторий Docker: это адрес, откуда apt берёт пакеты. Чтобы apt доверял ему, скачиваем GPG-ключ (файл, которым Docker подписывает пакеты: apt проверяет подпись и отбрасывает подделки). Репозиторий подключаем модулем deb822_repository: он пишет файл /etc/apt/sources.list.d/docker.sources в современном формате. (Старый модуль apt_repository помечен устаревшим и будет удалён в ansible-core 2.25.)
Предскажи: какие факты нужны, чтобы собрать описание репозитория для Ubuntu 24.04 (noble) и 26.04 одним плейбуком?
Ответ
Архитектура процессора (amd64 или arm64) и кодовое имя релиза. Оба даёт сбор фактов: ansible_facts['architecture'] (значения x86_64 и aarch64, поэтому нужна таблица соответствия) и ansible_facts['distribution_release']. Здесь архитектуру берём готовой командой dpkg --print-architecture.
Шаги:
- Создай
playbooks/docker.yml:
- name: Docker Engine на сервере
hosts: all
become: true
tasks:
- name: Каталог для ключей apt
ansible.builtin.file:
path: /etc/apt/keyrings
state: directory
mode: "0755"
- name: Ключ репозитория Docker скачан
ansible.builtin.get_url:
url: https://download.docker.com/linux/ubuntu/gpg
dest: /etc/apt/keyrings/docker.asc
mode: "0644"
- name: Пакет python3-debian для модуля deb822_repository
ansible.builtin.apt:
name: python3-debian
state: present
- name: Архитектура dpkg
ansible.builtin.command: dpkg --print-architecture
register: dpkg_arch
changed_when: false # команда только читает, изменений нет
- name: Репозиторий Docker подключён
ansible.builtin.deb822_repository:
name: docker
types: deb
uris: https://download.docker.com/linux/ubuntu
suites: "{{ ansible_facts['distribution_release'] }}"
components: stable
architectures: "{{ dpkg_arch.stdout }}"
signed_by: /etc/apt/keyrings/docker.asc
state: present
enabled: true
- name: Пакеты Docker установлены
ansible.builtin.apt:
name:
- docker-ce
- docker-ce-cli
- containerd.io
- docker-compose-plugin
state: present
update_cache: true
notify: Перезапустить Docker
- name: Docker включён и запущен
ansible.builtin.systemd_service:
name: docker
state: started
enabled: true
handlers:
- name: Перезапустить Docker
ansible.builtin.systemd_service:
name: docker
state: restarted
Разбор. /etc/apt/keyrings/ стандартное место для ключей сторонних репозиториев. get_url скачивает файл по адресу. python3-debian нужен самому модулю deb822_repository на целевой машине (модули это Python-программы, им нужны библиотеки). register: dpkg_arch сохраняет вывод команды в переменную, changed_when: false объясняет Ansible, что команда ничего не меняет. В deb822_repository: suites это кодовое имя релиза (noble), signed_by указывает apt, каким ключом проверять именно этот репозиторий. notify у установки пакетов вызывает handler «Перезапустить Docker», и он выполнится в конце игры, только если пакеты реально ставились. systemd_service с state: started и enabled: true запускает Docker сейчас и включает его автозапуск при загрузке (урок 1.8).
- Запусти два раза и проверь на ВМ:
ansible-playbook playbooks/docker.yml
ansible-playbook playbooks/docker.yml
ansible all -m ansible.builtin.command -a "docker compose version" --become
Что должно получиться: второй запуск с changed=0, в конце версия плагина Compose. Число версии зависит от даты установки (версия Docker не закреплена):
notes-vm | CHANGED | rc=0 >>
Docker Compose version v<версия>
Как читать вывод: CHANGED | rc=0 для command нормально: command всегда пишет CHANGED, а rc=0 значит, что команда завершилась успешно (урок 7.4). Если Docker не запустился, здесь будет ошибка.
Объясни себе:
- Зачем
changed_when: falseу задачи сdpkg, и что покажет второй запуск без него? - В каком порядке выполнятся задача установки и handler, и когда именно?
- Почему ключ лежит в
/etc/apt/keyrings/, а репозиторий ссылается на него черезsigned_by?
Типичные ошибки:
E: The repository ... does not have a Release file: у Docker может не быть репозитория для только что вышедшего релиза Ubuntu. Проверьcurl -I https://download.docker.com/linux/ubuntu/dists/<релиз>/Release; при 404 подставь предыдущий LTS вsuites(актуальную поддержку смотри на странице Docker).The task includes an option with an undefined variable. The error was: 'dpkg_arch' is undefined: задача сregisterне выполнилась раньше или переименована: имена вregisterи в шаблоне должны совпадать.Could not resolve host: download.docker.com: у ВМ нет выхода в интернет или DNS. Проверьdig download.docker.com(урок 2.3).[DEPRECATION WARNING]: ansible.builtin.apt_repository has been deprecated: так предупреждает старый модуль, если взять его из чужих примеров. Замени наdeb822_repository, как здесь.
Задание 3. Шаблон Jinja2 и handler на конфиг
Цель: сгенерировать /etc/notes/notes.env из шаблона, выдать права 640 root:notes и вызвать handler только при реальном изменении.
Предскажи: ты меняешь в плейбуке только notes_log_level с INFO на DEBUG и запускаешь. Что покажет задача с шаблоном и выполнится ли handler? А если запустить ещё раз?
Ответ
Первый запуск: задача changed, handler выполнится. Второй: задача ok, handler пропущен, потому что нечего перечитывать.
Шаги:
- Создай
templates/notes.env.j2:
# Файл создан Ansible, не правь руками: изменения перезапишутся
STORE={{ notes_store }}
HOST=127.0.0.1
PORT=8080
LOG_LEVEL={{ notes_log_level }}
DATABASE_URL=postgresql://notes:{{ notes_db_password }}@db:5432/notes
- Создай
playbooks/config.yml. Пароль здесь заглушкаCHANGE_ME; шифрование паролей это тема следующего урока:
- name: Конфиг Заметок
hosts: all
become: true
vars:
notes_store: postgres
notes_log_level: INFO
notes_db_password: CHANGE_ME
tasks:
- name: Файл окружения из шаблона
ansible.builtin.template:
src: ../templates/notes.env.j2
dest: /etc/notes/notes.env
owner: root
group: notes
mode: "0640"
notify: Сообщить о смене конфига
tags: [config]
handlers:
- name: Сообщить о смене конфига
ansible.builtin.debug:
msg: "Конфиг изменился, приложению нужен перезапуск"
Разбор. vars задаёт три переменные, которые подставятся в шаблон. src путь к шаблону (.. значит «на уровень выше каталога playbooks/»), dest куда положить готовый файл, owner, group, mode владелец и права. Режим 0640: владелец root читает и пишет, группа notes читает, остальные ничего (урок 1.3). Так сервис notes прочитает пароль, а посторонние пользователи нет. debug это модуль, который просто печатает сообщение: в учебном плейбуке он показывает, что handler сработал (в боевом здесь был бы перезапуск сервиса).
- Запусти, потом покажи разницу и права:
ansible-playbook playbooks/config.yml
ansible-playbook playbooks/config.yml -e notes_log_level=DEBUG --check --diff
ansible all -m ansible.builtin.command -a "stat -c '%a %U:%G %n' /etc/notes/notes.env" --become
Разбор. -e notes_log_level=DEBUG подменяет переменную только для этого запуска. stat -c '%a %U:%G %n' печатает права числом, владельца, группу и имя файла.
Что должно получиться: --diff показывает одну изменённую строку, права правильные (путь в строке +++ after у тебя будет другой, это временный файл):
--- before: /etc/notes/notes.env
+++ after: /home/ubuntu/.ansible/tmp/.../notes.env.j2
@@ -2,5 +2,5 @@
STORE=postgres
HOST=127.0.0.1
PORT=8080
-LOG_LEVEL=INFO
+LOG_LEVEL=DEBUG
DATABASE_URL=postgresql://notes:CHANGE_ME@db:5432/notes
changed: [notes-vm]
...
notes-vm | CHANGED | rc=0 >>
640 root:notes /etc/notes/notes.env
ИИ: шаблон
.j2можно попросить написать по готовому файлу, но пароли из него убери заранее и сверь список переменных с твоимиvars.
Как читать вывод: - строка, которая исчезнет, + строка, которая появится, строки без знака остались как были. @@ -2,5 +2,5 @@ значит «показаны 5 строк, начиная со второй». Заметь, что changed и вызванный handler видны даже при --check: Ansible показывает, что было бы, файл при этом не меняется. Запусти ansible-playbook playbooks/config.yml без -e ещё раз: задача будет ok, блока RUNNING HANDLER не будет.
Объясни себе:
- Где рендерится шаблон: на ВМ или на твоей машине?
- Почему
-e notes_log_level=DEBUGпобедил значение изvars? - Почему файл с паролем нельзя оставить
644? (урок 1.3)
Типичные ошибки:
Error while resolving value for 'notes_store': 'notes_store' is undefined(илиAnsibleUndefinedVariable): переменная не определена ни вvars, ни вgroup_vars. Определи её или задай значение по умолчанию{{ notes_store | default('postgres') }}.Could not find or access '../templates/notes.env.j2': путьsrcсчитается от каталога плейбука: проверь, что запускаешь изinfra/ansible/и шаблон лежит вtemplates/.did not find expected key while parsing a block mapping: значение с{{в начале не взято в кавычки.
Задание 4. Шаг проекта: site.yml собирает всё
Цель: оформить infra/ansible/site.yml точкой входа, которая подключает три плейбука по порядку, и получить changed=0 при втором запуске на настроенной ВМ.
Предскажи: что будет, если site.yml подключит config.yml раньше base.yml?
Ответ
Задача шаблона упадёт: каталога /etc/notes и группы notes ещё нет, template вернёт ошибку про несуществующую группу или каталог. Порядок import_playbook задаёт порядок выполнения.
Шаги:
- Создай
site.yml:
# Точка входа: порядок важен, каждый шаг опирается на предыдущий
- import_playbook: playbooks/base.yml
- import_playbook: playbooks/docker.yml
- import_playbook: playbooks/config.yml
- Проверь, сделай сухой прогон и запусти дважды. Затем ограничь запуск одним тегом:
ansible-playbook site.yml --syntax-check
ansible-playbook site.yml --check --diff
ansible-playbook site.yml
ansible-playbook site.yml
ansible-playbook site.yml --tags config
- Зафиксируй в git:
cd ~/notes
git add infra/ansible
git commit -m "ansible: site.yml, playbooks base/docker/config, шаблон notes.env"
Что должно получиться: во втором полном запуске везде changed=0; с --tags config выполняется только задача шаблона (и сбор фактов):
PLAY RECAP *********************************************************************
notes-vm : ok=15 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
Как читать вывод: число ok зависит от количества задач и фактов (у тебя может отличаться), важен changed=0. Сухой прогон --check на чистой ВМ может упасть на шагах, которые зависят от ещё не сделанных (например, config.yml без каталога): это ограничение check-режима, а не ошибка плейбука. Список задач под тегом можно посмотреть без запуска: ansible-playbook site.yml --tags config --list-tasks.
Объясни себе:
- Чем
import_playbookотличается от трёх запусков вручную? - Что даёт
--tags, и почему теги нужно ставить заранее?
Типичные ошибки:
ERROR! 'import_playbook' is not a valid attribute for a Play:import_playbookзаписан внутри спискаtasks:, а должен стоять на верхнем уровне файла.ERROR! Unable to retrieve file contents. Could not find or access '.../playbooks/base.yml': неверный путь: пути вimport_playbookсчитаются от каталогаsite.yml.UNREACHABLE!: изменился IP ВМ после пересоздания: обновиNOTES_VM_IPилиinventory.yml(урок 7.4).
Сломай и почини
Скрипта поломки для этого урока нет: ты ломаешь плейбук руками. Каждый сценарий это маленький отдельный плейбук в каталоге /tmp/b5/, чтобы не портить рабочие файлы. Подготовка:
mkdir -p /tmp/b5 && cd /tmp/b5
cp ~/notes/infra/ansible/ansible.cfg ~/notes/infra/ansible/inventory.yml .
Симптом
Четыре картины:
- второй запуск показывает
changed=1на одной и той же задаче; - запуск падает с
'foo' is undefined; - задача падает с
Permission deniedилиare you root?; - конфиг изменился (или должен был), а handler не отработал, либо игра падает с
The requested handler ... was not found.
Воспроизведи по очереди:
# 1. command без creates: всегда changed
cat > s1.yml <<'EOF'
- name: сценарий 1
hosts: all
tasks:
- name: создать флаг
ansible.builtin.command: touch /tmp/flag-b5
EOF
ansible-playbook s1.yml; ansible-playbook s1.yml
# 2. переменная не определена
cat > s2.yml <<'EOF'
- name: сценарий 2
hosts: all
tasks:
- name: печать
ansible.builtin.debug:
msg: "{{ foo }}"
EOF
ansible-playbook s2.yml
# 3. забыт become: apt без прав root
cat > s3.yml <<'EOF'
- name: сценарий 3
hosts: all
tasks:
- name: пакет
ansible.builtin.apt:
name: htop
state: present
EOF
ansible-playbook s3.yml
# 4. имя в notify не совпадает с именем handler
cat > s4.yml <<'EOF'
- name: сценарий 4
hosts: all
become: true
tasks:
- name: конфиг
ansible.builtin.copy:
content: "x"
dest: /tmp/cfg-b5
notify: Reload
handlers:
- name: reload
ansible.builtin.debug:
msg: "сработал"
EOF
ansible-playbook s4.yml
Гипотезы
Для каждого симптома выпиши по 2 причины до просмотра разбора. Подумай: что в задаче не идемпотентно; где определяется переменная; на каком уровне стоит become; как называется handler и вызывался ли он.
Проверки
Запускай ansible-playbook файл.yml --check --diff и -v: они показывают, что меняется и что вернул модуль. Для переменной поищи её определение: grep -rn foo .. Для handler сравни две строки: notify: и name: в блоке handlers.
Исправление
Разбор сценариев
changed=1каждый раз: в плейбукеcommandбезcreates,removesилиchanged_when. В recap второго запуска будетchanged=1. Замени на модуль (fileсstate: touch,copy,get_url) или добавьargs: {creates: /tmp/flag-b5}. Проверка: второй запуск даётchanged=0.'foo' is undefined: текстError while resolving value for 'msg': 'foo' is undefined. Переменная не задана или опечатка в имени. Ищиgrep -rn foo ., определи вvarsилиgroup_vars, либо задай{{ foo | default('значение') }}, если пустое допустимо.- Забыт
become: true: текстE: Could not open lock file /var/lib/dpkg/lock-frontend - open (13: Permission denied)иE: Unable to acquire the dpkg frontend lock (/var/lib/dpkg/lock-frontend), are you root?. Модульapt,userили запись в/etcвыполняется от обычного пользователя. Добавьbecome: trueв игру или в задачу. - Имя в
notify(Reload) не совпадает сnamehandler (reload), регистр важен. Текст:The requested handler 'Reload' was not found in either the main handlers list nor in the listening handlers list. Выровняй имена. Если имя верное, а handler всё равно не сработал, значит задача вернулаok(файл уже совпадал) или игра упала до конца: тогда добавьmeta: flush_handlersсразу после нужной задачи.
Убери за собой: rm -rf /tmp/b5 и на ВМ ansible all -m ansible.builtin.file -a "path=/tmp/flag-b5 state=absent".
ИИ в помощь
Нейросеть хорошо пишет шаблоны .j2 и объясняет вывод PLAY RECAP, но не видит твой сервер, поэтому перепроверяй имена модулей и параметры. Общие правила работы с ней: ИИ-помощник.
Задача: найти, почему плейбук неидемпотентен.
Мой плейбук на втором запуске показывает changed=2. Вот задачи: <вставь YAML без секретов> и вывод PLAY RECAP. Найди задачи, которые всегда changed, и предложи исправление: готовый модуль или creates, или changed_when.
Проверь ответ: сам запусти исправленный плейбук дважды: второй запуск должен дать changed=0. Типичная ошибка: нейросеть ставит changed_when: false на задачу, которая на самом деле меняет систему.
Задача: написать шаблон .j2 по готовому файлу настроек.
Вот файл /etc/notes/notes.env: <вставь без реальных паролей>. Сделай из него шаблон notes.env.j2 с переменными в двойных фигурных скобках и перечисли переменные, которые нужно задать. Пароль не вписывай, он придёт из Vault.
Проверь ответ: сверь список переменных с vars и group_vars: любой пропуск даст is undefined. Типичная ошибка: она оставляет пароль в шаблоне открытым текстом.
Задача: разобрать ошибку handler.
Плейбук падает с The requested handler was not found. Вот задача с notify и блок handlers: <вставь>. Объясни причину.
Проверь ответ: имя в notify и name у handler должны совпасть символ в символ, включая регистр. Типичная ошибка: нейросеть советует переименовать модуль вместо исправления имени.
Словарик урока
| Термин | Простыми словами |
|---|---|
| Фильтр Jinja2 (filter) | Функция в {{ значение \| фильтр }}, которая обрабатывает значение: default, upper, join, length |
when |
Условие у задачи: ложь даёт skipped, истина выполняет задачу |
skipped |
Задача пропущена, потому что условие when ложно (не ошибка) |
| Ansible Vault | Встроенное шифрование значений, чтобы хранить секреты в git |
no_log |
Параметр задачи: не печатать её параметры и результат, чтобы секрет не попал в лог |
--limit, --tags, --start-at-task |
Запустить плейбук только на части хостов, только помеченные задачи или с нужной задачи |
| Плейбук (playbook) | YAML-файл со списком игр: инструкция, что и на каких серверах сделать |
| Игра (play) | Часть плейбука: группа хостов плюс список задач для неё |
| Задача (task) | Один вызов модуля с параметрами |
PLAY RECAP |
Итоговая таблица запуска: ok, changed, failed по каждому хосту |
| Переменная (variable) | Именованное значение, которое подставляется в задачи и шаблоны |
| Jinja2 | Шаблонизатор: подставляет значения в {{ ... }} и умеет условия и циклы |
Шаблон (.j2) |
Текстовый файл с пустыми местами, из которого Ansible делает готовый конфиг |
| Рендер (render) | Сборка готового текста из шаблона и значений переменных |
| Фильтр Jinja2 | Функция через \|, например default('x') |
| Handler | Задача-обработчик, которая срабатывает по notify, только если задача изменила что-то |
notify |
«Позови этот handler, если я вернула changed» |
register |
Сохранить ответ модуля в переменную |
when |
Условие, при ложности которого задача пропускается (skipped) |
loop / item |
Повторить задачу для каждого элемента списка; item текущий элемент |
| Тег (tag) | Метка на задаче, чтобы запускать только помеченные (--tags) |
import_playbook |
Подключение другого плейбука по порядку из site.yml |
changed_when |
Явно сказать Ansible, считать ли задачу изменившей что-то |
--limit |
Ограничить запуск указанными хостами |
ansible-lint |
Программа проверки плейбука на плохие практики |
| GPG-ключ репозитория | Файл, которым apt проверяет подпись пакетов |
-e (extra vars) |
Значение переменной из командной строки, сильнее всех остальных источников |
Вопросы с собеседований
Раздел для повторения: ответь вслух, потом открой ответ. Короткие вопросы с пометкой [на скорость] тренируй на время: ответ за 30 секунд.
1. [junior] [часто] Ты запускаешь плейбук второй раз, и он показывает changed=3. Твои действия?
Ответ
Смотрю, какие именно задачи changed. Обычно это command или shell без creates, removes или changed_when, либо шаблон с меняющимся содержимым (метка времени внутри). Заменяю на модуль или добавляю условия, проверяю --check --diff.
Что хотят услышать: идемпотентность как критерий качества, поиск по PLAY RECAP, --diff, changed_when.
Красный флаг: «так и должно быть» или «просто игнорирую».
2. [junior] [часто] В плейбуке ты поменял конфиг nginx, но сервис не перезапустился. Почему?
Ответ
Проверю три вещи: есть ли notify у задачи, совпадает ли имя с name handler, и вернула ли задача changed. Если файл уже был таким, handler не вызывается. Ещё смотрю, не упал ли play до конца.
Что хотят услышать: handler запускается в конце play, только при changed, flush_handlers, --force-handlers.
Красный флаг: считает, что handler запускается при каждом запуске.
3. [junior] Что делает become: true и на каком уровне его лучше ставить?
Ответ
Повышает привилегии до root через sudo. Ставлю на play, если почти все задачи требуют root, и на задачу, если нужен один шаг: так остальное идёт с минимумом прав.
Что хотят услышать: sudo, become_user, минимальные права, NOPASSWD только для автоматизации.
Красный флаг: запускает всё ansible_user: root.
4. [junior] Плейбук падает с 'db_host' is undefined. Как ищешь?
Ответ
grep -rn db_host по плейбукам, шаблонам и group_vars. Проверяю имя (опечатка), область видимости (vars play или inventory) и приоритет. Отладка: ansible -m debug -a "var=db_host" хост.
Что хотят услышать: источники переменных, приоритет, debug, default() там, где пусто допустимо.
Красный флаг: «добавлю ignore_errors: true».
5. [middle] Плейбук на проде страшно запускать. Как снижаешь риск?
Ответ
--syntax-check, ansible-lint, затем --check --diff на одном хосте (--limit), потом по одному-двум хостам, потом на всех. Помню, что command в check пропускается, и проверяю такие места вручную.
Что хотят услышать: --check --diff, --limit, постепенный раскат, ограничения check, git-ревью.
Красный флаг: «запускаю сразу на всех, откатим».
6. [middle] После смены конфига нужно перезапустить сервис, но только если конфиг проходит проверку. Как сделать?
Ответ
В модуле template есть параметр validate (например, nginx -t -c %s): Ansible сначала проверяет временный файл этой командой, и невалидный файл не заменит рабочий. Перезапуск делаю через notify, чтобы он был только при изменении.
Что хотят услышать: validate, notify, reload против restart, откат при ошибке.
Красный флаг: пишет конфиг, потом отдельной command проверяет и не останавливает play при ошибке.
7. [middle] Как передать секрет в плейбук и не засветить его в логах и git?
Ответ
Не хранить в открытом виде: ansible-vault (шифрование файла с переменными, разбирается в следующем уроке) или внешний менеджер секретов. На задачах с секретом ставлю no_log: true, чтобы значение не попало в вывод.
Что хотят услышать: vault, no_log, секрет не в -e из истории shell, не в git.
Красный флаг: пароль в vars плейбука в репозитории.
8. [middle] Одна и та же переменная задана в group_vars, vars play и -e. Что победит и как отлаживать?
Ответ
-e сильнее всех, затем vars play, потом group_vars. Смотрю итоговое значение через debug: var= или ansible-inventory --host.
Что хотят услышать: порядок приоритета, -e для разового переопределения, ansible-inventory, минимум мест определения.
Красный флаг: «Ansible сам решит, не знаю».
9. [middle] Нужно пересоздать конфиг только на одной ВМ из десяти, а не гонять весь плейбук. Как?
Ответ
--limit host для хоста и --tags config для задач. Заранее размечаю задачи тегами, иначе ограничить нечем.
Что хотят услышать: --limit, --tags, --start-at-task, --list-tasks.
Красный флаг: правит плейбук, комментируя задачи.
10. [middle] Ты подставляешь в шаблон список серверов, а в файле пустые строки. Что не так?
Ответ
Скорее всего, лишние переводы строк от блоков {% for %}. Помогают trim_blocks и lstrip_blocks (они убирают перевод строки и отступы вокруг тегов блоков; в модуле template включены по умолчанию для Ansible) или знак - в тегах Jinja2 ({%- ... -%}). Проверяю результат --diff.
Что хотят услышать: циклы Jinja2, управление пробелами, --diff для проверки, отладка шаблона.
Красный флаг: правит файл на сервере руками после запуска.
11. [middle] Чем include_tasks отличается от import_tasks?
Ответ
import_tasks статический: файл разворачивается при разборе плейбука, задачи видны заранее, а теги и условия применяются к каждой задаче внутри. include_tasks динамический: файл подключается во время выполнения, поэтому имя файла можно собрать из переменной, а само включение допускает loop. Цена динамики в том, что --list-tasks не покажет вложенные задачи, а теги работают иначе: их нужно вешать и на сам include. Если набор задач известен заранее, я беру import_tasks, а include_tasks только когда выбор зависит от данных.
Что хотят услышать: static против dynamic, когда разворачивается, влияние на теги и loop, выбор по ситуации.
Красный флаг: «это синонимы» или массовое использование include по привычке.
12. [middle] Как работают register, when, changed_when и failed_when?
Ответ
register: результат сохраняет вывод задачи в переменную: код возврата, stdout, статус. when запускает следующую задачу только при выполненном условии, например when: result.rc != 0. По умолчанию модуль command всегда отдаёт changed, хотя ничего не менял, поэтому я задаю changed_when, по stdout или коду возврата, и идемпотентность в выводе становится честной. failed_when делает наоборот: объявляет задачу упавшей по моему условию, если модуль сам считает успехом код, который мне не подходит.
Что хотят услышать: register хранит результат, when по условию, changed_when для честного changed у command/shell, failed_when для своего критерия ошибки.
Красный флаг: оставлять command без changed_when и жить с вечным changed=1.
13. [middle] Как в плейбуке обработать ошибку задачи: что такое block, rescue и always?
Ответ
block группирует задачи. Если одна упала, управление переходит в rescue, где я делаю откат или уведомление. Секция always выполняется после блока независимо от успеха, как finally, например чтобы убрать временные файлы, но не гарантированно: при недоступном хосте (UNREACHABLE) ни rescue, ни always не сработают. Так я обрабатываю, к примеру, неудачный рестарт сервиса: в rescue возвращаю старый конфиг и снова запускаю сервис. ignore_errors: true проще, но он просто скрывает ошибку и идёт дальше, и использовать его надо точечно.
Что хотят услышать: block/rescue/always как try/catch/finally, пример отката, ignore_errors только точечно.
Красный флаг: ignore_errors: true на всех задачах.
Проверено на версиях
- ansible-core 2.21.4 в venv на Ubuntu 24.04 (Python 3.12, arm64), вход по SSH на
127.0.0.1в контейнере с sshd. Прогнаны:base.yml(два запуска,changed=0во втором),config.yml(первый запуск с handler, второй без,--check --diffс-e, права640 root:notes),--tags config --list-tasks,--syntax-checkдляsite.yml,ansible-lint, публикация репозитория Docker модулемdeb822_repository(два запуска, второйchanged=0), четыре сценария «Сломай и почини» (тексты ошибок настоящие). docker.ymlцеликом (установка пакетов Docker и запуск сервиса) не прогонялся: тяжёлые пакеты и systemd в контейнере; проверены синтаксис и шаг подключения репозитория. Версия Compose в выводе не подтверждена.site.ymlцеликом (ok=15) не прогонялся, число собрано из трёх прогнанных частей приблизительно.- Ubuntu 26.04 и облачная ВМ не проверялись.
- Скрипта поломки для этого урока нет: сценарии выполняются руками.
Итог урока: ты умеешь
- умею писать плейбук с play, tasks и
becomeи запускать его черезansible-playbook - умею доказать идемпотентность вторым запуском с
changed=0 - умею генерировать конфиг из шаблона Jinja2 и выдавать права
640 root:notes - умею вызывать handler через
notifyи объяснить, когда он срабатывает - умею читать
--check --diffи знаю его пределы дляcommand - умею ограничивать запуск через
--tags,--limitи--start-at-task - умею собрать
site.ymlиз нескольких плейбуков черезimport_playbook - умею найти причину
is undefinedи неидемпотентной задачи
Дальше: Урок 7.6: роли и деплой «Заметок» одним запуском
Проверь себя
Короткий тест по уроку: 5 вопросов из банка в 30. Засчитывается только полностью правильный ответ, порог 60%. Каждая новая попытка даёт другие вопросы, пока банк не закончится. Ответы видны после проверки.
Тест работает с включённым JavaScript.